fix: hard-isolate DM and guild messaging scopes

Rebind message store to mode-aware guildId/channelId before send, edit,
delete, and history; leave guild mesh without using stale guild binds;
gate attachment and message gossip by mode so DMs never hit the public guild.
This commit is contained in:
Raven Scott
2026-07-13 01:09:47 -04:00
parent 4b20955d5a
commit 54f5ea083a
4 changed files with 170 additions and 27 deletions
@@ -547,6 +547,7 @@ async forwardVoiceNote ({ messageId, targetChannelId }) {
channelId: targetChannelId,
authorId: user.id
})
if (typeof this._bindActiveMessageScope === 'function') this._bindActiveMessageScope()
const label = '🎤 Voice message'
const outMsg = await this.messages.send(label, { voiceNoteId: draft.id })
const out = await this.voiceNotes.commitDraft({ draftId: draft.id, messageId: outMsg.id })
@@ -557,7 +558,7 @@ async forwardVoiceNote ({ messageId, targetChannelId }) {
await dm.touchLastMessage(outMsg.channelId, { ...outMsg, content: label })
dm.gossipMessage(gossipMsg)
}
} else if (this.guild) {
} else if (this.mode === 'guild' && this.guild) {
void this._guildGossipOrQueue('message', () => this.guild.gossipMessage(gossipMsg))
void this.guild.gossipVoiceNoteCreate(out.gossip).catch(() => {})
}
@@ -619,11 +620,21 @@ async sendVoiceNote ({ replyToId } = {}) {
if (this.mode === 'guild') await this._assertCanParticipate('send')
await this._assertNotTimedOut()
await this._assertSlowmode()
if (typeof this._bindActiveMessageScope === 'function') {
const scope = this._bindActiveMessageScope()
if (!scope) throw new Error('no channel')
}
const label = '🎤 Voice message'
const msg = await this.messages.send(label, {
replyToId,
voiceNoteId: draft.id
})
if (this.mode === 'dm' && msg.guildId !== dmScope.DM_GUILD_ID) {
throw new Error('dm voice note isolation violation')
}
if (this.mode === 'guild' && msg.guildId === dmScope.DM_GUILD_ID) {
throw new Error('guild voice note isolation violation')
}
const out = await this.commitVoiceNoteDraft({ messageId: msg.id })
const gossipMsg = this._enrichOutboundMessage(msg, { voiceNote: out.record })
if (this.mode === 'dm') {
@@ -632,7 +643,7 @@ async sendVoiceNote ({ replyToId } = {}) {
await dm.touchLastMessage(msg.channelId, { ...msg, content: label })
dm.gossipMessage(gossipMsg)
}
} else if (this.guild) {
} else if (this.mode === 'guild' && this.guild) {
void this._guildGossipOrQueue('message', () => this.guild.gossipMessage(gossipMsg))
}
await this._recordSlowmodeSend()
@@ -428,8 +428,45 @@ async dryRunAutomodMessage (content, opts = {}) {
return { content: String(content || ''), result }
},
/**
* Force the local message store onto the active mode's guildId+channelId.
* Prevents DM/guild crosstalk when this.guild is still held after leaving the mesh
* or when setChannel lagged behind mode/activeChannelId.
*/
_bindActiveMessageScope () {
if (!this.messages) return null
const isDm = this.mode === 'dm'
const guildId = isDm
? dmScope.DM_GUILD_ID
: this.guild?.guild?.id || null
let channelId = this.activeChannelId || null
if (isDm) {
const dmChannelId = this.dm?.channel?.id || null
if (dmChannelId) {
channelId = dmChannelId
if (this.activeChannelId !== dmChannelId) this.activeChannelId = dmChannelId
}
}
if (!channelId || !guildId) return null
if (isDm && guildId !== dmScope.DM_GUILD_ID) return null
if (!isDm && guildId === dmScope.DM_GUILD_ID) return null
if (this.messages.channelId !== channelId || this.messages.guildId !== guildId) {
this.messages.setChannel({ channelId, guildId })
}
return { channelId, guildId, isDm }
},
async _sendPlainMessage (content, opts = {}) {
if (!this.messages) throw new Error('no channel')
// Always rebind before write so DM mode never persists into a guild channel (and vice versa).
const scope = this._bindActiveMessageScope()
if (!scope) throw new Error('no channel')
if (scope.isDm && scope.guildId !== dmScope.DM_GUILD_ID) {
throw new Error('dm message scope isolation violation')
}
if (!scope.isDm && scope.guildId === dmScope.DM_GUILD_ID) {
throw new Error('guild message scope isolation violation')
}
// Locked threads (guild + DM): block new messages while locked.
if (this.mode === 'dm' && this.dm?.channel?.locked) {
throw new Error('this thread is locked')
@@ -472,6 +509,13 @@ async _sendPlainMessage (content, opts = {}) {
}
await this._assertNotTimedOut()
await this._assertSlowmode()
// Re-bind after awaits — concurrent selectChannel/openDM must not race the write.
const scope2 = this._bindActiveMessageScope()
if (!scope2) throw new Error('no channel')
if (scope2.isDm !== scope.isDm || scope2.guildId !== scope.guildId || scope2.channelId !== scope.channelId) {
// Mode flipped mid-send; refuse rather than write into the wrong surface.
throw new Error('message scope changed during send')
}
const stickerNames = await this._normalizeStickerNames(opts.stickerNames)
const attachmentIds = opts.attachmentIds || []
if (attachmentIds.length && this.attachments) {
@@ -505,15 +549,24 @@ async _sendPlainMessage (content, opts = {}) {
stickerNames,
hubMirrorJson: opts.hubMirrorJson
})
// Defense in depth: never accept a row that crossed the DM/guild boundary.
if (this.mode === 'dm' && msg.guildId !== dmScope.DM_GUILD_ID) {
throw new Error('dm message isolation violation: wrote non-dm guildId')
}
if (this.mode === 'guild' && msg.guildId === dmScope.DM_GUILD_ID) {
throw new Error('guild message isolation violation: wrote dm guildId')
}
const linked = []
if (this.attachments && attachmentIds.length) {
for (const aid of attachmentIds) {
const row = await this.attachments.linkMessage(aid, msg.id)
linked.push(row)
if (this.guild) void this._guildGossipOrQueue('attachment-meta', () => this.guild.gossipAttachmentMeta(row))
else {
// Mode gates gossip — do not use this.guild merely because it still exists after openDM.
if (this.mode === 'dm') {
const dm = this._activeDmMesh()
if (dm?.gossipAttachmentMeta) dm.gossipAttachmentMeta(row)
} else if (this.mode === 'guild' && this.guild) {
void this._guildGossipOrQueue('attachment-meta', () => this.guild.gossipAttachmentMeta(row))
}
}
}
@@ -551,7 +604,7 @@ async _sendPlainMessage (content, opts = {}) {
dm.gossipMessage(gossipMsg)
}
this._queueLinkEmbed(msg).catch(() => {})
} else if (this.guild) {
} else if (this.mode === 'guild' && this.guild) {
void this._guildGossipOrQueue('message', () => this.guild.gossipMessage(gossipMsg))
if ((this.guild.peers?.size ?? 0) > 0 && !this._isGuildSyncPushHalted(msg.guildId)) {
this._scheduleGuildSyncPushCoalesced(
@@ -565,13 +618,11 @@ async _sendPlainMessage (content, opts = {}) {
}
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 && threadsScope.isAnnouncementChannel(srcCh)) {
await this._crosspostAnnouncement(msg, srcCh)
await this._publishAnnouncementHub(msg, srcCh)
}
const channels = await this.guild.listChannels()
const srcCh = channels.find((c) => c.id === msg.channelId)
if (srcCh && threadsScope.isAnnouncementChannel(srcCh)) {
await this._crosspostAnnouncement(msg, srcCh)
await this._publishAnnouncementHub(msg, srcCh)
}
}
this.emit('message', msg)
@@ -631,14 +682,22 @@ async sendMessage (content, opts = {}) {
if (this.mode === 'guild' && parseSlashInvocation(content)) {
return this.executeSlashCommand(content, opts)
}
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
const isDm = guildId === dmScope.DM_GUILD_ID
// Bind before deriving ids so spans and post-send guild replicator use the true scope.
const bound = this._bindActiveMessageScope()
const guildId = bound?.guildId || (this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null)
const channelId = bound?.channelId || this.activeChannelId
const isDm = this.mode === 'dm' || guildId === dmScope.DM_GUILD_ID
const spanKind = isDm ? 'dm.send' : 'message.send'
const span = this.log.time(spanKind, { spanKind, channelId, guildId, isDm })
try {
if (this._readOnly) throw new Error('read-only companion mode cannot send messages')
if (!this.messages) throw new Error('no channel')
if (this.mode === 'dm' && guildId !== dmScope.DM_GUILD_ID) {
throw new Error('dm send isolation violation: expected dm guildId')
}
if (this.mode === 'guild' && (!guildId || guildId === dmScope.DM_GUILD_ID)) {
throw new Error('guild send isolation violation: expected guild guildId')
}
const msg = await this._sendPlainMessage(content, opts)
if (msg && guildId && channelId && guildId !== dmScope.DM_GUILD_ID) {
await this._appendMessageToGuildReplicator(guildId, channelId, msg).catch(() => {})
@@ -768,8 +827,9 @@ async signalTyping () {
},
async editMessage (messageId, content) {
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
const bound = this._bindActiveMessageScope()
const guildId = bound?.guildId || (this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null)
const channelId = bound?.channelId || this.activeChannelId
const span = this.log.time('message.edit', {
spanKind: 'message.edit',
messageId,
@@ -777,6 +837,12 @@ async editMessage (messageId, content) {
channelId
})
try {
if (this.mode === 'dm' && guildId !== dmScope.DM_GUILD_ID) {
throw new Error('dm edit isolation violation')
}
if (this.mode === 'guild' && (!guildId || guildId === dmScope.DM_GUILD_ID)) {
throw new Error('guild edit isolation violation')
}
await this._assertCanModifyMessage(messageId, 'edit')
const prior = await this.db.get(sharedScope.COLLECTIONS.MESSAGES, {
channelId,
@@ -784,6 +850,7 @@ async editMessage (messageId, content) {
})
const priorPlain = this._messagePlaintext(prior || {})
const priorLength = String(prior?.content || '').length
this._bindActiveMessageScope()
const msg = await this.messages.edit(messageId, content)
const nextPlain = this._messagePlaintext(msg)
const priorUrl = sharedScope.extractFirstUrl(priorPlain)
@@ -806,8 +873,8 @@ async editMessage (messageId, content) {
this._queueLinkEmbed(msg).catch(() => {})
}
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageUpdate(msg)
else if (this.guild) this.guild.gossipMessageUpdate(msg)
if (this.guild?.guild?.id && channelId) {
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageUpdate(msg)
if (this.mode === 'guild' && this.guild?.guild?.id && channelId) {
const ch = await this.db.get(sharedScope.COLLECTIONS.CHANNELS, {
guildId: this.guild.guild.id,
id: channelId
@@ -1277,8 +1344,9 @@ _groupReactions (rows) {
},
async deleteMessage (messageId) {
const guildId = this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
const bound = this._bindActiveMessageScope()
const guildId = bound?.guildId || (this.mode === 'dm' ? dmScope.DM_GUILD_ID : this.guild?.guild?.id || null)
const channelId = bound?.channelId || this.activeChannelId
const span = this.log.time('message.delete', {
spanKind: 'message.delete',
messageId,
@@ -1388,6 +1456,7 @@ async deleteMessage (messageId) {
unlinkSpan.fail(err)
}
}
this._bindActiveMessageScope()
await this.messages.remove(messageId)
const payload = {
id: messageId,
@@ -1395,8 +1464,8 @@ async deleteMessage (messageId) {
guildId
}
if (this.mode === 'dm' && this.dm) this.dm.gossipMessageDelete(payload)
else if (this.guild) this.guild.gossipMessageDelete(payload)
if (this.guild?.guild?.id) {
else if (this.mode === 'guild' && this.guild) this.guild.gossipMessageDelete(payload)
if (this.mode === 'guild' && this.guild?.guild?.id) {
await this._removeMessageFromSearchIndex(messageId, this.guild.guild.id).catch(() => {})
}
this.emit('message-delete', payload)
@@ -1430,8 +1499,19 @@ async deleteMessage (messageId) {
async channelHistory () {
if (!this.messages) return []
// Ensure history uses the mode-bound channel+guild (not a stale guild bind after openDM).
this._bindActiveMessageScope()
const expectedGuildId = this.messages.guildId
const rows = await this.messages.history(80)
return rows.map(enrichmentScope.enrichMessageRow)
const scoped = expectedGuildId
? rows.filter((m) => {
if (m.guildId == null || m.guildId === '') {
return expectedGuildId !== dmScope.DM_GUILD_ID
}
return m.guildId === expectedGuildId
})
: rows
return scoped.map(enrichmentScope.enrichMessageRow)
}
}
+38 -2
View File
@@ -224,6 +224,20 @@ _resolveActiveChannelForView (channels, opts = {}) {
activeChannel = fallback
recovered = previousChannelId !== fallback.id
reason = 'dm-channel-drift'
if (this.messages) {
this.messages.setChannel({ channelId: fallback.id, guildId: dmScope.DM_GUILD_ID })
}
}
} else if (this.messages) {
// Keep message store on DM guild even when the channel id already matched.
if (
this.messages.channelId !== activeChannel.id ||
this.messages.guildId !== dmScope.DM_GUILD_ID
) {
this.messages.setChannel({
channelId: activeChannel.id,
guildId: dmScope.DM_GUILD_ID
})
}
}
} else if (previousChannelId && !activeChannel && list.length) {
@@ -519,11 +533,33 @@ async view (opts = {}) {
const historyGuildId =
this.mode === 'dm'
? dmScope.DM_GUILD_ID
: this.guild?.guild?.id || this.messages.guildId || null
if (historyGuildId && this.messages.channelId !== this.activeChannelId) {
: this.mode === 'guild'
? this.guild?.guild?.id || null
: null
// Rebind when channel OR guild scope drifts (openDM used to only fix channelId mismatch,
// so guild history could still load into a DM if channelId briefly matched).
if (
historyGuildId &&
(this.messages.channelId !== this.activeChannelId ||
this.messages.guildId !== historyGuildId)
) {
this.messages.setChannel({ channelId: this.activeChannelId, guildId: historyGuildId })
}
if (typeof this._bindActiveMessageScope === 'function') {
this._bindActiveMessageScope()
}
messages = await this.channelHistory()
if (historyGuildId) {
messages = messages.filter((m) => {
if (m.guildId == null || m.guildId === '') {
return historyGuildId !== dmScope.DM_GUILD_ID
}
return m.guildId === historyGuildId
})
} else if (this.mode === 'dm' || this.mode === 'home') {
// Never show guild-scoped rows while on DM/home surfaces.
messages = messages.filter((m) => m.guildId === dmScope.DM_GUILD_ID)
}
}
const linkEmbedsByMessage = {}
const embedResolveLatencyByMessage = {}
@@ -216,13 +216,27 @@ async listDMConversations () {
async _initDmSession () {
const user = this.identity.user
if (!user) throw new Error('register first')
await this._leaveMeshes()
// Enter DM mode and drop the active channel *before* mesh leave so concurrent
// view()/send cannot still bind guild channel history or write into the public guild.
this.mode = 'dm'
this.activeChannelId = null
if (this.messages) {
// Park the message store off any guild channel until openDM sets the DM channel.
this.messages.setChannel({ channelId: null, guildId: dmScope.DM_GUILD_ID })
}
await this._leaveMeshes()
// Keep this.guild for later restore, but messaging must never use it while mode === 'dm'.
this.dm = await this._ensureDmBackgroundListener()
if (!this.messages) {
this.messages = new PearcordMessage({ authorId: user.id, db: this.db })
await this.messages.ready()
}
if (this.messages.guildId !== dmScope.DM_GUILD_ID) {
this.messages.setChannel({
channelId: this.messages.channelId,
guildId: dmScope.DM_GUILD_ID
})
}
return user
},
@@ -249,6 +263,8 @@ async openDM ({ peerUserId, peerDisplayName, sidebarFilterActive = false }) {
} else if (this.dm?.gossipDmChannelUpsert) {
this.dm.gossipDmChannelUpsert(channel)
}
// Mode must stay dm for the whole open path (set early in _initDmSession).
this.mode = 'dm'
this.messages.setChannel({ channelId: channel.id, guildId: dmScope.DM_GUILD_ID })
this.activeChannelId = channel.id
await this.markChannelRead(channel.id, dmScope.DM_GUILD_ID)