@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.2

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.
Files changed (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
@@ -4,8 +4,16 @@
4
4
  *
5
5
  * The answerer is registered with `prepend: true` on the plugin context so it
6
6
  * runs BEFORE generic answerers: a request for a session this channel routes
7
- * is claimed here, and the decision card becomes the only way to answer.
8
- * Requests for sessions the channel does NOT route delegate through `next()`.
7
+ * is claimed here, and the decision card (or a typed topic reply) becomes the
8
+ * only way to answer. Requests for sessions the channel does NOT route
9
+ * delegate through `next()`.
10
+ *
11
+ * The host request carries no tool arguments, so the answerer keeps the
12
+ * current turn's `tool/call` arguments per session (fed from `session/event`)
13
+ * and looks the asked call up by `callId` to show what will run. Standing
14
+ * "stop asking in this topic" grants live only in memory, keyed by session,
15
+ * tool name, and requested sandbox mode; they end with the session (`/renew`),
16
+ * the topic, or the process.
9
17
  *
10
18
  * Rendering lives in `cards/approval.ts`; the shared pending-card lifecycle
11
19
  * (delivery, owner gating, timeout, settle-once, withdrawal) lives in
@@ -17,7 +25,7 @@
17
25
  import { randomUUID } from 'node:crypto'
18
26
  import { buildApprovalCard, buildApprovalSettledCard } from '../cards/approval.ts'
19
27
  import type { ApprovalCardState, ApprovalQuestion } from '../cards/approval.ts'
20
- import type { ApprovalOutcome, ApprovalRequest } from '../dsh-types.ts'
28
+ import type { ApprovalOutcome, ApprovalRequest, SessionEvent } from '../dsh-types.ts'
21
29
  import type { CardActionEvent, FeishuChannel } from '../feishu-channel.ts'
22
30
  import { PendingCardTable } from './pending.ts'
23
31
  import type { InteractionRoute, PendingCardEntry } from './pending.ts'
@@ -33,16 +41,87 @@ export interface ApprovalHooks {
33
41
  routeFor(sessionId: string): ApprovalRoute | undefined
34
42
  /** Log a non-fatal error (delivery/update failures). */
35
43
  onError(sessionId: string, error: unknown): void
44
+ /**
45
+ * The tool names a session is currently waiting on for approval (empty when
46
+ * none), called on every open and settle so the streaming card can show it.
47
+ */
48
+ onWaitingChange?(sessionId: string, toolNames: readonly string[]): void
36
49
  }
37
50
 
51
+ /**
52
+ * How one typed topic reply was consumed by {@link ApprovalAnswerer.submitText}:
53
+ * a keyword decision (`allowed`, `remembered`, `denied`), or any other text
54
+ * that rejected every pending request of the session (`superseded`) and must
55
+ * still be delivered as the next instruction.
56
+ */
57
+ export type ApprovalTextResult = 'allowed' | 'remembered' | 'denied' | 'superseded'
58
+
38
59
  /** One in-flight approval question: its card content and its resolver. */
39
60
  interface Pending extends PendingCardEntry {
40
61
  question: ApprovalQuestion
62
+ /** Standing-grant key for "stop asking in this topic". */
63
+ grantKey: string
64
+ /** Open order, so a typed reply answers the newest card. */
65
+ openedAt: number
41
66
  resolve(outcome: ApprovalOutcome): void
42
67
  }
43
68
 
69
+ /** One recorded `tool/call` of a session's current turn. */
70
+ interface RecordedCall {
71
+ turn: number
72
+ name: string
73
+ arguments: string
74
+ }
75
+
76
+ const ALLOW_WORDS = new Set(['允许', '同意', '可以', '好', '好的', '行', '确认', 'y', 'yes', 'ok', 'allow', 'approve'])
77
+ const DENY_WORDS = new Set(['拒绝', '不允许', '不行', '不', '否', '不同意', 'n', 'no', 'deny', 'reject'])
78
+ const ALWAYS_WORDS = new Set(['全部允许', '都允许', '允许所有', '允许全部', '总是允许', '始终允许', 'allow all', 'always'])
79
+
80
+ /**
81
+ * Classify one typed reply. Trailing punctuation and case are ignored; the
82
+ * whole message must be the keyword so ordinary sentences never match.
83
+ * @param text - the reply text with mentions already stripped.
84
+ * @returns The keyword decision, or `undefined` for any other text.
85
+ */
86
+ export function classifyApprovalReply(text: string): 'allow' | 'always' | 'deny' | undefined {
87
+ const word = text.trim().toLowerCase().replace(/[\s。.!!~~]+$/u, '')
88
+ if (ALWAYS_WORDS.has(word)) return 'always'
89
+ if (ALLOW_WORDS.has(word)) return 'allow'
90
+ if (DENY_WORDS.has(word)) return 'deny'
91
+ return undefined
92
+ }
93
+
94
+ /**
95
+ * Read what a call will run and the sandbox mode it asks for. `bash` shows
96
+ * its `command`; other tools show their raw arguments. Arguments are
97
+ * model-produced JSON, so a parse failure shows the raw text.
98
+ */
99
+ function describeCall(call: RecordedCall | undefined): { command?: string; escalateTo?: string } {
100
+ if (call === undefined) return {}
101
+ let args: Record<string, unknown> | undefined
102
+ try {
103
+ const parsed: unknown = JSON.parse(call.arguments)
104
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) args = parsed as Record<string, unknown>
105
+ } catch {
106
+ // Unparseable arguments: shown verbatim below.
107
+ }
108
+ if (args === undefined) return call.arguments === '' ? {} : { command: call.arguments }
109
+ const escalateTo = typeof args.sandbox_permissions === 'string' && args.sandbox_permissions !== ''
110
+ ? args.sandbox_permissions
111
+ : undefined
112
+ const command = typeof args.command === 'string' && args.command !== ''
113
+ ? args.command
114
+ : JSON.stringify(args, null, 2)
115
+ return { command, ...(escalateTo === undefined ? {} : { escalateTo }) }
116
+ }
117
+
44
118
  export class ApprovalAnswerer {
45
119
  private readonly table: PendingCardTable<Pending>
120
+ /** sessionId → callId → the current turn's recorded tool calls. */
121
+ private readonly calls = new Map<string, Map<string, RecordedCall>>()
122
+ /** sessionId → standing "stop asking in this topic" grant keys. */
123
+ private readonly grants = new Map<string, Set<string>>()
124
+ private opened = 0
46
125
 
47
126
  constructor(
48
127
  channel: FeishuChannel,
@@ -57,18 +136,52 @@ export class ApprovalAnswerer {
57
136
  return this.table.count()
58
137
  }
59
138
 
139
+ /** Whether a session currently has an undecided approval card. */
140
+ hasPendingFor(sessionId: string): boolean {
141
+ return this.pendingOf(sessionId).length > 0
142
+ }
143
+
144
+ /**
145
+ * Track the current turn's tool calls so a later approval can show what it
146
+ * approves. Only `tool/call` and `turn/end` are read; nothing earlier than
147
+ * the current turn is kept.
148
+ */
149
+ onSessionEvent(sessionId: string, event: SessionEvent): void {
150
+ if (event.type === 'tool/call') {
151
+ let calls = this.calls.get(sessionId)
152
+ if (calls === undefined) {
153
+ calls = new Map()
154
+ this.calls.set(sessionId, calls)
155
+ }
156
+ calls.set(event.data.callId, { turn: event.data.turn, name: event.data.name, arguments: event.data.arguments })
157
+ } else if (event.type === 'turn/end') {
158
+ const calls = this.calls.get(sessionId)
159
+ if (calls === undefined) return
160
+ for (const [callId, call] of calls) {
161
+ if (call.turn <= event.data.turn) calls.delete(callId)
162
+ }
163
+ if (calls.size === 0) this.calls.delete(sessionId)
164
+ }
165
+ }
166
+
60
167
  /**
61
168
  * The `approval/request` waterfall listener. Never rejects: the host service
62
169
  * already contains answerer failures to the fail-closed `'unavailable'`.
63
170
  */
64
171
  onRequest(req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome> {
65
- const route = this.hooks.routeFor(req.agent.session.id)
172
+ const sessionId = req.agent.session.id
173
+ const route = this.hooks.routeFor(sessionId)
66
174
  if (route === undefined) return next()
67
175
  // The abort window between the service's own signal check and this
68
176
  // listener is closed by settling synchronously: an aborted ask never
69
177
  // opens a question.
70
178
  if (req.signal?.aborted === true) return Promise.resolve('cancelled')
71
179
 
180
+ const call = req.callId === undefined ? undefined : this.calls.get(sessionId)?.get(req.callId)
181
+ const described = describeCall(call)
182
+ const grantKey = `${req.toolName}\u0000${described.escalateTo ?? ''}`
183
+ if (this.grants.get(sessionId)?.has(grantKey) === true) return Promise.resolve('allowed-once')
184
+
72
185
  const token = randomUUID()
73
186
  // `displayReason` (DSH 0.2.0) is presentation-only and locale-aware;
74
187
  // `reason` remains the audit-trail fallback.
@@ -77,13 +190,14 @@ export class ApprovalAnswerer {
77
190
  token,
78
191
  toolName: req.toolName,
79
192
  ...(displayReason === undefined ? {} : { reason: displayReason }),
193
+ ...described,
80
194
  timeoutMs: this.timeoutMs,
81
- sessionId: req.agent.session.id,
195
+ sessionId,
82
196
  }
83
197
  return new Promise<ApprovalOutcome>((resolve) => {
84
198
  const entry: Pending = {
85
199
  token,
86
- sessionId: req.agent.session.id,
200
+ sessionId,
87
201
  route,
88
202
  messageId: undefined,
89
203
  settled: false,
@@ -92,6 +206,8 @@ export class ApprovalAnswerer {
92
206
  onAbort: undefined,
93
207
  unauthorizedNoted: false,
94
208
  question,
209
+ grantKey,
210
+ openedAt: ++this.opened,
95
211
  resolve,
96
212
  }
97
213
  const onAbort = (): void => {
@@ -105,6 +221,7 @@ export class ApprovalAnswerer {
105
221
  // discards a late resolution (worst case: the card times out).
106
222
  req.signal?.addEventListener('abort', onAbort, { once: true })
107
223
  this.table.add(entry)
224
+ this.notifyWaiting(sessionId)
108
225
  void this.table.deliver(entry, {
109
226
  card: buildApprovalCard(question),
110
227
  onWithdrawn: e => this.table.patch(e, buildApprovalSettledCard(e.question, 'cancelled')),
@@ -114,6 +231,7 @@ export class ApprovalAnswerer {
114
231
  // No card → no human answerer → fail closed.
115
232
  onFailure: e => {
116
233
  this.table.close(e, () => {})
234
+ this.notifyWaiting(e.sessionId)
117
235
  e.resolve('unavailable')
118
236
  },
119
237
  })
@@ -134,12 +252,72 @@ export class ApprovalAnswerer {
134
252
  if (entry === undefined) return
135
253
  const decision = (event.action.value as { decision?: unknown } | undefined)?.decision
136
254
  if (decision === 'allow') this.settle(entry, 'allowed-once', 'allowed')
255
+ else if (decision === 'always') this.remember(entry)
137
256
  else if (decision === 'deny') this.settle(entry, 'rejected', 'denied')
138
257
  }
139
258
 
259
+ /**
260
+ * Answer a session's pending approvals with one typed topic reply. The
261
+ * caller has already checked that the sender owns the topic. A keyword
262
+ * decides the newest card; any other text rejects every pending card of the
263
+ * session and is reported as `superseded` so the caller still delivers it.
264
+ * @param sessionId - the session the reply was routed to.
265
+ * @param text - the reply text.
266
+ * @returns How the reply was consumed, or `undefined` when nothing is pending.
267
+ */
268
+ submitText(sessionId: string, text: string): ApprovalTextResult | undefined {
269
+ const pending = this.pendingOf(sessionId)
270
+ const newest = pending.at(-1)
271
+ if (newest === undefined) return undefined
272
+ switch (classifyApprovalReply(text)) {
273
+ case 'allow':
274
+ this.settle(newest, 'allowed-once', 'allowed-text')
275
+ return 'allowed'
276
+ case 'always':
277
+ this.remember(newest)
278
+ return 'remembered'
279
+ case 'deny':
280
+ this.settle(newest, 'rejected', 'denied-text')
281
+ return 'denied'
282
+ case undefined:
283
+ for (const entry of pending) this.settle(entry, 'rejected', 'superseded')
284
+ return 'superseded'
285
+ }
286
+ }
287
+
140
288
  /** Withdraw every in-flight question (plugin shutdown). */
141
289
  dispose(): void {
142
290
  this.table.withdrawAll(entry => this.settle(entry, 'cancelled'))
291
+ this.calls.clear()
292
+ this.grants.clear()
293
+ }
294
+
295
+ /** Undecided entries of one session, oldest first. */
296
+ private pendingOf(sessionId: string): Pending[] {
297
+ return [...this.table.values()]
298
+ .filter(entry => entry.sessionId === sessionId && !entry.settled)
299
+ .sort((left, right) => left.openedAt - right.openedAt)
300
+ }
301
+
302
+ /**
303
+ * Record a standing grant for the entry's tool + mode, allow it, and allow
304
+ * every other pending card of the session the grant now covers.
305
+ */
306
+ private remember(entry: Pending): void {
307
+ let grants = this.grants.get(entry.sessionId)
308
+ if (grants === undefined) {
309
+ grants = new Set()
310
+ this.grants.set(entry.sessionId, grants)
311
+ }
312
+ grants.add(entry.grantKey)
313
+ for (const candidate of this.pendingOf(entry.sessionId)) {
314
+ if (candidate.grantKey === entry.grantKey) this.settle(candidate, 'allowed-once', 'remembered')
315
+ }
316
+ }
317
+
318
+ /** Report the session's remaining waits to the streaming card. */
319
+ private notifyWaiting(sessionId: string): void {
320
+ this.hooks.onWaitingChange?.(sessionId, this.pendingOf(sessionId).map(entry => entry.question.toolName))
143
321
  }
144
322
 
145
323
  /** Settle once, then patch the card to its terminal state. */
@@ -149,6 +327,8 @@ export class ApprovalAnswerer {
149
327
  this.table.patch(entry, buildApprovalSettledCard(entry.question, cardState))
150
328
  }
151
329
  })
152
- if (closed) entry.resolve(outcome)
330
+ if (!closed) return
331
+ this.notifyWaiting(entry.sessionId)
332
+ entry.resolve(outcome)
153
333
  }
154
334
  }
@@ -20,8 +20,8 @@ export interface InteractionRoute {
20
20
  cardMessageId: string
21
21
  /** Whether the anchor lives in a real topic (replyInThread). */
22
22
  inThread: boolean
23
- /** The session owner (open_id) — the only operator allowed to decide. */
24
- senderOpenId: string
23
+ /** The session owner (user_id) — the only operator allowed to decide. */
24
+ senderUserId: string
25
25
  }
26
26
 
27
27
  /** Fields every pending interaction card carries. */
@@ -96,7 +96,7 @@ export class PendingCardTable<E extends PendingCardEntry> {
96
96
  }
97
97
  }
98
98
  if (entry === undefined || entry.settled) return undefined
99
- if (event.operator.openId !== entry.route.senderOpenId) {
99
+ if (event.operator.userId !== entry.route.senderUserId) {
100
100
  onUnauthorized(entry)
101
101
  return undefined
102
102
  }
@@ -17,7 +17,7 @@ import type { AttachmentStoreMode, FeishuConfig } from '../config.ts'
17
17
  import { buildCard, buildOnboardingCard, initialStreamingCard } from '../cards.ts'
18
18
  import { RouteOperationQueue } from '../commands.ts'
19
19
  import { chatRouteKey, sessionDirOf } from '../domain.ts'
20
- import type { FeishuDomainHandle, PendingAttachmentRecord, RouteRecord } from '../domain.ts'
20
+ import type { FeishuDomainHandle, RouteRecord } from '../domain.ts'
21
21
  import type { CardDelivery } from '../feishu/delivery.ts'
22
22
  import {
23
23
  adoptStagedAttachments, attachmentsDir, attachmentResources, hasAttachmentPayload,
@@ -31,10 +31,10 @@ import {
31
31
  InboundAttachmentStore, NativeAttachmentStore, effectiveAttachmentMode,
32
32
  } from '../feishu/native-attachments.ts'
33
33
  import type { InboundAttachmentStoreResult } from '../feishu/native-attachments.ts'
34
- import type { PendingAttachmentScope, PendingAttachmentStore } from '../feishu/pending-attachments.ts'
35
34
  import type { KnownAttachmentMessageInput, KnownAttachmentSource } from '../feishu/tools.ts'
36
35
  import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
37
36
  import type { QuestionAnswerer } from '../interactions/questions.ts'
37
+ import type { ApprovalAnswerer, ApprovalTextResult } from '../interactions/approvals.ts'
38
38
  import type { InflightRepair } from '../session/repair.ts'
39
39
  import type { SessionManager } from '../session/manager.ts'
40
40
  import type { RouteStore } from '../session/routes.ts'
@@ -120,12 +120,10 @@ export interface InboundDeps {
120
120
  nativeAttachments(): AttachmentStore | undefined
121
121
  /** Backend-selecting archive/fallback store (legacy workspace download). */
122
122
  attachmentStore: InboundAttachmentStore
123
- /** Durable metadata registry for silently registered attachments. */
124
- pendingAttachments: PendingAttachmentStore
125
123
  /**
126
124
  * Pre-authorize a message's attachments for `feishu_attachment_fetch`:
127
- * `trigger` (the triggering message) by default, `quote`/`history` for the
128
- * messages this turn's excerpt/history blocks name.
125
+ * `trigger` (the triggering message) by default, `quote`/`history`/`recent`
126
+ * for the messages this turn's excerpt, history and manifest blocks name.
129
127
  */
130
128
  noteKnownAttachments(
131
129
  record: RouteRecord,
@@ -133,9 +131,9 @@ export interface InboundDeps {
133
131
  source?: KnownAttachmentSource,
134
132
  ): void
135
133
  /**
136
- * Read-only message access: quoted-message excerpt and first-turn topic
137
- * history. Failures degrade silently to a `undefined` value (never a throw
138
- * into the turn).
134
+ * Read-only message access: quoted-message excerpt, first-turn topic
135
+ * history, and recent attachment messages. Failures degrade silently to an
136
+ * absent value (never a throw into the turn).
139
137
  */
140
138
  messageRead: InboundMessageReadPort
141
139
  /** Live image-prefetch policy for triggering messages. */
@@ -146,9 +144,17 @@ export interface InboundDeps {
146
144
  queue: RouteOperationQueue
147
145
  streamer(): TurnStreamer | undefined
148
146
  questionAnswerer(): QuestionAnswerer | undefined
147
+ approvalAnswerer(): ApprovalAnswerer | undefined
149
148
  commands: CommandPlanePort
150
149
  }
151
150
 
151
+ /** Confirmation line for each keyword approval reply. */
152
+ const APPROVAL_TEXT_REPLIES: Record<Exclude<ApprovalTextResult, 'superseded'>, string> = {
153
+ allowed: '已允许本次操作。',
154
+ remembered: '已允许,本话题内同类操作不再询问。',
155
+ denied: '已拒绝本次操作。',
156
+ }
157
+
152
158
  /**
153
159
  * User-facing text for attachments that could not be archived. Delivered as a
154
160
  * plain reply (never a card) so the user learns why the Agent cannot read a
@@ -194,7 +200,7 @@ export function knownAttachmentMessageOf(msg: NormalizedMessage): KnownAttachmen
194
200
  messageId: msg.messageId,
195
201
  kind: forwarded ? 'merge_forward' : 'message',
196
202
  resources,
197
- senderOpenId: msg.senderId,
203
+ senderUserId: msg.senderId,
198
204
  }
199
205
  }
200
206
 
@@ -211,11 +217,14 @@ interface TurnReadContext {
211
217
  historyBlock?: string
212
218
  }
213
219
 
214
- /** First-turn-only inputs of the prompt assembly. */
220
+ /** Per-turn inputs of the prompt assembly. */
215
221
  interface TurnDeliveryOptions {
216
222
  /** New Session's first turn: attach the bounded topic history bootstrap. */
217
223
  firstTurn?: boolean
218
- /** Message ids never shown in the history block (trigger + streaming card). */
224
+ /**
225
+ * Message ids never shown in the history block or listed as recent
226
+ * attachments (trigger + streaming card).
227
+ */
219
228
  excludeMessageIds?: readonly string[]
220
229
  }
221
230
 
@@ -253,10 +262,10 @@ export class InboundRouter {
253
262
  private get delivery(): CardDelivery { return this.deps.delivery }
254
263
  private get sessions(): SessionManager { return this.deps.sessions }
255
264
  private get attachmentStore(): InboundAttachmentStore { return this.deps.attachmentStore }
256
- private get pendingAttachments(): PendingAttachmentStore { return this.deps.pendingAttachments }
257
265
  private get routeOperations(): RouteOperationQueue { return this.deps.queue }
258
266
  private get streamer(): TurnStreamer | undefined { return this.deps.streamer() }
259
267
  private get questionAnswerer(): QuestionAnswerer | undefined { return this.deps.questionAnswerer() }
268
+ private get approvalAnswerer(): ApprovalAnswerer | undefined { return this.deps.approvalAnswerer() }
260
269
  private get domain(): FeishuDomainHandle | undefined { return this.deps.domain() }
261
270
  private get access(): InboundAccessPort { return this.deps.access }
262
271
 
@@ -322,8 +331,6 @@ export class InboundRouter {
322
331
 
323
332
  /** Entry point: one normalized inbound message. */
324
333
  async handle(msg: NormalizedMessage): Promise<void> {
325
- const botId = this.delivery.requireChannel().botOpenId()
326
- if (botId !== undefined && msg.senderId === botId) return
327
334
  if (msg.chatType !== 'p2p' && msg.chatType !== 'group') return
328
335
  let content = (msg.content ?? '').trim()
329
336
  if (content === '') return
@@ -399,24 +406,26 @@ export class InboundRouter {
399
406
  }
400
407
  } else {
401
408
  // Native pipeline: classify without downloading. Attachment-only
402
- // messages and messages that cannot start a turn are silently registered
403
- // (no reply, no reaction, no Session); a mixed caption becomes the prompt
404
- // with its placeholders stripped.
409
+ // messages and messages that cannot start a turn stay inert (no reply,
410
+ // no reaction, no Session); a mixed caption becomes the prompt with its
411
+ // placeholders stripped.
405
412
  const state = inboundAttachmentState(msg)
406
413
  if (state.hasPayload) {
407
- const resolution = await this.resolveNativeAttachmentMessage(msg, state)
414
+ const resolution = this.resolveNativeAttachmentMessage(msg, state)
408
415
  if (resolution.kind === 'silent') return
409
416
  content = resolution.text
410
417
  if (content === '') return
411
418
  }
412
419
  }
413
420
 
421
+ // A session blocked on an approval card reads the owner's reply as the
422
+ // decision; any other text rejects the card and continues below as the
423
+ // next instruction.
424
+ if (await this.tryAnswerPendingApproval(msg, content)) return
425
+
414
426
  // A session blocked on `ask_user_question` has no other use for an inbound
415
427
  // text message: the next reply becomes the free-text answer.
416
- if (await this.tryAnswerPendingQuestion(msg, content)) {
417
- await this.registerConsumedTriggerAttachments(msg)
418
- return
419
- }
428
+ if (await this.tryAnswerPendingQuestion(msg, content)) return
420
429
 
421
430
  // 1. Real topic route first (works without a mention, per product spec).
422
431
  if (msg.threadId !== undefined) {
@@ -571,75 +580,31 @@ export class InboundRouter {
571
580
  /**
572
581
  * Native-pipeline attachment decision for one message, without downloading.
573
582
  *
574
- * Returns `{ kind: 'silent' }` when the message must stay inert (metadata
575
- * registration only: no reply, no reaction, no Session, no download) and
576
- * `{ kind: 'prompt', text }` when a model turn starts with this text. The
577
- * caller keeps the message flowing through the normal route decision tree.
583
+ * Returns `{ kind: 'silent' }` when the message must stay inert (no reply,
584
+ * no reaction, no Session, no download, no local state; a later turn finds
585
+ * it through the recent-attachment read) and `{ kind: 'prompt', text }` when
586
+ * a model turn starts with this text. The caller keeps the message flowing
587
+ * through the normal route decision tree.
578
588
  */
579
- private async resolveNativeAttachmentMessage(
589
+ private resolveNativeAttachmentMessage(
580
590
  msg: NormalizedMessage,
581
591
  state: InboundAttachmentState,
582
- ): Promise<{ kind: 'silent' } | { kind: 'prompt'; text: string }> {
583
- // A foreign sender's attachment never registers (and is dropped later by
584
- // the route-ownership checks): silent metadata must stay per-sender.
585
- const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
586
- const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
587
- const chatRecord = threadRecord === undefined ? this.routes.get(chatKey) : undefined
588
- const foreign = (threadRecord !== undefined && !this.access.ownsRoute(msg, threadRecord))
589
- || (chatRecord !== undefined && !this.access.ownsRoute(msg, chatRecord))
590
- if (foreign) return { kind: 'prompt', text: state.text }
591
-
592
- if (!this.config.acceptAttachments && state.attachmentOnly) return { kind: 'silent' }
593
- if (state.attachmentOnly) {
594
- await this.registerSilentAttachment(msg)
595
- return { kind: 'silent' }
596
- }
592
+ ): { kind: 'silent' } | { kind: 'prompt'; text: string } {
593
+ if (state.attachmentOnly) return { kind: 'silent' }
597
594
  if (!this.config.acceptAttachments) return { kind: 'prompt', text: state.text }
598
595
 
599
596
  // Group messages without an @-mention (and without an already-bound topic)
600
- // never start a turn, but their attachments must remain discoverable from
601
- // the topic a later @-mentioned message creates (Roadmap AC-4).
602
- const pendingEstablishment = this.establishing.get(chatKey)
597
+ // never start a turn; their attachments stay discoverable from the topic a
598
+ // later @-mentioned message creates (Roadmap AC-4).
599
+ const threadRecord = msg.threadId === undefined ? undefined : this.routes.get(msg.threadId)
600
+ const chatKey = chatRouteKey(msg.chatType, msg.chatId, msg.senderId)
601
+ const chatRecord = threadRecord === undefined ? this.routes.get(chatKey) : undefined
603
602
  const requiresMention = msg.chatType === 'group' && this.config.requireMention && !msg.mentionedBot
604
603
  const triggers = threadRecord !== undefined || chatRecord !== undefined
605
- || pendingEstablishment !== undefined || !requiresMention
606
- if (!triggers) {
607
- await this.registerSilentAttachment(msg)
608
- return { kind: 'silent' }
609
- }
610
- return { kind: 'prompt', text: state.text }
611
- }
612
-
613
- /**
614
- * Record attachment metadata without any side effect beyond the durable
615
- * registry write. Never replies, reacts, downloads, or establishes a Session.
616
- */
617
- private async registerSilentAttachment(msg: NormalizedMessage): Promise<void> {
618
- if (!this.config.acceptAttachments) return
619
- // `admitted: true` is safe because every call site runs after
620
- // `access.admit` returned true; the registry itself has no admission policy.
621
- try {
622
- await this.pendingAttachments.registerMessage(msg, { admitted: true })
623
- } catch (error) {
624
- // Metadata only: a registry failure must never fail the inbound pipeline.
625
- this.ctx.logger?.warn?.(
626
- 'dsh-channels: registering pending attachment metadata for %s failed: %s',
627
- msg.messageId, String(error),
628
- )
629
- }
604
+ || this.establishing.has(chatKey) || !requiresMention
605
+ return triggers ? { kind: 'prompt', text: state.text } : { kind: 'silent' }
630
606
  }
631
607
 
632
- /**
633
- * A native-pipeline message whose caption was consumed as a structured
634
- * question answer starts no model turn, so its attachments would otherwise
635
- * vanish from the conversation. Register them silently for later turns;
636
- * the archive backend already downloaded them before question interception.
637
- */
638
- private async registerConsumedTriggerAttachments(msg: NormalizedMessage): Promise<void> {
639
- if (this.attachmentBackend() !== 'native') return
640
- if (!inboundAttachmentState(msg).hasPayload) return
641
- await this.registerSilentAttachment(msg)
642
- }
643
608
  /**
644
609
  * Build the first-turn user message for the native pipeline: the caption
645
610
  * text, the quoted-message excerpt, the prefetched trigger images, the
@@ -715,17 +680,6 @@ export class InboundRouter {
715
680
  if (context.quoteBlock !== undefined) blocks.push({ type: 'text', text: context.quoteBlock })
716
681
  blocks.push(...prefetchedBlocks)
717
682
 
718
- let pending: readonly PendingAttachmentRecord[] = []
719
- if (attachmentPipeline) {
720
- try {
721
- pending = this.pendingAttachments.list(this.pendingScopeOfRecord(record))
722
- } catch (error) {
723
- this.ctx.logger?.warn?.(
724
- 'dsh-channels: listing pending attachments for %s failed: %s',
725
- record.key, String(error),
726
- )
727
- }
728
- }
729
683
  const manifest = attachmentPipeline
730
684
  ? buildFeishuContextBlock({
731
685
  trigger: triggerManifestAttachments(msg, loadedFileKeys),
@@ -735,7 +689,6 @@ export class InboundRouter {
735
689
  ...(context.quoteMessage === undefined
736
690
  ? {}
737
691
  : { quote: manifestEntriesOfKnownMessage(context.quoteMessage) }),
738
- pending,
739
692
  })
740
693
  : ''
741
694
  if (manifest !== '') blocks.push({ type: 'text', text: manifest })
@@ -903,16 +856,6 @@ export class InboundRouter {
903
856
  }
904
857
  }
905
858
 
906
-
907
- /** Pending-registry isolation scope for one persisted route. */
908
- private pendingScopeOfRecord(record: RouteRecord): PendingAttachmentScope {
909
- return {
910
- chatId: record.chatId,
911
- senderOpenId: record.senderOpenId,
912
- ...(record.kind === 'thread' ? { threadId: record.key } : {}),
913
- }
914
- }
915
-
916
859
  /** Live channel accessor for best-effort prefetch; undefined when gone. */
917
860
  private liveChannel(): FeishuChannel | undefined {
918
861
  try {
@@ -922,6 +865,28 @@ export class InboundRouter {
922
865
  }
923
866
  }
924
867
 
868
+ /**
869
+ * Consume one plain reply as an approval decision. Only the topic owner's
870
+ * replies in a session with an undecided approval card are intercepted.
871
+ * @returns true when the reply was a keyword decision and is fully handled;
872
+ * false when nothing was pending or the reply rejected the pending cards and
873
+ * must still be delivered as the next instruction.
874
+ */
875
+ private async tryAnswerPendingApproval(msg: NormalizedMessage, content: string): Promise<boolean> {
876
+ const answerer = this.approvalAnswerer
877
+ if (answerer === undefined || answerer.pendingCount() === 0) return false
878
+ const route = await this.commandRoute(msg)
879
+ if (route === undefined || !this.access.ownsRoute(msg, route)) return false
880
+ if (!answerer.hasPendingFor(route.sessionId)) return false
881
+ const result = answerer.submitText(route.sessionId, content)
882
+ if (result === undefined || result === 'superseded') return false
883
+ await this.delivery.commandCard(msg, {
884
+ kind: 'success', command: 'approval', sessionId: route.sessionId,
885
+ text: APPROVAL_TEXT_REPLIES[result],
886
+ }, { createThread: false })
887
+ return true
888
+ }
889
+
925
890
  /**
926
891
  * Consume one plain reply as an `ask_user_question` free-text answer.
927
892
  *
@@ -1086,7 +1051,7 @@ export class InboundRouter {
1086
1051
  sessionId,
1087
1052
  chatId: msg.chatId,
1088
1053
  chatType: msg.chatType,
1089
- senderOpenId: msg.senderId,
1054
+ senderUserId: msg.senderId,
1090
1055
  workspacePath: workspace.path,
1091
1056
  sessionDir: placement.sessionDir,
1092
1057
  ...(placement.worktreeBranch === undefined ? {} : { worktreeBranch: placement.worktreeBranch }),
@@ -1168,6 +1133,8 @@ export class InboundRouter {
1168
1133
  lastActivityAt: Date.now(),
1169
1134
  })
1170
1135
  this.streamer?.attach(live.agent.session, sent.messageId, msg.messageId, typingId)
1171
- live.agent.followup(await this.deliverTurnMessage(msg, current, content))
1136
+ live.agent.followup(await this.deliverTurnMessage(msg, current, content, {
1137
+ excludeMessageIds: [msg.messageId, sent.messageId],
1138
+ }))
1172
1139
  }
1173
1140
  }