aicq-chat-plugin 3.8.1 → 3.9.1

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/src/ui-routes.js CHANGED
@@ -1,594 +1,647 @@
1
- /**
2
- * AICQ Channel Plugin — Gateway HTTP Routes
3
- *
4
- * Provides HTTP route handlers for the OpenClaw Gateway.
5
- * These routes serve the SPA UI and REST API endpoints.
6
- *
7
- * Strategy: We create an Express sub-app with all routes (defined
8
- * relative to the /plugins/aicq-chat mount point), then register it
9
- * via api.registerHttpRoute(). The OpenClaw gateway runs Express
10
- * internally and uses app.use(path, handler), so the mount prefix
11
- * is stripped before the request reaches our sub-app.
12
- *
13
- * ESM module — imported by index.js registerFull() to register routes
14
- * on the OpenClaw plugin API.
15
- */
16
-
17
- import path from "path";
18
- import fs from "fs";
19
- import { fileURLToPath } from "url";
20
- import { createRequire } from "module";
21
-
22
- const require = createRequire(import.meta.url);
23
- const QRCode = require("qrcode");
24
- const multer = require("multer");
25
- const express = require("express");
26
-
27
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
-
29
- /**
30
- * Create and return the Express sub-app with all AICQ routes.
31
- *
32
- * Routes are defined RELATIVE to the /plugins/aicq-chat mount point.
33
- * For example, the API route /plugins/aicq-chat/api/status is
34
- * defined as /api/status in the sub-app.
35
- *
36
- * @param {object} ctx - Plugin context { ensureInitialized, runtime, DATA_DIR, SERVER_URL }
37
- * @returns {import('express').Express} Express sub-app
38
- */
39
- function createAicqExpressApp(ctx) {
40
- const { ensureInitialized, runtime, DATA_DIR, SERVER_URL } = ctx;
41
- const UPLOADS_DIR = path.join(DATA_DIR, "uploads");
42
-
43
- // Ensure uploads directory exists
44
- fs.mkdirSync(UPLOADS_DIR, { recursive: true });
45
-
46
- const app = express();
47
-
48
- // Parse JSON bodies
49
- app.use(express.json());
50
-
51
- // Parse URL-encoded bodies
52
- app.use(express.urlencoded({ extended: true }));
53
-
54
- const upload = multer({
55
- storage: multer.memoryStorage(),
56
- limits: { fileSize: 50 * 1024 * 1024 },
57
- });
58
-
59
- /**
60
- * Helper to get current agent ID from a request
61
- */
62
- function getAgentId(req) {
63
- return (
64
- req.query?.agent_id ||
65
- req.body?.agent_id ||
66
- (runtime.identity && runtime.identity.listAgents()[0]?.agent_id)
67
- );
68
- }
69
-
70
- // ── Serve SPA static files ────────────────────────────────────────
71
- // NOTE: The mount point /plugins/aicq-chat is stripped by Express,
72
- // so our sub-app sees /ui/... for requests to /plugins/aicq-chat/ui/...
73
- const publicDir = path.join(__dirname, "..", "public");
74
-
75
- app.use("/ui", (req, res, next) => {
76
- const filePath = path.join(publicDir, req.path === "/" ? "index.html" : req.path);
77
- if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
78
- res.sendFile(filePath);
79
- } else {
80
- // SPA fallback: serve index.html for all unknown routes
81
- res.sendFile(path.join(publicDir, "index.html"));
82
- }
83
- });
84
-
85
- // ── API Routes ────────────────────────────────────────────────────
86
- // All paths are relative to the /plugins/aicq-chat mount point.
87
-
88
- // Status
89
- app.get("/api/status", (req, res) => {
90
- res.json({
91
- status: "running",
92
- version: "3.2.0",
93
- architecture: "channel",
94
- connected: runtime.serverClient?.connected || false,
95
- serverUrl: SERVER_URL,
96
- });
97
- });
98
-
99
- // Agents
100
- app.get("/api/agents", async (req, res) => {
101
- await ensureInitialized();
102
- res.json({ agents: runtime.identity.listAgents() });
103
- });
104
-
105
- app.post("/api/agents", async (req, res) => {
106
- await ensureInitialized();
107
- try {
108
- const { agent_id, nickname } = req.body;
109
- if (!agent_id) return res.status(400).json({ error: "agent_id is required" });
110
- const agent = runtime.identity.createAgent(agent_id, nickname);
111
- try {
112
- await runtime.serverClient.start(agent_id);
113
- } catch (e) {
114
- console.error("[AICQ] Server registration failed:", e.message);
115
- }
116
- res.json({ success: true, agent });
117
- } catch (e) {
118
- res.status(500).json({ error: e.message });
119
- }
120
- });
121
-
122
- app.delete("/api/agents/:id", async (req, res) => {
123
- await ensureInitialized();
124
- runtime.identity.deleteAgent(req.params.id);
125
- res.json({ success: true });
126
- });
127
-
128
- // Friends
129
- app.get("/api/friends", async (req, res) => {
130
- await ensureInitialized();
131
- const agentId = getAgentId(req);
132
- res.json({ friends: runtime.db.listFriends(agentId) });
133
- });
134
-
135
- app.post("/api/friends/add", async (req, res) => {
136
- await ensureInitialized();
137
- try {
138
- const { temp_number, friend_code, agent_id } = req.body;
139
- const agentId = agent_id || getAgentId(req);
140
- const code = temp_number || friend_code;
141
- if (!code)
142
- return res.status(400).json({ error: "temp_number or friend_code is required" });
143
- const result = await runtime.handshake.addFriendByCode(agentId, code);
144
- res.json({ success: true, result });
145
- } catch (e) {
146
- res.status(500).json({ error: e.message });
147
- }
148
- });
149
-
150
- app.delete("/api/friends/:id", async (req, res) => {
151
- await ensureInitialized();
152
- try {
153
- const agentId = getAgentId(req);
154
- runtime.db.removeFriend(agentId, req.params.id);
155
- try {
156
- await runtime.serverClient.removeFriend(req.params.id);
157
- } catch (e) {}
158
- res.json({ success: true });
159
- } catch (e) {
160
- res.status(500).json({ error: e.message });
161
- }
162
- });
163
-
164
- app.get("/api/friends/requests", async (req, res) => {
165
- await ensureInitialized();
166
- try {
167
- const agentId = getAgentId(req);
168
- let serverRequests = [];
169
- try {
170
- await runtime.serverClient.ensureAuth(agentId);
171
- const result = await runtime.serverClient.listFriendRequests();
172
- serverRequests = result.sent || [];
173
- serverRequests = serverRequests.concat(result.received || []);
174
- } catch (e) {}
175
- const localRequests = runtime.db.getPendingRequests(agentId);
176
- res.json({ requests: [...localRequests, ...serverRequests] });
177
- } catch (e) {
178
- res.status(500).json({ error: e.message });
179
- }
180
- });
181
-
182
- app.post("/api/friends/requests/:id/accept", async (req, res) => {
183
- await ensureInitialized();
184
- try {
185
- const agentId = getAgentId(req);
186
- const result = await runtime.handshake.acceptRequest(agentId, req.params.id);
187
- res.json(result);
188
- } catch (e) {
189
- res.status(500).json({ error: e.message });
190
- }
191
- });
192
-
193
- app.post("/api/friends/requests/:id/reject", async (req, res) => {
194
- await ensureInitialized();
195
- try {
196
- const agentId = getAgentId(req);
197
- const result = await runtime.handshake.rejectRequest(agentId, req.params.id);
198
- res.json(result);
199
- } catch (e) {
200
- res.status(500).json({ error: e.message });
201
- }
202
- });
203
-
204
- // Groups
205
- app.get("/api/groups", async (req, res) => {
206
- await ensureInitialized();
207
- const agentId = getAgentId(req);
208
- res.json({ groups: runtime.db.listGroups(agentId) });
209
- });
210
-
211
- app.post("/api/groups", async (req, res) => {
212
- await ensureInitialized();
213
- try {
214
- const agentId = getAgentId(req);
215
- const { name, description } = req.body;
216
- if (!name) return res.status(400).json({ error: "name is required" });
217
- await runtime.serverClient.ensureAuth(agentId);
218
- const result = await runtime.serverClient.createGroup(name, description);
219
- if (result.id) {
220
- runtime.db.addGroup({
221
- agent_id: agentId,
222
- id: result.id,
223
- name,
224
- owner_id: agentId,
225
- members_json: result.members || "[]",
226
- description: description || "",
227
- });
228
- }
229
- res.json({ success: true, group: result });
230
- } catch (e) {
231
- res.status(500).json({ error: e.message });
232
- }
233
- });
234
-
235
- app.post("/api/groups/:id/join", async (req, res) => {
236
- await ensureInitialized();
237
- try {
238
- const agentId = getAgentId(req);
239
- await runtime.serverClient.ensureAuth(agentId);
240
- const result = await runtime.serverClient.inviteGroupMember(req.params.id, agentId);
241
- res.json({ success: true, result });
242
- } catch (e) {
243
- res.status(500).json({ error: e.message });
244
- }
245
- });
246
-
247
- app.get("/api/groups/:id/messages", async (req, res) => {
248
- await ensureInitialized();
249
- try {
250
- const agentId = getAgentId(req);
251
- const limit = parseInt(req.query.limit || "50", 10);
252
- const before = req.query.before || null;
253
- try {
254
- await runtime.serverClient.ensureAuth(agentId);
255
- const result = await runtime.serverClient.getGroupMessages(
256
- req.params.id,
257
- limit,
258
- before
259
- );
260
- if (result.messages && result.messages.length > 0) {
261
- return res.json({ messages: result.messages });
262
- }
263
- } catch (e) {}
264
- const messages = runtime.db.getChatHistory(agentId, req.params.id, {
265
- limit,
266
- before,
267
- });
268
- res.json({ messages });
269
- } catch (e) {
270
- res.status(500).json({ error: e.message });
271
- }
272
- });
273
-
274
- app.put("/api/groups/:id/silent", async (req, res) => {
275
- await ensureInitialized();
276
- const agentId = getAgentId(req);
277
- const { silent } = req.body;
278
- runtime.db.setGroupSilentMode(agentId, req.params.id, !!silent);
279
- res.json({ success: true, silent: !!silent });
280
- });
281
-
282
- // Chat
283
- app.get("/api/chat/:targetId", async (req, res) => {
284
- await ensureInitialized();
285
- const agentId = getAgentId(req);
286
- const limit = parseInt(req.query.limit || "50", 10);
287
- const before = req.query.before || null;
288
- const messages = runtime.db.getChatHistory(agentId, req.params.targetId, {
289
- limit,
290
- before,
291
- });
292
- res.json({ messages });
293
- });
294
-
295
- app.post("/api/chat/send", async (req, res) => {
296
- await ensureInitialized();
297
- try {
298
- const {
299
- agent_id,
300
- targetId,
301
- content,
302
- type,
303
- isGroup,
304
- mentions,
305
- file_url,
306
- file_name,
307
- } = req.body;
308
- const agentId = agent_id || getAgentId(req);
309
- if (!targetId || !content)
310
- return res.status(400).json({ error: "targetId and content are required" });
311
- const msg = await runtime.chat.sendMessage(agentId, targetId, content, {
312
- type: type || "text",
313
- isGroup: !!isGroup,
314
- mentions: mentions || [],
315
- file_url,
316
- file_name,
317
- });
318
- res.json({ success: true, message: msg });
319
- } catch (e) {
320
- res.status(500).json({ error: e.message });
321
- }
322
- });
323
-
324
- app.delete("/api/chat/:messageId", async (req, res) => {
325
- await ensureInitialized();
326
- const agentId = getAgentId(req);
327
- runtime.db.deleteMessage(agentId, req.params.messageId);
328
- res.json({ success: true });
329
- });
330
-
331
- // Streaming endpoints
332
- app.post("/api/chat/stream-chunk", async (req, res) => {
333
- await ensureInitialized();
334
- try {
335
- const { targetId, friend_id, chunk_type, chunkType, data } = req.body;
336
- const streamTarget = targetId || friend_id;
337
- if (!streamTarget)
338
- return res.status(400).json({ error: "targetId or friend_id is required" });
339
- if (!data) return res.status(400).json({ error: "data is required" });
340
- const type = chunk_type || chunkType || "text";
341
- const ALLOWED_CHUNK_TYPES = [
342
- "text",
343
- "reasoning",
344
- "thinking",
345
- "clear_text",
346
- "tool_call",
347
- "tool_result",
348
- ];
349
- if (!ALLOWED_CHUNK_TYPES.includes(type)) {
350
- return res
351
- .status(400)
352
- .json({
353
- error: `Invalid chunk_type: ${type}. Allowed: ${ALLOWED_CHUNK_TYPES.join(", ")}`,
354
- });
355
- }
356
- const sent = runtime.serverClient.sendWS({
357
- type: "stream_chunk",
358
- to: streamTarget,
359
- chunkType: type,
360
- data: data,
361
- });
362
- if (!sent)
363
- return res.status(503).json({ error: "Not connected to server", success: false });
364
- res.json({ success: true });
365
- } catch (e) {
366
- res.status(500).json({ error: e.message });
367
- }
368
- });
369
-
370
- app.post("/api/chat/stream-end", async (req, res) => {
371
- await ensureInitialized();
372
- try {
373
- const { targetId, friend_id, message_id, messageId } = req.body;
374
- const streamTarget = targetId || friend_id;
375
- if (!streamTarget)
376
- return res.status(400).json({ error: "targetId or friend_id is required" });
377
- const msgId =
378
- message_id ||
379
- messageId ||
380
- "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6);
381
- const sent = runtime.serverClient.sendWS({
382
- type: "stream_end",
383
- to: streamTarget,
384
- messageId: msgId,
385
- });
386
- if (!sent)
387
- return res.status(503).json({ error: "Not connected to server", success: false });
388
- res.json({ success: true, messageId: msgId });
389
- } catch (e) {
390
- res.status(500).json({ error: e.message });
391
- }
392
- });
393
-
394
- // File upload
395
- app.post("/api/upload", upload.single("file"), async (req, res) => {
396
- await ensureInitialized();
397
- try {
398
- if (!req.file) return res.status(400).json({ error: "No file uploaded" });
399
- const agentId = getAgentId(req);
400
- const targetId = req.body.targetId;
401
- const isGroup = req.body.isGroup === "true" || req.body.isGroup === "1";
402
- const msg = await runtime.chat.handleFileUpload(agentId, targetId, req.file, isGroup);
403
- res.json({ success: true, message: msg });
404
- } catch (e) {
405
- res.status(500).json({ error: e.message });
406
- }
407
- });
408
-
409
- app.get("/api/files/:fileId", (req, res) => {
410
- const filePath = path.join(UPLOADS_DIR, req.params.fileId);
411
- if (fs.existsSync(filePath)) {
412
- res.sendFile(filePath);
413
- } else {
414
- res.status(404).json({ error: "File not found" });
415
- }
416
- });
417
-
418
- // Identity
419
- app.get("/api/identity", async (req, res) => {
420
- await ensureInitialized();
421
- const agentId = getAgentId(req);
422
- res.json(runtime.identity.getInfo(agentId) || {});
423
- });
424
-
425
- app.post("/api/identity/nickname", async (req, res) => {
426
- await ensureInitialized();
427
- const { agent_id, nickname } = req.body;
428
- const agentId = agent_id || getAgentId(req);
429
- runtime.identity.updateNickname(agentId, nickname);
430
- res.json({ success: true });
431
- });
432
-
433
- app.post("/api/identity/friend-code", async (req, res) => {
434
- await ensureInitialized();
435
- try {
436
- const agentId = req.body.agent_id || getAgentId(req);
437
- await runtime.serverClient.ensureAuth(agentId);
438
- const result = await runtime.handshake.generateFriendCode(agentId);
439
- res.json({
440
- success: true,
441
- code: result.number,
442
- expires_at: result.expiresAt || result.expires_at,
443
- });
444
- } catch (e) {
445
- res.status(500).json({ error: e.message });
446
- }
447
- });
448
-
449
- app.get("/api/identity/qr", async (req, res) => {
450
- await ensureInitialized();
451
- try {
452
- const agentId = getAgentId(req);
453
- const info = runtime.identity.getInfo(agentId);
454
- if (!info) return res.status(404).json({ error: "Agent not found" });
455
- const qrData = JSON.stringify({
456
- type: "aicq-friend",
457
- agent_id: info.agent_id,
458
- public_key: info.signing_public_key,
459
- exchange_public_key: info.exchange_public_key,
460
- fingerprint: info.fingerprint,
461
- });
462
- const qrImage = await QRCode.toDataURL(qrData);
463
- res.json({ qr: qrImage, data: qrData, info });
464
- } catch (e) {
465
- res.status(500).json({ error: e.message });
466
- }
467
- });
468
-
469
- app.post("/api/identity/rotate-keys", async (req, res) => {
470
- await ensureInitialized();
471
- try {
472
- const agentId = req.body.agent_id || getAgentId(req);
473
- runtime.identity.rotateKeys(agentId);
474
- res.json({ success: true, info: runtime.identity.getInfo(agentId) });
475
- } catch (e) {
476
- res.status(500).json({ error: e.message });
477
- }
478
- });
479
-
480
- app.get("/api/identity/keys", async (req, res) => {
481
- await ensureInitialized();
482
- const agentId = getAgentId(req);
483
- const info = runtime.identity.loadAgent(agentId);
484
- if (!info) return res.status(404).json({ error: "Agent not found" });
485
- res.json({
486
- agent_id: info.agent_id,
487
- nickname: info.nickname,
488
- signing_public_key: info.signing_public_key,
489
- exchange_public_key: info.exchange_public_key,
490
- signing_secret_key: info.signing_secret_key,
491
- exchange_secret_key: info.exchange_secret_key,
492
- fingerprint: info.fingerprint,
493
- });
494
- });
495
-
496
- // Sync endpoint
497
- app.post("/api/sync", async (req, res) => {
498
- await ensureInitialized();
499
- try {
500
- const agentId = req.body.agent_id || getAgentId(req);
501
- await runtime.serverClient.ensureAuth(agentId);
502
- // Sync friends
503
- const friendResult = await runtime.serverClient.listFriends();
504
- if (friendResult.friends) {
505
- for (const f of friendResult.friends) {
506
- const existing = runtime.db.getFriend(agentId, f.id);
507
- if (!existing) {
508
- runtime.db.addFriend({
509
- agent_id: agentId,
510
- id: f.id,
511
- public_key: f.public_key || f.publicKey || "",
512
- fingerprint: f.fingerprint || "",
513
- friend_type: f.type || f.friend_type || "ai",
514
- ai_name: f.agent_name || f.ai_name || f.displayName || "",
515
- });
516
- } else {
517
- runtime.db.updateFriendOnline(
518
- agentId,
519
- f.id,
520
- f.is_online || f.isOnline || false
521
- );
522
- }
523
- }
524
- }
525
- // Sync groups
526
- const groupResult = await runtime.serverClient.listGroups();
527
- if (groupResult.groups) {
528
- for (const g of groupResult.groups) {
529
- runtime.db.addGroup({
530
- agent_id: agentId,
531
- id: g.id,
532
- name: g.name,
533
- owner_id: g.owner_id || g.ownerId || "",
534
- members_json: g.members || g.members_json || "[]",
535
- description: g.description || "",
536
- });
537
- }
538
- }
539
- res.json({ success: true });
540
- } catch (e) {
541
- res.status(500).json({ error: e.message });
542
- }
543
- });
544
-
545
- // Gateway proxy endpoint (for backward compatibility)
546
- app.post("/api/gateway", async (req, res) => {
547
- await ensureInitialized();
548
- try {
549
- // This endpoint provides backward compatibility for SPA calls
550
- // that use the gateway RPC protocol. New code should prefer
551
- // the REST API endpoints above.
552
- const { method, kwargs } = req.body;
553
- if (!method) return res.status(400).json({ error: "method is required" });
554
-
555
- // Delegate to the gateway method handler in index.js
556
- // (imported via the ensureInitialized runtime store)
557
- const result = await runtime.handleGateway(method, kwargs || {});
558
- res.json(result);
559
- } catch (e) {
560
- res.status(500).json({ error: e.message });
561
- }
562
- });
563
-
564
- return app;
565
- }
566
-
567
- /**
568
- * Register all HTTP routes on the OpenClaw plugin API.
569
- *
570
- * Creates an Express sub-app with all AICQ routes and registers it
571
- * as a handler for the /plugins/aicq-chat path prefix.
572
- *
573
- * @param {object} api - OpenClawPluginApi instance
574
- * @param {object} ctx - Plugin context { ensureInitialized, runtime, DATA_DIR, SERVER_URL }
575
- */
576
- export function registerHttpRoutes(api, ctx) {
577
- const app = createAicqExpressApp(ctx);
578
-
579
- // Register the Express sub-app for all /plugins/aicq-chat requests.
580
- // The gateway uses app.use(path, handler), so the mount prefix is
581
- // stripped before the request reaches our sub-app.
582
- if (typeof api.registerHttpRoute === "function") {
583
- api.registerHttpRoute({
584
- path: "/plugins/aicq-chat",
585
- auth: "plugin",
586
- handler: app,
587
- });
588
- } else {
589
- console.warn(
590
- "[AICQ Channel] api.registerHttpRoute not available — HTTP UI routes not registered. " +
591
- "The SPA UI will not be accessible until the gateway supports plugin HTTP routes."
592
- );
593
- }
594
- }
1
+ /**
2
+ * AICQ Channel Plugin — Gateway HTTP Routes
3
+ *
4
+ * Provides HTTP route handlers for the OpenClaw Gateway.
5
+ * These routes serve the SPA UI and REST API endpoints.
6
+ *
7
+ * Strategy: We create an Express sub-app with all routes (defined
8
+ * relative to the /plugins/aicq-chat mount point), then register it
9
+ * via api.registerHttpRoute(). The OpenClaw gateway runs Express
10
+ * internally and uses app.use(path, handler), so the mount prefix
11
+ * is stripped before the request reaches our sub-app.
12
+ *
13
+ * ESM module — imported by index.js registerFull() to register routes
14
+ * on the OpenClaw plugin API.
15
+ */
16
+
17
+ import path from "path";
18
+ import fs from "fs";
19
+ import { fileURLToPath } from "url";
20
+ import { createRequire } from "module";
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const QRCode = require("qrcode");
24
+ const multer = require("multer");
25
+ const express = require("express");
26
+
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ /**
30
+ * Create and return the Express sub-app with all AICQ routes.
31
+ *
32
+ * Routes are defined RELATIVE to the /plugins/aicq-chat mount point.
33
+ * For example, the API route /plugins/aicq-chat/api/status is
34
+ * defined as /api/status in the sub-app.
35
+ *
36
+ * @param {object} ctx - Plugin context { ensureInitialized, runtime, DATA_DIR, SERVER_URL }
37
+ * @returns {import('express').Express} Express sub-app
38
+ */
39
+ function createAicqExpressApp(ctx) {
40
+ const { ensureInitialized, runtime, DATA_DIR, SERVER_URL } = ctx;
41
+ const UPLOADS_DIR = path.join(DATA_DIR, "uploads");
42
+
43
+ // Ensure uploads directory exists
44
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
45
+
46
+ const app = express();
47
+
48
+ // Parse JSON bodies
49
+ app.use(express.json());
50
+
51
+ // Parse URL-encoded bodies
52
+ app.use(express.urlencoded({ extended: true }));
53
+
54
+ const upload = multer({
55
+ storage: multer.memoryStorage(),
56
+ limits: { fileSize: 50 * 1024 * 1024 },
57
+ });
58
+
59
+ /**
60
+ * Helper to get current agent ID from a request
61
+ */
62
+ function getAgentId(req) {
63
+ return (
64
+ req.query?.agent_id ||
65
+ req.body?.agent_id ||
66
+ (runtime.identity && runtime.identity.listAgents()[0]?.agent_id)
67
+ );
68
+ }
69
+
70
+ // ── Serve SPA static files ────────────────────────────────────────
71
+ // NOTE: The mount point /plugins/aicq-chat is stripped by Express,
72
+ // so our sub-app sees /ui/... for requests to /plugins/aicq-chat/ui/...
73
+ const publicDir = path.join(__dirname, "..", "public");
74
+
75
+ app.use("/ui", (req, res, next) => {
76
+ const filePath = path.join(publicDir, req.path === "/" ? "index.html" : req.path);
77
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
78
+ res.sendFile(filePath);
79
+ } else {
80
+ // SPA fallback: serve index.html for all unknown routes
81
+ res.sendFile(path.join(publicDir, "index.html"));
82
+ }
83
+ });
84
+
85
+ // ── API Routes ────────────────────────────────────────────────────
86
+ // All paths are relative to the /plugins/aicq-chat mount point.
87
+
88
+ // Status
89
+ app.get("/api/status", (req, res) => {
90
+ res.json({
91
+ status: "running",
92
+ version: "3.2.0",
93
+ architecture: "channel",
94
+ connected: runtime.serverClient?.connected || false,
95
+ serverUrl: SERVER_URL,
96
+ });
97
+ });
98
+
99
+ // Agents
100
+ app.get("/api/agents", async (req, res) => {
101
+ await ensureInitialized();
102
+ res.json({ agents: runtime.identity.listAgents() });
103
+ });
104
+
105
+ app.post("/api/agents", async (req, res) => {
106
+ await ensureInitialized();
107
+ try {
108
+ const { agent_id, nickname } = req.body;
109
+ if (!agent_id) return res.status(400).json({ error: "agent_id is required" });
110
+ const agent = runtime.identity.createAgent(agent_id, nickname);
111
+ try {
112
+ await runtime.serverClient.start(agent_id);
113
+ } catch (e) {
114
+ console.error("[AICQ] Server registration failed:", e.message);
115
+ }
116
+ res.json({ success: true, agent });
117
+ } catch (e) {
118
+ res.status(500).json({ error: e.message });
119
+ }
120
+ });
121
+
122
+ app.delete("/api/agents/:id", async (req, res) => {
123
+ await ensureInitialized();
124
+ runtime.identity.deleteAgent(req.params.id);
125
+ res.json({ success: true });
126
+ });
127
+
128
+ // Friends
129
+ app.get("/api/friends", async (req, res) => {
130
+ await ensureInitialized();
131
+ const agentId = getAgentId(req);
132
+ res.json({ friends: runtime.db.listFriends(agentId) });
133
+ });
134
+
135
+ app.post("/api/friends/add", async (req, res) => {
136
+ await ensureInitialized();
137
+ try {
138
+ const { temp_number, friend_code, agent_id } = req.body;
139
+ const agentId = agent_id || getAgentId(req);
140
+ const code = temp_number || friend_code;
141
+ if (!code)
142
+ return res.status(400).json({ error: "temp_number or friend_code is required" });
143
+ const result = await runtime.handshake.addFriendByCode(agentId, code);
144
+ res.json({ success: true, result });
145
+ } catch (e) {
146
+ res.status(500).json({ error: e.message });
147
+ }
148
+ });
149
+
150
+ app.delete("/api/friends/:id", async (req, res) => {
151
+ await ensureInitialized();
152
+ try {
153
+ const agentId = getAgentId(req);
154
+ runtime.db.removeFriend(agentId, req.params.id);
155
+ try {
156
+ await runtime.serverClient.removeFriend(req.params.id);
157
+ } catch (e) {}
158
+ res.json({ success: true });
159
+ } catch (e) {
160
+ res.status(500).json({ error: e.message });
161
+ }
162
+ });
163
+
164
+ app.get("/api/friends/requests", async (req, res) => {
165
+ await ensureInitialized();
166
+ try {
167
+ const agentId = getAgentId(req);
168
+ let serverRequests = [];
169
+ try {
170
+ await runtime.serverClient.ensureAuth(agentId);
171
+ const result = await runtime.serverClient.listFriendRequests();
172
+ serverRequests = result.sent || [];
173
+ serverRequests = serverRequests.concat(result.received || []);
174
+ } catch (e) {}
175
+ const localRequests = runtime.db.getPendingRequests(agentId);
176
+ res.json({ requests: [...localRequests, ...serverRequests] });
177
+ } catch (e) {
178
+ res.status(500).json({ error: e.message });
179
+ }
180
+ });
181
+
182
+ app.post("/api/friends/requests/:id/accept", async (req, res) => {
183
+ await ensureInitialized();
184
+ try {
185
+ const agentId = getAgentId(req);
186
+ const result = await runtime.handshake.acceptRequest(agentId, req.params.id);
187
+ res.json(result);
188
+ } catch (e) {
189
+ res.status(500).json({ error: e.message });
190
+ }
191
+ });
192
+
193
+ app.post("/api/friends/requests/:id/reject", async (req, res) => {
194
+ await ensureInitialized();
195
+ try {
196
+ const agentId = getAgentId(req);
197
+ const result = await runtime.handshake.rejectRequest(agentId, req.params.id);
198
+ res.json(result);
199
+ } catch (e) {
200
+ res.status(500).json({ error: e.message });
201
+ }
202
+ });
203
+
204
+ // Groups
205
+ app.get("/api/groups", async (req, res) => {
206
+ await ensureInitialized();
207
+ const agentId = getAgentId(req);
208
+ res.json({ groups: runtime.db.listGroups(agentId) });
209
+ });
210
+
211
+ app.post("/api/groups", async (req, res) => {
212
+ await ensureInitialized();
213
+ try {
214
+ const agentId = getAgentId(req);
215
+ const { name, description } = req.body;
216
+ if (!name) return res.status(400).json({ error: "name is required" });
217
+ await runtime.serverClient.ensureAuth(agentId);
218
+ const result = await runtime.serverClient.createGroup(name, description);
219
+ if (result.id) {
220
+ runtime.db.addGroup({
221
+ agent_id: agentId,
222
+ id: result.id,
223
+ name,
224
+ owner_id: agentId,
225
+ members_json: result.members || "[]",
226
+ description: description || "",
227
+ });
228
+ }
229
+ res.json({ success: true, group: result });
230
+ } catch (e) {
231
+ res.status(500).json({ error: e.message });
232
+ }
233
+ });
234
+
235
+ app.post("/api/groups/:id/join", async (req, res) => {
236
+ await ensureInitialized();
237
+ try {
238
+ const agentId = getAgentId(req);
239
+ await runtime.serverClient.ensureAuth(agentId);
240
+ const result = await runtime.serverClient.inviteGroupMember(req.params.id, agentId);
241
+ res.json({ success: true, result });
242
+ } catch (e) {
243
+ res.status(500).json({ error: e.message });
244
+ }
245
+ });
246
+
247
+ app.get("/api/groups/:id/messages", async (req, res) => {
248
+ await ensureInitialized();
249
+ try {
250
+ const agentId = getAgentId(req);
251
+ const limit = parseInt(req.query.limit || "50", 10);
252
+ const before = req.query.before || null;
253
+ try {
254
+ await runtime.serverClient.ensureAuth(agentId);
255
+ const result = await runtime.serverClient.getGroupMessages(
256
+ req.params.id,
257
+ limit,
258
+ before
259
+ );
260
+ if (result.messages && result.messages.length > 0) {
261
+ return res.json({ messages: result.messages });
262
+ }
263
+ } catch (e) {}
264
+ const messages = runtime.db.getChatHistory(agentId, req.params.id, {
265
+ limit,
266
+ before,
267
+ });
268
+ res.json({ messages });
269
+ } catch (e) {
270
+ res.status(500).json({ error: e.message });
271
+ }
272
+ });
273
+
274
+ app.put("/api/groups/:id/silent", async (req, res) => {
275
+ await ensureInitialized();
276
+ const agentId = getAgentId(req);
277
+ const { silent } = req.body;
278
+ runtime.db.setGroupSilentMode(agentId, req.params.id, !!silent);
279
+ res.json({ success: true, silent: !!silent });
280
+ });
281
+
282
+ // Chat
283
+ app.get("/api/chat/:targetId", async (req, res) => {
284
+ await ensureInitialized();
285
+ const agentId = getAgentId(req);
286
+ const limit = parseInt(req.query.limit || "50", 10);
287
+ const before = req.query.before || null;
288
+ const messages = runtime.db.getChatHistory(agentId, req.params.targetId, {
289
+ limit,
290
+ before,
291
+ });
292
+ res.json({ messages });
293
+ });
294
+
295
+ app.post("/api/chat/send", async (req, res) => {
296
+ await ensureInitialized();
297
+ try {
298
+ const {
299
+ agent_id,
300
+ targetId,
301
+ content,
302
+ type,
303
+ isGroup,
304
+ mentions,
305
+ file_url,
306
+ file_name,
307
+ } = req.body;
308
+ const agentId = agent_id || getAgentId(req);
309
+ if (!targetId || !content)
310
+ return res.status(400).json({ error: "targetId and content are required" });
311
+ const msg = await runtime.chat.sendMessage(agentId, targetId, content, {
312
+ type: type || "text",
313
+ isGroup: !!isGroup,
314
+ mentions: mentions || [],
315
+ file_url,
316
+ file_name,
317
+ });
318
+ res.json({ success: true, message: msg });
319
+ } catch (e) {
320
+ res.status(500).json({ error: e.message });
321
+ }
322
+ });
323
+
324
+ app.delete("/api/chat/:messageId", async (req, res) => {
325
+ await ensureInitialized();
326
+ const agentId = getAgentId(req);
327
+ runtime.db.deleteMessage(agentId, req.params.messageId);
328
+ res.json({ success: true });
329
+ });
330
+
331
+ // Streaming endpoints
332
+ app.post("/api/chat/stream-chunk", async (req, res) => {
333
+ await ensureInitialized();
334
+ try {
335
+ const { targetId, friend_id, chunk_type, chunkType, data } = req.body;
336
+ const streamTarget = targetId || friend_id;
337
+ if (!streamTarget)
338
+ return res.status(400).json({ error: "targetId or friend_id is required" });
339
+ if (!data) return res.status(400).json({ error: "data is required" });
340
+ const type = chunk_type || chunkType || "text";
341
+ const ALLOWED_CHUNK_TYPES = [
342
+ "text",
343
+ "reasoning",
344
+ "thinking",
345
+ "clear_text",
346
+ "tool_call",
347
+ "tool_result",
348
+ ];
349
+ if (!ALLOWED_CHUNK_TYPES.includes(type)) {
350
+ return res
351
+ .status(400)
352
+ .json({
353
+ error: `Invalid chunk_type: ${type}. Allowed: ${ALLOWED_CHUNK_TYPES.join(", ")}`,
354
+ });
355
+ }
356
+ const sent = runtime.serverClient.sendWS({
357
+ type: "stream_chunk",
358
+ to: streamTarget,
359
+ chunkType: type,
360
+ data: data,
361
+ });
362
+ if (!sent)
363
+ return res.status(503).json({ error: "Not connected to server", success: false });
364
+ res.json({ success: true });
365
+ } catch (e) {
366
+ res.status(500).json({ error: e.message });
367
+ }
368
+ });
369
+
370
+ app.post("/api/chat/stream-end", async (req, res) => {
371
+ await ensureInitialized();
372
+ try {
373
+ const { targetId, friend_id, message_id, messageId } = req.body;
374
+ const streamTarget = targetId || friend_id;
375
+ if (!streamTarget)
376
+ return res.status(400).json({ error: "targetId or friend_id is required" });
377
+ const msgId =
378
+ message_id ||
379
+ messageId ||
380
+ "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6);
381
+ const sent = runtime.serverClient.sendWS({
382
+ type: "stream_end",
383
+ to: streamTarget,
384
+ messageId: msgId,
385
+ });
386
+ if (!sent)
387
+ return res.status(503).json({ error: "Not connected to server", success: false });
388
+ res.json({ success: true, messageId: msgId });
389
+ } catch (e) {
390
+ res.status(500).json({ error: e.message });
391
+ }
392
+ });
393
+
394
+ // File upload
395
+ app.post("/api/upload", upload.single("file"), async (req, res) => {
396
+ await ensureInitialized();
397
+ try {
398
+ if (!req.file) return res.status(400).json({ error: "No file uploaded" });
399
+ const agentId = getAgentId(req);
400
+ const targetId = req.body.targetId;
401
+ const isGroup = req.body.isGroup === "true" || req.body.isGroup === "1";
402
+ const msg = await runtime.chat.handleFileUpload(agentId, targetId, req.file, isGroup);
403
+ res.json({ success: true, message: msg });
404
+ } catch (e) {
405
+ res.status(500).json({ error: e.message });
406
+ }
407
+ });
408
+
409
+ // User file upload — saves to userfiles dir and notifies AI agent
410
+ app.post("/api/user-upload", upload.single("file"), async (req, res) => {
411
+ await ensureInitialized();
412
+ try {
413
+ if (!req.file) return res.status(400).json({ error: "No file uploaded" });
414
+ const agentId = getAgentId(req);
415
+ const fromId = req.body.fromId || req.body.from_id || agentId;
416
+ const isGroup = req.body.isGroup === "true" || req.body.isGroup === "1";
417
+ const result = await runtime.chat.handleUserFileUpload(agentId, fromId, req.file, isGroup);
418
+ res.json({ success: true, message: result.msg, localPath: result.localPath, originalName: result.originalName });
419
+ } catch (e) {
420
+ res.status(500).json({ error: e.message });
421
+ }
422
+ });
423
+
424
+ // List user files
425
+ app.get("/api/userfiles", async (req, res) => {
426
+ await ensureInitialized();
427
+ try {
428
+ const userfilesDir = runtime.userfilesDir;
429
+ if (!userfilesDir || !fs.existsSync(userfilesDir)) {
430
+ return res.json({ files: [] });
431
+ }
432
+ const files = fs.readdirSync(userfilesDir)
433
+ .filter(f => fs.statSync(path.join(userfilesDir, f)).isFile())
434
+ .map(f => {
435
+ const filePath = path.join(userfilesDir, f);
436
+ const stat = fs.statSync(filePath);
437
+ return {
438
+ name: f,
439
+ path: filePath,
440
+ size: stat.size,
441
+ modified: stat.mtime.toISOString(),
442
+ };
443
+ })
444
+ .sort((a, b) => b.modified.localeCompare(a.modified));
445
+ res.json({ files });
446
+ } catch (e) {
447
+ res.status(500).json({ error: e.message });
448
+ }
449
+ });
450
+
451
+ // Serve user files
452
+ app.get("/api/userfiles/:filename", (req, res) => {
453
+ const USERFILES_DIR = runtime.userfilesDir || path.join(DATA_DIR, "userfiles");
454
+ const filePath = path.join(USERFILES_DIR, req.params.filename);
455
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
456
+ res.sendFile(filePath);
457
+ } else {
458
+ res.status(404).json({ error: "File not found" });
459
+ }
460
+ });
461
+
462
+ app.get("/api/files/:fileId", (req, res) => {
463
+ const filePath = path.join(UPLOADS_DIR, req.params.fileId);
464
+ if (fs.existsSync(filePath)) {
465
+ res.sendFile(filePath);
466
+ } else {
467
+ res.status(404).json({ error: "File not found" });
468
+ }
469
+ });
470
+
471
+ // Identity
472
+ app.get("/api/identity", async (req, res) => {
473
+ await ensureInitialized();
474
+ const agentId = getAgentId(req);
475
+ res.json(runtime.identity.getInfo(agentId) || {});
476
+ });
477
+
478
+ app.post("/api/identity/nickname", async (req, res) => {
479
+ await ensureInitialized();
480
+ const { agent_id, nickname } = req.body;
481
+ const agentId = agent_id || getAgentId(req);
482
+ runtime.identity.updateNickname(agentId, nickname);
483
+ res.json({ success: true });
484
+ });
485
+
486
+ app.post("/api/identity/friend-code", async (req, res) => {
487
+ await ensureInitialized();
488
+ try {
489
+ const agentId = req.body.agent_id || getAgentId(req);
490
+ await runtime.serverClient.ensureAuth(agentId);
491
+ const result = await runtime.handshake.generateFriendCode(agentId);
492
+ res.json({
493
+ success: true,
494
+ code: result.number,
495
+ expires_at: result.expiresAt || result.expires_at,
496
+ });
497
+ } catch (e) {
498
+ res.status(500).json({ error: e.message });
499
+ }
500
+ });
501
+
502
+ app.get("/api/identity/qr", async (req, res) => {
503
+ await ensureInitialized();
504
+ try {
505
+ const agentId = getAgentId(req);
506
+ const info = runtime.identity.getInfo(agentId);
507
+ if (!info) return res.status(404).json({ error: "Agent not found" });
508
+ const qrData = JSON.stringify({
509
+ type: "aicq-friend",
510
+ agent_id: info.agent_id,
511
+ public_key: info.signing_public_key,
512
+ exchange_public_key: info.exchange_public_key,
513
+ fingerprint: info.fingerprint,
514
+ });
515
+ const qrImage = await QRCode.toDataURL(qrData);
516
+ res.json({ qr: qrImage, data: qrData, info });
517
+ } catch (e) {
518
+ res.status(500).json({ error: e.message });
519
+ }
520
+ });
521
+
522
+ app.post("/api/identity/rotate-keys", async (req, res) => {
523
+ await ensureInitialized();
524
+ try {
525
+ const agentId = req.body.agent_id || getAgentId(req);
526
+ runtime.identity.rotateKeys(agentId);
527
+ res.json({ success: true, info: runtime.identity.getInfo(agentId) });
528
+ } catch (e) {
529
+ res.status(500).json({ error: e.message });
530
+ }
531
+ });
532
+
533
+ app.get("/api/identity/keys", async (req, res) => {
534
+ await ensureInitialized();
535
+ const agentId = getAgentId(req);
536
+ const info = runtime.identity.loadAgent(agentId);
537
+ if (!info) return res.status(404).json({ error: "Agent not found" });
538
+ res.json({
539
+ agent_id: info.agent_id,
540
+ nickname: info.nickname,
541
+ signing_public_key: info.signing_public_key,
542
+ exchange_public_key: info.exchange_public_key,
543
+ signing_secret_key: info.signing_secret_key,
544
+ exchange_secret_key: info.exchange_secret_key,
545
+ fingerprint: info.fingerprint,
546
+ });
547
+ });
548
+
549
+ // Sync endpoint
550
+ app.post("/api/sync", async (req, res) => {
551
+ await ensureInitialized();
552
+ try {
553
+ const agentId = req.body.agent_id || getAgentId(req);
554
+ await runtime.serverClient.ensureAuth(agentId);
555
+ // Sync friends
556
+ const friendResult = await runtime.serverClient.listFriends();
557
+ if (friendResult.friends) {
558
+ for (const f of friendResult.friends) {
559
+ const existing = runtime.db.getFriend(agentId, f.id);
560
+ if (!existing) {
561
+ runtime.db.addFriend({
562
+ agent_id: agentId,
563
+ id: f.id,
564
+ public_key: f.public_key || f.publicKey || "",
565
+ fingerprint: f.fingerprint || "",
566
+ friend_type: f.type || f.friend_type || "ai",
567
+ ai_name: f.agent_name || f.ai_name || f.displayName || "",
568
+ });
569
+ } else {
570
+ runtime.db.updateFriendOnline(
571
+ agentId,
572
+ f.id,
573
+ f.is_online || f.isOnline || false
574
+ );
575
+ }
576
+ }
577
+ }
578
+ // Sync groups
579
+ const groupResult = await runtime.serverClient.listGroups();
580
+ if (groupResult.groups) {
581
+ for (const g of groupResult.groups) {
582
+ runtime.db.addGroup({
583
+ agent_id: agentId,
584
+ id: g.id,
585
+ name: g.name,
586
+ owner_id: g.owner_id || g.ownerId || "",
587
+ members_json: g.members || g.members_json || "[]",
588
+ description: g.description || "",
589
+ });
590
+ }
591
+ }
592
+ res.json({ success: true });
593
+ } catch (e) {
594
+ res.status(500).json({ error: e.message });
595
+ }
596
+ });
597
+
598
+ // Gateway proxy endpoint (for backward compatibility)
599
+ app.post("/api/gateway", async (req, res) => {
600
+ await ensureInitialized();
601
+ try {
602
+ // This endpoint provides backward compatibility for SPA calls
603
+ // that use the gateway RPC protocol. New code should prefer
604
+ // the REST API endpoints above.
605
+ const { method, kwargs } = req.body;
606
+ if (!method) return res.status(400).json({ error: "method is required" });
607
+
608
+ // Delegate to the gateway method handler in index.js
609
+ // (imported via the ensureInitialized runtime store)
610
+ const result = await runtime.handleGateway(method, kwargs || {});
611
+ res.json(result);
612
+ } catch (e) {
613
+ res.status(500).json({ error: e.message });
614
+ }
615
+ });
616
+
617
+ return app;
618
+ }
619
+
620
+ /**
621
+ * Register all HTTP routes on the OpenClaw plugin API.
622
+ *
623
+ * Creates an Express sub-app with all AICQ routes and registers it
624
+ * as a handler for the /plugins/aicq-chat path prefix.
625
+ *
626
+ * @param {object} api - OpenClawPluginApi instance
627
+ * @param {object} ctx - Plugin context { ensureInitialized, runtime, DATA_DIR, SERVER_URL }
628
+ */
629
+ export function registerHttpRoutes(api, ctx) {
630
+ const app = createAicqExpressApp(ctx);
631
+
632
+ // Register the Express sub-app for all /plugins/aicq-chat requests.
633
+ // The gateway uses app.use(path, handler), so the mount prefix is
634
+ // stripped before the request reaches our sub-app.
635
+ if (typeof api.registerHttpRoute === "function") {
636
+ api.registerHttpRoute({
637
+ path: "/plugins/aicq-chat",
638
+ auth: "plugin",
639
+ handler: app,
640
+ });
641
+ } else {
642
+ console.warn(
643
+ "[AICQ Channel] api.registerHttpRoute not available — HTTP UI routes not registered. " +
644
+ "The SPA UI will not be accessible until the gateway supports plugin HTTP routes."
645
+ );
646
+ }
647
+ }