@modesto_ma/dsh-channels 0.4.0 → 0.5.0-beta.1
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 +73 -55
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +33 -41
- package/docs/feishu-onboarding.md +33 -33
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +1 -1
- package/docs/workspace-isolation-design.md +7 -8
- package/lib/client.js +173 -287
- package/lib/client.js.map +1 -1
- package/lib/index.js +3001 -2627
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +25 -56
- package/src/domain.ts +28 -125
- package/src/feishu/inbound-attachments.ts +1 -1
- package/src/feishu/manifest.ts +8 -31
- package/src/feishu/message-read.ts +19 -22
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +72 -112
- package/src/feishu-channel.ts +270 -9
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +73 -106
- package/src/service.ts +463 -438
- package/src/session/manager.ts +1 -1
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +68 -85
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +37 -88
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +21 -29
- package/src/cleanup.ts +0 -546
- package/src/client/panel/cleanup-card.tsx +0 -106
- 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
|
|
8
|
-
* Requests for sessions the channel does NOT route
|
|
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
|
|
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
|
|
195
|
+
sessionId,
|
|
82
196
|
}
|
|
83
197
|
return new Promise<ApprovalOutcome>((resolve) => {
|
|
84
198
|
const entry: Pending = {
|
|
85
199
|
token,
|
|
86
|
-
sessionId
|
|
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)
|
|
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 (
|
|
24
|
-
|
|
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.
|
|
99
|
+
if (event.operator.userId !== entry.route.senderUserId) {
|
|
100
100
|
onUnauthorized(entry)
|
|
101
101
|
return undefined
|
|
102
102
|
}
|
package/src/routing/inbound.ts
CHANGED
|
@@ -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,
|
|
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`
|
|
128
|
-
* messages this turn's excerpt
|
|
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
|
|
137
|
-
* history. Failures degrade silently to
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
403
|
-
//
|
|
404
|
-
//
|
|
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 =
|
|
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 (
|
|
575
|
-
*
|
|
576
|
-
* `{ kind: 'prompt', text }` when
|
|
577
|
-
*
|
|
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
|
|
589
|
+
private resolveNativeAttachmentMessage(
|
|
580
590
|
msg: NormalizedMessage,
|
|
581
591
|
state: InboundAttachmentState,
|
|
582
|
-
):
|
|
583
|
-
|
|
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
|
|
601
|
-
//
|
|
602
|
-
const
|
|
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
|
-
||
|
|
606
|
-
|
|
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
|
-
|
|
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
|
}
|