@sunnoy/wecom 1.1.2 → 1.3.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 +465 -144
- package/crypto.js +110 -83
- package/dynamic-agent.js +70 -87
- package/image-processor.js +86 -93
- package/index.js +16 -1068
- package/logger.js +48 -49
- package/package.json +5 -6
- package/stream-manager.js +316 -265
- package/utils.js +76 -238
- package/webhook.js +434 -287
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +433 -0
- package/wecom/allow-from.js +58 -0
- package/wecom/channel-plugin.js +638 -0
- package/wecom/commands.js +85 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +519 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +175 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +82 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -289
- package/client.js +0 -127
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom Agent API Client
|
|
3
|
+
* Manages AccessToken caching and API calls for self-built applications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { logger } from "../logger.js";
|
|
8
|
+
import {
|
|
9
|
+
AGENT_API_ENDPOINTS,
|
|
10
|
+
AGENT_API_REQUEST_TIMEOUT_MS,
|
|
11
|
+
TOKEN_REFRESH_BUFFER_MS,
|
|
12
|
+
} from "./constants.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Token cache: Map<corpId:agentId, { token, expiresAt, refreshPromise }>
|
|
16
|
+
*/
|
|
17
|
+
const tokenCaches = new Map();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get a valid AccessToken, with caching and concurrent-refresh protection.
|
|
21
|
+
* @param {object} agent - { corpId, corpSecret, agentId }
|
|
22
|
+
* @returns {Promise<string>}
|
|
23
|
+
*/
|
|
24
|
+
export async function getAccessToken(agent) {
|
|
25
|
+
const cacheKey = `${agent.corpId}:${agent.agentId}`;
|
|
26
|
+
let cache = tokenCaches.get(cacheKey);
|
|
27
|
+
|
|
28
|
+
if (!cache) {
|
|
29
|
+
cache = { token: "", expiresAt: 0, refreshPromise: null };
|
|
30
|
+
tokenCaches.set(cacheKey, cache);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (cache.token && cache.expiresAt > now + TOKEN_REFRESH_BUFFER_MS) {
|
|
35
|
+
return cache.token;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Reuse in-flight refresh to prevent concurrent token requests.
|
|
39
|
+
if (cache.refreshPromise) {
|
|
40
|
+
return cache.refreshPromise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
cache.refreshPromise = (async () => {
|
|
44
|
+
try {
|
|
45
|
+
const url = `${AGENT_API_ENDPOINTS.GET_TOKEN}?corpid=${encodeURIComponent(agent.corpId)}&corpsecret=${encodeURIComponent(agent.corpSecret)}`;
|
|
46
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
|
|
47
|
+
const json = await res.json();
|
|
48
|
+
|
|
49
|
+
if (!json?.access_token) {
|
|
50
|
+
throw new Error(`gettoken failed: ${json?.errcode} ${json?.errmsg}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cache.token = json.access_token;
|
|
54
|
+
cache.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
|
|
55
|
+
return cache.token;
|
|
56
|
+
} finally {
|
|
57
|
+
cache.refreshPromise = null;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
return cache.refreshPromise;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Send a text message via Agent API.
|
|
66
|
+
*
|
|
67
|
+
* Uses `message/send` for user/party/tag targets, `appchat/send` for group chats.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} params
|
|
70
|
+
* @param {object} params.agent - { corpId, corpSecret, agentId }
|
|
71
|
+
* @param {string} [params.toUser]
|
|
72
|
+
* @param {string} [params.toParty]
|
|
73
|
+
* @param {string} [params.toTag]
|
|
74
|
+
* @param {string} [params.chatId]
|
|
75
|
+
* @param {string} params.text
|
|
76
|
+
*/
|
|
77
|
+
export async function agentSendText(params) {
|
|
78
|
+
const { agent, toUser, toParty, toTag, chatId, text } = params;
|
|
79
|
+
const token = await getAccessToken(agent);
|
|
80
|
+
|
|
81
|
+
const useChat = Boolean(chatId);
|
|
82
|
+
const url = useChat
|
|
83
|
+
? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
84
|
+
: `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
85
|
+
|
|
86
|
+
const body = useChat
|
|
87
|
+
? { chatid: chatId, msgtype: "text", text: { content: text } }
|
|
88
|
+
: {
|
|
89
|
+
touser: toUser,
|
|
90
|
+
toparty: toParty,
|
|
91
|
+
totag: toTag,
|
|
92
|
+
msgtype: "text",
|
|
93
|
+
agentid: agent.agentId,
|
|
94
|
+
text: { content: text },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const res = await fetch(url, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "Content-Type": "application/json" },
|
|
100
|
+
body: JSON.stringify(body),
|
|
101
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
102
|
+
});
|
|
103
|
+
const json = await res.json();
|
|
104
|
+
|
|
105
|
+
if (json?.errcode !== 0) {
|
|
106
|
+
throw new Error(`agent send text failed: ${json?.errcode} ${json?.errmsg}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
110
|
+
const details = [
|
|
111
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
112
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
113
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : "",
|
|
114
|
+
]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(", ");
|
|
117
|
+
throw new Error(`agent send text partial failure: ${details}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Upload a temporary media file to WeCom.
|
|
123
|
+
*
|
|
124
|
+
* @param {object} params
|
|
125
|
+
* @param {object} params.agent - { corpId, corpSecret, agentId }
|
|
126
|
+
* @param {"image"|"voice"|"video"|"file"} params.type
|
|
127
|
+
* @param {Buffer} params.buffer
|
|
128
|
+
* @param {string} params.filename
|
|
129
|
+
* @returns {Promise<string>} media_id
|
|
130
|
+
*/
|
|
131
|
+
export async function agentUploadMedia(params) {
|
|
132
|
+
const { agent, type, buffer, filename } = params;
|
|
133
|
+
const token = await getAccessToken(agent);
|
|
134
|
+
const url = `${AGENT_API_ENDPOINTS.UPLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&type=${encodeURIComponent(type)}`;
|
|
135
|
+
|
|
136
|
+
// Manually construct multipart/form-data (no extra dependencies).
|
|
137
|
+
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString("hex")}`;
|
|
138
|
+
|
|
139
|
+
const contentTypeMap = {
|
|
140
|
+
jpg: "image/jpeg",
|
|
141
|
+
jpeg: "image/jpeg",
|
|
142
|
+
png: "image/png",
|
|
143
|
+
gif: "image/gif",
|
|
144
|
+
bmp: "image/bmp",
|
|
145
|
+
amr: "voice/amr",
|
|
146
|
+
mp4: "video/mp4",
|
|
147
|
+
};
|
|
148
|
+
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
|
149
|
+
const fileContentType = contentTypeMap[ext] || "application/octet-stream";
|
|
150
|
+
|
|
151
|
+
const header = Buffer.from(
|
|
152
|
+
`--${boundary}\r\n` +
|
|
153
|
+
`Content-Disposition: form-data; name="media"; filename="${filename}"; filelength=${buffer.length}\r\n` +
|
|
154
|
+
`Content-Type: ${fileContentType}\r\n\r\n`,
|
|
155
|
+
);
|
|
156
|
+
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
157
|
+
const body = Buffer.concat([header, buffer, footer]);
|
|
158
|
+
|
|
159
|
+
const res = await fetch(url, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: {
|
|
162
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
|
163
|
+
"Content-Length": String(body.length),
|
|
164
|
+
},
|
|
165
|
+
body,
|
|
166
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
167
|
+
});
|
|
168
|
+
const json = await res.json();
|
|
169
|
+
|
|
170
|
+
if (!json?.media_id) {
|
|
171
|
+
throw new Error(`agent upload media failed: ${json?.errcode} ${json?.errmsg}`);
|
|
172
|
+
}
|
|
173
|
+
return json.media_id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send a media message (image/voice/video/file) via Agent API.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} params
|
|
180
|
+
* @param {object} params.agent
|
|
181
|
+
* @param {string} [params.toUser]
|
|
182
|
+
* @param {string} [params.toParty]
|
|
183
|
+
* @param {string} [params.toTag]
|
|
184
|
+
* @param {string} [params.chatId]
|
|
185
|
+
* @param {string} params.mediaId
|
|
186
|
+
* @param {"image"|"voice"|"video"|"file"} params.mediaType
|
|
187
|
+
*/
|
|
188
|
+
export async function agentSendMedia(params) {
|
|
189
|
+
const { agent, toUser, toParty, toTag, chatId, mediaId, mediaType } = params;
|
|
190
|
+
const token = await getAccessToken(agent);
|
|
191
|
+
|
|
192
|
+
const useChat = Boolean(chatId);
|
|
193
|
+
const url = useChat
|
|
194
|
+
? `${AGENT_API_ENDPOINTS.SEND_APPCHAT}?access_token=${encodeURIComponent(token)}`
|
|
195
|
+
: `${AGENT_API_ENDPOINTS.SEND_MESSAGE}?access_token=${encodeURIComponent(token)}`;
|
|
196
|
+
|
|
197
|
+
const mediaPayload = { media_id: mediaId };
|
|
198
|
+
const body = useChat
|
|
199
|
+
? { chatid: chatId, msgtype: mediaType, [mediaType]: mediaPayload }
|
|
200
|
+
: {
|
|
201
|
+
touser: toUser,
|
|
202
|
+
toparty: toParty,
|
|
203
|
+
totag: toTag,
|
|
204
|
+
msgtype: mediaType,
|
|
205
|
+
agentid: agent.agentId,
|
|
206
|
+
[mediaType]: mediaPayload,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const res = await fetch(url, {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "Content-Type": "application/json" },
|
|
212
|
+
body: JSON.stringify(body),
|
|
213
|
+
signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS),
|
|
214
|
+
});
|
|
215
|
+
const json = await res.json();
|
|
216
|
+
|
|
217
|
+
if (json?.errcode !== 0) {
|
|
218
|
+
throw new Error(`agent send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Download a temporary media file from WeCom by media_id.
|
|
224
|
+
*
|
|
225
|
+
* @param {object} params
|
|
226
|
+
* @param {object} params.agent
|
|
227
|
+
* @param {string} params.mediaId
|
|
228
|
+
* @returns {Promise<{ buffer: Buffer, contentType: string }>}
|
|
229
|
+
*/
|
|
230
|
+
export async function agentDownloadMedia(params) {
|
|
231
|
+
const { agent, mediaId } = params;
|
|
232
|
+
const token = await getAccessToken(agent);
|
|
233
|
+
const url = `${AGENT_API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
234
|
+
|
|
235
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(AGENT_API_REQUEST_TIMEOUT_MS) });
|
|
236
|
+
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
throw new Error(`agent download media failed: ${res.status}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
242
|
+
|
|
243
|
+
// WeCom may return an error JSON body instead of binary media.
|
|
244
|
+
if (contentType.includes("application/json")) {
|
|
245
|
+
const json = await res.json();
|
|
246
|
+
throw new Error(`agent download media failed: ${json?.errcode} ${json?.errmsg}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const arrayBuf = await res.arrayBuffer();
|
|
250
|
+
return { buffer: Buffer.from(arrayBuf), contentType };
|
|
251
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeCom Agent Inbound Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles XML-format callbacks from WeCom self-built applications (自建应用).
|
|
5
|
+
* - GET /webhooks/app → URL verification (echostr decrypt)
|
|
6
|
+
* - POST /webhooks/app → Message callback (decrypt → parse → dispatch to LLM)
|
|
7
|
+
*
|
|
8
|
+
* Replies are sent asynchronously via Agent API (not passive stream response).
|
|
9
|
+
* Uses the same sessionKey format as Bot mode for unified session management.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { logger } from "../logger.js";
|
|
13
|
+
import { WecomCrypto } from "../crypto.js";
|
|
14
|
+
import {
|
|
15
|
+
generateAgentId,
|
|
16
|
+
getDynamicAgentConfig,
|
|
17
|
+
shouldUseDynamicAgent,
|
|
18
|
+
} from "../dynamic-agent.js";
|
|
19
|
+
import { agentSendText, agentDownloadMedia } from "./agent-api.js";
|
|
20
|
+
import { resolveWecomCommandAuthorized } from "./allow-from.js";
|
|
21
|
+
import { checkCommandAllowlist, getCommandConfig, isWecomAdmin } from "./commands.js";
|
|
22
|
+
import { MAX_REQUEST_BODY_SIZE } from "./constants.js";
|
|
23
|
+
import { getRuntime, resolveAgentConfig } from "./state.js";
|
|
24
|
+
import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
25
|
+
import {
|
|
26
|
+
extractEncryptFromXml,
|
|
27
|
+
parseXml,
|
|
28
|
+
extractMsgType,
|
|
29
|
+
extractFromUser,
|
|
30
|
+
extractChatId,
|
|
31
|
+
extractContent,
|
|
32
|
+
extractMediaId,
|
|
33
|
+
extractMsgId,
|
|
34
|
+
extractFileName,
|
|
35
|
+
} from "./xml-parser.js";
|
|
36
|
+
|
|
37
|
+
// ── Message deduplication ──────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
|
|
40
|
+
const recentAgentMsgIds = new Map();
|
|
41
|
+
|
|
42
|
+
function rememberAgentMsgId(msgId) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const existing = recentAgentMsgIds.get(msgId);
|
|
45
|
+
if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
|
|
46
|
+
recentAgentMsgIds.set(msgId, now);
|
|
47
|
+
// Prune expired entries on write
|
|
48
|
+
for (const [k, ts] of recentAgentMsgIds) {
|
|
49
|
+
if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── HTTP body reader ───────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
async function readRawBody(req, maxSize = MAX_REQUEST_BODY_SIZE) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
|
|
61
|
+
req.on("data", (chunk) => {
|
|
62
|
+
size += chunk.length;
|
|
63
|
+
if (size > maxSize) {
|
|
64
|
+
reject(new Error("Request body too large"));
|
|
65
|
+
req.destroy();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
req.on("end", () => {
|
|
72
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
req.on("error", reject);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── URL Verification (GET) ─────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Handle WeCom URL verification during callback configuration.
|
|
83
|
+
* Verify signature → decrypt echostr → return plaintext.
|
|
84
|
+
*/
|
|
85
|
+
function handleUrlVerification(req, res, crypto) {
|
|
86
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
87
|
+
const timestamp = url.searchParams.get("timestamp") || "";
|
|
88
|
+
const nonce = url.searchParams.get("nonce") || "";
|
|
89
|
+
const echostr = url.searchParams.get("echostr") || "";
|
|
90
|
+
const msgSignature = url.searchParams.get("msg_signature") || "";
|
|
91
|
+
|
|
92
|
+
// Verify signature
|
|
93
|
+
const expectedSig = crypto.getSignature(timestamp, nonce, echostr);
|
|
94
|
+
if (expectedSig !== msgSignature) {
|
|
95
|
+
logger.warn("[agent-inbound] URL verification: signature mismatch");
|
|
96
|
+
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
|
97
|
+
res.end("unauthorized - 签名验证失败,请检查 Token 配置");
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Decrypt echostr
|
|
102
|
+
try {
|
|
103
|
+
const { message: plainEchostr } = crypto.decrypt(echostr);
|
|
104
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
105
|
+
res.end(plainEchostr);
|
|
106
|
+
logger.info("[agent-inbound] URL verification successful");
|
|
107
|
+
return true;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.error("[agent-inbound] URL verification: decrypt failed", { error: err.message });
|
|
110
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
111
|
+
res.end("decrypt failed - 解密失败,请检查 EncodingAESKey 配置");
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Message Callback (POST) ────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Handle WeCom message callback.
|
|
120
|
+
* Read XML → extract Encrypt → verify → decrypt → parse → dedup → respond 200 → async process.
|
|
121
|
+
*/
|
|
122
|
+
async function handleMessageCallback(req, res, crypto, agentConfig, config) {
|
|
123
|
+
try {
|
|
124
|
+
const rawXml = await readRawBody(req);
|
|
125
|
+
logger.debug("[agent-inbound] received callback", { bodyBytes: Buffer.byteLength(rawXml, "utf8") });
|
|
126
|
+
|
|
127
|
+
const encrypted = extractEncryptFromXml(rawXml);
|
|
128
|
+
|
|
129
|
+
const url = new URL(req.url || "", "http://localhost");
|
|
130
|
+
const timestamp = url.searchParams.get("timestamp") || "";
|
|
131
|
+
const nonce = url.searchParams.get("nonce") || "";
|
|
132
|
+
const msgSignature = url.searchParams.get("msg_signature") || "";
|
|
133
|
+
|
|
134
|
+
// Verify signature
|
|
135
|
+
const expectedSig = crypto.getSignature(timestamp, nonce, encrypted);
|
|
136
|
+
if (expectedSig !== msgSignature) {
|
|
137
|
+
logger.warn("[agent-inbound] message callback: signature mismatch");
|
|
138
|
+
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
|
139
|
+
res.end("unauthorized - 签名验证失败");
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Decrypt
|
|
144
|
+
const { message: decryptedXml } = crypto.decrypt(encrypted);
|
|
145
|
+
logger.debug("[agent-inbound] decrypted", { bytes: Buffer.byteLength(decryptedXml, "utf8") });
|
|
146
|
+
|
|
147
|
+
// Parse XML
|
|
148
|
+
const msg = parseXml(decryptedXml);
|
|
149
|
+
const msgType = extractMsgType(msg);
|
|
150
|
+
const fromUser = extractFromUser(msg);
|
|
151
|
+
const chatId = extractChatId(msg);
|
|
152
|
+
const msgId = extractMsgId(msg);
|
|
153
|
+
const content = extractContent(msg);
|
|
154
|
+
|
|
155
|
+
// Deduplication
|
|
156
|
+
if (msgId) {
|
|
157
|
+
if (!rememberAgentMsgId(msgId)) {
|
|
158
|
+
logger.debug("[agent-inbound] duplicate msgId, skipping", { msgId, fromUser });
|
|
159
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
160
|
+
res.end("success");
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
logger.info("[agent-inbound] message received", {
|
|
166
|
+
msgType,
|
|
167
|
+
fromUser,
|
|
168
|
+
chatId: chatId || "N/A",
|
|
169
|
+
msgId: msgId || "N/A",
|
|
170
|
+
contentPreview: content.substring(0, 100),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Respond immediately (Agent mode uses API for replies, not passive response)
|
|
174
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
175
|
+
res.end("success");
|
|
176
|
+
|
|
177
|
+
// Async message processing
|
|
178
|
+
processAgentMessage({
|
|
179
|
+
agentConfig,
|
|
180
|
+
config,
|
|
181
|
+
fromUser,
|
|
182
|
+
chatId,
|
|
183
|
+
msgType,
|
|
184
|
+
content,
|
|
185
|
+
msg,
|
|
186
|
+
}).catch((err) => {
|
|
187
|
+
logger.error("[agent-inbound] async processing failed", { error: err.message });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return true;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
logger.error("[agent-inbound] callback failed", { error: err.message });
|
|
193
|
+
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
194
|
+
res.end("error - 回调处理失败");
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Async Message Processing ───────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Process a decrypted Agent message and dispatch to the LLM.
|
|
203
|
+
* Uses the same dynamic agent routing and sessionKey format as Bot mode
|
|
204
|
+
* to ensure unified session management.
|
|
205
|
+
*/
|
|
206
|
+
async function processAgentMessage({
|
|
207
|
+
agentConfig,
|
|
208
|
+
config,
|
|
209
|
+
fromUser,
|
|
210
|
+
chatId,
|
|
211
|
+
msgType,
|
|
212
|
+
content,
|
|
213
|
+
msg,
|
|
214
|
+
}) {
|
|
215
|
+
const runtime = getRuntime();
|
|
216
|
+
const core = runtime.channel;
|
|
217
|
+
|
|
218
|
+
const isGroup = Boolean(chatId);
|
|
219
|
+
const peerId = isGroup ? chatId : fromUser;
|
|
220
|
+
const peerKind = isGroup ? "group" : "dm";
|
|
221
|
+
|
|
222
|
+
let finalContent = content;
|
|
223
|
+
const mediaPaths = [];
|
|
224
|
+
const mediaTypes = [];
|
|
225
|
+
|
|
226
|
+
// ── Media processing ──────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
if (["image", "voice", "video", "file"].includes(msgType)) {
|
|
229
|
+
const mediaId = extractMediaId(msg);
|
|
230
|
+
if (mediaId) {
|
|
231
|
+
try {
|
|
232
|
+
logger.debug("[agent-inbound] downloading media", { mediaId, msgType });
|
|
233
|
+
const { buffer, contentType } = await agentDownloadMedia({
|
|
234
|
+
agent: agentConfig,
|
|
235
|
+
mediaId,
|
|
236
|
+
});
|
|
237
|
+
const originalFileName = extractFileName(msg) || `${mediaId}.bin`;
|
|
238
|
+
|
|
239
|
+
// Save media via core SDK
|
|
240
|
+
const saved = await core.media.saveMediaBuffer(
|
|
241
|
+
buffer,
|
|
242
|
+
contentType,
|
|
243
|
+
"inbound",
|
|
244
|
+
25 * 1024 * 1024,
|
|
245
|
+
originalFileName,
|
|
246
|
+
);
|
|
247
|
+
logger.info("[agent-inbound] media saved", { path: saved.path, size: buffer.length });
|
|
248
|
+
|
|
249
|
+
mediaPaths.push(saved.path);
|
|
250
|
+
mediaTypes.push(contentType);
|
|
251
|
+
finalContent = `${content} (已下载 ${buffer.length} 字节)`;
|
|
252
|
+
|
|
253
|
+
// For image-only messages, set a placeholder body.
|
|
254
|
+
if (!content.trim() || content.startsWith("[图片]")) {
|
|
255
|
+
finalContent = "[用户发送了一张图片]";
|
|
256
|
+
}
|
|
257
|
+
} catch (err) {
|
|
258
|
+
logger.error("[agent-inbound] media download failed", { error: err.message });
|
|
259
|
+
finalContent = `${content}\n\n媒体处理失败:${err.message}`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Command allowlist ─────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
const senderIsAdmin = isWecomAdmin(fromUser, config);
|
|
267
|
+
const commandCheck = checkCommandAllowlist(finalContent, config);
|
|
268
|
+
|
|
269
|
+
if (commandCheck.isCommand && !commandCheck.allowed && !senderIsAdmin) {
|
|
270
|
+
const cmdConfig = getCommandConfig(config);
|
|
271
|
+
logger.warn("[agent-inbound] blocked command", { command: commandCheck.command, from: fromUser });
|
|
272
|
+
try {
|
|
273
|
+
await agentSendText({ agent: agentConfig, toUser: fromUser, text: cmdConfig.blockMessage });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
logger.error("[agent-inbound] failed to send block message", { error: err.message });
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Dynamic agent routing ─────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
const dynamicConfig = getDynamicAgentConfig(config);
|
|
283
|
+
const targetAgentId =
|
|
284
|
+
dynamicConfig.enabled && shouldUseDynamicAgent({ chatType: peerKind, config })
|
|
285
|
+
? generateAgentId(peerKind, peerId)
|
|
286
|
+
: null;
|
|
287
|
+
|
|
288
|
+
if (targetAgentId) {
|
|
289
|
+
await ensureDynamicAgentListed(targetAgentId);
|
|
290
|
+
logger.debug("[agent-inbound] dynamic agent", { agentId: targetAgentId, peerId });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Route resolution ──────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
const route = core.routing.resolveAgentRoute({
|
|
296
|
+
cfg: config,
|
|
297
|
+
channel: "wecom",
|
|
298
|
+
accountId: "default",
|
|
299
|
+
peer: { kind: peerKind, id: peerId },
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (targetAgentId) {
|
|
303
|
+
route.agentId = targetAgentId;
|
|
304
|
+
route.sessionKey = `agent:${targetAgentId}:${peerKind}:${peerId}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Build inbound context ─────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const fromLabel = isGroup ? `[${fromUser}]` : fromUser;
|
|
310
|
+
const storePath = core.session.resolveStorePath(config.session?.store, {
|
|
311
|
+
agentId: route.agentId,
|
|
312
|
+
});
|
|
313
|
+
const envelopeOptions = core.reply.resolveEnvelopeFormatOptions(config);
|
|
314
|
+
const previousTimestamp = core.session.readSessionUpdatedAt({
|
|
315
|
+
storePath,
|
|
316
|
+
sessionKey: route.sessionKey,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const body = core.reply.formatAgentEnvelope({
|
|
320
|
+
channel: isGroup ? "Enterprise WeChat Group" : "Enterprise WeChat",
|
|
321
|
+
from: fromLabel,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
previousTimestamp,
|
|
324
|
+
envelope: envelopeOptions,
|
|
325
|
+
body: finalContent,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const commandAuthorized = resolveWecomCommandAuthorized({
|
|
329
|
+
cfg: config,
|
|
330
|
+
accountId: "default",
|
|
331
|
+
senderId: fromUser,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const conversationId = isGroup ? `wecom:group:${chatId}` : `wecom:${fromUser}`;
|
|
335
|
+
|
|
336
|
+
const ctxPayload = core.reply.finalizeInboundContext({
|
|
337
|
+
Body: body,
|
|
338
|
+
RawBody: finalContent,
|
|
339
|
+
CommandBody: finalContent,
|
|
340
|
+
From: isGroup ? `wecom:group:${peerId}` : `wecom:${fromUser}`,
|
|
341
|
+
To: conversationId,
|
|
342
|
+
SessionKey: route.sessionKey,
|
|
343
|
+
AccountId: route.accountId,
|
|
344
|
+
ChatType: isGroup ? "group" : "direct",
|
|
345
|
+
ConversationLabel: isGroup ? `Group ${chatId}` : fromUser,
|
|
346
|
+
SenderName: fromUser,
|
|
347
|
+
SenderId: fromUser,
|
|
348
|
+
Provider: "wecom",
|
|
349
|
+
Surface: "wecom",
|
|
350
|
+
OriginatingChannel: "wecom",
|
|
351
|
+
OriginatingTo: `wecom-agent:${fromUser}`,
|
|
352
|
+
CommandAuthorized: commandAuthorized,
|
|
353
|
+
...(mediaPaths.length > 0 && { MediaPaths: mediaPaths }),
|
|
354
|
+
...(mediaTypes.length > 0 && { MediaTypes: mediaTypes }),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ── Record session ────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
void core.session
|
|
360
|
+
.recordSessionMetaFromInbound({
|
|
361
|
+
storePath,
|
|
362
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
363
|
+
ctx: ctxPayload,
|
|
364
|
+
})
|
|
365
|
+
.catch((err) => {
|
|
366
|
+
logger.error("[agent-inbound] session record failed", { error: err.message });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ── Dispatch to LLM ──────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
await core.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
372
|
+
ctx: ctxPayload,
|
|
373
|
+
cfg: config,
|
|
374
|
+
replyOptions: {
|
|
375
|
+
disableBlockStreaming: true,
|
|
376
|
+
},
|
|
377
|
+
dispatcherOptions: {
|
|
378
|
+
deliver: async (payload, info) => {
|
|
379
|
+
const text = payload.text ?? "";
|
|
380
|
+
if (!text.trim()) return;
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
// Agent mode: reply via API to the sender (DM, even for group messages)
|
|
384
|
+
await agentSendText({ agent: agentConfig, toUser: fromUser, text });
|
|
385
|
+
logger.info("[agent-inbound] reply delivered", {
|
|
386
|
+
kind: info.kind,
|
|
387
|
+
to: fromUser,
|
|
388
|
+
contentPreview: text.substring(0, 50),
|
|
389
|
+
});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
logger.error("[agent-inbound] reply delivery failed", { error: err.message });
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
onError: (err, info) => {
|
|
395
|
+
logger.error("[agent-inbound] dispatch error", { kind: info.kind, error: err.message });
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Public Entry Point ─────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Handle Agent inbound webhook request.
|
|
405
|
+
* Routes GET → URL verification, POST → message callback.
|
|
406
|
+
*
|
|
407
|
+
* @param {object} params
|
|
408
|
+
* @param {import("http").IncomingMessage} params.req
|
|
409
|
+
* @param {import("http").ServerResponse} params.res
|
|
410
|
+
* @param {object} params.agentAccount - { token, encodingAesKey, corpId, corpSecret, agentId }
|
|
411
|
+
* @param {object} params.config - Full openclaw config
|
|
412
|
+
* @returns {Promise<boolean>} Whether the request was handled
|
|
413
|
+
*/
|
|
414
|
+
export async function handleAgentInbound({ req, res, agentAccount, config }) {
|
|
415
|
+
const crypto = new WecomCrypto(agentAccount.token, agentAccount.encodingAesKey);
|
|
416
|
+
const agentConfig = {
|
|
417
|
+
corpId: agentAccount.corpId,
|
|
418
|
+
corpSecret: agentAccount.corpSecret,
|
|
419
|
+
agentId: agentAccount.agentId,
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
if (req.method === "GET") {
|
|
423
|
+
return handleUrlVerification(req, res, crypto);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (req.method === "POST") {
|
|
427
|
+
return handleMessageCallback(req, res, crypto, agentConfig, config);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
res.writeHead(405, { "Content-Type": "text/plain" });
|
|
431
|
+
res.end("Method Not Allowed");
|
|
432
|
+
return true;
|
|
433
|
+
}
|