cli-wechat-bridge 1.0.9 → 1.1.1

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.
@@ -110,7 +110,7 @@ export class OpenCodeServerAdapter {
110
110
  throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
111
111
  }
112
112
  if (this.pendingPermission) {
113
- throw new Error("An OpenCode approval request is pending. Reply with /confirm <code> or /deny.");
113
+ throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
114
114
  }
115
115
  const normalized = normalizeOutput(text).trim();
116
116
  if (!normalized) {
@@ -163,7 +163,7 @@ export class OpenCodeServerAdapter {
163
163
  throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
164
164
  }
165
165
  if (this.pendingPermission) {
166
- throw new Error("An OpenCode approval request is pending. Reply with /confirm <code> or /deny.");
166
+ throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
167
167
  }
168
168
  this.outputBatcher.clear();
169
169
  this.clearStreamedPartState();
@@ -254,6 +254,10 @@ export class OpenCodeServerAdapter {
254
254
  this.setStatus("busy");
255
255
  return true;
256
256
  }
257
+ async resolveAllApprovals(action) {
258
+ const ok = await this.resolveApproval(action);
259
+ return ok ? 1 : 0;
260
+ }
257
261
  async submitUserInput(_answers) {
258
262
  return false;
259
263
  }
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import net from "node:net";
4
4
  import { fileURLToPath } from "node:url";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { t } from "../i18n/index.js";
6
7
  import { spawn as spawnPty } from "node-pty";
7
8
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
8
9
  import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
@@ -1214,3 +1215,105 @@ export function resolveSpawnTarget(command, kind, options = {}) {
1214
1215
  }
1215
1216
  return { file: resolved, args: [...forwardArgs] };
1216
1217
  }
1218
+ export function spawnFallbackProcess(file, args, options) {
1219
+ const child = spawn(file, args, {
1220
+ cwd: options.cwd,
1221
+ env: { ...options.env, TERM: "dumb" },
1222
+ stdio: ["pipe", "pipe", "pipe"],
1223
+ windowsHide: false,
1224
+ shell: false,
1225
+ });
1226
+ if (!child.pid) {
1227
+ throw new Error(`Failed to spawn fallback process: ${file}`);
1228
+ }
1229
+ const dataListeners = [];
1230
+ const exitListeners = [];
1231
+ child.stdout?.on("data", (chunk) => {
1232
+ const text = chunk.toString("utf8");
1233
+ for (const listener of dataListeners) {
1234
+ listener(text);
1235
+ }
1236
+ });
1237
+ child.stderr?.on("data", (chunk) => {
1238
+ const text = chunk.toString("utf8");
1239
+ for (const listener of dataListeners) {
1240
+ listener(text);
1241
+ }
1242
+ });
1243
+ child.on("exit", (code, signal) => {
1244
+ const exitCode = code ?? (signal ? 128 : 1);
1245
+ for (const listener of exitListeners) {
1246
+ listener({ exitCode });
1247
+ }
1248
+ });
1249
+ return {
1250
+ pid: child.pid,
1251
+ write(data) {
1252
+ child.stdin?.write(data);
1253
+ },
1254
+ kill(signal) {
1255
+ try {
1256
+ if (process.platform === "win32") {
1257
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
1258
+ stdio: "ignore",
1259
+ windowsHide: true,
1260
+ });
1261
+ }
1262
+ else {
1263
+ child.kill(signal);
1264
+ }
1265
+ }
1266
+ catch {
1267
+ // Best effort.
1268
+ }
1269
+ },
1270
+ onData(callback) {
1271
+ dataListeners.push(callback);
1272
+ return {
1273
+ dispose() {
1274
+ const index = dataListeners.indexOf(callback);
1275
+ if (index >= 0)
1276
+ dataListeners.splice(index, 1);
1277
+ },
1278
+ };
1279
+ },
1280
+ onExit(callback) {
1281
+ exitListeners.push(callback);
1282
+ return {
1283
+ dispose() {
1284
+ const index = exitListeners.indexOf(callback);
1285
+ if (index >= 0)
1286
+ exitListeners.splice(index, 1);
1287
+ },
1288
+ };
1289
+ },
1290
+ };
1291
+ }
1292
+ export function buildSpawnDiagnostic(error, spawnTarget, platform = process.platform) {
1293
+ const errorMessage = error instanceof Error ? error.message : String(error);
1294
+ const target = spawnTarget ? spawnTarget.file : "(unknown)";
1295
+ const lines = [
1296
+ t("spawn.diagnostic.title", { target, error: errorMessage }),
1297
+ t("spawn.diagnostic.fixesHeader"),
1298
+ ];
1299
+ if (errorMessage.includes("posix_spawnp") || errorMessage.includes("node-pty")) {
1300
+ lines.push(t("spawn.diagnostic.nodePty"));
1301
+ if (platform === "darwin") {
1302
+ lines.push(t("spawn.diagnostic.xcode"));
1303
+ }
1304
+ if (platform === "linux") {
1305
+ lines.push(t("spawn.diagnostic.linuxBuildTools"));
1306
+ }
1307
+ }
1308
+ else if (errorMessage.includes("ENOENT") || errorMessage.includes("spawn")) {
1309
+ lines.push(t("spawn.diagnostic.notFound", { target }));
1310
+ }
1311
+ else {
1312
+ lines.push(t("spawn.diagnostic.generic"));
1313
+ }
1314
+ lines.push(t("spawn.diagnostic.nodeVersion"));
1315
+ if (platform === "win32") {
1316
+ lines.push(t("spawn.diagnostic.winFull"));
1317
+ }
1318
+ return lines.join("\n");
1319
+ }
@@ -67,7 +67,7 @@ export class ShellAdapter extends AbstractPtyAdapter {
67
67
  throw new Error("shell is still working. Wait for the current reply or use /stop.");
68
68
  }
69
69
  if (this.pendingApproval || this.state.status === "awaiting_approval") {
70
- throw new Error("A shell approval request is pending. Reply with /confirm <code> or /deny.");
70
+ throw new Error("A shell approval request is pending. Reply with /confirm or /deny.");
71
71
  }
72
72
  const rejectionMessage = getInteractiveShellCommandRejectionMessage(text);
73
73
  if (rejectionMessage) {
@@ -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
- return argument ? { type: "confirm", code: argument } : null;
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.adapter !== "claude") {
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":
@@ -385,7 +410,7 @@ const HIGH_RISK_PATTERNS = [
385
410
  /\brmdir\b/i,
386
411
  /\bdel\b/i,
387
412
  /\berase\b/i,
388
- /\bformat\b/i,
413
+ /\bformat\b(?!-)/i,
389
414
  /\bshutdown\b/i,
390
415
  /\bstop-computer\b/i,
391
416
  /\brestart-computer\b/i,
@@ -997,10 +1022,9 @@ export function formatApprovalMessage(pending, adapterState) {
997
1022
  return [
998
1023
  `${pending.source === "shell" ? "Shell" : "CLI"} approval is required.`,
999
1024
  `adapter: ${adapterState.kind}`,
1000
- `code: ${pending.code}`,
1001
1025
  `summary: ${pending.summary}`,
1002
1026
  `target: ${pending.commandPreview}`,
1003
- "Reply with /confirm <code> to continue or /deny to reject.",
1027
+ "Reply with /confirm or /yes to continue, /deny or /no to reject.",
1004
1028
  ].join("\n");
1005
1029
  }
1006
1030
  export function formatPendingApprovalReminder(pending, adapterState) {
@@ -1010,7 +1034,7 @@ export function formatPendingApprovalReminder(pending, adapterState) {
1010
1034
  : pending.commandPreview;
1011
1035
  return `Approval is pending for ${truncatePreview(target, 140)}. Reply with /confirm, confirm, or yes to continue, or /deny, deny, or no to reject.`;
1012
1036
  }
1013
- return `Approval is pending for ${pending.commandPreview}. Reply with /confirm ${pending.code} or /deny.`;
1037
+ return `Approval is pending for ${pending.commandPreview}. Reply with /confirm or /deny.`;
1014
1038
  }
1015
1039
  function formatUserInputQuestionLabel(question, index) {
1016
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
  }
@@ -90,13 +82,15 @@ export function buildClaudeHookScript(params) {
90
82
  params.runtimeExecPath,
91
83
  ...runtimeArgs,
92
84
  ].map(quoteWindowsCommandArg).join(" ");
85
+ const stderrRedirect = params.hookErrorLogPath
86
+ ? `2>>${quoteWindowsCommandArg(params.hookErrorLogPath)}`
87
+ : "2>nul";
93
88
  return [
94
89
  "@echo off",
95
90
  "setlocal",
96
91
  `set "CLAUDE_WECHAT_HOOK_PORT=${params.hookPort}"`,
97
92
  `set "CLAUDE_WECHAT_HOOK_TOKEN=${params.hookToken}"`,
98
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
99
- `${command} 2>nul`,
93
+ `${command} ${stderrRedirect}`,
100
94
  "exit /b 0",
101
95
  ].join("\r\n");
102
96
  }
@@ -104,12 +98,14 @@ export function buildClaudeHookScript(params) {
104
98
  params.runtimeExecPath,
105
99
  ...runtimeArgs,
106
100
  ].map(quotePosixCommandArg).join(" ");
101
+ const stderrRedirect = params.hookErrorLogPath
102
+ ? `2>>${quotePosixCommandArg(params.hookErrorLogPath)}`
103
+ : "2>/dev/null";
107
104
  return [
108
105
  "#!/bin/sh",
109
106
  `export CLAUDE_WECHAT_HOOK_PORT=${quotePosixCommandArg(String(params.hookPort))}`,
110
107
  `export CLAUDE_WECHAT_HOOK_TOKEN=${quotePosixCommandArg(params.hookToken)}`,
111
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
112
- `${command} 2>/dev/null || true`,
108
+ `${command} ${stderrRedirect} || true`,
113
109
  "exit 0",
114
110
  ].join("\n");
115
111
  }
@@ -214,15 +210,12 @@ export function getClaudePermissionAutoResponse(payload) {
214
210
  if (!toolName) {
215
211
  return null;
216
212
  }
217
- if (LOW_RISK_READ_TOOL_NAMES.has(toolName)) {
213
+ if (toolName !== "Bash") {
218
214
  return {
219
215
  action: "confirm",
220
216
  reason: `low-risk ${toolName} permission`,
221
217
  };
222
218
  }
223
- if (toolName !== "Bash") {
224
- return null;
225
- }
226
219
  const command = getClaudeToolInputString(payload, "command").trim();
227
220
  if (!command || isHighRiskShellCommand(command)) {
228
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;
@@ -240,6 +242,11 @@ function printUsageAndExit() {
240
242
  process.exit(0);
241
243
  }
242
244
  async function main() {
245
+ if (process.argv.includes("--doctor")) {
246
+ const { runDoctorCheck } = await import("../utils/doctor.js");
247
+ await runDoctorCheck(process.argv.slice(2), { mode: "bridge" });
248
+ process.exit(0);
249
+ }
243
250
  const options = parseCliArgs(process.argv.slice(2));
244
251
  const daemonEndpoint = readDaemonEndpoint();
245
252
  if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
@@ -608,6 +615,13 @@ async function main() {
608
615
  else if (options.adapter === "shell") {
609
616
  log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
610
617
  }
618
+ loadEmojiBindings();
619
+ const welcomeText = t("bridge.welcome", {
620
+ adapter: options.adapter,
621
+ cwd: options.cwd,
622
+ bindings: formatBindingsListMessage(listBindings()),
623
+ });
624
+ await queueWechatMessage(credentials.userId, welcomeText);
611
625
  while (true) {
612
626
  if (!ensureRuntimeOwnership()) {
613
627
  break;
@@ -792,6 +806,18 @@ function wireAdapterEvents(params) {
792
806
  }));
793
807
  }
794
808
  break;
809
+ case "thinking":
810
+ updateLastOutputAt();
811
+ if (event.text) {
812
+ const thinkingPreview = formatThinkingForWechat(event.text, 500);
813
+ if (thinkingPreview) {
814
+ stateStore.appendLog(`thinking: ${thinkingPreview}`);
815
+ trackWechatForwardTask((async () => {
816
+ await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
817
+ })());
818
+ }
819
+ }
820
+ break;
795
821
  case "approval_required":
796
822
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
797
823
  const pending = toPendingApproval(event.request);
@@ -911,17 +937,49 @@ function formatInboundMessagePreview(message) {
911
937
  return "(empty)";
912
938
  }
913
939
  async function handleInboundMessage(params) {
914
- const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
940
+ let { message, } = params;
941
+ const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
915
942
  const state = stateStore.getState();
943
+ if (message.senderId !== state.authorizedUserId) {
944
+ await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
945
+ return null;
946
+ }
947
+ // Emoji resolution: rewrite message text if it starts with a bound emoji
948
+ const emojiMatch = resolveEmojiCommand(message.text);
949
+ if (emojiMatch) {
950
+ const rewritten = emojiMatch.remainder
951
+ ? `${emojiMatch.command} ${emojiMatch.remainder}`
952
+ : emojiMatch.command;
953
+ message = { ...message, text: rewritten };
954
+ }
955
+ // Emoji binding management commands
956
+ const bindingsCmd = parseEmojiBindingsCommand(message.text);
957
+ if (bindingsCmd) {
958
+ switch (bindingsCmd.type) {
959
+ case "list":
960
+ await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
961
+ break;
962
+ case "bind":
963
+ setBinding(bindingsCmd.emoji, bindingsCmd.command);
964
+ await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
965
+ break;
966
+ case "unbind": {
967
+ const removed = removeBinding(bindingsCmd.emoji);
968
+ await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
969
+ break;
970
+ }
971
+ }
972
+ return null;
973
+ }
974
+ if (isBindCommandPrefix(message.text)) {
975
+ await queueWechatMessage(message.senderId, formatBindCommandUsage());
976
+ return null;
977
+ }
916
978
  const systemCommand = parseWechatControlCommand(message.text, {
917
979
  adapter: options.adapter,
918
980
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
919
981
  hasPendingUserInput: Boolean(state.pendingUserInput),
920
982
  });
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
983
  switch (systemCommand?.type) {
926
984
  case "status":
927
985
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
@@ -979,10 +1037,6 @@ async function handleInboundMessage(params) {
979
1037
  await queueWechatMessage(message.senderId, "No pending approval request.");
980
1038
  return null;
981
1039
  }
982
- if (options.adapter !== "claude" && pending.code !== systemCommand.code) {
983
- await queueWechatMessage(message.senderId, "Confirmation code did not match.");
984
- return null;
985
- }
986
1040
  const confirmed = await adapter.resolveApproval("confirm");
987
1041
  if (!confirmed) {
988
1042
  await queueWechatMessage(message.senderId, "The worker could not apply this approval request.");
@@ -127,6 +127,7 @@ export async function runLocalCompanion(options) {
127
127
  }
128
128
  socket.removeAllListeners("close");
129
129
  socket.removeAllListeners("error");
130
+ socket.on("error", () => { });
130
131
  if (destroy) {
131
132
  try {
132
133
  socket.destroy();
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
3
+ import { t } from "../i18n/index.js";
3
4
  export const DEFAULT_EMOJI_BINDINGS = {
4
5
  "[OK]": "/confirm",
5
6
  "[闭嘴]": "/stop",
@@ -78,3 +79,33 @@ export function resolveEmojiCommand(text) {
78
79
  }
79
80
  return null;
80
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
+ }