@yanhaidao/wecom 1.0.1 → 2.0.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/README.md +37 -17
- package/index.ts +4 -4
- package/{clawdbot.plugin.json → openclaw.plugin.json} +1 -0
- package/package.json +6 -6
- package/src/accounts.ts +9 -10
- package/src/channel.ts +12 -12
- package/src/config-schema.ts +3 -0
- package/src/crypto.ts +3 -3
- package/src/media.test.ts +49 -0
- package/src/media.ts +37 -0
- package/src/monitor.active.test.ts +137 -0
- package/src/monitor.integration.test.ts +171 -0
- package/src/monitor.ts +153 -32
- package/src/monitor.webhook.test.ts +162 -94
- package/src/runtime.ts +1 -2
- package/src/types.ts +19 -3
- package/tsconfig.json +1 -1
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
|
|
3
|
+
import { encryptWecomPlaintext, computeWecomMsgSignature, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
|
+
import * as runtime from "./runtime.js";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
8
|
+
import { Socket } from "node:net";
|
|
9
|
+
|
|
10
|
+
vi.mock("axios");
|
|
11
|
+
|
|
12
|
+
// Helpers to simulate HTTP request
|
|
13
|
+
function createMockRequest(bodyObj: any, query: URLSearchParams): IncomingMessage {
|
|
14
|
+
const socket = new Socket();
|
|
15
|
+
const req = new IncomingMessage(socket);
|
|
16
|
+
req.method = "POST";
|
|
17
|
+
req.url = `/wecom?${query.toString()}`;
|
|
18
|
+
req.push(JSON.stringify(bodyObj));
|
|
19
|
+
req.push(null);
|
|
20
|
+
return req;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createMockResponse(): ServerResponse & { _getData: () => string, _getStatusCode: () => number } {
|
|
24
|
+
const req = new IncomingMessage(new Socket());
|
|
25
|
+
const res = new ServerResponse(req);
|
|
26
|
+
let data = "";
|
|
27
|
+
res.write = (chunk: any) => { data += chunk; return true; };
|
|
28
|
+
res.end = (chunk: any) => { if (chunk) data += chunk; return res; };
|
|
29
|
+
(res as any)._getData = () => data;
|
|
30
|
+
(res as any)._getStatusCode = () => res.statusCode;
|
|
31
|
+
return res as any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// PKCS7 Pad Helper for manual encryption
|
|
35
|
+
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
36
|
+
const mod = buf.length % blockSize;
|
|
37
|
+
const pad = mod === 0 ? blockSize : blockSize - mod;
|
|
38
|
+
const padByte = Buffer.from([pad]);
|
|
39
|
+
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("Monitor Integration: Inbound Image", () => {
|
|
43
|
+
const token = "MY_TOKEN";
|
|
44
|
+
const encodingAESKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes key
|
|
45
|
+
const receiveId = "MY_CORPID";
|
|
46
|
+
let unregisterTarget: (() => void) | null = null;
|
|
47
|
+
|
|
48
|
+
// Mock Core Runtime
|
|
49
|
+
const mockDeliver = vi.fn();
|
|
50
|
+
const mockCore = {
|
|
51
|
+
channel: {
|
|
52
|
+
routing: { resolveAgentRoute: () => ({ agentId: "agent-1", sessionKey: "sess-1", accountId: "acc-1" }) },
|
|
53
|
+
session: {
|
|
54
|
+
resolveStorePath: () => "store/path",
|
|
55
|
+
readSessionUpdatedAt: () => 0,
|
|
56
|
+
recordInboundSession: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
reply: {
|
|
59
|
+
formatAgentEnvelope: () => "formatted-body",
|
|
60
|
+
finalizeInboundContext: (ctx: any) => ctx,
|
|
61
|
+
resolveEnvelopeFormatOptions: () => ({}),
|
|
62
|
+
dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
|
|
63
|
+
// Simulate Agent processing by calling deliver immediately or later
|
|
64
|
+
// For this test, verifying the Inbound Body is enough.
|
|
65
|
+
// The delivery payload is what the AGENT sees.
|
|
66
|
+
// But wait, dispatchReply... is for OUTBOUND streaming replies.
|
|
67
|
+
// startAgentForStream calls it.
|
|
68
|
+
// We really want to spy on what `rawBody` was passed to startAgentForStream context.
|
|
69
|
+
|
|
70
|
+
// Actually `recordInboundSession` receives `ctx` which contains `RawBody`.
|
|
71
|
+
return;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
text: { resolveMarkdownTableMode: () => "off", convertMarkdownTables: (t: string) => t },
|
|
75
|
+
},
|
|
76
|
+
logging: { shouldLogVerbose: () => true },
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore as any);
|
|
81
|
+
|
|
82
|
+
unregisterTarget?.();
|
|
83
|
+
unregisterTarget = registerWecomWebhookTarget({
|
|
84
|
+
account: {
|
|
85
|
+
accountId: "test-acc",
|
|
86
|
+
name: "Test",
|
|
87
|
+
enabled: true,
|
|
88
|
+
configured: true,
|
|
89
|
+
token,
|
|
90
|
+
encodingAESKey,
|
|
91
|
+
receiveId,
|
|
92
|
+
config: {} as any
|
|
93
|
+
},
|
|
94
|
+
config: {} as any,
|
|
95
|
+
runtime: { log: console.log, error: console.error },
|
|
96
|
+
core: mockCore as any,
|
|
97
|
+
path: "/wecom"
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
unregisterTarget?.();
|
|
103
|
+
unregisterTarget = null;
|
|
104
|
+
vi.restoreAllMocks();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("should decrypt inbound image and pass base64 to agent", async () => {
|
|
108
|
+
// 1. Prepare Encrypted Media (The "File" on WeCom Server)
|
|
109
|
+
// We pretend this is the media data returned by axios
|
|
110
|
+
const fileContent = Buffer.from("fake-image-data");
|
|
111
|
+
const aesKey = Buffer.from(encodingAESKey + "=", "base64");
|
|
112
|
+
const iv = aesKey.subarray(0, 16);
|
|
113
|
+
|
|
114
|
+
// Encrypt content (WeCom does this)
|
|
115
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
116
|
+
cipher.setAutoPadding(false);
|
|
117
|
+
const encryptedMedia = Buffer.concat([cipher.update(pkcs7Pad(fileContent, WECOM_PKCS7_BLOCK_SIZE)), cipher.final()]);
|
|
118
|
+
|
|
119
|
+
// Mock Axios to return this encrypted media
|
|
120
|
+
(axios.get as any).mockResolvedValue({ data: encryptedMedia });
|
|
121
|
+
|
|
122
|
+
// 2. Prepare Inbound Message (The Webhook JSON)
|
|
123
|
+
const imageUrl = "http://wecom.server/media/123";
|
|
124
|
+
const inboundMsg = {
|
|
125
|
+
msgtype: "image",
|
|
126
|
+
image: { url: imageUrl },
|
|
127
|
+
from: { userid: "yanhaidao" }
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// 3. Encrypt the *Inbound Message* Payload (The Envelope)
|
|
131
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
132
|
+
const nonce = "123456";
|
|
133
|
+
const encrypt = encryptWecomPlaintext({
|
|
134
|
+
encodingAESKey,
|
|
135
|
+
receiveId,
|
|
136
|
+
plaintext: JSON.stringify(inboundMsg)
|
|
137
|
+
});
|
|
138
|
+
const msgSignature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt });
|
|
139
|
+
|
|
140
|
+
const query = new URLSearchParams({
|
|
141
|
+
msg_signature: msgSignature,
|
|
142
|
+
timestamp,
|
|
143
|
+
nonce
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const bodyObj = {
|
|
147
|
+
touser: receiveId,
|
|
148
|
+
agentid: "10001",
|
|
149
|
+
encrypt, // Standard WeCom POST body structure
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// 4. Send Request
|
|
153
|
+
const req = createMockRequest(bodyObj, query);
|
|
154
|
+
const res = createMockResponse();
|
|
155
|
+
|
|
156
|
+
await handleWecomWebhookRequest(req, res);
|
|
157
|
+
|
|
158
|
+
// 5. Verify
|
|
159
|
+
// Check recordInboundSession was called with correct RawBody
|
|
160
|
+
const recordCall = (mockCore.channel.session.recordInboundSession as any).mock.calls[0][0];
|
|
161
|
+
const rawBody = recordCall.ctx.RawBody;
|
|
162
|
+
|
|
163
|
+
// Expect: [image] data:image/jpeg;base64,...
|
|
164
|
+
expect(rawBody).toContain("[image] data:image/jpeg;base64,");
|
|
165
|
+
const base64Part = rawBody.split("base64,")[1];
|
|
166
|
+
const decoded = Buffer.from(base64Part, "base64");
|
|
167
|
+
|
|
168
|
+
expect(decoded.toString()).toBe("fake-image-data");
|
|
169
|
+
expect(axios.get).toHaveBeenCalledWith(imageUrl, expect.anything());
|
|
170
|
+
});
|
|
171
|
+
});
|
package/src/monitor.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
5
5
|
|
|
6
|
-
import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js";
|
|
6
|
+
import type { ResolvedWecomAccount, WecomInboundMessage, WecomInboundQuote } from "./types.js";
|
|
7
7
|
import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
|
|
8
8
|
import { getWecomRuntime } from "./runtime.js";
|
|
9
|
+
import { decryptWecomMedia } from "./media.js";
|
|
10
|
+
import axios from "axios";
|
|
9
11
|
|
|
10
12
|
export type WecomRuntimeEnv = {
|
|
11
13
|
log?: (message: string) => void;
|
|
@@ -14,7 +16,7 @@ export type WecomRuntimeEnv = {
|
|
|
14
16
|
|
|
15
17
|
type WecomWebhookTarget = {
|
|
16
18
|
account: ResolvedWecomAccount;
|
|
17
|
-
config:
|
|
19
|
+
config: OpenClawConfig;
|
|
18
20
|
runtime: WecomRuntimeEnv;
|
|
19
21
|
core: PluginRuntime;
|
|
20
22
|
path: string;
|
|
@@ -24,12 +26,14 @@ type WecomWebhookTarget = {
|
|
|
24
26
|
type StreamState = {
|
|
25
27
|
streamId: string;
|
|
26
28
|
msgid?: string;
|
|
29
|
+
response_url?: string;
|
|
27
30
|
createdAt: number;
|
|
28
31
|
updatedAt: number;
|
|
29
32
|
started: boolean;
|
|
30
33
|
finished: boolean;
|
|
31
34
|
error?: string;
|
|
32
35
|
content: string;
|
|
36
|
+
image?: { base64: string; md5: string };
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
const webhookTargets = new Map<string, WecomWebhookTarget[]>();
|
|
@@ -151,14 +155,18 @@ function resolveSignatureParam(params: URLSearchParams): string {
|
|
|
151
155
|
);
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
function buildStreamPlaceholderReply(
|
|
158
|
+
function buildStreamPlaceholderReply(params: {
|
|
159
|
+
streamId: string;
|
|
160
|
+
placeholderContent?: string;
|
|
161
|
+
}): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
|
|
162
|
+
const content = params.placeholderContent?.trim() || "1";
|
|
155
163
|
return {
|
|
156
164
|
msgtype: "stream",
|
|
157
165
|
stream: {
|
|
158
|
-
id: streamId,
|
|
166
|
+
id: params.streamId,
|
|
159
167
|
finish: false,
|
|
160
168
|
// Spec: "第一次回复内容为 1" works as a minimal placeholder.
|
|
161
|
-
content
|
|
169
|
+
content,
|
|
162
170
|
},
|
|
163
171
|
};
|
|
164
172
|
}
|
|
@@ -171,6 +179,12 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
|
|
|
171
179
|
id: state.streamId,
|
|
172
180
|
finish: state.finished,
|
|
173
181
|
content,
|
|
182
|
+
...(state.finished && state.image ? {
|
|
183
|
+
msg_item: [{
|
|
184
|
+
msgtype: "image",
|
|
185
|
+
image: { base64: state.image.base64, md5: state.image.md5 }
|
|
186
|
+
}]
|
|
187
|
+
} : {})
|
|
174
188
|
},
|
|
175
189
|
};
|
|
176
190
|
}
|
|
@@ -195,6 +209,26 @@ function parseWecomPlainMessage(raw: string): WecomInboundMessage {
|
|
|
195
209
|
return parsed as WecomInboundMessage;
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<string> {
|
|
213
|
+
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
214
|
+
|
|
215
|
+
if (msgtype === "image") {
|
|
216
|
+
const url = String((msg as any).image?.url ?? "").trim();
|
|
217
|
+
const aesKey = target.account.encodingAESKey;
|
|
218
|
+
if (url && aesKey) {
|
|
219
|
+
try {
|
|
220
|
+
const buf = await decryptWecomMedia(url, aesKey);
|
|
221
|
+
const base64 = buf.toString("base64");
|
|
222
|
+
return `[image] data:image/jpeg;base64,${base64}`;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
|
|
225
|
+
return `[image] (decryption failed)`;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return buildInboundBody(msg);
|
|
230
|
+
}
|
|
231
|
+
|
|
198
232
|
async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
|
|
199
233
|
if (maxWaitMs <= 0) return;
|
|
200
234
|
const startedAt = Date.now();
|
|
@@ -202,7 +236,8 @@ async function waitForStreamContent(streamId: string, maxWaitMs: number): Promis
|
|
|
202
236
|
const tick = () => {
|
|
203
237
|
const state = streams.get(streamId);
|
|
204
238
|
if (!state) return resolve();
|
|
205
|
-
if (state.error || state.finished
|
|
239
|
+
if (state.error || state.finished) return resolve();
|
|
240
|
+
if (state.content.trim()) return resolve();
|
|
206
241
|
if (Date.now() - startedAt >= maxWaitMs) return resolve();
|
|
207
242
|
setTimeout(tick, 25);
|
|
208
243
|
};
|
|
@@ -224,7 +259,7 @@ async function startAgentForStream(params: {
|
|
|
224
259
|
const userid = msg.from?.userid?.trim() || "unknown";
|
|
225
260
|
const chatType = msg.chattype === "group" ? "group" : "direct";
|
|
226
261
|
const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
|
|
227
|
-
const rawBody =
|
|
262
|
+
const rawBody = await processInboundMessage(target, msg);
|
|
228
263
|
|
|
229
264
|
const route = core.channel.routing.resolveAgentRoute({
|
|
230
265
|
cfg: config,
|
|
@@ -291,9 +326,41 @@ async function startAgentForStream(params: {
|
|
|
291
326
|
cfg: config,
|
|
292
327
|
dispatcherOptions: {
|
|
293
328
|
deliver: async (payload) => {
|
|
294
|
-
|
|
329
|
+
let text = payload.text ?? "";
|
|
330
|
+
|
|
331
|
+
// Protect <think> tags from table conversion
|
|
332
|
+
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
|
333
|
+
const thinks: string[] = [];
|
|
334
|
+
text = text.replace(thinkRegex, (match: string) => {
|
|
335
|
+
thinks.push(match);
|
|
336
|
+
return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
text = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
340
|
+
|
|
341
|
+
// Restore <think> tags
|
|
342
|
+
thinks.forEach((think, i) => {
|
|
343
|
+
text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
|
|
344
|
+
});
|
|
345
|
+
|
|
295
346
|
const current = streams.get(streamId);
|
|
296
347
|
if (!current) return;
|
|
348
|
+
|
|
349
|
+
// Detect Markdown image: 
|
|
350
|
+
const imgMatch = text.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
|
|
351
|
+
if (imgMatch && imgMatch[1]) {
|
|
352
|
+
try {
|
|
353
|
+
const imgUrl = imgMatch[1];
|
|
354
|
+
const resp = await axios.get(imgUrl, { responseType: "arraybuffer", timeout: 10000 });
|
|
355
|
+
const buf = Buffer.from(resp.data);
|
|
356
|
+
const base64 = buf.toString("base64");
|
|
357
|
+
const md5 = crypto.createHash("md5").update(buf).digest("hex");
|
|
358
|
+
current.image = { base64, md5 };
|
|
359
|
+
} catch (err) {
|
|
360
|
+
target.runtime.error?.(`Failed to download outbound image: ${String(err)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
297
364
|
const nextText = current.content
|
|
298
365
|
? `${current.content}\n\n${text}`.trim()
|
|
299
366
|
: text.trim();
|
|
@@ -314,20 +381,45 @@ async function startAgentForStream(params: {
|
|
|
314
381
|
}
|
|
315
382
|
}
|
|
316
383
|
|
|
384
|
+
function formatQuote(quote: WecomInboundQuote): string {
|
|
385
|
+
const type = quote.msgtype ?? "";
|
|
386
|
+
if (type === "text") {
|
|
387
|
+
return quote.text?.content || "";
|
|
388
|
+
}
|
|
389
|
+
if (type === "image") {
|
|
390
|
+
return `[引用: 图片] ${quote.image?.url || ""}`;
|
|
391
|
+
}
|
|
392
|
+
if (type === "mixed" && quote.mixed?.msg_item) {
|
|
393
|
+
const items = quote.mixed.msg_item.map((item) => {
|
|
394
|
+
if (item.msgtype === "text") return item.text?.content;
|
|
395
|
+
if (item.msgtype === "image") return `[图片] ${item.image?.url || ""}`;
|
|
396
|
+
return "";
|
|
397
|
+
}).filter(Boolean).join(" ");
|
|
398
|
+
return `[引用: 图文] ${items}`;
|
|
399
|
+
}
|
|
400
|
+
if (type === "voice") {
|
|
401
|
+
return `[引用: 语音] ${quote.voice?.content || ""}`;
|
|
402
|
+
}
|
|
403
|
+
if (type === "file") {
|
|
404
|
+
return `[引用: 文件] ${quote.file?.url || ""}`;
|
|
405
|
+
}
|
|
406
|
+
return "";
|
|
407
|
+
}
|
|
408
|
+
|
|
317
409
|
function buildInboundBody(msg: WecomInboundMessage): string {
|
|
410
|
+
let body = "";
|
|
318
411
|
const msgtype = String(msg.msgtype ?? "").toLowerCase();
|
|
412
|
+
|
|
319
413
|
if (msgtype === "text") {
|
|
320
414
|
const content = (msg as any).text?.content;
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
if (msgtype === "voice") {
|
|
415
|
+
body = typeof content === "string" ? content : "";
|
|
416
|
+
} else if (msgtype === "voice") {
|
|
324
417
|
const content = (msg as any).voice?.content;
|
|
325
|
-
|
|
326
|
-
}
|
|
327
|
-
if (msgtype === "mixed") {
|
|
418
|
+
body = typeof content === "string" ? content : "[voice]";
|
|
419
|
+
} else if (msgtype === "mixed") {
|
|
328
420
|
const items = (msg as any).mixed?.msg_item;
|
|
329
421
|
if (Array.isArray(items)) {
|
|
330
|
-
|
|
422
|
+
body = items
|
|
331
423
|
.map((item: any) => {
|
|
332
424
|
const t = String(item?.msgtype ?? "").toLowerCase();
|
|
333
425
|
if (t === "text") return String(item?.text?.content ?? "");
|
|
@@ -336,26 +428,34 @@ function buildInboundBody(msg: WecomInboundMessage): string {
|
|
|
336
428
|
})
|
|
337
429
|
.filter((part: string) => Boolean(part && part.trim()))
|
|
338
430
|
.join("\n");
|
|
431
|
+
} else {
|
|
432
|
+
body = "[mixed]";
|
|
339
433
|
}
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
if (msgtype === "image") {
|
|
434
|
+
} else if (msgtype === "image") {
|
|
343
435
|
const url = String((msg as any).image?.url ?? "").trim();
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
if (msgtype === "file") {
|
|
436
|
+
body = url ? `[image] ${url}` : "[image]";
|
|
437
|
+
} else if (msgtype === "file") {
|
|
347
438
|
const url = String((msg as any).file?.url ?? "").trim();
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
if (msgtype === "event") {
|
|
439
|
+
body = url ? `[file] ${url}` : "[file]";
|
|
440
|
+
} else if (msgtype === "event") {
|
|
351
441
|
const eventtype = String((msg as any).event?.eventtype ?? "").trim();
|
|
352
|
-
|
|
353
|
-
}
|
|
354
|
-
if (msgtype === "stream") {
|
|
442
|
+
body = eventtype ? `[event] ${eventtype}` : "[event]";
|
|
443
|
+
} else if (msgtype === "stream") {
|
|
355
444
|
const id = String((msg as any).stream?.id ?? "").trim();
|
|
356
|
-
|
|
445
|
+
body = id ? `[stream_refresh] ${id}` : "[stream_refresh]";
|
|
446
|
+
} else {
|
|
447
|
+
body = msgtype ? `[${msgtype}]` : "";
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Append quote if available
|
|
451
|
+
const quote = (msg as any).quote;
|
|
452
|
+
if (quote) {
|
|
453
|
+
const quoteText = formatQuote(quote).trim();
|
|
454
|
+
if (quoteText) {
|
|
455
|
+
body += `\n\n> ${quoteText}`;
|
|
456
|
+
}
|
|
357
457
|
}
|
|
358
|
-
return
|
|
458
|
+
return body;
|
|
359
459
|
}
|
|
360
460
|
|
|
361
461
|
export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
|
|
@@ -543,7 +643,10 @@ export async function handleWecomWebhookRequest(
|
|
|
543
643
|
// Dedupe: if we already created a stream for this msgid, return placeholder again.
|
|
544
644
|
if (msgid && msgidToStreamId.has(msgid)) {
|
|
545
645
|
const streamId = msgidToStreamId.get(msgid) ?? "";
|
|
546
|
-
const reply = buildStreamPlaceholderReply(
|
|
646
|
+
const reply = buildStreamPlaceholderReply({
|
|
647
|
+
streamId,
|
|
648
|
+
placeholderContent: target.account.config.streamPlaceholderContent,
|
|
649
|
+
});
|
|
547
650
|
jsonOk(res, buildEncryptedJsonReply({
|
|
548
651
|
account: target.account,
|
|
549
652
|
plaintextJson: reply,
|
|
@@ -586,6 +689,7 @@ export async function handleWecomWebhookRequest(
|
|
|
586
689
|
streams.set(streamId, {
|
|
587
690
|
streamId,
|
|
588
691
|
msgid,
|
|
692
|
+
response_url: msg.response_url,
|
|
589
693
|
createdAt: Date.now(),
|
|
590
694
|
updatedAt: Date.now(),
|
|
591
695
|
started: false,
|
|
@@ -633,7 +737,10 @@ export async function handleWecomWebhookRequest(
|
|
|
633
737
|
const state = streams.get(streamId);
|
|
634
738
|
const initialReply = state && (state.content.trim() || state.error)
|
|
635
739
|
? buildStreamReplyFromState(state)
|
|
636
|
-
: buildStreamPlaceholderReply(
|
|
740
|
+
: buildStreamPlaceholderReply({
|
|
741
|
+
streamId,
|
|
742
|
+
placeholderContent: target.account.config.streamPlaceholderContent,
|
|
743
|
+
});
|
|
637
744
|
jsonOk(res, buildEncryptedJsonReply({
|
|
638
745
|
account: target.account,
|
|
639
746
|
plaintextJson: initialReply,
|
|
@@ -644,3 +751,17 @@ export async function handleWecomWebhookRequest(
|
|
|
644
751
|
logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`);
|
|
645
752
|
return true;
|
|
646
753
|
}
|
|
754
|
+
|
|
755
|
+
export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
|
|
756
|
+
const state = streams.get(streamId);
|
|
757
|
+
if (!state || !state.response_url) {
|
|
758
|
+
throw new Error(`Active message failed: No response_url for stream ${streamId}`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// WeCom Webhook Reply Format
|
|
762
|
+
// Note: Only works if response_url is valid and within time limit.
|
|
763
|
+
await axios.post(state.response_url, {
|
|
764
|
+
msgtype: "text",
|
|
765
|
+
text: { content },
|
|
766
|
+
});
|
|
767
|
+
}
|