aicq-chat-plugin 2.1.1 → 2.3.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.
@@ -94,7 +94,7 @@ console.log(`[AICQ] Starting plugin on port ${port}`);
94
94
  console.log(`[AICQ] Server: ${serverUrl}`);
95
95
 
96
96
  const env = { ...process.env, AICQ_PORT: port, AICQ_SERVER_URL: serverUrl };
97
- const child = spawn('node', [path.join(__dirname, '..', 'index.js')], {
97
+ const child = spawn('node', [path.join(__dirname, 'index.js')], {
98
98
  env,
99
99
  stdio: 'inherit',
100
100
  detached: false
package/index.js CHANGED
@@ -426,6 +426,88 @@ app.post('/api/identity/rotate-keys', (req, res) => {
426
426
  }
427
427
  });
428
428
 
429
+ // Avatar upload
430
+ const avatarUpload = multer({
431
+ storage: multer.memoryStorage(),
432
+ limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
433
+ fileFilter: (req, file, cb) => {
434
+ if (file.mimetype && file.mimetype.startsWith('image/')) {
435
+ cb(null, true);
436
+ } else {
437
+ cb(new Error('Only image files are allowed'));
438
+ }
439
+ },
440
+ });
441
+
442
+ app.post('/api/identity/avatar', avatarUpload.single('avatar'), async (req, res) => {
443
+ try {
444
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
445
+ const agentId = req.body.agent_id || currentAgentId;
446
+
447
+ // Save avatar locally
448
+ const avatarsDir = path.join(DATA_DIR, 'avatars');
449
+ fs.mkdirSync(avatarsDir, { recursive: true });
450
+ const ext = req.file.mimetype.split('/')[1] || 'png';
451
+ const avatarId = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
452
+ const filename = `${avatarId}.${ext}`;
453
+ const filePath = path.join(avatarsDir, filename);
454
+ fs.writeFileSync(filePath, req.file.buffer);
455
+
456
+ // Update local identity
457
+ const avatarUrl = `/api/identity/avatars/${filename}`;
458
+ identity.updateAvatar(agentId, avatarUrl);
459
+
460
+ // Try to upload to server too
461
+ try {
462
+ await serverClient.ensureAuth(agentId);
463
+ const FormData = (await import('form-data')).default;
464
+ const form = new FormData();
465
+ form.append('avatar', req.file.buffer, {
466
+ filename: req.file.originalname || 'avatar.' + ext,
467
+ contentType: req.file.mimetype,
468
+ });
469
+ // Use node-fetch to upload to server
470
+ const fetch = (await import('node-fetch')).default;
471
+ const serverUrl = SERVER_URL + '/api/v1/accounts/avatar';
472
+ const serverResp = await fetch(serverUrl, {
473
+ method: 'POST',
474
+ body: form,
475
+ headers: {
476
+ ...form.getHeaders(),
477
+ 'Authorization': 'Bearer ' + serverClient.getAccessToken(agentId),
478
+ },
479
+ });
480
+ if (serverResp.ok) {
481
+ const serverData = await serverResp.json();
482
+ if (serverData.avatar) {
483
+ identity.updateAvatar(agentId, serverData.avatar);
484
+ return res.json({ success: true, avatar: serverData.avatar });
485
+ }
486
+ }
487
+ } catch (e) {
488
+ console.error('[AICQ] Server avatar upload failed:', e.message);
489
+ }
490
+
491
+ res.json({ success: true, avatar: avatarUrl });
492
+ } catch (e) {
493
+ res.status(500).json({ error: e.message });
494
+ }
495
+ });
496
+
497
+ app.get('/api/identity/avatars/:filename', (req, res) => {
498
+ const filename = req.params.filename;
499
+ // Security: prevent directory traversal
500
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
501
+ return res.status(400).json({ error: 'Invalid filename' });
502
+ }
503
+ const filePath = path.join(DATA_DIR, 'avatars', filename);
504
+ if (fs.existsSync(filePath)) {
505
+ res.sendFile(filePath);
506
+ } else {
507
+ res.status(404).json({ error: 'Avatar not found' });
508
+ }
509
+ });
510
+
429
511
  app.get('/api/identity/keys', (req, res) => {
430
512
  const agentId = getAgentId(req);
431
513
  const info = identity.loadAgent(agentId);
package/lib/database.js CHANGED
@@ -24,6 +24,7 @@ class PluginDatabase {
24
24
  CREATE TABLE IF NOT EXISTS identity (
25
25
  agent_id TEXT PRIMARY KEY,
26
26
  nickname TEXT NOT NULL DEFAULT '',
27
+ avatar TEXT NOT NULL DEFAULT '',
27
28
  signing_public_key TEXT NOT NULL,
28
29
  signing_secret_key TEXT NOT NULL,
29
30
  exchange_public_key TEXT NOT NULL,
@@ -150,6 +151,21 @@ class PluginDatabase {
150
151
  this.db.prepare('UPDATE identity SET nickname = ?, updated_at = ? WHERE agent_id = ?').run(nickname, now, agentId);
151
152
  }
152
153
 
154
+ updateAvatar(agentId, avatarUrl) {
155
+ const now = new Date().toISOString();
156
+ // Ensure column exists (migration for existing databases)
157
+ try {
158
+ this.db.prepare('UPDATE identity SET avatar = ?, updated_at = ? WHERE agent_id = ?').run(avatarUrl, now, agentId);
159
+ } catch (e) {
160
+ if (e.message.includes('no column named avatar')) {
161
+ this.db.exec('ALTER TABLE identity ADD COLUMN avatar TEXT NOT NULL DEFAULT ""');
162
+ this.db.prepare('UPDATE identity SET avatar = ?, updated_at = ? WHERE agent_id = ?').run(avatarUrl, now, agentId);
163
+ } else {
164
+ throw e;
165
+ }
166
+ }
167
+ }
168
+
153
169
  // ─── Friends ───────────────────────────────────────────────────────
154
170
 
155
171
  addFriend({ agent_id, id, public_key, fingerprint, friend_type = 'ai', ai_name = '', permissions = ['chat'] }) {
package/lib/identity.js CHANGED
@@ -135,6 +135,16 @@ class IdentityManager {
135
135
  return this._cache[agentId];
136
136
  }
137
137
 
138
+ /**
139
+ * Update agent avatar
140
+ */
141
+ updateAvatar(agentId, avatarUrl) {
142
+ this.db.updateAvatar(agentId, avatarUrl);
143
+ if (this._cache[agentId]) {
144
+ this._cache[agentId].avatar = avatarUrl;
145
+ }
146
+ }
147
+
138
148
  /**
139
149
  * Get identity info (public keys only, no secrets)
140
150
  */
@@ -144,6 +154,7 @@ class IdentityManager {
144
154
  return {
145
155
  agent_id: identity.agent_id,
146
156
  nickname: identity.nickname,
157
+ avatar: identity.avatar || null,
147
158
  signing_public_key: identity.signing_public_key,
148
159
  exchange_public_key: identity.exchange_public_key,
149
160
  fingerprint: identity.fingerprint,
@@ -317,6 +317,13 @@ class ServerClient {
317
317
  this._running = false;
318
318
  this.disconnect();
319
319
  }
320
+
321
+ /**
322
+ * Get the current JWT access token for a given agent
323
+ */
324
+ getAccessToken(agentId) {
325
+ return this.jwtToken || '';
326
+ }
320
327
  }
321
328
 
322
329
  module.exports = ServerClient;
package/package.json CHANGED
@@ -1,14 +1,15 @@
1
1
  {
2
2
  "name": "aicq-chat-plugin",
3
- "version": "2.1.1",
3
+ "version": "2.3.0",
4
4
  "description": "AICQ End-to-end Encrypted Chat Plugin for OpenClaw — Full UI with friend management, group chat, file transfer, and AI agent communication",
5
5
  "main": "index.js",
6
6
  "bin": {
7
- "aicq-plugin": "./bin/aicq-plugin.js"
7
+ "aicq-plugin": "cli.js"
8
8
  },
9
9
  "files": [
10
10
  "index.js",
11
- "bin/",
11
+ "cli.js",
12
+ "postinstall.js",
12
13
  "lib/",
13
14
  "public/",
14
15
  "openclaw.plugin.json",
@@ -16,7 +17,7 @@
16
17
  ],
17
18
  "scripts": {
18
19
  "start": "node index.js",
19
- "postinstall": "node bin/postinstall.js",
20
+ "postinstall": "node postinstall.js",
20
21
  "install-deps": "npm install"
21
22
  },
22
23
  "keywords": [
@@ -30,11 +31,11 @@
30
31
  "messaging",
31
32
  "p2p"
32
33
  ],
33
- "author": "朱东山",
34
+ "author": "ctz168",
34
35
  "license": "MIT",
35
36
  "repository": {
36
37
  "type": "git",
37
- "url": "https://github.com/ctz168/aicq.git",
38
+ "url": "git+https://github.com/ctz168/aicq.git",
38
39
  "directory": "plugin-js"
39
40
  },
40
41
  "bugs": {
package/public/index.html CHANGED
@@ -29,7 +29,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
29
29
  .friend-item,.group-item{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;transition:background .15s;border-left:3px solid transparent}
30
30
  .friend-item:hover,.group-item:hover{background:var(--bg3)}
31
31
  .friend-item.active,.group-item.active{background:rgba(79,70,229,.2);border-left-color:var(--primary)}
32
- .avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff}
32
+ .avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff;overflow:hidden}
33
+ .avatar img{width:100%;height:100%;object-fit:cover;border-radius:50%}
33
34
  .avatar.online{box-shadow:0 0 0 2px var(--success)}
34
35
  .info{flex:1;min-width:0}
35
36
  .info .name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -225,6 +226,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
225
226
  <div class="modal">
226
227
  <h3>设置</h3>
227
228
  <div style="display:flex;flex-direction:column;gap:12px">
229
+ <div style="text-align:center;padding:8px 0">
230
+ <div style="position:relative;display:inline-block">
231
+ <div class="avatar" id="settingsAvatar" style="width:64px;height:64px;font-size:24px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">A</div>
232
+ <div style="position:absolute;bottom:-2px;right:-2px;width:20px;height:20px;background:var(--primary);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">📷</div>
233
+ </div>
234
+ <input type="file" id="avatarUploadInput" accept="image/*" style="display:none" onchange="handlePluginAvatarUpload(this)">
235
+ <div style="font-size:11px;color:var(--text2);margin-top:4px">点击更换头像</div>
236
+ </div>
228
237
  <button class="btn btn-secondary" onclick="showQRCode()" style="width:100%;text-align:left">📱 二维码 — 客户端扫描添加好友</button>
229
238
  <button class="btn btn-secondary" onclick="showNicknameModal()" style="width:100%;text-align:left">✏️ 修改昵称</button>
230
239
  <button class="btn btn-secondary" onclick="generateFriendCode()" style="width:100%;text-align:left">🔢 生成好友码 (24小时有效)</button>
@@ -426,7 +435,7 @@ async function loadFriends() {
426
435
  div.className = 'friend-item' + (currentTarget?.id === f.id && currentTarget?.type === 'friend' ? ' active' : '');
427
436
  div.onclick = () => selectTarget(f.id, f.ai_name || f.fingerprint?.slice(0,8) || f.id.slice(0,8), 'friend', !!f.is_online);
428
437
  div.innerHTML = `
429
- <div class="avatar ${f.is_online ? 'online' : ''}">${(f.ai_name||f.id).charAt(0).toUpperCase()}</div>
438
+ <div class="avatar ${f.is_online ? 'online' : ''}" ${f.ai_avatar ? 'style="background:none"' : ''}>${f.ai_avatar ? `<img src="${f.ai_avatar}" alt="">` : (f.ai_name||f.id).charAt(0).toUpperCase()}</div>
430
439
  <div class="info">
431
440
  <div class="name">${f.ai_name || f.fingerprint?.slice(0,16) || f.id.slice(0,12)}</div>
432
441
  <div class="status"><span class="${f.is_online ? 'badge-online' : 'badge-offline'}">●</span> ${f.is_online ? '在线' : '离线'}</div>
@@ -462,7 +471,29 @@ async function selectTarget(id, name, type, isOnline = false, silent = false) {
462
471
  showChatView();
463
472
 
464
473
  // Update header
465
- document.getElementById('chatAvatar').textContent = type === 'group' ? '👥' : name.charAt(0).toUpperCase();
474
+ const chatAvatar = document.getElementById('chatAvatar');
475
+ // Try to get avatar from friend data
476
+ let friendAvatar = null;
477
+ if (type === 'friend') {
478
+ const friends = document.querySelectorAll('.friend-item');
479
+ // We'll use the currentTarget data
480
+ }
481
+ if (type === 'group') {
482
+ chatAvatar.innerHTML = '👥';
483
+ } else {
484
+ const name = currentTarget?.name || id;
485
+ chatAvatar.textContent = name.charAt(0).toUpperCase();
486
+ chatAvatar.style.background = '';
487
+ }
488
+ // If friend has avatar, try to find it in loaded friends
489
+ try {
490
+ const friendData = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
491
+ const friend = (friendData.friends || []).find(f => f.id === id);
492
+ if (friend && friend.ai_avatar) {
493
+ chatAvatar.innerHTML = `<img src="${friend.ai_avatar}" alt="">`;
494
+ chatAvatar.style.background = 'none';
495
+ }
496
+ } catch(e) {}
466
497
  document.getElementById('chatName').textContent = name;
467
498
  document.getElementById('chatStatus').textContent = type === 'group' ? '群聊' : (isOnline ? '在线' : '离线');
468
499
  document.getElementById('silentBtn').textContent = silent ? '🔕' : '🔔';
@@ -769,7 +800,21 @@ async function toggleSilent() {
769
800
  }
770
801
 
771
802
  // ─── Modal Helpers ──────────────────────────────────────────────────
772
- function showModal(name) { document.getElementById('modal-' + name).classList.add('show'); }
803
+ function showModal(name) {
804
+ document.getElementById('modal-' + name).classList.add('show');
805
+ // Update settings avatar when opening settings modal
806
+ if (name === 'settings' && currentAgentId) {
807
+ api('GET', `/api/identity?agent_id=${currentAgentId}`).then(info => {
808
+ const avatarEl = document.getElementById('settingsAvatar');
809
+ if (info.avatar) {
810
+ avatarEl.innerHTML = `<img src="${info.avatar}" alt="头像">`;
811
+ avatarEl.style.background = 'none';
812
+ } else {
813
+ avatarEl.textContent = (info.nickname || 'A').charAt(0).toUpperCase();
814
+ }
815
+ }).catch(() => {});
816
+ }
817
+ }
773
818
  function hideModal(name) { document.getElementById('modal-' + name).classList.remove('show'); }
774
819
 
775
820
  // ─── Add Friend ─────────────────────────────────────────────────────
@@ -906,6 +951,34 @@ function showChatInfo() {
906
951
  : `好友: ${currentTarget.name}\nID: ${currentTarget.id}\n状态: ${currentTarget.isOnline ? '在线' : '离线'}`);
907
952
  }
908
953
 
954
+ // ─── Avatar Upload ──────────────────────────────────────────────────
955
+ async function handlePluginAvatarUpload(input) {
956
+ const file = input.files && input.files[0];
957
+ if (!file || !currentAgentId) return;
958
+ if (!file.type.startsWith('image/')) { alert('请选择图片文件'); return; }
959
+ if (file.size > 2 * 1024 * 1024) { alert('图片不能超过2MB'); return; }
960
+ try {
961
+ const formData = new FormData();
962
+ formData.append('avatar', file);
963
+ formData.append('agent_id', currentAgentId);
964
+ const resp = await fetch(API + '/api/identity/avatar', { method: 'POST', body: formData });
965
+ const data = await resp.json();
966
+ if (data.success || data.avatar) {
967
+ // Update the settings avatar display
968
+ const avatarUrl = data.avatar || (data.account && data.account.avatar);
969
+ if (avatarUrl) {
970
+ document.getElementById('settingsAvatar').innerHTML = `<img src="${avatarUrl}" alt="头像">`;
971
+ }
972
+ alert('头像已更新');
973
+ } else {
974
+ alert('上传失败: ' + (data.error || '未知错误'));
975
+ }
976
+ } catch (e) {
977
+ alert('头像上传失败: ' + e.message);
978
+ }
979
+ input.value = '';
980
+ }
981
+
909
982
  // ─── Init ───────────────────────────────────────────────────────────
910
983
  init();
911
984
 
File without changes