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:
@@ -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`.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user