@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/LICENSE +21 -0
- package/README.md +183 -0
- package/README.zh-CN.md +207 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +1233 -0
- package/lib/index-2mMnMMFx.d.ts +304 -0
- package/lib/index.js +3623 -0
- package/package.json +103 -0
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 };
|