@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.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/assets/wx.svg +5 -0
- package/package.json +9 -4
- package/pf-plugin.toml +18 -0
- package/pi/config.mjs +26 -0
- package/pi/design-002.test.mjs +323 -0
- package/pi/fixtures/ec-golden.json +1 -0
- package/pi/fixtures/getupdates-real.json +37 -0
- package/pi/fixtures/qr-golden.json +1 -0
- package/pi/hook.mjs +231 -0
- package/pi/hook.test.mjs +351 -0
- package/pi/index.mjs +648 -0
- package/pi/index.test.mjs +685 -0
- package/pi/inject.mjs +41 -0
- package/pi/inject.test.mjs +55 -0
- package/pi/jsonx.mjs +64 -0
- package/pi/lock.mjs +171 -0
- package/pi/messages.mjs +160 -0
- package/pi/poller.mjs +364 -0
- package/pi/poller.test.mjs +191 -0
- package/pi/qr.mjs +454 -0
- package/pi/qr.test.mjs +101 -0
- package/pi/register.mjs +236 -0
- package/pi/register.test.mjs +232 -0
- package/pi/reply.mjs +49 -0
- package/pi/state.mjs +248 -0
- package/pi/state.test.mjs +138 -0
- package/pi/transport.mjs +335 -0
- package/pi/transport.test.mjs +327 -0
- package/pi/wire.mjs +219 -0
- package/pi/wire.test.mjs +29 -0
- package/plugin.wasm +0 -0
- package/README.md +0 -3
package/pi/inject.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// inject —— 微信入站最小路由(004 §5.2 定案):纯文本透传 + /stop + /status。
|
|
2
|
+
// 002 新版「pf-wxbot 只负责收发消息」:菜单/审批/放行门/任务模型项目选择全裁。
|
|
3
|
+
// 文本 → 结构(route)与语义执行(run_action)分离,deps 注入 = 单测面。
|
|
4
|
+
|
|
5
|
+
/** 路由:入站文本 → 动作描述。
|
|
6
|
+
* - "/stop"|"/停止" → abort(停当前任务)
|
|
7
|
+
* - "/status"|"/状态" → status(回执运行状态)
|
|
8
|
+
* - 其余文本(含未知 /命令) → prompt 原文透传(pi 会话自有命令体系,不吞)
|
|
9
|
+
* - 空串 → ignore */
|
|
10
|
+
export function route(text) {
|
|
11
|
+
const trimmed = String(text ?? "").trim();
|
|
12
|
+
if (trimmed === "") return { kind: "ignore" };
|
|
13
|
+
if (trimmed.startsWith("/")) {
|
|
14
|
+
const name = trimmed.slice(1).split(/\s/)[0].toLowerCase();
|
|
15
|
+
if (name === "stop" || name === "停止") return { kind: "abort" };
|
|
16
|
+
if (name === "status" || name === "状态") return { kind: "status" };
|
|
17
|
+
}
|
|
18
|
+
return { kind: "prompt", text };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 动作执行(ops 注入)。 */
|
|
22
|
+
export function run_action(action, ops) {
|
|
23
|
+
switch (action?.kind) {
|
|
24
|
+
case "prompt":
|
|
25
|
+
ops.prompt(action.text);
|
|
26
|
+
return;
|
|
27
|
+
case "abort":
|
|
28
|
+
ops.abort();
|
|
29
|
+
return;
|
|
30
|
+
case "status":
|
|
31
|
+
ops.send(ops.statusText());
|
|
32
|
+
return;
|
|
33
|
+
default:
|
|
34
|
+
return; // ignore / unknown:静默
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 装配面(与旧 create_inject 同形,依赖面缩到最小)。 */
|
|
39
|
+
export function create_inject(_deps = {}) {
|
|
40
|
+
return { route, run_action };
|
|
41
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// inject 最小路由单测(004 §5.2):纯文本透传 + /stop + /status,其余不吞。
|
|
2
|
+
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
import { route, run_action, create_inject } from "./inject.mjs";
|
|
7
|
+
|
|
8
|
+
test("plain_text_passes_through_verbatim", () => {
|
|
9
|
+
assert.deepEqual(route("帮我跑下测试"), { kind: "prompt", text: "帮我跑下测试" });
|
|
10
|
+
// 透传保持原文(不 trim 参数,与旧 command.mjs 口径一致)
|
|
11
|
+
assert.deepEqual(route(" 前后空白 "), { kind: "prompt", text: " 前后空白 " });
|
|
12
|
+
assert.deepEqual(route("/model glm-5"), { kind: "prompt", text: "/model glm-5" }, "未知 /命令不吞");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("stop_and_status_commands", () => {
|
|
16
|
+
assert.deepEqual(route("/stop"), { kind: "abort" });
|
|
17
|
+
assert.deepEqual(route("/停止"), { kind: "abort" });
|
|
18
|
+
assert.deepEqual(route(" /STOP "), { kind: "abort" }, "命令名大小写不敏感");
|
|
19
|
+
assert.deepEqual(route("/status"), { kind: "status" });
|
|
20
|
+
assert.deepEqual(route("/状态"), { kind: "status" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("empty_text_ignored", () => {
|
|
24
|
+
assert.deepEqual(route(""), { kind: "ignore" });
|
|
25
|
+
assert.deepEqual(route(" "), { kind: "ignore" });
|
|
26
|
+
assert.deepEqual(route(null), { kind: "ignore" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("run_action_dispatches_to_ops", () => {
|
|
30
|
+
const calls = { prompt: [], abort: 0, send: [], statusText: 0 };
|
|
31
|
+
const ops = {
|
|
32
|
+
prompt: (t) => calls.prompt.push(t),
|
|
33
|
+
abort: () => (calls.abort += 1),
|
|
34
|
+
send: (t) => calls.send.push(t),
|
|
35
|
+
statusText: () => {
|
|
36
|
+
calls.statusText += 1;
|
|
37
|
+
return "STATUS";
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
run_action({ kind: "prompt", text: "hi" }, ops);
|
|
41
|
+
run_action({ kind: "abort" }, ops);
|
|
42
|
+
run_action({ kind: "status" }, ops);
|
|
43
|
+
run_action({ kind: "ignore" }, ops);
|
|
44
|
+
run_action({ kind: "whatever" }, ops); // unknown:静默
|
|
45
|
+
assert.deepEqual(calls.prompt, ["hi"]);
|
|
46
|
+
assert.equal(calls.abort, 1);
|
|
47
|
+
assert.deepEqual(calls.send, ["STATUS"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("create_inject_binds_same_surface", () => {
|
|
51
|
+
const inject = create_inject({});
|
|
52
|
+
assert.equal(typeof inject.route, "function");
|
|
53
|
+
assert.equal(typeof inject.run_action, "function");
|
|
54
|
+
assert.deepEqual(inject.route("/stop"), { kind: "abort" });
|
|
55
|
+
});
|
package/pi/jsonx.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// jsonx —— 微信 iLink 响应的大整数安全 JSON 解析(零依赖,pi ext 自持)。
|
|
2
|
+
//
|
|
3
|
+
// 协议坑:getupdates 的 message_id 是 19 位整数(如 7513480159106982024),
|
|
4
|
+
// 超出 Number.MAX_SAFE_INTEGER(2^53-1 ≈ 9.007e15),JSON.parse 会**静默丢精度**
|
|
5
|
+
// (尾部塌成 …988 之类的近似值)。Rust 参考实现(wxprobe wire.rs::str_of)走
|
|
6
|
+
// serde_json::Number 天然保精度;JS 没有等价物,这里在 parse 前做一次线性扫描,
|
|
7
|
+
// 把超出安全区间的**裸整数字面量**包成哨兵字符串,读侧 unbig() 还原 ——
|
|
8
|
+
// poller 的 str_of/first_str 由此与 Rust 行为逐字段对齐(msg_id 精确到个位)。
|
|
9
|
+
//
|
|
10
|
+
// 哨兵以 NUL 开头:微信是纯文本通道,业务文本不会出现 NUL,不会相撞。
|
|
11
|
+
// 出站方向(sendmessage)手工拼参数,不经过本模块,哨兵不外泄。
|
|
12
|
+
|
|
13
|
+
const SENTINEL = "\u0000BI:";
|
|
14
|
+
|
|
15
|
+
const isDigit = (c) => c >= "0" && c <= "9";
|
|
16
|
+
// 数字 token 的续字符(宽松抓取;合法性由 JSON.parse 把关,这里只决定哨兵与否)
|
|
17
|
+
const isNumPart = (c) => isDigit(c) || c === "." || c === "e" || c === "E" || c === "+" || c === "-";
|
|
18
|
+
|
|
19
|
+
/** parse 前:把超出 Number 安全区间的裸整数包成哨兵字符串(字符串字面量原样跳过)。 */
|
|
20
|
+
export function guardBigInt(text) {
|
|
21
|
+
let out = "";
|
|
22
|
+
let i = 0;
|
|
23
|
+
const n = text.length;
|
|
24
|
+
while (i < n) {
|
|
25
|
+
const c = text[i];
|
|
26
|
+
if (c === '"') {
|
|
27
|
+
// 原样拷贝整个字符串字面量(含转义),里面的数字不动
|
|
28
|
+
let j = i + 1;
|
|
29
|
+
while (j < n) {
|
|
30
|
+
if (text[j] === "\\") j += 2;
|
|
31
|
+
else if (text[j] === '"') { j += 1; break; }
|
|
32
|
+
else j += 1;
|
|
33
|
+
}
|
|
34
|
+
out += text.slice(i, j);
|
|
35
|
+
i = j;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (isDigit(c) || c === "-") {
|
|
39
|
+
let j = i + 1;
|
|
40
|
+
while (j < n && isNumPart(text[j])) j += 1;
|
|
41
|
+
const tok = text.slice(i, j);
|
|
42
|
+
if (/^-?\d+$/.test(tok) && !Number.isSafeInteger(Number(tok))) {
|
|
43
|
+
out += JSON.stringify(SENTINEL + tok); // 超安全区间的整数 → 哨兵字符串
|
|
44
|
+
} else {
|
|
45
|
+
out += tok; // 浮点 / 安全整数 / 病态 token 原样
|
|
46
|
+
}
|
|
47
|
+
i = j;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
out += c;
|
|
51
|
+
i += 1;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 读侧:哨兵字符串 → 原始数字文本;非哨兵原样返回。 */
|
|
57
|
+
export function unbig(v) {
|
|
58
|
+
return typeof v === "string" && v.startsWith(SENTINEL) ? v.slice(SENTINEL.length) : v;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 大整数安全 JSON.parse。 */
|
|
62
|
+
export function parse(text) {
|
|
63
|
+
return JSON.parse(guardBigInt(text));
|
|
64
|
+
}
|
package/pi/lock.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// lock —— 本地租约锁,自 PF 仓 wxprobe src/lock.rs **逐条移植**(JS 版)。
|
|
2
|
+
// 保证同一个 bot_token 只有一个轮询者在跑(进程即单例)。
|
|
3
|
+
//
|
|
4
|
+
// 三件套语义照搬 ZCode `channelRuntime.ts:86-240`:
|
|
5
|
+
// - `owner.json`(pid/botId/nonce/createdAt)+ `lease-<nonce>`(mtime 当心跳)
|
|
6
|
+
// 写在唯一临时目录里,**再原子 rename 成正式锁目录**——竞争者不会把没
|
|
7
|
+
// 初始化完的锁误判成 stale;
|
|
8
|
+
// - 心跳每 HEARTBEAT_MS 刷一次 `lease-<nonce>` 的 mtime;
|
|
9
|
+
// - 抢占时只有「租约新鲜」才让路,否则删掉陈旧锁目录重试。
|
|
10
|
+
//
|
|
11
|
+
// 与 ZCode 的唯一差异(wxprobe 同款):只用**租约新鲜度**判死活——心跳 10s
|
|
12
|
+
// 刷一次 mtime,租约 30s 未刷在本机语义上等价于持有者已死。
|
|
13
|
+
//
|
|
14
|
+
// **锁根必须是全局目录**(`~/.pi-flash/bots-runtime-locks/`):真机历史上
|
|
15
|
+
// PF 自带轮询器(remote_control / wxprobe 探针)曾与本插件抢同一个 bot——
|
|
16
|
+
// 那两个实现已移除(微信通信现只有本插件),但锁位仍是**跨实现共享约定**:
|
|
17
|
+
// 任何同 token 的轮询者(含未来重启的宿主 sidecar、诊断工具)天然互斥。
|
|
18
|
+
// namespace `weixin-polling` + key `sha256(token)` **不得改名**:换名会让在役
|
|
19
|
+
// 旧锁目录失效,同一 token 就可能出现两个轮询者(重复消费/游标互踩)。
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import {
|
|
23
|
+
existsSync, mkdirSync, readFileSync, renameSync,
|
|
24
|
+
rmSync, statSync, utimesSync, writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { sha256_hex } from "./wire.mjs";
|
|
29
|
+
|
|
30
|
+
export const LEASE_MS = 30_000;
|
|
31
|
+
export const HEARTBEAT_MS = 10_000;
|
|
32
|
+
/** 清理重试档(对齐 ZCode BOT_RUNTIME_LOCK_CLEANUP_RETRY_DELAYS_MS)。 */
|
|
33
|
+
const CLEANUP_RETRY_DELAYS_MS = [100, 200, 300];
|
|
34
|
+
/** 锁目录:`<base>/bots-runtime-locks/<namespace>/<sha256(key)>.lock`。 */
|
|
35
|
+
const LOCK_ROOT = "bots-runtime-locks";
|
|
36
|
+
|
|
37
|
+
/** 锁根:与 wxprobe `state::base_dir()/bots-runtime-locks` 同位(PI_FLASH_DIR 优先)。 */
|
|
38
|
+
export function lock_root() {
|
|
39
|
+
const base = process.env.PI_FLASH_DIR || join(homedir(), ".pi-flash");
|
|
40
|
+
return join(base, LOCK_ROOT);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class BotRuntimeLock {
|
|
44
|
+
constructor(lockPath, owner, heartbeatMs) {
|
|
45
|
+
this.lockPath = lockPath;
|
|
46
|
+
this.owner = owner;
|
|
47
|
+
this.released = false;
|
|
48
|
+
// 心跳:只有超时才刷 lease;release 时 clearInterval 立即停(不走满 10s)
|
|
49
|
+
this.timer = setInterval(() => touch(join(lockPath, `lease-${owner.nonce}`)), Math.max(heartbeatMs, 1));
|
|
50
|
+
this.timer.unref?.(); // 不阻止进程退出
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 停心跳并只在 owner 仍是自己时删锁目录(幂等)。 */
|
|
54
|
+
release() {
|
|
55
|
+
if (this.released) return;
|
|
56
|
+
this.released = true;
|
|
57
|
+
clearInterval(this.timer);
|
|
58
|
+
if (read_owner(this.lockPath)?.nonce === this.owner.nonce) {
|
|
59
|
+
try { remove_dir_with_retry(this.lockPath); } catch { /* 下次靠租约过期 */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 抢锁。成功 → BotRuntimeLock;被别的轮询者持有 → null;IO 异常 → throw。
|
|
66
|
+
* heartbeatMs/leaseMs 可注入(单测用短时长)。async:重试退避走真 sleep,不忙等。
|
|
67
|
+
*/
|
|
68
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
69
|
+
|
|
70
|
+
export async function acquire(namespace, lockKey, botId, { heartbeatMs = HEARTBEAT_MS, leaseMs = LEASE_MS } = {}) {
|
|
71
|
+
const lockPath = join(lock_root(), namespace, `${sha256_hex(lockKey.trim())}.lock`);
|
|
72
|
+
const owner = {
|
|
73
|
+
pid: process.pid,
|
|
74
|
+
botId,
|
|
75
|
+
nonce: `${Date.now().toString(16)}${process.pid.toString(16)}`,
|
|
76
|
+
createdAt: now_ms(),
|
|
77
|
+
};
|
|
78
|
+
mkdirSync(join(lock_root(), namespace), { recursive: true });
|
|
79
|
+
|
|
80
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
81
|
+
const pending = `${lockPath}.${owner.nonce}.pending`;
|
|
82
|
+
try { rmSync(pending, { recursive: true, force: true }); } catch { /* ZCode finally 前置清理 */ }
|
|
83
|
+
|
|
84
|
+
// 锁目录与 owner 文件必须作为一个完整状态对外可见(ZCode 同款 bugfix)
|
|
85
|
+
mkdirSync(pending);
|
|
86
|
+
write_owner(pending, owner);
|
|
87
|
+
writeFileSync(join(pending, `lease-${owner.nonce}`), "");
|
|
88
|
+
|
|
89
|
+
let outcome;
|
|
90
|
+
try {
|
|
91
|
+
renameSync(pending, lockPath);
|
|
92
|
+
outcome = { ok: true };
|
|
93
|
+
} catch (renameErr) {
|
|
94
|
+
outcome = { ok: false, renameErr };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try { rmSync(pending, { recursive: true, force: true }); } catch { /* pending 任何分支都要清 */ }
|
|
98
|
+
|
|
99
|
+
if (outcome.ok) return new BotRuntimeLock(lockPath, owner, heartbeatMs);
|
|
100
|
+
|
|
101
|
+
// Windows:rename 到已存在目录 = EPERM;POSIX = ENOTEMPTY。统一按
|
|
102
|
+
// 「正式锁路径确实存在才进竞争分支」处理,避免把权限问题误判成可接管。
|
|
103
|
+
if (!existsSync(lockPath)) {
|
|
104
|
+
throw new Error(`rename 锁失败(锁路径不存在,非竞争): ${outcome.renameErr?.message}`);
|
|
105
|
+
}
|
|
106
|
+
const current = read_owner(lockPath);
|
|
107
|
+
if (!current) {
|
|
108
|
+
await remove_dir_with_retry(lockPath);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// 竞争分支:只有「租约仍新鲜」才让路
|
|
112
|
+
const leaseAt = lease_mtime(lockPath, current.nonce);
|
|
113
|
+
const age = now_ms() - Math.max(leaseAt, 0);
|
|
114
|
+
if (leaseAt > 0 && age < leaseMs) return null;
|
|
115
|
+
await remove_dir_with_retry(lockPath);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function now_ms() {
|
|
121
|
+
return Date.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function write_owner(dir, owner) {
|
|
125
|
+
writeFileSync(
|
|
126
|
+
join(dir, "owner.json"),
|
|
127
|
+
JSON.stringify({ pid: owner.pid, botId: owner.botId, nonce: owner.nonce, createdAt: owner.createdAt }, null, 2),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function read_owner(lockPath) {
|
|
132
|
+
try {
|
|
133
|
+
const v = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
|
|
134
|
+
if (typeof v.pid !== "number" || typeof v.botId !== "string" || typeof v.nonce !== "string") return null;
|
|
135
|
+
return v;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function lease_mtime(lockPath, nonce) {
|
|
142
|
+
try {
|
|
143
|
+
return statSync(join(lockPath, `lease-${nonce}`)).mtimeMs;
|
|
144
|
+
} catch {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 刷新 lease 文件 mtime(等价 ZCode `utimes(leasePath, now, now)`)。 */
|
|
150
|
+
export function touch(path) {
|
|
151
|
+
try {
|
|
152
|
+
utimesSync(path, new Date(), new Date());
|
|
153
|
+
} catch {
|
|
154
|
+
try { writeFileSync(path, ""); } catch { /* 尽力而为 */ }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function remove_dir_with_retry(path) {
|
|
159
|
+
let lastErr = null;
|
|
160
|
+
for (let attempt = 0; attempt <= CLEANUP_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
161
|
+
try {
|
|
162
|
+
rmSync(path, { recursive: true });
|
|
163
|
+
return;
|
|
164
|
+
} catch (e) {
|
|
165
|
+
if (e.code === "ENOENT") return;
|
|
166
|
+
lastErr = e;
|
|
167
|
+
if (attempt < CLEANUP_RETRY_DELAYS_MS.length) await sleep(CLEANUP_RETRY_DELAYS_MS[attempt]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`清理锁目录失败 ${path}: ${lastErr?.message}`);
|
|
171
|
+
}
|
package/pi/messages.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// messages —— 微信侧文案表,自 wxprobe src/format/messages.rs 子集移植(JS 版)。
|
|
2
|
+
// 表**按 zh-CN 源串作键**(P2 决定),值 = [zh-CN, en];zh-TW 沿用 zh-CN
|
|
3
|
+
// (pipeline.rs help_title 同款口径)。inject/extui 消费的条目都从这里取,
|
|
4
|
+
// 不新造句子(ZCode 现成文案原则)。
|
|
5
|
+
|
|
6
|
+
export function normalize_lang(lang) {
|
|
7
|
+
const s = String(lang ?? "").toLowerCase();
|
|
8
|
+
if (s === "en-us" || s === "en") return "en-US";
|
|
9
|
+
return "zh-CN"; // zh-CN / zh-TW / 缺省全落 zh-CN
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** t(lang, key, params):`{name}` 模板替换(Rust tf 对位)。表外键回退 key 本身。 */
|
|
13
|
+
export function t(lang, key, params) {
|
|
14
|
+
const idx = normalize_lang(lang) === "en-US" ? 1 : 0;
|
|
15
|
+
const row = TABLE.get(key);
|
|
16
|
+
let out = row ? row[idx] : key;
|
|
17
|
+
if (params) {
|
|
18
|
+
for (const [k, v] of Object.entries(params)) {
|
|
19
|
+
out = out.replaceAll(`{${k}}`, String(v));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── 表(zh-CN 源串 → [zh-CN, en])────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** 启停通知(002·通知;hook.mjs 从这里取,保持单源)。zh 逐字 = 002 定稿,
|
|
28
|
+
* 微信侧不分语种固定 zh(与 WX_TASK_ERROR_PREFIX 同口径);en 只进 t() 表备用。 */
|
|
29
|
+
export const WX_LINK_ON = "⚡️Pi-Flash已连接";
|
|
30
|
+
export const WX_LINK_OFF = "⚡️Pi-Flash已失联";
|
|
31
|
+
|
|
32
|
+
/** 无项目/模型态回执:放行条件(项目+模型)未满足时,需要会话的动作统一引导。 */
|
|
33
|
+
export const NO_SETUP = [
|
|
34
|
+
"请先设置项目和模型:回复 **/项目** 选择工作区、**/模型** 选择模型后再发消息。",
|
|
35
|
+
"Please set a workspace and a model first: reply **/project** to choose a workspace and **/model** to choose a model before sending messages.",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/** 档 1 尚未接入的命令回执——不静默吞掉。 */
|
|
39
|
+
export const CMD_NOT_ENABLED = ["当前 bot 未启用这个命令。", "This command is not enabled for this bot."];
|
|
40
|
+
|
|
41
|
+
/** 任务运行中回执(/新建 /任务切换 共用)。 */
|
|
42
|
+
export const TASK_RUNNING = [
|
|
43
|
+
"当前任务正在运行,稍后再试,或使用 **/停止** 停止当前任务。",
|
|
44
|
+
"A task is currently running. Try again later, or use **/stop** to stop it.",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// /帮助 标题(品牌本地化偏差 2026-10-07 用户拍板,不进 ZCode 文案表)与
|
|
48
|
+
// 九条说明(顺序 = BOT_MENU_ORDER)——键也收进 TABLE,t() 单一入口。
|
|
49
|
+
export const HELP_TITLE_KEY = "pi-flash 机器人命令:";
|
|
50
|
+
export const HELP_LINES = {
|
|
51
|
+
help: "**/帮助** — 查看这份说明",
|
|
52
|
+
status: "**/状态** — 查看工作区、模型和任务状态",
|
|
53
|
+
new: "/新建 或 /clear — 开始新的任务草稿",
|
|
54
|
+
workspace: "**/项目** — 切换工作区",
|
|
55
|
+
model: "**/模型** — 切换模型",
|
|
56
|
+
mode: "**/模式** — 切换运行模式",
|
|
57
|
+
thoughtLevel: "**/思考** — 切换思考级别",
|
|
58
|
+
reply: "**/回复** — 切换回复详细程度",
|
|
59
|
+
bind: "**/bind <code>** — 绑定当前聊天",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** /帮助 展示顺序(command.rs BOT_MENU_ORDER 同表)。 */
|
|
63
|
+
export const BOT_MENU_ORDER = [
|
|
64
|
+
"help", "status", "new", "workspace", "model", "mode", "thoughtLevel", "reply", "bind",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// 状态卡标签 / 状态值 / 菜单与选择提示
|
|
68
|
+
const TABLE = new Map(Object.entries({
|
|
69
|
+
// 启停通知(002·通知)与引导/回执句
|
|
70
|
+
"⚡️Pi-Flash已连接": ["⚡️Pi-Flash已连接", "⚡️Pi-Flash connected"],
|
|
71
|
+
"⚡️Pi-Flash已失联": ["⚡️Pi-Flash已失联", "⚡️Pi-Flash disconnected"],
|
|
72
|
+
"请先设置项目和模型:回复 **/项目** 选择工作区、**/模型** 选择模型后再发消息。": [
|
|
73
|
+
"请先设置项目和模型:回复 **/项目** 选择工作区、**/模型** 选择模型后再发消息。",
|
|
74
|
+
"Please set a workspace and a model first: reply **/project** to choose a workspace and **/model** to choose a model before sending messages.",
|
|
75
|
+
],
|
|
76
|
+
"当前 bot 未启用这个命令。": ["当前 bot 未启用这个命令。", "This command is not enabled for this bot."],
|
|
77
|
+
"当前任务正在运行,稍后再试,或使用 **/停止** 停止当前任务。": [
|
|
78
|
+
"当前任务正在运行,稍后再试,或使用 **/停止** 停止当前任务。",
|
|
79
|
+
"A task is currently running. Try again later, or use **/stop** to stop it.",
|
|
80
|
+
],
|
|
81
|
+
// /帮助 标题与九条
|
|
82
|
+
"pi-flash 机器人命令:": ["pi-flash 机器人命令:", "pi-flash bot commands:"],
|
|
83
|
+
"**/帮助** — 查看这份说明": ["**/帮助** — 查看这份说明", "**/help** — Show this guide"],
|
|
84
|
+
"**/状态** — 查看工作区、模型和任务状态": ["**/状态** — 查看工作区、模型和任务状态", "**/status** — Show workspace, model, and task status"],
|
|
85
|
+
"/新建 或 /clear — 开始新的任务草稿": ["/新建 或 /clear — 开始新的任务草稿", "/new or /clear — Start a new task draft"],
|
|
86
|
+
"**/项目** — 切换工作区": ["**/项目** — 切换工作区", "**/project** — Switch workspace"],
|
|
87
|
+
"**/模型** — 切换模型": ["**/模型** — 切换模型", "**/model** — Switch model"],
|
|
88
|
+
"**/模式** — 切换运行模式": ["**/模式** — 切换运行模式", "**/mode** — Switch run mode"],
|
|
89
|
+
"**/思考** — 切换思考级别": ["**/思考** — 切换思考级别", "**/think** — Switch thought level"],
|
|
90
|
+
"**/回复** — 切换回复详细程度": ["**/回复** — 切换回复详细程度", "**/reply** — Switch reply detail"],
|
|
91
|
+
"**/bind <code>** — 绑定当前聊天": ["**/bind <code>** — 绑定当前聊天", "**/bind <code>** — Bind this chat"],
|
|
92
|
+
// 状态卡标签
|
|
93
|
+
"工作区": ["工作区", "Workspace"],
|
|
94
|
+
"模型": ["模型", "Model"],
|
|
95
|
+
"任务": ["任务", "Task"],
|
|
96
|
+
"状态": ["状态", "State"],
|
|
97
|
+
"已工作": ["已工作", "Worked"],
|
|
98
|
+
"进展": ["进展", "Progress"],
|
|
99
|
+
// 状态值(en 侧 status_state_value 直接透传原始 state,见 status.mjs)
|
|
100
|
+
"草稿": ["草稿", "draft"],
|
|
101
|
+
"远端未连接": ["远端未连接", "remote disconnected"],
|
|
102
|
+
"⏳ 运行中": ["⏳ 运行中", "Running"],
|
|
103
|
+
"✅ 已完成": ["✅ 已完成", "Completed"],
|
|
104
|
+
"失败": ["失败", "Failed"],
|
|
105
|
+
"已取消": ["已取消", "cancelled"],
|
|
106
|
+
"已停止": ["已停止", "stopped"],
|
|
107
|
+
// 选择菜单
|
|
108
|
+
"取消": ["取消", "Cancel"],
|
|
109
|
+
"已取消。": ["已取消。", "Cancelled."],
|
|
110
|
+
"回复数字选择,0 取消。": ["回复数字选择,0 取消。", "Reply with a number to choose, or 0 to cancel."],
|
|
111
|
+
"回复数字选择。": ["回复数字选择。", "Reply with a number to choose."],
|
|
112
|
+
// 菜单标题(PF i18n 键的现成值)
|
|
113
|
+
"选择思考档位": ["选择思考档位", "Select thinking tier"],
|
|
114
|
+
"选择模型供应商": ["选择模型供应商", "Select model provider"],
|
|
115
|
+
"选择模型": ["选择模型", "Select model"],
|
|
116
|
+
"切换会话": ["切换会话", "Switch session"],
|
|
117
|
+
"切换工作区": ["切换工作区", "Switch workspace"],
|
|
118
|
+
// 菜单上下文行与空回执
|
|
119
|
+
"当前 workspace {workspace}\n选择 workspace": ["当前 workspace {workspace}\n选择 workspace", "Current workspace {workspace}\nSelect workspace"],
|
|
120
|
+
"未找到可用 workspace。": ["未找到可用 workspace。", "No available workspace found."],
|
|
121
|
+
"当前模型 {model}\n选择模型供应商": ["当前模型 {model}\n选择模型供应商", "Current model {model}\nSelect model provider"],
|
|
122
|
+
"当前模型 {model}\n选择模型": ["当前模型 {model}\n选择模型", "Current model {model}\nSelect model"],
|
|
123
|
+
"未找到 model。": ["未找到 model。", "Model not found."],
|
|
124
|
+
// 空菜单回执(open_wx_menu 的现成句,保持原样)
|
|
125
|
+
"未找到回复颗粒度。": ["未找到回复颗粒度。", "No reply granularity found."],
|
|
126
|
+
// ExtUi input/editor 提示(pi 专有本地补充,extui.rs LOCAL_TEXT)
|
|
127
|
+
"回复文本,0 取消。": ["回复文本,0 取消。", "Reply with text, or 0 to cancel."],
|
|
128
|
+
// 新建会话回执(002 §6 之后回状态卡;草稿期标题)
|
|
129
|
+
"新任务草稿": ["新任务草稿", "New task draft"],
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
// ── /状态 回执卡(旧 inject.status_text 移植;多行文本直接 sendmessage)────
|
|
133
|
+
|
|
134
|
+
function state_word(state) {
|
|
135
|
+
switch (state) {
|
|
136
|
+
case "running": return "⏳ 运行中";
|
|
137
|
+
case "completed": return "✅ 已完成";
|
|
138
|
+
case "draft": return "草稿";
|
|
139
|
+
case "error":
|
|
140
|
+
case "failed": return "失败";
|
|
141
|
+
case "cancelled": return "已取消";
|
|
142
|
+
case "stopped": return "已停止";
|
|
143
|
+
default: return String(state ?? "-");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function status_text(lang, s) {
|
|
148
|
+
const L = normalize_lang(lang);
|
|
149
|
+
const line = (label, value) => `${t(L, label)}: ${value}`;
|
|
150
|
+
const out = [
|
|
151
|
+
line("工作区", s.workspace || "-"),
|
|
152
|
+
line("模型", s.model || "-"),
|
|
153
|
+
line("任务", `${s.task?.title ?? "-"}(${t(L, state_word(s.state))})`),
|
|
154
|
+
];
|
|
155
|
+
if (s.elapsed_ms != null) {
|
|
156
|
+
const sec = Math.max(0, Math.floor(s.elapsed_ms / 1000));
|
|
157
|
+
out.push(line("已工作", `${Math.floor(sec / 60)}m ${sec % 60}s`));
|
|
158
|
+
}
|
|
159
|
+
return out.join("\n");
|
|
160
|
+
}
|