hovclaw 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/dist/{doctor-I8YVuapp.js → doctor-D52M80De.js} +42 -4
- package/dist/gateway/ui/app.js +3 -3
- package/dist/gateway/ui/credentials.d.ts +1 -2
- package/dist/gateway/ui/credentials.js +5 -7
- package/dist/gateway/ui/index.html +177 -204
- package/dist/gateway/ui/styles.css +495 -101
- package/dist/hovclaw.js +1049 -236
- package/dist/index.js +2100 -504
- package/dist/{login-Ca1_XRup.js → login-BwvBMKdz.js} +2 -2
- package/dist/{onboard-Cgbgh2Jn.js → onboard-DL6VDf50.js} +43 -13
- package/dist/reset-BJUhrojJ.js +165 -0
- package/dist/{src-D_mIwpeq.js → src-Y6AqidKn.js} +1087 -259
- package/package.json +4 -1
- /package/dist/{oauth-6sxOTr3f.js → oauth-CQsXP0kP.js} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { D as
|
|
1
|
+
import { A as ensureConfigFromLegacyEnv, B as resolveTelegramAccountConfig, C as resolveModelAlias, D as parseGatewayFrame, E as parseConnectParams, F as hasConfigFile, L as loadConfig, N as getDefaultFileConfig, O as config, S as parseModelRef, T as PROTOCOL_VERSION, U as writeOpenClawMirror, V as saveConfigFile, _ as extractAssistantText, a as LocalHostRuntime, b as loadSkill, c as redactSensitiveData, d as PiAgentManager, f as composeSessionKey, g as extractAssistantError, h as resolveAgentWorkspaceDir, i as createTools, l as TelegramChannel, m as ensureWorkspaceBootstrapForConfig, o as ContainerRuntime, p as WORKSPACE_CONTEXT_FILE_ORDER, r as TelegramPairingStore, s as HovClawDb, t as requestDaemonRestartFromCurrentProcess, u as DiscordChannel, v as toUserFacingAssistantError, w as logger, x as listConfiguredModelRefs, y as listAvailableSkills, z as loadFileConfig } from "./hovclaw.js";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { ZodError, z } from "zod";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import WebSocket, { WebSocketServer } from "ws";
|
|
8
|
+
import fs$1 from "node:fs/promises";
|
|
8
9
|
import { createServer } from "node:http";
|
|
9
10
|
import { spawnSync } from "node:child_process";
|
|
10
11
|
import { Cron } from "croner";
|
|
@@ -157,98 +158,240 @@ function buildModelCatalog(aliases) {
|
|
|
157
158
|
}
|
|
158
159
|
|
|
159
160
|
//#endregion
|
|
160
|
-
//#region src/channels/
|
|
161
|
-
function
|
|
162
|
-
return
|
|
161
|
+
//#region src/channels/command-auth.ts
|
|
162
|
+
function normalizeAllowFromEntry(value) {
|
|
163
|
+
return String(value).trim().toLowerCase();
|
|
163
164
|
}
|
|
164
|
-
function
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
165
|
+
function senderCandidates(msg) {
|
|
166
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
167
|
+
const userId = msg.userId.trim();
|
|
168
|
+
if (userId) candidates.add(userId.toLowerCase());
|
|
169
|
+
const displayName = msg.displayName.trim();
|
|
170
|
+
if (displayName.startsWith("@")) candidates.add(displayName.toLowerCase());
|
|
171
|
+
return Array.from(candidates);
|
|
170
172
|
}
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
+
function resolveCommandsAllowFrom(config, channel) {
|
|
174
|
+
const entries = config.commands.allowFrom;
|
|
175
|
+
if (Object.keys(entries).length === 0) return null;
|
|
176
|
+
const providerSpecific = entries[channel];
|
|
177
|
+
if (Array.isArray(providerSpecific)) return providerSpecific;
|
|
178
|
+
const global = entries["*"];
|
|
179
|
+
if (Array.isArray(global)) return global;
|
|
180
|
+
return [];
|
|
173
181
|
}
|
|
174
|
-
function
|
|
175
|
-
const
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (allowSet.has("*")) return true;
|
|
181
|
-
const userId = msg.userId.trim().toLowerCase();
|
|
182
|
-
if (userId && allowSet.has(userId)) return true;
|
|
183
|
-
const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
|
|
184
|
-
if (displayName && allowSet.has(displayName)) return true;
|
|
182
|
+
function isCommandAuthorized(config, msg) {
|
|
183
|
+
const allowFrom = resolveCommandsAllowFrom(config, msg.channel);
|
|
184
|
+
if (allowFrom === null) return true;
|
|
185
|
+
const normalizedAllow = new Set(allowFrom.map((entry) => normalizeAllowFromEntry(entry)));
|
|
186
|
+
if (normalizedAllow.has("*")) return true;
|
|
187
|
+
for (const candidate of senderCandidates(msg)) if (normalizedAllow.has(candidate)) return true;
|
|
185
188
|
return false;
|
|
186
189
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
190
|
+
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/channels/commands-registry.ts
|
|
193
|
+
const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
|
|
194
|
+
const TELEGRAM_COMMAND_LIMIT = 100;
|
|
195
|
+
const BASE_COMMANDS = [
|
|
196
|
+
{
|
|
197
|
+
key: "help",
|
|
198
|
+
nativeName: "help",
|
|
199
|
+
description: "Show available commands"
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
key: "commands",
|
|
203
|
+
nativeName: "commands",
|
|
204
|
+
description: "List all slash commands"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: "status",
|
|
208
|
+
nativeName: "status",
|
|
209
|
+
description: "Show runtime status"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
key: "whoami",
|
|
213
|
+
nativeName: "whoami",
|
|
214
|
+
description: "Show your sender id"
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
key: "context",
|
|
218
|
+
nativeName: "context",
|
|
219
|
+
description: "Show prompt/workspace context info"
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
key: "model",
|
|
223
|
+
nativeName: "model",
|
|
224
|
+
description: "Show or set interactive model"
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
key: "models",
|
|
228
|
+
nativeName: "models",
|
|
229
|
+
description: "List available models"
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
key: "skill",
|
|
233
|
+
nativeName: "skill",
|
|
234
|
+
description: "Run a skill by name"
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
key: "skills",
|
|
238
|
+
nativeName: "skills",
|
|
239
|
+
description: "List installed skills"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
key: "identity",
|
|
243
|
+
nativeName: "identity",
|
|
244
|
+
description: "View or update IDENTITY.md"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
key: "soul",
|
|
248
|
+
nativeName: "soul",
|
|
249
|
+
description: "View or update SOUL.md"
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
key: "pair",
|
|
253
|
+
nativeName: "pair",
|
|
254
|
+
description: "Show pairing approval hint"
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
key: "new",
|
|
258
|
+
nativeName: "new",
|
|
259
|
+
description: "Start a fresh conversation session"
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
key: "reset",
|
|
263
|
+
nativeName: "reset",
|
|
264
|
+
description: "Reset current conversation session"
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
key: "think",
|
|
268
|
+
nativeName: "think",
|
|
269
|
+
description: "Set per-task or default thinking level"
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
key: "verbose",
|
|
273
|
+
nativeName: "verbose",
|
|
274
|
+
description: "Toggle verbose mode (hint)"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
key: "reasoning",
|
|
278
|
+
nativeName: "reasoning",
|
|
279
|
+
description: "Toggle reasoning visibility (hint)"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
key: "stop",
|
|
283
|
+
nativeName: "stop",
|
|
284
|
+
description: "Stop current run (not yet supported)"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
key: "config",
|
|
288
|
+
nativeName: "config",
|
|
289
|
+
description: "View or edit runtime config (if enabled)"
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
key: "debug",
|
|
293
|
+
nativeName: "debug",
|
|
294
|
+
description: "Inspect or change debug state (if enabled)"
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
key: "bash",
|
|
298
|
+
nativeName: "bash",
|
|
299
|
+
description: "Run a shell command through the assistant (if enabled)"
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
key: "restart",
|
|
303
|
+
nativeName: "restart",
|
|
304
|
+
description: "Restart daemon (if enabled)"
|
|
217
305
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (!
|
|
236
|
-
allowed: false,
|
|
237
|
-
reason: "group-not-allowlisted"
|
|
238
|
-
};
|
|
306
|
+
];
|
|
307
|
+
function sanitizeCommandName(raw) {
|
|
308
|
+
return raw.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32);
|
|
309
|
+
}
|
|
310
|
+
function coerceDescription(input, fallback) {
|
|
311
|
+
const trimmed = input.trim();
|
|
312
|
+
if (!trimmed) return fallback;
|
|
313
|
+
if (trimmed.length <= 256) return trimmed;
|
|
314
|
+
return `${trimmed.slice(0, 255)}…`;
|
|
315
|
+
}
|
|
316
|
+
function resolveSkillCommandName(skillName, used) {
|
|
317
|
+
const base = sanitizeCommandName(skillName) || "skill";
|
|
318
|
+
if (!used.has(base)) return base;
|
|
319
|
+
for (let i = 2; i < 500; i += 1) {
|
|
320
|
+
const suffix = `_${i}`;
|
|
321
|
+
const maxBaseLen = Math.max(1, 32 - suffix.length);
|
|
322
|
+
const candidate = `${base.slice(0, maxBaseLen)}${suffix}`;
|
|
323
|
+
if (!used.has(candidate)) return candidate;
|
|
239
324
|
}
|
|
240
|
-
|
|
241
|
-
allowed: false,
|
|
242
|
-
reason: "mention-required"
|
|
243
|
-
};
|
|
244
|
-
return { allowed: true };
|
|
325
|
+
return `skill_${used.size}`;
|
|
245
326
|
}
|
|
246
|
-
function
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
327
|
+
function resolveNativeEnabled(config, accountId) {
|
|
328
|
+
const accountMode = config.channels.telegram.accounts[accountId]?.commands;
|
|
329
|
+
if (typeof accountMode === "boolean") return accountMode;
|
|
330
|
+
if (typeof config.commands.native === "boolean") return config.commands.native;
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
function resolveNativeSkillsEnabled(config) {
|
|
334
|
+
if (typeof config.commands.nativeSkills === "boolean") return config.commands.nativeSkills;
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
function listSkillCommandSpecs(config) {
|
|
338
|
+
const installed = listAvailableSkills();
|
|
339
|
+
const used = /* @__PURE__ */ new Set();
|
|
340
|
+
for (const command of BASE_COMMANDS) used.add(command.nativeName);
|
|
341
|
+
const specs = [];
|
|
342
|
+
for (const skillName of installed) {
|
|
343
|
+
const loaded = loadSkill(skillName);
|
|
344
|
+
if (!loaded) continue;
|
|
345
|
+
const commandName = resolveSkillCommandName(skillName, used);
|
|
346
|
+
if (!TELEGRAM_COMMAND_NAME_RE.test(commandName)) continue;
|
|
347
|
+
used.add(commandName);
|
|
348
|
+
specs.push({
|
|
349
|
+
name: commandName,
|
|
350
|
+
skillName,
|
|
351
|
+
description: coerceDescription(loaded.frontmatter.description ?? `Run ${skillName} skill`, `Run ${skillName} skill`)
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return specs;
|
|
355
|
+
}
|
|
356
|
+
function listCustomCommandSpecs(config, accountId) {
|
|
357
|
+
const account = config.channels.telegram.accounts[accountId];
|
|
358
|
+
if (!account) return [];
|
|
359
|
+
const commands = Array.isArray(account.customCommands) ? account.customCommands : [];
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const entry of commands) {
|
|
362
|
+
const normalized = sanitizeCommandName(entry.command);
|
|
363
|
+
if (!TELEGRAM_COMMAND_NAME_RE.test(normalized)) continue;
|
|
364
|
+
out.push({
|
|
365
|
+
command: normalized,
|
|
366
|
+
description: coerceDescription(entry.description, `Run /${normalized}`)
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function listTelegramNativeCommandSpecs(config, accountId) {
|
|
372
|
+
if (!resolveNativeEnabled(config, accountId)) return [];
|
|
373
|
+
const out = BASE_COMMANDS.map((command) => ({
|
|
374
|
+
command: command.nativeName,
|
|
375
|
+
description: command.description
|
|
376
|
+
}));
|
|
377
|
+
if (resolveNativeSkillsEnabled(config)) out.push(...listSkillCommandSpecs(config).map((skill) => ({
|
|
378
|
+
command: skill.name,
|
|
379
|
+
description: skill.description
|
|
380
|
+
})));
|
|
381
|
+
out.push(...listCustomCommandSpecs(config, accountId));
|
|
382
|
+
const deduped = [];
|
|
383
|
+
const seen = /* @__PURE__ */ new Set();
|
|
384
|
+
for (const entry of out) {
|
|
385
|
+
if (!TELEGRAM_COMMAND_NAME_RE.test(entry.command)) continue;
|
|
386
|
+
if (seen.has(entry.command)) continue;
|
|
387
|
+
seen.add(entry.command);
|
|
388
|
+
deduped.push(entry);
|
|
389
|
+
if (deduped.length >= TELEGRAM_COMMAND_LIMIT) break;
|
|
390
|
+
}
|
|
391
|
+
return deduped;
|
|
392
|
+
}
|
|
393
|
+
function listBaseCommands() {
|
|
394
|
+
return [...BASE_COMMANDS];
|
|
252
395
|
}
|
|
253
396
|
|
|
254
397
|
//#endregion
|
|
@@ -339,11 +482,28 @@ function buildModelsKeyboard(params) {
|
|
|
339
482
|
|
|
340
483
|
//#endregion
|
|
341
484
|
//#region src/channels/telegram-commands.ts
|
|
485
|
+
const SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
486
|
+
const DEBUG_LOG_LEVELS = new Set([
|
|
487
|
+
"trace",
|
|
488
|
+
"debug",
|
|
489
|
+
"info",
|
|
490
|
+
"warn",
|
|
491
|
+
"error",
|
|
492
|
+
"fatal",
|
|
493
|
+
"silent"
|
|
494
|
+
]);
|
|
495
|
+
const BLOCKED_CONFIG_PATH_SEGMENTS = new Set([
|
|
496
|
+
"__proto__",
|
|
497
|
+
"prototype",
|
|
498
|
+
"constructor"
|
|
499
|
+
]);
|
|
342
500
|
function extractCommand(text) {
|
|
343
501
|
const trimmed = text.trim();
|
|
344
502
|
if (!trimmed.startsWith("/")) return null;
|
|
345
503
|
const segments = trimmed.split(/\s+/);
|
|
346
|
-
const
|
|
504
|
+
const raw = segments[0]?.slice(1).toLowerCase();
|
|
505
|
+
if (!raw) return null;
|
|
506
|
+
const command = raw.split("@")[0]?.trim();
|
|
347
507
|
if (!command) return null;
|
|
348
508
|
return {
|
|
349
509
|
command,
|
|
@@ -358,8 +518,125 @@ function callbackDataFromMessage(msg) {
|
|
|
358
518
|
function setInteractiveModel(modelRef) {
|
|
359
519
|
const next = loadFileConfig();
|
|
360
520
|
next.models.interactive = modelRef;
|
|
521
|
+
persistFileConfig(next);
|
|
522
|
+
}
|
|
523
|
+
function isRecord$1(value) {
|
|
524
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
525
|
+
}
|
|
526
|
+
function deepMerge$1(base, patch) {
|
|
527
|
+
const result = { ...base };
|
|
528
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
529
|
+
const existing = result[key];
|
|
530
|
+
if (isRecord$1(existing) && isRecord$1(value)) {
|
|
531
|
+
result[key] = deepMerge$1(existing, value);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
result[key] = value;
|
|
535
|
+
}
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
function reloadRuntimeConfig() {
|
|
539
|
+
const reloaded = loadConfig();
|
|
540
|
+
Object.assign(config, reloaded);
|
|
541
|
+
logger.level = reloaded.logLevel;
|
|
542
|
+
}
|
|
543
|
+
function persistFileConfig(next) {
|
|
361
544
|
saveConfigFile(next);
|
|
362
|
-
|
|
545
|
+
reloadRuntimeConfig();
|
|
546
|
+
writeOpenClawMirror(config);
|
|
547
|
+
}
|
|
548
|
+
function parseConfigPath(raw) {
|
|
549
|
+
const pathValue = raw?.trim();
|
|
550
|
+
if (!pathValue) return null;
|
|
551
|
+
const segments = pathValue.split(".").map((segment) => segment.trim()).filter(Boolean);
|
|
552
|
+
if (segments.length === 0) return null;
|
|
553
|
+
if (segments.some((segment) => BLOCKED_CONFIG_PATH_SEGMENTS.has(segment))) return null;
|
|
554
|
+
return segments;
|
|
555
|
+
}
|
|
556
|
+
function readPathValue(root, segments) {
|
|
557
|
+
let current = root;
|
|
558
|
+
for (const segment of segments) {
|
|
559
|
+
if (Array.isArray(current)) {
|
|
560
|
+
const index = Number.parseInt(segment, 10);
|
|
561
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false };
|
|
562
|
+
current = current[index];
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (!isRecord$1(current) || !(segment in current)) return { found: false };
|
|
566
|
+
current = current[segment];
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
found: true,
|
|
570
|
+
value: current
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function writePathValue(root, segments, value) {
|
|
574
|
+
if (segments.length === 0) return false;
|
|
575
|
+
let current = root;
|
|
576
|
+
for (let i = 0; i < segments.length - 1; i += 1) {
|
|
577
|
+
const segment = segments[i];
|
|
578
|
+
const nextSegment = segments[i + 1];
|
|
579
|
+
if (!segment || !nextSegment) return false;
|
|
580
|
+
if (Array.isArray(current)) {
|
|
581
|
+
const index = Number.parseInt(segment, 10);
|
|
582
|
+
if (!Number.isInteger(index) || index < 0) return false;
|
|
583
|
+
if (current[index] === void 0) current[index] = /^[0-9]+$/.test(nextSegment) ? [] : {};
|
|
584
|
+
current = current[index];
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (!isRecord$1(current)) return false;
|
|
588
|
+
const existing = current[segment];
|
|
589
|
+
if (existing === void 0 || !isRecord$1(existing) && !Array.isArray(existing)) current[segment] = /^[0-9]+$/.test(nextSegment) ? [] : {};
|
|
590
|
+
current = current[segment];
|
|
591
|
+
}
|
|
592
|
+
const lastSegment = segments[segments.length - 1];
|
|
593
|
+
if (!lastSegment) return false;
|
|
594
|
+
if (Array.isArray(current)) {
|
|
595
|
+
const index = Number.parseInt(lastSegment, 10);
|
|
596
|
+
if (!Number.isInteger(index) || index < 0) return false;
|
|
597
|
+
current[index] = value;
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
if (!isRecord$1(current)) return false;
|
|
601
|
+
current[lastSegment] = value;
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
function parseConfigValue(raw) {
|
|
605
|
+
const trimmed = raw.trim();
|
|
606
|
+
if (!trimmed) return "";
|
|
607
|
+
try {
|
|
608
|
+
return JSON.parse(trimmed);
|
|
609
|
+
} catch {
|
|
610
|
+
return raw;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function formatValue(value) {
|
|
614
|
+
if (typeof value === "string") return value;
|
|
615
|
+
return JSON.stringify(value, null, 2);
|
|
616
|
+
}
|
|
617
|
+
function configSummaryText(current) {
|
|
618
|
+
return [
|
|
619
|
+
"Config summary",
|
|
620
|
+
`assistant.name=${current.assistant.name}`,
|
|
621
|
+
`models.interactive=${current.models.interactive}`,
|
|
622
|
+
`models.discord=${current.models.discord}`,
|
|
623
|
+
`models.cron=${current.models.cron}`,
|
|
624
|
+
`runtime.mode=${current.runtime.mode}`,
|
|
625
|
+
`commands.text=${current.commands.text}`,
|
|
626
|
+
`commands.defaultThinkingLevel=${current.commands.defaultThinkingLevel}`,
|
|
627
|
+
`commands.config=${current.commands.config}`,
|
|
628
|
+
`commands.debug=${current.commands.debug}`,
|
|
629
|
+
`commands.bash=${current.commands.bash}`,
|
|
630
|
+
`commands.restart=${current.commands.restart}`,
|
|
631
|
+
`channels.telegram.enabled=${current.channels.telegram.enabled}`,
|
|
632
|
+
`channels.discord.enabled=${current.channels.discord.enabled}`,
|
|
633
|
+
"",
|
|
634
|
+
"Usage:",
|
|
635
|
+
"/config get <path>",
|
|
636
|
+
"/config set <path> <json-or-text>",
|
|
637
|
+
"/config patch <json-object>",
|
|
638
|
+
"/config reload"
|
|
639
|
+
].join("\n");
|
|
363
640
|
}
|
|
364
641
|
function groupedModels() {
|
|
365
642
|
return buildModelCatalog(config.models.aliases).reduce((acc, entry) => {
|
|
@@ -369,9 +646,57 @@ function groupedModels() {
|
|
|
369
646
|
return acc;
|
|
370
647
|
}, {});
|
|
371
648
|
}
|
|
649
|
+
function workspaceFilePath(agentId, fileName) {
|
|
650
|
+
return path.join(resolveAgentWorkspaceDir(config, agentId), fileName);
|
|
651
|
+
}
|
|
652
|
+
async function readWorkspaceFileOrEmpty(agentId, fileName) {
|
|
653
|
+
const filePath = workspaceFilePath(agentId, fileName);
|
|
654
|
+
try {
|
|
655
|
+
return await fs$1.readFile(filePath, "utf8");
|
|
656
|
+
} catch {
|
|
657
|
+
return "";
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
async function writeWorkspaceFile(agentId, fileName, content) {
|
|
661
|
+
const filePath = workspaceFilePath(agentId, fileName);
|
|
662
|
+
await fs$1.mkdir(path.dirname(filePath), { recursive: true });
|
|
663
|
+
await fs$1.writeFile(filePath, `${content.trimEnd()}\n`, "utf8");
|
|
664
|
+
}
|
|
665
|
+
function trimForTelegram(text, limit = 3600) {
|
|
666
|
+
if (text.length <= limit) return text;
|
|
667
|
+
return `${text.slice(0, limit)}\n\n[...truncated]`;
|
|
668
|
+
}
|
|
669
|
+
async function addSkillToAgent(agentId, skillName) {
|
|
670
|
+
const agentDir = path.join(config.agentsDir, agentId);
|
|
671
|
+
const agentPath = path.join(agentDir, "agent.json");
|
|
672
|
+
let parsed = {};
|
|
673
|
+
try {
|
|
674
|
+
const raw = await fs$1.readFile(agentPath, "utf8");
|
|
675
|
+
parsed = JSON.parse(raw);
|
|
676
|
+
} catch {
|
|
677
|
+
parsed = {};
|
|
678
|
+
}
|
|
679
|
+
const existingSkills = Array.isArray(parsed.skills) ? parsed.skills.filter((value) => typeof value === "string") : [];
|
|
680
|
+
if (existingSkills.includes(skillName)) return {
|
|
681
|
+
added: false,
|
|
682
|
+
path: agentPath
|
|
683
|
+
};
|
|
684
|
+
const nextSkills = [...existingSkills, skillName];
|
|
685
|
+
const next = {
|
|
686
|
+
...parsed,
|
|
687
|
+
skills: nextSkills
|
|
688
|
+
};
|
|
689
|
+
await fs$1.mkdir(agentDir, { recursive: true });
|
|
690
|
+
await fs$1.writeFile(agentPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
691
|
+
return {
|
|
692
|
+
added: true,
|
|
693
|
+
path: agentPath
|
|
694
|
+
};
|
|
695
|
+
}
|
|
372
696
|
async function handleModelCallback(context, callbackData) {
|
|
373
697
|
const parsed = parseModelCallbackData(callbackData);
|
|
374
698
|
if (!parsed) return false;
|
|
699
|
+
const authorized = isCommandAuthorized(config, context.msg);
|
|
375
700
|
const grouped = groupedModels();
|
|
376
701
|
const target = {
|
|
377
702
|
channel: "telegram",
|
|
@@ -403,6 +728,12 @@ async function handleModelCallback(context, callbackData) {
|
|
|
403
728
|
if (callbackId) await context.channel.answerCallbackQuery(callbackId);
|
|
404
729
|
return true;
|
|
405
730
|
}
|
|
731
|
+
if (!authorized) {
|
|
732
|
+
await context.channel.sendMessage(target, "You are not authorized to use this command.");
|
|
733
|
+
const callbackId = context.msg.raw?.callback_query?.id;
|
|
734
|
+
if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Not authorized");
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
406
737
|
const selectedRef = `${parsed.provider}:${parsed.model}`;
|
|
407
738
|
setInteractiveModel(selectedRef);
|
|
408
739
|
await context.channel.sendMessage(target, `Interactive model set to ${selectedRef}.`);
|
|
@@ -410,201 +741,575 @@ async function handleModelCallback(context, callbackData) {
|
|
|
410
741
|
if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Model updated");
|
|
411
742
|
return true;
|
|
412
743
|
}
|
|
744
|
+
function buildCommandsHelpText(accountId) {
|
|
745
|
+
const nativeCommands = listTelegramNativeCommandSpecs(config, accountId);
|
|
746
|
+
const fallbackCommands = [...listBaseCommands().map((command) => ({
|
|
747
|
+
command: command.nativeName,
|
|
748
|
+
description: command.description
|
|
749
|
+
})), ...listSkillCommandSpecs(config).map((command) => ({
|
|
750
|
+
command: command.name,
|
|
751
|
+
description: command.description
|
|
752
|
+
}))];
|
|
753
|
+
return ["Commands:", ...(nativeCommands.length > 0 ? nativeCommands : fallbackCommands).map((command) => `/${command.command} - ${command.description}`)].join("\n");
|
|
754
|
+
}
|
|
755
|
+
function resolveSkillPrompt(skillName, args) {
|
|
756
|
+
const input = args.join(" ").trim();
|
|
757
|
+
if (!input) return `Use the ${skillName} skill to help with the user's request.`;
|
|
758
|
+
return `Use the ${skillName} skill for this request: ${input}`;
|
|
759
|
+
}
|
|
760
|
+
function normalizeSkillName(raw) {
|
|
761
|
+
const name = raw?.trim();
|
|
762
|
+
if (!name) return null;
|
|
763
|
+
if (!SKILL_NAME_RE.test(name)) return null;
|
|
764
|
+
return name;
|
|
765
|
+
}
|
|
766
|
+
function parseThinkingLevel(raw) {
|
|
767
|
+
const value = raw?.trim().toLowerCase();
|
|
768
|
+
if (value === "low" || value === "medium" || value === "high") return value;
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
413
771
|
async function handleTelegramCommand(context) {
|
|
414
772
|
const callbackData = callbackDataFromMessage(context.msg);
|
|
415
|
-
if (callbackData) return await handleModelCallback(context, callbackData);
|
|
773
|
+
if (callbackData) return { handled: await handleModelCallback(context, callbackData) };
|
|
416
774
|
const parsed = extractCommand(context.msg.text);
|
|
417
|
-
if (!parsed) return false;
|
|
775
|
+
if (!parsed) return { handled: false };
|
|
776
|
+
if (!config.commands.text) return { handled: false };
|
|
418
777
|
const target = {
|
|
419
778
|
channel: "telegram",
|
|
420
779
|
chatId: context.msg.chatId,
|
|
421
780
|
accountId: context.msg.accountId
|
|
422
781
|
};
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
return true;
|
|
782
|
+
const authorized = isCommandAuthorized(config, context.msg);
|
|
783
|
+
const allowWithoutAuth = parsed.command === "help" || parsed.command === "commands";
|
|
784
|
+
if (!authorized && !allowWithoutAuth) {
|
|
785
|
+
await context.channel.sendMessage(target, "You are not authorized to use this command.");
|
|
786
|
+
return { handled: true };
|
|
787
|
+
}
|
|
788
|
+
if (parsed.command === "help" || parsed.command === "commands") {
|
|
789
|
+
const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
|
|
790
|
+
await context.channel.sendMessage(target, buildCommandsHelpText(accountId));
|
|
791
|
+
return { handled: true };
|
|
434
792
|
}
|
|
435
793
|
if (parsed.command === "status") {
|
|
436
794
|
const diagnostics = context.channel.getDiagnostics?.() ?? {};
|
|
437
795
|
await context.channel.sendMessage(target, `Status\nactiveSessions=${context.db.listSessions().length}\n${JSON.stringify(diagnostics)}`);
|
|
438
|
-
return true;
|
|
796
|
+
return { handled: true };
|
|
797
|
+
}
|
|
798
|
+
if (parsed.command === "whoami") {
|
|
799
|
+
const lines = [
|
|
800
|
+
"Identity",
|
|
801
|
+
`channel=${context.msg.channel}`,
|
|
802
|
+
`chatId=${context.msg.chatId}`,
|
|
803
|
+
`userId=${context.msg.userId}`,
|
|
804
|
+
`displayName=${context.msg.displayName}`,
|
|
805
|
+
`agent=${context.agentId}`
|
|
806
|
+
];
|
|
807
|
+
await context.channel.sendMessage(target, lines.join("\n"));
|
|
808
|
+
return { handled: true };
|
|
809
|
+
}
|
|
810
|
+
if (parsed.command === "context") {
|
|
811
|
+
const workspaceDir = resolveAgentWorkspaceDir(config, context.agentId);
|
|
812
|
+
const lines = [
|
|
813
|
+
`Agent: ${context.agentId}`,
|
|
814
|
+
`Workspace: ${workspaceDir}`,
|
|
815
|
+
"Context files:",
|
|
816
|
+
...WORKSPACE_CONTEXT_FILE_ORDER.map((file) => `- ${file}`)
|
|
817
|
+
];
|
|
818
|
+
await context.channel.sendMessage(target, lines.join("\n"));
|
|
819
|
+
return { handled: true };
|
|
439
820
|
}
|
|
440
821
|
if (parsed.command === "models") {
|
|
441
|
-
const lines = buildModelCatalog(config.models.aliases).slice(0,
|
|
822
|
+
const lines = buildModelCatalog(config.models.aliases).slice(0, 100).map((entry) => `- ${entry.ref}${entry.alias ? ` (alias: ${entry.alias})` : ""}`);
|
|
442
823
|
await context.channel.sendMessage(target, lines.length > 0 ? lines.join("\n") : "No models found.");
|
|
443
|
-
return true;
|
|
824
|
+
return { handled: true };
|
|
444
825
|
}
|
|
445
826
|
if (parsed.command === "model") {
|
|
446
|
-
const modelArg = parsed.args
|
|
827
|
+
const modelArg = parsed.args.join(" ").trim();
|
|
447
828
|
if (modelArg) {
|
|
448
829
|
setInteractiveModel(modelArg);
|
|
449
830
|
await context.channel.sendMessage(target, `Interactive model set to ${modelArg}.`);
|
|
450
|
-
return true;
|
|
831
|
+
return { handled: true };
|
|
451
832
|
}
|
|
452
833
|
const providerEntries = Object.entries(groupedModels()).map(([id, models]) => ({
|
|
453
834
|
id,
|
|
454
835
|
count: models.length
|
|
455
836
|
}));
|
|
456
837
|
await context.channel.sendRichMessage(target, `Current interactive model: ${config.models.interactive}`, { inlineKeyboard: providerEntries.length > 0 ? buildProviderKeyboard(providerEntries) : buildBrowseProvidersButton() });
|
|
457
|
-
return true;
|
|
838
|
+
return { handled: true };
|
|
458
839
|
}
|
|
459
840
|
if (parsed.command === "pair") {
|
|
460
|
-
if (!canApproveTelegramPairing({
|
|
461
|
-
config,
|
|
462
|
-
msg: context.msg
|
|
463
|
-
})) {
|
|
464
|
-
await context.channel.sendMessage(target, "Not authorized to approve pairing requests.");
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
467
|
-
const action = parsed.args[0]?.toLowerCase();
|
|
468
|
-
const code = parsed.args[1]?.trim().toUpperCase();
|
|
469
841
|
const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
842
|
+
const code = (parsed.args[1] ?? parsed.args[0])?.trim().toUpperCase() || "<code>";
|
|
843
|
+
await context.channel.sendMessage(target, `Pair approvals are CLI-only. Run: hovclaw pairing approve --channel telegram --account ${accountId} ${code}`);
|
|
844
|
+
return { handled: true };
|
|
845
|
+
}
|
|
846
|
+
if (parsed.command === "skills") {
|
|
847
|
+
const skills = listAvailableSkills();
|
|
848
|
+
if (skills.length === 0) {
|
|
849
|
+
await context.channel.sendMessage(target, "No installed skills found.");
|
|
850
|
+
return { handled: true };
|
|
851
|
+
}
|
|
852
|
+
const lines = skills.slice(0, 40).map((name) => {
|
|
853
|
+
return `- ${name}: ${loadSkill(name)?.frontmatter.description?.trim() || "No description"}`;
|
|
854
|
+
});
|
|
855
|
+
await context.channel.sendMessage(target, trimForTelegram(lines.join("\n")));
|
|
856
|
+
return { handled: true };
|
|
857
|
+
}
|
|
858
|
+
if (parsed.command === "identity" || parsed.command === "soul") {
|
|
859
|
+
const fileName = parsed.command === "identity" ? "IDENTITY.md" : "SOUL.md";
|
|
860
|
+
const subcommand = parsed.args[0]?.toLowerCase();
|
|
861
|
+
const body = parsed.args.slice(1).join(" ").trim();
|
|
862
|
+
if (!subcommand || subcommand === "view") {
|
|
863
|
+
const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
|
|
864
|
+
if (!current.trim()) {
|
|
865
|
+
await context.channel.sendMessage(target, `${fileName} is empty.`);
|
|
866
|
+
return { handled: true };
|
|
489
867
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (!
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
actor: "channel",
|
|
498
|
-
eventType: "telegram.pair.reject",
|
|
499
|
-
payload: {
|
|
500
|
-
accountId,
|
|
501
|
-
code,
|
|
502
|
-
userId: result.userId,
|
|
503
|
-
approver: context.msg.userId
|
|
504
|
-
}
|
|
505
|
-
});
|
|
506
|
-
await context.channel.sendMessage(target, `Rejected pairing for user ${result.userId}.`);
|
|
868
|
+
await context.channel.sendMessage(target, trimForTelegram(current));
|
|
869
|
+
return { handled: true };
|
|
870
|
+
}
|
|
871
|
+
if (subcommand === "set") {
|
|
872
|
+
if (!body) {
|
|
873
|
+
await context.channel.sendMessage(target, `Usage: /${parsed.command} set <content>`);
|
|
874
|
+
return { handled: true };
|
|
507
875
|
}
|
|
508
|
-
|
|
876
|
+
await writeWorkspaceFile(context.agentId, fileName, body);
|
|
877
|
+
await context.channel.sendMessage(target, `${fileName} updated.`);
|
|
878
|
+
return { handled: true };
|
|
509
879
|
}
|
|
510
|
-
|
|
511
|
-
|
|
880
|
+
if (subcommand === "append") {
|
|
881
|
+
if (!body) {
|
|
882
|
+
await context.channel.sendMessage(target, `Usage: /${parsed.command} append <content>`);
|
|
883
|
+
return { handled: true };
|
|
884
|
+
}
|
|
885
|
+
const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
|
|
886
|
+
const next = current.trim().length > 0 ? `${current.trimEnd()}\n${body}` : body;
|
|
887
|
+
await writeWorkspaceFile(context.agentId, fileName, next);
|
|
888
|
+
await context.channel.sendMessage(target, `${fileName} appended.`);
|
|
889
|
+
return { handled: true };
|
|
890
|
+
}
|
|
891
|
+
await context.channel.sendMessage(target, `Usage: /${parsed.command} [view]\n/${parsed.command} set <content>\n/${parsed.command} append <content>`);
|
|
892
|
+
return { handled: true };
|
|
512
893
|
}
|
|
513
|
-
|
|
894
|
+
if (parsed.command === "reset" || parsed.command === "new") {
|
|
895
|
+
const remainder = parsed.args.join(" ").trim();
|
|
896
|
+
if (!remainder) return {
|
|
897
|
+
handled: true,
|
|
898
|
+
resetSession: true
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
handled: false,
|
|
902
|
+
resetSession: true,
|
|
903
|
+
promptOverride: remainder
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (parsed.command === "skill") {
|
|
907
|
+
const sub = parsed.args[0]?.toLowerCase();
|
|
908
|
+
if (!sub) {
|
|
909
|
+
await context.channel.sendMessage(target, "Usage: /skill <name> [input] | /skill create <name> [description] | /skill add <name>");
|
|
910
|
+
return { handled: true };
|
|
911
|
+
}
|
|
912
|
+
if (sub === "create") {
|
|
913
|
+
const name = normalizeSkillName(parsed.args[1]);
|
|
914
|
+
if (!name) {
|
|
915
|
+
await context.channel.sendMessage(target, "Usage: /skill create <name> [description]");
|
|
916
|
+
return { handled: true };
|
|
917
|
+
}
|
|
918
|
+
const description = parsed.args.slice(2).join(" ").trim() || `Skill ${name}`;
|
|
919
|
+
const skillDir = path.join(config.skillsDir, name);
|
|
920
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
921
|
+
const scaffold = [
|
|
922
|
+
"---",
|
|
923
|
+
`name: ${name}`,
|
|
924
|
+
`description: ${description}`,
|
|
925
|
+
"---",
|
|
926
|
+
"",
|
|
927
|
+
`# ${name}`,
|
|
928
|
+
"",
|
|
929
|
+
"Describe how this skill should solve the task."
|
|
930
|
+
].join("\n");
|
|
931
|
+
try {
|
|
932
|
+
await fs$1.mkdir(skillDir, { recursive: false });
|
|
933
|
+
} catch {
|
|
934
|
+
await context.channel.sendMessage(target, `Skill already exists: ${name}`);
|
|
935
|
+
return { handled: true };
|
|
936
|
+
}
|
|
937
|
+
await fs$1.writeFile(skillPath, `${scaffold}\n`, "utf8");
|
|
938
|
+
await context.channel.sendMessage(target, `Created ${skillPath}`);
|
|
939
|
+
return { handled: true };
|
|
940
|
+
}
|
|
941
|
+
if (sub === "add") {
|
|
942
|
+
const skillName = normalizeSkillName(parsed.args[1]);
|
|
943
|
+
if (!skillName) {
|
|
944
|
+
await context.channel.sendMessage(target, "Usage: /skill add <name>");
|
|
945
|
+
return { handled: true };
|
|
946
|
+
}
|
|
947
|
+
const { added, path: agentPath } = await addSkillToAgent(context.agentId, skillName);
|
|
948
|
+
await context.channel.sendMessage(target, added ? `Added ${skillName} to ${agentPath}` : `${skillName} is already enabled for ${context.agentId}.`);
|
|
949
|
+
return { handled: true };
|
|
950
|
+
}
|
|
951
|
+
const targetSkill = normalizeSkillName(sub);
|
|
952
|
+
if (!targetSkill) {
|
|
953
|
+
await context.channel.sendMessage(target, "Invalid skill name.");
|
|
954
|
+
return { handled: true };
|
|
955
|
+
}
|
|
956
|
+
if (!new Set(listAvailableSkills()).has(targetSkill)) {
|
|
957
|
+
await context.channel.sendMessage(target, `Skill not found: ${targetSkill}. Run /skills to list installed skills or /skill create ${targetSkill}.`);
|
|
958
|
+
return { handled: true };
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
handled: false,
|
|
962
|
+
promptOverride: resolveSkillPrompt(targetSkill, parsed.args.slice(1))
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
const skillCommand = listSkillCommandSpecs(config).find((entry) => entry.name === parsed.command);
|
|
966
|
+
if (skillCommand) return {
|
|
967
|
+
handled: false,
|
|
968
|
+
promptOverride: resolveSkillPrompt(skillCommand.skillName, parsed.args)
|
|
969
|
+
};
|
|
970
|
+
if (parsed.command === "think") {
|
|
971
|
+
if (parsed.args.length === 0) {
|
|
972
|
+
await context.channel.sendMessage(target, [
|
|
973
|
+
`Default thinking level: ${config.commands.defaultThinkingLevel}`,
|
|
974
|
+
"Usage:",
|
|
975
|
+
"/think <low|medium|high> <task>",
|
|
976
|
+
"/think default <low|medium|high>"
|
|
977
|
+
].join("\n"));
|
|
978
|
+
return { handled: true };
|
|
979
|
+
}
|
|
980
|
+
if (parsed.args[0]?.trim().toLowerCase() === "default") {
|
|
981
|
+
const requested = parseThinkingLevel(parsed.args[1]);
|
|
982
|
+
if (!requested) {
|
|
983
|
+
await context.channel.sendMessage(target, `Current default thinking level: ${config.commands.defaultThinkingLevel}\nUsage: /think default <low|medium|high>`);
|
|
984
|
+
return { handled: true };
|
|
985
|
+
}
|
|
986
|
+
const next = loadFileConfig();
|
|
987
|
+
next.commands.defaultThinkingLevel = requested;
|
|
988
|
+
try {
|
|
989
|
+
persistFileConfig(next);
|
|
990
|
+
} catch (error) {
|
|
991
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
992
|
+
await context.channel.sendMessage(target, `Failed to update default thinking level: ${message}`);
|
|
993
|
+
return { handled: true };
|
|
994
|
+
}
|
|
995
|
+
await context.channel.sendMessage(target, `Default thinking level set to ${requested}.`);
|
|
996
|
+
return { handled: true };
|
|
997
|
+
}
|
|
998
|
+
const level = parseThinkingLevel(parsed.args[0]);
|
|
999
|
+
if (!level || parsed.args.length < 2) {
|
|
1000
|
+
await context.channel.sendMessage(target, "Usage: /think <low|medium|high> <task>\nOr set default: /think default <low|medium|high>");
|
|
1001
|
+
return { handled: true };
|
|
1002
|
+
}
|
|
1003
|
+
return {
|
|
1004
|
+
handled: false,
|
|
1005
|
+
promptOverride: parsed.args.slice(1).join(" ").trim(),
|
|
1006
|
+
thinkingLevelOverride: level
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
if (parsed.command === "reasoning") {
|
|
1010
|
+
if (parsed.args.length < 2) {
|
|
1011
|
+
await context.channel.sendMessage(target, "Usage: /reasoning <on|off> <task>\nThis command is per-message and not persisted.");
|
|
1012
|
+
return { handled: true };
|
|
1013
|
+
}
|
|
1014
|
+
const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
|
|
1015
|
+
const task = parsed.args.slice(1).join(" ").trim();
|
|
1016
|
+
return {
|
|
1017
|
+
handled: false,
|
|
1018
|
+
promptOverride: mode === "on" ? `Solve this task and include concise reasoning in your response: ${task}` : `Solve this task and return only final output without exposing internal reasoning: ${task}`
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
if (parsed.command === "verbose") {
|
|
1022
|
+
if (parsed.args.length < 2) {
|
|
1023
|
+
await context.channel.sendMessage(target, "Usage: /verbose <on|off> <task>\nThis command is per-message and not persisted.");
|
|
1024
|
+
return { handled: true };
|
|
1025
|
+
}
|
|
1026
|
+
const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
|
|
1027
|
+
const task = parsed.args.slice(1).join(" ").trim();
|
|
1028
|
+
return {
|
|
1029
|
+
handled: false,
|
|
1030
|
+
promptOverride: mode === "on" ? `Answer in a detailed, explicit style for this request: ${task}` : `Answer briefly and directly for this request: ${task}`
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
if (parsed.command === "stop") {
|
|
1034
|
+
await context.channel.sendMessage(target, "Stopping an in-flight run is not supported yet. Use /new to start a fresh session.");
|
|
1035
|
+
return { handled: true };
|
|
1036
|
+
}
|
|
1037
|
+
if (parsed.command === "restart") {
|
|
1038
|
+
if (!config.commands.restart) {
|
|
1039
|
+
await context.channel.sendMessage(target, "Restart command is disabled. Set commands.restart=true to enable.");
|
|
1040
|
+
return { handled: true };
|
|
1041
|
+
}
|
|
1042
|
+
await context.channel.sendMessage(target, "Restart requested. Applying restart now.");
|
|
1043
|
+
try {
|
|
1044
|
+
if ((await requestDaemonRestartFromCurrentProcess()).mode === "spawn-reexec") setTimeout(() => {
|
|
1045
|
+
try {
|
|
1046
|
+
process.kill(process.pid, "SIGTERM");
|
|
1047
|
+
} catch {}
|
|
1048
|
+
}, 250);
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1051
|
+
await context.channel.sendMessage(target, `Restart failed: ${message}`);
|
|
1052
|
+
}
|
|
1053
|
+
return { handled: true };
|
|
1054
|
+
}
|
|
1055
|
+
if (parsed.command === "config") {
|
|
1056
|
+
if (!config.commands.config) {
|
|
1057
|
+
await context.channel.sendMessage(target, "/config is disabled. Set commands.config=true to enable.");
|
|
1058
|
+
return { handled: true };
|
|
1059
|
+
}
|
|
1060
|
+
const sub = parsed.args[0]?.toLowerCase() ?? "show";
|
|
1061
|
+
if (sub === "show" || sub === "status") {
|
|
1062
|
+
const current = loadFileConfig();
|
|
1063
|
+
await context.channel.sendMessage(target, trimForTelegram(configSummaryText(current)));
|
|
1064
|
+
return { handled: true };
|
|
1065
|
+
}
|
|
1066
|
+
if (sub === "reload") {
|
|
1067
|
+
try {
|
|
1068
|
+
reloadRuntimeConfig();
|
|
1069
|
+
} catch (error) {
|
|
1070
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1071
|
+
await context.channel.sendMessage(target, `Failed to reload config: ${message}`);
|
|
1072
|
+
return { handled: true };
|
|
1073
|
+
}
|
|
1074
|
+
await context.channel.sendMessage(target, "Reloaded runtime config from disk.");
|
|
1075
|
+
return { handled: true };
|
|
1076
|
+
}
|
|
1077
|
+
if (sub === "get") {
|
|
1078
|
+
const pathSegments = parseConfigPath(parsed.args[1]);
|
|
1079
|
+
if (!pathSegments) {
|
|
1080
|
+
await context.channel.sendMessage(target, "Usage: /config get <path>");
|
|
1081
|
+
return { handled: true };
|
|
1082
|
+
}
|
|
1083
|
+
const found = readPathValue(loadFileConfig(), pathSegments);
|
|
1084
|
+
if (!found.found) {
|
|
1085
|
+
await context.channel.sendMessage(target, `Path not found: ${parsed.args[1]}`);
|
|
1086
|
+
return { handled: true };
|
|
1087
|
+
}
|
|
1088
|
+
const pretty = formatValue(found.value);
|
|
1089
|
+
await context.channel.sendMessage(target, trimForTelegram(`${parsed.args[1]} = ${pretty}`));
|
|
1090
|
+
return { handled: true };
|
|
1091
|
+
}
|
|
1092
|
+
if (sub === "set") {
|
|
1093
|
+
const pathSegments = parseConfigPath(parsed.args[1]);
|
|
1094
|
+
const valueRaw = parsed.args.slice(2).join(" ");
|
|
1095
|
+
if (!pathSegments || !valueRaw.trim()) {
|
|
1096
|
+
await context.channel.sendMessage(target, "Usage: /config set <path> <json-or-text>");
|
|
1097
|
+
return { handled: true };
|
|
1098
|
+
}
|
|
1099
|
+
const next = loadFileConfig();
|
|
1100
|
+
if (!writePathValue(next, pathSegments, parseConfigValue(valueRaw))) {
|
|
1101
|
+
await context.channel.sendMessage(target, `Could not update path: ${parsed.args[1]}`);
|
|
1102
|
+
return { handled: true };
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
persistFileConfig(next);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1108
|
+
await context.channel.sendMessage(target, `Config update failed: ${message}`);
|
|
1109
|
+
return { handled: true };
|
|
1110
|
+
}
|
|
1111
|
+
await context.channel.sendMessage(target, `Updated ${parsed.args[1]}.`);
|
|
1112
|
+
return { handled: true };
|
|
1113
|
+
}
|
|
1114
|
+
if (sub === "patch") {
|
|
1115
|
+
const patchRaw = parsed.args.slice(1).join(" ").trim();
|
|
1116
|
+
if (!patchRaw) {
|
|
1117
|
+
await context.channel.sendMessage(target, "Usage: /config patch <json-object>");
|
|
1118
|
+
return { handled: true };
|
|
1119
|
+
}
|
|
1120
|
+
let patch;
|
|
1121
|
+
try {
|
|
1122
|
+
patch = JSON.parse(patchRaw);
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1125
|
+
await context.channel.sendMessage(target, `Invalid JSON patch: ${message}`);
|
|
1126
|
+
return { handled: true };
|
|
1127
|
+
}
|
|
1128
|
+
if (!isRecord$1(patch)) {
|
|
1129
|
+
await context.channel.sendMessage(target, "Patch must be a JSON object.");
|
|
1130
|
+
return { handled: true };
|
|
1131
|
+
}
|
|
1132
|
+
const merged = deepMerge$1(loadFileConfig(), patch);
|
|
1133
|
+
try {
|
|
1134
|
+
persistFileConfig(merged);
|
|
1135
|
+
} catch (error) {
|
|
1136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1137
|
+
await context.channel.sendMessage(target, `Config patch failed: ${message}`);
|
|
1138
|
+
return { handled: true };
|
|
1139
|
+
}
|
|
1140
|
+
await context.channel.sendMessage(target, "Config patch applied.");
|
|
1141
|
+
return { handled: true };
|
|
1142
|
+
}
|
|
1143
|
+
await context.channel.sendMessage(target, "Usage: /config show | get <path> | set <path> <value> | patch <json-object> | reload");
|
|
1144
|
+
return { handled: true };
|
|
1145
|
+
}
|
|
1146
|
+
if (parsed.command === "debug") {
|
|
1147
|
+
const sub = parsed.args[0]?.toLowerCase() ?? "status";
|
|
1148
|
+
const canSelfEnable = sub === "on";
|
|
1149
|
+
if (!config.commands.debug && !canSelfEnable) {
|
|
1150
|
+
await context.channel.sendMessage(target, "/debug is disabled. Set commands.debug=true to enable.");
|
|
1151
|
+
return { handled: true };
|
|
1152
|
+
}
|
|
1153
|
+
if (sub === "on" || sub === "off") {
|
|
1154
|
+
const next = loadFileConfig();
|
|
1155
|
+
next.commands.debug = sub === "on";
|
|
1156
|
+
try {
|
|
1157
|
+
persistFileConfig(next);
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1160
|
+
await context.channel.sendMessage(target, `Failed to update debug mode: ${message}`);
|
|
1161
|
+
return { handled: true };
|
|
1162
|
+
}
|
|
1163
|
+
await context.channel.sendMessage(target, `commands.debug=${next.commands.debug}`);
|
|
1164
|
+
return { handled: true };
|
|
1165
|
+
}
|
|
1166
|
+
if (sub === "level") {
|
|
1167
|
+
const requested = parsed.args[1]?.trim().toLowerCase();
|
|
1168
|
+
if (!requested || !DEBUG_LOG_LEVELS.has(requested)) {
|
|
1169
|
+
await context.channel.sendMessage(target, "Usage: /debug level <trace|debug|info|warn|error|fatal|silent>");
|
|
1170
|
+
return { handled: true };
|
|
1171
|
+
}
|
|
1172
|
+
logger.level = requested;
|
|
1173
|
+
process.env.LOG_LEVEL = requested;
|
|
1174
|
+
await context.channel.sendMessage(target, `Logger level set to ${requested} (runtime only).`);
|
|
1175
|
+
return { handled: true };
|
|
1176
|
+
}
|
|
1177
|
+
if (sub === "dump") {
|
|
1178
|
+
const payload = {
|
|
1179
|
+
pid: process.pid,
|
|
1180
|
+
uptimeSec: Math.floor(process.uptime()),
|
|
1181
|
+
loggerLevel: logger.level,
|
|
1182
|
+
commands: config.commands,
|
|
1183
|
+
activeSessions: context.db.listSessions().length,
|
|
1184
|
+
channelDiagnostics: context.channel.getDiagnostics?.() ?? {}
|
|
1185
|
+
};
|
|
1186
|
+
await context.channel.sendMessage(target, trimForTelegram(JSON.stringify(payload, null, 2)));
|
|
1187
|
+
return { handled: true };
|
|
1188
|
+
}
|
|
1189
|
+
if (sub === "status") {
|
|
1190
|
+
const lines = [
|
|
1191
|
+
"Debug status",
|
|
1192
|
+
`pid=${process.pid}`,
|
|
1193
|
+
`uptimeSec=${Math.floor(process.uptime())}`,
|
|
1194
|
+
`logger.level=${logger.level}`,
|
|
1195
|
+
`commands.debug=${config.commands.debug}`,
|
|
1196
|
+
`commands.config=${config.commands.config}`,
|
|
1197
|
+
`commands.bash=${config.commands.bash}`,
|
|
1198
|
+
`commands.restart=${config.commands.restart}`,
|
|
1199
|
+
`activeSessions=${context.db.listSessions().length}`,
|
|
1200
|
+
"",
|
|
1201
|
+
"Usage:",
|
|
1202
|
+
"/debug status",
|
|
1203
|
+
"/debug level <trace|debug|info|warn|error|fatal|silent>",
|
|
1204
|
+
"/debug dump",
|
|
1205
|
+
"/debug on|off"
|
|
1206
|
+
];
|
|
1207
|
+
await context.channel.sendMessage(target, lines.join("\n"));
|
|
1208
|
+
return { handled: true };
|
|
1209
|
+
}
|
|
1210
|
+
await context.channel.sendMessage(target, "Usage: /debug status | level <...> | dump | on | off");
|
|
1211
|
+
return { handled: true };
|
|
1212
|
+
}
|
|
1213
|
+
if (parsed.command === "bash") {
|
|
1214
|
+
if (!config.commands.bash) {
|
|
1215
|
+
await context.channel.sendMessage(target, "/bash is disabled. Set commands.bash=true to enable.");
|
|
1216
|
+
return { handled: true };
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
handled: false,
|
|
1220
|
+
promptOverride: `Run this shell command safely: ${parsed.args.join(" ").trim()}`
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
return { handled: false };
|
|
514
1224
|
}
|
|
515
1225
|
|
|
516
1226
|
//#endregion
|
|
517
|
-
//#region src/channels/telegram-
|
|
518
|
-
function
|
|
519
|
-
return (
|
|
520
|
-
}
|
|
521
|
-
function
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
1227
|
+
//#region src/channels/telegram-policy.ts
|
|
1228
|
+
function toIdSet(values) {
|
|
1229
|
+
return new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean));
|
|
1230
|
+
}
|
|
1231
|
+
function splitThread(chatId) {
|
|
1232
|
+
const [baseChatIdRaw, threadIdRaw] = chatId.split("#");
|
|
1233
|
+
return {
|
|
1234
|
+
baseChatId: baseChatIdRaw ?? chatId,
|
|
1235
|
+
...threadIdRaw ? { threadId: threadIdRaw } : {}
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
function isDirectMessage(msg) {
|
|
1239
|
+
return msg.peer?.kind === "direct" || !msg.chatId.startsWith("-") && !msg.chatId.includes("#");
|
|
1240
|
+
}
|
|
1241
|
+
function containsMention(text, assistantName) {
|
|
1242
|
+
const normalized = assistantName.trim().toLowerCase();
|
|
1243
|
+
if (!normalized) return false;
|
|
1244
|
+
return text.toLowerCase().includes(`@${normalized}`);
|
|
1245
|
+
}
|
|
1246
|
+
function isAllowListed(msg, allowSet) {
|
|
1247
|
+
if (allowSet.has("*")) return true;
|
|
1248
|
+
const userId = msg.userId.trim().toLowerCase();
|
|
1249
|
+
if (userId && allowSet.has(userId)) return true;
|
|
1250
|
+
const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
|
|
1251
|
+
if (displayName && allowSet.has(displayName)) return true;
|
|
1252
|
+
return false;
|
|
1253
|
+
}
|
|
1254
|
+
function evaluateTelegramPolicy(params) {
|
|
1255
|
+
const { config, msg, pairingStore } = params;
|
|
1256
|
+
const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
|
|
1257
|
+
const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
|
|
1258
|
+
if (!account || account.enabled === false) return {
|
|
1259
|
+
allowed: false,
|
|
1260
|
+
reason: "account-disabled"
|
|
1261
|
+
};
|
|
1262
|
+
const direct = isDirectMessage(msg);
|
|
1263
|
+
const allowFromSet = toIdSet(account.allowFrom);
|
|
1264
|
+
if (direct) {
|
|
1265
|
+
const dmPolicy = account.dmPolicy ?? "pairing";
|
|
1266
|
+
if (dmPolicy === "disabled") return {
|
|
1267
|
+
allowed: false,
|
|
1268
|
+
reason: "dm-disabled"
|
|
1269
|
+
};
|
|
1270
|
+
if (dmPolicy === "open") return { allowed: true };
|
|
1271
|
+
if (dmPolicy === "allowlist") {
|
|
1272
|
+
if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
|
|
1273
|
+
return {
|
|
1274
|
+
allowed: false,
|
|
1275
|
+
reason: "dm-not-allowlisted"
|
|
536
1276
|
};
|
|
537
|
-
return this.state;
|
|
538
1277
|
}
|
|
539
|
-
|
|
540
|
-
const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
541
|
-
this.state = {
|
|
542
|
-
approved: parsed.approved ?? {},
|
|
543
|
-
pending: parsed.pending ?? {}
|
|
544
|
-
};
|
|
545
|
-
return this.state;
|
|
546
|
-
} catch {
|
|
547
|
-
this.state = {
|
|
548
|
-
approved: {},
|
|
549
|
-
pending: {}
|
|
550
|
-
};
|
|
551
|
-
return this.state;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
save() {
|
|
555
|
-
if (!this.state) return;
|
|
556
|
-
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
557
|
-
fs.writeFileSync(this.filePath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
|
|
558
|
-
}
|
|
559
|
-
isApproved(accountId, userId) {
|
|
560
|
-
return (this.load().approved[accountId] ?? []).includes(userId);
|
|
561
|
-
}
|
|
562
|
-
ensurePendingCode(accountId, userId) {
|
|
563
|
-
const state = this.load();
|
|
564
|
-
const pendingByAccount = state.pending[accountId] ?? {};
|
|
565
|
-
const existing = Object.entries(pendingByAccount).find(([, entry]) => entry.userId === userId);
|
|
566
|
-
if (existing) return existing[0];
|
|
567
|
-
const code = randomCode();
|
|
568
|
-
state.pending[accountId] = {
|
|
569
|
-
...pendingByAccount,
|
|
570
|
-
[code]: {
|
|
571
|
-
userId,
|
|
572
|
-
createdAt: nowIso()
|
|
573
|
-
}
|
|
574
|
-
};
|
|
575
|
-
this.save();
|
|
576
|
-
return code;
|
|
577
|
-
}
|
|
578
|
-
approveByCode(accountId, code) {
|
|
579
|
-
const state = this.load();
|
|
580
|
-
const pendingByAccount = state.pending[accountId] ?? {};
|
|
581
|
-
const entry = pendingByAccount[code];
|
|
582
|
-
if (!entry) return { ok: false };
|
|
583
|
-
const approved = new Set(state.approved[accountId] ?? []);
|
|
584
|
-
approved.add(entry.userId);
|
|
585
|
-
state.approved[accountId] = Array.from(approved).sort((a, b) => a.localeCompare(b));
|
|
586
|
-
delete pendingByAccount[code];
|
|
587
|
-
state.pending[accountId] = pendingByAccount;
|
|
588
|
-
this.save();
|
|
1278
|
+
if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
|
|
589
1279
|
return {
|
|
590
|
-
|
|
591
|
-
|
|
1280
|
+
allowed: false,
|
|
1281
|
+
reason: "pairing-required",
|
|
1282
|
+
pairingCode: pairingStore.ensurePendingCode(accountId, msg.userId)
|
|
592
1283
|
};
|
|
593
1284
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
1285
|
+
const { baseChatId, threadId } = splitThread(msg.chatId);
|
|
1286
|
+
const groupConfig = account.groups?.[baseChatId];
|
|
1287
|
+
const topicConfig = threadId ? groupConfig?.topics?.[threadId] : void 0;
|
|
1288
|
+
if (groupConfig?.enabled === false) return {
|
|
1289
|
+
allowed: false,
|
|
1290
|
+
reason: "group-disabled"
|
|
1291
|
+
};
|
|
1292
|
+
if (topicConfig?.enabled === false) return {
|
|
1293
|
+
allowed: false,
|
|
1294
|
+
reason: "topic-disabled"
|
|
1295
|
+
};
|
|
1296
|
+
const groupPolicy = topicConfig?.groupPolicy ?? groupConfig?.groupPolicy ?? account.groupPolicy ?? "open";
|
|
1297
|
+
if (groupPolicy === "disabled") return {
|
|
1298
|
+
allowed: false,
|
|
1299
|
+
reason: "group-disabled"
|
|
1300
|
+
};
|
|
1301
|
+
if (groupPolicy === "allowlist") {
|
|
1302
|
+
if (!isAllowListed(msg, toIdSet(topicConfig?.allowFrom ?? groupConfig?.allowFrom ?? account.groupAllowFrom))) return {
|
|
1303
|
+
allowed: false,
|
|
1304
|
+
reason: "group-not-allowlisted"
|
|
605
1305
|
};
|
|
606
1306
|
}
|
|
607
|
-
|
|
1307
|
+
if ((topicConfig?.requireMention ?? groupConfig?.requireMention ?? false) && !containsMention(msg.text, config.assistantName)) return {
|
|
1308
|
+
allowed: false,
|
|
1309
|
+
reason: "mention-required"
|
|
1310
|
+
};
|
|
1311
|
+
return { allowed: true };
|
|
1312
|
+
}
|
|
608
1313
|
|
|
609
1314
|
//#endregion
|
|
610
1315
|
//#region src/gateway/methods/agent.ts
|
|
@@ -773,7 +1478,7 @@ function deepMerge(base, patch) {
|
|
|
773
1478
|
return result;
|
|
774
1479
|
}
|
|
775
1480
|
const configGetMethod = async (_params, context) => {
|
|
776
|
-
return context.readFileConfig();
|
|
1481
|
+
return redactSensitiveData(context.readFileConfig());
|
|
777
1482
|
};
|
|
778
1483
|
const configSetMethod = async (params, context) => {
|
|
779
1484
|
const parsed = configSetParamsSchema.parse(params);
|
|
@@ -855,7 +1560,7 @@ const logsTailMethod = async (params, context) => {
|
|
|
855
1560
|
message: event.eventType,
|
|
856
1561
|
sessionKey: event.sessionKey,
|
|
857
1562
|
actor: event.actor,
|
|
858
|
-
payload: event.payload
|
|
1563
|
+
payload: redactSensitiveData(event.payload)
|
|
859
1564
|
})) };
|
|
860
1565
|
};
|
|
861
1566
|
|
|
@@ -1102,7 +1807,46 @@ function sendResponse(socket, id, ok, payload, error) {
|
|
|
1102
1807
|
function setUiSecurityHeaders(res) {
|
|
1103
1808
|
res.setHeader("X-Frame-Options", "DENY");
|
|
1104
1809
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1105
|
-
res.setHeader("Content-Security-Policy",
|
|
1810
|
+
res.setHeader("Content-Security-Policy", [
|
|
1811
|
+
"default-src 'self'",
|
|
1812
|
+
"script-src 'self'",
|
|
1813
|
+
"style-src 'self'",
|
|
1814
|
+
"img-src 'self' data:",
|
|
1815
|
+
"font-src 'self'",
|
|
1816
|
+
"connect-src 'self'",
|
|
1817
|
+
"object-src 'none'",
|
|
1818
|
+
"base-uri 'none'",
|
|
1819
|
+
"frame-ancestors 'none'",
|
|
1820
|
+
"form-action 'none'"
|
|
1821
|
+
].join("; "));
|
|
1822
|
+
}
|
|
1823
|
+
function normalizeOrigin(value) {
|
|
1824
|
+
try {
|
|
1825
|
+
const parsed = new URL(value);
|
|
1826
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
1827
|
+
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
|
1828
|
+
} catch {
|
|
1829
|
+
return null;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
function isOriginAllowed(request, config) {
|
|
1833
|
+
const rawOrigin = request.headers.origin;
|
|
1834
|
+
if (typeof rawOrigin !== "string" || rawOrigin.trim().length === 0) return true;
|
|
1835
|
+
const normalizedOrigin = normalizeOrigin(rawOrigin.trim());
|
|
1836
|
+
if (!normalizedOrigin) return false;
|
|
1837
|
+
const rawHost = request.headers.host;
|
|
1838
|
+
if (typeof rawHost === "string" && rawHost.trim().length > 0) {
|
|
1839
|
+
const normalizedHost = rawHost.trim().toLowerCase();
|
|
1840
|
+
if (normalizedOrigin === `http://${normalizedHost}` || normalizedOrigin === `https://${normalizedHost}`) return true;
|
|
1841
|
+
}
|
|
1842
|
+
const allowedOrigins = config.gateway.auth.allowedOrigins;
|
|
1843
|
+
if (allowedOrigins.includes("*")) return true;
|
|
1844
|
+
for (const allowed of allowedOrigins) {
|
|
1845
|
+
const normalizedAllowed = normalizeOrigin(allowed);
|
|
1846
|
+
if (!normalizedAllowed) continue;
|
|
1847
|
+
if (normalizedAllowed === normalizedOrigin) return true;
|
|
1848
|
+
}
|
|
1849
|
+
return false;
|
|
1106
1850
|
}
|
|
1107
1851
|
function contentTypeForFile(filePath) {
|
|
1108
1852
|
const ext = path.extname(filePath).toLowerCase();
|
|
@@ -1203,6 +1947,15 @@ var HovClawGatewayServer = class {
|
|
|
1203
1947
|
socket.destroy();
|
|
1204
1948
|
return;
|
|
1205
1949
|
}
|
|
1950
|
+
if (!isOriginAllowed(request, this.runtimeConfig)) {
|
|
1951
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
1952
|
+
socket.destroy();
|
|
1953
|
+
logger.warn({
|
|
1954
|
+
origin: request.headers.origin,
|
|
1955
|
+
host: request.headers.host
|
|
1956
|
+
}, "Rejected websocket upgrade due to origin policy");
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1206
1959
|
this.wss.handleUpgrade(request, socket, head, (wsSocket) => {
|
|
1207
1960
|
this.wss?.emit("connection", wsSocket, request);
|
|
1208
1961
|
});
|
|
@@ -1317,7 +2070,14 @@ var HovClawGatewayServer = class {
|
|
|
1317
2070
|
const connectParams = parseConnectParams(request.params);
|
|
1318
2071
|
const expectedToken = this.runtimeConfig.gateway.auth.token.trim();
|
|
1319
2072
|
const expectedPassword = this.runtimeConfig.gateway.auth.password.trim();
|
|
1320
|
-
|
|
2073
|
+
const allowUnauthenticated = this.runtimeConfig.gateway.auth.allowUnauthenticated;
|
|
2074
|
+
if (!expectedToken && !expectedPassword) {
|
|
2075
|
+
if (allowUnauthenticated) return { ok: true };
|
|
2076
|
+
return {
|
|
2077
|
+
ok: false,
|
|
2078
|
+
reason: "gateway auth required"
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
1321
2081
|
if (expectedToken && connectParams.auth?.token === expectedToken) return { ok: true };
|
|
1322
2082
|
if (expectedPassword && connectParams.auth?.password === expectedPassword) return { ok: true };
|
|
1323
2083
|
return {
|
|
@@ -1765,6 +2525,19 @@ var HovClawScheduler = class {
|
|
|
1765
2525
|
function normalizePrompt(msg) {
|
|
1766
2526
|
return msg.text.trim();
|
|
1767
2527
|
}
|
|
2528
|
+
function formatModelForSessionNotice(modelRef) {
|
|
2529
|
+
const match = modelRef.match(/^([^:\/]+)[:\/](.+)$/);
|
|
2530
|
+
if (!match) return modelRef;
|
|
2531
|
+
const provider = match[1];
|
|
2532
|
+
const model = match[2];
|
|
2533
|
+
if (!provider || !model) return modelRef;
|
|
2534
|
+
return `${provider}/${model}`;
|
|
2535
|
+
}
|
|
2536
|
+
function applyThinkingLevel(prompt, level, force = false) {
|
|
2537
|
+
if (/^Use (low|medium|high) reasoning effort for this task:/i.test(prompt)) return prompt;
|
|
2538
|
+
if (level === "medium" && !force) return prompt;
|
|
2539
|
+
return `Use ${level} reasoning effort for this task: ${prompt}`;
|
|
2540
|
+
}
|
|
1768
2541
|
function buildChannelTarget(msg) {
|
|
1769
2542
|
return {
|
|
1770
2543
|
channel: msg.channel,
|
|
@@ -1800,6 +2573,17 @@ var MultiNotifier = class {
|
|
|
1800
2573
|
}, text);
|
|
1801
2574
|
}
|
|
1802
2575
|
};
|
|
2576
|
+
function validateGatewaySecurityConfig() {
|
|
2577
|
+
if (!config.gateway.enabled) return;
|
|
2578
|
+
const auth = config.gateway.auth;
|
|
2579
|
+
if (auth.allowUnauthenticated) return;
|
|
2580
|
+
if (auth.token.trim() || auth.password.trim()) return;
|
|
2581
|
+
throw new Error([
|
|
2582
|
+
"Gateway auth is required when gateway is enabled.",
|
|
2583
|
+
"Set gateway.auth.token or gateway.auth.password in ~/.hovclaw/config.json,",
|
|
2584
|
+
"or explicitly set gateway.auth.allowUnauthenticated=true for insecure compatibility mode."
|
|
2585
|
+
].join(" "));
|
|
2586
|
+
}
|
|
1803
2587
|
async function main() {
|
|
1804
2588
|
const importedLegacyEnv = ensureConfigFromLegacyEnv();
|
|
1805
2589
|
if (!hasConfigFile()) {
|
|
@@ -1810,6 +2594,7 @@ async function main() {
|
|
|
1810
2594
|
configPath: config.configPath,
|
|
1811
2595
|
credentialsPath: config.credentialsPath
|
|
1812
2596
|
}, "Imported legacy env configuration into ~/.hovclaw");
|
|
2597
|
+
validateGatewaySecurityConfig();
|
|
1813
2598
|
try {
|
|
1814
2599
|
const bootstrap = await ensureWorkspaceBootstrapForConfig(config);
|
|
1815
2600
|
if (bootstrap.createdFileCount > 0) logger.info({
|
|
@@ -1840,7 +2625,8 @@ async function main() {
|
|
|
1840
2625
|
});
|
|
1841
2626
|
const agentManager = new PiAgentManager(db, createTools({
|
|
1842
2627
|
runtime,
|
|
1843
|
-
audit: (record) => db.appendAuditEvent(record)
|
|
2628
|
+
audit: (record) => db.appendAuditEvent(record),
|
|
2629
|
+
bashEnabled: config.runtime.tools.bashEnabled
|
|
1844
2630
|
}));
|
|
1845
2631
|
let lastMessageAt = null;
|
|
1846
2632
|
const telegramPairingStore = new TelegramPairingStore(config.storeDir);
|
|
@@ -1851,10 +2637,19 @@ async function main() {
|
|
|
1851
2637
|
const channels = channelPluginManager.initializeAdapters();
|
|
1852
2638
|
const handleMessage = async (msg) => {
|
|
1853
2639
|
lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1854
|
-
const normalizedPrompt = normalizePrompt(msg);
|
|
1855
|
-
if (!normalizedPrompt) return;
|
|
1856
2640
|
const channel = channels.get(msg.channel);
|
|
1857
2641
|
if (!channel) return;
|
|
2642
|
+
const agentId = resolveAgentIdForInbound(config, msg);
|
|
2643
|
+
const sessionKey = composeSessionKey({
|
|
2644
|
+
agent: agentId,
|
|
2645
|
+
channel: msg.channel,
|
|
2646
|
+
identity: canonicalizeSessionIdentity(msg)
|
|
2647
|
+
});
|
|
2648
|
+
const target = buildChannelTarget(msg);
|
|
2649
|
+
let normalizedPrompt = normalizePrompt(msg);
|
|
2650
|
+
if (!normalizedPrompt) return;
|
|
2651
|
+
let thinkingLevel = config.commands.defaultThinkingLevel;
|
|
2652
|
+
let thinkingLevelForced = false;
|
|
1858
2653
|
if (msg.channel === "telegram") {
|
|
1859
2654
|
const policy = evaluateTelegramPolicy({
|
|
1860
2655
|
config,
|
|
@@ -1863,12 +2658,13 @@ async function main() {
|
|
|
1863
2658
|
});
|
|
1864
2659
|
if (!policy.allowed) {
|
|
1865
2660
|
if (policy.pairingCode && channel instanceof TelegramChannel) {
|
|
1866
|
-
|
|
2661
|
+
const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
|
|
2662
|
+
await channel.sendMessage(target, `Pairing required. Ask an approver to run: hovclaw pairing approve --channel telegram --account ${accountId} ${policy.pairingCode}`);
|
|
1867
2663
|
db.appendAuditEvent({
|
|
1868
2664
|
actor: "channel",
|
|
1869
2665
|
eventType: "telegram.pair.pending",
|
|
1870
2666
|
payload: {
|
|
1871
|
-
accountId
|
|
2667
|
+
accountId,
|
|
1872
2668
|
userId: msg.userId,
|
|
1873
2669
|
code: policy.pairingCode
|
|
1874
2670
|
}
|
|
@@ -1877,20 +2673,36 @@ async function main() {
|
|
|
1877
2673
|
return;
|
|
1878
2674
|
}
|
|
1879
2675
|
if (channel instanceof TelegramChannel) {
|
|
1880
|
-
|
|
2676
|
+
const result = await handleTelegramCommand({
|
|
1881
2677
|
msg,
|
|
1882
2678
|
channel,
|
|
1883
2679
|
db,
|
|
1884
|
-
|
|
1885
|
-
|
|
2680
|
+
agentId,
|
|
2681
|
+
sessionKey
|
|
2682
|
+
});
|
|
2683
|
+
if (result.resetSession) {
|
|
2684
|
+
try {
|
|
2685
|
+
await agentManager.resetSession(sessionKey);
|
|
2686
|
+
} catch (error) {
|
|
2687
|
+
logger.error({
|
|
2688
|
+
error,
|
|
2689
|
+
sessionKey
|
|
2690
|
+
}, "Failed to reset session");
|
|
2691
|
+
await channel.sendMessage(target, "Failed to reset session. Check logs and try again.");
|
|
2692
|
+
return;
|
|
2693
|
+
}
|
|
2694
|
+
const modelLabel = formatModelForSessionNotice(config.models.interactive);
|
|
2695
|
+
await channel.sendMessage(target, `New session started. Model: ${modelLabel}`);
|
|
2696
|
+
}
|
|
2697
|
+
if (result.promptOverride?.trim()) normalizedPrompt = result.promptOverride.trim();
|
|
2698
|
+
if (result.thinkingLevelOverride) {
|
|
2699
|
+
thinkingLevel = result.thinkingLevelOverride;
|
|
2700
|
+
thinkingLevelForced = true;
|
|
2701
|
+
}
|
|
2702
|
+
if (result.handled) return;
|
|
1886
2703
|
}
|
|
1887
2704
|
}
|
|
1888
|
-
|
|
1889
|
-
agent: resolveAgentIdForInbound(config, msg),
|
|
1890
|
-
channel: msg.channel,
|
|
1891
|
-
identity: canonicalizeSessionIdentity(msg)
|
|
1892
|
-
});
|
|
1893
|
-
const target = buildChannelTarget(msg);
|
|
2705
|
+
normalizedPrompt = applyThinkingLevel(normalizedPrompt, thinkingLevel, thinkingLevelForced);
|
|
1894
2706
|
try {
|
|
1895
2707
|
await channel.setTyping?.(target, true);
|
|
1896
2708
|
let finalText = null;
|
|
@@ -1940,6 +2752,18 @@ async function main() {
|
|
|
1940
2752
|
channel.onMessage(handleMessage);
|
|
1941
2753
|
try {
|
|
1942
2754
|
await channel.start();
|
|
2755
|
+
if (channel instanceof TelegramChannel) {
|
|
2756
|
+
const commands = listTelegramNativeCommandSpecs(config, channel.getAccountId());
|
|
2757
|
+
try {
|
|
2758
|
+
await channel.setMyCommands(commands);
|
|
2759
|
+
} catch (commandError) {
|
|
2760
|
+
logger.warn({
|
|
2761
|
+
error: commandError,
|
|
2762
|
+
channel: channelName,
|
|
2763
|
+
commandCount: commands.length
|
|
2764
|
+
}, "Telegram command registration failed; continuing without native command menu sync");
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
1943
2767
|
} catch (error) {
|
|
1944
2768
|
logger.error({
|
|
1945
2769
|
error,
|
|
@@ -1979,6 +2803,7 @@ async function main() {
|
|
|
1979
2803
|
gatewayServer?.start();
|
|
1980
2804
|
const healthPortRaw = process.env.HEALTH_PORT || "8787";
|
|
1981
2805
|
const healthPort = Number(healthPortRaw);
|
|
2806
|
+
const healthHost = (process.env.HEALTH_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
|
1982
2807
|
const healthServer = Number.isFinite(healthPort) && healthPort > 0 ? createServer((req, res) => {
|
|
1983
2808
|
if (req.url !== "/health") {
|
|
1984
2809
|
res.statusCode = 404;
|
|
@@ -1999,8 +2824,11 @@ async function main() {
|
|
|
1999
2824
|
port: config.gateway.port
|
|
2000
2825
|
}
|
|
2001
2826
|
}));
|
|
2002
|
-
}).listen(healthPort, () => {
|
|
2003
|
-
logger.info({
|
|
2827
|
+
}).listen(healthPort, healthHost, () => {
|
|
2828
|
+
logger.info({
|
|
2829
|
+
healthHost,
|
|
2830
|
+
healthPort
|
|
2831
|
+
}, "Health endpoint started");
|
|
2004
2832
|
}) : null;
|
|
2005
2833
|
const shutdown = async () => {
|
|
2006
2834
|
logger.info("Shutting down...");
|