omp-wechat 1.5.0 → 1.6.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 +405 -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,13 +1825,185 @@ 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");
1808
2009
  var STALE_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
@@ -1839,7 +2040,7 @@ function cleanupStaleSessions() {
1839
2040
  let newestMtime = 0;
1840
2041
  try {
1841
2042
  for (const file of readdirSync2(dir)) {
1842
- const mtime = statSync2(join7(dir, file)).mtimeMs;
2043
+ const mtime = statSync3(join7(dir, file)).mtimeMs;
1843
2044
  if (mtime > newestMtime)
1844
2045
  newestMtime = mtime;
1845
2046
  }
@@ -1879,6 +2080,28 @@ function clearAllSessions() {
1879
2080
  }
1880
2081
 
1881
2082
  // src/engine/session.ts
2083
+ import { mkdirSync as mkdirSync6, readdirSync as readdirSync3, statSync as statSync4 } from "fs";
2084
+ import { homedir as homedir7 } from "os";
2085
+ import { join as join8 } from "path";
2086
+ var DEFAULT_OUTBOX_BASE = join8(homedir7(), ".omp-wechat", "outbox");
2087
+ function sanitizeChatId2(chatId) {
2088
+ return chatId.replace(/[^a-zA-Z0-9_-]/g, "_");
2089
+ }
2090
+ function outboxDirFor(chatId, config) {
2091
+ return join8(config.outboxDir ?? DEFAULT_OUTBOX_BASE, sanitizeChatId2(chatId));
2092
+ }
2093
+ function outboxInstructions(outboxDir) {
2094
+ return `## File delivery to WeChat
2095
+
2096
+ You can send files (documents, spreadsheets, images, PDFs, code, etc.) to the WeChat user.
2097
+
2098
+ Rules:
2099
+ - Write each final deliverable to the outbox directory:
2100
+ ${outboxDir}
2101
+ - Only final deliverables belong there \u2014 never intermediate or scratch files.
2102
+ - You may write multiple files; every new file in the outbox is delivered to the user after your turn.
2103
+ - Mention the file name(s) in your reply so the user knows what to expect.`;
2104
+ }
1882
2105
  function extractAssistantText(message) {
1883
2106
  const content = message.content;
1884
2107
  if (!Array.isArray(content))
@@ -1899,14 +2122,30 @@ class ChatSession {
1899
2122
  contextToken;
1900
2123
  lastActive;
1901
2124
  replyCount = 0;
1902
- constructor(session, chatId, contextToken) {
2125
+ outboxDir;
2126
+ sendFilesEnabled;
2127
+ onFiles;
2128
+ outboxSnapshot = new Map;
2129
+ constructor(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles) {
1903
2130
  this.session = session;
1904
2131
  this.chatId = chatId;
1905
2132
  this.contextToken = contextToken;
1906
2133
  this.lastActive = Date.now();
2134
+ this.outboxDir = outboxDir;
2135
+ this.sendFilesEnabled = sendFilesEnabled;
2136
+ this.onFiles = onFiles;
1907
2137
  }
1908
- static async create(chatId, contextToken, config, onReply) {
2138
+ static async create(chatId, contextToken, config, onReply, onFiles) {
1909
2139
  logger.info(`Creating session: ${chatId}`);
2140
+ const outboxDir = outboxDirFor(chatId, config);
2141
+ const sendFilesEnabled = config.sendFiles !== false;
2142
+ if (sendFilesEnabled) {
2143
+ try {
2144
+ mkdirSync6(outboxDir, { recursive: true });
2145
+ } catch (err) {
2146
+ logger.warn(`[${chatId}] Could not create outbox dir ${outboxDir}:`, err);
2147
+ }
2148
+ }
1910
2149
  ensureSessionsDir();
1911
2150
  const sessionDir = sessionDirFor(chatId);
1912
2151
  const sessionManager = await SessionManager.continueRecent(config.cwd || process.cwd(), sessionDir);
@@ -1915,17 +2154,23 @@ class ChatSession {
1915
2154
  sessionManager,
1916
2155
  enableMCP: false,
1917
2156
  enableLsp: false,
1918
- systemPrompt: config.systemPrompt,
2157
+ systemPrompt: sendFilesEnabled ? `${config.systemPrompt}
2158
+
2159
+ ${outboxInstructions(outboxDir)}` : config.systemPrompt,
1919
2160
  modelPattern: config.model
1920
2161
  });
1921
2162
  session.setAdvisorEnabled(false);
1922
2163
  if (modelFallbackMessage) {
1923
2164
  logger.warn(`Model fallback: ${modelFallbackMessage}`);
1924
2165
  }
1925
- const wrapper = new ChatSession(session, chatId, contextToken);
2166
+ const wrapper = new ChatSession(session, chatId, contextToken, outboxDir, sendFilesEnabled, onFiles);
1926
2167
  const visionRole = session.settings.getModelRole("vision");
1927
2168
  logger.info(`[${chatId}] Model: ${session.model?.id ?? "unknown"}, vision role: ${visionRole ?? "(not configured)"}`);
1928
2169
  session.subscribe((event) => {
2170
+ if (event.type === "turn_end") {
2171
+ wrapper.flushOutbox();
2172
+ return;
2173
+ }
1929
2174
  if (event.type !== "message_end")
1930
2175
  return;
1931
2176
  if (event.message.role !== "assistant")
@@ -1941,8 +2186,39 @@ class ChatSession {
1941
2186
  }
1942
2187
  async prompt(text, images) {
1943
2188
  this.lastActive = Date.now();
2189
+ this.outboxSnapshot = this.snapshotOutbox();
1944
2190
  await this.session.prompt(text, images?.length ? { images } : undefined);
1945
2191
  }
2192
+ flushOutbox() {
2193
+ if (!this.sendFilesEnabled)
2194
+ return;
2195
+ const current = this.snapshotOutbox();
2196
+ const fresh = [];
2197
+ for (const [name, stat] of current) {
2198
+ const prev = this.outboxSnapshot.get(name);
2199
+ if (!prev || prev.size !== stat.size || prev.mtimeMs !== stat.mtimeMs) {
2200
+ fresh.push(join8(this.outboxDir, name));
2201
+ }
2202
+ }
2203
+ this.outboxSnapshot = current;
2204
+ if (fresh.length > 0) {
2205
+ logger.info(`[${this.chatId}] Outbox: ${fresh.length} new file(s) for delivery`);
2206
+ this.onFiles(this.chatId, fresh);
2207
+ }
2208
+ }
2209
+ snapshotOutbox() {
2210
+ const map = new Map;
2211
+ try {
2212
+ for (const name of readdirSync3(this.outboxDir)) {
2213
+ try {
2214
+ const st = statSync4(join8(this.outboxDir, name));
2215
+ if (st.isFile())
2216
+ map.set(name, { size: st.size, mtimeMs: st.mtimeMs });
2217
+ } catch {}
2218
+ }
2219
+ } catch {}
2220
+ return map;
2221
+ }
1946
2222
  supportsVision() {
1947
2223
  return this.session.settings.getModelRole("vision") !== undefined;
1948
2224
  }
@@ -1965,9 +2241,11 @@ class SessionPool {
1965
2241
  pool = new Map;
1966
2242
  maxSessions;
1967
2243
  replyHandler;
1968
- constructor(maxSessions, replyHandler) {
2244
+ fileHandler;
2245
+ constructor(maxSessions, replyHandler, fileHandler) {
1969
2246
  this.maxSessions = maxSessions;
1970
2247
  this.replyHandler = replyHandler;
2248
+ this.fileHandler = fileHandler;
1971
2249
  }
1972
2250
  setMaxSessions(n) {
1973
2251
  this.maxSessions = n;
@@ -1981,7 +2259,7 @@ class SessionPool {
1981
2259
  if (this.pool.size >= this.maxSessions) {
1982
2260
  this.evictOldest();
1983
2261
  }
1984
- entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler);
2262
+ entry = await ChatSession.create(chatId, contextToken, config, this.replyHandler, this.fileHandler);
1985
2263
  this.pool.set(chatId, entry);
1986
2264
  return entry;
1987
2265
  }
@@ -2223,6 +2501,34 @@ var RETRY_MS = 2000;
2223
2501
  var MAX_SEND_RETRIES = 2;
2224
2502
  var CHUNK_LIMIT = 2000;
2225
2503
  var LOCK_PORT = 19821;
2504
+ var FILE_TEXT_LIMIT = 1e4;
2505
+ var TEXT_FILE_EXTENSIONS = {
2506
+ ".txt": true,
2507
+ ".md": true,
2508
+ ".csv": true,
2509
+ ".json": true,
2510
+ ".xml": true,
2511
+ ".html": true,
2512
+ ".yaml": true,
2513
+ ".yml": true,
2514
+ ".toml": true,
2515
+ ".log": true,
2516
+ ".py": true,
2517
+ ".js": true,
2518
+ ".jsx": true,
2519
+ ".ts": true,
2520
+ ".tsx": true,
2521
+ ".go": true,
2522
+ ".rs": true,
2523
+ ".java": true,
2524
+ ".c": true,
2525
+ ".cpp": true,
2526
+ ".h": true,
2527
+ ".sh": true,
2528
+ ".bash": true,
2529
+ ".sql": true,
2530
+ ".css": true
2531
+ };
2226
2532
 
2227
2533
  class WeChatBridge {
2228
2534
  state = null;
@@ -2257,7 +2563,12 @@ class WeChatBridge {
2257
2563
  logger.error(`[${chatId}] Reply send failed:`, err);
2258
2564
  });
2259
2565
  };
2260
- this.pool = new SessionPool(config.maxSessions, replyHandler);
2566
+ const fileHandler = (chatId, files) => {
2567
+ this.sendFiles(creds, chatId, files).catch((err) => {
2568
+ logger.error(`[${chatId}] File delivery failed:`, err);
2569
+ });
2570
+ };
2571
+ this.pool = new SessionPool(config.maxSessions, replyHandler, fileHandler);
2261
2572
  this.state = { running: true, config, creds, lastError: null };
2262
2573
  logger.info("OMP-Wechat poll loop starting", {
2263
2574
  maxSessions: config.maxSessions,
@@ -2402,7 +2713,11 @@ class WeChatBridge {
2402
2713
  if (!text && !hasImages)
2403
2714
  return;
2404
2715
  const images = await this.downloadImages(creds, msg, senderId);
2405
- await session.prompt(text, images);
2716
+ const fileTexts = await this.downloadFileTexts(creds, msg, senderId);
2717
+ const fullText = [text, ...fileTexts].filter(Boolean).join(`
2718
+
2719
+ `);
2720
+ await session.prompt(fullText, images);
2406
2721
  } catch (err) {
2407
2722
  logger.error(`[${senderId}] prompt failed:`, err);
2408
2723
  await this.sendReply(creds, senderId, "Processing failed, please try again.");
@@ -2435,15 +2750,71 @@ class WeChatBridge {
2435
2750
  }
2436
2751
  return results;
2437
2752
  }
2753
+ async sendFiles(creds, chatId, files) {
2754
+ const contextToken = this.pool?.getContextToken(chatId) ?? "";
2755
+ if (!contextToken) {
2756
+ logger.warn(`[${chatId}] No context_token, cannot send files`);
2757
+ return;
2758
+ }
2759
+ const config = loadConfig();
2760
+ const maxBytes = (config.maxFileSizeMb ?? 100) * 1024 * 1024;
2761
+ for (const filePath of files) {
2762
+ const result = await uploadAndSendFile(creds, chatId, contextToken, filePath, maxBytes);
2763
+ switch (result.status) {
2764
+ case "sent":
2765
+ logger.info(`[${chatId}] Delivered: ${filePath}`);
2766
+ try {
2767
+ rmSync2(filePath, { force: true });
2768
+ } catch (err) {
2769
+ logger.warn(`[${chatId}] Could not remove delivered file ${filePath}:`, err);
2770
+ }
2771
+ break;
2772
+ case "too-large":
2773
+ logger.warn(`[${chatId}] File too large, skipped: ${filePath}`);
2774
+ await this.sendReply(creds, chatId, `[File not sent: ${basename3(filePath)} exceeds ${Math.round(result.maxBytes / 1024 / 1024)}MB limit]`);
2775
+ break;
2776
+ case "error":
2777
+ logger.error(`[${chatId}] File send failed: ${filePath}: ${result.error}`);
2778
+ await this.sendReply(creds, chatId, `[File delivery failed: ${basename3(filePath)} \u2014 ${result.error}]`);
2779
+ break;
2780
+ }
2781
+ }
2782
+ }
2783
+ async downloadFileTexts(_creds, msg, chatId) {
2784
+ const items = (msg.item_list ?? []).filter((item) => item.type === 4);
2785
+ if (items.length === 0)
2786
+ return [];
2787
+ const results = [];
2788
+ for (const item of items) {
2789
+ const file = item.file_item;
2790
+ const name = file.file_name ?? "unknown";
2791
+ if (!TEXT_FILE_EXTENSIONS[extname2(name).toLowerCase()])
2792
+ continue;
2793
+ const buf = await downloadAndDecrypt(file.media?.encrypt_query_param, file.media?.full_url, undefined, file.media?.aes_key, `file[${chatId}]`);
2794
+ if (!buf)
2795
+ continue;
2796
+ const len = parseInt(file.len ?? "", 10);
2797
+ const text = buf.toString("utf-8");
2798
+ const truncated = text.length > FILE_TEXT_LIMIT ? `${text.slice(0, FILE_TEXT_LIMIT)}
2799
+ ... [truncated]` : text;
2800
+ results.push(`[File: ${name}${Number.isFinite(len) && len > 0 ? ` (${formatSize(len)})` : ""}]
2801
+
2802
+ \`\`\`
2803
+ ${truncated}
2804
+ \`\`\``);
2805
+ logger.info(`[${chatId}] Extracted text from file: ${name} (${buf.length} bytes)`);
2806
+ }
2807
+ return results;
2808
+ }
2438
2809
  async sendReply(creds, chatId, text) {
2439
2810
  const contextToken = this.pool?.getContextToken(chatId) ?? "";
2440
2811
  if (!contextToken) {
2441
2812
  logger.warn(`[${chatId}] No context_token, cannot reply`);
2442
2813
  return;
2443
2814
  }
2444
- const chunks = chunkText(text, CHUNK_LIMIT);
2815
+ const chunks = chunkText(stripMarkdown(text), CHUNK_LIMIT);
2445
2816
  for (const chunk of chunks) {
2446
- const clientId = `omp-wechat-${Date.now()}-${randomBytes3(4).toString("hex")}`;
2817
+ const clientId = `omp-wechat-${Date.now()}-${randomBytes5(4).toString("hex")}`;
2447
2818
  let retries = 0;
2448
2819
  while (retries <= MAX_SEND_RETRIES) {
2449
2820
  try {
@@ -2464,9 +2835,9 @@ class WeChatBridge {
2464
2835
  }
2465
2836
 
2466
2837
  // src/service.ts
2467
- 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";
2838
+ import { platform, homedir as homedir8 } from "os";
2839
+ import { join as join9, basename as basename4 } from "path";
2840
+ import { existsSync as existsSync3, mkdirSync as mkdirSync7, writeFileSync as writeFileSync4, rmSync as rmSync3 } from "fs";
2470
2841
  var PLIST_LABEL = "com.omp-wechat";
2471
2842
  var SERVICE_NAME = "omp-wechat";
2472
2843
  function detectPlatform() {
@@ -2480,14 +2851,14 @@ function detectPlatform() {
2480
2851
  return "other";
2481
2852
  }
2482
2853
  function getLogDir() {
2483
- return join8(homedir7(), ".omp", "logs");
2854
+ return join9(homedir8(), ".omp", "logs");
2484
2855
  }
2485
2856
  function resolveHostBinary() {
2486
2857
  const exe = process.execPath || "omp";
2487
- return basename2(exe);
2858
+ return basename4(exe);
2488
2859
  }
2489
2860
  function plistPath() {
2490
- return join8(homedir7(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2861
+ return join9(homedir8(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
2491
2862
  }
2492
2863
  function generatePlist() {
2493
2864
  const omp = resolveHostBinary();
@@ -2523,16 +2894,16 @@ function generatePlist() {
2523
2894
  <key>PATH</key>
2524
2895
  <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
2525
2896
  <key>HOME</key>
2526
- <string>${homedir7()}</string>
2897
+ <string>${homedir8()}</string>
2527
2898
  </dict>
2528
2899
  </dict>
2529
2900
  </plist>
2530
2901
  `;
2531
2902
  }
2532
2903
  function installLaunchd() {
2533
- const dir = join8(homedir7(), "Library", "LaunchAgents");
2534
- mkdirSync6(dir, { recursive: true });
2535
- mkdirSync6(getLogDir(), { recursive: true });
2904
+ const dir = join9(homedir8(), "Library", "LaunchAgents");
2905
+ mkdirSync7(dir, { recursive: true });
2906
+ mkdirSync7(getLogDir(), { recursive: true });
2536
2907
  const plist = plistPath();
2537
2908
  if (existsSync3(plist)) {
2538
2909
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "ignore" });
@@ -2550,7 +2921,7 @@ function uninstallLaunchd() {
2550
2921
  throw new Error("No launchd service found (may not be installed)");
2551
2922
  }
2552
2923
  Bun.spawnSync(["launchctl", "unload", plist], { stderr: "inherit" });
2553
- rmSync2(plist);
2924
+ rmSync3(plist);
2554
2925
  }
2555
2926
  function servicePath() {
2556
2927
  return `/etc/systemd/system/${SERVICE_NAME}.service`;
@@ -2572,7 +2943,7 @@ ExecStart=/bin/sh -c 'while true; do echo "{\\"id\\":\\"ka\\",\\"type\\":\\"get_
2572
2943
  Restart=always
2573
2944
  RestartSec=10
2574
2945
 
2575
- Environment=HOME=${homedir7()}
2946
+ Environment=HOME=${homedir8()}
2576
2947
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
2577
2948
 
2578
2949
  StandardOutput=null
@@ -2581,7 +2952,7 @@ StandardError=append:${logDir}/rpc.log
2581
2952
  NoNewPrivileges=true
2582
2953
  ProtectSystem=strict
2583
2954
  ProtectHome=read-only
2584
- ReadWritePaths=${logDir} ${join8(homedir7(), ".omp-wechat")} ${join8(homedir7(), ".omp")}
2955
+ ReadWritePaths=${logDir} ${join9(homedir8(), ".omp-wechat")} ${join9(homedir8(), ".omp")}
2585
2956
  PrivateTmp=true
2586
2957
 
2587
2958
  [Install]
@@ -2590,7 +2961,7 @@ WantedBy=multi-user.target
2590
2961
  }
2591
2962
  function installSystemd() {
2592
2963
  const svc = servicePath();
2593
- mkdirSync6(getLogDir(), { recursive: true });
2964
+ mkdirSync7(getLogDir(), { recursive: true });
2594
2965
  if (existsSync3(svc)) {
2595
2966
  Bun.spawnSync(["sudo", "systemctl", "stop", SERVICE_NAME], { stderr: "inherit" });
2596
2967
  }
@@ -2600,7 +2971,7 @@ function installSystemd() {
2600
2971
  if (result.exitCode !== 0) {
2601
2972
  result = Bun.spawnSync(["sudo", "cp", tmp, svc], { stderr: "inherit" });
2602
2973
  }
2603
- rmSync2(tmp);
2974
+ rmSync3(tmp);
2604
2975
  if (result.exitCode !== 0) {
2605
2976
  throw new Error("Failed to write service file (need sudo)");
2606
2977
  }
@@ -2622,7 +2993,7 @@ function uninstallSystemd() {
2622
2993
  }
2623
2994
  var WIN_TASK_NAME = "OMP-Wechat";
2624
2995
  function winScriptPath() {
2625
- return join8(homedir7(), ".omp-wechat", "omp-wechat-rpc.ps1");
2996
+ return join9(homedir8(), ".omp-wechat", "omp-wechat-rpc.ps1");
2626
2997
  }
2627
2998
  function generateWinScript() {
2628
2999
  const omp = process.execPath || "omp";
@@ -2665,8 +3036,8 @@ while ($true) {
2665
3036
  `;
2666
3037
  }
2667
3038
  function installWinTask() {
2668
- mkdirSync6(join8(homedir7(), ".omp-wechat"), { recursive: true });
2669
- mkdirSync6(getLogDir(), { recursive: true });
3039
+ mkdirSync7(join9(homedir8(), ".omp-wechat"), { recursive: true });
3040
+ mkdirSync7(getLogDir(), { recursive: true });
2670
3041
  writeFileSync4(winScriptPath(), generateWinScript());
2671
3042
  Bun.spawnSync(["schtasks", "/end", "/tn", WIN_TASK_NAME], { stderr: "ignore" });
2672
3043
  Bun.spawnSync(["schtasks", "/delete", "/tn", WIN_TASK_NAME, "/f"], { stderr: "ignore" });
@@ -2689,7 +3060,7 @@ function uninstallWinTask() {
2689
3060
  }
2690
3061
  const script = winScriptPath();
2691
3062
  if (existsSync3(script)) {
2692
- rmSync2(script);
3063
+ rmSync3(script);
2693
3064
  }
2694
3065
  }
2695
3066
  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.6.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",