cli-wechat-bridge 1.0.7 → 1.0.9
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 +8 -0
- package/dist/bridge/bridge-adapters.codex.js +26 -13
- package/dist/bridge/bridge-adapters.core.js +19 -5
- package/dist/bridge/bridge-adapters.opencode.js +14 -10
- package/dist/bridge/bridge-process-reaper.js +7 -2
- package/dist/bridge/bridge-utils.js +4 -2
- package/dist/bridge/claude-hooks.js +5 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/daemon/daemon-link.js +6 -0
- package/dist/daemon/emoji-bindings.js +80 -0
- package/dist/daemon/wechat-daemon.js +165 -10
- package/dist/wechat/channel-config.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dm/cli-wechat-bridge?label=Downloads&style=for-the-badge&logo=npm&color=16a34a&labelColor=1c1917"></a>
|
|
11
11
|
<a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/dm/@unlinearity/cli-wechat-bridge?label=Scoped%20downloads&style=for-the-badge&logo=npm&color=15803d&labelColor=1c1917"></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/cli-wechat-bridge">
|
|
13
|
-
<img alt="License" src="https://img.shields.io/
|
|
13
|
+
<img alt="License" src="https://img.shields.io/badge/License-AGPL--3.0-7c3aed?style=for-the-badge&labelColor=1c1917">
|
|
14
14
|
</a>
|
|
15
15
|
</p>
|
|
16
16
|
|
|
@@ -305,9 +305,44 @@ wechat-claude-start --model sonnet --dangerously-skip-permissions
|
|
|
305
305
|
| `/new` 或 `/new-session` | OpenCode 模式下新建 session |
|
|
306
306
|
| `/confirm` / `/deny` | 处理 CLI 权限请求;需要一次性 code 的请求会在消息中提示具体确认格式 |
|
|
307
307
|
| `/daemon-stop` | daemon 模式下停止常驻进程 |
|
|
308
|
+
| `/bindings` | 查看当前所有表情绑定 |
|
|
309
|
+
| `/bind [表情] /命令` | 绑定表情到指定命令 |
|
|
310
|
+
| `/unbind [表情]` | 解除指定表情的绑定 |
|
|
308
311
|
|
|
309
312
|
说明:微信侧 `/resume` 目前暂时保持禁用;需要切换 Codex / Claude / OpenCode 会话时,优先在本地 companion 中使用原生 `/resume`、`/new` 或对应 CLI 命令,微信会跟随本地活动会话。
|
|
310
313
|
|
|
314
|
+
### 表情绑定
|
|
315
|
+
|
|
316
|
+
Daemon 模式支持将微信表情映射为命令,在微信中发送表情即可快速触发操作。(不过注意,第一次启动相关cli的时候,最好不要带消息,等启动完成再“表情+文本”快速给指定 cli 发送消息。
|
|
317
|
+
|
|
318
|
+

|
|
319
|
+
|
|
320
|
+
**默认绑定:**
|
|
321
|
+
|
|
322
|
+
| 表情 | 命令 | 说明 |
|
|
323
|
+
| --- | --- | --- |
|
|
324
|
+
| `[OK]` | `/confirm` | 批准权限请求 |
|
|
325
|
+
| `[闭嘴]` | `/stop` | 中断当前任务 |
|
|
326
|
+
| `[拥抱]` | `/claude` | 切换到 Claude Code |
|
|
327
|
+
| `[强]` | `/codex` | 切换到 Codex |
|
|
328
|
+
| `[胜利]` | `/opencode` | 切换到 OpenCode |
|
|
329
|
+
| `[再见]` | `/daemon-stop` | 停止 daemon |
|
|
330
|
+
|
|
331
|
+
**管理命令:**
|
|
332
|
+
|
|
333
|
+
```
|
|
334
|
+
/bindings 查看当前所有绑定
|
|
335
|
+
/bind [表情] /命令 绑定表情到命令,如 /bind [微笑] /status
|
|
336
|
+
/unbind [表情] 解除绑定,如 /unbind [微笑]
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**触发规则:**
|
|
340
|
+
|
|
341
|
+
- 表情必须出现在消息开头才会触发(如 `[OK]` 触发,`你好[OK]` 不触发);
|
|
342
|
+
- 如果表情后面还有文本(如 `[拥抱]帮我写个脚本`),会先执行命令再将剩余文本作为消息转发;
|
|
343
|
+
- 大小写不敏感(`[OK]` 和 `[ok]` 等价);
|
|
344
|
+
- 修改后立即生效并持久化到 `~/.cli-bridge/emoji-bindings.json`,重启 daemon 后保留。
|
|
345
|
+
|
|
311
346
|
## 工作区模型
|
|
312
347
|
|
|
313
348
|
本项目采用“当前目录即当前工作区”的模型:
|
|
@@ -390,7 +425,19 @@ npm install -g cli-wechat-bridge@latest
|
|
|
390
425
|
|
|
391
426
|
## License
|
|
392
427
|
|
|
393
|
-
|
|
428
|
+
本项目采用**双协议**授权:
|
|
429
|
+
|
|
430
|
+
**开源协议:[AGPL-3.0](LICENSE.txt)**
|
|
431
|
+
|
|
432
|
+
- 个人使用、学习、研究:完全免费
|
|
433
|
+
- 修改和衍生作品必须以相同协议(AGPL-3.0)开源
|
|
434
|
+
- 通过网络提供基于本项目的服务,也必须公开完整源代码
|
|
435
|
+
|
|
436
|
+
**商业授权**
|
|
437
|
+
|
|
438
|
+
如果你希望在闭源商业产品中使用本项目(不公开你的源代码),需要获得商业许可。请联系作者获取商业授权方案:
|
|
439
|
+
|
|
440
|
+
- GitHub: [@UNLINEARITY](https://github.com/UNLINEARITY)
|
|
394
441
|
|
|
395
442
|
<p align="center">
|
|
396
443
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=UNLINEARITY/CLI-WeChat-Bridge&type=timeline&legend=top-left">
|
|
@@ -200,6 +200,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
200
200
|
this.pendingCliApprovalHints = null;
|
|
201
201
|
this.flushPendingClaudeHookApprovals();
|
|
202
202
|
this.writeToPty("\u0003");
|
|
203
|
+
this.scheduleTaskComplete(shared.INTERRUPT_SETTLE_DELAY_MS);
|
|
203
204
|
return true;
|
|
204
205
|
}
|
|
205
206
|
async reset() {
|
|
@@ -566,6 +567,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
566
567
|
}
|
|
567
568
|
return `${CLAUDE_BRACKETED_PASTE_START}${normalizedText}${CLAUDE_BRACKETED_PASTE_END}`;
|
|
568
569
|
}
|
|
570
|
+
// Fallback heuristic for older Claude Code versions that lack PostCompact hooks.
|
|
571
|
+
// The structured PostCompact hook event (handled in handleClaudeHookEnvelope) is
|
|
572
|
+
// the reliable signal; this regex match serves as a best-effort fallback.
|
|
569
573
|
shouldTreatClaudeOutputAsCompactCompletion(text) {
|
|
570
574
|
if (this.state.status !== "busy" &&
|
|
571
575
|
this.state.status !== "awaiting_approval" &&
|
|
@@ -695,6 +699,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
695
699
|
this.handleClaudeStopFailure(payload);
|
|
696
700
|
this.respondToClaudeHook(params.socket, params.requestId);
|
|
697
701
|
return;
|
|
702
|
+
case "PostCompact":
|
|
703
|
+
this.completeClaudeCompact();
|
|
704
|
+
this.respondToClaudeHook(params.socket, params.requestId);
|
|
705
|
+
return;
|
|
698
706
|
default:
|
|
699
707
|
this.respondToClaudeHook(params.socket, params.requestId);
|
|
700
708
|
return;
|
|
@@ -495,9 +495,27 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
495
495
|
this.sessionPartialLine = "";
|
|
496
496
|
this.seedSessionReplayCutoff(startedAtMs);
|
|
497
497
|
}
|
|
498
|
-
let
|
|
498
|
+
let chunk;
|
|
499
499
|
try {
|
|
500
|
-
|
|
500
|
+
const stat = fs.statSync(this.sessionFilePath);
|
|
501
|
+
if (stat.size < this.sessionReadOffset) {
|
|
502
|
+
this.sessionReadOffset = 0;
|
|
503
|
+
this.sessionPartialLine = "";
|
|
504
|
+
}
|
|
505
|
+
if (stat.size === this.sessionReadOffset) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const fd = fs.openSync(this.sessionFilePath, "r");
|
|
509
|
+
try {
|
|
510
|
+
const bytesToRead = stat.size - this.sessionReadOffset;
|
|
511
|
+
const buf = Buffer.alloc(bytesToRead);
|
|
512
|
+
fs.readSync(fd, buf, 0, bytesToRead, this.sessionReadOffset);
|
|
513
|
+
chunk = buf.toString("utf8");
|
|
514
|
+
this.sessionReadOffset = stat.size;
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
fs.closeSync(fd);
|
|
518
|
+
}
|
|
501
519
|
}
|
|
502
520
|
catch {
|
|
503
521
|
this.sessionFilePath = null;
|
|
@@ -505,15 +523,6 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
505
523
|
this.sessionPartialLine = "";
|
|
506
524
|
return;
|
|
507
525
|
}
|
|
508
|
-
if (content.length < this.sessionReadOffset) {
|
|
509
|
-
this.sessionReadOffset = 0;
|
|
510
|
-
this.sessionPartialLine = "";
|
|
511
|
-
}
|
|
512
|
-
if (content.length === this.sessionReadOffset) {
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
const chunk = content.slice(this.sessionReadOffset);
|
|
516
|
-
this.sessionReadOffset = content.length;
|
|
517
526
|
const lines = `${this.sessionPartialLine}${chunk}`.split(/\r?\n/);
|
|
518
527
|
this.sessionPartialLine = lines.pop() ?? "";
|
|
519
528
|
for (const line of lines) {
|
|
@@ -1627,10 +1636,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1627
1636
|
const requestId = ++this.rpcRequestCounter;
|
|
1628
1637
|
const requestKey = this.rpcRequestKey(requestId);
|
|
1629
1638
|
const responsePromise = new Promise((resolve, reject) => {
|
|
1639
|
+
const timer = setTimeout(() => {
|
|
1640
|
+
this.pendingRpcRequests.delete(requestKey);
|
|
1641
|
+
reject(new Error(`Codex RPC request timed out after 30s (method: ${method})`));
|
|
1642
|
+
}, 30_000);
|
|
1630
1643
|
this.pendingRpcRequests.set(requestKey, {
|
|
1631
1644
|
method,
|
|
1632
|
-
resolve,
|
|
1633
|
-
reject,
|
|
1645
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
1646
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
1634
1647
|
});
|
|
1635
1648
|
});
|
|
1636
1649
|
try {
|
|
@@ -294,6 +294,7 @@ export class LocalCompanionProxyAdapter {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
detachPanelSocket() {
|
|
297
|
+
this.rejectPendingRequests("Companion socket disconnected unexpectedly.");
|
|
297
298
|
this.detachMessageListener?.();
|
|
298
299
|
this.detachMessageListener = null;
|
|
299
300
|
if (this.socket) {
|
|
@@ -393,7 +394,14 @@ export class LocalCompanionProxyAdapter {
|
|
|
393
394
|
}
|
|
394
395
|
const id = `${++this.requestCounter}`;
|
|
395
396
|
const response = new Promise((resolve, reject) => {
|
|
396
|
-
|
|
397
|
+
const timer = setTimeout(() => {
|
|
398
|
+
this.pendingRequests.delete(id);
|
|
399
|
+
reject(new Error(`${this.options.kind} companion request timed out after 30s (command: ${payload.command})`));
|
|
400
|
+
}, 30_000);
|
|
401
|
+
this.pendingRequests.set(id, {
|
|
402
|
+
resolve: (value) => { clearTimeout(timer); resolve(value); },
|
|
403
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
404
|
+
});
|
|
397
405
|
});
|
|
398
406
|
sendLocalCompanionMessage(socket, {
|
|
399
407
|
type: "request",
|
|
@@ -588,15 +596,21 @@ export class AbstractPtyAdapter {
|
|
|
588
596
|
return;
|
|
589
597
|
}
|
|
590
598
|
this.shuttingDown = true;
|
|
599
|
+
const pty = this.pty;
|
|
600
|
+
this.pty = null;
|
|
601
|
+
this.state.status = "stopped";
|
|
602
|
+
this.state.pid = undefined;
|
|
591
603
|
try {
|
|
592
|
-
|
|
604
|
+
const exitPromise = new Promise((resolve) => {
|
|
605
|
+
const timeout = setTimeout(resolve, 3_000);
|
|
606
|
+
pty.onExit(() => { clearTimeout(timeout); resolve(); });
|
|
607
|
+
});
|
|
608
|
+
pty.kill();
|
|
609
|
+
await exitPromise;
|
|
593
610
|
}
|
|
594
611
|
catch {
|
|
595
612
|
// Best effort shutdown.
|
|
596
613
|
}
|
|
597
|
-
this.pty = null;
|
|
598
|
-
this.state.status = "stopped";
|
|
599
|
-
this.state.pid = undefined;
|
|
600
614
|
}
|
|
601
615
|
getState() {
|
|
602
616
|
return JSON.parse(JSON.stringify(this.state));
|
|
@@ -285,21 +285,25 @@ export class OpenCodeServerAdapter {
|
|
|
285
285
|
if (this.nativeProcess) {
|
|
286
286
|
const proc = this.nativeProcess;
|
|
287
287
|
this.nativeProcess = null;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
288
|
+
if (proc.pid != null) {
|
|
289
|
+
try {
|
|
290
|
+
killProcessTreeSync(proc.pid);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Best effort.
|
|
294
|
+
}
|
|
293
295
|
}
|
|
294
296
|
}
|
|
295
297
|
if (this.serverProcess) {
|
|
296
298
|
const proc = this.serverProcess;
|
|
297
299
|
this.serverProcess = null;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
if (proc.pid != null) {
|
|
301
|
+
try {
|
|
302
|
+
killProcessTreeSync(proc.pid);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// Best effort.
|
|
306
|
+
}
|
|
303
307
|
}
|
|
304
308
|
}
|
|
305
309
|
this.client = null;
|
|
@@ -328,9 +328,14 @@ export function killProcessTreeSync(pid) {
|
|
|
328
328
|
}
|
|
329
329
|
else {
|
|
330
330
|
try {
|
|
331
|
-
process.kill(pid);
|
|
331
|
+
process.kill(-pid);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
try {
|
|
335
|
+
process.kill(pid);
|
|
336
|
+
}
|
|
337
|
+
catch { /* best effort */ }
|
|
332
338
|
}
|
|
333
|
-
catch { /* best effort */ }
|
|
334
339
|
}
|
|
335
340
|
}
|
|
336
341
|
export async function reapPeerBridgeProcesses(params = {}) {
|
|
@@ -268,8 +268,8 @@ function formatPromptByteSize(bytes) {
|
|
|
268
268
|
}
|
|
269
269
|
function formatWechatInboundAttachmentPrompt(attachments) {
|
|
270
270
|
const lines = [
|
|
271
|
-
"[WeChat inbound attachments]",
|
|
272
|
-
"The user sent
|
|
271
|
+
"[WeChat inbound attachments — ACTION REQUIRED]",
|
|
272
|
+
"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
273
|
];
|
|
274
274
|
attachments.forEach((attachment, index) => {
|
|
275
275
|
const sizeText = formatPromptByteSize(attachment.sizeBytes);
|
|
@@ -280,6 +280,8 @@ function formatWechatInboundAttachmentPrompt(attachments) {
|
|
|
280
280
|
].filter(Boolean);
|
|
281
281
|
lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
|
|
282
282
|
});
|
|
283
|
+
lines.push("");
|
|
284
|
+
lines.push("Remember: Read each path above with the Read tool NOW. Images are viewable via Read.");
|
|
283
285
|
return lines.join("\n");
|
|
284
286
|
}
|
|
285
287
|
export function buildWechatInboundPrompt(text, attachments = []) {
|
|
@@ -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,80 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
|
|
3
|
+
export const DEFAULT_EMOJI_BINDINGS = {
|
|
4
|
+
"[OK]": "/confirm",
|
|
5
|
+
"[闭嘴]": "/stop",
|
|
6
|
+
"[拥抱]": "/claude",
|
|
7
|
+
"[强]": "/codex",
|
|
8
|
+
"[胜利]": "/opencode",
|
|
9
|
+
"[再见]": "/daemon-stop",
|
|
10
|
+
};
|
|
11
|
+
let bindingsMap = null;
|
|
12
|
+
function buildMap(bindings) {
|
|
13
|
+
const map = new Map();
|
|
14
|
+
for (const [emoji, command] of Object.entries(bindings)) {
|
|
15
|
+
map.set(emoji.toLowerCase(), command);
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
export function loadEmojiBindings() {
|
|
20
|
+
if (bindingsMap) {
|
|
21
|
+
return bindingsMap;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
if (fs.existsSync(EMOJI_BINDINGS_FILE)) {
|
|
25
|
+
const raw = JSON.parse(fs.readFileSync(EMOJI_BINDINGS_FILE, "utf-8"));
|
|
26
|
+
if (raw.bindings && typeof raw.bindings === "object") {
|
|
27
|
+
bindingsMap = buildMap(raw.bindings);
|
|
28
|
+
return bindingsMap;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Fall through to defaults on parse error.
|
|
34
|
+
}
|
|
35
|
+
bindingsMap = buildMap(DEFAULT_EMOJI_BINDINGS);
|
|
36
|
+
return bindingsMap;
|
|
37
|
+
}
|
|
38
|
+
export function saveEmojiBindings(map) {
|
|
39
|
+
const bindings = {};
|
|
40
|
+
for (const [emoji, command] of map) {
|
|
41
|
+
bindings[emoji] = command;
|
|
42
|
+
}
|
|
43
|
+
const data = { bindings };
|
|
44
|
+
fs.writeFile(EMOJI_BINDINGS_FILE, JSON.stringify(data, null, 2) + "\n", () => { });
|
|
45
|
+
}
|
|
46
|
+
export function setBinding(emoji, command) {
|
|
47
|
+
const map = loadEmojiBindings();
|
|
48
|
+
map.set(emoji.toLowerCase(), command);
|
|
49
|
+
bindingsMap = map;
|
|
50
|
+
saveEmojiBindings(map);
|
|
51
|
+
}
|
|
52
|
+
export function removeBinding(emoji) {
|
|
53
|
+
const map = loadEmojiBindings();
|
|
54
|
+
const key = emoji.toLowerCase();
|
|
55
|
+
if (!map.has(key)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
map.delete(key);
|
|
59
|
+
bindingsMap = map;
|
|
60
|
+
saveEmojiBindings(map);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
export function listBindings() {
|
|
64
|
+
return loadEmojiBindings();
|
|
65
|
+
}
|
|
66
|
+
export function resolveEmojiCommand(text) {
|
|
67
|
+
const trimmed = text.trim();
|
|
68
|
+
if (!trimmed.startsWith("[")) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const map = loadEmojiBindings();
|
|
72
|
+
const lower = trimmed.toLowerCase();
|
|
73
|
+
for (const [emoji, command] of map) {
|
|
74
|
+
if (lower.startsWith(emoji)) {
|
|
75
|
+
const remainder = trimmed.slice(emoji.length).trim();
|
|
76
|
+
return { command, remainder };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|