cli-wechat-bridge 1.0.7 → 1.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.txt +661 -21
- package/README.md +49 -2
- package/dist/bridge/bridge-adapters.claude.js +194 -5
- package/dist/bridge/bridge-adapters.codex.js +84 -37
- package/dist/bridge/bridge-adapters.core.js +58 -10
- package/dist/bridge/bridge-adapters.opencode.js +20 -12
- package/dist/bridge/bridge-adapters.shared.js +101 -1
- package/dist/bridge/bridge-adapters.shell.js +1 -1
- package/dist/bridge/bridge-process-reaper.js +7 -2
- package/dist/bridge/bridge-utils.js +40 -14
- package/dist/bridge/claude-hooks.js +14 -16
- package/dist/bridge/wechat-bridge.js +59 -10
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion.js +1 -0
- package/dist/daemon/daemon-link.js +6 -0
- package/dist/daemon/emoji-bindings.js +111 -0
- package/dist/daemon/wechat-daemon.js +197 -61
- package/dist/i18n/index.js +32 -0
- package/dist/i18n/messages-en.js +48 -0
- package/dist/i18n/messages-zh.js +48 -0
- package/dist/runtime/legacy-adapter-runtime.js +3 -0
- package/dist/wechat/channel-config.js +3 -0
- package/package.json +2 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import os from "node:os";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
const ANSI_ESCAPE_RE =
|
|
@@ -122,6 +123,34 @@ export function truncatePreview(text, maxLength = 140) {
|
|
|
122
123
|
}
|
|
123
124
|
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
124
125
|
}
|
|
126
|
+
export function isThinkingForwardEnabled() {
|
|
127
|
+
if (process.env.CLI_BRIDGE_THINKING_FORWARD === "1") {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const accountPath = process.env.CLI_BRIDGE_DATA_DIR
|
|
132
|
+
? path.join(process.env.CLI_BRIDGE_DATA_DIR, "account.json")
|
|
133
|
+
: path.join(os.homedir(), ".cli-bridge", "account.json");
|
|
134
|
+
if (!fs.existsSync(accountPath))
|
|
135
|
+
return false;
|
|
136
|
+
const raw = fs.readFileSync(accountPath, "utf8");
|
|
137
|
+
const data = JSON.parse(raw);
|
|
138
|
+
return data.enableThinkingForward === true;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function formatThinkingForWechat(text, maxLength = 500) {
|
|
145
|
+
const normalized = normalizeOutput(text).trim();
|
|
146
|
+
if (!normalized) {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
if (normalized.length <= maxLength) {
|
|
150
|
+
return normalized;
|
|
151
|
+
}
|
|
152
|
+
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
|
|
153
|
+
}
|
|
125
154
|
export function buildOneTimeCode(length = 6) {
|
|
126
155
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
127
156
|
let code = "";
|
|
@@ -157,8 +186,10 @@ export function parseSystemCommand(text) {
|
|
|
157
186
|
case "/reset":
|
|
158
187
|
return { type: "reset" };
|
|
159
188
|
case "/confirm":
|
|
160
|
-
|
|
189
|
+
case "/yes":
|
|
190
|
+
return { type: "confirm" };
|
|
161
191
|
case "/deny":
|
|
192
|
+
case "/no":
|
|
162
193
|
return { type: "deny" };
|
|
163
194
|
case "/answer":
|
|
164
195
|
return argument ? { type: "answer", raw: argument } : null;
|
|
@@ -171,19 +202,13 @@ export function parseWechatControlCommand(text, options) {
|
|
|
171
202
|
if (systemCommand) {
|
|
172
203
|
return systemCommand;
|
|
173
204
|
}
|
|
174
|
-
if (options.
|
|
205
|
+
if (!options.hasPendingConfirmation) {
|
|
175
206
|
return null;
|
|
176
207
|
}
|
|
177
208
|
const normalized = text.trim().toLowerCase();
|
|
178
209
|
if (!normalized) {
|
|
179
210
|
return null;
|
|
180
211
|
}
|
|
181
|
-
if (normalized === "/confirm") {
|
|
182
|
-
return { type: "confirm" };
|
|
183
|
-
}
|
|
184
|
-
if (!options.hasPendingConfirmation) {
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
212
|
switch (normalized) {
|
|
188
213
|
case "confirm":
|
|
189
214
|
case "yes":
|
|
@@ -268,8 +293,8 @@ function formatPromptByteSize(bytes) {
|
|
|
268
293
|
}
|
|
269
294
|
function formatWechatInboundAttachmentPrompt(attachments) {
|
|
270
295
|
const lines = [
|
|
271
|
-
"[WeChat inbound attachments]",
|
|
272
|
-
"The user sent
|
|
296
|
+
"[WeChat inbound attachments — ACTION REQUIRED]",
|
|
297
|
+
"The user sent the following files through WeChat. You MUST use the Read tool to read each file path below BEFORE responding. Do NOT skip reading these files — the user expects you to see their content (including images).",
|
|
273
298
|
];
|
|
274
299
|
attachments.forEach((attachment, index) => {
|
|
275
300
|
const sizeText = formatPromptByteSize(attachment.sizeBytes);
|
|
@@ -280,6 +305,8 @@ function formatWechatInboundAttachmentPrompt(attachments) {
|
|
|
280
305
|
].filter(Boolean);
|
|
281
306
|
lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
|
|
282
307
|
});
|
|
308
|
+
lines.push("");
|
|
309
|
+
lines.push("Remember: Read each path above with the Read tool NOW. Images are viewable via Read.");
|
|
283
310
|
return lines.join("\n");
|
|
284
311
|
}
|
|
285
312
|
export function buildWechatInboundPrompt(text, attachments = []) {
|
|
@@ -383,7 +410,7 @@ const HIGH_RISK_PATTERNS = [
|
|
|
383
410
|
/\brmdir\b/i,
|
|
384
411
|
/\bdel\b/i,
|
|
385
412
|
/\berase\b/i,
|
|
386
|
-
/\bformat\b/i,
|
|
413
|
+
/\bformat\b(?!-)/i,
|
|
387
414
|
/\bshutdown\b/i,
|
|
388
415
|
/\bstop-computer\b/i,
|
|
389
416
|
/\brestart-computer\b/i,
|
|
@@ -995,10 +1022,9 @@ export function formatApprovalMessage(pending, adapterState) {
|
|
|
995
1022
|
return [
|
|
996
1023
|
`${pending.source === "shell" ? "Shell" : "CLI"} approval is required.`,
|
|
997
1024
|
`adapter: ${adapterState.kind}`,
|
|
998
|
-
`code: ${pending.code}`,
|
|
999
1025
|
`summary: ${pending.summary}`,
|
|
1000
1026
|
`target: ${pending.commandPreview}`,
|
|
1001
|
-
"Reply with /confirm
|
|
1027
|
+
"Reply with /confirm or /yes to continue, /deny or /no to reject.",
|
|
1002
1028
|
].join("\n");
|
|
1003
1029
|
}
|
|
1004
1030
|
export function formatPendingApprovalReminder(pending, adapterState) {
|
|
@@ -1008,7 +1034,7 @@ export function formatPendingApprovalReminder(pending, adapterState) {
|
|
|
1008
1034
|
: pending.commandPreview;
|
|
1009
1035
|
return `Approval is pending for ${truncatePreview(target, 140)}. Reply with /confirm, confirm, or yes to continue, or /deny, deny, or no to reject.`;
|
|
1010
1036
|
}
|
|
1011
|
-
return `Approval is pending for ${pending.commandPreview}. Reply with /confirm
|
|
1037
|
+
return `Approval is pending for ${pending.commandPreview}. Reply with /confirm or /deny.`;
|
|
1012
1038
|
}
|
|
1013
1039
|
function formatUserInputQuestionLabel(question, index) {
|
|
1014
1040
|
return `${index + 1}. ${question.header} [id: ${question.id}]`;
|
|
@@ -6,14 +6,6 @@ const FILE_MUTATION_TOOL_NAMES = new Set([
|
|
|
6
6
|
"MultiEdit",
|
|
7
7
|
"NotebookEdit",
|
|
8
8
|
]);
|
|
9
|
-
const LOW_RISK_READ_TOOL_NAMES = new Set([
|
|
10
|
-
"Glob",
|
|
11
|
-
"Grep",
|
|
12
|
-
"LS",
|
|
13
|
-
"NotebookRead",
|
|
14
|
-
"Read",
|
|
15
|
-
"TodoRead",
|
|
16
|
-
]);
|
|
17
9
|
function quoteWindowsCommandArg(value) {
|
|
18
10
|
return `"${value.replace(/"/g, '""')}"`;
|
|
19
11
|
}
|
|
@@ -71,6 +63,11 @@ export function buildClaudeHookSettings(command) {
|
|
|
71
63
|
],
|
|
72
64
|
Stop: [hook],
|
|
73
65
|
StopFailure: [hook],
|
|
66
|
+
PreToolUse: [hook],
|
|
67
|
+
PostToolUse: [hook],
|
|
68
|
+
PreCompact: [hook],
|
|
69
|
+
PostCompact: [hook],
|
|
70
|
+
SubagentStop: [hook],
|
|
74
71
|
},
|
|
75
72
|
};
|
|
76
73
|
}
|
|
@@ -85,13 +82,15 @@ export function buildClaudeHookScript(params) {
|
|
|
85
82
|
params.runtimeExecPath,
|
|
86
83
|
...runtimeArgs,
|
|
87
84
|
].map(quoteWindowsCommandArg).join(" ");
|
|
85
|
+
const stderrRedirect = params.hookErrorLogPath
|
|
86
|
+
? `2>>${quoteWindowsCommandArg(params.hookErrorLogPath)}`
|
|
87
|
+
: "2>nul";
|
|
88
88
|
return [
|
|
89
89
|
"@echo off",
|
|
90
90
|
"setlocal",
|
|
91
91
|
`set "CLAUDE_WECHAT_HOOK_PORT=${params.hookPort}"`,
|
|
92
92
|
`set "CLAUDE_WECHAT_HOOK_TOKEN=${params.hookToken}"`,
|
|
93
|
-
|
|
94
|
-
`${command} 2>nul`,
|
|
93
|
+
`${command} ${stderrRedirect}`,
|
|
95
94
|
"exit /b 0",
|
|
96
95
|
].join("\r\n");
|
|
97
96
|
}
|
|
@@ -99,12 +98,14 @@ export function buildClaudeHookScript(params) {
|
|
|
99
98
|
params.runtimeExecPath,
|
|
100
99
|
...runtimeArgs,
|
|
101
100
|
].map(quotePosixCommandArg).join(" ");
|
|
101
|
+
const stderrRedirect = params.hookErrorLogPath
|
|
102
|
+
? `2>>${quotePosixCommandArg(params.hookErrorLogPath)}`
|
|
103
|
+
: "2>/dev/null";
|
|
102
104
|
return [
|
|
103
105
|
"#!/bin/sh",
|
|
104
106
|
`export CLAUDE_WECHAT_HOOK_PORT=${quotePosixCommandArg(String(params.hookPort))}`,
|
|
105
107
|
`export CLAUDE_WECHAT_HOOK_TOKEN=${quotePosixCommandArg(params.hookToken)}`,
|
|
106
|
-
|
|
107
|
-
`${command} 2>/dev/null || true`,
|
|
108
|
+
`${command} ${stderrRedirect} || true`,
|
|
108
109
|
"exit 0",
|
|
109
110
|
].join("\n");
|
|
110
111
|
}
|
|
@@ -209,15 +210,12 @@ export function getClaudePermissionAutoResponse(payload) {
|
|
|
209
210
|
if (!toolName) {
|
|
210
211
|
return null;
|
|
211
212
|
}
|
|
212
|
-
if (
|
|
213
|
+
if (toolName !== "Bash") {
|
|
213
214
|
return {
|
|
214
215
|
action: "confirm",
|
|
215
216
|
reason: `low-risk ${toolName} permission`,
|
|
216
217
|
};
|
|
217
218
|
}
|
|
218
|
-
if (toolName !== "Bash") {
|
|
219
|
-
return null;
|
|
220
|
-
}
|
|
221
219
|
const command = getClaudeToolInputString(payload, "command").trim();
|
|
222
220
|
if (!command || isHighRiskShellCommand(command)) {
|
|
223
221
|
return null;
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
|
|
4
4
|
import { delay } from "./bridge-adapters.shared.js";
|
|
5
|
+
import { t } from "../i18n/index.js";
|
|
5
6
|
import { BridgeController } from "./bridge-controller.js";
|
|
6
7
|
import { forwardWechatFinalReply } from "./bridge-final-reply.js";
|
|
7
8
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
8
9
|
import { BridgeStateStore } from "./bridge-state.js";
|
|
9
10
|
import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
|
|
10
11
|
import { createRuntimeHost } from "../runtime/create-runtime-host.js";
|
|
11
|
-
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
12
|
+
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
12
13
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
13
14
|
import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
|
|
14
15
|
import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
|
|
16
|
+
import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "../daemon/emoji-bindings.js";
|
|
15
17
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
16
18
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
17
19
|
const PARENT_PROCESS_POLL_MS = 5_000;
|
|
@@ -608,6 +610,13 @@ async function main() {
|
|
|
608
610
|
else if (options.adapter === "shell") {
|
|
609
611
|
log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
|
|
610
612
|
}
|
|
613
|
+
loadEmojiBindings();
|
|
614
|
+
const welcomeText = t("bridge.welcome", {
|
|
615
|
+
adapter: options.adapter,
|
|
616
|
+
cwd: options.cwd,
|
|
617
|
+
bindings: formatBindingsListMessage(listBindings()),
|
|
618
|
+
});
|
|
619
|
+
await queueWechatMessage(credentials.userId, welcomeText);
|
|
611
620
|
while (true) {
|
|
612
621
|
if (!ensureRuntimeOwnership()) {
|
|
613
622
|
break;
|
|
@@ -792,6 +801,18 @@ function wireAdapterEvents(params) {
|
|
|
792
801
|
}));
|
|
793
802
|
}
|
|
794
803
|
break;
|
|
804
|
+
case "thinking":
|
|
805
|
+
updateLastOutputAt();
|
|
806
|
+
if (event.text) {
|
|
807
|
+
const thinkingPreview = formatThinkingForWechat(event.text, 500);
|
|
808
|
+
if (thinkingPreview) {
|
|
809
|
+
stateStore.appendLog(`thinking: ${thinkingPreview}`);
|
|
810
|
+
trackWechatForwardTask((async () => {
|
|
811
|
+
await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
|
|
812
|
+
})());
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
break;
|
|
795
816
|
case "approval_required":
|
|
796
817
|
trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
|
|
797
818
|
const pending = toPendingApproval(event.request);
|
|
@@ -911,17 +932,49 @@ function formatInboundMessagePreview(message) {
|
|
|
911
932
|
return "(empty)";
|
|
912
933
|
}
|
|
913
934
|
async function handleInboundMessage(params) {
|
|
914
|
-
|
|
935
|
+
let { message, } = params;
|
|
936
|
+
const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
|
|
915
937
|
const state = stateStore.getState();
|
|
938
|
+
if (message.senderId !== state.authorizedUserId) {
|
|
939
|
+
await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
// Emoji resolution: rewrite message text if it starts with a bound emoji
|
|
943
|
+
const emojiMatch = resolveEmojiCommand(message.text);
|
|
944
|
+
if (emojiMatch) {
|
|
945
|
+
const rewritten = emojiMatch.remainder
|
|
946
|
+
? `${emojiMatch.command} ${emojiMatch.remainder}`
|
|
947
|
+
: emojiMatch.command;
|
|
948
|
+
message = { ...message, text: rewritten };
|
|
949
|
+
}
|
|
950
|
+
// Emoji binding management commands
|
|
951
|
+
const bindingsCmd = parseEmojiBindingsCommand(message.text);
|
|
952
|
+
if (bindingsCmd) {
|
|
953
|
+
switch (bindingsCmd.type) {
|
|
954
|
+
case "list":
|
|
955
|
+
await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
|
|
956
|
+
break;
|
|
957
|
+
case "bind":
|
|
958
|
+
setBinding(bindingsCmd.emoji, bindingsCmd.command);
|
|
959
|
+
await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
|
|
960
|
+
break;
|
|
961
|
+
case "unbind": {
|
|
962
|
+
const removed = removeBinding(bindingsCmd.emoji);
|
|
963
|
+
await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
if (isBindCommandPrefix(message.text)) {
|
|
970
|
+
await queueWechatMessage(message.senderId, formatBindCommandUsage());
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
916
973
|
const systemCommand = parseWechatControlCommand(message.text, {
|
|
917
974
|
adapter: options.adapter,
|
|
918
975
|
hasPendingConfirmation: Boolean(state.pendingConfirmation),
|
|
919
976
|
hasPendingUserInput: Boolean(state.pendingUserInput),
|
|
920
977
|
});
|
|
921
|
-
if (message.senderId !== state.authorizedUserId) {
|
|
922
|
-
await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
|
|
923
|
-
return null;
|
|
924
|
-
}
|
|
925
978
|
switch (systemCommand?.type) {
|
|
926
979
|
case "status":
|
|
927
980
|
await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
|
|
@@ -979,10 +1032,6 @@ async function handleInboundMessage(params) {
|
|
|
979
1032
|
await queueWechatMessage(message.senderId, "No pending approval request.");
|
|
980
1033
|
return null;
|
|
981
1034
|
}
|
|
982
|
-
if (options.adapter !== "claude" && pending.code !== systemCommand.code) {
|
|
983
|
-
await queueWechatMessage(message.senderId, "Confirmation code did not match.");
|
|
984
|
-
return null;
|
|
985
|
-
}
|
|
986
1035
|
const confirmed = await adapter.resolveApproval("confirm");
|
|
987
1036
|
if (!confirmed) {
|
|
988
1037
|
await queueWechatMessage(message.senderId, "The worker could not apply this approval request.");
|
|
@@ -210,10 +210,16 @@ export function sendLocalCompanionMessage(socket, message) {
|
|
|
210
210
|
return false;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
const MAX_IPC_BUFFER_SIZE = 1_048_576; // 1MB
|
|
213
214
|
export function attachLocalCompanionMessageListener(socket, onMessage) {
|
|
214
215
|
let buffer = "";
|
|
215
216
|
const onData = (chunk) => {
|
|
216
217
|
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
218
|
+
if (buffer.length > MAX_IPC_BUFFER_SIZE) {
|
|
219
|
+
socket.destroy(new Error("IPC buffer overflow: exceeded 1MB without complete frame"));
|
|
220
|
+
buffer = "";
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
217
223
|
while (true) {
|
|
218
224
|
const newlineIndex = buffer.indexOf("\n");
|
|
219
225
|
if (newlineIndex < 0) {
|
|
@@ -80,10 +80,16 @@ export function sendDaemonResponse(socket, id, response) {
|
|
|
80
80
|
return false;
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
+
const MAX_IPC_BUFFER_SIZE = 1_048_576; // 1MB
|
|
83
84
|
export function attachDaemonRequestListener(socket, onRequest) {
|
|
84
85
|
let buffer = "";
|
|
85
86
|
const onData = (chunk) => {
|
|
86
87
|
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
88
|
+
if (buffer.length > MAX_IPC_BUFFER_SIZE) {
|
|
89
|
+
socket.destroy(new Error("IPC buffer overflow: exceeded 1MB without complete frame"));
|
|
90
|
+
buffer = "";
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
87
93
|
while (true) {
|
|
88
94
|
const newlineIndex = buffer.indexOf("\n");
|
|
89
95
|
if (newlineIndex < 0) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
|
|
3
|
+
import { t } from "../i18n/index.js";
|
|
4
|
+
export const DEFAULT_EMOJI_BINDINGS = {
|
|
5
|
+
"[OK]": "/confirm",
|
|
6
|
+
"[闭嘴]": "/stop",
|
|
7
|
+
"[拥抱]": "/claude",
|
|
8
|
+
"[强]": "/codex",
|
|
9
|
+
"[胜利]": "/opencode",
|
|
10
|
+
"[再见]": "/daemon-stop",
|
|
11
|
+
};
|
|
12
|
+
let bindingsMap = null;
|
|
13
|
+
function buildMap(bindings) {
|
|
14
|
+
const map = new Map();
|
|
15
|
+
for (const [emoji, command] of Object.entries(bindings)) {
|
|
16
|
+
map.set(emoji.toLowerCase(), command);
|
|
17
|
+
}
|
|
18
|
+
return map;
|
|
19
|
+
}
|
|
20
|
+
export function loadEmojiBindings() {
|
|
21
|
+
if (bindingsMap) {
|
|
22
|
+
return bindingsMap;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
if (fs.existsSync(EMOJI_BINDINGS_FILE)) {
|
|
26
|
+
const raw = JSON.parse(fs.readFileSync(EMOJI_BINDINGS_FILE, "utf-8"));
|
|
27
|
+
if (raw.bindings && typeof raw.bindings === "object") {
|
|
28
|
+
bindingsMap = buildMap(raw.bindings);
|
|
29
|
+
return bindingsMap;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Fall through to defaults on parse error.
|
|
35
|
+
}
|
|
36
|
+
bindingsMap = buildMap(DEFAULT_EMOJI_BINDINGS);
|
|
37
|
+
return bindingsMap;
|
|
38
|
+
}
|
|
39
|
+
export function saveEmojiBindings(map) {
|
|
40
|
+
const bindings = {};
|
|
41
|
+
for (const [emoji, command] of map) {
|
|
42
|
+
bindings[emoji] = command;
|
|
43
|
+
}
|
|
44
|
+
const data = { bindings };
|
|
45
|
+
fs.writeFile(EMOJI_BINDINGS_FILE, JSON.stringify(data, null, 2) + "\n", () => { });
|
|
46
|
+
}
|
|
47
|
+
export function setBinding(emoji, command) {
|
|
48
|
+
const map = loadEmojiBindings();
|
|
49
|
+
map.set(emoji.toLowerCase(), command);
|
|
50
|
+
bindingsMap = map;
|
|
51
|
+
saveEmojiBindings(map);
|
|
52
|
+
}
|
|
53
|
+
export function removeBinding(emoji) {
|
|
54
|
+
const map = loadEmojiBindings();
|
|
55
|
+
const key = emoji.toLowerCase();
|
|
56
|
+
if (!map.has(key)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
map.delete(key);
|
|
60
|
+
bindingsMap = map;
|
|
61
|
+
saveEmojiBindings(map);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
export function listBindings() {
|
|
65
|
+
return loadEmojiBindings();
|
|
66
|
+
}
|
|
67
|
+
export function resolveEmojiCommand(text) {
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
if (!trimmed.startsWith("[")) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const map = loadEmojiBindings();
|
|
73
|
+
const lower = trimmed.toLowerCase();
|
|
74
|
+
for (const [emoji, command] of map) {
|
|
75
|
+
if (lower.startsWith(emoji)) {
|
|
76
|
+
const remainder = trimmed.slice(emoji.length).trim();
|
|
77
|
+
return { command, remainder };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
export function parseEmojiBindingsCommand(text) {
|
|
83
|
+
const trimmed = text.trim();
|
|
84
|
+
const lower = trimmed.toLowerCase();
|
|
85
|
+
if (lower === "/bindings") {
|
|
86
|
+
return { type: "list" };
|
|
87
|
+
}
|
|
88
|
+
const bindMatch = trimmed.match(/^\/bind\s+(\[[^\]]+\])\s+(.+)$/i);
|
|
89
|
+
if (bindMatch) {
|
|
90
|
+
return { type: "bind", emoji: bindMatch[1], command: bindMatch[2].trim() };
|
|
91
|
+
}
|
|
92
|
+
const unbindMatch = trimmed.match(/^\/unbind\s+(\[[^\]]+\])$/i);
|
|
93
|
+
if (unbindMatch) {
|
|
94
|
+
return { type: "unbind", emoji: unbindMatch[1] };
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
export function isBindCommandPrefix(text) {
|
|
99
|
+
const lower = text.trim().toLowerCase();
|
|
100
|
+
return lower.startsWith("/bind") || lower.startsWith("/unbind");
|
|
101
|
+
}
|
|
102
|
+
export function formatBindCommandUsage() {
|
|
103
|
+
return t("binding.usage");
|
|
104
|
+
}
|
|
105
|
+
export function formatBindingsListMessage(map) {
|
|
106
|
+
if (map.size === 0) {
|
|
107
|
+
return t("binding.listEmpty");
|
|
108
|
+
}
|
|
109
|
+
const lines = Array.from(map.entries()).map(([emoji, command]) => `${emoji} → ${command}`);
|
|
110
|
+
return `${t("binding.listHeader")}\n${lines.join("\n")}`;
|
|
111
|
+
}
|