@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.
Files changed (58) hide show
  1. package/README.md +73 -55
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +33 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +173 -287
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +3001 -2627
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +12 -12
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +115 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/client/api.ts +5 -25
  26. package/src/client/panel/access-card.tsx +24 -15
  27. package/src/client/panel/access-tab.tsx +3 -3
  28. package/src/client/panel/connection-tab.tsx +70 -1
  29. package/src/client/panel/index.tsx +11 -62
  30. package/src/client/panel/shared.tsx +0 -23
  31. package/src/client/panel/workspace-tab.tsx +2 -18
  32. package/src/client/styles.ts +2 -0
  33. package/src/commands/registry.ts +2 -2
  34. package/src/config.ts +25 -56
  35. package/src/domain.ts +28 -125
  36. package/src/feishu/inbound-attachments.ts +1 -1
  37. package/src/feishu/manifest.ts +8 -31
  38. package/src/feishu/message-read.ts +19 -22
  39. package/src/feishu/scopes.ts +171 -0
  40. package/src/feishu/send-file.ts +543 -0
  41. package/src/feishu/slash-commands.ts +134 -0
  42. package/src/feishu/tools.ts +72 -112
  43. package/src/feishu-channel.ts +270 -9
  44. package/src/index.ts +5 -4
  45. package/src/interactions/approvals.ts +187 -7
  46. package/src/interactions/pending.ts +3 -3
  47. package/src/routing/inbound.ts +73 -106
  48. package/src/service.ts +463 -438
  49. package/src/session/manager.ts +1 -1
  50. package/src/session/routes.ts +2 -3
  51. package/src/settings/contract.ts +68 -85
  52. package/src/settings/routes.ts +3 -17
  53. package/src/settings/surface.ts +37 -88
  54. package/src/streamer.ts +94 -5
  55. package/src/workspaces.ts +21 -29
  56. package/src/cleanup.ts +0 -546
  57. package/src/client/panel/cleanup-card.tsx +0 -106
  58. package/src/feishu/pending-attachments.ts +0 -562
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, PendingAttachmentRecord,
60
- PendingAttachmentsTable, ProcessedRecord, RawDomainHandle, 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'
@@ -66,8 +62,8 @@ 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'
68
64
  import { InboundAttachmentStore, effectiveAttachmentMode, resolveAttachmentService } from './feishu/native-attachments.ts'
69
- import { PendingAttachmentStore } from './feishu/pending-attachments.ts'
70
65
  import { AttachmentFetchToolRuntime, TopicMessagesToolRuntime } from './feishu/tools.ts'
66
+ import { SendFileToolRuntime } from './feishu/send-file.ts'
71
67
  import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
72
68
  import {
73
69
  MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
@@ -78,7 +74,8 @@ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
78
74
  import type { AttachmentStore } from './host/attachments.ts'
79
75
  import { InboundRouter } from './routing/inbound.ts'
80
76
  import type { RequestPolicyController } from './session/request-policy.ts'
81
- 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'
82
79
  import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
83
80
  import type { HealthDependencyName, HealthSnapshot } from './health.ts'
84
81
  import {
@@ -95,11 +92,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
95
92
  import type { ProjectedStats, StreamerHooks } from './streamer.ts'
96
93
  import type { FeishuConfig } from './config.ts'
97
94
  import type {
98
- AccessMutationResponse, AccessRequestWire, AccessWire,
95
+ AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
99
96
  } from './settings/contract.ts'
97
+ import { checkScopes } from './feishu/scopes.ts'
98
+ import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
100
99
  import { mergeOverlay } from './settings/overlay.ts'
101
100
  import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
102
- import { legacyTopicWorkspaceRoot } from './workspaces.ts'
103
101
 
104
102
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
105
103
  * dead records; TTL semantics make 6 h granularity more than sufficient). */
@@ -108,9 +106,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
108
106
  /** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
109
107
  * day is 100×+ headroom while keeping the table bounded. */
110
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
111
115
 
112
- /** Workspace-retention sweep cadence when automatic cleanup is enabled. */
113
- 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 }
114
121
 
115
122
 
116
123
  interface RenewAdmission {
@@ -139,29 +146,6 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
139
146
  const accessGroupsRaw = domain.table('access_groups')
140
147
  const accessUsersRaw = domain.table('access_users')
141
148
  const accessRequestsRaw = domain.table('access_requests')
142
- // Pending attachment metadata is a table ADDED to the domain at the same
143
- // version (see `pendingAttachmentRecordSchema`); hosts that cannot accept the
144
- // addition leave this undefined and the registry degrades to its memory TTL
145
- // fallback without taking the whole storage domain down.
146
- let pendingAttachmentsRaw: ReturnType<RawDomainHandle['table']> | undefined
147
- try {
148
- pendingAttachmentsRaw = domain.table('pending_attachments')
149
- } catch (error) {
150
- ctx.logger?.warn?.('dsh-channels: pending attachment table unavailable: %s', String(error))
151
- }
152
- const pendingAttachments: PendingAttachmentsTable | undefined = pendingAttachmentsRaw === undefined
153
- ? undefined
154
- : {
155
- get: key => pendingAttachmentsRaw.get(key) as PendingAttachmentRecord | undefined,
156
- put: (key, value) => pendingAttachmentsRaw.put(key, value),
157
- update: (key, fn) => pendingAttachmentsRaw.update(
158
- key,
159
- current => fn(current as PendingAttachmentRecord),
160
- ),
161
- delete: key => pendingAttachmentsRaw.delete(key),
162
- entries: () =>
163
- pendingAttachmentsRaw.entries() as IterableIterator<[string, PendingAttachmentRecord]>,
164
- }
165
149
  return {
166
150
  routes: {
167
151
  get: key => table.get(key) as RouteRecord | undefined,
@@ -205,7 +189,6 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
205
189
  delete: key => accessRequestsRaw.delete(key),
206
190
  entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
207
191
  },
208
- pendingAttachments,
209
192
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
210
193
  setGlobal: value => domain.global.set(value),
211
194
  close: () => domain.close(),
@@ -239,52 +222,6 @@ function messageOf(error: unknown): string {
239
222
  return error instanceof Error ? error.message : String(error)
240
223
  }
241
224
 
242
- /**
243
- * Measure one workspace directory for the cleanup preview: total regular-file
244
- * bytes and file count. Symlinks are never followed; unreadable entries are
245
- * skipped so a permission problem cannot fail the whole pass.
246
- */
247
- async function measureWorkspace(dir: string): Promise<WorkspaceStats | undefined> {
248
- let info
249
- try {
250
- info = await lstat(dir)
251
- } catch {
252
- return undefined
253
- }
254
- if (!info.isDirectory()) return undefined
255
- let bytes = 0
256
- let files = 0
257
- const pending = [dir]
258
- while (pending.length > 0) {
259
- const current = pending.pop()!
260
- let entries
261
- try {
262
- entries = await readdir(current, { withFileTypes: true })
263
- } catch {
264
- continue
265
- }
266
- for (const entry of entries) {
267
- const child = path.join(current, entry.name)
268
- if (entry.isSymbolicLink()) continue
269
- if (entry.isDirectory()) {
270
- pending.push(child)
271
- continue
272
- }
273
- if (!entry.isFile()) continue
274
- try {
275
- const stat = await lstat(child)
276
- if (stat.isFile()) {
277
- bytes += stat.size
278
- files += 1
279
- }
280
- } catch {
281
- // The directory changed while walking it; the byte total is advisory.
282
- }
283
- }
284
- }
285
- return { mtimeMs: info.mtimeMs, bytes, files }
286
- }
287
-
288
225
  export class FeishuService {
289
226
  private readonly ctx: HostContext
290
227
  /** Live config object. Mutated in place — collaborators captured this reference. */
@@ -313,12 +250,6 @@ export class FeishuService {
313
250
  * fallback when the host does not mount `ctx.attachments`.
314
251
  */
315
252
  private readonly inboundAttachmentStore: InboundAttachmentStore
316
- /**
317
- * Silently registered attachment metadata (`pending_attachments` table):
318
- * admission-layer registration, prompt manifest reads, fetch authorization
319
- * whitelist, and the TTL/cap sweep wired into the periodic record GC.
320
- */
321
- private readonly pendingAttachments: PendingAttachmentStore
322
253
  /**
323
254
  * Read-only Feishu message access (P2): the quoted-message excerpt and the
324
255
  * first-turn topic history. Constructed before the inbound router so the
@@ -336,6 +267,7 @@ export class FeishuService {
336
267
  */
337
268
  private readonly attachmentFetchTools: AttachmentFetchToolRuntime
338
269
  private readonly topicMessagesTools: TopicMessagesToolRuntime
270
+ private readonly sendFileTools: SendFileToolRuntime
339
271
  /** FIFO admission for ordinary turns and commands targeting one route. */
340
272
  private readonly routeOperations = new RouteOperationQueue()
341
273
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -369,10 +301,6 @@ export class FeishuService {
369
301
  private readonly unlistenHost: Array<() => boolean> = []
370
302
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
371
303
  private gcTimer: ReturnType<typeof setInterval> | undefined
372
- /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
373
- private retentionTimer: ReturnType<typeof setInterval> | undefined
374
- /** Guards overlapping cleanup passes (panel button / daily timer). */
375
- private cleanupRunning = false
376
304
  /** HITL approval answerer (created in `start()`). */
377
305
  private approvalAnswerer: ApprovalAnswerer | undefined
378
306
  /** HITL structured-question answerer (`ask_user_question`). */
@@ -389,6 +317,17 @@ export class FeishuService {
389
317
  /** Administrator DM authorization cards + bot-added group info cards. */
390
318
  private accessRequests: AccessRequests | undefined
391
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
392
331
 
393
332
  constructor(
394
333
  ctx: HostContext,
@@ -430,13 +369,15 @@ export class FeishuService {
430
369
  //
431
370
  // The topic-messages tool is backend-independent: quoted excerpts and
432
371
  // topic history are read-only, so it is mounted for both backends and
433
- // self-gates on `feishuTools.topicMessages`. Both mounts start
434
- // together and resolve as one promise (no serial blocking).
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).
435
375
  const mounts: Array<Promise<void>> = []
436
376
  if (this.effectiveAttachmentBackend() === 'native') {
437
377
  mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
438
378
  }
439
379
  mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
380
+ mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
440
381
  return Promise.all(mounts).then(() => undefined)
441
382
  },
442
383
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -457,18 +398,9 @@ export class FeishuService {
457
398
  archive: this.attachments,
458
399
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
459
400
  })
460
- this.pendingAttachments = new PendingAttachmentStore({
461
- // Undefined storage domain/table → the registry's memory TTL fallback.
462
- table: () => this.domain?.pendingAttachments,
463
- enabled: () => this.config.acceptAttachments,
464
- ttlMs: () => this.config.pendingAttachmentTtlMs,
465
- maxPerScope: () => this.config.pendingAttachmentLimit,
466
- warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
467
- })
468
401
  this.attachmentFetchTools = new AttachmentFetchToolRuntime({
469
402
  config: () => this.config,
470
403
  channel: () => this.channel,
471
- pending: () => this.pendingAttachments,
472
404
  routeOfSession: sessionId => {
473
405
  const record = this.routes.findBySession(sessionId)
474
406
  if (record === undefined) return undefined
@@ -477,7 +409,7 @@ export class FeishuService {
477
409
  kind: record.kind,
478
410
  chatId: record.chatId,
479
411
  chatType: record.chatType,
480
- senderOpenId: record.senderOpenId,
412
+ senderUserId: record.senderUserId,
481
413
  }
482
414
  },
483
415
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -493,7 +425,7 @@ export class FeishuService {
493
425
  kind: record.kind,
494
426
  chatId: record.chatId,
495
427
  chatType: record.chatType,
496
- senderOpenId: record.senderOpenId,
428
+ senderUserId: record.senderUserId,
497
429
  }
498
430
  },
499
431
  noteKnownMessage: (route, message, source) => {
@@ -505,6 +437,20 @@ export class FeishuService {
505
437
  fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
506
438
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
507
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
+ })
508
454
  this.delivery = new CardDelivery({
509
455
  channel: () => this.channel,
510
456
  started: () => this.started,
@@ -516,7 +462,7 @@ export class FeishuService {
516
462
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
517
463
  })
518
464
  this.accessController = new AccessController({
519
- config: () => this.config,
465
+ config: () => this.accessConfig(),
520
466
  store: this.accessStore,
521
467
  channel: () => this.channel,
522
468
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -543,7 +489,6 @@ export class FeishuService {
543
489
  attachmentMode: () => this.config.attachmentStore,
544
490
  nativeAttachments: () => this.nativeAttachmentService(),
545
491
  attachmentStore: this.inboundAttachmentStore,
546
- pendingAttachments: this.pendingAttachments,
547
492
  noteKnownAttachments: (record, message, source) => {
548
493
  this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
549
494
  },
@@ -561,6 +506,7 @@ export class FeishuService {
561
506
  queue: this.routeOperations,
562
507
  streamer: () => this.streamer,
563
508
  questionAnswerer: () => this.questionAnswerer,
509
+ approvalAnswerer: () => this.approvalAnswerer,
564
510
  commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
565
511
  })
566
512
  }
@@ -603,8 +549,8 @@ export class FeishuService {
603
549
  * Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
604
550
  *
605
551
  * The admission and quote layers call this after their access gates for the
606
- * triggering / quoted message; the tool otherwise only authorizes messages
607
- * visible in the durable pending-attachment registry. Registering is a pure
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
608
554
  * in-memory operation (one LRU entry per route) and never fails the caller.
609
555
  */
610
556
  noteKnownAttachmentMessage(
@@ -617,7 +563,7 @@ export class FeishuService {
617
563
  kind: record.kind,
618
564
  chatId: record.chatId,
619
565
  chatType: record.chatType,
620
- senderOpenId: record.senderOpenId,
566
+ senderUserId: record.senderUserId,
621
567
  }, message, source)
622
568
  }
623
569
 
@@ -633,7 +579,7 @@ export class FeishuService {
633
579
  kind: record.kind,
634
580
  chatId: record.chatId,
635
581
  chatType: record.chatType,
636
- senderOpenId: record.senderOpenId,
582
+ senderUserId: record.senderUserId,
637
583
  }
638
584
  }
639
585
 
@@ -907,7 +853,6 @@ export class FeishuService {
907
853
  */
908
854
  applyOverlay(overlay: OverlayPatch): void {
909
855
  this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
910
- this.refreshRetentionTimer()
911
856
  }
912
857
 
913
858
  /**
@@ -956,15 +901,17 @@ export class FeishuService {
956
901
 
957
902
  /** Whitelists + pending authorization requests (read-only Web projection). */
958
903
  accessSnapshot(): AccessWire {
959
- const admin = this.config.adminOpenId
904
+ const admin = this.effectiveAdminUserId()
905
+ const owner = this.appOwner
960
906
  return {
961
907
  mode: this.config.accessMode,
962
908
  // Whitelists live in the storage domain, which is only open while the
963
909
  // channel runs; an unconnected panel must not present them as empty.
964
910
  loaded: this.domain !== undefined,
965
911
  admin: {
966
- ...(admin === undefined ? {} : { openId: admin }),
912
+ ...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
967
913
  configured: admin !== undefined,
914
+ ...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
968
915
  },
969
916
  groups: this.accessStore.groupRecords().map(record => ({
970
917
  chatId: record.chatId,
@@ -973,7 +920,7 @@ export class FeishuService {
973
920
  addedAt: record.addedAt,
974
921
  })),
975
922
  users: this.accessStore.userRecords().map(record => ({
976
- openId: record.openId,
923
+ userId: record.userId,
977
924
  ...(record.name === undefined ? {} : { name: record.name }),
978
925
  source: record.source,
979
926
  addedAt: record.addedAt,
@@ -990,7 +937,7 @@ export class FeishuService {
990
937
  if (name !== undefined) wire.name = name
991
938
  if (record.chatId !== undefined) wire.chatId = record.chatId
992
939
  if (record.groupName !== undefined) wire.groupName = record.groupName
993
- const requester = record.requesterName ?? record.requesterOpenId
940
+ const requester = record.requesterName ?? record.requesterUserId
994
941
  if (requester !== undefined) wire.requester = requester
995
942
  return wire
996
943
  }),
@@ -1001,8 +948,8 @@ export class FeishuService {
1001
948
  async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
1002
949
  const id = this.cleanAccessTargetId(chatId)
1003
950
  if (id === undefined) return { ok: false, error: 'chat_id 不合法(应为 oc_ 开头的群 ID)' }
1004
- const admin = this.config.adminOpenId
1005
- if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 open_id' }
951
+ const admin = this.effectiveAdminUserId()
952
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
1006
953
  try {
1007
954
  if (!this.accessStore.hasGroup(id)) {
1008
955
  await this.accessStore.addGroup({
@@ -1032,15 +979,15 @@ export class FeishuService {
1032
979
  }
1033
980
 
1034
981
  /** Panel: authorize one user (global across authorized groups). */
1035
- async addAccessUser(openId: string, name?: string): Promise<AccessMutationResponse> {
1036
- const id = this.cleanAccessUserId(openId)
1037
- if (id === undefined) return { ok: false, error: 'open_id 不合法(应以 ou_ 开头)' }
1038
- const admin = this.config.adminOpenId
1039
- 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' }
1040
987
  try {
1041
988
  if (!this.accessStore.hasUser(id)) {
1042
989
  await this.accessStore.addUser({
1043
- openId: id,
990
+ userId: id,
1044
991
  ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
1045
992
  source: 'panel',
1046
993
  addedBy: admin,
@@ -1054,9 +1001,9 @@ export class FeishuService {
1054
1001
  }
1055
1002
 
1056
1003
  /** Panel: revoke one user. */
1057
- async removeAccessUser(openId: string): Promise<AccessMutationResponse> {
1058
- const id = this.cleanAccessUserId(openId)
1059
- 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 不合法' }
1060
1007
  try {
1061
1008
  await this.accessStore.removeUser(id)
1062
1009
  return { ok: true, access: this.accessSnapshot() }
@@ -1069,8 +1016,8 @@ export class FeishuService {
1069
1016
  async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
1070
1017
  const requests = this.accessRequests
1071
1018
  if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
1072
- const actor = this.config.adminOpenId
1073
- if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
1019
+ const actor = this.effectiveAdminUserId()
1020
+ if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
1074
1021
  try {
1075
1022
  const result = await requests.resolve(token, decision, actor)
1076
1023
  if (!result.ok) return { ok: false, error: result.error }
@@ -1080,276 +1027,191 @@ export class FeishuService {
1080
1027
  }
1081
1028
  }
1082
1029
 
1083
- /** Trim + bound one panel-supplied identifier; rejects control characters. */
1084
- private cleanAccessTargetId(raw: string): string | undefined {
1085
- const value = raw.trim()
1086
- if (value === '' || value.length > 128) return undefined
1087
- // eslint-disable-next-line no-control-regex
1088
- if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
1089
- 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}\``
1090
1035
  }
1091
1036
 
1092
- /** Same normalization for user open_ids, which must look like ou_xxx. */
1093
- private cleanAccessUserId(raw: string): string | undefined {
1094
- const value = this.cleanAccessTargetId(raw)
1095
- if (value === undefined || !/^ou_[A-Za-z0-9_-]+$/u.test(value)) return undefined
1096
- 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
1097
1041
  }
1098
1042
 
1099
- // ---- 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
+ }
1100
1050
 
1101
- /**
1102
- * Retention preview for the Web panel: how many plugin workspaces exist,
1103
- * how many directories are past the configured window, and their measured
1104
- * footprint. Never deletes anything.
1105
- */
1106
- async cleanupPreview(): Promise<{
1107
- retention: string
1108
- thresholdMs?: number
1109
- auto: boolean
1110
- workspaces: number
1111
- candidates: number
1112
- candidateBytes: number
1113
- error?: string
1114
- }> {
1115
- const retention = this.config.workspaceRetention
1116
- const window = retentionMs(retention)
1117
- const base = {
1118
- retention,
1119
- ...(window === undefined ? {} : { thresholdMs: window }),
1120
- auto: this.config.workspaceRetentionAuto,
1121
- }
1122
- if (this.domain === undefined) {
1123
- // No connection: routes live in the closed storage domain, so a preview
1124
- // would mistake every directory for a candidate. Report why instead.
1125
- return {
1126
- ...base,
1127
- workspaces: 0,
1128
- candidates: 0,
1129
- candidateBytes: 0,
1130
- error: '飞书连接建立后即可预览和清理工作区',
1131
- }
1132
- }
1133
- try {
1134
- const workspaces = this.ctx.workspaceRegistry.list().filter(workspace => this.isPluginWorkspace(workspace))
1135
- const plan = await planWorkspaceCleanup(this.cleanupDeps())
1136
- return {
1137
- ...base,
1138
- workspaces: workspaces.length,
1139
- candidates: plan?.candidates.length ?? 0,
1140
- candidateBytes: plan?.candidates.reduce((total, candidate) => total + candidate.bytes, 0) ?? 0,
1141
- }
1142
- } catch (error) {
1143
- return { ...base, workspaces: 0, candidates: 0, candidateBytes: 0, error: messageOf(error) }
1144
- }
1051
+ /** Live config with `adminUserId` replaced by the effective administrator. */
1052
+ private accessConfig(): FeishuConfig {
1053
+ return { ...this.config, adminUserId: this.effectiveAdminUserId() }
1145
1054
  }
1146
1055
 
1147
1056
  /**
1148
- * Delete every stale plugin directory the configured retention allows: the
1149
- * per-topic working directories of current conversations and the legacy
1150
- * one-workspace-per-topic directories of older versions. User workspaces are
1151
- * never deleted.
1152
- *
1153
- * Active routes bound to a removed directory are retired first (thread
1154
- * routes leave a tombstone), so an old topic fails with the explicit expired
1155
- * card instead of resuming into a missing directory. DSH Session logs are not
1156
- * 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).
1157
1060
  */
1158
- async runWorkspaceCleanup(): Promise<{
1159
- ok: boolean
1160
- deletedWorkspaces: number
1161
- deletedSessionDirs: number
1162
- retiredRoutes: number
1163
- freedBytes: number
1164
- failures: string[]
1165
- error?: string
1166
- }> {
1167
- const empty = {
1168
- ok: false,
1169
- deletedWorkspaces: 0,
1170
- deletedSessionDirs: 0,
1171
- retiredRoutes: 0,
1172
- freedBytes: 0,
1173
- failures: [] as string[],
1174
- }
1175
- if (this.domain === undefined) {
1176
- // Without the storage domain the route table is unreadable, so a cleanup
1177
- // could mistake live directories for candidates and fail to retire their
1178
- // routes. Refuse rather than rely on the panel disabling the button.
1179
- return { ...empty, error: '飞书连接建立后才能清理工作区' }
1180
- }
1181
- if (retentionMs(this.config.workspaceRetention) === undefined) {
1182
- return { ...empty, error: '未启用目录清理阈值(workspaceRetention 为 off)' }
1183
- }
1184
- if (this.cleanupRunning) return { ...empty, error: '目录清理正在进行中,请稍候' }
1185
- 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
1186
1066
  try {
1187
- const deps = this.cleanupDeps()
1188
- const plan = await planWorkspaceCleanup(deps)
1189
- if (plan === undefined) return { ...empty, error: '目录清理不可用:baseDir 配置无效' }
1190
- const outcome = await applyWorkspaceCleanup(deps, plan)
1191
- this.ctx.logger?.info?.(
1192
- 'dsh-channels: retention cleanup removed %d legacy workspace(s) and %d topic dir(s), retired %d route(s), freed %d bytes',
1193
- outcome.deletedWorkspaces, outcome.deletedSessionDirs, outcome.retiredRoutes, outcome.freedBytes,
1194
- )
1195
- return {
1196
- ok: true,
1197
- deletedWorkspaces: outcome.deletedWorkspaces,
1198
- deletedSessionDirs: outcome.deletedSessionDirs,
1199
- retiredRoutes: outcome.retiredRoutes,
1200
- freedBytes: outcome.freedBytes,
1201
- failures: outcome.failures.map(failure => `${failure.path}: ${failure.message}`),
1202
- }
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 }
1203
1076
  } catch (error) {
1204
- 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
+ }
1205
1082
  } finally {
1206
- 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)
1207
1090
  }
1208
1091
  }
1209
1092
 
1210
- /** Whether one registry workspace was created by this plugin inside `baseDir`. */
1211
- private isPluginWorkspace(workspace: Workspace): boolean {
1212
- const pluginTitle = workspace.title.startsWith('飞书私聊 ·') || workspace.title.startsWith('飞书群聊 ·')
1213
- 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
1214
1096
  }
1215
1097
 
1216
- /** Effect seams for the cleanup policy module (all I/O stays here). */
1217
- private cleanupDeps(): CleanupDeps {
1218
- const baseDir = path.resolve(this.config.baseDir)
1219
- return {
1220
- baseDir,
1221
- retention: this.config.workspaceRetention,
1222
- listWorkspaces: () => this.ctx.workspaceRegistry.list(),
1223
- listSessionDirs: () => this.listCleanableSessionDirs(baseDir),
1224
- activeRoutes: () => [...(this.domain?.routes.entries() ?? [])].map(([key, record]): CleanupRoute => ({
1225
- key,
1226
- kind: record.kind,
1227
- workspacePath: record.workspacePath,
1228
- ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
1229
- sessionId: record.sessionId,
1230
- lastActivityAt: record.lastActivityAt,
1231
- })),
1232
- tombstones: () => [...(this.domain?.expiredRoutes.entries() ?? [])].map(([key, record]): CleanupTombstone => ({
1233
- key,
1234
- ...(record.workspacePath === undefined ? {} : { workspacePath: record.workspacePath }),
1235
- ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
1236
- expiredAt: record.expiredAt,
1237
- ...(record.lastActivityAt === undefined ? {} : { lastActivityAt: record.lastActivityAt }),
1238
- })),
1239
- isLive: sessionId => this.sessions.get(sessionId) !== undefined
1240
- || this.ctx.agents.get(sessionId as SessionId) !== undefined,
1241
- statWorkspace: directory => measureWorkspace(directory),
1242
- removeWorkspace: async workspace => {
1243
- await this.deleteDirectoryInsideBase(baseDir, workspace.path)
1244
- try {
1245
- await this.ctx.workspaceRegistry.delete(workspace.id)
1246
- } catch (error) {
1247
- this.ctx.logger?.warn?.(
1248
- 'dsh-channels: unregistering cleaned workspace %s failed: %s', workspace.path, String(error),
1249
- )
1250
- }
1251
- },
1252
- removeSessionDir: async directory => {
1253
- await this.deleteDirectoryInsideBase(baseDir, directory)
1254
- },
1255
- retireRoute: async route => {
1256
- if (route.kind === 'thread') await this.routes.retire(route.key)
1257
- else await this.routes.remove(route.key)
1258
- this.attachmentFetchTools.forgetRoute(route.key)
1259
- },
1260
- warn: message => this.ctx.logger?.warn?.(message),
1261
- }
1098
+ /** Latest native slash-command panel sync, when one finished for this connection. */
1099
+ slashCommandSync(): SlashCommandSyncWire | undefined {
1100
+ return this.slashCommandSyncState
1262
1101
  }
1263
1102
 
1264
1103
  /**
1265
- * Scan the live user workspaces for per-topic working directories that no
1266
- * route record points at anymore (for example the old route of a degraded
1267
- * chat conversation was deleted by TTL retirement). Route/tombstone evidence
1268
- * 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.
1269
1107
  */
1270
- private async listCleanableSessionDirs(baseDir: string): Promise<ScannedSessionDir[]> {
1271
- const legacyRoot = legacyTopicWorkspaceRoot(baseDir)
1272
- const roots: string[] = []
1273
- for (const workspace of this.ctx.workspaceRegistry.list()) {
1274
- if (!this.isPluginWorkspace(workspace)) continue
1275
- const resolved = path.resolve(workspace.path)
1276
- // Legacy per-topic workspaces are cleaned as workspaces, not scanned here.
1277
- if (isPathInside(legacyRoot, resolved)) continue
1278
- roots.push(resolved)
1279
- }
1280
- const scanned: ScannedSessionDir[] = []
1281
- for (const root of roots) {
1282
- for (const relative of ['topics', path.join('.agents', 'worktrees')]) {
1283
- const directory = path.join(root, relative)
1284
- let entries
1285
- try {
1286
- entries = await readdir(directory, { withFileTypes: true })
1287
- } catch {
1288
- continue
1289
- }
1290
- for (const entry of entries) {
1291
- if (!entry.isDirectory()) continue
1292
- const candidate = path.join(directory, entry.name)
1293
- try {
1294
- const info = await lstat(candidate)
1295
- if (info.isDirectory()) scanned.push({ path: candidate, mtimeMs: info.mtimeMs })
1296
- } catch {
1297
- // Removed or unreadable between readdir and lstat; skip it.
1298
- }
1299
- }
1300
- }
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)
1301
1142
  }
1302
- return scanned
1303
1143
  }
1304
1144
 
1305
1145
  /**
1306
- * Recursively delete one plugin directory below `baseDir`.
1307
- *
1308
- * The lexical check refuses paths outside `baseDir`; the realpath check then
1309
- * refuses a symlinked ancestor that would let the recursive delete escape
1310
- * 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.
1311
1149
  */
1312
- private async deleteDirectoryInsideBase(baseDir: string, target: string): Promise<void> {
1313
- const resolved = path.resolve(target)
1314
- if (!isPathInside(baseDir, resolved)) {
1315
- throw new Error(`refusing to delete a path outside baseDir (${resolved})`)
1316
- }
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
1317
1156
  try {
1318
- const [physicalBase, physicalTarget] = await Promise.all([realpath(baseDir), realpath(resolved)])
1319
- if (!isPathInside(physicalBase, physicalTarget)) {
1320
- throw new Error(`refusing to delete a symlink escape outside baseDir (${physicalTarget})`)
1321
- }
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
+ }
1322
1182
  } catch (error) {
1323
- if (error instanceof Error && error.message.startsWith('refusing to delete a symlink escape')) throw error
1324
- // 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)
1325
1198
  }
1326
- await rm(resolved, { recursive: true, force: true })
1327
1199
  }
1328
1200
 
1329
- /**
1330
- * (Re)arm the daily retention timer to match the live config. `runNow`
1331
- * performs one pass immediately; it is only used at startup so saving any
1332
- * unrelated setting can never trigger a surprise deletion pass.
1333
- */
1334
- private refreshRetentionTimer(runNow = false): void {
1335
- if (this.retentionTimer !== undefined) {
1336
- clearInterval(this.retentionTimer)
1337
- this.retentionTimer = undefined
1338
- }
1339
- if (!this.started
1340
- || !this.config.workspaceRetentionAuto
1341
- || retentionMs(this.config.workspaceRetention) === undefined) return
1342
- const sweep = (): void => {
1343
- void this.runWorkspaceCleanup().then(result => {
1344
- if (!result.ok && result.error !== '目录清理正在进行中,请稍候') {
1345
- this.ctx.logger?.warn?.('dsh-channels: automatic workspace cleanup failed: %s', result.error)
1346
- }
1347
- })
1348
- }
1349
- if (runNow) sweep()
1350
- const timer = setInterval(sweep, RETENTION_SWEEP_INTERVAL_MS)
1351
- timer.unref?.()
1352
- 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
1353
1215
  }
1354
1216
 
1355
1217
  /** Aggregate health used by `/health` and the Web settings panel. */
@@ -1481,6 +1343,9 @@ export class FeishuService {
1481
1343
  onError: (sessionId, error) => {
1482
1344
  this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
1483
1345
  },
1346
+ onWaitingChange: (sessionId, toolNames) => {
1347
+ this.streamer?.setAwaitingApproval(sessionId, toolNames)
1348
+ },
1484
1349
  }, this.config.approvalTimeoutMs)
1485
1350
  this.approvalAnswerer = answerer
1486
1351
  this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
@@ -1500,7 +1365,7 @@ export class FeishuService {
1500
1365
  { prepend: true },
1501
1366
  )
1502
1367
  const accessRequests = new AccessRequests({
1503
- config: () => this.config,
1368
+ config: () => this.accessConfig(),
1504
1369
  store: this.accessStore,
1505
1370
  channel: () => this.channel,
1506
1371
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -1518,10 +1383,15 @@ export class FeishuService {
1518
1383
  void accessRequests.onCardAction(event)
1519
1384
  answerer.onCardAction(event)
1520
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
+ })
1521
1390
  })
1522
1391
 
1523
1392
  this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
1524
1393
  this.streamer?.onSessionEvent(session, event)
1394
+ this.approvalAnswerer?.onSessionEvent(session.id, event)
1525
1395
  }))
1526
1396
  // DSH removed the durable `assistant/chunk` event (format v2 embeds the
1527
1397
  // stream in the settlement); the live deltas arrive here instead.
@@ -1537,7 +1407,15 @@ export class FeishuService {
1537
1407
  .catch(() => {})
1538
1408
  })
1539
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
1540
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)
1541
1419
  // Repair cards a previous instance left mid-flight (best-effort, background).
1542
1420
  void this.repair.repairAll().catch((error: unknown) => {
1543
1421
  this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
@@ -1554,8 +1432,6 @@ export class FeishuService {
1554
1432
  }, GC_SWEEP_INTERVAL_MS)
1555
1433
  timer.unref?.()
1556
1434
  this.gcTimer = timer
1557
- // Workspace retention: opt-in automatic cleanup (default off).
1558
- this.refreshRetentionTimer(true)
1559
1435
  this.ctx.logger?.info?.(
1560
1436
  'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
1561
1437
  channel.botOpenId() ?? 'unknown',
@@ -1571,11 +1447,15 @@ export class FeishuService {
1571
1447
  })
1572
1448
  if (this.config.accessMode === 'open') {
1573
1449
  console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
1574
- } 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
1575
1452
  console.warn(
1576
- '[dsh-channels] 准入=guarded 但未配置管理员 open_id:所有对话都会被拒绝;'
1577
- + '私聊机器人发送 /userid 取得 open_id 后填入 Web 设置面板',
1453
+ '[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
1454
+ + '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
1455
+ + (ownerError === undefined ? '' : `(${ownerError})`),
1578
1456
  )
1457
+ } else if (this.configuredAdminUserId() === undefined) {
1458
+ console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
1579
1459
  } else {
1580
1460
  console.info('[dsh-channels] 准入=guarded,管理员已配置')
1581
1461
  }
@@ -1629,10 +1509,6 @@ export class FeishuService {
1629
1509
  clearInterval(this.gcTimer)
1630
1510
  this.gcTimer = undefined
1631
1511
  }
1632
- if (this.retentionTimer !== undefined) {
1633
- clearInterval(this.retentionTimer)
1634
- this.retentionTimer = undefined
1635
- }
1636
1512
  await this.sessions.disposeAll()
1637
1513
  this.attachmentFetchTools.clear()
1638
1514
  this.routeOperations.clear()
@@ -1645,10 +1521,16 @@ export class FeishuService {
1645
1521
  // Already disconnected is fine.
1646
1522
  }
1647
1523
  this.channel = undefined
1524
+ this.streamer?.detachAll()
1648
1525
  this.streamer = undefined
1649
1526
  await this.domain?.close()
1650
1527
  this.domain = undefined
1651
1528
  this.runningConfig = undefined
1529
+ this.appOwner = undefined
1530
+ this.scopeCheckState = undefined
1531
+ this.slashCommandSyncState = undefined
1532
+ this.slashCommandSyncAbort?.abort()
1533
+ this.slashCommandSyncAbort = undefined
1652
1534
  }
1653
1535
 
1654
1536
  // ---- inbound routing -------------------------------------------------
@@ -1722,7 +1604,7 @@ export class FeishuService {
1722
1604
  return
1723
1605
  }
1724
1606
 
1725
- if (msg.senderId !== route.senderOpenId) {
1607
+ if (msg.senderId !== route.senderUserId) {
1726
1608
  await this.delivery.commandCard(msg, {
1727
1609
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1728
1610
  })
@@ -1732,7 +1614,7 @@ export class FeishuService {
1732
1614
  const operation = this.routeOperations.run(route.key, async () => {
1733
1615
  if (!this.started) return
1734
1616
  const current = this.routes.get(route.key) ?? route
1735
- if (msg.senderId !== current.senderOpenId) {
1617
+ if (msg.senderId !== current.senderUserId) {
1736
1618
  await this.delivery.commandCard(msg, {
1737
1619
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1738
1620
  })
@@ -1765,12 +1647,12 @@ export class FeishuService {
1765
1647
  const pending = this.accessStore.pendingRequests()
1766
1648
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
1767
1649
  `**准入模式**:\`${this.config.accessMode}\``,
1768
- `**管理员**:${this.config.adminOpenId === undefined ? '未配置' : `\`${this.config.adminOpenId}\``}`,
1650
+ `**管理员**:${this.adminStatusLine()}`,
1769
1651
  ...(msg.chatType === 'group'
1770
1652
  ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
1771
1653
  : []),
1772
1654
  `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
1773
- '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <open_id>`。',
1655
+ '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`。',
1774
1656
  ]), direct)
1775
1657
  return
1776
1658
  }
@@ -1817,35 +1699,39 @@ export class FeishuService {
1817
1699
  }
1818
1700
  if (action === 'user') {
1819
1701
  const sub = words[1]
1820
- const openId = words[2]
1821
- if ((sub !== 'add' && sub !== 'remove') || openId === undefined
1822
- || !/^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) {
1823
1706
  await this.delivery.commandCard(msg, {
1824
- kind: 'input-error', command: 'auth',
1825
- 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],
1826
1708
  }, direct)
1827
1709
  return
1828
1710
  }
1711
+ const { userId } = target
1712
+ const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
1829
1713
  if (sub === 'add') {
1830
- if (this.accessStore.hasUser(openId)) {
1714
+ if (this.accessStore.hasUser(userId)) {
1831
1715
  await this.delivery.commandCard(msg, {
1832
- kind: 'success', command: 'auth', text: `该用户已在白名单中(\`${openId}\`)。`,
1716
+ kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
1833
1717
  }, direct)
1834
1718
  return
1835
1719
  }
1836
1720
  await this.accessStore.addUser({
1837
- 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,
1838
1724
  })
1839
1725
  await this.delivery.commandCard(msg, {
1840
1726
  kind: 'success', command: 'auth',
1841
- text: `✅ 已授权用户 \`${openId}\`(在所有已授权群里生效)。`,
1727
+ text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
1842
1728
  }, direct)
1843
1729
  return
1844
1730
  }
1845
- const removed = await this.accessStore.removeUser(openId)
1731
+ const removed = await this.accessStore.removeUser(userId)
1846
1732
  await this.delivery.commandCard(msg, {
1847
1733
  kind: removed ? 'success' : 'unavailable', command: 'auth',
1848
- text: removed ? `已移除用户 \`${openId}\`,立即生效。` : `用户 \`${openId}\` 不在白名单中。`,
1734
+ text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
1849
1735
  }, direct)
1850
1736
  return
1851
1737
  }
@@ -1858,7 +1744,7 @@ export class FeishuService {
1858
1744
  : groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
1859
1745
  const userLines = users.length === 0
1860
1746
  ? ['(暂无)']
1861
- : 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}\``)
1862
1748
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
1863
1749
  `**已授权群(${groups.length})**`,
1864
1750
  ...groupLines,
@@ -1870,7 +1756,7 @@ export class FeishuService {
1870
1756
  }
1871
1757
  await this.delivery.commandCard(msg, {
1872
1758
  kind: 'input-error', command: 'auth',
1873
- 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>]',
1874
1760
  }, direct)
1875
1761
  } catch (error) {
1876
1762
  this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
@@ -1979,10 +1865,7 @@ export class FeishuService {
1979
1865
  return
1980
1866
  }
1981
1867
  if (spec.name === 'stop') {
1982
- const agent = this.sessions.get(current.sessionId)?.agent
1983
- ?? this.ctx.agents.get(current.sessionId as SessionId)
1984
- const running = agent?.status === 'running'
1985
- agent?.cancel?.({ kind: 'user' })
1868
+ const running = this.cancelTurn(current.sessionId)
1986
1869
  await this.delivery.commandCard(msg, {
1987
1870
  kind: 'success', command: 'stop', sessionId: current.sessionId,
1988
1871
  text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
@@ -1995,6 +1878,72 @@ export class FeishuService {
1995
1878
  })
1996
1879
  }
1997
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
+
1998
1947
  /**
1999
1948
  * `/permissions`: show this Session's effective sandbox+approval preset and
2000
1949
  * the selectable catalog; switch only when the deployment opted in.
@@ -2015,14 +1964,27 @@ export class FeishuService {
2015
1964
  const live = await this.sessions.ensureLive(current)
2016
1965
  let active: string | undefined
2017
1966
  let catalogLine = ''
1967
+ let choices: PickerOption[] = []
2018
1968
  try {
2019
1969
  active = presets.current(live.agent.session)
2020
1970
  const catalog = presets.catalog()
2021
1971
  catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
1972
+ choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
2022
1973
  } catch {
2023
1974
  active = undefined
2024
1975
  }
2025
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
+ }
2026
1988
  if (requested === '') {
2027
1989
  const lines = [
2028
1990
  `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
@@ -2149,6 +2111,7 @@ export class FeishuService {
2149
2111
  }
2150
2112
  await this.renderCommandResult({
2151
2113
  spec, msg, current, live,
2114
+ bare: parsed.rawInput.trim() === '',
2152
2115
  result: execution.result,
2153
2116
  compactBefore,
2154
2117
  renewAdmission,
@@ -2163,6 +2126,8 @@ export class FeishuService {
2163
2126
  msg: NormalizedMessage
2164
2127
  current: RouteRecord
2165
2128
  live: LiveAgent
2129
+ /** The command was typed without arguments. */
2130
+ bare: boolean
2166
2131
  result: CommandResult
2167
2132
  compactBefore: ContextMeasurement | undefined
2168
2133
  renewAdmission: RenewAdmission | undefined
@@ -2172,6 +2137,17 @@ export class FeishuService {
2172
2137
  const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
2173
2138
  if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
2174
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
+ }
2175
2151
  if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
2176
2152
  await this.delivery.standaloneReply(msg, buildRenewCard({
2177
2153
  previousSessionId: renewAdmission.commit.previous.sessionId,
@@ -2395,18 +2371,13 @@ export class FeishuService {
2395
2371
 
2396
2372
 
2397
2373
  /**
2398
- * GC pass over the routing domain. Three independent parts:
2374
+ * GC pass over the routing domain. Two independent parts:
2399
2375
  *
2400
2376
  * 1. Dedup records (unconditional): `processed` records and in-process
2401
2377
  * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
2402
2378
  * redelivery windows are minutes, so a day of retention is ample and
2403
2379
  * keeps the table bounded regardless of the route TTL setting.
2404
- * 2. Pending attachment metadata (unconditional): rows past
2405
- * `pendingAttachmentTtlMs` and rows above `pendingAttachmentLimit` per
2406
- * `(chat, thread?, sender)` scope are removed. Independent of the route
2407
- * TTL: the registry keeps its own 7-day default even when route retirement
2408
- * is disabled.
2409
- * 3. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2380
+ * 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2410
2381
  * are retired into minimal persistent tombstones before their active
2411
2382
  * records are deleted. A later message in that visible topic is then
2412
2383
  * rejected instead of silently creating a context-free Session. Degraded
@@ -2439,18 +2410,6 @@ export class FeishuService {
2439
2410
  }
2440
2411
  this.inbound.pruneAccepted(processedCutoff)
2441
2412
 
2442
- try {
2443
- const pruned = await this.pendingAttachments.prune()
2444
- if (pruned.removedExpired > 0 || pruned.removedOverLimit > 0) {
2445
- this.ctx.logger?.info?.(
2446
- 'dsh-channels: record GC removed %d expired and %d over-limit pending attachment record(s)',
2447
- pruned.removedExpired, pruned.removedOverLimit,
2448
- )
2449
- }
2450
- } catch (error) {
2451
- this.ctx.logger?.warn?.('dsh-channels: pending attachment GC failed: %s', String(error))
2452
- }
2453
-
2454
2413
  const ttlMs = this.config.routeTtlMs
2455
2414
  if (ttlMs === 0) {
2456
2415
  if (removedProcessed > 0) {
@@ -2526,7 +2485,7 @@ export class FeishuService {
2526
2485
  chatId: record.chatId,
2527
2486
  cardMessageId: record.cardMessageId,
2528
2487
  inThread: record.kind === 'thread',
2529
- senderOpenId: record.senderOpenId,
2488
+ senderUserId: record.senderUserId,
2530
2489
  }
2531
2490
  }
2532
2491
 
@@ -2546,22 +2505,10 @@ export class FeishuService {
2546
2505
  if (admission.live.agent !== invocation.agent) {
2547
2506
  return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
2548
2507
  }
2549
- const session = invocation.agent.session
2550
- const state = admission.controller.state
2551
- const header = session.requestHeader?.()?.config
2552
- const currentProvider = state.effectiveModel?.provider
2553
- ?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined)
2554
- const currentModel = state.effectiveModel?.model
2555
- ?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined)
2556
- const source = state.modelSource === 'session'
2557
- ? 'Session 覆盖'
2558
- : state.modelSource === 'config' ? '配置默认' : '部署默认'
2508
+ const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
2559
2509
 
2560
2510
  if (rawInput === '') {
2561
- const lines = [
2562
- `**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
2563
- + `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
2564
- ]
2511
+ const lines = [currentModelLine(admission.live, admission.controller)]
2565
2512
  const models = await this.listModels(currentProvider)
2566
2513
  if (models.length > 0) {
2567
2514
  lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
@@ -2598,6 +2545,59 @@ export class FeishuService {
2598
2545
  return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
2599
2546
  }
2600
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
+
2601
2601
  /** Model ids advertised for one provider route (empty when unavailable). */
2602
2602
  private async listModels(provider: string | undefined): Promise<string[]> {
2603
2603
  if (provider === undefined) return []
@@ -2738,3 +2738,28 @@ export class FeishuService {
2738
2738
  }
2739
2739
  }
2740
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
+ }