chore: publish pearcord-guild from pearcord workspace
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
# pearcord-guild
|
||||||
|
|
||||||
|
Guild CRUD, member mesh, and protomux gossip.
|
||||||
|
|
||||||
|
Part of **[Pearcord](https://git.ssh.surf/pearcord)** — 100% peer-to-peer community chat on [Pear](https://pear.holepunch.to). No central servers.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- **Org:** [`pearcord`](https://git.ssh.surf/pearcord)
|
||||||
|
- **Clone:** `git clone https://git.ssh.surf/pearcord/pearcord-guild.git`
|
||||||
|
|
||||||
|
## Install (npm)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install git+https://git.ssh.surf/pearcord/pearcord-guild.git#main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Hyperswarm · HyperDB · Protomux · Pear / Bare
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See [`pearcord/pearcord-docs`](https://git.ssh.surf/pearcord/pearcord-docs) for architecture, roadmap, and IPC reference.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Pearcord modules are developed for the Pearcord platform. See the org README for contribution guidelines.
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
require('bare-process/global')
|
||||||
|
const EventEmitter = require('bare-events')
|
||||||
|
const Hyperswarm = require('hyperswarm')
|
||||||
|
const b4a = require('b4a')
|
||||||
|
const { LocalDatabase } = require('pearcord-db')
|
||||||
|
const {
|
||||||
|
COLLECTIONS, id, now, guildTopic, topicToBuffer, CHANNEL_TYPES, RPC
|
||||||
|
} = require('pearcord-shared')
|
||||||
|
const { attachGossipMesh, broadcastGossip } = require('./mesh')
|
||||||
|
const { attachDriveMesh, requestAttachmentFromPeers } = require('pearcord-drive/attach-mesh')
|
||||||
|
const { attachVoiceMesh, broadcastVoiceMedia } = require('pearcord-voice-media')
|
||||||
|
|
||||||
|
class PearcordGuild extends EventEmitter {
|
||||||
|
constructor (opts = {}) {
|
||||||
|
super()
|
||||||
|
this.ownerId = opts.ownerId
|
||||||
|
this.userId = opts.userId || opts.ownerId
|
||||||
|
this.db = opts.db || new LocalDatabase(opts.dbPath || './pearcord-storage/db')
|
||||||
|
this.swarm = opts.swarm || new Hyperswarm({ keyPair: opts.keyPair })
|
||||||
|
this.guild = null
|
||||||
|
this.peers = new Map()
|
||||||
|
this._channels = new Map()
|
||||||
|
this._attachChannels = new Map()
|
||||||
|
this._voiceChannels = new Map()
|
||||||
|
this._meshHandler = null
|
||||||
|
this._attachmentProvider = null
|
||||||
|
this._voiceMediaHub = null
|
||||||
|
}
|
||||||
|
|
||||||
|
setVoiceMediaHub (hub) {
|
||||||
|
this._voiceMediaHub = hub || null
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttachmentProvider (fn) {
|
||||||
|
this._attachmentProvider = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAttachmentFromPeers (attachmentId, timeoutMs = 12000) {
|
||||||
|
const channels = [...this._attachChannels.values()]
|
||||||
|
if (!channels.length) return null
|
||||||
|
return requestAttachmentFromPeers(channels, attachmentId, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
async ready () {
|
||||||
|
await this.db.ready()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
async create ({ name, iconHash = null } = {}) {
|
||||||
|
if (!this.ownerId) throw new Error('ownerId required')
|
||||||
|
if (!name) throw new Error('name required')
|
||||||
|
const guildId = id()
|
||||||
|
const guild = {
|
||||||
|
id: guildId,
|
||||||
|
name,
|
||||||
|
iconHash,
|
||||||
|
ownerId: this.ownerId,
|
||||||
|
topic: guildTopic(guildId),
|
||||||
|
createdAt: now()
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.GUILDS, guild)
|
||||||
|
await this._addMember(guildId, this.ownerId, 'owner')
|
||||||
|
const general = await this._createChannel(guildId, 'general', CHANNEL_TYPES.TEXT, 0)
|
||||||
|
const voice = await this._createChannel(guildId, 'Voice Lobby', CHANNEL_TYPES.VOICE, 1)
|
||||||
|
this.guild = guild
|
||||||
|
this.emit('created', { guild, channels: [general, voice] })
|
||||||
|
return { guild, channels: [general, voice] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async _createChannel (guildId, name, type, position, parentId = null, extra = {}) {
|
||||||
|
if (type === CHANNEL_TYPES.CATEGORY) parentId = null
|
||||||
|
if (parentId && type !== CHANNEL_TYPES.THREAD) {
|
||||||
|
const parent = await this.db.get(COLLECTIONS.CHANNELS, { guildId, id: parentId })
|
||||||
|
if (!parent || parent.type !== CHANNEL_TYPES.CATEGORY) {
|
||||||
|
throw new Error('parent must be a category')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === CHANNEL_TYPES.THREAD) {
|
||||||
|
const parent = await this.db.get(COLLECTIONS.CHANNELS, { guildId, id: parentId })
|
||||||
|
if (!parent || parent.type !== CHANNEL_TYPES.TEXT) {
|
||||||
|
throw new Error('thread parent must be a text channel')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const channel = {
|
||||||
|
id: id(),
|
||||||
|
guildId,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
parentId: parentId || null,
|
||||||
|
position,
|
||||||
|
topic: type === CHANNEL_TYPES.TEXT ? 'Welcome to Pearcord' : null
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.CHANNELS, channel)
|
||||||
|
if (type === CHANNEL_TYPES.THREAD && extra.rootMessageId) {
|
||||||
|
await this._upsertThreadMeta({
|
||||||
|
guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
rootMessageId: extra.rootMessageId,
|
||||||
|
archived: extra.archived === true,
|
||||||
|
createdAt: now()
|
||||||
|
})
|
||||||
|
return this._attachThreadMeta(channel)
|
||||||
|
}
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
async _getThreadMeta (guildId, channelId) {
|
||||||
|
return this.db.get(COLLECTIONS.THREAD_META, { guildId, channelId })
|
||||||
|
}
|
||||||
|
|
||||||
|
async _upsertThreadMeta (meta) {
|
||||||
|
const row = {
|
||||||
|
guildId: meta.guildId,
|
||||||
|
channelId: meta.channelId,
|
||||||
|
rootMessageId: meta.rootMessageId,
|
||||||
|
archived: meta.archived === true,
|
||||||
|
createdAt: meta.createdAt || now()
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.THREAD_META, row)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
async _attachThreadMeta (channel) {
|
||||||
|
if (!channel || channel.type !== CHANNEL_TYPES.THREAD) return channel
|
||||||
|
const meta = await this._getThreadMeta(channel.guildId, channel.id)
|
||||||
|
if (!meta) return channel
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
rootMessageId: meta.rootMessageId,
|
||||||
|
archived: meta.archived === true,
|
||||||
|
createdAt: meta.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextPosition (channels, parentId = null) {
|
||||||
|
const pid = parentId || null
|
||||||
|
const siblings = channels.filter((c) => {
|
||||||
|
if (c.type === CHANNEL_TYPES.CATEGORY) return false
|
||||||
|
return (c.parentId || null) === pid
|
||||||
|
})
|
||||||
|
return siblings.length
|
||||||
|
}
|
||||||
|
|
||||||
|
async _addMember (guildId, userId, roles, nickname = null) {
|
||||||
|
const existing = await this.db.get(COLLECTIONS.MEMBERS, { guildId, userId })
|
||||||
|
if (existing) return existing
|
||||||
|
const member = {
|
||||||
|
guildId,
|
||||||
|
userId,
|
||||||
|
nickname,
|
||||||
|
roles,
|
||||||
|
joinedAt: now(),
|
||||||
|
isBot: false
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.MEMBERS, member)
|
||||||
|
return member
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinByInvite (invite, userId) {
|
||||||
|
let guild = await this.db.get(COLLECTIONS.GUILDS, { id: invite.guildId })
|
||||||
|
if (!guild) {
|
||||||
|
guild = {
|
||||||
|
id: invite.guildId,
|
||||||
|
name: invite.guildName || 'Server',
|
||||||
|
iconHash: null,
|
||||||
|
ownerId: invite.creatorId,
|
||||||
|
topic: invite.topic || guildTopic(invite.guildId),
|
||||||
|
createdAt: now()
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.GUILDS, guild)
|
||||||
|
if (invite.channels?.length) {
|
||||||
|
for (const ch of invite.channels) {
|
||||||
|
await this.db.insert(COLLECTIONS.CHANNELS, { ...ch, guildId: guild.id })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this._createChannel(guild.id, 'general', CHANNEL_TYPES.TEXT, 0)
|
||||||
|
await this._createChannel(guild.id, 'Voice Lobby', CHANNEL_TYPES.VOICE, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (invite.expiresAt && invite.expiresAt < now()) throw new Error('invite expired')
|
||||||
|
if (invite.maxUses > 0 && invite.uses >= invite.maxUses) throw new Error('invite max uses')
|
||||||
|
const banned = await this.db.get(COLLECTIONS.BANS, { guildId: guild.id, userId })
|
||||||
|
if (banned) throw new Error('you are banned from this server')
|
||||||
|
const member = await this._addMember(guild.id, userId, 'member')
|
||||||
|
this.gossipMemberJoin(member)
|
||||||
|
invite.uses = (invite.uses || 0) + 1
|
||||||
|
await this.db.insert(COLLECTIONS.INVITES, invite)
|
||||||
|
this.guild = guild
|
||||||
|
return guild
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessageHandler (fn) {
|
||||||
|
this._meshHandler = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
_onGossip (method, payload, conn) {
|
||||||
|
const peerId = b4a.toString(conn.remotePublicKey, 'hex')
|
||||||
|
if (method === RPC.MESSAGE_CREATE && this._meshHandler) {
|
||||||
|
this._meshHandler(payload).then((stored) => {
|
||||||
|
if (stored) this.emit('message', stored)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MESSAGE_UPDATE && this._meshHandler) {
|
||||||
|
this.db.insert(COLLECTIONS.MESSAGES, payload).then((msg) => {
|
||||||
|
this.emit('message', msg)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MESSAGE_DELETE) {
|
||||||
|
this.db.delete(COLLECTIONS.MESSAGES, {
|
||||||
|
channelId: payload.channelId,
|
||||||
|
id: payload.id
|
||||||
|
}).then(() => this.emit('message-delete', payload)).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.PRESENCE_UPDATE) {
|
||||||
|
this.emit('presence', { peerId, ...payload })
|
||||||
|
}
|
||||||
|
if (method === RPC.MEMBER_JOIN) {
|
||||||
|
this.db.insert(COLLECTIONS.MEMBERS, payload).then(() => {
|
||||||
|
this.emit('member', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.REACTION_TOGGLE) {
|
||||||
|
const key = {
|
||||||
|
channelId: payload.channelId,
|
||||||
|
messageId: payload.messageId,
|
||||||
|
emoji: payload.emoji,
|
||||||
|
userId: payload.userId
|
||||||
|
}
|
||||||
|
if (payload.removed) {
|
||||||
|
this.db.delete(COLLECTIONS.REACTIONS, key).then(() => {
|
||||||
|
this.emit('reaction', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
} else {
|
||||||
|
this.db.insert(COLLECTIONS.REACTIONS, payload).then(() => {
|
||||||
|
this.emit('reaction', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (method === RPC.CHANNEL_CREATE) {
|
||||||
|
this.ingestChannel(payload).then((ch) => {
|
||||||
|
this.emit('channel', ch)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MESSAGE_PIN) {
|
||||||
|
this.db.insert(COLLECTIONS.PINS, payload).then(() => {
|
||||||
|
this.emit('pin', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MEMBER_BAN) {
|
||||||
|
this.db.insert(COLLECTIONS.BANS, payload).then(() => {
|
||||||
|
this.emit('ban', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.CHANNEL_SETTINGS) {
|
||||||
|
this.db.insert(COLLECTIONS.CHANNEL_SETTINGS, payload).then(() => {
|
||||||
|
this.emit('channel-settings', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MEMBER_ROLE_UPDATE) {
|
||||||
|
this.db.insert(COLLECTIONS.MEMBERS, payload).then(() => {
|
||||||
|
this.emit('member', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.ATTACHMENT_META) {
|
||||||
|
this.db.insert(COLLECTIONS.ATTACHMENTS, payload).then(() => {
|
||||||
|
this.emit('attachment', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.GUILD_UPDATE) {
|
||||||
|
this.db.insert(COLLECTIONS.GUILDS, payload).then(() => {
|
||||||
|
if (this.guild?.id === payload.id) this.guild = payload
|
||||||
|
this.emit('guild-update', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MEMBER_KICK) {
|
||||||
|
this.db.delete(COLLECTIONS.MEMBERS, {
|
||||||
|
guildId: payload.guildId,
|
||||||
|
userId: payload.userId
|
||||||
|
}).then(() => {
|
||||||
|
this.emit('kick', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.MEMBER_TIMEOUT) {
|
||||||
|
this.emit('timeout', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.AUDIT_APPEND) {
|
||||||
|
this.db.insert(COLLECTIONS.AUDIT_LOG, payload).then(() => {
|
||||||
|
this.emit('audit', payload)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
if (method === RPC.VOICE_STATE) {
|
||||||
|
this.emit('voice-state', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.VOICE_MEDIA_READY) {
|
||||||
|
this.emit('voice-media-ready', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.VOICE_MEDIA_MUTE) {
|
||||||
|
this.emit('voice-media-mute', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.VOICE_MEDIA_LEAVE) {
|
||||||
|
this.emit('voice-media-leave', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.SLASH_COMMAND_UPSERT) {
|
||||||
|
this.emit('slash-command', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.BOT_EVENT) {
|
||||||
|
this.emit('bot-event', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.BOT_INSTALL_UPSERT) {
|
||||||
|
this.emit('bot-install', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.LINK_EMBED) {
|
||||||
|
this.emit('link-embed', payload)
|
||||||
|
}
|
||||||
|
if (method === RPC.CHANNEL_UPDATE) {
|
||||||
|
this.updateChannel(payload).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic gossip injection for Bare smokes (no live mesh). */
|
||||||
|
async simulateGossip (method, payload) {
|
||||||
|
if (method === RPC.CHANNEL_UPDATE) {
|
||||||
|
await this.updateChannel(payload).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (method === RPC.CHANNEL_CREATE) {
|
||||||
|
await this.ingestChannel(payload).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._onGossip(method, payload, { remotePublicKey: b4a.alloc(32) })
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipMessage (msg) {
|
||||||
|
broadcastGossip(this, RPC.MESSAGE_CREATE, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipMessageUpdate (msg) {
|
||||||
|
broadcastGossip(this, RPC.MESSAGE_UPDATE, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipMessageDelete (payload) {
|
||||||
|
broadcastGossip(this, RPC.MESSAGE_DELETE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipReaction (payload) {
|
||||||
|
broadcastGossip(this, RPC.REACTION_TOGGLE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipChannel (channel) {
|
||||||
|
broadcastGossip(this, RPC.CHANNEL_CREATE, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipPin (payload) {
|
||||||
|
broadcastGossip(this, RPC.MESSAGE_PIN, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipBan (payload) {
|
||||||
|
broadcastGossip(this, RPC.MEMBER_BAN, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipChannelSettings (payload) {
|
||||||
|
broadcastGossip(this, RPC.CHANNEL_SETTINGS, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipMemberRole (member) {
|
||||||
|
broadcastGossip(this, RPC.MEMBER_ROLE_UPDATE, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipMemberJoin (member) {
|
||||||
|
broadcastGossip(this, RPC.MEMBER_JOIN, member)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipAttachmentMeta (attachment) {
|
||||||
|
broadcastGossip(this, RPC.ATTACHMENT_META, attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipGuildUpdate (guild) {
|
||||||
|
broadcastGossip(this, RPC.GUILD_UPDATE, guild)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipKick (payload) {
|
||||||
|
broadcastGossip(this, RPC.MEMBER_KICK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipTimeout (payload) {
|
||||||
|
broadcastGossip(this, RPC.MEMBER_TIMEOUT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipAudit (entry) {
|
||||||
|
broadcastGossip(this, RPC.AUDIT_APPEND, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipVoiceState (payload) {
|
||||||
|
broadcastGossip(this, RPC.VOICE_STATE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipVoiceMediaReady (payload) {
|
||||||
|
broadcastGossip(this, RPC.VOICE_MEDIA_READY, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipVoiceMediaMute (payload) {
|
||||||
|
broadcastGossip(this, RPC.VOICE_MEDIA_MUTE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipVoiceMediaLeave (payload) {
|
||||||
|
broadcastGossip(this, RPC.VOICE_MEDIA_LEAVE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipSlashCommand (row) {
|
||||||
|
broadcastGossip(this, RPC.SLASH_COMMAND_UPSERT, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipBotEvent (payload) {
|
||||||
|
broadcastGossip(this, RPC.BOT_EVENT, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipBotInstall (bot) {
|
||||||
|
broadcastGossip(this, RPC.BOT_INSTALL_UPSERT, bot)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendVoiceMedia (msg) {
|
||||||
|
broadcastVoiceMedia(this, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipLinkEmbed (payload) {
|
||||||
|
broadcastGossip(this, RPC.LINK_EMBED, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipChannelUpdate (channel) {
|
||||||
|
broadcastGossip(this, RPC.CHANNEL_UPDATE, channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
gossipPresence (payload) {
|
||||||
|
broadcastGossip(this, RPC.PRESENCE_UPDATE, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinMesh () {
|
||||||
|
if (!this.guild) throw new Error('no active guild')
|
||||||
|
const topic = topicToBuffer(this.guild.topic)
|
||||||
|
this.swarm.removeAllListeners('connection')
|
||||||
|
this.swarm.on('connection', (conn) => {
|
||||||
|
const peerId = b4a.toString(conn.remotePublicKey, 'hex')
|
||||||
|
this.peers.set(peerId, conn)
|
||||||
|
const ch = attachGossipMesh(this, conn)
|
||||||
|
this._channels.set(peerId, ch)
|
||||||
|
const attachCh = attachDriveMesh(this, conn, () => this._attachmentProvider)
|
||||||
|
this._attachChannels.set(peerId, attachCh)
|
||||||
|
if (this._voiceMediaHub) {
|
||||||
|
const voiceCh = attachVoiceMesh(this, conn, this._voiceMediaHub)
|
||||||
|
if (voiceCh) this._voiceChannels.set(peerId, voiceCh)
|
||||||
|
}
|
||||||
|
this.emit('peer', { peerId, type: 'join' })
|
||||||
|
conn.on('close', () => {
|
||||||
|
this.peers.delete(peerId)
|
||||||
|
this._channels.delete(peerId)
|
||||||
|
this._attachChannels.delete(peerId)
|
||||||
|
this._voiceChannels.delete(peerId)
|
||||||
|
this.emit('peer', { peerId, type: 'leave' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await this.swarm.join(topic, { server: true, client: true })
|
||||||
|
await this.swarm.flush()
|
||||||
|
return this.guild
|
||||||
|
}
|
||||||
|
|
||||||
|
async leaveMesh () {
|
||||||
|
this.peers.clear()
|
||||||
|
this._channels.clear()
|
||||||
|
this._attachChannels.clear()
|
||||||
|
this._voiceChannels.clear()
|
||||||
|
if (this.guild?.topic) {
|
||||||
|
await this.swarm.leave(topicToBuffer(this.guild.topic)).catch(() => {})
|
||||||
|
}
|
||||||
|
this.swarm.removeAllListeners('connection')
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory ({ name } = {}) {
|
||||||
|
return this.createChannel({ name, type: CHANNEL_TYPES.CATEGORY })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createThread ({ parentChannelId, rootMessageId, name }) {
|
||||||
|
if (!this.guild) throw new Error('no active guild')
|
||||||
|
if (!parentChannelId || !rootMessageId) {
|
||||||
|
throw new Error('parentChannelId and rootMessageId required')
|
||||||
|
}
|
||||||
|
const parent = await this.db.get(COLLECTIONS.CHANNELS, {
|
||||||
|
guildId: this.guild.id,
|
||||||
|
id: parentChannelId
|
||||||
|
})
|
||||||
|
if (!parent || parent.type !== CHANNEL_TYPES.TEXT) {
|
||||||
|
throw new Error('threads must be under a text channel')
|
||||||
|
}
|
||||||
|
const root = await this.db.get(COLLECTIONS.MESSAGES, {
|
||||||
|
channelId: parentChannelId,
|
||||||
|
id: rootMessageId
|
||||||
|
})
|
||||||
|
if (!root) throw new Error('root message not found in parent channel')
|
||||||
|
const existing = await this.listChannels()
|
||||||
|
const threads = existing.filter(
|
||||||
|
(c) => c.type === CHANNEL_TYPES.THREAD && c.parentId === parentChannelId
|
||||||
|
)
|
||||||
|
const dup = threads.find((t) => t.rootMessageId === rootMessageId && !t.archived)
|
||||||
|
if (dup) return dup
|
||||||
|
const label = name || `Thread`
|
||||||
|
const channel = await this._createChannel(
|
||||||
|
this.guild.id,
|
||||||
|
label.slice(0, 80),
|
||||||
|
CHANNEL_TYPES.THREAD,
|
||||||
|
threads.length,
|
||||||
|
parentChannelId,
|
||||||
|
{ rootMessageId, archived: false }
|
||||||
|
)
|
||||||
|
this.emit('channel', channel)
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
async listThreads (parentChannelId, { includeArchived = false } = {}) {
|
||||||
|
if (!this.guild) return []
|
||||||
|
const all = await this.listChannels()
|
||||||
|
return all.filter((c) => {
|
||||||
|
if (c.type !== CHANNEL_TYPES.THREAD || c.parentId !== parentChannelId) return false
|
||||||
|
if (!includeArchived && c.archived) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingestChannel (channel) {
|
||||||
|
if (!channel?.id) throw new Error('invalid channel')
|
||||||
|
const base = {
|
||||||
|
id: channel.id,
|
||||||
|
guildId: channel.guildId,
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
parentId: channel.parentId || null,
|
||||||
|
position: channel.position,
|
||||||
|
topic: channel.topic ?? null
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.CHANNELS, base)
|
||||||
|
if (channel.type === CHANNEL_TYPES.THREAD && channel.rootMessageId) {
|
||||||
|
await this._upsertThreadMeta({
|
||||||
|
guildId: channel.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
rootMessageId: channel.rootMessageId,
|
||||||
|
archived: channel.archived === true,
|
||||||
|
createdAt: channel.createdAt || now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return this._attachThreadMeta(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChannel (channel) {
|
||||||
|
if (!channel?.id || !this.guild) throw new Error('invalid channel')
|
||||||
|
const base = {
|
||||||
|
id: channel.id,
|
||||||
|
guildId: channel.guildId,
|
||||||
|
name: channel.name,
|
||||||
|
type: channel.type,
|
||||||
|
parentId: channel.parentId || null,
|
||||||
|
position: channel.position,
|
||||||
|
topic: channel.topic ?? null
|
||||||
|
}
|
||||||
|
await this.db.insert(COLLECTIONS.CHANNELS, base)
|
||||||
|
if (channel.type === CHANNEL_TYPES.THREAD) {
|
||||||
|
const prev = await this._getThreadMeta(channel.guildId, channel.id)
|
||||||
|
await this._upsertThreadMeta({
|
||||||
|
guildId: channel.guildId,
|
||||||
|
channelId: channel.id,
|
||||||
|
rootMessageId: channel.rootMessageId || prev?.rootMessageId,
|
||||||
|
archived: channel.archived === true,
|
||||||
|
createdAt: channel.createdAt || prev?.createdAt || now()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const merged = await this._attachThreadMeta(base)
|
||||||
|
this.emit('channel-update', merged)
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel ({ name, type = CHANNEL_TYPES.TEXT, parentId = null } = {}) {
|
||||||
|
if (!this.guild) throw new Error('no active guild')
|
||||||
|
if (!name) throw new Error('name required')
|
||||||
|
if (type === CHANNEL_TYPES.CATEGORY && parentId) {
|
||||||
|
throw new Error('categories cannot have a parent')
|
||||||
|
}
|
||||||
|
const existing = await this.listChannels()
|
||||||
|
const position = type === CHANNEL_TYPES.CATEGORY
|
||||||
|
? existing.filter((c) => c.type === CHANNEL_TYPES.CATEGORY).length
|
||||||
|
: this._nextPosition(existing, parentId)
|
||||||
|
const channel = await this._createChannel(
|
||||||
|
this.guild.id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
parentId
|
||||||
|
)
|
||||||
|
this.emit('channel', channel)
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
async listChannels () {
|
||||||
|
if (!this.guild) return []
|
||||||
|
const rows = await this.db.find(COLLECTIONS.CHANNELS, { guildId: this.guild.id })
|
||||||
|
const sorted = rows.sort((a, b) => a.position - b.position)
|
||||||
|
const out = []
|
||||||
|
for (const ch of sorted) {
|
||||||
|
out.push(await this._attachThreadMeta(ch))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMembers () {
|
||||||
|
if (!this.guild) return []
|
||||||
|
return this.db.find(COLLECTIONS.MEMBERS, { guildId: this.guild.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveGuild (guild) {
|
||||||
|
this.guild = guild
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats () {
|
||||||
|
let openGossip = 0
|
||||||
|
for (const ch of this._channels.values()) {
|
||||||
|
if (ch.opened) openGossip++
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
guildId: this.guild?.id || null,
|
||||||
|
peers: this.peers.size,
|
||||||
|
openGossip,
|
||||||
|
topic: this.guild?.topic || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close () {
|
||||||
|
await this.leaveMesh().catch(() => {})
|
||||||
|
if (this.swarm && !this.swarm.destroyed) {
|
||||||
|
await this.swarm.destroy({ force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PearcordGuild }
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict'
|
||||||
|
|
||||||
|
const Protomux = require('protomux')
|
||||||
|
const b4a = require('b4a')
|
||||||
|
const c = require('compact-encoding')
|
||||||
|
const { RPC, encodeRpc, decodeRpc } = require('pearcord-shared')
|
||||||
|
|
||||||
|
const GOSSIP_PROTOCOL = 'pearcord-gossip-v1'
|
||||||
|
|
||||||
|
const rpcPayload = c.json
|
||||||
|
|
||||||
|
function attachGossipMesh (guild, conn) {
|
||||||
|
const mux = Protomux.from(conn)
|
||||||
|
const channel = mux.createChannel({
|
||||||
|
protocol: GOSSIP_PROTOCOL,
|
||||||
|
onopen () {
|
||||||
|
channel.open()
|
||||||
|
},
|
||||||
|
onmessage (buf) {
|
||||||
|
let packet
|
||||||
|
try {
|
||||||
|
packet = decodeRpc(buf)
|
||||||
|
const payload = JSON.parse(b4a.toString(packet.payload))
|
||||||
|
guild._onGossip(packet.method, payload, conn)
|
||||||
|
} catch {
|
||||||
|
// ignore malformed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return channel
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastGossip (guild, method, payload) {
|
||||||
|
const buf = encodeRpc(method, b4a.from(JSON.stringify(payload)))
|
||||||
|
for (const ch of guild._channels.values()) {
|
||||||
|
ch.fullyOpened().then((ok) => {
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
ch.send(buf)
|
||||||
|
} catch {
|
||||||
|
// peer gone
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { attachGossipMesh, broadcastGossip, GOSSIP_PROTOCOL }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "pearcord-guild",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"bare-events": "^2.8.0",
|
||||||
|
"bare-process": "^4.4.0",
|
||||||
|
"b4a": "^1.6.7",
|
||||||
|
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
|
||||||
|
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main",
|
||||||
|
"pearcord-drive": "git+https://git.ssh.surf/pearcord/pearcord-drive.git#main",
|
||||||
|
"pearcord-voice-media": "git+https://git.ssh.surf/pearcord/pearcord-voice-media.git#main",
|
||||||
|
"hyperswarm": "^4.8.0",
|
||||||
|
"protomux": "^3.0.0",
|
||||||
|
"compact-encoding": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user