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