@modesto_ma/dsh-channels 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -39
- package/docs/access-control-design.md +1 -1
- package/docs/attachment-pipeline-v2.md +318 -0
- package/docs/feasibility-and-design.md +54 -26
- package/docs/feishu-onboarding.md +123 -15
- package/docs/persistence.md +4 -4
- package/docs/refactor-plan.md +7 -6
- package/docs/workspace-isolation-design.md +81 -0
- package/lib/client.js +30 -15
- package/lib/client.js.map +1 -1
- package/lib/index.js +11008 -5132
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +23 -0
- package/src/cards/primitives.ts +20 -0
- package/src/cards/streaming.ts +310 -291
- package/src/cleanup.ts +267 -119
- package/src/client/panel/cleanup-card.tsx +4 -2
- package/src/client/panel/workspace-tab.tsx +1 -1
- package/src/config.ts +221 -13
- package/src/domain.ts +141 -3
- package/src/feishu/agent-context.ts +66 -16
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +270 -0
- package/src/feishu/message-read.ts +1333 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/pending-attachments.ts +562 -0
- package/src/feishu/tools.ts +2345 -0
- package/src/feishu-channel.ts +55 -0
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +8 -6
- package/src/routing/inbound.ts +730 -81
- package/src/service.ts +536 -50
- package/src/session/isolation.ts +277 -0
- package/src/session/manager.ts +120 -23
- package/src/session/repair.ts +2 -3
- package/src/session/routes.ts +2 -0
- package/src/session-commands.ts +41 -14
- package/src/settings/contract.ts +43 -21
- package/src/settings/overlay.ts +124 -13
- package/src/settings/surface.ts +147 -14
- package/src/streamer.ts +70 -30
- package/src/workspaces.ts +74 -31
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending attachment registry: silent metadata registration for inbound
|
|
3
|
+
* messages that do not trigger a model turn.
|
|
4
|
+
*
|
|
5
|
+
* Product rule (decisions D2): attachment-only p2p messages, un-mentioned group
|
|
6
|
+
* messages, and attachment-only messages inside an already-bound topic are
|
|
7
|
+
* recorded as metadata only — no download, no reply, no session, no reaction.
|
|
8
|
+
* A later triggered message can list the most recent unconsumed entries in its
|
|
9
|
+
* prompt manifest and fetch them on demand; the fetch tool also uses the
|
|
10
|
+
* registry as its per-route authorization whitelist.
|
|
11
|
+
*
|
|
12
|
+
* Isolation is structural: every record carries a `scopeKey` derived from
|
|
13
|
+
* `(chat, thread?, sender)`. `list`/`get`/`consume` all require a route scope,
|
|
14
|
+
* so a known message id alone can never unlock another sender's, chat's, or
|
|
15
|
+
* topic's record. A thread route also sees the same sender's chat-scope rows of
|
|
16
|
+
* the same chat (a group file posted without an @ before the topic existed must
|
|
17
|
+
* remain reachable from the topic that the later @-mention creates — Roadmap
|
|
18
|
+
* AC-4); everything else is exactly scope-bound.
|
|
19
|
+
*
|
|
20
|
+
* This task deliberately does NOT call the registry from the inbound path — the
|
|
21
|
+
* admission layer (`admission_manifest_flow`) wires registration after its
|
|
22
|
+
* access gates. To keep that contract enforceable, `registerMessage` requires
|
|
23
|
+
* an explicit admission decision and is a no-op unless it says `admitted`,
|
|
24
|
+
* while `acceptAttachments=false` disables registration entirely.
|
|
25
|
+
*
|
|
26
|
+
* Durability: the primary backing table is `pending_attachments` in the
|
|
27
|
+
* `channels_routes` storage domain (added at the same domain version; the JSON
|
|
28
|
+
* backend initializes the missing declared table as empty). When the domain
|
|
29
|
+
* open succeeds but the host refuses that table (older/embedded host), the same
|
|
30
|
+
* TTL/cap/query semantics run over an in-process map instead; records then live
|
|
31
|
+
* only for this process and are lost on restart. That limitation is logged
|
|
32
|
+
* once.
|
|
33
|
+
*
|
|
34
|
+
* No I/O beyond the injected table: this module never touches the Feishu
|
|
35
|
+
* channel, the filesystem, or the native attachment library.
|
|
36
|
+
*
|
|
37
|
+
* @module dsh-channels/feishu/pending-attachments
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { pendingAttachmentRecordSchema } from '../domain.ts'
|
|
41
|
+
import type {
|
|
42
|
+
PendingAttachmentKind, PendingAttachmentRecord, PendingAttachmentResource, PendingAttachmentsTable,
|
|
43
|
+
} from '../domain.ts'
|
|
44
|
+
import type { NormalizedMessage } from '../feishu-channel.ts'
|
|
45
|
+
import {
|
|
46
|
+
EMPTY_POST_PLACEHOLDER, attachmentResources, stripAttachmentPlaceholders,
|
|
47
|
+
} from './inbound-attachments.ts'
|
|
48
|
+
|
|
49
|
+
/** Manifest rows shown to the model: only the most recent N unconsumed entries. */
|
|
50
|
+
export const PENDING_ATTACHMENT_MANIFEST_LIMIT = 5
|
|
51
|
+
|
|
52
|
+
/** Longest caption excerpt kept per record, in Unicode code points. */
|
|
53
|
+
export const PENDING_ATTACHMENT_TEXT_EXCERPT_MAX = 200
|
|
54
|
+
|
|
55
|
+
/** `(chat, thread?, sender)` isolation unit of one pending record. */
|
|
56
|
+
export interface PendingAttachmentScope {
|
|
57
|
+
/** Feishu chat id. */
|
|
58
|
+
readonly chatId: string
|
|
59
|
+
/** Feishu topic id; absent for the chat's main timeline. */
|
|
60
|
+
readonly threadId?: string
|
|
61
|
+
/** Normalized sender id (open_id) owning the scope. */
|
|
62
|
+
readonly senderOpenId: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stable, collision-free table value for one scope. Chat-scope and thread-scope
|
|
67
|
+
* keys carry different prefixes so a sender id can never alias a thread id.
|
|
68
|
+
*/
|
|
69
|
+
export function pendingAttachmentScopeKey(scope: PendingAttachmentScope): string {
|
|
70
|
+
return scope.threadId === undefined
|
|
71
|
+
? `chat:${scope.chatId}:${scope.senderOpenId}`
|
|
72
|
+
: `thread:${scope.chatId}:${scope.threadId}:${scope.senderOpenId}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether one record is visible to a route query scope.
|
|
77
|
+
*
|
|
78
|
+
* The exact scope always matches. A thread-bound route additionally matches the
|
|
79
|
+
* same sender's chat-scope rows of the same chat: in a group, a file posted
|
|
80
|
+
* without an @ before the topic existed is registered without a `threadId`, yet
|
|
81
|
+
* the later @-triggered topic must be able to list and fetch it (Roadmap AC-4).
|
|
82
|
+
* Other topics, other senders, and other chats never match.
|
|
83
|
+
*/
|
|
84
|
+
function recordInScope(record: PendingAttachmentRecord, scope: PendingAttachmentScope): boolean {
|
|
85
|
+
if (record.scopeKey === pendingAttachmentScopeKey(scope)) return true
|
|
86
|
+
if (scope.threadId === undefined) return false
|
|
87
|
+
return record.scopeKey === pendingAttachmentScopeKey({
|
|
88
|
+
chatId: scope.chatId,
|
|
89
|
+
senderOpenId: scope.senderOpenId,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The admission decision a registration must carry. Only an explicit
|
|
95
|
+
* `admitted: true` may write a record — the registry has no own admission
|
|
96
|
+
* policy, it just refuses to record anything that did not pass the caller's
|
|
97
|
+
* gate.
|
|
98
|
+
*/
|
|
99
|
+
export type PendingAttachmentAdmission =
|
|
100
|
+
| { readonly admitted: true }
|
|
101
|
+
| { readonly admitted: false; readonly reason?: string }
|
|
102
|
+
|
|
103
|
+
/** Why a registration did not store anything (`stored: false`). */
|
|
104
|
+
export type PendingAttachmentSkipReason =
|
|
105
|
+
| 'disabled'
|
|
106
|
+
| 'not-admitted'
|
|
107
|
+
| 'not-attachment-message'
|
|
108
|
+
| 'invalid-message'
|
|
109
|
+
| 'duplicate'
|
|
110
|
+
| 'storage-error'
|
|
111
|
+
|
|
112
|
+
/** Outcome of one registration attempt. */
|
|
113
|
+
export type PendingAttachmentRegistration =
|
|
114
|
+
| {
|
|
115
|
+
readonly stored: true
|
|
116
|
+
readonly record: PendingAttachmentRecord
|
|
117
|
+
/** Older records evicted from the same scope to respect the per-scope cap. */
|
|
118
|
+
readonly evicted: number
|
|
119
|
+
}
|
|
120
|
+
| { readonly stored: false; readonly reason: PendingAttachmentSkipReason }
|
|
121
|
+
|
|
122
|
+
/** Backing store actually used by a registry instance. */
|
|
123
|
+
export type PendingAttachmentMode = 'durable' | 'memory'
|
|
124
|
+
|
|
125
|
+
/** Result of one TTL + per-scope-cap sweep (safe to run repeatedly). */
|
|
126
|
+
export interface PendingAttachmentPruneResult {
|
|
127
|
+
/** Records removed because they were older than the TTL. */
|
|
128
|
+
readonly removedExpired: number
|
|
129
|
+
/** Oldest records removed because their scope exceeded the row cap. */
|
|
130
|
+
readonly removedOverLimit: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Every effect the registry needs, injected so tests can drive a fake clock. */
|
|
134
|
+
export interface PendingAttachmentStoreDeps {
|
|
135
|
+
/**
|
|
136
|
+
* Durable table handle. `undefined` = the host opened the storage domain but
|
|
137
|
+
* refused the added table → the registry degrades to the in-process fallback
|
|
138
|
+
* (a domain that cannot open at all fails service startup instead).
|
|
139
|
+
*/
|
|
140
|
+
table(): PendingAttachmentsTable | undefined
|
|
141
|
+
/** `acceptAttachments`; `false` makes registration a no-op. */
|
|
142
|
+
enabled(): boolean
|
|
143
|
+
/** Retention window in ms; `<= 0` disables age pruning (the cap still applies). */
|
|
144
|
+
ttlMs(): number
|
|
145
|
+
/** Maximum rows kept per scope (>= 1). */
|
|
146
|
+
maxPerScope(): number
|
|
147
|
+
/** Clock seam; defaults to `Date.now()`. */
|
|
148
|
+
now?(): number
|
|
149
|
+
/** Warning sink (in-memory fallback, per-key write failures). Never throws. */
|
|
150
|
+
warn?(message: string, ...args: unknown[]): void
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One extracted attachment message, before it becomes a durable record. */
|
|
154
|
+
export interface PendingAttachmentCandidate {
|
|
155
|
+
readonly messageId: string
|
|
156
|
+
readonly chatId: string
|
|
157
|
+
readonly chatType: 'p2p' | 'group'
|
|
158
|
+
readonly threadId?: string
|
|
159
|
+
readonly senderOpenId: string
|
|
160
|
+
readonly kind: PendingAttachmentKind
|
|
161
|
+
readonly resources: readonly PendingAttachmentResource[]
|
|
162
|
+
readonly textExcerpt?: string
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Extract the attachable payload of one normalized message, or `undefined` when
|
|
167
|
+
* the message carries nothing the registry may track.
|
|
168
|
+
*
|
|
169
|
+
* Only image / file / rich-text-with-files / merged-forward messages qualify
|
|
170
|
+
* (audio, video and sticker payloads are out of scope). Feishu folders carry no
|
|
171
|
+
* downloadable bytes but are recorded with a `folder` marker so the fetch tool
|
|
172
|
+
* can answer with a structured "folder unsupported". A merged-forward container
|
|
173
|
+
* has no directly listed resources: children are downloaded through the
|
|
174
|
+
* record's own `messageId`, per the P0 container rule.
|
|
175
|
+
*/
|
|
176
|
+
export function pendingAttachmentCandidate(
|
|
177
|
+
msg: NormalizedMessage,
|
|
178
|
+
): PendingAttachmentCandidate | undefined {
|
|
179
|
+
if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return undefined
|
|
180
|
+
const forwarded = msg.rawContentType === 'merge_forward'
|
|
181
|
+
const resources = attachmentResources(msg).map((resource): PendingAttachmentResource => ({
|
|
182
|
+
type: resource.type,
|
|
183
|
+
fileKey: resource.fileKey,
|
|
184
|
+
...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
|
|
185
|
+
...(resource.folder === true ? { folder: true } : {}),
|
|
186
|
+
}))
|
|
187
|
+
let kind: PendingAttachmentKind
|
|
188
|
+
if (forwarded) {
|
|
189
|
+
kind = 'merge_forward'
|
|
190
|
+
} else if (msg.rawContentType === 'post') {
|
|
191
|
+
if (resources.length === 0) return undefined
|
|
192
|
+
kind = 'post'
|
|
193
|
+
} else if (msg.rawContentType === 'image' && resources.length > 0) {
|
|
194
|
+
kind = 'image'
|
|
195
|
+
} else if (msg.rawContentType === 'file' && resources.length > 0) {
|
|
196
|
+
kind = 'file'
|
|
197
|
+
} else {
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
const excerpt = textExcerptOf(msg.content ?? '', forwarded)
|
|
201
|
+
return {
|
|
202
|
+
messageId: msg.messageId,
|
|
203
|
+
chatId: msg.chatId,
|
|
204
|
+
chatType: msg.chatType,
|
|
205
|
+
...(msg.threadId === undefined ? {} : { threadId: msg.threadId }),
|
|
206
|
+
senderOpenId: msg.senderId,
|
|
207
|
+
kind,
|
|
208
|
+
resources,
|
|
209
|
+
...(excerpt === undefined ? {} : { textExcerpt: excerpt }),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remaining caption text after attachment placeholders, truncated. A
|
|
215
|
+
* merged-forward record's content is an expanded transcript, not a caption, so
|
|
216
|
+
* it never contributes an excerpt (the manifest formats `kind` instead). The
|
|
217
|
+
* SDK's `[rich text message]` placeholder for a file-only rich-text message is
|
|
218
|
+
* treated as "no caption" exactly like the archive path does.
|
|
219
|
+
*/
|
|
220
|
+
function textExcerptOf(content: string, forwarded: boolean): string | undefined {
|
|
221
|
+
if (forwarded) return undefined
|
|
222
|
+
const stripped = stripAttachmentPlaceholders(content, false).trim()
|
|
223
|
+
if (stripped === '' || stripped === EMPTY_POST_PLACEHOLDER) return undefined
|
|
224
|
+
const chars = [...stripped]
|
|
225
|
+
if (chars.length <= PENDING_ATTACHMENT_TEXT_EXCERPT_MAX) return stripped
|
|
226
|
+
return chars.slice(0, PENDING_ATTACHMENT_TEXT_EXCERPT_MAX).join('')
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- backing stores ------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** Minimal storage contract shared by the durable table and the fallback map. */
|
|
232
|
+
interface PendingBacking {
|
|
233
|
+
readonly mode: PendingAttachmentMode
|
|
234
|
+
get(key: string): PendingAttachmentRecord | undefined
|
|
235
|
+
put(key: string, value: PendingAttachmentRecord): Promise<unknown>
|
|
236
|
+
update(key: string, fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord): Promise<unknown>
|
|
237
|
+
delete(key: string): Promise<unknown>
|
|
238
|
+
entries(): IterableIterator<[string, PendingAttachmentRecord]>
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Wrap one normalized domain table as a backing store. */
|
|
242
|
+
function tableBacking(table: PendingAttachmentsTable): PendingBacking {
|
|
243
|
+
return {
|
|
244
|
+
mode: 'durable',
|
|
245
|
+
get: key => table.get(key),
|
|
246
|
+
put: (key, value) => Promise.resolve(table.put(key, value)),
|
|
247
|
+
update: (key, fn) => table.update(key, current => fn(current)),
|
|
248
|
+
delete: key => Promise.resolve(table.delete(key)),
|
|
249
|
+
entries: () => table.entries(),
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Process-memory fallback for hosts without the added domain table. Same
|
|
255
|
+
* semantics as the durable table (`update` rejects on a missing key) so the
|
|
256
|
+
* registry code path is identical; records are lost when the process exits.
|
|
257
|
+
*/
|
|
258
|
+
class MemoryBacking implements PendingBacking {
|
|
259
|
+
readonly mode = 'memory' as const
|
|
260
|
+
private readonly records = new Map<string, PendingAttachmentRecord>()
|
|
261
|
+
|
|
262
|
+
get(key: string): PendingAttachmentRecord | undefined {
|
|
263
|
+
return this.records.get(key)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async put(key: string, value: PendingAttachmentRecord): Promise<unknown> {
|
|
267
|
+
this.records.set(key, value)
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async update(
|
|
272
|
+
key: string,
|
|
273
|
+
fn: (current: PendingAttachmentRecord) => PendingAttachmentRecord,
|
|
274
|
+
): Promise<unknown> {
|
|
275
|
+
const current = this.records.get(key)
|
|
276
|
+
if (current === undefined) throw new Error('missing-key')
|
|
277
|
+
const next = fn(current)
|
|
278
|
+
this.records.set(key, next)
|
|
279
|
+
return next
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async delete(key: string): Promise<unknown> {
|
|
283
|
+
return this.records.delete(key)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
entries(): IterableIterator<[string, PendingAttachmentRecord]> {
|
|
287
|
+
return this.records.entries()
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- registry ------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Narrow registry API used by the admission layer (register), the prompt
|
|
295
|
+
* manifest (list) and the fetch tool (get/consume). The periodic record GC
|
|
296
|
+
* calls `prune` together with the other routing-domain sweeps.
|
|
297
|
+
*/
|
|
298
|
+
export class PendingAttachmentStore {
|
|
299
|
+
private readonly deps: PendingAttachmentStoreDeps
|
|
300
|
+
private readonly memory = new MemoryBacking()
|
|
301
|
+
private warnedFallback = false
|
|
302
|
+
|
|
303
|
+
constructor(deps: PendingAttachmentStoreDeps) {
|
|
304
|
+
this.deps = deps
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Backing mode of the current configuration (no side effects). */
|
|
308
|
+
mode(): PendingAttachmentMode {
|
|
309
|
+
return this.deps.table() === undefined ? 'memory' : 'durable'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Register one admitted attachment message. A no-op for non-attachment
|
|
314
|
+
* messages, a denied admission, `acceptAttachments=false`, and a message id
|
|
315
|
+
* that is already registered (storage-level idempotency; the inbound dedup
|
|
316
|
+
* table remains the authoritative redelivery guard).
|
|
317
|
+
*
|
|
318
|
+
* The durable write happens BEFORE evicting older rows of the same scope: a
|
|
319
|
+
* failed write leaves the scope untouched instead of losing an old row for
|
|
320
|
+
* nothing. A successful eviction restores the cap immediately; only a failed
|
|
321
|
+
* delete can leave the scope over the cap until the next sweep.
|
|
322
|
+
*/
|
|
323
|
+
async registerMessage(
|
|
324
|
+
msg: NormalizedMessage,
|
|
325
|
+
admission: PendingAttachmentAdmission,
|
|
326
|
+
): Promise<PendingAttachmentRegistration> {
|
|
327
|
+
if (!this.deps.enabled()) return { stored: false, reason: 'disabled' }
|
|
328
|
+
if (admission.admitted !== true) return { stored: false, reason: 'not-admitted' }
|
|
329
|
+
const candidate = pendingAttachmentCandidate(msg)
|
|
330
|
+
if (candidate === undefined) return { stored: false, reason: 'not-attachment-message' }
|
|
331
|
+
const now = this.now()
|
|
332
|
+
const backing = this.backing()
|
|
333
|
+
const existing = backing.get(candidate.messageId)
|
|
334
|
+
if (existing !== undefined && !this.expired(existing, now)) {
|
|
335
|
+
return { stored: false, reason: 'duplicate' }
|
|
336
|
+
}
|
|
337
|
+
const record = this.recordOf(candidate, now)
|
|
338
|
+
// A record written here must survive the next domain open, which validates
|
|
339
|
+
// every stored row against the schema; reject malformed input up front
|
|
340
|
+
// instead of poisoning the whole `channels_routes` domain.
|
|
341
|
+
const parsed = pendingAttachmentRecordSchema.safeParse(record)
|
|
342
|
+
if (!parsed.success) {
|
|
343
|
+
this.deps.warn?.(
|
|
344
|
+
'dsh-channels: skipping malformed pending attachment metadata for message %s: %s',
|
|
345
|
+
record.messageId, parsed.error.issues[0]?.message ?? 'invalid record',
|
|
346
|
+
)
|
|
347
|
+
return { stored: false, reason: 'invalid-message' }
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
await backing.put(record.key, record)
|
|
351
|
+
} catch (error) {
|
|
352
|
+
this.deps.warn?.(
|
|
353
|
+
'dsh-channels: pending attachment registration failed for %s: %s',
|
|
354
|
+
record.messageId, String(error),
|
|
355
|
+
)
|
|
356
|
+
return { stored: false, reason: 'storage-error' }
|
|
357
|
+
}
|
|
358
|
+
const evicted = await this.evictOverCap(backing, record.scopeKey, now, record.key)
|
|
359
|
+
return { stored: true, record, evicted }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Most recent unconsumed records visible to one route scope, newest first.
|
|
364
|
+
* The prompt manifest shows at most `PENDING_ATTACHMENT_MANIFEST_LIMIT` rows;
|
|
365
|
+
* callers that need a different window pass an explicit limit (never above the
|
|
366
|
+
* per-scope cap). Expired and consumed rows are excluded.
|
|
367
|
+
*
|
|
368
|
+
* Visibility follows {@link recordInScope}: file-only messages posted in a
|
|
369
|
+
* group's main timeline before the topic existed are registered without a
|
|
370
|
+
* `threadId`, so the topic that a later @-mention creates still lists them
|
|
371
|
+
* (Roadmap AC-4). Other topics, senders, and chats stay invisible.
|
|
372
|
+
*/
|
|
373
|
+
list(
|
|
374
|
+
scope: PendingAttachmentScope,
|
|
375
|
+
options: { limit?: number } = {},
|
|
376
|
+
): PendingAttachmentRecord[] {
|
|
377
|
+
const now = this.now()
|
|
378
|
+
// A thread scope can see its own rows plus the same sender's chat-scope
|
|
379
|
+
// rows (see `recordInScope`), so at most two scopes' caps (2× the per-scope
|
|
380
|
+
// cap) can be visible; the requested manifest window is clamped to that.
|
|
381
|
+
const maxVisible = Math.max(1, this.deps.maxPerScope() * 2)
|
|
382
|
+
const requested = options.limit ?? PENDING_ATTACHMENT_MANIFEST_LIMIT
|
|
383
|
+
const limit = Math.max(1, Math.min(requested, maxVisible))
|
|
384
|
+
const backing = this.backing()
|
|
385
|
+
const rows: PendingAttachmentRecord[] = []
|
|
386
|
+
for (const [, record] of backing.entries()) {
|
|
387
|
+
if (!recordInScope(record, scope)) continue
|
|
388
|
+
if (record.consumed === true) continue
|
|
389
|
+
if (this.expired(record, now)) continue
|
|
390
|
+
rows.push(record)
|
|
391
|
+
}
|
|
392
|
+
rows.sort((left, right) =>
|
|
393
|
+
right.receivedAt - left.receivedAt || left.messageId.localeCompare(right.messageId))
|
|
394
|
+
return rows.slice(0, limit)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Look up one registered message id in one route scope (the fetch tool's
|
|
399
|
+
* authorization whitelist). Consumed rows still resolve — a fetch may be
|
|
400
|
+
* retried — but expired rows and records of another sender / chat / topic do
|
|
401
|
+
* not. Visibility follows {@link recordInScope}.
|
|
402
|
+
*/
|
|
403
|
+
get(scope: PendingAttachmentScope, messageId: string): PendingAttachmentRecord | undefined {
|
|
404
|
+
const record = this.backing().get(messageId)
|
|
405
|
+
if (record === undefined) return undefined
|
|
406
|
+
if (!recordInScope(record, scope)) return undefined
|
|
407
|
+
return this.expired(record, this.now()) ? undefined : record
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Mark one record consumed so it drops out of the prompt manifest. Returns
|
|
412
|
+
* the record (marked consumed), or `undefined` when absent, expired, or
|
|
413
|
+
* outside the caller's scope. If the write fails the pre-read record is
|
|
414
|
+
* returned anyway and the failure is only logged: the fetch itself must not
|
|
415
|
+
* fail because bookkeeping did.
|
|
416
|
+
*/
|
|
417
|
+
async consume(
|
|
418
|
+
scope: PendingAttachmentScope,
|
|
419
|
+
messageId: string,
|
|
420
|
+
): Promise<PendingAttachmentRecord | undefined> {
|
|
421
|
+
const backing = this.backing()
|
|
422
|
+
const current = backing.get(messageId)
|
|
423
|
+
if (current === undefined || this.expired(current, this.now())) return undefined
|
|
424
|
+
if (!recordInScope(current, scope)) return undefined
|
|
425
|
+
if (current.consumed === true) return current
|
|
426
|
+
try {
|
|
427
|
+
await backing.update(messageId, record => ({ ...record, consumed: true }))
|
|
428
|
+
return { ...current, consumed: true }
|
|
429
|
+
} catch (error) {
|
|
430
|
+
this.deps.warn?.(
|
|
431
|
+
'dsh-channels: marking pending attachment %s consumed failed: %s',
|
|
432
|
+
messageId, String(error),
|
|
433
|
+
)
|
|
434
|
+
return current
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Remove expired rows and enforce the per-scope cap, oldest first. Runs at
|
|
440
|
+
* startup and on every periodic record-GC sweep; idempotent by design.
|
|
441
|
+
*/
|
|
442
|
+
async prune(): Promise<PendingAttachmentPruneResult> {
|
|
443
|
+
const backing = this.backing()
|
|
444
|
+
const now = this.now()
|
|
445
|
+
const maxRows = Math.max(1, this.deps.maxPerScope())
|
|
446
|
+
let removedExpired = 0
|
|
447
|
+
const remaining: PendingAttachmentRecord[] = []
|
|
448
|
+
for (const [key, record] of [...backing.entries()]) {
|
|
449
|
+
if (this.expired(record, now)) {
|
|
450
|
+
if (await this.remove(backing, key)) removedExpired += 1
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
remaining.push(record)
|
|
454
|
+
}
|
|
455
|
+
const byScope = new Map<string, PendingAttachmentRecord[]>()
|
|
456
|
+
for (const record of remaining) {
|
|
457
|
+
const rows = byScope.get(record.scopeKey)
|
|
458
|
+
if (rows === undefined) byScope.set(record.scopeKey, [record])
|
|
459
|
+
else rows.push(record)
|
|
460
|
+
}
|
|
461
|
+
let removedOverLimit = 0
|
|
462
|
+
for (const rows of byScope.values()) {
|
|
463
|
+
if (rows.length <= maxRows) continue
|
|
464
|
+
rows.sort((left, right) =>
|
|
465
|
+
left.receivedAt - right.receivedAt || left.messageId.localeCompare(right.messageId))
|
|
466
|
+
for (const record of rows.slice(0, rows.length - maxRows)) {
|
|
467
|
+
if (await this.remove(backing, record.key)) removedOverLimit += 1
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { removedExpired, removedOverLimit }
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Resolve a backing store, warning once when durability is unavailable. */
|
|
474
|
+
private backing(): PendingBacking {
|
|
475
|
+
const table = this.deps.table()
|
|
476
|
+
if (table !== undefined) return tableBacking(table)
|
|
477
|
+
if (!this.warnedFallback) {
|
|
478
|
+
this.warnedFallback = true
|
|
479
|
+
this.deps.warn?.(
|
|
480
|
+
'dsh-channels: pending attachment storage domain unavailable; '
|
|
481
|
+
+ 'using a process-memory TTL fallback (records are lost on restart)',
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
return this.memory
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private recordOf(candidate: PendingAttachmentCandidate, receivedAt: number): PendingAttachmentRecord {
|
|
488
|
+
const scopeKey = pendingAttachmentScopeKey({
|
|
489
|
+
chatId: candidate.chatId,
|
|
490
|
+
...(candidate.threadId === undefined ? {} : { threadId: candidate.threadId }),
|
|
491
|
+
senderOpenId: candidate.senderOpenId,
|
|
492
|
+
})
|
|
493
|
+
return {
|
|
494
|
+
key: candidate.messageId,
|
|
495
|
+
scopeKey,
|
|
496
|
+
messageId: candidate.messageId,
|
|
497
|
+
chatId: candidate.chatId,
|
|
498
|
+
chatType: candidate.chatType,
|
|
499
|
+
...(candidate.threadId === undefined ? {} : { threadId: candidate.threadId }),
|
|
500
|
+
senderOpenId: candidate.senderOpenId,
|
|
501
|
+
receivedAt,
|
|
502
|
+
kind: candidate.kind,
|
|
503
|
+
resources: [...candidate.resources],
|
|
504
|
+
...(candidate.textExcerpt === undefined ? {} : { textExcerpt: candidate.textExcerpt }),
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* After a successful write, delete the oldest rows of one scope until the
|
|
510
|
+
* record fits in the cap again (the new record may briefly make the scope
|
|
511
|
+
* exceed the cap by one; a successful eviction restores the cap immediately,
|
|
512
|
+
* only a failed delete leaves the scope over until the next sweep). The just
|
|
513
|
+
* written `keepKey` is never a candidate: `receivedAt` ties (injected clock,
|
|
514
|
+
* clock rollback) would otherwise let the message-id tiebreak evict it.
|
|
515
|
+
*/
|
|
516
|
+
private async evictOverCap(
|
|
517
|
+
backing: PendingBacking,
|
|
518
|
+
scopeKey: string,
|
|
519
|
+
now: number,
|
|
520
|
+
keepKey: string,
|
|
521
|
+
): Promise<number> {
|
|
522
|
+
const maxRows = Math.max(1, this.deps.maxPerScope())
|
|
523
|
+
const rows: PendingAttachmentRecord[] = []
|
|
524
|
+
for (const [, record] of backing.entries()) {
|
|
525
|
+
if (record.scopeKey !== scopeKey) continue
|
|
526
|
+
if (this.expired(record, now)) continue
|
|
527
|
+
rows.push(record)
|
|
528
|
+
}
|
|
529
|
+
if (rows.length <= maxRows) return 0
|
|
530
|
+
const excess = rows.length - maxRows
|
|
531
|
+
const candidates = rows
|
|
532
|
+
.filter(record => record.key !== keepKey)
|
|
533
|
+
.sort((left, right) =>
|
|
534
|
+
left.receivedAt - right.receivedAt || left.messageId.localeCompare(right.messageId))
|
|
535
|
+
let evicted = 0
|
|
536
|
+
for (const record of candidates.slice(0, excess)) {
|
|
537
|
+
if (await this.remove(backing, record.key)) evicted += 1
|
|
538
|
+
}
|
|
539
|
+
return evicted
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async remove(backing: PendingBacking, key: string): Promise<boolean> {
|
|
543
|
+
try {
|
|
544
|
+
await backing.delete(key)
|
|
545
|
+
return true
|
|
546
|
+
} catch (error) {
|
|
547
|
+
this.deps.warn?.('dsh-channels: pruning pending attachment %s failed: %s', key, String(error))
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private now(): number {
|
|
553
|
+
return this.deps.now?.() ?? Date.now()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Expired exactly AT the TTL boundary (`receivedAt + ttl <= now`). */
|
|
557
|
+
private expired(record: PendingAttachmentRecord, now: number): boolean {
|
|
558
|
+
const ttl = this.deps.ttlMs()
|
|
559
|
+
if (ttl <= 0) return false
|
|
560
|
+
return record.receivedAt + ttl <= now
|
|
561
|
+
}
|
|
562
|
+
}
|