@lijian-ui/dsh-im-gateway 0.1.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.
package/lib/index.js ADDED
@@ -0,0 +1,3623 @@
1
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import Schema from "@deepseek-ai/schemastery";
4
+ import { SessionId } from "@deepseek-ai/dsh-session";
5
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
6
+ import { installModelSelection } from "@deepseek-ai/dsh-agent";
7
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import os from "node:os";
10
+ import crypto, { randomUUID } from "node:crypto";
11
+ import { readFile } from "node:fs/promises";
12
+ import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
13
+ import QRCode from "qrcode";
14
+ //#region src/gateway/im-gateway.ts
15
+ const Config = Schema.object({
16
+ cwd: Schema.string().default(process.cwd()),
17
+ streamThrottleMs: Schema.number().default(800),
18
+ slashCommands: Schema.boolean().default(true),
19
+ channels: Schema.array(Schema.object({
20
+ id: Schema.string().required(),
21
+ type: Schema.union([
22
+ "dingtalk",
23
+ "qq",
24
+ "weixin"
25
+ ]),
26
+ name: Schema.string().default(""),
27
+ enabled: Schema.boolean().default(false),
28
+ config: Schema.object({
29
+ clientId: Schema.string().default(""),
30
+ clientSecret: Schema.string().role("secret").default(""),
31
+ callbackBaseUrl: Schema.string().default(""),
32
+ appId: Schema.string().default(""),
33
+ botAppId: Schema.string().default(""),
34
+ baseUrl: Schema.string().default(""),
35
+ token: Schema.string().role("secret").default(""),
36
+ botId: Schema.string().default(""),
37
+ cdnBaseUrl: Schema.string().default(""),
38
+ pollIntervalMs: Schema.number().default(3e3)
39
+ }).default({})
40
+ })).default([])
41
+ });
42
+ /**
43
+ * Core IM gateway service.
44
+ *
45
+ * Responsibilities (ported from pi-desk-top/src/main/im/im-gateway.ts):
46
+ * - channel registry (`registerChannel`)
47
+ * - conversation → host session mapping
48
+ * - per-conversation serial queue (one agent turn at a time)
49
+ * - slash-command handling (/help, /reset, /clear)
50
+ * - agent-event → channel reply routing (streaming, tool notifications, flush)
51
+ *
52
+ * ─────────────────────────────────────────────────────────────────────────
53
+ * HOST API CONTRACT (verified against deepseek-harness source):
54
+ * - Sessions: `ctx.sessions` is the `SessionStore` (from @deepseek-ai/dsh-session).
55
+ * `create(id, { meta: { cwd } })` builds an event-sourced Session.
56
+ * The Session object has NO `.prompt()` / `.on()` — it is a log.
57
+ * - Agents: `ctx.agents` is the `AgentRegistry` (from @deepseek-ai/dsh-agent).
58
+ * `get(sessionId)` → Agent | undefined; `createAgent(ownerCtx, opts)`
59
+ * starts the agent loop on a session. Drive a turn with
60
+ * `agent.followup(userMessage)` (see host/apiproxy/src/api-proxy.ts:2461).
61
+ * - Events: subscribe at the CONTEXT level: `ctx.on('session/event', (session, event) => …)`.
62
+ * Typed events (packages/core/session/src/types.ts):
63
+ * 'turn/start' { turn }
64
+ * 'assistant/chunk' { turn, step, chunk: StreamChunk } // chunk.type==='text-delta' → text
65
+ * 'assistant/message' { turn, step, message: AssistantMessage }
66
+ * 'tool/call' { turn, step, callId, name, arguments }
67
+ * 'turn/end' { turn, reason }
68
+ * ─────────────────────────────────────────────────────────────────────────
69
+ */
70
+ var ImGatewayService = class extends Service {
71
+ config;
72
+ static inject = ["agents"];
73
+ channels = /* @__PURE__ */ new Map();
74
+ convToSession = /* @__PURE__ */ new Map();
75
+ sessionToConv = /* @__PURE__ */ new Map();
76
+ /** Deduplicate concurrent ensureSession calls for the same conversation. */
77
+ sessionCreations = /* @__PURE__ */ new Map();
78
+ serialQueues = /* @__PURE__ */ new Map();
79
+ pendingStream = /* @__PURE__ */ new Map();
80
+ finalText = /* @__PURE__ */ new Map();
81
+ /** Whether beginStream was already called for this conversation's reply cycle. */
82
+ streamStarted = /* @__PURE__ */ new Map();
83
+ agentHandles = /* @__PURE__ */ new Map();
84
+ /** Per-session model-selection ref (so /model can switch without re-injecting). */
85
+ modelRefs = /* @__PURE__ */ new Map();
86
+ /** Per-session model override captured from /model (absent → fall back to globalModelOverride). */
87
+ modelOverrides = /* @__PURE__ */ new Map();
88
+ /**
89
+ * Default model when there is no session override AND no /cwd-provided default.
90
+ * Set by /model <name> issued BEFORE the first message of a conversation
91
+ * (i.e. session not yet created). Survives across conversations until cleared.
92
+ */
93
+ globalModelOverride;
94
+ /** Live channel runtime status, surfaced to the settings UI via getChannelStatuses. */
95
+ channelStatuses = /* @__PURE__ */ new Map();
96
+ unsubSessionEvent;
97
+ active = false;
98
+ constructor(ctx, config) {
99
+ super(ctx, "imGateway");
100
+ this.config = config;
101
+ }
102
+ registerChannel(adapter) {
103
+ if (this.channels.has(adapter.id)) this.ctx.logger.warn(`[im-gateway] channel "${adapter.id}" re-registered; replacing.`);
104
+ this.channels.set(adapter.id, adapter);
105
+ adapter.statusListener = (status) => this.setChannelStatus(adapter.id, status);
106
+ if (this.active) Promise.resolve(adapter.start()).catch((err) => this.ctx.logger.error(`[im-gateway] channel "${adapter.id}" start failed`, err));
107
+ }
108
+ getChannel(id) {
109
+ return this.channels.get(id);
110
+ }
111
+ listChannels() {
112
+ return [...this.channels.keys()];
113
+ }
114
+ /**
115
+ * Stop every channel and clear the registry. Used by config reload
116
+ * (mirrors the reference project's applyConfig → stopAll): after a save
117
+ * the caller re-creates adapters from the new settings and re-registers.
118
+ */
119
+ async stopAll() {
120
+ const adapters = [...this.channels.values()];
121
+ this.channels.clear();
122
+ this.channelStatuses.clear();
123
+ for (const adapter of adapters) try {
124
+ await adapter.stop();
125
+ } catch (err) {
126
+ this.ctx.logger.error(`[im-gateway] stop channel "${adapter.id}" failed`, err);
127
+ }
128
+ }
129
+ /** Snapshot of every channel's runtime status for the settings UI. */
130
+ getChannelStatuses() {
131
+ const out = {};
132
+ for (const [id, status] of this.channelStatuses) out[id] = status;
133
+ return out;
134
+ }
135
+ /** Record a channel's runtime status and log meaningful transitions. */
136
+ setChannelStatus(id, status) {
137
+ const prev = this.channelStatuses.get(id)?.status;
138
+ this.channelStatuses.set(id, {
139
+ ...status,
140
+ lastChange: Date.now()
141
+ });
142
+ if (prev !== status.status) {
143
+ if (status.status === "error") this.ctx.logger.warn(`[im-gateway] channel "${id}" → error: ${status.error ?? "unknown"}`);
144
+ else this.ctx.logger.info(`[im-gateway] channel "${id}" → ${status.status}`);
145
+ }
146
+ }
147
+ async *[Service.init]() {
148
+ yield async () => {
149
+ this.active = false;
150
+ this.unsubSessionEvent?.();
151
+ this.unsubSessionEvent = void 0;
152
+ for (const adapter of this.channels.values()) try {
153
+ await adapter.stop();
154
+ } catch (err) {
155
+ this.ctx.logger.error(`[im-gateway] stop channel "${adapter.id}" failed`, err);
156
+ }
157
+ for (const [, handle] of this.agentHandles) await handle.dispose().catch((err) => this.ctx.logger.warn("[im-gateway] agent dispose failed", err));
158
+ this.agentHandles.clear();
159
+ this.convToSession.clear();
160
+ this.sessionToConv.clear();
161
+ };
162
+ this.active = true;
163
+ this.unsubSessionEvent = this.ctx.root.on("session/event", (session, event) => this.onSessionEvent(session, event));
164
+ for (const adapter of this.channels.values()) try {
165
+ await adapter.start();
166
+ } catch (err) {
167
+ this.ctx.logger.error(`[im-gateway] start channel "${adapter.id}" failed`, err);
168
+ }
169
+ this.ctx.logger.info(`[im-gateway] active; channels: ${this.listChannels().join(", ") || "(none)"}`);
170
+ }
171
+ async handleInbound(message) {
172
+ const channel = this.channels.get(message.channelId);
173
+ if (!channel) {
174
+ this.ctx.logger.warn(`[im-gateway] inbound from unknown channel "${message.channelId}"`);
175
+ return;
176
+ }
177
+ if (channel.onInbound) {
178
+ if (await channel.onInbound(message)) return;
179
+ }
180
+ if (this.config.slashCommands && message.text.trimStart().startsWith("/")) try {
181
+ if (await this.handleSlash(channel, message)) return;
182
+ } catch (err) {
183
+ const convId = convOf(`${message.channelId}:${message.conversationId}`);
184
+ const detail = err instanceof Error ? err.message : String(err);
185
+ this.ctx.logger.error(`[im-gateway] slash command failed: ${detail}`, err);
186
+ await channel.sendText(convId, `⚠️ 命令执行失败:${detail}`).catch(() => void 0);
187
+ return;
188
+ }
189
+ const convKey = `${message.channelId}:${message.conversationId}`;
190
+ const sessionId = await this.ensureSession(convKey);
191
+ const parts = toContentBlocks(message);
192
+ const next = (this.serialQueues.get(convKey) ?? Promise.resolve()).then(() => this.runTurn(sessionId, convKey, channel, parts));
193
+ this.serialQueues.set(convKey, next.catch(() => void 0));
194
+ }
195
+ async ensureSession(convKey) {
196
+ const existing = this.convToSession.get(convKey);
197
+ if (existing) return existing;
198
+ let creation = this.sessionCreations.get(convKey);
199
+ if (creation === void 0) {
200
+ creation = this.ensureSessionInner(convKey);
201
+ this.sessionCreations.set(convKey, creation);
202
+ creation.finally(() => this.sessionCreations.delete(convKey)).catch(() => void 0);
203
+ }
204
+ return creation;
205
+ }
206
+ /**
207
+ * Resolve an IM conversation to a live agent session, following the OFFICIAL
208
+ * dsh pattern (dsh-host-apiproxy/src/api-proxy.ts `ensureSession`):
209
+ *
210
+ * 1. A live agent with this id already exists → reuse it.
211
+ * 2. A persisted log exists on disk (from a previous run) → `agents.resume`
212
+ * the SAME id. This is the crux: IM conversations are long-lived (the
213
+ * DingTalk conversationId is stable across restarts), so the session id
214
+ * MUST be stable too. `agents.create` on a stored id would reject with
215
+ * "id collision" because the fresh seed does not reproduce the stored
216
+ * log; `agents.resume` replays the stored log AS the seed and keeps the
217
+ * conversation history. A random per-boot suffix would dodge the
218
+ * collision but silently throw history away — do NOT do that.
219
+ * 3. Neither → genuinely new conversation: `agents.create`.
220
+ */
221
+ async ensureSessionInner(convKey) {
222
+ const sessionId = SessionId(`im:${convKey}`);
223
+ if (this.ctx.agents.get(sessionId)) {
224
+ this.convToSession.set(convKey, sessionId);
225
+ this.sessionToConv.set(sessionId, convKey);
226
+ await this.attachToWorkspace(sessionId);
227
+ return sessionId;
228
+ }
229
+ const persistence = this.ctx.get("sessionPersistence");
230
+ if (persistence) {
231
+ if ((await persistence.list()).find((header) => header.id === sessionId)) {
232
+ const handle = await this.ctx.agents.resume({ resumeSessionId: sessionId });
233
+ this.installModelSelection(handle.agent, sessionId);
234
+ this.agentHandles.set(sessionId, handle);
235
+ this.convToSession.set(convKey, sessionId);
236
+ this.sessionToConv.set(sessionId, convKey);
237
+ this.ctx.logger.info(`[im-gateway] resumed session ${sessionId} (history kept)`);
238
+ await this.attachToWorkspace(sessionId);
239
+ return sessionId;
240
+ }
241
+ }
242
+ let handle;
243
+ try {
244
+ handle = await this.ctx.agents.create({
245
+ sessionId,
246
+ meta: { cwd: this.config.cwd }
247
+ });
248
+ } catch (err) {
249
+ if (isIdCollision(err)) handle = await this.ctx.agents.resume({ resumeSessionId: sessionId });
250
+ else throw err;
251
+ }
252
+ this.installModelSelection(handle.agent, sessionId);
253
+ this.agentHandles.set(sessionId, handle);
254
+ this.convToSession.set(convKey, sessionId);
255
+ this.sessionToConv.set(sessionId, convKey);
256
+ await this.attachToWorkspace(sessionId);
257
+ return sessionId;
258
+ }
259
+ /**
260
+ * Associate an IM session with the workspace whose canonical path matches its
261
+ * cwd, so the session shows up under the correct project group in the web
262
+ * sidebar instead of the "Ungrouped" bucket.
263
+ *
264
+ * dsh groups the sidebar by Host Workspace: sessions created through the UI
265
+ * attach to a workspace via `session.create(workspaceId)`. Our bare
266
+ * `agents.create({ meta: { cwd } })` only stamps the header cwd — it does NOT
267
+ * enter any workspace's `sessionIds` account, so the session is "stray" and
268
+ * lands in the Ungrouped group (workspace auto-bootstrap only runs once on
269
+ * first init, before our IM sessions exist). Explicitly resolving the cwd's
270
+ * workspace (creating it if the user never opened it) and attaching is the
271
+ * official path (workspaceRegistry.attachSession).
272
+ *
273
+ * Best-effort: workspace grouping must never block or fail an IM turn.
274
+ */
275
+ async attachToWorkspace(sessionId) {
276
+ try {
277
+ const workspaceRegistry = this.ctx.get("workspaceRegistry");
278
+ if (!workspaceRegistry) return;
279
+ const cwd = this.config.cwd;
280
+ if (!cwd) return;
281
+ let workspace = await workspaceRegistry.resolveByPath(cwd);
282
+ if (!workspace) workspace = await workspaceRegistry.create(cwd);
283
+ await workspace.attachSession(sessionId);
284
+ this.ctx.logger.info(`[im-gateway] attached session ${sessionId} to workspace "${cwd}"`);
285
+ } catch (err) {
286
+ this.ctx.logger.warn(`[im-gateway] workspace attach failed for ${sessionId}`, err);
287
+ }
288
+ }
289
+ /**
290
+ * Wire the agent-default-model selection into one agent's prompt assembly
291
+ * (mirrors dsh-host-apiproxy selectionFor). Listeners live on the agent's
292
+ * scoped ctx and are torn down with it.
293
+ *
294
+ * The `current` getter returns this session's /model override when set,
295
+ * otherwise the deployment default (`agentDefaultModel`). The setter records
296
+ * an override — that is exactly how the official `session.selectModel`
297
+ * switches the model for one conversation. We keep the ref alive in
298
+ * `modelRefs` so `/model` can mutate `current` on the same object the prompt
299
+ * assembly reads.
300
+ */
301
+ installModelSelection(agent, sessionId) {
302
+ const gateway = this;
303
+ const defaults = this.ctx.get("agentDefaultModel");
304
+ const ref = {
305
+ get current() {
306
+ const override = gateway.modelOverrides.get(sessionId) ?? gateway.globalModelOverride;
307
+ const base = defaults?.currentSelection();
308
+ return override ?? base ?? void 0;
309
+ },
310
+ set current(next) {
311
+ if (next) gateway.modelOverrides.set(sessionId, {
312
+ provider: next.provider,
313
+ model: next.model,
314
+ ...next.reasoningEffort === void 0 ? {} : { reasoningEffort: String(next.reasoningEffort) }
315
+ });
316
+ else gateway.modelOverrides.delete(sessionId);
317
+ },
318
+ assembled: void 0
319
+ };
320
+ installModelSelection(agent.ctx, ref);
321
+ this.modelRefs.set(sessionId, ref);
322
+ }
323
+ async runTurn(sessionId, convKey, channel, parts) {
324
+ const convId = convOf(convKey);
325
+ const agent = this.ctx.agents.get(sessionId);
326
+ if (!agent) {
327
+ this.ctx.logger.warn(`[im-gateway] no agent for session ${sessionId}`);
328
+ await channel.sendText(convId, "⚠️ 会话未就绪,请稍后重试。");
329
+ return;
330
+ }
331
+ const userMessage = createUserMessage({
332
+ content: parts,
333
+ source: { kind: "user" }
334
+ });
335
+ try {
336
+ agent.followup(userMessage);
337
+ } catch (err) {
338
+ this.ctx.logger.error(`[im-gateway] followup failed for ${convKey}`, err);
339
+ await channel.sendText(convId, "⚠️ 处理失败,请稍后重试。");
340
+ }
341
+ }
342
+ onSessionEvent(session, event) {
343
+ const convKey = this.sessionToConv.get(String(session.id));
344
+ if (!convKey) return;
345
+ const channel = this.channelForConv(convKey);
346
+ if (!channel) return;
347
+ const convId = convOf(convKey);
348
+ switch (event.type) {
349
+ case "turn/start":
350
+ this.finalText.set(convId, "");
351
+ this.streamStarted.delete(convId);
352
+ break;
353
+ case "assistant/chunk": {
354
+ const chunk = event.data.chunk;
355
+ if (chunk.type === "text-delta") {
356
+ if (channel.beginStream && !this.streamStarted.get(convId)) {
357
+ this.streamStarted.set(convId, true);
358
+ channel.beginStream(convId).catch((err) => {
359
+ this.ctx.logger.warn(`[im-gateway] beginStream failed: ${err instanceof Error ? err.message : String(err)}`);
360
+ });
361
+ }
362
+ this.finalText.set(convId, (this.finalText.get(convId) ?? "") + chunk.text);
363
+ this.streamToChannel(channel, convId, chunk.text);
364
+ }
365
+ break;
366
+ }
367
+ case "tool/call": {
368
+ const name = event.data.name ?? "未知工具";
369
+ if (channel.streamText) channel.streamText(convId, `🔧 正在调用工具:${name} …`).catch(() => void 0);
370
+ else channel.sendText(convId, `🔧 ${name} …`);
371
+ break;
372
+ }
373
+ case "assistant/message":
374
+ this.finalText.set(convId, extractText(event.data.message.content));
375
+ break;
376
+ case "turn/end": {
377
+ this.flushStream(channel, convId);
378
+ const full = this.finalText.get(convId) ?? "";
379
+ if (channel.endStream) channel.endStream(convId, full || "✅ 已完成(无文本输出)").catch((err) => {
380
+ this.ctx.logger.warn(`[im-gateway] endStream failed: ${err instanceof Error ? err.message : String(err)}`);
381
+ if (full) channel.sendText(convId, full).catch(() => void 0);
382
+ });
383
+ else if (!channel.updateCard) {
384
+ if (full) channel.sendText(convId, full);
385
+ else {
386
+ const reason = event.data?.reason;
387
+ if (reason?.kind === "error") {
388
+ const detail = typeof reason.error?.message === "string" ? reason.error.message : "未知错误";
389
+ channel.sendText(convId, `⚠️ 处理失败:${detail.slice(0, 200)}`);
390
+ }
391
+ }
392
+ }
393
+ break;
394
+ }
395
+ }
396
+ }
397
+ streamToChannel(channel, convId, delta) {
398
+ if (!channel.streamText && !channel.updateCard) return;
399
+ const entry = this.pendingStream.get(convId) ?? { text: "" };
400
+ entry.text += delta;
401
+ if (!entry.timer) entry.timer = setTimeout(() => this.flushStream(channel, convId), this.config.streamThrottleMs);
402
+ this.pendingStream.set(convId, entry);
403
+ }
404
+ flushStream(channel, convId) {
405
+ const entry = this.pendingStream.get(convId);
406
+ if (!entry) return;
407
+ const text = entry.text;
408
+ entry.text = "";
409
+ entry.timer = void 0;
410
+ this.pendingStream.set(convId, entry);
411
+ if (!text) return;
412
+ if (channel.streamText) channel.streamText(convId, text);
413
+ else channel.updateCard?.(convId, text);
414
+ }
415
+ async handleSlash(channel, message) {
416
+ const parts = message.text.slice(1).split(/\s+/);
417
+ const cmd = parts[0];
418
+ const arg = parts.slice(1).join(" ").trim();
419
+ const convKey = `${message.channelId}:${message.conversationId}`;
420
+ const convId = convOf(convKey);
421
+ switch (cmd) {
422
+ case "help":
423
+ await channel.sendText(convId, [
424
+ "**可用指令**",
425
+ "",
426
+ "- `/help` — 显示本帮助",
427
+ "- `/reset` · `/clear` · `/new` — 重置当前会话(开启新一轮)",
428
+ "- `/model` — 列出可用模型;`/model <编号|模型名|provider/model>` 切换",
429
+ "- `/status` — 查看当前渠道、工作目录与模型",
430
+ "- `/stop` — 中止当前正在生成的回答"
431
+ ].join("\n"));
432
+ return true;
433
+ case "reset":
434
+ case "clear":
435
+ case "new": {
436
+ const sessionId = this.convToSession.get(convKey);
437
+ if (sessionId) {
438
+ await this.agentHandles.get(sessionId)?.dispose().catch(() => void 0);
439
+ this.agentHandles.delete(sessionId);
440
+ this.modelRefs.delete(sessionId);
441
+ this.modelOverrides.delete(sessionId);
442
+ this.sessionToConv.delete(sessionId);
443
+ this.convToSession.delete(convKey);
444
+ }
445
+ this.finalText.delete(convId);
446
+ await channel.sendText(convId, "✅ 会话已重置,下一句将开启新一轮。");
447
+ return true;
448
+ }
449
+ case "model":
450
+ await this.handleModelCommand(channel, convKey, convId, arg);
451
+ return true;
452
+ case "status":
453
+ await this.handleStatusCommand(channel, convKey, convId, message);
454
+ return true;
455
+ case "stop":
456
+ await this.handleStopCommand(channel, convKey, convId);
457
+ return true;
458
+ default: return false;
459
+ }
460
+ }
461
+ async handleModelCommand(channel, convKey, convId, arg) {
462
+ const sessionId = this.convToSession.get(convKey);
463
+ const models = await this.listAvailableModels();
464
+ if (models.length === 0) {
465
+ await channel.sendText(convId, "⚠️ 暂无可用的模型(未配置 provider)。");
466
+ return;
467
+ }
468
+ if (!arg) {
469
+ const current = (sessionId ? this.modelOverrides.get(sessionId) : void 0) ?? this.globalModelOverride ?? this.ctx.get("agentDefaultModel")?.currentSelection();
470
+ const NUM_EMOJI = [
471
+ "1️⃣",
472
+ "2️⃣",
473
+ "3️⃣",
474
+ "4️⃣",
475
+ "5️⃣",
476
+ "6️⃣",
477
+ "7️⃣",
478
+ "8️⃣",
479
+ "9️⃣",
480
+ "🔟"
481
+ ];
482
+ const lines = models.map((m, i) => {
483
+ const isCur = current && current.provider === m.provider && current.model === m.model;
484
+ const label = m.name && m.name !== m.model ? `${m.model}(${m.name})` : m.model;
485
+ return `- ${NUM_EMOJI[i] ?? `${i + 1}.`} ${label} \`[${m.provider}]\`${isCur ? " ← 当前" : ""}`;
486
+ });
487
+ await channel.sendText(convId, [
488
+ "**可用模型**(输入 `/model <emoji|模型名|provider/model>` 切换——emoji 即列表左侧的 1️⃣2️⃣ 等;无会话时切换将设为默认模型,下次建立会话生效)",
489
+ "",
490
+ ...lines
491
+ ].join("\n"));
492
+ return;
493
+ }
494
+ const target = this.resolveModelArg(arg, models);
495
+ if (!target) {
496
+ await channel.sendText(convId, `⚠️ 未找到模型「${arg}」。输入 /model 查看可用列表。`);
497
+ return;
498
+ }
499
+ if (!sessionId) {
500
+ this.globalModelOverride = {
501
+ provider: target.provider,
502
+ model: target.model
503
+ };
504
+ await channel.sendText(convId, `✅ 已设置默认模型:\`${target.model}\` [\`${target.provider}\`](下次建立会话时生效,下一次发送消息后立即应用)`);
505
+ return;
506
+ }
507
+ this.modelOverrides.set(sessionId, {
508
+ provider: target.provider,
509
+ model: target.model
510
+ });
511
+ const ref = this.modelRefs.get(sessionId);
512
+ if (ref) ref.current = {
513
+ provider: target.provider,
514
+ model: target.model
515
+ };
516
+ await channel.sendText(convId, `✅ 已切换模型:\`${target.model}\` [\`${target.provider}\`](下一句生效)`);
517
+ }
518
+ /** Build the provider/model catalog from the host llm registry. */
519
+ async listAvailableModels() {
520
+ const llm = this.ctx.get("llm");
521
+ if (!llm?.listProviders) return [];
522
+ const out = [];
523
+ let providers = [];
524
+ try {
525
+ providers = llm.listProviders() ?? [];
526
+ } catch (err) {
527
+ this.ctx.logger.warn(`[im-gateway] listProviders failed: ${err instanceof Error ? err.message : String(err)}`);
528
+ return [];
529
+ }
530
+ for (const p of providers) {
531
+ let models = [];
532
+ try {
533
+ models = await llm.listModels(p.id);
534
+ } catch (err) {
535
+ this.ctx.logger.warn(`[im-gateway] listModels("${p.id}") failed: ${err instanceof Error ? err.message : String(err)}`);
536
+ continue;
537
+ }
538
+ for (const m of models) out.push({
539
+ provider: p.id,
540
+ model: m.id,
541
+ name: m.name ?? m.id
542
+ });
543
+ }
544
+ return out;
545
+ }
546
+ /** Resolve a /model argument (index | model id | partial | provider/model) to a catalog entry. */
547
+ resolveModelArg(arg, models) {
548
+ const byIndex = arg.match(/^(\d+)$/);
549
+ if (byIndex) return models[Number(byIndex[1]) - 1];
550
+ if (arg.includes("/")) {
551
+ const [provider, ...rest] = arg.split("/");
552
+ const model = rest.join("/");
553
+ return models.find((m) => m.provider === provider && m.model === model);
554
+ }
555
+ return models.find((m) => m.model === arg) ?? models.find((m) => m.model.toLowerCase() === arg.toLowerCase()) ?? models.find((m) => m.model.toLowerCase().includes(arg.toLowerCase()));
556
+ }
557
+ async handleStatusCommand(channel, convKey, convId, message) {
558
+ const sessionId = this.convToSession.get(convKey);
559
+ const sessionOverride = sessionId ? this.modelOverrides.get(sessionId) : void 0;
560
+ const sel = sessionOverride ?? this.globalModelOverride ?? this.ctx.get("agentDefaultModel")?.currentSelection();
561
+ const handle = sessionId ? this.agentHandles.get(sessionId) : void 0;
562
+ const lines = ["**当前状态**", ""];
563
+ lines.push(`- 📡 渠道:\`${message.channelId}\``, `- 📁 工作目录:\`${this.config.cwd}\``, `- 🤖 当前模型:${sel ? `\`${sel.provider} / ${sel.model}\`` : "未知"}`, `- 💬 会话:${sessionId ? "已建立" : "未建立"}`);
564
+ if (this.globalModelOverride && !sessionOverride) lines.push(`- 🌐 全局默认模型(预选):\`${this.globalModelOverride.provider} / ${this.globalModelOverride.model}\``);
565
+ if (handle) lines.push(`- ⚙️ 运行状态:\`${handle.agent.status}\``);
566
+ await channel.sendText(convId, lines.join("\n"));
567
+ }
568
+ async handleStopCommand(channel, convKey, convId) {
569
+ const sessionId = this.convToSession.get(convKey);
570
+ const handle = sessionId ? this.agentHandles.get(sessionId) : void 0;
571
+ if (!handle) {
572
+ await channel.sendText(convId, "⚠️ 当前没有进行中的会话。");
573
+ return;
574
+ }
575
+ try {
576
+ handle.agent.cancel({ kind: "user" }, { keepInbox: true });
577
+ await channel.sendText(convId, "🛑 已中止当前轮次。");
578
+ } catch (err) {
579
+ this.ctx.logger.error(`[im-gateway] /stop failed for ${convKey}`, err);
580
+ await channel.sendText(convId, "⚠️ 中止失败,请稍后重试。");
581
+ }
582
+ }
583
+ channelForConv(convKey) {
584
+ const channelId = convKey.slice(0, convKey.indexOf(":"));
585
+ return this.channels.get(channelId);
586
+ }
587
+ };
588
+ /** convKey = `channelId:conversationId` → conversationId (supports ':' inside it). */
589
+ function convOf(convKey) {
590
+ return convKey.slice(convKey.indexOf(":") + 1);
591
+ }
592
+ /**
593
+ * Build dsh ContentBlock[] from a normalized inbound message.
594
+ *
595
+ * PoC is TEXT-ONLY. Real multimodal requires registering raw bytes with the
596
+ * `@deepseek-ai/dsh-attachment` service to obtain an `ImageAttachmentRef`
597
+ * (the actual `ImageBlock` shape is `{ type: 'image'; attachment }`, NOT inline
598
+ * base64). That is a tracked seam: inbound images currently arrive as the
599
+ * "[图片]" text note the channel adapter emits. Wire `ctx.attachments` before
600
+ * enabling vision.
601
+ */
602
+ function toContentBlocks(message) {
603
+ const blocks = [];
604
+ if (message.text) {
605
+ const text = message.isGroup && message.senderNick ? `[@${message.senderNick}] ${message.text}` : message.text;
606
+ blocks.push({
607
+ type: "text",
608
+ text
609
+ });
610
+ }
611
+ return blocks;
612
+ }
613
+ /** Extract plain text from an assistant message's content blocks. */
614
+ function extractText(content) {
615
+ return content.filter((b) => b.type === "text").map((b) => b.text).join("");
616
+ }
617
+ /**
618
+ * Whether an error is a session-persistence id collision. The persistence
619
+ * coordinator throws several distinct collision messages; match on the
620
+ * stable substring instead of the error class (which can be duplicated when a
621
+ * dependency is inlined into the bundle).
622
+ */
623
+ function isIdCollision(err) {
624
+ const message = err instanceof Error ? err.message : String(err);
625
+ return message.includes("id collision") || message.includes("already has a persisted log");
626
+ }
627
+ //#endregion
628
+ //#region src/channels/dingtalk-connection.ts
629
+ /**
630
+ * DingTalk stream connection (ported from pi-desk-top/src/main/im/dingtalk/
631
+ * dingtalk-connection.ts — MIT, protocol code only; OpenClaw framework parts
632
+ * dropped, EventEmitter removed in favour of plain callback options).
633
+ *
634
+ * What this gives us:
635
+ * - DWClient WebSocket long connection (no public callback URL needed)
636
+ * - application-layer heartbeat: 10s ping / 20s timeout (SDK keepAlive off)
637
+ * - exponential-backoff reconnect with jitter, guarded against concurrency
638
+ * - message dedup with 5-minute TTL (stream-account scoped)
639
+ *
640
+ * The DingTalk "stream" bot pushes messages over an OUTBOUND WebSocket. We
641
+ * register a CALLBACK listener for TOPIC_ROBOT BEFORE connect() so the connect
642
+ * handshake carries the subscription — otherwise the server never delivers bot
643
+ * messages to our callback (this is the gotcha the reference calls out).
644
+ */
645
+ const HEARTBEAT_INTERVAL = 1e4;
646
+ const BASE_BACKOFF_DELAY$2 = 2e3;
647
+ const MAX_BACKOFF_DELAY$2 = 3e4;
648
+ const DEDUP_TTL_MS = 3e5;
649
+ let DWClientCtor = null;
650
+ let TOPIC_ROBOT = "";
651
+ async function loadDingtalkStream() {
652
+ if (DWClientCtor) return;
653
+ const mod = await import("dingtalk-stream");
654
+ DWClientCtor = mod.DWClient ?? mod.default?.DWClient;
655
+ TOPIC_ROBOT = mod.TOPIC_ROBOT ?? "/v1.0/im/bot/messages/get";
656
+ if (!DWClientCtor) throw new Error("dingtalk-stream: DWClient not found");
657
+ }
658
+ /**
659
+ * Thin wrapper around the official DWClient streaming socket. NOT an EventEmitter
660
+ * in this port — callers subscribe via the constructor callbacks.
661
+ */
662
+ var DingtalkConnection = class {
663
+ opts;
664
+ client = null;
665
+ stopped = false;
666
+ isReconnecting = false;
667
+ reconnectAttempts = 0;
668
+ keepAliveTimer = null;
669
+ dedup = /* @__PURE__ */ new Map();
670
+ connected = false;
671
+ constructor(opts) {
672
+ this.opts = opts;
673
+ }
674
+ get isConnected() {
675
+ return this.connected;
676
+ }
677
+ /** Mark a message id as processed (returns true if it was already seen). */
678
+ checkAndMark(accountId, msgId) {
679
+ const now = Date.now();
680
+ for (const [k, ts] of this.dedup) if (now - ts > DEDUP_TTL_MS) this.dedup.delete(k);
681
+ const key = `${accountId}:${msgId}`;
682
+ if (this.dedup.has(key)) return true;
683
+ this.dedup.set(key, now);
684
+ return false;
685
+ }
686
+ setupSocketListeners() {
687
+ const socket = this.client?.socket;
688
+ if (!socket) return;
689
+ socket.on("pong", () => {
690
+ this.reconnectAttempts = 0;
691
+ this.connected = true;
692
+ });
693
+ socket.on("message", () => {
694
+ this.reconnectAttempts = 0;
695
+ this.connected = true;
696
+ });
697
+ socket.on("close", () => {
698
+ this.connected = false;
699
+ if (!this.stopped && !this.isReconnecting) this.doReconnect(true);
700
+ });
701
+ }
702
+ startKeepAlive() {
703
+ this.stopKeepAlive();
704
+ this.keepAliveTimer = setInterval(() => {
705
+ if (!this.client?.socket) return;
706
+ if (this.client.socket.readyState === 1) {
707
+ this.client.socket.ping();
708
+ this.connected = true;
709
+ } else if (!this.isReconnecting) this.doReconnect(false);
710
+ }, HEARTBEAT_INTERVAL);
711
+ }
712
+ stopKeepAlive() {
713
+ if (this.keepAliveTimer) {
714
+ clearInterval(this.keepAliveTimer);
715
+ this.keepAliveTimer = null;
716
+ }
717
+ }
718
+ backoffDelay() {
719
+ const exponential = BASE_BACKOFF_DELAY$2 * 2 ** this.reconnectAttempts;
720
+ const jitter = Math.random() * 1e3;
721
+ return Math.min(exponential + jitter, MAX_BACKOFF_DELAY$2);
722
+ }
723
+ async doReconnect(immediate) {
724
+ if (this.isReconnecting || this.stopped) return;
725
+ this.isReconnecting = true;
726
+ try {
727
+ if (!immediate) await new Promise((r) => setTimeout(r, this.backoffDelay()));
728
+ this.reconnectAttempts++;
729
+ this.client?.disconnect();
730
+ this.registerMessageListener();
731
+ await this.client.connect();
732
+ this.setupSocketListeners();
733
+ if (await this.waitForSocketOpen()) {
734
+ this.connected = true;
735
+ this.reconnectAttempts = 0;
736
+ this.opts.onStatusChange?.(true);
737
+ }
738
+ } catch {} finally {
739
+ this.isReconnecting = false;
740
+ }
741
+ }
742
+ waitForSocketOpen() {
743
+ return new Promise((resolve) => {
744
+ const t = setTimeout(() => resolve(false), 1e4);
745
+ const check = () => {
746
+ if (this.client?.socket?.readyState === 1) {
747
+ clearTimeout(t);
748
+ resolve(true);
749
+ } else setTimeout(check, 200);
750
+ };
751
+ check();
752
+ });
753
+ }
754
+ registerMessageListener() {
755
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (res) => {
756
+ const headers = res?.headers ?? {};
757
+ const messageId = headers.messageId;
758
+ const rawData = res?.data ?? "";
759
+ if (messageId) this.client.socketCallBackResponse?.(messageId, { success: true });
760
+ if (messageId && this.checkAndMark("stream", messageId)) return { status: "SUCCESS" };
761
+ this.opts.onMessage(rawData, headers);
762
+ return { status: "SUCCESS" };
763
+ });
764
+ }
765
+ async connect() {
766
+ await loadDingtalkStream();
767
+ if (this.stopped) return;
768
+ this.client = new DWClientCtor({
769
+ clientId: this.opts.clientId,
770
+ clientSecret: this.opts.clientSecret,
771
+ autoReconnect: false,
772
+ keepAlive: false
773
+ });
774
+ this.client.on("error", (err) => {
775
+ this.connected = false;
776
+ this.opts.onStatusChange?.(false);
777
+ console.error("[im:dingtalk] connection error:", err?.message);
778
+ });
779
+ this.registerMessageListener();
780
+ await this.client.connect();
781
+ this.setupSocketListeners();
782
+ this.startKeepAlive();
783
+ this.connected = true;
784
+ this.opts.onStatusChange?.(true);
785
+ }
786
+ async stop() {
787
+ this.stopped = true;
788
+ this.stopKeepAlive();
789
+ this.client?.disconnect();
790
+ this.client = null;
791
+ this.connected = false;
792
+ }
793
+ };
794
+ //#endregion
795
+ //#region src/channels/dingtalk-card.ts
796
+ /**
797
+ * DingTalk AI Card streaming protocol (ported from
798
+ * pi-desk-top/src/main/im/dingtalk/dingtalk-card.ts, itself ported from
799
+ * dingtalk-openclaw-connector, MIT — protocol code only).
800
+ *
801
+ * Lifecycle:
802
+ * createDingtalkCard() → POST /v1.0/card/instances + /deliver (card instance)
803
+ * streamDingtalkCard() → PUT /v1.0/card/streaming (incremental content updates)
804
+ * finishDingtalkCard() → final streaming frame + PUT /v1.0/card/instances (FINISHED)
805
+ *
806
+ * The whole path is rate-limited by a global token bucket (DingTalk caps at
807
+ * ~40 req/s; we stay at 20) with a 2s backoff + one retry on QPS errors.
808
+ *
809
+ * HTTP uses the global fetch (Node >=18); no axios dependency.
810
+ *
811
+ * CRITICAL: every request goes through `cardFetch` which enforces a 10s
812
+ * timeout (AbortController) and checks DingTalk's business error code
813
+ * (HTTP 200 but errcode != 0). WITHOUT the timeout, a hung PUT would leave
814
+ * the gateway's endStream() pending forever — no card finalize AND no plain
815
+ * text fallback, i.e. the agent "replies" but the user sees nothing.
816
+ */
817
+ const DINGTALK_API = "https://api.dingtalk.com";
818
+ /** Official DingTalk AI Card template (same as the reference project). */
819
+ const AI_CARD_TEMPLATE_ID = "02fcf2f4-5e02-4a85-b672-46d1f715543e.schema";
820
+ const CARD_API_MAX_QPS = 20;
821
+ const QPS_BACKOFF_DURATION_MS = 2e3;
822
+ const TOKEN_EXPIRE_MS = 72e5;
823
+ /** Hard cap per card API request — a hang must become a rejection. */
824
+ const CARD_REQUEST_TIMEOUT_MS = 1e4;
825
+ /** Card flow status codes (DingTalk AI Card protocol). */
826
+ const AICardStatus = {
827
+ PROCESSING: "1",
828
+ INPUTING: "2",
829
+ FINISHED: "3",
830
+ EXECUTING: "4",
831
+ FAILED: "5"
832
+ };
833
+ const tokenCache = /* @__PURE__ */ new Map();
834
+ async function getAccessToken(cfg) {
835
+ const key = cfg.clientId;
836
+ const cached = tokenCache.get(key);
837
+ if (cached && cached.expiryMs > Date.now() + 6e4) return cached.token;
838
+ const data = await cardFetch(`${DINGTALK_API}/v1.0/oauth2/accessToken`, {
839
+ method: "POST",
840
+ headers: { "Content-Type": "application/json" },
841
+ body: JSON.stringify({
842
+ appKey: cfg.clientId,
843
+ appSecret: cfg.clientSecret
844
+ })
845
+ });
846
+ const token = data?.accessToken;
847
+ if (!token) throw new Error(`dingtalk card accessToken missing: ${JSON.stringify(data).slice(0, 200)}`);
848
+ const expireInSec = Number(data?.expireIn ?? 0) || 7200;
849
+ tokenCache.set(key, {
850
+ token,
851
+ expiryMs: Date.now() + expireInSec * 1e3
852
+ });
853
+ return token;
854
+ }
855
+ var TokenBucket = class {
856
+ tokens = CARD_API_MAX_QPS;
857
+ lastRefill = Date.now();
858
+ backoffUntil = 0;
859
+ async waitForToken() {
860
+ for (;;) {
861
+ const now = Date.now();
862
+ if (now < this.backoffUntil) {
863
+ await sleep(this.backoffUntil - now);
864
+ continue;
865
+ }
866
+ const elapsed = (now - this.lastRefill) / 1e3;
867
+ this.tokens = Math.min(CARD_API_MAX_QPS, this.tokens + elapsed * CARD_API_MAX_QPS);
868
+ this.lastRefill = now;
869
+ if (this.tokens >= 1) {
870
+ this.tokens -= 1;
871
+ return;
872
+ }
873
+ await sleep(Math.ceil((1 - this.tokens) / CARD_API_MAX_QPS * 1e3));
874
+ }
875
+ }
876
+ triggerBackoff() {
877
+ this.backoffUntil = Date.now() + QPS_BACKOFF_DURATION_MS;
878
+ this.tokens = 0;
879
+ }
880
+ };
881
+ const cardRateLimiter = new TokenBucket();
882
+ function sleep(ms) {
883
+ return new Promise((r) => setTimeout(r, ms));
884
+ }
885
+ function isQpsLimitError(err) {
886
+ const msg = `${err?.message ?? ""} ${err?.errmsg ?? ""} ${err?.code ?? ""}`.toLowerCase();
887
+ return msg.includes("qps") || msg.includes("rate limit") || msg.includes("too many");
888
+ }
889
+ /**
890
+ * fetch wrapper for every card API call. Guarantees:
891
+ * 1. 10s hard timeout — a hung request rejects instead of hanging forever
892
+ * (the caller's catch then triggers the plain-text fallback).
893
+ * 2. Business error detection — DingTalk often returns HTTP 200 with
894
+ * `errcode`/`errorCode` != 0; a bare `res.ok` check would treat a failed
895
+ * FINISHED as success and leave the card spinning forever.
896
+ * Returns the parsed JSON body.
897
+ */
898
+ async function cardFetch(url, init) {
899
+ const controller = new AbortController();
900
+ const timer = setTimeout(() => controller.abort(), CARD_REQUEST_TIMEOUT_MS);
901
+ let res;
902
+ try {
903
+ res = await fetch(url, {
904
+ ...init,
905
+ signal: controller.signal
906
+ });
907
+ } catch (err) {
908
+ clearTimeout(timer);
909
+ if (err?.name === "AbortError") {
910
+ const e = /* @__PURE__ */ new Error(`card api timeout after ${CARD_REQUEST_TIMEOUT_MS}ms: ${url}`);
911
+ e.timeout = true;
912
+ throw e;
913
+ }
914
+ throw err;
915
+ }
916
+ clearTimeout(timer);
917
+ let data = null;
918
+ try {
919
+ data = await res.json();
920
+ } catch {}
921
+ const errcode = data?.errcode ?? data?.errorCode;
922
+ if (!res.ok || errcode !== void 0 && errcode !== null && Number(errcode) !== 0) {
923
+ const err = new Error(`card api HTTP ${res.status} errcode ${errcode}: ${data?.errmsg ?? data?.message ?? ""}`.slice(0, 300));
924
+ err.status = res.status;
925
+ err.code = String(errcode);
926
+ err.errmsg = data?.errmsg ?? data?.message;
927
+ throw err;
928
+ }
929
+ return data;
930
+ }
931
+ function buildDeliverBody(cardInstanceId, target, robotCode) {
932
+ const base = {
933
+ outTrackId: cardInstanceId,
934
+ userIdType: 1
935
+ };
936
+ if (target.type === "group") return {
937
+ ...base,
938
+ openSpaceId: `dtv1.card//IM_GROUP.${target.targetId}`,
939
+ imGroupOpenDeliverModel: { robotCode }
940
+ };
941
+ return {
942
+ ...base,
943
+ openSpaceId: `dtv1.card//IM_ROBOT.${target.targetId}`,
944
+ imRobotOpenDeliverModel: {
945
+ spaceType: "IM_ROBOT",
946
+ robotCode,
947
+ extension: { dynamicSummary: "true" }
948
+ }
949
+ };
950
+ }
951
+ /** Create a card instance and deliver it to the target. */
952
+ async function createDingtalkCard(cfg, target) {
953
+ try {
954
+ const token = await getAccessToken(cfg);
955
+ const cardInstanceId = `card_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
956
+ const createBody = {
957
+ cardTemplateId: AI_CARD_TEMPLATE_ID,
958
+ outTrackId: cardInstanceId,
959
+ cardData: { cardParamMap: { config: JSON.stringify({ autoLayout: true }) } },
960
+ callbackType: "STREAM",
961
+ imGroupOpenSpaceModel: { supportForward: true },
962
+ imRobotOpenSpaceModel: { supportForward: true }
963
+ };
964
+ await cardFetch(`${DINGTALK_API}/v1.0/card/instances`, {
965
+ method: "POST",
966
+ headers: {
967
+ "x-acs-dingtalk-access-token": token,
968
+ "Content-Type": "application/json"
969
+ },
970
+ body: JSON.stringify(createBody)
971
+ });
972
+ const deliverBody = buildDeliverBody(cardInstanceId, target, String(cfg.clientId ?? ""));
973
+ await cardFetch(`${DINGTALK_API}/v1.0/card/instances/deliver`, {
974
+ method: "POST",
975
+ headers: {
976
+ "x-acs-dingtalk-access-token": token,
977
+ "Content-Type": "application/json"
978
+ },
979
+ body: JSON.stringify(deliverBody)
980
+ });
981
+ return {
982
+ cardInstanceId,
983
+ accessToken: token,
984
+ tokenExpireTime: Date.now() + TOKEN_EXPIRE_MS,
985
+ inputingStarted: false
986
+ };
987
+ } catch (err) {
988
+ console.error(`[im:dingtalk] AI Card create failed:`, err?.message ?? err);
989
+ return null;
990
+ }
991
+ }
992
+ async function ensureValidToken(card, cfg) {
993
+ if (Date.now() > card.tokenExpireTime - 3e5) {
994
+ card.accessToken = await getAccessToken(cfg);
995
+ card.tokenExpireTime = Date.now() + TOKEN_EXPIRE_MS;
996
+ }
997
+ return card.accessToken;
998
+ }
999
+ async function putWithRetry(url, body, token) {
1000
+ await cardRateLimiter.waitForToken();
1001
+ try {
1002
+ await cardFetch(url, {
1003
+ method: "PUT",
1004
+ headers: {
1005
+ "x-acs-dingtalk-access-token": token,
1006
+ "Content-Type": "application/json"
1007
+ },
1008
+ body: JSON.stringify(body)
1009
+ });
1010
+ } catch (err) {
1011
+ if (isQpsLimitError(err)) {
1012
+ cardRateLimiter.triggerBackoff();
1013
+ await cardRateLimiter.waitForToken();
1014
+ await cardFetch(url, {
1015
+ method: "PUT",
1016
+ headers: {
1017
+ "x-acs-dingtalk-access-token": token,
1018
+ "Content-Type": "application/json"
1019
+ },
1020
+ body: JSON.stringify(body)
1021
+ });
1022
+ } else throw err;
1023
+ }
1024
+ }
1025
+ /** Incrementally update the card content (finished=true finalizes the text). */
1026
+ async function streamDingtalkCard(card, content, finished = false, cfg) {
1027
+ if (!card) return;
1028
+ if (cfg) await ensureValidToken(card, cfg);
1029
+ if (!card.inputingStarted) {
1030
+ await cardRateLimiter.waitForToken();
1031
+ const statusBody = {
1032
+ outTrackId: card.cardInstanceId,
1033
+ cardData: { cardParamMap: {
1034
+ flowStatus: AICardStatus.INPUTING,
1035
+ msgContent: content,
1036
+ staticMsgContent: "",
1037
+ sys_full_json_obj: JSON.stringify({ order: ["msgContent"] }),
1038
+ config: JSON.stringify({ autoLayout: true })
1039
+ } }
1040
+ };
1041
+ try {
1042
+ await cardFetch(`${DINGTALK_API}/v1.0/card/instances`, {
1043
+ method: "PUT",
1044
+ headers: {
1045
+ "x-acs-dingtalk-access-token": card.accessToken,
1046
+ "Content-Type": "application/json"
1047
+ },
1048
+ body: JSON.stringify(statusBody)
1049
+ });
1050
+ } catch (err) {
1051
+ if (isQpsLimitError(err)) {
1052
+ cardRateLimiter.triggerBackoff();
1053
+ await cardRateLimiter.waitForToken();
1054
+ await cardFetch(`${DINGTALK_API}/v1.0/card/instances`, {
1055
+ method: "PUT",
1056
+ headers: {
1057
+ "x-acs-dingtalk-access-token": card.accessToken,
1058
+ "Content-Type": "application/json"
1059
+ },
1060
+ body: JSON.stringify(statusBody)
1061
+ });
1062
+ } else throw err;
1063
+ }
1064
+ card.inputingStarted = true;
1065
+ }
1066
+ const fixedContent = content;
1067
+ const streamContent = finished ? fixedContent : fixedContent.replace(/\n+$/, "");
1068
+ const body = {
1069
+ outTrackId: card.cardInstanceId,
1070
+ guid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1071
+ key: "msgContent",
1072
+ content: streamContent,
1073
+ isFull: true,
1074
+ isFinalize: finished,
1075
+ isError: false
1076
+ };
1077
+ await putWithRetry(`${DINGTALK_API}/v1.0/card/streaming`, body, card.accessToken);
1078
+ }
1079
+ /** Finalize the card (FINISHED status — removes the loading animation). */
1080
+ async function finishDingtalkCard(card, content, cfg, skipStreamFinalize = false) {
1081
+ if (!card) return;
1082
+ if (cfg) await ensureValidToken(card, cfg);
1083
+ const fixedContent = content;
1084
+ if (!skipStreamFinalize) await streamDingtalkCard(card, fixedContent, true, cfg);
1085
+ const body = {
1086
+ outTrackId: card.cardInstanceId,
1087
+ cardData: { cardParamMap: {
1088
+ flowStatus: AICardStatus.FINISHED,
1089
+ msgContent: fixedContent,
1090
+ staticMsgContent: "",
1091
+ sys_full_json_obj: JSON.stringify({ order: ["msgContent"] }),
1092
+ config: JSON.stringify({ autoLayout: true })
1093
+ } },
1094
+ cardUpdateOptions: { updateCardDataByKey: true }
1095
+ };
1096
+ await putWithRetry(`${DINGTALK_API}/v1.0/card/instances`, body, card.accessToken);
1097
+ }
1098
+ //#endregion
1099
+ //#region src/channels/dingtalk.ts
1100
+ function parseJsonish(raw) {
1101
+ if (raw == null) return null;
1102
+ if (typeof raw === "object") return raw;
1103
+ if (typeof raw === "string") try {
1104
+ const parsed = JSON.parse(raw);
1105
+ if (parsed && typeof parsed === "object") return parsed;
1106
+ } catch {}
1107
+ return null;
1108
+ }
1109
+ /** DingTalk stream messages carry the payload in `content` (a JSON STRING). */
1110
+ function resolveDingtalkContent(data) {
1111
+ return parseJsonish(data?.content);
1112
+ }
1113
+ /**
1114
+ * Remove a leading `@name ` mention that DingTalk injects onto group messages.
1115
+ * Matches either "@Name " (no whitespace inside the handle) or the bracket form
1116
+ * "@[Name] " some clients emit. Returns the text unchanged if no mention lead.
1117
+ */
1118
+ function stripLeadingMention(text) {
1119
+ return text.replace(/^@\[?([^\]\s]+)\]?\s*/, "");
1120
+ }
1121
+ function extractQuotedText(container) {
1122
+ const content = parseJsonish(container?.repliedMsg?.content);
1123
+ switch (container?.repliedMsg?.msgType) {
1124
+ case "text": return content?.text ?? "";
1125
+ case "picture": return "[图片]";
1126
+ case "audio": return content?.recognition ?? "[语音消息]";
1127
+ case "file": return `[文件: ${content?.fileName ?? "?"}]`;
1128
+ case "richText": return (content?.richText ?? []).map((i) => i?.text ?? "").join("");
1129
+ default: return "";
1130
+ }
1131
+ }
1132
+ function extractQuotedMedia(container) {
1133
+ const content = parseJsonish(container?.repliedMsg?.content);
1134
+ if (container?.repliedMsg?.msgType === "picture") return content?.downloadCode ? [content.downloadCode] : [];
1135
+ if (container?.repliedMsg?.msgType === "richText") return (content?.richText ?? []).map((i) => i?.downloadCode ?? "").filter(Boolean);
1136
+ return [];
1137
+ }
1138
+ var DingtalkAdapter = class {
1139
+ ctx;
1140
+ config;
1141
+ id;
1142
+ label;
1143
+ conn = null;
1144
+ active = false;
1145
+ token = null;
1146
+ /**
1147
+ * peer → reply context. The userId (senderStaffId) drives group replies: we
1148
+ * @ the last user who talked to the bot in that conversation. encryptedId
1149
+ * (senderId) is the encrypted form some @ channels need. The webhook is the
1150
+ * group/single reply channel that actually honors `at`.
1151
+ */
1152
+ peerInfo = /* @__PURE__ */ new Map();
1153
+ /** Runtime-status sink, assigned by the gateway on registration (ChannelStatus broadcast). */
1154
+ statusListener;
1155
+ /** Active AI card per conversation — one streaming card at a time per peer. */
1156
+ cards = /* @__PURE__ */ new Map();
1157
+ constructor(ctx, config, id, label) {
1158
+ this.ctx = ctx;
1159
+ this.config = config;
1160
+ this.id = id;
1161
+ this.label = label;
1162
+ }
1163
+ isActive() {
1164
+ return this.active;
1165
+ }
1166
+ async start() {
1167
+ if (!this.config.clientId || !this.config.clientSecret) {
1168
+ this.ctx.logger.warn(`[im-channel-dingtalk:${this.id}] clientId/clientSecret missing; not connecting`);
1169
+ this.statusListener?.({
1170
+ status: "offline",
1171
+ lastChange: Date.now()
1172
+ });
1173
+ return;
1174
+ }
1175
+ this.conn = new DingtalkConnection({
1176
+ clientId: this.config.clientId,
1177
+ clientSecret: this.config.clientSecret,
1178
+ onMessage: (rawData, headers) => void this.handleRaw(rawData, headers),
1179
+ onStatusChange: (connected) => {
1180
+ this.active = connected;
1181
+ this.statusListener?.({
1182
+ status: connected ? "online" : "offline",
1183
+ lastChange: Date.now()
1184
+ });
1185
+ this.ctx.logger.info(`[im-channel-dingtalk] ${connected ? "connected" : "disconnected"}`);
1186
+ }
1187
+ });
1188
+ try {
1189
+ await this.conn.connect();
1190
+ this.active = true;
1191
+ } catch (err) {
1192
+ this.active = false;
1193
+ this.statusListener?.({
1194
+ status: "error",
1195
+ error: String(err),
1196
+ lastChange: Date.now()
1197
+ });
1198
+ this.ctx.logger.error("[im-channel-dingtalk] connect failed", err);
1199
+ throw err;
1200
+ }
1201
+ }
1202
+ async stop() {
1203
+ await this.conn?.stop();
1204
+ this.conn = null;
1205
+ this.active = false;
1206
+ }
1207
+ async handleRaw(rawData, _headers) {
1208
+ let data;
1209
+ try {
1210
+ data = JSON.parse(rawData);
1211
+ } catch {
1212
+ return;
1213
+ }
1214
+ const msgtype = data.msgtype;
1215
+ if (!(msgtype === "text" || msgtype === "picture" || msgtype === "richText" || msgtype === "audio" || msgtype === "file")) return;
1216
+ if (data.conversationType !== "1" && data.conversationType !== "2") return;
1217
+ const isGroup = data.conversationType === "2";
1218
+ const peer = isGroup ? data.conversationId ?? "" : data.senderStaffId ?? data.senderId ?? "";
1219
+ const userId = data.senderStaffId ?? data.senderId ?? "";
1220
+ if (!peer) return;
1221
+ if (isGroup && !data.isInAtList) return;
1222
+ this.peerInfo.set(peer, {
1223
+ isGroup,
1224
+ userId,
1225
+ encryptedId: data.senderId,
1226
+ webhook: data.sessionWebhook,
1227
+ senderNick: data.senderNick
1228
+ });
1229
+ const content = resolveDingtalkContent(data);
1230
+ const downloadCodes = [];
1231
+ let text = "";
1232
+ if (msgtype === "text") {
1233
+ text = data.text?.content ?? "";
1234
+ const quoted = extractQuotedText(data.text);
1235
+ if (quoted) text = `[引用] ${quoted}\n\n${text}`;
1236
+ downloadCodes.push(...extractQuotedMedia(data.text));
1237
+ } else if (msgtype === "picture") {
1238
+ text = "[图片]";
1239
+ const code = content?.downloadCode ?? data.picture?.downloadCode ?? "";
1240
+ if (code) downloadCodes.push(code);
1241
+ } else if (msgtype === "richText") {
1242
+ const richList = content?.richText ?? data?.richText?.richTextList ?? [];
1243
+ const parts = [];
1244
+ for (const item of richList) {
1245
+ if (typeof item?.text === "string") parts.push(item.text);
1246
+ if (item?.downloadCode) downloadCodes.push(item.downloadCode);
1247
+ }
1248
+ const quoted = extractQuotedText(content);
1249
+ if (quoted) parts.unshift(`[引用] ${quoted}`);
1250
+ downloadCodes.push(...extractQuotedMedia(content));
1251
+ text = parts.join("\n");
1252
+ if (!text) text = downloadCodes.length ? "[图片]" : "[富文本消息]";
1253
+ } else if (msgtype === "audio") text = content?.recognition || "[语音消息]";
1254
+ else if (msgtype === "file") {
1255
+ const fileName = content?.fileName || "文件";
1256
+ text = content?.downloadCode ?? "" ? `[文件: ${fileName}]` : `[文件: ${fileName}]`;
1257
+ }
1258
+ if (!text && downloadCodes.length === 0) return;
1259
+ text = stripLeadingMention(text);
1260
+ const message = {
1261
+ channelId: this.id,
1262
+ conversationId: peer,
1263
+ userId,
1264
+ text,
1265
+ senderNick: data.senderNick,
1266
+ isGroup
1267
+ };
1268
+ this.ctx.imGateway.handleInbound(message).catch((err) => this.ctx.logger.error("[im-channel-dingtalk] handleInbound failed", err));
1269
+ }
1270
+ async sendText(conversationId, text) {
1271
+ const info = this.peerInfo.get(conversationId) ?? {
1272
+ isGroup: true,
1273
+ userId: ""
1274
+ };
1275
+ if (!info.webhook) {
1276
+ this.ctx.logger.warn(`[im-channel-dingtalk] no sessionWebhook for peer ${conversationId}; cannot reply (single-chat fallback not implemented in PoC)`);
1277
+ return;
1278
+ }
1279
+ const body = {
1280
+ msgtype: "markdown",
1281
+ markdown: {
1282
+ title: this.label || "AI 助手",
1283
+ text: info.senderNick ? `@${info.senderNick} ${text}` : text
1284
+ }
1285
+ };
1286
+ if (info.isGroup) {
1287
+ body.at = {};
1288
+ if (info.userId) body.at.atUserIds = [info.userId];
1289
+ if (info.encryptedId) body.at.atDingtalkIds = [info.encryptedId];
1290
+ }
1291
+ try {
1292
+ const res = await fetch(info.webhook, {
1293
+ method: "POST",
1294
+ headers: { "Content-Type": "application/json" },
1295
+ body: JSON.stringify(body)
1296
+ });
1297
+ if (!res.ok) this.ctx.logger.warn(`[im-channel-dingtalk] webhook reply failed: HTTP ${res.status}`);
1298
+ } catch (err) {
1299
+ this.ctx.logger.error("[im-channel-dingtalk] webhook reply error", err);
1300
+ }
1301
+ }
1302
+ async sendImage(conversationId, image) {
1303
+ const info = this.peerInfo.get(conversationId);
1304
+ if (!info?.webhook) {
1305
+ this.ctx.logger.warn(`[im-channel-dingtalk] no sessionWebhook for peer ${conversationId}; skipping image`);
1306
+ return;
1307
+ }
1308
+ try {
1309
+ const token = await this.getToken();
1310
+ const buf = Buffer.from(image.data, "base64");
1311
+ const form = new FormData();
1312
+ form.append("media", new Blob([buf], { type: image.mediaType }), "image");
1313
+ const upJson = await (await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}&type=image`, {
1314
+ method: "POST",
1315
+ body: form
1316
+ })).json();
1317
+ if (upJson.errcode) {
1318
+ this.ctx.logger.warn(`[im-channel-dingtalk] media upload failed: ${upJson.errcode} ${upJson.errmsg}`);
1319
+ return;
1320
+ }
1321
+ await fetch(info.webhook, {
1322
+ method: "POST",
1323
+ headers: { "Content-Type": "application/json" },
1324
+ body: JSON.stringify({
1325
+ msgtype: "image",
1326
+ image: { media_id: upJson.media_id }
1327
+ })
1328
+ });
1329
+ } catch (err) {
1330
+ this.ctx.logger.error("[im-channel-dingtalk] sendImage error", err);
1331
+ }
1332
+ }
1333
+ cardTarget(conversationId) {
1334
+ return this.peerInfo.get(conversationId)?.isGroup ?? true ? {
1335
+ type: "group",
1336
+ targetId: conversationId
1337
+ } : {
1338
+ type: "user",
1339
+ targetId: conversationId
1340
+ };
1341
+ }
1342
+ async beginStream(conversationId) {
1343
+ if (this.cards.has(conversationId)) this.cards.delete(conversationId);
1344
+ const card = await createDingtalkCard(this.config, this.cardTarget(conversationId));
1345
+ this.cards.set(conversationId, card ?? null);
1346
+ if (!card) {
1347
+ this.ctx.logger.warn(`[im-channel-dingtalk:${this.id}] beginStream: card create FAILED for ${conversationId}; falling back to text`);
1348
+ await this.sendText(conversationId, "⏳ 正在思考…").catch(() => {});
1349
+ } else this.ctx.logger.info(`[im-channel-dingtalk:${this.id}] beginStream: card ${card.cardInstanceId} created for ${conversationId}`);
1350
+ }
1351
+ async streamText(conversationId, text, finished = false) {
1352
+ const card = this.cards.get(conversationId);
1353
+ if (!card) return;
1354
+ try {
1355
+ await streamDingtalkCard(card, text, finished, this.config);
1356
+ } catch (err) {
1357
+ this.ctx.logger.warn(`[im-channel-dingtalk:${this.id}] streamText failed: ${err instanceof Error ? err.message : String(err)}`);
1358
+ this.cards.delete(conversationId);
1359
+ }
1360
+ }
1361
+ async endStream(conversationId, text) {
1362
+ const card = this.cards.get(conversationId);
1363
+ this.cards.delete(conversationId);
1364
+ if (!card) {
1365
+ this.ctx.logger.warn(`[im-channel-dingtalk:${this.id}] endStream: no card for ${conversationId}; plain-text fallback`);
1366
+ await this.sendText(conversationId, text);
1367
+ return;
1368
+ }
1369
+ const info = this.peerInfo.get(conversationId);
1370
+ const final = (info?.isGroup ?? true) && info?.senderNick ? `@${info.senderNick} ${text}` : text;
1371
+ try {
1372
+ const long = final.length > CARD_STREAM_FRAME_LIMIT;
1373
+ await finishDingtalkCard(card, final, this.config, long);
1374
+ this.ctx.logger.info(`[im-channel-dingtalk:${this.id}] endStream: card ${card.cardInstanceId} FINISHED for ${conversationId} (${final.length} chars)`);
1375
+ } catch (err) {
1376
+ this.ctx.logger.warn(`[im-channel-dingtalk:${this.id}] endStream finalize failed: ${err instanceof Error ? err.message : String(err)}; fallback to text`);
1377
+ await this.sendText(conversationId, text).catch(() => {});
1378
+ }
1379
+ }
1380
+ async getToken() {
1381
+ if (this.token && this.token.expiresAt > Date.now() + 6e4) return this.token.value;
1382
+ const url = `https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent(this.config.clientId)}&appsecret=${encodeURIComponent(this.config.clientSecret)}`;
1383
+ const json = await (await fetch(url)).json();
1384
+ if (json.errcode) throw new Error(`dingtalk gettoken: ${json.errcode} ${json.errmsg}`);
1385
+ this.token = {
1386
+ value: json.access_token,
1387
+ expiresAt: Date.now() + (json.expires_in ?? 7200) * 1e3
1388
+ };
1389
+ return this.token.value;
1390
+ }
1391
+ };
1392
+ /** DingTalk card streaming API caps a single content frame at ~1K. */
1393
+ const CARD_STREAM_FRAME_LIMIT = 1e3;
1394
+ //#endregion
1395
+ //#region src/channels/qq.ts
1396
+ const BASE_BACKOFF_DELAY$1 = 2e3;
1397
+ const MAX_BACKOFF_DELAY$1 = 3e4;
1398
+ /** True when a path points at a local file (drive letter, /, ~, file://). */
1399
+ function isLocalPath$1(raw) {
1400
+ return raw.startsWith("file://") || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith("/") || raw.startsWith("~");
1401
+ }
1402
+ /** True when the path exists AND is a regular file (directories are skipped
1403
+ * so a `pwd`-style output never gets uploaded as media). */
1404
+ function isRegularFile(p) {
1405
+ try {
1406
+ return statSync(p).isFile();
1407
+ } catch {
1408
+ return false;
1409
+ }
1410
+ }
1411
+ /** Strip file:// / URL-encoding to get the on-disk absolute path. */
1412
+ function toLocalPath$1(raw) {
1413
+ let p = raw.startsWith("file://") ? raw.slice(7) : raw;
1414
+ try {
1415
+ p = decodeURIComponent(p);
1416
+ } catch {}
1417
+ return p;
1418
+ }
1419
+ /** Guess a MIME type from a download URL (QQ attachments expose content_type). */
1420
+ function mimeFromContentType(ct) {
1421
+ if (!ct) return "image/jpeg";
1422
+ const base = ct.split(";")[0].trim().toLowerCase();
1423
+ if (base.startsWith("image/")) return base;
1424
+ return "image/jpeg";
1425
+ }
1426
+ var QQAdapter = class {
1427
+ ctx;
1428
+ id;
1429
+ label;
1430
+ appId;
1431
+ appSecret;
1432
+ bot = null;
1433
+ stopped = true;
1434
+ active = false;
1435
+ reconnectAttempts = 0;
1436
+ reconnectTimer = null;
1437
+ /** Per-peer (target string) last inbound msgId — anchors stream replies. */
1438
+ lastMsgIds = /* @__PURE__ */ new Map();
1439
+ /** Active StreamSession per peer (C2C only). */
1440
+ streamSessions = /* @__PURE__ */ new Map();
1441
+ /** Runtime-status sink, assigned by the gateway on registration. */
1442
+ statusListener;
1443
+ constructor(ctx, config, id, label) {
1444
+ this.ctx = ctx;
1445
+ this.id = id;
1446
+ this.label = label;
1447
+ this.appId = config.appId ?? "";
1448
+ this.appSecret = config.clientSecret ?? "";
1449
+ }
1450
+ isActive() {
1451
+ return this.active;
1452
+ }
1453
+ setStatus(s, error) {
1454
+ this.statusListener?.({
1455
+ status: s,
1456
+ error,
1457
+ lastChange: Date.now()
1458
+ });
1459
+ }
1460
+ async start() {
1461
+ if (!this.appId || !this.appSecret) {
1462
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] credentials missing — bind via QR scan first`);
1463
+ this.setStatus("error", "credentials missing — bind via QR scan first");
1464
+ return;
1465
+ }
1466
+ this.stopped = false;
1467
+ this.setStatus("offline");
1468
+ const { QQBot } = await import("@tencent-connect/qqbot-nodejs");
1469
+ const bot = new QQBot({
1470
+ appId: this.appId,
1471
+ appSecret: this.appSecret,
1472
+ accountId: this.id,
1473
+ markdownSupport: true,
1474
+ tokenPrefetch: "async"
1475
+ });
1476
+ this.bot = bot;
1477
+ bot.on("ready", () => {
1478
+ this.reconnectAttempts = 0;
1479
+ this.active = true;
1480
+ this.setStatus("online");
1481
+ });
1482
+ bot.on("resumed", () => {
1483
+ this.active = true;
1484
+ this.setStatus("online");
1485
+ });
1486
+ bot.on("error", (err) => {
1487
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] gateway error: ${err.message}`);
1488
+ if (!this.stopped && this.active) {
1489
+ this.setStatus("offline");
1490
+ this.scheduleReconnect();
1491
+ }
1492
+ });
1493
+ bot.on("message", (_ctx, msg) => {
1494
+ try {
1495
+ this.handleMessage(msg);
1496
+ } catch (err) {
1497
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] handleMessage failed: ${String(err)}`);
1498
+ }
1499
+ });
1500
+ bot.on("interaction", (_ctx, event) => {
1501
+ const btnId = event.data?.resolved?.button_data ?? event.data?.resolved?.button_id;
1502
+ if (!btnId) return;
1503
+ bot.acknowledgeInteraction(event.id).catch(() => void 0);
1504
+ const userId = event.data?.resolved?.user_id;
1505
+ if (userId) {
1506
+ const msg = {
1507
+ channelId: this.id,
1508
+ conversationId: `c2c:${userId}`,
1509
+ userId,
1510
+ text: `[按钮点击] ${btnId}`
1511
+ };
1512
+ this.ctx.imGateway.handleInbound(msg).catch(() => void 0);
1513
+ }
1514
+ });
1515
+ let startResolved = false;
1516
+ await Promise.race([bot.start().then(() => {
1517
+ startResolved = true;
1518
+ }).catch((err) => {
1519
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] bot.start rejected: ${String(err?.message)}`);
1520
+ this.setStatus("error", `bot.start rejected: ${String(err?.message)}`);
1521
+ }), new Promise((resolve) => setTimeout(() => {
1522
+ if (!startResolved) this.ctx.logger.warn(`[im-channel-qq:${this.id}] bot.start did not resolve within 5s — continuing in background`);
1523
+ resolve();
1524
+ }, 5e3))]);
1525
+ }
1526
+ async stop() {
1527
+ this.stopped = true;
1528
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
1529
+ this.reconnectTimer = null;
1530
+ const bot = this.bot;
1531
+ this.bot = null;
1532
+ if (bot) try {
1533
+ bot.stop();
1534
+ } catch (err) {
1535
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] stop failed: ${String(err)}`);
1536
+ }
1537
+ this.active = false;
1538
+ this.setStatus("offline");
1539
+ }
1540
+ scheduleReconnect() {
1541
+ if (this.stopped || this.reconnectTimer) return;
1542
+ const attempt = this.reconnectAttempts;
1543
+ this.reconnectAttempts += 1;
1544
+ const backoff = Math.min(MAX_BACKOFF_DELAY$1, BASE_BACKOFF_DELAY$1 * 2 ** Math.min(attempt, 5));
1545
+ this.reconnectTimer = setTimeout(() => {
1546
+ this.reconnectTimer = null;
1547
+ if (this.stopped) return;
1548
+ this.setStatus("offline");
1549
+ this.start().catch((err) => {
1550
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] reconnect failed (${attempt}): ${String(err?.message)}`);
1551
+ this.scheduleReconnect();
1552
+ });
1553
+ }, backoff);
1554
+ }
1555
+ async handleMessage(msg) {
1556
+ const kind = msg.kind;
1557
+ if (kind !== "c2c" && kind !== "group") return;
1558
+ if (msg.senderIsBot) return;
1559
+ let text = (msg.content ?? "").trim();
1560
+ if (msg.refMsgIdx && Array.isArray(msg.msgElements) && msg.msgElements.length > 0) {
1561
+ const quoted = msg.msgElements[0].content?.trim();
1562
+ if (quoted) text = `[用户正在回复以下消息: ${quoted}]\n${text}`;
1563
+ }
1564
+ const voiceText = (msg.attachments ?? []).map((a) => a.asr_refer_text).find((t) => Boolean(t));
1565
+ if (voiceText) text = text ? `${text}\n[语音转文字: ${voiceText}]` : `[语音转文字: ${voiceText}]`;
1566
+ const images = [];
1567
+ for (const att of msg.attachments ?? []) {
1568
+ if (!att.url) continue;
1569
+ if (!att.content_type?.startsWith("image/")) continue;
1570
+ try {
1571
+ const res = await fetch(att.url);
1572
+ if (!res.ok) continue;
1573
+ const buf = Buffer.from(await res.arrayBuffer());
1574
+ images.push({
1575
+ mediaType: mimeFromContentType(att.content_type),
1576
+ data: buf.toString("base64")
1577
+ });
1578
+ } catch (err) {
1579
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] image download failed: ${String(err)}`);
1580
+ }
1581
+ }
1582
+ if (!text && images.length === 0) return;
1583
+ const base = {
1584
+ channelId: this.id,
1585
+ text,
1586
+ images: images.length ? images : void 0
1587
+ };
1588
+ if (kind === "c2c") {
1589
+ this.lastMsgIds.set(`c2c:${msg.senderId}`, msg.messageId);
1590
+ const message = {
1591
+ ...base,
1592
+ conversationId: `c2c:${msg.senderId}`,
1593
+ userId: msg.senderId,
1594
+ senderNick: msg.senderName,
1595
+ isGroup: false
1596
+ };
1597
+ this.ctx.imGateway.handleInbound(message).catch((err) => this.ctx.logger.error(`[im-channel-qq:${this.id}] handleInbound failed`, err));
1598
+ return;
1599
+ }
1600
+ const groupOpenid = msg.groupOpenid ?? msg.channelId ?? "";
1601
+ if (!groupOpenid) return;
1602
+ if (!(msg.mentions ?? []).some((m) => m?.is_you === true)) return;
1603
+ const message = {
1604
+ ...base,
1605
+ conversationId: `group:${groupOpenid}`,
1606
+ userId: msg.senderId,
1607
+ senderNick: msg.senderName,
1608
+ isGroup: true
1609
+ };
1610
+ this.ctx.imGateway.handleInbound(message).catch((err) => this.ctx.logger.error(`[im-channel-qq:${this.id}] handleInbound failed`, err));
1611
+ }
1612
+ parseTarget(target) {
1613
+ const sep = target.indexOf(":");
1614
+ const scope = target.slice(0, sep);
1615
+ const targetId = target.slice(sep + 1);
1616
+ if (scope !== "c2c" && scope !== "group" || !targetId) return null;
1617
+ return {
1618
+ scope,
1619
+ targetId
1620
+ };
1621
+ }
1622
+ async sendText(conversationId, text) {
1623
+ const bot = this.bot;
1624
+ if (!bot) throw new Error("QQ bot not connected");
1625
+ const rt = this.parseTarget(conversationId);
1626
+ if (!rt) {
1627
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] invalid send target: ${conversationId}`);
1628
+ return;
1629
+ }
1630
+ const enriched = await this.sendMediaForText(rt, text);
1631
+ await bot.sendText(rt, enriched);
1632
+ }
1633
+ async sendImage(conversationId, image) {
1634
+ const bot = this.bot;
1635
+ const rt = this.parseTarget(conversationId);
1636
+ if (!bot || !rt) return;
1637
+ try {
1638
+ await bot.sendImage(rt, {
1639
+ base64: image.data,
1640
+ type: "image/jpeg"
1641
+ });
1642
+ } catch (err) {
1643
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] sendImage failed: ${String(err)}`);
1644
+ }
1645
+ }
1646
+ /**
1647
+ * Detect local image/file paths in a reply, upload each and send it as a
1648
+ * standalone media message (sendImage / sendMedia). Returns the text with
1649
+ * the references replaced by short notes.
1650
+ */
1651
+ async sendMediaForText(rt, text) {
1652
+ if (!this.bot) return text;
1653
+ let result = text;
1654
+ for (const m of text.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)) {
1655
+ const [full, alt, p] = m;
1656
+ if (!isLocalPath$1(p)) continue;
1657
+ const filePath = toLocalPath$1(p);
1658
+ if (!isRegularFile(filePath)) continue;
1659
+ await this.sendLocalMedia(rt, filePath);
1660
+ result = result.replace(full, alt ? `[${alt}]` : "[图片]");
1661
+ }
1662
+ for (const m of result.matchAll(/(?:file:\/\/)?[A-Za-z]:[\\/][^\s"'()<>]+|(?:\/(?:Users|home|tmp|var|private|root)\/[^\s"'()<>]+)/g)) {
1663
+ const filePath = toLocalPath$1(m[0]);
1664
+ if (!isRegularFile(filePath)) continue;
1665
+ if (await this.sendLocalMedia(rt, filePath)) {
1666
+ const isImg = /\.(png|jpe?g|gif|bmp|webp)$/i.test(filePath);
1667
+ result = result.replace(m[0], isImg ? "[图片]" : `[文件已发送:${basename(filePath)}]`);
1668
+ }
1669
+ }
1670
+ return result;
1671
+ }
1672
+ /** Upload one local file and send it as an IMAGE / FILE media message. */
1673
+ async sendLocalMedia(rt, filePath) {
1674
+ const bot = this.bot;
1675
+ if (!bot) return false;
1676
+ try {
1677
+ if (/\.(png|jpe?g|gif|bmp|webp)$/i.test(filePath)) await bot.sendImage(rt, { localPath: filePath });
1678
+ else {
1679
+ const { MediaFileType: MFT } = await import("@tencent-connect/qqbot-nodejs");
1680
+ await bot.sendMedia({
1681
+ target: rt,
1682
+ fileType: MFT.FILE,
1683
+ localPath: filePath,
1684
+ fileName: basename(filePath)
1685
+ });
1686
+ }
1687
+ return true;
1688
+ } catch (err) {
1689
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] media send failed (${filePath}): ${String(err)}`);
1690
+ return false;
1691
+ }
1692
+ }
1693
+ async beginStream(conversationId) {
1694
+ const rt = this.parseTarget(conversationId);
1695
+ const bot = this.bot;
1696
+ if (!rt || !bot) return;
1697
+ if (rt.scope !== "c2c") {
1698
+ this.streamSessions.set(conversationId, {
1699
+ session: null,
1700
+ enabled: false
1701
+ });
1702
+ return;
1703
+ }
1704
+ const msgId = this.lastMsgIds.get(conversationId);
1705
+ if (!msgId) {
1706
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] beginStream: no inbound msgId for ${conversationId}`);
1707
+ this.streamSessions.set(conversationId, {
1708
+ session: null,
1709
+ enabled: false
1710
+ });
1711
+ return;
1712
+ }
1713
+ try {
1714
+ const { StreamSession: StreamSessionCtor } = await import("@tencent-connect/qqbot-nodejs");
1715
+ const session = new StreamSessionCtor(bot.messageApi, {
1716
+ openid: rt.targetId,
1717
+ msgId,
1718
+ creds: {
1719
+ appId: this.appId,
1720
+ clientSecret: this.appSecret
1721
+ }
1722
+ });
1723
+ this.streamSessions.set(conversationId, {
1724
+ session,
1725
+ enabled: true
1726
+ });
1727
+ } catch (err) {
1728
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] beginStream failed: ${String(err)}`);
1729
+ this.streamSessions.set(conversationId, {
1730
+ session: null,
1731
+ enabled: false
1732
+ });
1733
+ }
1734
+ }
1735
+ async streamText(conversationId, text, _finished = false) {
1736
+ const s = this.streamSessions.get(conversationId);
1737
+ if (!s?.enabled || !s.session) return;
1738
+ try {
1739
+ await s.session.update(text);
1740
+ } catch (err) {
1741
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] streamText failed — disabling stream: ${String(err)}`);
1742
+ s.enabled = false;
1743
+ }
1744
+ }
1745
+ async endStream(conversationId, text) {
1746
+ const s = this.streamSessions.get(conversationId);
1747
+ this.streamSessions.delete(conversationId);
1748
+ if (!s) {
1749
+ await this.sendText(conversationId, text);
1750
+ return;
1751
+ }
1752
+ if (s.session) {
1753
+ if (s.enabled) try {
1754
+ await s.session.update(text);
1755
+ await s.session.complete();
1756
+ return;
1757
+ } catch (err) {
1758
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] endStream failed, falling back to sendText: ${String(err)}`);
1759
+ try {
1760
+ s.session.cancel();
1761
+ } catch {}
1762
+ }
1763
+ else try {
1764
+ s.session.cancel();
1765
+ } catch (err) {
1766
+ this.ctx.logger.warn(`[im-channel-qq:${this.id}] endStream cancel failed: ${String(err)}`);
1767
+ }
1768
+ }
1769
+ await this.sendText(conversationId, text);
1770
+ }
1771
+ };
1772
+ //#endregion
1773
+ //#region src/channels/weixin-api.ts
1774
+ /**
1775
+ * Weixin iLink CGI API — all calls are plain `fetch`, zero HTTP deps.
1776
+ * Ported from pi-desk-top/src/main/im/weixin/weixin-api.ts (itself ported
1777
+ * from the official tencent-weixin connector):
1778
+ * - iLink-App-Id is the fixed upstream value "bot"
1779
+ * - bot_agent is a fixed self-identity string
1780
+ * - logging goes through console (no redaction framework needed)
1781
+ */
1782
+ /** iLink-App-Id — fixed upstream value used by the official connector. */
1783
+ const ILINK_APP_ID = "bot";
1784
+ /** iLink-App-ClientVersion: uint32 encoded as 0x00MMNNPP. */
1785
+ const ILINK_APP_CLIENT_VERSION = 131075;
1786
+ /** Self-identity reported in base_info.bot_agent. */
1787
+ const BOT_AGENT = "dsh-desktop";
1788
+ /** Default timeout for long-poll getUpdates requests. */
1789
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
1790
+ /** Default timeout for regular API requests. */
1791
+ const DEFAULT_API_TIMEOUT_MS = 15e3;
1792
+ /** Default timeout for lightweight API requests. */
1793
+ const DEFAULT_CONFIG_TIMEOUT_MS = 1e4;
1794
+ function ensureTrailingSlash(url) {
1795
+ return url.endsWith("/") ? url : `${url}/`;
1796
+ }
1797
+ /** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
1798
+ function randomWechatUin() {
1799
+ const bytes = crypto.randomBytes(4).readUInt32BE(0);
1800
+ return Buffer.from(String(bytes), "utf-8").toString("base64");
1801
+ }
1802
+ /** Build the `base_info` payload included in every API request. */
1803
+ function buildBaseInfo() {
1804
+ return {
1805
+ channel_version: "2.4.3",
1806
+ bot_agent: BOT_AGENT
1807
+ };
1808
+ }
1809
+ function buildHeaders(opts) {
1810
+ const headers = {
1811
+ "Content-Type": "application/json",
1812
+ AuthorizationType: "ilink_bot_token",
1813
+ "X-WECHAT-UIN": randomWechatUin(),
1814
+ "iLink-App-Id": ILINK_APP_ID,
1815
+ "iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION)
1816
+ };
1817
+ if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
1818
+ return headers;
1819
+ }
1820
+ function buildCommonHeaders() {
1821
+ return {
1822
+ "iLink-App-Id": ILINK_APP_ID,
1823
+ "iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION)
1824
+ };
1825
+ }
1826
+ /** GET fetch wrapper. Returns raw response text; throws on HTTP error/timeout. */
1827
+ async function apiGetFetch(params) {
1828
+ const base = ensureTrailingSlash(params.baseUrl);
1829
+ const url = new URL(params.endpoint, base);
1830
+ const controller = params.timeoutMs != null && params.timeoutMs > 0 ? new AbortController() : void 0;
1831
+ const t = controller != null ? setTimeout(() => controller.abort(), params.timeoutMs) : void 0;
1832
+ try {
1833
+ const res = await fetch(url.toString(), {
1834
+ method: "GET",
1835
+ headers: buildCommonHeaders(),
1836
+ ...controller ? { signal: controller.signal } : {}
1837
+ });
1838
+ if (t !== void 0) clearTimeout(t);
1839
+ const rawText = await res.text();
1840
+ if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText.slice(0, 300)}`);
1841
+ return rawText;
1842
+ } catch (err) {
1843
+ if (t !== void 0) clearTimeout(t);
1844
+ throw err;
1845
+ }
1846
+ }
1847
+ /** POST JSON fetch wrapper. Returns raw response text; throws on error/timeout. */
1848
+ async function apiPostFetch(params) {
1849
+ const base = ensureTrailingSlash(params.baseUrl);
1850
+ const url = new URL(params.endpoint, base);
1851
+ const controller = params.timeoutMs !== void 0 ? new AbortController() : void 0;
1852
+ const t = controller != null ? setTimeout(() => controller.abort(), params.timeoutMs) : void 0;
1853
+ try {
1854
+ const res = await fetch(url.toString(), {
1855
+ method: "POST",
1856
+ headers: buildHeaders({ token: params.token }),
1857
+ body: params.body,
1858
+ ...controller ? { signal: controller.signal } : {}
1859
+ });
1860
+ if (t !== void 0) clearTimeout(t);
1861
+ const rawText = await res.text();
1862
+ if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText.slice(0, 300)}`);
1863
+ return rawText;
1864
+ } catch (err) {
1865
+ if (t !== void 0) clearTimeout(t);
1866
+ throw err;
1867
+ }
1868
+ }
1869
+ /**
1870
+ * Long-poll getUpdates. Server holds the request until new messages or
1871
+ * timeout. Client-side timeout returns an empty response so the caller can
1872
+ * simply retry — this is normal for long-poll.
1873
+ */
1874
+ async function getUpdates(params) {
1875
+ const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
1876
+ try {
1877
+ const rawText = await apiPostFetch({
1878
+ baseUrl: params.baseUrl,
1879
+ endpoint: "ilink/bot/getupdates",
1880
+ body: JSON.stringify({
1881
+ get_updates_buf: params.get_updates_buf ?? "",
1882
+ base_info: buildBaseInfo()
1883
+ }),
1884
+ token: params.token,
1885
+ timeoutMs: timeout,
1886
+ label: "getUpdates"
1887
+ });
1888
+ return JSON.parse(rawText);
1889
+ } catch (err) {
1890
+ if (err instanceof Error && err.name === "AbortError") return {
1891
+ ret: 0,
1892
+ msgs: [],
1893
+ get_updates_buf: params.get_updates_buf
1894
+ };
1895
+ throw err;
1896
+ }
1897
+ }
1898
+ /** Send a single message downstream. */
1899
+ async function sendMessage(params) {
1900
+ await apiPostFetch({
1901
+ baseUrl: params.baseUrl,
1902
+ endpoint: "ilink/bot/sendmessage",
1903
+ body: JSON.stringify({
1904
+ ...params.body,
1905
+ base_info: buildBaseInfo()
1906
+ }),
1907
+ token: params.token,
1908
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
1909
+ label: "sendMessage"
1910
+ });
1911
+ }
1912
+ /** Fetch bot config (includes typing_ticket) for a given user. */
1913
+ async function getConfig(params) {
1914
+ const rawText = await apiPostFetch({
1915
+ baseUrl: params.baseUrl,
1916
+ endpoint: "ilink/bot/getconfig",
1917
+ body: JSON.stringify({
1918
+ ilink_user_id: params.ilinkUserId,
1919
+ context_token: params.contextToken,
1920
+ base_info: buildBaseInfo()
1921
+ }),
1922
+ token: params.token,
1923
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
1924
+ label: "getConfig"
1925
+ });
1926
+ return JSON.parse(rawText);
1927
+ }
1928
+ /** Send a typing indicator to a user. */
1929
+ async function sendTyping(params) {
1930
+ await apiPostFetch({
1931
+ baseUrl: params.baseUrl,
1932
+ endpoint: "ilink/bot/sendtyping",
1933
+ body: JSON.stringify({
1934
+ ...params.body,
1935
+ base_info: buildBaseInfo()
1936
+ }),
1937
+ token: params.token,
1938
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
1939
+ label: "sendTyping"
1940
+ });
1941
+ }
1942
+ /** Notify Weixin that the channel client is stopping. */
1943
+ async function notifyStop(params) {
1944
+ const rawText = await apiPostFetch({
1945
+ baseUrl: params.baseUrl,
1946
+ endpoint: "ilink/bot/msg/notifystop",
1947
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
1948
+ token: params.token,
1949
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
1950
+ label: "notifyStop"
1951
+ });
1952
+ return JSON.parse(rawText);
1953
+ }
1954
+ /** Notify Weixin that the channel client is starting. */
1955
+ async function notifyStart(params) {
1956
+ const rawText = await apiPostFetch({
1957
+ baseUrl: params.baseUrl,
1958
+ endpoint: "ilink/bot/msg/notifystart",
1959
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
1960
+ token: params.token,
1961
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
1962
+ label: "notifyStart"
1963
+ });
1964
+ return JSON.parse(rawText);
1965
+ }
1966
+ //#endregion
1967
+ //#region src/channels/weixin-types.ts
1968
+ const MessageItemType = {
1969
+ NONE: 0,
1970
+ TEXT: 1,
1971
+ IMAGE: 2,
1972
+ VOICE: 3,
1973
+ FILE: 4,
1974
+ VIDEO: 5
1975
+ };
1976
+ const MessageType = {
1977
+ NONE: 0,
1978
+ USER: 1,
1979
+ BOT: 2
1980
+ };
1981
+ const MessageState = {
1982
+ NEW: 0,
1983
+ GENERATING: 1,
1984
+ FINISH: 2
1985
+ };
1986
+ const TypingStatus = {
1987
+ TYPING: 1,
1988
+ CANCEL: 2
1989
+ };
1990
+ //#endregion
1991
+ //#region src/channels/markdown-filter.ts
1992
+ /**
1993
+ * Streaming markdown filter — character-level state machine that strips
1994
+ * unsupported markdown syntax on-the-fly. Ported VERBATIM from the official
1995
+ * tencent-weixin connector (src/messaging/markdown-filter.ts) — WeChat does
1996
+ * not render markdown, so agent replies must pass through this before send.
1997
+ *
1998
+ * Outputs as much filtered text as possible on each `feed()` call, only
1999
+ * holding back the minimum characters needed for pattern disambiguation.
2000
+ *
2001
+ * Constructs passed through (not filtered):
2002
+ * - Code fences (```), inline code (`), tables (|...|), horizontal rules
2003
+ * - Bold (**), italic/bold-italic wrapping non-CJK content
2004
+ *
2005
+ * Constructs filtered (markers stripped, content kept):
2006
+ * - Italic/bold-italic wrapping CJK content, headings H5/H6, images (removed)
2007
+ */
2008
+ var StreamingMarkdownFilter = class StreamingMarkdownFilter {
2009
+ buf = "";
2010
+ fence = false;
2011
+ sol = true;
2012
+ inl = null;
2013
+ feed(delta) {
2014
+ this.buf += delta;
2015
+ return this.pump(false);
2016
+ }
2017
+ flush() {
2018
+ return this.pump(true);
2019
+ }
2020
+ pump(eof) {
2021
+ let out = "";
2022
+ while (this.buf) {
2023
+ const sLen = this.buf.length;
2024
+ const sSol = this.sol;
2025
+ const sFence = this.fence;
2026
+ const sInl = this.inl;
2027
+ if (this.fence) out += this.pumpFence(eof);
2028
+ else if (this.inl) out += this.pumpInline(eof);
2029
+ else if (this.sol) out += this.pumpSOL(eof);
2030
+ else out += this.pumpBody(eof);
2031
+ if (this.buf.length === sLen && this.sol === sSol && this.fence === sFence && this.inl === sInl) break;
2032
+ }
2033
+ if (eof && this.inl) {
2034
+ out += ({
2035
+ image: "![",
2036
+ bold3: "***",
2037
+ italic: "*",
2038
+ ubold3: "___",
2039
+ uitalic: "_"
2040
+ }[this.inl.type] ?? "") + this.inl.acc;
2041
+ this.inl = null;
2042
+ }
2043
+ return out;
2044
+ }
2045
+ /** Inside a code fence: pass content and markers through verbatim. */
2046
+ pumpFence(eof) {
2047
+ if (this.sol) {
2048
+ if (this.buf.length < 3 && !eof) return "";
2049
+ if (this.buf.startsWith("```")) {
2050
+ const nl = this.buf.indexOf("\n", 3);
2051
+ if (nl !== -1) {
2052
+ this.fence = false;
2053
+ const line = this.buf.slice(0, nl + 1);
2054
+ this.buf = this.buf.slice(nl + 1);
2055
+ this.sol = true;
2056
+ return line;
2057
+ }
2058
+ if (eof) {
2059
+ this.fence = false;
2060
+ const line = this.buf;
2061
+ this.buf = "";
2062
+ return line;
2063
+ }
2064
+ return "";
2065
+ }
2066
+ this.sol = false;
2067
+ }
2068
+ const nl = this.buf.indexOf("\n");
2069
+ if (nl !== -1) {
2070
+ const chunk = this.buf.slice(0, nl + 1);
2071
+ this.buf = this.buf.slice(nl + 1);
2072
+ this.sol = true;
2073
+ return chunk;
2074
+ }
2075
+ const chunk = this.buf;
2076
+ this.buf = "";
2077
+ return chunk;
2078
+ }
2079
+ /** At start of line: detect and consume line-start patterns. */
2080
+ pumpSOL(eof) {
2081
+ const b = this.buf;
2082
+ if (b[0] === "\n") {
2083
+ this.buf = b.slice(1);
2084
+ return "\n";
2085
+ }
2086
+ if (b[0] === "`") {
2087
+ if (b.length < 3 && !eof) return "";
2088
+ if (b.startsWith("```")) {
2089
+ const nl = b.indexOf("\n", 3);
2090
+ if (nl !== -1) {
2091
+ this.fence = true;
2092
+ const line = b.slice(0, nl + 1);
2093
+ this.buf = b.slice(nl + 1);
2094
+ this.sol = true;
2095
+ return line;
2096
+ }
2097
+ if (eof) {
2098
+ this.buf = "";
2099
+ return b;
2100
+ }
2101
+ return "";
2102
+ }
2103
+ this.sol = false;
2104
+ return "";
2105
+ }
2106
+ if (b[0] === ">") {
2107
+ this.sol = false;
2108
+ return "";
2109
+ }
2110
+ if (b[0] === "#") {
2111
+ let n = 0;
2112
+ while (n < b.length && b[n] === "#") n++;
2113
+ if (n === b.length && !eof) return "";
2114
+ if (n >= 5 && n <= 6 && n < b.length && b[n] === " ") {
2115
+ this.buf = b.slice(n + 1);
2116
+ this.sol = false;
2117
+ return "";
2118
+ }
2119
+ this.sol = false;
2120
+ return "";
2121
+ }
2122
+ if (b[0] === " " || b[0] === " ") {
2123
+ if (b.search(/[^ \t]/) === -1 && !eof) return "";
2124
+ this.sol = false;
2125
+ return "";
2126
+ }
2127
+ if (b[0] === "-" || b[0] === "*" || b[0] === "_") {
2128
+ const ch = b[0];
2129
+ let j = 0;
2130
+ while (j < b.length && (b[j] === ch || b[j] === " ")) j++;
2131
+ if (j === b.length && !eof) return "";
2132
+ if (j === b.length || b[j] === "\n") {
2133
+ let count = 0;
2134
+ for (let k = 0; k < j; k++) if (b[k] === ch) count++;
2135
+ if (count >= 3) {
2136
+ if (j < b.length) {
2137
+ this.buf = b.slice(j + 1);
2138
+ this.sol = true;
2139
+ return b.slice(0, j + 1);
2140
+ }
2141
+ this.buf = "";
2142
+ return b;
2143
+ }
2144
+ }
2145
+ this.sol = false;
2146
+ return "";
2147
+ }
2148
+ this.sol = false;
2149
+ return "";
2150
+ }
2151
+ /** Scan line body for inline pattern triggers; output safe chars eagerly. */
2152
+ pumpBody(eof) {
2153
+ let out = "";
2154
+ let i = 0;
2155
+ while (i < this.buf.length) {
2156
+ const c = this.buf[i];
2157
+ if (c === "\n") {
2158
+ out += this.buf.slice(0, i + 1);
2159
+ this.buf = this.buf.slice(i + 1);
2160
+ this.sol = true;
2161
+ return out;
2162
+ }
2163
+ if (c === "!" && i + 1 < this.buf.length && this.buf[i + 1] === "[") {
2164
+ out += this.buf.slice(0, i);
2165
+ this.buf = this.buf.slice(i + 2);
2166
+ this.inl = {
2167
+ type: "image",
2168
+ acc: ""
2169
+ };
2170
+ return out;
2171
+ }
2172
+ if (c === "~") {
2173
+ i++;
2174
+ continue;
2175
+ }
2176
+ if (c === "*") {
2177
+ if (i + 2 < this.buf.length && this.buf[i + 1] === "*" && this.buf[i + 2] === "*") {
2178
+ out += this.buf.slice(0, i);
2179
+ this.buf = this.buf.slice(i + 3);
2180
+ this.inl = {
2181
+ type: "bold3",
2182
+ acc: ""
2183
+ };
2184
+ return out;
2185
+ }
2186
+ if (i + 1 < this.buf.length && this.buf[i + 1] === "*") {
2187
+ i += 2;
2188
+ continue;
2189
+ }
2190
+ if (i + 1 < this.buf.length && this.buf[i + 1] !== " " && this.buf[i + 1] !== "\n") {
2191
+ out += this.buf.slice(0, i);
2192
+ this.buf = this.buf.slice(i + 1);
2193
+ this.inl = {
2194
+ type: "italic",
2195
+ acc: ""
2196
+ };
2197
+ return out;
2198
+ }
2199
+ i++;
2200
+ continue;
2201
+ }
2202
+ if (c === "_") {
2203
+ if (i + 2 < this.buf.length && this.buf[i + 1] === "_" && this.buf[i + 2] === "_") {
2204
+ out += this.buf.slice(0, i);
2205
+ this.buf = this.buf.slice(i + 3);
2206
+ this.inl = {
2207
+ type: "ubold3",
2208
+ acc: ""
2209
+ };
2210
+ return out;
2211
+ }
2212
+ if (i + 1 < this.buf.length && this.buf[i + 1] === "_") {
2213
+ i += 2;
2214
+ continue;
2215
+ }
2216
+ if (i + 1 < this.buf.length && this.buf[i + 1] !== " " && this.buf[i + 1] !== "\n") {
2217
+ out += this.buf.slice(0, i);
2218
+ this.buf = this.buf.slice(i + 1);
2219
+ this.inl = {
2220
+ type: "uitalic",
2221
+ acc: ""
2222
+ };
2223
+ return out;
2224
+ }
2225
+ i++;
2226
+ continue;
2227
+ }
2228
+ i++;
2229
+ }
2230
+ let hold = 0;
2231
+ if (!eof) {
2232
+ if (this.buf.endsWith("**")) hold = 2;
2233
+ else if (this.buf.endsWith("__")) hold = 2;
2234
+ else if (this.buf.endsWith("*")) hold = 1;
2235
+ else if (this.buf.endsWith("_")) hold = 1;
2236
+ else if (this.buf.endsWith("!")) hold = 1;
2237
+ }
2238
+ out += this.buf.slice(0, this.buf.length - hold);
2239
+ this.buf = hold > 0 ? this.buf.slice(-hold) : "";
2240
+ return out;
2241
+ }
2242
+ /** Accumulate inline content until closing marker is found. */
2243
+ pumpInline(_eof) {
2244
+ if (!this.inl) return "";
2245
+ this.inl.acc += this.buf;
2246
+ this.buf = "";
2247
+ switch (this.inl.type) {
2248
+ case "bold3": {
2249
+ const idx = this.inl.acc.indexOf("***");
2250
+ if (idx !== -1) {
2251
+ const content = this.inl.acc.slice(0, idx);
2252
+ this.buf = this.inl.acc.slice(idx + 3);
2253
+ this.inl = null;
2254
+ if (StreamingMarkdownFilter.containsCJK(content)) return content;
2255
+ return `***${content}***`;
2256
+ }
2257
+ return "";
2258
+ }
2259
+ case "ubold3": {
2260
+ const idx = this.inl.acc.indexOf("___");
2261
+ if (idx !== -1) {
2262
+ const content = this.inl.acc.slice(0, idx);
2263
+ this.buf = this.inl.acc.slice(idx + 3);
2264
+ this.inl = null;
2265
+ if (StreamingMarkdownFilter.containsCJK(content)) return content;
2266
+ return `___${content}___`;
2267
+ }
2268
+ return "";
2269
+ }
2270
+ case "italic":
2271
+ for (let j = 0; j < this.inl.acc.length; j++) {
2272
+ if (this.inl.acc[j] === "\n") {
2273
+ const r = "*" + this.inl.acc.slice(0, j + 1);
2274
+ this.buf = this.inl.acc.slice(j + 1);
2275
+ this.inl = null;
2276
+ this.sol = true;
2277
+ return r;
2278
+ }
2279
+ if (this.inl.acc[j] === "*") {
2280
+ if (j + 1 < this.inl.acc.length && this.inl.acc[j + 1] === "*") {
2281
+ j++;
2282
+ continue;
2283
+ }
2284
+ const content = this.inl.acc.slice(0, j);
2285
+ this.buf = this.inl.acc.slice(j + 1);
2286
+ this.inl = null;
2287
+ if (StreamingMarkdownFilter.containsCJK(content)) return content;
2288
+ return `*${content}*`;
2289
+ }
2290
+ }
2291
+ return "";
2292
+ case "uitalic":
2293
+ for (let j = 0; j < this.inl.acc.length; j++) {
2294
+ if (this.inl.acc[j] === "\n") {
2295
+ const r = "_" + this.inl.acc.slice(0, j + 1);
2296
+ this.buf = this.inl.acc.slice(j + 1);
2297
+ this.inl = null;
2298
+ this.sol = true;
2299
+ return r;
2300
+ }
2301
+ if (this.inl.acc[j] === "_") {
2302
+ if (j + 1 < this.inl.acc.length && this.inl.acc[j + 1] === "_") {
2303
+ j++;
2304
+ continue;
2305
+ }
2306
+ const content = this.inl.acc.slice(0, j);
2307
+ this.buf = this.inl.acc.slice(j + 1);
2308
+ this.inl = null;
2309
+ if (StreamingMarkdownFilter.containsCJK(content)) return content;
2310
+ return `_${content}_`;
2311
+ }
2312
+ }
2313
+ return "";
2314
+ case "image": {
2315
+ const cb = this.inl.acc.indexOf("]");
2316
+ if (cb === -1) return "";
2317
+ if (cb + 1 >= this.inl.acc.length) return "";
2318
+ if (this.inl.acc[cb + 1] !== "(") {
2319
+ const r = "![" + this.inl.acc.slice(0, cb + 1);
2320
+ this.buf = this.inl.acc.slice(cb + 1);
2321
+ this.inl = null;
2322
+ return r;
2323
+ }
2324
+ const cp = this.inl.acc.indexOf(")", cb + 2);
2325
+ if (cp !== -1) {
2326
+ this.buf = this.inl.acc.slice(cp + 1);
2327
+ this.inl = null;
2328
+ return "";
2329
+ }
2330
+ return "";
2331
+ }
2332
+ }
2333
+ return "";
2334
+ }
2335
+ static containsCJK(text) {
2336
+ return /[\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/.test(text);
2337
+ }
2338
+ };
2339
+ function encryptAesEcb(plaintext, key) {
2340
+ const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
2341
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
2342
+ }
2343
+ function decryptAesEcb(ciphertext, key) {
2344
+ const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
2345
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
2346
+ }
2347
+ /** AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
2348
+ function aesEcbPaddedSize(plaintextSize) {
2349
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
2350
+ }
2351
+ function buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl) {
2352
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
2353
+ }
2354
+ function buildCdnUploadUrl(params) {
2355
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
2356
+ }
2357
+ /**
2358
+ * Parse a base64 aes_key into the raw 16-byte AES key. Two encodings exist:
2359
+ * base64(raw 16 bytes) for images; base64(hex string of 16 bytes) for
2360
+ * file/voice/video.
2361
+ */
2362
+ function parseAesKey(aesKeyBase64, label) {
2363
+ const decoded = Buffer.from(aesKeyBase64, "base64");
2364
+ if (decoded.length === 16) return decoded;
2365
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) return Buffer.from(decoded.toString("ascii"), "hex");
2366
+ throw new Error(`${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes`);
2367
+ }
2368
+ async function fetchCdnBytes(url, label) {
2369
+ const res = await fetch(url);
2370
+ if (!res.ok) throw new Error(`${label}: CDN download ${res.status} ${res.statusText}`);
2371
+ return Buffer.from(await res.arrayBuffer());
2372
+ }
2373
+ /** Download + AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer. */
2374
+ async function downloadAndDecryptBuffer(params) {
2375
+ const { encryptedQueryParam, aesKeyBase64, cdnBaseUrl, label, fullUrl } = params;
2376
+ const key = parseAesKey(aesKeyBase64, label);
2377
+ return decryptAesEcb(await fetchCdnBytes(fullUrl ?? buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl), label), key);
2378
+ }
2379
+ const UPLOAD_MAX_RETRIES = 3;
2380
+ /** Upload a ciphertext buffer to the CDN; returns the download param. */
2381
+ async function uploadBufferToCdn(params) {
2382
+ const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
2383
+ const ciphertext = encryptAesEcb(buf, aeskey);
2384
+ const cdnUrl = uploadFullUrl?.trim() || (uploadParam ? buildCdnUploadUrl({
2385
+ cdnBaseUrl,
2386
+ uploadParam,
2387
+ filekey
2388
+ }) : void 0);
2389
+ if (!cdnUrl) throw new Error(`${label}: CDN upload URL missing`);
2390
+ let downloadParam;
2391
+ let lastError;
2392
+ for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) try {
2393
+ const res = await fetch(cdnUrl, {
2394
+ method: "POST",
2395
+ headers: { "Content-Type": "application/octet-stream" },
2396
+ body: new Uint8Array(ciphertext)
2397
+ });
2398
+ if (res.status >= 400 && res.status < 500) {
2399
+ const errMsg = res.headers.get("x-error-message") ?? await res.text();
2400
+ throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
2401
+ }
2402
+ if (res.status !== 200) throw new Error(`CDN upload server error: ${res.headers.get("x-error-message") ?? `status ${res.status}`}`);
2403
+ downloadParam = res.headers.get("x-encrypted-param") ?? void 0;
2404
+ if (!downloadParam) throw new Error("CDN upload response missing x-encrypted-param header");
2405
+ break;
2406
+ } catch (err) {
2407
+ lastError = err;
2408
+ if (err instanceof Error && err.message.includes("client error")) throw err;
2409
+ if (attempt < UPLOAD_MAX_RETRIES) continue;
2410
+ }
2411
+ if (!downloadParam) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
2412
+ return { downloadParam };
2413
+ }
2414
+ /**
2415
+ * Upload a local file to the Weixin CDN with AES-128-ECB encryption.
2416
+ * Shared by image / video / file attachment paths.
2417
+ */
2418
+ async function uploadLocalFileToWeixin(params) {
2419
+ const { filePath, toUserId, mediaType, baseUrl, token, cdnBaseUrl } = params;
2420
+ const plaintext = await readFile(filePath);
2421
+ const rawsize = plaintext.length;
2422
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
2423
+ const filesize = aesEcbPaddedSize(rawsize);
2424
+ const filekey = crypto.randomBytes(16).toString("hex");
2425
+ const aeskey = crypto.randomBytes(16);
2426
+ const rawText = await postJson(baseUrl, "ilink/bot/getuploadurl", token, {
2427
+ filekey,
2428
+ media_type: mediaType,
2429
+ to_user_id: toUserId,
2430
+ rawsize,
2431
+ rawfilemd5,
2432
+ filesize,
2433
+ no_need_thumb: true,
2434
+ aeskey: aeskey.toString("hex"),
2435
+ base_info: {
2436
+ channel_version: "2.4.3",
2437
+ bot_agent: "dsh-desktop"
2438
+ }
2439
+ });
2440
+ const resp = JSON.parse(rawText);
2441
+ if (!resp.upload_full_url?.trim() && !resp.upload_param) throw new Error("getuploadurl returned no upload URL");
2442
+ const { downloadParam } = await uploadBufferToCdn({
2443
+ buf: plaintext,
2444
+ uploadFullUrl: resp.upload_full_url,
2445
+ uploadParam: resp.upload_param,
2446
+ filekey,
2447
+ cdnBaseUrl: cdnBaseUrl ?? "https://novac2c.cdn.weixin.qq.com/c2c",
2448
+ aeskey,
2449
+ label: "uploadLocalFileToWeixin"
2450
+ });
2451
+ return {
2452
+ filekey,
2453
+ downloadEncryptedQueryParam: downloadParam,
2454
+ aeskey: aeskey.toString("hex"),
2455
+ fileSize: rawsize,
2456
+ fileSizeCiphertext: filesize
2457
+ };
2458
+ }
2459
+ /** Shared POST helper with the iLink headers (kept local to media module). */
2460
+ async function postJson(baseUrl, endpoint, token, body) {
2461
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/${endpoint}`, {
2462
+ method: "POST",
2463
+ headers: {
2464
+ "Content-Type": "application/json",
2465
+ AuthorizationType: "ilink_bot_token",
2466
+ "X-WECHAT-UIN": Buffer.from(String(Math.floor(Math.random() * 4e9)), "utf-8").toString("base64"),
2467
+ "iLink-App-Id": "bot",
2468
+ "iLink-App-ClientVersion": "132099",
2469
+ Authorization: `Bearer ${token}`
2470
+ },
2471
+ body: JSON.stringify(body)
2472
+ });
2473
+ const raw = await res.text();
2474
+ if (!res.ok) throw new Error(`${endpoint} ${res.status}: ${raw.slice(0, 300)}`);
2475
+ return raw;
2476
+ }
2477
+ //#endregion
2478
+ //#region src/channels/weixin.ts
2479
+ const LONG_POLL_TIMEOUT_MS = 35e3;
2480
+ const BASE_BACKOFF_DELAY = 2e3;
2481
+ const MAX_BACKOFF_DELAY = 3e4;
2482
+ const TYPING_INTERVAL_MS = 5e3;
2483
+ /** getupdates returns this errcode when the bot token / session expired. */
2484
+ const SESSION_EXPIRED_ERRCODE = -14;
2485
+ /** How long to pause polling after a session-expired error. */
2486
+ const SESSION_PAUSE_MS = 3e5;
2487
+ function isMediaItem(item) {
2488
+ return item.type === MessageItemType.IMAGE || item.type === MessageItemType.VIDEO || item.type === MessageItemType.FILE || item.type === MessageItemType.VOICE;
2489
+ }
2490
+ /** Guess the MIME type from a decrypted image's magic bytes. */
2491
+ function sniffImageMime(buf) {
2492
+ if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from("89504e470d0a1a0a", "hex"))) return "image/png";
2493
+ if (buf.length >= 3 && buf.subarray(0, 3).equals(Buffer.from("ffd8ff", "hex"))) return "image/jpeg";
2494
+ if (buf.length >= 6 && buf.subarray(0, 6).toString("ascii") === "GIF87a") return "image/gif";
2495
+ if (buf.length >= 6 && buf.subarray(0, 6).toString("ascii") === "GIF89a") return "image/gif";
2496
+ if (buf.length >= 4 && buf.subarray(0, 4).toString("ascii") === "RIFF") return "image/webp";
2497
+ return "image/jpeg";
2498
+ }
2499
+ /** True when a path points at a local file (drive letter, /, ~, file://). */
2500
+ function isLocalPath(raw) {
2501
+ return raw.startsWith("file://") || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith("/") || raw.startsWith("~");
2502
+ }
2503
+ /** Strip file:// / URL-encoding to get the on-disk absolute path. */
2504
+ function toLocalPath(raw) {
2505
+ let p = raw.startsWith("file://") ? raw.slice(7) : raw;
2506
+ try {
2507
+ p = decodeURIComponent(p);
2508
+ } catch {}
2509
+ return p;
2510
+ }
2511
+ /** Extract the plain-text body from an item list (quotes + voice-to-text). */
2512
+ function bodyFromItemList(itemList) {
2513
+ if (!itemList?.length) return "";
2514
+ for (const item of itemList) {
2515
+ if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
2516
+ const text = String(item.text_item.text);
2517
+ const ref = item.ref_msg;
2518
+ if (!ref) return text;
2519
+ if (ref.message_item && isMediaItem(ref.message_item)) return text;
2520
+ const parts = [];
2521
+ if (ref.title) parts.push(ref.title);
2522
+ if (ref.message_item) {
2523
+ const refBody = bodyFromItemList([ref.message_item]);
2524
+ if (refBody) parts.push(refBody);
2525
+ }
2526
+ if (!parts.length) return text;
2527
+ return `[引用: ${parts.join(" | ")}]\n${text}`;
2528
+ }
2529
+ if (item.type === MessageItemType.VOICE && item.voice_item?.text) return item.voice_item.text;
2530
+ }
2531
+ return "";
2532
+ }
2533
+ /** client_id for outbound messages — random per message. */
2534
+ function generateClientId() {
2535
+ return `pi-weixin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
2536
+ }
2537
+ /** Per-bot state directory: ~/.dsh/im-gateway (survives restarts). */
2538
+ function stateDir() {
2539
+ return join(os.homedir(), ".dsh", "im-gateway");
2540
+ }
2541
+ var WeixinAdapter = class {
2542
+ ctx;
2543
+ id;
2544
+ label;
2545
+ token;
2546
+ botId;
2547
+ baseUrl;
2548
+ cdnBaseUrl;
2549
+ stopped = true;
2550
+ active = false;
2551
+ pollTimer = null;
2552
+ reconnectAttempts = 0;
2553
+ typingTimer = null;
2554
+ typingTarget = null;
2555
+ /** get_updates_buf cursor — persisted for restart continuity. */
2556
+ updatesBuf = "";
2557
+ /** Per-conversation context tokens (persisted). */
2558
+ contextTokens = /* @__PURE__ */ new Map();
2559
+ /** Per-user typing_ticket from getconfig (needed for sendTyping). */
2560
+ typingTickets = /* @__PURE__ */ new Map();
2561
+ /** Runtime-status sink, assigned by the gateway on registration. */
2562
+ statusListener;
2563
+ constructor(ctx, config, id, label) {
2564
+ this.ctx = ctx;
2565
+ this.id = id;
2566
+ this.label = label;
2567
+ this.token = config.token ?? "";
2568
+ this.botId = config.botId ?? "";
2569
+ this.baseUrl = config.baseUrl?.trim() || "https://ilinkai.weixin.qq.com";
2570
+ this.cdnBaseUrl = config.cdnBaseUrl?.trim() || "https://novac2c.cdn.weixin.qq.com/c2c";
2571
+ }
2572
+ isActive() {
2573
+ return this.active;
2574
+ }
2575
+ setStatus(s, error) {
2576
+ this.statusListener?.({
2577
+ status: s,
2578
+ error,
2579
+ lastChange: Date.now()
2580
+ });
2581
+ }
2582
+ stateFilePath(name) {
2583
+ return join(stateDir(), `weixin-${name}-${this.botId || "unbound"}.json`);
2584
+ }
2585
+ restoreState() {
2586
+ try {
2587
+ const bufPath = this.stateFilePath("syncbuf");
2588
+ if (existsSync(bufPath)) {
2589
+ const parsed = JSON.parse(readFileSync(bufPath, "utf-8"));
2590
+ if (typeof parsed.buf === "string") this.updatesBuf = parsed.buf;
2591
+ }
2592
+ } catch {}
2593
+ try {
2594
+ const tokPath = this.stateFilePath("context-tokens");
2595
+ if (existsSync(tokPath)) {
2596
+ const parsed = JSON.parse(readFileSync(tokPath, "utf-8"));
2597
+ for (const [k, v] of Object.entries(parsed)) if (typeof v === "string" && v) this.contextTokens.set(k, v);
2598
+ }
2599
+ } catch {}
2600
+ }
2601
+ persistBuf() {
2602
+ try {
2603
+ mkdirSync(stateDir(), { recursive: true });
2604
+ writeFileSync(this.stateFilePath("syncbuf"), JSON.stringify({ buf: this.updatesBuf }), "utf-8");
2605
+ } catch (err) {
2606
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] persist syncbuf failed: ${String(err)}`);
2607
+ }
2608
+ }
2609
+ persistContextTokens() {
2610
+ try {
2611
+ mkdirSync(stateDir(), { recursive: true });
2612
+ writeFileSync(this.stateFilePath("context-tokens"), JSON.stringify(Object.fromEntries(this.contextTokens)), "utf-8");
2613
+ } catch (err) {
2614
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] persist context tokens failed: ${String(err)}`);
2615
+ }
2616
+ }
2617
+ contextTokenKey(userId) {
2618
+ return `${this.botId}:${userId}`;
2619
+ }
2620
+ async start() {
2621
+ if (!this.token) {
2622
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] token missing — scan the QR code first`);
2623
+ this.setStatus("error", "token missing — scan the QR code first");
2624
+ return;
2625
+ }
2626
+ this.stopped = false;
2627
+ this.restoreState();
2628
+ try {
2629
+ await notifyStart({
2630
+ baseUrl: this.baseUrl,
2631
+ token: this.token,
2632
+ timeoutMs: 1e4
2633
+ }).catch(() => void 0);
2634
+ } catch {}
2635
+ this.active = true;
2636
+ this.setStatus("online");
2637
+ this.schedulePoll(0);
2638
+ }
2639
+ async stop() {
2640
+ this.stopped = true;
2641
+ if (this.pollTimer) clearTimeout(this.pollTimer);
2642
+ this.pollTimer = null;
2643
+ this.stopTyping();
2644
+ try {
2645
+ await notifyStop({
2646
+ baseUrl: this.baseUrl,
2647
+ token: this.token,
2648
+ timeoutMs: 1e4
2649
+ }).catch(() => void 0);
2650
+ } catch {}
2651
+ this.active = false;
2652
+ this.setStatus("offline");
2653
+ }
2654
+ schedulePoll(delayMs) {
2655
+ if (this.stopped) return;
2656
+ this.pollTimer = setTimeout(() => void this.pollOnce(), delayMs);
2657
+ }
2658
+ async pollOnce() {
2659
+ if (this.stopped) return;
2660
+ try {
2661
+ const resp = await getUpdates({
2662
+ baseUrl: this.baseUrl,
2663
+ token: this.token,
2664
+ get_updates_buf: this.updatesBuf,
2665
+ timeoutMs: LONG_POLL_TIMEOUT_MS
2666
+ });
2667
+ this.reconnectAttempts = 0;
2668
+ if (!this.active) {
2669
+ this.active = true;
2670
+ this.setStatus("online");
2671
+ }
2672
+ if (resp.ret !== void 0 && resp.ret !== 0 || resp.errcode !== void 0 && resp.errcode !== 0) {
2673
+ const expired = resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
2674
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] getUpdates ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg}` + (expired ? " — session expired, pausing" : ""));
2675
+ if (expired) this.setStatus("error", "session expired — re-scan the QR code");
2676
+ this.schedulePoll(expired ? SESSION_PAUSE_MS : 2e3);
2677
+ return;
2678
+ }
2679
+ if (resp.get_updates_buf && resp.get_updates_buf !== this.updatesBuf) {
2680
+ this.updatesBuf = resp.get_updates_buf;
2681
+ this.persistBuf();
2682
+ }
2683
+ for (const msg of resp.msgs ?? []) try {
2684
+ await this.handleMessage(msg);
2685
+ } catch (err) {
2686
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] handleMessage failed: ${String(err)}`);
2687
+ }
2688
+ this.schedulePoll(resp.msgs?.length ? 100 : 300);
2689
+ } catch (err) {
2690
+ const attempt = this.reconnectAttempts;
2691
+ this.reconnectAttempts += 1;
2692
+ const backoff = Math.min(MAX_BACKOFF_DELAY, BASE_BACKOFF_DELAY * 2 ** Math.min(attempt, 5));
2693
+ this.active = false;
2694
+ this.setStatus("error", `poll error: ${String(err)}`);
2695
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] poll error (${attempt}), retry in ${backoff}ms: ${String(err)}`);
2696
+ this.schedulePoll(backoff);
2697
+ }
2698
+ }
2699
+ async handleMessage(msg) {
2700
+ const fromUserId = msg.from_user_id ?? "";
2701
+ if (!fromUserId) return;
2702
+ if (msg.message_type === MessageType.BOT) return;
2703
+ const text = bodyFromItemList(msg.item_list);
2704
+ if (msg.context_token) {
2705
+ this.contextTokens.set(this.contextTokenKey(fromUserId), msg.context_token);
2706
+ this.persistContextTokens();
2707
+ }
2708
+ const images = [];
2709
+ for (const item of msg.item_list ?? []) {
2710
+ if (item.type !== MessageItemType.IMAGE || !item.image_item) continue;
2711
+ const img = item.image_item;
2712
+ if (!img.media) continue;
2713
+ try {
2714
+ const aesKeyBase64 = img.aeskey ? Buffer.from(img.aeskey, "hex").toString("base64") : img.media.aes_key;
2715
+ if (!aesKeyBase64) continue;
2716
+ const buf = await downloadAndDecryptBuffer({
2717
+ encryptedQueryParam: img.media.encrypt_query_param ?? "",
2718
+ aesKeyBase64,
2719
+ cdnBaseUrl: this.cdnBaseUrl,
2720
+ label: "weixin image",
2721
+ fullUrl: img.media.full_url
2722
+ });
2723
+ images.push({
2724
+ mediaType: sniffImageMime(buf),
2725
+ data: buf.toString("base64")
2726
+ });
2727
+ } catch (err) {
2728
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] image download/decrypt failed: ${String(err)}`);
2729
+ }
2730
+ }
2731
+ if (!text && images.length === 0) return;
2732
+ const message = {
2733
+ channelId: this.id,
2734
+ conversationId: fromUserId,
2735
+ userId: fromUserId,
2736
+ text,
2737
+ images: images.length ? images : void 0
2738
+ };
2739
+ this.ctx.imGateway.handleInbound(message).catch((err) => this.ctx.logger.error(`[im-channel-weixin:${this.id}] handleInbound failed`, err));
2740
+ }
2741
+ async sendText(conversationId, text) {
2742
+ const contextToken = this.contextTokens.get(this.contextTokenKey(conversationId));
2743
+ const enriched = await this.sendMediaForText(conversationId, text, contextToken);
2744
+ const filter = new StreamingMarkdownFilter();
2745
+ const filtered = filter.feed(enriched) + filter.flush();
2746
+ if (!filtered.trim()) return;
2747
+ const req = { msg: {
2748
+ from_user_id: "",
2749
+ to_user_id: conversationId,
2750
+ client_id: generateClientId(),
2751
+ message_type: MessageType.BOT,
2752
+ message_state: MessageState.FINISH,
2753
+ item_list: [{
2754
+ type: MessageItemType.TEXT,
2755
+ text_item: { text: filtered }
2756
+ }],
2757
+ context_token: contextToken ?? void 0
2758
+ } };
2759
+ await sendMessage({
2760
+ baseUrl: this.baseUrl,
2761
+ token: this.token,
2762
+ timeoutMs: 15e3,
2763
+ body: req
2764
+ });
2765
+ this.stopTyping();
2766
+ }
2767
+ async sendImage(conversationId, image) {
2768
+ const tmpPath = join(os.tmpdir(), `weixin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.img`);
2769
+ try {
2770
+ writeFileSync(tmpPath, Buffer.from(image.data, "base64"));
2771
+ const contextToken = this.contextTokens.get(this.contextTokenKey(conversationId));
2772
+ await this.sendLocalMedia(conversationId, tmpPath, contextToken);
2773
+ } catch (err) {
2774
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] sendImage failed: ${String(err)}`);
2775
+ }
2776
+ }
2777
+ /**
2778
+ * Detect local image/file paths in a reply, upload each to the CDN and
2779
+ * send it as a standalone media message (IMAGE / FILE). Returns the text
2780
+ * with the references replaced by short notes.
2781
+ */
2782
+ async sendMediaForText(conversationId, text, contextToken) {
2783
+ let result = text;
2784
+ for (const m of text.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)) {
2785
+ const [full, alt, p] = m;
2786
+ if (!isLocalPath(p)) continue;
2787
+ const filePath = toLocalPath(p);
2788
+ if (!existsSync(filePath)) continue;
2789
+ await this.sendLocalMedia(conversationId, filePath, contextToken);
2790
+ result = result.replace(full, alt ? `[${alt}]` : "[图片]");
2791
+ }
2792
+ for (const m of result.matchAll(/(?:file:\/\/)?[A-Za-z]:[\\/][^\s"'()<>]+|(?:\/(?:Users|home|tmp|var|private|root)\/[^\s"'()<>]+)/g)) {
2793
+ const filePath = toLocalPath(m[0]);
2794
+ if (!existsSync(filePath)) continue;
2795
+ if (await this.sendLocalMedia(conversationId, filePath, contextToken)) {
2796
+ const isImg = /\.(png|jpe?g|gif|bmp|webp)$/i.test(filePath);
2797
+ result = result.replace(m[0], isImg ? "[图片]" : `[文件已发送:${basename(filePath)}]`);
2798
+ }
2799
+ }
2800
+ return result;
2801
+ }
2802
+ /** Upload one local file and send it as an IMAGE/FILE media message. */
2803
+ async sendLocalMedia(conversationId, filePath, contextToken) {
2804
+ try {
2805
+ const isImage = /\.(png|jpe?g|gif|bmp|webp)$/i.test(filePath);
2806
+ const uploaded = await uploadLocalFileToWeixin({
2807
+ filePath,
2808
+ toUserId: conversationId,
2809
+ mediaType: isImage ? 1 : 3,
2810
+ baseUrl: this.baseUrl,
2811
+ token: this.token,
2812
+ cdnBaseUrl: this.cdnBaseUrl
2813
+ });
2814
+ const media = isImage ? {
2815
+ type: MessageItemType.IMAGE,
2816
+ image_item: {
2817
+ media: {
2818
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
2819
+ aes_key: Buffer.from(uploaded.aeskey, "hex").toString("base64"),
2820
+ encrypt_type: 1
2821
+ },
2822
+ mid_size: uploaded.fileSizeCiphertext
2823
+ }
2824
+ } : {
2825
+ type: MessageItemType.FILE,
2826
+ file_item: {
2827
+ media: {
2828
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
2829
+ aes_key: Buffer.from(uploaded.aeskey, "hex").toString("base64"),
2830
+ encrypt_type: 1
2831
+ },
2832
+ file_name: basename(filePath),
2833
+ len: String(uploaded.fileSize)
2834
+ }
2835
+ };
2836
+ await sendMessage({
2837
+ baseUrl: this.baseUrl,
2838
+ token: this.token,
2839
+ timeoutMs: 15e3,
2840
+ body: { msg: {
2841
+ from_user_id: "",
2842
+ to_user_id: conversationId,
2843
+ client_id: generateClientId(),
2844
+ message_type: MessageType.BOT,
2845
+ message_state: MessageState.FINISH,
2846
+ item_list: [media],
2847
+ context_token: contextToken ?? void 0
2848
+ } }
2849
+ });
2850
+ return true;
2851
+ } catch (err) {
2852
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] media send failed (${filePath}): ${String(err)}`);
2853
+ return false;
2854
+ }
2855
+ }
2856
+ /**
2857
+ * Fetch (and cache) the per-user typing_ticket from getconfig — required
2858
+ * by sendtyping. Mirrors the official connector's per-user config cache.
2859
+ */
2860
+ async ensureTypingTicket(userId) {
2861
+ const cached = this.typingTickets.get(userId);
2862
+ if (cached) return cached;
2863
+ try {
2864
+ const ctxToken = this.contextTokens.get(this.contextTokenKey(userId));
2865
+ const resp = await getConfig({
2866
+ baseUrl: this.baseUrl,
2867
+ token: this.token,
2868
+ ilinkUserId: userId,
2869
+ contextToken: ctxToken
2870
+ });
2871
+ if (resp.typing_ticket) {
2872
+ this.typingTickets.set(userId, resp.typing_ticket);
2873
+ return resp.typing_ticket;
2874
+ }
2875
+ } catch (err) {
2876
+ this.ctx.logger.warn(`[im-channel-weixin:${this.id}] getConfig (typing ticket) failed: ${String(err)}`);
2877
+ }
2878
+ }
2879
+ async sendTyping(conversationId) {
2880
+ this.typingTarget = conversationId;
2881
+ if (this.typingTimer) return;
2882
+ const ticket = await this.ensureTypingTicket(conversationId);
2883
+ if (!ticket) return;
2884
+ const fire = () => {
2885
+ if (!this.typingTarget) return;
2886
+ sendTyping({
2887
+ baseUrl: this.baseUrl,
2888
+ token: this.token,
2889
+ timeoutMs: 1e4,
2890
+ body: {
2891
+ ilink_user_id: this.typingTarget,
2892
+ typing_ticket: ticket,
2893
+ status: TypingStatus.TYPING
2894
+ }
2895
+ }).catch(() => void 0);
2896
+ this.typingTimer = setTimeout(fire, TYPING_INTERVAL_MS);
2897
+ };
2898
+ fire();
2899
+ }
2900
+ /** Stop the keepalive and send the cancel typing status (status=2). */
2901
+ stopTyping() {
2902
+ const target = this.typingTarget;
2903
+ if (this.typingTimer) clearTimeout(this.typingTimer);
2904
+ this.typingTimer = null;
2905
+ this.typingTarget = null;
2906
+ const ticket = target ? this.typingTickets.get(target) : void 0;
2907
+ if (target && ticket) sendTyping({
2908
+ baseUrl: this.baseUrl,
2909
+ token: this.token,
2910
+ timeoutMs: 1e4,
2911
+ body: {
2912
+ ilink_user_id: target,
2913
+ typing_ticket: ticket,
2914
+ status: TypingStatus.CANCEL
2915
+ }
2916
+ }).catch(() => void 0);
2917
+ }
2918
+ };
2919
+ //#endregion
2920
+ //#region src/sync.ts
2921
+ const NS$2 = "im-gateway";
2922
+ /** Channels that should actually run = settings merged value (user layer wins). */
2923
+ function readEffectiveConfig(ctx, fallback) {
2924
+ try {
2925
+ const channels = ctx.get("settings").describe({ redactSecrets: false }).find((n) => n.ns === String(NS$2))?.value?.channels;
2926
+ if (Array.isArray(channels) && channels.length > 0) return channels;
2927
+ } catch (err) {
2928
+ ctx.logger.warn("[im-gateway] settings read failed; falling back to boot config", err);
2929
+ }
2930
+ return fallback.channels ?? [];
2931
+ }
2932
+ /** Instantiate the right adapter class for a channel instance. */
2933
+ function createAdapter(ctx, inst) {
2934
+ const label = inst.name || typeLabel(inst.type);
2935
+ switch (inst.type) {
2936
+ case "dingtalk": return new DingtalkAdapter(ctx, inst.config, inst.id, label);
2937
+ case "qq": return new QQAdapter(ctx, inst.config, inst.id, label);
2938
+ case "weixin": return new WeixinAdapter(ctx, inst.config, inst.id, label);
2939
+ default:
2940
+ ctx.logger.warn(`[im-gateway] unknown channel type "${String(inst.type)}" for "${inst.id}"`);
2941
+ return null;
2942
+ }
2943
+ }
2944
+ function typeLabel(type) {
2945
+ switch (type) {
2946
+ case "dingtalk": return "钉钉";
2947
+ case "qq": return "QQ";
2948
+ case "weixin": return "个人微信";
2949
+ }
2950
+ }
2951
+ /**
2952
+ * Stop every channel and re-create them from the CURRENT settings merged
2953
+ * config. Called at boot and after every config save (mirrors the reference
2954
+ * project's applyConfig → stopAll → recreate → start). Without this, a
2955
+ * QR-scan binding saved from the settings UI would never take effect in the
2956
+ * running process — adapters created at boot with empty credentials stay
2957
+ * dead forever.
2958
+ *
2959
+ * @param imGateway - the gateway service instance. Passed explicitly instead
2960
+ * of resolving `ctx.imGateway` because callers live in different fibers
2961
+ * (boot inject vs Typert remote) whose `inject` lists differ — property
2962
+ * access would throw `cannot get property "imGateway" without inject`.
2963
+ */
2964
+ async function syncChannels(imGateway, ctx, fallback) {
2965
+ await imGateway.stopAll();
2966
+ const effective = readEffectiveConfig(ctx, fallback);
2967
+ for (const inst of effective) {
2968
+ if (!inst.enabled) continue;
2969
+ const adapter = createAdapter(ctx, inst);
2970
+ if (!adapter) continue;
2971
+ ctx.logger.info(`[im-gateway] starting channel "${inst.id}" (${inst.type})`);
2972
+ imGateway.registerChannel(adapter);
2973
+ }
2974
+ }
2975
+ //#endregion
2976
+ //#region src/channels/qq-login.ts
2977
+ /**
2978
+ * QQ Bot QR scan binding — ported from
2979
+ * pi-desk-top/src/main/im/qq/qq-login.ts. Wraps
2980
+ * `@tencent-connect/qqbot-connector`'s callback-style `startQrConnect` into
2981
+ * the same snapshot model used by WeChat: startLogin kicks off a background
2982
+ * flow (the connector polls internally and calls back), the UI polls
2983
+ * getStatus() for snapshots.
2984
+ *
2985
+ * On success the connector hands back the robot credentials directly
2986
+ * (appId + appSecret) — no need to create the bot on the QQ open platform
2987
+ * manually.
2988
+ */
2989
+ const logins$1 = /* @__PURE__ */ new Map();
2990
+ function snapshot$1(l) {
2991
+ const s = {
2992
+ loginId: l.loginId,
2993
+ status: l.status,
2994
+ qrcodeUrl: l.qrcodeUrl,
2995
+ qrcode: l.qrcode,
2996
+ message: l.message
2997
+ };
2998
+ if (l.status === "confirmed" && l.appId && l.appSecret) s.credentials = {
2999
+ appId: l.appId,
3000
+ appSecret: l.appSecret
3001
+ };
3002
+ return s;
3003
+ }
3004
+ /** Purge stale logins so the map never grows unbounded. */
3005
+ function purgeStale$1() {
3006
+ const now = Date.now();
3007
+ for (const [id, l] of logins$1) if (l.status !== "running" && now - l.startedAt > 3e5) logins$1.delete(id);
3008
+ }
3009
+ /**
3010
+ * Kick off a QQ QR binding. Returns immediately with a login id; the
3011
+ * connector polls in the background and the UI polls getStatus().
3012
+ */
3013
+ async function startLogin$1() {
3014
+ purgeStale$1();
3015
+ const { startQrConnect } = await import("@tencent-connect/qqbot-connector");
3016
+ const loginId = randomUUID();
3017
+ const login = {
3018
+ loginId,
3019
+ status: "running",
3020
+ qrcodeUrl: "",
3021
+ qrcode: "",
3022
+ message: "正在获取二维码…",
3023
+ startedAt: Date.now()
3024
+ };
3025
+ logins$1.set(loginId, login);
3026
+ login.stop = startQrConnect({
3027
+ onQrDisplayed: (url) => {
3028
+ login.qrcode = url;
3029
+ QRCode.toDataURL(url, {
3030
+ width: 240,
3031
+ margin: 1
3032
+ }).then((dataUrl) => {
3033
+ login.qrcodeUrl = dataUrl;
3034
+ login.message = "请用手机 QQ 扫描二维码绑定机器人。";
3035
+ }).catch(() => {
3036
+ login.message = "二维码渲染失败,可用下方链接扫码。";
3037
+ });
3038
+ },
3039
+ onQrExpired: () => {
3040
+ login.message = "二维码已过期,正在刷新…";
3041
+ },
3042
+ onSuccess: (creds) => {
3043
+ const first = creds[0];
3044
+ if (!first) {
3045
+ login.status = "error";
3046
+ login.message = "绑定失败:未获取到机器人凭据。";
3047
+ return;
3048
+ }
3049
+ login.appId = first.appId;
3050
+ login.appSecret = first.appSecret;
3051
+ login.status = "confirmed";
3052
+ login.message = `绑定成功!AppID: ${first.appId}`;
3053
+ },
3054
+ onFailure: (err) => {
3055
+ login.status = "error";
3056
+ login.message = `绑定失败:${err.message}`;
3057
+ }
3058
+ }, {
3059
+ displayQrCodeToConsole: false,
3060
+ source: "dsh-desktop"
3061
+ });
3062
+ return snapshot$1(login);
3063
+ }
3064
+ /** Read the current login snapshot (null if the login is gone). */
3065
+ function getLoginStatus$1(loginId) {
3066
+ const l = logins$1.get(loginId);
3067
+ if (!l) return null;
3068
+ return snapshot$1(l);
3069
+ }
3070
+ /** Cancel an in-flight login. */
3071
+ function cancelLogin$1(loginId) {
3072
+ const l = logins$1.get(loginId);
3073
+ if (!l) return;
3074
+ l.stop?.();
3075
+ l.status = "canceled";
3076
+ l.message = "绑定已取消。";
3077
+ }
3078
+ //#endregion
3079
+ //#region src/channels/weixin-login.ts
3080
+ /**
3081
+ * Weixin QR scan login — ported from
3082
+ * pi-desk-top/src/main/im/weixin/weixin-login.ts (itself adapted from the
3083
+ * official connector's login-qr.ts). The login loop runs IN-PROCESS
3084
+ * (started by startLogin, never awaited); the UI polls getStatus() for a
3085
+ * snapshot and submits the pairing code via submitVerifyCode when the server
3086
+ * asks for one.
3087
+ *
3088
+ * On "confirmed" the loop resolves with the bot credentials; the caller
3089
+ * stores them into the channel instance config.
3090
+ */
3091
+ /** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status. */
3092
+ const DEFAULT_ILINK_BOT_TYPE = "3";
3093
+ /** Fixed API base URL for all QR code requests. */
3094
+ const FIXED_BASE_URL = "https://ilinkai.weixin.qq.com";
3095
+ /** Client-side timeout for the long-poll get_qrcode_status request. */
3096
+ const QR_LONG_POLL_TIMEOUT_MS = 35e3;
3097
+ /** A QR code that was never scanned expires after this long. */
3098
+ const LOGIN_TTL_MS = 3e5;
3099
+ /** Max QR refresh attempts before giving up. */
3100
+ const MAX_QR_REFRESH_COUNT = 3;
3101
+ const logins = /* @__PURE__ */ new Map();
3102
+ /** Remove stale logins so the map never grows unbounded. */
3103
+ function purgeStale() {
3104
+ const now = Date.now();
3105
+ for (const [id, l] of logins) if (!l.running && now - l.startedAt > LOGIN_TTL_MS) logins.delete(id);
3106
+ }
3107
+ function snapshot(l) {
3108
+ const s = {
3109
+ loginId: l.loginId,
3110
+ status: l.status,
3111
+ qrcodeUrl: l.qrcodeUrl,
3112
+ qrcode: l.qrContent,
3113
+ message: l.message
3114
+ };
3115
+ if (l.status === "need_verifycode") s.verifyCodeNeeded = true;
3116
+ if (l.status === "confirmed" && l.botToken && l.botId) s.credentials = {
3117
+ token: l.botToken,
3118
+ botId: l.botId,
3119
+ baseUrl: l.baseUrl ?? FIXED_BASE_URL,
3120
+ userId: l.userId
3121
+ };
3122
+ return s;
3123
+ }
3124
+ async function fetchQRCode(apiBaseUrl, botType) {
3125
+ const rawText = await apiPostFetch({
3126
+ baseUrl: apiBaseUrl,
3127
+ endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
3128
+ body: JSON.stringify({ local_token_list: [] }),
3129
+ timeoutMs: 15e3,
3130
+ label: "fetchQRCode"
3131
+ });
3132
+ return JSON.parse(rawText);
3133
+ }
3134
+ async function pollQRStatus(apiBaseUrl, qrcode, verifyCode) {
3135
+ try {
3136
+ let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
3137
+ if (verifyCode) endpoint += `&verify_code=${encodeURIComponent(verifyCode)}`;
3138
+ const rawText = await apiGetFetch({
3139
+ baseUrl: apiBaseUrl,
3140
+ endpoint,
3141
+ timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
3142
+ label: "pollQRStatus"
3143
+ });
3144
+ return JSON.parse(rawText);
3145
+ } catch (err) {
3146
+ return { status: "wait" };
3147
+ }
3148
+ }
3149
+ async function refreshQR(l) {
3150
+ try {
3151
+ const qr = await fetchQRCode(FIXED_BASE_URL, DEFAULT_ILINK_BOT_TYPE);
3152
+ l.qrcode = qr.qrcode;
3153
+ l.qrContent = qr.qrcode_img_content;
3154
+ l.qrcodeUrl = await QRCode.toDataURL(qr.qrcode_img_content, {
3155
+ width: 240,
3156
+ margin: 1
3157
+ });
3158
+ l.startedAt = Date.now();
3159
+ l.qrRefreshCount += 1;
3160
+ l.message = "二维码已更新,请重新扫描。";
3161
+ return true;
3162
+ } catch (err) {
3163
+ l.message = `刷新二维码失败: ${String(err)}`;
3164
+ return false;
3165
+ }
3166
+ }
3167
+ /**
3168
+ * Kick off a QR login. Returns the login id + QR material immediately; the
3169
+ * polling loop runs in the background and updates the snapshot.
3170
+ */
3171
+ async function startLogin() {
3172
+ purgeStale();
3173
+ const loginId = randomUUID();
3174
+ const login = {
3175
+ loginId,
3176
+ qrcode: "",
3177
+ qrcodeUrl: "",
3178
+ qrContent: "",
3179
+ startedAt: Date.now(),
3180
+ status: "running",
3181
+ message: "正在获取二维码…",
3182
+ currentApiBaseUrl: FIXED_BASE_URL,
3183
+ qrRefreshCount: 0,
3184
+ running: true
3185
+ };
3186
+ logins.set(loginId, login);
3187
+ (async () => {
3188
+ try {
3189
+ const qr = await fetchQRCode(FIXED_BASE_URL, DEFAULT_ILINK_BOT_TYPE);
3190
+ login.qrcode = qr.qrcode;
3191
+ login.qrContent = qr.qrcode_img_content;
3192
+ login.qrcodeUrl = await QRCode.toDataURL(qr.qrcode_img_content, {
3193
+ width: 240,
3194
+ margin: 1
3195
+ });
3196
+ login.message = "请用手机微信扫描二维码。";
3197
+ await runLoop(login);
3198
+ } catch (err) {
3199
+ login.status = "error";
3200
+ login.message = `获取二维码失败: ${String(err)}`;
3201
+ login.running = false;
3202
+ }
3203
+ })();
3204
+ return snapshot(login);
3205
+ }
3206
+ async function runLoop(l) {
3207
+ while (l.running && Date.now() - l.startedAt < LOGIN_TTL_MS) {
3208
+ const resp = await pollQRStatus(l.currentApiBaseUrl, l.qrcode, l.pendingVerifyCode);
3209
+ switch (resp.status) {
3210
+ case "wait": break;
3211
+ case "scaned":
3212
+ if (l.pendingVerifyCode) l.pendingVerifyCode = void 0;
3213
+ if (l.status !== "scaned") {
3214
+ l.status = "scaned";
3215
+ l.message = "已扫码,正在确认…";
3216
+ }
3217
+ break;
3218
+ case "need_verifycode":
3219
+ l.status = "need_verifycode";
3220
+ l.message = l.pendingVerifyCode ? "❌ 数字不匹配,请重新输入。" : "请在手机微信上查看数字,并在此输入。";
3221
+ await new Promise((resolve) => {
3222
+ const iv = setInterval(() => {
3223
+ if (!l.running || l.pendingVerifyCode !== void 0) {
3224
+ clearInterval(iv);
3225
+ resolve();
3226
+ }
3227
+ }, 500);
3228
+ });
3229
+ if (!l.running) return;
3230
+ break;
3231
+ case "expired":
3232
+ if (!await refreshQR(l) || l.qrRefreshCount > MAX_QR_REFRESH_COUNT) {
3233
+ l.status = "error";
3234
+ l.message = "二维码多次失效,连接流程已停止。请稍后再试。";
3235
+ l.running = false;
3236
+ return;
3237
+ }
3238
+ l.status = "wait";
3239
+ break;
3240
+ case "verify_code_blocked":
3241
+ l.pendingVerifyCode = void 0;
3242
+ l.message = "多次输入错误,请稍后再试。";
3243
+ await new Promise((r) => setTimeout(r, 3e3));
3244
+ break;
3245
+ case "binded_redirect":
3246
+ l.status = "confirmed";
3247
+ l.message = "该微信已绑定过此实例,无需重复连接。";
3248
+ l.running = false;
3249
+ return;
3250
+ case "scaned_but_redirect":
3251
+ if (resp.redirect_host) l.currentApiBaseUrl = `https://${resp.redirect_host}`;
3252
+ break;
3253
+ case "confirmed":
3254
+ if (!resp.ilink_bot_id) {
3255
+ l.status = "error";
3256
+ l.message = "登录失败:服务器未返回 ilink_bot_id。";
3257
+ l.running = false;
3258
+ return;
3259
+ }
3260
+ l.status = "confirmed";
3261
+ l.botToken = resp.bot_token;
3262
+ l.botId = resp.ilink_bot_id;
3263
+ l.baseUrl = resp.baseurl;
3264
+ l.userId = resp.ilink_user_id;
3265
+ l.message = "已连接到微信。";
3266
+ l.running = false;
3267
+ return;
3268
+ }
3269
+ await new Promise((r) => setTimeout(r, 800));
3270
+ }
3271
+ if (l.running) {
3272
+ l.status = "error";
3273
+ l.message = "登录超时,请重试。";
3274
+ l.running = false;
3275
+ }
3276
+ }
3277
+ /** Read the current login snapshot (null if the login is gone). */
3278
+ function getLoginStatus(loginId) {
3279
+ const l = logins.get(loginId);
3280
+ if (!l) return null;
3281
+ return snapshot(l);
3282
+ }
3283
+ /** Submit the pairing code shown on the phone. */
3284
+ function submitVerifyCode(loginId, code) {
3285
+ const l = logins.get(loginId);
3286
+ if (!l || !l.running) return false;
3287
+ l.pendingVerifyCode = code.trim();
3288
+ if (l.status === "need_verifycode") l.status = "wait";
3289
+ return true;
3290
+ }
3291
+ /** Cancel an in-flight login. */
3292
+ function cancelLogin(loginId) {
3293
+ const l = logins.get(loginId);
3294
+ if (!l) return;
3295
+ l.running = false;
3296
+ l.status = "canceled";
3297
+ l.message = "登录已取消。";
3298
+ }
3299
+ //#endregion
3300
+ //#region src/remote.ts
3301
+ const NS$1 = "im-gateway";
3302
+ const SERVICE = "imGatewayRemote";
3303
+ const PACKAGE = "@lijian-ui/dsh-im-gateway";
3304
+ /** Secret-typed credential keys (mirrors the host Config schema roles). */
3305
+ const SECRET_KEYS = /* @__PURE__ */ new Set(["clientSecret", "token"]);
3306
+ /** Minimal boundary schema: channels must be an array. */
3307
+ const channelsSchema = { parse(value) {
3308
+ if (!Array.isArray(value)) throw new Error("channels must be an array");
3309
+ return value;
3310
+ } };
3311
+ /** Minimal boundary schema: revision is an optional number. */
3312
+ const revisionSchema = { parse(value) {
3313
+ if (value === void 0 || value === null) return void 0;
3314
+ if (typeof value !== "number") throw new Error("expectedRevision must be a number");
3315
+ return value;
3316
+ } };
3317
+ const codec = (typeSymbol, schema) => ({
3318
+ mode: "strict",
3319
+ typeSymbol,
3320
+ schema
3321
+ });
3322
+ /**
3323
+ * The typed wire contribution registered with `ctx.typert`. HOST face:
3324
+ * the registry expects `face: 'host'` + `invocations` (the client-side
3325
+ * `$mount` contribution instead carries `descriptors`).
3326
+ */
3327
+ const CONTRIBUTION = {
3328
+ package: PACKAGE,
3329
+ face: "host",
3330
+ schemas: [],
3331
+ invocations: [
3332
+ {
3333
+ id: `${PACKAGE}#${SERVICE}/getConfig`,
3334
+ service: SERVICE,
3335
+ namespace: SERVICE,
3336
+ method: "getConfig",
3337
+ invocation: { kind: "direct" },
3338
+ parameters: [],
3339
+ result: codec(`${PACKAGE}#ImGatewayConfigView`, { parse: (v) => v })
3340
+ },
3341
+ {
3342
+ id: `${PACKAGE}#${SERVICE}/saveConfig`,
3343
+ service: SERVICE,
3344
+ namespace: SERVICE,
3345
+ method: "saveConfig",
3346
+ invocation: { kind: "direct" },
3347
+ parameters: [{
3348
+ name: "channels",
3349
+ wire: "channels",
3350
+ source: "json",
3351
+ codec: codec(`${PACKAGE}#ChannelsArray`, channelsSchema)
3352
+ }, {
3353
+ name: "expectedRevision",
3354
+ wire: "expectedRevision",
3355
+ source: "json",
3356
+ acceptsUndefined: true,
3357
+ codec: codec(`${PACKAGE}#Revision`, revisionSchema)
3358
+ }],
3359
+ result: codec(`${PACKAGE}#ImGatewayConfigView`, { parse: (v) => v })
3360
+ },
3361
+ {
3362
+ id: `${PACKAGE}#${SERVICE}/getChannelStatuses`,
3363
+ service: SERVICE,
3364
+ namespace: SERVICE,
3365
+ method: "getChannelStatuses",
3366
+ invocation: { kind: "direct" },
3367
+ parameters: [],
3368
+ result: codec(`${PACKAGE}#ChannelStatuses`, { parse: (v) => v })
3369
+ },
3370
+ {
3371
+ id: `${PACKAGE}#${SERVICE}/startQrLogin`,
3372
+ service: SERVICE,
3373
+ namespace: SERVICE,
3374
+ method: "startQrLogin",
3375
+ invocation: { kind: "direct" },
3376
+ parameters: [{
3377
+ name: "channelType",
3378
+ wire: "channelType",
3379
+ source: "json",
3380
+ codec: codec(`${PACKAGE}#ChannelType`, { parse: (v) => v })
3381
+ }],
3382
+ result: codec(`${PACKAGE}#QrLoginStatus`, { parse: (v) => v })
3383
+ },
3384
+ {
3385
+ id: `${PACKAGE}#${SERVICE}/getQrLoginStatus`,
3386
+ service: SERVICE,
3387
+ namespace: SERVICE,
3388
+ method: "getQrLoginStatus",
3389
+ invocation: { kind: "direct" },
3390
+ parameters: [{
3391
+ name: "loginId",
3392
+ wire: "loginId",
3393
+ source: "json",
3394
+ codec: codec(`${PACKAGE}#LoginId`, { parse: (v) => v })
3395
+ }],
3396
+ result: codec(`${PACKAGE}#QrLoginStatus`, { parse: (v) => v })
3397
+ },
3398
+ {
3399
+ id: `${PACKAGE}#${SERVICE}/submitQrVerifyCode`,
3400
+ service: SERVICE,
3401
+ namespace: SERVICE,
3402
+ method: "submitQrVerifyCode",
3403
+ invocation: { kind: "direct" },
3404
+ parameters: [{
3405
+ name: "loginId",
3406
+ wire: "loginId",
3407
+ source: "json",
3408
+ codec: codec(`${PACKAGE}#LoginId`, { parse: (v) => v })
3409
+ }, {
3410
+ name: "code",
3411
+ wire: "code",
3412
+ source: "json",
3413
+ codec: codec(`${PACKAGE}#VerifyCode`, { parse: (v) => v })
3414
+ }],
3415
+ result: codec(`${PACKAGE}#Bool`, { parse: (v) => v })
3416
+ },
3417
+ {
3418
+ id: `${PACKAGE}#${SERVICE}/cancelQrLogin`,
3419
+ service: SERVICE,
3420
+ namespace: SERVICE,
3421
+ method: "cancelQrLogin",
3422
+ invocation: { kind: "direct" },
3423
+ parameters: [{
3424
+ name: "loginId",
3425
+ wire: "loginId",
3426
+ source: "json",
3427
+ codec: codec(`${PACKAGE}#LoginId`, { parse: (v) => v })
3428
+ }],
3429
+ result: codec(`${PACKAGE}#Bool`, { parse: (v) => v })
3430
+ }
3431
+ ],
3432
+ model: {
3433
+ services: [],
3434
+ events: [],
3435
+ objects: []
3436
+ }
3437
+ };
3438
+ /** Read the namespace view (channels + revision). Secrets redacted on the wire. */
3439
+ function readView(settings) {
3440
+ const view = settings.describe({ redactSecrets: true }).find((n) => n.ns === NS$1);
3441
+ const channels = view?.value?.channels;
3442
+ return {
3443
+ channels: Array.isArray(channels) ? channels : [],
3444
+ revision: view?.revision ?? 0
3445
+ };
3446
+ }
3447
+ /** Pull the current channels array (plaintext secrets) from the settings service. */
3448
+ function currentChannels(settings) {
3449
+ const channels = settings.describe({ redactSecrets: false }).find((n) => n.ns === NS$1)?.value?.channels;
3450
+ return Array.isArray(channels) ? channels : [];
3451
+ }
3452
+ /**
3453
+ * Merge blank secrets back from storage. `channels` is a wholesale-replace
3454
+ * array; without this, a save would wipe every secret the user did not
3455
+ * retype (they see a blank password field because describe redacts).
3456
+ */
3457
+ function mergeStoredSecrets(current, incoming) {
3458
+ return incoming.map((inst) => {
3459
+ const stored = current.find((c) => c.id === inst.id);
3460
+ if (!stored) return inst;
3461
+ const config = { ...inst.config };
3462
+ for (const key of SECRET_KEYS) {
3463
+ const value = config[key];
3464
+ if (value === "" || value === void 0) {
3465
+ const old = stored.config?.[key];
3466
+ if (old !== void 0 && old !== "") config[key] = old;
3467
+ }
3468
+ }
3469
+ return {
3470
+ ...inst,
3471
+ config
3472
+ };
3473
+ });
3474
+ }
3475
+ /**
3476
+ * The Remote service. Constructing it registers the `imGatewayRemote` cordis
3477
+ * service; the contribution above lets the API gateway dispatch the two
3478
+ * methods. Plain methods are fine — the descriptors carry the wire contract.
3479
+ */
3480
+ var ImGatewayApi = class extends TypertRemoteService {
3481
+ settings;
3482
+ constructor(ctx) {
3483
+ super(ctx, SERVICE);
3484
+ this.settings = ctx.get("settings");
3485
+ }
3486
+ /** Current channels + revision (secrets redacted). */
3487
+ getConfig() {
3488
+ return readView(this.settings);
3489
+ }
3490
+ /** Wholesale-replace the channels array. Blank secrets are kept from storage. */
3491
+ async saveConfig(channels, expectedRevision) {
3492
+ if (!Array.isArray(channels)) throw new Error("channels must be an array");
3493
+ const merged = mergeStoredSecrets(currentChannels(this.settings), channels);
3494
+ await this.settings.update(NS$1, { channels: merged }, expectedRevision);
3495
+ try {
3496
+ const gateway = this.ctx.get("imGateway");
3497
+ if (gateway) await syncChannels(gateway, this.ctx, {
3498
+ cwd: process.cwd(),
3499
+ streamThrottleMs: 800,
3500
+ slashCommands: true,
3501
+ channels: []
3502
+ });
3503
+ } catch (err) {
3504
+ this.ctx.logger.error("[im-gateway] channel reload after save failed", err);
3505
+ }
3506
+ return readView(this.settings);
3507
+ }
3508
+ /** Live channel runtime statuses (online/offline/error) for the settings UI. */
3509
+ getChannelStatuses() {
3510
+ return this.ctx.get("imGateway").getChannelStatuses();
3511
+ }
3512
+ /**
3513
+ * Kick off a QR-scan login for a channel type ('qq' | 'weixin'). Returns
3514
+ * `{ status: snapshot }` (the client's generic `call()` spreads the value
3515
+ * to the top level, so the snapshot MUST be wrapped in a `status` key —
3516
+ * otherwise `res.status` would be the snapshot's `status` string, not the
3517
+ * snapshot object, and the polling effect would never start).
3518
+ */
3519
+ async startQrLogin(channelType) {
3520
+ try {
3521
+ if (channelType === "qq") return { status: await startLogin$1() };
3522
+ if (channelType === "weixin") return { status: await startLogin() };
3523
+ throw new Error(`unsupported channel type for QR login: ${channelType}`);
3524
+ } catch (err) {
3525
+ const message = err instanceof Error ? err.message : String(err);
3526
+ console.error(`[im-gateway] startQrLogin(${channelType}) failed:`, message);
3527
+ return { status: {
3528
+ loginId: "",
3529
+ status: "error",
3530
+ qrcodeUrl: "",
3531
+ qrcode: "",
3532
+ message: `二维码获取失败:${message}`
3533
+ } };
3534
+ }
3535
+ }
3536
+ /** Read the current login snapshot (null → the login is gone/expired). */
3537
+ getQrLoginStatus(loginId) {
3538
+ const qq = getLoginStatus$1(loginId);
3539
+ if (qq) return { status: qq };
3540
+ const wx = getLoginStatus(loginId);
3541
+ if (wx) return { status: wx };
3542
+ return { status: null };
3543
+ }
3544
+ /** Submit the phone pairing code (WeChat only; QQ returns false). */
3545
+ submitQrVerifyCode(loginId, code) {
3546
+ return submitVerifyCode(loginId, code);
3547
+ }
3548
+ /** Cancel an in-flight QR login. */
3549
+ cancelQrLogin(loginId) {
3550
+ cancelLogin$1(loginId);
3551
+ cancelLogin(loginId);
3552
+ return true;
3553
+ }
3554
+ };
3555
+ /**
3556
+ * Register the Remote config service. Called from the bundle apply AFTER the
3557
+ * entry's plugin-level `inject: ['typert', 'settings']` guarantee, so both
3558
+ * services are live here (synchronous registration, mirroring the official
3559
+ * dsh-skill-viewer host: `export const inject = ["typert", ...]`).
3560
+ */
3561
+ function registerRemoteApi(ctx) {
3562
+ new ImGatewayApi(ctx);
3563
+ ctx.effect(() => ctx.get("typert").register(CONTRIBUTION), `im-gateway: typert contribution ${PACKAGE}`);
3564
+ }
3565
+ //#endregion
3566
+ //#region src/index.ts
3567
+ /**
3568
+ * Plugin-level dependency declaration: the bundle waits for these host
3569
+ * services before apply runs (mirrors the official dsh-skill-viewer host:
3570
+ * `export const inject = ["typert", ...]`). `imGateway` (our own core
3571
+ * service) is created inside apply and waited on via ctx.inject below.
3572
+ */
3573
+ const inject = ["typert", "settings"];
3574
+ /** Settings namespace for the whole IM bundle (web UI renders `channels[]`). */
3575
+ const NS = settingsNamespace("im-gateway");
3576
+ /**
3577
+ * Single-entry IM gateway bundle. One plugin (`@lijian-ui/dsh-im-gateway`) provides the
3578
+ * core gateway service AND every channel (DingTalk / QQ / 个人微信) as
3579
+ * INSTANCES: `config.channels` is an array, and the same channel type may
3580
+ * appear multiple times (multi-bot support, mirroring pi-desk-top). Only
3581
+ * `enabled` instances connect out.
3582
+ */
3583
+ function apply(ctx, config) {
3584
+ installConsoleLoggerExporter(ctx);
3585
+ installSettingsSection(ctx, NS, Config, config, {
3586
+ setSource: () => {},
3587
+ onChange: () => {}
3588
+ });
3589
+ ctx.plugin(ImGatewayService, config);
3590
+ registerRemoteApi(ctx);
3591
+ ctx.inject(["imGateway"], (sctx) => {
3592
+ syncChannels(sctx.imGateway, sctx, config);
3593
+ });
3594
+ }
3595
+ /**
3596
+ * Route every cordis logger message to the native console so IM gateway logs
3597
+ * actually reach the terminal. Without this, `ctx.logger.*` only lands in
3598
+ * cordis's in-memory buffer (its default exporter) and is invisible while
3599
+ * debugging. Message shape: { name, type, level, args, ... }.
3600
+ */
3601
+ function installConsoleLoggerExporter(ctx) {
3602
+ try {
3603
+ ctx.root.logger?.exporter?.({
3604
+ colors: 0,
3605
+ export: (message) => {
3606
+ const { name = "im-gateway", type = "log", args = [] } = message;
3607
+ const line = `[${name}] ${args.map((arg) => arg instanceof Error ? arg.stack ?? arg.message : typeof arg === "string" ? arg : (() => {
3608
+ try {
3609
+ return JSON.stringify(arg);
3610
+ } catch {
3611
+ return String(arg);
3612
+ }
3613
+ })()).join(" ")}`;
3614
+ if (type === "error") console.error(line);
3615
+ else if (type === "warn") console.warn(line);
3616
+ else if (type === "debug") console.debug(line);
3617
+ else console.info(line);
3618
+ }
3619
+ });
3620
+ } catch {}
3621
+ }
3622
+ //#endregion
3623
+ export { Config, apply, inject };