@modesto_ma/dsh-channels 0.3.0 → 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +123 -53
  2. package/docs/access-control-design.md +7 -5
  3. package/docs/attachment-pipeline-v2.md +310 -0
  4. package/docs/feasibility-and-design.md +1 -1
  5. package/docs/feishu-onboarding.md +127 -21
  6. package/docs/persistence.md +6 -4
  7. package/docs/refactor-plan.md +2 -2
  8. package/docs/workspace-isolation-design.md +13 -11
  9. package/lib/client.js +179 -288
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +12125 -6382
  12. package/lib/index.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/access/cards.ts +9 -9
  15. package/src/access/controller.ts +10 -10
  16. package/src/access/index.ts +1 -0
  17. package/src/access/policy.ts +10 -10
  18. package/src/access/requests.ts +32 -31
  19. package/src/access/store.ts +12 -12
  20. package/src/access/targets.ts +56 -0
  21. package/src/cards/approval.ts +96 -26
  22. package/src/cards/index.ts +1 -0
  23. package/src/cards/onboarding.ts +1 -0
  24. package/src/cards/picker.ts +115 -0
  25. package/src/cards/streaming.ts +56 -7
  26. package/src/client/api.ts +5 -25
  27. package/src/client/panel/access-card.tsx +24 -15
  28. package/src/client/panel/access-tab.tsx +3 -3
  29. package/src/client/panel/connection-tab.tsx +70 -1
  30. package/src/client/panel/index.tsx +11 -62
  31. package/src/client/panel/shared.tsx +0 -23
  32. package/src/client/panel/workspace-tab.tsx +2 -18
  33. package/src/client/styles.ts +2 -0
  34. package/src/commands/registry.ts +2 -2
  35. package/src/config.ts +190 -32
  36. package/src/domain.ts +28 -18
  37. package/src/feishu/agent-context.ts +65 -19
  38. package/src/feishu/inbound-attachments.ts +440 -3
  39. package/src/feishu/manifest.ts +247 -0
  40. package/src/feishu/message-read.ts +1330 -0
  41. package/src/feishu/native-attachments.ts +998 -0
  42. package/src/feishu/scopes.ts +171 -0
  43. package/src/feishu/send-file.ts +543 -0
  44. package/src/feishu/slash-commands.ts +134 -0
  45. package/src/feishu/tools.ts +2305 -0
  46. package/src/feishu-channel.ts +322 -6
  47. package/src/host/agent.ts +55 -0
  48. package/src/host/attachments.ts +210 -0
  49. package/src/host/index.ts +1 -0
  50. package/src/index.ts +5 -4
  51. package/src/interactions/approvals.ts +187 -7
  52. package/src/interactions/pending.ts +3 -3
  53. package/src/routing/inbound.ts +620 -49
  54. package/src/service.ts +808 -375
  55. package/src/session/manager.ts +67 -13
  56. package/src/session/routes.ts +2 -3
  57. package/src/settings/contract.ts +90 -89
  58. package/src/settings/overlay.ts +124 -13
  59. package/src/settings/routes.ts +3 -17
  60. package/src/settings/surface.ts +156 -82
  61. package/src/streamer.ts +94 -5
  62. package/src/workspaces.ts +39 -26
  63. package/src/cleanup.ts +0 -535
  64. package/src/client/panel/cleanup-card.tsx +0 -106
@@ -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. */
@@ -88,6 +124,26 @@ export interface FeishuChannel {
88
124
  fileKey: string,
89
125
  type: 'image' | 'file',
90
126
  ): Promise<ResourceChunkStream>
127
+ /** P0 probe surface: raw `im.v1.message.get` (quoted / merged-forward container). */
128
+ messageGet?(messageId: string): Promise<unknown>
129
+ /**
130
+ * Batch read surface: raw `im.v1.message.mget` (≤50 message ids). The SDK
131
+ * 1.76 client does not expose this endpoint as a typed method, so the
132
+ * adapter calls it through `Client.request`; every caller must tolerate the
133
+ * method being absent (older channels / stubs) and fall back to `message.get`.
134
+ */
135
+ messageMget?(messageIds: readonly string[]): Promise<unknown>
136
+ /** P0 probe surface: raw `im.v1.message.list` (thread / chat history). */
137
+ messageList?(params: RawMessageListParams): Promise<unknown>
138
+ }
139
+
140
+ /** Query for the raw `im.v1.message.list` endpoint. */
141
+ export interface RawMessageListParams {
142
+ container_id_type: 'chat' | 'thread'
143
+ container_id: string
144
+ page_size?: number
145
+ page_token?: string
146
+ sort_type?: 'ByCreateTimeAsc' | 'ByCreateTimeDesc'
91
147
  }
92
148
 
93
149
  /** Ordered byte chunks of one inbound resource. */
@@ -161,6 +217,136 @@ export async function* downloadResourceInRanges(
161
217
  }
162
218
  }
163
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
+
164
350
  export interface ChannelConnectOptions {
165
351
  appId: string
166
352
  appSecret: string
@@ -171,17 +357,18 @@ export interface ChannelConnectOptions {
171
357
 
172
358
  class LarkChannelAdapter implements FeishuChannel {
173
359
  private readonly channel: LarkChannel
360
+ private readonly appId: string
174
361
  private readonly requireMention: boolean
175
362
  private readonly dmMode: 'open' | 'disabled'
363
+ readonly slashCommands: SlashCommandApi
176
364
 
177
365
  constructor(options: ChannelConnectOptions) {
178
366
  this.channel = createLarkChannel({
179
367
  appId: options.appId,
180
368
  appSecret: options.appSecret,
181
369
  loggerLevel: LOGGER_LEVELS[options.loggerLevel],
182
- // Keep the raw event body: rich-text messages attach files in a
183
- // top-level `files` array the SDK normalizer drops, so the attachment
184
- // 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`.
185
372
  includeRawEvent: true,
186
373
  policy: {
187
374
  // The plugin routes mentions itself (thread routing must work without
@@ -190,13 +377,38 @@ class LarkChannelAdapter implements FeishuChannel {
190
377
  dmMode: options.dmMode === 'open' ? 'open' : 'disabled',
191
378
  respondToMentionAll: false,
192
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
+ },
193
388
  })
389
+ this.appId = options.appId
194
390
  this.requireMention = options.requireMention
195
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))
196
394
  }
197
395
 
198
396
  onMessage(handler: (message: NormalizedMessage) => void | Promise<void>): () => void {
199
- 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
+ })
200
412
  }
201
413
 
202
414
  onCardAction(handler: (event: CardActionEvent) => void | Promise<void>): () => void {
@@ -223,6 +435,34 @@ class LarkChannelAdapter implements FeishuChannel {
223
435
  return this.channel.getConnectionStatus()
224
436
  }
225
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
+
226
466
  async connect(): Promise<void> {
227
467
  await this.channel.connect()
228
468
  }
@@ -239,6 +479,31 @@ class LarkChannelAdapter implements FeishuChannel {
239
479
  return this.channel.send(chatId, input, options)
240
480
  }
241
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
+
242
507
  async updateCard(messageId: string, card: object): Promise<void> {
243
508
  await this.channel.updateCard(messageId, card)
244
509
  }
@@ -256,6 +521,41 @@ class LarkChannelAdapter implements FeishuChannel {
256
521
  return res?.data?.items?.[0]?.thread_id
257
522
  }
258
523
 
524
+ async messageGet(messageId: string): Promise<unknown> {
525
+ const message = this.channel.rawClient.im.v1.message as unknown as {
526
+ get(input: { path: { message_id: string }; params: { user_id_type: 'user_id' } }): Promise<unknown>
527
+ }
528
+ return await message.get({ path: { message_id: messageId }, params: { user_id_type: 'user_id' } })
529
+ }
530
+
531
+ async messageMget(messageIds: readonly string[]): Promise<unknown> {
532
+ // SDK 1.76 has no typed `im.v1.message.mget`; the endpoint is
533
+ // `GET /open-apis/im/v1/messages/mget` with *repeated* `message_ids`
534
+ // query params (comma-separated ids are rejected by Feishu with 99992354).
535
+ // Axios would serialize an array with brackets, so the query string is
536
+ // built explicitly inside the URL.
537
+ if (messageIds.length === 0 || messageIds.length > 50) {
538
+ throw new Error(`message.mget accepts 1-50 message ids (got ${messageIds.length})`)
539
+ }
540
+ const query = messageIds
541
+ .map(messageId => `message_ids=${encodeURIComponent(messageId)}`)
542
+ .join('&')
543
+ const client = this.channel.rawClient as unknown as {
544
+ request(input: { method: string; url: string }): Promise<unknown>
545
+ }
546
+ return await client.request({
547
+ method: 'GET',
548
+ url: `/open-apis/im/v1/messages/mget?${query}&card_msg_content_type=raw_card_content&user_id_type=user_id`,
549
+ })
550
+ }
551
+
552
+ async messageList(params: RawMessageListParams): Promise<unknown> {
553
+ const message = this.channel.rawClient.im.v1.message as unknown as {
554
+ list(input: { params: RawMessageListParams }): Promise<unknown>
555
+ }
556
+ return await message.list({ params })
557
+ }
558
+
259
559
  async downloadResource(
260
560
  messageId: string,
261
561
  fileKey: string,
@@ -326,6 +626,22 @@ export function sniffImageMediaType(data: Uint8Array): 'image/png' | 'image/jpeg
326
626
  return undefined
327
627
  }
328
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
+
329
645
  const LOGGER_LEVELS: Record<ChannelConnectOptions['loggerLevel'], LoggerLevel> = {
330
646
  debug: LoggerLevel.debug,
331
647
  info: LoggerLevel.info,
package/src/host/agent.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * @module dsh-channels/host/agent
6
6
  */
7
7
 
8
+ import type { AttachmentStore } from './attachments.ts'
8
9
  import type { CommandRuntime } from './commands.ts'
9
10
  import type { Session, SessionId, UserMessage } from './core.ts'
10
11
 
@@ -58,10 +59,64 @@ export interface SystemPromptRuntime {
58
59
  section(section: PromptSection): () => void
59
60
  }
60
61
 
62
+ /** One model-facing content block a channel tool may project (text/image/file). */
63
+ export interface ToolContentBlock {
64
+ readonly type: string
65
+ readonly [key: string]: unknown
66
+ }
67
+
68
+ /** One tool execution's cooperative cancellation signal. */
69
+ export interface ToolExecution {
70
+ readonly signal: AbortSignal
71
+ }
72
+
73
+ /** Canonical output contract of one channel tool registration. */
74
+ export interface ToolOutputContract {
75
+ readonly schema: Record<string, unknown>
76
+ render(args: Record<string, unknown>, value: unknown): readonly ToolContentBlock[]
77
+ presentationMeta?(args: Record<string, unknown>, value: unknown): unknown
78
+ }
79
+
80
+ /**
81
+ * One model-facing tool registration. Kept structural (no runtime import of
82
+ * the host's `@deepseek-ai/dsh-tools` package) so the plugin keeps its
83
+ * zero-host-dependency stance.
84
+ */
85
+ export interface ToolDefinition {
86
+ readonly name: string
87
+ readonly description: string
88
+ readonly parameters: Record<string, unknown>
89
+ readonly output: ToolOutputContract
90
+ readonly timeoutMs?: number
91
+ execute(
92
+ args: Record<string, unknown>,
93
+ exec: ToolExecution,
94
+ ): Promise<unknown> | unknown
95
+ presentCall?(args: Record<string, unknown>): unknown
96
+ presentResult?(args: Record<string, unknown>, value: unknown): unknown
97
+ readonly isConcurrencySafe?: () => boolean
98
+ }
99
+
100
+ /** The Agent-scoped slice of the host `tools` registry. */
101
+ export interface ToolRuntime {
102
+ register(definition: ToolDefinition): unknown
103
+ }
104
+
61
105
  export interface AgentContext {
62
106
  commands: CommandRuntime
63
107
  /** Present inside a setup scope that injected `systemPrompt`. */
64
108
  systemPrompt?: SystemPromptRuntime
109
+ /** Present inside a setup scope that injected `tools`. */
110
+ tools?: ToolRuntime
111
+ /**
112
+ * Present inside a setup scope that injected `attachments` (the DSH native
113
+ * attachment store). Optional: headless or legacy hosts, and test doubles,
114
+ * may not mount it, so consumers resolve it through the degradation-aware
115
+ * helper in `src/feishu/native-attachments.ts` instead of assuming presence.
116
+ */
117
+ attachments?: AttachmentStore
118
+ /** Safe service lookup (returns undefined for absent services). */
119
+ get?(name: string): unknown
65
120
  /** Register an Agent-scoped model-call override. */
66
121
  on(
67
122
  event: 'agent/request',