@cubeai/dsh-feishu-channels 0.0.0-stage → 0.2.0

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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +15 -0
  3. package/README.md +56 -2
  4. package/client.js +4189 -0
  5. package/cordis.patch.yml +4 -0
  6. package/docs/coexistence.md +112 -0
  7. package/docs/dsh-feishu-channels-plan.md +1406 -0
  8. package/docs/ui-revamp-plan.md +263 -0
  9. package/docs/verification.md +120 -0
  10. package/icon.svg +17 -0
  11. package/locale/en.json +6 -0
  12. package/locale/zh.json +6 -0
  13. package/package.json +76 -4
  14. package/src/channels/connection-state.js +12 -0
  15. package/src/channels/diagnostics.js +77 -0
  16. package/src/channels/factory.js +20 -0
  17. package/src/channels/feishu-app.js +267 -0
  18. package/src/channels/feishu.js +399 -0
  19. package/src/channels/media.js +9 -0
  20. package/src/channels/meta.js +24 -0
  21. package/src/channels/qr/feishu.js +84 -0
  22. package/src/channels/qr/hub.js +229 -0
  23. package/src/channels/qr/render.js +8 -0
  24. package/src/channels/qr/shared.js +50 -0
  25. package/src/engine/abort.js +35 -0
  26. package/src/engine/access.js +23 -0
  27. package/src/engine/agent-options.js +19 -0
  28. package/src/engine/approval.js +70 -0
  29. package/src/engine/assistant-settings.js +38 -0
  30. package/src/engine/assistant-text.js +11 -0
  31. package/src/engine/atomic-file.js +45 -0
  32. package/src/engine/chat-commands.js +752 -0
  33. package/src/engine/choice-delivery.js +20 -0
  34. package/src/engine/choices.js +103 -0
  35. package/src/engine/command-locale.js +70 -0
  36. package/src/engine/command-messages.js +434 -0
  37. package/src/engine/command-permissions.js +38 -0
  38. package/src/engine/command-replies.js +59 -0
  39. package/src/engine/credentials.js +54 -0
  40. package/src/engine/deferred-delivery.js +260 -0
  41. package/src/engine/delivery-history.js +39 -0
  42. package/src/engine/file-delivery.js +215 -0
  43. package/src/engine/file-input.js +52 -0
  44. package/src/engine/file-log.js +25 -0
  45. package/src/engine/gateway.js +1337 -0
  46. package/src/engine/image-input.js +31 -0
  47. package/src/engine/json-state.js +25 -0
  48. package/src/engine/keyed-queue.js +20 -0
  49. package/src/engine/merge.js +56 -0
  50. package/src/engine/message-progress.js +274 -0
  51. package/src/engine/question.js +201 -0
  52. package/src/engine/quiet-logger.js +25 -0
  53. package/src/engine/reply-stream.js +86 -0
  54. package/src/engine/router.js +713 -0
  55. package/src/engine/secure-file.js +8 -0
  56. package/src/engine/seen-store.js +43 -0
  57. package/src/engine/session-export.js +64 -0
  58. package/src/engine/session-history.js +30 -0
  59. package/src/engine/session-id.js +62 -0
  60. package/src/engine/session-store.js +109 -0
  61. package/src/engine/session-title.js +29 -0
  62. package/src/engine/split.js +291 -0
  63. package/src/engine/workspace-path.js +9 -0
  64. package/src/index.js +62 -0
  65. package/src/manager.js +1325 -0
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Feishu app provisioning for QR-created PersonalAgent apps.
3
+ *
4
+ * - QR link: prefill app name and incremental scopes via `name` / `addons`.
5
+ * - After creation: try to write scopes / redirect URLs / name through the
6
+ * application OpenAPI. Some Feishu tenants reject these for apps created
7
+ * outside the developer console; callers must treat the result as best-effort.
8
+ */
9
+ import { gzipSync } from 'node:zlib'
10
+
11
+ export const FEISHU_APP_SCOPES = {
12
+ // `application:application:patch` is required by the app-config OpenAPI
13
+ // that writes redirect URLs. Without it Feishu returns 99991672 even for
14
+ // an app updating itself. It is prefilled together with the user-requested
15
+ // message scopes so a freshly QR-created app can configure itself.
16
+ tenant: ['im:message', 'application:application:patch'],
17
+ user: ['im:message.send_as_user'],
18
+ }
19
+
20
+ export const FEISHU_REDIRECT_URLS = [
21
+ 'http://127.0.0.1:3080/api/dsh-remote-feishu/oauth/callback',
22
+ 'http://localhost:3080/api/dsh-remote-feishu/oauth/callback',
23
+ 'http://127.0.0.1:32123/api/dsh-remote-feishu/oauth/callback',
24
+ 'http://127.0.0.1:8731/api/oauth/callback',
25
+ 'https://www.cubeai.com:6443/cubeaispace-dsh1-1234567890-dsh/api/dsh-remote-feishu/oauth/callback',
26
+ ]
27
+
28
+ const REQUESTED_SCOPES = [
29
+ { scope_name: 'im:message', token_type: 'tenant' },
30
+ { scope_name: 'im:message.send_as_user', token_type: 'user' },
31
+ ]
32
+
33
+ /** Encode addons exactly like the Feishu SDK: JSON -> gzip -> base64url. */
34
+ export function encodeAddons(addons = { scopes: FEISHU_APP_SCOPES }) {
35
+ const json = JSON.stringify(addons)
36
+ return gzipSync(Buffer.from(json, 'utf8'))
37
+ .toString('base64')
38
+ .replace(/\+/g, '-')
39
+ .replace(/\//g, '_')
40
+ .replace(/=+$/, '')
41
+ }
42
+
43
+ /** Prefill the app-creation page. The user may still edit these values in Feishu. */
44
+ export function decorateQrUrl(raw, { name } = {}) {
45
+ const url = new URL(raw)
46
+ url.searchParams.set('from', 'sdk')
47
+ url.searchParams.set('tp', 'sdk')
48
+ url.searchParams.set('source', 'dsh-feishu-channels')
49
+ url.searchParams.set('createOnly', 'true')
50
+ if (typeof name === 'string' && name.trim()) url.searchParams.set('name', name.trim())
51
+ url.searchParams.set('addons', encodeAddons({ scopes: FEISHU_APP_SCOPES }))
52
+ return url.toString()
53
+ }
54
+
55
+ function safeError(step, error) {
56
+ const response = error && typeof error === 'object' ? error.response : undefined
57
+ // Prefer the business code inside response.data: Axios puts a string such as
58
+ // ERR_BAD_REQUEST on error.code, which hides Feishu's numeric code.
59
+ const rawCode = response?.data?.code ?? error?.code ?? response?.status
60
+ const rawStatus = response?.status
61
+ const rawMessage = typeof response?.data?.msg === 'string'
62
+ ? response.data.msg
63
+ : typeof error?.msg === 'string'
64
+ ? error.msg
65
+ : typeof error?.message === 'string' ? error.message : ''
66
+ const message = /secret|token|authorization|app_secret/i.test(rawMessage)
67
+ ? 'request failed'
68
+ : rawMessage.slice(0, 160)
69
+ const code = Number(rawCode)
70
+ const status = Number(rawStatus)
71
+ return {
72
+ step,
73
+ ...(Number.isFinite(code) ? { code } : {}),
74
+ ...(Number.isFinite(status) ? { status } : {}),
75
+ ...(message ? { message } : {}),
76
+ }
77
+ }
78
+
79
+ function noopLogger() {
80
+ return { debug() {}, info() {}, warn() {}, error() {}, trace() {} }
81
+ }
82
+
83
+ async function readApp(client, appId) {
84
+ const response = await client.application.v6.application.get({
85
+ path: { app_id: appId },
86
+ params: { lang: 'zh_cn' },
87
+ })
88
+ return response?.data?.app ?? response?.app ?? null
89
+ }
90
+
91
+ function scopeNames(app) {
92
+ return (app?.scopes ?? []).map((item) => String(item?.scope ?? '')).filter(Boolean)
93
+ }
94
+
95
+ function redirectUrls(app) {
96
+ const list = app?.security?.redirect_urls ?? app?.redirect_urls ?? []
97
+ return Array.isArray(list) ? list.map(String) : []
98
+ }
99
+
100
+ /**
101
+ * Best-effort app provisioning. Never throws and never returns the App Secret.
102
+ * @returns {Promise<{
103
+ * checkedAt: string,
104
+ * name: { requested: string, present?: string, applied: boolean, ok: boolean },
105
+ * scopes: { requested: string[], present: string[], missing: string[], applied: boolean },
106
+ * redirectUrls: { requested: string[], present: string[], missing: string[], written: boolean },
107
+ * verified: boolean,
108
+ * errors: Array<{ step: string, code?: number, status?: number, message?: string }>
109
+ * }>}
110
+ */
111
+ export async function provisionFeishuApp({ appId, appSecret, name, log = () => {}, client: injectedClient }) {
112
+ const requestedName = typeof name === 'string' ? name.trim() : ''
113
+ const result = {
114
+ checkedAt: new Date().toISOString(),
115
+ name: { requested: requestedName, applied: false, ok: false },
116
+ scopes: {
117
+ requested: REQUESTED_SCOPES.map((item) => item.scope_name),
118
+ present: [],
119
+ missing: REQUESTED_SCOPES.map((item) => item.scope_name),
120
+ applied: false,
121
+ },
122
+ redirectUrls: {
123
+ requested: [...FEISHU_REDIRECT_URLS],
124
+ present: [],
125
+ missing: [...FEISHU_REDIRECT_URLS],
126
+ written: false,
127
+ },
128
+ verified: false,
129
+ errors: [],
130
+ }
131
+ if (!appId || !appSecret) {
132
+ result.errors.push({ step: 'credentials', message: 'missing app credentials' })
133
+ return result
134
+ }
135
+
136
+ let client = injectedClient
137
+ if (!client) {
138
+ try {
139
+ const sdk = await import('@larksuiteoapi/node-sdk')
140
+ client = new sdk.Client({
141
+ appId,
142
+ appSecret,
143
+ logger: noopLogger(),
144
+ loggerLevel: sdk.LoggerLevel?.error ?? 1,
145
+ })
146
+ } catch (error) {
147
+ result.errors.push(safeError('sdk', error))
148
+ log('[feishu-app] SDK 不可用,跳过应用配置')
149
+ return result
150
+ }
151
+ }
152
+
153
+ if (requestedName) {
154
+ try {
155
+ await client.application.v7.applicationBase.patch({
156
+ path: { app_id: appId },
157
+ data: { i18ns: [{ i18n_key: 'zh_cn', name: requestedName }] },
158
+ })
159
+ result.name.applied = true
160
+ } catch (error) {
161
+ result.errors.push(safeError('name', error))
162
+ }
163
+ }
164
+
165
+ let configApplied = false
166
+ try {
167
+ await client.application.v7.applicationConfig.patch({
168
+ path: { app_id: appId },
169
+ data: {
170
+ scope: { add_scopes: REQUESTED_SCOPES },
171
+ security: { add: { redirect_urls: FEISHU_REDIRECT_URLS } },
172
+ },
173
+ })
174
+ configApplied = true
175
+ result.scopes.applied = true
176
+ result.redirectUrls.written = true
177
+ } catch (error) {
178
+ result.errors.push(safeError('config-v7', error))
179
+ }
180
+
181
+ if (!configApplied) {
182
+ try {
183
+ await client.application.v7.applicationConfig.patch({
184
+ path: { app_id: appId },
185
+ data: { scope: { add_scopes: REQUESTED_SCOPES } },
186
+ })
187
+ result.scopes.applied = true
188
+ } catch (error) {
189
+ result.errors.push(safeError('scopes-v7', error))
190
+ }
191
+ try {
192
+ await client.application.v7.applicationConfig.patch({
193
+ path: { app_id: appId },
194
+ data: { security: { add: { redirect_urls: FEISHU_REDIRECT_URLS } } },
195
+ })
196
+ result.redirectUrls.written = true
197
+ } catch (error) {
198
+ result.errors.push(safeError('redirect-v7', error))
199
+ }
200
+ }
201
+
202
+ // v6 compatibility: security.redirect_urls is a full-list update. Merge the
203
+ // current list first so we never drop an existing redirect URL.
204
+ let v6Attempted = false
205
+ const attemptV6Redirects = async () => {
206
+ if (v6Attempted) return
207
+ v6Attempted = true
208
+ let existing = []
209
+ try {
210
+ const app = await readApp(client, appId)
211
+ existing = redirectUrls(app)
212
+ } catch (error) {
213
+ result.errors.push(safeError('app-get', error))
214
+ }
215
+ const merged = [...new Set([...existing, ...FEISHU_REDIRECT_URLS])]
216
+ try {
217
+ await client.application.v6.application.patch({
218
+ path: { app_id: appId },
219
+ params: { lang: 'zh_cn' },
220
+ data: { security: { redirect_urls: merged } },
221
+ })
222
+ result.redirectUrls.written = true
223
+ } catch (error) {
224
+ result.errors.push(safeError('redirect-v6', error))
225
+ }
226
+ }
227
+
228
+ if (!result.redirectUrls.written) await attemptV6Redirects()
229
+
230
+ const observeApp = (app) => {
231
+ if (!app) return
232
+ result.verified = true
233
+ const presentScopes = scopeNames(app)
234
+ result.scopes.present = result.scopes.requested.filter((key) => presentScopes.includes(key))
235
+ result.scopes.missing = result.scopes.requested.filter((key) => !presentScopes.includes(key))
236
+ const presentRedirects = redirectUrls(app)
237
+ result.redirectUrls.present = result.redirectUrls.requested.filter((url) => presentRedirects.includes(url))
238
+ result.redirectUrls.missing = result.redirectUrls.requested.filter((url) => !presentRedirects.includes(url))
239
+ result.redirectUrls.written = result.redirectUrls.missing.length === 0
240
+ if (typeof app.app_name === 'string') {
241
+ result.name.present = app.app_name
242
+ result.name.ok = requestedName !== '' && app.app_name === requestedName
243
+ }
244
+ result.scopes.applied = result.scopes.missing.length === 0
245
+ }
246
+
247
+ try {
248
+ observeApp(await readApp(client, appId))
249
+ } catch (error) {
250
+ result.errors.push(safeError('verify', error))
251
+ }
252
+
253
+ // v7 may report success while the tenant silently ignores the redirect
254
+ // update. If verification still misses some URLs, try v6 once more.
255
+ if (result.redirectUrls.missing.length > 0 && !v6Attempted) {
256
+ await attemptV6Redirects()
257
+ try {
258
+ observeApp(await readApp(client, appId))
259
+ } catch (error) {
260
+ result.errors.push(safeError('verify-v6', error))
261
+ }
262
+ }
263
+
264
+ const failed = result.errors.map((item) => item.step).join(',')
265
+ log(`[feishu-app] 配置完成:name=${result.name.ok ? 'ok' : 'pending'} scopes=${result.scopes.missing.length === 0 ? 'ok' : 'missing'} redirects=${result.redirectUrls.missing.length === 0 ? 'ok' : 'missing'}${failed ? ` errors=${failed}` : ''}`)
266
+ return result
267
+ }
@@ -0,0 +1,399 @@
1
+ import { channelNotice } from '../engine/command-locale.js';
2
+ import { choiceSendError, ChoiceSendError } from '../engine/choice-delivery.js';
3
+ import { afterPartialDelivery, DeliveryRejected } from '../engine/deferred-delivery.js';
4
+ import { diagnosticJson, platformResult, probe, requireDiagnostic } from './diagnostics.js';
5
+ import { fileOperation } from '../engine/abort.js';
6
+ import { quietSdkLogger } from '../engine/quiet-logger.js';
7
+ import { KeyedSerialQueue } from '../engine/keyed-queue.js';
8
+ import { timeoutSignal } from '../engine/abort.js';
9
+ import { fileMedia, MAX_CHANNEL_IMAGES } from './media.js';
10
+ import { markdownTableOffsets } from '../engine/split.js';
11
+ /** 飞书一张卡片最多 5 个 table 元素,超了整个写入会被拒(230099 / ErrCode 11310),旧卡片内容会卡住。 */
12
+ export const FEISHU_CARD_MAX_TABLES = 5;
13
+ /** 按表格数量拆正文,保证每张卡不超上限,且不会把表格切一半。 */
14
+ export function splitByTableLimit(text, maxTables = FEISHU_CARD_MAX_TABLES) {
15
+ const value = text ?? '';
16
+ const starts = markdownTableOffsets(value);
17
+ if (starts.length <= maxTables)
18
+ return value ? [value] : [];
19
+ const parts = [];
20
+ let from = 0;
21
+ for (let index = maxTables; index < starts.length; index += maxTables) {
22
+ parts.push(value.slice(from, starts[index]));
23
+ from = starts[index];
24
+ }
25
+ parts.push(value.slice(from));
26
+ return parts.filter((part) => part.trim() !== '');
27
+ }
28
+ /** 飞书限流:消息接口 230020 / 11232,通用频控 99991400(HTTP 429,旧接口为 400)。 */
29
+ const FEISHU_THROTTLE_CODES = new Set([230020, 11232, 99991400]);
30
+ function isThrottleCode(code) {
31
+ return FEISHU_THROTTLE_CODES.has(Number(code));
32
+ }
33
+ /** SDK 在 HTTP 非 2xx 时抛 AxiosError:429,或响应体带限流码,说明请求被限流、未被受理。 */
34
+ function throttledResponse(error) {
35
+ const response = error?.response;
36
+ return response?.status === 429 || isThrottleCode(response?.data?.code);
37
+ }
38
+ /** 诊断只保留受限长度的数字状态码,禁止输出响应正文、请求配置或凭据。 */
39
+ function httpFailureSummary(error) {
40
+ const response = error?.response;
41
+ const status = response?.status;
42
+ const code = response?.data?.code;
43
+ const safeStatus = typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? status : 'unknown';
44
+ const safeCode = typeof code === 'number' && Number.isSafeInteger(code) && code >= 0 && code <= 999_999_999
45
+ ? code : typeof code === 'string' && /^\d{1,9}$/.test(code) ? code : 'unknown';
46
+ return `http=${safeStatus} code=${safeCode}`;
47
+ }
48
+ // 沿用 SDK 允许缺省业务码的响应,同时兼容数字和字符串零码。
49
+ function isRejectedCode(code) {
50
+ return code !== undefined && code !== 0 && code !== '0';
51
+ }
52
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
53
+ // Shared across all images in one message, not renewed for each resource.
54
+ const MAX_MESSAGE_IMAGE_BYTES = MAX_IMAGE_BYTES;
55
+ /** SDK messageResource.get is the receive-side API, not im.image.get. */
56
+ export async function resolveFeishuContent(client, message, signal = AbortSignal.timeout(30_000)) {
57
+ const content = JSON.parse(message.content ?? '{}');
58
+ const keys = [];
59
+ const texts = [];
60
+ if (message.message_type === 'text')
61
+ texts.push(content.text ?? '');
62
+ else if (message.message_type === 'image') {
63
+ if (!content.image_key)
64
+ throw new Error('图片资源缺失');
65
+ keys.push(content.image_key);
66
+ }
67
+ else if (message.message_type === 'file') {
68
+ if (!content.file_key)
69
+ throw new Error('文件资源缺失');
70
+ keys.push(content.file_key);
71
+ }
72
+ else if (message.message_type === 'post') {
73
+ const post = content.content ? content : content.zh_cn ?? content.en_us ?? Object.values(content)[0];
74
+ if (!post || !Array.isArray(post.content))
75
+ throw new Error('富文本内容无效');
76
+ if (post.title)
77
+ texts.push(post.title);
78
+ for (const row of post.content) {
79
+ for (const item of row) {
80
+ if (item.tag === 'img') {
81
+ if (!item.image_key)
82
+ throw new Error('图片资源缺失');
83
+ keys.push(item.image_key);
84
+ }
85
+ else if (typeof item.text === 'string')
86
+ texts.push(item.text);
87
+ }
88
+ }
89
+ }
90
+ else
91
+ throw new Error('暂不支持该消息类型,请发送文字或图片。');
92
+ if (keys.length > MAX_CHANNEL_IMAGES)
93
+ throw new Error('图片数量超过上限');
94
+ const media = [];
95
+ let totalBytes = 0;
96
+ for (const key of keys) {
97
+ if (totalBytes >= MAX_MESSAGE_IMAGE_BYTES)
98
+ throw new Error('图片累计大小超过上限');
99
+ if (!message.message_id)
100
+ throw new Error('图片消息 ID 缺失');
101
+ signal.throwIfAborted();
102
+ let stream;
103
+ let abort = () => { };
104
+ const aborted = new Promise((_, reject) => {
105
+ abort = () => { stream?.destroy(new Error('图片下载已取消或超时')); reject(new Error('图片下载已取消或超时')); };
106
+ signal.addEventListener('abort', abort, { once: true });
107
+ });
108
+ try {
109
+ const resource = await Promise.race([
110
+ client.im.messageResource.get({ path: { message_id: message.message_id, file_key: key }, params: { type: message.message_type === 'file' ? 'file' : 'image' } }).then(resource => {
111
+ stream = resource.getReadableStream();
112
+ if (signal.aborted) {
113
+ stream.destroy();
114
+ throw new Error('图片下载已取消或超时');
115
+ }
116
+ return resource;
117
+ }), aborted,
118
+ ]);
119
+ const consume = async () => {
120
+ const chunks = [];
121
+ let size = 0;
122
+ if (Number(resource.headers?.['content-length']) > MAX_IMAGE_BYTES)
123
+ throw new Error('图片超过 20 MB 上限');
124
+ if (Number(resource.headers?.['content-length']) > MAX_MESSAGE_IMAGE_BYTES - totalBytes)
125
+ throw new Error('图片累计大小超过上限');
126
+ for await (const chunk of stream) {
127
+ const bytes = Buffer.from(chunk);
128
+ size += bytes.length;
129
+ totalBytes += bytes.length;
130
+ if (totalBytes > MAX_MESSAGE_IMAGE_BYTES)
131
+ throw new Error('图片累计大小超过上限');
132
+ if (size > MAX_IMAGE_BYTES)
133
+ throw new Error('图片超过 20 MB 上限');
134
+ chunks.push(bytes);
135
+ }
136
+ if (!size)
137
+ throw new Error('图片为空');
138
+ if (message.message_type === 'file')
139
+ return fileMedia(Buffer.concat(chunks), content.file_name);
140
+ return { kind: 'image', data: Buffer.concat(chunks), mediaType: resource.headers?.['content-type']?.split(';')[0] };
141
+ };
142
+ media.push(await Promise.race([consume(), aborted]));
143
+ }
144
+ finally {
145
+ signal.removeEventListener('abort', abort);
146
+ stream?.destroy();
147
+ }
148
+ }
149
+ return { text: texts.join('\n'), media };
150
+ }
151
+ /** 群消息只有明确 mention 当前机器人本身才算 addressed;@ 其他成员不触发。 */
152
+ export function isFeishuBotMentioned(mentions, botOpenId) {
153
+ if (!botOpenId || !Array.isArray(mentions))
154
+ return false;
155
+ return mentions.some((mention) => mention.id?.open_id === botOpenId);
156
+ }
157
+ export function createFeishuChannel(id, config, log, loadSdk) {
158
+ const appId = config.appId?.trim();
159
+ const appSecret = config.appSecret?.trim();
160
+ if (!appId || !appSecret)
161
+ return undefined;
162
+ let handler;
163
+ let client;
164
+ let ws;
165
+ let statusText = '未连接';
166
+ let lifecycle;
167
+ const label = '飞书';
168
+ return {
169
+ id,
170
+ label,
171
+ maxMessageLength: 4000,
172
+ async start() {
173
+ lifecycle?.abort();
174
+ const generation = new AbortController();
175
+ lifecycle = generation;
176
+ const current = () => lifecycle === generation && !generation.signal.aborted;
177
+ const incoming = new KeyedSerialQueue();
178
+ let sdk;
179
+ try {
180
+ sdk = await (loadSdk ? loadSdk() : import('@larksuiteoapi/node-sdk'));
181
+ }
182
+ catch {
183
+ throw new Error('缺少依赖 @larksuiteoapi/node-sdk');
184
+ }
185
+ const logger = quietSdkLogger(() => log(`[${id}] SDK 请求或连接异常(敏感详情已隐藏)`), id);
186
+ const loggerLevel = sdk.LoggerLevel?.error ?? 1;
187
+ const httpInstance = Object.create(sdk.defaultHttpInstance);
188
+ httpInstance.request = (opts) => sdk.defaultHttpInstance.request({ ...opts, timeout: 30_000, maxRedirects: 0 });
189
+ client = new sdk.Client({ appId, appSecret, logger, loggerLevel, httpInstance });
190
+ const identity = await client.request({ url: '/open-apis/bot/v3/info', method: 'GET' });
191
+ const botOpenId = identity.bot?.open_id?.trim() ?? '';
192
+ if (!botOpenId)
193
+ throw new Error(`${id}: 无法获取机器人 open_id`);
194
+ const dispatcher = new sdk.EventDispatcher({}).register({
195
+ 'card.action.trigger': async (data) => {
196
+ const token = data.action?.value?.token;
197
+ const chatId = data.context?.open_chat_id;
198
+ if (current() && token && chatId && data.operator?.open_id)
199
+ void Promise.resolve(handler?.({ chatId, userId: data.operator.open_id,
200
+ text: '', actionToken: token, kind: data.action?.value?.group ? 'group' : 'dm', addressed: true,
201
+ })).catch(() => log(`[${id}] 按钮操作失败`));
202
+ return {};
203
+ },
204
+ 'im.message.receive_v1': async (data) => {
205
+ const message = data.message;
206
+ if (!message)
207
+ return;
208
+ const group = message.chat_type === 'group';
209
+ const addressed = !group || isFeishuBotMentioned(message.mentions, botOpenId);
210
+ if (!addressed || !message.chat_id || !current())
211
+ return;
212
+ const chatId = message.chat_id;
213
+ return incoming.run(chatId, async () => {
214
+ if (!current())
215
+ return;
216
+ let resolved;
217
+ try {
218
+ resolved = await resolveFeishuContent(client, message, timeoutSignal(30_000, generation.signal));
219
+ }
220
+ catch {
221
+ if (!current())
222
+ return;
223
+ log(`[${id}] 图片或消息读取失败`);
224
+ await client?.im.message.create({
225
+ params: { receive_id_type: 'chat_id' },
226
+ data: { receive_id: message.chat_id, msg_type: 'text', content: JSON.stringify({ text: channelNotice(config.host, '图片或消息读取失败,请重发图片或发送文字。') }) },
227
+ }).then(result => {
228
+ const code = result?.code;
229
+ if (isRejectedCode(code))
230
+ throw new Error('media-notice-rejected');
231
+ }).catch(() => log(`[${id}] 媒体提示发送失败`));
232
+ return;
233
+ }
234
+ if (!current())
235
+ return;
236
+ await handler?.({
237
+ chatId,
238
+ userId: data.sender?.sender_id?.open_id,
239
+ text: resolved.text,
240
+ ...(resolved.media.length ? { media: resolved.media } : {}),
241
+ messageId: message.message_id,
242
+ kind: group ? 'group' : 'dm',
243
+ addressed,
244
+ });
245
+ }).catch(() => { if (current())
246
+ log(`[${id}] 消息处理失败`); });
247
+ },
248
+ });
249
+ const wsClient = new sdk.WSClient({ appId, appSecret, logger, loggerLevel });
250
+ ws = wsClient;
251
+ await wsClient.start({ eventDispatcher: dispatcher });
252
+ statusText = '长连接已建立';
253
+ log(`[${id}] WebSocket 长连接已启动`);
254
+ },
255
+ async stop() {
256
+ lifecycle?.abort();
257
+ lifecycle = undefined;
258
+ ws?.close({ force: true });
259
+ ws = undefined;
260
+ statusText = '已停止';
261
+ },
262
+ canDeliverDeferred() { return statusText === '长连接已建立'; },
263
+ async send(chatId, text) {
264
+ if (!client)
265
+ throw new Error(`${id}: 尚未连接`);
266
+ const sender = client;
267
+ // 普通回复也走卡片 markdown,标题、列表和表格才会被渲染;按表格数量拆卡。
268
+ let delivered = false;
269
+ for (const part of splitByTableLimit(text)) {
270
+ try {
271
+ // 仅明确业务拒绝或 HTTP 400 回退;网络结果未知时不重发,避免重复投递。
272
+ // 飞书要渲染 Markdown 表格,卡片必须是 Schema 2.0(body.elements)
273
+ const card = await sender.im.message.create({
274
+ params: { receive_id_type: 'chat_id' },
275
+ data: {
276
+ receive_id: chatId,
277
+ msg_type: 'interactive',
278
+ content: JSON.stringify({ schema: '2.0', config: { wide_screen_mode: true }, body: { elements: [{ tag: 'markdown', content: part }] } }),
279
+ },
280
+ }).catch((error) => {
281
+ const response = error?.response;
282
+ // 旧接口也可能用 HTTP 400 返回限流,必须优先保留上层退避语义。
283
+ if (response?.status !== 400 || throttledResponse(error))
284
+ throw error;
285
+ log(`[${id}] 卡片发送被拒,回退纯文本 ${httpFailureSummary(error)}`);
286
+ return { code: 'http-400' };
287
+ });
288
+ if (!isRejectedCode(card?.code)) {
289
+ delivered = true;
290
+ continue;
291
+ }
292
+ // 被限流时改发纯文本只会再被限流,直接交给上层退避重试。
293
+ if (isThrottleCode(card?.code))
294
+ throw new DeliveryRejected(`${id}: text-send-throttled code=${card?.code}`);
295
+ const rejected = card?.code;
296
+ // 卡片被拒(内容过大、接口不支持等)时退回纯文本,不丢正文。
297
+ const plain = await sender.im.message.create({
298
+ params: { receive_id_type: 'chat_id' },
299
+ data: { receive_id: chatId, msg_type: 'text', content: JSON.stringify({ text: part }) },
300
+ }).catch((error) => {
301
+ log(`[${id}] 纯文本回退发送失败 ${httpFailureSummary(error)}`);
302
+ throw error;
303
+ });
304
+ if (isThrottleCode(plain?.code))
305
+ throw new DeliveryRejected(`${id}: text-send-throttled code=${plain?.code}`);
306
+ if (isRejectedCode(plain?.code))
307
+ throw new Error(`${id}: text-send-rejected code=${plain?.code} card=${String(rejected)}`);
308
+ delivered = true;
309
+ }
310
+ catch (error) {
311
+ const normalized = error instanceof DeliveryRejected || !throttledResponse(error)
312
+ ? error : new DeliveryRejected(`${id}: text-send-throttled http=${error.response?.status}`);
313
+ throw delivered ? afterPartialDelivery(normalized) : normalized;
314
+ }
315
+ }
316
+ },
317
+ async sendChoices(message, text, buttons) {
318
+ if (!client)
319
+ throw new Error('channel-unavailable');
320
+ const result = await client.im.message.create({ params: { receive_id_type: 'chat_id' }, data: {
321
+ receive_id: message.chatId, msg_type: 'interactive', content: JSON.stringify({ config: { wide_screen_mode: true }, elements: [
322
+ { tag: 'markdown', content: text },
323
+ ...buttons.map(button => ({ tag: 'action', actions: [{ tag: 'button', text: { tag: 'plain_text', content: button.label.slice(0, 60) },
324
+ type: 'default', value: { token: button.token, group: message.kind === 'group' } }] })),
325
+ ] }),
326
+ } }).catch(error => { throw choiceSendError(error); });
327
+ if (isRejectedCode(result?.code))
328
+ throw new ChoiceSendError('rejected');
329
+ const messageId = result?.data?.message_id;
330
+ const sender = client;
331
+ if (!messageId)
332
+ return;
333
+ return { close: async (status) => {
334
+ const updated = await sender.im.message.patch({ path: { message_id: messageId }, data: { content: JSON.stringify({ config: { wide_screen_mode: true }, elements: [
335
+ { tag: 'markdown', content: text }, { tag: 'div', text: { tag: 'plain_text', content: status } },
336
+ ] }) } });
337
+ if (isRejectedCode(updated?.code))
338
+ throw new Error('card-update-failed');
339
+ } };
340
+ },
341
+ async addStatusReaction(message, state, _label, signal) {
342
+ if (!client || !message.messageId || state === 'cancelled')
343
+ return;
344
+ const emoji = (state === 'success' || state === 'ended') ? 'DONE' : state === 'error' ? 'ERROR' : 'OnIt';
345
+ signal.throwIfAborted();
346
+ const result = await fileOperation(client.im.messageReaction.create({
347
+ path: { message_id: message.messageId }, data: { reaction_type: { emoji_type: emoji } },
348
+ }), signal);
349
+ if (isRejectedCode(result.code) || !result.data?.reaction_id)
350
+ throw new Error('reaction-rejected');
351
+ return result.data.reaction_id;
352
+ },
353
+ async removeStatusReaction(message, reaction, signal) {
354
+ if (!client)
355
+ return;
356
+ signal.throwIfAborted();
357
+ const result = await fileOperation(client.im.messageReaction.delete({ path: { message_id: message.messageId, reaction_id: reaction } }), signal);
358
+ if (isRejectedCode(result.code))
359
+ throw new Error('reaction-rejected');
360
+ },
361
+ async sendFile(chatId, file, signal) {
362
+ if (!client || !lifecycle)
363
+ throw new Error(`${id}: 尚未连接`);
364
+ const active = client;
365
+ const scope = AbortSignal.any([timeoutSignal(120_000, lifecycle.signal), ...(signal ? [signal] : [])]);
366
+ scope.throwIfAborted();
367
+ const uploaded = await fileOperation(active.im.file.create({ data: { file_type: 'stream', file_name: file.name, file: Buffer.from(file.data) } }), scope);
368
+ scope.throwIfAborted();
369
+ if (!uploaded?.file_key)
370
+ throw new Error('file-upload-rejected');
371
+ const sent = await active.im.message.create({
372
+ params: { receive_id_type: 'chat_id' },
373
+ data: { receive_id: chatId, msg_type: 'file', content: JSON.stringify({ file_key: uploaded.file_key }) },
374
+ });
375
+ if (isRejectedCode(sent.code))
376
+ throw new Error('file-send-rejected');
377
+ },
378
+ setMessageHandler(h) { handler = h; },
379
+ async diagnose(signal) {
380
+ const domain = 'https://open.feishu.cn';
381
+ let token;
382
+ const auth = await probe('credentials', signal, async () => {
383
+ const data = await diagnosticJson(`${domain}/open-apis/auth/v3/tenant_access_token/internal`, signal, { app_id: appId, app_secret: appSecret });
384
+ platformResult(data.code, [10003, 10014]);
385
+ requireDiagnostic(typeof data.tenant_access_token === 'string' && data.tenant_access_token);
386
+ token = data.tenant_access_token;
387
+ });
388
+ if (auth.status !== 'passed')
389
+ return [auth];
390
+ return [auth, await probe('bot', signal, async () => {
391
+ const data = await diagnosticJson(`${domain}/open-apis/bot/v3/info`, signal, undefined, { Authorization: `Bearer ${token}` });
392
+ platformResult(data.code);
393
+ const bot = data.bot;
394
+ requireDiagnostic(typeof bot?.open_id === 'string' && bot.open_id);
395
+ })];
396
+ },
397
+ status() { return statusText; },
398
+ };
399
+ }
@@ -0,0 +1,9 @@
1
+ /** Media helpers used by the Feishu adapter. */
2
+ export const MAX_CHANNEL_IMAGES = 4
3
+
4
+ /** Generic files are not validated by image magic bytes; Chat admission decides. */
5
+ export function fileMedia(data, name = 'file.bin', maxBytes = 20 * 1024 * 1024) {
6
+ if (!data.length || data.length > maxBytes) throw new Error('文件为空或超过大小限制')
7
+ const leaf = name.replace(/\\/g, '/').split('/').pop()?.replace(/[\x00-\x1f]/g, '').slice(0, 200) || 'file.bin'
8
+ return { kind: 'file', data, name: leaf }
9
+ }