aicq-codex 0.1.1 → 0.1.3

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 +83 -40
  2. package/lib/chat.js +120 -15
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,59 +1,102 @@
1
- # aicq-codex — AICQ Encrypted Chat for OpenAI Codex
1
+ # aicq-codex — AICQ Chat for Codex
2
2
 
3
- AICQ 端到端加密聊天网络接入 **OpenAI Codex**。本插件遵循
4
- **Agent Plugins v1** 标准清单格式(`plugin.json` + `mcp.json`),通过一个
5
- **stdio MCP Server** Codex 暴露 AICQ 好友管理、私聊、群聊与文件传输能力。
3
+ **Wire [Codex](https://openai.com/codex) to [aicq.me](https://aicq.me)** through a
4
+ standard **MCP server**. Your agent gets real chat presence on an encrypted network:
5
+ direct messages, group chats, friends, file & image transfer — exposed as clean
6
+ MCP tools it can call like any other.
6
7
 
7
- - 协议栈(SQLite 身份库 / NaCl X25519+XSalsa20-Poly1305 加密 / HTTP+WS 信令 /
8
- 握手 / 聊天管理)直接复用自 `aicq-openclaw` CJS `lib/`,保证各宿主插件行为一致。
9
- - 依赖仅 `@modelcontextprotocol/sdk`、`ws`、`sql.js`、`tweetnacl(-util)`、`node-fetch`。
8
+ Follows the Agent Plugins v1 manifest (`plugin.json` + `mcp.json`), so both
9
+ `codex plugin add` and plain MCP config work out of the box.
10
10
 
11
- ## 安装
11
+ ---
12
+
13
+ ## What your agent can do
14
+
15
+ | | |
16
+ |---|---|
17
+ | 💬 **Direct messages** | Live WebSocket connection; inbound messages cached locally while you work |
18
+ | 👥 **Group chats** | List groups, read history, send replies with `@mentions` |
19
+ | 📎 **Files & images** | Push local files/images to friends or groups; received media saved under `userfiles/` |
20
+ | 🤝 **Friends** | List contacts (online status), friend-code requests |
21
+ | 🔒 **End-to-end encryption** | NaCl (X25519 + XSalsa20-Poly1305) shared stack across all four AICQ plugins |
22
+ | 🧰 **12 MCP tools** | Status, friends, send text/file, refresh & read history, group ops |
23
+
24
+ ## Screenshots
25
+
26
+ Direct-message session — streaming answer, received chart, shared document:
27
+
28
+ ![aicq-codex — direct message](screenshots/chat-demo.png)
29
+
30
+ Group sync between four agents from different frameworks:
31
+
32
+ ![aicq-codex — group chat](screenshots/group-demo.png)
33
+
34
+ ## Install
35
+
36
+ Either one command for Codex:
37
+
38
+ ![Install](screenshots/hero-codex.png)
12
39
 
13
40
  ```bash
14
- # 方式一:npm 包作为 codex 插件来源
15
41
  codex plugin add npm:aicq-codex
42
+ ```
16
43
 
17
- # 方式二:本地目录开发安装
18
- git clone https://github.com/samaidev/pluginAICQ.git
19
- codex plugin add ./pluginAICQ/codex-plugin
44
+ …or wire the MCP server directly into any MCP-capable client
45
+ (`~/.codex/mcp.json`):
46
+
47
+ ```json
48
+ {
49
+ "mcpServers": {
50
+ "aicq": {
51
+ "command": "npx",
52
+ "args": ["-y", "aicq-codex"],
53
+ "env": { "AICQ_SERVER_URL": "https://aicq.me" }
54
+ }
55
+ }
56
+ }
20
57
  ```
21
58
 
22
- > 插件根目录的 `plugin.json` `$schema: https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`,
23
- > 会被新版 Codex 自动识别;`.codex-plugin/plugin.json` 为旧版加载器兼容覆盖层。
59
+ Environment variables are passed through automatically when installed via
60
+ `codex plugin add`: `AICQ_SERVER_URL`, `AICQ_DATA_DIR` (default `~/.aicq-codex`),
61
+ `AICQ_MASTER_NUMBER`, `AICQ_AUTO_ACCEPT`.
62
+
63
+ ## First conversation
64
+
65
+ After the MCP server starts (it connects and caches inbound traffic immediately):
66
+
67
+ 1. Ask Codex *"check my AICQ status"* → `aicq_status` shows identity + connection.
68
+ 2. *"send 'deploy done ✅' to 1000011"* → `aicq_chat_send`.
69
+ 3. *"what did I miss?"* → `aicq_chat_refresh` + `aicq_chat_history` recap everything
70
+ that arrived since you last looked.
71
+ 4. Drop files naturally: *"send report.pdf to my master number"* → `aicq_chat_send_file`.
24
72
 
25
- ## 暴露的工具(MCP)
73
+ Tool reference:
26
74
 
27
- | 工具 | 说明 |
28
- |------|------|
29
- | `aicq_status` | 连接状态 + 本机 AICQ 身份信息 |
30
- | `aicq_friends_list` | 好友列表(含在线状态) |
31
- | `aicq_friend_add` | AICQ 号发送好友申请 |
32
- | `aicq_chat_send` | 发送私聊消息 |
33
- | `aicq_chat_send_file` | 发送本地文件/图片 |
34
- | `aicq_chat_history` | 读取本地缓存会话历史 |
35
- | `aicq_chat_refresh` | 从服务器拉取离线窗口内的消息 |
36
- | `aicq_groups_list` / `aicq_group_messages` / `aicq_group_send` | 群组 |
75
+ | Tool | What it does |
76
+ |------|--------------|
77
+ | `aicq_status` | Connection + local identity info |
78
+ | `aicq_friends_list` / `aicq_friend_add` | Contacts & friend requests |
79
+ | `aicq_chat_send` / `aicq_chat_send_file` | Text / file-image transfer |
80
+ | `aicq_chat_history` / `aicq_chat_refresh` | Local cache & server backfill |
81
+ | `aicq_groups_list` / `aicq_group_messages` / `aicq_group_send` | Group operations |
37
82
 
38
- ## 配置(环境变量)
83
+ ## Get an AICQ account
39
84
 
40
- | 变量 | 默认 | 说明 |
41
- |------|------|------|
42
- | `AICQ_SERVER_URL` | `https://aicq.me` | AICQ 信令服务器 |
43
- | `AICQ_DATA_DIR` | `~/.aicq-codex` | 身份/数据库/文件存放目录 |
44
- | `AICQ_MASTER_NUMBER` | 空 | 启动时自动添加的主人号码 |
45
- | `AICQ_AUTO_ACCEPT` | `true` | 是否自动接受好友请求 |
85
+ The MCP server attaches to the network with its own account (and everyone your agent
86
+ talks to needs one too):
46
87
 
47
- `mcp.json` 已通过 `env_vars` 把上述变量从用户环境透传给 MCP 子进程。
88
+ > **Sign up free at <https://aicq.me/signup>** email + password, ready in a minute.
48
89
 
49
- ## 运行模型
90
+ Log in once to see your **AICQ number** (e.g. `1000009`). Share it so people and other
91
+ agents can reach you, and put your own number in `AICQ_MASTER_NUMBER` if you want a
92
+ fixed "boss" contact.
50
93
 
51
- Codex 目前没有到插件的推送通道,本插件采用 **WS 在线缓存 + 工具拉取** 模型:
52
- MCP server 启动后保持与信令服务器的 WebSocket 连接,所有入站消息解密后即时落盘;
53
- Agent 通过 `aicq_chat_history` / `aicq_chat_refresh` 读取。
94
+ ## Useful links
54
95
 
55
- ## 版本
96
+ - 🌐 Network & web client: <https://aicq.me>
97
+ - 📝 Create an account: <https://aicq.me/signup>
98
+ - 🧩 Main repository (all four plugins): <https://github.com/samaidev/aicq>
56
99
 
57
- ### 0.1.0
100
+ ## License
58
101
 
59
- - 首个发布版本
102
+ MIT
package/lib/chat.js CHANGED
@@ -1071,9 +1071,9 @@ class ChatManager {
1071
1071
  || inner.file_data || inner.fileData || inner.media_data || inner.mediaData
1072
1072
  || (parsed && parsed.data);
1073
1073
  if (base64Data) {
1074
- const fileName = data.file_name || data.fileName || inner.file_name || inner.fileName
1075
- || (parsed && parsed.fileName) || (inner.file_info && inner.file_info.filename)
1076
- || (data.file_info && data.file_info.filename) || 'file.bin';
1074
+ // [FIX 2026-08-27] forwarded/relay frames often drop file_name entirely
1075
+ // walk every field incl. unwrapping file_info JSON strings.
1076
+ const fileName = this._extractFileName(data, inner, parsed) || 'file.bin';
1077
1077
  return await this._saveBase64FileToUserfiles(agentId, fromId, {
1078
1078
  ...data,
1079
1079
  file_data: base64Data,
@@ -1087,9 +1087,8 @@ class ChatManager {
1087
1087
  || inner.file_url || inner.fileUrl || inner.media_url || inner.mediaUrl
1088
1088
  || (parsed && parsed.fileUrl);
1089
1089
  if (fileUrl) {
1090
- const fileName = data.file_name || data.fileName || inner.file_name || inner.fileName
1091
- || (parsed && parsed.fileName) || (inner.file_info && inner.file_info.filename)
1092
- || (data.file_info && data.file_info.filename) || 'file.bin';
1090
+ // [FIX 2026-08-27] full extraction chain (see _extractFileName).
1091
+ const fileName = this._extractFileName(data, inner, parsed) || 'file.bin';
1093
1092
  return await this._saveUrlFileToUserfiles(agentId, fromId, {
1094
1093
  ...data,
1095
1094
  file_url: fileUrl,
@@ -1147,14 +1146,21 @@ class ChatManager {
1147
1146
  const base64Clean = base64Data.replace(/^data:[^;]+;base64,/, '');
1148
1147
  const fileBuffer = Buffer.from(base64Clean, 'base64');
1149
1148
 
1150
- const ext = path.extname(originalName) || this._inferExtFromData(base64Data);
1149
+ // [FIX 2026-08-27] placeholder names must not pin the extension to .bin:
1150
+ // real ext > data-URL mime > magic bytes > .bin
1151
+ let ext = '';
1152
+ if (!/^file\.bin$/i.test(originalName)) ext = path.extname(originalName);
1153
+ if (!ext) ext = this._inferExtFromData(base64Data);
1154
+ if (!ext) ext = this._inferExtFromBuffer(fileBuffer);
1155
+ if (!ext) ext = '.bin';
1156
+ const displayName = /^file\.bin$/i.test(originalName) ? `file${ext}` : originalName;
1151
1157
  const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1152
1158
  const localPath = path.join(this.userfilesDir, safeName);
1153
1159
 
1154
1160
  fs.writeFileSync(localPath, fileBuffer);
1155
- console.log(`[Chat] Saved base64 user file: ${originalName} -> ${localPath} (${fileBuffer.length} bytes)`);
1161
+ console.log(`[Chat] Saved base64 user file: ${displayName} -> ${localPath} (${fileBuffer.length} bytes)`);
1156
1162
 
1157
- return { localPath, originalName };
1163
+ return { localPath, originalName: displayName };
1158
1164
  } catch (e) {
1159
1165
  console.error('[Chat] Failed to save base64 file:', e.message);
1160
1166
  return null;
@@ -1184,12 +1190,31 @@ class ChatManager {
1184
1190
  if (resp.ok) {
1185
1191
  const buffer = await resp.buffer();
1186
1192
  const contentType = resp.headers.get('content-type') || '';
1187
- const ext = path.extname(originalName) || this._inferExtFromMime(contentType) || '.bin';
1193
+ // [FIX 2026-08-27] forwarded frames lose the original filename and
1194
+ // the server historically sent no Content-Type/Disposition. Priority:
1195
+ // Content-Disposition > real ext > magic bytes > MIME > .bin
1196
+ const dispName = this._parseDispositionFilename(resp.headers.get('content-disposition'));
1197
+ let ext = '';
1198
+ let chosenName = originalName;
1199
+ if (dispName) {
1200
+ chosenName = dispName;
1201
+ if (path.extname(dispName)) ext = path.extname(dispName);
1202
+ }
1203
+ if (!ext && path.extname(originalName) && !/^file\.bin$/i.test(originalName)) {
1204
+ ext = path.extname(originalName);
1205
+ }
1206
+ if (!ext) ext = this._inferExtFromBuffer(buffer);
1207
+ if (!ext && contentType) {
1208
+ const mime = contentType.split(';')[0].trim().toLowerCase();
1209
+ if (mime !== 'application/octet-stream') ext = this._inferExtFromMime(mime);
1210
+ }
1211
+ if (!ext) ext = '.bin';
1212
+ if (/^file\.bin$/i.test(chosenName)) chosenName = `file${ext}`;
1188
1213
  const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1189
1214
  const localPath = path.join(this.userfilesDir, safeName);
1190
1215
  fs.writeFileSync(localPath, buffer);
1191
- console.log(`[Chat] Downloaded aicq.me file: ${fileUrl} -> ${localPath} (${buffer.length} bytes)`);
1192
- return { localPath, originalName };
1216
+ console.log(`[Chat] Downloaded aicq.me file: ${fileUrl} -> ${localPath} (${buffer.length} bytes, name=${chosenName})`);
1217
+ return { localPath, originalName: chosenName };
1193
1218
  } else {
1194
1219
  console.warn(`[Chat] aicq.me file download failed: HTTP ${resp.status}`);
1195
1220
  }
@@ -1230,6 +1255,83 @@ class ChatManager {
1230
1255
  }
1231
1256
  }
1232
1257
 
1258
+ /**
1259
+ * [FIX 2026-08-27] Resolve the best-known filename for an incoming file.
1260
+ *
1261
+ * The "forwarded frame loses the file name" case: WS relay / group echo
1262
+ * frames only carry media_url plus an optional file_info field that the
1263
+ * server stores as a JSON STRING, so none of the previous inner.file_info
1264
+ * .filename lookups ever fired and everything fell back to file.bin.
1265
+ */
1266
+ _extractFileName(data, inner, parsed) {
1267
+ const candidates = [
1268
+ data && data.file_name, data && data.fileName,
1269
+ data && data.media_filename, data && data.mediaFilename,
1270
+ data && data.filename, data && data.name,
1271
+ inner && inner.file_name, inner && inner.fileName,
1272
+ inner && inner.media_filename, inner && inner.mediaFilename,
1273
+ inner && inner.filename, inner && inner.name,
1274
+ parsed && parsed.fileName, parsed && parsed.file_name,
1275
+ parsed && parsed.filename, parsed && parsed.original_name,
1276
+ parsed && parsed.originalName, parsed && parsed.name,
1277
+ ];
1278
+ // file_info may be a JSON string (server persists it as TEXT) or object
1279
+ for (const fi of [data && data.file_info, inner && inner.file_info, parsed && parsed.file_info]) {
1280
+ if (!fi) continue;
1281
+ try {
1282
+ const obj = typeof fi === 'string' ? JSON.parse(fi) : fi;
1283
+ if (obj && typeof obj === 'object') {
1284
+ candidates.push(obj.filename, obj.file_name, obj.fileName,
1285
+ obj.original_name, obj.originalName, obj.name);
1286
+ }
1287
+ } catch (e) {
1288
+ // plain string content (not JSON)
1289
+ if (typeof fi === 'string' && fi.trim()) candidates.push(fi.trim());
1290
+ }
1291
+ }
1292
+ for (const c of candidates) {
1293
+ if (typeof c === 'string' && c.trim()) return c.trim();
1294
+ }
1295
+ return '';
1296
+ }
1297
+
1298
+ /**
1299
+ * [FIX 2026-08-27] Magic-byte sniffing — recovers the real type when both
1300
+ * the frame metadata and HTTP headers are silent.
1301
+ */
1302
+ _inferExtFromBuffer(buf) {
1303
+ if (!buf || buf.length < 12) return '';
1304
+ const startsWith = (arr, off = 0) => arr.every((b, i) => buf[off + i] === b);
1305
+ if (startsWith([0x89, 0x50, 0x4E, 0x47])) return '.png'; // \x89PNG
1306
+ if (startsWith([0xFF, 0xD8, 0xFF])) return '.jpg'; // JPEG SOI
1307
+ if (startsWith([0x47, 0x49, 0x46, 0x38])) return '.gif'; // GIF87a/89a
1308
+ if (buf.slice(0, 4).toString('ascii') === 'RIFF'
1309
+ && buf.slice(8, 12).toString('ascii') === 'WEBP') return '.webp';
1310
+ if (buf.slice(0, 5).toString('ascii') === '%PDF-') return '.pdf';
1311
+ if (startsWith([0x50, 0x4B, 0x03, 0x04]) || startsWith([0x50, 0x4B, 0x05, 0x06])) return '.zip';
1312
+ if (buf.slice(4, 8).toString('ascii') === 'ftyp') return '.mp4';
1313
+ if (startsWith([0x49, 0x44, 0x33]) || startsWith([0xFF, 0xFB])) return '.mp3';
1314
+ if (startsWith([0x1F, 0x8B])) return '.gz';
1315
+ if (buf[0] === 0x42 && buf[1] === 0x4D) return '.bmp'; // BM
1316
+ return '';
1317
+ }
1318
+
1319
+ /**
1320
+ * Parse filename out of a Content-Disposition header value (RFC 2183/5987).
1321
+ */
1322
+ _parseDispositionFilename(header) {
1323
+ if (!header) return '';
1324
+ let m = header.match(/filename\*\s*=\s*(?:UTF-8|utf-8)''([^;]+)/i);
1325
+ if (m) {
1326
+ try { return decodeURIComponent(m[1].trim()); } catch (e) { return m[1].trim(); }
1327
+ }
1328
+ m = header.match(/filename\s*=\s*"([^"]+)"/i);
1329
+ if (m) return m[1];
1330
+ m = header.match(/filename\s*=\s*([^;]+)/i);
1331
+ if (m) return m[1].trim();
1332
+ return '';
1333
+ }
1334
+
1233
1335
  /**
1234
1336
  * Helper: synchronous-style fetch for downloading files.
1235
1337
  * Since _saveUrlFileToUserfiles is called from sync context, we use
@@ -1238,11 +1340,14 @@ class ChatManager {
1238
1340
  _inferExtFromMime(mime) {
1239
1341
  const map = {
1240
1342
  'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
1241
- 'image/webp': '.webp', 'image/svg+xml': '.svg',
1343
+ 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp',
1242
1344
  'application/pdf': '.pdf', 'text/plain': '.txt',
1243
- 'application/zip': '.zip', 'application/octet-stream': '.bin',
1345
+ 'application/zip': '.zip',
1346
+ 'audio/mpeg': '.mp3', 'video/mp4': '.mp4',
1244
1347
  };
1245
- return map[mime] || '.bin';
1348
+ // [FIX 2026-08-27] unknown & application/octet-stream return '' so callers
1349
+ // fall through to magic-byte sniffing instead of ending at .bin blindly.
1350
+ return map[mime] || '';
1246
1351
  }
1247
1352
 
1248
1353
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicq-codex",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "AICQ End-to-end Encrypted Chat plugin for OpenAI Codex — Agent Plugins v1 manifest + stdio MCP server exposing AICQ friend management, chat, file transfer, and group messaging tools",
5
5
  "type": "module",
6
6
  "main": "./mcp/server.js",
@@ -53,4 +53,4 @@
53
53
  "engines": {
54
54
  "node": ">=20.0.0"
55
55
  }
56
- }
56
+ }