omp-wechat 1.2.0 → 1.3.0
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/README.md +6 -2
- package/dist/index.js +32 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,7 @@ For boot-time persistence, install a launchd/systemd service via `/wechat instal
|
|
|
31
31
|
- **Singleton**: port lock guarantees one poll loop across all concurrent OMP/Pi processes — no duplicate replies
|
|
32
32
|
- **Failover**: 30s timer takes over automatically if the lock holder crashes
|
|
33
33
|
- **Bidirectional**: receive and reply to WeChat text messages
|
|
34
|
+
- **Image recognition**: inbound images are downloaded from WeChat CDN, AES-decrypted, and passed to the vision model
|
|
34
35
|
- **Per-chat sessions**: each WeChat chat gets an independent AI session (concurrent, isolated)
|
|
35
36
|
- **LRU pool**: caps memory usage by evicting least-recently-used sessions (default: 50)
|
|
36
37
|
- **Typing indicator**: native WeChat "Typing..." shown during AI processing
|
|
@@ -108,6 +109,8 @@ systemPrompt: |
|
|
|
108
109
|
| `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
|
|
109
110
|
|
|
110
111
|
> **Model and tools are managed by OMP/Pi.** `createAgentSession()` automatically calls `discoverAuthStorage()`, reusing your existing `omp login` / `pi login` OAuth, `~/.omp/agent/agent.db` API keys, or `models.yml` config. This project never touches API keys.
|
|
112
|
+
>
|
|
113
|
+
> **Image recognition** requires a vision model role configured in OMP (e.g. `omp model role vision xfyun/xopkimik25`). If no vision role is set, inbound images are skipped — only the text placeholder is sent to the AI.
|
|
111
114
|
|
|
112
115
|
## Slash Commands
|
|
113
116
|
|
|
@@ -188,11 +191,12 @@ OMP-Wechat/
|
|
|
188
191
|
- **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations
|
|
189
192
|
- **1:1 only**: iLink Bot API does not support group chats
|
|
190
193
|
- **Single instance**: iLink allows only one bot connection per account
|
|
191
|
-
- **
|
|
194
|
+
- **Media**: inbound images are downloaded from WeChat CDN, AES-decrypted, and passed to the vision model (if `modelRoles.vision` is configured); voice/video remain as placeholders
|
|
192
195
|
|
|
193
196
|
## Roadmap
|
|
194
197
|
|
|
195
|
-
- [
|
|
198
|
+
- [x] **Phase 2a**: Inbound image support (CDN download + AES decrypt + vision model)
|
|
199
|
+
- [ ] **Phase 2b**: Voice transcription / video support
|
|
196
200
|
- [x] **Phase 3**: Persistent sessions — `SessionManager.continueRecent()` per chat, context survives restarts
|
|
197
201
|
- [x] **Phase 4**: Per-chat model selection — `/model` `/models` chat commands for manual switching
|
|
198
202
|
- [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
|
package/dist/index.js
CHANGED
|
@@ -1322,7 +1322,7 @@ function saveSyncBuf(buf) {
|
|
|
1322
1322
|
mkdirSync2(STATE_DIR, { recursive: true });
|
|
1323
1323
|
writeFileSync(SYNC_BUF_FILE, buf);
|
|
1324
1324
|
}
|
|
1325
|
-
function extractInboundText(msg) {
|
|
1325
|
+
function extractInboundText(msg, includeImagePlaceholder = true) {
|
|
1326
1326
|
const items = msg.item_list ?? [];
|
|
1327
1327
|
const parts = [];
|
|
1328
1328
|
let imgCount = 0;
|
|
@@ -1346,9 +1346,9 @@ function extractInboundText(msg) {
|
|
|
1346
1346
|
break;
|
|
1347
1347
|
}
|
|
1348
1348
|
}
|
|
1349
|
-
if (imgCount > 0 && parts.length === 0) {
|
|
1349
|
+
if (includeImagePlaceholder && imgCount > 0 && parts.length === 0) {
|
|
1350
1350
|
parts.push(`(user sent ${imgCount} image${imgCount > 1 ? "s" : ""})`);
|
|
1351
|
-
} else if (imgCount > 0) {
|
|
1351
|
+
} else if (includeImagePlaceholder && imgCount > 0) {
|
|
1352
1352
|
parts.push(`(+${imgCount} image${imgCount > 1 ? "s" : ""})`);
|
|
1353
1353
|
}
|
|
1354
1354
|
return parts.join(`
|
|
@@ -1758,17 +1758,15 @@ function decryptAesEcb(ciphertext, key) {
|
|
|
1758
1758
|
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
1759
1759
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1760
1760
|
}
|
|
1761
|
-
function buildCdnUrl(
|
|
1761
|
+
function buildCdnUrl(encryptQueryParam, fullUrl) {
|
|
1762
1762
|
if (fullUrl)
|
|
1763
1763
|
return fullUrl;
|
|
1764
|
-
if (!
|
|
1764
|
+
if (!encryptQueryParam)
|
|
1765
1765
|
return null;
|
|
1766
|
-
|
|
1767
|
-
return fileUrl;
|
|
1768
|
-
return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(fileUrl)}`;
|
|
1766
|
+
return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptQueryParam)}`;
|
|
1769
1767
|
}
|
|
1770
|
-
async function downloadAndDecrypt(
|
|
1771
|
-
const url = buildCdnUrl(
|
|
1768
|
+
async function downloadAndDecrypt(encryptQueryParam, fullUrl, aeskeyHex, aesKeyBase64, label = "media") {
|
|
1769
|
+
const url = buildCdnUrl(encryptQueryParam, fullUrl);
|
|
1772
1770
|
if (!url) {
|
|
1773
1771
|
logger.warn(`[${label}] No CDN URL available`);
|
|
1774
1772
|
return null;
|
|
@@ -2357,34 +2355,42 @@ class WeChatBridge {
|
|
|
2357
2355
|
if (result.action === "pair") {
|
|
2358
2356
|
if (contextToken) {
|
|
2359
2357
|
const lead = result.isResend ? "Still waiting for pairing" : "Pairing required";
|
|
2360
|
-
const
|
|
2361
|
-
await sendMessage(creds, senderId,
|
|
2358
|
+
const text = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
|
|
2359
|
+
await sendMessage(creds, senderId, text, contextToken).catch((err) => {
|
|
2362
2360
|
logger.warn("Pairing reply send failed:", err);
|
|
2363
2361
|
});
|
|
2364
2362
|
}
|
|
2365
2363
|
return;
|
|
2366
2364
|
}
|
|
2367
|
-
|
|
2368
|
-
if (!text)
|
|
2365
|
+
if (!(msg.item_list ?? []).length)
|
|
2369
2366
|
return;
|
|
2370
|
-
const
|
|
2367
|
+
const rawText = (msg.item_list ?? []).filter((item) => item.type === 1).map((item) => item.text_item?.text ?? "").filter(Boolean).join(`
|
|
2368
|
+
`);
|
|
2369
|
+
const hasImages = (msg.item_list ?? []).some((item) => item.type === 2);
|
|
2370
|
+
const dedupKey = makeDedupKey(senderId, msg.create_time_ms, rawText || "(image)");
|
|
2371
2371
|
if (dedupKey && isDuplicate(dedupKey)) {
|
|
2372
|
-
logger.info(`[${senderId}] Skipping duplicate message: ${
|
|
2372
|
+
logger.info(`[${senderId}] Skipping duplicate message: ${(rawText || "(image)").slice(0, 80)}`);
|
|
2373
2373
|
return;
|
|
2374
2374
|
}
|
|
2375
2375
|
const config = loadConfig();
|
|
2376
|
-
logger.info(`[${senderId}] Inbound (ts=${msg.create_time_ms ?? "n/a"}): ${
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2376
|
+
logger.info(`[${senderId}] Inbound (ts=${msg.create_time_ms ?? "n/a"}): ${(rawText || "(image)").slice(0, 80)}`);
|
|
2377
|
+
if (rawText) {
|
|
2378
|
+
const invocation = this.commands.tryParse(rawText);
|
|
2379
|
+
if (invocation) {
|
|
2380
|
+
const reply = await invocation.execute({ pool: this.pool, config, chatId: senderId }).catch((err) => `Command failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2381
|
+
await sendMessage(creds, senderId, reply, contextToken).catch((err) => {
|
|
2382
|
+
logger.warn(`[${senderId}] Command reply send failed:`, err);
|
|
2383
|
+
});
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2384
2386
|
}
|
|
2385
2387
|
await sendTyping(creds, senderId, 1).catch(() => {});
|
|
2386
2388
|
try {
|
|
2387
2389
|
const session = await this.pool.ensure(senderId, contextToken, config);
|
|
2390
|
+
const hasVision = session.supportsVision();
|
|
2391
|
+
const text = extractInboundText(msg, !hasVision);
|
|
2392
|
+
if (!text && !hasImages)
|
|
2393
|
+
return;
|
|
2388
2394
|
const images = await this.downloadImages(creds, msg, senderId);
|
|
2389
2395
|
await session.prompt(text, images);
|
|
2390
2396
|
} catch (err) {
|
|
@@ -2403,7 +2409,8 @@ class WeChatBridge {
|
|
|
2403
2409
|
const results = [];
|
|
2404
2410
|
for (const item of imageItems) {
|
|
2405
2411
|
const img = item.image_item;
|
|
2406
|
-
|
|
2412
|
+
logger.debug(`[${chatId}] image_item raw: ${JSON.stringify(img)}`);
|
|
2413
|
+
const buf = await downloadAndDecrypt(img.media?.encrypt_query_param, img.media?.full_url, img.aeskey, img.media?.aes_key, `image[${chatId}]`);
|
|
2407
2414
|
if (buf) {
|
|
2408
2415
|
const mimeType = buf.length > 4 && buf[0] === 137 && buf[1] === 80 ? "image/png" : "image/jpeg";
|
|
2409
2416
|
results.push({
|