@fengming-gh/oc-wechat-bridge 1.0.5 → 1.0.7

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.
Files changed (3) hide show
  1. package/README.md +102 -0
  2. package/package.json +3 -7
  3. package/src/index.ts +25 -36
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # oc-wechat-bridge
2
+
3
+ 微信 ↔ OpenCode 双向桥接插件。在微信中直接与 OC AI 对话,接收实时进度、切换会话。集成跨会话转发与自动续命。
4
+
5
+ ## 功能
6
+
7
+ - **双向消息**:微信↔OC 消息实时互通,AI 回复自动转发到微信
8
+ - **流式输出**:AI 思考、工具调用、回复文字实时推送微信,不等完整回复
9
+ - **多项目目录**:自动发现同级带 `.opencode/` 的项目,会话按目录分组显示
10
+ - **会话管理**:全局编号 `/switch` 跨目录切换、`/status` 查看全貌
11
+
12
+ - **自动恢复**:AI 出错或上下文压缩后自动重试,微信端无感(集成了 [oc-auto-continue](https://github.com/Fengming-GH/oc-auto-continue))
13
+ - **跨会话转发**:AI 可通过 `!` 指令将消息转发到其他会话(集成了 [oc-forward](https://github.com/Fengming-GH/oc-forward))
14
+ - **消息不阻塞**:`promptAsync` 注射消息后立即返回,AI 处理期间仍可接收新消息
15
+
16
+ ## 安装
17
+
18
+ ### 通过 npm 安装
19
+
20
+ 在项目根目录的 `opencode.json` 中添加:
21
+
22
+ ```json
23
+ {
24
+ "plugin": ["@fengming-gh/oc-wechat-bridge"]
25
+ }
26
+ ```
27
+
28
+ 重启 OC 后自动加载。无需手动 `npm install`。
29
+
30
+ ### 复制文件安装
31
+
32
+ 将 `src/index.ts` 复制到 OC 项目的 `.opencode/plugins/` 目录下:
33
+
34
+ ```bash
35
+ cp src/index.ts 你的项目/.opencode/plugins/wechat-bridge.ts
36
+ ```
37
+
38
+ 重启 OC,首次启动弹出二维码,微信扫码登录。无需 `npm install`,插件零外部运行时依赖。
39
+
40
+ ## 指令
41
+
42
+ 所有指令通过微信发送给 Bot,以 `/` 开头。中英文别名均可识别。
43
+
44
+ | 指令 | 别名 | 功能 |
45
+ |------|------|------|
46
+ | `/stop` | `/停止` | 中断 AI 任务 |
47
+ | `/status` | `/状态` / `/会话` | 查看所有目录和会话 |
48
+ | `/switch N` | `/切换 N` | 切换到指定会话并绑定 |
49
+ | `/new [N]` | `/新建 [N]` | 创建新会话 |
50
+ | `/unbind` | `/解绑` | 解绑当前会话 |
51
+ | `/rename <标题>` | `/改名 <标题>` | 修改会话标题 |
52
+ | `/mode` | `/模式` | 查看当前会话模式(只读) |
53
+ | `/help` | `/帮助` | 显示指令列表 |
54
+
55
+ **无空格参数:** `/switch3` 与 `/switch 3` 等价。
56
+
57
+ ## AI 工具 / 跨会话指令
58
+
59
+ AI 对话中支持以下操作:
60
+
61
+ | 输入 | 说明 |
62
+ |------|------|
63
+ | `!会话` 或 `!sessions` | AI 调用 `list_sessions` 列出所有会话 |
64
+ | `!<前缀> <消息>` 或 `!<前缀> <消息>` | AI 调用 `forward_to_session` 转发消息到目标会话 |
65
+
66
+ `!`(全角)和 `!`(半角)都支持。
67
+
68
+ ## 进度与格式
69
+
70
+ AI 处理时微信实时收到:
71
+
72
+ ```
73
+ 思考中... ← 推理进度
74
+ read ← 工具调用
75
+ edit
76
+ 好的…… ← AI 回复文字
77
+ ```
78
+
79
+ 会话列表按目录分组,带空行分隔:
80
+
81
+ ```
82
+ 📁 项目A — 3 个会话
83
+ 1. 📱WeChat插件 [当前会话]
84
+ 2. 微信测试
85
+ 3. 改造计划审查
86
+
87
+ 📁 项目B — 1 个会话
88
+ 4. 编译 4.1 版本
89
+ ```
90
+
91
+ ## 技术栈
92
+
93
+ - 单文件 TypeScript(~740 行)
94
+ - 零外部运行时依赖(仅 Node 内置模块)
95
+ - 基于微信官方 iLink Bot API 开发,非逆向/非破解
96
+ - 原生 Web Fetch API
97
+ - AES-128-ECB 加密传输(CDN 媒体文件)
98
+ - 事件驱动架构(`message.part.updated` / `session.idle` 等)
99
+
100
+ ## 许可
101
+
102
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fengming-gh/oc-wechat-bridge",
3
- "version": "1.0.5",
4
- "description": "将微信消息桥接到 OpenCode,支持双向对话与权限审批",
3
+ "version": "1.0.7",
4
+ "description": "将微信消息桥接到 OpenCode,支持双向对话、会话管理、跨会话转发",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -20,10 +20,6 @@
20
20
  "publishConfig": {
21
21
  "access": "public"
22
22
  },
23
- "peerDependencies": {
24
- "@opencode-ai/plugin": ">=1.0.0",
25
- "@opencode-ai/sdk": ">=1.0.0"
26
- },
27
23
  "repository": {
28
24
  "type": "git",
29
25
  "url": "git+https://github.com/Fengming-GH/oc-wechat-bridge.git"
@@ -31,5 +27,5 @@
31
27
  "bugs": {
32
28
  "url": "https://github.com/Fengming-GH/oc-wechat-bridge/issues"
33
29
  },
34
- "homepage": "https://github.com/Fengming-GH/oc-wechat-bridge#readme"
30
+ "homepage": "https://github.com/Fengming-GH/oc-wechat-bridge#readme"
35
31
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // ============================================================
2
2
  // oc-wechat-bridge: 将微信消息桥接到 OpenCode
3
+ // 本插件用于 OpenCode WIN平台 桌面端,实现打通微信和 OpenCode 会话。最多允许一个微信在同一时刻绑定一个会话,允许微信切换会话。
3
4
  // ============================================================
4
5
 
5
6
  import type { Plugin } from "@opencode-ai/plugin"
@@ -56,6 +57,7 @@ const sidTitle = new Map<string, string>()
56
57
  const wechatSid = new Map<string, string>()
57
58
  const _pendingFirstContact = new Set<string>()
58
59
  let _projectDirs: string[] = []
60
+ let _worktree = ""
59
61
  const _modeCache = new Map<string, string>()
60
62
 
61
63
  const _thinkingSent = new Set<string>()
@@ -68,7 +70,7 @@ const _compacted = new Set<string>()
68
70
 
69
71
  function enqueueSend(sid: string, fn: () => Promise<void>) {
70
72
  const prev = _fwdQueue.get(sid) ?? Promise.resolve()
71
- _fwdQueue.set(sid, prev.then(() => fn(), () => fn()))
73
+ _fwdQueue.set(sid, prev.then(() => fn()).catch(() => {}))
72
74
  }
73
75
 
74
76
  // ============================================================
@@ -141,7 +143,7 @@ function buildCdnUploadUrl(uploadParam: string, filekey: string): string {
141
143
  function initSessionCache(client: any) {
142
144
  setTimeout(async () => {
143
145
  try {
144
- const { flat } = await listAllSessions(client)
146
+ const { flat } = await _listSessionsByDirs(client, _projectDirs)
145
147
  for (const s of flat) sidTitle.set(s.id, s.title)
146
148
  log("INIT", `cached ${sidTitle.size} sessions`)
147
149
  } catch (err) {
@@ -230,18 +232,6 @@ async function getOrCreateSession(client: any, wechatId: string, _worktree: stri
230
232
  return sid
231
233
  }
232
234
  } catch (err) { log("SESSION_CREATE_FAIL", `${err}`) }
233
- try {
234
- const resp: any = await client.session.list()
235
- const all: Session[] = Array.isArray(resp) ? resp : resp.data ?? []
236
- const first = all.find((s: Session) => !s.parentID)
237
- if (first) {
238
- wechatSid.set(wechatId, first.id)
239
- sidTitle.set(first.id, first.title)
240
- await updateSessionIcon(client, first.id, "normal")
241
- saveState()
242
- return first.id
243
- }
244
- } catch { /* best effort */ }
245
235
  throw new Error("No available session")
246
236
  }
247
237
 
@@ -311,7 +301,7 @@ function loadCredentials(): WechatCredentials | null {
311
301
  function saveCredentials(cred: WechatCredentials) {
312
302
  ensureDataDir()
313
303
  const tmp = CREDENTIALS_FILE + ".tmp"
314
- try { writeFileSync(tmp, JSON.stringify(cred, null, 2), "utf-8"); renameSync(tmp, CREDENTIALS_FILE) } catch (e) { log("CRED_WRITE_ERR", `${e}`) }
304
+ try { writeFileSync(tmp, JSON.stringify(cred, null, 2), "utf-8"); renameSync(tmp, CREDENTIALS_FILE) } catch (e) { log("CRED_WRITE_ERR", `${e}`) } finally { try { rmSync(tmp) } catch { } }
315
305
  }
316
306
 
317
307
  async function validateCredentials(cred: WechatCredentials): Promise<string | null> {
@@ -338,7 +328,7 @@ function saveState() {
338
328
  for (const [wx, sid] of wechatSid) { if (wx.endsWith("@im.wechat")) sm[wx] = sid }
339
329
  const state: any = { syncBuf: syncBuffer, contextTokens: ct }
340
330
  if (Object.keys(sm).length) state.sessionMap = sm
341
- try { writeFileSync(tmp, JSON.stringify(state), "utf-8"); renameSync(tmp, STATE_FILE) } catch (e) { log("STATE_WRITE_ERR", `${e}`) }
331
+ try { writeFileSync(tmp, JSON.stringify(state), "utf-8"); renameSync(tmp, STATE_FILE) } catch (e) { log("STATE_WRITE_ERR", `${e}`) } finally { try { rmSync(tmp) } catch { } }
342
332
  }
343
333
  function loadState() {
344
334
  try {
@@ -478,7 +468,7 @@ async function processInboundMessage(raw: any, account: WechatCredentials, clien
478
468
  recentMessageKeys.add(msgKey); recentMessageOrder.push(msgKey)
479
469
  while (recentMessageOrder.length > RECENT_KEYS_MAX) recentMessageKeys.delete(recentMessageOrder.shift()!)
480
470
  const senderId = raw.from_user_id ?? "unknown"
481
- if (raw.context_token) { contextTokens.set(senderId, raw.context_token); saveState() }
471
+ if (raw.context_token && contextTokens.get(senderId) !== raw.context_token) { contextTokens.set(senderId, raw.context_token); saveState() }
482
472
  const downloadedPaths: string[] = []
483
473
  for (const att of attachments) {
484
474
  try { const enc = await downloadFromCdn(att.media); const pt = decryptInboundMediaPayload(enc, att.aesKey); downloadedPaths.push(saveAttachment(att.fileName || `wechat-${att.kind}`, pt)) }
@@ -529,11 +519,11 @@ function saveAttachment(fileName: string, data: Buffer): string {
529
519
  function resolveDir(dirIdx: number | null, worktree: string): string { return (!dirIdx || dirIdx < 1 || dirIdx > _projectDirs.length) ? worktree : _projectDirs[dirIdx - 1] }
530
520
  function getNick(dir: string): string { const n = basename(dir); return n || dir }
531
521
 
532
- async function listAllSessions(client: any): Promise<{ flat: Session[]; dirMap: Map<string, number> }> {
522
+ async function _listSessionsByDirs(client: any, dirs: string[]): Promise<{ flat: Session[]; dirMap: Map<string, number> }> {
533
523
  const flat: Session[] = []; const dm = new Map<string, number>()
534
- for (let di = 0; di < _projectDirs.length; di++) {
524
+ for (let di = 0; di < dirs.length; di++) {
535
525
  try {
536
- const resp: any = await client.session.list({ query: { directory: _projectDirs[di] } })
526
+ const resp: any = await client.session.list({ query: { directory: dirs[di] } })
537
527
  const all: Session[] = Array.isArray(resp) ? resp : resp.data ?? []
538
528
  for (const s of all) { if (!s.parentID) { flat.push(s); dm.set(s.id, di) } }
539
529
  } catch { /* skip */ }
@@ -541,6 +531,11 @@ async function listAllSessions(client: any): Promise<{ flat: Session[]; dirMap:
541
531
  return { flat, dirMap: dm }
542
532
  }
543
533
 
534
+ async function listAllSessions(client: any): Promise<{ flat: Session[]; dirMap: Map<string, number> }> {
535
+ _projectDirs = findProjectDirs(_worktree)
536
+ return _listSessionsByDirs(client, _projectDirs)
537
+ }
538
+
544
539
  function formatDirSessions(flat: Session[], dm: Map<string, number>, cur: string | undefined): string[] {
545
540
  const lines: string[] = []; let idx = 0
546
541
  for (let di = 0; di < _projectDirs.length; di++) {
@@ -552,9 +547,9 @@ function formatDirSessions(flat: Session[], dm: Map<string, number>, cur: string
552
547
  return lines
553
548
  }
554
549
 
555
- async function formatSessionGuide(client: any, cur: string | undefined): Promise<string> {
550
+ async function formatSessionGuide(client: any, cur: string | undefined, prefetched?: { flat: Session[]; dirMap: Map<string, number> }): Promise<string> {
556
551
  try {
557
- const { flat, dirMap: dm } = await listAllSessions(client)
552
+ const { flat, dirMap: dm } = prefetched ?? await listAllSessions(client)
558
553
  const sl = formatDirSessions(flat, dm, cur)
559
554
  return (sl.length ? sl.join("\n") : "(无会话)") + "\n回复 /switch <编号> 切换"
560
555
  } catch { return "获取会话列表失败" }
@@ -576,13 +571,14 @@ async function handleCommand(cmd: string, senderId: string, account: WechatCrede
576
571
  if (pv && pv !== m.id) { const pt = flat.find(s => s.id === pv)?.title; await stripSessionIcon(client, pv, pt) }
577
572
  await updateSessionIcon(client, m.id, "normal"); await wx(`已切换到: ${m.title}`) } catch { await wx("切换失败") }; break }
578
573
  case "unbind": case "解绑": { const old = wechatSid.get(senderId)
574
+ let fetched: { flat: Session[]; dirMap: Map<string, number> } | undefined
579
575
  let oldTitle = sidTitle.get(old)
580
576
  if (old) {
581
- try { const { flat } = await listAllSessions(client); const found = flat.find(s => s.id === old); if (found) oldTitle = found.title } catch { /* */ }
582
- if (oldTitle && ICON_PREFIXES.some(p => oldTitle.startsWith(p))) { await stripSessionIcon(client, old, oldTitle) }
577
+ try { fetched = await listAllSessions(client); const found = fetched.flat.find(s => s.id === old); if (found) oldTitle = found.title } catch { /* */ }
583
578
  wechatSid.delete(senderId); saveState()
579
+ if (oldTitle && ICON_PREFIXES.some(p => oldTitle.startsWith(p))) { await stripSessionIcon(client, old, oldTitle) }
584
580
  }
585
- await wx(`WeChat 桥接\n${await formatSessionGuide(client, undefined)}`); break }
581
+ await wx(`WeChat 桥接\n${await formatSessionGuide(client, undefined, fetched)}`); break }
586
582
  case "rename": case "改名": { const nn = args.join(" ").trim(); if (!nn) { await wx("请指定标题"); break }; const sid = wechatSid.get(senderId); if (!sid) { await wx("未绑定"); break }
587
583
  try { await client.session.update({ path: { id: sid }, body: { title: nn } }); sidTitle.set(sid, nn); await updateSessionIcon(client, sid, "normal"); await wx(`已改名: ${t(sid)}`) } catch { await wx("改名失败") }; break }
588
584
  case "mode": case "模式": { const sid = wechatSid.get(senderId); if (!sid) { await wx("未绑定"); break }
@@ -670,7 +666,7 @@ export const WechatBridgePlugin: Plugin = async ({ client, worktree }) => {
670
666
  try { await client.app.log({ body: { service: "wechat-bridge", level: "info", message: "plugin loaded" } }) } catch { /* */ }
671
667
  migrateOldDataDir()
672
668
  migrateOldStateFiles(worktree)
673
- _projectDirs = findProjectDirs(worktree)
669
+ _projectDirs = findProjectDirs(worktree); _worktree = worktree
674
670
  initSessionCache(client)
675
671
  loadState()
676
672
  const abortController = new AbortController()
@@ -679,13 +675,7 @@ export const WechatBridgePlugin: Plugin = async ({ client, worktree }) => {
679
675
  event: createEventHandler(client),
680
676
  "permission.ask": createPermissionHandler(client),
681
677
  "experimental.chat.system.transform": async (_input: any, output: { system: string[] }) => {
682
- try {
683
- const { flat, dirMap } = await listAllSessions(client)
684
- if (flat.length === 0) return
685
- const sl = formatDirSessions(flat, dirMap, undefined)
686
- const lines = ["当前可用的会话:", ...sl, "", "用户输入以 ! 或 ! 开头的消息时,这是跨会话指令:", " - !会话 或 !sessions → 调用 list_sessions 工具", " - !<前缀> <消息> 或 !<前缀> <消息> → 调用 forward_to_session 工具转发"]
687
- output.system.push(lines.join("\n"))
688
- } catch { /* best effort */ }
678
+ output.system.push("用户输入以 ! 或 ! 开头的消息时,这是跨会话指令:\n - !会话 或 !sessions → 调用 list_sessions 工具\n - !<前缀> <消息> 或 !<前缀> <消息> → 调用 forward_to_session 工具转发\n\n注意:list_sessions 工具返回的内容包含 ``` 代码块,这是数据格式的一部分,删除代码块会导致数据损坏,必须一字不差地输出。")
689
679
  },
690
680
  tool: createTools(client),
691
681
  }
@@ -706,7 +696,6 @@ function createEventHandler(client: any) {
706
696
  await updateSessionIcon(client, sid, "normal")
707
697
  try { const resp: any = await client.session.messages({ path: { id: sid }, query: { limit: 15 } }); const msgs = Array.isArray(resp) ? resp : resp.data ?? []
708
698
  for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i].info?.role === "assistant") { if (msgs[i].info.mode) _modeCache.set(sid, msgs[i].info.mode); break } } } catch { /* */ }
709
- _thinkingSent.delete(sid)
710
699
  if (_pendingContinue.has(sid)) { _pendingContinue.delete(sid); try { await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text" as any, text: "检测到异常,请继续" }] } }) } catch { } }
711
700
  if (_compacted.has(sid)) { _compacted.delete(sid); try { await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text" as any, text: "上下文被压缩" }] } }) } catch { } }
712
701
  return
@@ -741,9 +730,9 @@ function createTools(client: any) {
741
730
  if (!_creds) return { output: "微信桥接尚未完成登录" }
742
731
  return { output: [`微信账户: ${_creds.accountId}`, `绑定用户: ${_creds.userId ?? "(无)"}`, `会话缓存: ${sidTitle.size} 个`, `上下文令牌: ${contextTokens.size} 个`, `同步游标: ${syncBuffer ? "存在" : "无"}`, `数据目录: ${DATA_DIR}`].join("\n") }
743
732
  } }),
744
- list_sessions: tool({ description: "列出所有可用会话的标题和 ID", args: {},
733
+ list_sessions: tool({ description: "列出所有可用会话的标题和 ID。调用后带 ``` 代码块原样输出,删除代码块会破坏数据格式,必须保留。", args: {},
745
734
  execute: async (_args: any, ctx: any) => {
746
- try { const { flat, dirMap } = await listAllSessions(client); if (flat.length === 0) return { output: "暂无会话" }; return { output: formatDirSessions(flat, dirMap, ctx?.sessionID).join("\n") } } catch { return { output: "获取会话列表失败" } }
735
+ try { const { flat, dirMap } = await listAllSessions(client); if (flat.length === 0) return { output: "暂无会话" }; const body = formatDirSessions(flat, dirMap, ctx?.sessionID).join("\n"); return { output: "```\n" + body + "\n```" } } catch { return { output: "获取会话列表失败" } }
747
736
  } }),
748
737
  forward_to_session: tool({ description: "转发消息到标题前缀匹配的会话。用户说「转发」时使用此工具", args: { prefix: tool.schema.string().describe("目标会话标题前缀"), message: tool.schema.string().describe("要转发的消息内容") },
749
738
  execute: async (args: any, ctx: any) => {