返回 CodeWhale
index.mjs
1 import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
2
3 import {
4 activeTurnBlock,
5 commandAction,
6 compactRuntimeError,
7 helpText,
8 incomingIdentity,
9 isAllowed,
10 latestRunningTurn,
11 pairingRefusalText,
12 parseBool,
13 parseCommand,
14 parseList,
15 parseApprovalDecisionArgs,
16 isApprovalResponse,
17 isDenyResponse,
18 preservedChatStateFields,
19 requiredEnv,
20 splitMessage,
21 stripGroupPrefix,
22 ThreadStore
23 } from "./lib.mjs";
24 import { createRuntimeClient, readJsonSafe, readSse } from "../../bridge-core/src/lib.mjs";
25
26 /** Map of chatId -> latest pending approval info for natural-language approval. */
27 const pendingApprovals = new Map();
28 // Clean up stale approvals every 2 minutes
29 setInterval(() => {
30 const now = Date.now();
31 for (const [chatId, approval] of pendingApprovals) {
32 if (now - approval.timestamp > 300_000) pendingApprovals.delete(chatId);
33 }
34 }, 120_000);
35
36 const config = {
37 botId: requiredEnv("WECOM_BOT_ID"),
38 botSecret: requiredEnv("WECOM_BOT_SECRET"),
39 runtimeUrl: (process.env.CODEWHALE_RUNTIME_URL || "http://127.0.0.1:7878").replace(/\/+$/, ""),
40 runtimeToken: requiredEnv("CODEWHALE_RUNTIME_TOKEN"),
41 workspace: process.env.CODEWHALE_WORKSPACE || process.cwd(),
42 model: process.env.CODEWHALE_MODEL || "auto",
43 mode: process.env.CODEWHALE_MODE || "agent",
44 allowShell: parseBool(process.env.CODEWHALE_ALLOW_SHELL, true),
45 trustMode: parseBool(process.env.CODEWHALE_TRUST_MODE, false),
46 autoApprove: parseBool(process.env.CODEWHALE_AUTO_APPROVE, false),
47 allowlist: parseList(process.env.WECOM_CHAT_ALLOWLIST),
48 allowUnlisted: parseBool(process.env.WECOM_ALLOW_UNLISTED, false),
49 threadMapPath: process.env.WECOM_THREAD_MAP_PATH || "/var/lib/codewhale-wecom-bridge/thread-map.json",
50 maxReplyChars: Number(process.env.WECOM_MAX_REPLY_CHARS || 3500),
51 turnTimeoutMs: Number(process.env.CODEWHALE_TURN_TIMEOUT_MS || 900000),
52 approvalTimeoutMs: Number(process.env.CODEWHALE_APPROVAL_TIMEOUT_MS || 300000)
53 };
54
55 const { runtimeJson, authHeaders } = createRuntimeClient(config);
56
57 const threadStore = await ThreadStore.open(config.threadMapPath);
58
59 const client = new WSClient({
60 botId: config.botId,
61 secret: config.botSecret
62 });
63
64 client.on("message", async (frame) => {
65 try {
66 await handleIncomingMessage(frame);
67 } catch (error) {
68 await reportHandlerError(frame, "Failed to handle WeCom message", error);
69 }
70 });
71
72 client.on("event", async (frame) => {
73 try {
74 await handleEvent(frame);
75 } catch (error) {
76 await reportHandlerError(frame, "Failed to handle WeCom event", error);
77 }
78 });
79
80 client.on("error", (error) => {
81 console.error("WeCom client error:", error);
82 });
83
84 console.log("Starting CodeWhale WeCom bridge");
85 console.log(`Runtime: ${config.runtimeUrl}`);
86 console.log(`Workspace: ${config.workspace}`);
87 if (!config.allowlist.length && !config.allowUnlisted) {
88 console.log("No allowlist configured. Incoming chats will receive their IDs and be refused.");
89 }
90
91 client.connect();
92
93 function replyText(frame, text) {
94 const chunks = splitMessage(text, config.maxReplyChars);
95 return chunks.reduce(
96 (chain, chunk) => chain.then(() => client.replyStream(frame, generateReqId("stream"), chunk, true)),
97 Promise.resolve()
98 );
99 }
100
101 async function reportHandlerError(frame, context, error) {
102 console.error(context, error);
103 try {
104 await replyText(frame, `${context}: ${publicBridgeError(error)}`);
105 } catch (replyError) {
106 console.error("Failed to report WeCom bridge error", replyError);
107 }
108 }
109
110 function publicBridgeError(error) {
111 const message = String(error?.message || error || "unknown error");
112 return message.replaceAll(config.runtimeToken, "<redacted>").slice(0, 500);
113 }
114
115 async function handleIncomingMessage(frame) {
116 const body = frame.body || {};
117 const identity = incomingIdentity(body);
118 console.log(`Incoming message: chatId=${identity.chatId} userId=${identity.userId} chatType=${identity.chatType}`);
119 if (!identity.chatId || !identity.messageId) return;
120
121 if (body.msgtype && body.msgtype !== "text") {
122 await replyText(frame, "目前仅支持文本消息。");
123 return;
124 }
125
126 const textContent = body.text?.content || "";
127 const scoped = stripGroupPrefix(textContent, {
128 chatType: identity.chatType,
129 requirePrefix: identity.chatType === "group",
130 prefix: "/ds"
131 });
132 if (!scoped.accepted) return;
133
134 if (!isAllowed(identity, config.allowlist, config.allowUnlisted)) {
135 await replyText(frame, pairingRefusalText(identity));
136 return;
137 }
138
139 const command = parseCommand(scoped.text);
140 await handleCommand(identity.chatId, command, frame);
141 }
142
143 async function handleEvent(frame) {
144 const body = frame.body || {};
145 const eventType = body.event?.eventtype || "";
146 if (eventType === "enter_chat") {
147 const chatId = body.chatid;
148 if (chatId) {
149 await client.replyWelcome(frame, { msgtype: "text", text: { content: "欢迎使用 CodeWhale!发送 /help 查看可用命令。" } });
150 }
151 }
152 }
153
154 async function handleCommand(chatId, command, frame) {
155 const action = commandAction(command);
156 switch (action.kind) {
157 case "help":
158 await replyText(frame, helpText());
159 return;
160 case "status":
161 await sendStatus(chatId, frame);
162 return;
163 case "threads":
164 await sendThreads(chatId, frame);
165 return;
166 case "new_thread": {
167 const state = await ensureThread(chatId);
168 await replyText(frame, `Created thread ${state.threadId}`);
169 return;
170 }
171 case "resume":
172 await resumeThread(chatId, action.threadId, frame);
173 return;
174 case "interrupt":
175 await interruptActiveTurn(chatId, frame);
176 return;
177 case "compact":
178 await compactThread(chatId, frame);
179 return;
180 case "approval":
181 await decideApproval(chatId, action, frame);
182 return;
183 case "set_model":
184 await setChatModel(chatId, action.modelName, frame);
185 return;
186 case "prompt":
187 // Check if this is a natural-language approval/deny response
188 if (pendingApprovals.has(chatId)) {
189 const pending = pendingApprovals.get(chatId);
190 if (Date.now() - pending.timestamp < config.approvalTimeoutMs) {
191 if (isApprovalResponse(action.prompt)) {
192 const action2 = { kind: "approval", decision: "allow", approvalId: pending.approvalId };
193 await decideApproval(chatId, action2, frame);
194 pendingApprovals.delete(chatId);
195 return;
196 }
197 if (isDenyResponse(action.prompt)) {
198 const action2 = { kind: "approval", decision: "deny", approvalId: pending.approvalId };
199 await decideApproval(chatId, action2, frame);
200 pendingApprovals.delete(chatId);
201 return;
202 }
203 }
204 }
205 await runPrompt(chatId, action.prompt, frame);
206 return;
207 default:
208 await replyText(frame, helpText());
209 }
210 }
211
212 async function ensureThread(chatId, { forceNew = false } = {}) {
213 const existing = await threadStore.getChat(chatId);
214 if (existing?.threadId && !forceNew) return existing;
215
216 const effectiveModel = existing?.model || config.model;
217
218 const thread = await runtimeJson("/v1/threads", {
219 method: "POST",
220 body: {
221 model: effectiveModel,
222 workspace: config.workspace,
223 mode: config.mode,
224 allow_shell: config.allowShell,
225 trust_mode: config.trustMode,
226 auto_approve: config.autoApprove,
227 archived: false,
228 system_prompt:
229 "You are being controlled from a WeCom (企业微信) phone chat. Keep status updates concise. Ask for tool approvals when needed; do not assume mobile messages imply blanket approval."
230 }
231 });
232
233 const state = {
234 ...preservedChatStateFields(existing),
235 threadId: thread.id,
236 lastSeq: 0,
237 activeTurnId: null,
238 updatedAt: new Date().toISOString()
239 };
240 await threadStore.setChat(chatId, state);
241 return state;
242 }
243
244 async function runPrompt(chatId, prompt, frame) {
245 if (!prompt.trim()) {
246 await replyText(frame, helpText());
247 return;
248 }
249 const state = await ensureThread(chatId);
250 const effectiveModel = state?.model || config.model;
251 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
252 const activeBlock = activeTurnBlock(detail, state);
253 if (activeBlock) {
254 await threadStore.patchChat(chatId, {
255 activeTurnId: activeBlock.turnId,
256 updatedAt: new Date().toISOString()
257 });
258 await replyText(frame, activeBlock.message);
259 return;
260 }
261 if (state.activeTurnId) {
262 await threadStore.patchChat(chatId, { activeTurnId: null });
263 }
264 const sinceSeq = Number(detail.latest_seq || state.lastSeq || 0);
265
266 const turnResponse = await runtimeJson(
267 `/v1/threads/${encodeURIComponent(state.threadId)}/turns`,
268 {
269 method: "POST",
270 body: {
271 prompt,
272 input_summary: prompt.slice(0, 200),
273 model: effectiveModel,
274 mode: config.mode,
275 allow_shell: config.allowShell,
276 trust_mode: config.trustMode,
277 auto_approve: config.autoApprove
278 }
279 }
280 );
281
282 const turnId = turnResponse.turn?.id;
283 await threadStore.patchChat(chatId, {
284 activeTurnId: turnId || null,
285 lastSeq: sinceSeq,
286 updatedAt: new Date().toISOString()
287 });
288 await replyText(frame, `Started turn ${turnId || "(unknown)"}`);
289
290 try {
291 await streamTurnEvents(chatId, frame, state.threadId, turnId, sinceSeq);
292 } finally {
293 await threadStore.patchChat(chatId, {
294 activeTurnId: null,
295 updatedAt: new Date().toISOString()
296 });
297 }
298 }
299
300 async function streamTurnEvents(chatId, frame, threadId, turnId, sinceSeq) {
301 const controller = new AbortController();
302 const timeout = setTimeout(() => controller.abort(), config.turnTimeoutMs);
303 const streamId = generateReqId("stream");
304 let responseText = "";
305 let latestSeq = sinceSeq;
306
307 try {
308 const response = await fetch(
309 `${config.runtimeUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${sinceSeq}`,
310 {
311 headers: authHeaders(),
312 signal: controller.signal
313 }
314 );
315 if (!response.ok) {
316 const body = await readJsonSafe(response);
317 throw new Error(compactRuntimeError(response.status, body));
318 }
319
320 for await (const event of readSse(response)) {
321 if (!event.data) continue;
322 let record;
323 try {
324 record = JSON.parse(event.data);
325 } catch (error) {
326 console.warn("Skipping malformed runtime SSE event:", publicBridgeError(error));
327 continue;
328 }
329 latestSeq = Math.max(latestSeq, Number(record.seq || 0));
330 await threadStore.patchChat(chatId, { lastSeq: latestSeq });
331
332 if (turnId && record.turn_id && record.turn_id !== turnId) continue;
333
334 if (record.event === "item.delta" && record.payload?.kind === "agent_message") {
335 responseText += record.payload.delta || "";
336 await client.replyStream(frame, streamId, responseText, false);
337 }
338
339 if (record.event === "approval.required") {
340 const approval = record.payload || {};
341 const approvalId = approval.approval_id || approval.id;
342 // Track latest pending approval per chat for natural-language responses
343 if (approvalId) {
344 pendingApprovals.set(chatId, {
345 approvalId,
346 toolName: approval.tool_name || "unknown",
347 description: approval.description || "",
348 timestamp: Date.now()
349 });
350 }
351 await replyText(
352 frame,
353 [
354 "审批请求",
355 `tool=${approval.tool_name || "unknown"}`,
356 `approval_id=${approvalId}`,
357 approval.description || "",
358 "",
359 `回复 /allow ${approvalId}`,
360 `回复 /deny ${approvalId}`,
361 "也可以直接回复「允许」或「拒绝」"
362 ]
363 .filter(Boolean)
364 .join("\n")
365 );
366 }
367
368 if (record.event === "turn.completed") {
369 const turn = record.payload?.turn || {};
370 const status = turn.status || "completed";
371 const errorText = turn.error ? `\n${turn.error}` : "";
372 const fallback = status === "completed" ? "Turn completed." : `Turn ${status}.${errorText}`;
373 await client.replyStream(frame, streamId, responseText.trim() || fallback, true);
374 return;
375 }
376
377 if (record.event === "turn.lifecycle") {
378 const turn = record.payload?.turn || {};
379 const status = turn.status || record.payload?.status;
380 if (["failed", "canceled", "interrupted"].includes(status)) {
381 const errorText = turn.error || record.payload?.error;
382 await client.replyStream(frame, streamId, `Turn ${status}.${errorText ? `\n${errorText}` : ""}`, true);
383 return;
384 }
385 }
386 }
387 } catch (error) {
388 if (error.name === "AbortError") {
389 await replyText(frame, `Turn timed out after ${Math.round(config.turnTimeoutMs / 1000)}s.`);
390 return;
391 }
392 throw error;
393 } finally {
394 clearTimeout(timeout);
395 }
396 }
397
398 async function sendStatus(chatId, frame) {
399 const [health, runtimeInfo, workspace] = await Promise.all([
400 runtimeJson("/health", { auth: false }),
401 runtimeJson("/v1/runtime/info"),
402 runtimeJson("/v1/workspace/status")
403 ]);
404 await replyText(
405 frame,
406 [
407 `runtime=${health.status || "unknown"}`,
408 `version=${runtimeInfo.version || "unknown"}`,
409 `bind=${runtimeInfo.bind_host}:${runtimeInfo.port}`,
410 `auth_required=${runtimeInfo.auth_required}`,
411 `workspace=${workspace.workspace}`,
412 `git_repo=${workspace.git_repo}`,
413 workspace.branch ? `branch=${workspace.branch}` : "",
414 `staged=${workspace.staged} unstaged=${workspace.unstaged} untracked=${workspace.untracked}`
415 ]
416 .filter(Boolean)
417 .join("\n")
418 );
419 }
420
421 async function sendThreads(chatId, frame) {
422 const threads = await runtimeJson("/v1/threads/summary?limit=8&include_archived=true");
423 if (!threads.length) {
424 await replyText(frame, "No runtime threads yet.");
425 return;
426 }
427 await replyText(
428 frame,
429 threads
430 .map((thread) => {
431 const status = thread.latest_turn_status || "none";
432 return `${thread.id} [${status}] ${thread.title || thread.preview || ""}`;
433 })
434 .join("\n")
435 );
436 }
437
438 async function resumeThread(chatId, args, frame) {
439 const threadId = args.trim();
440 if (!threadId) {
441 await replyText(frame, "Usage: /resume <thread_id>");
442 return;
443 }
444 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(threadId)}`);
445 const existing = await threadStore.getChat(chatId);
446 await threadStore.setChat(chatId, {
447 ...preservedChatStateFields(existing),
448 threadId,
449 lastSeq: Number(detail.latest_seq || 0),
450 activeTurnId: null,
451 updatedAt: new Date().toISOString()
452 });
453 await replyText(frame, `Resumed thread ${threadId}`);
454 }
455
456 async function interruptActiveTurn(chatId, frame) {
457 const state = await threadStore.getChat(chatId);
458 if (!state?.threadId) {
459 await replyText(frame, "No runtime thread recorded for this chat.");
460 return;
461 }
462 const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
463 const runningTurn = latestRunningTurn(detail);
464 const turnId = state.activeTurnId || runningTurn?.id;
465 if (!turnId) {
466 await replyText(frame, "No active turn recorded for this chat.");
467 return;
468 }
469 await runtimeJson(
470 `/v1/threads/${encodeURIComponent(state.threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`,
471 { method: "POST" }
472 );
473 await threadStore.patchChat(chatId, {
474 activeTurnId: turnId,
475 updatedAt: new Date().toISOString()
476 });
477 await replyText(frame, `Interrupt requested for ${turnId}`);
478 }
479
480 async function compactThread(chatId, frame) {
481 const state = await ensureThread(chatId);
482 const result = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}/compact`, {
483 method: "POST",
484 body: { reason: "phone bridge request" }
485 });
486 await replyText(frame, `Compaction started: ${result.turn?.id || "unknown turn"}`);
487 }
488
489 async function decideApproval(chatId, action, frame) {
490 const decision = action.decision;
491 const { approvalId, remember } =
492 action.approvalId != null ? action : parseApprovalDecisionArgs(action.args);
493 if (!approvalId) {
494 await replyText(frame, `Usage: /${decision} <approval_id>${decision === "allow" ? " [remember]" : ""}`);
495 return;
496 }
497 await runtimeJson(`/v1/approvals/${encodeURIComponent(approvalId)}`, {
498 method: "POST",
499 body: { decision, remember }
500 });
501
502 // Clear activeTurnId so the user can send follow-up messages
503 // immediately instead of being blocked by activeTurnBlock
504 // while the SSE stream processes the turn cancellation.
505 await threadStore.patchChat(chatId, {
506 activeTurnId: null,
507 updatedAt: new Date().toISOString()
508 });
509
510 await replyText(frame, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
511 }
512
513 async function setChatModel(chatId, modelName, frame) {
514 if (!modelName || modelName === "default") {
515 await threadStore.patchChat(chatId, {
516 model: null,
517 updatedAt: new Date().toISOString()
518 });
519 await replyText(frame, `Reset per-chat model. Using bridge default: ${config.model}`);
520 return;
521 }
522 await threadStore.patchChat(chatId, {
523 model: modelName,
524 updatedAt: new Date().toISOString()
525 });
526 await replyText(frame, `Per-chat model set to: ${modelName}`);
527 }
528
528 lines Plain Text