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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +83 -57
  2. package/docs/access-control-design.md +8 -5
  3. package/docs/attachment-pipeline-v2.md +33 -41
  4. package/docs/feishu-onboarding.md +36 -33
  5. package/docs/persistence.md +6 -4
  6. package/docs/refactor-plan.md +1 -1
  7. package/docs/workspace-isolation-design.md +7 -8
  8. package/lib/client.js +444 -288
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +9017 -6794
  11. package/lib/index.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/access/cards.ts +9 -9
  14. package/src/access/controller.ts +10 -10
  15. package/src/access/index.ts +1 -0
  16. package/src/access/policy.ts +10 -10
  17. package/src/access/requests.ts +32 -31
  18. package/src/access/store.ts +43 -13
  19. package/src/access/targets.ts +56 -0
  20. package/src/cards/approval.ts +96 -26
  21. package/src/cards/index.ts +1 -0
  22. package/src/cards/onboarding.ts +1 -0
  23. package/src/cards/picker.ts +108 -0
  24. package/src/cards/streaming.ts +56 -7
  25. package/src/cards/wiki.ts +124 -0
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +96 -24
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/string-list.ts +139 -0
  34. package/src/client/styles.ts +5 -0
  35. package/src/commands/registry.ts +8 -5
  36. package/src/config.ts +55 -54
  37. package/src/domain.ts +36 -123
  38. package/src/feishu/delivery.ts +34 -1
  39. package/src/feishu/inbound-attachments.ts +1 -1
  40. package/src/feishu/manifest.ts +8 -31
  41. package/src/feishu/message-read.ts +97 -24
  42. package/src/feishu/scopes.ts +194 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +72 -112
  46. package/src/feishu/wiki/api.ts +346 -0
  47. package/src/feishu/wiki/consent.ts +183 -0
  48. package/src/feishu/wiki/links.ts +122 -0
  49. package/src/feishu/wiki/scope.ts +66 -0
  50. package/src/feishu/wiki/tools.ts +984 -0
  51. package/src/feishu-channel.ts +280 -9
  52. package/src/index.ts +5 -4
  53. package/src/interactions/approvals.ts +187 -7
  54. package/src/interactions/pending.ts +3 -3
  55. package/src/routing/inbound.ts +73 -106
  56. package/src/service.ts +545 -440
  57. package/src/session/manager.ts +1 -1
  58. package/src/session/routes.ts +2 -3
  59. package/src/settings/contract.ts +110 -89
  60. package/src/settings/routes.ts +3 -17
  61. package/src/settings/surface.ts +144 -94
  62. package/src/streamer.ts +94 -5
  63. package/src/workspaces.ts +21 -29
  64. package/src/cleanup.ts +0 -546
  65. package/src/client/panel/cleanup-card.tsx +0 -106
  66. package/src/feishu/pending-attachments.ts +0 -562
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,10 @@ 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'
67
+ import { WikiConsent } from './feishu/wiki/consent.ts'
68
+ import { WikiToolRuntime } from './feishu/wiki/tools.ts'
71
69
  import type { KnownAttachmentMessageInput, KnownAttachmentSource } from './feishu/tools.ts'
72
70
  import {
73
71
  MessageReader, TOPIC_MESSAGES_MAX_LIMIT, buildHistoryContextBlock,
@@ -78,7 +76,8 @@ import type { MessageReadRoute, RawMessageItem } from './feishu/message-read.ts'
78
76
  import type { AttachmentStore } from './host/attachments.ts'
79
77
  import { InboundRouter } from './routing/inbound.ts'
80
78
  import type { RequestPolicyController } from './session/request-policy.ts'
81
- import type { ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
79
+ import type { CardActionEvent, ChannelFactory, FeishuChannel, NormalizedMessage } from './feishu-channel.ts'
80
+ import { AppOwnerLookupError, FeishuApiError } from './feishu-channel.ts'
82
81
  import { buildHealthCard, foldHealthSnapshot, HEALTH_DEPENDENCY_NAMES } from './health.ts'
83
82
  import type { HealthDependencyName, HealthSnapshot } from './health.ts'
84
83
  import {
@@ -95,11 +94,12 @@ import { ACTIVITY_BUILD_MARKER, TurnStreamer } from './streamer.ts'
95
94
  import type { ProjectedStats, StreamerHooks } from './streamer.ts'
96
95
  import type { FeishuConfig } from './config.ts'
97
96
  import type {
98
- AccessMutationResponse, AccessRequestWire, AccessWire,
97
+ AccessMutationResponse, AccessRequestWire, AccessWire, ScopeCheckWire, SlashCommandSyncWire,
99
98
  } from './settings/contract.ts'
99
+ import { checkScopes } from './feishu/scopes.ts'
100
+ import { desiredSlashCommands, syncSlashCommands } from './feishu/slash-commands.ts'
100
101
  import { mergeOverlay } from './settings/overlay.ts'
101
102
  import type { OverlayPatch, OverlayStore } from './settings/overlay.ts'
102
- import { legacyTopicWorkspaceRoot } from './workspaces.ts'
103
103
 
104
104
  /** Periodic route-GC sweep cadence (a long-running instance also accumulates
105
105
  * dead records; TTL semantics make 6 h granularity more than sufficient). */
@@ -108,9 +108,18 @@ const GC_SWEEP_INTERVAL_MS = 6 * 60 * 60 * 1000
108
108
  /** Dedup-record retention: Feishu redelivery windows are minutes-scale, so a
109
109
  * day is 100×+ headroom while keeping the table bounded. */
110
110
  const PROCESSED_RETENTION_MS = 24 * 60 * 60 * 1000
111
+ /** Upper bound on the boot-time app-owner lookup before the connection opens. */
112
+ const APP_OWNER_LOOKUP_TIMEOUT_MS = 10_000
113
+ /** Upper bound on the post-connect scope grant query. */
114
+ const SCOPE_CHECK_TIMEOUT_MS = 10_000
115
+ /** Upper bound on the whole post-connect slash-command panel sync (one list plus per-command writes). */
116
+ const SLASH_COMMAND_SYNC_TIMEOUT_MS = 30_000
111
117
 
112
- /** Workspace-retention sweep cadence when automatic cleanup is enabled. */
113
- const RETENTION_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000
118
+ /** Outcome of the app-owner lookup for the current connection. */
119
+ type AppOwnerState =
120
+ | { readonly userId: string }
121
+ /** `apiRejected`: Feishu answered with an error code (typically a missing app-info scope). */
122
+ | { readonly error: string; readonly apiRejected: boolean }
114
123
 
115
124
 
116
125
  interface RenewAdmission {
@@ -139,29 +148,7 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
139
148
  const accessGroupsRaw = domain.table('access_groups')
140
149
  const accessUsersRaw = domain.table('access_users')
141
150
  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
- }
151
+ const accessWikiUsersRaw = domain.table('access_wiki_users')
165
152
  return {
166
153
  routes: {
167
154
  get: key => table.get(key) as RouteRecord | undefined,
@@ -205,13 +192,21 @@ async function openFeishuDomain(ctx: HostContext): Promise<FeishuDomainHandle> {
205
192
  delete: key => accessRequestsRaw.delete(key),
206
193
  entries: () => accessRequestsRaw.entries() as IterableIterator<[string, AccessRequestRecord]>,
207
194
  },
208
- pendingAttachments,
195
+ accessWikiUsers: {
196
+ get: key => accessWikiUsersRaw.get(key) as AccessUserRecord | undefined,
197
+ put: (key, value) => accessWikiUsersRaw.put(key, value),
198
+ delete: key => accessWikiUsersRaw.delete(key),
199
+ entries: () => accessWikiUsersRaw.entries() as IterableIterator<[string, AccessUserRecord]>,
200
+ },
209
201
  getGlobal: () => (domain.global.get() ?? {}) as FeishuDomainGlobal,
210
202
  setGlobal: value => domain.global.set(value),
211
203
  close: () => domain.close(),
212
204
  }
213
205
  }
214
206
 
207
+ /** `/auth wiki` usage line (input-error card). */
208
+ const AUTH_WIKI_USAGE = '用法:/auth wiki add <@用户|user_id> 或 /auth wiki remove <@用户|user_id>。知识库白名单里的用户(以及管理员)发起的话题可以使用知识库工具。'
209
+
215
210
  /**
216
211
  * Resolve one credential reference through the host credentials seam.
217
212
  *
@@ -239,52 +234,6 @@ function messageOf(error: unknown): string {
239
234
  return error instanceof Error ? error.message : String(error)
240
235
  }
241
236
 
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
237
  export class FeishuService {
289
238
  private readonly ctx: HostContext
290
239
  /** Live config object. Mutated in place — collaborators captured this reference. */
@@ -313,12 +262,6 @@ export class FeishuService {
313
262
  * fallback when the host does not mount `ctx.attachments`.
314
263
  */
315
264
  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
265
  /**
323
266
  * Read-only Feishu message access (P2): the quoted-message excerpt and the
324
267
  * first-turn topic history. Constructed before the inbound router so the
@@ -336,6 +279,9 @@ export class FeishuService {
336
279
  */
337
280
  private readonly attachmentFetchTools: AttachmentFetchToolRuntime
338
281
  private readonly topicMessagesTools: TopicMessagesToolRuntime
282
+ private readonly sendFileTools: SendFileToolRuntime
283
+ private readonly wikiConsent: WikiConsent
284
+ private readonly wikiTools: WikiToolRuntime
339
285
  /** FIFO admission for ordinary turns and commands targeting one route. */
340
286
  private readonly routeOperations = new RouteOperationQueue()
341
287
  /** Active Host command executions, aborted before shutdown tears peers down. */
@@ -369,10 +315,6 @@ export class FeishuService {
369
315
  private readonly unlistenHost: Array<() => boolean> = []
370
316
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
371
317
  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
318
  /** HITL approval answerer (created in `start()`). */
377
319
  private approvalAnswerer: ApprovalAnswerer | undefined
378
320
  /** HITL structured-question answerer (`ask_user_question`). */
@@ -389,6 +331,17 @@ export class FeishuService {
389
331
  /** Administrator DM authorization cards + bot-added group info cards. */
390
332
  private accessRequests: AccessRequests | undefined
391
333
  private unlistenBotAdded: (() => void) | undefined
334
+ /**
335
+ * App owner resolved at `start()`; the administrator whenever `adminUserId`
336
+ * is unset. Cleared on shutdown so a reconnect looks it up again.
337
+ */
338
+ private appOwner: AppOwnerState | undefined
339
+ /** Post-connect scope self-check; cleared on shutdown. */
340
+ private scopeCheckState: ScopeCheckWire | undefined
341
+ /** Post-connect slash-command panel sync; cleared on shutdown. */
342
+ private slashCommandSyncState: SlashCommandSyncWire | undefined
343
+ /** Aborts the running panel sync's remaining writes (timeout, shutdown, reconnect). */
344
+ private slashCommandSyncAbort: AbortController | undefined
392
345
 
393
346
  constructor(
394
347
  ctx: HostContext,
@@ -430,13 +383,16 @@ export class FeishuService {
430
383
  //
431
384
  // The topic-messages tool is backend-independent: quoted excerpts and
432
385
  // 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).
386
+ // self-gates on `feishuTools.topicMessages`; `feishu_send_file`
387
+ // self-gates on `feishuTools.sendFile`. The mounts start together and
388
+ // resolve as one promise (no serial blocking).
435
389
  const mounts: Array<Promise<void>> = []
436
390
  if (this.effectiveAttachmentBackend() === 'native') {
437
391
  mounts.push(this.attachmentFetchTools.mount(agentCtx, sessionId))
438
392
  }
439
393
  mounts.push(this.topicMessagesTools.mount(agentCtx, sessionId))
394
+ mounts.push(this.sendFileTools.mount(agentCtx, sessionId))
395
+ mounts.push(this.wikiTools.mount(agentCtx, sessionId))
440
396
  return Promise.all(mounts).then(() => undefined)
441
397
  },
442
398
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -457,18 +413,9 @@ export class FeishuService {
457
413
  archive: this.attachments,
458
414
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
459
415
  })
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
416
  this.attachmentFetchTools = new AttachmentFetchToolRuntime({
469
417
  config: () => this.config,
470
418
  channel: () => this.channel,
471
- pending: () => this.pendingAttachments,
472
419
  routeOfSession: sessionId => {
473
420
  const record = this.routes.findBySession(sessionId)
474
421
  if (record === undefined) return undefined
@@ -477,7 +424,7 @@ export class FeishuService {
477
424
  kind: record.kind,
478
425
  chatId: record.chatId,
479
426
  chatType: record.chatType,
480
- senderOpenId: record.senderOpenId,
427
+ senderUserId: record.senderUserId,
481
428
  }
482
429
  },
483
430
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -493,7 +440,7 @@ export class FeishuService {
493
440
  kind: record.kind,
494
441
  chatId: record.chatId,
495
442
  chatType: record.chatType,
496
- senderOpenId: record.senderOpenId,
443
+ senderUserId: record.senderUserId,
497
444
  }
498
445
  },
499
446
  noteKnownMessage: (route, message, source) => {
@@ -505,6 +452,35 @@ export class FeishuService {
505
452
  fetchAvailable: () => this.effectiveAttachmentBackend() === 'native',
506
453
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
507
454
  })
455
+ this.wikiConsent = new WikiConsent({
456
+ channel: () => (this.started ? this.channel : undefined),
457
+ timeoutMs: () => this.config.approvalTimeoutMs,
458
+ onError: (sessionId, error) => {
459
+ this.ctx.logger?.warn?.('dsh-channels wiki consent error (session %s): %s', sessionId, String(error))
460
+ },
461
+ })
462
+ this.wikiTools = new WikiToolRuntime({
463
+ config: () => this.config,
464
+ channel: () => (this.started ? this.channel : undefined),
465
+ routeOfSession: sessionId => this.approvalRouteFor(sessionId),
466
+ mayUseWiki: userId => this.accessController.isAdmin(userId) || this.accessStore.hasWikiUser(userId),
467
+ consent: this.wikiConsent,
468
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
469
+ })
470
+ this.sendFileTools = new SendFileToolRuntime({
471
+ config: () => this.config,
472
+ channel: () => (this.started ? this.channel : undefined),
473
+ targetOfSession: sessionId => {
474
+ const record = this.routes.findBySession(sessionId)
475
+ if (record === undefined) return undefined
476
+ return {
477
+ replyTo: record.cardMessageId,
478
+ inThread: record.kind === 'thread',
479
+ rootDir: sessionDirOf(record),
480
+ }
481
+ },
482
+ warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
483
+ })
508
484
  this.delivery = new CardDelivery({
509
485
  channel: () => this.channel,
510
486
  started: () => this.started,
@@ -516,7 +492,7 @@ export class FeishuService {
516
492
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
517
493
  })
518
494
  this.accessController = new AccessController({
519
- config: () => this.config,
495
+ config: () => this.accessConfig(),
520
496
  store: this.accessStore,
521
497
  channel: () => this.channel,
522
498
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -543,7 +519,6 @@ export class FeishuService {
543
519
  attachmentMode: () => this.config.attachmentStore,
544
520
  nativeAttachments: () => this.nativeAttachmentService(),
545
521
  attachmentStore: this.inboundAttachmentStore,
546
- pendingAttachments: this.pendingAttachments,
547
522
  noteKnownAttachments: (record, message, source) => {
548
523
  this.noteKnownAttachmentMessage(record, message, source ?? 'trigger')
549
524
  },
@@ -561,6 +536,7 @@ export class FeishuService {
561
536
  queue: this.routeOperations,
562
537
  streamer: () => this.streamer,
563
538
  questionAnswerer: () => this.questionAnswerer,
539
+ approvalAnswerer: () => this.approvalAnswerer,
564
540
  commands: { handleSlash: (msg, line) => this.handleSlashMessage(msg, line) },
565
541
  })
566
542
  }
@@ -603,8 +579,8 @@ export class FeishuService {
603
579
  * Pre-authorize one admitted Feishu message for `feishu_attachment_fetch`.
604
580
  *
605
581
  * 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
582
+ * triggering / quoted message; the tool otherwise only authorizes the route
583
+ * owner's own messages it reads back from Feishu. Registering is a pure
608
584
  * in-memory operation (one LRU entry per route) and never fails the caller.
609
585
  */
610
586
  noteKnownAttachmentMessage(
@@ -617,7 +593,7 @@ export class FeishuService {
617
593
  kind: record.kind,
618
594
  chatId: record.chatId,
619
595
  chatType: record.chatType,
620
- senderOpenId: record.senderOpenId,
596
+ senderUserId: record.senderUserId,
621
597
  }, message, source)
622
598
  }
623
599
 
@@ -633,7 +609,7 @@ export class FeishuService {
633
609
  kind: record.kind,
634
610
  chatId: record.chatId,
635
611
  chatType: record.chatType,
636
- senderOpenId: record.senderOpenId,
612
+ senderUserId: record.senderUserId,
637
613
  }
638
614
  }
639
615
 
@@ -907,7 +883,6 @@ export class FeishuService {
907
883
  */
908
884
  applyOverlay(overlay: OverlayPatch): void {
909
885
  this.replaceConfig(mergeOverlay(this.baseConfig, overlay))
910
- this.refreshRetentionTimer()
911
886
  }
912
887
 
913
888
  /**
@@ -956,15 +931,17 @@ export class FeishuService {
956
931
 
957
932
  /** Whitelists + pending authorization requests (read-only Web projection). */
958
933
  accessSnapshot(): AccessWire {
959
- const admin = this.config.adminOpenId
934
+ const admin = this.effectiveAdminUserId()
935
+ const owner = this.appOwner
960
936
  return {
961
937
  mode: this.config.accessMode,
962
938
  // Whitelists live in the storage domain, which is only open while the
963
939
  // channel runs; an unconnected panel must not present them as empty.
964
940
  loaded: this.domain !== undefined,
965
941
  admin: {
966
- ...(admin === undefined ? {} : { openId: admin }),
942
+ ...(admin === undefined ? {} : { userId: admin, source: this.configuredAdminUserId() === undefined ? 'owner' : 'config' }),
967
943
  configured: admin !== undefined,
944
+ ...(owner !== undefined && 'error' in owner ? { ownerLookupError: owner.error } : {}),
968
945
  },
969
946
  groups: this.accessStore.groupRecords().map(record => ({
970
947
  chatId: record.chatId,
@@ -973,7 +950,7 @@ export class FeishuService {
973
950
  addedAt: record.addedAt,
974
951
  })),
975
952
  users: this.accessStore.userRecords().map(record => ({
976
- openId: record.openId,
953
+ userId: record.userId,
977
954
  ...(record.name === undefined ? {} : { name: record.name }),
978
955
  source: record.source,
979
956
  addedAt: record.addedAt,
@@ -990,7 +967,7 @@ export class FeishuService {
990
967
  if (name !== undefined) wire.name = name
991
968
  if (record.chatId !== undefined) wire.chatId = record.chatId
992
969
  if (record.groupName !== undefined) wire.groupName = record.groupName
993
- const requester = record.requesterName ?? record.requesterOpenId
970
+ const requester = record.requesterName ?? record.requesterUserId
994
971
  if (requester !== undefined) wire.requester = requester
995
972
  return wire
996
973
  }),
@@ -1001,8 +978,8 @@ export class FeishuService {
1001
978
  async addAccessGroup(chatId: string, name?: string): Promise<AccessMutationResponse> {
1002
979
  const id = this.cleanAccessTargetId(chatId)
1003
980
  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' }
981
+ const admin = this.effectiveAdminUserId()
982
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
1006
983
  try {
1007
984
  if (!this.accessStore.hasGroup(id)) {
1008
985
  await this.accessStore.addGroup({
@@ -1032,15 +1009,15 @@ export class FeishuService {
1032
1009
  }
1033
1010
 
1034
1011
  /** 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' }
1012
+ async addAccessUser(userId: string, name?: string): Promise<AccessMutationResponse> {
1013
+ const id = this.cleanAccessUserId(userId)
1014
+ if (id === undefined) return { ok: false, error: 'user_id 不合法(只能包含字母、数字、_ 和 -,不是 ou_ 开头的 open_id)' }
1015
+ const admin = this.effectiveAdminUserId()
1016
+ if (admin === undefined) return { ok: false, error: '请先在「访问授权」标签页设置管理员 user_id' }
1040
1017
  try {
1041
1018
  if (!this.accessStore.hasUser(id)) {
1042
1019
  await this.accessStore.addUser({
1043
- openId: id,
1020
+ userId: id,
1044
1021
  ...(name === undefined || name.trim() === '' ? {} : { name: name.trim() }),
1045
1022
  source: 'panel',
1046
1023
  addedBy: admin,
@@ -1054,9 +1031,9 @@ export class FeishuService {
1054
1031
  }
1055
1032
 
1056
1033
  /** 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 不合法' }
1034
+ async removeAccessUser(userId: string): Promise<AccessMutationResponse> {
1035
+ const id = this.cleanAccessUserId(userId)
1036
+ if (id === undefined) return { ok: false, error: 'user_id 不合法' }
1060
1037
  try {
1061
1038
  await this.accessStore.removeUser(id)
1062
1039
  return { ok: true, access: this.accessSnapshot() }
@@ -1069,8 +1046,8 @@ export class FeishuService {
1069
1046
  async resolveAccessRequest(token: string, decision: 'allow' | 'deny'): Promise<AccessMutationResponse> {
1070
1047
  const requests = this.accessRequests
1071
1048
  if (requests === undefined) return { ok: false, error: '授权服务尚未启动' }
1072
- const actor = this.config.adminOpenId
1073
- if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 open_id' }
1049
+ const actor = this.effectiveAdminUserId()
1050
+ if (actor === undefined) return { ok: false, error: '请先在「访问授权」标签页配置管理员 user_id' }
1074
1051
  try {
1075
1052
  const result = await requests.resolve(token, decision, actor)
1076
1053
  if (!result.ok) return { ok: false, error: result.error }
@@ -1080,276 +1057,193 @@ export class FeishuService {
1080
1057
  }
1081
1058
  }
1082
1059
 
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
1060
+ /** `/auth status` administrator line: id plus where it came from. */
1061
+ private adminStatusLine(): string {
1062
+ const admin = this.effectiveAdminUserId()
1063
+ if (admin === undefined) return '未配置'
1064
+ return this.configuredAdminUserId() === undefined ? `\`${admin}\`(应用所有者,自动)` : `\`${admin}\``
1090
1065
  }
1091
1066
 
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
1067
+ /** The explicitly configured administrator, trimmed; empty counts as unset. */
1068
+ private configuredAdminUserId(): string | undefined {
1069
+ const admin = this.config.adminUserId?.trim()
1070
+ return admin === undefined || admin === '' ? undefined : admin
1097
1071
  }
1098
1072
 
1099
- // ---- retention cleanup ------------------------------------------------
1073
+ /** Configured administrator, else the app owner looked up at `start()`. */
1074
+ private effectiveAdminUserId(): string | undefined {
1075
+ const configured = this.configuredAdminUserId()
1076
+ if (configured !== undefined) return configured
1077
+ const owner = this.appOwner
1078
+ return owner !== undefined && 'userId' in owner ? owner.userId : undefined
1079
+ }
1100
1080
 
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
- }
1081
+ /** Live config with `adminUserId` replaced by the effective administrator. */
1082
+ private accessConfig(): FeishuConfig {
1083
+ return { ...this.config, adminUserId: this.effectiveAdminUserId() }
1145
1084
  }
1146
1085
 
1147
1086
  /**
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.
1087
+ * Look up the app owner for the administrator fallback. Never throws: a
1088
+ * failure is recorded for the panel and only warned about when no
1089
+ * administrator is configured (the fallback is then what admission needs).
1157
1090
  */
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
1091
+ private async resolveAppOwner(channel: FeishuChannel): Promise<void> {
1092
+ this.appOwner = undefined
1093
+ if (channel.appOwnerUserId === undefined) return
1094
+ let timer: ReturnType<typeof setTimeout> | undefined
1095
+ let state: AppOwnerState
1186
1096
  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
- }
1097
+ const userId = await Promise.race([
1098
+ channel.appOwnerUserId(),
1099
+ new Promise<never>((_, reject) => {
1100
+ timer = setTimeout(() => reject(new Error(`超时(${APP_OWNER_LOOKUP_TIMEOUT_MS} ms)`)), APP_OWNER_LOOKUP_TIMEOUT_MS)
1101
+ }),
1102
+ ])
1103
+ state = userId !== undefined && FEISHU_USER_ID_PATTERN.test(userId)
1104
+ ? { userId }
1105
+ : { error: '应用所有者不是企业内成员,或接口未返回其 user_id', apiRejected: false }
1203
1106
  } catch (error) {
1204
- return { ...empty, error: messageOf(error) }
1107
+ const detail = error instanceof AppOwnerLookupError ? error.detail : messageOf(error)
1108
+ state = {
1109
+ error: `获取应用信息失败:${detail}。请在「访问授权」手动填写管理员 user_id(私聊机器人发 /userid 获取,私聊模式关闭时可在飞书管理后台的成员详情里查看用户 ID);或为应用开通「获取应用信息」权限并发布版本后重新连接`,
1110
+ apiRejected: error instanceof AppOwnerLookupError && error.code !== undefined,
1111
+ }
1205
1112
  } finally {
1206
- this.cleanupRunning = false
1113
+ if (timer !== undefined) clearTimeout(timer)
1114
+ }
1115
+ // A background lookup can outlive its connection (shutdown/reconnect).
1116
+ if (this.channel !== channel) return
1117
+ this.appOwner = state
1118
+ if ('error' in state && this.configuredAdminUserId() === undefined) {
1119
+ this.ctx.logger?.warn?.('dsh-channels: app owner fallback unavailable: %s', state.error)
1207
1120
  }
1208
1121
  }
1209
1122
 
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)
1123
+ /** Latest post-connect scope self-check, when one finished for this connection. */
1124
+ scopeCheck(): ScopeCheckWire | undefined {
1125
+ return this.scopeCheckState
1214
1126
  }
1215
1127
 
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
- }
1128
+ /** Latest native slash-command panel sync, when one finished for this connection. */
1129
+ slashCommandSync(): SlashCommandSyncWire | undefined {
1130
+ return this.slashCommandSyncState
1262
1131
  }
1263
1132
 
1264
1133
  /**
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.
1134
+ * Reconcile Feishu's native `/` panel with the command registry and log the
1135
+ * outcome. Never throws; a failure only loses the panel listing, typed
1136
+ * commands keep working.
1269
1137
  */
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
- }
1138
+ private async runSlashCommandSync(channel: FeishuChannel): Promise<void> {
1139
+ this.slashCommandSyncState = undefined
1140
+ this.slashCommandSyncAbort?.abort()
1141
+ this.slashCommandSyncAbort = undefined
1142
+ const api = channel.slashCommands
1143
+ if (api === undefined) return
1144
+ const abort = new AbortController()
1145
+ this.slashCommandSyncAbort = abort
1146
+ let timer: ReturnType<typeof setTimeout> | undefined
1147
+ let state: SlashCommandSyncWire
1148
+ try {
1149
+ const result = await Promise.race([
1150
+ syncSlashCommands(api, desiredSlashCommands(), abort.signal),
1151
+ new Promise<never>((_, reject) => {
1152
+ timer = setTimeout(() => reject(new Error(`超时(${SLASH_COMMAND_SYNC_TIMEOUT_MS} ms)`)), SLASH_COMMAND_SYNC_TIMEOUT_MS)
1153
+ }),
1154
+ ])
1155
+ state = { state: 'ok', checkedAt: Date.now(), ...result }
1156
+ } catch (error) {
1157
+ const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
1158
+ state = { state: 'error', checkedAt: Date.now(), error: detail }
1159
+ } finally {
1160
+ if (timer !== undefined) clearTimeout(timer)
1161
+ // A timed-out sync must not keep writing behind a reported failure.
1162
+ abort.abort()
1163
+ if (this.slashCommandSyncAbort === abort) this.slashCommandSyncAbort = undefined
1164
+ }
1165
+ // A background sync can outlive its connection (shutdown/reconnect).
1166
+ if (this.channel !== channel) return
1167
+ this.slashCommandSyncState = state
1168
+ if (state.state === 'ok') {
1169
+ 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)
1170
+ } else {
1171
+ this.ctx.logger?.warn?.('dsh-channels: 飞书原生斜杠指令面板同步失败(直接输入命令不受影响):%s', state.error)
1301
1172
  }
1302
- return scanned
1303
1173
  }
1304
1174
 
1305
1175
  /**
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.
1176
+ * Compare the app's granted scopes with what the plugin uses and log the
1177
+ * missing ones. Never throws; waits for the owner lookup so the app-info
1178
+ * need reflects its outcome.
1311
1179
  */
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
- }
1180
+ private async runScopeCheck(channel: FeishuChannel, appId: string, ownerLookup: Promise<void>): Promise<void> {
1181
+ this.scopeCheckState = undefined
1182
+ if (channel.grantedScopes === undefined) return
1183
+ await ownerLookup
1184
+ let timer: ReturnType<typeof setTimeout> | undefined
1185
+ let state: ScopeCheckWire
1317
1186
  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
- }
1187
+ const grants = await Promise.race([
1188
+ channel.grantedScopes(),
1189
+ new Promise<never>((_, reject) => {
1190
+ timer = setTimeout(() => reject(new Error(`超时(${SCOPE_CHECK_TIMEOUT_MS} ms)`)), SCOPE_CHECK_TIMEOUT_MS)
1191
+ }),
1192
+ ])
1193
+ const result = checkScopes(grants, appId, {
1194
+ dmEnabled: this.config.dmMode === 'open',
1195
+ requireMention: this.config.requireMention,
1196
+ ownerLookupFailed: this.config.accessMode === 'guarded'
1197
+ && this.configuredAdminUserId() === undefined
1198
+ && this.appOwner !== undefined && 'error' in this.appOwner && this.appOwner.apiRejected,
1199
+ slashCommandPanel: this.config.slashCommandPanel,
1200
+ sendFile: this.config.feishuTools.sendFile,
1201
+ wikiRead: this.config.feishuTools.wikiRead && this.config.wikiRoots.length > 0,
1202
+ wikiWrite: this.config.feishuTools.wikiWrite && this.config.wikiRoots.length > 0,
1203
+ })
1204
+ state = result.consoleUrl === undefined
1205
+ ? { state: 'ok', checkedAt: Date.now() }
1206
+ : {
1207
+ state: 'missing',
1208
+ checkedAt: Date.now(),
1209
+ missing: result.missing.map(item => ({
1210
+ scopes: [...item.anyOf], level: item.level, purpose: item.purpose, consoleUrl: item.consoleUrl,
1211
+ })),
1212
+ consoleUrl: result.consoleUrl,
1213
+ }
1322
1214
  } 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).
1215
+ const detail = error instanceof FeishuApiError ? error.detail : messageOf(error)
1216
+ state = { state: 'error', checkedAt: Date.now(), error: detail }
1217
+ } finally {
1218
+ if (timer !== undefined) clearTimeout(timer)
1219
+ }
1220
+ // A background check can outlive its connection (shutdown/reconnect).
1221
+ if (this.channel !== channel) return
1222
+ this.scopeCheckState = state
1223
+ if (state.state === 'ok') {
1224
+ this.ctx.logger?.info?.('dsh-channels: scope self-check passed')
1225
+ } else if (state.state === 'missing') {
1226
+ const lines = state.missing.map(item => `${item.level === 'required' ? '必需' : '可选'} ${item.scopes.join(' 或 ')}:${item.purpose}`)
1227
+ this.ctx.logger?.warn?.('dsh-channels: 飞书权限缺失 %d 项,开通后需发布新版本(%s):\n %s', state.missing.length, state.consoleUrl, lines.join('\n '))
1228
+ } else {
1229
+ this.ctx.logger?.warn?.('dsh-channels: 飞书权限自检失败:%s', state.error)
1325
1230
  }
1326
- await rm(resolved, { recursive: true, force: true })
1327
1231
  }
1328
1232
 
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
1233
+ /** Trim + bound one panel-supplied identifier; rejects control characters. */
1234
+ private cleanAccessTargetId(raw: string): string | undefined {
1235
+ const value = raw.trim()
1236
+ if (value === '' || value.length > 128) return undefined
1237
+ // eslint-disable-next-line no-control-regex
1238
+ if (/[\u0000-\u001f\u007f]/u.test(value)) return undefined
1239
+ return value
1240
+ }
1241
+
1242
+ /** Same normalization for user ids, which must be Feishu `user_id`s. */
1243
+ private cleanAccessUserId(raw: string): string | undefined {
1244
+ const value = this.cleanAccessTargetId(raw)
1245
+ if (value === undefined || !FEISHU_USER_ID_PATTERN.test(value)) return undefined
1246
+ return value
1353
1247
  }
1354
1248
 
1355
1249
  /** Aggregate health used by `/health` and the Web settings panel. */
@@ -1481,6 +1375,9 @@ export class FeishuService {
1481
1375
  onError: (sessionId, error) => {
1482
1376
  this.ctx.logger?.warn?.('dsh-channels approval error (session %s): %s', sessionId, String(error))
1483
1377
  },
1378
+ onWaitingChange: (sessionId, toolNames) => {
1379
+ this.streamer?.setAwaitingApproval(sessionId, toolNames)
1380
+ },
1484
1381
  }, this.config.approvalTimeoutMs)
1485
1382
  this.approvalAnswerer = answerer
1486
1383
  this.unlistenApproval = this.ctx.on('approval/request', (req, next) => answerer.onRequest(req, next), { prepend: true })
@@ -1500,7 +1397,7 @@ export class FeishuService {
1500
1397
  { prepend: true },
1501
1398
  )
1502
1399
  const accessRequests = new AccessRequests({
1503
- config: () => this.config,
1400
+ config: () => this.accessConfig(),
1504
1401
  store: this.accessStore,
1505
1402
  channel: () => this.channel,
1506
1403
  warn: (message, ...args) => this.ctx.logger?.warn?.(message, ...args),
@@ -1518,10 +1415,16 @@ export class FeishuService {
1518
1415
  void accessRequests.onCardAction(event)
1519
1416
  answerer.onCardAction(event)
1520
1417
  questions.onCardAction(event)
1418
+ this.wikiConsent.onCardAction(event)
1419
+ this.onStopAction(event)
1420
+ void this.onPickAction(event).catch((error: unknown) => {
1421
+ this.ctx.logger?.warn?.('dsh-channels: picker card action failed: %s', String(error))
1422
+ })
1521
1423
  })
1522
1424
 
1523
1425
  this.unlistenHost.push(this.ctx.on('session/event', (session, event) => {
1524
1426
  this.streamer?.onSessionEvent(session, event)
1427
+ this.approvalAnswerer?.onSessionEvent(session.id, event)
1525
1428
  }))
1526
1429
  // DSH removed the durable `assistant/chunk` event (format v2 embeds the
1527
1430
  // stream in the settlement); the live deltas arrive here instead.
@@ -1537,7 +1440,15 @@ export class FeishuService {
1537
1440
  .catch(() => {})
1538
1441
  })
1539
1442
  })
1443
+ // Without a configured administrator, resolve the owner before the
1444
+ // handshake so the first inbound message already sees the fallback; with
1445
+ // one, the lookup only backs a later runtime clear and must not delay boot.
1446
+ const ownerLookup = this.resolveAppOwner(channel)
1447
+ if (this.configuredAdminUserId() === undefined) await ownerLookup
1540
1448
  await channel.connect()
1449
+ // Background: the results only feed the panel and the log.
1450
+ void this.runScopeCheck(channel, appId, ownerLookup)
1451
+ if (this.config.slashCommandPanel) void this.runSlashCommandSync(channel)
1541
1452
  // Repair cards a previous instance left mid-flight (best-effort, background).
1542
1453
  void this.repair.repairAll().catch((error: unknown) => {
1543
1454
  this.ctx.logger?.warn?.('dsh-channels: inflight repair failed: %s', String(error))
@@ -1554,8 +1465,6 @@ export class FeishuService {
1554
1465
  }, GC_SWEEP_INTERVAL_MS)
1555
1466
  timer.unref?.()
1556
1467
  this.gcTimer = timer
1557
- // Workspace retention: opt-in automatic cleanup (default off).
1558
- this.refreshRetentionTimer(true)
1559
1468
  this.ctx.logger?.info?.(
1560
1469
  'dsh-channels: feishu long connection established (bot open_id %s, %d route(s) loaded)',
1561
1470
  channel.botOpenId() ?? 'unknown',
@@ -1571,11 +1480,15 @@ export class FeishuService {
1571
1480
  })
1572
1481
  if (this.config.accessMode === 'open') {
1573
1482
  console.warn('[dsh-channels] 准入=open:任何应用可用范围内的用户都可以对话(不安全,仅调试)')
1574
- } else if (this.config.adminOpenId === undefined) {
1483
+ } else if (this.effectiveAdminUserId() === undefined) {
1484
+ const ownerError = this.appOwner !== undefined && 'error' in this.appOwner ? this.appOwner.error : undefined
1575
1485
  console.warn(
1576
- '[dsh-channels] 准入=guarded 但未配置管理员 open_id:所有对话都会被拒绝;'
1577
- + '私聊机器人发送 /userid 取得 open_id 后填入 Web 设置面板',
1486
+ '[dsh-channels] 准入=guarded 但未配置管理员 user_id,也无法使用应用所有者兜底:所有对话都会被拒绝;'
1487
+ + '私聊机器人发送 /userid 取得 user_id 后填入 Web 设置面板'
1488
+ + (ownerError === undefined ? '' : `(${ownerError})`),
1578
1489
  )
1490
+ } else if (this.configuredAdminUserId() === undefined) {
1491
+ console.info('[dsh-channels] 准入=guarded,未配置管理员,使用应用所有者', this.effectiveAdminUserId())
1579
1492
  } else {
1580
1493
  console.info('[dsh-channels] 准入=guarded,管理员已配置')
1581
1494
  }
@@ -1624,15 +1537,12 @@ export class FeishuService {
1624
1537
  this.approvalAnswerer?.dispose()
1625
1538
  this.approvalAnswerer = undefined
1626
1539
  this.questionAnswerer?.dispose()
1540
+ this.wikiConsent.dispose()
1627
1541
  this.questionAnswerer = undefined
1628
1542
  if (this.gcTimer !== undefined) {
1629
1543
  clearInterval(this.gcTimer)
1630
1544
  this.gcTimer = undefined
1631
1545
  }
1632
- if (this.retentionTimer !== undefined) {
1633
- clearInterval(this.retentionTimer)
1634
- this.retentionTimer = undefined
1635
- }
1636
1546
  await this.sessions.disposeAll()
1637
1547
  this.attachmentFetchTools.clear()
1638
1548
  this.routeOperations.clear()
@@ -1645,10 +1555,16 @@ export class FeishuService {
1645
1555
  // Already disconnected is fine.
1646
1556
  }
1647
1557
  this.channel = undefined
1558
+ this.streamer?.detachAll()
1648
1559
  this.streamer = undefined
1649
1560
  await this.domain?.close()
1650
1561
  this.domain = undefined
1651
1562
  this.runningConfig = undefined
1563
+ this.appOwner = undefined
1564
+ this.scopeCheckState = undefined
1565
+ this.slashCommandSyncState = undefined
1566
+ this.slashCommandSyncAbort?.abort()
1567
+ this.slashCommandSyncAbort = undefined
1652
1568
  }
1653
1569
 
1654
1570
  // ---- inbound routing -------------------------------------------------
@@ -1722,7 +1638,7 @@ export class FeishuService {
1722
1638
  return
1723
1639
  }
1724
1640
 
1725
- if (msg.senderId !== route.senderOpenId) {
1641
+ if (msg.senderId !== route.senderUserId) {
1726
1642
  await this.delivery.commandCard(msg, {
1727
1643
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1728
1644
  })
@@ -1732,7 +1648,7 @@ export class FeishuService {
1732
1648
  const operation = this.routeOperations.run(route.key, async () => {
1733
1649
  if (!this.started) return
1734
1650
  const current = this.routes.get(route.key) ?? route
1735
- if (msg.senderId !== current.senderOpenId) {
1651
+ if (msg.senderId !== current.senderUserId) {
1736
1652
  await this.delivery.commandCard(msg, {
1737
1653
  kind: 'forbidden', command: parsed.name, text: '无权操作此会话。',
1738
1654
  })
@@ -1765,12 +1681,12 @@ export class FeishuService {
1765
1681
  const pending = this.accessStore.pendingRequests()
1766
1682
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权状态**', [
1767
1683
  `**准入模式**:\`${this.config.accessMode}\``,
1768
- `**管理员**:${this.config.adminOpenId === undefined ? '未配置' : `\`${this.config.adminOpenId}\``}`,
1684
+ `**管理员**:${this.adminStatusLine()}`,
1769
1685
  ...(msg.chatType === 'group'
1770
1686
  ? [`**本群**:${this.accessStore.hasGroup(msg.chatId) ? '已授权' : '未授权'}(\`${msg.chatId}\`)`]
1771
1687
  : []),
1772
- `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 待处理申请 ${pending.length} 个`,
1773
- '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add <open_id>`。',
1688
+ `**白名单**:群 ${groups.length} 个 · 用户 ${users.length} 个 · 知识库 ${this.accessStore.wikiUserRecords().length} 个 · 待处理申请 ${pending.length} 个`,
1689
+ '群授权:`/auth allow` 或 `/auth revoke`(在目标群内发);用户授权:`/auth user add @用户` 或 `/auth user add <user_id>`;知识库授权:`/auth wiki add @用户`。',
1774
1690
  ]), direct)
1775
1691
  return
1776
1692
  }
@@ -1817,35 +1733,78 @@ export class FeishuService {
1817
1733
  }
1818
1734
  if (action === 'user') {
1819
1735
  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)) {
1736
+ const target = sub === 'add' || sub === 'remove'
1737
+ ? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
1738
+ : { ok: false as const, reason: 'usage' as const }
1739
+ if (!target.ok) {
1823
1740
  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 查看)。',
1741
+ kind: 'input-error', command: 'auth', text: AUTH_USER_TARGET_ERRORS[target.reason],
1826
1742
  }, direct)
1827
1743
  return
1828
1744
  }
1745
+ const { userId } = target
1746
+ const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
1829
1747
  if (sub === 'add') {
1830
- if (this.accessStore.hasUser(openId)) {
1748
+ if (this.accessStore.hasUser(userId)) {
1831
1749
  await this.delivery.commandCard(msg, {
1832
- kind: 'success', command: 'auth', text: `该用户已在白名单中(\`${openId}\`)。`,
1750
+ kind: 'success', command: 'auth', text: `该用户已在白名单中:${label}。`,
1833
1751
  }, direct)
1834
1752
  return
1835
1753
  }
1836
1754
  await this.accessStore.addUser({
1837
- openId, source: 'command', addedBy: msg.senderId, addedAt: now,
1755
+ userId,
1756
+ ...(target.name === undefined ? {} : { name: target.name }),
1757
+ source: 'command', addedBy: msg.senderId, addedAt: now,
1838
1758
  })
1839
1759
  await this.delivery.commandCard(msg, {
1840
1760
  kind: 'success', command: 'auth',
1841
- text: `✅ 已授权用户 \`${openId}\`(在所有已授权群里生效)。`,
1761
+ text: `✅ 已授权用户 ${label}(在所有已授权群里生效)。`,
1842
1762
  }, direct)
1843
1763
  return
1844
1764
  }
1845
- const removed = await this.accessStore.removeUser(openId)
1765
+ const removed = await this.accessStore.removeUser(userId)
1846
1766
  await this.delivery.commandCard(msg, {
1847
1767
  kind: removed ? 'success' : 'unavailable', command: 'auth',
1848
- text: removed ? `已移除用户 \`${openId}\`,立即生效。` : `用户 \`${openId}\` 不在白名单中。`,
1768
+ text: removed ? `已移除用户 ${label},立即生效。` : `用户 ${label} 不在白名单中。`,
1769
+ }, direct)
1770
+ return
1771
+ }
1772
+ if (action === 'wiki') {
1773
+ const sub = words[1]
1774
+ const target = sub === 'add' || sub === 'remove'
1775
+ ? resolveAuthUserTarget(words.slice(2).join(' '), msg.mentions, this.channel?.botOpenId())
1776
+ : { ok: false as const, reason: 'usage' as const }
1777
+ if (!target.ok) {
1778
+ await this.delivery.commandCard(msg, {
1779
+ kind: 'input-error', command: 'auth',
1780
+ text: target.reason === 'usage' ? AUTH_WIKI_USAGE : AUTH_USER_TARGET_ERRORS[target.reason],
1781
+ }, direct)
1782
+ return
1783
+ }
1784
+ const { userId } = target
1785
+ const label = target.name === undefined ? `\`${userId}\`` : `${target.name}(\`${userId}\`)`
1786
+ if (sub === 'add') {
1787
+ if (this.accessStore.hasWikiUser(userId)) {
1788
+ await this.delivery.commandCard(msg, {
1789
+ kind: 'success', command: 'auth', text: `该用户已在知识库白名单中:${label}。`,
1790
+ }, direct)
1791
+ return
1792
+ }
1793
+ await this.accessStore.addWikiUser({
1794
+ userId,
1795
+ ...(target.name === undefined ? {} : { name: target.name }),
1796
+ source: 'command', addedBy: msg.senderId, addedAt: now,
1797
+ })
1798
+ await this.delivery.commandCard(msg, {
1799
+ kind: 'success', command: 'auth',
1800
+ text: `✅ 已把 ${label} 加入知识库白名单:他发起的话题里可以使用知识库工具(每次读取、新建、修改仍需他在授权卡上确认)。`,
1801
+ }, direct)
1802
+ return
1803
+ }
1804
+ const removed = await this.accessStore.removeWikiUser(userId)
1805
+ await this.delivery.commandCard(msg, {
1806
+ kind: removed ? 'success' : 'unavailable', command: 'auth',
1807
+ text: removed ? `已把 ${label} 移出知识库白名单,立即生效。` : `用户 ${label} 不在知识库白名单中。`,
1849
1808
  }, direct)
1850
1809
  return
1851
1810
  }
@@ -1858,19 +1817,25 @@ export class FeishuService {
1858
1817
  : groups.slice(0, 30).map(group => `- ${group.name ?? group.chatId} \`${group.chatId}\``)
1859
1818
  const userLines = users.length === 0
1860
1819
  ? ['(暂无)']
1861
- : users.slice(0, 30).map(user => `- ${user.name ?? user.openId} \`${user.openId}\``)
1820
+ : users.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
1821
+ const wikiUsers = this.accessStore.wikiUserRecords()
1822
+ const wikiLines = wikiUsers.length === 0
1823
+ ? ['(暂无;管理员始终可用)']
1824
+ : wikiUsers.slice(0, 30).map(user => `- ${user.name ?? user.userId} \`${user.userId}\``)
1862
1825
  await this.delivery.standaloneReply(msg, standaloneInfoCard('**🔐 访问授权清单**', [
1863
1826
  `**已授权群(${groups.length})**`,
1864
1827
  ...groupLines,
1865
1828
  `**已授权用户(${users.length})**`,
1866
1829
  ...userLines,
1830
+ `**知识库白名单(${wikiUsers.length})**`,
1831
+ ...wikiLines,
1867
1832
  `**待处理申请**:${pending.length} 个(可在 Web 设置面板处理)`,
1868
1833
  ]), direct)
1869
1834
  return
1870
1835
  }
1871
1836
  await this.delivery.commandCard(msg, {
1872
1837
  kind: 'input-error', command: 'auth',
1873
- text: '用法:/auth [status|allow|revoke|list|user add <open_id>|user remove <open_id>]',
1838
+ text: '用法:/auth [status|allow|revoke|list|user add <@用户|user_id>|user remove <@用户|user_id>|wiki add <@用户|user_id>|wiki remove <@用户|user_id>]',
1874
1839
  }, direct)
1875
1840
  } catch (error) {
1876
1841
  this.ctx.logger?.warn?.('dsh-channels: /auth failed: %s', String(error))
@@ -1979,10 +1944,7 @@ export class FeishuService {
1979
1944
  return
1980
1945
  }
1981
1946
  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' })
1947
+ const running = this.cancelTurn(current.sessionId)
1986
1948
  await this.delivery.commandCard(msg, {
1987
1949
  kind: 'success', command: 'stop', sessionId: current.sessionId,
1988
1950
  text: running ? '已请求停止当前回合。' : '当前没有正在运行的回合。',
@@ -1995,6 +1957,73 @@ export class FeishuService {
1995
1957
  })
1996
1958
  }
1997
1959
 
1960
+ /**
1961
+ * Cancel one Session's live turn without resuming anything. Queued
1962
+ * messages stay in the inbox: each already has its own streaming card and
1963
+ * runs next.
1964
+ * @param sessionId The Session to cancel.
1965
+ * @returns Whether a turn was running.
1966
+ */
1967
+ private cancelTurn(sessionId: string): boolean {
1968
+ const agent = this.sessions.get(sessionId)?.agent
1969
+ ?? this.ctx.agents.get(sessionId as SessionId)
1970
+ const running = agent?.status === 'running'
1971
+ agent?.cancel?.({ kind: 'user' }, { keepInbox: true })
1972
+ if (running) this.streamer?.markStopping(sessionId)
1973
+ return running
1974
+ }
1975
+
1976
+ /**
1977
+ * Streaming-card stop button. Only the session owner, clicking inside the
1978
+ * session's own chat, cancels the turn; another operator gets the
1979
+ * owner-only notice on the card instead.
1980
+ */
1981
+ private onStopAction(event: CardActionEvent): void {
1982
+ if (!this.started) return
1983
+ if ((event.action.value as { decision?: unknown } | undefined)?.decision !== STOP_ACTION) return
1984
+ const sessionId = this.streamer?.stopTargetOf(event.messageId)
1985
+ if (sessionId === undefined) return
1986
+ const route = this.routes.findBySession(sessionId)
1987
+ if (route === undefined) return
1988
+ if (event.operator.userId !== route.senderUserId || event.chatId !== route.chatId) {
1989
+ this.streamer?.markStopDenied(sessionId)
1990
+ return
1991
+ }
1992
+ this.cancelTurn(sessionId)
1993
+ }
1994
+
1995
+ /**
1996
+ * Picker-card click: run the chosen `/<command> <arg>` exactly as if the
1997
+ * operator had typed it under the picker card, so access admission, route
1998
+ * ownership and validation are the typed command's. The result replaces
1999
+ * the picker card in place instead of arriving as a new card. Stale pickers (the
2000
+ * topic was rebound, or the card was forwarded elsewhere) and non-owner
2001
+ * clicks are ignored.
2002
+ */
2003
+ private async onPickAction(event: CardActionEvent): Promise<void> {
2004
+ if (!this.started) return
2005
+ const action = parsePickAction(event.action.value)
2006
+ if (action === undefined) return
2007
+ const route = this.routes.findBySession(action.sessionId)
2008
+ const userId = event.operator.userId
2009
+ if (route === undefined || route.chatId !== event.chatId || userId !== route.senderUserId) return
2010
+ const line = `/${action.command} ${action.arg}`
2011
+ await this.delivery.replacingCard(event.messageId, () => this.handleSlashMessage({
2012
+ messageId: event.messageId,
2013
+ chatId: route.chatId,
2014
+ chatType: route.chatType,
2015
+ senderId: userId,
2016
+ content: line,
2017
+ rawContentType: 'text',
2018
+ resources: [],
2019
+ mentions: [],
2020
+ mentionAll: false,
2021
+ mentionedBot: true,
2022
+ ...(route.kind === 'thread' ? { threadId: route.key } : {}),
2023
+ createTime: Date.now(),
2024
+ }, line))
2025
+ }
2026
+
1998
2027
  /**
1999
2028
  * `/permissions`: show this Session's effective sandbox+approval preset and
2000
2029
  * the selectable catalog; switch only when the deployment opted in.
@@ -2015,14 +2044,27 @@ export class FeishuService {
2015
2044
  const live = await this.sessions.ensureLive(current)
2016
2045
  let active: string | undefined
2017
2046
  let catalogLine = ''
2047
+ let choices: PickerOption[] = []
2018
2048
  try {
2019
2049
  active = presets.current(live.agent.session)
2020
2050
  const catalog = presets.catalog()
2021
2051
  catalogLine = catalog.options.map(option => `\`${option.value}\``).join('、')
2052
+ choices = catalog.options.map(option => ({ label: option.name || option.value, arg: option.value }))
2022
2053
  } catch {
2023
2054
  active = undefined
2024
2055
  }
2025
2056
  const requested = parsed.rawInput.trim()
2057
+ if (requested === '' && this.config.allowPermissionSwitch && choices.length > 0) {
2058
+ await this.delivery.standaloneReply(msg, buildPickerCard({
2059
+ title: '🔐 权限预设',
2060
+ command: 'permissions',
2061
+ text: `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}\n切换会同时调整沙箱与审批策略。`,
2062
+ sessionId: current.sessionId,
2063
+ options: choices,
2064
+ ...(active === undefined ? {} : { current: active }),
2065
+ }))
2066
+ return
2067
+ }
2026
2068
  if (requested === '') {
2027
2069
  const lines = [
2028
2070
  `**当前权限预设**:${active === undefined ? '暂无数据' : `\`${active}\``}`,
@@ -2149,6 +2191,7 @@ export class FeishuService {
2149
2191
  }
2150
2192
  await this.renderCommandResult({
2151
2193
  spec, msg, current, live,
2194
+ bare: parsed.rawInput.trim() === '',
2152
2195
  result: execution.result,
2153
2196
  compactBefore,
2154
2197
  renewAdmission,
@@ -2163,6 +2206,8 @@ export class FeishuService {
2163
2206
  msg: NormalizedMessage
2164
2207
  current: RouteRecord
2165
2208
  live: LiveAgent
2209
+ /** The command was typed without arguments. */
2210
+ bare: boolean
2166
2211
  result: CommandResult
2167
2212
  compactBefore: ContextMeasurement | undefined
2168
2213
  renewAdmission: RenewAdmission | undefined
@@ -2172,6 +2217,17 @@ export class FeishuService {
2172
2217
  const { spec, msg, current, live, result, compactBefore, renewAdmission, effortAdmission, modelAdmission } = input
2173
2218
  if (spec.name === 'effort') effortAdmission?.controller.sync(live.agent.session.snapshotEvents())
2174
2219
  if (spec.name === 'model') modelAdmission?.controller.sync(live.agent.session.snapshotEvents())
2220
+ if (input.bare && result.kind === 'success') {
2221
+ const picker = spec.name === 'model' && modelAdmission !== undefined
2222
+ ? await this.modelPicker(current, live, modelAdmission)
2223
+ : spec.name === 'effort' && effortAdmission !== undefined
2224
+ ? await this.effortPicker(current, live, effortAdmission, result.text ?? '')
2225
+ : undefined
2226
+ if (picker !== undefined) {
2227
+ await this.delivery.standaloneReply(msg, picker)
2228
+ return
2229
+ }
2230
+ }
2175
2231
  if (spec.name === 'renew' && result.kind === 'success' && renewAdmission?.commit !== undefined) {
2176
2232
  await this.delivery.standaloneReply(msg, buildRenewCard({
2177
2233
  previousSessionId: renewAdmission.commit.previous.sessionId,
@@ -2181,7 +2237,7 @@ export class FeishuService {
2181
2237
  return
2182
2238
  }
2183
2239
  if (spec.name === 'health' && result.kind === 'success') {
2184
- await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()))
2240
+ await this.delivery.standaloneReply(msg, buildHealthCard(this.healthSnapshot()), { createThread: false })
2185
2241
  return
2186
2242
  }
2187
2243
  if (spec.name === 'status' && result.kind === 'success') {
@@ -2395,18 +2451,13 @@ export class FeishuService {
2395
2451
 
2396
2452
 
2397
2453
  /**
2398
- * GC pass over the routing domain. Three independent parts:
2454
+ * GC pass over the routing domain. Two independent parts:
2399
2455
  *
2400
2456
  * 1. Dedup records (unconditional): `processed` records and in-process
2401
2457
  * guard entries older than `PROCESSED_RETENTION_MS` are dropped —
2402
2458
  * redelivery windows are minutes, so a day of retention is ample and
2403
2459
  * 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
2460
+ * 2. Route/inflight GC (`routeTtlMs > 0`): thread routes idle past the TTL
2410
2461
  * are retired into minimal persistent tombstones before their active
2411
2462
  * records are deleted. A later message in that visible topic is then
2412
2463
  * rejected instead of silently creating a context-free Session. Degraded
@@ -2439,18 +2490,6 @@ export class FeishuService {
2439
2490
  }
2440
2491
  this.inbound.pruneAccepted(processedCutoff)
2441
2492
 
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
2493
  const ttlMs = this.config.routeTtlMs
2455
2494
  if (ttlMs === 0) {
2456
2495
  if (removedProcessed > 0) {
@@ -2526,7 +2565,7 @@ export class FeishuService {
2526
2565
  chatId: record.chatId,
2527
2566
  cardMessageId: record.cardMessageId,
2528
2567
  inThread: record.kind === 'thread',
2529
- senderOpenId: record.senderOpenId,
2568
+ senderUserId: record.senderUserId,
2530
2569
  }
2531
2570
  }
2532
2571
 
@@ -2546,22 +2585,10 @@ export class FeishuService {
2546
2585
  if (admission.live.agent !== invocation.agent) {
2547
2586
  return { kind: 'error', text: '/model 只能通过已授权的飞书话题执行。' }
2548
2587
  }
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' ? '配置默认' : '部署默认'
2588
+ const { provider: currentProvider, model: currentModel } = currentModelRoute(admission.live, admission.controller)
2559
2589
 
2560
2590
  if (rawInput === '') {
2561
- const lines = [
2562
- `**当前模型**:${currentModel === undefined ? '尚无模型请求记录' : `\`${currentModel}\``}`
2563
- + `${currentProvider === undefined ? '' : `(provider \`${currentProvider}\`)`} · ${source}`,
2564
- ]
2591
+ const lines = [currentModelLine(admission.live, admission.controller)]
2565
2592
  const models = await this.listModels(currentProvider)
2566
2593
  if (models.length > 0) {
2567
2594
  lines.push(`**可选模型(${models.length})**:${models.slice(0, 20).map(id => `\`${id}\``).join('、')}`
@@ -2598,6 +2625,59 @@ export class FeishuService {
2598
2625
  return { kind: 'success', text: `当前 Session 模型已切换为 \`${label}\`(下一条消息生效)。` }
2599
2626
  }
2600
2627
 
2628
+ /** `/model` picker: the provider's advertised models, or undefined when none are listed. */
2629
+ private async modelPicker(
2630
+ current: RouteRecord,
2631
+ live: LiveAgent,
2632
+ admission: ModelAdmission,
2633
+ ): Promise<object | undefined> {
2634
+ const route = currentModelRoute(live, admission.controller)
2635
+ const models = await this.listModels(route.provider)
2636
+ if (models.length === 0) return undefined
2637
+ return buildPickerCard({
2638
+ title: '🧠 模型',
2639
+ command: 'model',
2640
+ text: `${currentModelLine(live, admission.controller)}\n切换在下一条消息生效。`,
2641
+ sessionId: current.sessionId,
2642
+ options: models.map(id => ({ label: id, arg: id })),
2643
+ ...(route.model === undefined ? {} : { current: route.model }),
2644
+ reset: true,
2645
+ })
2646
+ }
2647
+
2648
+ /**
2649
+ * `/effort` picker: the efforts the current model route advertises, or
2650
+ * undefined when the route is unknown or advertises none.
2651
+ */
2652
+ private async effortPicker(
2653
+ current: RouteRecord,
2654
+ live: LiveAgent,
2655
+ admission: EffortAdmission,
2656
+ text: string,
2657
+ ): Promise<object | undefined> {
2658
+ const route = currentModelRoute(live, admission.controller)
2659
+ if (route.provider === undefined || route.model === undefined) return undefined
2660
+ let reasoning: LlmModelInfo['reasoning']
2661
+ try {
2662
+ const llm = this.ctx.get?.('llm') as LlmService | undefined
2663
+ reasoning = (await llm?.resolveModelInfo(route.provider, route.model))?.reasoning
2664
+ } catch (error) {
2665
+ this.ctx.logger?.warn?.('dsh-channels: effort catalog lookup failed: %s', String(error))
2666
+ return undefined
2667
+ }
2668
+ if (reasoning === undefined || reasoning.efforts.length === 0) return undefined
2669
+ const active = admission.controller.state.effectiveEffort ?? reasoning.defaultEffort
2670
+ return buildPickerCard({
2671
+ title: '💭 推理强度',
2672
+ command: 'effort',
2673
+ text,
2674
+ sessionId: current.sessionId,
2675
+ options: reasoning.efforts.map(effort => ({ label: effort.name || effort.id, arg: effort.id })),
2676
+ ...(active === undefined ? {} : { current: active }),
2677
+ reset: true,
2678
+ })
2679
+ }
2680
+
2601
2681
  /** Model ids advertised for one provider route (empty when unavailable). */
2602
2682
  private async listModels(provider: string | undefined): Promise<string[]> {
2603
2683
  if (provider === undefined) return []
@@ -2738,3 +2818,28 @@ export class FeishuService {
2738
2818
  }
2739
2819
  }
2740
2820
  }
2821
+
2822
+ /** The model route the next request of a Session uses, as far as it is known. */
2823
+ function currentModelRoute(
2824
+ live: LiveAgent,
2825
+ controller: RequestPolicyController,
2826
+ ): { provider: string | undefined; model: string | undefined } {
2827
+ const state = controller.state
2828
+ const header = live.agent.session.requestHeader?.()?.config
2829
+ return {
2830
+ provider: state.effectiveModel?.provider
2831
+ ?? (typeof header?.provider === 'string' && header.provider !== '' ? header.provider : undefined),
2832
+ model: state.effectiveModel?.model
2833
+ ?? (typeof header?.model === 'string' && header.model !== '' ? header.model : undefined),
2834
+ }
2835
+ }
2836
+
2837
+ /** `**当前模型**:…` line shared by the `/model` text answer and its picker. */
2838
+ function currentModelLine(live: LiveAgent, controller: RequestPolicyController): string {
2839
+ const { provider, model } = currentModelRoute(live, controller)
2840
+ const source = controller.state.modelSource === 'session'
2841
+ ? 'Session 覆盖'
2842
+ : controller.state.modelSource === 'config' ? '配置默认' : '部署默认'
2843
+ return `**当前模型**:${model === undefined ? '尚无模型请求记录' : `\`${model}\``}`
2844
+ + `${provider === undefined ? '' : `(provider \`${provider}\`)`} · ${source}`
2845
+ }