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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user