aicq-chat-plugin 2.1.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.
- package/README.md +74 -0
- package/bin/aicq-plugin.js +118 -0
- package/bin/postinstall.js +27 -0
- package/index.js +499 -0
- package/lib/chat.js +244 -0
- package/lib/crypto.js +156 -0
- package/lib/database.js +319 -0
- package/lib/file-transfer.js +266 -0
- package/lib/handshake.js +147 -0
- package/lib/identity.js +154 -0
- package/lib/server-client.js +322 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +58 -0
- package/public/index.html +921 -0
package/lib/chat.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AICQ Chat Manager — Send/receive messages, group chat, file handling
|
|
3
|
+
*/
|
|
4
|
+
const { encryptMessage, decryptMessage } = require('./crypto');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
class ChatManager {
|
|
10
|
+
constructor(identityManager, serverClient, db, uploadsDir) {
|
|
11
|
+
this.identity = identityManager;
|
|
12
|
+
this.server = serverClient;
|
|
13
|
+
this.db = db;
|
|
14
|
+
this.uploadsDir = uploadsDir;
|
|
15
|
+
this._onNewMessage = null;
|
|
16
|
+
|
|
17
|
+
// Listen for incoming messages via WS
|
|
18
|
+
this.server.onMessage('relay', (data) => this._handleIncoming(data));
|
|
19
|
+
this.server.onMessage('message', (data) => this._handleIncoming(data));
|
|
20
|
+
this.server.onMessage('group_message', (data) => this._handleGroupIncoming(data));
|
|
21
|
+
this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
|
|
22
|
+
this.server.onMessage('presence', (data) => this._handlePresence(data));
|
|
23
|
+
this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setOnNewMessage(callback) {
|
|
27
|
+
this._onNewMessage = callback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Send Messages ────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async sendMessage(agentId, targetId, content, { type = 'text', isGroup = false, mentions = [], file_url = null, file_name = null } = {}) {
|
|
33
|
+
const identity = this.identity.loadAgent(agentId);
|
|
34
|
+
|
|
35
|
+
if (isGroup) {
|
|
36
|
+
// Group message via WebSocket
|
|
37
|
+
const sent = this.server.sendWS({
|
|
38
|
+
type: 'group_message',
|
|
39
|
+
groupId: targetId,
|
|
40
|
+
content,
|
|
41
|
+
msgType: type,
|
|
42
|
+
mentions,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Save locally
|
|
46
|
+
const msg = this.db.saveMessage({
|
|
47
|
+
agent_id: agentId,
|
|
48
|
+
target_id: targetId,
|
|
49
|
+
from_id: agentId,
|
|
50
|
+
to_id: targetId,
|
|
51
|
+
type,
|
|
52
|
+
content,
|
|
53
|
+
file_url,
|
|
54
|
+
file_name,
|
|
55
|
+
is_group: 1,
|
|
56
|
+
mentions,
|
|
57
|
+
status: sent ? 'sent' : 'pending',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (this._onNewMessage) this._onNewMessage(msg);
|
|
61
|
+
return msg;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Direct message
|
|
65
|
+
// Try to encrypt if we have a session key
|
|
66
|
+
const session = this.db.loadSession(agentId, targetId);
|
|
67
|
+
let payload = content;
|
|
68
|
+
if (session && session.session_key) {
|
|
69
|
+
try {
|
|
70
|
+
payload = encryptMessage(content, session.session_key);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
console.error('[Chat] Encryption failed, sending plaintext:', e.message);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Send via WebSocket relay
|
|
77
|
+
const sent = this.server.sendWS({
|
|
78
|
+
type: 'relay',
|
|
79
|
+
targetId: targetId,
|
|
80
|
+
payload,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Also try REST fallback
|
|
84
|
+
if (!sent) {
|
|
85
|
+
try {
|
|
86
|
+
await this.server._request('POST', '/messages/send', {
|
|
87
|
+
targetId,
|
|
88
|
+
payload,
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
// Queue offline
|
|
92
|
+
this.db.enqueueOffline({
|
|
93
|
+
agent_id: agentId,
|
|
94
|
+
target_id: targetId,
|
|
95
|
+
data: JSON.stringify({ type: 'relay', targetId, payload }),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Save locally
|
|
101
|
+
const msg = this.db.saveMessage({
|
|
102
|
+
agent_id: agentId,
|
|
103
|
+
target_id: targetId,
|
|
104
|
+
from_id: agentId,
|
|
105
|
+
to_id: targetId,
|
|
106
|
+
type,
|
|
107
|
+
content,
|
|
108
|
+
file_url,
|
|
109
|
+
file_name,
|
|
110
|
+
is_group: 0,
|
|
111
|
+
mentions,
|
|
112
|
+
status: sent ? 'sent' : 'pending',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Update session message count
|
|
116
|
+
if (session) {
|
|
117
|
+
this.db.incrementSessionMessageCount(agentId, targetId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (this._onNewMessage) this._onNewMessage(msg);
|
|
121
|
+
return msg;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Receive Messages ─────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
_handleIncoming(data) {
|
|
127
|
+
const agentId = this.server.currentAgentId;
|
|
128
|
+
if (!agentId) return;
|
|
129
|
+
|
|
130
|
+
const fromId = data.fromId || data.from;
|
|
131
|
+
let content = data.payload || data.data || '';
|
|
132
|
+
|
|
133
|
+
// Try to decrypt if we have a session key
|
|
134
|
+
const session = this.db.loadSession(agentId, fromId);
|
|
135
|
+
if (session && session.session_key && typeof content === 'string') {
|
|
136
|
+
try {
|
|
137
|
+
content = decryptMessage(content, session.session_key);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
// Might be plaintext, keep as is
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const msg = this.db.saveMessage({
|
|
144
|
+
agent_id: agentId,
|
|
145
|
+
target_id: fromId,
|
|
146
|
+
from_id: fromId,
|
|
147
|
+
to_id: agentId,
|
|
148
|
+
type: 'text',
|
|
149
|
+
content: typeof content === 'string' ? content : JSON.stringify(content),
|
|
150
|
+
is_group: 0,
|
|
151
|
+
status: 'delivered',
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (this._onNewMessage) this._onNewMessage(msg);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_handleGroupIncoming(data) {
|
|
158
|
+
const agentId = this.server.currentAgentId;
|
|
159
|
+
if (!agentId) return;
|
|
160
|
+
|
|
161
|
+
const fromId = data.fromId;
|
|
162
|
+
const groupId = data.groupId;
|
|
163
|
+
|
|
164
|
+
// Check silent mode
|
|
165
|
+
const silent = this.db.getGroupSilentMode(agentId, groupId);
|
|
166
|
+
const mentions = data.mentions || [];
|
|
167
|
+
const isMentioned = mentions.includes(agentId) || mentions.includes('all');
|
|
168
|
+
|
|
169
|
+
const msg = this.db.saveMessage({
|
|
170
|
+
agent_id: agentId,
|
|
171
|
+
target_id: groupId,
|
|
172
|
+
from_id: fromId,
|
|
173
|
+
to_id: groupId,
|
|
174
|
+
type: data.msgType || 'text',
|
|
175
|
+
content: data.content || '',
|
|
176
|
+
is_group: 1,
|
|
177
|
+
mentions,
|
|
178
|
+
status: (silent && !isMentioned) ? 'silent' : 'delivered',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (this._onNewMessage) this._onNewMessage(msg);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_handleHandshakeRequest(data) {
|
|
185
|
+
const agentId = this.server.currentAgentId;
|
|
186
|
+
if (!agentId) return;
|
|
187
|
+
|
|
188
|
+
this.db.savePendingRequest({
|
|
189
|
+
agent_id: agentId,
|
|
190
|
+
session_id: data.sessionId || crypto.randomUUID(),
|
|
191
|
+
requester_id: data.requesterId || data.from,
|
|
192
|
+
requester_public_key: data.requesterPublicKey || data.exchangePublicKey || '',
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_handlePresence(data) {
|
|
197
|
+
const agentId = this.server.currentAgentId;
|
|
198
|
+
if (!agentId) return;
|
|
199
|
+
|
|
200
|
+
const friendId = data.nodeId;
|
|
201
|
+
const isOnline = data.online === true || data.status === 'online';
|
|
202
|
+
this.db.updateFriendOnline(agentId, friendId, isOnline);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_handleFileChunk(data) {
|
|
206
|
+
// File chunk handling — assemble in uploads dir
|
|
207
|
+
// For now, just log
|
|
208
|
+
console.log('[Chat] File chunk from', data.from);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Chat History ─────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
|
|
214
|
+
return this.db.getChatHistory(agentId, targetId, { limit, before });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
deleteMessage(agentId, messageId) {
|
|
218
|
+
this.db.deleteMessage(agentId, messageId);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── File Upload ──────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
async handleFileUpload(agentId, targetId, file, isGroup = false) {
|
|
224
|
+
const fileId = crypto.randomUUID();
|
|
225
|
+
const ext = path.extname(file.originalname || '.bin');
|
|
226
|
+
const fileName = `${fileId}${ext}`;
|
|
227
|
+
const filePath = path.join(this.uploadsDir, fileName);
|
|
228
|
+
fs.writeFileSync(filePath, file.buffer);
|
|
229
|
+
|
|
230
|
+
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
|
|
231
|
+
|
|
232
|
+
// Send message with file reference
|
|
233
|
+
const msg = await this.sendMessage(agentId, targetId, isImage ? '[图片]' : `[文件] ${file.originalname}`, {
|
|
234
|
+
type: isImage ? 'image' : 'file',
|
|
235
|
+
isGroup,
|
|
236
|
+
file_url: `/api/files/${fileName}`,
|
|
237
|
+
file_name: file.originalname,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
return msg;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = ChatManager;
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AICQ Crypto Utilities
|
|
3
|
+
* NaCl-based E2EE: Ed25519 signing, X25519 key exchange, symmetric encryption
|
|
4
|
+
*/
|
|
5
|
+
const nacl = require('tweetnacl');
|
|
6
|
+
const naclUtil = require('tweetnacl-util');
|
|
7
|
+
|
|
8
|
+
// ─── Key Generation ────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
function generateSigningKeypair() {
|
|
11
|
+
const keyPair = nacl.sign.keyPair();
|
|
12
|
+
return {
|
|
13
|
+
publicKey: Buffer.from(keyPair.publicKey).toString('hex'),
|
|
14
|
+
secretKey: Buffer.from(keyPair.secretKey).toString('hex'),
|
|
15
|
+
publicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'),
|
|
16
|
+
secretKeyB64: Buffer.from(keyPair.secretKey).toString('base64'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function generateExchangeKeypair() {
|
|
21
|
+
const keyPair = nacl.box.keyPair();
|
|
22
|
+
return {
|
|
23
|
+
publicKey: Buffer.from(keyPair.publicKey).toString('hex'),
|
|
24
|
+
secretKey: Buffer.from(keyPair.secretKey).toString('hex'),
|
|
25
|
+
publicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'),
|
|
26
|
+
secretKeyB64: Buffer.from(keyPair.secretKey).toString('base64'),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Signing ───────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function signMessage(message, secretKeyHex) {
|
|
33
|
+
const secretKey = Buffer.from(secretKeyHex, 'hex');
|
|
34
|
+
const messageBytes = naclUtil.decodeUTF8(message);
|
|
35
|
+
const signature = nacl.sign.detached(messageBytes, secretKey);
|
|
36
|
+
return Buffer.from(signature).toString('hex');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function verifySignature(message, signatureHex, publicKeyHex) {
|
|
40
|
+
try {
|
|
41
|
+
const publicKey = Buffer.from(publicKeyHex, 'hex');
|
|
42
|
+
const messageBytes = naclUtil.decodeUTF8(message);
|
|
43
|
+
const signature = Buffer.from(signatureHex, 'hex');
|
|
44
|
+
return nacl.sign.detached.verify(messageBytes, signature, publicKey);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Key Exchange & Session Key Derivation ─────────────────────────────
|
|
51
|
+
|
|
52
|
+
function deriveSessionKey(ourSecretKeyHex, theirPublicKeyHex) {
|
|
53
|
+
const ourSecret = Buffer.from(ourSecretKeyHex, 'hex');
|
|
54
|
+
const theirPublic = Buffer.from(theirPublicKeyHex, 'hex');
|
|
55
|
+
const shared = nacl.box.before(theirPublic, ourSecret);
|
|
56
|
+
const hash = nacl.hash(shared);
|
|
57
|
+
return Buffer.from(hash).toString('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Symmetric Encryption (NaCl SecretBox) ─────────────────────────────
|
|
61
|
+
|
|
62
|
+
function encryptMessage(plaintext, sessionKeyB64) {
|
|
63
|
+
const key = Buffer.from(sessionKeyB64, 'base64');
|
|
64
|
+
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
65
|
+
const messageBytes = naclUtil.decodeUTF8(plaintext);
|
|
66
|
+
const encrypted = nacl.secretbox(messageBytes, nonce, key);
|
|
67
|
+
if (!encrypted) throw new Error('Encryption failed');
|
|
68
|
+
// Combine nonce + ciphertext
|
|
69
|
+
const combined = new Uint8Array(nonce.length + encrypted.length);
|
|
70
|
+
combined.set(nonce);
|
|
71
|
+
combined.set(encrypted, nonce.length);
|
|
72
|
+
return Buffer.from(combined).toString('base64');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function decryptMessage(ciphertextB64, sessionKeyB64) {
|
|
76
|
+
const key = Buffer.from(sessionKeyB64, 'base64');
|
|
77
|
+
const combined = Buffer.from(ciphertextB64, 'base64');
|
|
78
|
+
const nonce = combined.slice(0, nacl.secretbox.nonceLength);
|
|
79
|
+
const ciphertext = combined.slice(nacl.secretbox.nonceLength);
|
|
80
|
+
const decrypted = nacl.secretbox.open(ciphertext, nonce, key);
|
|
81
|
+
if (!decrypted) throw new Error('Decryption failed');
|
|
82
|
+
return naclUtil.encodeUTF8(decrypted);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Fingerprint ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function computeFingerprint(publicKeyHex) {
|
|
88
|
+
const publicKey = Buffer.from(publicKeyHex, 'hex');
|
|
89
|
+
const hash = nacl.hash(publicKey);
|
|
90
|
+
const hex = Buffer.from(hash).toString('hex');
|
|
91
|
+
return hex.match(/.{2}/g).join(':');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Password-based Encryption ─────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function encryptWithPassword(plaintext, password) {
|
|
97
|
+
const salt = nacl.randomBytes(nacl.pwhash.saltbytes);
|
|
98
|
+
const key = nacl.pwhash(plaintext.length + nacl.secretbox.overheadLength,
|
|
99
|
+
naclUtil.decodeUTF8(password), salt,
|
|
100
|
+
nacl.pwhash.opslimit.interactive,
|
|
101
|
+
nacl.pwhash.memlimit.interactive);
|
|
102
|
+
// Simplified: use secretbox with derived key
|
|
103
|
+
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength);
|
|
104
|
+
const messageBytes = naclUtil.decodeUTF8(plaintext);
|
|
105
|
+
// Fallback: just use a hash-based key
|
|
106
|
+
const keyHash = nacl.hash(naclUtil.decodeUTF8(password + Buffer.from(salt).toString('base64')));
|
|
107
|
+
const encrypted = nacl.secretbox(messageBytes, nonce, keyHash.slice(0, 32));
|
|
108
|
+
if (!encrypted) throw new Error('Password encryption failed');
|
|
109
|
+
const result = new Uint8Array(salt.length + nonce.length + encrypted.length);
|
|
110
|
+
result.set(salt);
|
|
111
|
+
result.set(nonce, salt.length);
|
|
112
|
+
result.set(encrypted, salt.length + nonce.length);
|
|
113
|
+
return Buffer.from(result).toString('base64');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function decryptWithPassword(ciphertextB64, password) {
|
|
117
|
+
const combined = Buffer.from(ciphertextB64, 'base64');
|
|
118
|
+
const salt = combined.slice(0, nacl.pwhash.saltbytes);
|
|
119
|
+
const nonce = combined.slice(nacl.pwhash.saltbytes, nacl.pwhash.saltbytes + nacl.secretbox.nonceLength);
|
|
120
|
+
const ciphertext = combined.slice(nacl.pwhash.saltbytes + nacl.secretbox.nonceLength);
|
|
121
|
+
const keyHash = nacl.hash(naclUtil.decodeUTF8(password + Buffer.from(salt).toString('base64')));
|
|
122
|
+
const decrypted = nacl.secretbox.open(ciphertext, nonce, keyHash.slice(0, 32));
|
|
123
|
+
if (!decrypted) throw new Error('Password decryption failed');
|
|
124
|
+
return naclUtil.encodeUTF8(decrypted);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Convert Ed25519 to X25519 ─────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function convertEd25519ToX25519(ed25519PublicKeyB64) {
|
|
130
|
+
const edPk = Buffer.from(ed25519PublicKeyB64, 'base64');
|
|
131
|
+
// NaCl provides convert for this
|
|
132
|
+
try {
|
|
133
|
+
const xPk = nacl.sign.keyPair.fromSeed(edPk.slice(0, 32)).publicKey;
|
|
134
|
+
// This is approximate - in production use proper conversion
|
|
135
|
+
return Buffer.from(xPk).toString('base64');
|
|
136
|
+
} catch(e) {
|
|
137
|
+
// Fallback: derive from hash
|
|
138
|
+
const hash = nacl.hash(edPk);
|
|
139
|
+
return Buffer.from(hash.slice(0, 32)).toString('base64');
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = {
|
|
144
|
+
generateSigningKeypair,
|
|
145
|
+
generateExchangeKeypair,
|
|
146
|
+
signMessage,
|
|
147
|
+
verifySignature,
|
|
148
|
+
deriveSessionKey,
|
|
149
|
+
encryptMessage,
|
|
150
|
+
decryptMessage,
|
|
151
|
+
computeFingerprint,
|
|
152
|
+
encryptWithPassword,
|
|
153
|
+
decryptWithPassword,
|
|
154
|
+
convertEd25519ToX25519,
|
|
155
|
+
randomBytes: (n) => Buffer.from(nacl.randomBytes(n)).toString('base64'),
|
|
156
|
+
};
|