omp-wechat 1.5.0 → 1.7.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 +38 -3
  2. package/dist/index.js +457 -34
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,6 +32,9 @@ For boot-time persistence, install a launchd/systemd service via `/wechat instal
32
32
  - **Failover**: 30s timer takes over automatically if the lock holder crashes
33
33
  - **Bidirectional**: receive and reply to WeChat text messages
34
34
  - **Image recognition**: inbound images are downloaded from WeChat CDN, AES-decrypted, and passed to the vision model
35
+ - **Inbound file content**: text files (`.txt/.md/.csv/.json/.py/…`) sent by the user are downloaded, decrypted, and their content is handed to the AI; binary files arrive as metadata placeholders
36
+ - **Markdown stripping**: AI replies are stripped of markdown formatting before delivery — WeChat renders plain text only
37
+ - **File delivery**: AI-generated files (documents, images, PDFs, spreadsheets, code…) written to a per-chat outbox directory are automatically uploaded to the WeChat CDN (AES-128-ECB encrypted) and sent back to the user as file/image messages
35
38
  - **Per-chat sessions**: each WeChat chat gets an independent AI session (concurrent, isolated)
36
39
  - **LRU pool**: caps memory usage by evicting least-recently-used sessions (default: 50)
37
40
  - **Typing indicator**: native WeChat "Typing..." shown during AI processing
@@ -110,11 +113,39 @@ systemPrompt: |
110
113
  | `model` | OMP default | Default model: role alias (`@smol`, `@slow`) or `provider/id` |
111
114
  | `cwd` | `process.cwd()` | Working directory for AI sessions — determines which project context (CLAUDE.md, .omp/) the agent loads |
112
115
  | `systemPrompt` | Built-in | System prompt for WeChat chat sessions |
116
+ | `outboxDir` | `~/.omp-wechat/outbox` | Base directory for per-chat outboxes (see File Delivery below) |
117
+ | `maxFileSizeMb` | `100` | Max file size delivered via WeChat, in MB |
118
+ | `sendFiles` | `true` | Set to `false` to disable file delivery entirely |
113
119
 
114
120
  > **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.
115
121
  >
116
122
  > **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.
117
123
 
124
+ ## File Delivery
125
+
126
+ Ask the AI to generate a file (report, PDF, spreadsheet, image, code, …) — the finished artifact is sent back to you as a WeChat file or image message automatically.
127
+
128
+ ### How it works
129
+
130
+ 1. Every chat gets a private **outbox directory**: `~/.omp-wechat/outbox/<wxid>/`
131
+ 2. The session's system prompt teaches the AI to write final deliverables there (and only there)
132
+ 3. When the AI finishes a turn, the plugin diffs the outbox and uploads every new/changed file:
133
+ - `getuploadurl` → CDN upload (AES-128-ECB encrypted) → `sendmessage` as a file item
134
+ - Images (`.png/.jpg/.jpeg/.gif/.webp/.bmp`) are auto-routed to image messages; everything else goes as a file
135
+ 4. Delivered files are removed from the outbox; failed or oversized files stay on disk and a text notice is sent instead
136
+
137
+ ### Example
138
+
139
+ > **You**: Please generate a weekly report as a PDF
140
+ > **AI**: *(writes `weekly-report.pdf` to the outbox)* Done — the report is attached.
141
+ > **You** (WeChat): receives the text reply **plus** the `weekly-report.pdf` file
142
+
143
+ ### Limitations
144
+
145
+ - Files are sent via `sendmessage` using the latest inbound `context_token`, same as text replies — an expired token (long-running task, restart) may fail delivery until you message again
146
+ - Files larger than `maxFileSizeMb` are skipped with a text notice
147
+ - Only files in the per-chat outbox are ever sent — the AI cannot exfiltrate arbitrary paths
148
+
118
149
  ## Slash Commands
119
150
 
120
151
  | Command | Description |
@@ -174,9 +205,11 @@ OMP-Wechat/
174
205
  │ ├── ilink/
175
206
  │ │ ├── types.ts # iLink Bot API type definitions
176
207
  │ │ ├── client.ts # iLink API client (poll/send/typing)
208
+ │ │ ├── upload.ts # Outbound media: CDN upload + file/image send
209
+ │ │ ├── cdn.ts # CDN media download/upload + AES-128-ECB crypto
177
210
  │ │ └── login.ts # QR code login flow
178
211
  │ ├── engine/
179
- │ │ ├── session.ts # AI session creation + reply subscription
212
+ │ │ ├── session.ts # AI session creation + reply/outbox subscription
180
213
  │ │ └── pool.ts # Session pool (LRU eviction, concurrency)
181
214
  │ ├── access/
182
215
  │ │ └── control.ts # Access control (pairing/allowlist/disabled)
@@ -191,15 +224,17 @@ OMP-Wechat/
191
224
 
192
225
  ## Limitations
193
226
 
194
- - **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations
227
+ - **Reply-only**: iLink requires `context_token` from an inbound message; you cannot initiate conversations (applies to text and file replies alike)
195
228
  - **1:1 only**: iLink Bot API does not support group chats
196
229
  - **Single instance**: iLink allows only one bot connection per account
197
- - **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
230
+ - **Media**: inbound images and text files are fully processed (vision model / content extraction); voice (unless the server provides transcription) and video remain as placeholders
198
231
 
199
232
  ## Roadmap
200
233
 
201
234
  - [x] **Phase 2a**: Inbound image support (CDN download + AES decrypt + vision model)
202
235
  - [ ] **Phase 2b**: Voice transcription / video support
236
+ - [x] **Phase 2c**: Outbound file delivery — AI-generated files sent back via WeChat (CDN upload + file/image messages)
237
+ - [x] **Phase 2d**: Inbound file content extraction + markdown stripping for replies
203
238
  - [x] **Phase 3**: Persistent sessions — `SessionManager.continueRecent()` per chat, context survives restarts
204
239
  - [x] **Phase 4**: Per-chat model selection — `/model` `/models` chat commands for manual switching
205
240
  - [ ] **Phase 5**: Fine-grained permissions (per-user tool restrictions, bash approval via WeChat)
package/dist/index.js CHANGED
@@ -1326,6 +1326,13 @@ function saveSyncBuf(buf) {
1326
1326
  mkdirSync2(STATE_DIR, { recursive: true });
1327
1327
  writeFileSync(SYNC_BUF_FILE, buf);
1328
1328
  }
1329
+ function formatSize(bytes) {
1330
+ if (bytes < 1024)
1331
+ return `${bytes}B`;
1332
+ if (bytes < 1024 * 1024)
1333
+ return `${(bytes / 1024).toFixed(1)}KB`;
1334
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
1335
+ }
1329
1336
  function extractInboundText(msg, includeImagePlaceholder = true) {
1330
1337
  const items = msg.item_list ?? [];
1331
1338
  const parts = [];
@@ -1343,7 +1350,11 @@ function extractInboundText(msg, includeImagePlaceholder = true) {
1343
1350
  parts.push(item.voice_item?.text ?? "(voice)");
1344
1351
  break;
1345
1352
  case 4:
1346
- parts.push(`(file: ${item.file_item?.file_name ?? "unknown"})`);
1353
+ {
1354
+ const name = item.file_item?.file_name ?? "unknown";
1355
+ const len = parseInt(item.file_item?.len ?? "", 10);
1356
+ parts.push(`(file: ${name}${Number.isFinite(len) && len > 0 ? ", " + formatSize(len) : ""})`);
1357
+ }
1347
1358
  break;
1348
1359
  case 5:
1349
1360
  parts.push("(video)");
@@ -1572,7 +1583,9 @@ async function pollQrStatus(qrcode, baseUrl) {
1572
1583
  }
1573
1584
 
1574
1585
  // src/bridge.ts
1575
- import { randomBytes as randomBytes3 } from "crypto";
1586
+ import { randomBytes as randomBytes5 } from "crypto";
1587
+ import { rmSync as rmSync2 } from "fs";
1588
+ import { basename as basename3, extname as extname2 } from "path";
1576
1589
 
1577
1590
  // src/config.ts
1578
1591
  import { homedir as homedir4 } from "os";
@@ -1607,6 +1620,15 @@ function loadConfig() {
1607
1620
  config.cwd = expandTilde(parsed.cwd);
1608
1621
  if (parsed.systemPrompt)
1609
1622
  config.systemPrompt = parsed.systemPrompt;
1623
+ if (parsed.outboxDir)
1624
+ config.outboxDir = expandTilde(parsed.outboxDir);
1625
+ if (parsed.maxFileSizeMb) {
1626
+ const mb = parseInt(parsed.maxFileSizeMb, 10);
1627
+ if (Number.isFinite(mb) && mb > 0)
1628
+ config.maxFileSizeMb = mb;
1629
+ }
1630
+ if (parsed.sendFiles === "false")
1631
+ config.sendFiles = false;
1610
1632
  }
1611
1633
  } catch (err) {
1612
1634
  logger.warn("Failed to load config.yml, using defaults", err);
@@ -1742,7 +1764,7 @@ function isDuplicate(key) {
1742
1764
  }
1743
1765
 
1744
1766
  // src/ilink/cdn.ts
1745
- import { createDecipheriv } from "crypto";
1767
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "crypto";
1746
1768
  var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
1747
1769
  function parseAesKey(aeskeyHex, aesKeyBase64) {
1748
1770
  if (aeskeyHex && /^[0-9a-fA-F]{32}$/.test(aeskeyHex)) {
@@ -1762,6 +1784,13 @@ function decryptAesEcb(ciphertext, key) {
1762
1784
  const decipher = createDecipheriv("aes-128-ecb", key, null);
1763
1785
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1764
1786
  }
1787
+ function encryptAesEcb(plaintext, key) {
1788
+ const cipher = createCipheriv("aes-128-ecb", key, null);
1789
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
1790
+ }
1791
+ function generateAesKey() {
1792
+ return randomBytes3(16);
1793
+ }
1765
1794
  function buildCdnUrl(encryptQueryParam, fullUrl) {
1766
1795
  if (fullUrl)
1767
1796
  return fullUrl;
@@ -1796,22 +1825,201 @@ async function downloadAndDecrypt(encryptQueryParam, fullUrl, aeskeyHex, aesKeyB
1796
1825
  }
1797
1826
  }
1798
1827
 
1828
+ // src/ilink/upload.ts
1829
+ import { createHash, randomBytes as randomBytes4 } from "crypto";
1830
+ import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
1831
+ import { basename as basename2, extname } from "path";
1832
+ var CDN_BASE_URL2 = "https://novac2c.cdn.weixin.qq.com/c2c";
1833
+ var UPLOAD_MAX_RETRIES = 3;
1834
+ var SEND_MAX_RETRIES = 2;
1835
+ var IMAGE_EXTENSIONS = {
1836
+ ".png": true,
1837
+ ".jpg": true,
1838
+ ".jpeg": true,
1839
+ ".gif": true,
1840
+ ".webp": true,
1841
+ ".bmp": true
1842
+ };
1843
+ function mediaTypeFor(fileName) {
1844
+ return IMAGE_EXTENSIONS[extname(fileName).toLowerCase()] ? 1 /* IMAGE */ : 3 /* FILE */;
1845
+ }
1846
+ async function uploadAndSendFile(creds, to, contextToken, filePath, maxSizeBytes = 100 * 1024 * 1024) {
1847
+ let size;
1848
+ try {
1849
+ size = statSync2(filePath).size;
1850
+ } catch (err) {
1851
+ return { status: "error", error: `cannot stat: ${err instanceof Error ? err.message : String(err)}` };
1852
+ }
1853
+ if (size <= 0) {
1854
+ return { status: "error", error: "empty file" };
1855
+ }
1856
+ if (size > maxSizeBytes) {
1857
+ return { status: "too-large", bytes: size, maxBytes: maxSizeBytes };
1858
+ }
1859
+ const fileName = basename2(filePath);
1860
+ const mediaType = mediaTypeFor(fileName);
1861
+ const data = readFileSync5(filePath);
1862
+ try {
1863
+ const aesKey = generateAesKey();
1864
+ const ciphertext = encryptAesEcb(data, aesKey);
1865
+ const filekey = randomBytes4(16).toString("hex");
1866
+ const rawMd5 = createHash("md5").update(data).digest("hex");
1867
+ const uploadParams = await apiFetch(creds, "ilink/bot/getuploadurl", {
1868
+ filekey,
1869
+ media_type: mediaType,
1870
+ to_user_id: to,
1871
+ rawsize: data.length,
1872
+ rawfilemd5: rawMd5,
1873
+ filesize: ciphertext.length,
1874
+ no_need_thumb: true,
1875
+ aeskey: aesKey.toString("hex"),
1876
+ base_info: { channel_version: "0.1.0" }
1877
+ }, 15000);
1878
+ const uploadFullUrl = uploadParams.upload_full_url?.trim();
1879
+ if (!uploadFullUrl && !uploadParams.upload_param) {
1880
+ return { status: "error", error: "getuploadurl returned no upload URL" };
1881
+ }
1882
+ const uploadUrl = uploadFullUrl || `${CDN_BASE_URL2}/upload?encrypted_query_param=${encodeURIComponent(uploadParams.upload_param)}&filekey=${encodeURIComponent(filekey)}`;
1883
+ let encryptQueryParam;
1884
+ let lastError;
1885
+ for (let attempt = 1;attempt <= UPLOAD_MAX_RETRIES; attempt++) {
1886
+ try {
1887
+ const resp = await fetch(uploadUrl, {
1888
+ method: "POST",
1889
+ headers: { "Content-Type": "application/octet-stream" },
1890
+ body: new Uint8Array(ciphertext),
1891
+ signal: AbortSignal.timeout(60000)
1892
+ });
1893
+ if (resp.status >= 400 && resp.status < 500) {
1894
+ return {
1895
+ status: "error",
1896
+ error: `CDN upload client error ${resp.status}: ${resp.headers.get("x-error-message") ?? "rejected"}`
1897
+ };
1898
+ }
1899
+ if (!resp.ok) {
1900
+ throw new Error(`CDN upload server error: ${resp.status}`);
1901
+ }
1902
+ encryptQueryParam = resp.headers.get("x-encrypted-param") ?? undefined;
1903
+ if (!encryptQueryParam) {
1904
+ throw new Error("CDN upload response missing x-encrypted-param header");
1905
+ }
1906
+ break;
1907
+ } catch (err) {
1908
+ lastError = err;
1909
+ if (attempt < UPLOAD_MAX_RETRIES) {
1910
+ logger.warn(`CDN upload attempt ${attempt}/${UPLOAD_MAX_RETRIES} failed, retrying:`, err);
1911
+ await Bun.sleep(1000 * attempt);
1912
+ }
1913
+ }
1914
+ }
1915
+ if (!encryptQueryParam) {
1916
+ return {
1917
+ status: "error",
1918
+ error: `CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`
1919
+ };
1920
+ }
1921
+ const media = {
1922
+ encrypt_query_param: encryptQueryParam,
1923
+ aes_key: aesKey.toString("base64"),
1924
+ encrypt_type: 1
1925
+ };
1926
+ const item = mediaType === 1 /* IMAGE */ ? {
1927
+ type: 2,
1928
+ image_item: { media, mid_size: ciphertext.length }
1929
+ } : {
1930
+ type: 4,
1931
+ file_item: {
1932
+ media,
1933
+ file_name: fileName,
1934
+ md5: rawMd5,
1935
+ len: String(data.length)
1936
+ }
1937
+ };
1938
+ let lastSendError;
1939
+ for (let attempt = 0;attempt <= SEND_MAX_RETRIES; attempt++) {
1940
+ try {
1941
+ await apiFetch(creds, "ilink/bot/sendmessage", {
1942
+ msg: {
1943
+ from_user_id: "",
1944
+ to_user_id: to,
1945
+ client_id: `omp-wechat-${Date.now()}-${randomBytes4(4).toString("hex")}`,
1946
+ message_type: 2,
1947
+ message_state: 2,
1948
+ item_list: [item],
1949
+ context_token: contextToken
1950
+ },
1951
+ base_info: { channel_version: "0.1.0" }
1952
+ }, 15000);
1953
+ logger.info(`Sent ${mediaType === 1 /* IMAGE */ ? "image" : "file"} to ${to}: ${fileName} (${data.length} bytes)`);
1954
+ return { status: "sent", mediaType, bytes: data.length };
1955
+ } catch (err) {
1956
+ lastSendError = err;
1957
+ if (attempt < SEND_MAX_RETRIES) {
1958
+ logger.warn(`Send retry ${attempt + 1}/${SEND_MAX_RETRIES}:`, err);
1959
+ await Bun.sleep(1000 * (attempt + 1));
1960
+ }
1961
+ }
1962
+ }
1963
+ return {
1964
+ status: "error",
1965
+ error: `send failed: ${lastSendError instanceof Error ? lastSendError.message : String(lastSendError)}`
1966
+ };
1967
+ } catch (err) {
1968
+ return { status: "error", error: err instanceof Error ? err.message : String(err) };
1969
+ }
1970
+ }
1971
+
1972
+ // src/utils/markdown.ts
1973
+ function stripMarkdown(text) {
1974
+ let result = text;
1975
+ result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_match, code) => code.trim());
1976
+ result = result.replace(/`([^`]+)`/g, "$1");
1977
+ result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
1978
+ result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
1979
+ result = result.replace(/^\|[\s:|-]+\|$/gm, "");
1980
+ result = result.replace(/^\|(.+)\|$/gm, (_match, inner) => inner.split("|").map((cell) => cell.trim()).join(" "));
1981
+ result = result.replace(/^#{1,6}\s+/gm, "");
1982
+ result = result.replace(/^[-*_]{3,}\s*$/gm, "");
1983
+ result = result.replace(/\*\*\*(.+?)\*\*\*/g, "$1");
1984
+ result = result.replace(/\*\*(.+?)\*\*/g, "$1");
1985
+ result = result.replace(/\*(.+?)\*/g, "$1");
1986
+ result = result.replace(/___(.+?)___/g, "$1");
1987
+ result = result.replace(/__(.+?)__/g, "$1");
1988
+ result = result.replace(/_(.+?)_/g, "$1");
1989
+ result = result.replace(/~~(.+?)~~/g, "$1");
1990
+ result = result.replace(/^>\s?/gm, "");
1991
+ result = result.replace(/^[\s]*[-*+]\s+/gm, "\u2022 ");
1992
+ result = result.replace(/^[\s]*\d+\.\s+/gm, "");
1993
+ result = result.replace(/<[^>]+>/g, "");
1994
+ result = result.replace(/\n{3,}/g, `
1995
+
1996
+ `);
1997
+ return result.trim();
1998
+ }
1999
+
1799
2000
  // src/engine/session.ts
1800
2001
  import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
1801
2002
 
1802
2003
  // src/engine/session-store.ts
1803
2004
  import { join as join7 } from "path";
1804
2005
  import { homedir as homedir6 } from "os";
1805
- import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync2, rmSync } from "fs";
2006
+ import { existsSync as existsSync2, mkdirSync as mkdirSync5, readdirSync as readdirSync2, statSync as statSync3, rmSync } from "fs";
1806
2007
  var STATE_DIR4 = join7(homedir6(), ".omp-wechat");
1807
2008
  var SESSIONS_DIR = join7(STATE_DIR4, "sessions");
2009
+ var DEFAULT_OUTBOX_BASE = join7(STATE_DIR4, "outbox");
1808
2010
  var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
1809
2011
  function sanitizeChatId(chatId) {
1810
2012
  return chatId.replace(/[^a-zA-Z0-9_@.-]/g, "_").slice(0, 200);
1811
2013
  }
2014
+ function sanitizeOutboxChatId(chatId) {
2015
+ return chatId.replace(/[^a-zA-Z0-9_-]/g, "_");
2016
+ }
1812
2017
  function sessionDirFor(chatId) {
1813
2018
  return join7(SESSIONS_DIR, sanitizeChatId(chatId));
1814
2019
  }
2020
+ function outboxDirFor(chatId, outboxBase) {
2021
+ return join7(outboxBase ?? DEFAULT_OUTBOX_BASE, sanitizeOutboxChatId(chatId));
2022
+ }
1815
2023
  function ensureSessionsDir() {
1816
2024
  mkdirSync5(SESSIONS_DIR, { recursive: true, mode: 448 });
1817
2025
  }
@@ -1827,6 +2035,54 @@ function removeSessionDir(chatId) {
1827
2035
  return false;
1828
2036
  }
1829
2037
  }
2038
+ function removeOutboxDir(chatId, outboxBase) {
2039
+ const dir = outboxDirFor(chatId, outboxBase);
2040
+ if (!existsSync2(dir))
2041
+ return false;
2042
+ try {
2043
+ rmSync(dir, { recursive: true, force: true });
2044
+ logger.info(`Removed outbox dir: ${dir}`);
2045
+ return true;
2046
+ } catch (err) {
2047
+ logger.warn(`Failed to remove outbox dir ${dir}: ${err}`);
2048
+ return false;
2049
+ }
2050
+ }
2051
+ function cleanupStaleOutboxes(outboxBase) {
2052
+ const base = outboxBase ?? DEFAULT_OUTBOX_BASE;
2053
+ if (!existsSync2(base))
2054
+ return 0;
2055
+ const now = Date.now();
2056
+ let removed = 0;
2057
+ for (const entry of readdirSync2(base, { withFileTypes: true })) {
2058
+ if (!entry.isDirectory())
2059
+ continue;
2060
+ const dir = join7(base, entry.name);
2061
+ let newestMtime = 0;
2062
+ try {
2063
+ for (const file of readdirSync2(dir)) {
2064
+ const mtime = statSync3(join7(dir, file)).mtimeMs;
2065
+ if (mtime > newestMtime)
2066
+ newestMtime = mtime;
2067
+ }
2068
+ } catch {
2069
+ continue;
2070
+ }
2071
+ if (newestMtime === 0 || now - newestMtime > STALE_THRESHOLD_MS) {
2072
+ try {
2073
+ rmSync(dir, { recursive: true, force: true });
2074
+ removed++;
2075
+ logger.info(`Cleaned up stale outbox dir: ${entry.name}`);
2076
+ } catch (err) {
2077
+ logger.warn(`Failed to cleanup outbox dir ${entry.name}: ${err}`);
2078
+ }
2079
+ }
2080
+ }
2081
+ if (removed > 0) {
2082
+ logger.info(`Outbox cleanup: removed ${removed} stale outbox(es)`);
2083
+ }
2084
+ return removed;
2085
+ }
1830
2086
  function cleanupStaleSessions() {
1831
2087
  if (!existsSync2(SESSIONS_DIR))
1832
2088
  return 0;
@@ -1839,7 +2095,7 @@ function cleanupStaleSessions() {
1839
2095
  let newestMtime = 0;
1840
2096
  try {
1841
2097
  for (const file of readdirSync2(dir)) {
1842
- const mtime = statSync2(join7(dir, file)).mtimeMs;
2098
+ const mtime = statSync3(join7(dir, file)).mtimeMs;
1843
2099
  if (mtime > newestMtime)
1844
2100
  newestMtime = mtime;
1845
2101
  }
@@ -1879,6 +2135,20 @@ function clearAllSessions() {
1879
2135
  }
1880
2136
 
1881
2137
  // src/engine/session.ts
2138
+ import { mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
2139
+ import { join as join8 } from "path";
2140
+ function outboxInstructions(outboxDir) {
2141
+ return `## File delivery to WeChat
2142
+
2143
+ You can send files (documents, spreadsheets, images, PDFs, code, etc.) to the WeChat user.
2144
+
2145
+ Rules:
2146
+ - Write each final deliverable to the outbox directory:
2147
+ ${outboxDir}
2148
+ - Only final deliverables belong there \u2014 never intermediate or scratch files.
2149
+ - You may write multiple files; every new file in the outbox is delivered to the user after your turn.
2150
+ - Mention the file name(s) in your reply so the user knows what to expect.`;
2151
+ }
1882
2152
  function extractAssistantText(message) {
1883
2153
  const content = message.content;
1884
2154
  if (!Array.isArray(content))
@@ -1899,14 +2169,30 @@ class ChatSession {
1899
2169
  contextToken;
1900
2170
  lastActive;
1901
2171
  replyCount = 0;
1902
- constructor(session, chatId, contextToken) {
2172
+ outboxDir;
2173
+ sendFilesEnabled;
2174
+ onFiles;
2175
+ outboxSnapshot = new Map;
2176
+ constructor(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles) {
1903
2177
  this.session = session;
1904
2178
  this.chatId = chatId;
1905
2179
  this.contextToken = contextToken;
1906
2180
  this.lastActive = Date.now();
2181
+ this.outboxDir = outboxDir;
2182
+ this.sendFilesEnabled = sendFilesEnabled;
2183
+ this.onFiles = onFiles;
1907
2184
  }
1908
- static async create(chatId, contextToken, config, onReply) {
2185
+ static async create(chatId, contextToken, config, onReply, onFiles) {
1909
2186
  logger.info(`Creating session: ${chatId}`);
2187
+ const outboxDir = outboxDirFor(chatId, config.outboxDir);
2188
+ const sendFilesEnabled = config.sendFiles !== false;
2189
+ if (sendFilesEnabled) {
2190
+ try {
2191
+ mkdirSync6(outboxDir, { recursive: true });
2192
+ } catch (err) {
2193
+ logger.warn(`[${chatId}] Could not create outbox dir ${outboxDir}:`, err);
2194
+ }
2195
+ }
1910
2196
  ensureSessionsDir();
1911
2197
  const sessionDir = sessionDirFor(chatId);
1912
2198
  const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
@@ -1915,17 +2201,23 @@ class ChatSession {
1915
2201
  sessionManager,
1916
2202
  enableMCP: false,
1917
2203
  enableLsp: false,
1918
- systemPrompt: config.systemPrompt,
2204
+ systemPrompt: sendFilesEnabled ? `${config.systemPrompt}
2205
+
2206
+ ${outboxInstructions(outboxDir)}` : config.systemPrompt,
1919
2207
  modelPattern: config.model
1920
2208
  });
1921
2209
  session.setAdvisorEnabled(false);
1922
2210
  if (modelFallbackMessage) {
1923
2211
  logger.warn(`Model fallback: ${modelFallbackMessage}`);
1924
2212
  }
1925
- const wrapper = new ChatSession(session, chatId, contextToken);
2213
+ const wrapper = new ChatSession(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles);
1926
2214
  const visionRole = session.settings.getModelRole("vision");
1927
2215
  logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
1928
2216
  session.subscribe((event) => {
2217
+ if (event.type === "turn_end") {
2218
+ wrapper.flushOutbox();
2219
+ return;
2220
+ }
1929
2221
  if (event.type !== "message_end")
1930
2222
  return;
1931
2223
  if (event.message.role !== "assistant")
@@ -1941,8 +2233,39 @@ class ChatSession {
1941
2233
  }
1942
2234
  async prompt(text, images) {
1943
2235
  this.lastActive = Date.now();
2236
+ this.outboxSnapshot = this.snapshotOutbox();
1944
2237
  await this.session.prompt(text, images?.length ? { images } : undefined);
1945
2238
  }
2239
+ flushOutbox() {
2240
+ if (!this.sendFilesEnabled)
2241
+ return;
2242
+ const current = this.snapshotOutbox();
2243
+ const fresh = [];
2244
+ for (const [name, stat] of current) {
2245
+ const prev = this.outboxSnapshot.get(name);
2246
+ if (!prev || prev.size !== stat.size || prev.mtimeMs !== stat.mtimeMs) {
2247
+ fresh.push(join8(this.outboxDir, name));
2248
+ }
2249
+ }
2250
+ this.outboxSnapshot = current;
2251
+ if (fresh.length > 0) {
2252
+ logger.info(`[${this.chatId}] Outbox: ${fresh.length} new file(s) for delivery`);
2253
+ this.onFiles(this.chatId, fresh);
2254
+ }
2255
+ }
2256
+ snapshotOutbox() {
2257
+ const map = new Map;
2258
+ try {
2259
+ for (const name of readdirSync3(this.outboxDir)) {
2260
+ try {
2261
+ const st = statSync4(join8(this.outboxDir, name));
2262
+ if (st.isFile())
2263
+ map.set(name, { size: st.size, mtimeMs: st.mtimeMs });
2264
+ } catch {}
2265
+ }
2266
+ } catch {}
2267
+ return map;
2268
+ }
1946
2269
  supportsVision() {
1947
2270
  return this.session.settings.getModelRole("vision") !== undefined;
1948
2271
  }
@@ -1965,9 +2288,11 @@ class SessionPool {
1965
2288
  pool = new Map;
1966
2289
  maxSessions;
1967
2290
  replyHandler;
1968
- constructor(maxSessions, replyHandler) {
2291
+ fileHandler;
2292
+ constructor(maxSessions, replyHandler, fileHandler) {
1969
2293
  this.maxSessions = maxSessions;
1970
2294
  this.replyHandler = replyHandler;
2295
+ this.fileHandler = fileHandler;
1971
2296
  }
1972
2297
  setMaxSessions(n) {
1973
2298
  this.maxSessions = n;
@@ -1981,7 +2306,7 @@ class SessionPool {
1981
2306
  if (this.pool.size >= this.maxSessions) {
1982
2307
  this.evictOldest();
1983
2308
  }
1984
- entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
2309
+ entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler, this.fileHandler);
1985
2310
  this.pool.set(chatId, entry);
1986
2311
  return entry;
1987
2312
  }
@@ -1992,13 +2317,14 @@ class SessionPool {
1992
2317
  get(chatId) {
1993
2318
  return this.pool.get(chatId);
1994
2319
  }
1995
- async resetSession(chatId) {
2320
+ async resetSession(chatId, config) {
1996
2321
  const entry = this.pool.get(chatId);
1997
2322
  if (entry) {
1998
2323
  await entry.dispose();
1999
2324
  this.pool.delete(chatId);
2000
2325
  }
2001
2326
  removeSessionDir(chatId);
2327
+ removeOutboxDir(chatId, config.outboxDir);
2002
2328
  }
2003
2329
  getContextToken(chatId) {
2004
2330
  return this.pool.get(chatId)?.getContextToken() ?? "";
@@ -2201,7 +2527,7 @@ class ModelCommand {
2201
2527
  // src/command/new-session-command.ts
2202
2528
  class NewSessionInvocation {
2203
2529
  async execute(ctx) {
2204
- await ctx.pool.resetSession(ctx.chatId);
2530
+ await ctx.pool.resetSession(ctx.chatId, ctx.config);
2205
2531
  return "Session reset. Your next message starts a fresh conversation.";
2206
2532
  }
2207
2533
  }
@@ -2223,6 +2549,34 @@ var RETRY_MS = 2000;
2223
2549
  var MAX_SEND_RETRIES = 2;
2224
2550
  var CHUNK_LIMIT = 2000;
2225
2551
  var LOCK_PORT = 19821;
2552
+ var FILE_TEXT_LIMIT = 1e4;
2553
+ var TEXT_FILE_EXTENSIONS = {
2554
+ ".txt": true,
2555
+ ".md": true,
2556
+ ".csv": true,
2557
+ ".json": true,
2558
+ ".xml": true,
2559
+ ".html": true,
2560
+ ".yaml": true,
2561
+ ".yml": true,
2562
+ ".toml": true,
2563
+ ".log": true,
2564
+ ".py": true,
2565
+ ".js": true,
2566
+ ".jsx": true,
2567
+ ".ts": true,
2568
+ ".tsx": true,
2569
+ ".go": true,
2570
+ ".rs": true,
2571
+ ".java": true,
2572
+ ".c": true,
2573
+ ".cpp": true,
2574
+ ".h": true,
2575
+ ".sh": true,
2576
+ ".bash": true,
2577
+ ".sql": true,
2578
+ ".css": true
2579
+ };
2226
2580
 
2227
2581
  class WeChatBridge {
2228
2582
  state = null;
@@ -2257,7 +2611,12 @@ class WeChatBridge {
2257
2611
  logger.error(`[${chatId}] Reply send failed:`, err);
2258
2612
  });
2259
2613
  };
2260
- this.pool = new SessionPool(config.maxSessions, replyHandler);
2614
+ const fileHandler = (chatId, files) => {
2615
+ this.sendFiles(creds, chatId, files).catch((err) => {
2616
+ logger.error(`[${chatId}] File delivery failed:`, err);
2617
+ });
2618
+ };
2619
+ this.pool = new SessionPool(config.maxSessions, replyHandler, fileHandler);
2261
2620
  this.state = { running: true, config, creds, lastError: null };
2262
2621
  logger.info("OMP-Wechat poll loop starting", {
2263
2622
  maxSessions: config.maxSessions,
@@ -2272,7 +2631,11 @@ class WeChatBridge {
2272
2631
  this.releaseLock();
2273
2632
  });
2274
2633
  cleanupStaleSessions();
2275
- this.cleanupTimer = setInterval(() => cleanupStaleSessions(), 6 * 60 * 60 * 1000);
2634
+ cleanupStaleOutboxes(config.outboxDir);
2635
+ this.cleanupTimer = setInterval(() => {
2636
+ cleanupStaleSessions();
2637
+ cleanupStaleOutboxes(config.outboxDir);
2638
+ }, 6 * 60 * 60 * 1000);
2276
2639
  return this.state;
2277
2640
  }
2278
2641
  async stop() {
@@ -2402,7 +2765,11 @@ class WeChatBridge {
2402
2765
  if (!text && !hasImages)
2403
2766
  return;
2404
2767
  const images = await this.downloadImages(creds, msg, senderId);
2405
- await session.prompt(text, images);
2768
+ const fileTexts = await this.downloadFileTexts(creds, msg, senderId);
2769
+ const fullText = [text, ...fileTexts].filter(Boolean).join(`
2770
+
2771
+ `);
2772
+ await session.prompt(fullText, images);
2406
2773
  } catch (err) {
2407
2774
  logger.error(`[${senderId}] prompt failed:`, err);
2408
2775
  await this.sendReply(creds, senderId, "Processing failed, please try again.");
@@ -2435,15 +2802,71 @@ class WeChatBridge {
2435
2802
  }
2436
2803
  return results;
2437
2804
  }
2805
+ async sendFiles(creds, chatId, files) {
2806
+ const contextToken = this.pool?.getContextToken(chatId) ?? "";
2807
+ if (!contextToken) {
2808
+ logger.warn(`[${chatId}] No context_token, cannot send files`);
2809
+ return;
2810
+ }
2811
+ const config = loadConfig();
2812
+ const maxBytes = (config.maxFileSizeMb ?? 100) * 1024 * 1024;
2813
+ for (const filePath of files) {
2814
+ const result = await uploadAndSendFile(creds, chatId, contextToken, filePath, maxBytes);
2815
+ switch (result.status) {
2816
+ case "sent":
2817
+ logger.info(`[${chatId}] Delivered: ${filePath}`);
2818
+ try {
2819
+ rmSync2(filePath, { force: true });
2820
+ } catch (err) {
2821
+ logger.warn(`[${chatId}] Could not remove delivered file ${filePath}:`, err);
2822
+ }
2823
+ break;
2824
+ case "too-large":
2825
+ logger.warn(`[${chatId}] File too large, skipped: ${filePath}`);
2826
+ await this.sendReply(creds, chatId, `[File not sent: ${basename3(filePath)} exceeds ${Math.round(result.maxBytes / 1024 / 1024)}MB limit]`);
2827
+ break;
2828
+ case "error":
2829
+ logger.error(`[${chatId}] File send failed: ${filePath}: ${result.error}`);
2830
+ await this.sendReply(creds, chatId, `[File delivery failed: ${basename3(filePath)} \u2014 ${result.error}]`);
2831
+ break;
2832
+ }
2833
+ }
2834
+ }
2835
+ async downloadFileTexts(_creds, msg, chatId) {
2836
+ const items = (msg.item_list ?? []).filter((item) => item.type === 4);
2837
+ if (items.length === 0)
2838
+ return [];
2839
+ const results = [];
2840
+ for (const item of items) {
2841
+ const file = item.file_item;
2842
+ const name = file.file_name ?? "unknown";
2843
+ if (!TEXT_FILE_EXTENSIONS[extname2(name).toLowerCase()])
2844
+ continue;
2845
+ const buf = await downloadAndDecrypt(file.media?.encrypt_query_param, file.media?.full_url, undefined, file.media?.aes_key, `file[${chatId}]`);
2846
+ if (!buf)
2847
+ continue;
2848
+ const len = parseInt(file.len ?? "", 10);
2849
+ const text = buf.toString("utf-8");
2850
+ const truncated = text.length > FILE_TEXT_LIMIT ? `${text.slice(0, FILE_TEXT_LIMIT)}
2851
+ ... [truncated]` : text;
2852
+ results.push(`[File: ${name}${Number.isFinite(len) && len > 0 ? ` (${formatSize(len)})` : ""}]
2853
+
2854
+ \`\`\`
2855
+ ${truncated}
2856
+ \`\`\``);
2857
+ logger.info(`[${chatId}] Extracted text from file: ${name} (${buf.length} bytes)`);
2858
+ }
2859
+ return results;
2860
+ }
2438
2861
  async sendReply(creds, chatId, text) {
2439
2862
  const contextToken = this.pool?.getContextToken(chatId) ?? "";
2440
2863
  if (!contextToken) {
2441
2864
  logger.warn(`[${chatId}] No context_token, cannot reply`);
2442
2865
  return;
2443
2866
  }
2444
- const chunks = chunkText(text, CHUNK_LIMIT);
2867
+ const chunks = chunkText(stripMarkdown(text), CHUNK_LIMIT);
2445
2868
  for (const chunk of chunks) {
2446
- const clientId = `omp-wechat-${Date.now()}-${randomBytes3(4).toString("hex")}`;
2869
+ const clientId = `omp-wechat-${Date.now()}-${randomBytes5(4).toString("hex")}`;
2447
2870
  let retries = 0;
2448
2871
  while (retries <= MAX_SEND_RETRIES) {
2449
2872
  try {
@@ -2465,8 +2888,8 @@ class WeChatBridge {
2465
2888
 
2466
2889
  // src/service.ts
2467
2890
  import { platform, homedir as homedir7 } from "os";
2468
- import { join as join8, basename as basename2 } from "path";
2469
- import { existsSync as existsSync3, mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, rmSync as rmSync2 } from "fs";
2891
+ import { join as join9, basename as basename4 } from "path";
2892
+ import { existsSync as existsSync3, mkdirSync as mkdirSync7, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
2470
2893
  var PLIST_LABEL = "com.omp-wechat";
2471
2894
  var SERVICE_NAME = "omp-wechat";
2472
2895
  function detectPlatform() {
@@ -2480,14 +2903,14 @@ function detectPlatform() {
2480
2903
  return "other";
2481
2904
  }
2482
2905
  function getLogDir() {
2483
- return join8(homedir7(), ".omp", "logs");
2906
+ return join9(homedir7(), ".omp", "logs");
2484
2907
  }
2485
2908
  function resolveHostBinary() {
2486
2909
  const exe = process.execPath || "omp";
2487
- return basename2(exe);
2910
+ return basename4(exe);
2488
2911
  }
2489
2912
  function plistPath() {
2490
- return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2913
+ return join9(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2491
2914
  }
2492
2915
  function generatePlist() {
2493
2916
  const omp = resolveHostBinary();
@@ -2530,9 +2953,9 @@ function generatePlist() {
2530
2953
  `;
2531
2954
  }
2532
2955
  function installLaunchd() {
2533
- const dir = join8(homedir7(), "Library", "LaunchAgents");
2534
- mkdirSync6(dir, { recursive: true });
2535
- mkdirSync6(getLogDir(), { recursive: true });
2956
+ const dir = join9(homedir7(), "Library", "LaunchAgents");
2957
+ mkdirSync7(dir, { recursive: true });
2958
+ mkdirSync7(getLogDir(), { recursive: true });
2536
2959
  const plist = plistPath();
2537
2960
  if (existsSync3(plist)) {
2538
2961
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
@@ -2550,7 +2973,7 @@ function uninstallLaunchd() {
2550
2973
  throw new Error("No launchd service found (may not be installed)");
2551
2974
  }
2552
2975
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
2553
- rmSync2(plist);
2976
+ rmSync3(plist);
2554
2977
  }
2555
2978
  function servicePath() {
2556
2979
  return `/etc/systemd/system/${SERVICE_NAME}.service`;
@@ -2581,7 +3004,7 @@ StandardError=append:${logDir}/rpc.log
2581
3004
  NoNewPrivileges=true
2582
3005
  ProtectSystem=strict
2583
3006
  ProtectHome=read-only
2584
- ReadWritePaths=${logDir} ${join8(homedir7(), ".omp-wechat")} ${join8(homedir7(), ".omp")}
3007
+ ReadWritePaths=${logDir} ${join9(homedir7(), ".omp-wechat")} ${join9(homedir7(), ".omp")}
2585
3008
  PrivateTmp=true
2586
3009
 
2587
3010
  [Install]
@@ -2590,7 +3013,7 @@ WantedBy=multi-user.target
2590
3013
  }
2591
3014
  function installSystemd() {
2592
3015
  const svc = servicePath();
2593
- mkdirSync6(getLogDir(), { recursive: true });
3016
+ mkdirSync7(getLogDir(), { recursive: true });
2594
3017
  if (existsSync3(svc)) {
2595
3018
  Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
2596
3019
  }
@@ -2600,7 +3023,7 @@ function installSystemd() {
2600
3023
  if (result.exitCode !== 0) {
2601
3024
  result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
2602
3025
  }
2603
- rmSync2(tmp);
3026
+ rmSync3(tmp);
2604
3027
  if (result.exitCode !== 0) {
2605
3028
  throw new Error("Failed to write service file (need sudo)");
2606
3029
  }
@@ -2622,7 +3045,7 @@ function uninstallSystemd() {
2622
3045
  }
2623
3046
  var WIN_TASK_NAME = "OMP-Wechat";
2624
3047
  function winScriptPath() {
2625
- return join8(homedir7(), ".omp-wechat", "omp-wechat-rpc.ps1");
3048
+ return join9(homedir7(), ".omp-wechat", "omp-wechat-rpc.ps1");
2626
3049
  }
2627
3050
  function generateWinScript() {
2628
3051
  const omp = process.execPath || "omp";
@@ -2665,8 +3088,8 @@ while ($true) {
2665
3088
  `;
2666
3089
  }
2667
3090
  function installWinTask() {
2668
- mkdirSync6(join8(homedir7(), ".omp-wechat"), { recursive: true });
2669
- mkdirSync6(getLogDir(), { recursive: true });
3091
+ mkdirSync7(join9(homedir7(), ".omp-wechat"), { recursive: true });
3092
+ mkdirSync7(getLogDir(), { recursive: true });
2670
3093
  writeFileSync4(winScriptPath(), generateWinScript());
2671
3094
  Bun.spawnSync(["schtasks", "/end", "/tn", WIN_TASK_NAME], { stderr: "ignore" });
2672
3095
  Bun.spawnSync(["schtasks", "/delete", "/tn", WIN_TASK_NAME, "/f"], { stderr: "ignore" });
@@ -2689,7 +3112,7 @@ function uninstallWinTask() {
2689
3112
  }
2690
3113
  const script = winScriptPath();
2691
3114
  if (existsSync3(script)) {
2692
- rmSync2(script);
3115
+ rmSync3(script);
2693
3116
  }
2694
3117
  }
2695
3118
  function winTaskExists() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-wechat",
3
- "version": "1.5.0",
3
+ "version": "1.7.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",