chore: publish pearcord-guild from pearcord workspace

This commit is contained in:
Pearcord
2026-05-21 20:04:37 -04:00
commit 76e3272ced
4 changed files with 735 additions and 0 deletions
+28
View File
@@ -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.
+642
View File
@@ -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 }
+47
View File
@@ -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 }
+18
View File
@@ -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"
}
}