dsh-connect 0.7.2 → 0.8.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 (114) hide show
  1. package/README.md +68 -32
  2. package/README.zh.md +67 -31
  3. package/client/client.js +387 -0
  4. package/client/client.js.map +7 -0
  5. package/client/settings-client.mjs +177 -0
  6. package/examples/minimal.config.json +16 -0
  7. package/lib/channels/dingtalk/adapter.d.ts +59 -0
  8. package/lib/channels/dingtalk/adapter.d.ts.map +1 -0
  9. package/lib/channels/dingtalk/adapter.js +123 -0
  10. package/lib/channels/dingtalk/adapter.js.map +1 -0
  11. package/lib/channels/dingtalk/i18n.d.ts +14 -0
  12. package/lib/channels/dingtalk/i18n.d.ts.map +1 -0
  13. package/lib/channels/dingtalk/i18n.js +16 -0
  14. package/lib/channels/dingtalk/i18n.js.map +1 -0
  15. package/lib/channels/dingtalk/index.d.ts +127 -0
  16. package/lib/channels/dingtalk/index.d.ts.map +1 -0
  17. package/lib/channels/dingtalk/index.js +144 -0
  18. package/lib/channels/dingtalk/index.js.map +1 -0
  19. package/lib/channels/dingtalk/message.d.ts +49 -0
  20. package/lib/channels/dingtalk/message.d.ts.map +1 -0
  21. package/lib/channels/dingtalk/message.js +43 -0
  22. package/lib/channels/dingtalk/message.js.map +1 -0
  23. package/lib/channels/dingtalk/stomp.d.ts +36 -0
  24. package/lib/channels/dingtalk/stomp.d.ts.map +1 -0
  25. package/lib/channels/dingtalk/stomp.js +107 -0
  26. package/lib/channels/dingtalk/stomp.js.map +1 -0
  27. package/lib/channels/dingtalk/stream.d.ts +53 -0
  28. package/lib/channels/dingtalk/stream.d.ts.map +1 -0
  29. package/lib/channels/dingtalk/stream.js +199 -0
  30. package/lib/channels/dingtalk/stream.js.map +1 -0
  31. package/lib/channels/dingtalk/webhook.d.ts +84 -0
  32. package/lib/channels/dingtalk/webhook.d.ts.map +1 -0
  33. package/lib/channels/dingtalk/webhook.js +143 -0
  34. package/lib/channels/dingtalk/webhook.js.map +1 -0
  35. package/lib/channels/feishu/adapter.d.ts +139 -0
  36. package/lib/channels/feishu/adapter.d.ts.map +1 -0
  37. package/lib/channels/feishu/adapter.js +677 -0
  38. package/lib/channels/feishu/adapter.js.map +1 -0
  39. package/lib/channels/feishu/i18n.d.ts +28 -0
  40. package/lib/channels/feishu/i18n.d.ts.map +1 -0
  41. package/lib/channels/feishu/i18n.js +40 -0
  42. package/lib/channels/feishu/i18n.js.map +1 -0
  43. package/lib/channels/feishu/index.d.ts +61 -0
  44. package/lib/channels/feishu/index.d.ts.map +1 -0
  45. package/lib/channels/feishu/index.js +77 -0
  46. package/lib/channels/feishu/index.js.map +1 -0
  47. package/lib/channels/feishu/onboard.d.ts +16 -0
  48. package/lib/channels/feishu/onboard.d.ts.map +1 -0
  49. package/lib/channels/feishu/onboard.js +86 -0
  50. package/lib/channels/feishu/onboard.js.map +1 -0
  51. package/lib/channels/telegram/adapter.d.ts +70 -0
  52. package/lib/channels/telegram/adapter.d.ts.map +1 -0
  53. package/lib/channels/telegram/adapter.js +454 -0
  54. package/lib/channels/telegram/adapter.js.map +1 -0
  55. package/lib/channels/telegram/client.d.ts +115 -0
  56. package/lib/channels/telegram/client.d.ts.map +1 -0
  57. package/lib/channels/telegram/client.js +198 -0
  58. package/lib/channels/telegram/client.js.map +1 -0
  59. package/lib/channels/telegram/i18n.d.ts +14 -0
  60. package/lib/channels/telegram/i18n.d.ts.map +1 -0
  61. package/lib/channels/telegram/i18n.js +18 -0
  62. package/lib/channels/telegram/i18n.js.map +1 -0
  63. package/lib/channels/telegram/index.d.ts +46 -0
  64. package/lib/channels/telegram/index.d.ts.map +1 -0
  65. package/lib/channels/telegram/index.js +54 -0
  66. package/lib/channels/telegram/index.js.map +1 -0
  67. package/lib/channels/web/adapter.d.ts +103 -0
  68. package/lib/channels/web/adapter.d.ts.map +1 -0
  69. package/lib/channels/web/adapter.js +161 -0
  70. package/lib/channels/web/adapter.js.map +1 -0
  71. package/lib/channels/web/index.d.ts +47 -0
  72. package/lib/channels/web/index.d.ts.map +1 -0
  73. package/lib/channels/web/index.js +59 -0
  74. package/lib/channels/web/index.js.map +1 -0
  75. package/lib/index.d.ts +68 -11
  76. package/lib/index.d.ts.map +1 -1
  77. package/lib/index.js +141 -8
  78. package/lib/index.js.map +1 -1
  79. package/lib/runner.d.ts +1 -2
  80. package/lib/runner.d.ts.map +1 -1
  81. package/lib/runner.js.map +1 -1
  82. package/lib/service.d.ts +1 -1
  83. package/lib/service.d.ts.map +1 -1
  84. package/lib/service.js +1 -1
  85. package/lib/service.js.map +1 -1
  86. package/lib/settings/channels.d.ts +51 -0
  87. package/lib/settings/channels.d.ts.map +1 -0
  88. package/lib/settings/channels.js +101 -0
  89. package/lib/settings/channels.js.map +1 -0
  90. package/lib/settings/credential-store.d.ts +39 -0
  91. package/lib/settings/credential-store.d.ts.map +1 -0
  92. package/lib/settings/credential-store.js +68 -0
  93. package/lib/settings/credential-store.js.map +1 -0
  94. package/lib/settings/index.d.ts +14 -0
  95. package/lib/settings/index.d.ts.map +1 -0
  96. package/lib/settings/index.js +14 -0
  97. package/lib/settings/index.js.map +1 -0
  98. package/lib/settings/rpc-client.d.ts +36 -0
  99. package/lib/settings/rpc-client.d.ts.map +1 -0
  100. package/lib/settings/rpc-client.js +45 -0
  101. package/lib/settings/rpc-client.js.map +1 -0
  102. package/lib/settings/settings-model.d.ts +107 -0
  103. package/lib/settings/settings-model.d.ts.map +1 -0
  104. package/lib/settings/settings-model.js +110 -0
  105. package/lib/settings/settings-model.js.map +1 -0
  106. package/lib/settings/settings-rpc.d.ts +62 -0
  107. package/lib/settings/settings-rpc.d.ts.map +1 -0
  108. package/lib/settings/settings-rpc.js +90 -0
  109. package/lib/settings/settings-rpc.js.map +1 -0
  110. package/lib/settings/settings-service.d.ts +27 -0
  111. package/lib/settings/settings-service.d.ts.map +1 -0
  112. package/lib/settings/settings-service.js +103 -0
  113. package/lib/settings/settings-service.js.map +1 -0
  114. package/package.json +48 -6
@@ -0,0 +1,677 @@
1
+ /**
2
+ * Feishu transport over the official SDK's `createLarkChannel`. The SDK handles
3
+ * WebSocket handshake, auto-reconnect, message normalization, @-mention policy,
4
+ * streaming typewriter cards, and media — the adapter only maps DSH shapes.
5
+ * @module dsh-connect/channels/feishu/adapter
6
+ */
7
+ import { createLarkChannel, adaptDefault, LoggerLevel } from "@larksuiteoapi/node-sdk";
8
+ import { createServer } from "node:http";
9
+ import { readdir, rm, stat, writeFile, mkdir } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { basename, extname, join } from "node:path";
12
+ import { feishuMessages } from "./i18n.js";
13
+ /** Zero-width / variation-selector chars that render at width 0. */
14
+ const ZERO_WIDTH = new Set([0x200b, 0x200c, 0x200d, 0xfe0e, 0xfe0f]);
15
+ /** Approximate rendered width: full-width (CJK/emoji) chars count 2, others 1. */
16
+ function displayWidth(s) {
17
+ let w = 0;
18
+ for (const ch of s) {
19
+ const cp = ch.codePointAt(0);
20
+ if (ZERO_WIDTH.has(cp))
21
+ continue;
22
+ w += cp > 0xff ? 2 : 1;
23
+ }
24
+ return w;
25
+ }
26
+ /** Pad every label with trailing spaces up to the same display width. */
27
+ export function padLabels(options) {
28
+ const widths = options.map((o) => displayWidth(o.label));
29
+ const max = Math.max(0, ...widths);
30
+ // Cap the padding target so very long labels don't force wrapping everywhere.
31
+ const target = Math.min(20, max);
32
+ return options.map((o) => {
33
+ const pad = Math.max(0, target - displayWidth(o.label));
34
+ if (pad === 0)
35
+ return o;
36
+ // Full-width spaces (U+3000, 2 units) resist collapsing; a trailing
37
+ // half-width space covers an odd leftover unit.
38
+ const full = Math.floor(pad / 2);
39
+ const half = pad % 2;
40
+ return { ...o, label: o.label + " ".repeat(full) + (half ? " " : "") };
41
+ });
42
+ }
43
+ /**
44
+ * Render options as an equal-width button grid: 2 buttons per row via
45
+ * `column_set` (two weighted columns), padding the last row with empty
46
+ * columns so every row is a uniform 2-cell layout. Labels are padded to the
47
+ * same display width so the buttons themselves render uniformly. Destructive
48
+ * actions (labels starting with ❌) render as red danger buttons.
49
+ */
50
+ export function buildButtonGrid(options, columnsPerRow = 2) {
51
+ const padded = padLabels(options);
52
+ const rows = [];
53
+ for (let i = 0; i < padded.length; i += columnsPerRow) {
54
+ const group = padded.slice(i, i + columnsPerRow);
55
+ const columns = group.map((opt) => ({
56
+ tag: "column",
57
+ width: "weighted",
58
+ weight: 1,
59
+ vertical_align: "center",
60
+ elements: [
61
+ {
62
+ tag: "button",
63
+ text: { tag: "plain_text", content: opt.label },
64
+ type: opt.label.startsWith("❌") ? "danger" : "default",
65
+ value: { choice: opt.id },
66
+ },
67
+ ],
68
+ }));
69
+ while (columns.length < columnsPerRow) {
70
+ columns.push({ tag: "column", width: "weighted", weight: 1, vertical_align: "center", elements: [] });
71
+ }
72
+ rows.push({
73
+ tag: "column_set",
74
+ horizontal_spacing: "small",
75
+ flex_mode: "none",
76
+ columns,
77
+ });
78
+ }
79
+ return rows;
80
+ }
81
+ /**
82
+ * Render options as a single-select dropdown (`select_static`) inside an
83
+ * `action` container. Selecting an entry fires the card action with
84
+ * `action.tag === "select_static"` and the picked option id in `action.option`.
85
+ */
86
+ export function buildSelectMenu(options, placeholder = "请选择", initial) {
87
+ return [
88
+ {
89
+ tag: "action",
90
+ actions: [
91
+ {
92
+ tag: "select_static",
93
+ placeholder: { tag: "plain_text", content: placeholder },
94
+ ...(initial === undefined ? {} : { initial_option: initial }),
95
+ options: options.map((o) => ({ text: { tag: "plain_text", content: o.label }, value: o.id })),
96
+ },
97
+ ],
98
+ },
99
+ ];
100
+ }
101
+ /** Largest option count that still renders as a button grid in auto mode. */
102
+ const AUTO_DROPDOWN_THRESHOLD = 6;
103
+ /**
104
+ * Extract the chosen option id from a card action. Buttons carry the id in
105
+ * `action.value.choice`; a `select_static` dropdown delivers the picked
106
+ * option's id in `action.option` (a string).
107
+ */
108
+ function choiceIdOf(evt) {
109
+ if (evt.action.tag === "select_static" || evt.action.tag === "select_person") {
110
+ return evt.action.option;
111
+ }
112
+ const value = evt.action.value;
113
+ return value?.choice;
114
+ }
115
+ /**
116
+ * Assemble the card elements for a choice prompt. When `sections` is given,
117
+ * the options are split into titled groups — each with a bold section caption
118
+ * and a divider before it — instead of one flat grid. Options not listed in
119
+ * any section (typically the trailing exit/back buttons) are rendered after
120
+ * the sections, separated by a divider.
121
+ *
122
+ * @param prompt - The choice prompt to render
123
+ * @param defaultColumns - Default number of columns per row (default: 2)
124
+ */
125
+ export function buildChoiceElements(prompt, defaultColumns = 2) {
126
+ const { options, sections } = prompt;
127
+ // Dropdown mode: an option-heavy set renders as a single select (Feishu
128
+ // `select_static`) so the card stays compact. `auto` picks dropdown when the
129
+ // flat list is large; explicit `buttons` always uses the grid. Sections do
130
+ // not apply to a dropdown (it is one flat select), so a grouped prompt
131
+ // stays on buttons unless explicitly forced to dropdown.
132
+ const render = prompt.render ?? "buttons";
133
+ const dropdown = render === "dropdown" ||
134
+ (render === "auto" && (sections === undefined || sections.length === 0) && options.length > AUTO_DROPDOWN_THRESHOLD);
135
+ if (dropdown)
136
+ return buildSelectMenu(options, undefined, prompt.initialOption);
137
+ if (sections === undefined || sections.length === 0)
138
+ return buildButtonGrid(options, defaultColumns);
139
+ const byId = new Map(options.map((o) => [o.id, o]));
140
+ const listed = new Set(sections.flatMap((s) => s.ids));
141
+ const elements = [];
142
+ let firstSection = true;
143
+ for (const section of sections) {
144
+ const group = section.ids.map((id) => byId.get(id)).filter((o) => o !== undefined);
145
+ if (group.length === 0)
146
+ continue;
147
+ if (!firstSection)
148
+ elements.push({ tag: "hr" });
149
+ firstSection = false;
150
+ if (section.title !== undefined) {
151
+ elements.push({ tag: "div", text: { tag: "lark_md", content: `**${section.title}**` } });
152
+ }
153
+ // Use section-specific columns if defined, otherwise use prompt default
154
+ const sectionColumns = section.columnsPerRow ?? defaultColumns;
155
+ elements.push(...buildButtonGrid(group, sectionColumns));
156
+ }
157
+ const rest = options.filter((o) => !listed.has(o.id));
158
+ if (rest.length > 0) {
159
+ elements.push({ tag: "hr" });
160
+ elements.push(...buildButtonGrid(rest, defaultColumns));
161
+ }
162
+ return elements;
163
+ }
164
+ /** Collect a Node.js readable stream into a single Buffer with a size cap and a hard timeout. */
165
+ function collectStream(stream, maxBytes = 20 * 1024 * 1024, timeoutMs = 60_000) {
166
+ return new Promise((resolve, reject) => {
167
+ const chunks = [];
168
+ let total = 0;
169
+ let settled = false;
170
+ // Abort the stream on overflow/timeout. The SDK hands us a web
171
+ // ReadableStream (no .destroy) — cancel it; Node streams also accept cancel
172
+ // semantics via destroy.
173
+ const abortStream = () => {
174
+ const anyStream = stream;
175
+ anyStream.destroy?.();
176
+ const cancel = anyStream.cancel?.();
177
+ if (cancel !== undefined)
178
+ void cancel.catch(() => undefined);
179
+ };
180
+ const done = (value) => {
181
+ if (settled)
182
+ return;
183
+ settled = true;
184
+ clearTimeout(timer);
185
+ resolve(value);
186
+ };
187
+ const failed = (err) => {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ clearTimeout(timer);
192
+ reject(err);
193
+ };
194
+ const timer = setTimeout(() => {
195
+ abortStream();
196
+ failed(new Error(`download timed out after ${Math.round(timeoutMs / 1000)}s`));
197
+ }, timeoutMs);
198
+ stream.on("data", (chunk) => {
199
+ if (settled)
200
+ return;
201
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
202
+ total += buf.length;
203
+ if (total > maxBytes) {
204
+ abortStream();
205
+ failed(new Error(`download exceeds the ${Math.round(maxBytes / 1024 / 1024)}MB limit`));
206
+ return;
207
+ }
208
+ chunks.push(buf);
209
+ });
210
+ stream.on("end", () => done(Buffer.concat(chunks)));
211
+ stream.on("error", (err) => failed(err));
212
+ });
213
+ }
214
+ /**
215
+ * Encode a chatKey with an optional thread id. With `threadIsolation` on, a
216
+ * group message inside a thread gets `chatId:thread=<rootId>` as its chatKey,
217
+ * so each thread binds its own DSH session while outbound sends still target
218
+ * the base chat id (replies land in the thread via replyRef).
219
+ */
220
+ export function encodeChatKey(chatId, threadId) {
221
+ return threadId === undefined || threadId === "" ? chatId : `${chatId}:thread=${threadId}`;
222
+ }
223
+ /** Decode a possibly thread-scoped chatKey back to its base chat id. */
224
+ export function decodeChatKey(chatKey) {
225
+ const sep = chatKey.indexOf(":thread=");
226
+ if (sep === -1)
227
+ return { chatId: chatKey };
228
+ return { chatId: chatKey.slice(0, sep), threadId: chatKey.slice(sep + 8) };
229
+ }
230
+ /** Image extensions Feishu delivers as inline images; everything else is a file. */
231
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"]);
232
+ /** Classify a local file name for the Feishu upload API (image vs stream file). */
233
+ export function classifyFeishuFile(filename) {
234
+ const ext = extname(filename).toLowerCase();
235
+ return IMAGE_EXTENSIONS.has(ext) ? "image" : "file";
236
+ }
237
+ /** Strip path separators / control chars from a downloaded file's name. */
238
+ export function sanitizeFileName(name) {
239
+ const clean = name
240
+ .replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_")
241
+ .trim()
242
+ .slice(0, 200);
243
+ return clean === "" ? "file" : clean;
244
+ }
245
+ /**
246
+ * Extract a human-readable error detail. Feishu business errors (missing
247
+ * permission, invalid resource, …) arrive as HTTP 200/400 with the real
248
+ * `{code, msg}` in the response body; prefer that over the axios fallback
249
+ * message like "Request failed with status code 400" that hides the cause.
250
+ */
251
+ export function extractErrorDetail(error) {
252
+ const raw = error;
253
+ const body = raw?.response?.data;
254
+ if (body !== undefined && (body.code !== undefined || body.msg !== undefined || body.message !== undefined)) {
255
+ const status = raw?.response?.status;
256
+ const detail = [body.code !== undefined ? `code=${String(body.code)}` : "", body.msg ?? body.message]
257
+ .map((s) => String(s))
258
+ .filter((s) => s !== "")
259
+ .join(" ");
260
+ return `HTTP ${status ?? "?"}${detail ? `(${detail})` : ""}`;
261
+ }
262
+ return error instanceof Error ? error.message : String(error);
263
+ }
264
+ const CHOICE_TIMEOUT_MS = 60_000;
265
+ export class FeishuAdapter {
266
+ logger;
267
+ isChatAllowed;
268
+ id = "feishu";
269
+ channel;
270
+ pendingChoices = new Map();
271
+ /** Cards whose stale-button tap was already noticed (dedupe, self-clearing). */
272
+ staleNoticed = new Set();
273
+ staleTimers = new Set();
274
+ t;
275
+ transport;
276
+ webhookPort;
277
+ webhookPath;
278
+ threadIsolation;
279
+ server;
280
+ handler;
281
+ constructor(config, logger, isChatAllowed) {
282
+ this.logger = logger;
283
+ this.isChatAllowed = isChatAllowed;
284
+ const appId = config.appId ?? process.env.FEISHU_APP_ID;
285
+ const appSecret = config.appSecret ?? process.env.FEISHU_APP_SECRET;
286
+ if (!appId || !appSecret) {
287
+ throw new Error("connect-feishu: appId and appSecret are required (config or FEISHU_APP_ID / FEISHU_APP_SECRET)");
288
+ }
289
+ this.t = feishuMessages(config.language ?? "zh");
290
+ this.transport = config.transport ?? "websocket";
291
+ this.webhookPort = config.webhookPort ?? 9000;
292
+ this.webhookPath = config.webhookPath ?? "/";
293
+ this.threadIsolation = config.threadIsolation ?? false;
294
+ this.channel = createLarkChannel({
295
+ appId,
296
+ appSecret,
297
+ transport: this.transport,
298
+ ...(this.transport === "webhook"
299
+ ? {
300
+ webhook: {
301
+ ...(config.verificationToken ? { verificationToken: config.verificationToken } : {}),
302
+ ...(config.encryptKey ? { encryptKey: config.encryptKey } : {}),
303
+ },
304
+ }
305
+ : {}),
306
+ policy: {
307
+ requireMention: config.requireMention ?? true,
308
+ dmMode: config.dmMode ?? "open",
309
+ },
310
+ loggerLevel: LoggerLevel.info,
311
+ });
312
+ }
313
+ onInbound(handler) {
314
+ this.handler = handler;
315
+ }
316
+ /** Resource types downloadable via `im.v1.messageResource.get`. Stickers are not supported by the Feishu API. */
317
+ static DOWNLOADABLE_TYPES = new Set(["image", "file", "audio", "video"]);
318
+ /** Download attached images / files into local temp dirs; return their paths (and any failures). */
319
+ async downloadResources(msg) {
320
+ const resources = msg.resources?.filter((r) => FeishuAdapter.DOWNLOADABLE_TYPES.has(r.type)) ?? [];
321
+ if (resources.length === 0)
322
+ return { images: [], files: [] };
323
+ const imagesDir = join(tmpdir(), "dsh-connect-images");
324
+ const filesDir = join(tmpdir(), "dsh-connect-files");
325
+ try {
326
+ await mkdir(imagesDir, { recursive: true });
327
+ await mkdir(filesDir, { recursive: true });
328
+ }
329
+ catch (error) {
330
+ return { images: [], files: [], imageError: this.t.tempDirFailed(String(error)), fileError: this.t.tempDirFailed(String(error)) };
331
+ }
332
+ const images = [];
333
+ const files = [];
334
+ let imageFailed = 0;
335
+ let fileFailed = 0;
336
+ let firstImageError;
337
+ let firstFileError;
338
+ for (const r of resources) {
339
+ const isImage = r.type === "image";
340
+ const target = isImage ? images : files;
341
+ try {
342
+ const buf = await this.downloadMessageResource(msg.messageId, r.fileKey, r.type);
343
+ const dir = isImage ? imagesDir : filesDir;
344
+ const name = isImage
345
+ ? `${msg.messageId}-${r.fileKey.replace(/[^a-zA-Z0-9]/g, "_")}`
346
+ : `${msg.messageId}-${sanitizeFileName(r.fileName ?? `${r.type}-${r.fileKey}`)}`;
347
+ const file = join(dir, name);
348
+ await writeFile(file, buf);
349
+ target.push(file);
350
+ }
351
+ catch (error) {
352
+ const detail = extractErrorDetail(error);
353
+ if (isImage) {
354
+ imageFailed += 1;
355
+ firstImageError ??= detail;
356
+ this.logger?.warn?.(this.t.imageDownloadLog(r.fileKey, detail));
357
+ }
358
+ else {
359
+ fileFailed += 1;
360
+ firstFileError ??= detail;
361
+ this.logger?.warn?.(this.t.fileDownloadLog(r.fileKey, detail));
362
+ }
363
+ }
364
+ }
365
+ const out = { images, files };
366
+ if (images.length === 0 && imageFailed > 0) {
367
+ // Keep the real Feishu error code/detail so the user can pinpoint the
368
+ // actual missing permission instead of a generic hint.
369
+ const detail = firstImageError === undefined ? "" : this.t.errorDetail(firstImageError.slice(0, 200));
370
+ out.imageError = this.t.imageDownloadError(imageFailed, detail);
371
+ }
372
+ if (files.length === 0 && fileFailed > 0) {
373
+ const detail = firstFileError === undefined ? "" : this.t.errorDetail(firstFileError.slice(0, 200));
374
+ out.fileError = this.t.fileDownloadError(fileFailed, detail);
375
+ }
376
+ return out;
377
+ }
378
+ /**
379
+ * Download a resource inside a user message.
380
+ *
381
+ * Note: do NOT use the SDK's `downloadResource(fileKey, type)` — it calls
382
+ * `im.v1.image.get` / `im.v1.file.get` (download image / download file), and
383
+ * per the Feishu docs those endpoints **can only download resources uploaded
384
+ * by the bot itself**. Resources inside user-sent messages must be fetched
385
+ * with "get resource file from message" `im.v1.messageResource.get` (with
386
+ * message_id + type=image/file/audio/video), otherwise it returns HTTP 400.
387
+ */
388
+ async downloadMessageResource(messageId, fileKey, type) {
389
+ const res = await this.channel.rawClient.im.v1.messageResource.get({
390
+ path: { message_id: messageId, file_key: fileKey },
391
+ params: { type },
392
+ });
393
+ return await collectStream(res.getReadableStream());
394
+ }
395
+ /**
396
+ * Housekeeping for the per-channel temp dirs: remove downloads older than
397
+ * 24h so a busy bot doesn't fill the OS temp partition. Best-effort — a
398
+ * failure to clean is logged, never fatal.
399
+ */
400
+ async cleanupTempDirs() {
401
+ const dirs = [join(tmpdir(), "dsh-connect-images"), join(tmpdir(), "dsh-connect-files")];
402
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
403
+ const now = Date.now();
404
+ for (const dir of dirs) {
405
+ let entries;
406
+ try {
407
+ entries = await readdir(dir);
408
+ }
409
+ catch {
410
+ continue; // directory never created — nothing to clean
411
+ }
412
+ for (const entry of entries) {
413
+ const p = join(dir, entry);
414
+ try {
415
+ const st = await stat(p);
416
+ if (now - st.mtimeMs > MAX_AGE_MS)
417
+ await rm(p, { force: true });
418
+ }
419
+ catch (error) {
420
+ this.logger?.warn?.(`connect-feishu: temp cleanup failed for ${p}: ${String(error)}`);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ async start() {
426
+ // Sweep stale downloads once at startup (cheap, bounded by dir size).
427
+ await this.cleanupTempDirs().catch(() => undefined);
428
+ this.channel.on("message", async (msg) => {
429
+ // First-line diagnostic: if the Feishu app is subscribed to
430
+ // im.message.receive_v1 this fires for every inbound message. Its absence
431
+ // when the user sends a message means events aren't being delivered at all.
432
+ this.logger?.info?.(`connect-feishu: received message chat=${msg.chatId} sender=${msg.senderId} type=${msg.chatType} len=${String(msg.content?.length ?? 0)}`);
433
+ // Allowlist gate BEFORE downloading anything: rejected senders' files
434
+ // never touch disk (the core re-checks later for the full policy).
435
+ if (this.isChatAllowed !== undefined && !this.isChatAllowed("feishu", msg.chatId, msg.senderId)) {
436
+ this.logger?.warn?.(`connect-feishu: message from chat=${msg.chatId} sender=${msg.senderId} rejected by allowlist (skipped download)`);
437
+ return;
438
+ }
439
+ // Guard the whole handling path: a throw here (download failure, adapter
440
+ // error, or a throw from the core handler) must never become an unhandled
441
+ // rejection — dsh web has no runtime fallback and the whole process would
442
+ // exit mid-task.
443
+ try {
444
+ const dl = await this.downloadResources(msg);
445
+ // With thread isolation, a message inside a group thread gets its own
446
+ // chatKey (hence its own DSH session). The base chat id still drives the
447
+ // allowlist gate above.
448
+ const threadId = this.threadIsolation ? msg.root_id : undefined;
449
+ await this.handler?.({
450
+ channel: "feishu",
451
+ chatKey: encodeChatKey(msg.chatId, threadId),
452
+ chatType: msg.chatType,
453
+ senderKey: msg.senderId,
454
+ text: msg.content,
455
+ replyRef: msg.messageId,
456
+ ...(dl.images.length > 0 ? { images: dl.images } : {}),
457
+ ...(dl.files.length > 0 ? { files: dl.files } : {}),
458
+ ...(dl.imageError === undefined ? {} : { imageError: dl.imageError }),
459
+ ...(dl.fileError === undefined ? {} : { fileError: dl.fileError }),
460
+ });
461
+ }
462
+ catch (error) {
463
+ this.logger?.error?.(`connect-feishu: message handling failed (chat=${msg.chatId} sender=${msg.senderId}): ${String(error)}`);
464
+ }
465
+ });
466
+ this.channel.on("cardAction", (evt) => {
467
+ const pending = this.pendingChoices.get(evt.messageId);
468
+ if (pending === undefined) {
469
+ // The card's interaction is no longer pending: it was already handled,
470
+ // expired, or replaced by a newer card. Tell the user instead of
471
+ // silently ignoring the tap — a stale authorization captions the
472
+ // "did my tap do anything?" confusion. Wait: the core now also updates
473
+ // the card in place on acceptance, so most stale taps hit cards the
474
+ // user can see are done; this is the fallback for anything else.
475
+ const choice = choiceIdOf(evt);
476
+ if (choice !== undefined && !this.staleNoticed.has(evt.messageId)) {
477
+ this.staleNoticed.add(evt.messageId);
478
+ // Keep the notice once per card to avoid spam on repeated taps.
479
+ const timer = setTimeout(() => {
480
+ this.staleNoticed.delete(evt.messageId);
481
+ this.staleTimers.delete(timer);
482
+ }, 30_000);
483
+ this.staleTimers.add(timer);
484
+ void this.sendText({ chatKey: evt.chatId, chatType: "p2p" }, this.t.actionStale).catch(() => undefined);
485
+ }
486
+ return;
487
+ }
488
+ this.pendingChoices.delete(evt.messageId);
489
+ clearTimeout(pending.timer);
490
+ pending.resolve(choiceIdOf(evt));
491
+ });
492
+ this.channel.on("reject", (evt) => {
493
+ // Log a compact reason — never the full event JSON (it carries message
494
+ // content / sender PII).
495
+ const e = evt;
496
+ const why = [e?.reason, e?.code, e?.message].map((v) => String(v)).filter((v) => v !== "" && v !== "undefined").join(" / ");
497
+ const where = e?.msg?.chatId === undefined ? "" : ` chat=${e.msg.chatId}`;
498
+ this.logger?.warn?.(`connect-feishu: inbound message rejected — ${why || "no detail"}${where}`);
499
+ });
500
+ this.channel.on("error", (err) => {
501
+ this.logger?.error?.(`connect-feishu: inbound dispatcher error: ${String(err)}`);
502
+ });
503
+ await this.channel.connect();
504
+ // Webhook transport: the SDK's doConnect only wires the dispatcher for WS;
505
+ // for `transport: "webhook"` it expects the host to plug `channel.dispatcher`
506
+ // into an HTTP handler. We host it here (URL verification is answered
507
+ // automatically by the SDK's adaptDefault with autoChallenge).
508
+ if (this.transport === "webhook") {
509
+ // TS marks LarkChannel#dispatcher private even though it is public at
510
+ // runtime; the SDK ships no exported type for it either.
511
+ const dispatcher = this.channel.dispatcher;
512
+ const handler = adaptDefault(this.webhookPath, dispatcher, { autoChallenge: true });
513
+ this.server = createServer((req, res) => {
514
+ void handler(req, res).catch((error) => {
515
+ this.logger?.error?.(`connect-feishu: webhook dispatch failed: ${String(error)}`);
516
+ res.statusCode = 500;
517
+ res.end("internal error");
518
+ });
519
+ });
520
+ await new Promise((resolve, reject) => {
521
+ this.server.once("error", reject);
522
+ this.server.listen(this.webhookPort, () => {
523
+ this.server.removeListener("error", reject);
524
+ resolve();
525
+ });
526
+ });
527
+ this.logger?.warn?.(`connect-feishu: webhook transport listening on http://0.0.0.0:${this.webhookPort}${this.webhookPath === "/" ? "" : this.webhookPath}`);
528
+ }
529
+ }
530
+ async stop() {
531
+ // Release the webhook listener first so no new callbacks arrive mid-teardown.
532
+ const server = this.server;
533
+ this.server = undefined;
534
+ if (server !== undefined) {
535
+ await new Promise((resolve) => server.close(() => resolve()));
536
+ }
537
+ // Cancel outstanding choice/stale timers so stop() doesn't leave handles
538
+ // that fire after disconnect.
539
+ for (const pending of this.pendingChoices.values())
540
+ clearTimeout(pending.timer);
541
+ this.pendingChoices.clear();
542
+ for (const timer of this.staleTimers)
543
+ clearTimeout(timer);
544
+ this.staleTimers.clear();
545
+ this.staleNoticed.clear();
546
+ await this.channel.disconnect();
547
+ }
548
+ /** Base chat id for outbound sends (strips any `:thread=` suffix). */
549
+ chatIdOf(chatKey) {
550
+ return decodeChatKey(chatKey).chatId;
551
+ }
552
+ async sendText(target, text) {
553
+ await this.channel.send(this.chatIdOf(target.chatKey), { text }, this.sendOpts(target));
554
+ }
555
+ async sendCard(target, card) {
556
+ await this.channel.send(this.chatIdOf(target.chatKey), { markdown: card.markdown }, this.sendOpts(target));
557
+ }
558
+ async streamText(target, chunks) {
559
+ await this.channel.stream(this.chatIdOf(target.chatKey), {
560
+ markdown: async (sink) => {
561
+ for await (const chunk of chunks) {
562
+ await sink.append(chunk);
563
+ }
564
+ },
565
+ }, { ...(target.replyRef === undefined ? {} : { replyTo: target.replyRef }) });
566
+ }
567
+ /**
568
+ * Deliver a local file to the chat. The SDK uploads the source itself:
569
+ * images are sent inline, everything else as an attachment.
570
+ */
571
+ async sendFile(target, filePath, options) {
572
+ const filename = options?.filename ?? basename(filePath);
573
+ if (classifyFeishuFile(filename) === "image") {
574
+ await this.channel.send(this.chatIdOf(target.chatKey), { image: { source: filePath } }, this.sendOpts(target));
575
+ }
576
+ else {
577
+ await this.channel.send(this.chatIdOf(target.chatKey), { file: { source: filePath, fileName: filename } }, this.sendOpts(target));
578
+ }
579
+ }
580
+ /**
581
+ * Build the send options shared by text/markdown deliveries: an optional
582
+ * reply target and @-mentions (SDK renders `<at user_id=…>` prefixes for
583
+ * both text and post messages, so group completion cards can nudge the
584
+ * requester).
585
+ */
586
+ sendOpts(target) {
587
+ return {
588
+ ...(target.replyRef === undefined ? {} : { replyTo: target.replyRef }),
589
+ ...(target.atUsers === undefined || target.atUsers.length === 0
590
+ ? {}
591
+ : { mentions: target.atUsers.map((id) => ({ key: id, openId: id })) }),
592
+ };
593
+ }
594
+ async promptChoice(target, prompt, updateMessageId) {
595
+ const columnsPerRow = prompt.columnsPerRow ?? 2;
596
+ const card = {
597
+ header: { title: { tag: "plain_text", content: prompt.title }, template: "indigo" },
598
+ elements: [
599
+ ...(prompt.description === undefined
600
+ ? []
601
+ : [{ tag: "div", text: { tag: "plain_text", content: prompt.description } }]),
602
+ ...buildChoiceElements(prompt, columnsPerRow),
603
+ ...(prompt.footer === undefined
604
+ ? []
605
+ : [{ tag: "note", elements: [{ tag: "plain_text", content: prompt.footer }] }]),
606
+ ],
607
+ };
608
+ let messageId;
609
+ if (updateMessageId !== undefined) {
610
+ messageId = updateMessageId;
611
+ }
612
+ else {
613
+ ({ messageId } = await this.channel.send(this.chatIdOf(target.chatKey), { card }, { ...(target.replyRef === undefined ? {} : { replyTo: target.replyRef }) }));
614
+ }
615
+ // Register the pending choice BEFORE the async card update. Rapid taps on
616
+ // the previous menu arrive while updateCard is still in flight; if the
617
+ // pending record only exists after the redraw, those taps hit the stale
618
+ // branch and the menu appears to swallow them ("can't go back"). Register
619
+ // first, then redraw, so every tap has a live listener.
620
+ let resolvePending;
621
+ const pending = new Promise((resolve) => { resolvePending = resolve; });
622
+ const registerPending = (id, timer) => {
623
+ this.pendingChoices.set(id, {
624
+ resolve: (choice) => {
625
+ this.pendingChoices.delete(id);
626
+ clearTimeout(timer);
627
+ resolvePending({ choice, messageId: id });
628
+ },
629
+ timer,
630
+ });
631
+ };
632
+ const timer = setTimeout(async () => {
633
+ this.pendingChoices.delete(messageId);
634
+ // Replace the stale menu with an expired notice instead of leaving it silent.
635
+ await this.channel
636
+ .updateCard(messageId, {
637
+ header: { title: { tag: "plain_text", content: this.t.menuExpired }, template: "grey" },
638
+ elements: [{ tag: "note", elements: [{ tag: "plain_text", content: this.t.menuExpiredHint }] }],
639
+ })
640
+ .catch(() => undefined);
641
+ resolvePending({ choice: undefined, messageId });
642
+ }, CHOICE_TIMEOUT_MS);
643
+ registerPending(messageId, timer);
644
+ if (updateMessageId !== undefined) {
645
+ // Reuse the existing card when possible so a menu chain stays on one card.
646
+ // But never leave the previous menu's content on screen: if the in-place
647
+ // update fails, fall back to a fresh card so the correct options always
648
+ // render, and rebind the tap listener to the fresh card's message id.
649
+ try {
650
+ await this.channel.updateCard(updateMessageId, card);
651
+ }
652
+ catch (error) {
653
+ this.logger?.warn?.(`connect-feishu: menu card update failed (${String(error)}); sending a fresh card`);
654
+ this.pendingChoices.delete(messageId);
655
+ try {
656
+ const sent = await this.channel.send(this.chatIdOf(target.chatKey), { card }, { ...(target.replyRef === undefined ? {} : { replyTo: target.replyRef }) });
657
+ messageId = sent.messageId;
658
+ }
659
+ catch (sendError) {
660
+ this.logger?.warn?.(`connect-feishu: fresh menu card send failed (${String(sendError)})`);
661
+ messageId = updateMessageId;
662
+ }
663
+ registerPending(messageId, timer);
664
+ }
665
+ }
666
+ return pending;
667
+ }
668
+ async closeMenu(messageId, summary) {
669
+ await this.channel
670
+ .updateCard(messageId, {
671
+ header: { title: { tag: "plain_text", content: this.t.doneHeader }, template: "green" },
672
+ elements: [{ tag: "div", text: { tag: "plain_text", content: summary } }],
673
+ })
674
+ .catch(() => undefined);
675
+ }
676
+ }
677
+ //# sourceMappingURL=adapter.js.map