@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.
- package/README.md +123 -53
- package/docs/access-control-design.md +7 -5
- package/docs/attachment-pipeline-v2.md +310 -0
- package/docs/feasibility-and-design.md +1 -1
- package/docs/feishu-onboarding.md +127 -21
- package/docs/persistence.md +6 -4
- package/docs/refactor-plan.md +2 -2
- package/docs/workspace-isolation-design.md +13 -11
- package/lib/client.js +179 -288
- package/lib/client.js.map +1 -1
- package/lib/index.js +12125 -6382
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/access/cards.ts +9 -9
- package/src/access/controller.ts +10 -10
- package/src/access/index.ts +1 -0
- package/src/access/policy.ts +10 -10
- package/src/access/requests.ts +32 -31
- package/src/access/store.ts +12 -12
- package/src/access/targets.ts +56 -0
- package/src/cards/approval.ts +96 -26
- package/src/cards/index.ts +1 -0
- package/src/cards/onboarding.ts +1 -0
- package/src/cards/picker.ts +115 -0
- package/src/cards/streaming.ts +56 -7
- package/src/client/api.ts +5 -25
- package/src/client/panel/access-card.tsx +24 -15
- package/src/client/panel/access-tab.tsx +3 -3
- package/src/client/panel/connection-tab.tsx +70 -1
- package/src/client/panel/index.tsx +11 -62
- package/src/client/panel/shared.tsx +0 -23
- package/src/client/panel/workspace-tab.tsx +2 -18
- package/src/client/styles.ts +2 -0
- package/src/commands/registry.ts +2 -2
- package/src/config.ts +190 -32
- package/src/domain.ts +28 -18
- package/src/feishu/agent-context.ts +65 -19
- package/src/feishu/inbound-attachments.ts +440 -3
- package/src/feishu/manifest.ts +247 -0
- package/src/feishu/message-read.ts +1330 -0
- package/src/feishu/native-attachments.ts +998 -0
- package/src/feishu/scopes.ts +171 -0
- package/src/feishu/send-file.ts +543 -0
- package/src/feishu/slash-commands.ts +134 -0
- package/src/feishu/tools.ts +2305 -0
- package/src/feishu-channel.ts +322 -6
- package/src/host/agent.ts +55 -0
- package/src/host/attachments.ts +210 -0
- package/src/host/index.ts +1 -0
- package/src/index.ts +5 -4
- package/src/interactions/approvals.ts +187 -7
- package/src/interactions/pending.ts +3 -3
- package/src/routing/inbound.ts +620 -49
- package/src/service.ts +808 -375
- package/src/session/manager.ts +67 -13
- package/src/session/routes.ts +2 -3
- package/src/settings/contract.ts +90 -89
- package/src/settings/overlay.ts +124 -13
- package/src/settings/routes.ts +3 -17
- package/src/settings/surface.ts +156 -82
- package/src/streamer.ts +94 -5
- package/src/workspaces.ts +39 -26
- package/src/cleanup.ts +0 -535
- package/src/client/panel/cleanup-card.tsx +0 -106
package/src/feishu-channel.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
183
|
-
//
|
|
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',
|
|
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',
|