omp-wechat 1.2.1 → 1.4.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 +16 -12
  2. package/dist/index.js +35 -35
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,9 +12,9 @@ WeChat user → iLink Bot API → [OMP/Pi process] → SDK → AI provider
12
12
  └──── reply ← message_end ─┘
13
13
  ```
14
14
 
15
- The extension runs **inside** the OMP/Pi process. On `session_start`, the iLink long-poll loop starts in-process as a background promise. A singleton port lock ensures only one process runs the poll loop at a time — other OMP/Pi processes standby with a 30s failover timer to take over if the lock holder crashes.
15
+ The extension runs **inside** the OMP/Pi process. The iLink long-poll loop starts at extension load time (not `session_start`) as a background promise. A singleton port lock ensures only one process runs the poll loop at a time — other OMP/Pi processes standby with a 30s failover timer to take over if the lock holder crashes.
16
16
 
17
- For boot-time persistence, install a launchd/systemd service via `/wechat install`. The service runs `omp --mode rpc` (or `pi --mode rpc`) with `KeepAlive`/`Restart=always`, so the host (and the poll loop) survive crashes and reboots.
17
+ For boot-time persistence, install a launchd/systemd service via `/wechat install`. The service runs `omp --mode rpc` (or `pi --mode rpc`) with a `get_state` JSON-RPC heartbeat piped to stdin every 5s — without an active RPC client, `omp --mode rpc` exits on idle stdin, so the heartbeat keeps the process alive. `KeepAlive`/`Restart=always` handles crashes and reboots.
18
18
 
19
19
  - **No external `bun` required** — OMP/Pi is a standalone binary with an embedded runtime
20
20
  - **Singleton** — port lock guarantees one poll loop across all concurrent OMP/Pi processes
@@ -26,11 +26,12 @@ For boot-time persistence, install a launchd/systemd service via `/wechat instal
26
26
 
27
27
  ## Features
28
28
 
29
- - **OMP/Pi extension**: installs via `omp plugin link .` or `pi plugin link .`, auto-starts poll loop on `session_start`
29
+ - **OMP/Pi extension**: installs via `omp plugin link .` or `pi plugin link .`, auto-starts poll loop at extension load time
30
30
  - **Slash commands**: `/wechat login`, `/wechat status`, `/wechat pair`, `/wechat allow`, `/wechat revoke`, `/wechat list`, `/wechat stop`, `/wechat install`, `/wechat uninstall`
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
@@ -55,7 +56,7 @@ bun run build
55
56
  omp plugin link . # or: pi plugin link .
56
57
  ```
57
58
 
58
- This links the extension into OMP/Pi. The poll loop starts automatically on your next `session_start`.
59
+ This links the extension into OMP/Pi. The poll loop starts at extension load time — no `session_start` required.
59
60
 
60
61
  ### Login (scan QR code)
61
62
 
@@ -67,7 +68,7 @@ A QR code appears in the terminal. Scan it with WeChat and confirm on your phone
67
68
 
68
69
  ### Run
69
70
 
70
- No explicit run command needed — the poll loop auto-starts on `session_start`. Once running, send a message to the bot on WeChat — it will be processed and the reply sent back.
71
+ No explicit run command needed — the poll loop starts at extension load time. Once running, send a message to the bot on WeChat — it will be processed and the reply sent back.
71
72
 
72
73
  To check status: `/wechat status`. To stop: `/wechat stop`.
73
74
 
@@ -77,9 +78,9 @@ To check status: `/wechat status`. To stop: `/wechat stop`.
77
78
  /wechat install
78
79
  ```
79
80
 
80
- Installs a launchd (macOS) or systemd (Linux) service that runs the host (`omp --mode rpc` or `pi --mode rpc`) at boot. The host stays alive via `KeepAlive`/`Restart=always`, keeping the poll loop running across crashes and reboots.
81
+ Installs a launchd (macOS) or systemd (Linux) service that runs the host (`omp --mode rpc` or `pi --mode rpc`) at boot. A `get_state` JSON-RPC heartbeat is piped to stdin every 5s to keep the process alive (without an active RPC client, `omp --mode rpc` exits on idle stdin). `KeepAlive`/`Restart=always` handles crashes and reboots.
81
82
 
82
- Logs: `~/.omp-wechat/logs/rpc.log`
83
+ Logs: `~/.omp/logs/rpc.log` (stderr only; stdout discarded) and `~/.omp/logs/wechat.log` (poll loop)
83
84
  Manage: `launchctl start|stop com.omp-wechat` (macOS) or `sudo systemctl start|stop omp-wechat` (Linux)
84
85
 
85
86
  To remove: `/wechat uninstall`
@@ -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
 
@@ -146,14 +149,14 @@ The logged-in user (who scanned the QR code) is automatically added to the allow
146
149
 
147
150
  | Scenario | Behavior |
148
151
  |---|---|
149
- | Host process starts | Poll loop starts automatically (acquires singleton lock) |
152
+ | Host process starts | Poll loop starts at extension load time (acquires singleton lock) |
150
153
  | Other host processes | Standby with 30s failover timer, take over if lock holder dies |
151
154
  | Host process exits | Poll loop stops, lock released, all sessions disposed |
152
155
  | Host crashes | Failover timer in another process detects dead lock and takes over; or launchd/systemd restarts the host (if `/wechat install` was run) |
153
156
  | Machine reboots | Service auto-starts the host (if installed), poll loop resumes |
154
157
  | No boot service | Poll loop only runs while a host process is active |
155
158
 
156
- Logs: `~/.omp-wechat/logs/daemon.log` (poll loop) and `~/.omp-wechat/logs/rpc.log` (boot service RPC output)
159
+ Logs: `~/.omp/logs/wechat.log` (poll loop) and `~/.omp/logs/rpc.log` (boot service stderr)
157
160
 
158
161
  ## Project Structure
159
162
 
@@ -161,7 +164,7 @@ Logs: `~/.omp-wechat/logs/daemon.log` (poll loop) and `~/.omp-wechat/logs/rpc.lo
161
164
  OMP-Wechat/
162
165
  ├── package.json # omp.extensions / pi.extensions manifest
163
166
  ├── src/
164
- │ ├── index.ts # OMP/Pi extension entry (session_start + /wechat commands)
167
+ │ ├── index.ts # OMP/Pi extension entry (extension load + /wechat commands)
165
168
  │ ├── bridge.ts # In-process poll loop + message handling + singleton port lock
166
169
  │ ├── service.ts # Boot-time launchd/systemd install
167
170
  │ ├── config.ts # Config loading (config.yml + defaults)
@@ -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
@@ -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;
@@ -2411,7 +2409,8 @@ class WeChatBridge {
2411
2409
  const results = [];
2412
2410
  for (const item of imageItems) {
2413
2411
  const img = item.image_item;
2414
- 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}]`);
2415
2414
  if (buf) {
2416
2415
  const mimeType = buf.length > 4 && buf[0] === 137 && buf[1] === 80 ? "image/png" : "image/jpeg";
2417
2416
  results.push({
@@ -2472,7 +2471,9 @@ function getLogDir() {
2472
2471
  return join8(homedir7(), ".omp", "logs");
2473
2472
  }
2474
2473
  function resolveHostBinary() {
2475
- return process.execPath || "omp";
2474
+ const exe = process.execPath || "omp";
2475
+ const basename2 = exe.split("/").pop() || "omp";
2476
+ return basename2;
2476
2477
  }
2477
2478
  function plistPath() {
2478
2479
  return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
@@ -2489,10 +2490,9 @@ function generatePlist() {
2489
2490
 
2490
2491
  <key>ProgramArguments</key>
2491
2492
  <array>
2492
- <string>${omp}</string>
2493
- <string>--mode</string>
2494
- <string>rpc</string>
2495
- <string>--no-title</string>
2493
+ <string>/bin/sh</string>
2494
+ <string>-c</string>
2495
+ <string>while true; do echo '{"id":"ka","type":"get_state"}'; sleep 5; done | exec ${omp} --mode rpc --no-title >/dev/null</string>
2496
2496
  </array>
2497
2497
 
2498
2498
  <key>RunAtLoad</key>
@@ -2504,11 +2504,6 @@ function generatePlist() {
2504
2504
  <key>ThrottleInterval</key>
2505
2505
  <integer>10</integer>
2506
2506
 
2507
- <key>StandardInputPath</key>
2508
- <string>/dev/null</string>
2509
-
2510
- <key>StandardOutPath</key>
2511
- <string>${logDir}/rpc.log</string>
2512
2507
  <key>StandardErrorPath</key>
2513
2508
  <string>${logDir}/rpc.log</string>
2514
2509
 
@@ -2562,15 +2557,14 @@ Wants=network-online.target
2562
2557
  Type=simple
2563
2558
  User=${user}
2564
2559
  Group=${user}
2565
- ExecStart=${omp} --mode rpc --no-title
2566
- StandardInput=null
2560
+ ExecStart=/bin/sh -c 'while true; do echo "{\\"id\\":\\"ka\\",\\"type\\":\\"get_state\\"}"; sleep 5; done | exec ${omp} --mode rpc --no-title >/dev/null'
2567
2561
  Restart=always
2568
2562
  RestartSec=10
2569
2563
 
2570
2564
  Environment=HOME=${homedir7()}
2571
2565
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
2572
2566
 
2573
- StandardOutput=append:${logDir}/rpc.log
2567
+ StandardOutput=null
2574
2568
  StandardError=append:${logDir}/rpc.log
2575
2569
 
2576
2570
  NoNewPrivileges=true
@@ -2660,21 +2654,27 @@ function wechatExtension(pi) {
2660
2654
  if (pi.pi && typeof pi.setLabel === "function") {
2661
2655
  pi.setLabel("OMP-Wechat Bridge");
2662
2656
  }
2657
+ bridge = new WeChatBridge;
2658
+ daemonState = bridge.start();
2659
+ if (daemonState.running) {
2660
+ logger.info("WeChat bridge started at extension load");
2661
+ } else {
2662
+ logger.debug("WeChat bridge: another instance holds the lock, starting failover watch");
2663
+ setInterval(() => {
2664
+ if (daemonState?.running)
2665
+ return;
2666
+ daemonState = bridge.start();
2667
+ if (daemonState.running) {
2668
+ logger.info("WeChat bridge: took over from failed instance");
2669
+ }
2670
+ }, 30000);
2671
+ }
2663
2672
  pi.on("session_start", async (_event, ctx) => {
2664
- bridge = new WeChatBridge;
2665
- daemonState = bridge.start();
2666
- if (daemonState.running) {
2667
- ctx.ui.notify("WeChat bridge started", "info");
2668
- } else {
2669
- logger.debug("WeChat bridge: another instance holds the lock, starting failover watch");
2670
- ctx.setInterval(() => {
2671
- if (daemonState?.running)
2672
- return;
2673
- daemonState = bridge.start();
2674
- if (daemonState.running) {
2675
- ctx.ui.notify("WeChat bridge: took over from failed instance", "info");
2676
- }
2677
- }, 30000);
2673
+ if (bridge && !daemonState?.running) {
2674
+ daemonState = bridge.start();
2675
+ if (daemonState.running) {
2676
+ ctx.ui.notify("WeChat bridge started", "info");
2677
+ }
2678
2678
  }
2679
2679
  });
2680
2680
  pi.registerCommand("wechat", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.2.1",
3
+ "version": "1.4.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",