@mooncompany/uplink-chat 0.5.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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * Agents Routes - Direct config file access
3
+ *
4
+ * Reads/writes the OpenClaw config file directly instead of going through
5
+ * gateway WebSocket RPC. This avoids the device-auth scope requirement
6
+ * introduced in OpenClaw v2026.2.14.
7
+ *
8
+ * For write operations, sends SIGUSR1 to the gateway process to trigger
9
+ * a config reload/restart.
10
+ */
11
+
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { execFile } from 'child_process';
16
+ import crypto from 'crypto';
17
+ import os from 'os';
18
+ import lockfile from 'proper-lockfile';
19
+ import { log } from '../utils.js';
20
+ import { requirePremium } from '../premium/index.js';
21
+ import { internalError, ErrorCodes } from '../../utils/errors.js';
22
+ import { GATEWAY_RESTART_DELAY_MS } from '../config.js';
23
+
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ const AVATARS_DIR = path.join(__dirname, '..', '..', 'public', 'img', 'agents');
26
+
27
+ // OpenClaw config file path
28
+ const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
29
+ const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_STATE_DIR, 'openclaw.json');
30
+
31
+ /**
32
+ * Read the OpenClaw config file and compute a hash for concurrency control.
33
+ * @returns {{ config: Object, hash: string, raw: string }}
34
+ */
35
+ async function readGatewayConfig() {
36
+ const raw = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8');
37
+ const config = JSON.parse(raw);
38
+ const hash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
39
+ return { config, hash, raw };
40
+ }
41
+
42
+ /**
43
+ * Write the OpenClaw config file with file locking for safety.
44
+ * Creates a backup before writing.
45
+ * @param {Object} config - The full config object to write
46
+ * @returns {{ hash: string }}
47
+ */
48
+ async function writeGatewayConfig(config) {
49
+ let release;
50
+ try {
51
+ release = await lockfile.lock(OPENCLAW_CONFIG_PATH, {
52
+ retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
53
+ });
54
+
55
+ // Backup current config
56
+ try {
57
+ await fs.copyFile(OPENCLAW_CONFIG_PATH, OPENCLAW_CONFIG_PATH + '.bak');
58
+ } catch {
59
+ // Backup failure is non-fatal
60
+ }
61
+
62
+ const raw = JSON.stringify(config, null, 2);
63
+ await fs.writeFile(OPENCLAW_CONFIG_PATH, raw, 'utf8');
64
+ const hash = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 16);
65
+ return { hash };
66
+ } finally {
67
+ if (release) await release();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Signal the gateway to reload config.
73
+ * Uses `openclaw gateway restart` which sends SIGUSR1 for a graceful reload.
74
+ */
75
+ function signalGatewayReload() {
76
+ try {
77
+ execFile('openclaw', ['gateway', 'restart'], { timeout: 10000 }, (err, stdout, stderr) => {
78
+ if (err) {
79
+ log('warn', `[Agents] Gateway restart signal failed: ${err.message}`);
80
+ } else {
81
+ log('info', '[Agents] Gateway restart signal sent');
82
+ }
83
+ });
84
+ } catch (err) {
85
+ log('warn', `[Agents] Failed to signal gateway: ${err.message}`);
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Setup agent routes
91
+ * @param {Express} app - Express app instance
92
+ * @param {Object} context - Request context
93
+ */
94
+ export function setupAgentRoutes(app, context) {
95
+
96
+ /**
97
+ * GET /api/agents
98
+ *
99
+ * Returns the agent list + defaults + bindings from the gateway config file.
100
+ */
101
+ app.get('/api/agents', requirePremium('Agent management'), async (req, res) => {
102
+ try {
103
+ const { config: gwConfig, hash } = await readGatewayConfig();
104
+
105
+ // Extract only agent-related config
106
+ const agents = gwConfig.agents || {};
107
+ const gwBindings = gwConfig.bindings || [];
108
+ const tools = gwConfig.tools || {};
109
+
110
+ // Build a clean response
111
+ const agentList = (agents.list || []).map(agent => ({
112
+ id: agent.id,
113
+ name: agent.name || agent.id,
114
+ default: agent.default || false,
115
+ workspace: agent.workspace || null,
116
+ model: agent.model || null,
117
+ identity: agent.identity || null,
118
+ groupChat: agent.groupChat || null,
119
+ sandbox: agent.sandbox || null,
120
+ tools: agent.tools || null,
121
+ subagents: agent.subagents || null,
122
+ }));
123
+
124
+ // If no agents in list, there's at least a "main" agent
125
+ if (agentList.length === 0) {
126
+ agentList.push({
127
+ id: 'main',
128
+ name: 'Main',
129
+ default: true,
130
+ workspace: agents.defaults?.workspace || null,
131
+ model: null,
132
+ identity: null,
133
+ groupChat: null,
134
+ sandbox: null,
135
+ tools: null,
136
+ subagents: null,
137
+ });
138
+ }
139
+
140
+ // Mark the default agent
141
+ const hasExplicitDefault = agentList.some(a => a.default);
142
+ if (!hasExplicitDefault && agentList.length > 0) {
143
+ agentList[0].default = true;
144
+ }
145
+
146
+ // Extract available channels for the binding editor
147
+ const gwChannels = gwConfig.channels || {};
148
+ const channelList = Object.keys(gwChannels);
149
+
150
+ res.json({
151
+ ok: true,
152
+ agents: agentList,
153
+ defaults: {
154
+ model: agents.defaults?.model || null,
155
+ models: agents.defaults?.models ? Object.keys(agents.defaults.models) : [],
156
+ workspace: agents.defaults?.workspace || null,
157
+ heartbeat: agents.defaults?.heartbeat || null,
158
+ subagents: agents.defaults?.subagents || null,
159
+ sandbox: agents.defaults?.sandbox || null,
160
+ maxConcurrent: agents.defaults?.maxConcurrent || null,
161
+ },
162
+ bindings: (gwBindings || []).map(b => ({
163
+ agentId: b.agentId,
164
+ match: b.match || {},
165
+ })),
166
+ channels: channelList,
167
+ globalTools: {
168
+ allow: tools.allow || null,
169
+ deny: tools.deny || null,
170
+ profile: tools.profile || null,
171
+ },
172
+ hash,
173
+ });
174
+ } catch (err) {
175
+ log('error', `[Agents] Failed to read agent config: ${err.message}`);
176
+ return internalError(res, err.message, ErrorCodes.GATEWAY_ERROR);
177
+ }
178
+ });
179
+
180
+ /**
181
+ * PATCH /api/agents/:agentId
182
+ *
183
+ * Updates an existing agent's config in the config file.
184
+ *
185
+ * Body: { changes: { model?, identity?, tools?, sandbox?, subagents?, groupChat? }, baseHash: string }
186
+ */
187
+ app.patch('/api/agents/:agentId', requirePremium('Agent management'), async (req, res) => {
188
+ try {
189
+ const { agentId } = req.params;
190
+ const { changes, baseHash } = req.body;
191
+
192
+ if (!changes || typeof changes !== 'object') {
193
+ return res.status(400).json({ ok: false, error: 'Missing changes object' });
194
+ }
195
+ if (!baseHash) {
196
+ return res.status(400).json({ ok: false, error: 'Missing baseHash for concurrency safety' });
197
+ }
198
+
199
+ const { config: gwConfig, hash: currentHash } = await readGatewayConfig();
200
+
201
+ // Verify hash hasn't changed (someone else edited config)
202
+ if (currentHash !== baseHash) {
203
+ return res.status(409).json({
204
+ ok: false,
205
+ error: 'Config was modified externally. Please refresh and try again.',
206
+ currentHash
207
+ });
208
+ }
209
+
210
+ const agentsList = gwConfig.agents?.list || [];
211
+ const agentIndex = agentsList.findIndex(a => a.id === agentId);
212
+
213
+ if (agentIndex === -1) {
214
+ return res.status(404).json({ ok: false, error: `Agent "${agentId}" not found` });
215
+ }
216
+
217
+ // Apply changes to the agent
218
+ const updatedAgent = { ...agentsList[agentIndex] };
219
+ if (changes.model !== undefined) updatedAgent.model = changes.model;
220
+ if (changes.identity !== undefined) updatedAgent.identity = changes.identity;
221
+ if (changes.tools !== undefined) updatedAgent.tools = changes.tools;
222
+ if (changes.sandbox !== undefined) updatedAgent.sandbox = changes.sandbox;
223
+ if (changes.subagents !== undefined) updatedAgent.subagents = changes.subagents;
224
+ if (changes.groupChat !== undefined) updatedAgent.groupChat = changes.groupChat;
225
+ if (changes.workspace !== undefined) updatedAgent.workspace = changes.workspace;
226
+ if (changes.name !== undefined) updatedAgent.name = changes.name;
227
+
228
+ // Update the config
229
+ gwConfig.agents.list[agentIndex] = updatedAgent;
230
+
231
+ const { hash: newHash } = await writeGatewayConfig(gwConfig);
232
+
233
+ // Signal gateway to reload
234
+ signalGatewayReload();
235
+
236
+ log('info', `[Agents] Patched agent "${agentId}": ${Object.keys(changes).join(', ')}`);
237
+
238
+ res.json({
239
+ ok: true,
240
+ agent: updatedAgent,
241
+ hash: newHash,
242
+ });
243
+ } catch (err) {
244
+ log('error', `[Agents] Failed to patch agent "${req.params.agentId}": ${err.message}`);
245
+ return internalError(res, err.message, ErrorCodes.GATEWAY_ERROR);
246
+ }
247
+ });
248
+
249
+ /**
250
+ * POST /api/agents
251
+ *
252
+ * Creates a new agent. Appends to agents.list in the config file.
253
+ * Requires a gateway restart.
254
+ *
255
+ * Body: { agent: { id, name?, model?, identity?, ... }, baseHash: string }
256
+ */
257
+ app.post('/api/agents', requirePremium('Agent management'), async (req, res) => {
258
+ try {
259
+ const { agent, baseHash } = req.body;
260
+
261
+ if (!agent?.id) {
262
+ return res.status(400).json({ ok: false, error: 'Missing agent.id' });
263
+ }
264
+ if (!baseHash) {
265
+ return res.status(400).json({ ok: false, error: 'Missing baseHash' });
266
+ }
267
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(agent.id)) {
268
+ return res.status(400).json({ ok: false, error: 'Agent ID must be lowercase alphanumeric + hyphens' });
269
+ }
270
+
271
+ const { config: gwConfig, hash: currentHash } = await readGatewayConfig();
272
+
273
+ if (currentHash !== baseHash) {
274
+ return res.status(409).json({ ok: false, error: 'Config modified externally. Refresh and retry.', currentHash });
275
+ }
276
+
277
+ const agentsList = gwConfig.agents?.list || [];
278
+
279
+ // Check for duplicate ID
280
+ if (agentsList.some(a => a.id === agent.id)) {
281
+ return res.status(409).json({ ok: false, error: `Agent "${agent.id}" already exists` });
282
+ }
283
+
284
+ // Build the new agent entry (only include defined fields)
285
+ const newAgent = { id: agent.id };
286
+ if (agent.name) newAgent.name = agent.name;
287
+ if (agent.model) newAgent.model = agent.model;
288
+ if (agent.identity) newAgent.identity = agent.identity;
289
+ if (agent.workspace) newAgent.workspace = agent.workspace;
290
+ if (agent.sandbox) newAgent.sandbox = agent.sandbox;
291
+ if (agent.tools) newAgent.tools = agent.tools;
292
+ if (agent.subagents) newAgent.subagents = agent.subagents;
293
+ if (agent.groupChat) newAgent.groupChat = agent.groupChat;
294
+
295
+ if (!gwConfig.agents) gwConfig.agents = {};
296
+ if (!gwConfig.agents.list) gwConfig.agents.list = [];
297
+ gwConfig.agents.list.push(newAgent);
298
+
299
+ const { hash: newHash } = await writeGatewayConfig(gwConfig);
300
+
301
+ // Signal gateway to restart (adding agents is a cold change)
302
+ signalGatewayReload();
303
+
304
+ log('info', `[Agents] Created agent "${agent.id}" — gateway restart triggered`);
305
+
306
+ res.json({
307
+ ok: true,
308
+ agent: newAgent,
309
+ hash: newHash,
310
+ requiresRestart: true,
311
+ });
312
+ } catch (err) {
313
+ log('error', `[Agents] Failed to create agent: ${err.message}`);
314
+ return internalError(res, err.message, ErrorCodes.GATEWAY_ERROR);
315
+ }
316
+ });
317
+
318
+ /**
319
+ * DELETE /api/agents/:agentId
320
+ *
321
+ * Removes an agent from agents.list. Requires gateway restart.
322
+ * Cannot delete the "main" agent or the last remaining agent.
323
+ */
324
+ app.delete('/api/agents/:agentId', requirePremium('Agent management'), async (req, res) => {
325
+ try {
326
+ const { agentId } = req.params;
327
+ const { baseHash } = req.body || {};
328
+
329
+ if (!baseHash) {
330
+ return res.status(400).json({ ok: false, error: 'Missing baseHash' });
331
+ }
332
+ if (agentId === 'main') {
333
+ return res.status(400).json({ ok: false, error: 'Cannot delete the main agent' });
334
+ }
335
+
336
+ const { config: gwConfig, hash: currentHash } = await readGatewayConfig();
337
+
338
+ if (currentHash !== baseHash) {
339
+ return res.status(409).json({ ok: false, error: 'Config modified externally. Refresh and retry.', currentHash });
340
+ }
341
+
342
+ const agentsList = gwConfig.agents?.list || [];
343
+ const agentIndex = agentsList.findIndex(a => a.id === agentId);
344
+
345
+ if (agentIndex === -1) {
346
+ return res.status(404).json({ ok: false, error: `Agent "${agentId}" not found` });
347
+ }
348
+
349
+ // Don't allow deleting if it's the only agent
350
+ if (agentsList.length <= 1) {
351
+ return res.status(400).json({ ok: false, error: 'Cannot delete the only agent' });
352
+ }
353
+
354
+ // Remove the agent
355
+ gwConfig.agents.list = agentsList.filter(a => a.id !== agentId);
356
+
357
+ // Also remove any bindings that point to this agent
358
+ if (gwConfig.bindings) {
359
+ gwConfig.bindings = gwConfig.bindings.filter(b => b.agentId !== agentId);
360
+ }
361
+
362
+ const { hash: newHash } = await writeGatewayConfig(gwConfig);
363
+
364
+ // Signal gateway to restart
365
+ signalGatewayReload();
366
+
367
+ log('info', `[Agents] Deleted agent "${agentId}" — gateway restart triggered`);
368
+
369
+ res.json({
370
+ ok: true,
371
+ hash: newHash,
372
+ requiresRestart: true,
373
+ });
374
+ } catch (err) {
375
+ log('error', `[Agents] Failed to delete agent "${req.params.agentId}": ${err.message}`);
376
+ return internalError(res, err.message, ErrorCodes.GATEWAY_ERROR);
377
+ }
378
+ });
379
+
380
+ /**
381
+ * PUT /api/bindings
382
+ *
383
+ * Replaces all bindings in the config file.
384
+ *
385
+ * Body: { bindings: [{ agentId, match: { channel?, accountId?, peer?, guildId?, teamId? } }], baseHash: string }
386
+ */
387
+ app.put('/api/bindings', requirePremium('Agent management'), async (req, res) => {
388
+ try {
389
+ const { bindings: newBindings, baseHash } = req.body;
390
+
391
+ if (!Array.isArray(newBindings)) {
392
+ return res.status(400).json({ ok: false, error: 'bindings must be an array' });
393
+ }
394
+ if (!baseHash) {
395
+ return res.status(400).json({ ok: false, error: 'Missing baseHash' });
396
+ }
397
+
398
+ // Validate each binding
399
+ for (const b of newBindings) {
400
+ if (!b.agentId || typeof b.agentId !== 'string') {
401
+ return res.status(400).json({ ok: false, error: 'Each binding must have an agentId' });
402
+ }
403
+ if (!b.match || typeof b.match !== 'object') {
404
+ return res.status(400).json({ ok: false, error: 'Each binding must have a match object' });
405
+ }
406
+ }
407
+
408
+ const { config: gwConfig, hash: currentHash } = await readGatewayConfig();
409
+
410
+ if (currentHash !== baseHash) {
411
+ return res.status(409).json({ ok: false, error: 'Config modified externally. Refresh and retry.', currentHash });
412
+ }
413
+
414
+ // Clean up the bindings — only include valid fields
415
+ const cleanBindings = newBindings.map(b => {
416
+ const clean = { agentId: b.agentId, match: {} };
417
+ if (b.match.channel) clean.match.channel = b.match.channel;
418
+ if (b.match.accountId) clean.match.accountId = b.match.accountId;
419
+ if (b.match.peer?.kind && b.match.peer?.id) {
420
+ clean.match.peer = { kind: b.match.peer.kind, id: b.match.peer.id };
421
+ }
422
+ if (b.match.guildId) clean.match.guildId = b.match.guildId;
423
+ if (b.match.teamId) clean.match.teamId = b.match.teamId;
424
+ return clean;
425
+ });
426
+
427
+ gwConfig.bindings = cleanBindings;
428
+
429
+ const { hash: newHash } = await writeGatewayConfig(gwConfig);
430
+
431
+ // Signal gateway to reload (bindings are hot-reloadable)
432
+ signalGatewayReload();
433
+
434
+ log('info', `[Agents] Updated bindings: ${cleanBindings.length} routes`);
435
+
436
+ res.json({
437
+ ok: true,
438
+ bindings: cleanBindings,
439
+ hash: newHash,
440
+ });
441
+ } catch (err) {
442
+ log('error', `[Agents] Failed to update bindings: ${err.message}`);
443
+ return internalError(res, err.message, ErrorCodes.GATEWAY_ERROR);
444
+ }
445
+ });
446
+
447
+ /**
448
+ * POST /api/agents/avatar
449
+ * POST /api/agents/:agentId/avatar
450
+ *
451
+ * Upload an agent avatar image. Saves as /public/img/agents/{agentId}.png.
452
+ * Accepts multipart form data with 'avatar' file and 'agentId' field.
453
+ */
454
+ const handleAvatarUpload = async (req, res) => {
455
+ try {
456
+ // Collect body chunks
457
+ const chunks = [];
458
+ for await (const chunk of req) {
459
+ chunks.push(chunk);
460
+ }
461
+ const body = Buffer.concat(chunks);
462
+
463
+ // Parse multipart boundary from content-type header
464
+ const contentType = req.headers['content-type'] || '';
465
+ const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
466
+ if (!boundaryMatch) {
467
+ return res.status(400).json({ ok: false, error: 'Missing multipart boundary' });
468
+ }
469
+ const boundary = boundaryMatch[1] || boundaryMatch[2];
470
+
471
+ // Split by boundary and find parts
472
+ const boundaryBuf = Buffer.from(`--${boundary}`);
473
+ let agentId = req.params.agentId || null; // Get from URL params if present
474
+ let fileData = null;
475
+
476
+ // Simple multipart parser
477
+ const parts = splitMultipart(body, boundaryBuf);
478
+ for (const part of parts) {
479
+ const headerEnd = part.indexOf('\r\n\r\n');
480
+ if (headerEnd === -1) continue;
481
+ const headers = part.subarray(0, headerEnd).toString();
482
+ const content = part.subarray(headerEnd + 4);
483
+
484
+ if (headers.includes('name="agentId"')) {
485
+ // Only override if not already set from URL params
486
+ if (!agentId) agentId = content.toString().trim();
487
+ } else if (headers.includes('name="avatar"')) {
488
+ // Strip trailing \r\n if present
489
+ fileData = content.at(-2) === 0x0d && content.at(-1) === 0x0a
490
+ ? content.subarray(0, content.length - 2)
491
+ : content;
492
+ }
493
+ }
494
+
495
+ if (!agentId || !fileData) {
496
+ return res.status(400).json({ ok: false, error: 'Missing agentId or avatar file' });
497
+ }
498
+
499
+ // Sanitize agentId to prevent path traversal
500
+ const cleanId = agentId.replace(/[^a-z0-9-]/g, '').substring(0, 64);
501
+ if (!cleanId) {
502
+ return res.status(400).json({ ok: false, error: 'Invalid agentId' });
503
+ }
504
+
505
+ // Ensure directory exists
506
+ await fs.mkdir(AVATARS_DIR, { recursive: true });
507
+
508
+ // Save as PNG (we accept any image, browser will handle display)
509
+ const filePath = path.join(AVATARS_DIR, `${cleanId}.png`);
510
+ const resolved = path.resolve(filePath);
511
+ if (!resolved.startsWith(path.resolve(AVATARS_DIR))) {
512
+ return res.status(400).json({ ok: false, error: 'Invalid path' });
513
+ }
514
+
515
+ await fs.writeFile(filePath, fileData);
516
+
517
+ // Resize to max 512x512 if ffmpeg is available (preserves aspect ratio)
518
+ try {
519
+ const tmpPath = filePath + '.tmp.png';
520
+ await new Promise((resolve, reject) => {
521
+ execFile('ffmpeg', ['-y', '-i', filePath, '-vf', 'scale=512:512:force_original_aspect_ratio=decrease', tmpPath], (err) => {
522
+ if (err) reject(err); else resolve();
523
+ });
524
+ });
525
+ await fs.rename(tmpPath, filePath);
526
+ const stat = await fs.stat(filePath);
527
+ log('info', `[Agents] Avatar saved for ${cleanId} (resized to max 512px, ${stat.size} bytes)`);
528
+ } catch {
529
+ // ffmpeg not available — keep original
530
+ log('info', `[Agents] Avatar saved for ${cleanId} (${fileData.length} bytes, no resize)`);
531
+ }
532
+
533
+ res.json({ ok: true, path: `/img/agents/${cleanId}.png` });
534
+ } catch (err) {
535
+ log('error', `[Agents] Avatar upload error: ${err.message}`);
536
+ return internalError(res, 'Upload failed', ErrorCodes.UPLOAD_FAILED);
537
+ }
538
+ };
539
+
540
+ // Register both route patterns
541
+ app.post('/api/agents/avatar', handleAvatarUpload);
542
+ app.post('/api/agents/:agentId/avatar', handleAvatarUpload);
543
+ }
544
+
545
+ /**
546
+ * Split a multipart buffer by boundary marker.
547
+ */
548
+ function splitMultipart(buf, boundary) {
549
+ const parts = [];
550
+ let start = 0;
551
+ while (true) {
552
+ const idx = buf.indexOf(boundary, start);
553
+ if (idx === -1) break;
554
+ if (start > 0) {
555
+ parts.push(buf.subarray(start, idx));
556
+ }
557
+ start = idx + boundary.length;
558
+ // Skip \r\n after boundary
559
+ if (buf[start] === 0x0d && buf[start + 1] === 0x0a) start += 2;
560
+ // Check for -- (end marker)
561
+ if (buf[start] === 0x2d && buf[start + 1] === 0x2d) break;
562
+ }
563
+ return parts;
564
+ }