weclaude 0.0.4 → 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 +1 -1
- package/README.md +34 -22
- package/cli/{wrc.sh → weclaude.sh} +34 -18
- package/commands/wrc.md +4 -4
- package/config.example.jsonc +6 -6
- package/dist/cli/init.js +10 -10
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/sync.js +35 -18
- package/dist/cli/sync.js.map +1 -1
- package/dist/daemon/approval.js +480 -36
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-bridge.js +37 -20
- package/dist/daemon/cc-bridge.js.map +1 -1
- package/dist/daemon/claim.js +1 -1
- package/dist/daemon/claim.js.map +1 -1
- package/dist/daemon/detail.js +500 -0
- package/dist/daemon/detail.js.map +1 -0
- package/dist/daemon/http.js +2 -1
- package/dist/daemon/http.js.map +1 -1
- package/dist/daemon/inbound.js +115 -21
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +24 -7
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +972 -151
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js +39 -0
- package/dist/daemon/mirror-store.js.map +1 -0
- package/dist/daemon/pending.js +46 -0
- package/dist/daemon/pending.js.map +1 -1
- package/dist/daemon/session-cache.js +71 -3
- package/dist/daemon/session-cache.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +132 -0
- package/dist/daemon/spawn-tmux.js.map +1 -0
- package/dist/mcp/server.js +104 -65
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config-writer.js +1 -1
- package/dist/shared/config.js +34 -20
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/paths.js +6 -0
- package/dist/shared/paths.js.map +1 -1
- package/docs/DESIGN-INIT.md +6 -6
- package/docs/ONBOARDING.md +25 -25
- package/hooks/pre-tool-use.sh +32 -7
- package/launchd/{com.cc-wecom.daemon.plist.template → com.weclaude.daemon.plist.template} +3 -3
- package/package.json +10 -11
- package/scripts/install.sh +6 -6
- package/scripts/uninstall.sh +3 -3
- package/systemd/{cc-wecom.service.template → weclaude.service.template} +3 -3
package/dist/daemon/approval.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createPending, getPending, resolvePending } from "./pending.js";
|
|
2
|
-
import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, } from "./session-cache.js";
|
|
1
|
+
import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsBySession } from "./pending.js";
|
|
2
|
+
import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, clearAutoWindow, getWindowMeta, } from "./session-cache.js";
|
|
3
3
|
import { redact } from "./redact.js";
|
|
4
|
+
import { recordApproval, recordApprovalDecision, buildDetailUrl } from "./detail.js";
|
|
4
5
|
import { json, readBody } from "./http.js";
|
|
5
6
|
// ── Routing helpers ────────────────────────────────────────────────────
|
|
6
7
|
const targetChatId = (principal) => {
|
|
@@ -35,13 +36,13 @@ const EDIT_SNIPPET_LEN = 160;
|
|
|
35
36
|
const WRITE_PREVIEW_LINES = 4;
|
|
36
37
|
const WRITE_PREVIEW_CHARS = 200;
|
|
37
38
|
const SOURCE_BASE = {
|
|
38
|
-
icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/
|
|
39
|
+
icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/3d-claude-ai-logo.bce0ddae70.jpg",
|
|
39
40
|
desc: "Claude Code",
|
|
40
41
|
desc_color: 0,
|
|
41
42
|
};
|
|
42
43
|
// Source bar sits ABOVE main_title — only place we can hoist transcript context.
|
|
43
44
|
const buildSource = (tail) => {
|
|
44
|
-
const desc = tail ?
|
|
45
|
+
const desc = tail ? TRUNC(tail, 80) : SOURCE_BASE.desc;
|
|
45
46
|
return { ...SOURCE_BASE, desc };
|
|
46
47
|
};
|
|
47
48
|
const prefixLines = (s, prefix) => s.split("\n").map((l) => `${prefix} ${l}`).join("\n");
|
|
@@ -117,56 +118,114 @@ const renderInput = (toolName, toolInput, _toolInputStr, cwd) => {
|
|
|
117
118
|
};
|
|
118
119
|
const quoteArea = (text) => ({ type: 0, quote_text: text });
|
|
119
120
|
const dirName = (cwd) => cwd.replace(/^.*\//, "") || cwd;
|
|
121
|
+
const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 详情", url }] : undefined;
|
|
120
122
|
const buildCard = (a) => {
|
|
121
123
|
const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
|
|
122
124
|
const dir = dirName(a.cwd);
|
|
123
125
|
const tail = oneLine(a.transcriptTail).trim();
|
|
126
|
+
const jl = detailJumpList(a.detailUrl);
|
|
124
127
|
return {
|
|
125
128
|
card_type: "button_interaction",
|
|
126
129
|
source: buildSource(tail),
|
|
127
130
|
main_title: {
|
|
128
|
-
title: `🔐 授权 · ${a.toolName} ·
|
|
131
|
+
title: `🔐 授权 · ${a.toolName} · ${dir}/`,
|
|
129
132
|
},
|
|
130
133
|
...(r.body ? { quote_area: quoteArea(r.body) } : {}),
|
|
134
|
+
...(jl ? { jump_list: jl } : {}),
|
|
131
135
|
task_id: a.reqId,
|
|
132
136
|
button_list: [
|
|
133
|
-
{ text: "❌", style:
|
|
134
|
-
{ text: "
|
|
135
|
-
{ text: "✅", style:
|
|
137
|
+
{ text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
|
|
138
|
+
{ text: "10min", style: 3, key: encodeKey(a.reqId, "allow_window") },
|
|
139
|
+
{ text: "✅", style: 4, key: encodeKey(a.reqId, "allow") },
|
|
136
140
|
],
|
|
137
141
|
};
|
|
138
142
|
};
|
|
139
143
|
const verbOf = (d, windowMinutes) => {
|
|
140
144
|
switch (d) {
|
|
141
145
|
case "deny": return "已拒绝";
|
|
142
|
-
case "allow_window": return `${windowMinutes}
|
|
146
|
+
case "allow_window": return `${windowMinutes}min会话内通过所有`;
|
|
143
147
|
case "allow_session": return "本会话通过";
|
|
144
148
|
default: return "已通过";
|
|
145
149
|
}
|
|
146
150
|
};
|
|
147
151
|
const emojiOf = (d) => (d === "deny" ? "❌" : "✅");
|
|
148
|
-
|
|
152
|
+
// allow_window 仍可点击以取消自动窗口;其余决策为最终态 noop。
|
|
153
|
+
const resolvedButton = (d, windowMinutes, reqId, sessionId) => {
|
|
154
|
+
if (d === "allow_window") {
|
|
155
|
+
return {
|
|
156
|
+
text: `${verbOf(d, windowMinutes)}(点击取消)`,
|
|
157
|
+
style: 4,
|
|
158
|
+
key: encodeCancelKey(sessionId),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
text: `${emojiOf(d)} ${verbOf(d, windowMinutes)}`,
|
|
163
|
+
style: 4,
|
|
164
|
+
key: `noop:${reqId}`,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
149
167
|
const buildResolvedCard = (a) => {
|
|
150
168
|
const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
|
|
151
169
|
const dir = dirName(a.cwd);
|
|
152
170
|
const tail = oneLine(a.transcriptTail).trim();
|
|
171
|
+
const jl = detailJumpList(a.detailUrl);
|
|
153
172
|
return {
|
|
154
173
|
card_type: "button_interaction",
|
|
155
174
|
source: buildSource(tail),
|
|
156
175
|
main_title: {
|
|
157
|
-
title: `${a.toolName} ·
|
|
176
|
+
title: `${a.toolName} · ${dir}/`,
|
|
158
177
|
},
|
|
159
178
|
...(r.body ? { quote_area: quoteArea(r.body) } : {}),
|
|
179
|
+
...(jl ? { jump_list: jl } : {}),
|
|
180
|
+
task_id: a.reqId,
|
|
181
|
+
button_list: [resolvedButton(a.decision, a.windowMinutes, a.reqId, a.sessionId)],
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
const buildCancelledCard = (a) => {
|
|
185
|
+
const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
|
|
186
|
+
const dir = dirName(a.cwd);
|
|
187
|
+
const tail = oneLine(a.transcriptTail).trim();
|
|
188
|
+
const jl = detailJumpList(a.detailUrl);
|
|
189
|
+
return {
|
|
190
|
+
card_type: "button_interaction",
|
|
191
|
+
source: buildSource(tail),
|
|
192
|
+
main_title: { title: `${a.toolName} · ${dir}/` },
|
|
193
|
+
...(r.body ? { quote_area: quoteArea(r.body) } : {}),
|
|
194
|
+
...(jl ? { jump_list: jl } : {}),
|
|
160
195
|
task_id: a.reqId,
|
|
161
196
|
button_list: [
|
|
162
|
-
{ text:
|
|
197
|
+
{ text: "已取消自动通过", style: 4, key: `noop:cancelled:${a.reqId}` },
|
|
163
198
|
],
|
|
164
199
|
};
|
|
165
200
|
};
|
|
201
|
+
// 已 resolved 的卡再次被点击 — 仅作视觉反馈, 不改变任何状态。
|
|
202
|
+
const buildAlreadyResolvedCard = (a) => {
|
|
203
|
+
const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
|
|
204
|
+
const dir = dirName(a.cwd);
|
|
205
|
+
const tail = oneLine(a.transcriptTail).trim();
|
|
206
|
+
const jl = detailJumpList(a.detailUrl);
|
|
207
|
+
return {
|
|
208
|
+
card_type: "button_interaction",
|
|
209
|
+
source: buildSource(tail),
|
|
210
|
+
main_title: { title: `${a.toolName} · ${dir}/` },
|
|
211
|
+
...(r.body ? { quote_area: quoteArea(r.body) } : {}),
|
|
212
|
+
...(jl ? { jump_list: jl } : {}),
|
|
213
|
+
task_id: a.reqId,
|
|
214
|
+
button_list: [{ text: "已经放行", style: 4, key: `noop:${a.reqId}` }],
|
|
215
|
+
};
|
|
216
|
+
};
|
|
166
217
|
const encodeKey = (reqId, decision) => `${reqId}|${decision}`;
|
|
218
|
+
const NOOP_PREFIX = "noop:";
|
|
219
|
+
const CANCEL_PREFIX = "cancel_window:";
|
|
220
|
+
const encodeCancelKey = (sessionId) => `${CANCEL_PREFIX}${sessionId}`;
|
|
167
221
|
const decodeKey = (key) => {
|
|
168
|
-
if (key.startsWith(
|
|
169
|
-
|
|
222
|
+
if (key.startsWith(NOOP_PREFIX)) {
|
|
223
|
+
// noop:cancelled:<id> 也走这里,noopReqId 取剩余部分作为 task_id 兜底。
|
|
224
|
+
return { noopReqId: key.slice(NOOP_PREFIX.length) };
|
|
225
|
+
}
|
|
226
|
+
if (key.startsWith(CANCEL_PREFIX)) {
|
|
227
|
+
return { cancelSessionId: key.slice(CANCEL_PREFIX.length) };
|
|
228
|
+
}
|
|
170
229
|
const [reqId, d] = key.split("|");
|
|
171
230
|
if (!reqId || !d)
|
|
172
231
|
return {};
|
|
@@ -174,12 +233,198 @@ const decodeKey = (key) => {
|
|
|
174
233
|
return {};
|
|
175
234
|
return { reqId, decision: d };
|
|
176
235
|
};
|
|
236
|
+
// ── AskUserQuestion 投票卡分支 ─────────────────────────────────────────
|
|
237
|
+
// PreToolUse 协议端只能输出 allow/deny/ask, 没有「合成 tool_result」通道。
|
|
238
|
+
// 取舍: 用户在 WeCom 选了选项 → 走 deny + 把答案塞进 reason, model 把 reason
|
|
239
|
+
// 当作上下文继续推理(CLI 不会弹原生 picker, 流程不被打断);
|
|
240
|
+
// 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答。两路互斥。
|
|
241
|
+
// vote_interaction 不支持 button_list (SDK 类型注释明写「button_interaction 类型卡片使用」),
|
|
242
|
+
// 微信侧静默吞掉, 所以 cli 入口只能塞进 checkbox option_list 作为哨兵 id。
|
|
243
|
+
const ASKQ_PREFIX = "ASKQ|";
|
|
244
|
+
const ASKQ_PICKED_PREFIX = "picked:";
|
|
245
|
+
const ASKQ_CLI_OPTION_ID = "__cli__";
|
|
246
|
+
const ASKQ_NOOP_PREFIX = "askq_noop:";
|
|
247
|
+
const encodeAskqKey = (reqId) => `${ASKQ_PREFIX}${reqId}|submit`;
|
|
248
|
+
const encodeAskqNoopKey = (reqId) => `${ASKQ_NOOP_PREFIX}${reqId}`;
|
|
249
|
+
const decodeAskqKey = (key) => {
|
|
250
|
+
if (!key.startsWith(ASKQ_PREFIX))
|
|
251
|
+
return undefined;
|
|
252
|
+
const [reqId, action] = key.slice(ASKQ_PREFIX.length).split("|");
|
|
253
|
+
if (!reqId || action !== "submit")
|
|
254
|
+
return undefined;
|
|
255
|
+
return { reqId, action };
|
|
256
|
+
};
|
|
257
|
+
const decodeAskqNoopKey = (key) => key.startsWith(ASKQ_NOOP_PREFIX) ? key.slice(ASKQ_NOOP_PREFIX.length) : undefined;
|
|
258
|
+
const parseAskqInput = (i) => {
|
|
259
|
+
if (!i || typeof i !== "object")
|
|
260
|
+
return undefined;
|
|
261
|
+
const qs = i.questions;
|
|
262
|
+
if (!Array.isArray(qs))
|
|
263
|
+
return undefined;
|
|
264
|
+
return qs.map((q) => {
|
|
265
|
+
const qq = (q ?? {});
|
|
266
|
+
const opts = Array.isArray(qq.options) ? qq.options : [];
|
|
267
|
+
return {
|
|
268
|
+
question: typeof qq.question === "string" ? qq.question : "",
|
|
269
|
+
header: typeof qq.header === "string" ? qq.header : "",
|
|
270
|
+
multiSelect: Boolean(qq.multiSelect),
|
|
271
|
+
options: opts.flatMap((o) => {
|
|
272
|
+
const oo = (o ?? {});
|
|
273
|
+
return typeof oo.label === "string"
|
|
274
|
+
? [{
|
|
275
|
+
label: oo.label,
|
|
276
|
+
description: typeof oo.description === "string" ? oo.description : undefined,
|
|
277
|
+
}]
|
|
278
|
+
: [];
|
|
279
|
+
}),
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
const ASKQ_OPTION_TEXT_MAX = 11;
|
|
284
|
+
const ASKQ_TITLE_MAX = 26;
|
|
285
|
+
const ASKQ_SUB_MAX = 480;
|
|
286
|
+
const buildAskqCard = (reqId, q, transcriptTail) => {
|
|
287
|
+
const lines = [];
|
|
288
|
+
if (q.question)
|
|
289
|
+
lines.push(q.question);
|
|
290
|
+
q.options.forEach((o, idx) => {
|
|
291
|
+
if (o.description)
|
|
292
|
+
lines.push(`${idx + 1}. ${o.label} — ${o.description}`);
|
|
293
|
+
});
|
|
294
|
+
const tail = oneLine(transcriptTail).trim();
|
|
295
|
+
return {
|
|
296
|
+
card_type: "vote_interaction",
|
|
297
|
+
source: buildSource(tail),
|
|
298
|
+
main_title: { title: TRUNC(`🤔 ${q.header || "请选择"}`, ASKQ_TITLE_MAX) },
|
|
299
|
+
sub_title_text: TRUNC(lines.join("\n"), ASKQ_SUB_MAX),
|
|
300
|
+
task_id: reqId,
|
|
301
|
+
checkbox: {
|
|
302
|
+
question_key: "q",
|
|
303
|
+
mode: q.multiSelect ? 1 : 0,
|
|
304
|
+
option_list: [
|
|
305
|
+
...q.options.map((o, idx) => ({
|
|
306
|
+
id: String(idx),
|
|
307
|
+
text: TRUNC(o.label, ASKQ_OPTION_TEXT_MAX),
|
|
308
|
+
})),
|
|
309
|
+
{ id: ASKQ_CLI_OPTION_ID, text: "🖥️ CLI 处理" },
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
submit_button: { text: "提交", key: encodeAskqKey(reqId) },
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
const askqResolvedStash = new Map();
|
|
316
|
+
const ASKQ_RESOLVED_TTL_MS = 30 * 60_000;
|
|
317
|
+
const ASKQ_RESOLVED_MAX = 200;
|
|
318
|
+
const stashAskqResolved = (reqId, snap) => {
|
|
319
|
+
askqResolvedStash.set(reqId, { ...snap, at: Date.now() });
|
|
320
|
+
if (askqResolvedStash.size > ASKQ_RESOLVED_MAX) {
|
|
321
|
+
const cutoff = Date.now() - ASKQ_RESOLVED_TTL_MS;
|
|
322
|
+
for (const [k, v] of askqResolvedStash)
|
|
323
|
+
if (v.at < cutoff)
|
|
324
|
+
askqResolvedStash.delete(k);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const getAskqResolved = (reqId) => {
|
|
328
|
+
const e = askqResolvedStash.get(reqId);
|
|
329
|
+
if (!e)
|
|
330
|
+
return undefined;
|
|
331
|
+
if (Date.now() - e.at > ASKQ_RESOLVED_TTL_MS) {
|
|
332
|
+
askqResolvedStash.delete(reqId);
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
return e;
|
|
336
|
+
};
|
|
337
|
+
const buildAskqResolvedCard = (reqId, q, outcome, transcriptTail) => {
|
|
338
|
+
const summary = outcome.kind === "cli"
|
|
339
|
+
? "🖥️ 已转 CLI 处理"
|
|
340
|
+
: outcome.kind === "empty"
|
|
341
|
+
? "⚠️ 未选择"
|
|
342
|
+
: `✅ ${outcome.picked.map((i) => q.options[i]?.label ?? `#${i}`).join(", ")}`;
|
|
343
|
+
const tail = oneLine(transcriptTail).trim();
|
|
344
|
+
return {
|
|
345
|
+
card_type: "button_interaction",
|
|
346
|
+
source: buildSource(tail),
|
|
347
|
+
main_title: { title: TRUNC(`🤔 ${q.header || "已回答"}`, ASKQ_TITLE_MAX) },
|
|
348
|
+
sub_title_text: TRUNC(q.question, ASKQ_SUB_MAX),
|
|
349
|
+
task_id: reqId,
|
|
350
|
+
button_list: [{ text: TRUNC(summary, 30), style: 4, key: encodeAskqNoopKey(reqId) }],
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget }) => {
|
|
354
|
+
const questions = parseAskqInput(body.tool_input);
|
|
355
|
+
if (!questions || questions.length === 0)
|
|
356
|
+
return { decision: "ask", reason: "askq_unparsable" };
|
|
357
|
+
if (questions.length > 1)
|
|
358
|
+
return { decision: "ask", reason: "askq_multi_unsupported" };
|
|
359
|
+
const q = questions[0];
|
|
360
|
+
if (q.options.length === 0)
|
|
361
|
+
return { decision: "ask", reason: "askq_no_options" };
|
|
362
|
+
const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
|
|
363
|
+
if (!approver)
|
|
364
|
+
return { decision: "ask", reason: "no_approver" };
|
|
365
|
+
if (!client.isConnected)
|
|
366
|
+
return { decision: "ask", reason: "ws_disconnected" };
|
|
367
|
+
const longPollMs = cfg.approval.longPollSec * 1000;
|
|
368
|
+
// toolInput 存原始 input,事件 listener 通过 getPending 重解析。
|
|
369
|
+
// transcriptTail 一并存进 meta, resolved 卡渲染时复用同一份 source。
|
|
370
|
+
const { reqId, promise } = createPending({
|
|
371
|
+
meta: {
|
|
372
|
+
kind: "generic",
|
|
373
|
+
createdAt: Date.now(),
|
|
374
|
+
toolName: "AskUserQuestion",
|
|
375
|
+
toolInput: body.tool_input,
|
|
376
|
+
cwd: body.cwd,
|
|
377
|
+
sessionId: body.session_id,
|
|
378
|
+
transcriptTail: body.transcript_tail ?? "",
|
|
379
|
+
},
|
|
380
|
+
timeoutMs: longPollMs,
|
|
381
|
+
});
|
|
382
|
+
try {
|
|
383
|
+
await client.sendMessage(targetChatId(approver), {
|
|
384
|
+
msgtype: "template_card",
|
|
385
|
+
template_card: buildAskqCard(reqId, q, body.transcript_tail ?? ""),
|
|
386
|
+
});
|
|
387
|
+
log.info({ reqId, approver }, "askq card sent");
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
log.error({ err: e.message }, "askq send failed");
|
|
391
|
+
resolvePending(reqId, "deny"); // 释放 pending 槽
|
|
392
|
+
return { decision: "ask", reason: `askq_send_fail:${e.message}` };
|
|
393
|
+
}
|
|
394
|
+
let raw;
|
|
395
|
+
try {
|
|
396
|
+
raw = (await promise);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return { decision: "ask", reason: "askq_timeout" };
|
|
400
|
+
}
|
|
401
|
+
if (raw === "cli")
|
|
402
|
+
return { decision: "ask", reason: "askq_cli" };
|
|
403
|
+
if (raw.startsWith(ASKQ_PICKED_PREFIX)) {
|
|
404
|
+
const idxs = raw.slice(ASKQ_PICKED_PREFIX.length)
|
|
405
|
+
.split(",")
|
|
406
|
+
.map((s) => parseInt(s, 10))
|
|
407
|
+
.filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
|
|
408
|
+
if (idxs.length === 0)
|
|
409
|
+
return { decision: "ask", reason: "askq_empty_pick" };
|
|
410
|
+
const labels = idxs.map((i) => q.options[i].label).join(", ");
|
|
411
|
+
return {
|
|
412
|
+
decision: "deny",
|
|
413
|
+
reason: `User answered "${q.header || q.question}" via WeCom: ${labels}`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
return { decision: "ask", reason: "askq_unknown" };
|
|
417
|
+
};
|
|
177
418
|
const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
|
|
178
419
|
const fallback = (cfg, reason) => ({
|
|
179
420
|
decision: cfg.approval.fallbackOnError,
|
|
180
421
|
reason,
|
|
181
422
|
});
|
|
182
|
-
|
|
423
|
+
const resolveApprover = (cfg, sessionId, getMirrorTarget) => {
|
|
424
|
+
const mirror = sessionId ? getMirrorTarget?.(sessionId) : undefined;
|
|
425
|
+
return mirror || pickApprover(cfg);
|
|
426
|
+
};
|
|
427
|
+
export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget }) => {
|
|
183
428
|
return async (req, res) => {
|
|
184
429
|
if (!cfg.approval.enabled) {
|
|
185
430
|
json(res, 200, { decision: "ask", reason: "approval_disabled" });
|
|
@@ -200,6 +445,24 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
|
|
|
200
445
|
json(res, 200, { decision: "allow", reason: "matcher_skip" });
|
|
201
446
|
return;
|
|
202
447
|
}
|
|
448
|
+
// AskUserQuestion 走单独的投票卡分支(deny+reason 注入答案 / ask 转 CLI)。
|
|
449
|
+
if (toolName === "AskUserQuestion") {
|
|
450
|
+
const resp = await handleAskUserQuestion({
|
|
451
|
+
cfg,
|
|
452
|
+
log,
|
|
453
|
+
client,
|
|
454
|
+
getMirrorTarget,
|
|
455
|
+
body: {
|
|
456
|
+
session_id: sessionId,
|
|
457
|
+
tool_name: toolName,
|
|
458
|
+
tool_input: toolInput,
|
|
459
|
+
cwd,
|
|
460
|
+
transcript_tail: transcriptTail,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
json(res, 200, resp);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
203
466
|
// Auto-approve window: while active for THIS session, requests short-circuit to allow.
|
|
204
467
|
if (isAutoWindowActive(sessionId)) {
|
|
205
468
|
const remainSec = Math.ceil(autoWindowRemainingMs(sessionId) / 1000);
|
|
@@ -221,7 +484,7 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
|
|
|
221
484
|
});
|
|
222
485
|
return;
|
|
223
486
|
}
|
|
224
|
-
const approver =
|
|
487
|
+
const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
|
|
225
488
|
if (!approver) {
|
|
226
489
|
log.warn("no approver configured");
|
|
227
490
|
json(res, 200, fallback(cfg, "no_approver"));
|
|
@@ -256,6 +519,15 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
|
|
|
256
519
|
},
|
|
257
520
|
timeoutMs: longPollMs,
|
|
258
521
|
});
|
|
522
|
+
const detailUrl = buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, reqId);
|
|
523
|
+
recordApproval({
|
|
524
|
+
id: reqId,
|
|
525
|
+
toolName,
|
|
526
|
+
toolInput: display,
|
|
527
|
+
cwd,
|
|
528
|
+
sessionId,
|
|
529
|
+
transcriptTail,
|
|
530
|
+
});
|
|
259
531
|
const card = buildCard({
|
|
260
532
|
reqId,
|
|
261
533
|
toolName,
|
|
@@ -265,6 +537,7 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
|
|
|
265
537
|
sessionShort,
|
|
266
538
|
transcriptTail,
|
|
267
539
|
windowMinutes: cfg.approval.windowMinutes,
|
|
540
|
+
detailUrl,
|
|
268
541
|
});
|
|
269
542
|
try {
|
|
270
543
|
await client.sendMessage(targetChatId(approver), {
|
|
@@ -292,8 +565,32 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
|
|
|
292
565
|
cachePut(ck, decision, cfg.approval.sessionCacheMinutes * 60_000);
|
|
293
566
|
}
|
|
294
567
|
if (decision === "allow_window" && cfg.approval.windowMinutes > 0) {
|
|
295
|
-
setAutoWindow(sessionId, cfg.approval.windowMinutes * 60_000
|
|
296
|
-
|
|
568
|
+
setAutoWindow(sessionId, cfg.approval.windowMinutes * 60_000, {
|
|
569
|
+
toolName,
|
|
570
|
+
toolInput: display,
|
|
571
|
+
cwd,
|
|
572
|
+
transcriptTail,
|
|
573
|
+
});
|
|
574
|
+
// 同 turn 并发触发的其它 pending 卡 — 一并放行,免得用户逐个点。
|
|
575
|
+
// 我们没有那些卡的事件 frame, 不能 updateTemplateCard 改文案;
|
|
576
|
+
// 改用一条 markdown 消息回执让用户知道发生了什么。
|
|
577
|
+
const swept = resolvePendingsBySession(sessionId, "allow_window", reqId);
|
|
578
|
+
log.info({ sessionId, minutes: cfg.approval.windowMinutes, swept: swept.length }, "auto-window opened");
|
|
579
|
+
if (swept.length > 0) {
|
|
580
|
+
try {
|
|
581
|
+
const tools = swept
|
|
582
|
+
.map(({ meta }) => meta.toolName)
|
|
583
|
+
.filter((s) => Boolean(s));
|
|
584
|
+
const summary = tools.length > 0 ? tools.join(" / ") : `${swept.length} 个`;
|
|
585
|
+
await client.sendMessage(targetChatId(approver), {
|
|
586
|
+
msgtype: "markdown",
|
|
587
|
+
markdown: { content: `⚡ 已批量自动放行其他 ${swept.length} 个并发请求:${summary}` },
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
log.warn({ err: e.message }, "sweep notice send failed");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
297
594
|
}
|
|
298
595
|
// Resolved-card refresh happens inline in the click listener via
|
|
299
596
|
// `updateTemplateCard` (5-sec window). No follow-up sendMessage here.
|
|
@@ -318,18 +615,148 @@ export const installApprovalEventListener = (client, log, cfg) => {
|
|
|
318
615
|
// SDK d.ts says ev.event_key, but the actual payload nests it under
|
|
319
616
|
// ev.template_card_event.event_key. Fall back across both for safety.
|
|
320
617
|
const key = ev?.template_card_event?.event_key ?? ev?.event_key ?? "";
|
|
321
|
-
|
|
618
|
+
// Update 时 task_id 必须跟回调里的一致,否则微信会拒掉更新。
|
|
619
|
+
const cbTaskId = ev?.template_card_event?.task_id ?? ev?.task_id ?? "";
|
|
620
|
+
// ── AskUserQuestion 投票卡: 在普通 approval 解码前先匹配 ASKQ| 前缀。
|
|
621
|
+
const askq = decodeAskqKey(key);
|
|
622
|
+
if (askq) {
|
|
623
|
+
const meta = getPending(askq.reqId);
|
|
624
|
+
const q = parseAskqInput(meta?.toolInput)?.[0];
|
|
625
|
+
// 实际 payload 是 XML→JSON 双层包装, 不能直接 [0].option_ids; 同时
|
|
626
|
+
// 兼容 SDK 文档里那个扁平形态(以防固件升级)。
|
|
627
|
+
const si = ev?.template_card_event?.selected_items;
|
|
628
|
+
const firstItem = Array.isArray(si)
|
|
629
|
+
? si[0]
|
|
630
|
+
: si?.selected_item?.[0];
|
|
631
|
+
const oids = firstItem?.option_ids;
|
|
632
|
+
const rawIds = Array.isArray(oids)
|
|
633
|
+
? oids
|
|
634
|
+
: (oids?.option_id ?? []);
|
|
635
|
+
const cliPicked = rawIds.includes(ASKQ_CLI_OPTION_ID);
|
|
636
|
+
const numericIdxs = rawIds
|
|
637
|
+
.filter((s) => s !== ASKQ_CLI_OPTION_ID)
|
|
638
|
+
.map((s) => parseInt(s, 10))
|
|
639
|
+
.filter((n) => Number.isInteger(n) && n >= 0);
|
|
640
|
+
// CLI 哨兵优先 (即便和其它选项混选,也按转 CLI 处理)。
|
|
641
|
+
const outcome = cliPicked
|
|
642
|
+
? { kind: "cli" }
|
|
643
|
+
: numericIdxs.length === 0
|
|
644
|
+
? { kind: "empty" }
|
|
645
|
+
: { kind: "picked", picked: numericIdxs };
|
|
646
|
+
const resolved = outcome.kind === "cli"
|
|
647
|
+
? "cli"
|
|
648
|
+
: `${ASKQ_PICKED_PREFIX}${numericIdxs.join(",")}`;
|
|
649
|
+
const ok = resolvePending(askq.reqId, resolved);
|
|
650
|
+
log.info({ reqId: askq.reqId, outcome, ok }, "askq event resolved");
|
|
651
|
+
if (q) {
|
|
652
|
+
// 存一份给后续在 resolved 卡上重复点击 askq_noop 用 — resolvePending
|
|
653
|
+
// 已经把 pending entry 删掉了,不再 stash 这次拿不到原 question。
|
|
654
|
+
stashAskqResolved(askq.reqId, { q, outcome, transcriptTail: meta?.transcriptTail ?? "" });
|
|
655
|
+
try {
|
|
656
|
+
await client.updateTemplateCard(frame, buildAskqResolvedCard(cbTaskId || askq.reqId, q, outcome, meta?.transcriptTail ?? ""));
|
|
657
|
+
}
|
|
658
|
+
catch (e) {
|
|
659
|
+
log.warn({ err: e.message, reqId: askq.reqId }, "askq updateTemplateCard failed");
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
const decoded = decodeKey(key);
|
|
665
|
+
const detailUrlFor = (id) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id);
|
|
666
|
+
// Askq-noop 分支: askq 投票卡 submit 后那张「已回答」卡再次被点。
|
|
667
|
+
// 必须在普通 noop 分支前匹配 — 否则会落到 buildAlreadyResolvedCard
|
|
668
|
+
// 渲染成「授权 · 已经放行」,污染原卡。
|
|
669
|
+
const askqNoopReqId = decodeAskqNoopKey(key);
|
|
670
|
+
if (askqNoopReqId !== undefined) {
|
|
671
|
+
const snap = getAskqResolved(askqNoopReqId);
|
|
672
|
+
if (snap) {
|
|
673
|
+
try {
|
|
674
|
+
await client.updateTemplateCard(frame, buildAskqResolvedCard(cbTaskId || askqNoopReqId, snap.q, snap.outcome, snap.transcriptTail));
|
|
675
|
+
log.info({ reqId: askqNoopReqId }, "askq noop click — resolved card refreshed");
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
log.warn({ err: e.message, reqId: askqNoopReqId }, "updateTemplateCard (askq-noop) failed");
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
log.info({ reqId: askqNoopReqId }, "askq noop click — stash expired, ignored");
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
// Noop branch: 卡片已 resolved, 用户再次点击 — 给一次视觉反馈。
|
|
687
|
+
if (decoded.noopReqId !== undefined) {
|
|
688
|
+
const reqId = decoded.noopReqId;
|
|
689
|
+
const snap = getResolvedSnapshot(reqId);
|
|
690
|
+
try {
|
|
691
|
+
await client.updateTemplateCard(frame, buildAlreadyResolvedCard({
|
|
692
|
+
reqId: cbTaskId || reqId,
|
|
693
|
+
toolName: snap?.meta.toolName ?? "授权",
|
|
694
|
+
toolInput: snap?.meta.toolInput ?? {},
|
|
695
|
+
toolInputStr: "",
|
|
696
|
+
cwd: snap?.meta.cwd ?? "",
|
|
697
|
+
sessionShort: snap?.meta.sessionId ? snap.meta.sessionId.slice(-8) : "?",
|
|
698
|
+
transcriptTail: snap?.meta.transcriptTail ?? "",
|
|
699
|
+
windowMinutes: cfg.approval.windowMinutes,
|
|
700
|
+
detailUrl: detailUrlFor(reqId),
|
|
701
|
+
}));
|
|
702
|
+
log.info({ reqId }, "noop click — already-resolved card refreshed");
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
log.warn({ err: e.message, reqId }, "updateTemplateCard (noop) failed");
|
|
706
|
+
}
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
// Cancel branch: resolved allow_window card was clicked again to cancel
|
|
710
|
+
// the auto-approve window for that session. No pending to resolve.
|
|
711
|
+
if (decoded.cancelSessionId) {
|
|
712
|
+
const wmeta = getWindowMeta(decoded.cancelSessionId);
|
|
713
|
+
clearAutoWindow(decoded.cancelSessionId);
|
|
714
|
+
log.info({ sessionId: decoded.cancelSessionId }, "auto-window cancelled by click");
|
|
715
|
+
try {
|
|
716
|
+
await client.updateTemplateCard(frame, buildCancelledCard({
|
|
717
|
+
reqId: cbTaskId, // 必须用回调的 task_id,否则微信拒更新
|
|
718
|
+
toolName: wmeta?.toolName ?? "授权",
|
|
719
|
+
toolInput: wmeta?.toolInput ?? {},
|
|
720
|
+
toolInputStr: "",
|
|
721
|
+
cwd: wmeta?.cwd ?? "",
|
|
722
|
+
sessionShort: decoded.cancelSessionId.slice(-8),
|
|
723
|
+
transcriptTail: wmeta?.transcriptTail ?? "",
|
|
724
|
+
windowMinutes: cfg.approval.windowMinutes,
|
|
725
|
+
detailUrl: cbTaskId ? detailUrlFor(cbTaskId) : undefined,
|
|
726
|
+
}));
|
|
727
|
+
log.info({ sessionId: decoded.cancelSessionId, cbTaskId }, "cancel card updated in place");
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
log.warn({ err: e.message }, "updateTemplateCard (cancel) failed");
|
|
731
|
+
}
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const { reqId, decision } = decoded;
|
|
322
735
|
if (!reqId || !decision) {
|
|
323
736
|
log.info({ key }, "card event ignored (bad key)");
|
|
324
737
|
return;
|
|
325
738
|
}
|
|
326
739
|
// Snapshot meta BEFORE resolve (resolve deletes the entry).
|
|
327
|
-
const
|
|
740
|
+
const livePending = getPending(reqId);
|
|
328
741
|
const ok = resolvePending(reqId, decision);
|
|
742
|
+
// 兜底: 这张卡如果是被 sweep 提前 resolve 掉的"鬼卡", livePending 已经没了
|
|
743
|
+
// (resolvePendingsBySession 删过); 从 resolvedStash 里捞回原始 meta + 真实
|
|
744
|
+
// decision, 渲染成"已自动放行"形态而非 (probe)。
|
|
745
|
+
const snap = livePending ? undefined : getResolvedSnapshot(reqId);
|
|
746
|
+
const meta = livePending ?? snap?.meta;
|
|
747
|
+
const effectiveDecision = snap?.decision ?? decision;
|
|
748
|
+
const by = frame.body?.from?.userid ?? "?";
|
|
749
|
+
recordApprovalDecision(reqId, snap ? "swept" : effectiveDecision, by);
|
|
329
750
|
if (ok)
|
|
330
751
|
log.info({ reqId, decision }, "card event resolved");
|
|
331
|
-
else
|
|
332
|
-
log.
|
|
752
|
+
else if (snap)
|
|
753
|
+
log.info({ reqId, snap: snap.decision }, "card event on swept card — rendering snapshot");
|
|
754
|
+
else {
|
|
755
|
+
// meta 完全缺失 (daemon 重启 / stash 过期 / 真探测包)。任何 update 都会用
|
|
756
|
+
// 空字段把原卡覆盖成 "(probe) · /",比保留原卡更糟。直接放弃 update。
|
|
757
|
+
log.warn({ reqId, decision }, "card event for unknown reqId (probe? expired?) — skipping update to avoid clobbering original card");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
333
760
|
// Refresh original card in place (must be within 5s of click).
|
|
334
761
|
// Independent of pending resolution: we always want visual ACK on any
|
|
335
762
|
// well-formed click so the user knows the click landed.
|
|
@@ -342,21 +769,38 @@ export const installApprovalEventListener = (client, log, cfg) => {
|
|
|
342
769
|
}
|
|
343
770
|
})();
|
|
344
771
|
const sessionShort = meta?.sessionId ? meta.sessionId.slice(-8) : "?";
|
|
345
|
-
|
|
772
|
+
// snap 命中 = 这张卡已被 sweep / 重复点击, 没有真正状态变更, 渲染成
|
|
773
|
+
// 「已经放行」而不是再画一遍 allow_window「点击取消」按钮 — 否则用户
|
|
774
|
+
// 在被批量放行的卡上会看到一个会去取消整个窗口的按钮, 误导。
|
|
346
775
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
776
|
+
const card = snap
|
|
777
|
+
? buildAlreadyResolvedCard({
|
|
778
|
+
reqId: cbTaskId || reqId,
|
|
779
|
+
toolName: meta?.toolName ?? "授权",
|
|
780
|
+
toolInput: meta?.toolInput ?? {},
|
|
781
|
+
toolInputStr,
|
|
782
|
+
cwd: meta?.cwd ?? "",
|
|
783
|
+
sessionShort,
|
|
784
|
+
transcriptTail: meta?.transcriptTail ?? "",
|
|
785
|
+
windowMinutes: cfg.approval.windowMinutes,
|
|
786
|
+
detailUrl: detailUrlFor(reqId),
|
|
787
|
+
})
|
|
788
|
+
: buildResolvedCard({
|
|
789
|
+
reqId,
|
|
790
|
+
toolName: meta?.toolName ?? "授权",
|
|
791
|
+
toolInput: meta?.toolInput,
|
|
792
|
+
toolInputStr,
|
|
793
|
+
cwd: meta?.cwd ?? "",
|
|
794
|
+
sessionShort,
|
|
795
|
+
transcriptTail: meta?.transcriptTail ?? "",
|
|
796
|
+
windowMinutes: cfg.approval.windowMinutes,
|
|
797
|
+
decision: effectiveDecision,
|
|
798
|
+
by,
|
|
799
|
+
sessionId: meta?.sessionId ?? "",
|
|
800
|
+
detailUrl: detailUrlFor(reqId),
|
|
801
|
+
});
|
|
802
|
+
await client.updateTemplateCard(frame, card);
|
|
803
|
+
log.info({ reqId, decision, swept: Boolean(snap) }, "card updated in place");
|
|
360
804
|
}
|
|
361
805
|
catch (e) {
|
|
362
806
|
log.warn({ err: e.message, reqId }, "updateTemplateCard failed");
|