@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
@@ -9,6 +9,9 @@
9
9
  */
10
10
 
11
11
  import { createLarkChannel, LoggerLevel } from '@larksuiteoapi/node-sdk'
12
+ import { SCOPE_GRANTED_STATUS } from './feishu/scopes.ts'
13
+ import type { ScopeGrant } from './feishu/scopes.ts'
14
+ import type { RemoteSlashCommand, SlashCommandApi, SlashCommandEntry } from './feishu/slash-commands.ts'
12
15
  import type {
13
16
  BotAddedEvent,
14
17
  ChatInfo,
@@ -21,12 +24,18 @@ import type {
21
24
  export type { BotAddedEvent, ChatInfo, NormalizedMessage, SendResult } from '@larksuiteoapi/node-sdk'
22
25
  export type FeishuConnectionStatus = WSConnectionStatus
23
26
 
24
- /** Outbound payloads used by this plugin, including Buffer-backed file upload. */
27
+ /** Outbound payloads used by this plugin, including Buffer-backed image and file uploads. */
25
28
  export type FeishuSendInput =
26
29
  | { card: object }
27
30
  | { text: string }
31
+ | { image: { source: Buffer } }
28
32
  | { file: { source: string | Buffer; fileName: string } }
29
33
 
34
+ /** One outbound image or file uploaded from memory. */
35
+ export type OutboundMedia =
36
+ | { readonly kind: 'image'; readonly data: Buffer }
37
+ | { readonly kind: 'file'; readonly data: Buffer; readonly fileName: string }
38
+
30
39
  /**
31
40
  * One normalized card action (button click) event, as delivered by the
32
41
  * official SDK's `card.action.trigger` handler (card JSON 2.0 callback
@@ -58,18 +67,45 @@ export interface FeishuChannel {
58
67
  getChatInfo(chatId: string): Promise<ChatInfo>
59
68
  /** Bot open_id, available after `connect()` resolves. */
60
69
  botOpenId(): string | undefined
70
+ /**
71
+ * The app owner's `user_id` (`application.v6.application.get`), used as the
72
+ * administrator when none is configured. Resolves `undefined` when the owner
73
+ * is not a tenant member; rejects with an {@link AppOwnerLookupError} when
74
+ * the API call fails (typically a missing app-info scope). Optional so stubs
75
+ * and older channels can omit it; absence means no owner fallback.
76
+ */
77
+ appOwnerUserId?(): Promise<string | undefined>
78
+ /**
79
+ * Scopes the app requested from the tenant and their grant state
80
+ * (`application.v6.scope.list`). Rejects with a {@link FeishuApiError} when
81
+ * the call fails. Optional so stubs can omit it; absence skips the check.
82
+ */
83
+ grantedScopes?(): Promise<readonly ScopeGrant[]>
84
+ /**
85
+ * The app's native slash-command panel endpoints
86
+ * (`application/v7/app_slash_commands`). Optional so stubs can omit it;
87
+ * absence skips the panel sync.
88
+ */
89
+ readonly slashCommands?: SlashCommandApi
61
90
  /** Current official-SDK WebSocket lifecycle snapshot, when initialized. */
62
91
  getConnectionStatus(): FeishuConnectionStatus | undefined
63
92
  /** Start the WebSocket long connection (resolves after the handshake). */
64
93
  connect(): Promise<void>
65
94
  /** Stop the connection. */
66
95
  disconnect(): Promise<void>
67
- /** Send one message (text or card) with optional reply targeting. */
96
+ /** Send one message (text, card, image or file) with optional reply targeting. */
68
97
  send(
69
98
  chatId: string,
70
99
  input: FeishuSendInput,
71
100
  options?: { replyTo?: string; replyInThread?: boolean },
72
101
  ): Promise<SendResult>
102
+ /**
103
+ * Upload one image or file and send it as a reply to `replyTo`. Unlike
104
+ * {@link FeishuChannel.send}, a recalled or deleted reply target is NOT
105
+ * retried as a fresh chat message: the call rejects with a
106
+ * {@link FeishuApiError}, so topic output never lands on the main timeline.
107
+ */
108
+ replyMedia(replyTo: string, media: OutboundMedia, options: { replyInThread: boolean }): Promise<SendResult>
73
109
  /** Full-card replacement of a previously sent card message. */
74
110
  updateCard(messageId: string, card: object): Promise<void>
75
111
  /** Add an emoji reaction; resolves with the reaction id needed for removal. */
@@ -181,6 +217,136 @@ export async function* downloadResourceInRanges(
181
217
  }
182
218
  }
183
219
 
220
+ /** Owner type `2` = tenant member; `0`/`1` are Feishu itself or an ISV partner. */
221
+ const APP_OWNER_TYPE_TENANT_MEMBER = 2
222
+
223
+ /** Failed Feishu OpenAPI call, carrying the Feishu `code`/`msg` when present. */
224
+ export class FeishuApiError extends Error {
225
+ /**
226
+ * @param detail Feishu `code=<n> <msg>` or the transport error text.
227
+ * @param code Feishu API error code, when the response carried one.
228
+ */
229
+ constructor(readonly detail: string, readonly code?: number) {
230
+ super(`feishu api call failed: ${detail}`)
231
+ this.name = 'FeishuApiError'
232
+ }
233
+ }
234
+
235
+ /** Failed app-owner lookup, carrying the Feishu `code`/`msg` when present. */
236
+ export class AppOwnerLookupError extends FeishuApiError {
237
+ /**
238
+ * @param detail Feishu `code=<n> <msg>` or the transport error text.
239
+ * @param code Feishu API error code, when the response carried one.
240
+ */
241
+ constructor(detail: string, code?: number) {
242
+ super(detail, code)
243
+ this.message = `app owner lookup failed: ${detail}`
244
+ this.name = 'AppOwnerLookupError'
245
+ }
246
+ }
247
+
248
+ /** `detail`/`code` of a thrown SDK/Axios error, preferring the Feishu error body. */
249
+ function thrownErrorFields(error: unknown): { detail: string; code?: number } {
250
+ const data = (error as { response?: { data?: { code?: unknown; msg?: unknown } } } | undefined)?.response?.data
251
+ const code = typeof data?.code === 'number' ? data.code : undefined
252
+ const msg = typeof data?.msg === 'string' ? data.msg : undefined
253
+ if (code === undefined && msg === undefined) return { detail: error instanceof Error ? error.message : String(error) }
254
+ return { detail: [code === undefined ? undefined : `code=${code}`, msg].filter(Boolean).join(' '), code }
255
+ }
256
+
257
+ /** Normalize a thrown SDK/Axios error into an {@link AppOwnerLookupError}. */
258
+ /** Throw a {@link FeishuApiError} for a non-zero Feishu `code`. */
259
+ function assertFeishuOk(response: { code?: number; msg?: string }): void {
260
+ if (response.code !== undefined && response.code !== 0) {
261
+ throw new FeishuApiError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
262
+ }
263
+ }
264
+
265
+ /** Run one raw request, mapping thrown and non-zero-code failures to {@link FeishuApiError}. */
266
+ async function feishuCall<T extends { code?: number; msg?: string }>(call: () => Promise<T>): Promise<T> {
267
+ let response: T
268
+ try {
269
+ response = await call()
270
+ } catch (error) {
271
+ const { detail, code } = thrownErrorFields(error)
272
+ throw new FeishuApiError(detail, code)
273
+ }
274
+ assertFeishuOk(response)
275
+ return response
276
+ }
277
+
278
+ /**
279
+ * Run one upload. The typed upload methods return the `data` object directly
280
+ * (or `null`) and throw on a non-zero code, so only thrown errors need mapping.
281
+ */
282
+ async function feishuUpload<T>(call: () => Promise<T>): Promise<T> {
283
+ try {
284
+ return await call()
285
+ } catch (error) {
286
+ const { detail, code } = thrownErrorFields(error)
287
+ throw new FeishuApiError(detail, code)
288
+ }
289
+ }
290
+
291
+ /** Base path of the app slash-command endpoints (absent from SDK 1.76's typed client). */
292
+ const SLASH_COMMANDS_PATH = '/open-apis/application/v7/app_slash_commands'
293
+
294
+ /** One `app_slash_commands` list response (unpaginated). */
295
+ interface SlashCommandListResponse {
296
+ code?: number
297
+ msg?: string
298
+ data?: {
299
+ items?: Array<{ command_id?: string; command?: string; description?: { default_value?: string } }>
300
+ }
301
+ }
302
+
303
+ /** `rawClient.request` as the slash-command endpoints use it. */
304
+ type RawRequest = <T>(input: { method: string; url: string; data?: object }) => Promise<T>
305
+
306
+ /**
307
+ * Create/update body. The panel icon stays at Feishu's default; PATCH
308
+ * replaces the whole `description` object.
309
+ */
310
+ function slashCommandDescription(entry: SlashCommandEntry): object {
311
+ return { default_value: entry.description, i18n: { zh_cn: entry.description } }
312
+ }
313
+
314
+ /** {@link SlashCommandApi} over the SDK's generic authenticated request. */
315
+ function slashCommandApi(request: RawRequest): SlashCommandApi {
316
+ const commandUrl = (id: string) => `${SLASH_COMMANDS_PATH}/${encodeURIComponent(id)}`
317
+ return {
318
+ async list(): Promise<readonly RemoteSlashCommand[]> {
319
+ const response = await feishuCall(() => request<SlashCommandListResponse>({ method: 'GET', url: SLASH_COMMANDS_PATH }))
320
+ return (response.data?.items ?? []).flatMap(item => item.command_id === undefined || item.command === undefined
321
+ ? []
322
+ : [{ id: item.command_id, command: item.command, description: item.description?.default_value ?? '' }])
323
+ },
324
+ async create(entry: SlashCommandEntry): Promise<void> {
325
+ await feishuCall(() => request<{ code?: number; msg?: string }>({
326
+ method: 'POST',
327
+ url: SLASH_COMMANDS_PATH,
328
+ data: { command: entry.command, description: slashCommandDescription(entry) },
329
+ }))
330
+ },
331
+ async update(id: string, entry: SlashCommandEntry): Promise<void> {
332
+ await feishuCall(() => request<{ code?: number; msg?: string }>({
333
+ method: 'PATCH',
334
+ url: commandUrl(id),
335
+ data: { description: slashCommandDescription(entry) },
336
+ }))
337
+ },
338
+ async delete(id: string): Promise<void> {
339
+ await feishuCall(() => request<{ code?: number; msg?: string }>({ method: 'DELETE', url: commandUrl(id) }))
340
+ },
341
+ }
342
+ }
343
+
344
+ function appOwnerLookupError(error: unknown): AppOwnerLookupError {
345
+ if (error instanceof AppOwnerLookupError) return error
346
+ const { detail, code } = thrownErrorFields(error)
347
+ return new AppOwnerLookupError(detail, code)
348
+ }
349
+
184
350
  export interface ChannelConnectOptions {
185
351
  appId: string
186
352
  appSecret: string
@@ -191,17 +357,18 @@ export interface ChannelConnectOptions {
191
357
 
192
358
  class LarkChannelAdapter implements FeishuChannel {
193
359
  private readonly channel: LarkChannel
360
+ private readonly appId: string
194
361
  private readonly requireMention: boolean
195
362
  private readonly dmMode: 'open' | 'disabled'
363
+ readonly slashCommands: SlashCommandApi
196
364
 
197
365
  constructor(options: ChannelConnectOptions) {
198
366
  this.channel = createLarkChannel({
199
367
  appId: options.appId,
200
368
  appSecret: options.appSecret,
201
369
  loggerLevel: LOGGER_LEVELS[options.loggerLevel],
202
- // Keep the raw event body: rich-text messages attach files in a
203
- // top-level `files` array the SDK normalizer drops, so the attachment
204
- // intake recovers them from `msg.raw` instead.
370
+ // Keep the raw event body: the sender `user_id` and the top-level
371
+ // rich-text `files` array are only recoverable from `msg.raw`.
205
372
  includeRawEvent: true,
206
373
  policy: {
207
374
  // The plugin routes mentions itself (thread routing must work without
@@ -210,13 +377,38 @@ class LarkChannelAdapter implements FeishuChannel {
210
377
  dmMode: options.dmMode === 'open' ? 'open' : 'disabled',
211
378
  respondToMentionAll: false,
212
379
  },
380
+ safety: {
381
+ // The SDK debounces messages per chatId and merges a burst into one
382
+ // message carrying the LAST sender/thread/message id. In a group that
383
+ // attributes one user's text and files to another, bypassing admission
384
+ // and topic ownership. Flush every message on its own; per-chat
385
+ // serialization and dedup stay in place.
386
+ batch: { text: { delayMs: 0 } },
387
+ },
213
388
  })
389
+ this.appId = options.appId
214
390
  this.requireMention = options.requireMention
215
391
  this.dmMode = options.dmMode
392
+ const rawClient = this.channel.rawClient
393
+ this.slashCommands = slashCommandApi(async <T,>(input: Parameters<RawRequest>[0]) => await rawClient.request<T>(input))
216
394
  }
217
395
 
218
396
  onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
219
- return this.channel.on('message', handler)
397
+ return this.channel.on('message', message => {
398
+ const userId = senderUserIdOf(message.raw)
399
+ if (userId === undefined) {
400
+ // Bots and other apps never carry a user_id; neither do external
401
+ // users or a tenant app without the user ID scope. None of them can
402
+ // be identified, so none of them may reach admission or routing.
403
+ console.warn(
404
+ '[dsh-channels] dropped message %s: sender has no user_id '
405
+ + '(bot/app sender, external user, or the app lacks contact:user.employee_id:readonly)',
406
+ message.messageId,
407
+ )
408
+ return
409
+ }
410
+ return handler({ ...message, senderId: userId })
411
+ })
220
412
  }
221
413
 
222
414
  onCardAction(handler: (event: CardActionEvent) => void | Promise<void>): () => void {
@@ -243,6 +435,34 @@ class LarkChannelAdapter implements FeishuChannel {
243
435
  return this.channel.getConnectionStatus()
244
436
  }
245
437
 
438
+ async appOwnerUserId(): Promise<string | undefined> {
439
+ let response
440
+ try {
441
+ response = await this.channel.rawClient.application.v6.application.get({
442
+ path: { app_id: this.appId },
443
+ params: { lang: 'zh_cn', user_id_type: 'user_id' },
444
+ })
445
+ } catch (error) {
446
+ throw appOwnerLookupError(error)
447
+ }
448
+ if (response.code !== undefined && response.code !== 0) {
449
+ throw new AppOwnerLookupError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
450
+ }
451
+ const owner = response.data?.app?.owner
452
+ if (owner?.type !== APP_OWNER_TYPE_TENANT_MEMBER) return undefined
453
+ const userId = owner.owner_id?.trim()
454
+ return userId === undefined || userId === '' ? undefined : userId
455
+ }
456
+
457
+ async grantedScopes(): Promise<readonly ScopeGrant[]> {
458
+ const response = await feishuCall(() => this.channel.rawClient.application.v6.scope.list())
459
+ return (response.data?.scopes ?? []).map(scope => ({
460
+ name: scope.scope_name,
461
+ granted: scope.grant_status === SCOPE_GRANTED_STATUS,
462
+ ...(scope.scope_type === undefined ? {} : { type: scope.scope_type }),
463
+ }))
464
+ }
465
+
246
466
  async connect(): Promise<void> {
247
467
  await this.channel.connect()
248
468
  }
@@ -259,6 +479,31 @@ class LarkChannelAdapter implements FeishuChannel {
259
479
  return this.channel.send(chatId, input, options)
260
480
  }
261
481
 
482
+ async replyMedia(replyTo: string, media: OutboundMedia, options: { replyInThread: boolean }): Promise<SendResult> {
483
+ const im = this.channel.rawClient.im.v1
484
+ let content: string
485
+ if (media.kind === 'image') {
486
+ const uploaded = await feishuUpload(() => im.image.create({ data: { image_type: 'message', image: media.data } }))
487
+ const imageKey = uploaded?.image_key
488
+ if (imageKey === undefined) throw new FeishuApiError('image_key missing in upload response')
489
+ content = JSON.stringify({ image_key: imageKey })
490
+ } else {
491
+ const uploaded = await feishuUpload(() => im.file.create({
492
+ data: { file_type: 'stream', file_name: media.fileName, file: media.data },
493
+ }))
494
+ const fileKey = uploaded?.file_key
495
+ if (fileKey === undefined) throw new FeishuApiError('file_key missing in upload response')
496
+ content = JSON.stringify({ file_key: fileKey })
497
+ }
498
+ const sent = await feishuCall(() => im.message.reply({
499
+ path: { message_id: replyTo },
500
+ data: { msg_type: media.kind, content, reply_in_thread: options.replyInThread },
501
+ }))
502
+ const messageId = sent.data?.message_id
503
+ if (messageId === undefined) throw new FeishuApiError('message_id missing in reply response')
504
+ return { messageId }
505
+ }
506
+
262
507
  async updateCard(messageId: string, card: object): Promise<void> {
263
508
  await this.channel.updateCard(messageId, card)
264
509
  }
@@ -278,9 +523,9 @@ class LarkChannelAdapter implements FeishuChannel {
278
523
 
279
524
  async messageGet(messageId: string): Promise<unknown> {
280
525
  const message = this.channel.rawClient.im.v1.message as unknown as {
281
- get(input: { path: { message_id: string } }): Promise<unknown>
526
+ get(input: { path: { message_id: string }; params: { user_id_type: 'user_id' } }): Promise<unknown>
282
527
  }
283
- return await message.get({ path: { message_id: messageId } })
528
+ return await message.get({ path: { message_id: messageId }, params: { user_id_type: 'user_id' } })
284
529
  }
285
530
 
286
531
  async messageMget(messageIds: readonly string[]): Promise<unknown> {
@@ -300,7 +545,7 @@ class LarkChannelAdapter implements FeishuChannel {
300
545
  }
301
546
  return await client.request({
302
547
  method: 'GET',
303
- url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content`,
548
+ url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content&user_id_type=user_id`,
304
549
  })
305
550
  }
306
551
 
@@ -381,6 +626,22 @@ export function sniffImageMediaType(data: Uint8Array): 'image/png' | 'image/jpeg
381
626
  return undefined
382
627
  }
383
628
 
629
+ /**
630
+ * The sender `user_id` of one raw `im.message.receive_v1` event body. The SDK
631
+ * normalizer fills `senderId` with the open_id; the plugin identifies users by
632
+ * the tenant-wide `user_id`, which Feishu only sends to tenant-internal apps
633
+ * granted `contact:user.employee_id:readonly`.
634
+ *
635
+ * @param raw - `NormalizedMessage.raw` (requires `includeRawEvent`).
636
+ * @returns the non-empty `user_id`, or `undefined` when the event has none.
637
+ */
638
+ export function senderUserIdOf(raw: unknown): string | undefined {
639
+ if (raw === null || typeof raw !== 'object') return undefined
640
+ const sender = (raw as { sender?: { sender_id?: { user_id?: unknown } } }).sender
641
+ const userId = sender?.sender_id?.user_id
642
+ return typeof userId === 'string' && userId !== '' ? userId : undefined
643
+ }
644
+
384
645
  const LOGGER_LEVELS: Record<ChannelConnectOptions['loggerLevel'], LoggerLevel> = {
385
646
  debug: LoggerLevel.debug,
386
647
  info: LoggerLevel.info,
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ export type * from './questions.ts'
41
41
  export type * from './settings/contract.ts'
42
42
  export * from './access/index.ts'
43
43
  export type { AccessGroupRecord, AccessRequestRecord, AccessUserRecord } from './domain.ts'
44
- export { CATALOG_ROUTE, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
44
+ export { CATALOG_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, EDITABLE_KEYS, PANEL_COPY, PANEL_ROUTE, PICK_DIRECTORY_ROUTE, RECONNECT_ROUTE, RESTART_KEYS, STATUS_ROUTE } from './settings/contract.ts'
45
45
  export type { PickDirectoryResponse } from './settings/contract.ts'
46
46
  export { defaultOverlayPath, mergeOverlay, normalizeOverlay, OverlayStore, parseOverlay, pickEditable, resolveDshHome } from './settings/overlay.ts'
47
47
  export { applyCatalog, emptyCatalog, FIELD_DESCRIPTORS, normalizeCredentialValue, readOnlyFields, SettingsSurface, withDirectoryBrowse } from './settings/surface.ts'
@@ -63,7 +63,7 @@ export { createLogicalSessionExport, DSH_CHANNELS_VERSION, LOGICAL_EXPORT_FORMAT
63
63
  export { addUsage, applyProjectionViews, averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct, EMPTY_TOTALS, foldAll, foldEvent, foldSessionStats, measureSessionContext, readProjectionViews, usageFromBuckets } from './stats.ts'
64
64
  export { FeishuService } from './service.ts'
65
65
  export { TurnStreamer } from './streamer.ts'
66
- export { ensureWorkspace, legacyTopicWorkspaceRoot, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
66
+ export { ensureWorkspace, stagingPathFor, stagingRoot, topicDirFor, topicTokenFor, workspacePathFor, workspaceTitleFor, worktreeBranchFor } from './workspaces.ts'
67
67
  export { accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, appendSessionHistory, chatRouteKey, expiredRouteRecordSchema, feishuDomain, normalizeRouteRecord, normalizeSessionHistory, routeRecordSchema, sessionDirOf, sessionHistoryEntrySchema, inflightSnapshotSchema, processedRecordSchema } from './domain.ts'
68
68
 
69
69
  /** Cordis function-plugin name. */
@@ -105,9 +105,10 @@ export function apply(ctx: HostContext, config?: unknown): void {
105
105
  const resolved = resolveConfig(config)
106
106
  const factory: ChannelFactory = (config as FeishuPluginOptions | undefined)?.channelFactory
107
107
  ?? defaultChannelFactory
108
- // Web settings overlay (optional file; degrades to "no overrides").
108
+ // Web settings overlay at `$DSH_HOME/dsh-channels/settings.json` (optional
109
+ // file; degrades to "no overrides").
109
110
  const overlay = new OverlayStore(
110
- resolved.settingsFile ?? defaultOverlayPath(),
111
+ defaultOverlayPath(),
111
112
  { warn: (message, ...args) => ctx.logger?.warn?.(message, ...args) },
112
113
  )
113
114
  const service = new FeishuService(ctx, resolved, factory, undefined, overlay)