@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
@@ -9,6 +9,11 @@
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'
15
+ import { createWikiApi } from './feishu/wiki/api.ts'
16
+ import type { WikiApi, WikiRawRequest } from './feishu/wiki/api.ts'
12
17
  import type {
13
18
  BotAddedEvent,
14
19
  ChatInfo,
@@ -21,12 +26,18 @@ import type {
21
26
  export type { BotAddedEvent, ChatInfo, NormalizedMessage, SendResult } from '@larksuiteoapi/node-sdk'
22
27
  export type FeishuConnectionStatus = WSConnectionStatus
23
28
 
24
- /** Outbound payloads used by this plugin, including Buffer-backed file upload. */
29
+ /** Outbound payloads used by this plugin, including Buffer-backed image and file uploads. */
25
30
  export type FeishuSendInput =
26
31
  | { card: object }
27
32
  | { text: string }
33
+ | { image: { source: Buffer } }
28
34
  | { file: { source: string | Buffer; fileName: string } }
29
35
 
36
+ /** One outbound image or file uploaded from memory. */
37
+ export type OutboundMedia =
38
+ | { readonly kind: 'image'; readonly data: Buffer }
39
+ | { readonly kind: 'file'; readonly data: Buffer; readonly fileName: string }
40
+
30
41
  /**
31
42
  * One normalized card action (button click) event, as delivered by the
32
43
  * official SDK's `card.action.trigger` handler (card JSON 2.0 callback
@@ -58,18 +69,51 @@ export interface FeishuChannel {
58
69
  getChatInfo(chatId: string): Promise<ChatInfo>
59
70
  /** Bot open_id, available after `connect()` resolves. */
60
71
  botOpenId(): string | undefined
72
+ /**
73
+ * The app owner's `user_id` (`application.v6.application.get`), used as the
74
+ * administrator when none is configured. Resolves `undefined` when the owner
75
+ * is not a tenant member; rejects with an {@link AppOwnerLookupError} when
76
+ * the API call fails (typically a missing app-info scope). Optional so stubs
77
+ * and older channels can omit it; absence means no owner fallback.
78
+ */
79
+ appOwnerUserId?(): Promise<string | undefined>
80
+ /**
81
+ * Scopes the app requested from the tenant and their grant state
82
+ * (`application.v6.scope.list`). Rejects with a {@link FeishuApiError} when
83
+ * the call fails. Optional so stubs can omit it; absence skips the check.
84
+ */
85
+ grantedScopes?(): Promise<readonly ScopeGrant[]>
86
+ /**
87
+ * The app's native slash-command panel endpoints
88
+ * (`application/v7/app_slash_commands`). Optional so stubs can omit it;
89
+ * absence skips the panel sync.
90
+ */
91
+ readonly slashCommands?: SlashCommandApi
92
+ /**
93
+ * Wiki and docx endpoints used by the wiki tools, called with the app
94
+ * identity. Optional so stubs can omit it; absence fails wiki calls with
95
+ * a channel-unavailable result.
96
+ */
97
+ readonly wiki?: WikiApi
61
98
  /** Current official-SDK WebSocket lifecycle snapshot, when initialized. */
62
99
  getConnectionStatus(): FeishuConnectionStatus | undefined
63
100
  /** Start the WebSocket long connection (resolves after the handshake). */
64
101
  connect(): Promise<void>
65
102
  /** Stop the connection. */
66
103
  disconnect(): Promise<void>
67
- /** Send one message (text or card) with optional reply targeting. */
104
+ /** Send one message (text, card, image or file) with optional reply targeting. */
68
105
  send(
69
106
  chatId: string,
70
107
  input: FeishuSendInput,
71
108
  options?: { replyTo?: string; replyInThread?: boolean },
72
109
  ): Promise<SendResult>
110
+ /**
111
+ * Upload one image or file and send it as a reply to `replyTo`. Unlike
112
+ * {@link FeishuChannel.send}, a recalled or deleted reply target is NOT
113
+ * retried as a fresh chat message: the call rejects with a
114
+ * {@link FeishuApiError}, so topic output never lands on the main timeline.
115
+ */
116
+ replyMedia(replyTo: string, media: OutboundMedia, options: { replyInThread: boolean }): Promise<SendResult>
73
117
  /** Full-card replacement of a previously sent card message. */
74
118
  updateCard(messageId: string, card: object): Promise<void>
75
119
  /** Add an emoji reaction; resolves with the reaction id needed for removal. */
@@ -181,6 +225,136 @@ export async function* downloadResourceInRanges(
181
225
  }
182
226
  }
183
227
 
228
+ /** Owner type `2` = tenant member; `0`/`1` are Feishu itself or an ISV partner. */
229
+ const APP_OWNER_TYPE_TENANT_MEMBER = 2
230
+
231
+ /** Failed Feishu OpenAPI call, carrying the Feishu `code`/`msg` when present. */
232
+ export class FeishuApiError extends Error {
233
+ /**
234
+ * @param detail Feishu `code=<n> <msg>` or the transport error text.
235
+ * @param code Feishu API error code, when the response carried one.
236
+ */
237
+ constructor(readonly detail: string, readonly code?: number) {
238
+ super(`feishu api call failed: ${detail}`)
239
+ this.name = 'FeishuApiError'
240
+ }
241
+ }
242
+
243
+ /** Failed app-owner lookup, carrying the Feishu `code`/`msg` when present. */
244
+ export class AppOwnerLookupError extends FeishuApiError {
245
+ /**
246
+ * @param detail Feishu `code=<n> <msg>` or the transport error text.
247
+ * @param code Feishu API error code, when the response carried one.
248
+ */
249
+ constructor(detail: string, code?: number) {
250
+ super(detail, code)
251
+ this.message = `app owner lookup failed: ${detail}`
252
+ this.name = 'AppOwnerLookupError'
253
+ }
254
+ }
255
+
256
+ /** `detail`/`code` of a thrown SDK/Axios error, preferring the Feishu error body. */
257
+ function thrownErrorFields(error: unknown): { detail: string; code?: number } {
258
+ const data = (error as { response?: { data?: { code?: unknown; msg?: unknown } } } | undefined)?.response?.data
259
+ const code = typeof data?.code === 'number' ? data.code : undefined
260
+ const msg = typeof data?.msg === 'string' ? data.msg : undefined
261
+ if (code === undefined && msg === undefined) return { detail: error instanceof Error ? error.message : String(error) }
262
+ return { detail: [code === undefined ? undefined : `code=${code}`, msg].filter(Boolean).join(' '), code }
263
+ }
264
+
265
+ /** Normalize a thrown SDK/Axios error into an {@link AppOwnerLookupError}. */
266
+ /** Throw a {@link FeishuApiError} for a non-zero Feishu `code`. */
267
+ function assertFeishuOk(response: { code?: number; msg?: string }): void {
268
+ if (response.code !== undefined && response.code !== 0) {
269
+ throw new FeishuApiError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
270
+ }
271
+ }
272
+
273
+ /** Run one raw request, mapping thrown and non-zero-code failures to {@link FeishuApiError}. */
274
+ async function feishuCall<T extends { code?: number; msg?: string }>(call: () => Promise<T>): Promise<T> {
275
+ let response: T
276
+ try {
277
+ response = await call()
278
+ } catch (error) {
279
+ const { detail, code } = thrownErrorFields(error)
280
+ throw new FeishuApiError(detail, code)
281
+ }
282
+ assertFeishuOk(response)
283
+ return response
284
+ }
285
+
286
+ /**
287
+ * Run one upload. The typed upload methods return the `data` object directly
288
+ * (or `null`) and throw on a non-zero code, so only thrown errors need mapping.
289
+ */
290
+ async function feishuUpload<T>(call: () => Promise<T>): Promise<T> {
291
+ try {
292
+ return await call()
293
+ } catch (error) {
294
+ const { detail, code } = thrownErrorFields(error)
295
+ throw new FeishuApiError(detail, code)
296
+ }
297
+ }
298
+
299
+ /** Base path of the app slash-command endpoints (absent from SDK 1.76's typed client). */
300
+ const SLASH_COMMANDS_PATH = '/open-apis/application/v7/app_slash_commands'
301
+
302
+ /** One `app_slash_commands` list response (unpaginated). */
303
+ interface SlashCommandListResponse {
304
+ code?: number
305
+ msg?: string
306
+ data?: {
307
+ items?: Array<{ command_id?: string; command?: string; description?: { default_value?: string } }>
308
+ }
309
+ }
310
+
311
+ /** `rawClient.request` as the slash-command endpoints use it. */
312
+ type RawRequest = <T>(input: { method: string; url: string; data?: object }) => Promise<T>
313
+
314
+ /**
315
+ * Create/update body. The panel icon stays at Feishu's default; PATCH
316
+ * replaces the whole `description` object.
317
+ */
318
+ function slashCommandDescription(entry: SlashCommandEntry): object {
319
+ return { default_value: entry.description, i18n: { zh_cn: entry.description } }
320
+ }
321
+
322
+ /** {@link SlashCommandApi} over the SDK's generic authenticated request. */
323
+ function slashCommandApi(request: RawRequest): SlashCommandApi {
324
+ const commandUrl = (id: string) => `${SLASH_COMMANDS_PATH}/${encodeURIComponent(id)}`
325
+ return {
326
+ async list(): Promise<readonly RemoteSlashCommand[]> {
327
+ const response = await feishuCall(() => request<SlashCommandListResponse>({ method: 'GET', url: SLASH_COMMANDS_PATH }))
328
+ return (response.data?.items ?? []).flatMap(item => item.command_id === undefined || item.command === undefined
329
+ ? []
330
+ : [{ id: item.command_id, command: item.command, description: item.description?.default_value ?? '' }])
331
+ },
332
+ async create(entry: SlashCommandEntry): Promise<void> {
333
+ await feishuCall(() => request<{ code?: number; msg?: string }>({
334
+ method: 'POST',
335
+ url: SLASH_COMMANDS_PATH,
336
+ data: { command: entry.command, description: slashCommandDescription(entry) },
337
+ }))
338
+ },
339
+ async update(id: string, entry: SlashCommandEntry): Promise<void> {
340
+ await feishuCall(() => request<{ code?: number; msg?: string }>({
341
+ method: 'PATCH',
342
+ url: commandUrl(id),
343
+ data: { description: slashCommandDescription(entry) },
344
+ }))
345
+ },
346
+ async delete(id: string): Promise<void> {
347
+ await feishuCall(() => request<{ code?: number; msg?: string }>({ method: 'DELETE', url: commandUrl(id) }))
348
+ },
349
+ }
350
+ }
351
+
352
+ function appOwnerLookupError(error: unknown): AppOwnerLookupError {
353
+ if (error instanceof AppOwnerLookupError) return error
354
+ const { detail, code } = thrownErrorFields(error)
355
+ return new AppOwnerLookupError(detail, code)
356
+ }
357
+
184
358
  export interface ChannelConnectOptions {
185
359
  appId: string
186
360
  appSecret: string
@@ -191,17 +365,19 @@ export interface ChannelConnectOptions {
191
365
 
192
366
  class LarkChannelAdapter implements FeishuChannel {
193
367
  private readonly channel: LarkChannel
368
+ private readonly appId: string
194
369
  private readonly requireMention: boolean
195
370
  private readonly dmMode: 'open' | 'disabled'
371
+ readonly slashCommands: SlashCommandApi
372
+ readonly wiki: WikiApi
196
373
 
197
374
  constructor(options: ChannelConnectOptions) {
198
375
  this.channel = createLarkChannel({
199
376
  appId: options.appId,
200
377
  appSecret: options.appSecret,
201
378
  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.
379
+ // Keep the raw event body: the sender `user_id` and the top-level
380
+ // rich-text `files` array are only recoverable from `msg.raw`.
205
381
  includeRawEvent: true,
206
382
  policy: {
207
383
  // The plugin routes mentions itself (thread routing must work without
@@ -210,13 +386,39 @@ class LarkChannelAdapter implements FeishuChannel {
210
386
  dmMode: options.dmMode === 'open' ? 'open' : 'disabled',
211
387
  respondToMentionAll: false,
212
388
  },
389
+ safety: {
390
+ // The SDK debounces messages per chatId and merges a burst into one
391
+ // message carrying the LAST sender/thread/message id. In a group that
392
+ // attributes one user's text and files to another, bypassing admission
393
+ // and topic ownership. Flush every message on its own; per-chat
394
+ // serialization and dedup stay in place.
395
+ batch: { text: { delayMs: 0 } },
396
+ },
213
397
  })
398
+ this.appId = options.appId
214
399
  this.requireMention = options.requireMention
215
400
  this.dmMode = options.dmMode
401
+ const rawClient = this.channel.rawClient
402
+ this.slashCommands = slashCommandApi(async <T,>(input: Parameters<RawRequest>[0]) => await rawClient.request<T>(input))
403
+ this.wiki = createWikiApi(async <T,>(input: Parameters<WikiRawRequest>[0]) => await rawClient.request<T>(input))
216
404
  }
217
405
 
218
406
  onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
219
- return this.channel.on('message', handler)
407
+ return this.channel.on('message', message => {
408
+ const userId = senderUserIdOf(message.raw)
409
+ if (userId === undefined) {
410
+ // Bots and other apps never carry a user_id; neither do external
411
+ // users or a tenant app without the user ID scope. None of them can
412
+ // be identified, so none of them may reach admission or routing.
413
+ console.warn(
414
+ '[dsh-channels] dropped message %s: sender has no user_id '
415
+ + '(bot/app sender, external user, or the app lacks contact:user.employee_id:readonly)',
416
+ message.messageId,
417
+ )
418
+ return
419
+ }
420
+ return handler({ ...message, senderId: userId })
421
+ })
220
422
  }
221
423
 
222
424
  onCardAction(handler: (event: CardActionEvent) => void | Promise<void>): () => void {
@@ -243,6 +445,34 @@ class LarkChannelAdapter implements FeishuChannel {
243
445
  return this.channel.getConnectionStatus()
244
446
  }
245
447
 
448
+ async appOwnerUserId(): Promise<string | undefined> {
449
+ let response
450
+ try {
451
+ response = await this.channel.rawClient.application.v6.application.get({
452
+ path: { app_id: this.appId },
453
+ params: { lang: 'zh_cn', user_id_type: 'user_id' },
454
+ })
455
+ } catch (error) {
456
+ throw appOwnerLookupError(error)
457
+ }
458
+ if (response.code !== undefined && response.code !== 0) {
459
+ throw new AppOwnerLookupError(`code=${response.code}${response.msg === undefined ? '' : ` ${response.msg}`}`, response.code)
460
+ }
461
+ const owner = response.data?.app?.owner
462
+ if (owner?.type !== APP_OWNER_TYPE_TENANT_MEMBER) return undefined
463
+ const userId = owner.owner_id?.trim()
464
+ return userId === undefined || userId === '' ? undefined : userId
465
+ }
466
+
467
+ async grantedScopes(): Promise<readonly ScopeGrant[]> {
468
+ const response = await feishuCall(() => this.channel.rawClient.application.v6.scope.list())
469
+ return (response.data?.scopes ?? []).map(scope => ({
470
+ name: scope.scope_name,
471
+ granted: scope.grant_status === SCOPE_GRANTED_STATUS,
472
+ ...(scope.scope_type === undefined ? {} : { type: scope.scope_type }),
473
+ }))
474
+ }
475
+
246
476
  async connect(): Promise<void> {
247
477
  await this.channel.connect()
248
478
  }
@@ -259,6 +489,31 @@ class LarkChannelAdapter implements FeishuChannel {
259
489
  return this.channel.send(chatId, input, options)
260
490
  }
261
491
 
492
+ async replyMedia(replyTo: string, media: OutboundMedia, options: { replyInThread: boolean }): Promise<SendResult> {
493
+ const im = this.channel.rawClient.im.v1
494
+ let content: string
495
+ if (media.kind === 'image') {
496
+ const uploaded = await feishuUpload(() => im.image.create({ data: { image_type: 'message', image: media.data } }))
497
+ const imageKey = uploaded?.image_key
498
+ if (imageKey === undefined) throw new FeishuApiError('image_key missing in upload response')
499
+ content = JSON.stringify({ image_key: imageKey })
500
+ } else {
501
+ const uploaded = await feishuUpload(() => im.file.create({
502
+ data: { file_type: 'stream', file_name: media.fileName, file: media.data },
503
+ }))
504
+ const fileKey = uploaded?.file_key
505
+ if (fileKey === undefined) throw new FeishuApiError('file_key missing in upload response')
506
+ content = JSON.stringify({ file_key: fileKey })
507
+ }
508
+ const sent = await feishuCall(() => im.message.reply({
509
+ path: { message_id: replyTo },
510
+ data: { msg_type: media.kind, content, reply_in_thread: options.replyInThread },
511
+ }))
512
+ const messageId = sent.data?.message_id
513
+ if (messageId === undefined) throw new FeishuApiError('message_id missing in reply response')
514
+ return { messageId }
515
+ }
516
+
262
517
  async updateCard(messageId: string, card: object): Promise<void> {
263
518
  await this.channel.updateCard(messageId, card)
264
519
  }
@@ -278,9 +533,9 @@ class LarkChannelAdapter implements FeishuChannel {
278
533
 
279
534
  async messageGet(messageId: string): Promise<unknown> {
280
535
  const message = this.channel.rawClient.im.v1.message as unknown as {
281
- get(input: { path: { message_id: string } }): Promise<unknown>
536
+ get(input: { path: { message_id: string }; params: { user_id_type: 'user_id' } }): Promise<unknown>
282
537
  }
283
- return await message.get({ path: { message_id: messageId } })
538
+ return await message.get({ path: { message_id: messageId }, params: { user_id_type: 'user_id' } })
284
539
  }
285
540
 
286
541
  async messageMget(messageIds: readonly string[]): Promise<unknown> {
@@ -300,7 +555,7 @@ class LarkChannelAdapter implements FeishuChannel {
300
555
  }
301
556
  return await client.request({
302
557
  method: 'GET',
303
- url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content`,
558
+ url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content&user_id_type=user_id`,
304
559
  })
305
560
  }
306
561
 
@@ -381,6 +636,22 @@ export function sniffImageMediaType(data: Uint8Array): 'image/png' | 'image/jpeg
381
636
  return undefined
382
637
  }
383
638
 
639
+ /**
640
+ * The sender `user_id` of one raw `im.message.receive_v1` event body. The SDK
641
+ * normalizer fills `senderId` with the open_id; the plugin identifies users by
642
+ * the tenant-wide `user_id`, which Feishu only sends to tenant-internal apps
643
+ * granted `contact:user.employee_id:readonly`.
644
+ *
645
+ * @param raw - `NormalizedMessage.raw` (requires `includeRawEvent`).
646
+ * @returns the non-empty `user_id`, or `undefined` when the event has none.
647
+ */
648
+ export function senderUserIdOf(raw: unknown): string | undefined {
649
+ if (raw === null || typeof raw !== 'object') return undefined
650
+ const sender = (raw as { sender?: { sender_id?: { user_id?: unknown } } }).sender
651
+ const userId = sender?.sender_id?.user_id
652
+ return typeof userId === 'string' && userId !== '' ? userId : undefined
653
+ }
654
+
384
655
  const LOGGER_LEVELS: Record<ChannelConnectOptions['loggerLevel'], LoggerLevel> = {
385
656
  debug: LoggerLevel.debug,
386
657
  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)