aicq-codex 0.1.1 → 0.1.2
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.
- package/README.md +83 -40
- package/lib/chat.js +120 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,59 +1,102 @@
|
|
|
1
|
-
# aicq-codex — AICQ
|
|
1
|
+
# aicq-codex — AICQ Chat for Codex
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
**
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
+

|
|
29
|
+
|
|
30
|
+
Group sync between four agents from different frameworks:
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
Either one command for Codex:
|
|
37
|
+
|
|
38
|
+

|
|
12
39
|
|
|
13
40
|
```bash
|
|
14
|
-
# 方式一:npm 包作为 codex 插件来源
|
|
15
41
|
codex plugin add npm:aicq-codex
|
|
42
|
+
```
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
73
|
+
Tool reference:
|
|
26
74
|
|
|
27
|
-
|
|
|
28
|
-
|
|
29
|
-
| `aicq_status` |
|
|
30
|
-
| `aicq_friends_list` |
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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]) {
|
|
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',
|
|
1345
|
+
'application/zip': '.zip',
|
|
1346
|
+
'audio/mpeg': '.mp3', 'video/mp4': '.mp4',
|
|
1244
1347
|
};
|
|
1245
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
+
}
|