feat(attachments): forward, heal, preview reads for Phase 665 batch 2

Add forwardAttachment, cancelAttachmentDownload, _healOrphanedAttachmentRefs,
read-only stageAttachment gate, and extended readAttachmentPreview for video
and modern image formats.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 19:50:52 -04:00
co-authored by Cursor
parent 0eb4922790
commit 13aecb1211
2 changed files with 106 additions and 3 deletions
+2
View File
@@ -4,6 +4,8 @@ Application facade: one `PearcordPlatform` class that wires identity, database,
**Phase 659 (v0.8.638):** DM polls slice — `sendMessage` parses `/poll`, `/pollm`, `/polla`, `/pollma` in DM mode; `createDmPoll` supports expiry metadata, `voteDmPoll` enforces close/expiry guardrails with idempotent desired-state apply, `editDmPoll` allows edits only before first vote, `closeDmPoll` force-closes active polls, and `deleteDmPoll` enforces DM/group DM poll delete permissions. See [DM_POLLS.md](../../docs/DM_POLLS.md).
**Phase 665 batch 2 (v0.8.640):** `forwardAttachment`, `cancelAttachmentDownload`, `_healOrphanedAttachmentRefs`, read-only `stageAttachment` gate, video/WEBP/HEIC/GIF preview reads. Bundle: `npm run test:phase665-media-batch2`.
**Phase 665 (v0.8.640):** Attachment gallery — `exportAttachmentDiagnostics`/`exportAttachmentDiagnosticsAsync`, `readAttachmentBytesWithSource` `attachment.fetch` span + fetch progress, group DM `groupDmAttachmentMaxBytes` quota on `stageAttachment`, slowmode on staging. Bundle: `npm run test:ci-phase665`.
**Phase 664 (v0.8.639):** Voice-note UX batch 2 — `fetchVoiceNoteBlob`, `forwardVoiceNote`, `_healVoiceNoteFailedDrafts`, group DM byte quota, failed-commit sidecar watermark. Bundle: `npm run test:ci-phase664`.
+104 -3
View File
@@ -483,6 +483,8 @@ class PearcordPlatform extends EventEmitter {
this._lastVoiceSpeakingGossipAt = 0
this._readOnly = opts.readOnly === true
this._companionMode = opts.companionMode === true || opts.readOnly === true
/** @type {Set<string>} */
this._attachmentDownloadCancel = new Set()
this._discoveryFilter = { query: '', sort: 'newest', minMembers: 0, tag: '' }
this._guildTyping = new Map()
this._typingTimers = new Set()
@@ -1504,7 +1506,10 @@ class PearcordPlatform extends EventEmitter {
const voiceNoteHeal = await this._healVoiceNoteFailedDrafts(gid).catch(() => ({
retried: 0
}))
return { voiceApplied, emojiSlots, automodReconciled, voiceNoteHeal }
const attachmentHeal = await this._healOrphanedAttachmentRefs(gid).catch(() => ({
relinked: 0
}))
return { voiceApplied, emojiSlots, automodReconciled, voiceNoteHeal, attachmentHeal }
}
async _healVoiceNoteFailedDrafts (guildId) {
@@ -12851,6 +12856,7 @@ class PearcordPlatform extends EventEmitter {
filename: filename || null
})
try {
if (this._readOnly) throw new Error('read-only companion mode cannot attach files')
if (this.mode === 'guild') await this._assertCanParticipate('attach')
await this._assertSlowmode()
if (!this.attachments) throw new Error('attachments not ready')
@@ -17043,6 +17049,10 @@ class PearcordPlatform extends EventEmitter {
}
async readAttachmentBytesWithSource (attachmentId) {
if (this._attachmentDownloadCancel.has(attachmentId)) {
this._attachmentDownloadCancel.delete(attachmentId)
throw new Error('attachment download cancelled')
}
const row = await this.attachments?.get(attachmentId)
if (!row) throw new Error('attachment not found')
const fetchSpan = this.log.time('attachment.fetch', {
@@ -17130,6 +17140,91 @@ class PearcordPlatform extends EventEmitter {
}
}
cancelAttachmentDownload (attachmentId) {
if (attachmentId) this._attachmentDownloadCancel.add(String(attachmentId))
}
async forwardAttachment ({ messageId, targetChannelId, attachmentIds = null }) {
if (!this.attachments) throw new Error('attachments not ready')
if (!targetChannelId) throw new Error('targetChannelId required')
let ids = Array.isArray(attachmentIds) ? attachmentIds.filter(Boolean) : []
if (!ids.length && messageId) {
const msg = await this.db.get(COLLECTIONS.MESSAGES, { id: messageId }).catch(() => null)
ids = msg?.attachmentIds || []
}
if (!ids.length) throw new Error('no attachments to forward')
const user = this.identity.user
if (!user) throw new Error('register first')
const prevChannel = this.activeChannelId
await this.selectChannel(targetChannelId)
const span = this.log.time('attachment.forward', {
spanKind: 'attachment.forward',
sourceMessageId: messageId,
targetChannelId,
count: ids.length
})
try {
const stagedIds = []
for (const aid of ids) {
const row = await this.attachments.get(aid)
if (!row) continue
const buf = await this.readAttachmentBytes(aid)
const staged = await this.stageAttachment({
data: buf,
filename: row.filename,
mimeType: row.mimeType
})
if (staged?.id) stagedIds.push(staged.id)
}
if (!stagedIds.length) throw new Error('failed to stage forwarded attachments')
const outMsg = await this.sendMessage('', { attachmentIds: stagedIds })
span.end({ messageId: outMsg?.id, stagedCount: stagedIds.length })
if (prevChannel && prevChannel !== targetChannelId) {
await this.selectChannel(prevChannel).catch(() => null)
}
return { message: outMsg, attachmentIds: stagedIds }
} catch (err) {
span.fail(err)
throw err
}
}
async _healOrphanedAttachmentRefs (guildId, channelId = null) {
const gid = guildId || this.guild?.guild?.id
if (!gid || !this.attachments) return { relinked: 0, skipped: true }
const chFilter = channelId || this.activeChannelId
const messages = chFilter
? await this.db.find(COLLECTIONS.MESSAGES, { channelId: chFilter }, { limit: 400 })
: await this.db.find(COLLECTIONS.MESSAGES, { guildId: gid }, { limit: 400 })
let relinked = 0
for (const msg of messages) {
const ids = msg.attachmentIds || []
if (!ids.length) continue
for (const aid of ids) {
const existing = await this.attachments.get(aid).catch(() => null)
if (!existing) continue
if (existing.messageId === msg.id) continue
if (!existing.messageId) {
await this.attachments.linkMessage(aid, msg.id).catch(() => null)
relinked++
}
}
}
const store = await this._getGuildSidecar(gid).catch(() => null)
if (store?.patchAttachmentMetaWatermark && relinked) {
await store.patchAttachmentMetaWatermark({ watermarkTs: Date.now(), relinked }).catch(() => null)
}
if (relinked) {
this.log.info('attachment.heal', {
spanKind: 'attachment.heal',
guildId: gid,
channelId: chFilter,
relinked
})
}
return { relinked, channelId: chFilter }
}
async readAttachmentPreview (attachmentId) {
const guildId = this.mode === 'dm' ? DM_GUILD_ID : this.guild?.guild?.id || null
const channelId = this.activeChannelId
@@ -17142,8 +17237,14 @@ class PearcordPlatform extends EventEmitter {
try {
const row = await this.attachments?.get(attachmentId)
if (!row) throw new Error('attachment not found')
if (!String(row.mimeType || '').startsWith('image/')) {
throw new Error('preview only for images')
const mime = String(row.mimeType || '').toLowerCase()
const fname = String(row.filename || '').toLowerCase()
const previewable =
mime.startsWith('image/') ||
mime.startsWith('video/') ||
/\.(webp|heic|heif|gif)$/i.test(fname)
if (!previewable) {
throw new Error('preview not supported for this mime type')
}
const { buf, source } = await this.readAttachmentBytesWithSource(attachmentId)
const preview = {