feat(platform): v0.8.0 announcement follow and cross-post

Mirror announcement posts to configured text channels via P2P gossip,
per-user follow with announcement notifications, server settings APIs,
and view fields for UI.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-05-21 22:07:25 -04:00
co-authored by Cursor
parent d000ac43d6
commit 3f614a5c9c
2 changed files with 202 additions and 1 deletions
+200
View File
@@ -25,6 +25,10 @@ const { PearcordForum } = require('pearcord-forum')
const { PearcordStage, STAGE_ROLE, isStageChannel } = require('pearcord-stage')
const { PearcordAutomod } = require('pearcord-automod')
const { ScreenShareHub } = require('pearcord-screen-share')
const {
PearcordAnnouncements,
formatCrosspostBody
} = require('pearcord-announcements')
const {
createInstallToken,
verifyInstallToken,
@@ -79,6 +83,7 @@ class PearcordPlatform extends EventEmitter {
this.voice = null
this.voiceMedia = null
this.screenShare = null
this.announcements = null
this.slashCommands = null
this.emojiRegistry = null
this.botEvents = null
@@ -216,6 +221,8 @@ class PearcordPlatform extends EventEmitter {
await this.stage.ready()
this.automod = new PearcordAutomod({ storagePath: this.storagePath })
await this.automod.ready()
this.announcements = new PearcordAnnouncements({ storagePath: this.storagePath })
await this.announcements.ready()
this._forumTagFilter = null
this.guilds = await this.listGuilds()
await this._joinDiscoveryMesh()
@@ -768,6 +775,14 @@ class PearcordPlatform extends EventEmitter {
this.screenShare?.ingestGossip(p).catch(() => {})
this.emit('screen-share', p)
})
guildInstance.on('announcement-crosspost', (p) => {
this.announcements?.ingestCrosspostConfig(p).catch(() => {})
this.emit('announcements', p)
})
guildInstance.on('announcement-follow', (p) => {
this.announcements?.ingestFollow(p).catch(() => {})
this.emit('announcements', p)
})
guildInstance.setAttachmentProvider(async (attachmentId) => {
const row = await this.attachments?.get(attachmentId)
if (!row) throw new Error('attachment not found')
@@ -917,6 +932,21 @@ class PearcordPlatform extends EventEmitter {
guildId: msg.guildId,
id: msg.channelId
})
if (ch?.type === 'announcement' && this.announcements) {
const following = await this.announcements.isFollowing(
msg.guildId,
msg.channelId,
user.id
)
if (following) {
await this._notifyAnnouncementFollow({
message: { ...msg, content: plain },
guildName: this.guild?.guild?.name,
channelName: ch.name,
authorName: await this._authorDisplayName(msg.authorId, members, usersById)
})
}
}
await this._maybeNotify({
message: { ...msg, content: plain },
mode: msg.guildId === DM_GUILD_ID ? 'dm' : 'guild',
@@ -1939,6 +1969,10 @@ class PearcordPlatform extends EventEmitter {
this.automod = new PearcordAutomod({ storagePath: this.storagePath })
await this.automod.ready()
}
if (!this.announcements) {
this.announcements = new PearcordAnnouncements({ storagePath: this.storagePath })
await this.announcements.ready()
}
const guildId = this.guild?.guild?.id
if (guildId && this.automod) this.automod.setGuild(guildId)
return { forum: this.forum, stage: this.stage, automod: this.automod }
@@ -2211,11 +2245,149 @@ class PearcordPlatform extends EventEmitter {
this.guild.gossipMessage(msg)
this._queueLinkEmbed(msg).catch(() => {})
this._fanoutBotEvent(BOT_EVENTS.MESSAGE_CREATE, { message: msg })
if (this.mode === 'guild') {
const channels = await this.guild.listChannels()
const srcCh = channels.find((c) => c.id === msg.channelId)
if (srcCh && isAnnouncementChannel(srcCh)) {
await this._crosspostAnnouncement(msg, srcCh)
}
}
}
this.emit('message', msg)
return msg
}
async _insertGuildMessage ({ guildId, channelId, content, authorId }) {
if (!this.messages || !guildId || !channelId) throw new Error('invalid message insert')
const prevChannel = this.messages.channelId
const prevGuild = this.messages.guildId
this.messages.setChannel({ channelId, guildId })
const row = await this.messages.send(content, { authorId: authorId || this.identity.user?.id })
this.messages.setChannel({ channelId: prevChannel, guildId: prevGuild })
if (this.guild) {
this.guild.gossipMessage(row)
this._queueLinkEmbed(row).catch(() => {})
}
return row
}
async _crosspostAnnouncement (sourceMsg, sourceCh) {
if (!this.announcements || !this.guild?.guild) return []
const targets = await this.announcements.getCrosspostTargets(
sourceMsg.guildId,
sourceMsg.channelId
)
if (!targets.length) return []
const channels = await this.guild.listChannels()
const body = formatCrosspostBody(sourceCh.name, sourceMsg.content)
const out = []
for (const targetId of targets) {
if (targetId === sourceMsg.channelId) continue
const tch = channels.find((c) => c.id === targetId)
if (!tch || tch.type !== 'text') continue
try {
const row = await this._insertGuildMessage({
guildId: sourceMsg.guildId,
channelId: targetId,
content: body,
authorId: sourceMsg.authorId
})
out.push(row)
} catch {
// skip invalid target
}
}
if (out.length) this.emit('message')
return out
}
async _notifyAnnouncementFollow ({ message, guildName, channelName, authorName }) {
if (!this.notifications) return null
const prefs = await this.notifications.getPrefs()
if (prefs.notifyOnAnnouncement === false) return null
if (prefs.mutedGuildIds?.includes(message.guildId)) return null
if (prefs.mutedChannelIds?.includes(message.channelId)) return null
if (this.activeChannelId === message.channelId) return null
const record = {
id: id(),
userId: this.identity.user.id,
kind: 'announcement',
guildId: message.guildId,
channelId: message.channelId,
messageId: message.id,
authorId: message.authorId,
authorName,
title: `📢 ${authorName} posted in #${channelName || 'announcements'}`,
body: String(message.content || '').slice(0, 140),
place: `#${channelName || 'announcements'} · ${guildName || 'Server'}`,
read: false,
createdAt: now()
}
return this.notifications.push(record)
}
async setAnnouncementCrosspostTargets (sourceChannelId, targetChannelIds) {
if (!this.guild?.guild) throw new Error('no guild')
const roles = await this._memberRoles()
if (!roleHasPermission(roles, PERMISSION.MANAGE_CHANNELS)) {
throw new Error('no permission to configure cross-post')
}
await this._ensureGuildModules()
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: sourceChannelId
})
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
const row = await this.announcements.setCrosspostTargets(
this.guild.guild.id,
sourceChannelId,
targetChannelIds
)
this.guild.gossipAnnouncementCrosspost(row)
this.emit('announcements', row)
return row
}
async getAnnouncementCrosspostTargets (sourceChannelId) {
await this._ensureGuildModules()
if (!this.guild?.guild) return []
return this.announcements.getCrosspostTargets(
this.guild.guild.id,
sourceChannelId
)
}
async followAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
const ch = await this.db.get(COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: channelId
})
if (!ch || !isAnnouncementChannel(ch)) throw new Error('not an announcement channel')
await this._ensureGuildModules()
const row = await this.announcements.follow(
this.guild.guild.id,
channelId,
this.identity.user.id
)
this.guild.gossipAnnouncementFollow(row)
this.emit('announcements', row)
return row
}
async unfollowAnnouncementChannel (channelId) {
if (!this.guild?.guild || !this.identity?.user) throw new Error('no guild')
await this._ensureGuildModules()
const row = await this.announcements.unfollow(
this.guild.guild.id,
channelId,
this.identity.user.id
)
this.guild.gossipAnnouncementFollow(row)
this.emit('announcements', row)
return row
}
async sendMessage (content, opts = {}) {
if (!this.messages) throw new Error('no channel')
if (this.mode === 'guild' && parseSlashInvocation(content)) {
@@ -2874,6 +3046,29 @@ class PearcordPlatform extends EventEmitter {
this.automod.setGuild(guild.id)
automodConfig = await this.automod.getConfig()
}
let announcementCrosspostTargets = []
let announcementCrosspostByChannel = {}
let announcementFollowing = false
let announcementFollows = []
if (guild && this.announcements && user) {
for (const ch of channels) {
if (!isAnnouncementChannel(ch)) continue
announcementCrosspostByChannel[ch.id] = await this.announcements.getCrosspostTargets(
guild.id,
ch.id
)
}
if (activeChannel && isAnnouncementChannel(activeChannel)) {
announcementCrosspostTargets =
announcementCrosspostByChannel[activeChannel.id] || []
announcementFollowing = await this.announcements.isFollowing(
guild.id,
activeChannel.id,
user.id
)
}
announcementFollows = await this.announcements.listMyFollows(user.id, guild.id)
}
return {
onboarded: this.onboarded,
user,
@@ -2916,6 +3111,10 @@ class PearcordPlatform extends EventEmitter {
myStageRole,
stagePendingRequests,
automodConfig,
announcementCrosspostTargets,
announcementCrosspostByChannel,
announcementFollowing,
announcementFollows,
myVoiceChannelId,
voiceMedia: this.voiceMedia?.snapshot() || null,
screenShare: this.screenShare?.snapshot() || null,
@@ -3042,6 +3241,7 @@ class PearcordPlatform extends EventEmitter {
this.voice = null
this.voiceMedia = null
this.screenShare = null
this.announcements = null
this.removeAllListeners()
await this.db.close()
}
+2 -1
View File
@@ -37,7 +37,8 @@
"pearcord-forum": "file:../pearcord-forum",
"pearcord-stage": "file:../pearcord-stage",
"pearcord-automod": "file:../pearcord-automod",
"pearcord-screen-share": "git+https://git.ssh.surf/pearcord/pearcord-screen-share.git#main",
"pearcord-screen-share": "file:../pearcord-screen-share",
"pearcord-announcements": "git+https://git.ssh.surf/pearcord/pearcord-announcements.git#main",
"hyperswarm": "^4.11.6",
"pearcord-shared": "file:../pearcord-shared"
}