@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- 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
|
+
}
|