@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.
- package/package.json +35 -0
- 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
|
+
}
|