wezard 0.3.3
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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +14 -0
- package/CLAUDE.md +83 -0
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/cli/weclaude.sh +236 -0
- package/commands/audit.md +7 -0
- package/commands/wrc.md +7 -0
- package/config.example.jsonc +102 -0
- package/dist/cli/audit.js +156 -0
- package/dist/cli/audit.js.map +1 -0
- package/dist/cli/init.js +311 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/sync.js +175 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/daemon/approval.js +1301 -0
- package/dist/daemon/approval.js.map +1 -0
- package/dist/daemon/ask.js +101 -0
- package/dist/daemon/ask.js.map +1 -0
- package/dist/daemon/audit.js +168 -0
- package/dist/daemon/audit.js.map +1 -0
- package/dist/daemon/cc-bridge.js +204 -0
- package/dist/daemon/cc-bridge.js.map +1 -0
- package/dist/daemon/cfg-sync.js +240 -0
- package/dist/daemon/cfg-sync.js.map +1 -0
- package/dist/daemon/claim.js +95 -0
- package/dist/daemon/claim.js.map +1 -0
- package/dist/daemon/detail.js +153 -0
- package/dist/daemon/detail.js.map +1 -0
- package/dist/daemon/graph.js +176 -0
- package/dist/daemon/graph.js.map +1 -0
- package/dist/daemon/http.js +83 -0
- package/dist/daemon/http.js.map +1 -0
- package/dist/daemon/inbound.js +919 -0
- package/dist/daemon/inbound.js.map +1 -0
- package/dist/daemon/index.js +569 -0
- package/dist/daemon/index.js.map +1 -0
- package/dist/daemon/last-response.js +59 -0
- package/dist/daemon/last-response.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +3512 -0
- package/dist/daemon/mirror-bridge.js.map +1 -0
- package/dist/daemon/mirror-store.js +39 -0
- package/dist/daemon/mirror-store.js.map +1 -0
- package/dist/daemon/outbound.js +42 -0
- package/dist/daemon/outbound.js.map +1 -0
- package/dist/daemon/peers.js +146 -0
- package/dist/daemon/peers.js.map +1 -0
- package/dist/daemon/pending.js +90 -0
- package/dist/daemon/pending.js.map +1 -0
- package/dist/daemon/quota.js +164 -0
- package/dist/daemon/quota.js.map +1 -0
- package/dist/daemon/redact.js +27 -0
- package/dist/daemon/redact.js.map +1 -0
- package/dist/daemon/session-cache.js +149 -0
- package/dist/daemon/session-cache.js.map +1 -0
- package/dist/daemon/session-scan.js +273 -0
- package/dist/daemon/session-scan.js.map +1 -0
- package/dist/daemon/sessions.js +35 -0
- package/dist/daemon/sessions.js.map +1 -0
- package/dist/daemon/spawn-tmux.js +213 -0
- package/dist/daemon/spawn-tmux.js.map +1 -0
- package/dist/daemon/tips.js +25 -0
- package/dist/daemon/tips.js.map +1 -0
- package/dist/daemon/topics.js +197 -0
- package/dist/daemon/topics.js.map +1 -0
- package/dist/daemon/usage.js +358 -0
- package/dist/daemon/usage.js.map +1 -0
- package/dist/daemon/wedoc.js +210 -0
- package/dist/daemon/wedoc.js.map +1 -0
- package/dist/daemon/ws.js +67 -0
- package/dist/daemon/ws.js.map +1 -0
- package/dist/mcp/server.js +385 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/shared/ansi.js +134 -0
- package/dist/shared/ansi.js.map +1 -0
- package/dist/shared/chat-http.js +195 -0
- package/dist/shared/chat-http.js.map +1 -0
- package/dist/shared/chat-render.js +53 -0
- package/dist/shared/chat-render.js.map +1 -0
- package/dist/shared/chat-view.js +116 -0
- package/dist/shared/chat-view.js.map +1 -0
- package/dist/shared/cli-backends.js +304 -0
- package/dist/shared/cli-backends.js.map +1 -0
- package/dist/shared/config-writer.js +39 -0
- package/dist/shared/config-writer.js.map +1 -0
- package/dist/shared/config.js +278 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/detail-render.js +836 -0
- package/dist/shared/detail-render.js.map +1 -0
- package/dist/shared/detail-store.js +205 -0
- package/dist/shared/detail-store.js.map +1 -0
- package/dist/shared/highlight.js +154 -0
- package/dist/shared/highlight.js.map +1 -0
- package/dist/shared/lan-ip.js +35 -0
- package/dist/shared/lan-ip.js.map +1 -0
- package/dist/shared/log.js +18 -0
- package/dist/shared/log.js.map +1 -0
- package/dist/shared/md-chunk.js +99 -0
- package/dist/shared/md-chunk.js.map +1 -0
- package/dist/shared/paths.js +12 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/session-label.js +67 -0
- package/dist/shared/session-label.js.map +1 -0
- package/dist/shared/web-assets.js +51 -0
- package/dist/shared/web-assets.js.map +1 -0
- package/dist/svr/index.js +246 -0
- package/dist/svr/index.js.map +1 -0
- package/hooks/hooks.json +16 -0
- package/hooks/pre-tool-use.sh +167 -0
- package/launchd/com.weclaude.daemon.plist.template +45 -0
- package/package.json +97 -0
- package/scripts/install.sh +73 -0
- package/scripts/postinstall.sh +12 -0
- package/scripts/uninstall.sh +44 -0
- package/systemd/weclaude.service.template +17 -0
- package/web/chat.css +171 -0
- package/web/chat.js +367 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
// Inbound text router. Hands the message off to either the headless CC bridge
|
|
2
|
+
// (mode=headless) or the mirror bridge (mode=mirror).
|
|
3
|
+
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
6
|
+
import { tryConsumeClaim, persistClaim, ackClaim, shouldAutoClaim, ackAutoClaim } from "./claim.js";
|
|
7
|
+
import { getLastResponse } from "./last-response.js";
|
|
8
|
+
import { scanClaudeSessions } from "./session-scan.js";
|
|
9
|
+
import { computeUsage, renderUsageReport } from "./usage.js";
|
|
10
|
+
import { computeAuditReport } from "./audit.js";
|
|
11
|
+
import { syncProjectConfig, renderSyncReport } from "./cfg-sync.js";
|
|
12
|
+
import { captureQuota, renderQuotaReport } from "./quota.js";
|
|
13
|
+
import { tagOfKey, withTagHeader } from "../shared/session-label.js";
|
|
14
|
+
import { parseSubscribe, parseUnsubscribe, parseBroadcast, parseDailySchedule, parseCancelSchedule, isListSchedules, isListSubs, subscribe, unsubscribe, addSchedule, removeSchedulesByTopic, publish, renderSubs, renderSchedules, } from "./topics.js";
|
|
15
|
+
// Chat-binding key: stable id for "this conversation thread". Used as
|
|
16
|
+
// session-map key, mirror target, defaultChat. NOT used for auth.
|
|
17
|
+
const chatPrincipal = (msg) => msg.chattype === "group" && msg.chatid ? `chat:${msg.chatid}` : `user:${msg.from.userid}`;
|
|
18
|
+
// A single chat can host multiple concurrent Claude sessions via `#tag`. The
|
|
19
|
+
// session key = base principal + optional `#tag` suffix. Untagged = default
|
|
20
|
+
// session (backward-compatible). Tags: [\p{L}\p{N}_-]{1,32}, must be
|
|
21
|
+
// space-delimited or edge-of-string so genuine URLs / paths like
|
|
22
|
+
// "#L45-foo/bar" survive. Only the FIRST tag in a message is honored — that
|
|
23
|
+
// tag is stripped from the forwarded text; any additional #foo tokens flow
|
|
24
|
+
// through verbatim (may be actual references in the user's prompt).
|
|
25
|
+
const TAG_RE = /(^|\s)#([\p{L}\p{N}_-]{1,32})(?=\s|$)/u;
|
|
26
|
+
const parseTag = (text) => {
|
|
27
|
+
const m = TAG_RE.exec(text);
|
|
28
|
+
if (!m)
|
|
29
|
+
return { tag: "", cleaned: text };
|
|
30
|
+
const tag = m[2] ?? "";
|
|
31
|
+
const before = text.slice(0, m.index);
|
|
32
|
+
const sep = m[1] ?? "";
|
|
33
|
+
const after = text.slice(m.index + m[0].length);
|
|
34
|
+
const cleaned = (before + sep + after).replace(/[ \t]+/g, " ").trim();
|
|
35
|
+
return { tag, cleaned };
|
|
36
|
+
};
|
|
37
|
+
const sessionKey = (base, tag) => (tag ? `${base}#${tag}` : base);
|
|
38
|
+
const tagOf = tagOfKey;
|
|
39
|
+
// Auth principals: any-of test against allowFrom. Tiered — allowing a user
|
|
40
|
+
// grants them access in any chat; allowing a group grants every member of
|
|
41
|
+
// that group access. DMs collapse to just the sender.
|
|
42
|
+
const authPrincipals = (msg) => {
|
|
43
|
+
const user = `user:${msg.from.userid}`;
|
|
44
|
+
if (msg.chattype === "group" && msg.chatid)
|
|
45
|
+
return [`chat:${msg.chatid}`, user];
|
|
46
|
+
return [user];
|
|
47
|
+
};
|
|
48
|
+
// "会话id" = chat-binding (session/mirror key); "权限id" = either the group
|
|
49
|
+
// OR the sender — allowFrom passes if any one of them is whitelisted.
|
|
50
|
+
// Also surfaces per-id 授权状态 + 对应 `weclaude mirror` CLI 参数 (vid:/chatid:),
|
|
51
|
+
// so users can copy-paste straight into a terminal to bind a Claude session.
|
|
52
|
+
const renderIds = (msg, cfg) => {
|
|
53
|
+
const allowed = new Set(cfg.wrc.allowFrom.map((e) => sanitizeId(e)));
|
|
54
|
+
const mark = (id) => (allowed.has(id) ? "✅ 已授权" : "❌ 未授权");
|
|
55
|
+
const sender = `user:${msg.from.userid}`;
|
|
56
|
+
if (msg.chattype === "group" && msg.chatid) {
|
|
57
|
+
const chat = `chat:${msg.chatid}`;
|
|
58
|
+
return [
|
|
59
|
+
`群: \`${chat}\` ${mark(chat)}`,
|
|
60
|
+
`发送者: \`${sender}\` ${mark(sender)}`,
|
|
61
|
+
`(allowFrom 任一通过即可)`,
|
|
62
|
+
`在已有claude会话中绑定本群聊: \`/weclaude:wrc chat:${msg.chatid}\``,
|
|
63
|
+
].join("\n");
|
|
64
|
+
}
|
|
65
|
+
return [
|
|
66
|
+
`会话id: \`${sender}\` ${mark(sender)}`,
|
|
67
|
+
`在已有claude会话中绑定本单聊: \`/weclaude:wrc user:${msg.from.userid}\``,
|
|
68
|
+
].join("\n");
|
|
69
|
+
};
|
|
70
|
+
const isIdCommand = (text) => text.trim() === "/id";
|
|
71
|
+
const isPwdCommand = (text) => text.trim() === "/pwd";
|
|
72
|
+
const isCostCommand = (text) => text.trim() === "/cost";
|
|
73
|
+
// `/audit` or `/audit some-tag`. With a tag, `/audit` re-routes to the
|
|
74
|
+
// newest-by-mtime mirror whose target carries `#<tag>` (see resolveAuditMirror);
|
|
75
|
+
// without a tag, falls back to the caller's own mirror binding.
|
|
76
|
+
const parseAuditCommand = (text) => {
|
|
77
|
+
const m = /^\/audit(?:\s+(.+))?$/u.exec(text.trim());
|
|
78
|
+
return m ? { tag: (m[1] ?? "").trim().replace(/^#/, "") } : undefined;
|
|
79
|
+
};
|
|
80
|
+
const resolveAuditMirror = (mirrors, tag, who, chatWho) => {
|
|
81
|
+
if (tag) {
|
|
82
|
+
const wanted = tag.replace(/^#/, "");
|
|
83
|
+
const matches = mirrors.filter((m) => (m.target.split("#")[1] ?? "") === wanted);
|
|
84
|
+
if (matches.length <= 1)
|
|
85
|
+
return matches[0];
|
|
86
|
+
return matches
|
|
87
|
+
.map((m) => {
|
|
88
|
+
let mt = 0;
|
|
89
|
+
try {
|
|
90
|
+
mt = statSync(expandHome(m.jsonlPath)).mtimeMs;
|
|
91
|
+
}
|
|
92
|
+
catch { /* ignore */ }
|
|
93
|
+
return { m, mt };
|
|
94
|
+
})
|
|
95
|
+
.sort((a, b) => b.mt - a.mt)[0]?.m;
|
|
96
|
+
}
|
|
97
|
+
return mirrors.find((m) => m.target === who || m.target === chatWho);
|
|
98
|
+
};
|
|
99
|
+
// `/new` optionally names which CLI to launch (`/new codebuddy`). Bare `/new`
|
|
100
|
+
// keeps whatever CLI the chat's current session runs — see newSession's inherit
|
|
101
|
+
// rule — so naming one is only needed to *switch* backends.
|
|
102
|
+
const NEW_RE = /^\/new(?:\s+(claude-internal|claude|codebuddy))?$/i;
|
|
103
|
+
const isNewCommand = (text) => NEW_RE.test(text.trim());
|
|
104
|
+
const cliOfNewCommand = (text) => NEW_RE.exec(text.trim())?.[1]?.toLowerCase();
|
|
105
|
+
// `/cfgsync` (alias `/sync`) — reconcile the project's per-CLI config trees.
|
|
106
|
+
// Bare form is a dry run; `apply` is the only form that writes.
|
|
107
|
+
const CFGSYNC_RE = /^\/(?:cfgsync|sync)(?:\s+(apply))?$/i;
|
|
108
|
+
const parseCfgSyncCommand = (text) => {
|
|
109
|
+
const m = CFGSYNC_RE.exec(text.trim());
|
|
110
|
+
return m ? { apply: Boolean(m[1]) } : undefined;
|
|
111
|
+
};
|
|
112
|
+
const isUsageCommand = (text) => text.trim() === "/usage";
|
|
113
|
+
const isStopCommand = (text) => text.trim() === "/stop";
|
|
114
|
+
const isEnterCommand = (text) => text.trim() === "/n";
|
|
115
|
+
const isRevealCommand = (text) => text.trim() === "/reveal";
|
|
116
|
+
const isHelpCommand = (text) => /^\/(?:help|\?|h)$/i.test(text.trim());
|
|
117
|
+
const isSkillBCommand = (text) => text.trim() === "/skill-b";
|
|
118
|
+
// Static command reference. Grouped: session control, usage/info, topic
|
|
119
|
+
// broadcast (natural-language, zh+en). Anything not matching a command is a
|
|
120
|
+
// prompt forwarded to the bound Claude session.
|
|
121
|
+
const renderHelp = () => [
|
|
122
|
+
"*weclaude 命令*",
|
|
123
|
+
"",
|
|
124
|
+
"▎会话",
|
|
125
|
+
"`/new` 新开会话并绑定本聊天 (沿用当前会话的 CLI)",
|
|
126
|
+
"`/clear` 清空当前会话上下文 (有待切项目时自动升级为 /new)",
|
|
127
|
+
"`/sessions` 列出 live 会话 · `/sessions <emoji|id>` 切换",
|
|
128
|
+
"`/stop` 打断当前生成 (Esc)",
|
|
129
|
+
"`/n` 向 CLI 输入回车 (Enter)",
|
|
130
|
+
"`/reveal` 把终端的 tmux 窗口切到本会话",
|
|
131
|
+
"",
|
|
132
|
+
"▎切换 CLI 后端",
|
|
133
|
+
"`/new codebuddy` 用指定 CLI 新开 (claude / claude-internal / codebuddy)",
|
|
134
|
+
"不写则沿用本会话当前的 CLI;新开 `#tag` 会话则继承本聊天的 CLI。",
|
|
135
|
+
"切换后 `/clear`、`/stop`、`--resume` 自愈都仍绑在该 CLI 上。",
|
|
136
|
+
"",
|
|
137
|
+
"▎多会话路由",
|
|
138
|
+
"同一聊天可同时运行多个 claude:消息中任意位置带 `#tag`(如 `#docs 帮我改 README`)",
|
|
139
|
+
"即路由到该标签会话;不带 tag = 默认会话。tagged 会话的回复以 `emoji #tag` 前缀标注。",
|
|
140
|
+
"`/clear #tag`、`/pwd #tag`、`/stop #tag` 等命令同理按 tag 路由。",
|
|
141
|
+
"`#tag` 与 CLI 名可同时写:`/new codebuddy #docs` = 用 codebuddy 开 docs 会话。",
|
|
142
|
+
"",
|
|
143
|
+
"▎多会话协作",
|
|
144
|
+
"`/peers` 列出本聊天的所有会话及忙闲状态",
|
|
145
|
+
"同一聊天内的会话互为 peer,可以互相观察和驱动。直接说人话即可:",
|
|
146
|
+
"「看下 `#fix` 的进展,推动它直到结束」— AI 会读它的终端、注入指令、等它跑完。",
|
|
147
|
+
"「让 `#fix` 和 `#review` 互相迭代到 review 说 LGTM」— AI 会建一个 loop graph,",
|
|
148
|
+
"把多个带 tag 的会话(可各用不同 CLI / 模型)串成流水线并循环驱动。",
|
|
149
|
+
"",
|
|
150
|
+
"▎信息 (免授权)",
|
|
151
|
+
"`/id` 查看会话/权限 id",
|
|
152
|
+
"`/pwd` 当前项目路径",
|
|
153
|
+
"`/usage` 真实订阅额度 %",
|
|
154
|
+
"`/cost` token/成本估算",
|
|
155
|
+
"`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <tag>` 指定标签会话",
|
|
156
|
+
"`/cfgsync` 预演跨 CLI 项目配置同步 · `/cfgsync apply` 执行 (需授权)",
|
|
157
|
+
"`/help` 本帮助",
|
|
158
|
+
"`/skill-b` 广播接口用法 (贴给 Claude)",
|
|
159
|
+
"",
|
|
160
|
+
"▎广播订阅",
|
|
161
|
+
"`订阅 <topic>` · `退订 <topic>`",
|
|
162
|
+
"`广播 <topic> <内容>`",
|
|
163
|
+
"`每天 08:30 广播 <topic> <内容>`",
|
|
164
|
+
"`取消广播 <topic>`",
|
|
165
|
+
"`订阅列表` · `广播列表`",
|
|
166
|
+
"",
|
|
167
|
+
"▎引用 (quote)",
|
|
168
|
+
"引用消息 + 新文字:被引用内容作为上下文前缀附在你的话前。",
|
|
169
|
+
"纯引用不加字:把被引用内容当正文重发 —— 微信会去重相同文本,",
|
|
170
|
+
"这是重新触发同一条命令 (如 `/usage`) 的唯一方式。",
|
|
171
|
+
"",
|
|
172
|
+
"其余文本直接转发给已绑定的 Claude 会话。",
|
|
173
|
+
].join("\n");
|
|
174
|
+
// /skill-b — 回执一段 prompt,告诉 Claude 如何调用本 daemon 的 /publish 广播接口。
|
|
175
|
+
// 用围栏代码块包裹便于用户在 WeCom 长按 copy 后贴给上游 Claude。
|
|
176
|
+
const renderSkillBroadcast = () => [
|
|
177
|
+
"把下面这段贴给 Claude,它就知道怎么广播:",
|
|
178
|
+
"```",
|
|
179
|
+
"本机 daemon 在 127.0.0.1:17890 暴露 POST /publish,用来向已订阅某 topic 的会话广播消息。",
|
|
180
|
+
"",
|
|
181
|
+
"调用示例:",
|
|
182
|
+
"curl -sS -X POST http://127.0.0.1:17890/publish \\",
|
|
183
|
+
" -H 'Content-Type: application/json' \\",
|
|
184
|
+
" -d '{\"topic\":\"daily-report\",\"markdown\":\"# 标题\\n正文...\"}'",
|
|
185
|
+
"",
|
|
186
|
+
"请求体:",
|
|
187
|
+
"- topic (必填):订阅表里的 key",
|
|
188
|
+
"- markdown 或 text (二选一):消息内容,markdown 语法",
|
|
189
|
+
"",
|
|
190
|
+
"返回: { ok, topic, sent, failed, subs }。ws_disconnected=true 表示 daemon 与 WeCom WS 断开。",
|
|
191
|
+
"",
|
|
192
|
+
"长消息用 --data-binary @file.json,避免 shell 转义踩坑。",
|
|
193
|
+
"```",
|
|
194
|
+
].join("\n");
|
|
195
|
+
// /session(s) [arg] — list live Claude sessions, or switch the mirror to one.
|
|
196
|
+
// Bare "/sessions" (or "/session") lists; an arg (animal emoji, sessionId, or
|
|
197
|
+
// sessionId prefix) switches. Tolerates an optional trailing "s" and any spacing.
|
|
198
|
+
const parseSessionsCommand = (text) => {
|
|
199
|
+
const m = /^\/sessions?(?:\s+(.+))?$/u.exec(text.trim());
|
|
200
|
+
if (!m)
|
|
201
|
+
return undefined;
|
|
202
|
+
return { arg: (m[1] ?? "").trim() };
|
|
203
|
+
};
|
|
204
|
+
// Render the scanned session list into a WeCom-friendly markdown block. The
|
|
205
|
+
// session currently mirrored to this chat's target (if any) is flagged.
|
|
206
|
+
const renderSessionsList = (sessions, currentSid) => {
|
|
207
|
+
if (sessions.length === 0)
|
|
208
|
+
return "[weclaude] 未发现正在运行的 Claude 会话";
|
|
209
|
+
// Only annotate the CLI when the list actually spans more than one — with a
|
|
210
|
+
// single backend the tag is pure noise on every row.
|
|
211
|
+
const mixed = new Set(sessions.map((s) => s.cli)).size > 1;
|
|
212
|
+
const lines = sessions.map((s) => {
|
|
213
|
+
const here = s.sessionId === currentSid ? " ⬅️ 当前" : "";
|
|
214
|
+
const dir = s.cwd.replace(/^.*\//, "") || s.cwd || "?";
|
|
215
|
+
const cli = mixed ? ` _(${s.cli})_` : "";
|
|
216
|
+
return `${s.label || "▫️"} \`${s.sessionId.slice(0, 8)}\` ${dir}${cli}${here}`;
|
|
217
|
+
});
|
|
218
|
+
return [
|
|
219
|
+
"[weclaude] 正在运行的会话:",
|
|
220
|
+
...lines,
|
|
221
|
+
"> 切换:`/sessions <emoji 或 id>`,如 `/sessions 🐼`",
|
|
222
|
+
].join("\n");
|
|
223
|
+
};
|
|
224
|
+
// /peers — roster of the sessions living in THIS chat (default + every `#tag`).
|
|
225
|
+
// Distinct from /sessions, which sweeps the whole host: peers are the ones an
|
|
226
|
+
// agent here can actually collaborate with (shared chat = shared address space).
|
|
227
|
+
const isPeersCommand = (text) => /^\/(?:peers?|agents?)$/iu.test(text.trim());
|
|
228
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
229
|
+
const dirOf = (p) => p.cwd.replace(/^.*\//, "") || p.cwd;
|
|
230
|
+
const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
231
|
+
// A field with the same value on every row (project dir, CLI backend) is noise
|
|
232
|
+
// repeated N times — hoist those into the header and annotate rows only where
|
|
233
|
+
// they actually differ. Rows are blank-line separated so a wrapped summary can't
|
|
234
|
+
// visually merge into the next peer.
|
|
235
|
+
const renderPeers = (peers) => {
|
|
236
|
+
if (peers.length === 0)
|
|
237
|
+
return "[weclaude] 本聊天还没有会话。发消息或 `/new` 建一个。";
|
|
238
|
+
const dirs = uniq(peers.map(dirOf));
|
|
239
|
+
const clis = uniq(peers.map((p) => p.cli));
|
|
240
|
+
const shared = [dirs.length === 1 ? dirs[0] : "", clis.length === 1 ? clis[0] : ""].filter(Boolean);
|
|
241
|
+
const rows = peers.flatMap((p) => {
|
|
242
|
+
const name = p.tag ? `#${p.tag}` : "默认";
|
|
243
|
+
const state = !p.paneAlive ? "⚫️ 已关闭" : p.busy ? "🔴 忙" : "🟢 空闲";
|
|
244
|
+
const varies = [dirs.length > 1 ? dirOf(p) : "", clis.length > 1 ? p.cli : ""].filter(Boolean);
|
|
245
|
+
const me = p.self ? " ⬅️ 本会话" : "";
|
|
246
|
+
return [
|
|
247
|
+
`**${p.label} ${name}** ${state}${varies.length ? ` · ${varies.join(" · ")}` : ""}${me}`,
|
|
248
|
+
` ${clip(p.summary, 64)}`,
|
|
249
|
+
"",
|
|
250
|
+
];
|
|
251
|
+
});
|
|
252
|
+
return [
|
|
253
|
+
`[weclaude] 本聊天的会话 · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
|
|
254
|
+
"",
|
|
255
|
+
...rows,
|
|
256
|
+
"> 协作:直接说「看下 #fix 的进展并推动它」,AI 会读它的终端并注入指令",
|
|
257
|
+
].join("\n");
|
|
258
|
+
};
|
|
259
|
+
// Match a switch arg against a scanned session: animal emoji label, full
|
|
260
|
+
// sessionId, or a ≥6 char sessionId prefix. Returns the session or undefined.
|
|
261
|
+
const matchSession = (sessions, arg) => sessions.find((s) => s.label === arg) ??
|
|
262
|
+
sessions.find((s) => s.sessionId === arg) ??
|
|
263
|
+
(arg.length >= 6 ? sessions.find((s) => s.sessionId.startsWith(arg)) : undefined);
|
|
264
|
+
// Strip any "@<botname>" mention (leading, mid-text, or trailing) so it doesn't
|
|
265
|
+
// leak into claude's prompt. WeCom may place the mention anywhere depending on
|
|
266
|
+
// where the user typed it.
|
|
267
|
+
// Safety: if the text contains more than one "@", it's ambiguous (user likely
|
|
268
|
+
// also @'d a file path like "@src/foo.ts"), so leave it untouched rather than
|
|
269
|
+
// risk eating the path.
|
|
270
|
+
const stripMentions = (text) => {
|
|
271
|
+
const atCount = (text.match(/@/gu) ?? []).length;
|
|
272
|
+
if (atCount !== 1)
|
|
273
|
+
return text;
|
|
274
|
+
return text.replace(/\s*@\S+\s*/u, " ").replace(/\s+/gu, " ").trim();
|
|
275
|
+
};
|
|
276
|
+
// DMs can't @ a bot — any "@" the user types is content (e.g. "@src/foo.ts"),
|
|
277
|
+
// so we only strip mentions in group chats.
|
|
278
|
+
const isGroup = (msg) => msg.chattype === "group" && !!msg.chatid;
|
|
279
|
+
// Always kill "@weclaude" (bot's own name) regardless of chat type / @-count:
|
|
280
|
+
// a DM user typing "@weclaude start …" would otherwise leak the mention into
|
|
281
|
+
// Claude's prompt and get semantically parsed (e.g. spawning wrc). Word-tail
|
|
282
|
+
// guard `(?![A-Za-z0-9_])` keeps identifiers like "@weclaude-foo" intact
|
|
283
|
+
// while allowing CJK / punctuation right after.
|
|
284
|
+
const stripBotName = (text) => text.replace(/[ \t]*@weclaude(?![A-Za-z0-9_])[ \t]*/giu, " ").replace(/[ \t]{2,}/g, " ").trim();
|
|
285
|
+
const maybeStripMentions = (msg, text) => {
|
|
286
|
+
const cleaned = stripBotName(text);
|
|
287
|
+
return isGroup(msg) ? stripMentions(cleaned) : cleaned;
|
|
288
|
+
};
|
|
289
|
+
// Render the user's "引用" (quoted message) into a markdown blockquote so the
|
|
290
|
+
// claude prompt carries the upstream context. WeCom delivers `quote` as a
|
|
291
|
+
// sibling field on the message body — currently we surface text/voice (already
|
|
292
|
+
// transcribed) inline; image/mixed-image/file are rendered as a placeholder
|
|
293
|
+
// (download would mean an extra round-trip + clipboard paste, which is too
|
|
294
|
+
// heavy for a quote — user can always send the file directly if needed).
|
|
295
|
+
const quoteToText = (q) => {
|
|
296
|
+
if (q.msgtype === "text")
|
|
297
|
+
return q.text?.content ?? "";
|
|
298
|
+
if (q.msgtype === "voice")
|
|
299
|
+
return q.voice?.content ?? "";
|
|
300
|
+
if (q.msgtype === "mixed") {
|
|
301
|
+
return (q.mixed?.msg_item ?? [])
|
|
302
|
+
.map((it) => (it.msgtype === "text" ? it.text?.content ?? "" : "[图片]"))
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.join(" ");
|
|
305
|
+
}
|
|
306
|
+
if (q.msgtype === "image")
|
|
307
|
+
return "[图片]";
|
|
308
|
+
if (q.msgtype === "file")
|
|
309
|
+
return "[文件]";
|
|
310
|
+
return "";
|
|
311
|
+
};
|
|
312
|
+
const renderQuotePrefix = (q) => {
|
|
313
|
+
if (!q)
|
|
314
|
+
return "";
|
|
315
|
+
const body = quoteToText(q).trim();
|
|
316
|
+
if (!body)
|
|
317
|
+
return "";
|
|
318
|
+
// Quote each line so multi-line引用渲染整洁; trailing blank line separates
|
|
319
|
+
// from the user's actual message.
|
|
320
|
+
const quoted = body.split("\n").map((l) => `> ${l}`).join("\n");
|
|
321
|
+
return `> [引用]\n${quoted}\n\n`;
|
|
322
|
+
};
|
|
323
|
+
// Normalize for self-reply quote dedup: WeCom mangles formatting on its quote
|
|
324
|
+
// bubble in unpredictable ways — strips backticks, swaps `-` bullets for `·`,
|
|
325
|
+
// re-wraps whitespace, sometimes loses inline markdown. Reduce both sides to
|
|
326
|
+
// just letters + digits (Unicode + CJK) and compare on that — robust against
|
|
327
|
+
// any punctuation/whitespace/markup churn while keeping content fidelity.
|
|
328
|
+
const canonForCompare = (s) => s.replace(/[^\p{L}\p{N}]/gu, "");
|
|
329
|
+
const isLastResponseQuote = (target, quoted) => {
|
|
330
|
+
const last = getLastResponse(target);
|
|
331
|
+
if (!last || !quoted)
|
|
332
|
+
return false;
|
|
333
|
+
const a = canonForCompare(last);
|
|
334
|
+
const b = canonForCompare(quoted);
|
|
335
|
+
if (a.length < 4 || b.length < 6)
|
|
336
|
+
return false; // too short — false-positive risk
|
|
337
|
+
// Substring match (prefix subsumes; suffix covers tool-heavy turns where the
|
|
338
|
+
// tracked `s.acc` interleaves tool entries before the final text). Both
|
|
339
|
+
// sides are canon'd to letters+digits only, so formatting/punctuation drift
|
|
340
|
+
// can't break the match.
|
|
341
|
+
return a.includes(b);
|
|
342
|
+
};
|
|
343
|
+
const withQuote = (msg, text) => {
|
|
344
|
+
if (!msg.quote)
|
|
345
|
+
return text;
|
|
346
|
+
// Drop the quote when the user is replying to weclaude's most recent message
|
|
347
|
+
// in this chat — claude already has that turn in its context, surfacing it
|
|
348
|
+
// again is redundant noise. Older self-quotes still flow through (the user
|
|
349
|
+
// is genuinely pointing back to something earlier).
|
|
350
|
+
const quoted = quoteToText(msg.quote).trim();
|
|
351
|
+
if (isLastResponseQuote(chatPrincipal(msg), quoted))
|
|
352
|
+
return text;
|
|
353
|
+
const prefix = renderQuotePrefix(msg.quote);
|
|
354
|
+
return prefix ? `${prefix}${text}` : text;
|
|
355
|
+
};
|
|
356
|
+
// Effective body for a text message.
|
|
357
|
+
// - Normal: strip the bot @mention, attach any quote as a markdown context prefix.
|
|
358
|
+
// - Pure-quote re-trigger: when the user adds NO new text and just quotes a
|
|
359
|
+
// message, promote the quoted message to the body (strip its @mention so
|
|
360
|
+
// "@weclaude /usage" → "/usage" hits the command path). WeCom silently dedups
|
|
361
|
+
// identical text sends, so re-quoting the same command is the only way to
|
|
362
|
+
// re-fire it — this makes that work. Self-quotes of weclaude's own last reply
|
|
363
|
+
// are excluded (would echo weclaude's text back as a command).
|
|
364
|
+
// Also extracts the leading `#tag` (if any) from the effective body; the
|
|
365
|
+
// returned `text` has the tag stripped so command matchers see clean
|
|
366
|
+
// "/new"/"/pwd"/etc.
|
|
367
|
+
const resolveTextBody = (msg) => {
|
|
368
|
+
const body = maybeStripMentions(msg, msg.text?.content ?? "");
|
|
369
|
+
if (body.trim()) {
|
|
370
|
+
const { tag, cleaned } = parseTag(body);
|
|
371
|
+
return { text: withQuote(msg, cleaned), tag, promoted: false };
|
|
372
|
+
}
|
|
373
|
+
const quoted = msg.quote ? quoteToText(msg.quote).trim() : "";
|
|
374
|
+
if (quoted && !isLastResponseQuote(chatPrincipal(msg), quoted)) {
|
|
375
|
+
const { tag, cleaned } = parseTag(maybeStripMentions(msg, quoted).trim());
|
|
376
|
+
return { text: cleaned, tag, promoted: true };
|
|
377
|
+
}
|
|
378
|
+
return { text: withQuote(msg, body), tag: "", promoted: false };
|
|
379
|
+
};
|
|
380
|
+
const isAllowed = (cfg, principals) => {
|
|
381
|
+
if (cfg.wrc.allowFrom.length === 0)
|
|
382
|
+
return false;
|
|
383
|
+
// Tolerate invisible chars sneaking into hand-edited config (paste artifacts).
|
|
384
|
+
const allowed = new Set(cfg.wrc.allowFrom.map((e) => sanitizeId(e)));
|
|
385
|
+
return principals.some((p) => allowed.has(p));
|
|
386
|
+
};
|
|
387
|
+
// Mirror mode grants implicit talkback: any chat that's currently a mirror
|
|
388
|
+
// target can post back without being in `allowFrom`. The act of /wrc'ing into
|
|
389
|
+
// that chat is the authorization signal.
|
|
390
|
+
const isMirrorTarget = (bridge, who) => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(who);
|
|
391
|
+
// Sniff extension from magic bytes; falls back to .bin. WeCom doesn't always
|
|
392
|
+
// give us a filename for images, and we want claude's Read tool to recognize
|
|
393
|
+
// the file (it dispatches on extension).
|
|
394
|
+
const sniffExt = (buf) => {
|
|
395
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
396
|
+
return ".png";
|
|
397
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff)
|
|
398
|
+
return ".jpg";
|
|
399
|
+
if (buf.length >= 6 && buf.subarray(0, 6).toString("ascii").startsWith("GIF8"))
|
|
400
|
+
return ".gif";
|
|
401
|
+
if (buf.length >= 12 && buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP")
|
|
402
|
+
return ".webp";
|
|
403
|
+
if (buf.length >= 12 && buf.subarray(4, 12).toString("ascii") === "ftypheic")
|
|
404
|
+
return ".heic";
|
|
405
|
+
return ".bin";
|
|
406
|
+
};
|
|
407
|
+
const downloadToInbox = async (deps, url, aesKey, msgid, index) => {
|
|
408
|
+
try {
|
|
409
|
+
const { buffer, filename } = await deps.client.downloadFile(url, aesKey);
|
|
410
|
+
const ext = filename ? `.${filename.split(".").pop()}` : sniffExt(buffer);
|
|
411
|
+
const safeName = `${msgid.replace(/[^A-Za-z0-9_-]/g, "_")}_${index}${ext}`;
|
|
412
|
+
mkdirSync(deps.inboxDir, { recursive: true });
|
|
413
|
+
const abs = join(deps.inboxDir, safeName);
|
|
414
|
+
writeFileSync(abs, buffer);
|
|
415
|
+
deps.log.info({ url: url.slice(0, 80), bytes: buffer.length, abs }, "media saved");
|
|
416
|
+
return abs;
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
deps.log.error({ err: e.message }, "media download failed");
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
424
|
+
const inboxDir = expandHome(cfg.wrc.mirror.inboxDir);
|
|
425
|
+
// Render /pwd output. Mirror mode reads the live attachment + persisted
|
|
426
|
+
// store via bridge.getCwd; headless mode has no per-chat cwd, so it just
|
|
427
|
+
// shows cfg.wrc.cwd as the global default.
|
|
428
|
+
const renderPwd = (who) => {
|
|
429
|
+
if ("getCwd" in bridge) {
|
|
430
|
+
const { runningCwd, pendingCwd, defaultCwd } = bridge.getCwd(who);
|
|
431
|
+
const lines = [`[weclaude] 📂 当前项目: \`${runningCwd}\``];
|
|
432
|
+
if (pendingCwd && pendingCwd !== runningCwd) {
|
|
433
|
+
lines.push(`下次切换: \`${pendingCwd}\` (使用 /new 或 /clear 生效)`);
|
|
434
|
+
}
|
|
435
|
+
if (runningCwd !== defaultCwd)
|
|
436
|
+
lines.push(`(默认: \`${defaultCwd}\`)`);
|
|
437
|
+
lines.push("> 切换其他项目: 让 AI 调用 `cd` MCP 工具");
|
|
438
|
+
return lines.join("\n");
|
|
439
|
+
}
|
|
440
|
+
return `[weclaude] 📂 当前项目: \`${expandHome(cfg.wrc.cwd)}\` (headless mode, 全局默认)`;
|
|
441
|
+
};
|
|
442
|
+
// Mirror-only auto-spawn / /new helper. Routes through bridge.newSession
|
|
443
|
+
// which kills the old pane, spawns fresh in pendingCwd ?? runningCwd ??
|
|
444
|
+
// default, attaches, and pushes "📂 当前项目" info to the chat. Returns
|
|
445
|
+
// the user-facing one-line ack. When `who` carries a `#tag` suffix, use
|
|
446
|
+
// the raw tag as the tmux window name so the pane shows readably in the
|
|
447
|
+
// status bar (e.g. `#docs` → window `docs`, not the principal slug).
|
|
448
|
+
const spawnSession = async (who, cli) => {
|
|
449
|
+
if (!("newSession" in bridge))
|
|
450
|
+
return "[weclaude] /new only available in mirror mode";
|
|
451
|
+
const tag = tagOf(who);
|
|
452
|
+
const r = await bridge.newSession(who, tag || who, cli);
|
|
453
|
+
if (!r.ok)
|
|
454
|
+
return `[weclaude] /new failed: ${r.reason ?? "unknown"}`;
|
|
455
|
+
return `✅ 新会话已建立 \`${r.sessionId}\``;
|
|
456
|
+
};
|
|
457
|
+
// 同一 `#tag` 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
|
|
458
|
+
// newSession 会 kill 掉前者的 pane,前者的 dispatch 再 `--resume` 重生出孤儿
|
|
459
|
+
// pane,消息乱序。spawn 窗口有 3s+(TUI_SETTLE_MS),所以必须按会话串行。
|
|
460
|
+
const spawnQ = new Map();
|
|
461
|
+
const serializeSpawn = (key, job) => {
|
|
462
|
+
const next = (spawnQ.get(key) ?? Promise.resolve()).then(job, job);
|
|
463
|
+
spawnQ.set(key, next.catch(() => undefined).finally(() => {
|
|
464
|
+
if (spawnQ.get(key) === next)
|
|
465
|
+
spawnQ.delete(key);
|
|
466
|
+
}));
|
|
467
|
+
return next;
|
|
468
|
+
};
|
|
469
|
+
// 显式 /new:排队但仍强制重开(用户就是要换一个)。
|
|
470
|
+
const autoSpawnAndAttach = (who, cli) => serializeSpawn(who, () => spawnSession(who, cli));
|
|
471
|
+
// 隐式建会话(裸 `#tag` 第一条消息):轮到自己时若前一条已经把会话建好,直接
|
|
472
|
+
// 复用,不再 respawn —— 否则先到的消息会被注入进一个刚被杀掉的 pane。
|
|
473
|
+
const ensureSession = (who) => serializeSpawn(who, async () => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(who) ? "✅ 复用已建立的会话" : await spawnSession(who));
|
|
474
|
+
// 事件订阅 / 广播命令。授权后调用,不再走 bridge dispatch。返回 true 表示已处理。
|
|
475
|
+
const topicLog = log.child({ mod: "topics" });
|
|
476
|
+
const handleTopicCommand = async (frame, msg, who, text) => {
|
|
477
|
+
// 订阅/广播回执同样按会话 tag 标注 —— `#docs /subs` 的回执必须落在 `#docs`
|
|
478
|
+
// 的视觉通道里, 否则同一聊天并行会话时分不清是谁的回执。
|
|
479
|
+
const reply = async (t) => {
|
|
480
|
+
try {
|
|
481
|
+
await client.replyStream(frame, msg.msgid, withTagHeader(who, t), true);
|
|
482
|
+
}
|
|
483
|
+
catch { /* ignore */ }
|
|
484
|
+
};
|
|
485
|
+
const sub = parseSubscribe(text);
|
|
486
|
+
if (sub) {
|
|
487
|
+
const r = subscribe(cfg, sourcePath, sub.topic, who);
|
|
488
|
+
await reply(r.added
|
|
489
|
+
? `✅ 已订阅 \`${sub.topic}\` (会话: \`${who}\`)`
|
|
490
|
+
: `ℹ️ 已在 \`${sub.topic}\` 订阅列表中`);
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
const unsub = parseUnsubscribe(text);
|
|
494
|
+
if (unsub) {
|
|
495
|
+
const r = unsubscribe(cfg, sourcePath, unsub.topic, who);
|
|
496
|
+
await reply(r.removed ? `✅ 已退订 \`${unsub.topic}\`` : `ℹ️ 未订阅 \`${unsub.topic}\``);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
const daily = parseDailySchedule(text);
|
|
500
|
+
if (daily) {
|
|
501
|
+
addSchedule(cfg, sourcePath, {
|
|
502
|
+
topic: daily.topic,
|
|
503
|
+
hour: daily.hour,
|
|
504
|
+
minute: daily.minute,
|
|
505
|
+
content: daily.content,
|
|
506
|
+
createdBy: who,
|
|
507
|
+
createdAt: Date.now(),
|
|
508
|
+
});
|
|
509
|
+
const hh = String(daily.hour).padStart(2, "0");
|
|
510
|
+
const mm = String(daily.minute).padStart(2, "0");
|
|
511
|
+
const subCount = (cfg.topics.subs[daily.topic] ?? []).length;
|
|
512
|
+
await reply(`✅ 定时已添加: 每天 ${hh}:${mm} 广播 \`${daily.topic}\` (当前 ${subCount} 订阅者)`);
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
const bcast = parseBroadcast(text);
|
|
516
|
+
if (bcast) {
|
|
517
|
+
const r = await publish(client, cfg, topicLog, bcast.topic, bcast.content);
|
|
518
|
+
await reply(`📣 已广播到 \`${r.topic}\`: 送达 ${r.sent} / ${r.subs.length}${r.failed ? `,失败 ${r.failed}` : ""}`);
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
const cancel = parseCancelSchedule(text);
|
|
522
|
+
if (cancel) {
|
|
523
|
+
const n = removeSchedulesByTopic(cfg, sourcePath, cancel.topic);
|
|
524
|
+
await reply(n > 0 ? `✅ 已删除 \`${cancel.topic}\` 的 ${n} 条定时` : `ℹ️ \`${cancel.topic}\` 无定时任务`);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
if (isListSubs(text)) {
|
|
528
|
+
await reply(renderSubs(cfg, who));
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
if (isListSchedules(text)) {
|
|
532
|
+
await reply(renderSchedules(cfg));
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
return false;
|
|
536
|
+
};
|
|
537
|
+
// Prefix user-visible daemon replies with `<emoji> #tag` when the routed
|
|
538
|
+
// session is tagged, so a chat hosting multiple concurrent tagged sessions
|
|
539
|
+
// stays visually disambiguated. Untagged (default) session passes through
|
|
540
|
+
// unchanged. Emoji is derived from the tag string (not sessionId) so it
|
|
541
|
+
// stays stable across /clear cycles.
|
|
542
|
+
const withTagPrefix = withTagHeader;
|
|
543
|
+
const replyText = async (frame, msg, who, text) => {
|
|
544
|
+
try {
|
|
545
|
+
await client.replyStream(frame, msg.msgid, withTagPrefix(who, text), true);
|
|
546
|
+
}
|
|
547
|
+
catch { /* ignore */ }
|
|
548
|
+
};
|
|
549
|
+
// Common gating: claim bootstrap + allowFrom check. Returns true if the
|
|
550
|
+
// caller should stop (claim consumed or message rejected).
|
|
551
|
+
const gate = async (frame, msg, text, who) => {
|
|
552
|
+
const auths = authPrincipals(msg);
|
|
553
|
+
// Bootstrap / allowFrom operations are chat-scoped, not session-scoped;
|
|
554
|
+
// strip any `#tag` suffix so a first-time user typing `hello #foo`
|
|
555
|
+
// still promotes them as `user:xxx` (not `user:xxx#foo`).
|
|
556
|
+
const basePrincipal = chatPrincipal(msg);
|
|
557
|
+
// /id — bypass allowFrom so users can discover their ids before configuring.
|
|
558
|
+
if (isIdCommand(text)) {
|
|
559
|
+
await replyText(frame, msg, who, renderIds(msg, cfg));
|
|
560
|
+
return { stop: true };
|
|
561
|
+
}
|
|
562
|
+
// /help — static command reference. Bypasses allowFrom like /id so a new
|
|
563
|
+
// user can discover the command surface before being authorized.
|
|
564
|
+
if (isHelpCommand(text)) {
|
|
565
|
+
await replyText(frame, msg, who, renderHelp());
|
|
566
|
+
return { stop: true };
|
|
567
|
+
}
|
|
568
|
+
// /pwd — bypass allowFrom too. Read-only project-path lookup.
|
|
569
|
+
if (isPwdCommand(text)) {
|
|
570
|
+
await replyText(frame, msg, who, renderPwd(who));
|
|
571
|
+
return { stop: true };
|
|
572
|
+
}
|
|
573
|
+
// /skill-b — 静态 prompt 回执,教 Claude 调用 /publish 广播接口。纯文本,bypass allowFrom。
|
|
574
|
+
if (isSkillBCommand(text)) {
|
|
575
|
+
await replyText(frame, msg, who, renderSkillBroadcast());
|
|
576
|
+
return { stop: true };
|
|
577
|
+
}
|
|
578
|
+
// /cost — token / cost ESTIMATE pulled from ~/.claude(-internal)?/projects
|
|
579
|
+
// jsonl transcripts (ccusage-style). Read-only, no session state, so it
|
|
580
|
+
// bypasses allowFrom like /id and /pwd. Real subscription %: use /usage.
|
|
581
|
+
if (isCostCommand(text)) {
|
|
582
|
+
let body;
|
|
583
|
+
try {
|
|
584
|
+
body = renderUsageReport(computeUsage());
|
|
585
|
+
}
|
|
586
|
+
catch (e) {
|
|
587
|
+
body = `[weclaude] /cost failed: ${e.message}`;
|
|
588
|
+
}
|
|
589
|
+
await replyText(frame, msg, who, body);
|
|
590
|
+
return { stop: true };
|
|
591
|
+
}
|
|
592
|
+
// /audit [tag] — per-session cost/token breakdown (main + subagents). We
|
|
593
|
+
// handle it here instead of paste-forwarding to the Claude REPL because
|
|
594
|
+
// (a) tmux paste + Enter is racy for slash commands and often fails to
|
|
595
|
+
// submit, and (b) even when it does, the LLM turn adds 30-40s over what
|
|
596
|
+
// is really just a jsonl read. Read-only, no state — bypasses allowFrom.
|
|
597
|
+
//
|
|
598
|
+
// Tag routing: `/audit <tag>` resolves to the SINGLE most-recently-active
|
|
599
|
+
// mirror whose target carries `#<tag>` (by jsonl mtime), NOT the caller's
|
|
600
|
+
// current session and NOT a sum over all sessions sharing the tag.
|
|
601
|
+
// Untagged form falls back to the caller's own mirror binding.
|
|
602
|
+
const audit = parseAuditCommand(text);
|
|
603
|
+
if (audit) {
|
|
604
|
+
const mirror = "status" in bridge
|
|
605
|
+
? resolveAuditMirror(bridge.status().mirrors, audit.tag, who, chatPrincipal(msg))
|
|
606
|
+
: undefined;
|
|
607
|
+
let body;
|
|
608
|
+
if (!mirror) {
|
|
609
|
+
body = audit.tag
|
|
610
|
+
? `[weclaude] /audit: 未找到 tag \`${audit.tag}\` 对应的 Claude 会话。`
|
|
611
|
+
: `[weclaude] /audit: 未找到 ${who} 绑定的 Claude 会话。先 \`/new\` 或用 \`weclaude mirror\` 绑定后再试。`;
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
try {
|
|
615
|
+
body = computeAuditReport({
|
|
616
|
+
sessionId: mirror.sessionId,
|
|
617
|
+
jsonlPath: mirror.jsonlPath,
|
|
618
|
+
tag: audit.tag || undefined,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
catch (e) {
|
|
622
|
+
body = `[weclaude] /audit failed: ${e.message}`;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
await replyText(frame, msg, who, body);
|
|
626
|
+
return { stop: true };
|
|
627
|
+
}
|
|
628
|
+
if (tryConsumeClaim(text, basePrincipal)) {
|
|
629
|
+
log.info({ who: basePrincipal }, "claim consumed — bootstrapping defaultChat + allowFrom");
|
|
630
|
+
try {
|
|
631
|
+
persistClaim(cfg, sourcePath, basePrincipal);
|
|
632
|
+
}
|
|
633
|
+
catch (e) {
|
|
634
|
+
log.error({ err: e.message }, "persistClaim failed");
|
|
635
|
+
}
|
|
636
|
+
await ackClaim(client, basePrincipal, log);
|
|
637
|
+
await replyText(frame, msg, who, "✅ done");
|
|
638
|
+
return { stop: true };
|
|
639
|
+
}
|
|
640
|
+
// Auto-claim: empty allowFrom + DM ⇒ first sender becomes super admin.
|
|
641
|
+
// Falls through so the same message is also dispatched as a real prompt —
|
|
642
|
+
// user types "hi" and gets both the promotion ack and the assistant reply.
|
|
643
|
+
const isDm = !(msg.chattype === "group" && msg.chatid);
|
|
644
|
+
if (shouldAutoClaim(cfg, isDm)) {
|
|
645
|
+
log.info({ who: basePrincipal }, "auto-claim — empty allowFrom, first DM sender promoted");
|
|
646
|
+
try {
|
|
647
|
+
persistClaim(cfg, sourcePath, basePrincipal);
|
|
648
|
+
}
|
|
649
|
+
catch (e) {
|
|
650
|
+
log.error({ err: e.message }, "auto-claim persistClaim failed");
|
|
651
|
+
}
|
|
652
|
+
await ackAutoClaim(client, basePrincipal, log);
|
|
653
|
+
// fall through to dispatch
|
|
654
|
+
}
|
|
655
|
+
if (!isAllowed(cfg, auths) && !isMirrorTarget(bridge, who)) {
|
|
656
|
+
log.warn({ from: who, auths }, "drop: not in allowFrom");
|
|
657
|
+
try {
|
|
658
|
+
await client.replyStream(frame, msg.msgid, `未授权\n${renderIds(msg, cfg)}\n请将上述任一权限id加入 config 的 wrc.allowFrom 数组`, true);
|
|
659
|
+
}
|
|
660
|
+
catch { /* ignore */ }
|
|
661
|
+
return { stop: true };
|
|
662
|
+
}
|
|
663
|
+
// Authorized `/usage` — real subscription rate-limit %, scraped from Claude
|
|
664
|
+
// Code's own `/usage` TUI (/cost can only estimate cost/tokens; the true
|
|
665
|
+
// limit % is server-side). Drives a throwaway isolated pane (~10s) → interim
|
|
666
|
+
// ack, then replace with the result.
|
|
667
|
+
if (isUsageCommand(text)) {
|
|
668
|
+
log.info({ who }, "/usage panel: start");
|
|
669
|
+
try {
|
|
670
|
+
await client.replyStream(frame, msg.msgid, withTagPrefix(who, "⏳ 正在拉起 /usage 面板查询真实额度…"), false);
|
|
671
|
+
}
|
|
672
|
+
catch (e) {
|
|
673
|
+
log.warn({ err: e.message }, "/usage: interim ack failed");
|
|
674
|
+
}
|
|
675
|
+
let body;
|
|
676
|
+
try {
|
|
677
|
+
const report = await captureQuota(cfg, log);
|
|
678
|
+
// Wrap in a fenced code block so WeCom renders the aligned panel in a
|
|
679
|
+
// monospace bubble (columns stay lined up).
|
|
680
|
+
body = "```\n" + renderQuotaReport(report) + "\n```";
|
|
681
|
+
log.info({ who, limits: report.limits.length }, "/usage panel: done");
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
body = `[weclaude] /usage failed: ${e.message}`;
|
|
685
|
+
log.error({ who, err: e.message }, "/usage panel: failed");
|
|
686
|
+
}
|
|
687
|
+
await replyText(frame, msg, who, body);
|
|
688
|
+
return { stop: true };
|
|
689
|
+
}
|
|
690
|
+
// Authorized `/new` — spawn a tmux+claude pair and attach it to this chat.
|
|
691
|
+
// Runs BEFORE the mirror-not-attached short-circuit so it works as the
|
|
692
|
+
// very first message from a fresh user. When routed with a `#tag`, the
|
|
693
|
+
// tag becomes both the mirror-store key and the tmux window name.
|
|
694
|
+
if (isNewCommand(text)) {
|
|
695
|
+
const reply = await autoSpawnAndAttach(who, cliOfNewCommand(text));
|
|
696
|
+
await replyText(frame, msg, who, reply);
|
|
697
|
+
return { stop: true };
|
|
698
|
+
}
|
|
699
|
+
// 事件订阅 / 广播 / 定时。授权后即可使用,任意 principal 可对本会话订阅/退订,
|
|
700
|
+
// 广播权限同 allowFrom(已过上面的 auth 门)。命令不进入 bridge dispatch,
|
|
701
|
+
// 用轻量 replyStream 回执。
|
|
702
|
+
const topicHandled = await handleTopicCommand(frame, msg, who, text);
|
|
703
|
+
if (topicHandled)
|
|
704
|
+
return { stop: true };
|
|
705
|
+
// Authorized `/stop` — Esc the live pane to interrupt whatever Claude is
|
|
706
|
+
// currently doing. Mirror-mode only; bails cleanly when no attachment.
|
|
707
|
+
if (isStopCommand(text)) {
|
|
708
|
+
if (!("interruptPane" in bridge)) {
|
|
709
|
+
await replyText(frame, msg, who, "[weclaude] /stop only available in mirror mode");
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
const r = await bridge.interruptPane(who);
|
|
713
|
+
await replyText(frame, msg, who, r.ok ? "✅ Esc sent" : `[weclaude] /stop failed: ${r.reason ?? "unknown"}`);
|
|
714
|
+
}
|
|
715
|
+
return { stop: true };
|
|
716
|
+
}
|
|
717
|
+
// Authorized `/n` — send a bare Enter to the live pane. Confirms a prompt /
|
|
718
|
+
// dismisses a "press enter to continue", or submits whatever's already in
|
|
719
|
+
// the input box. Mirror-mode only; bails cleanly when no attachment.
|
|
720
|
+
if (isEnterCommand(text)) {
|
|
721
|
+
if (!("submitPane" in bridge)) {
|
|
722
|
+
await replyText(frame, msg, who, "[weclaude] /n only available in mirror mode");
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
const r = await bridge.submitPane(who);
|
|
726
|
+
await replyText(frame, msg, who, r.ok ? "✅ Enter sent" : `[weclaude] /n failed: ${r.reason ?? "unknown"}`);
|
|
727
|
+
}
|
|
728
|
+
return { stop: true };
|
|
729
|
+
}
|
|
730
|
+
// Authorized `/reveal` — switch the attached tmux client to this session's
|
|
731
|
+
// pane so the user lands in the terminal showing the live TUI. Mirror-mode
|
|
732
|
+
// only; routed by `#tag` like any other session command.
|
|
733
|
+
if (isRevealCommand(text)) {
|
|
734
|
+
if (!("revealPane" in bridge)) {
|
|
735
|
+
await replyText(frame, msg, who, "[weclaude] /reveal only available in mirror mode");
|
|
736
|
+
}
|
|
737
|
+
else {
|
|
738
|
+
const r = await bridge.revealPane(who);
|
|
739
|
+
await replyText(frame, msg, who, r.ok ? "✅ 已切到本会话的 tmux 窗口" : `[weclaude] /reveal failed: ${r.reason ?? "unknown"}`);
|
|
740
|
+
}
|
|
741
|
+
return { stop: true };
|
|
742
|
+
}
|
|
743
|
+
// /cfgsync [apply] — 3-way merge of the bound project's per-CLI config
|
|
744
|
+
// trees (CLAUDE.md ⇄ CODEBUDDY.md, .claude/{skills,commands,agents} ⇄
|
|
745
|
+
// .codebuddy/...). Writes files, so it sits AFTER the allowFrom gate.
|
|
746
|
+
const cs = parseCfgSyncCommand(text);
|
|
747
|
+
if (cs) {
|
|
748
|
+
const cwd = "getCwd" in bridge ? bridge.getCwd(who).runningCwd : expandHome(cfg.wrc.cwd);
|
|
749
|
+
let body;
|
|
750
|
+
try {
|
|
751
|
+
body = renderSyncReport(await syncProjectConfig(cwd, cs.apply));
|
|
752
|
+
}
|
|
753
|
+
catch (e) {
|
|
754
|
+
body = `[weclaude] /cfgsync failed: ${e.message}`;
|
|
755
|
+
}
|
|
756
|
+
log.info({ who, cwd, apply: cs.apply }, "/cfgsync");
|
|
757
|
+
await replyText(frame, msg, who, body);
|
|
758
|
+
return { stop: true };
|
|
759
|
+
}
|
|
760
|
+
// /peers — this chat's own session roster (default + `#tag` siblings), with
|
|
761
|
+
// live busy state. Read-only, mirror-mode only.
|
|
762
|
+
if (isPeersCommand(text)) {
|
|
763
|
+
if (!("peers" in bridge)) {
|
|
764
|
+
await replyText(frame, msg, who, "[weclaude] /peers only available in mirror mode");
|
|
765
|
+
return { stop: true };
|
|
766
|
+
}
|
|
767
|
+
let body;
|
|
768
|
+
try {
|
|
769
|
+
body = renderPeers(await bridge.peers(who));
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
body = `[weclaude] /peers failed: ${e.message}`;
|
|
773
|
+
}
|
|
774
|
+
await replyText(frame, msg, who, body);
|
|
775
|
+
return { stop: true };
|
|
776
|
+
}
|
|
777
|
+
// /sessions [arg] — list live Claude sessions, or switch the mirror to one.
|
|
778
|
+
// Bare lists; with an arg (emoji / sessionId / ≥6-char prefix) it re-points
|
|
779
|
+
// THIS chat's mirror at the matched session. Reuses the same scan+attach
|
|
780
|
+
// path as the /sessions/switch route so IM and MCP behave identically.
|
|
781
|
+
const sc = parseSessionsCommand(text);
|
|
782
|
+
if (sc) {
|
|
783
|
+
if (!("attach" in bridge)) {
|
|
784
|
+
await replyText(frame, msg, who, "[weclaude] /sessions only available in mirror mode");
|
|
785
|
+
return { stop: true };
|
|
786
|
+
}
|
|
787
|
+
let sessions = [];
|
|
788
|
+
try {
|
|
789
|
+
sessions = await scanClaudeSessions();
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
log.error({ err: e.message }, "/sessions scan failed");
|
|
793
|
+
}
|
|
794
|
+
// Resolve which session is currently mirrored to THIS chat.
|
|
795
|
+
const currentSid = bridge.status().mirrors.find((mm) => mm.target === who)?.sessionId ?? "";
|
|
796
|
+
if (!sc.arg) {
|
|
797
|
+
await replyText(frame, msg, who, renderSessionsList(sessions, currentSid));
|
|
798
|
+
return { stop: true };
|
|
799
|
+
}
|
|
800
|
+
const hit = matchSession(sessions, sc.arg);
|
|
801
|
+
if (!hit) {
|
|
802
|
+
const avail = sessions.map((s) => `${s.label || "▫️"} ${s.sessionId.slice(0, 8)}`).join("、") || "无";
|
|
803
|
+
await replyText(frame, msg, who, `[weclaude] 未找到会话 \`${sc.arg}\`。可用:${avail}`);
|
|
804
|
+
return { stop: true };
|
|
805
|
+
}
|
|
806
|
+
if (hit.sessionId === currentSid) {
|
|
807
|
+
await replyText(frame, msg, who, `[weclaude] 已经在该会话 ${hit.label} \`${hit.sessionId.slice(0, 8)}\``);
|
|
808
|
+
return { stop: true };
|
|
809
|
+
}
|
|
810
|
+
const att = bridge.attach({ sessionId: hit.sessionId, jsonlPath: hit.jsonlPath, target: who, tmuxPane: hit.tmuxPane, tmuxSession: hit.tmuxSession, cwd: hit.cwd });
|
|
811
|
+
await replyText(frame, msg, who, att.ok
|
|
812
|
+
? `✅ 已切到 ${hit.label} \`${hit.sessionId.slice(0, 8)}\` (${hit.cwd})`
|
|
813
|
+
: `[weclaude] 切换失败: ${att.reason ?? "unknown"}`);
|
|
814
|
+
return { stop: true };
|
|
815
|
+
}
|
|
816
|
+
// Mirror mode but no Claude session attached for this chat yet. Since the
|
|
817
|
+
// sender is already in allowFrom, we treat that authorization as license
|
|
818
|
+
// to auto-spawn: this inbound becomes both the binding signal and the
|
|
819
|
+
// first prompt — attach, then fall through to dispatch.
|
|
820
|
+
if ("hasMirrorTarget" in bridge && !bridge.hasMirrorTarget(who)) {
|
|
821
|
+
const reply = await ensureSession(who);
|
|
822
|
+
if (!reply.startsWith("✅")) {
|
|
823
|
+
await replyText(frame, msg, who, reply);
|
|
824
|
+
return { stop: true };
|
|
825
|
+
}
|
|
826
|
+
// attached — fall through to dispatch
|
|
827
|
+
}
|
|
828
|
+
return { stop: false };
|
|
829
|
+
};
|
|
830
|
+
const send = async (frame, msg, who, text, images = []) => {
|
|
831
|
+
try {
|
|
832
|
+
await bridge.dispatch({ principal: who, text, images, frame, streamId: msg.msgid });
|
|
833
|
+
}
|
|
834
|
+
catch (e) {
|
|
835
|
+
log.error({ err: e.message }, "bridge dispatch failed");
|
|
836
|
+
try {
|
|
837
|
+
await client.replyStream(frame, msg.msgid, withTagHeader(who, `[weclaude] error: ${e.message}`), true);
|
|
838
|
+
}
|
|
839
|
+
catch { /* ignore */ }
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
client.on("message.text", async (frame) => {
|
|
843
|
+
const msg = frame.body;
|
|
844
|
+
if (!msg)
|
|
845
|
+
return;
|
|
846
|
+
const { text, tag, promoted } = resolveTextBody(msg);
|
|
847
|
+
const who = sessionKey(chatPrincipal(msg), tag);
|
|
848
|
+
log.info({ msgid: msg.msgid, len: text.length, tag, hasQuote: !!msg.quote, promoted }, "rx text");
|
|
849
|
+
const { stop } = await gate(frame, msg, text, who);
|
|
850
|
+
if (stop)
|
|
851
|
+
return;
|
|
852
|
+
await send(frame, msg, who, text);
|
|
853
|
+
});
|
|
854
|
+
client.on("message.image", async (frame) => {
|
|
855
|
+
const msg = frame.body;
|
|
856
|
+
if (!msg)
|
|
857
|
+
return;
|
|
858
|
+
log.info({ msgid: msg.msgid, hasQuote: !!msg.quote }, "rx image");
|
|
859
|
+
// Images carry no text — always route to the chat's default session.
|
|
860
|
+
const who = chatPrincipal(msg);
|
|
861
|
+
const { stop } = await gate(frame, msg, "", who);
|
|
862
|
+
if (stop)
|
|
863
|
+
return;
|
|
864
|
+
const path = await downloadToInbox({ client, log, inboxDir }, msg.image.url, msg.image.aeskey, msg.msgid, 0);
|
|
865
|
+
if (!path) {
|
|
866
|
+
try {
|
|
867
|
+
await client.replyStream(frame, msg.msgid, "[weclaude] 图片下载失败", true);
|
|
868
|
+
}
|
|
869
|
+
catch { /* ignore */ }
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
// Pass the path through the bridge's `images` channel — mirror mode pumps
|
|
873
|
+
// each via macOS clipboard + Ctrl+V into the live TTY (matches Claude
|
|
874
|
+
// Code's documented image paste flow → image content block, no Read tool
|
|
875
|
+
// turn). Spawn-mode falls back to `@<path>` automatically.
|
|
876
|
+
await send(frame, msg, who, withQuote(msg, ""), [path]);
|
|
877
|
+
});
|
|
878
|
+
client.on("message.mixed", async (frame) => {
|
|
879
|
+
const msg = frame.body;
|
|
880
|
+
if (!msg)
|
|
881
|
+
return;
|
|
882
|
+
log.info({ msgid: msg.msgid, items: msg.mixed?.msg_item?.length, hasQuote: !!msg.quote }, "rx mixed");
|
|
883
|
+
// Concatenate all text items to sniff a leading `#tag`, then strip it from
|
|
884
|
+
// the effective body before forwarding to Claude.
|
|
885
|
+
const rawText = (msg.mixed?.msg_item ?? [])
|
|
886
|
+
.filter((it) => it.msgtype === "text")
|
|
887
|
+
.map((it) => it.text?.content ?? "")
|
|
888
|
+
.join("\n");
|
|
889
|
+
const { tag } = parseTag(maybeStripMentions(msg, rawText));
|
|
890
|
+
const who = sessionKey(chatPrincipal(msg), tag);
|
|
891
|
+
const { stop } = await gate(frame, msg, "", who);
|
|
892
|
+
if (stop)
|
|
893
|
+
return;
|
|
894
|
+
const texts = [];
|
|
895
|
+
const images = [];
|
|
896
|
+
let imgIdx = 0;
|
|
897
|
+
for (const item of msg.mixed?.msg_item ?? []) {
|
|
898
|
+
if (item.msgtype === "text" && item.text?.content) {
|
|
899
|
+
const t = maybeStripMentions(msg, item.text.content);
|
|
900
|
+
if (t)
|
|
901
|
+
texts.push(t);
|
|
902
|
+
}
|
|
903
|
+
else if (item.msgtype === "image" && item.image?.url) {
|
|
904
|
+
const path = await downloadToInbox({ client, log, inboxDir }, item.image.url, item.image.aeskey, msg.msgid, imgIdx++);
|
|
905
|
+
if (path)
|
|
906
|
+
images.push(path);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (texts.length === 0 && images.length === 0 && !msg.quote)
|
|
910
|
+
return;
|
|
911
|
+
// Strip the routing `#tag` from the concatenated body before forwarding —
|
|
912
|
+
// it was consumed by parseTag above; leaving it in would leak into Claude.
|
|
913
|
+
const joined = texts.join("\n");
|
|
914
|
+
const bodyForClaude = tag ? parseTag(joined).cleaned : joined;
|
|
915
|
+
await send(frame, msg, who, withQuote(msg, bodyForClaude), images);
|
|
916
|
+
});
|
|
917
|
+
// template_card_event is handled in approval module; no listener here.
|
|
918
|
+
};
|
|
919
|
+
//# sourceMappingURL=inbound.js.map
|