@modesto_ma/dsh-channels 0.3.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.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  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 +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -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 +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
package/src/service.ts CHANGED
@@ -18,11 +18,10 @@
18
18
  * @module dsh-channels/service
19
19
  */
20
20
 
21
- import { lstat, readdir, realpath, rm } from 'node:fs/promises'
22
- import path from 'node:path'
23
21
  import { AccessController } from './access/controller.ts'
24
22
  import { AccessRequests } from './access/requests.ts'
25
23
  import { AccessStore } from './access/store.ts'
24
+ import { AUTH_USER_TARGET_ERRORS, resolveAuthUserTarget } from './access/targets.ts'
26
25
  import { ApprovalAnswerer } from './approvals.ts'
27
26
  import type { ApprovalRoute } from './approvals.ts'
28
27
  import { QuestionAnswerer } from './questions.ts'
@@ -33,14 +32,15 @@ import type {
33
32
  CredentialProvider,
34
33
  CordisFiber,
35
34
  HostContext,
35
+ LlmModelInfo,
36
36
  LlmService,
37
37
  Session,
38
38
  SessionId,
39
39
  SessionProjections,
40
40
  TokenMeter,
41
- Workspace,
42
41
  } from './dsh-types.ts'
43
- import { standaloneInfoCard } from './cards.ts'
42
+ import { buildPickerCard, parsePickAction, standaloneInfoCard, STOP_ACTION } from './cards.ts'
43
+ import type { PickerOption } from './cards.ts'
44
44
  import {
45
45
  armEffortCommand, armModelCommand, armRenewCommand, registerModelCommand, commandResultCardKind, parseSlashCommand,
46
46
  registerEffortCommand, registerRenewCommand, RouteOperationQueue, supportedCommandHelp, supportedCommandList,
@@ -49,15 +49,11 @@ import type { EffortCommandAction, ParsedSlashCommand } from './commands.ts'
49
49
  import { commandSpec } from './commands/registry.ts'
50
50
  import type { ChannelCommandSpec } from './commands/registry.ts'
51
51
  import type { PermissionPresets, SessionQuery } from './dsh-types.ts'
52
- import { feishuDomain, sessionDirOf } from './domain.ts'
53
- import {
54
- applyWorkspaceCleanup, isPathInside, planWorkspaceCleanup, retentionMs,
55
- } from './cleanup.ts'
56
- import type { CleanupDeps, CleanupRoute, CleanupTombstone, ScannedSessionDir, WorkspaceStats } from './cleanup.ts'
52
+ import { FEISHU_USER_ID_PATTERN, feishuDomain, sessionDirOf } from './domain.ts'
57
53
  import type {
58
54
  AccessGroupRecord, AccessRequestRecord, AccessUserRecord,
59
- ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot, ProcessedRecord, RawDomainHandle,
60
- RouteRecord,
55
+ ExpiredRouteRecord, FeishuDomainGlobal, FeishuDomainHandle, InflightSnapshot,
56
+ ProcessedRecord, RawDomainHandle, RouteRecord,
61
57
  } from './domain.ts'
62
58
  import { RouteStore } from './session/routes.ts'
63
59
  import { InflightRepair } from './session/repair.ts'
@@ -65,9 +61,21 @@ import { SessionManager } from './session/manager.ts'
65
61
  import type { LiveAgent, RenewCommit } from './session/manager.ts'
66
62
  import { CardDelivery } from './feishu/delivery.ts'
67
63
  import { AttachmentIntake } from './feishu/inbound-attachments.ts'
64
+ import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
65
+ import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
66
+ import { SendFileToolRuntime } from './feishu/send-file.ts'
67
+ import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
68
+ import {
69
+ MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
70
+ buildQuoteContextBlock, clampInt, knownMessageOfItems, knownMessagesOfHistory, quoteItemsOf,
71
+ readQuoteItems,
72
+ } from './feishu/message-read.ts'
73
+ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
74
+ import type { AttachmentStore } from './host/attachments.ts'
68
75
  import { InboundRouter } from './routing/inbound.ts'
69
76
  import type { RequestPolicyController } from './session/request-policy.ts'
70
- import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
77
+ import type { CardActionEvent, ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
78
+ import { AppOwnerLookupError, FeishuApiError } from './feishu-channel.ts'
71
79
  import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
72
80
  import type { HealthDependencyName, HealthSnapshot } from './health.ts'
73
81
  import {
@@ -84,10 +92,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
84
92
  import type { ProjectedStats, StreamerHooks } from './streamer.ts'
85
93
  import type { FeishuConfig } from './config.ts'
86
94
  import type {
87
- AccessMutationResponse, AccessRequestWire, AccessWire,
95
+ AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
88
96
  } from './settings/contract.ts'
89
- import type { OverlayStore } from './settings/overlay.ts'
90
- import { legacyTopicWorkspaceRoot } from './workspaces.ts'
97
+ import { checkScopes } from './feishu/scopes.ts'
98
+ import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
99
+ import { mergeOverlay } from './settings/overlay.ts'
100
+ import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
91
101
 
92
102
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
93
103
  * dead records; TTL semantics make 6 h granularity more than sufficient). */
@@ -96,9 +106,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
96
106
  /** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
97
107
  * day is 100×+ headroom while keeping the table bounded. */
98
108
  const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
109
+ /** Upper bound on the boot-time app-owner lookup before the connection opens. */
110
+ const APP_OWNER_LOOKUP_TIMEOUT_MS = 10_000
111
+ /** Upper bound on the post-connect scope grant query. */
112
+ const SCOPE_CHECK_TIMEOUT_MS = 10_000
113
+ /** Upper bound on the whole post-connect slash-command panel sync (one list plus per-command writes). */
114
+ const SLASH_COMMAND_SYNC_TIMEOUT_MS = 30_000
99
115
 
100
- /** Workspace-retention sweep cadence when automatic cleanup is enabled. */
101
- const RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
116
+ /** Outcome of the app-owner lookup for the current connection. */
117
+ type AppOwnerState =
118
+ | { readonly userId: string }
119
+ /** `apiRejected`: Feishu answered with an error code (typically a missing app-info scope). */
120
+ | { readonly error: string; readonly apiRejected: boolean }
102
121
 
103
122
 
104
123
  interface RenewAdmission {
@@ -203,52 +222,6 @@ function messageOf(error: unknown): string {
203
222
  return error instanceof Error ? error.message : String(error)
204
223
  }
205
224
 
206
- /**
207
- * Measure one workspace directory for the cleanup preview: total regular-file
208
- * bytes and file count. Symlinks are never followed; unreadable entries are
209
- * skipped so a permission problem cannot fail the whole pass.
210
- */
211
- async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
212
- let info
213
- try {
214
- info = await lstat(dir)
215
- } catch {
216
- return undefined
217
- }
218
- if (!info.isDirectory()) return undefined
219
- let bytes = 0
220
- let files = 0
221
- const pending = [dir]
222
- while (pending.length > 0) {
223
- const current = pending.pop()!
224
- let entries
225
- try {
226
- entries = await readdir(current, { withFileTypes: true })
227
- } catch {
228
- continue
229
- }
230
- for (const entry of entries) {
231
- const child = path.join(current, entry.name)
232
- if (entry.isSymbolicLink()) continue
233
- if (entry.isDirectory()) {
234
- pending.push(child)
235
- continue
236
- }
237
- if (!entry.isFile()) continue
238
- try {
239
- const stat = await lstat(child)
240
- if (stat.isFile()) {
241
- bytes += stat.size
242
- files += 1
243
- }
244
- } catch {
245
- // The directory changed while walking it; the byte total is advisory.
246
- }
247
- }
248
- }
249
- return { mtimeMs: info.mtimeMs, bytes, files }
250
- }
251
-
252
225
  export class FeishuService {
253
226
  private readonly ctx: HostContext
254
227
  /** Live config object. Mutated in place — collaborators captured this reference. */
@@ -270,10 +243,31 @@ export class FeishuService {
270
243
  private readonly delivery: CardDelivery
271
244
  /** Inbound attachment archival (Feishu resources → workspace files). */
272
245
  private readonly attachments: AttachmentIntake
246
+ /**
247
+ * Backend-selecting inbound storage facade. The native path stays lazy in
248
+ * admission (prefetch images + manifest); this facade owns the legacy
249
+ * workspace download used by `attachmentStore: 'archive'` and by the
250
+ * fallback when the host does not mount `ctx.attachments`.
251
+ */
252
+ private readonly inboundAttachmentStore: InboundAttachmentStore
253
+ /**
254
+ * Read-only Feishu message access (P2): the quoted-message excerpt and the
255
+ * first-turn topic history. Constructed before the inbound router so the
256
+ * turn assembly can reach it; it resolves the live channel lazily.
257
+ */
258
+ private readonly messageReader = new MessageReader({ channel: () => this.channel })
273
259
  /** Inbound routing: dedup, admission, decision tree, session establishment. */
274
260
  private readonly inbound: InboundRouter
275
261
  /** Agent/session lifecycle: live registry, resume, presets, renew. */
276
262
  private readonly sessions: SessionManager
263
+ /**
264
+ * Model-facing Feishu tools. `feishu_attachment_fetch` plus the shared
265
+ * per-route known-message whitelist the admission, quote and history layers
266
+ * populate; `feishu_topic_messages` reads the current topic on demand.
267
+ */
268
+ private readonly attachmentFetchTools: AttachmentFetchToolRuntime
269
+ private readonly topicMessagesTools: TopicMessagesToolRuntime
270
+ private readonly sendFileTools: SendFileToolRuntime
277
271
  /** FIFO admission for ordinary turns and commands targeting one route. */
278
272
  private readonly routeOperations = new RouteOperationQueue()
279
273
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -298,15 +292,15 @@ export class FeishuService {
298
292
  private runningConfig: FeishuConfig | undefined
299
293
  /** Guards overlapping panel-triggered reconnects. */
300
294
  private restarting = false
295
+ /** One-shot warning for a native deployment without the host attachment library. */
296
+ private warnedNativeAttachmentsUnmounted = false
297
+ /** One-shot warning for silently degraded quote/history reads (missing scope). */
298
+ private warnedPromptReadDegraded = false
301
299
  private unlisten: (() => void) | undefined
302
300
  /** Disposers for host-scoped listeners registered by `start()`. */
303
301
  private readonly unlistenHost: Array<() => boolean> = []
304
302
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
305
303
  private gcTimer: ReturnType<typeof setInterval> | undefined
306
- /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
307
- private retentionTimer: ReturnType<typeof setInterval> | undefined
308
- /** Guards overlapping cleanup passes (panel button / daily timer). */
309
- private cleanupRunning = false
310
304
  /** HITL approval answerer (created in `start()`). */
311
305
  private approvalAnswerer: ApprovalAnswerer | undefined
312
306
  /** HITL structured-question answerer (`ask_user_question`). */
@@ -323,6 +317,17 @@ export class FeishuService {
323
317
  /** Administrator DM authorization cards + bot-added group info cards. */
324
318
  private accessRequests: AccessRequests | undefined
325
319
  private unlistenBotAdded: (() => void) | undefined
320
+ /**
321
+ * App owner resolved at `start()`; the administrator whenever `adminUserId`
322
+ * is unset. Cleared on shutdown so a reconnect looks it up again.
323
+ */
324
+ private appOwner: AppOwnerState | undefined
325
+ /** Post-connect scope self-check; cleared on shutdown. */
326
+ private scopeCheckState: ScopeCheckWire | undefined
327
+ /** Post-connect slash-command panel sync; cleared on shutdown. */
328
+ private slashCommandSyncState: SlashCommandSyncWire | undefined
329
+ /** Aborts the running panel sync's remaining writes (timeout, shutdown, reconnect). */
330
+ private slashCommandSyncAbort: AbortController | undefined
326
331
 
327
332
  constructor(
328
333
  ctx: HostContext,
@@ -355,6 +360,26 @@ export class FeishuService {
355
360
  persistRoute: (key, record) => this.routes.put(key, record),
356
361
  started: () => this.started,
357
362
  detachStream: sessionId => this.streamer?.detach(sessionId),
363
+ mountTools: (agentCtx, sessionId) => {
364
+ // The fetch tool only makes sense for the lazy native backend: in
365
+ // archive deployments (explicit, missing host library, or fetch tool
366
+ // disabled) every attachment is downloaded before the turn and the
367
+ // Agent reads the workspace copy, so mounting the tool would only
368
+ // advertise an operation that can never be authorized.
369
+ //
370
+ // The topic-messages tool is backend-independent: quoted excerpts and
371
+ // topic history are read-only, so it is mounted for both backends and
372
+ // self-gates on `feishuTools.topicMessages`; `feishu_send_file`
373
+ // self-gates on `feishuTools.sendFile`. The mounts start together and
374
+ // resolve as one promise (no serial blocking).
375
+ const mounts: Array<Promise<void>> = []
376
+ if (this.effectiveAttachmentBackend() === 'native') {
377
+ mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
378
+ }
379
+ mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
380
+ mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
381
+ return Promise.all(mounts).then(() => undefined)
382
+ },
358
383
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
359
384
  })
360
385
  this.attachments = new AttachmentIntake({
@@ -362,6 +387,70 @@ export class FeishuService {
362
387
  channel: () => this.channel,
363
388
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
364
389
  })
390
+ this.inboundAttachmentStore = new InboundAttachmentStore({
391
+ enabled: () => this.config.acceptAttachments,
392
+ // Effective backend, not the raw config: `store()` is only called after
393
+ // admission already decided `archive`, so a native-configured deployment
394
+ // with the fetch tool disabled must download to the workspace here too.
395
+ mode: () => this.effectiveAttachmentBackend(),
396
+ service: () => this.nativeAttachmentService(),
397
+ channel: () => this.channel,
398
+ archive: this.attachments,
399
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
400
+ })
401
+ this.attachmentFetchTools = new AttachmentFetchToolRuntime({
402
+ config: () => this.config,
403
+ channel: () => this.channel,
404
+ routeOfSession: sessionId => {
405
+ const record = this.routes.findBySession(sessionId)
406
+ if (record === undefined) return undefined
407
+ return {
408
+ key: record.key,
409
+ kind: record.kind,
410
+ chatId: record.chatId,
411
+ chatType: record.chatType,
412
+ senderUserId: record.senderUserId,
413
+ }
414
+ },
415
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
416
+ })
417
+ this.topicMessagesTools = new TopicMessagesToolRuntime({
418
+ config: () => this.config,
419
+ channel: () => this.channel,
420
+ routeOfSession: sessionId => {
421
+ const record = this.routes.findBySession(sessionId)
422
+ if (record === undefined) return undefined
423
+ return {
424
+ key: record.key,
425
+ kind: record.kind,
426
+ chatId: record.chatId,
427
+ chatType: record.chatType,
428
+ senderUserId: record.senderUserId,
429
+ }
430
+ },
431
+ noteKnownMessage: (route, message, source) => {
432
+ this.attachmentFetchTools.noteKnownMessage(route, message, source)
433
+ },
434
+ isKnownMessage: (route, messageId) => this.attachmentFetchTools.isKnownMessage(route, messageId),
435
+ // The tool description must not promise a fetch tool that archive
436
+ // deployments do not mount; the runtime appends a degradation note.
437
+ fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
438
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
439
+ })
440
+ this.sendFileTools = new SendFileToolRuntime({
441
+ config: () => this.config,
442
+ channel: () => (this.started ? this.channel : undefined),
443
+ targetOfSession: sessionId => {
444
+ const record = this.routes.findBySession(sessionId)
445
+ if (record === undefined) return undefined
446
+ return {
447
+ replyTo: record.cardMessageId,
448
+ inThread: record.kind === 'thread',
449
+ rootDir: sessionDirOf(record),
450
+ }
451
+ },
452
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
453
+ })
365
454
  this.delivery = new CardDelivery({
366
455
  channel: () => this.channel,
367
456
  started: () => this.started,
@@ -373,7 +462,7 @@ export class FeishuService {
373
462
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
374
463
  })
375
464
  this.accessController = new AccessController({
376
- config: () => this.config,
465
+ config: () => this.accessConfig(),
377
466
  store: this.accessStore,
378
467
  channel: () => this.channel,
379
468
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -397,15 +486,247 @@ export class FeishuService {
397
486
  repair: this.repair,
398
487
  delivery: this.delivery,
399
488
  sessions: this.sessions,
400
- attachments: this.attachments,
489
+ attachmentMode: () => this.config.attachmentStore,
490
+ nativeAttachments: () => this.nativeAttachmentService(),
491
+ attachmentStore: this.inboundAttachmentStore,
492
+ noteKnownAttachments: (record, message, source) => {
493
+ this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
494
+ },
495
+ messageRead: {
496
+ quote: input => this.readQuoteContext(input),
497
+ history: input => this.readHistoryContext(input),
498
+ },
499
+ prefetchImages: () => ({
500
+ enabled: this.config.prefetchImages,
501
+ maxImageBytes: this.config.prefetchImageMaxBytes,
502
+ maxTotalBytes: this.config.prefetchTotalMaxBytes,
503
+ timeoutMs: this.config.prefetchTimeoutMs,
504
+ }),
401
505
  access: this.accessController,
402
506
  queue: this.routeOperations,
403
507
  streamer: () => this.streamer,
404
508
  questionAnswerer: () => this.questionAnswerer,
509
+ approvalAnswerer: () => this.approvalAnswerer,
405
510
  commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
406
511
  })
407
512
  }
408
513
 
514
+ /** Effective inbound attachment backend (matches the inbound router). */
515
+ private effectiveAttachmentBackend(): 'native' | 'archive' {
516
+ return effectiveAttachmentMode(
517
+ this.config.attachmentStore,
518
+ this.nativeAttachmentService(),
519
+ this.config.feishuTools.attachmentFetch,
520
+ this.config.acceptAttachments,
521
+ )
522
+ }
523
+
524
+ /**
525
+ * Resolve the host's native attachment library from the plugin scope.
526
+ *
527
+ * The service is registered host-wide (`@deepseek-ai/dsh-attachment-local`),
528
+ * so the safe `ctx.get` lookup resolves it even though this plugin does not
529
+ * declare `attachments` in its cordis `inject` list (the per-Agent scopes
530
+ * still inject it for the fetch tool). A missing service reads as
531
+ * `undefined` and switches admission to the legacy archive backend.
532
+ */
533
+ private nativeAttachmentService(): AttachmentStore | undefined {
534
+ const service = resolveAttachmentService(this.ctx)
535
+ if (service === undefined && this.config.attachmentStore === 'native'
536
+ && !this.warnedNativeAttachmentsUnmounted) {
537
+ // The facade now receives the effective backend, so its own downgrade
538
+ // warning cannot fire: report the missing library once from the resolver.
539
+ this.warnedNativeAttachmentsUnmounted = true
540
+ this.ctx.logger?.warn?.(
541
+ 'dsh-channels: attachmentStore=native 但宿主未挂载 ctx.attachments,'
542
+ + '入站附件回退话题 attachments/ 工作区落盘',
543
+ )
544
+ }
545
+ return service
546
+ }
547
+
548
+ /**
549
+ * Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
550
+ *
551
+ * The admission and quote layers call this after their access gates for the
552
+ * triggering / quoted message; the tool otherwise only authorizes the route
553
+ * owner's own messages it reads back from Feishu. Registering is a pure
554
+ * in-memory operation (one LRU entry per route) and never fails the caller.
555
+ */
556
+ noteKnownAttachmentMessage(
557
+ record: RouteRecord,
558
+ message: KnownAttachmentMessageInput,
559
+ source: KnownAttachmentSource = 'trigger',
560
+ ): void {
561
+ this.attachmentFetchTools.noteKnownMessage({
562
+ key: record.key,
563
+ kind: record.kind,
564
+ chatId: record.chatId,
565
+ chatType: record.chatType,
566
+ senderUserId: record.senderUserId,
567
+ }, message, source)
568
+ }
569
+
570
+ // ---- read-only message context (quoted excerpt / topic history) -------
571
+
572
+ /**
573
+ * Channel-plane identity of one route for message reads (structurally the
574
+ * same as the attachment-tool route: key/kind/chat/sender).
575
+ */
576
+ private messageReadRouteOf(record: RouteRecord): MessageReadRoute {
577
+ return {
578
+ key: record.key,
579
+ kind: record.kind,
580
+ chatId: record.chatId,
581
+ chatType: record.chatType,
582
+ senderUserId: record.senderUserId,
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Resolve one quoted message into the bounded `<feishu_quote>` block and the
588
+ * whitelist registration of the quoted message (merged-forward containers
589
+ * keep their container id, per the P0 download rule).
590
+ *
591
+ * The quoted message must belong to this route's visible container: another
592
+ * chat is always rejected; on a thread route a quoted message of a
593
+ * *different* topic is rejected as well (a main-timeline message carries no
594
+ * thread id and stays allowed — quotes happen before the bot owns a topic);
595
+ * on a degraded chat route any topic message is rejected (a chat route must
596
+ * not read another topic of the same group).
597
+ *
598
+ * The read budget is the short prompt-enrichment budget, never the model
599
+ * tool timeout. Read failures (missing scope, deleted message, channel down)
600
+ * degrade to `undefined`: the turn runs without an excerpt (AC6).
601
+ */
602
+ private async readQuoteContext(input: {
603
+ messageId: string
604
+ record: RouteRecord
605
+ }): Promise<{ block: string; message?: KnownAttachmentMessageInput } | undefined> {
606
+ try {
607
+ const result = await readQuoteItems(this.messageReader, input.messageId, {
608
+ timeoutMs: this.config.promptContextTimeoutMs,
609
+ })
610
+ if (!result.ok) {
611
+ this.reportDegradedRead('quoted message', input.messageId, result.reason)
612
+ return undefined
613
+ }
614
+ const container: RawMessageItem | undefined = quoteItemsOf(result.value)
615
+ if (container === undefined) return undefined
616
+ if (!this.quotedMessageBelongsToRoute(input.record, container)) {
617
+ this.ctx.logger?.debug?.(
618
+ 'dsh-channels: dropping quoted message %s from another chat/topic', input.messageId,
619
+ )
620
+ return undefined
621
+ }
622
+ const block = buildQuoteContextBlock(result.value, {
623
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
624
+ })
625
+ if (block === '') return undefined
626
+ const message = knownMessageOfItems(result.value)
627
+ return { block, ...(message === undefined ? {} : { message }) }
628
+ } catch (error) {
629
+ // Defensive: the reader reports structured failures; a raw throw is still
630
+ // only ever a missing excerpt, never a failed turn.
631
+ this.ctx.logger?.debug?.(
632
+ 'dsh-channels: reading quoted message %s failed: %s', input.messageId, String(error),
633
+ )
634
+ return undefined
635
+ }
636
+ }
637
+
638
+ /** Whether one quoted message may be shown/authorized for one route. */
639
+ private quotedMessageBelongsToRoute(record: RouteRecord, container: RawMessageItem): boolean {
640
+ // Fail closed on a missing/foreign chat id: every real Feishu item carries
641
+ // one, so an absent id is a malformed response, never a legitimate quote.
642
+ if (container.chatId === undefined || container.chatId !== record.chatId) return false
643
+ // A main-timeline message has no thread id; a message of another topic must
644
+ // never leak into this route's excerpt or whitelist. A degraded chat route
645
+ // (no topic) must reject topic messages entirely, even in the same chat.
646
+ if (record.kind === 'thread') {
647
+ return container.threadId === undefined || container.threadId === record.key
648
+ }
649
+ return container.threadId === undefined
650
+ }
651
+
652
+ /**
653
+ * Resolve the bounded first-turn `<feishu_history>` block plus the whitelist
654
+ * registrations of the history messages (chronological order).
655
+ *
656
+ * `limit` is `historyBootstrapLimit` (0/negative → no read at all). The page
657
+ * is requested newest-first so a long topic yields the most recent messages;
658
+ * excluded ids (the trigger and the streaming card), deleted messages, and
659
+ * messages outside the route container are filtered before the newest
660
+ * `limit` rows are reversed into chronological order for rendering. The read
661
+ * uses the short prompt-enrichment budget and degrades to `undefined` (AC6).
662
+ */
663
+ private async readHistoryContext(input: {
664
+ record: RouteRecord
665
+ limit: number
666
+ excludeMessageIds: readonly string[]
667
+ }): Promise<{ block: string; messages: readonly KnownAttachmentMessageInput[] } | undefined> {
668
+ if (!Number.isFinite(input.limit) || input.limit <= 0) return undefined
669
+ try {
670
+ const route = this.messageReadRouteOf(input.record)
671
+ const pageSize = clampInt(
672
+ input.limit + input.excludeMessageIds.length,
673
+ 1,
674
+ TOPIC_MESSAGES_MAX_LIMIT,
675
+ )
676
+ const result = await this.messageReader.listRoute(route, {
677
+ pageSize,
678
+ order: 'newest-first',
679
+ timeoutMs: this.config.promptContextTimeoutMs,
680
+ })
681
+ if (!result.ok) {
682
+ this.reportDegradedRead('topic history', input.record.key, result.reason)
683
+ return undefined
684
+ }
685
+ const excluded = new Set(input.excludeMessageIds)
686
+ const recent = result.value.items
687
+ .filter(item => !item.deleted && !excluded.has(item.messageId))
688
+ .slice(0, input.limit)
689
+ .reverse()
690
+ const block = buildHistoryContextBlock(recent, {
691
+ maxMessages: input.limit,
692
+ attachmentFetchAvailable: this.attachmentFetchMounted(),
693
+ })
694
+ if (block === '') return undefined
695
+ return { block, messages: knownMessagesOfHistory(recent) }
696
+ } catch (error) {
697
+ this.ctx.logger?.debug?.(
698
+ 'dsh-channels: reading topic history of %s failed: %s', input.record.key, String(error),
699
+ )
700
+ return undefined
701
+ }
702
+ }
703
+
704
+ /** Whether this deployment mounts the lazy attachment fetch tool. */
705
+ private attachmentFetchMounted(): boolean {
706
+ return this.effectiveAttachmentBackend() === 'native'
707
+ }
708
+
709
+ /**
710
+ * Report one silently degraded prompt read: debug per occurrence (expected
711
+ * when a deployment misses a scope) plus ONE warn per service lifetime so an
712
+ * operator can find the cause without log spam.
713
+ */
714
+ private reportDegradedRead(kind: string, target: string, reason: string): void {
715
+ if (this.warnedPromptReadDegraded) {
716
+ this.ctx.logger?.debug?.(
717
+ 'dsh-channels: %s read of %s failed (degraded): %s', kind, target, reason,
718
+ )
719
+ return
720
+ }
721
+ this.warnedPromptReadDegraded = true
722
+ this.ctx.logger?.warn?.(
723
+ 'dsh-channels: %s(%s)读取失败,已静默降级(不带引用/历史上下文继续回合):%s。'
724
+ + '如持续出现请检查飞书应用的消息读取权限(如 im:message:readonly / im:message.group_msg),'
725
+ + '或确认该消息未被撤回、网络正常',
726
+ kind, target, reason,
727
+ )
728
+ }
729
+
409
730
  // ---- lifecycle / Web settings panel ----------------------------------
410
731
 
411
732
  /**
@@ -511,7 +832,7 @@ export class FeishuService {
511
832
  /** Re-read the Web overlay over the profile config (no channel touch). */
512
833
  private async loadOverlay(): Promise<void> {
513
834
  const overlay = this.overlay === undefined ? {} : await this.overlay.load()
514
- this.replaceConfig({ ...this.baseConfig, ...overlay })
835
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
515
836
  }
516
837
 
517
838
  /**
@@ -530,9 +851,8 @@ export class FeishuService {
530
851
  * Apply an already-validated overlay over the profile config. Fields read
531
852
  * per event/turn take effect immediately; `RESTART_KEYS` need `restart()`.
532
853
  */
533
- applyOverlay(overlay: Partial<FeishuConfig>): void {
534
- this.replaceConfig({ ...this.baseConfig, ...overlay })
535
- this.refreshRetentionTimer()
854
+ applyOverlay(overlay: OverlayPatch): void {
855
+ this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
536
856
  }
537
857
 
538
858
  /**
@@ -581,15 +901,17 @@ export class FeishuService {
581
901
 
582
902
  /** Whitelists + pending authorization requests (read-only Web projection). */
583
903
  accessSnapshot(): AccessWire {
584
- const admin = this.config.adminOpenId
904
+ const admin = this.effectiveAdminUserId()
905
+ const owner = this.appOwner
585
906
  return {
586
907
  mode: this.config.accessMode,
587
908
  // Whitelists live in the storage domain, which is only open while the
588
909
  // channel runs; an unconnected panel must not present them as empty.
589
910
  loaded: this.domain !== undefined,
590
911
  admin: {
591
- ...(admin === undefined ? {} : { openId: admin }),
912
+ ...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
592
913
  configured: admin !== undefined,
914
+ ...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
593
915
  },
594
916
  groups: this.accessStore.groupRecords().map(record => ({
595
917
  chatId: record.chatId,
@@ -598,7 +920,7 @@ export class FeishuService {
598
920
  addedAt: record.addedAt,
599
921
  })),
600
922
  users: this.accessStore.userRecords().map(record => ({
601
- openId: record.openId,
923
+ userId: record.userId,
602
924
  ...(record.name === undefined ? {} : { name: record.name }),
603
925
  source: record.source,
604
926
  addedAt: record.addedAt,
@@ -615,7 +937,7 @@ export class FeishuService {
615
937
  if (name !== undefined) wire.name = name
616
938
  if (record.chatId !== undefined) wire.chatId = record.chatId
617
939
  if (record.groupName !== undefined) wire.groupName = record.groupName
618
- const requester = record.requesterName ?? record.requesterOpenId
940
+ const requester = record.requesterName ?? record.requesterUserId
619
941
  if (requester !== undefined) wire.requester = requester
620
942
  return wire
621
943
  }),
@@ -626,8 +948,8 @@ export class FeishuService {
626
948
  async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
627
949
  const id = this.cleanAccessTargetId(chatId)
628
950
  if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
629
- const admin = this.config.adminOpenId
630
- if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
951
+ const admin = this.effectiveAdminUserId()
952
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
631
953
  try {
632
954
  if (!this.accessStore.hasGroup(id)) {
633
955
  await this.accessStore.addGroup({
@@ -657,15 +979,15 @@ export class FeishuService {
657
979
  }
658
980
 
659
981
  /** Panel: authorize one user (global across authorized groups). */
660
- async addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
661
- const id = this.cleanAccessUserId(openId)
662
- if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
663
- const admin = this.config.adminOpenId
664
- if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
982
+ async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
983
+ const id = this.cleanAccessUserId(userId)
984
+ if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
985
+ const admin = this.effectiveAdminUserId()
986
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
665
987
  try {
666
988
  if (!this.accessStore.hasUser(id)) {
667
989
  await this.accessStore.addUser({
668
- openId: id,
990
+ userId: id,
669
991
  ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
670
992
  source: 'panel',
671
993
  addedBy: admin,
@@ -679,9 +1001,9 @@ export class FeishuService {
679
1001
  }
680
1002
 
681
1003
  /** Panel: revoke one user. */
682
- async removeAccessUser(openId: string): Promise<AccessMutationResponse> {
683
- const id = this.cleanAccessUserId(openId)
684
- if (id === undefined) return { ok: false, error: 'open_id 不合法' }
1004
+ async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
1005
+ const id = this.cleanAccessUserId(userId)
1006
+ if (id === undefined) return { ok: false, error: 'user_id 不合法' }
685
1007
  try {
686
1008
  await this.accessStore.removeUser(id)
687
1009
  return { ok: true, access: this.accessSnapshot() }
@@ -694,8 +1016,8 @@ export class FeishuService {
694
1016
  async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
695
1017
  const requests = this.accessRequests
696
1018
  if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
697
- const actor = this.config.adminOpenId
698
- if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
1019
+ const actor = this.effectiveAdminUserId()
1020
+ if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
699
1021
  try {
700
1022
  const result = await requests.resolve(token, decision, actor)
701
1023
  if (!result.ok) return { ok: false, error: result.error }
@@ -705,275 +1027,191 @@ export class FeishuService {
705
1027
  }
706
1028
  }
707
1029
 
708
- /** Trim + bound one panel-supplied identifier; rejects control characters. */
709
- private cleanAccessTargetId(raw: string): string | undefined {
710
- const value = raw.trim()
711
- if (value === '' || value.length > 128) return undefined
712
- // eslint-disable-next-line no-control-regex
713
- if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
714
- return value
1030
+ /** `/auth status` administrator line: id plus where it came from. */
1031
+ private adminStatusLine(): string {
1032
+ const admin = this.effectiveAdminUserId()
1033
+ if (admin === undefined) return '未配置'
1034
+ return this.configuredAdminUserId() === undefined ? `\`${admin}\`(应用所有者,自动)` : `\`${admin}\``
715
1035
  }
716
1036
 
717
- /** Same normalization for user open_ids, which must look like ou_xxx. */
718
- private cleanAccessUserId(raw: string): string | undefined {
719
- const value = this.cleanAccessTargetId(raw)
720
- if (value === undefined || !/^ou_[A-Za-z0-9_-]+$/u.test(value)) return undefined
721
- return value
1037
+ /** The explicitly configured administrator, trimmed; empty counts as unset. */
1038
+ private configuredAdminUserId(): string | undefined {
1039
+ const admin = this.config.adminUserId?.trim()
1040
+ return admin === undefined || admin === '' ? undefined : admin
722
1041
  }
723
1042
 
724
- // ---- retention cleanup ------------------------------------------------
1043
+ /** Configured administrator, else the app owner looked up at `start()`. */
1044
+ private effectiveAdminUserId(): string | undefined {
1045
+ const configured = this.configuredAdminUserId()
1046
+ if (configured !== undefined) return configured
1047
+ const owner = this.appOwner
1048
+ return owner !== undefined && 'userId' in owner ? owner.userId : undefined
1049
+ }
725
1050
 
726
- /**
727
- * Retention preview for the Web panel: how many plugin workspaces exist,
728
- * how many directories are past the configured window, and their measured
729
- * footprint. Never deletes anything.
730
- */
731
- async cleanupPreview(): Promise<{
732
- retention: string
733
- thresholdMs?: number
734
- auto: boolean
735
- workspaces: number
736
- candidates: number
737
- candidateBytes: number
738
- error?: string
739
- }> {
740
- const retention = this.config.workspaceRetention
741
- const window = retentionMs(retention)
742
- const base = {
743
- retention,
744
- ...(window === undefined ? {} : { thresholdMs: window }),
745
- auto: this.config.workspaceRetentionAuto,
746
- }
747
- if (this.domain === undefined) {
748
- // No connection: routes live in the closed storage domain, so a preview
749
- // would mistake every directory for a candidate. Report why instead.
750
- return {
751
- ...base,
752
- workspaces: 0,
753
- candidates: 0,
754
- candidateBytes: 0,
755
- error: '飞书连接建立后即可预览和清理工作区',
756
- }
757
- }
758
- try {
759
- const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
760
- const plan = await planWorkspaceCleanup(this.cleanupDeps())
761
- return {
762
- ...base,
763
- workspaces: workspaces.length,
764
- candidates: plan?.candidates.length ?? 0,
765
- candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
766
- }
767
- } catch (error) {
768
- return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
769
- }
1051
+ /** Live config with `adminUserId` replaced by the effective administrator. */
1052
+ private accessConfig(): FeishuConfig {
1053
+ return { ...this.config, adminUserId: this.effectiveAdminUserId() }
770
1054
  }
771
1055
 
772
1056
  /**
773
- * Delete every stale plugin directory the configured retention allows: the
774
- * per-topic working directories of current conversations and the legacy
775
- * one-workspace-per-topic directories of older versions. User workspaces are
776
- * never deleted.
777
- *
778
- * Active routes bound to a removed directory are retired first (thread
779
- * routes leave a tombstone), so an old topic fails with the explicit expired
780
- * card instead of resuming into a missing directory. DSH Session logs are not
781
- * touched.
1057
+ * Look up the app owner for the administrator fallback. Never throws: a
1058
+ * failure is recorded for the panel and only warned about when no
1059
+ * administrator is configured (the fallback is then what admission needs).
782
1060
  */
783
- async runWorkspaceCleanup(): Promise<{
784
- ok: boolean
785
- deletedWorkspaces: number
786
- deletedSessionDirs: number
787
- retiredRoutes: number
788
- freedBytes: number
789
- failures: string[]
790
- error?: string
791
- }> {
792
- const empty = {
793
- ok: false,
794
- deletedWorkspaces: 0,
795
- deletedSessionDirs: 0,
796
- retiredRoutes: 0,
797
- freedBytes: 0,
798
- failures: [] as string[],
799
- }
800
- if (this.domain === undefined) {
801
- // Without the storage domain the route table is unreadable, so a cleanup
802
- // could mistake live directories for candidates and fail to retire their
803
- // routes. Refuse rather than rely on the panel disabling the button.
804
- return { ...empty, error: '飞书连接建立后才能清理工作区' }
805
- }
806
- if (retentionMs(this.config.workspaceRetention) === undefined) {
807
- return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
808
- }
809
- if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
810
- this.cleanupRunning = true
1061
+ private async resolveAppOwner(channel: FeishuChannel): Promise<void> {
1062
+ this.appOwner = undefined
1063
+ if (channel.appOwnerUserId === undefined) return
1064
+ let timer: ReturnType<typeof setTimeout> | undefined
1065
+ let state: AppOwnerState
811
1066
  try {
812
- const deps = this.cleanupDeps()
813
- const plan = await planWorkspaceCleanup(deps)
814
- if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
815
- const outcome = await applyWorkspaceCleanup(deps, plan)
816
- this.ctx.logger?.info?.(
817
- 'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
818
- outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
819
- )
820
- return {
821
- ok: true,
822
- deletedWorkspaces: outcome.deletedWorkspaces,
823
- deletedSessionDirs: outcome.deletedSessionDirs,
824
- retiredRoutes: outcome.retiredRoutes,
825
- freedBytes: outcome.freedBytes,
826
- failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
827
- }
1067
+ const userId = await Promise.race([
1068
+ channel.appOwnerUserId(),
1069
+ new Promise<never>((_, reject) => {
1070
+ timer = setTimeout(() => reject(new Error(`超时(${APP_OWNER_LOOKUP_TIMEOUT_MS} ms)`)), APP_OWNER_LOOKUP_TIMEOUT_MS)
1071
+ }),
1072
+ ])
1073
+ state = userId !== undefined && FEISHU_USER_ID_PATTERN.test(userId)
1074
+ ? { userId }
1075
+ : { error: '应用所有者不是企业内成员,或接口未返回其 user_id', apiRejected: false }
828
1076
  } catch (error) {
829
- return { ...empty, error: messageOf(error) }
1077
+ const detail = error instanceof AppOwnerLookupError ? error.detail : messageOf(error)
1078
+ state = {
1079
+ error: `获取应用信息失败:${detail}。请在「访问授权」手动填写管理员 user_id(私聊机器人发 /userid 获取,私聊模式关闭时可在飞书管理后台的成员详情里查看用户 ID);或为应用开通「获取应用信息」权限并发布版本后重新连接`,
1080
+ apiRejected: error instanceof AppOwnerLookupError && error.code !== undefined,
1081
+ }
830
1082
  } finally {
831
- this.cleanupRunning = false
1083
+ if (timer !== undefined) clearTimeout(timer)
1084
+ }
1085
+ // A background lookup can outlive its connection (shutdown/reconnect).
1086
+ if (this.channel !== channel) return
1087
+ this.appOwner = state
1088
+ if ('error' in state && this.configuredAdminUserId() === undefined) {
1089
+ this.ctx.logger?.warn?.('dsh-channels: app owner fallback unavailable: %s', state.error)
832
1090
  }
833
1091
  }
834
1092
 
835
- /** Whether one registry workspace was created by this plugin inside `baseDir`. */
836
- private isPluginWorkspace(workspace: Workspace): boolean {
837
- const pluginTitle = workspace.title.startsWith('飞书私聊 ·') || workspace.title.startsWith('飞书群聊 ·')
838
- return pluginTitle && isPathInside(path.resolve(this.config.baseDir), workspace.path)
1093
+ /** Latest post-connect scope self-check, when one finished for this connection. */
1094
+ scopeCheck(): ScopeCheckWire | undefined {
1095
+ return this.scopeCheckState
839
1096
  }
840
1097
 
841
- /** Effect seams for the cleanup policy module (all I/O stays here). */
842
- private cleanupDeps(): CleanupDeps {
843
- const baseDir = path.resolve(this.config.baseDir)
844
- return {
845
- baseDir,
846
- retention: this.config.workspaceRetention,
847
- listWorkspaces: () => this.ctx.workspaceRegistry.list(),
848
- listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
849
- activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
850
- key,
851
- kind: record.kind,
852
- workspacePath: record.workspacePath,
853
- ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
854
- sessionId: record.sessionId,
855
- lastActivityAt: record.lastActivityAt,
856
- })),
857
- tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
858
- key,
859
- ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
860
- ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
861
- expiredAt: record.expiredAt,
862
- ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
863
- })),
864
- isLive: sessionId => this.sessions.get(sessionId) !== undefined
865
- || this.ctx.agents.get(sessionId as SessionId) !== undefined,
866
- statWorkspace: directory => measureWorkspace(directory),
867
- removeWorkspace: async workspace => {
868
- await this.deleteDirectoryInsideBase(baseDir, workspace.path)
869
- try {
870
- await this.ctx.workspaceRegistry.delete(workspace.id)
871
- } catch (error) {
872
- this.ctx.logger?.warn?.(
873
- 'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
874
- )
875
- }
876
- },
877
- removeSessionDir: async directory => {
878
- await this.deleteDirectoryInsideBase(baseDir, directory)
879
- },
880
- retireRoute: async route => {
881
- if (route.kind === 'thread') await this.routes.retire(route.key)
882
- else await this.routes.remove(route.key)
883
- },
884
- warn: message => this.ctx.logger?.warn?.(message),
885
- }
1098
+ /** Latest native slash-command panel sync, when one finished for this connection. */
1099
+ slashCommandSync(): SlashCommandSyncWire | undefined {
1100
+ return this.slashCommandSyncState
886
1101
  }
887
1102
 
888
1103
  /**
889
- * Scan the live user workspaces for per-topic working directories that no
890
- * route record points at anymore (for example the old route of a degraded
891
- * chat conversation was deleted by TTL retirement). Route/tombstone evidence
892
- * wins for age; the scan mtime only covers true orphans.
1104
+ * Reconcile Feishu's native `/` panel with the command registry and log the
1105
+ * outcome. Never throws; a failure only loses the panel listing, typed
1106
+ * commands keep working.
893
1107
  */
894
- private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
895
- const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
896
- const roots: string[] = []
897
- for (const workspace of this.ctx.workspaceRegistry.list()) {
898
- if (!this.isPluginWorkspace(workspace)) continue
899
- const resolved = path.resolve(workspace.path)
900
- // Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
901
- if (isPathInside(legacyRoot, resolved)) continue
902
- roots.push(resolved)
903
- }
904
- const scanned: ScannedSessionDir[] = []
905
- for (const root of roots) {
906
- for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
907
- const directory = path.join(root, relative)
908
- let entries
909
- try {
910
- entries = await readdir(directory, { withFileTypes: true })
911
- } catch {
912
- continue
913
- }
914
- for (const entry of entries) {
915
- if (!entry.isDirectory()) continue
916
- const candidate = path.join(directory, entry.name)
917
- try {
918
- const info = await lstat(candidate)
919
- if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
920
- } catch {
921
- // Removed or unreadable between readdir and lstat; skip it.
922
- }
923
- }
924
- }
1108
+ private async runSlashCommandSync(channel: FeishuChannel): Promise<void> {
1109
+ this.slashCommandSyncState = undefined
1110
+ this.slashCommandSyncAbort?.abort()
1111
+ this.slashCommandSyncAbort = undefined
1112
+ const api = channel.slashCommands
1113
+ if (api === undefined) return
1114
+ const abort = new AbortController()
1115
+ this.slashCommandSyncAbort = abort
1116
+ let timer: ReturnType<typeof setTimeout> | undefined
1117
+ let state: SlashCommandSyncWire
1118
+ try {
1119
+ const result = await Promise.race([
1120
+ syncSlashCommands(api, desiredSlashCommands(), abort.signal),
1121
+ new Promise<never>((_, reject) => {
1122
+ timer = setTimeout(() => reject(new Error(`超时(${SLASH_COMMAND_SYNC_TIMEOUT_MS} ms)`)), SLASH_COMMAND_SYNC_TIMEOUT_MS)
1123
+ }),
1124
+ ])
1125
+ state = { state: 'ok', checkedAt: Date.now(), ...result }
1126
+ } catch (error) {
1127
+ const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
1128
+ state = { state: 'error', checkedAt: Date.now(), error: detail }
1129
+ } finally {
1130
+ if (timer !== undefined) clearTimeout(timer)
1131
+ // A timed-out sync must not keep writing behind a reported failure.
1132
+ abort.abort()
1133
+ if (this.slashCommandSyncAbort === abort) this.slashCommandSyncAbort = undefined
1134
+ }
1135
+ // A background sync can outlive its connection (shutdown/reconnect).
1136
+ if (this.channel !== channel) return
1137
+ this.slashCommandSyncState = state
1138
+ if (state.state === 'ok') {
1139
+ this.ctx.logger?.info?.('dsh-channels: slash command panel synced (%d commands, %d created, %d updated, %d removed)', state.total, state.created, state.updated, state.removed)
1140
+ } else {
1141
+ this.ctx.logger?.warn?.('dsh-channels: 飞书原生斜杠指令面板同步失败(直接输入命令不受影响):%s', state.error)
925
1142
  }
926
- return scanned
927
1143
  }
928
1144
 
929
1145
  /**
930
- * Recursively delete one plugin directory below `baseDir`.
931
- *
932
- * The lexical check refuses paths outside `baseDir`; the realpath check then
933
- * refuses a symlinked ancestor that would let the recursive delete escape
934
- * the plugin root. A missing directory is already effectively removed.
1146
+ * Compare the app's granted scopes with what the plugin uses and log the
1147
+ * missing ones. Never throws; waits for the owner lookup so the app-info
1148
+ * need reflects its outcome.
935
1149
  */
936
- private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
937
- const resolved = path.resolve(target)
938
- if (!isPathInside(baseDir, resolved)) {
939
- throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
940
- }
1150
+ private async runScopeCheck(channel: FeishuChannel, appId: string, ownerLookup: Promise<void>): Promise<void> {
1151
+ this.scopeCheckState = undefined
1152
+ if (channel.grantedScopes === undefined) return
1153
+ await ownerLookup
1154
+ let timer: ReturnType<typeof setTimeout> | undefined
1155
+ let state: ScopeCheckWire
941
1156
  try {
942
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
943
- if (!isPathInside(physicalBase, physicalTarget)) {
944
- throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
945
- }
1157
+ const grants = await Promise.race([
1158
+ channel.grantedScopes(),
1159
+ new Promise<never>((_, reject) => {
1160
+ timer = setTimeout(() => reject(new Error(`超时(${SCOPE_CHECK_TIMEOUT_MS} ms)`)), SCOPE_CHECK_TIMEOUT_MS)
1161
+ }),
1162
+ ])
1163
+ const result = checkScopes(grants, appId, {
1164
+ dmEnabled: this.config.dmMode === 'open',
1165
+ requireMention: this.config.requireMention,
1166
+ ownerLookupFailed: this.config.accessMode === 'guarded'
1167
+ && this.configuredAdminUserId() === undefined
1168
+ && this.appOwner !== undefined && 'error' in this.appOwner && this.appOwner.apiRejected,
1169
+ slashCommandPanel: this.config.slashCommandPanel,
1170
+ sendFile: this.config.feishuTools.sendFile,
1171
+ })
1172
+ state = result.consoleUrl === undefined
1173
+ ? { state: 'ok', checkedAt: Date.now() }
1174
+ : {
1175
+ state: 'missing',
1176
+ checkedAt: Date.now(),
1177
+ missing: result.missing.map(item => ({
1178
+ scopes: [...item.anyOf], level: item.level, purpose: item.purpose, consoleUrl: item.consoleUrl,
1179
+ })),
1180
+ consoleUrl: result.consoleUrl,
1181
+ }
946
1182
  } catch (error) {
947
- if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
948
- // A missing directory is already effectively removed (rm is idempotent).
1183
+ const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
1184
+ state = { state: 'error', checkedAt: Date.now(), error: detail }
1185
+ } finally {
1186
+ if (timer !== undefined) clearTimeout(timer)
1187
+ }
1188
+ // A background check can outlive its connection (shutdown/reconnect).
1189
+ if (this.channel !== channel) return
1190
+ this.scopeCheckState = state
1191
+ if (state.state === 'ok') {
1192
+ this.ctx.logger?.info?.('dsh-channels: scope self-check passed')
1193
+ } else if (state.state === 'missing') {
1194
+ const lines = state.missing.map(item => `${item.level === 'required' ? '必需' : '可选'} ${item.scopes.join(' 或 ')}:${item.purpose}`)
1195
+ this.ctx.logger?.warn?.('dsh-channels: 飞书权限缺失 %d 项,开通后需发布新版本(%s):\n %s', state.missing.length, state.consoleUrl, lines.join('\n '))
1196
+ } else {
1197
+ this.ctx.logger?.warn?.('dsh-channels: 飞书权限自检失败:%s', state.error)
949
1198
  }
950
- await rm(resolved, { recursive: true, force: true })
951
1199
  }
952
1200
 
953
- /**
954
- * (Re)arm the daily retention timer to match the live config. `runNow`
955
- * performs one pass immediately; it is only used at startup so saving any
956
- * unrelated setting can never trigger a surprise deletion pass.
957
- */
958
- private refreshRetentionTimer(runNow = false): void {
959
- if (this.retentionTimer !== undefined) {
960
- clearInterval(this.retentionTimer)
961
- this.retentionTimer = undefined
962
- }
963
- if (!this.started
964
- || !this.config.workspaceRetentionAuto
965
- || retentionMs(this.config.workspaceRetention) === undefined) return
966
- const sweep = (): void => {
967
- void this.runWorkspaceCleanup().then(result => {
968
- if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
969
- this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
970
- }
971
- })
972
- }
973
- if (runNow) sweep()
974
- const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
975
- timer.unref?.()
976
- this.retentionTimer = timer
1201
+ /** Trim + bound one panel-supplied identifier; rejects control characters. */
1202
+ private cleanAccessTargetId(raw: string): string | undefined {
1203
+ const value = raw.trim()
1204
+ if (value === '' || value.length > 128) return undefined
1205
+ // eslint-disable-next-line no-control-regex
1206
+ if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
1207
+ return value
1208
+ }
1209
+
1210
+ /** Same normalization for user ids, which must be Feishu `user_id`s. */
1211
+ private cleanAccessUserId(raw: string): string | undefined {
1212
+ const value = this.cleanAccessTargetId(raw)
1213
+ if (value === undefined || !FEISHU_USER_ID_PATTERN.test(value)) return undefined
1214
+ return value
977
1215
  }
978
1216
 
979
1217
  /** Aggregate health used by `/health` and the Web settings panel. */
@@ -1105,6 +1343,9 @@ export class FeishuService {
1105
1343
  onError: (sessionId, error) => {
1106
1344
  this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
1107
1345
  },
1346
+ onWaitingChange: (sessionId, toolNames) => {
1347
+ this.streamer?.setAwaitingApproval(sessionId, toolNames)
1348
+ },
1108
1349
  }, this.config.approvalTimeoutMs)
1109
1350
  this.approvalAnswerer = answerer
1110
1351
  this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
@@ -1124,7 +1365,7 @@ export class FeishuService {
1124
1365
  { prepend: true },
1125
1366
  )
1126
1367
  const accessRequests = new AccessRequests({
1127
- config: () => this.config,
1368
+ config: () => this.accessConfig(),
1128
1369
  store: this.accessStore,
1129
1370
  channel: () => this.channel,
1130
1371
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -1142,10 +1383,15 @@ export class FeishuService {
1142
1383
  void accessRequests.onCardAction(event)
1143
1384
  answerer.onCardAction(event)
1144
1385
  questions.onCardAction(event)
1386
+ this.onStopAction(event)
1387
+ void this.onPickAction(event).catch((error: unknown) => {
1388
+ this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
1389
+ })
1145
1390
  })
1146
1391
 
1147
1392
  this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
1148
1393
  this.streamer?.onSessionEvent(session, event)
1394
+ this.approvalAnswerer?.onSessionEvent(session.id, event)
1149
1395
  }))
1150
1396
  // DSH removed the durable `assistant/chunk` event (format v2 embeds the
1151
1397
  // stream in the settlement); the live deltas arrive here instead.
@@ -1161,7 +1407,15 @@ export class FeishuService {
1161
1407
  .catch(() => {})
1162
1408
  })
1163
1409
  })
1410
+ // Without a configured administrator, resolve the owner before the
1411
+ // handshake so the first inbound message already sees the fallback; with
1412
+ // one, the lookup only backs a later runtime clear and must not delay boot.
1413
+ const ownerLookup = this.resolveAppOwner(channel)
1414
+ if (this.configuredAdminUserId() === undefined) await ownerLookup
1164
1415
  await channel.connect()
1416
+ // Background: the results only feed the panel and the log.
1417
+ void this.runScopeCheck(channel, appId, ownerLookup)
1418
+ if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
1165
1419
  // Repair cards a previous instance left mid-flight (best-effort, background).
1166
1420
  void this.repair.repairAll().catch((error: unknown) => {
1167
1421
  this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
@@ -1178,8 +1432,6 @@ export class FeishuService {
1178
1432
  }, GC_SWEEP_INTERVAL_MS)
1179
1433
  timer.unref?.()
1180
1434
  this.gcTimer = timer
1181
- // Workspace retention: opt-in automatic cleanup (default off).
1182
- this.refreshRetentionTimer(true)
1183
1435
  this.ctx.logger?.info?.(
1184
1436
  'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
1185
1437
  channel.botOpenId() ?? 'unknown',
@@ -1195,11 +1447,15 @@ export class FeishuService {
1195
1447
  })
1196
1448
  if (this.config.accessMode === 'open') {
1197
1449
  console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
1198
- } else if (this.config.adminOpenId === undefined) {
1450
+ } else if (this.effectiveAdminUserId() === undefined) {
1451
+ const ownerError = this.appOwner !== undefined && 'error' in this.appOwner ? this.appOwner.error : undefined
1199
1452
  console.warn(
1200
- '[dsh-channels] 准入=guarded 但未配置管理员 open_id:所有对话都会被拒绝;'
1201
- + '私聊机器人发送 /userid 取得 open_id 后填入 Web 设置面板',
1453
+ '[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
1454
+ + '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
1455
+ + (ownerError === undefined ? '' : `(${ownerError})`),
1202
1456
  )
1457
+ } else if (this.configuredAdminUserId() === undefined) {
1458
+ console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
1203
1459
  } else {
1204
1460
  console.info('[dsh-channels] 准入=guarded,管理员已配置')
1205
1461
  }
@@ -1253,11 +1509,8 @@ export class FeishuService {
1253
1509
  clearInterval(this.gcTimer)
1254
1510
  this.gcTimer = undefined
1255
1511
  }
1256
- if (this.retentionTimer !== undefined) {
1257
- clearInterval(this.retentionTimer)
1258
- this.retentionTimer = undefined
1259
- }
1260
1512
  await this.sessions.disposeAll()
1513
+ this.attachmentFetchTools.clear()
1261
1514
  this.routeOperations.clear()
1262
1515
  this.inbound.clear()
1263
1516
  this.routes.clear()
@@ -1268,10 +1521,16 @@ export class FeishuService {
1268
1521
  // Already disconnected is fine.
1269
1522
  }
1270
1523
  this.channel = undefined
1524
+ this.streamer?.detachAll()
1271
1525
  this.streamer = undefined
1272
1526
  await this.domain?.close()
1273
1527
  this.domain = undefined
1274
1528
  this.runningConfig = undefined
1529
+ this.appOwner = undefined
1530
+ this.scopeCheckState = undefined
1531
+ this.slashCommandSyncState = undefined
1532
+ this.slashCommandSyncAbort?.abort()
1533
+ this.slashCommandSyncAbort = undefined
1275
1534
  }
1276
1535
 
1277
1536
  // ---- inbound routing -------------------------------------------------
@@ -1345,7 +1604,7 @@ export class FeishuService {
1345
1604
  return
1346
1605
  }
1347
1606
 
1348
- if (msg.senderId !== route.senderOpenId) {
1607
+ if (msg.senderId !== route.senderUserId) {
1349
1608
  await this.delivery.commandCard(msg, {
1350
1609
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1351
1610
  })
@@ -1355,7 +1614,7 @@ export class FeishuService {
1355
1614
  const operation = this.routeOperations.run(route.key, async () => {
1356
1615
  if (!this.started) return
1357
1616
  const current = this.routes.get(route.key) ?? route
1358
- if (msg.senderId !== current.senderOpenId) {
1617
+ if (msg.senderId !== current.senderUserId) {
1359
1618
  await this.delivery.commandCard(msg, {
1360
1619
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1361
1620
  })
@@ -1388,12 +1647,12 @@ export class FeishuService {
1388
1647
  const pending = this.accessStore.pendingRequests()
1389
1648
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
1390
1649
  `**准入模式**:\`${this.config.accessMode}\``,
1391
- `**管理员**:${this.config.adminOpenId === undefined ? '未配置' : `\`${this.config.adminOpenId}\``}`,
1650
+ `**管理员**:${this.adminStatusLine()}`,
1392
1651
  ...(msg.chatType === 'group'
1393
1652
  ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
1394
1653
  : []),
1395
1654
  `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
1396
- '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <open_id>`。',
1655
+ '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`。',
1397
1656
  ]), direct)
1398
1657
  return
1399
1658
  }
@@ -1440,35 +1699,39 @@ export class FeishuService {
1440
1699
  }
1441
1700
  if (action === 'user') {
1442
1701
  const sub = words[1]
1443
- const openId = words[2]
1444
- if ((sub !== 'add' && sub !== 'remove') || openId === undefined
1445
- || !/^ou_[A-Za-z0-9_-]+$/u.test(openId)) {
1702
+ const target = sub === 'add' || sub === 'remove'
1703
+ ? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
1704
+ : { ok: false as const, reason: 'usage' as const }
1705
+ if (!target.ok) {
1446
1706
  await this.delivery.commandCard(msg, {
1447
- kind: 'input-error', command: 'auth',
1448
- text: '用法:/auth user add <open_id> 或 /auth user remove <open_id>(open_id 以 ou_ 开头,可用 /userid 查看)。',
1707
+ kind: 'input-error', command: 'auth', text: AUTH_USER_TARGET_ERRORS[target.reason],
1449
1708
  }, direct)
1450
1709
  return
1451
1710
  }
1711
+ const { userId } = target
1712
+ const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
1452
1713
  if (sub === 'add') {
1453
- if (this.accessStore.hasUser(openId)) {
1714
+ if (this.accessStore.hasUser(userId)) {
1454
1715
  await this.delivery.commandCard(msg, {
1455
- kind: 'success', command: 'auth', text: `该用户已在白名单中(\`${openId}\`)。`,
1716
+ kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
1456
1717
  }, direct)
1457
1718
  return
1458
1719
  }
1459
1720
  await this.accessStore.addUser({
1460
- openId, source: 'command', addedBy: msg.senderId, addedAt: now,
1721
+ userId,
1722
+ ...(target.name === undefined ? {} : { name: target.name }),
1723
+ source: 'command', addedBy: msg.senderId, addedAt: now,
1461
1724
  })
1462
1725
  await this.delivery.commandCard(msg, {
1463
1726
  kind: 'success', command: 'auth',
1464
- text: `✅ 已授权用户 \`${openId}\`(在所有已授权群里生效)。`,
1727
+ text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
1465
1728
  }, direct)
1466
1729
  return
1467
1730
  }
1468
- const removed = await this.accessStore.removeUser(openId)
1731
+ const removed = await this.accessStore.removeUser(userId)
1469
1732
  await this.delivery.commandCard(msg, {
1470
1733
  kind: removed ? 'success' : 'unavailable', command: 'auth',
1471
- text: removed ? `已移除用户 \`${openId}\`,立即生效。` : `用户 \`${openId}\` 不在白名单中。`,
1734
+ text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
1472
1735
  }, direct)
1473
1736
  return
1474
1737
  }
@@ -1481,7 +1744,7 @@ export class FeishuService {
1481
1744
  : groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
1482
1745
  const userLines = users.length === 0
1483
1746
  ? ['(暂无)']
1484
- : users.slice(0, 30).map(user => `- ${user.name ?? user.openId} \`${user.openId}\``)
1747
+ : users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
1485
1748
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
1486
1749
  `**已授权群(${groups.length})**`,
1487
1750
  ...groupLines,
@@ -1493,7 +1756,7 @@ export class FeishuService {
1493
1756
  }
1494
1757
  await this.delivery.commandCard(msg, {
1495
1758
  kind: 'input-error', command: 'auth',
1496
- text: '用法:/auth [status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
1759
+ text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>]',
1497
1760
  }, direct)
1498
1761
  } catch (error) {
1499
1762
  this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
@@ -1602,10 +1865,7 @@ export class FeishuService {
1602
1865
  return
1603
1866
  }
1604
1867
  if (spec.name === 'stop') {
1605
- const agent = this.sessions.get(current.sessionId)?.agent
1606
- ?? this.ctx.agents.get(current.sessionId as SessionId)
1607
- const running = agent?.status === 'running'
1608
- agent?.cancel?.({ kind: 'user' })
1868
+ const running = this.cancelTurn(current.sessionId)
1609
1869
  await this.delivery.commandCard(msg, {
1610
1870
  kind: 'success', command: 'stop', sessionId: current.sessionId,
1611
1871
  text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
@@ -1618,6 +1878,72 @@ export class FeishuService {
1618
1878
  })
1619
1879
  }
1620
1880
 
1881
+ /**
1882
+ * Cancel one Session's live turn without resuming anything. Queued
1883
+ * messages stay in the inbox: each already has its own streaming card and
1884
+ * runs next.
1885
+ * @param sessionId The Session to cancel.
1886
+ * @returns Whether a turn was running.
1887
+ */
1888
+ private cancelTurn(sessionId: string): boolean {
1889
+ const agent = this.sessions.get(sessionId)?.agent
1890
+ ?? this.ctx.agents.get(sessionId as SessionId)
1891
+ const running = agent?.status === 'running'
1892
+ agent?.cancel?.({ kind: 'user' }, { keepInbox: true })
1893
+ if (running) this.streamer?.markStopping(sessionId)
1894
+ return running
1895
+ }
1896
+
1897
+ /**
1898
+ * Streaming-card stop button. Only the session owner, clicking inside the
1899
+ * session's own chat, cancels the turn; another operator gets the
1900
+ * owner-only notice on the card instead.
1901
+ */
1902
+ private onStopAction(event: CardActionEvent): void {
1903
+ if (!this.started) return
1904
+ if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STOP_ACTION) return
1905
+ const sessionId = this.streamer?.stopTargetOf(event.messageId)
1906
+ if (sessionId === undefined) return
1907
+ const route = this.routes.findBySession(sessionId)
1908
+ if (route === undefined) return
1909
+ if (event.operator.userId !== route.senderUserId || event.chatId !== route.chatId) {
1910
+ this.streamer?.markStopDenied(sessionId)
1911
+ return
1912
+ }
1913
+ this.cancelTurn(sessionId)
1914
+ }
1915
+
1916
+ /**
1917
+ * Picker-card click: run the chosen `/<command> <arg>` exactly as if the
1918
+ * operator had typed it under the picker card, so access admission, route
1919
+ * ownership and validation are the typed command's. Stale pickers (the
1920
+ * topic was rebound, or the card was forwarded elsewhere) and non-owner
1921
+ * clicks are ignored.
1922
+ */
1923
+ private async onPickAction(event: CardActionEvent): Promise<void> {
1924
+ if (!this.started) return
1925
+ const action = parsePickAction(event.action.value)
1926
+ if (action === undefined) return
1927
+ const route = this.routes.findBySession(action.sessionId)
1928
+ const userId = event.operator.userId
1929
+ if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
1930
+ const line = `/${action.command} ${action.arg}`
1931
+ await this.handleSlashMessage({
1932
+ messageId: event.messageId,
1933
+ chatId: route.chatId,
1934
+ chatType: route.chatType,
1935
+ senderId: userId,
1936
+ content: line,
1937
+ rawContentType: 'text',
1938
+ resources: [],
1939
+ mentions: [],
1940
+ mentionAll: false,
1941
+ mentionedBot: true,
1942
+ ...(route.kind === 'thread' ? { threadId: route.key } : {}),
1943
+ createTime: Date.now(),
1944
+ }, line)
1945
+ }
1946
+
1621
1947
  /**
1622
1948
  * `/permissions`: show this Session's effective sandbox+approval preset and
1623
1949
  * the selectable catalog; switch only when the deployment opted in.
@@ -1638,14 +1964,27 @@ export class FeishuService {
1638
1964
  const live = await this.sessions.ensureLive(current)
1639
1965
  let active: string | undefined
1640
1966
  let catalogLine = ''
1967
+ let choices: PickerOption[] = []
1641
1968
  try {
1642
1969
  active = presets.current(live.agent.session)
1643
1970
  const catalog = presets.catalog()
1644
1971
  catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
1972
+ choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
1645
1973
  } catch {
1646
1974
  active = undefined
1647
1975
  }
1648
1976
  const requested = parsed.rawInput.trim()
1977
+ if (requested === '' && this.config.allowPermissionSwitch && choices.length > 0) {
1978
+ await this.delivery.standaloneReply(msg, buildPickerCard({
1979
+ title: '🔐 权限预设',
1980
+ command: 'permissions',
1981
+ text: `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}\n切换会同时调整沙箱与审批策略。`,
1982
+ sessionId: current.sessionId,
1983
+ options: choices,
1984
+ ...(active === undefined ? {} : { current: active }),
1985
+ }))
1986
+ return
1987
+ }
1649
1988
  if (requested === '') {
1650
1989
  const lines = [
1651
1990
  `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
@@ -1772,6 +2111,7 @@ export class FeishuService {
1772
2111
  }
1773
2112
  await this.renderCommandResult({
1774
2113
  spec, msg, current, live,
2114
+ bare: parsed.rawInput.trim() === '',
1775
2115
  result: execution.result,
1776
2116
  compactBefore,
1777
2117
  renewAdmission,
@@ -1786,6 +2126,8 @@ export class FeishuService {
1786
2126
  msg: NormalizedMessage
1787
2127
  current: RouteRecord
1788
2128
  live: LiveAgent
2129
+ /** The command was typed without arguments. */
2130
+ bare: boolean
1789
2131
  result: CommandResult
1790
2132
  compactBefore: ContextMeasurement | undefined
1791
2133
  renewAdmission: RenewAdmission | undefined
@@ -1795,6 +2137,17 @@ export class FeishuService {
1795
2137
  const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
1796
2138
  if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
1797
2139
  if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
2140
+ if (input.bare && result.kind === 'success') {
2141
+ const picker = spec.name === 'model' && modelAdmission !== undefined
2142
+ ? await this.modelPicker(current, live, modelAdmission)
2143
+ : spec.name === 'effort' && effortAdmission !== undefined
2144
+ ? await this.effortPicker(current, live, effortAdmission, result.text ?? '')
2145
+ : undefined
2146
+ if (picker !== undefined) {
2147
+ await this.delivery.standaloneReply(msg, picker)
2148
+ return
2149
+ }
2150
+ }
1798
2151
  if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
1799
2152
  await this.delivery.standaloneReply(msg, buildRenewCard({
1800
2153
  previousSessionId: renewAdmission.commit.previous.sessionId,
@@ -2018,7 +2371,7 @@ export class FeishuService {
2018
2371
 
2019
2372
 
2020
2373
  /**
2021
- * GC pass over the routing domain. Two independent halves:
2374
+ * GC pass over the routing domain. Two independent parts:
2022
2375
  *
2023
2376
  * 1. Dedup records (unconditional): `processed` records and in-process
2024
2377
  * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
@@ -2077,6 +2430,7 @@ export class FeishuService {
2077
2430
  } else {
2078
2431
  await this.routes.remove(key)
2079
2432
  }
2433
+ this.attachmentFetchTools.forgetRoute(key)
2080
2434
  removedRoutes++
2081
2435
  } catch (error) {
2082
2436
  this.ctx.logger?.warn?.('dsh-channels: GC of route %s failed: %s', key, String(error))
@@ -2084,6 +2438,19 @@ export class FeishuService {
2084
2438
  }
2085
2439
  }
2086
2440
 
2441
+ // Reconcile the attachment-fetch whitelist with the active route set: the
2442
+ // inbound TTL path retires a thread route directly (tombstone), so its
2443
+ // in-memory whitelist bucket would otherwise live until process exit.
2444
+ const droppedToolRoutes = this.attachmentFetchTools.pruneRoutes(
2445
+ routeKey => this.routes.get(routeKey) !== undefined,
2446
+ )
2447
+ if (droppedToolRoutes > 0) {
2448
+ this.ctx.logger?.info?.(
2449
+ 'dsh-channels: record GC dropped %d stale attachment-tool route bucket(s)',
2450
+ droppedToolRoutes,
2451
+ )
2452
+ }
2453
+
2087
2454
  const inflight = this.domain?.inflight
2088
2455
  if (inflight !== undefined) {
2089
2456
  for (const [key, snapshot] of [...inflight.entries()]) {
@@ -2118,7 +2485,7 @@ export class FeishuService {
2118
2485
  chatId: record.chatId,
2119
2486
  cardMessageId: record.cardMessageId,
2120
2487
  inThread: record.kind === 'thread',
2121
- senderOpenId: record.senderOpenId,
2488
+ senderUserId: record.senderUserId,
2122
2489
  }
2123
2490
  }
2124
2491
 
@@ -2138,22 +2505,10 @@ export class FeishuService {
2138
2505
  if (admission.live.agent !== invocation.agent) {
2139
2506
  return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
2140
2507
  }
2141
- const session = invocation.agent.session
2142
- const state = admission.controller.state
2143
- const header = session.requestHeader?.()?.config
2144
- const currentProvider = state.effectiveModel?.provider
2145
- ?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
2146
- const currentModel = state.effectiveModel?.model
2147
- ?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
2148
- const source = state.modelSource === 'session'
2149
- ? 'Session 覆盖'
2150
- : state.modelSource === 'config' ? '配置默认' : '部署默认'
2508
+ const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
2151
2509
 
2152
2510
  if (rawInput === '') {
2153
- const lines = [
2154
- `**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
2155
- + `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
2156
- ]
2511
+ const lines = [currentModelLine(admission.live, admission.controller)]
2157
2512
  const models = await this.listModels(currentProvider)
2158
2513
  if (models.length > 0) {
2159
2514
  lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
@@ -2190,6 +2545,59 @@ export class FeishuService {
2190
2545
  return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
2191
2546
  }
2192
2547
 
2548
+ /** `/model` picker: the provider's advertised models, or undefined when none are listed. */
2549
+ private async modelPicker(
2550
+ current: RouteRecord,
2551
+ live: LiveAgent,
2552
+ admission: ModelAdmission,
2553
+ ): Promise<object | undefined> {
2554
+ const route = currentModelRoute(live, admission.controller)
2555
+ const models = await this.listModels(route.provider)
2556
+ if (models.length === 0) return undefined
2557
+ return buildPickerCard({
2558
+ title: '🧠 模型',
2559
+ command: 'model',
2560
+ text: `${currentModelLine(live, admission.controller)}\n切换在下一条消息生效。`,
2561
+ sessionId: current.sessionId,
2562
+ options: models.map(id => ({ label: id, arg: id })),
2563
+ ...(route.model === undefined ? {} : { current: route.model }),
2564
+ reset: true,
2565
+ })
2566
+ }
2567
+
2568
+ /**
2569
+ * `/effort` picker: the efforts the current model route advertises, or
2570
+ * undefined when the route is unknown or advertises none.
2571
+ */
2572
+ private async effortPicker(
2573
+ current: RouteRecord,
2574
+ live: LiveAgent,
2575
+ admission: EffortAdmission,
2576
+ text: string,
2577
+ ): Promise<object | undefined> {
2578
+ const route = currentModelRoute(live, admission.controller)
2579
+ if (route.provider === undefined || route.model === undefined) return undefined
2580
+ let reasoning: LlmModelInfo['reasoning']
2581
+ try {
2582
+ const llm = this.ctx.get?.('llm') as LlmService | undefined
2583
+ reasoning = (await llm?.resolveModelInfo(route.provider, route.model))?.reasoning
2584
+ } catch (error) {
2585
+ this.ctx.logger?.warn?.('dsh-channels: effort catalog lookup failed: %s', String(error))
2586
+ return undefined
2587
+ }
2588
+ if (reasoning === undefined || reasoning.efforts.length === 0) return undefined
2589
+ const active = admission.controller.state.effectiveEffort ?? reasoning.defaultEffort
2590
+ return buildPickerCard({
2591
+ title: '💭 推理强度',
2592
+ command: 'effort',
2593
+ text,
2594
+ sessionId: current.sessionId,
2595
+ options: reasoning.efforts.map(effort => ({ label: effort.name || effort.id, arg: effort.id })),
2596
+ ...(active === undefined ? {} : { current: active }),
2597
+ reset: true,
2598
+ })
2599
+ }
2600
+
2193
2601
  /** Model ids advertised for one provider route (empty when unavailable). */
2194
2602
  private async listModels(provider: string | undefined): Promise<string[]> {
2195
2603
  if (provider === undefined) return []
@@ -2330,3 +2738,28 @@ export class FeishuService {
2330
2738
  }
2331
2739
  }
2332
2740
  }
2741
+
2742
+ /** The model route the next request of a Session uses, as far as it is known. */
2743
+ function currentModelRoute(
2744
+ live: LiveAgent,
2745
+ controller: RequestPolicyController,
2746
+ ): { provider: string | undefined; model: string | undefined } {
2747
+ const state = controller.state
2748
+ const header = live.agent.session.requestHeader?.()?.config
2749
+ return {
2750
+ provider: state.effectiveModel?.provider
2751
+ ?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined),
2752
+ model: state.effectiveModel?.model
2753
+ ?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined),
2754
+ }
2755
+ }
2756
+
2757
+ /** `**当前模型**:…` line shared by the `/model` text answer and its picker. */
2758
+ function currentModelLine(live: LiveAgent, controller: RequestPolicyController): string {
2759
+ const { provider, model } = currentModelRoute(live, controller)
2760
+ const source = controller.state.modelSource === 'session'
2761
+ ? 'Session 覆盖'
2762
+ : controller.state.modelSource === 'config' ? '配置默认' : '部署默认'
2763
+ return `**当前模型**:${model === undefined ? '尚无模型请求记录' : `\`${model}\``}`
2764
+ + `${provider === undefined ? '' : `(provider \`${provider}\`)`} · ${source}`
2765
+ }