@fengming-gh/oc-wechat-bridge 1.0.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.
Files changed (2) hide show
  1. package/package.json +35 -0
  2. package/src/index.ts +756 -0
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@fengming-gh/oc-wechat-bridge",
3
+ "version": "1.0.1",
4
+ "description": "将微信消息桥接到 OpenCode,支持双向对话与权限审批",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "files": [
9
+ "src"
10
+ ],
11
+ "keywords": [
12
+ "opencode",
13
+ "plugin",
14
+ "wechat",
15
+ "weixin",
16
+ "bridge"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "fengming-gh",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "peerDependencies": {
24
+ "@opencode-ai/plugin": ">=1.0.0",
25
+ "@opencode-ai/sdk": ">=1.0.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/Fengming-GH/oc-wechat-bridge.git"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/Fengming-GH/oc-wechat-bridge/issues"
33
+ },
34
+ "homepage": "https://github.com/Fengming-GH/oc-wechat-bridge#readme"
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,756 @@
1
+ // ============================================================
2
+ // oc-wechat-bridge: 将微信消息桥接到 OpenCode
3
+ // ============================================================
4
+
5
+ import type { Plugin } from "@opencode-ai/plugin"
6
+ import type {
7
+ EventSessionIdle, EventSessionCreated, EventSessionDeleted, EventSessionUpdated, Session,
8
+ } from "@opencode-ai/sdk"
9
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
10
+ const tool: any = Object.assign(
11
+ (x: any) => x,
12
+ {
13
+ schema: {
14
+ string: () => {
15
+ const obj: any = { _def: { typeName: "ZodString" }, describe(d: string) { this._def.description = d; return this } }
16
+ return obj
17
+ },
18
+ },
19
+ },
20
+ )
21
+ import { appendFileSync, mkdirSync, writeFileSync, readFileSync, existsSync, renameSync, readdirSync, rmSync } from "node:fs"
22
+ import { resolve, dirname, join, basename } from "node:path"
23
+ import { fileURLToPath } from "node:url"
24
+
25
+ // ============================================================
26
+ // Section 2: Constants & Files
27
+ // ============================================================
28
+ const __dirname = dirname(fileURLToPath(import.meta.url))
29
+ const LOG_PATH = resolve(__dirname, "log", "wechat-bridge.log")
30
+ const PROJECT_ROOT = resolve(__dirname, "..", "..")
31
+ try { mkdirSync(join(__dirname, "log"), { recursive: true }) } catch { /* ok */ }
32
+
33
+ const HOME_DIR = process.env.HOME ?? process.env.USERPROFILE ?? "."
34
+ const OLD_DATA_DIR = join(HOME_DIR, ".cli-bridge")
35
+ const DATA_DIR = process.env.WECHAT_BRIDGE_DATA_DIR?.trim() ?? join(HOME_DIR, ".opencode", "wechat-bridge")
36
+ const CREDENTIALS_FILE = join(DATA_DIR, "account.json")
37
+ const STATE_FILE = join(DATA_DIR, "state.json")
38
+ const ATTACHMENTS_DIR = join(DATA_DIR, "inbound-attachments")
39
+ const BASE_URL = process.env.WECHAT_ILINK_BASE_URL?.trim() ?? "https://ilinkai.weixin.qq.com"
40
+ const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c"
41
+ const CHANNEL_VERSION = "0.3.0"
42
+ const LONG_POLL_TIMEOUT_MS = 20_000
43
+ const SEND_TIMEOUT_MS = 15_000
44
+ const CDN_DOWNLOAD_TIMEOUT_MS = 30_000
45
+ const CDN_MAX_RETRIES = 3
46
+ const RECENT_KEYS_MAX = 500
47
+ const MSG_TYPE_USER = 1; const MSG_TYPE_BOT = 2
48
+ const MSG_ITEM_TEXT = 1; const MSG_ITEM_IMAGE = 2; const MSG_ITEM_VOICE = 3; const MSG_ITEM_FILE = 4; const MSG_ITEM_VIDEO = 5
49
+ const MSG_STATE_FINISH = 2
50
+
51
+ let syncBuffer = ""
52
+ const contextTokens = new Map<string, string>()
53
+ const recentMessageKeys = new Set<string>()
54
+ const recentMessageOrder: string[] = []
55
+ const sidTitle = new Map<string, string>()
56
+ const wechatSid = new Map<string, string>()
57
+ const _pendingFirstContact = new Set<string>()
58
+ let _projectDirs: string[] = []
59
+ const _modeCache = new Map<string, string>()
60
+
61
+ const _thinkingSent = new Set<string>()
62
+ const _fwdLastTool = new Map<string, string>()
63
+ const _userMsgIds = new Set<string>()
64
+ const _fwdQueue = new Map<string, Promise<void>>()
65
+ const _pendingContinue = new Set<string>()
66
+ const CONTINUE_ERRORS = new Set(["MessageOutputLengthError", "APIError", "UnknownError"])
67
+ const _compacted = new Set<string>()
68
+
69
+ function enqueueSend(sid: string, fn: () => Promise<void>) {
70
+ const prev = _fwdQueue.get(sid) ?? Promise.resolve()
71
+ _fwdQueue.set(sid, prev.then(() => fn(), () => fn()))
72
+ }
73
+
74
+ // ============================================================
75
+ // Section 3: Utility Helpers
76
+ // ============================================================
77
+ function bjNow(): string {
78
+ return new Intl.DateTimeFormat("zh-CN", {
79
+ timeZone: "Asia/Shanghai",
80
+ year: "numeric", month: "2-digit", day: "2-digit",
81
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
82
+ hour12: false,
83
+ }).format(new Date()).replace(/\//g, "-")
84
+ }
85
+
86
+ function log(level: string, msg: string) {
87
+ try { appendFileSync(LOG_PATH, `${bjNow()} [${level}] ${msg}\n`, "utf-8") } catch { /* best effort */ }
88
+ }
89
+
90
+ function t(sid: string): string {
91
+ return sidTitle.get(sid) ?? "?" + sid.slice(0, 8)
92
+ }
93
+
94
+ function sleep(ms: number): Promise<void> {
95
+ return new Promise(r => setTimeout(r, ms))
96
+ }
97
+
98
+ // ============================================================
99
+ // Section 4: Crypto Helpers (AES-128-ECB)
100
+ // ============================================================
101
+ function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer {
102
+ const cipher = createCipheriv("aes-128-ecb", key, null)
103
+ return Buffer.concat([cipher.update(plaintext), cipher.final()])
104
+ }
105
+ function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer {
106
+ const decipher = createDecipheriv("aes-128-ecb", key, null)
107
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()])
108
+ }
109
+ function aesEcbPaddedSize(plaintextSize: number): number {
110
+ return Math.ceil((plaintextSize + 1) / 16) * 16
111
+ }
112
+ function randomWechatUin(): string {
113
+ const uint32 = randomBytes(4).readUInt32BE(0)
114
+ return Buffer.from(String(uint32), "utf-8").toString("base64")
115
+ }
116
+ function decodeInboundMediaAesKey(value: string): Buffer {
117
+ const trimmed = value.trim()
118
+ if (/^[a-f0-9]{32}$/i.test(trimmed)) return Buffer.from(trimmed, "hex")
119
+ const decoded = Buffer.from(trimmed, "base64")
120
+ if (decoded.length === 16) return decoded
121
+ const decodedText = decoded.toString("utf8").trim()
122
+ if (/^[a-f0-9]{32}$/i.test(decodedText)) return Buffer.from(decodedText, "hex")
123
+ throw new Error("Unsupported inbound media aes key format: " + value.slice(0, 20))
124
+ }
125
+ function decryptInboundMediaPayload(ciphertext: Buffer, aesKey: string): Buffer {
126
+ return decryptAesEcb(ciphertext, decodeInboundMediaAesKey(aesKey))
127
+ }
128
+ function encodeMessageAesKey(aesKey: Buffer): string {
129
+ return Buffer.from(aesKey.toString("hex")).toString("base64")
130
+ }
131
+ function buildCdnDownloadUrl(encryptQueryParam: string): string {
132
+ return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`
133
+ }
134
+ function buildCdnUploadUrl(uploadParam: string, filekey: string): string {
135
+ return `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`
136
+ }
137
+
138
+ // ============================================================
139
+ // Section 5: Session Cache
140
+ // ============================================================
141
+ function initSessionCache(client: any) {
142
+ setTimeout(async () => {
143
+ try {
144
+ const { flat } = await listAllSessions(client)
145
+ for (const s of flat) sidTitle.set(s.id, s.title)
146
+ log("INIT", `cached ${sidTitle.size} sessions`)
147
+ } catch (err) {
148
+ log("INIT_FAIL", `${err}`)
149
+ }
150
+ }, 0)
151
+ }
152
+
153
+ const WECHAT_ICON = "📱"
154
+ const WECHAT_ICON_DEGRADED = "📵"
155
+ const WECHAT_ICON_PROCESSING = "💬"
156
+ const WECHAT_ICON_OFFLINE = "🔴"
157
+ const ICON_PREFIXES = [WECHAT_ICON, WECHAT_ICON_DEGRADED, WECHAT_ICON_PROCESSING, WECHAT_ICON_OFFLINE]
158
+
159
+ function wechatTitle(): string {
160
+ const d = new Date()
161
+ const parts = new Intl.DateTimeFormat("zh-CN", {
162
+ timeZone: "Asia/Shanghai",
163
+ year: "numeric", month: "numeric", day: "numeric",
164
+ hour: "2-digit", minute: "2-digit", hour12: false,
165
+ }).formatToParts(d)
166
+ const m = new Map(parts.map(p => [p.type, p.value]))
167
+ return `微信-${m.get("year")}-${m.get("month")}-${m.get("day")}/${m.get("hour")}-${m.get("minute")}`
168
+ }
169
+
170
+ function stripIconPrefix(title: string): string {
171
+ for (const p of ICON_PREFIXES) {
172
+ if (title.startsWith(p)) return title.slice(p.length)
173
+ }
174
+ return title
175
+ }
176
+
177
+ async function updateSessionIcon(client: any, sid: string, status: "normal" | "degraded" | "processing" | "offline") {
178
+ try {
179
+ let title = sidTitle.get(sid)
180
+ if (!title) return
181
+ const base = stripIconPrefix(title)
182
+ const iconMap: Record<string, string> = {
183
+ normal: WECHAT_ICON, degraded: WECHAT_ICON_DEGRADED, processing: WECHAT_ICON_PROCESSING, offline: WECHAT_ICON_OFFLINE,
184
+ }
185
+ const newTitle = iconMap[status] + base
186
+ if (newTitle !== title) {
187
+ await client.session.update({ path: { id: sid }, body: { title: newTitle } })
188
+ sidTitle.set(sid, newTitle)
189
+ }
190
+ } catch { /* best effort */ }
191
+ }
192
+
193
+ async function stripSessionIcon(client: any, sid: string, title: string | undefined): Promise<boolean> {
194
+ if (!title || !ICON_PREFIXES.some(p => title.startsWith(p))) return false
195
+ const stripped = stripIconPrefix(title)
196
+ try { await client.session.update({ path: { id: sid }, body: { title: stripped } }); sidTitle.set(sid, stripped); return true } catch { return false }
197
+ }
198
+
199
+ const sendFailCount = new Map<string, number>()
200
+ const SEND_FAIL_THRESHOLD = 3
201
+
202
+ async function recordSendResult(sid: string | null, success: boolean, client: any) {
203
+ if (!sid) return
204
+ const current = sendFailCount.get(sid) ?? 0
205
+ if (success) {
206
+ sendFailCount.set(sid, 0)
207
+ await updateSessionIcon(client, sid, "normal")
208
+ } else {
209
+ const next = current + 1
210
+ sendFailCount.set(sid, next)
211
+ if (next >= SEND_FAIL_THRESHOLD) await updateSessionIcon(client, sid, "degraded")
212
+ }
213
+ }
214
+
215
+ async function getOrCreateSession(client: any, wechatId: string, _worktree: string): Promise<string> {
216
+ const existing = wechatSid.get(wechatId)
217
+ if (existing) {
218
+ await updateSessionIcon(client, existing, "normal")
219
+ return existing
220
+ }
221
+ try {
222
+ const title = wechatTitle()
223
+ const resp: any = await client.session.create({ body: { title } })
224
+ const sid = resp.id ?? resp.sessionID ?? resp.data?.id
225
+ if (sid) {
226
+ wechatSid.set(wechatId, sid); sidTitle.set(sid, title)
227
+ saveState()
228
+ await updateSessionIcon(client, sid, "normal")
229
+ log("SESSION", `created [${t(sid)}] for ${wechatId.slice(0, 8)}`)
230
+ return sid
231
+ }
232
+ } 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
+ throw new Error("No available session")
246
+ }
247
+
248
+ function findWechatSender(sid: string): string | null {
249
+ let fallback: string | null = null
250
+ for (const [wx, s] of wechatSid) {
251
+ if (s === sid) {
252
+ if (wx.endsWith("@im.wechat")) return wx
253
+ fallback = wx
254
+ }
255
+ }
256
+ return fallback
257
+ }
258
+
259
+ function resolveWorktree(worktree: string): string {
260
+ return (!worktree || worktree === "/" || worktree === "\\") ? PROJECT_ROOT : worktree
261
+ }
262
+
263
+ function findProjectDirs(worktree: string): string[] {
264
+ const effective = resolveWorktree(worktree)
265
+ const parent = resolve(effective, "..")
266
+ const dirs: string[] = [effective]
267
+ try {
268
+ for (const entry of readdirSync(parent)) {
269
+ const full = join(parent, entry)
270
+ if (full !== effective && existsSync(join(full, ".opencode"))) dirs.push(full)
271
+ }
272
+ } catch (e) { log("FIND_DIRS_ERR", `${worktree} ${e}`) }
273
+ return dirs
274
+ }
275
+
276
+ // ============================================================
277
+ // Section 6: Credentials & Data Persistence
278
+ // ============================================================
279
+ function ensureDataDir() {
280
+ try { mkdirSync(DATA_DIR, { recursive: true }) } catch { /* ok */ }
281
+ }
282
+
283
+ function migrateOldDataDir() {
284
+ ensureDataDir()
285
+ if (!existsSync(OLD_DATA_DIR)) return
286
+ const oldCreds = join(OLD_DATA_DIR, "account.json")
287
+ if (!existsSync(oldCreds)) {
288
+ try { rmSync(OLD_DATA_DIR, { recursive: true }) } catch { }
289
+ return
290
+ }
291
+ log("MIGRATE", `restoring old creds from ${OLD_DATA_DIR}`)
292
+ try {
293
+ for (const f of ["account.json", "wechat-login.html"]) {
294
+ const src = join(OLD_DATA_DIR, f)
295
+ if (existsSync(src)) renameSync(src, join(DATA_DIR, f))
296
+ }
297
+ const oldAtt = join(OLD_DATA_DIR, "inbound-attachments")
298
+ if (existsSync(oldAtt)) renameSync(oldAtt, ATTACHMENTS_DIR)
299
+ try { rmSync(OLD_DATA_DIR, { recursive: true }) } catch { }
300
+ log("MIGRATE", "done")
301
+ } catch (e) { log("MIGRATE_ERR", `${e}`) }
302
+ }
303
+
304
+ function loadCredentials(): WechatCredentials | null {
305
+ try {
306
+ if (!existsSync(CREDENTIALS_FILE)) return null
307
+ return JSON.parse(readFileSync(CREDENTIALS_FILE, "utf-8"))
308
+ } catch { return null }
309
+ }
310
+
311
+ function saveCredentials(cred: WechatCredentials) {
312
+ ensureDataDir()
313
+ 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}`) }
315
+ }
316
+
317
+ async function validateCredentials(cred: WechatCredentials): Promise<string | null> {
318
+ try {
319
+ const res = await fetch(`${BASE_URL}/ilink/bot/getupdates`, {
320
+ method: "POST",
321
+ headers: { "Content-Type": "application/json", AuthorizationType: "ilink_bot_token", Authorization: `Bearer ${cred.token}`, "X-WECHAT-UIN": randomWechatUin() },
322
+ body: JSON.stringify({ get_updates_buf: "", base_info: { channel_version: CHANNEL_VERSION } }),
323
+ signal: AbortSignal.timeout(5_000),
324
+ })
325
+ if (res.status === 401 || res.status === 403) return "Credentials rejected"
326
+ const parsed = JSON.parse(await res.text())
327
+ if (parsed.errcode === -14 && /session timeout/i.test(parsed.errmsg ?? "")) return "Session expired"
328
+ return null
329
+ } catch { return null }
330
+ }
331
+
332
+ function saveState() {
333
+ ensureDataDir()
334
+ const tmp = STATE_FILE + ".tmp"
335
+ const ct: Record<string, string> = {}
336
+ for (const [k, v] of contextTokens) { if (k.endsWith("@im.wechat")) ct[k] = v }
337
+ const sm: Record<string, string> = {}
338
+ for (const [wx, sid] of wechatSid) { if (wx.endsWith("@im.wechat")) sm[wx] = sid }
339
+ const state: any = { syncBuf: syncBuffer, contextTokens: ct }
340
+ 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}`) }
342
+ }
343
+ function loadState() {
344
+ try {
345
+ if (!existsSync(STATE_FILE)) { log("STATE", "not found"); return }
346
+ const raw = JSON.parse(readFileSync(STATE_FILE, "utf-8"))
347
+ if (raw.syncBuf) syncBuffer = raw.syncBuf
348
+ if (raw.contextTokens) for (const [k, v] of Object.entries(raw.contextTokens)) contextTokens.set(k, v as string)
349
+ if (raw.sessionMap) for (const [wx, sid] of Object.entries(raw.sessionMap)) wechatSid.set(wx, sid as string)
350
+ log("STATE", `restored buf=${!!raw.syncBuf} tokens=${Object.keys(raw.contextTokens??{}).length} map=${Object.keys(raw.sessionMap??{}).length}`)
351
+ } catch (e: any) { log("STATE_LOAD_ERR", `${e.message}`) }
352
+ }
353
+
354
+ async function qrCodeLogin(): Promise<WechatCredentials> {
355
+ log("LOGIN", "Starting QR code login")
356
+ const qrResp: any = await (await fetch(`${BASE_URL}/ilink/bot/get_bot_qrcode?bot_type=3`)).json()
357
+ const qrcode = qrResp.qrcode; const qrContent = qrResp.qrcode_img_content
358
+ const encodedUrl = encodeURIComponent(qrContent)
359
+ const QR_HTML_PATH = join(DATA_DIR, "wechat-login.html")
360
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>WeChat login</title></head><body style="display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#f5f5f5"><div style="text-align:center;background:#fff;padding:30px;border-radius:12px"><img src="https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodedUrl}" width="300"><p>scan QR code</p></div></body></html>`
361
+ ensureDataDir(); try { writeFileSync(QR_HTML_PATH, html, "utf-8") } catch { }
362
+ log("QR", `saved to ${QR_HTML_PATH}`)
363
+ try { const { exec } = await import("node:child_process"); exec(`start "" "${QR_HTML_PATH}"`) } catch { }
364
+ for (let i = 0; i < 120; i++) {
365
+ const status: any = await (await fetch(`${BASE_URL}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, { headers: { "iLink-App-ClientVersion": "1" } })).json()
366
+ if (status.status === "confirmed") {
367
+ const account: WechatCredentials = { token: status.bot_token, baseUrl: BASE_URL, accountId: status.ilink_bot_id, userId: status.ilink_user_id, savedAt: new Date().toISOString() }
368
+ saveCredentials(account)
369
+ log("LOGIN", `success: ${account.accountId}`)
370
+ return account
371
+ }
372
+ if (status.status === "scaned") log("QR", "scanned, waiting")
373
+ await sleep(1_000)
374
+ }
375
+ throw new Error("QR login timed out")
376
+ }
377
+
378
+ interface WechatCredentials { token: string; baseUrl: string; accountId: string; userId: string; savedAt: string }
379
+
380
+ // ============================================================
381
+ // Section 7: WeChat HTTP Transport
382
+ // ============================================================
383
+ async function apiFetch(endpoint: string, body: object, token: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
384
+ const base = BASE_URL.endsWith("/") ? BASE_URL : BASE_URL + "/"
385
+ const jsonBody = JSON.stringify(body)
386
+ const controller = new AbortController()
387
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
388
+ if (signal) signal.addEventListener("abort", () => controller.abort(), { once: true })
389
+ try {
390
+ const res = await fetch(`${base}${endpoint}`, {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json", AuthorizationType: "ilink_bot_token", "X-WECHAT-UIN": randomWechatUin(), "Content-Length": String(Buffer.byteLength(jsonBody, "utf-8")), Authorization: `Bearer ${token}` },
393
+ body: jsonBody, signal: controller.signal,
394
+ })
395
+ clearTimeout(timer)
396
+ const text = await res.text()
397
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
398
+ if (!text.trim()) return text
399
+ const parsed = JSON.parse(text)
400
+ const ret = parsed.ret ?? 0; const errcode = parsed.errcode ?? 0; const errmsg = parsed.errmsg ?? ""
401
+ if (ret !== 0 || errcode !== 0) {
402
+ if (errcode === -14 && /session timeout/i.test(errmsg)) throw new Error("Session timed out")
403
+ if (ret === -2) throw new Error("Context token stale")
404
+ throw new Error(`API error ret=${ret} errcode=${errcode}`)
405
+ }
406
+ return text
407
+ } catch (err) { clearTimeout(timer); throw err }
408
+ }
409
+
410
+ async function getUpdates(account: WechatCredentials, timeoutMs: number, signal?: AbortSignal): Promise<{ msgs: any[]; get_updates_buf: string | null }> {
411
+ const raw = await apiFetch("ilink/bot/getupdates", { get_updates_buf: syncBuffer, base_info: { channel_version: CHANNEL_VERSION } }, account.token, timeoutMs, signal)
412
+ if (!raw.trim()) return { msgs: [], get_updates_buf: null }
413
+ const parsed = JSON.parse(raw)
414
+ const newBuf = parsed.get_updates_buf ?? null
415
+ if (newBuf) { syncBuffer = newBuf; saveState() }
416
+ return { msgs: parsed.msgs ?? [], get_updates_buf: newBuf }
417
+ }
418
+
419
+ function findSidByRecipient(recipientId: string): string | null {
420
+ for (const [wx, sid] of wechatSid) { if (wx === recipientId) return sid }
421
+ return null
422
+ }
423
+
424
+ async function sendText(account: WechatCredentials, recipientId: string, text: string, contextToken?: string, client?: any) {
425
+ const trimmed = text.trim()
426
+ if (!trimmed) return
427
+ let token = contextToken
428
+ if (!token) token = contextTokens.get(recipientId) ?? contextTokens.get(recipientId.replace(/@im\.wechat$/, ""))
429
+ if (!token) { log("SEND_SKIP", `no context token for ${recipientId.slice(0,16)}`); return }
430
+ try {
431
+ await apiFetch("ilink/bot/sendmessage", {
432
+ msg: { from_user_id: "", to_user_id: recipientId, client_id: `wechat:${Date.now()}`, message_type: MSG_TYPE_BOT, message_state: MSG_STATE_FINISH, item_list: [{ type: MSG_ITEM_TEXT, text_item: { text: trimmed } }], context_token: token },
433
+ base_info: { channel_version: CHANNEL_VERSION },
434
+ }, account.token, SEND_TIMEOUT_MS)
435
+ if (client) await recordSendResult(findSidByRecipient(recipientId), true, client)
436
+ } catch (err: any) {
437
+ if (err.message?.includes("Context token stale")) { contextTokens.delete(recipientId); saveState(); return }
438
+ if (client) await recordSendResult(findSidByRecipient(recipientId), false, client)
439
+ throw err
440
+ }
441
+ }
442
+
443
+ // ============================================================
444
+ // Section 9: Long Polling Loop
445
+ // ============================================================
446
+ function startPollingLoop(account: WechatCredentials, client: any, signal: AbortSignal, worktree: string) {
447
+ let backoff = 1_000
448
+ ;(async () => {
449
+ log("POLL", "polling started")
450
+ while (!signal.aborted) {
451
+ try {
452
+ const { msgs } = await getUpdates(account, LONG_POLL_TIMEOUT_MS, signal)
453
+ backoff = 1_000; sendFailCount.clear()
454
+ for (const raw of msgs) await processInboundMessage(raw, account, client, worktree)
455
+ } catch (err: any) {
456
+ if (signal.aborted) break
457
+ if (err.name === "AbortError") continue
458
+ if (err.message?.includes("session timed out")) { log("POLL_FATAL", err.message); break }
459
+ if (err.message?.includes("Context token stale")) { contextTokens.clear(); saveState(); continue }
460
+ log("POLL_RETRY", `${err.message}, backoff=${backoff}`)
461
+ await sleep(Math.min(backoff, 30_000)); backoff *= 2
462
+ }
463
+ }
464
+ log("POLL", "ended")
465
+ })()
466
+ signal.addEventListener("abort", () => log("INFO", "polling stopped"), { once: true })
467
+ }
468
+
469
+ // ============================================================
470
+ // Section 10: WeChat Message Handling
471
+ // ============================================================
472
+ async function processInboundMessage(raw: any, account: WechatCredentials, client: any, worktree: string) {
473
+ if (raw.message_type !== MSG_TYPE_USER) return
474
+ const { text, attachments } = extractInboundContent(raw)
475
+ if (!text && attachments.length === 0) return
476
+ const msgKey = [raw.from_user_id ?? "", raw.client_id ?? "", String(raw.create_time_ms ?? ""), raw.context_token ?? ""].join("|")
477
+ if (recentMessageKeys.has(msgKey)) return
478
+ recentMessageKeys.add(msgKey); recentMessageOrder.push(msgKey)
479
+ while (recentMessageOrder.length > RECENT_KEYS_MAX) recentMessageKeys.delete(recentMessageOrder.shift()!)
480
+ const senderId = raw.from_user_id ?? "unknown"
481
+ if (raw.context_token) { contextTokens.set(senderId, raw.context_token); saveState() }
482
+ const downloadedPaths: string[] = []
483
+ for (const att of attachments) {
484
+ try { const enc = await downloadFromCdn(att.media); const pt = decryptInboundMediaPayload(enc, att.aesKey); downloadedPaths.push(saveAttachment(att.fileName || `wechat-${att.kind}`, pt)) }
485
+ catch (err: any) { log("ATTACH_DL_FAIL", `${att.kind}: ${err.message}`) }
486
+ }
487
+ const trimmed = text.trim()
488
+ if (trimmed.startsWith("/")) { await handleCommand(trimmed, senderId, account, client, worktree); return }
489
+ if (!wechatSid.has(senderId)) { if (await handleFirstContact(text, senderId, account, client, worktree)) return }
490
+ log("WX_IN", `[${senderId.slice(0,8)}] ${trimmed.slice(0,80)}`)
491
+ try {
492
+ const sid = await getOrCreateSession(client, senderId, worktree)
493
+ const prompt = downloadedPaths.length > 0 ? `${trimmed}\n\n[文件] ${downloadedPaths.join(", ")}` : trimmed
494
+ await updateSessionIcon(client, sid, "processing")
495
+ await client.session.promptAsync({ path: { id: sid }, body: { agent: "build", parts: [{ type: "text" as any, text: prompt }] } })
496
+ log("INJECT", `[${t(sid)}] <- ${trimmed.slice(0,60)}`)
497
+ } catch (err: any) { log("INJECT_FAIL", `${err.message}`) }
498
+ }
499
+
500
+ function extractInboundContent(raw: any): { text: string; attachments: Array<{ kind: string; fileName: string; media: any; aesKey: string }> } {
501
+ const lines: string[] = []
502
+ const attachments: Array<{ kind: string; fileName: string; media: any; aesKey: string }> = []
503
+ for (const item of raw.item_list ?? []) {
504
+ if (item.ref_msg) {
505
+ const rp: string[] = []
506
+ if (item.ref_msg.title?.trim()) rp.push(item.ref_msg.title.trim())
507
+ if (item.ref_msg.message_item?.text_item?.text?.trim()) rp.push(item.ref_msg.message_item.text_item.text.trim())
508
+ if (rp.length) lines.push(`引用: ${rp.join(" | ")}`)
509
+ }
510
+ if (item.type === MSG_ITEM_TEXT) { const t = item.text_item?.text?.trim(); if (t) lines.push(t) }
511
+ if (item.type === MSG_ITEM_VOICE) { const t = item.voice_item?.text?.trim(); if (t) lines.push(t) }
512
+ if (item.type === MSG_ITEM_IMAGE) { const m = item.image_item?.media; const ak = m?.aes_key ?? m?.aeskey ?? item.image_item?.aes_key ?? item.image_item?.aeskey; if (m && ak?.trim()) attachments.push({ kind: "image", fileName: item.image_item?.file_name?.trim() || "wechat-image.jpg", media: m, aesKey: ak }); else lines.push("[图片]") }
513
+ if (item.type === MSG_ITEM_FILE) { const m = item.file_item?.media; const ak = m?.aes_key ?? m?.aeskey ?? item.file_item?.aes_key ?? item.file_item?.aeskey; if (m && ak?.trim()) attachments.push({ kind: "file", fileName: item.file_item?.file_name?.trim() || "wechat-file", media: m, aesKey: ak }); else lines.push("[文件]") }
514
+ }
515
+ return { text: lines.join("\n").trim(), attachments }
516
+ }
517
+
518
+ function saveAttachment(fileName: string, data: Buffer): string {
519
+ const today = new Date().toISOString().slice(0, 10); const dir = join(ATTACHMENTS_DIR, today)
520
+ mkdirSync(dir, { recursive: true })
521
+ const safe = fileName.replace(/[<>:"/\\|?*]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 160)
522
+ const fp = join(dir, `${today}-${randomBytes(4).toString("hex")}-${safe}`)
523
+ writeFileSync(fp, data); return fp
524
+ }
525
+
526
+ // ============================================================
527
+ // Section 10b: Command Handler
528
+ // ============================================================
529
+ function resolveDir(dirIdx: number | null, worktree: string): string { return (!dirIdx || dirIdx < 1 || dirIdx > _projectDirs.length) ? worktree : _projectDirs[dirIdx - 1] }
530
+ function getNick(dir: string): string { const n = basename(dir); return n || dir }
531
+
532
+ async function listAllSessions(client: any): Promise<{ flat: Session[]; dirMap: Map<string, number> }> {
533
+ const flat: Session[] = []; const dm = new Map<string, number>()
534
+ for (let di = 0; di < _projectDirs.length; di++) {
535
+ try {
536
+ const resp: any = await client.session.list({ query: { directory: _projectDirs[di] } })
537
+ const all: Session[] = Array.isArray(resp) ? resp : resp.data ?? []
538
+ for (const s of all) { if (!s.parentID) { flat.push(s); dm.set(s.id, di) } }
539
+ } catch { /* skip */ }
540
+ }
541
+ return { flat, dirMap: dm }
542
+ }
543
+
544
+ function formatDirSessions(flat: Session[], dm: Map<string, number>, cur: string | undefined): string[] {
545
+ const lines: string[] = []; let idx = 0
546
+ for (let di = 0; di < _projectDirs.length; di++) {
547
+ const ds = flat.filter(s => dm.get(s.id) === di); if (ds.length === 0) continue
548
+ lines.push(`📁 ${getNick(_projectDirs[di])} — ${ds.length} 个会话`)
549
+ for (const s of ds) { idx++; const isCur = s.id === cur; lines.push(` ${idx}. ${isCur ? WECHAT_ICON : ""}${stripIconPrefix(s.title)}${isCur ? " [当前]" : ""}`) }
550
+ lines.push("")
551
+ }
552
+ return lines
553
+ }
554
+
555
+ async function formatSessionGuide(client: any, cur: string | undefined): Promise<string> {
556
+ try {
557
+ const { flat, dirMap: dm } = await listAllSessions(client)
558
+ const sl = formatDirSessions(flat, dm, cur)
559
+ return (sl.length ? sl.join("\n") : "(无会话)") + "\n回复 /switch <编号> 切换"
560
+ } catch { return "获取会话列表失败" }
561
+ }
562
+
563
+ async function handleCommand(cmd: string, senderId: string, account: WechatCredentials, client: any, worktree: string) {
564
+ const parts = cmd.slice(1).split(/\s+/); let command = parts[0].toLowerCase(); const args = parts.slice(1)
565
+ const a = command.match(/^(switch|切换|new|新建|unbind|解绑|mode|模式)(\d+)$/)
566
+ if (a) { command = a[1]; args.unshift(a[2]) }
567
+ const wx = (text: string) => sendText(account, senderId, text, undefined, client)
568
+ switch (command) {
569
+ case "stop": case "停止": { const sid = wechatSid.get(senderId); if (sid) try { await client.session.abort({ path: { id: sid } }) } catch { /* ok */ }; await wx("已中断"); break }
570
+ case "status": case "状态": case "会话": { await wx(await formatSessionGuide(client, wechatSid.get(senderId))); break }
571
+ case "new": case "新建": { const n = parseInt(args[0]); let td: string | undefined; if (!isNaN(n)) { try { const { flat } = await listAllSessions(client); td = flat[n-1]?.directory } catch { /* */ } }
572
+ try { const ttl = wechatTitle(); const resp: any = await client.session.create({ query: { directory: td || resolveDir(null, worktree) }, body: { title: ttl } }); const ns = resp.id ?? resp.sessionID ?? resp.data?.id; if (ns) { wechatSid.set(senderId, ns); sidTitle.set(ns, ttl); saveState(); await updateSessionIcon(client, ns, "normal"); await wx(`已创建 [${t(ns)}]`) } } catch { await wx("创建失败") }; break }
573
+ case "switch": case "切换": { const tgt = args.join(" ").trim(); if (!tgt) { await wx("请指定编号或 ID"); break }
574
+ try { const { flat } = await listAllSessions(client); const n = parseInt(tgt); const m = (n>=1 && n<=flat.length) ? flat[n-1] : flat.find(s => s.id.startsWith(tgt)) ?? null; if (!m) { await wx(`未找到: ${tgt}`); break }
575
+ const pv = wechatSid.get(senderId); wechatSid.set(senderId, m.id); sidTitle.set(m.id, m.title); saveState()
576
+ if (pv && pv !== m.id) { const pt = flat.find(s => s.id === pv)?.title; await stripSessionIcon(client, pv, pt) }
577
+ await updateSessionIcon(client, m.id, "normal"); await wx(`已切换到: ${m.title}`) } catch { await wx("切换失败") }; break }
578
+ case "unbind": case "解绑": { const old = wechatSid.get(senderId)
579
+ let oldTitle = sidTitle.get(old)
580
+ 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) }
583
+ wechatSid.delete(senderId); saveState()
584
+ }
585
+ await wx(`WeChat 桥接\n${await formatSessionGuide(client, undefined)}`); break }
586
+ case "rename": case "改名": { const nn = args.join(" ").trim(); if (!nn) { await wx("请指定标题"); break }; const sid = wechatSid.get(senderId); if (!sid) { await wx("未绑定"); break }
587
+ 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
+ case "mode": case "模式": { const sid = wechatSid.get(senderId); if (!sid) { await wx("未绑定"); break }
589
+ try { const resp = await client.session.messages({ path: { id: sid }, query: { limit: 5 } }); const msgs = Array.isArray(resp) ? resp : resp.data ?? []; let mode: string | undefined; for (let i = msgs.length-1; i>=0; i--) { if (msgs[i].info?.role === "assistant") { mode = msgs[i].info.mode; break } }; await wx(`当前模式: ${mode ?? _modeCache.get(sid) ?? "build"}`) } catch { await wx(`模式: ${_modeCache.get(sid) ?? "build"}`) }; break }
590
+ case "help": case "帮助": await wx("/stop /status /switch N /new N\n/unbind /rename /mode /help\n审批: 同意 拒绝"); break
591
+ default: await wx(`未知指令: /${command}`)
592
+ }
593
+ }
594
+
595
+ // ============================================================
596
+ // Section 10c: First Contact
597
+ // ============================================================
598
+ async function handleFirstContact(text: string, senderId: string, account: WechatCredentials, client: any, worktree: string): Promise<boolean> {
599
+ if (_pendingFirstContact.has(senderId)) { _pendingFirstContact.delete(senderId); return false }
600
+ _pendingFirstContact.add(senderId); setTimeout(() => _pendingFirstContact.delete(senderId), 10 * 60 * 1000)
601
+ await sendText(account, senderId, `WeChat 桥接\n${await formatSessionGuide(client, undefined)}`, undefined, null)
602
+ return true
603
+ }
604
+
605
+ function migrateOldStateFiles(worktree: string) {
606
+ if (existsSync(STATE_FILE)) return
607
+ let sessionMap: Record<string, string> = {}
608
+ const oldDir = join(resolveWorktree(worktree), ".opencode", "plugins", "wechat-bridge")
609
+ const oldMap = join(oldDir, "session-map.json")
610
+ try {
611
+ if (existsSync(oldMap)) {
612
+ sessionMap = JSON.parse(readFileSync(oldMap, "utf-8"))
613
+ try { rmSync(oldMap) } catch {}
614
+ try { if (readdirSync(oldDir).length === 0) rmSync(oldDir) } catch {}
615
+ log("MIGRATE", "migrated session-map.json")
616
+ }
617
+ } catch { log("MIGRATE", "session-map.json skip") }
618
+ let syncBuf = ""
619
+ const oldSyncBuf = join(DATA_DIR, "sync_buf.txt")
620
+ try {
621
+ if (existsSync(oldSyncBuf)) { syncBuf = readFileSync(oldSyncBuf, "utf-8"); try { rmSync(oldSyncBuf) } catch {}; log("MIGRATE", "migrated sync_buf.txt") }
622
+ } catch {}
623
+ let ctxTokens: Record<string, string> = {}
624
+ const oldCtx = join(DATA_DIR, "context_tokens.json")
625
+ try {
626
+ if (existsSync(oldCtx)) { ctxTokens = JSON.parse(readFileSync(oldCtx, "utf-8")); try { rmSync(oldCtx) } catch {}; log("MIGRATE", "migrated context_tokens.json") }
627
+ } catch {}
628
+ ensureDataDir()
629
+ try { writeFileSync(STATE_FILE, JSON.stringify({ syncBuf, contextTokens: ctxTokens, sessionMap }), "utf-8"); log("MIGRATE", "all -> state.json") } catch (e) { log("MIGRATE_ERR", `${e}`) }
630
+ }
631
+
632
+ // ---- lazy init (credentials + polling) ----
633
+ let _creds: WechatCredentials | null = null
634
+ async function lazyInit(client: any, worktree: string, signal: AbortSignal) {
635
+ let creds = loadCredentials()
636
+ if (!creds) { creds = await qrCodeLogin() }
637
+ else { const reason = await validateCredentials(creds); if (reason) { log("CRED", `${reason}, re-login`); creds = await qrCodeLogin() } else log("CRED", `loaded: ${creds.accountId}`) }
638
+ _creds = creds
639
+ startPollingLoop(creds, client, signal, worktree)
640
+ }
641
+
642
+ // ============================================================
643
+ // Section 8: CDN Upload / Download
644
+ // ============================================================
645
+ async function downloadFromCdn(media: { encrypt_query_param?: string; full_url?: string; aes_key?: string }): Promise<Buffer> {
646
+ let cdnUrl: string
647
+ if (media.full_url?.trim()) cdnUrl = media.full_url.trim()
648
+ else if (media.encrypt_query_param?.trim()) cdnUrl = buildCdnDownloadUrl(media.encrypt_query_param.trim())
649
+ else throw new Error("CDN download: missing url")
650
+ for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt++) {
651
+ try {
652
+ const res = await fetch(cdnUrl, { signal: AbortSignal.timeout(CDN_DOWNLOAD_TIMEOUT_MS) })
653
+ if (res.status >= 400 && res.status < 500) throw new Error(`CDN client err ${res.status}`)
654
+ if (res.status !== 200) throw new Error(`CDN download err ${res.status}`)
655
+ return Buffer.from(await res.arrayBuffer())
656
+ } catch (err: any) {
657
+ if (err.message?.includes("client err")) throw err
658
+ if (attempt >= CDN_MAX_RETRIES) throw err
659
+ log("CDN_RETRY", `download ${attempt}: ${err.message}`)
660
+ }
661
+ }
662
+ throw new Error("CDN download failed")
663
+ }
664
+
665
+ // ============================================================
666
+ // Section 11: Plugin Entry
667
+ // ============================================================
668
+ export const WechatBridgePlugin: Plugin = async ({ client, worktree }) => {
669
+ try { mkdirSync(dirname(LOG_PATH), { recursive: true }) } catch { /* ok */ }
670
+ try { await client.app.log({ body: { service: "wechat-bridge", level: "info", message: "plugin loaded" } }) } catch { /* */ }
671
+ migrateOldDataDir()
672
+ migrateOldStateFiles(worktree)
673
+ _projectDirs = findProjectDirs(worktree)
674
+ initSessionCache(client)
675
+ loadState()
676
+ const abortController = new AbortController()
677
+ lazyInit(client, worktree, abortController.signal)
678
+ return {
679
+ event: createEventHandler(client),
680
+ "permission.ask": createPermissionHandler(client),
681
+ "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 */ }
689
+ },
690
+ tool: createTools(client),
691
+ }
692
+ }
693
+
694
+ // ============================================================
695
+ // Section 12: Event Hooks
696
+ // ============================================================
697
+ function createEventHandler(client: any) {
698
+ return async ({ event }: { event: any }) => {
699
+ if (!_creds) return
700
+ if (event.type === "session.idle") {
701
+ const sid = (event as EventSessionIdle).properties.sessionID
702
+ log("IDLE", `[${t(sid)}] completed`)
703
+ const wxId = findWechatSender(sid)
704
+ if (!wxId) return
705
+ _fwdLastTool.delete(sid); _thinkingSent.delete(sid); _fwdQueue.delete(sid)
706
+ await updateSessionIcon(client, sid, "normal")
707
+ try { const resp: any = await client.session.messages({ path: { id: sid }, query: { limit: 15 } }); const msgs = Array.isArray(resp) ? resp : resp.data ?? []
708
+ 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
+ if (_pendingContinue.has(sid)) { _pendingContinue.delete(sid); try { await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text" as any, text: "检测到异常,请继续" }] } }) } catch { } }
711
+ if (_compacted.has(sid)) { _compacted.delete(sid); try { await client.session.prompt({ path: { id: sid }, body: { parts: [{ type: "text" as any, text: "上下文被压缩" }] } }) } catch { } }
712
+ return
713
+ }
714
+ if (event.type === "session.compacted") { const sid = (event as any).properties?.sessionID; if (sid) _compacted.add(sid); return }
715
+ if (event.type === "message.updated") { const info = event.properties?.info; if (info?.id) { if (info.role === "user") { _userMsgIds.add(info.id); if (_userMsgIds.size > 1000) _userMsgIds.clear() } else if (info.role === "assistant" && info.error && CONTINUE_ERRORS.has(info.error.name)) { _pendingContinue.add(info.sessionID); if (_pendingContinue.size > 100) _pendingContinue.clear() } } return }
716
+ if (event.type === "session.created") { const s = (event as EventSessionCreated).properties.info; if (!s.parentID) sidTitle.set(s.id, s.title); return }
717
+ if (event.type === "session.deleted") { const s = (event as EventSessionDeleted).properties.info; sidTitle.delete(s.id); _modeCache.delete(s.id); _pendingContinue.delete(s.id); _compacted.delete(s.id); for (const [wx, sid] of wechatSid) { if (sid === s.id) { wechatSid.delete(wx); saveState(); break } } return }
718
+ if (event.type === "session.updated") { const s = event.properties.info as Session; if (!s.parentID && sidTitle.get(s.id) !== s.title) { sidTitle.set(s.id, s.title); const wxId = findWechatSender(s.id); if (wxId) updateSessionIcon(client, s.id, "normal").catch(() => {}) } return }
719
+ if (event.type === "message.part.updated") { const p = event.properties?.part; const sid = p?.sessionID; if (!sid) return; const wxId = findWechatSender(sid); if (!wxId) return
720
+ if (p.type === "reasoning") { if (p.text && !_thinkingSent.has(sid)) { _thinkingSent.add(sid); enqueueSend(sid, () => sendText(_creds!, wxId, "思考中...", undefined, client)) } }
721
+ else if (p.type === "tool") { const name = p.tool ?? ""; if (name && _fwdLastTool.get(sid) !== name) { _fwdLastTool.set(sid, name); enqueueSend(sid, () => sendText(_creds!, wxId, name, undefined, client)) } }
722
+ else if (p.type === "text" && !p.ignored && !p.synthetic && !_userMsgIds.has(p.messageID)) { const t = p.text?.trim(); if (t) enqueueSend(sid, () => sendText(_creds!, wxId, t, undefined, client)) }
723
+ return }
724
+ }
725
+ }
726
+
727
+ // ============================================================
728
+ // Section 12b: Permission Handler
729
+ // ============================================================
730
+ function createPermissionHandler(_client: any) {
731
+ return async (_input: any, output: { status: "ask" | "deny" | "allow" }) => { output.status = "ask" }
732
+ }
733
+
734
+ // ============================================================
735
+ // Section 13: Tools
736
+ // ============================================================
737
+ function createTools(client: any) {
738
+ return {
739
+ wechat_status: tool({ description: "查看微信桥接插件的当前状态,包括登录账户、连接状态、缓存会话数", args: {},
740
+ execute: async () => {
741
+ if (!_creds) return { output: "微信桥接尚未完成登录" }
742
+ return { output: [`微信账户: ${_creds.accountId}`, `绑定用户: ${_creds.userId ?? "(无)"}`, `会话缓存: ${sidTitle.size} 个`, `上下文令牌: ${contextTokens.size} 个`, `同步游标: ${syncBuffer ? "存在" : "无"}`, `数据目录: ${DATA_DIR}`].join("\n") }
743
+ } }),
744
+ list_sessions: tool({ description: "列出所有可用会话的标题和 ID", args: {},
745
+ 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: "获取会话列表失败" } }
747
+ } }),
748
+ forward_to_session: tool({ description: "转发消息到标题前缀匹配的会话。用户说「转发」时使用此工具", args: { prefix: tool.schema.string().describe("目标会话标题前缀"), message: tool.schema.string().describe("要转发的消息内容") },
749
+ execute: async (args: any, ctx: any) => {
750
+ try { const { flat } = await listAllSessions(client); const target = flat.find(s => s.title.startsWith(args.prefix)); if (!target) return { output: `未找到标题以「${args.prefix}」开头的会话` }; if (target.id === ctx?.sessionID) return { output: "不能转发给自己" }
751
+ const srcTitle = sidTitle.get(ctx?.sessionID) ?? "未知"; const text = `[转发自「${srcTitle}」] ${args.message}`
752
+ client.session.prompt({ path: { id: target.id }, body: { noReply: false, parts: [{ type: "text" as any, text }] } }).catch((err: any) => log("FWD_ERR", `${err}`))
753
+ return { output: `已转发给「${target.title}」` } } catch { return { output: "转发失败" } }
754
+ } }),
755
+ }
756
+ }