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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/dist/index.js +32 -25
  3. 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
- - **Text only (Phase 1)**: images/voice/video are represented as `(image)` / `(voice)` placeholders; media support is planned
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
- - [ ] **Phase 2**: Media support (inbound images as base64, voice transcription)
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(fileUrl, fullUrl) {
1761
+ function buildCdnUrl(encryptQueryParam, fullUrl) {
1762
1762
  if (fullUrl)
1763
1763
  return fullUrl;
1764
- if (!fileUrl)
1764
+ if (!encryptQueryParam)
1765
1765
  return null;
1766
- if (fileUrl.startsWith("http"))
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(fileUrl, fullUrl, aeskeyHex, aesKeyBase64, label = "media") {
1771
- const url = buildCdnUrl(fileUrl, fullUrl);
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 text2 = `${lead} \u2014 approve in OMP with: /wechat pair ${result.code}`;
2361
- await sendMessage(creds, senderId, text2, contextToken).catch((err) => {
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
- const text = extractInboundText(msg);
2368
- if (!text)
2365
+ if (!(msg.item_list ?? []).length)
2369
2366
  return;
2370
- const dedupKey = makeDedupKey(senderId, msg.create_time_ms, text);
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: ${text.slice(0, 80)}`);
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"}): ${text.slice(0, 80)}`);
2377
- const invocation = this.commands.tryParse(text);
2378
- if (invocation) {
2379
- const reply = await invocation.execute({ pool: this.pool, config, chatId: senderId }).catch((err) => `Command failed: ${err instanceof Error ? err.message : String(err)}`);
2380
- await sendMessage(creds, senderId, reply, contextToken).catch((err) => {
2381
- logger.warn(`[${senderId}] Command reply send failed:`, err);
2382
- });
2383
- return;
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
- const buf = await downloadAndDecrypt(img.file_url, img.full_url, img.aeskey, img.aes_key ?? img.media?.aes_key, `image[${chatId}]`);
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "OMP/Pi extension: bridge WeChat messages to OMP's AI engine via the iLink Bot API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",