@dotsetlabs/dotclaw 1.9.0 → 2.0.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.
- package/.env.example +6 -0
- package/README.md +14 -7
- package/config-examples/groups/global/CLAUDE.md +6 -14
- package/config-examples/groups/main/CLAUDE.md +8 -39
- package/config-examples/runtime.json +4 -4
- package/container/agent-runner/package-lock.json +258 -0
- package/container/agent-runner/package.json +2 -1
- package/container/agent-runner/src/agent-config.ts +57 -8
- package/container/agent-runner/src/browser.ts +180 -0
- package/container/agent-runner/src/container-protocol.ts +2 -0
- package/container/agent-runner/src/id.ts +3 -2
- package/container/agent-runner/src/index.ts +167 -436
- package/container/agent-runner/src/ipc.ts +33 -1
- package/container/agent-runner/src/mcp-client.ts +222 -0
- package/container/agent-runner/src/mcp-registry.ts +163 -0
- package/container/agent-runner/src/skill-loader.ts +375 -0
- package/container/agent-runner/src/tools.ts +249 -21
- package/container/agent-runner/src/tts.ts +61 -0
- package/dist/admin-commands.d.ts.map +1 -1
- package/dist/admin-commands.js +12 -0
- package/dist/admin-commands.js.map +1 -1
- package/dist/agent-execution.d.ts +3 -1
- package/dist/agent-execution.d.ts.map +1 -1
- package/dist/agent-execution.js +20 -0
- package/dist/agent-execution.js.map +1 -1
- package/dist/background-job-classifier.d.ts +1 -1
- package/dist/background-job-classifier.d.ts.map +1 -1
- package/dist/background-jobs.d.ts.map +1 -1
- package/dist/background-jobs.js +9 -0
- package/dist/background-jobs.js.map +1 -1
- package/dist/cli.js +61 -16
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +0 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -3
- package/dist/config.js.map +1 -1
- package/dist/container-protocol.d.ts +2 -0
- package/dist/container-protocol.d.ts.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +3 -8
- package/dist/container-runner.js.map +1 -1
- package/dist/dashboard.d.ts +5 -0
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +11 -5
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts +0 -13
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +41 -70
- package/dist/db.js.map +1 -1
- package/dist/hooks.d.ts +7 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +93 -0
- package/dist/hooks.js.map +1 -0
- package/dist/id.d.ts.map +1 -1
- package/dist/id.js +2 -1
- package/dist/id.js.map +1 -1
- package/dist/index.js +741 -2812
- package/dist/index.js.map +1 -1
- package/dist/ipc-dispatcher.d.ts +26 -0
- package/dist/ipc-dispatcher.d.ts.map +1 -0
- package/dist/ipc-dispatcher.js +1044 -0
- package/dist/ipc-dispatcher.js.map +1 -0
- package/dist/local-embeddings.d.ts +7 -0
- package/dist/local-embeddings.d.ts.map +1 -0
- package/dist/local-embeddings.js +60 -0
- package/dist/local-embeddings.js.map +1 -0
- package/dist/maintenance.d.ts.map +1 -1
- package/dist/maintenance.js +7 -1
- package/dist/maintenance.js.map +1 -1
- package/dist/memory-embeddings.d.ts +1 -1
- package/dist/memory-embeddings.d.ts.map +1 -1
- package/dist/memory-embeddings.js +59 -31
- package/dist/memory-embeddings.js.map +1 -1
- package/dist/memory-store.d.ts +0 -10
- package/dist/memory-store.d.ts.map +1 -1
- package/dist/memory-store.js +11 -27
- package/dist/memory-store.js.map +1 -1
- package/dist/message-pipeline.d.ts +47 -0
- package/dist/message-pipeline.d.ts.map +1 -0
- package/dist/message-pipeline.js +876 -0
- package/dist/message-pipeline.js.map +1 -0
- package/dist/metrics.d.ts +8 -8
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/model-registry.d.ts +0 -14
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +0 -36
- package/dist/model-registry.js.map +1 -1
- package/dist/orchestration.d.ts +39 -0
- package/dist/orchestration.d.ts.map +1 -0
- package/dist/orchestration.js +136 -0
- package/dist/orchestration.js.map +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +2 -0
- package/dist/paths.js.map +1 -1
- package/dist/providers/discord/discord-format.d.ts +16 -0
- package/dist/providers/discord/discord-format.d.ts.map +1 -0
- package/dist/providers/discord/discord-format.js +153 -0
- package/dist/providers/discord/discord-format.js.map +1 -0
- package/dist/providers/discord/discord-provider.d.ts +50 -0
- package/dist/providers/discord/discord-provider.d.ts.map +1 -0
- package/dist/providers/discord/discord-provider.js +604 -0
- package/dist/providers/discord/discord-provider.js.map +1 -0
- package/dist/providers/discord/index.d.ts +4 -0
- package/dist/providers/discord/index.d.ts.map +1 -0
- package/dist/providers/discord/index.js +3 -0
- package/dist/providers/discord/index.js.map +1 -0
- package/dist/providers/registry.d.ts +14 -0
- package/dist/providers/registry.d.ts.map +1 -0
- package/dist/providers/registry.js +49 -0
- package/dist/providers/registry.js.map +1 -0
- package/dist/providers/telegram/index.d.ts +4 -0
- package/dist/providers/telegram/index.d.ts.map +1 -0
- package/dist/providers/telegram/index.js +3 -0
- package/dist/providers/telegram/index.js.map +1 -0
- package/dist/providers/telegram/telegram-format.d.ts +3 -0
- package/dist/providers/telegram/telegram-format.d.ts.map +1 -0
- package/dist/providers/telegram/telegram-format.js +215 -0
- package/dist/providers/telegram/telegram-format.js.map +1 -0
- package/dist/providers/telegram/telegram-provider.d.ts +51 -0
- package/dist/providers/telegram/telegram-provider.d.ts.map +1 -0
- package/dist/providers/telegram/telegram-provider.js +824 -0
- package/dist/providers/telegram/telegram-provider.js.map +1 -0
- package/dist/providers/types.d.ts +107 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/request-router.d.ts.map +1 -1
- package/dist/request-router.js +12 -26
- package/dist/request-router.js.map +1 -1
- package/dist/runtime-config.d.ts +70 -6
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +119 -65
- package/dist/runtime-config.js.map +1 -1
- package/dist/skill-manager.d.ts +39 -0
- package/dist/skill-manager.d.ts.map +1 -0
- package/dist/skill-manager.js +286 -0
- package/dist/skill-manager.js.map +1 -0
- package/dist/task-scheduler.d.ts.map +1 -1
- package/dist/task-scheduler.js +4 -0
- package/dist/task-scheduler.js.map +1 -1
- package/dist/tool-intent-probe.d.ts +11 -0
- package/dist/tool-intent-probe.d.ts.map +1 -0
- package/dist/tool-intent-probe.js +63 -0
- package/dist/tool-intent-probe.js.map +1 -0
- package/dist/tool-policy.d.ts.map +1 -1
- package/dist/tool-policy.js +18 -0
- package/dist/tool-policy.js.map +1 -1
- package/dist/transcription.d.ts +8 -0
- package/dist/transcription.d.ts.map +1 -0
- package/dist/transcription.js +174 -0
- package/dist/transcription.js.map +1 -0
- package/dist/types.d.ts +2 -9
- package/dist/types.d.ts.map +1 -1
- package/dist/workflow-engine.d.ts +51 -0
- package/dist/workflow-engine.d.ts.map +1 -0
- package/dist/workflow-engine.js +281 -0
- package/dist/workflow-engine.js.map +1 -0
- package/dist/workflow-store.d.ts +39 -0
- package/dist/workflow-store.d.ts.map +1 -0
- package/dist/workflow-store.js +173 -0
- package/dist/workflow-store.js.map +1 -0
- package/package.json +15 -3
- package/scripts/bootstrap.js +40 -4
- package/scripts/configure.js +48 -7
- package/scripts/doctor.js +30 -4
- package/scripts/init.js +13 -6
package/dist/index.js
CHANGED
|
@@ -1,39 +1,43 @@
|
|
|
1
1
|
import dotenv from 'dotenv';
|
|
2
|
-
import { Telegraf } from 'telegraf';
|
|
3
2
|
import { execSync } from 'child_process';
|
|
4
3
|
import fs from 'fs';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR,
|
|
5
|
+
import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, CONTAINER_MODE, CONTAINER_PRIVILEGED, WARM_START_ENABLED, ENV_PATH, } from './config.js';
|
|
7
6
|
// Load .env from the canonical location (~/.dotclaw/.env)
|
|
8
7
|
dotenv.config({ path: ENV_PATH });
|
|
9
|
-
import { initDatabase, closeDatabase, storeMessage, upsertChat,
|
|
10
|
-
import { startSchedulerLoop, stopSchedulerLoop
|
|
11
|
-
import { startBackgroundJobLoop, stopBackgroundJobLoop,
|
|
8
|
+
import { initDatabase, closeDatabase, storeMessage, upsertChat, getChatState, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, getTraceIdForMessage, recordUserFeedback, getChatsWithPendingMessages, resetStalledMessages, resetStalledBackgroundJobs, } from './db.js';
|
|
9
|
+
import { startSchedulerLoop, stopSchedulerLoop } from './task-scheduler.js';
|
|
10
|
+
import { startBackgroundJobLoop, stopBackgroundJobLoop, } from './background-jobs.js';
|
|
12
11
|
import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
|
|
13
|
-
import { hostPathToContainerGroupPath, resolveContainerGroupPathToHost } from './path-mapping.js';
|
|
14
12
|
import { writeTrace } from './trace-writer.js';
|
|
15
|
-
import {
|
|
16
|
-
import { initMemoryStore, closeMemoryStore, getMemoryStats, upsertMemoryItems, searchMemories, listMemories, forgetMemories, cleanupExpiredMemories } from './memory-store.js';
|
|
13
|
+
import { initMemoryStore, closeMemoryStore, cleanupExpiredMemories, upsertMemoryItems } from './memory-store.js';
|
|
17
14
|
import { startEmbeddingWorker, stopEmbeddingWorker } from './memory-embeddings.js';
|
|
18
|
-
import { createProgressManager, DEFAULT_PROGRESS_MESSAGES, DEFAULT_PROGRESS_STAGES, formatProgressWithPlan, formatPlanStepList } from './progress.js';
|
|
19
15
|
import { parseAdminCommand } from './admin-commands.js';
|
|
20
16
|
import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
|
|
21
|
-
import { startMetricsServer, stopMetricsServer, recordMessage,
|
|
17
|
+
import { startMetricsServer, stopMetricsServer, recordMessage, recordRoutingDecision, recordStageLatency } from './metrics.js';
|
|
22
18
|
import { startMaintenanceLoop, stopMaintenanceLoop } from './maintenance.js';
|
|
23
19
|
import { warmGroupContainer, startDaemonHealthCheckLoop, stopDaemonHealthCheckLoop, cleanupInstanceContainers, suppressHealthChecks, resetUnhealthyDaemons } from './container-runner.js';
|
|
24
20
|
import { startWakeDetector, stopWakeDetector } from './wake-detector.js';
|
|
25
21
|
import { loadRuntimeConfig } from './runtime-config.js';
|
|
22
|
+
import { transcribeVoice } from './transcription.js';
|
|
23
|
+
import { emitHook } from './hooks.js';
|
|
24
|
+
import { closeWorkflowStore } from './workflow-store.js';
|
|
26
25
|
import { invalidatePersonalizationCache } from './personalization.js';
|
|
26
|
+
import { installSkill, removeSkill, listSkills, updateSkill } from './skill-manager.js';
|
|
27
27
|
import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionError } from './agent-execution.js';
|
|
28
28
|
import { logger } from './logger.js';
|
|
29
|
-
import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
29
|
+
import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime } from './dashboard.js';
|
|
30
|
+
import { routePrompt } from './request-router.js';
|
|
31
|
+
// Provider system
|
|
32
|
+
import { ProviderRegistry } from './providers/registry.js';
|
|
33
|
+
import { createTelegramProvider } from './providers/telegram/index.js';
|
|
34
|
+
import { createMessagePipeline, getActiveDrains, getActiveRuns, providerAttachmentToMessageAttachment } from './message-pipeline.js';
|
|
35
|
+
import { startIpcWatcher, stopIpcWatcher } from './ipc-dispatcher.js';
|
|
36
36
|
const runtime = loadRuntimeConfig();
|
|
37
|
+
// ───────────────────────── State ─────────────────────────
|
|
38
|
+
let sessions = {};
|
|
39
|
+
let registeredGroups = {};
|
|
40
|
+
// ───────────────────────── Helpers ─────────────────────────
|
|
37
41
|
function buildTriggerRegex(pattern) {
|
|
38
42
|
if (!pattern)
|
|
39
43
|
return null;
|
|
@@ -52,87 +56,23 @@ function buildAvailableGroupsSnapshot() {
|
|
|
52
56
|
isRegistered: true
|
|
53
57
|
}));
|
|
54
58
|
}
|
|
55
|
-
function
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
function isMemoryScope(value) {
|
|
59
|
-
return value === 'user' || value === 'group' || value === 'global';
|
|
60
|
-
}
|
|
61
|
-
function isMemoryType(value) {
|
|
62
|
-
return value === 'identity'
|
|
63
|
-
|| value === 'preference'
|
|
64
|
-
|| value === 'fact'
|
|
65
|
-
|| value === 'relationship'
|
|
66
|
-
|| value === 'project'
|
|
67
|
-
|| value === 'task'
|
|
68
|
-
|| value === 'note'
|
|
69
|
-
|| value === 'archive';
|
|
70
|
-
}
|
|
71
|
-
function isMemoryKind(value) {
|
|
72
|
-
return value === 'semantic'
|
|
73
|
-
|| value === 'episodic'
|
|
74
|
-
|| value === 'procedural'
|
|
75
|
-
|| value === 'preference';
|
|
76
|
-
}
|
|
77
|
-
function clampInputMessage(content, maxChars) {
|
|
78
|
-
if (!content)
|
|
79
|
-
return '';
|
|
80
|
-
if (!Number.isFinite(maxChars) || maxChars <= 0)
|
|
81
|
-
return content;
|
|
82
|
-
if (content.length <= maxChars)
|
|
83
|
-
return content;
|
|
84
|
-
return `${content.slice(0, maxChars)}\n\n[Message truncated for length]`;
|
|
85
|
-
}
|
|
86
|
-
function coerceMemoryItems(input) {
|
|
87
|
-
if (!Array.isArray(input))
|
|
88
|
-
return [];
|
|
89
|
-
const items = [];
|
|
90
|
-
for (const raw of input) {
|
|
91
|
-
if (!isRecord(raw))
|
|
92
|
-
continue;
|
|
93
|
-
const scope = raw.scope;
|
|
94
|
-
const type = raw.type;
|
|
95
|
-
const kind = raw.kind;
|
|
96
|
-
const content = raw.content;
|
|
97
|
-
if (!isMemoryScope(scope) || !isMemoryType(type) || typeof content !== 'string' || !content.trim()) {
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
items.push({
|
|
101
|
-
scope,
|
|
102
|
-
type,
|
|
103
|
-
kind: isMemoryKind(kind) ? kind : undefined,
|
|
104
|
-
conflict_key: typeof raw.conflict_key === 'string' ? raw.conflict_key : undefined,
|
|
105
|
-
content: content.trim(),
|
|
106
|
-
subject_id: typeof raw.subject_id === 'string' ? raw.subject_id : null,
|
|
107
|
-
importance: typeof raw.importance === 'number' ? raw.importance : undefined,
|
|
108
|
-
confidence: typeof raw.confidence === 'number' ? raw.confidence : undefined,
|
|
109
|
-
tags: Array.isArray(raw.tags) ? raw.tags.filter((tag) => typeof tag === 'string') : undefined,
|
|
110
|
-
ttl_days: typeof raw.ttl_days === 'number' ? raw.ttl_days : undefined,
|
|
111
|
-
source: typeof raw.source === 'string' ? raw.source : undefined,
|
|
112
|
-
metadata: isRecord(raw.metadata) ? raw.metadata : undefined
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
return items;
|
|
59
|
+
function sleep(ms) {
|
|
60
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
116
61
|
}
|
|
117
|
-
// Rate
|
|
118
|
-
const RATE_LIMIT_MAX_MESSAGES = 20;
|
|
119
|
-
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
62
|
+
// ───────────────────────── Rate Limiter ─────────────────────────
|
|
63
|
+
const RATE_LIMIT_MAX_MESSAGES = 20;
|
|
64
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
120
65
|
const rateLimiter = new Map();
|
|
121
|
-
const JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS = 120_000;
|
|
122
|
-
const lastJobUpdateNotifications = new Map();
|
|
123
66
|
function checkRateLimit(userId) {
|
|
124
67
|
const now = Date.now();
|
|
125
68
|
const entry = rateLimiter.get(userId);
|
|
126
69
|
if (!entry || now > entry.resetAt) {
|
|
127
|
-
// New window
|
|
128
70
|
rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
129
71
|
return { allowed: true };
|
|
130
72
|
}
|
|
131
73
|
if (entry.count >= RATE_LIMIT_MAX_MESSAGES) {
|
|
132
|
-
// Rate limited
|
|
133
74
|
return { allowed: false, retryAfterMs: entry.resetAt - now };
|
|
134
75
|
}
|
|
135
|
-
// Increment counter
|
|
136
76
|
entry.count += 1;
|
|
137
77
|
return { allowed: true };
|
|
138
78
|
}
|
|
@@ -144,171 +84,34 @@ function cleanupRateLimiter() {
|
|
|
144
84
|
}
|
|
145
85
|
}
|
|
146
86
|
}
|
|
147
|
-
// Clean up expired rate limit entries periodically
|
|
148
87
|
const rateLimiterInterval = setInterval(cleanupRateLimiter, 60_000);
|
|
149
|
-
|
|
150
|
-
const TELEGRAM_SEND_RETRIES = runtime.host.telegram.sendRetries;
|
|
151
|
-
const TELEGRAM_SEND_RETRY_DELAY_MS = runtime.host.telegram.sendRetryDelayMs;
|
|
88
|
+
// ───────────────────────── Config Constants ─────────────────────────
|
|
152
89
|
const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
|
|
153
90
|
const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
|
|
154
|
-
const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
|
|
155
91
|
const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
|
|
156
92
|
const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
|
|
157
93
|
const HEARTBEAT_GROUP_FOLDER = (runtime.host.heartbeat.groupFolder || MAIN_GROUP_FOLDER).trim() || MAIN_GROUP_FOLDER;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
telegrafBot.catch((err, ctx) => {
|
|
171
|
-
logger.error({ err, chatId: ctx?.chat?.id }, 'Unhandled Telegraf error');
|
|
172
|
-
});
|
|
173
|
-
let sessions = {};
|
|
174
|
-
let registeredGroups = {};
|
|
175
|
-
const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
|
|
176
|
-
const TELEGRAM_SEND_DELAY_MS = 250;
|
|
177
|
-
const TELEGRAM_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
|
178
|
-
const TELEGRAM_FILE_DOWNLOAD_TIMEOUT_MS = 45_000;
|
|
179
|
-
const MESSAGE_QUEUE_MAX_RETRIES = Math.max(1, runtime.host.messageQueue.maxRetries ?? 4);
|
|
180
|
-
const MESSAGE_QUEUE_RETRY_BASE_MS = Math.max(250, runtime.host.messageQueue.retryBaseMs ?? 3_000);
|
|
181
|
-
const MESSAGE_QUEUE_RETRY_MAX_MS = Math.max(MESSAGE_QUEUE_RETRY_BASE_MS, runtime.host.messageQueue.retryMaxMs ?? 60_000);
|
|
182
|
-
const activeDrains = new Set();
|
|
183
|
-
const activeRuns = new Map();
|
|
184
|
-
const MAX_DRAIN_ITERATIONS = 50;
|
|
185
|
-
const CANCEL_PHRASES = new Set([
|
|
186
|
-
'cancel', 'stop', 'abort', 'cancel request', 'stop request'
|
|
187
|
-
]);
|
|
188
|
-
function isCancelMessage(content) {
|
|
189
|
-
if (!content)
|
|
190
|
-
return false;
|
|
191
|
-
const trimmed = content.trim();
|
|
192
|
-
if (trimmed.length > 20)
|
|
193
|
-
return false;
|
|
194
|
-
return CANCEL_PHRASES.has(trimmed.toLowerCase());
|
|
195
|
-
}
|
|
196
|
-
function inferProgressStage(params) {
|
|
197
|
-
const content = params.content.toLowerCase();
|
|
198
|
-
const tools = params.plannerTools.map(tool => tool.toLowerCase());
|
|
199
|
-
const hasWebTool = tools.some(tool => tool.includes('web') || tool.includes('search') || tool.includes('fetch'));
|
|
200
|
-
const hasCodeTool = tools.some(tool => tool.includes('bash') || tool.includes('edit') || tool.includes('write') || tool.includes('git'));
|
|
201
|
-
if (params.enablePlanner)
|
|
202
|
-
return 'planning';
|
|
203
|
-
if (hasWebTool || /research|search|browse|web|site|docs/.test(content))
|
|
204
|
-
return 'searching';
|
|
205
|
-
if (hasCodeTool || /build|code|implement|refactor|fix|debug/.test(content))
|
|
206
|
-
return 'coding';
|
|
207
|
-
return 'drafting';
|
|
208
|
-
}
|
|
209
|
-
function estimateForegroundMs(params) {
|
|
210
|
-
if (typeof params.routing.estimatedMinutes === 'number' && Number.isFinite(params.routing.estimatedMinutes)) {
|
|
211
|
-
return Math.max(1000, params.routing.estimatedMinutes * 60_000);
|
|
212
|
-
}
|
|
213
|
-
const baseChars = params.content.length;
|
|
214
|
-
if (baseChars === 0)
|
|
215
|
-
return null;
|
|
216
|
-
const stepFactor = params.plannerSteps.length > 0 ? params.plannerSteps.length * 6000 : 0;
|
|
217
|
-
const toolFactor = params.plannerTools.length > 0 ? params.plannerTools.length * 8000 : 0;
|
|
218
|
-
const lengthFactor = Math.min(60_000, Math.max(3000, Math.round(baseChars / 3)));
|
|
219
|
-
const profileFactor = params.routing.profile === 'deep' ? 1.4 : 1;
|
|
220
|
-
return Math.round((lengthFactor + stepFactor + toolFactor) * profileFactor);
|
|
221
|
-
}
|
|
222
|
-
function inferPlanStepIndex(stage, totalSteps) {
|
|
223
|
-
if (!Number.isFinite(totalSteps) || totalSteps <= 0)
|
|
224
|
-
return null;
|
|
225
|
-
const normalized = stage.trim().toLowerCase();
|
|
226
|
-
if (!normalized)
|
|
227
|
-
return 1;
|
|
228
|
-
switch (normalized) {
|
|
229
|
-
case 'planning':
|
|
230
|
-
return 1;
|
|
231
|
-
case 'searching':
|
|
232
|
-
return Math.min(2, totalSteps);
|
|
233
|
-
case 'coding':
|
|
234
|
-
return Math.min(Math.max(2, Math.ceil(totalSteps * 0.6)), totalSteps);
|
|
235
|
-
case 'drafting':
|
|
236
|
-
return Math.min(Math.max(2, Math.ceil(totalSteps * 0.8)), totalSteps);
|
|
237
|
-
case 'finalizing':
|
|
238
|
-
return totalSteps;
|
|
239
|
-
default:
|
|
240
|
-
return 1;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
async function setTyping(chatId) {
|
|
244
|
-
try {
|
|
245
|
-
await telegrafBot.telegram.sendChatAction(chatId, 'typing');
|
|
246
|
-
}
|
|
247
|
-
catch (err) {
|
|
248
|
-
logger.debug({ chatId, err }, 'Failed to set typing indicator');
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
function sleep(ms) {
|
|
252
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
253
|
-
}
|
|
254
|
-
function isBotMentioned(text, entities, botUsername, botId) {
|
|
255
|
-
if (!entities || entities.length === 0)
|
|
256
|
-
return false;
|
|
257
|
-
const normalized = botUsername ? botUsername.toLowerCase() : '';
|
|
258
|
-
for (const entity of entities) {
|
|
259
|
-
const segment = text.slice(entity.offset, entity.offset + entity.length);
|
|
260
|
-
if (entity.type === 'mention') {
|
|
261
|
-
if (segment.toLowerCase() === `@${normalized}`)
|
|
262
|
-
return true;
|
|
94
|
+
// ───────────────────────── State Management ─────────────────────────
|
|
95
|
+
function loadState() {
|
|
96
|
+
sessions = {};
|
|
97
|
+
const rawGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
|
|
98
|
+
// Migrate: prefix unprefixed chat IDs with 'telegram:'
|
|
99
|
+
let migrated = false;
|
|
100
|
+
const loadedGroups = {};
|
|
101
|
+
for (const [chatId, group] of Object.entries(rawGroups)) {
|
|
102
|
+
if (!chatId.includes(':')) {
|
|
103
|
+
// Unprefixed — add telegram: prefix
|
|
104
|
+
loadedGroups[ProviderRegistry.addPrefix('telegram', chatId)] = group;
|
|
105
|
+
migrated = true;
|
|
263
106
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (entity.type === 'bot_command') {
|
|
267
|
-
if (segment.toLowerCase().includes(`@${normalized}`))
|
|
268
|
-
return true;
|
|
107
|
+
else {
|
|
108
|
+
loadedGroups[chatId] = group;
|
|
269
109
|
}
|
|
270
110
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!message?.reply_to_message?.from?.id || !botId)
|
|
275
|
-
return false;
|
|
276
|
-
return message.reply_to_message.from.id === botId;
|
|
277
|
-
}
|
|
278
|
-
function getTelegramErrorCode(err) {
|
|
279
|
-
const anyErr = err;
|
|
280
|
-
if (typeof anyErr?.code === 'number')
|
|
281
|
-
return anyErr.code;
|
|
282
|
-
if (typeof anyErr?.response?.error_code === 'number')
|
|
283
|
-
return anyErr.response.error_code;
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
function getTelegramRetryAfterMs(err) {
|
|
287
|
-
const anyErr = err;
|
|
288
|
-
const retryAfter = anyErr?.parameters?.retry_after ?? anyErr?.response?.parameters?.retry_after;
|
|
289
|
-
if (typeof retryAfter === 'number' && Number.isFinite(retryAfter))
|
|
290
|
-
return retryAfter * 1000;
|
|
291
|
-
if (typeof retryAfter === 'string') {
|
|
292
|
-
const parsed = Number.parseInt(retryAfter, 10);
|
|
293
|
-
if (Number.isFinite(parsed))
|
|
294
|
-
return parsed * 1000;
|
|
111
|
+
if (migrated) {
|
|
112
|
+
saveJson(path.join(DATA_DIR, 'registered_groups.json'), loadedGroups);
|
|
113
|
+
logger.info('Migrated registered_groups.json chat IDs with telegram: prefix');
|
|
295
114
|
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
function isRetryableTelegramError(err) {
|
|
299
|
-
const code = getTelegramErrorCode(err);
|
|
300
|
-
if (code === 429)
|
|
301
|
-
return true;
|
|
302
|
-
if (code && code >= 500 && code < 600)
|
|
303
|
-
return true;
|
|
304
|
-
const anyErr = err;
|
|
305
|
-
if (!anyErr?.code)
|
|
306
|
-
return false;
|
|
307
|
-
return ['ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'EAI_AGAIN', 'ENOTFOUND'].includes(anyErr.code);
|
|
308
|
-
}
|
|
309
|
-
function loadState() {
|
|
310
|
-
sessions = {};
|
|
311
|
-
const loadedGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
|
|
312
115
|
const sanitizedGroups = {};
|
|
313
116
|
const usedFolders = new Set();
|
|
314
117
|
let invalidCount = 0;
|
|
@@ -360,7 +163,6 @@ function registerGroup(chatId, group) {
|
|
|
360
163
|
}
|
|
361
164
|
registeredGroups[chatId] = group;
|
|
362
165
|
saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
|
|
363
|
-
// Create group folder
|
|
364
166
|
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
365
167
|
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
|
366
168
|
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
|
|
@@ -418,2405 +220,636 @@ function unregisterGroup(identifier) {
|
|
|
418
220
|
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
|
|
419
221
|
return { ok: true, group: { ...group, chat_id: chatId } };
|
|
420
222
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
223
|
+
// ───────────────────────── Admin Commands ─────────────────────────
|
|
224
|
+
function formatGroups(groups) {
|
|
225
|
+
if (groups.length === 0)
|
|
226
|
+
return 'No registered groups.';
|
|
227
|
+
const lines = groups.map(group => {
|
|
228
|
+
const trigger = group.trigger ? ` (trigger: ${group.trigger})` : '';
|
|
229
|
+
return `- ${group.name} [${group.folder}] chat=${group.chat_id}${trigger}`;
|
|
230
|
+
});
|
|
231
|
+
return ['Registered groups:', ...lines].join('\n');
|
|
429
232
|
}
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const sendChunk = async (chunk, isFirst) => {
|
|
437
|
-
for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
|
|
438
|
-
try {
|
|
439
|
-
const payload = {};
|
|
440
|
-
if (parseMode)
|
|
441
|
-
payload.parse_mode = parseMode;
|
|
442
|
-
if (options?.messageThreadId)
|
|
443
|
-
payload.message_thread_id = options.messageThreadId;
|
|
444
|
-
if (isFirst && options?.replyToMessageId) {
|
|
445
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
446
|
-
}
|
|
447
|
-
const sent = await telegrafBot.telegram.sendMessage(chatId, chunk, payload);
|
|
448
|
-
if (!firstMessageId) {
|
|
449
|
-
firstMessageId = String(sent.message_id);
|
|
450
|
-
}
|
|
451
|
-
return true;
|
|
452
|
-
}
|
|
453
|
-
catch (err) {
|
|
454
|
-
const retryAfterMs = getTelegramRetryAfterMs(err);
|
|
455
|
-
const retryable = isRetryableTelegramError(err);
|
|
456
|
-
if (!retryable || attempt === TELEGRAM_SEND_RETRIES) {
|
|
457
|
-
logger.error({ chatId, attempt, err }, 'Failed to send Telegram message chunk');
|
|
458
|
-
return false;
|
|
459
|
-
}
|
|
460
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
461
|
-
logger.warn({ chatId, attempt, delayMs }, 'Telegram send failed; retrying');
|
|
462
|
-
await sleep(delayMs);
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
return false;
|
|
466
|
-
};
|
|
467
|
-
try {
|
|
468
|
-
// Telegram bots send messages as themselves, no prefix needed
|
|
469
|
-
for (let i = 0; i < chunks.length; i += 1) {
|
|
470
|
-
const ok = await sendChunk(chunks[i], i === 0);
|
|
471
|
-
if (!ok)
|
|
472
|
-
return { success: false };
|
|
473
|
-
if (i < chunks.length - 1) {
|
|
474
|
-
await sleep(TELEGRAM_SEND_DELAY_MS);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
logger.info({ chatId, length: text.length }, 'Message sent');
|
|
478
|
-
return { success: true, messageId: firstMessageId };
|
|
479
|
-
}
|
|
480
|
-
catch (err) {
|
|
481
|
-
logger.error({ chatId, err }, 'Failed to send message');
|
|
482
|
-
return { success: false };
|
|
233
|
+
function applyModelOverride(params) {
|
|
234
|
+
const defaultModel = runtime.host.defaultModel;
|
|
235
|
+
const config = loadModelRegistry(defaultModel);
|
|
236
|
+
const nextModel = params.model.trim();
|
|
237
|
+
if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
|
|
238
|
+
return { ok: false, error: 'Model not in allowlist' };
|
|
483
239
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
function resolveContainerPathToHost(containerPath, groupFolder) {
|
|
489
|
-
return resolveContainerGroupPathToHost(containerPath, groupFolder, GROUPS_DIR);
|
|
490
|
-
}
|
|
491
|
-
async function sendDocument(chatId, filePath, options) {
|
|
492
|
-
for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
|
|
493
|
-
try {
|
|
494
|
-
const payload = {};
|
|
495
|
-
if (options?.caption)
|
|
496
|
-
payload.caption = options.caption;
|
|
497
|
-
if (options?.replyToMessageId) {
|
|
498
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
499
|
-
}
|
|
500
|
-
await telegrafBot.telegram.sendDocument(chatId, { source: filePath }, payload);
|
|
501
|
-
logger.info({ chatId, filePath }, 'Document sent');
|
|
502
|
-
return { success: true };
|
|
503
|
-
}
|
|
504
|
-
catch (err) {
|
|
505
|
-
if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
|
|
506
|
-
logger.error({ chatId, filePath, attempt, err }, 'Failed to send document');
|
|
507
|
-
return { success: false };
|
|
508
|
-
}
|
|
509
|
-
const retryAfterMs = getTelegramRetryAfterMs(err);
|
|
510
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
511
|
-
logger.warn({ chatId, attempt, delayMs }, 'Document send failed; retrying');
|
|
512
|
-
await sleep(delayMs);
|
|
513
|
-
}
|
|
240
|
+
const scope = params.scope || 'global';
|
|
241
|
+
const targetId = params.targetId;
|
|
242
|
+
if (scope === 'user' && !targetId) {
|
|
243
|
+
return { ok: false, error: 'Missing target_id for user scope' };
|
|
514
244
|
}
|
|
515
|
-
|
|
516
|
-
}
|
|
517
|
-
async function sendPhoto(chatId, filePath, options) {
|
|
518
|
-
for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
|
|
519
|
-
try {
|
|
520
|
-
const payload = {};
|
|
521
|
-
if (options?.caption)
|
|
522
|
-
payload.caption = options.caption;
|
|
523
|
-
if (options?.replyToMessageId) {
|
|
524
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
525
|
-
}
|
|
526
|
-
await telegrafBot.telegram.sendPhoto(chatId, { source: filePath }, payload);
|
|
527
|
-
logger.info({ chatId, filePath }, 'Photo sent');
|
|
528
|
-
return { success: true };
|
|
529
|
-
}
|
|
530
|
-
catch (err) {
|
|
531
|
-
if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
|
|
532
|
-
logger.error({ chatId, filePath, attempt, err }, 'Failed to send photo');
|
|
533
|
-
return { success: false };
|
|
534
|
-
}
|
|
535
|
-
const retryAfterMs = getTelegramRetryAfterMs(err);
|
|
536
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
537
|
-
logger.warn({ chatId, attempt, delayMs }, 'Photo send failed; retrying');
|
|
538
|
-
await sleep(delayMs);
|
|
539
|
-
}
|
|
245
|
+
if (scope === 'group' && !targetId) {
|
|
246
|
+
return { ok: false, error: 'Missing target_id for group scope' };
|
|
540
247
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
let localPath = null;
|
|
545
|
-
let tmpPath = null;
|
|
546
|
-
try {
|
|
547
|
-
const fileLink = await telegrafBot.telegram.getFileLink(fileId);
|
|
548
|
-
const url = fileLink.href || String(fileLink);
|
|
549
|
-
const abortController = new AbortController();
|
|
550
|
-
const timeout = setTimeout(() => abortController.abort(), TELEGRAM_FILE_DOWNLOAD_TIMEOUT_MS);
|
|
551
|
-
let response;
|
|
552
|
-
try {
|
|
553
|
-
response = await fetch(url, { signal: abortController.signal });
|
|
554
|
-
}
|
|
555
|
-
finally {
|
|
556
|
-
clearTimeout(timeout);
|
|
557
|
-
}
|
|
558
|
-
if (!response.ok) {
|
|
559
|
-
logger.warn({ fileId, status: response.status }, 'Failed to download Telegram file');
|
|
560
|
-
return { path: null, error: 'download_failed' };
|
|
561
|
-
}
|
|
562
|
-
const contentLength = response.headers.get('content-length');
|
|
563
|
-
const declaredSize = contentLength ? parseInt(contentLength, 10) : NaN;
|
|
564
|
-
if (Number.isFinite(declaredSize) && declaredSize > TELEGRAM_MAX_ATTACHMENT_BYTES) {
|
|
565
|
-
logger.warn({ fileId, size: contentLength }, 'Telegram file too large (>20MB)');
|
|
566
|
-
return { path: null, error: 'too_large' };
|
|
567
|
-
}
|
|
568
|
-
const inboxDir = path.join(GROUPS_DIR, groupFolder, 'inbox');
|
|
569
|
-
fs.mkdirSync(inboxDir, { recursive: true });
|
|
570
|
-
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
571
|
-
const localName = `${Date.now()}_${safeName}`;
|
|
572
|
-
localPath = path.join(inboxDir, localName);
|
|
573
|
-
tmpPath = `${localPath}.tmp`;
|
|
574
|
-
const fileStream = fs.createWriteStream(tmpPath, { flags: 'wx' });
|
|
575
|
-
let bytesWritten = 0;
|
|
576
|
-
const body = response.body;
|
|
577
|
-
if (!body) {
|
|
578
|
-
throw new Error('Telegram response had no body');
|
|
579
|
-
}
|
|
580
|
-
const reader = body.getReader();
|
|
581
|
-
try {
|
|
582
|
-
while (true) {
|
|
583
|
-
const { done, value } = await reader.read();
|
|
584
|
-
if (done)
|
|
585
|
-
break;
|
|
586
|
-
if (!value || value.byteLength === 0)
|
|
587
|
-
continue;
|
|
588
|
-
bytesWritten += value.byteLength;
|
|
589
|
-
if (bytesWritten > TELEGRAM_MAX_ATTACHMENT_BYTES) {
|
|
590
|
-
await reader.cancel();
|
|
591
|
-
throw new Error('STREAMING_TOO_LARGE');
|
|
592
|
-
}
|
|
593
|
-
if (!fileStream.write(Buffer.from(value))) {
|
|
594
|
-
await new Promise(resolve => fileStream.once('drain', resolve));
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
await new Promise((resolve, reject) => {
|
|
598
|
-
fileStream.end((err) => {
|
|
599
|
-
if (err)
|
|
600
|
-
reject(err);
|
|
601
|
-
else
|
|
602
|
-
resolve();
|
|
603
|
-
});
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
catch (streamErr) {
|
|
607
|
-
fileStream.destroy();
|
|
608
|
-
throw streamErr;
|
|
609
|
-
}
|
|
610
|
-
fs.renameSync(tmpPath, localPath);
|
|
611
|
-
tmpPath = null;
|
|
612
|
-
logger.info({ fileId, localPath, size: bytesWritten }, 'Downloaded Telegram file');
|
|
613
|
-
return { path: localPath };
|
|
248
|
+
const nextConfig = { ...config };
|
|
249
|
+
if (scope === 'global') {
|
|
250
|
+
nextConfig.model = nextModel;
|
|
614
251
|
}
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
logger.warn({ fileId }, 'Telegram file too large (>20MB) during streaming');
|
|
619
|
-
}
|
|
620
|
-
else {
|
|
621
|
-
logger.error({ fileId, err }, 'Error downloading Telegram file');
|
|
622
|
-
}
|
|
623
|
-
return { path: null, error: isTooLarge ? 'too_large' : 'download_failed' };
|
|
252
|
+
else if (scope === 'group') {
|
|
253
|
+
nextConfig.per_group = nextConfig.per_group || {};
|
|
254
|
+
nextConfig.per_group[targetId] = { model: nextModel };
|
|
624
255
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
fs.unlinkSync(tmpPath);
|
|
629
|
-
}
|
|
630
|
-
catch {
|
|
631
|
-
// ignore cleanup failure
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
if (localPath && fs.existsSync(localPath) && fs.statSync(localPath).size === 0) {
|
|
635
|
-
try {
|
|
636
|
-
fs.unlinkSync(localPath);
|
|
637
|
-
}
|
|
638
|
-
catch {
|
|
639
|
-
// ignore cleanup failure
|
|
640
|
-
}
|
|
641
|
-
}
|
|
256
|
+
else if (scope === 'user') {
|
|
257
|
+
nextConfig.per_user = nextConfig.per_user || {};
|
|
258
|
+
nextConfig.per_user[targetId] = { model: nextModel };
|
|
642
259
|
}
|
|
260
|
+
nextConfig.updated_at = new Date().toISOString();
|
|
261
|
+
saveModelRegistry(nextConfig);
|
|
262
|
+
return { ok: true };
|
|
643
263
|
}
|
|
644
|
-
function
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const attrs = [`type="${escapeXml(a.type)}"`];
|
|
654
|
-
const containerPath = a.local_path ? hostPathToContainerPath(a.local_path, groupFolder) : null;
|
|
655
|
-
if (containerPath)
|
|
656
|
-
attrs.push(`path="${escapeXml(containerPath)}"`);
|
|
657
|
-
if (a.file_name)
|
|
658
|
-
attrs.push(`filename="${escapeXml(a.file_name)}"`);
|
|
659
|
-
if (a.mime_type)
|
|
660
|
-
attrs.push(`mime="${escapeXml(a.mime_type)}"`);
|
|
661
|
-
if (a.file_size)
|
|
662
|
-
attrs.push(`size="${a.file_size}"`);
|
|
663
|
-
if (a.duration)
|
|
664
|
-
attrs.push(`duration="${a.duration}"`);
|
|
665
|
-
if (a.width)
|
|
666
|
-
attrs.push(`width="${a.width}"`);
|
|
667
|
-
if (a.height)
|
|
668
|
-
attrs.push(`height="${a.height}"`);
|
|
669
|
-
return `<attachment ${attrs.join(' ')} />`;
|
|
670
|
-
}).join('\n');
|
|
671
|
-
}
|
|
672
|
-
async function sendVoice(chatId, filePath, options) {
|
|
673
|
-
for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
|
|
674
|
-
try {
|
|
675
|
-
const payload = {};
|
|
676
|
-
if (options?.caption)
|
|
677
|
-
payload.caption = options.caption;
|
|
678
|
-
if (options?.duration)
|
|
679
|
-
payload.duration = options.duration;
|
|
680
|
-
if (options?.replyToMessageId) {
|
|
681
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
682
|
-
}
|
|
683
|
-
await telegrafBot.telegram.sendVoice(chatId, { source: filePath }, payload);
|
|
684
|
-
logger.info({ chatId, filePath }, 'Voice sent');
|
|
685
|
-
return { success: true };
|
|
686
|
-
}
|
|
687
|
-
catch (err) {
|
|
688
|
-
if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
|
|
689
|
-
logger.error({ chatId, filePath, attempt, err }, 'Failed to send voice');
|
|
690
|
-
return { success: false };
|
|
691
|
-
}
|
|
692
|
-
const retryAfterMs = getTelegramRetryAfterMs(err);
|
|
693
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
694
|
-
await sleep(delayMs);
|
|
695
|
-
}
|
|
264
|
+
async function handleAdminCommand(params, sendReply) {
|
|
265
|
+
const parsed = parseAdminCommand(params.content, params.botUsername);
|
|
266
|
+
if (!parsed)
|
|
267
|
+
return false;
|
|
268
|
+
const reply = (text) => sendReply(params.chatId, text, { threadId: params.threadId });
|
|
269
|
+
const group = registeredGroups[params.chatId];
|
|
270
|
+
if (!group) {
|
|
271
|
+
await reply('This chat is not registered with DotClaw.');
|
|
272
|
+
return true;
|
|
696
273
|
}
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
274
|
+
const isMain = group.folder === MAIN_GROUP_FOLDER;
|
|
275
|
+
const command = parsed.command;
|
|
276
|
+
const args = parsed.args;
|
|
277
|
+
const requireMain = (name) => {
|
|
278
|
+
if (isMain)
|
|
279
|
+
return false;
|
|
280
|
+
reply(`${name} is only available in the main group.`).catch(() => undefined);
|
|
281
|
+
return true;
|
|
282
|
+
};
|
|
283
|
+
if (command === 'help') {
|
|
284
|
+
await reply([
|
|
285
|
+
'DotClaw admin commands:',
|
|
286
|
+
'- `/dotclaw help`',
|
|
287
|
+
'- `/dotclaw groups` (main only)',
|
|
288
|
+
'- `/dotclaw add-group <chat_id> <name> [folder]` (main only)',
|
|
289
|
+
'- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
|
|
290
|
+
'- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
|
|
291
|
+
'- `/dotclaw remember <fact>` (main only)',
|
|
292
|
+
'- `/dotclaw skill install <url> [--global]` (main only)',
|
|
293
|
+
'- `/dotclaw skill remove <name> [--global]` (main only)',
|
|
294
|
+
'- `/dotclaw skill list [--global]` (main only)',
|
|
295
|
+
'- `/dotclaw skill update <name> [--global]` (main only)',
|
|
296
|
+
'- `/dotclaw style <concise|balanced|detailed>`',
|
|
297
|
+
'- `/dotclaw tools <conservative|balanced|proactive>`',
|
|
298
|
+
'- `/dotclaw caution <low|balanced|high>`',
|
|
299
|
+
'- `/dotclaw memory <strict|balanced|loose>`'
|
|
300
|
+
].join('\n'));
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
if (command === 'groups') {
|
|
304
|
+
if (requireMain('Listing groups'))
|
|
305
|
+
return true;
|
|
306
|
+
await reply(formatGroups(listRegisteredGroups()));
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
if (command === 'add-group') {
|
|
310
|
+
if (requireMain('Adding groups'))
|
|
311
|
+
return true;
|
|
312
|
+
if (args.length < 2) {
|
|
313
|
+
await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
|
|
314
|
+
return true;
|
|
717
315
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
725
|
-
await sleep(delayMs);
|
|
316
|
+
const newChatId = args[0];
|
|
317
|
+
const name = args[1];
|
|
318
|
+
const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 50);
|
|
319
|
+
if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
|
|
320
|
+
await reply(`Invalid folder name: "${folder}"`);
|
|
321
|
+
return true;
|
|
726
322
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
async function sendLocation(chatId, latitude, longitude, options) {
|
|
731
|
-
try {
|
|
732
|
-
const payload = {};
|
|
733
|
-
if (options?.replyToMessageId) {
|
|
734
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
323
|
+
if (registeredGroups[newChatId]) {
|
|
324
|
+
await reply(`Chat ${newChatId} is already registered.`);
|
|
325
|
+
return true;
|
|
735
326
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
327
|
+
const newGroup = {
|
|
328
|
+
name,
|
|
329
|
+
folder,
|
|
330
|
+
added_at: new Date().toISOString()
|
|
331
|
+
};
|
|
332
|
+
registerGroup(newChatId, newGroup);
|
|
333
|
+
await reply(`Group "${name}" registered (folder: ${folder}).`);
|
|
334
|
+
return true;
|
|
743
335
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
if (options?.replyToMessageId) {
|
|
751
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
336
|
+
if (command === 'remove-group') {
|
|
337
|
+
if (requireMain('Removing groups'))
|
|
338
|
+
return true;
|
|
339
|
+
if (args.length < 1) {
|
|
340
|
+
await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
|
|
341
|
+
return true;
|
|
752
342
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
catch (err) {
|
|
758
|
-
logger.error({ chatId, err }, 'Failed to send contact');
|
|
759
|
-
return { success: false };
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
async function sendPoll(chatId, question, pollOptions, pollConfig) {
|
|
763
|
-
try {
|
|
764
|
-
const payload = {};
|
|
765
|
-
if (pollConfig?.is_anonymous !== undefined)
|
|
766
|
-
payload.is_anonymous = pollConfig.is_anonymous;
|
|
767
|
-
if (pollConfig?.type)
|
|
768
|
-
payload.type = pollConfig.type;
|
|
769
|
-
if (pollConfig?.allows_multiple_answers !== undefined)
|
|
770
|
-
payload.allows_multiple_answers = pollConfig.allows_multiple_answers;
|
|
771
|
-
if (pollConfig?.correct_option_id !== undefined)
|
|
772
|
-
payload.correct_option_id = pollConfig.correct_option_id;
|
|
773
|
-
if (pollConfig?.replyToMessageId) {
|
|
774
|
-
payload.reply_parameters = { message_id: pollConfig.replyToMessageId, allow_sending_without_reply: true };
|
|
343
|
+
const result = unregisterGroup(args[0]);
|
|
344
|
+
if (!result.ok) {
|
|
345
|
+
await reply(`Failed to remove group: ${result.error}`);
|
|
346
|
+
return true;
|
|
775
347
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
return { success: true, messageId: sent.message_id };
|
|
779
|
-
}
|
|
780
|
-
catch (err) {
|
|
781
|
-
logger.error({ chatId, err }, 'Failed to send poll');
|
|
782
|
-
return { success: false };
|
|
348
|
+
await reply(`Group "${result.group.name}" removed.`);
|
|
349
|
+
return true;
|
|
783
350
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (options?.replyToMessageId) {
|
|
791
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
351
|
+
if (command === 'set-model') {
|
|
352
|
+
if (requireMain('Setting models'))
|
|
353
|
+
return true;
|
|
354
|
+
if (args.length < 1) {
|
|
355
|
+
await reply('Usage: /dotclaw set-model <model> [global|group|user] [target_id]');
|
|
356
|
+
return true;
|
|
792
357
|
}
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
|
|
803
|
-
const callbackDataStore = new Map();
|
|
804
|
-
function registerCallbackData(chatJid, data, label) {
|
|
805
|
-
const id = generateId('cb');
|
|
806
|
-
callbackDataStore.set(id, { chatJid, data, label, createdAt: Date.now() });
|
|
807
|
-
return id;
|
|
808
|
-
}
|
|
809
|
-
// Clean up expired callback data every 60s
|
|
810
|
-
setInterval(() => {
|
|
811
|
-
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
812
|
-
for (const [id, entry] of callbackDataStore) {
|
|
813
|
-
if (entry.createdAt < cutoff) {
|
|
814
|
-
callbackDataStore.delete(id);
|
|
358
|
+
const model = args[0];
|
|
359
|
+
const scopeCandidate = (args[1] || '').toLowerCase();
|
|
360
|
+
const scope = (scopeCandidate === 'global' || scopeCandidate === 'group' || scopeCandidate === 'user')
|
|
361
|
+
? scopeCandidate
|
|
362
|
+
: 'global';
|
|
363
|
+
const targetId = args[2] || (scope === 'group' ? group.folder : scope === 'user' ? params.senderId : undefined);
|
|
364
|
+
const result = applyModelOverride({ model, scope, targetId });
|
|
365
|
+
if (!result.ok) {
|
|
366
|
+
await reply(`Failed to set model: ${result.error || 'unknown error'}`);
|
|
367
|
+
return true;
|
|
815
368
|
}
|
|
369
|
+
await reply(`Model set to ${model} (${scope}${targetId ? `:${targetId}` : ''}).`);
|
|
370
|
+
return true;
|
|
816
371
|
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
.filter(Boolean);
|
|
825
|
-
if (options.length < 2 || options.length > 10)
|
|
826
|
-
return null;
|
|
827
|
-
if (options.some(option => option.length > 100))
|
|
828
|
-
return null;
|
|
829
|
-
if (new Set(options.map(option => option.toLowerCase())).size !== options.length)
|
|
830
|
-
return null;
|
|
831
|
-
return options;
|
|
832
|
-
}
|
|
833
|
-
function isAllowedInlineButtonUrl(value) {
|
|
834
|
-
try {
|
|
835
|
-
const parsed = new URL(value);
|
|
836
|
-
return parsed.protocol === 'http:' || parsed.protocol === 'https:' || parsed.protocol === 'tg:';
|
|
837
|
-
}
|
|
838
|
-
catch {
|
|
839
|
-
return false;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
function normalizeInlineKeyboard(rawButtons) {
|
|
843
|
-
if (!Array.isArray(rawButtons) || rawButtons.length === 0)
|
|
844
|
-
return null;
|
|
845
|
-
const rows = [];
|
|
846
|
-
for (const rawRow of rawButtons) {
|
|
847
|
-
if (!Array.isArray(rawRow) || rawRow.length === 0)
|
|
848
|
-
return null;
|
|
849
|
-
const row = [];
|
|
850
|
-
for (const rawButton of rawRow) {
|
|
851
|
-
if (!rawButton || typeof rawButton !== 'object')
|
|
852
|
-
return null;
|
|
853
|
-
const button = rawButton;
|
|
854
|
-
const text = typeof button.text === 'string' ? button.text.trim() : '';
|
|
855
|
-
const url = typeof button.url === 'string' ? button.url.trim() : '';
|
|
856
|
-
const callbackData = typeof button.callback_data === 'string' ? button.callback_data : '';
|
|
857
|
-
const hasUrl = url.length > 0;
|
|
858
|
-
const hasCallback = callbackData.length > 0;
|
|
859
|
-
if (!text || hasUrl === hasCallback)
|
|
860
|
-
return null;
|
|
861
|
-
if (hasUrl && !isAllowedInlineButtonUrl(url))
|
|
862
|
-
return null;
|
|
863
|
-
if (hasCallback && callbackData.length > 64)
|
|
864
|
-
return null;
|
|
865
|
-
if (hasUrl)
|
|
866
|
-
row.push({ text, url });
|
|
867
|
-
else
|
|
868
|
-
row.push({ text, callback_data: callbackData });
|
|
372
|
+
if (command === 'remember') {
|
|
373
|
+
if (requireMain('Remembering facts'))
|
|
374
|
+
return true;
|
|
375
|
+
const fact = args.join(' ').trim();
|
|
376
|
+
if (!fact) {
|
|
377
|
+
await reply('Usage: /dotclaw remember <fact>');
|
|
378
|
+
return true;
|
|
869
379
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
const exp = Math.max(0, attempt - 1);
|
|
882
|
-
const base = Math.min(MESSAGE_QUEUE_RETRY_MAX_MS, MESSAGE_QUEUE_RETRY_BASE_MS * Math.pow(2, exp));
|
|
883
|
-
const jitter = base * (0.8 + Math.random() * 0.4);
|
|
884
|
-
return Math.max(250, Math.round(jitter));
|
|
885
|
-
}
|
|
886
|
-
async function sendMessageForQueue(chatId, text, options) {
|
|
887
|
-
const result = await sendMessage(chatId, text, options);
|
|
888
|
-
if (!result.success) {
|
|
889
|
-
throw new RetryableMessageProcessingError('Failed to deliver Telegram message');
|
|
380
|
+
const items = [{
|
|
381
|
+
scope: 'global',
|
|
382
|
+
type: 'fact',
|
|
383
|
+
content: fact,
|
|
384
|
+
importance: 0.7,
|
|
385
|
+
confidence: 0.8,
|
|
386
|
+
tags: ['manual']
|
|
387
|
+
}];
|
|
388
|
+
upsertMemoryItems('global', items, 'admin-command');
|
|
389
|
+
await reply(`Remembered: "${fact}"`);
|
|
390
|
+
return true;
|
|
890
391
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
return;
|
|
392
|
+
if (command === 'style') {
|
|
393
|
+
const level = (args[0] || '').toLowerCase();
|
|
394
|
+
const mapping = {
|
|
395
|
+
concise: 'Prefers concise, short responses.',
|
|
396
|
+
balanced: 'Prefers balanced-length responses.',
|
|
397
|
+
detailed: 'Prefers detailed, thorough responses.'
|
|
398
|
+
};
|
|
399
|
+
if (!mapping[level]) {
|
|
400
|
+
await reply('Usage: /dotclaw style <concise|balanced|detailed>');
|
|
401
|
+
return true;
|
|
901
402
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
setMessageQueueDepth(getPendingMessageCount());
|
|
917
|
-
if (!activeDrains.has(msg.chatId)) {
|
|
918
|
-
void drainQueue(msg.chatId);
|
|
403
|
+
const items = [{
|
|
404
|
+
scope: 'user',
|
|
405
|
+
subject_id: params.senderId,
|
|
406
|
+
type: 'preference',
|
|
407
|
+
conflict_key: 'response_style',
|
|
408
|
+
content: mapping[level],
|
|
409
|
+
importance: 0.6,
|
|
410
|
+
confidence: 0.8,
|
|
411
|
+
tags: [`response_style:${level}`]
|
|
412
|
+
}];
|
|
413
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
414
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
415
|
+
await reply(`Response style set to ${level}.`);
|
|
416
|
+
return true;
|
|
919
417
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
const batch = claimBatchForChat(chatId, BATCH_WINDOW_MS, MAX_BATCH_SIZE);
|
|
931
|
-
if (batch.length === 0)
|
|
932
|
-
break;
|
|
933
|
-
iterations++;
|
|
934
|
-
const last = batch[batch.length - 1];
|
|
935
|
-
const triggerMsg = {
|
|
936
|
-
chatId: last.chat_jid,
|
|
937
|
-
messageId: last.message_id,
|
|
938
|
-
senderId: last.sender_id,
|
|
939
|
-
senderName: last.sender_name,
|
|
940
|
-
content: last.content,
|
|
941
|
-
timestamp: last.timestamp,
|
|
942
|
-
isGroup: last.is_group === 1,
|
|
943
|
-
chatType: last.chat_type,
|
|
944
|
-
messageThreadId: last.message_thread_id ?? undefined
|
|
945
|
-
};
|
|
946
|
-
const batchIds = batch.map(b => b.id);
|
|
947
|
-
try {
|
|
948
|
-
await processMessage(triggerMsg);
|
|
949
|
-
completeQueuedMessages(batchIds);
|
|
950
|
-
}
|
|
951
|
-
catch (err) {
|
|
952
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
953
|
-
const attempt = Math.max(1, ...batch.map(row => {
|
|
954
|
-
const previousAttempts = Number.isFinite(row.attempt_count) ? Number(row.attempt_count) : 0;
|
|
955
|
-
return previousAttempts + 1;
|
|
956
|
-
}));
|
|
957
|
-
const isRetryable = err instanceof RetryableMessageProcessingError;
|
|
958
|
-
if (isRetryable && attempt < MESSAGE_QUEUE_MAX_RETRIES) {
|
|
959
|
-
requeueQueuedMessages(batchIds, errMsg);
|
|
960
|
-
const delayMs = computeMessageQueueRetryDelayMs(attempt);
|
|
961
|
-
logger.warn({
|
|
962
|
-
chatId,
|
|
963
|
-
attempt,
|
|
964
|
-
maxRetries: MESSAGE_QUEUE_MAX_RETRIES,
|
|
965
|
-
delayMs,
|
|
966
|
-
error: errMsg
|
|
967
|
-
}, 'Retryable batch failure; re-queued for retry');
|
|
968
|
-
await sleep(delayMs);
|
|
969
|
-
continue;
|
|
970
|
-
}
|
|
971
|
-
failQueuedMessages(batchIds, errMsg);
|
|
972
|
-
logger.error({ chatId, attempt, err }, 'Error processing message batch');
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
if (iterations >= MAX_DRAIN_ITERATIONS) {
|
|
976
|
-
reschedule = true;
|
|
977
|
-
logger.warn({ chatId, iterations }, 'Drain loop hit iteration limit; re-scheduling');
|
|
978
|
-
setTimeout(() => {
|
|
979
|
-
activeDrains.delete(chatId);
|
|
980
|
-
void drainQueue(chatId);
|
|
981
|
-
}, 1000);
|
|
418
|
+
if (command === 'tools') {
|
|
419
|
+
const level = (args[0] || '').toLowerCase();
|
|
420
|
+
const mapping = {
|
|
421
|
+
conservative: 'Prefers conservative tool usage.',
|
|
422
|
+
balanced: 'Prefers balanced tool usage.',
|
|
423
|
+
proactive: 'Prefers proactive tool usage.'
|
|
424
|
+
};
|
|
425
|
+
if (!mapping[level]) {
|
|
426
|
+
await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
|
|
427
|
+
return true;
|
|
982
428
|
}
|
|
429
|
+
const items = [{
|
|
430
|
+
scope: 'user',
|
|
431
|
+
subject_id: params.senderId,
|
|
432
|
+
type: 'preference',
|
|
433
|
+
conflict_key: 'tool_usage',
|
|
434
|
+
content: mapping[level],
|
|
435
|
+
importance: 0.6,
|
|
436
|
+
confidence: 0.8,
|
|
437
|
+
tags: [`tool_usage:${level}`]
|
|
438
|
+
}];
|
|
439
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
440
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
441
|
+
await reply(`Tool usage set to ${level}.`);
|
|
442
|
+
return true;
|
|
983
443
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
444
|
+
if (command === 'caution') {
|
|
445
|
+
const level = (args[0] || '').toLowerCase();
|
|
446
|
+
const mapping = {
|
|
447
|
+
low: 'Prefers low caution.',
|
|
448
|
+
balanced: 'Prefers balanced caution.',
|
|
449
|
+
high: 'Prefers high caution.'
|
|
450
|
+
};
|
|
451
|
+
if (!mapping[level]) {
|
|
452
|
+
await reply('Usage: /dotclaw caution <low|balanced|high>');
|
|
453
|
+
return true;
|
|
987
454
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
455
|
+
const items = [{
|
|
456
|
+
scope: 'user',
|
|
457
|
+
subject_id: params.senderId,
|
|
458
|
+
type: 'preference',
|
|
459
|
+
conflict_key: 'caution_level',
|
|
460
|
+
content: mapping[level],
|
|
461
|
+
importance: 0.6,
|
|
462
|
+
confidence: 0.8,
|
|
463
|
+
tags: [`caution_level:${level}`]
|
|
464
|
+
}];
|
|
465
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
466
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
467
|
+
await reply(`Caution level set to ${level}.`);
|
|
468
|
+
return true;
|
|
996
469
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
let missedMessages = getMessagesSinceCursor(msg.chatId, chatState?.last_agent_timestamp || null, chatState?.last_agent_message_id || null);
|
|
1003
|
-
const triggerMessageId = Number.parseInt(msg.messageId, 10);
|
|
1004
|
-
missedMessages = missedMessages.filter((message) => {
|
|
1005
|
-
if (message.timestamp < msg.timestamp)
|
|
470
|
+
if (command === 'memory') {
|
|
471
|
+
const level = (args[0] || '').toLowerCase();
|
|
472
|
+
const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
|
|
473
|
+
if (threshold === null) {
|
|
474
|
+
await reply('Usage: /dotclaw memory <strict|balanced|loose>');
|
|
1006
475
|
return true;
|
|
1007
|
-
if (message.timestamp !== msg.timestamp)
|
|
1008
|
-
return false;
|
|
1009
|
-
const numericId = Number.parseInt(message.id, 10);
|
|
1010
|
-
if (Number.isFinite(triggerMessageId) && Number.isFinite(numericId)) {
|
|
1011
|
-
return numericId <= triggerMessageId;
|
|
1012
476
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
sender: msg.senderId,
|
|
1024
|
-
sender_name: msg.senderName,
|
|
1025
|
-
content: msg.content,
|
|
1026
|
-
timestamp: msg.timestamp,
|
|
1027
|
-
attachments_json: fallbackAttachments
|
|
477
|
+
const items = [{
|
|
478
|
+
scope: 'user',
|
|
479
|
+
subject_id: params.senderId,
|
|
480
|
+
type: 'preference',
|
|
481
|
+
conflict_key: 'memory_importance_threshold',
|
|
482
|
+
content: `Prefers memory strictness ${level}.`,
|
|
483
|
+
importance: 0.6,
|
|
484
|
+
confidence: 0.8,
|
|
485
|
+
tags: [`memory_importance_threshold:${threshold}`],
|
|
486
|
+
metadata: { memory_importance_threshold: threshold, threshold }
|
|
1028
487
|
}];
|
|
488
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
489
|
+
await reply(`Memory strictness set to ${level}.`);
|
|
490
|
+
return true;
|
|
1029
491
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const attachmentXml = buildAttachmentsXml(attachments, group.folder);
|
|
1047
|
-
const inner = attachmentXml
|
|
1048
|
-
? `${escapeXml(safeContent)}\n${attachmentXml}`
|
|
1049
|
-
: escapeXml(safeContent);
|
|
1050
|
-
return `<message sender="${escapeXml(m.sender_name)}" sender_id="${escapeXml(m.sender)}" time="${m.timestamp}">${inner}</message>`;
|
|
1051
|
-
});
|
|
1052
|
-
const prompt = `<messages>
|
|
1053
|
-
${lines.join('\n')}
|
|
1054
|
-
</messages>`;
|
|
1055
|
-
const lastMessage = missedMessages[missedMessages.length - 1];
|
|
1056
|
-
const parsedReplyToMessageId = Number.parseInt(msg.messageId, 10);
|
|
1057
|
-
const replyToMessageId = Number.isFinite(parsedReplyToMessageId) ? parsedReplyToMessageId : undefined;
|
|
1058
|
-
const containerAttachments = (() => {
|
|
1059
|
-
for (let idx = missedMessages.length - 1; idx >= 0; idx -= 1) {
|
|
1060
|
-
const raw = missedMessages[idx].attachments_json;
|
|
1061
|
-
if (!raw)
|
|
1062
|
-
continue;
|
|
1063
|
-
try {
|
|
1064
|
-
const parsed = JSON.parse(raw);
|
|
1065
|
-
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
1066
|
-
continue;
|
|
1067
|
-
const mapped = parsed.flatMap(attachment => {
|
|
1068
|
-
if (!attachment?.local_path)
|
|
1069
|
-
return [];
|
|
1070
|
-
const containerPath = hostPathToContainerPath(attachment.local_path, group.folder);
|
|
1071
|
-
if (!containerPath)
|
|
1072
|
-
return [];
|
|
1073
|
-
return [{
|
|
1074
|
-
type: attachment.type,
|
|
1075
|
-
path: containerPath,
|
|
1076
|
-
file_name: attachment.file_name,
|
|
1077
|
-
mime_type: attachment.mime_type,
|
|
1078
|
-
file_size: attachment.file_size,
|
|
1079
|
-
duration: attachment.duration,
|
|
1080
|
-
width: attachment.width,
|
|
1081
|
-
height: attachment.height
|
|
1082
|
-
}];
|
|
1083
|
-
});
|
|
1084
|
-
if (mapped.length > 0)
|
|
1085
|
-
return mapped;
|
|
1086
|
-
}
|
|
1087
|
-
catch {
|
|
1088
|
-
// ignore malformed attachment payloads
|
|
1089
|
-
}
|
|
492
|
+
if (command === 'skill-help') {
|
|
493
|
+
await reply([
|
|
494
|
+
'Skill commands:',
|
|
495
|
+
'- `/dotclaw skill install <url> [--global]` — install from git repo or URL',
|
|
496
|
+
'- `/dotclaw skill remove <name> [--global]` — remove a skill',
|
|
497
|
+
'- `/dotclaw skill list [--global]` — list installed skills',
|
|
498
|
+
'- `/dotclaw skill update <name> [--global]` — re-pull from source'
|
|
499
|
+
].join('\n'));
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
if (command === 'skill-install') {
|
|
503
|
+
if (requireMain('Installing skills'))
|
|
504
|
+
return true;
|
|
505
|
+
if (!runtime.agent.skills.installEnabled) {
|
|
506
|
+
await reply('Skill installation is disabled in runtime config (`agent.skills.installEnabled`).');
|
|
507
|
+
return true;
|
|
1090
508
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
lastMessage
|
|
1097
|
-
});
|
|
1098
|
-
recordRoutingDecision(routingDecision.profile);
|
|
1099
|
-
const routerMs = Date.now() - routingStartedAt;
|
|
1100
|
-
recordStageLatency('router', routerMs, 'telegram');
|
|
1101
|
-
logger.info({
|
|
1102
|
-
chatId: msg.chatId,
|
|
1103
|
-
profile: routingDecision.profile,
|
|
1104
|
-
reason: routingDecision.reason,
|
|
1105
|
-
shouldBackground: routingDecision.shouldBackground
|
|
1106
|
-
}, 'Routing decision');
|
|
1107
|
-
const traceBase = createTraceBase({
|
|
1108
|
-
chatId: msg.chatId,
|
|
1109
|
-
groupFolder: group.folder,
|
|
1110
|
-
userId: msg.senderId,
|
|
1111
|
-
inputText: prompt,
|
|
1112
|
-
source: 'dotclaw'
|
|
1113
|
-
});
|
|
1114
|
-
logger.info({ group: group.name, messageCount: missedMessages.length }, 'Processing message');
|
|
1115
|
-
await setTyping(msg.chatId);
|
|
1116
|
-
const recallQuery = missedMessages.map(entry => entry.content).join('\n');
|
|
1117
|
-
let output = null;
|
|
1118
|
-
let context = null;
|
|
1119
|
-
let errorMessage = null;
|
|
1120
|
-
const isTimeoutError = (value) => {
|
|
1121
|
-
if (!value)
|
|
1122
|
-
return false;
|
|
1123
|
-
return /timed out|timeout/i.test(value);
|
|
1124
|
-
};
|
|
1125
|
-
const shouldPlannerProbe = () => {
|
|
1126
|
-
const config = runtime.host.routing.plannerProbe;
|
|
1127
|
-
if (!config.enabled)
|
|
1128
|
-
return false;
|
|
1129
|
-
if (routingDecision.profile === 'fast' || routingDecision.shouldBackground)
|
|
1130
|
-
return false;
|
|
1131
|
-
const contentLength = lastMessage?.content?.length || 0;
|
|
1132
|
-
return contentLength >= config.minChars;
|
|
1133
|
-
};
|
|
1134
|
-
const maybeAutoSpawn = async (reason, _detail, overrides) => {
|
|
1135
|
-
if (!BACKGROUND_JOBS_ENABLED)
|
|
1136
|
-
return false;
|
|
1137
|
-
if (reason !== 'router' && !AUTO_SPAWN_ENABLED)
|
|
1138
|
-
return false;
|
|
1139
|
-
if (reason === 'timeout' && !AUTO_SPAWN_ON_TIMEOUT)
|
|
1140
|
-
return false;
|
|
1141
|
-
if (reason === 'tool_limit' && !AUTO_SPAWN_ON_TOOL_LIMIT)
|
|
1142
|
-
return false;
|
|
1143
|
-
const tags = ['auto-spawn', reason, `profile:${routingDecision.profile}`];
|
|
1144
|
-
if (overrides?.tags && overrides.tags.length > 0) {
|
|
1145
|
-
tags.push(...overrides.tags);
|
|
509
|
+
const isGlobal = args.includes('--global');
|
|
510
|
+
const source = args.filter(a => a !== '--global')[0];
|
|
511
|
+
if (!source) {
|
|
512
|
+
await reply('Usage: /dotclaw skill install <url> [--global]');
|
|
513
|
+
return true;
|
|
1146
514
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
515
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
516
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
517
|
+
await reply(`Installing skill from ${source}...`);
|
|
518
|
+
const result = await installSkill({ source, targetDir, scope });
|
|
519
|
+
if (!result.ok) {
|
|
520
|
+
await reply(`Failed to install skill: ${result.error}`);
|
|
1149
521
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
: null;
|
|
1153
|
-
const computedTimeoutMs = estimatedMs
|
|
1154
|
-
? Math.min(runtime.host.backgroundJobs.maxRuntimeMs, Math.max(5 * 60_000, Math.round(estimatedMs * 2)))
|
|
1155
|
-
: undefined;
|
|
1156
|
-
const result = spawnBackgroundJob({
|
|
1157
|
-
prompt,
|
|
1158
|
-
groupFolder: group.folder,
|
|
1159
|
-
chatJid: msg.chatId,
|
|
1160
|
-
contextMode: 'group',
|
|
1161
|
-
tags,
|
|
1162
|
-
parentTraceId: traceBase.trace_id,
|
|
1163
|
-
parentMessageId: msg.messageId,
|
|
1164
|
-
modelOverride: overrides?.modelOverride ?? routingDecision.modelOverride,
|
|
1165
|
-
maxToolSteps: overrides?.maxToolSteps ?? routingDecision.maxToolSteps,
|
|
1166
|
-
toolAllow: routingDecision.toolAllow,
|
|
1167
|
-
toolDeny: routingDecision.toolDeny,
|
|
1168
|
-
timeoutMs: overrides?.timeoutMs ?? computedTimeoutMs
|
|
1169
|
-
});
|
|
1170
|
-
if (!result.ok || !result.jobId) {
|
|
1171
|
-
logger.warn({ chatId: msg.chatId, reason, error: result.error }, 'Auto-spawn background job failed');
|
|
1172
|
-
return false;
|
|
522
|
+
else {
|
|
523
|
+
await reply(`Skill "${result.name}" installed (${scope}). Available on next agent run.`);
|
|
1173
524
|
}
|
|
1174
|
-
const queuePosition = getBackgroundJobQueuePosition({ jobId: result.jobId, groupFolder: group.folder });
|
|
1175
|
-
const eta = routingDecision.estimatedMinutes ? `~${routingDecision.estimatedMinutes} min` : null;
|
|
1176
|
-
const queueLine = queuePosition && queuePosition.position > 1
|
|
1177
|
-
? `\n\n${queuePosition.position - 1} job${queuePosition.position > 2 ? 's' : ''} ahead of this one.`
|
|
1178
|
-
: '';
|
|
1179
|
-
const etaLine = eta ? `\n\nEstimated time: ${eta}.` : '';
|
|
1180
|
-
const planPreview = plannerProbeSteps.length > 0
|
|
1181
|
-
? formatPlanStepList({ steps: plannerProbeSteps, currentStep: 1, maxSteps: 4 })
|
|
1182
|
-
: '';
|
|
1183
|
-
const planLine = planPreview ? `\n\nPlanned steps:\n${planPreview}` : '';
|
|
1184
|
-
await sendMessageForQueue(msg.chatId, `Working on it in the background. I'll send the result when it's done.${queueLine}${etaLine}${planLine}`, { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1185
|
-
updateChatState(msg.chatId, msg.timestamp, msg.messageId);
|
|
1186
525
|
return true;
|
|
1187
|
-
};
|
|
1188
|
-
let plannerProbeTools = [];
|
|
1189
|
-
let plannerProbeSteps = [];
|
|
1190
|
-
let plannerProbeMs = null;
|
|
1191
|
-
if (shouldPlannerProbe() && lastMessage) {
|
|
1192
|
-
const probeStarted = Date.now();
|
|
1193
|
-
const probeResult = await probePlanner({
|
|
1194
|
-
lastMessage,
|
|
1195
|
-
recentMessages: missedMessages
|
|
1196
|
-
});
|
|
1197
|
-
plannerProbeMs = Date.now() - probeStarted;
|
|
1198
|
-
recordStageLatency('planner_probe', plannerProbeMs, 'telegram');
|
|
1199
|
-
if (probeResult.steps.length > 0)
|
|
1200
|
-
plannerProbeSteps = probeResult.steps;
|
|
1201
|
-
if (probeResult.tools.length > 0)
|
|
1202
|
-
plannerProbeTools = probeResult.tools;
|
|
1203
|
-
logger.info({
|
|
1204
|
-
chatId: msg.chatId,
|
|
1205
|
-
shouldBackground: probeResult.shouldBackground,
|
|
1206
|
-
steps: probeResult.steps.length,
|
|
1207
|
-
tools: probeResult.tools.length,
|
|
1208
|
-
latencyMs: probeResult.latencyMs,
|
|
1209
|
-
model: probeResult.model,
|
|
1210
|
-
error: probeResult.error
|
|
1211
|
-
}, 'Planner probe decision');
|
|
1212
|
-
if (probeResult.shouldBackground) {
|
|
1213
|
-
const autoSpawned = await maybeAutoSpawn('planner', 'planner probe predicted multi-step work');
|
|
1214
|
-
if (autoSpawned)
|
|
1215
|
-
return true;
|
|
1216
|
-
}
|
|
1217
526
|
}
|
|
1218
|
-
if (
|
|
1219
|
-
|
|
1220
|
-
|
|
527
|
+
if (command === 'skill-remove') {
|
|
528
|
+
if (requireMain('Removing skills'))
|
|
529
|
+
return true;
|
|
530
|
+
const isGlobal = args.includes('--global');
|
|
531
|
+
const name = args.filter(a => a !== '--global')[0];
|
|
532
|
+
if (!name) {
|
|
533
|
+
await reply('Usage: /dotclaw skill remove <name> [--global]');
|
|
1221
534
|
return true;
|
|
1222
535
|
}
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const queueDepth = getBackgroundJobQueueDepth({ groupFolder: group.folder });
|
|
1228
|
-
const classifierResult = await classifyBackgroundJob({
|
|
1229
|
-
lastMessage,
|
|
1230
|
-
recentMessages: missedMessages,
|
|
1231
|
-
isGroup: msg.isGroup,
|
|
1232
|
-
chatType: msg.chatType,
|
|
1233
|
-
queueDepth,
|
|
1234
|
-
metricsSource: 'telegram'
|
|
1235
|
-
});
|
|
1236
|
-
if (classifierResult.latencyMs) {
|
|
1237
|
-
classifierMs = classifierResult.latencyMs;
|
|
1238
|
-
recordStageLatency('classifier', classifierResult.latencyMs, 'telegram');
|
|
1239
|
-
}
|
|
1240
|
-
logger.info({
|
|
1241
|
-
chatId: msg.chatId,
|
|
1242
|
-
decision: classifierResult.shouldBackground,
|
|
1243
|
-
confidence: classifierResult.confidence,
|
|
1244
|
-
latencyMs: classifierResult.latencyMs,
|
|
1245
|
-
model: classifierResult.model,
|
|
1246
|
-
reason: classifierResult.reason,
|
|
1247
|
-
error: classifierResult.error
|
|
1248
|
-
}, 'Background job classifier decision');
|
|
1249
|
-
if (classifierResult.shouldBackground) {
|
|
1250
|
-
const estimated = classifierResult.estimatedMinutes;
|
|
1251
|
-
if (typeof estimated === 'number' && Number.isFinite(estimated) && estimated > 0) {
|
|
1252
|
-
routingDecision.estimatedMinutes = Math.round(estimated);
|
|
1253
|
-
}
|
|
1254
|
-
const autoSpawned = await maybeAutoSpawn('classifier', classifierResult.reason);
|
|
1255
|
-
if (autoSpawned) {
|
|
1256
|
-
return true;
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
536
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
537
|
+
const result = removeSkill({ name, targetDir });
|
|
538
|
+
if (!result.ok) {
|
|
539
|
+
await reply(`Failed to remove skill: ${result.error}`);
|
|
1259
540
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
541
|
+
else {
|
|
542
|
+
await reply(`Skill "${name}" removed.`);
|
|
1262
543
|
}
|
|
544
|
+
return true;
|
|
1263
545
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
content: lastMessage?.content || prompt,
|
|
1274
|
-
routing: routingDecision,
|
|
1275
|
-
plannerSteps: plannerProbeSteps,
|
|
1276
|
-
plannerTools: plannerProbeTools
|
|
1277
|
-
});
|
|
1278
|
-
const planStepIndex = inferPlanStepIndex(predictedStage, plannerProbeSteps.length);
|
|
1279
|
-
const progressManager = createProgressManager({
|
|
1280
|
-
enabled: routingDecision.progress.enabled,
|
|
1281
|
-
initialDelayMs: routingDecision.progress.initialMs,
|
|
1282
|
-
intervalMs: routingDecision.progress.intervalMs,
|
|
1283
|
-
maxUpdates: routingDecision.progress.maxUpdates,
|
|
1284
|
-
messages: routingDecision.progress.messages.length > 0
|
|
1285
|
-
? routingDecision.progress.messages
|
|
1286
|
-
: DEFAULT_PROGRESS_MESSAGES,
|
|
1287
|
-
stageMessages: DEFAULT_PROGRESS_STAGES,
|
|
1288
|
-
stageThrottleMs: 20_000,
|
|
1289
|
-
send: async (text) => { await sendMessage(msg.chatId, text, { messageThreadId: msg.messageThreadId }); },
|
|
1290
|
-
onError: (err) => logger.debug({ chatId: msg.chatId, err }, 'Failed to send progress update')
|
|
1291
|
-
});
|
|
1292
|
-
progressManager.start();
|
|
1293
|
-
let sentPlan = false;
|
|
1294
|
-
if (predictedMs && predictedMs >= 10_000 && routingDecision.progress.enabled) {
|
|
1295
|
-
if (plannerProbeSteps.length > 0) {
|
|
1296
|
-
const planMessage = formatProgressWithPlan({
|
|
1297
|
-
steps: plannerProbeSteps,
|
|
1298
|
-
currentStep: planStepIndex ?? 1,
|
|
1299
|
-
stage: predictedStage
|
|
1300
|
-
});
|
|
1301
|
-
progressManager.notify(planMessage);
|
|
1302
|
-
sentPlan = true;
|
|
546
|
+
if (command === 'skill-list') {
|
|
547
|
+
if (requireMain('Listing skills'))
|
|
548
|
+
return true;
|
|
549
|
+
const isGlobal = args.includes('--global');
|
|
550
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
551
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
552
|
+
const skills = listSkills(targetDir, scope);
|
|
553
|
+
if (skills.length === 0) {
|
|
554
|
+
await reply(`No skills installed (${scope}).`);
|
|
1303
555
|
}
|
|
1304
556
|
else {
|
|
1305
|
-
|
|
557
|
+
const lines = skills.map(s => `- ${s.name} (v${s.version}, source: ${s.source === 'local' ? 'local' : 'remote'})`);
|
|
558
|
+
await reply(`Installed skills (${scope}):\n${lines.join('\n')}`);
|
|
1306
559
|
}
|
|
560
|
+
return true;
|
|
1307
561
|
}
|
|
1308
|
-
if (
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
const
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
562
|
+
if (command === 'skill-update') {
|
|
563
|
+
if (requireMain('Updating skills'))
|
|
564
|
+
return true;
|
|
565
|
+
const isGlobal = args.includes('--global');
|
|
566
|
+
const name = args.filter(a => a !== '--global')[0];
|
|
567
|
+
if (!name) {
|
|
568
|
+
await reply('Usage: /dotclaw skill update <name> [--global]');
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
572
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
573
|
+
const result = await updateSkill({ name, targetDir, scope });
|
|
574
|
+
if (!result.ok) {
|
|
575
|
+
await reply(`Failed to update skill: ${result.error}`);
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
await reply(`Skill "${name}" updated.`);
|
|
1321
579
|
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
await reply('Unknown command. Use `/dotclaw help` for options.');
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
// ───────────────────────── Heartbeat ─────────────────────────
|
|
586
|
+
async function runHeartbeatOnce() {
|
|
587
|
+
const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
|
|
588
|
+
if (!entry) {
|
|
589
|
+
logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
|
|
590
|
+
return;
|
|
1322
591
|
}
|
|
1323
|
-
const
|
|
1324
|
-
|
|
592
|
+
const [chatId, group] = entry;
|
|
593
|
+
const prompt = [
|
|
594
|
+
'[HEARTBEAT]',
|
|
595
|
+
'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
|
|
596
|
+
'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
|
|
597
|
+
].join('\n');
|
|
598
|
+
const traceBase = createTraceBase({
|
|
599
|
+
chatId,
|
|
600
|
+
groupFolder: group.folder,
|
|
601
|
+
userId: null,
|
|
602
|
+
inputText: prompt,
|
|
603
|
+
source: 'dotclaw-heartbeat'
|
|
604
|
+
});
|
|
605
|
+
const routingStartedAt = Date.now();
|
|
606
|
+
const routingDecision = routePrompt(prompt);
|
|
607
|
+
recordRoutingDecision(routingDecision.profile);
|
|
608
|
+
const routerMs = Date.now() - routingStartedAt;
|
|
609
|
+
recordStageLatency('router', routerMs, 'scheduler');
|
|
610
|
+
let output = null;
|
|
611
|
+
let context = null;
|
|
612
|
+
let errorMessage = null;
|
|
613
|
+
const baseRecallResults = Number.isFinite(routingDecision.recallMaxResults)
|
|
614
|
+
? Math.max(0, Math.floor(routingDecision.recallMaxResults))
|
|
615
|
+
: MEMORY_RECALL_MAX_RESULTS;
|
|
616
|
+
const baseRecallTokens = Number.isFinite(routingDecision.recallMaxTokens)
|
|
617
|
+
? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
|
|
618
|
+
: MEMORY_RECALL_MAX_TOKENS;
|
|
619
|
+
const recallMaxResults = routingDecision.enableMemoryRecall ? Math.max(4, baseRecallResults - 2) : 0;
|
|
620
|
+
const recallMaxTokens = routingDecision.enableMemoryRecall ? Math.max(600, baseRecallTokens - 200) : 0;
|
|
1325
621
|
try {
|
|
1326
|
-
const recallMaxResults = routingDecision.enableMemoryRecall
|
|
1327
|
-
? (Number.isFinite(routingDecision.recallMaxResults)
|
|
1328
|
-
? Math.max(0, Math.floor(routingDecision.recallMaxResults))
|
|
1329
|
-
: MEMORY_RECALL_MAX_RESULTS)
|
|
1330
|
-
: 0;
|
|
1331
|
-
const recallMaxTokens = routingDecision.enableMemoryRecall
|
|
1332
|
-
? (Number.isFinite(routingDecision.recallMaxTokens)
|
|
1333
|
-
? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
|
|
1334
|
-
: MEMORY_RECALL_MAX_TOKENS)
|
|
1335
|
-
: 0;
|
|
1336
622
|
const execution = await executeAgentRun({
|
|
1337
623
|
group,
|
|
1338
624
|
prompt,
|
|
1339
|
-
chatJid:
|
|
1340
|
-
userId:
|
|
1341
|
-
|
|
1342
|
-
recallQuery: recallQuery || msg.content,
|
|
625
|
+
chatJid: chatId,
|
|
626
|
+
userId: null,
|
|
627
|
+
recallQuery: prompt,
|
|
1343
628
|
recallMaxResults,
|
|
1344
629
|
recallMaxTokens,
|
|
1345
|
-
toolAllow: routingDecision.toolAllow,
|
|
1346
|
-
toolDeny: routingDecision.toolDeny,
|
|
1347
630
|
sessionId: sessions[group.folder],
|
|
1348
631
|
onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
|
|
632
|
+
isScheduledTask: true,
|
|
1349
633
|
availableGroups: buildAvailableGroupsSnapshot(),
|
|
1350
634
|
modelOverride: routingDecision.modelOverride,
|
|
1351
635
|
modelMaxOutputTokens: routingDecision.maxOutputTokens,
|
|
1352
636
|
maxToolSteps: routingDecision.maxToolSteps,
|
|
1353
637
|
disablePlanner: !routingDecision.enablePlanner,
|
|
1354
638
|
disableResponseValidation: !routingDecision.enableResponseValidation,
|
|
1355
|
-
responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
|
|
1356
|
-
disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
output = execution.output;
|
|
1364
|
-
context = execution.context;
|
|
1365
|
-
progressManager.setStage('finalizing');
|
|
1366
|
-
if (output.status === 'error') {
|
|
1367
|
-
errorMessage = output.error || 'Unknown error';
|
|
1368
|
-
}
|
|
1369
|
-
}
|
|
1370
|
-
catch (err) {
|
|
1371
|
-
if (err instanceof AgentExecutionError) {
|
|
1372
|
-
context = err.context;
|
|
1373
|
-
errorMessage = err.message;
|
|
1374
|
-
}
|
|
1375
|
-
else {
|
|
1376
|
-
errorMessage = err instanceof Error ? err.message : String(err);
|
|
1377
|
-
}
|
|
1378
|
-
logger.error({ group: group.name, err }, 'Agent error');
|
|
1379
|
-
}
|
|
1380
|
-
finally {
|
|
1381
|
-
clearInterval(typingInterval);
|
|
1382
|
-
progressManager.stop();
|
|
1383
|
-
activeRuns.delete(msg.chatId);
|
|
1384
|
-
}
|
|
1385
|
-
const extraTimings = {};
|
|
1386
|
-
extraTimings.router_ms = routerMs;
|
|
1387
|
-
if (classifierMs !== null)
|
|
1388
|
-
extraTimings.classifier_ms = classifierMs;
|
|
1389
|
-
if (plannerProbeMs !== null)
|
|
1390
|
-
extraTimings.planner_probe_ms = plannerProbeMs;
|
|
1391
|
-
if (!output) {
|
|
1392
|
-
const message = errorMessage || 'No output from agent';
|
|
1393
|
-
if (context) {
|
|
1394
|
-
recordAgentTelemetry({
|
|
1395
|
-
traceBase,
|
|
1396
|
-
output: null,
|
|
1397
|
-
context,
|
|
1398
|
-
metricsSource: 'telegram',
|
|
1399
|
-
toolAuditSource: 'message',
|
|
1400
|
-
errorMessage: message,
|
|
1401
|
-
errorType: 'agent',
|
|
1402
|
-
extraTimings
|
|
1403
|
-
});
|
|
1404
|
-
}
|
|
1405
|
-
else {
|
|
1406
|
-
recordError('agent');
|
|
1407
|
-
writeTrace({
|
|
1408
|
-
trace_id: traceBase.trace_id,
|
|
1409
|
-
timestamp: traceBase.timestamp,
|
|
1410
|
-
created_at: traceBase.created_at,
|
|
1411
|
-
chat_id: traceBase.chat_id,
|
|
1412
|
-
group_folder: traceBase.group_folder,
|
|
1413
|
-
user_id: traceBase.user_id,
|
|
1414
|
-
input_text: traceBase.input_text,
|
|
1415
|
-
output_text: null,
|
|
1416
|
-
model_id: 'unknown',
|
|
1417
|
-
memory_recall: [],
|
|
1418
|
-
error_code: message,
|
|
1419
|
-
source: traceBase.source
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
if (isTimeoutError(message)) {
|
|
1423
|
-
const autoSpawned = await maybeAutoSpawn('timeout', message);
|
|
1424
|
-
if (autoSpawned) {
|
|
1425
|
-
return true;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
const userMessage = humanizeError(errorMessage || 'Unknown error');
|
|
1429
|
-
await sendMessageForQueue(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1430
|
-
return false;
|
|
1431
|
-
}
|
|
1432
|
-
if (output.status === 'error') {
|
|
1433
|
-
if (context) {
|
|
1434
|
-
recordAgentTelemetry({
|
|
1435
|
-
traceBase,
|
|
1436
|
-
output,
|
|
1437
|
-
context,
|
|
1438
|
-
metricsSource: 'telegram',
|
|
1439
|
-
toolAuditSource: 'message',
|
|
1440
|
-
errorMessage: errorMessage || output.error || 'Unknown error',
|
|
1441
|
-
errorType: 'agent',
|
|
1442
|
-
extraTimings
|
|
1443
|
-
});
|
|
1444
|
-
}
|
|
1445
|
-
logger.error({ group: group.name, error: output.error }, 'Container agent error');
|
|
1446
|
-
const errorText = errorMessage || output.error || 'Unknown error';
|
|
1447
|
-
if (isTimeoutError(errorText)) {
|
|
1448
|
-
const autoSpawned = await maybeAutoSpawn('timeout', errorText);
|
|
1449
|
-
if (autoSpawned) {
|
|
1450
|
-
return true;
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
const userMessage = humanizeError(errorText);
|
|
1454
|
-
await sendMessageForQueue(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1455
|
-
return false;
|
|
1456
|
-
}
|
|
1457
|
-
updateChatState(msg.chatId, msg.timestamp, msg.messageId);
|
|
1458
|
-
if (output.result && output.result.trim()) {
|
|
1459
|
-
const sendResult = await sendMessageForQueue(msg.chatId, output.result, { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1460
|
-
const sentMessageId = sendResult.messageId;
|
|
1461
|
-
// Link the sent message to the trace for feedback tracking
|
|
1462
|
-
if (sentMessageId) {
|
|
1463
|
-
try {
|
|
1464
|
-
linkMessageToTrace(sentMessageId, msg.chatId, traceBase.trace_id);
|
|
1465
|
-
}
|
|
1466
|
-
catch {
|
|
1467
|
-
// Don't fail if linking fails
|
|
1468
|
-
}
|
|
1469
|
-
}
|
|
1470
|
-
if (output.stdoutTruncated) {
|
|
1471
|
-
await sendMessageForQueue(msg.chatId, 'That response was cut short because it was too large. Ask me to continue or try a smaller request.', { messageThreadId: msg.messageThreadId });
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
else if (output.tool_calls && output.tool_calls.length > 0) {
|
|
1475
|
-
const toolLimitHit = !output.result || !output.result.trim() || TOOL_CALL_FALLBACK_PATTERN.test(output.result);
|
|
1476
|
-
if (toolLimitHit) {
|
|
1477
|
-
const autoSpawned = await maybeAutoSpawn('tool_limit', 'Tool-call step limit reached');
|
|
1478
|
-
if (autoSpawned) {
|
|
1479
|
-
if (context) {
|
|
1480
|
-
recordAgentTelemetry({
|
|
1481
|
-
traceBase,
|
|
1482
|
-
output,
|
|
1483
|
-
context,
|
|
1484
|
-
metricsSource: 'telegram',
|
|
1485
|
-
toolAuditSource: 'message',
|
|
1486
|
-
extraTimings
|
|
1487
|
-
});
|
|
1488
|
-
}
|
|
1489
|
-
return true;
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
await sendMessageForQueue(msg.chatId, "I ran out of steps before I could finish. Try narrowing the scope or asking for a specific part.", { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1493
|
-
}
|
|
1494
|
-
else {
|
|
1495
|
-
logger.warn({ chatId: msg.chatId }, 'Agent returned empty/whitespace response');
|
|
1496
|
-
await sendMessageForQueue(msg.chatId, "I wasn't able to come up with a response. Could you try rephrasing?", { messageThreadId: msg.messageThreadId, replyToMessageId });
|
|
1497
|
-
}
|
|
1498
|
-
if (context) {
|
|
1499
|
-
recordAgentTelemetry({
|
|
1500
|
-
traceBase,
|
|
1501
|
-
output,
|
|
1502
|
-
context,
|
|
1503
|
-
metricsSource: 'telegram',
|
|
1504
|
-
toolAuditSource: 'message',
|
|
1505
|
-
extraTimings
|
|
1506
|
-
});
|
|
1507
|
-
}
|
|
1508
|
-
return true;
|
|
1509
|
-
}
|
|
1510
|
-
let ipcWatcher = null;
|
|
1511
|
-
let ipcPollingTimer = null;
|
|
1512
|
-
let ipcStopped = false;
|
|
1513
|
-
function stopIpcWatcher() {
|
|
1514
|
-
ipcStopped = true;
|
|
1515
|
-
if (ipcWatcher) {
|
|
1516
|
-
ipcWatcher.close();
|
|
1517
|
-
ipcWatcher = null;
|
|
1518
|
-
}
|
|
1519
|
-
if (ipcPollingTimer) {
|
|
1520
|
-
clearTimeout(ipcPollingTimer);
|
|
1521
|
-
ipcPollingTimer = null;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
function startIpcWatcher() {
|
|
1525
|
-
const ipcBaseDir = path.join(DATA_DIR, 'ipc');
|
|
1526
|
-
fs.mkdirSync(ipcBaseDir, { recursive: true });
|
|
1527
|
-
ipcStopped = false;
|
|
1528
|
-
let processing = false;
|
|
1529
|
-
let scheduled = false;
|
|
1530
|
-
let rerunRequested = false;
|
|
1531
|
-
const processIpcFiles = async () => {
|
|
1532
|
-
if (processing) {
|
|
1533
|
-
rerunRequested = true;
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
|
-
processing = true;
|
|
1537
|
-
try {
|
|
1538
|
-
do {
|
|
1539
|
-
rerunRequested = false;
|
|
1540
|
-
// Scan all group IPC directories (identity determined by directory)
|
|
1541
|
-
let groupFolders;
|
|
1542
|
-
try {
|
|
1543
|
-
groupFolders = fs.readdirSync(ipcBaseDir).filter(f => {
|
|
1544
|
-
const stat = fs.statSync(path.join(ipcBaseDir, f));
|
|
1545
|
-
return stat.isDirectory() && f !== 'errors';
|
|
1546
|
-
});
|
|
1547
|
-
}
|
|
1548
|
-
catch (err) {
|
|
1549
|
-
logger.error({ err }, 'Error reading IPC base directory');
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
for (const sourceGroup of groupFolders) {
|
|
1553
|
-
const isMain = sourceGroup === MAIN_GROUP_FOLDER;
|
|
1554
|
-
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
|
|
1555
|
-
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
|
|
1556
|
-
const requestsDir = path.join(ipcBaseDir, sourceGroup, 'requests');
|
|
1557
|
-
const responsesDir = path.join(ipcBaseDir, sourceGroup, 'responses');
|
|
1558
|
-
// Process messages from this group's IPC directory
|
|
1559
|
-
try {
|
|
1560
|
-
if (fs.existsSync(messagesDir)) {
|
|
1561
|
-
const messageFiles = fs.readdirSync(messagesDir).filter(f => f.endsWith('.json'));
|
|
1562
|
-
for (const file of messageFiles) {
|
|
1563
|
-
const filePath = path.join(messagesDir, file);
|
|
1564
|
-
try {
|
|
1565
|
-
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1566
|
-
const chatJid = data.chatJid;
|
|
1567
|
-
const targetGroup = chatJid ? registeredGroups[chatJid] : undefined;
|
|
1568
|
-
const isAuthorized = chatJid && (isMain || (targetGroup && targetGroup.folder === sourceGroup));
|
|
1569
|
-
const rawReplyTo = typeof data.reply_to_message_id === 'number'
|
|
1570
|
-
? Math.trunc(data.reply_to_message_id)
|
|
1571
|
-
: NaN;
|
|
1572
|
-
const replyTo = Number.isInteger(rawReplyTo) && rawReplyTo > 0
|
|
1573
|
-
? rawReplyTo
|
|
1574
|
-
: undefined;
|
|
1575
|
-
const messageText = typeof data.text === 'string' ? data.text.trim() : '';
|
|
1576
|
-
if (data.type === 'message' && chatJid && messageText) {
|
|
1577
|
-
if (isAuthorized) {
|
|
1578
|
-
await sendMessage(chatJid, messageText, { replyToMessageId: replyTo });
|
|
1579
|
-
logger.info({ chatJid, sourceGroup }, 'IPC message sent');
|
|
1580
|
-
}
|
|
1581
|
-
else {
|
|
1582
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked');
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
else if ((data.type === 'send_file' || data.type === 'send_photo') && chatJid && data.path) {
|
|
1586
|
-
if (isAuthorized) {
|
|
1587
|
-
const hostPath = resolveContainerPathToHost(data.path, sourceGroup);
|
|
1588
|
-
if (hostPath && fs.existsSync(hostPath)) {
|
|
1589
|
-
const caption = typeof data.caption === 'string' ? data.caption : undefined;
|
|
1590
|
-
if (data.type === 'send_photo') {
|
|
1591
|
-
await sendPhoto(chatJid, hostPath, { caption, replyToMessageId: replyTo });
|
|
1592
|
-
}
|
|
1593
|
-
else {
|
|
1594
|
-
await sendDocument(chatJid, hostPath, { caption, replyToMessageId: replyTo });
|
|
1595
|
-
}
|
|
1596
|
-
logger.info({ chatJid, sourceGroup, type: data.type, path: data.path }, 'IPC file sent');
|
|
1597
|
-
}
|
|
1598
|
-
else {
|
|
1599
|
-
logger.warn({ chatJid, sourceGroup, path: data.path, hostPath }, 'IPC file not found or path not resolvable');
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
else {
|
|
1603
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC file send attempt blocked');
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
else if ((data.type === 'send_voice' || data.type === 'send_audio') && chatJid && data.path) {
|
|
1607
|
-
if (isAuthorized) {
|
|
1608
|
-
const hostPath = resolveContainerPathToHost(data.path, sourceGroup);
|
|
1609
|
-
if (hostPath && fs.existsSync(hostPath)) {
|
|
1610
|
-
const caption = typeof data.caption === 'string' ? data.caption : undefined;
|
|
1611
|
-
const duration = typeof data.duration === 'number' ? data.duration : undefined;
|
|
1612
|
-
if (data.type === 'send_voice') {
|
|
1613
|
-
await sendVoice(chatJid, hostPath, { caption, duration, replyToMessageId: replyTo });
|
|
1614
|
-
}
|
|
1615
|
-
else {
|
|
1616
|
-
const performer = typeof data.performer === 'string' ? data.performer : undefined;
|
|
1617
|
-
const title = typeof data.title === 'string' ? data.title : undefined;
|
|
1618
|
-
await sendAudio(chatJid, hostPath, { caption, duration, performer, title, replyToMessageId: replyTo });
|
|
1619
|
-
}
|
|
1620
|
-
logger.info({ chatJid, sourceGroup, type: data.type }, 'IPC audio/voice sent');
|
|
1621
|
-
}
|
|
1622
|
-
else {
|
|
1623
|
-
logger.warn({ chatJid, sourceGroup, path: data.path }, 'IPC audio file not found');
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
else {
|
|
1627
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC audio send blocked');
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
else if (data.type === 'send_location' && chatJid) {
|
|
1631
|
-
if (isAuthorized) {
|
|
1632
|
-
const lat = typeof data.latitude === 'number' ? data.latitude : NaN;
|
|
1633
|
-
const lng = typeof data.longitude === 'number' ? data.longitude : NaN;
|
|
1634
|
-
if (Number.isFinite(lat)
|
|
1635
|
-
&& Number.isFinite(lng)
|
|
1636
|
-
&& lat >= -90
|
|
1637
|
-
&& lat <= 90
|
|
1638
|
-
&& lng >= -180
|
|
1639
|
-
&& lng <= 180) {
|
|
1640
|
-
await sendLocation(chatJid, lat, lng, { replyToMessageId: replyTo });
|
|
1641
|
-
logger.info({ chatJid, sourceGroup }, 'IPC location sent');
|
|
1642
|
-
}
|
|
1643
|
-
else {
|
|
1644
|
-
logger.warn({ chatJid, sourceGroup }, 'Invalid location coordinates');
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
else {
|
|
1648
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC location send blocked');
|
|
1649
|
-
}
|
|
1650
|
-
}
|
|
1651
|
-
else if (data.type === 'send_contact' && chatJid) {
|
|
1652
|
-
if (isAuthorized) {
|
|
1653
|
-
const phone = typeof data.phone_number === 'string' ? data.phone_number.trim() : '';
|
|
1654
|
-
const firstName = typeof data.first_name === 'string' ? data.first_name.trim() : '';
|
|
1655
|
-
const lastName = typeof data.last_name === 'string' ? data.last_name.trim() : undefined;
|
|
1656
|
-
if (phone && firstName) {
|
|
1657
|
-
await sendContact(chatJid, phone, firstName, { lastName, replyToMessageId: replyTo });
|
|
1658
|
-
logger.info({ chatJid, sourceGroup }, 'IPC contact sent');
|
|
1659
|
-
}
|
|
1660
|
-
else {
|
|
1661
|
-
logger.warn({ chatJid, sourceGroup }, 'Invalid contact (phone/name missing)');
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
else {
|
|
1665
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC contact send blocked');
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1668
|
-
else if (data.type === 'send_poll' && chatJid) {
|
|
1669
|
-
if (isAuthorized) {
|
|
1670
|
-
const question = typeof data.question === 'string' ? data.question.trim() : '';
|
|
1671
|
-
const options = normalizePollOptions(data.options);
|
|
1672
|
-
const pollType = data.poll_type === 'quiz' ? 'quiz' : 'regular';
|
|
1673
|
-
const allowsMultipleAnswers = typeof data.allows_multiple_answers === 'boolean'
|
|
1674
|
-
? data.allows_multiple_answers
|
|
1675
|
-
: undefined;
|
|
1676
|
-
const rawCorrectOptionId = typeof data.correct_option_id === 'number' ? Math.trunc(data.correct_option_id) : undefined;
|
|
1677
|
-
const hasValidCorrectOption = rawCorrectOptionId !== undefined
|
|
1678
|
-
&& options !== null
|
|
1679
|
-
&& rawCorrectOptionId >= 0
|
|
1680
|
-
&& rawCorrectOptionId < options.length;
|
|
1681
|
-
const invalidQuizConfig = pollType === 'quiz' && allowsMultipleAnswers;
|
|
1682
|
-
const unexpectedCorrectOption = pollType !== 'quiz' && rawCorrectOptionId !== undefined;
|
|
1683
|
-
if (question && question.length <= 300 && options && !invalidQuizConfig && !unexpectedCorrectOption && (rawCorrectOptionId === undefined || hasValidCorrectOption)) {
|
|
1684
|
-
await sendPoll(chatJid, question, options, {
|
|
1685
|
-
is_anonymous: typeof data.is_anonymous === 'boolean' ? data.is_anonymous : undefined,
|
|
1686
|
-
type: pollType,
|
|
1687
|
-
allows_multiple_answers: allowsMultipleAnswers,
|
|
1688
|
-
correct_option_id: pollType === 'quiz' ? rawCorrectOptionId : undefined,
|
|
1689
|
-
replyToMessageId: replyTo
|
|
1690
|
-
});
|
|
1691
|
-
logger.info({ chatJid, sourceGroup }, 'IPC poll sent');
|
|
1692
|
-
}
|
|
1693
|
-
else {
|
|
1694
|
-
logger.warn({ chatJid, sourceGroup }, 'Invalid poll payload');
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
else {
|
|
1698
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC poll send blocked');
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
else if (data.type === 'send_buttons' && chatJid) {
|
|
1702
|
-
if (isAuthorized) {
|
|
1703
|
-
const text = typeof data.text === 'string' ? data.text.trim() : '';
|
|
1704
|
-
const normalizedButtons = normalizeInlineKeyboard(data.buttons);
|
|
1705
|
-
if (text && normalizedButtons) {
|
|
1706
|
-
// Register callback data and replace with IDs
|
|
1707
|
-
const buttons = normalizedButtons.map(row => row.map(btn => {
|
|
1708
|
-
if (btn.callback_data && !btn.url) {
|
|
1709
|
-
const cbId = registerCallbackData(chatJid, btn.callback_data, btn.text);
|
|
1710
|
-
return { text: btn.text, callback_data: cbId };
|
|
1711
|
-
}
|
|
1712
|
-
return btn;
|
|
1713
|
-
}));
|
|
1714
|
-
await sendInlineKeyboard(chatJid, text, buttons, { replyToMessageId: replyTo });
|
|
1715
|
-
logger.info({ chatJid, sourceGroup }, 'IPC buttons sent');
|
|
1716
|
-
}
|
|
1717
|
-
else {
|
|
1718
|
-
logger.warn({ chatJid, sourceGroup }, 'Invalid buttons message');
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
else {
|
|
1722
|
-
logger.warn({ chatJid, sourceGroup }, 'Unauthorized IPC buttons send blocked');
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
fs.unlinkSync(filePath);
|
|
1726
|
-
}
|
|
1727
|
-
catch (err) {
|
|
1728
|
-
logger.error({ file, sourceGroup, err }, 'Error processing IPC message');
|
|
1729
|
-
const errorDir = path.join(ipcBaseDir, 'errors');
|
|
1730
|
-
fs.mkdirSync(errorDir, { recursive: true });
|
|
1731
|
-
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
|
1732
|
-
}
|
|
1733
|
-
}
|
|
1734
|
-
}
|
|
1735
|
-
}
|
|
1736
|
-
catch (err) {
|
|
1737
|
-
logger.error({ err, sourceGroup }, 'Error reading IPC messages directory');
|
|
1738
|
-
}
|
|
1739
|
-
// Process tasks from this group's IPC directory
|
|
1740
|
-
try {
|
|
1741
|
-
if (fs.existsSync(tasksDir)) {
|
|
1742
|
-
const taskFiles = fs.readdirSync(tasksDir).filter(f => f.endsWith('.json'));
|
|
1743
|
-
for (const file of taskFiles) {
|
|
1744
|
-
const filePath = path.join(tasksDir, file);
|
|
1745
|
-
try {
|
|
1746
|
-
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1747
|
-
// Pass source group identity to processTaskIpc for authorization
|
|
1748
|
-
await processTaskIpc(data, sourceGroup, isMain);
|
|
1749
|
-
fs.unlinkSync(filePath);
|
|
1750
|
-
}
|
|
1751
|
-
catch (err) {
|
|
1752
|
-
logger.error({ file, sourceGroup, err }, 'Error processing IPC task');
|
|
1753
|
-
const errorDir = path.join(ipcBaseDir, 'errors');
|
|
1754
|
-
fs.mkdirSync(errorDir, { recursive: true });
|
|
1755
|
-
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
catch (err) {
|
|
1761
|
-
logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory');
|
|
1762
|
-
}
|
|
1763
|
-
// Process request/response IPC for synchronous operations (memory, etc.)
|
|
1764
|
-
try {
|
|
1765
|
-
if (fs.existsSync(requestsDir)) {
|
|
1766
|
-
fs.mkdirSync(responsesDir, { recursive: true });
|
|
1767
|
-
const requestFiles = fs.readdirSync(requestsDir).filter(f => f.endsWith('.json'));
|
|
1768
|
-
for (const file of requestFiles) {
|
|
1769
|
-
const filePath = path.join(requestsDir, file);
|
|
1770
|
-
try {
|
|
1771
|
-
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
1772
|
-
const response = await processRequestIpc(data, sourceGroup, isMain);
|
|
1773
|
-
if (response?.id) {
|
|
1774
|
-
const responsePath = path.join(responsesDir, `${response.id}.json`);
|
|
1775
|
-
const tmpPath = responsePath + '.tmp';
|
|
1776
|
-
fs.writeFileSync(tmpPath, JSON.stringify(response, null, 2));
|
|
1777
|
-
fs.renameSync(tmpPath, responsePath);
|
|
1778
|
-
}
|
|
1779
|
-
fs.unlinkSync(filePath);
|
|
1780
|
-
}
|
|
1781
|
-
catch (err) {
|
|
1782
|
-
logger.error({ file, sourceGroup, err }, 'Error processing IPC request');
|
|
1783
|
-
const errorDir = path.join(ipcBaseDir, 'errors');
|
|
1784
|
-
fs.mkdirSync(errorDir, { recursive: true });
|
|
1785
|
-
fs.renameSync(filePath, path.join(errorDir, `${sourceGroup}-${file}`));
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
catch (err) {
|
|
1791
|
-
logger.error({ err, sourceGroup }, 'Error reading IPC requests directory');
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
} while (rerunRequested && !ipcStopped);
|
|
1795
|
-
}
|
|
1796
|
-
finally {
|
|
1797
|
-
processing = false;
|
|
1798
|
-
}
|
|
1799
|
-
};
|
|
1800
|
-
const scheduleProcess = () => {
|
|
1801
|
-
if (ipcStopped)
|
|
1802
|
-
return;
|
|
1803
|
-
if (processing) {
|
|
1804
|
-
rerunRequested = true;
|
|
1805
|
-
return;
|
|
1806
|
-
}
|
|
1807
|
-
if (scheduled)
|
|
1808
|
-
return;
|
|
1809
|
-
scheduled = true;
|
|
1810
|
-
setTimeout(async () => {
|
|
1811
|
-
scheduled = false;
|
|
1812
|
-
if (!ipcStopped)
|
|
1813
|
-
await processIpcFiles();
|
|
1814
|
-
}, 100);
|
|
1815
|
-
};
|
|
1816
|
-
let watcherActive = false;
|
|
1817
|
-
try {
|
|
1818
|
-
ipcWatcher = fs.watch(ipcBaseDir, { recursive: true }, () => {
|
|
1819
|
-
scheduleProcess();
|
|
1820
|
-
});
|
|
1821
|
-
ipcWatcher.on('error', (err) => {
|
|
1822
|
-
logger.warn({ err }, 'IPC watcher error; falling back to polling');
|
|
1823
|
-
ipcWatcher?.close();
|
|
1824
|
-
ipcWatcher = null;
|
|
1825
|
-
if (!ipcPollingTimer && !ipcStopped) {
|
|
1826
|
-
const poll = () => {
|
|
1827
|
-
if (ipcStopped)
|
|
1828
|
-
return;
|
|
1829
|
-
scheduleProcess();
|
|
1830
|
-
ipcPollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
|
|
1831
|
-
};
|
|
1832
|
-
poll();
|
|
1833
|
-
}
|
|
1834
|
-
});
|
|
1835
|
-
watcherActive = true;
|
|
1836
|
-
}
|
|
1837
|
-
catch (err) {
|
|
1838
|
-
logger.warn({ err }, 'IPC watch unsupported; falling back to polling');
|
|
1839
|
-
}
|
|
1840
|
-
if (!watcherActive) {
|
|
1841
|
-
const poll = () => {
|
|
1842
|
-
if (ipcStopped)
|
|
1843
|
-
return;
|
|
1844
|
-
scheduleProcess();
|
|
1845
|
-
ipcPollingTimer = setTimeout(poll, IPC_POLL_INTERVAL);
|
|
1846
|
-
};
|
|
1847
|
-
poll();
|
|
1848
|
-
}
|
|
1849
|
-
else {
|
|
1850
|
-
scheduleProcess();
|
|
1851
|
-
}
|
|
1852
|
-
if (ipcPollingTimer) {
|
|
1853
|
-
logger.info('IPC watcher started (polling)');
|
|
1854
|
-
}
|
|
1855
|
-
else {
|
|
1856
|
-
logger.info('IPC watcher started (fs.watch)');
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
async function runHeartbeatOnce() {
|
|
1860
|
-
const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
|
|
1861
|
-
if (!entry) {
|
|
1862
|
-
logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
|
|
1863
|
-
return;
|
|
1864
|
-
}
|
|
1865
|
-
const [chatId, group] = entry;
|
|
1866
|
-
const prompt = [
|
|
1867
|
-
'[HEARTBEAT]',
|
|
1868
|
-
'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
|
|
1869
|
-
'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
|
|
1870
|
-
].join('\n');
|
|
1871
|
-
const traceBase = createTraceBase({
|
|
1872
|
-
chatId,
|
|
1873
|
-
groupFolder: group.folder,
|
|
1874
|
-
userId: null,
|
|
1875
|
-
inputText: prompt,
|
|
1876
|
-
source: 'dotclaw-heartbeat'
|
|
1877
|
-
});
|
|
1878
|
-
const routingStartedAt = Date.now();
|
|
1879
|
-
const routingDecision = routePrompt(prompt);
|
|
1880
|
-
recordRoutingDecision(routingDecision.profile);
|
|
1881
|
-
const routerMs = Date.now() - routingStartedAt;
|
|
1882
|
-
recordStageLatency('router', routerMs, 'scheduler');
|
|
1883
|
-
let output = null;
|
|
1884
|
-
let context = null;
|
|
1885
|
-
let errorMessage = null;
|
|
1886
|
-
const baseRecallResults = Number.isFinite(routingDecision.recallMaxResults)
|
|
1887
|
-
? Math.max(0, Math.floor(routingDecision.recallMaxResults))
|
|
1888
|
-
: MEMORY_RECALL_MAX_RESULTS;
|
|
1889
|
-
const baseRecallTokens = Number.isFinite(routingDecision.recallMaxTokens)
|
|
1890
|
-
? Math.max(0, Math.floor(routingDecision.recallMaxTokens))
|
|
1891
|
-
: MEMORY_RECALL_MAX_TOKENS;
|
|
1892
|
-
const recallMaxResults = routingDecision.enableMemoryRecall ? Math.max(4, baseRecallResults - 2) : 0;
|
|
1893
|
-
const recallMaxTokens = routingDecision.enableMemoryRecall ? Math.max(600, baseRecallTokens - 200) : 0;
|
|
1894
|
-
try {
|
|
1895
|
-
const execution = await executeAgentRun({
|
|
1896
|
-
group,
|
|
1897
|
-
prompt,
|
|
1898
|
-
chatJid: chatId,
|
|
1899
|
-
userId: null,
|
|
1900
|
-
recallQuery: prompt,
|
|
1901
|
-
recallMaxResults,
|
|
1902
|
-
recallMaxTokens,
|
|
1903
|
-
sessionId: sessions[group.folder],
|
|
1904
|
-
onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
|
|
1905
|
-
isScheduledTask: true,
|
|
1906
|
-
availableGroups: buildAvailableGroupsSnapshot(),
|
|
1907
|
-
modelOverride: routingDecision.modelOverride,
|
|
1908
|
-
modelMaxOutputTokens: routingDecision.maxOutputTokens,
|
|
1909
|
-
maxToolSteps: routingDecision.maxToolSteps,
|
|
1910
|
-
disablePlanner: !routingDecision.enablePlanner,
|
|
1911
|
-
disableResponseValidation: !routingDecision.enableResponseValidation,
|
|
1912
|
-
responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
|
|
1913
|
-
disableMemoryExtraction: !routingDecision.enableMemoryExtraction
|
|
1914
|
-
});
|
|
1915
|
-
output = execution.output;
|
|
1916
|
-
context = execution.context;
|
|
1917
|
-
if (output.status === 'error') {
|
|
1918
|
-
errorMessage = output.error || 'Unknown error';
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
catch (err) {
|
|
1922
|
-
if (err instanceof AgentExecutionError) {
|
|
1923
|
-
context = err.context;
|
|
1924
|
-
errorMessage = err.message;
|
|
1925
|
-
}
|
|
1926
|
-
else {
|
|
1927
|
-
errorMessage = err instanceof Error ? err.message : String(err);
|
|
1928
|
-
}
|
|
1929
|
-
logger.error({ err }, 'Heartbeat run failed');
|
|
1930
|
-
}
|
|
1931
|
-
if (context) {
|
|
1932
|
-
recordAgentTelemetry({
|
|
1933
|
-
traceBase,
|
|
1934
|
-
output,
|
|
1935
|
-
context,
|
|
1936
|
-
toolAuditSource: 'heartbeat',
|
|
1937
|
-
errorMessage: errorMessage ?? undefined,
|
|
1938
|
-
extraTimings: { router_ms: routerMs }
|
|
1939
|
-
});
|
|
1940
|
-
}
|
|
1941
|
-
else if (errorMessage) {
|
|
1942
|
-
writeTrace({
|
|
1943
|
-
trace_id: traceBase.trace_id,
|
|
1944
|
-
timestamp: traceBase.timestamp,
|
|
1945
|
-
created_at: traceBase.created_at,
|
|
1946
|
-
chat_id: traceBase.chat_id,
|
|
1947
|
-
group_folder: traceBase.group_folder,
|
|
1948
|
-
user_id: traceBase.user_id,
|
|
1949
|
-
input_text: traceBase.input_text,
|
|
1950
|
-
output_text: null,
|
|
1951
|
-
model_id: 'unknown',
|
|
1952
|
-
memory_recall: [],
|
|
1953
|
-
error_code: errorMessage,
|
|
1954
|
-
source: traceBase.source
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
let heartbeatStopped = false;
|
|
1959
|
-
function stopHeartbeatLoop() {
|
|
1960
|
-
heartbeatStopped = true;
|
|
1961
|
-
}
|
|
1962
|
-
function startHeartbeatLoop() {
|
|
1963
|
-
if (!HEARTBEAT_ENABLED)
|
|
1964
|
-
return;
|
|
1965
|
-
heartbeatStopped = false;
|
|
1966
|
-
const loop = async () => {
|
|
1967
|
-
if (heartbeatStopped)
|
|
1968
|
-
return;
|
|
1969
|
-
try {
|
|
1970
|
-
await runHeartbeatOnce();
|
|
1971
|
-
}
|
|
1972
|
-
catch (err) {
|
|
1973
|
-
logger.error({ err }, 'Heartbeat run failed');
|
|
1974
|
-
}
|
|
1975
|
-
if (!heartbeatStopped) {
|
|
1976
|
-
setTimeout(loop, HEARTBEAT_INTERVAL_MS);
|
|
1977
|
-
}
|
|
1978
|
-
};
|
|
1979
|
-
loop();
|
|
1980
|
-
}
|
|
1981
|
-
async function onWakeRecovery(sleepDurationMs) {
|
|
1982
|
-
logger.info({ sleepDurationMs }, 'Running wake recovery');
|
|
1983
|
-
// 1. Suppress daemon health check kills for 60s
|
|
1984
|
-
suppressHealthChecks(60_000);
|
|
1985
|
-
resetUnhealthyDaemons();
|
|
1986
|
-
// 2. Reconnect Telegram
|
|
1987
|
-
try {
|
|
1988
|
-
setTelegramConnected(false);
|
|
1989
|
-
telegrafBot.stop('WAKE');
|
|
1990
|
-
await sleep(1_000);
|
|
1991
|
-
telegrafBot.launch();
|
|
1992
|
-
setTelegramConnected(true);
|
|
1993
|
-
logger.info('Telegram reconnected after wake');
|
|
1994
|
-
}
|
|
1995
|
-
catch (err) {
|
|
1996
|
-
logger.error({ err }, 'Failed to reconnect Telegram after wake');
|
|
1997
|
-
}
|
|
1998
|
-
// 3. Reset stalled messages (1s cutoff spares anything claimed post-wake)
|
|
1999
|
-
try {
|
|
2000
|
-
const resetCount = resetStalledMessages(1_000);
|
|
2001
|
-
if (resetCount > 0)
|
|
2002
|
-
logger.info({ resetCount }, 'Reset stalled messages after wake');
|
|
2003
|
-
}
|
|
2004
|
-
catch (err) {
|
|
2005
|
-
logger.error({ err }, 'Failed to reset stalled messages after wake');
|
|
2006
|
-
}
|
|
2007
|
-
// 4. Reset stalled background jobs
|
|
2008
|
-
try {
|
|
2009
|
-
const resetJobCount = resetStalledBackgroundJobs();
|
|
2010
|
-
if (resetJobCount > 0)
|
|
2011
|
-
logger.info({ count: resetJobCount }, 'Re-queued stalled background jobs after wake');
|
|
2012
|
-
}
|
|
2013
|
-
catch (err) {
|
|
2014
|
-
logger.error({ err }, 'Failed to reset stalled background jobs after wake');
|
|
2015
|
-
}
|
|
2016
|
-
// 5. Re-drain pending message queues
|
|
2017
|
-
try {
|
|
2018
|
-
const pendingChats = getChatsWithPendingMessages();
|
|
2019
|
-
for (const chatId of pendingChats) {
|
|
2020
|
-
if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
|
|
2021
|
-
void drainQueue(chatId);
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
catch (err) {
|
|
2026
|
-
logger.error({ err }, 'Failed to resume message drains after wake');
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
async function processTaskIpc(data, sourceGroup, isMain) {
|
|
2030
|
-
const { CronExpressionParser } = await import('cron-parser');
|
|
2031
|
-
switch (data.type) {
|
|
2032
|
-
case 'schedule_task':
|
|
2033
|
-
if (data.prompt && data.schedule_type && data.schedule_value && data.groupFolder) {
|
|
2034
|
-
// Authorization: non-main groups can only schedule for themselves
|
|
2035
|
-
const targetGroup = data.groupFolder;
|
|
2036
|
-
if (!isMain && targetGroup !== sourceGroup) {
|
|
2037
|
-
logger.warn({ sourceGroup, targetGroup }, 'Unauthorized schedule_task attempt blocked');
|
|
2038
|
-
break;
|
|
2039
|
-
}
|
|
2040
|
-
// Resolve the correct chat ID for the target group (don't trust IPC payload)
|
|
2041
|
-
const targetChatId = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup)?.[0];
|
|
2042
|
-
if (!targetChatId) {
|
|
2043
|
-
logger.warn({ targetGroup }, 'Cannot schedule task: target group not registered');
|
|
2044
|
-
break;
|
|
2045
|
-
}
|
|
2046
|
-
const scheduleType = data.schedule_type;
|
|
2047
|
-
let taskTimezone = TIMEZONE;
|
|
2048
|
-
if (typeof data.timezone === 'string' && data.timezone.trim()) {
|
|
2049
|
-
const candidateTimezone = data.timezone.trim();
|
|
2050
|
-
if (!isValidTimezone(candidateTimezone)) {
|
|
2051
|
-
logger.warn({ timezone: data.timezone }, 'Invalid task timezone');
|
|
2052
|
-
break;
|
|
2053
|
-
}
|
|
2054
|
-
taskTimezone = candidateTimezone;
|
|
2055
|
-
}
|
|
2056
|
-
let nextRun = null;
|
|
2057
|
-
if (scheduleType === 'cron') {
|
|
2058
|
-
try {
|
|
2059
|
-
const interval = CronExpressionParser.parse(data.schedule_value, { tz: taskTimezone });
|
|
2060
|
-
nextRun = interval.next().toISOString();
|
|
2061
|
-
}
|
|
2062
|
-
catch {
|
|
2063
|
-
logger.warn({ scheduleValue: data.schedule_value, timezone: taskTimezone }, 'Invalid cron expression');
|
|
2064
|
-
break;
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
else if (scheduleType === 'interval') {
|
|
2068
|
-
const ms = parseInt(data.schedule_value, 10);
|
|
2069
|
-
if (isNaN(ms) || ms <= 0) {
|
|
2070
|
-
logger.warn({ scheduleValue: data.schedule_value }, 'Invalid interval');
|
|
2071
|
-
break;
|
|
2072
|
-
}
|
|
2073
|
-
nextRun = new Date(Date.now() + ms).toISOString();
|
|
2074
|
-
}
|
|
2075
|
-
else if (scheduleType === 'once') {
|
|
2076
|
-
const scheduled = parseScheduledTimestamp(data.schedule_value, taskTimezone);
|
|
2077
|
-
if (!scheduled) {
|
|
2078
|
-
logger.warn({ scheduleValue: data.schedule_value, timezone: taskTimezone }, 'Invalid timestamp');
|
|
2079
|
-
break;
|
|
2080
|
-
}
|
|
2081
|
-
nextRun = scheduled.toISOString();
|
|
2082
|
-
}
|
|
2083
|
-
const taskId = generateId('task');
|
|
2084
|
-
const contextMode = (data.context_mode === 'group' || data.context_mode === 'isolated')
|
|
2085
|
-
? data.context_mode
|
|
2086
|
-
: 'isolated';
|
|
2087
|
-
createTask({
|
|
2088
|
-
id: taskId,
|
|
2089
|
-
group_folder: targetGroup,
|
|
2090
|
-
chat_jid: targetChatId,
|
|
2091
|
-
prompt: data.prompt,
|
|
2092
|
-
schedule_type: scheduleType,
|
|
2093
|
-
schedule_value: data.schedule_value,
|
|
2094
|
-
timezone: taskTimezone,
|
|
2095
|
-
context_mode: contextMode,
|
|
2096
|
-
next_run: nextRun,
|
|
2097
|
-
status: 'active',
|
|
2098
|
-
created_at: new Date().toISOString()
|
|
2099
|
-
});
|
|
2100
|
-
logger.info({ taskId, sourceGroup, targetGroup, contextMode, timezone: taskTimezone }, 'Task created via IPC');
|
|
2101
|
-
}
|
|
2102
|
-
break;
|
|
2103
|
-
case 'pause_task':
|
|
2104
|
-
if (data.taskId) {
|
|
2105
|
-
const task = getTaskById(data.taskId);
|
|
2106
|
-
if (task && (isMain || task.group_folder === sourceGroup)) {
|
|
2107
|
-
updateTask(data.taskId, { status: 'paused' });
|
|
2108
|
-
logger.info({ taskId: data.taskId, sourceGroup }, 'Task paused via IPC');
|
|
2109
|
-
}
|
|
2110
|
-
else {
|
|
2111
|
-
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt');
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2114
|
-
break;
|
|
2115
|
-
case 'resume_task':
|
|
2116
|
-
if (data.taskId) {
|
|
2117
|
-
const task = getTaskById(data.taskId);
|
|
2118
|
-
if (task && (isMain || task.group_folder === sourceGroup)) {
|
|
2119
|
-
updateTask(data.taskId, { status: 'active' });
|
|
2120
|
-
logger.info({ taskId: data.taskId, sourceGroup }, 'Task resumed via IPC');
|
|
2121
|
-
}
|
|
2122
|
-
else {
|
|
2123
|
-
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt');
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
break;
|
|
2127
|
-
case 'cancel_task':
|
|
2128
|
-
if (data.taskId) {
|
|
2129
|
-
const task = getTaskById(data.taskId);
|
|
2130
|
-
if (task && (isMain || task.group_folder === sourceGroup)) {
|
|
2131
|
-
deleteTask(data.taskId);
|
|
2132
|
-
logger.info({ taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC');
|
|
2133
|
-
}
|
|
2134
|
-
else {
|
|
2135
|
-
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt');
|
|
2136
|
-
}
|
|
2137
|
-
}
|
|
2138
|
-
break;
|
|
2139
|
-
case 'update_task':
|
|
2140
|
-
if (data.taskId) {
|
|
2141
|
-
const task = getTaskById(data.taskId);
|
|
2142
|
-
if (!task || (!isMain && task.group_folder !== sourceGroup)) {
|
|
2143
|
-
logger.warn({ taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt');
|
|
2144
|
-
break;
|
|
2145
|
-
}
|
|
2146
|
-
const updates = {};
|
|
2147
|
-
if (typeof data.prompt === 'string')
|
|
2148
|
-
updates.prompt = data.prompt;
|
|
2149
|
-
if (typeof data.context_mode === 'string')
|
|
2150
|
-
updates.context_mode = data.context_mode;
|
|
2151
|
-
if (typeof data.status === 'string')
|
|
2152
|
-
updates.status = data.status;
|
|
2153
|
-
if (typeof data.state_json === 'string')
|
|
2154
|
-
updates.state_json = data.state_json;
|
|
2155
|
-
if (typeof data.timezone === 'string') {
|
|
2156
|
-
const timezoneValue = data.timezone.trim();
|
|
2157
|
-
if (timezoneValue) {
|
|
2158
|
-
if (!isValidTimezone(timezoneValue)) {
|
|
2159
|
-
logger.warn({ timezone: data.timezone }, 'Invalid timezone for update_task');
|
|
2160
|
-
break;
|
|
2161
|
-
}
|
|
2162
|
-
updates.timezone = timezoneValue;
|
|
2163
|
-
}
|
|
2164
|
-
else {
|
|
2165
|
-
updates.timezone = normalizeTaskTimezone(task.timezone, TIMEZONE);
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
if (typeof data.schedule_type === 'string' && typeof data.schedule_value === 'string') {
|
|
2169
|
-
updates.schedule_type = data.schedule_type;
|
|
2170
|
-
updates.schedule_value = data.schedule_value;
|
|
2171
|
-
const taskTimezone = updates.timezone || task.timezone || TIMEZONE;
|
|
2172
|
-
let nextRun = null;
|
|
2173
|
-
if (updates.schedule_type === 'cron') {
|
|
2174
|
-
try {
|
|
2175
|
-
const interval = CronExpressionParser.parse(updates.schedule_value, { tz: taskTimezone });
|
|
2176
|
-
nextRun = interval.next().toISOString();
|
|
2177
|
-
}
|
|
2178
|
-
catch {
|
|
2179
|
-
logger.warn({ scheduleValue: updates.schedule_value, timezone: taskTimezone }, 'Invalid cron expression for update_task');
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
else if (updates.schedule_type === 'interval') {
|
|
2183
|
-
const ms = parseInt(updates.schedule_value, 10);
|
|
2184
|
-
if (!isNaN(ms) && ms > 0) {
|
|
2185
|
-
nextRun = new Date(Date.now() + ms).toISOString();
|
|
2186
|
-
}
|
|
2187
|
-
}
|
|
2188
|
-
else if (updates.schedule_type === 'once') {
|
|
2189
|
-
const scheduled = parseScheduledTimestamp(updates.schedule_value, taskTimezone);
|
|
2190
|
-
if (scheduled) {
|
|
2191
|
-
nextRun = scheduled.toISOString();
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
if (nextRun) {
|
|
2195
|
-
updates.next_run = nextRun;
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
updateTask(data.taskId, updates);
|
|
2199
|
-
logger.info({ taskId: data.taskId, sourceGroup }, 'Task updated via IPC');
|
|
2200
|
-
}
|
|
2201
|
-
break;
|
|
2202
|
-
case 'register_group':
|
|
2203
|
-
// Only main group can register new groups
|
|
2204
|
-
if (!isMain) {
|
|
2205
|
-
logger.warn({ sourceGroup }, 'Unauthorized register_group attempt blocked');
|
|
2206
|
-
break;
|
|
2207
|
-
}
|
|
2208
|
-
if (data.jid && data.name && data.folder) {
|
|
2209
|
-
registerGroup(data.jid, {
|
|
2210
|
-
name: data.name,
|
|
2211
|
-
folder: data.folder,
|
|
2212
|
-
trigger: data.trigger,
|
|
2213
|
-
added_at: new Date().toISOString(),
|
|
2214
|
-
containerConfig: data.containerConfig
|
|
2215
|
-
});
|
|
2216
|
-
}
|
|
2217
|
-
else {
|
|
2218
|
-
logger.warn({ data }, 'Invalid register_group request - missing required fields');
|
|
2219
|
-
}
|
|
2220
|
-
break;
|
|
2221
|
-
case 'remove_group':
|
|
2222
|
-
if (!isMain) {
|
|
2223
|
-
logger.warn({ sourceGroup }, 'Unauthorized remove_group attempt blocked');
|
|
2224
|
-
break;
|
|
2225
|
-
}
|
|
2226
|
-
if (!data.identifier || typeof data.identifier !== 'string') {
|
|
2227
|
-
logger.warn({ data }, 'Invalid remove_group request - missing identifier');
|
|
2228
|
-
break;
|
|
2229
|
-
}
|
|
2230
|
-
{
|
|
2231
|
-
const result = unregisterGroup(data.identifier);
|
|
2232
|
-
if (!result.ok) {
|
|
2233
|
-
logger.warn({ identifier: data.identifier, error: result.error }, 'Failed to remove group');
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
break;
|
|
2237
|
-
case 'set_model':
|
|
2238
|
-
if (!isMain) {
|
|
2239
|
-
logger.warn({ sourceGroup }, 'Unauthorized set_model attempt blocked');
|
|
2240
|
-
break;
|
|
2241
|
-
}
|
|
2242
|
-
if (!data.model || typeof data.model !== 'string') {
|
|
2243
|
-
logger.warn({ data }, 'Invalid set_model request - missing model');
|
|
2244
|
-
break;
|
|
2245
|
-
}
|
|
2246
|
-
{
|
|
2247
|
-
const defaultModel = runtime.host.defaultModel;
|
|
2248
|
-
const config = loadModelRegistry(defaultModel);
|
|
2249
|
-
const nextModel = data.model.trim();
|
|
2250
|
-
if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
|
|
2251
|
-
logger.warn({ model: nextModel }, 'Model not in allowlist; refusing set_model');
|
|
2252
|
-
break;
|
|
2253
|
-
}
|
|
2254
|
-
const scope = typeof data.scope === 'string' ? data.scope : 'global';
|
|
2255
|
-
const targetId = typeof data.target_id === 'string' ? data.target_id : undefined;
|
|
2256
|
-
if (scope === 'user' && !targetId) {
|
|
2257
|
-
logger.warn({ data }, 'set_model missing target_id for user scope');
|
|
2258
|
-
break;
|
|
2259
|
-
}
|
|
2260
|
-
if (scope === 'group' && !targetId) {
|
|
2261
|
-
logger.warn({ data }, 'set_model missing target_id for group scope');
|
|
2262
|
-
break;
|
|
2263
|
-
}
|
|
2264
|
-
const nextConfig = { ...config };
|
|
2265
|
-
if (scope === 'global') {
|
|
2266
|
-
nextConfig.model = nextModel;
|
|
2267
|
-
}
|
|
2268
|
-
else if (scope === 'group') {
|
|
2269
|
-
nextConfig.per_group = nextConfig.per_group || {};
|
|
2270
|
-
nextConfig.per_group[targetId] = { model: nextModel };
|
|
2271
|
-
}
|
|
2272
|
-
else if (scope === 'user') {
|
|
2273
|
-
nextConfig.per_user = nextConfig.per_user || {};
|
|
2274
|
-
nextConfig.per_user[targetId] = { model: nextModel };
|
|
2275
|
-
}
|
|
2276
|
-
nextConfig.updated_at = new Date().toISOString();
|
|
2277
|
-
saveModelRegistry(nextConfig);
|
|
2278
|
-
logger.info({ model: nextModel, scope, targetId }, 'Model updated via IPC');
|
|
2279
|
-
}
|
|
2280
|
-
break;
|
|
2281
|
-
default:
|
|
2282
|
-
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
|
2283
|
-
}
|
|
2284
|
-
}
|
|
2285
|
-
async function processRequestIpc(data, sourceGroup, isMain) {
|
|
2286
|
-
const requestId = typeof data.id === 'string' ? data.id : undefined;
|
|
2287
|
-
const payload = data.payload || {};
|
|
2288
|
-
const resolveGroupFolder = () => {
|
|
2289
|
-
const target = typeof payload.target_group === 'string' ? payload.target_group : null;
|
|
2290
|
-
if (target && isMain)
|
|
2291
|
-
return target;
|
|
2292
|
-
return sourceGroup;
|
|
2293
|
-
};
|
|
2294
|
-
try {
|
|
2295
|
-
switch (data.type) {
|
|
2296
|
-
case 'memory_upsert': {
|
|
2297
|
-
const items = coerceMemoryItems(payload.items);
|
|
2298
|
-
const groupFolder = resolveGroupFolder();
|
|
2299
|
-
const source = typeof payload.source === 'string' ? payload.source : 'agent';
|
|
2300
|
-
const results = upsertMemoryItems(groupFolder, items, source);
|
|
2301
|
-
invalidatePersonalizationCache(groupFolder);
|
|
2302
|
-
return { id: requestId, ok: true, result: { count: results.length } };
|
|
2303
|
-
}
|
|
2304
|
-
case 'memory_forget': {
|
|
2305
|
-
const groupFolder = resolveGroupFolder();
|
|
2306
|
-
const ids = Array.isArray(payload.ids) ? payload.ids : undefined;
|
|
2307
|
-
const content = typeof payload.content === 'string' ? payload.content : undefined;
|
|
2308
|
-
const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
|
|
2309
|
-
const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
|
|
2310
|
-
const count = forgetMemories({
|
|
2311
|
-
groupFolder,
|
|
2312
|
-
ids,
|
|
2313
|
-
content,
|
|
2314
|
-
scope,
|
|
2315
|
-
userId
|
|
2316
|
-
});
|
|
2317
|
-
invalidatePersonalizationCache(groupFolder);
|
|
2318
|
-
return { id: requestId, ok: true, result: { count } };
|
|
2319
|
-
}
|
|
2320
|
-
case 'memory_list': {
|
|
2321
|
-
const groupFolder = resolveGroupFolder();
|
|
2322
|
-
const scope = isMemoryScope(payload.scope) ? payload.scope : undefined;
|
|
2323
|
-
const type = isMemoryType(payload.type) ? payload.type : undefined;
|
|
2324
|
-
const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
|
|
2325
|
-
const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
|
|
2326
|
-
const items = listMemories({
|
|
2327
|
-
groupFolder,
|
|
2328
|
-
scope,
|
|
2329
|
-
type,
|
|
2330
|
-
userId,
|
|
2331
|
-
limit
|
|
2332
|
-
});
|
|
2333
|
-
return { id: requestId, ok: true, result: { items } };
|
|
2334
|
-
}
|
|
2335
|
-
case 'memory_search': {
|
|
2336
|
-
const groupFolder = resolveGroupFolder();
|
|
2337
|
-
const query = typeof payload.query === 'string' ? payload.query : '';
|
|
2338
|
-
const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
|
|
2339
|
-
const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
|
|
2340
|
-
const results = searchMemories({
|
|
2341
|
-
groupFolder,
|
|
2342
|
-
userId,
|
|
2343
|
-
query,
|
|
2344
|
-
limit
|
|
2345
|
-
});
|
|
2346
|
-
return { id: requestId, ok: true, result: { items: results } };
|
|
2347
|
-
}
|
|
2348
|
-
case 'memory_stats': {
|
|
2349
|
-
const groupFolder = resolveGroupFolder();
|
|
2350
|
-
const userId = typeof payload.userId === 'string' ? payload.userId : undefined;
|
|
2351
|
-
const stats = getMemoryStats({ groupFolder, userId });
|
|
2352
|
-
return { id: requestId, ok: true, result: { stats } };
|
|
2353
|
-
}
|
|
2354
|
-
case 'list_groups': {
|
|
2355
|
-
if (!isMain) {
|
|
2356
|
-
return { id: requestId, ok: false, error: 'Only the main group can list groups.' };
|
|
2357
|
-
}
|
|
2358
|
-
const groups = listRegisteredGroups();
|
|
2359
|
-
return { id: requestId, ok: true, result: { groups } };
|
|
2360
|
-
}
|
|
2361
|
-
case 'run_task': {
|
|
2362
|
-
const taskId = typeof payload.task_id === 'string' ? payload.task_id : '';
|
|
2363
|
-
if (!taskId) {
|
|
2364
|
-
return { id: requestId, ok: false, error: 'task_id is required.' };
|
|
2365
|
-
}
|
|
2366
|
-
const task = getTaskById(taskId);
|
|
2367
|
-
if (!task) {
|
|
2368
|
-
return { id: requestId, ok: false, error: 'Task not found.' };
|
|
2369
|
-
}
|
|
2370
|
-
if (!isMain && task.group_folder !== sourceGroup) {
|
|
2371
|
-
return { id: requestId, ok: false, error: 'Unauthorized task run attempt.' };
|
|
2372
|
-
}
|
|
2373
|
-
const result = await runTaskNow(taskId, {
|
|
2374
|
-
sendMessage: async (jid, text) => { await sendMessage(jid, text); },
|
|
2375
|
-
registeredGroups: () => registeredGroups,
|
|
2376
|
-
getSessions: () => sessions,
|
|
2377
|
-
setSession: (groupFolder, sessionId) => { sessions[groupFolder] = sessionId; }
|
|
2378
|
-
});
|
|
2379
|
-
return {
|
|
2380
|
-
id: requestId,
|
|
2381
|
-
ok: result.ok,
|
|
2382
|
-
result: { result: result.result ?? null },
|
|
2383
|
-
error: result.ok ? undefined : result.error
|
|
2384
|
-
};
|
|
2385
|
-
}
|
|
2386
|
-
case 'spawn_job': {
|
|
2387
|
-
const prompt = typeof payload.prompt === 'string' ? payload.prompt.trim() : '';
|
|
2388
|
-
if (!prompt) {
|
|
2389
|
-
return { id: requestId, ok: false, error: 'prompt is required.' };
|
|
2390
|
-
}
|
|
2391
|
-
const targetGroup = (typeof payload.target_group === 'string' && isMain)
|
|
2392
|
-
? payload.target_group
|
|
2393
|
-
: sourceGroup;
|
|
2394
|
-
const groupEntry = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup);
|
|
2395
|
-
if (!groupEntry) {
|
|
2396
|
-
return { id: requestId, ok: false, error: 'Target group not registered.' };
|
|
2397
|
-
}
|
|
2398
|
-
const [chatId, group] = groupEntry;
|
|
2399
|
-
const result = spawnBackgroundJob({
|
|
2400
|
-
prompt,
|
|
2401
|
-
groupFolder: group.folder,
|
|
2402
|
-
chatJid: chatId,
|
|
2403
|
-
contextMode: (payload.context_mode === 'group' || payload.context_mode === 'isolated')
|
|
2404
|
-
? payload.context_mode
|
|
2405
|
-
: undefined,
|
|
2406
|
-
timeoutMs: typeof payload.timeout_ms === 'number' ? payload.timeout_ms : undefined,
|
|
2407
|
-
maxToolSteps: typeof payload.max_tool_steps === 'number' ? payload.max_tool_steps : undefined,
|
|
2408
|
-
toolAllow: Array.isArray(payload.tool_allow) ? payload.tool_allow : undefined,
|
|
2409
|
-
toolDeny: Array.isArray(payload.tool_deny) ? payload.tool_deny : undefined,
|
|
2410
|
-
modelOverride: typeof payload.model_override === 'string' ? payload.model_override : undefined,
|
|
2411
|
-
priority: typeof payload.priority === 'number' ? payload.priority : undefined,
|
|
2412
|
-
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
|
2413
|
-
parentTraceId: typeof payload.parent_trace_id === 'string' ? payload.parent_trace_id : undefined,
|
|
2414
|
-
parentMessageId: typeof payload.parent_message_id === 'string' ? payload.parent_message_id : undefined
|
|
2415
|
-
});
|
|
2416
|
-
return {
|
|
2417
|
-
id: requestId,
|
|
2418
|
-
ok: result.ok,
|
|
2419
|
-
result: result.ok ? { job_id: result.jobId } : undefined,
|
|
2420
|
-
error: result.ok ? undefined : result.error
|
|
2421
|
-
};
|
|
2422
|
-
}
|
|
2423
|
-
case 'job_status': {
|
|
2424
|
-
const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
|
|
2425
|
-
if (!jobId) {
|
|
2426
|
-
return { id: requestId, ok: false, error: 'job_id is required.' };
|
|
2427
|
-
}
|
|
2428
|
-
const job = getBackgroundJobStatus(jobId);
|
|
2429
|
-
if (!job) {
|
|
2430
|
-
return { id: requestId, ok: false, error: 'Job not found.' };
|
|
2431
|
-
}
|
|
2432
|
-
if (!isMain && job.group_folder !== sourceGroup) {
|
|
2433
|
-
return { id: requestId, ok: false, error: 'Unauthorized job status request.' };
|
|
2434
|
-
}
|
|
2435
|
-
return { id: requestId, ok: true, result: { job } };
|
|
2436
|
-
}
|
|
2437
|
-
case 'list_jobs': {
|
|
2438
|
-
const targetGroup = (typeof payload.target_group === 'string' && isMain)
|
|
2439
|
-
? payload.target_group
|
|
2440
|
-
: sourceGroup;
|
|
2441
|
-
const statusRaw = typeof payload.status === 'string' ? payload.status : undefined;
|
|
2442
|
-
const allowedStatuses = ['queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'];
|
|
2443
|
-
const status = statusRaw && allowedStatuses.includes(statusRaw)
|
|
2444
|
-
? statusRaw
|
|
2445
|
-
: undefined;
|
|
2446
|
-
const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
|
|
2447
|
-
const jobs = listBackgroundJobsForGroup({ groupFolder: targetGroup, status, limit });
|
|
2448
|
-
return { id: requestId, ok: true, result: { jobs } };
|
|
2449
|
-
}
|
|
2450
|
-
case 'cancel_job': {
|
|
2451
|
-
const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
|
|
2452
|
-
if (!jobId) {
|
|
2453
|
-
return { id: requestId, ok: false, error: 'job_id is required.' };
|
|
2454
|
-
}
|
|
2455
|
-
const job = getBackgroundJobStatus(jobId);
|
|
2456
|
-
if (!job) {
|
|
2457
|
-
return { id: requestId, ok: false, error: 'Job not found.' };
|
|
2458
|
-
}
|
|
2459
|
-
if (!isMain && job.group_folder !== sourceGroup) {
|
|
2460
|
-
return { id: requestId, ok: false, error: 'Unauthorized job cancel attempt.' };
|
|
2461
|
-
}
|
|
2462
|
-
const result = cancelBackgroundJob(jobId);
|
|
2463
|
-
return { id: requestId, ok: result.ok, error: result.error };
|
|
2464
|
-
}
|
|
2465
|
-
case 'job_update': {
|
|
2466
|
-
const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
|
|
2467
|
-
const message = typeof payload.message === 'string' ? payload.message.trim() : '';
|
|
2468
|
-
const levelRaw = typeof payload.level === 'string' ? payload.level : 'progress';
|
|
2469
|
-
const allowedLevels = ['info', 'progress', 'warn', 'error'];
|
|
2470
|
-
const level = allowedLevels.includes(levelRaw)
|
|
2471
|
-
? levelRaw
|
|
2472
|
-
: 'progress';
|
|
2473
|
-
if (!jobId || !message) {
|
|
2474
|
-
return { id: requestId, ok: false, error: 'job_id and message are required.' };
|
|
2475
|
-
}
|
|
2476
|
-
const job = getBackgroundJobStatus(jobId);
|
|
2477
|
-
if (!job) {
|
|
2478
|
-
return { id: requestId, ok: false, error: 'Job not found.' };
|
|
2479
|
-
}
|
|
2480
|
-
if (!isMain && job.group_folder !== sourceGroup) {
|
|
2481
|
-
return { id: requestId, ok: false, error: 'Unauthorized job update attempt.' };
|
|
2482
|
-
}
|
|
2483
|
-
const result = recordBackgroundJobUpdate({
|
|
2484
|
-
jobId,
|
|
2485
|
-
level,
|
|
2486
|
-
message,
|
|
2487
|
-
data: typeof payload.data === 'object' && payload.data ? payload.data : undefined
|
|
2488
|
-
});
|
|
2489
|
-
if (result.ok && payload.notify === true && job.chat_jid) {
|
|
2490
|
-
const nowMs = Date.now();
|
|
2491
|
-
const previous = lastJobUpdateNotifications.get(job.id);
|
|
2492
|
-
const isDuplicate = previous !== undefined
|
|
2493
|
-
&& previous.message === message
|
|
2494
|
-
&& (nowMs - previous.at) < JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS;
|
|
2495
|
-
if (!isDuplicate) {
|
|
2496
|
-
const notifyResult = await sendMessage(job.chat_jid, message);
|
|
2497
|
-
if (!notifyResult.success) {
|
|
2498
|
-
return { id: requestId, ok: false, error: 'Background job update saved, but notification delivery failed.' };
|
|
2499
|
-
}
|
|
2500
|
-
lastJobUpdateNotifications.set(job.id, { message, at: nowMs });
|
|
2501
|
-
}
|
|
2502
|
-
}
|
|
2503
|
-
return { id: requestId, ok: result.ok, error: result.error };
|
|
2504
|
-
}
|
|
2505
|
-
case 'edit_message': {
|
|
2506
|
-
const messageId = typeof payload.message_id === 'number' ? payload.message_id : parseInt(String(payload.message_id), 10);
|
|
2507
|
-
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
|
|
2508
|
-
const chatJid = typeof payload.chat_jid === 'string' ? payload.chat_jid : '';
|
|
2509
|
-
if (!Number.isFinite(messageId) || !text || !chatJid) {
|
|
2510
|
-
return { id: requestId, ok: false, error: 'message_id, text, and chat_jid are required.' };
|
|
2511
|
-
}
|
|
2512
|
-
const group = Object.entries(registeredGroups).find(([id]) => id === chatJid);
|
|
2513
|
-
if (!group) {
|
|
2514
|
-
return { id: requestId, ok: false, error: 'Chat not registered.' };
|
|
2515
|
-
}
|
|
2516
|
-
if (!isMain && group[1].folder !== sourceGroup) {
|
|
2517
|
-
return { id: requestId, ok: false, error: 'Unauthorized edit_message attempt.' };
|
|
2518
|
-
}
|
|
2519
|
-
await telegrafBot.telegram.editMessageText(chatJid, messageId, undefined, text);
|
|
2520
|
-
return { id: requestId, ok: true, result: { edited: true } };
|
|
2521
|
-
}
|
|
2522
|
-
case 'delete_message': {
|
|
2523
|
-
const messageId = typeof payload.message_id === 'number' ? payload.message_id : parseInt(String(payload.message_id), 10);
|
|
2524
|
-
const chatJid = typeof payload.chat_jid === 'string' ? payload.chat_jid : '';
|
|
2525
|
-
if (!Number.isFinite(messageId) || !chatJid) {
|
|
2526
|
-
return { id: requestId, ok: false, error: 'message_id and chat_jid are required.' };
|
|
2527
|
-
}
|
|
2528
|
-
const group = Object.entries(registeredGroups).find(([id]) => id === chatJid);
|
|
2529
|
-
if (!group) {
|
|
2530
|
-
return { id: requestId, ok: false, error: 'Chat not registered.' };
|
|
2531
|
-
}
|
|
2532
|
-
if (!isMain && group[1].folder !== sourceGroup) {
|
|
2533
|
-
return { id: requestId, ok: false, error: 'Unauthorized delete_message attempt.' };
|
|
2534
|
-
}
|
|
2535
|
-
await telegrafBot.telegram.deleteMessage(chatJid, messageId);
|
|
2536
|
-
return { id: requestId, ok: true, result: { deleted: true } };
|
|
2537
|
-
}
|
|
2538
|
-
default:
|
|
2539
|
-
return { id: requestId, ok: false, error: `Unknown request type: ${data.type}` };
|
|
2540
|
-
}
|
|
2541
|
-
}
|
|
2542
|
-
catch (err) {
|
|
2543
|
-
return { id: requestId, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2544
|
-
}
|
|
2545
|
-
}
|
|
2546
|
-
function formatGroups(groups) {
|
|
2547
|
-
if (groups.length === 0)
|
|
2548
|
-
return 'No registered groups.';
|
|
2549
|
-
const lines = groups.map(group => {
|
|
2550
|
-
const trigger = group.trigger ? ` (trigger: ${group.trigger})` : '';
|
|
2551
|
-
return `- ${group.name} [${group.folder}] chat=${group.chat_id}${trigger}`;
|
|
2552
|
-
});
|
|
2553
|
-
return ['Registered groups:', ...lines].join('\n');
|
|
2554
|
-
}
|
|
2555
|
-
function applyModelOverride(params) {
|
|
2556
|
-
const defaultModel = runtime.host.defaultModel;
|
|
2557
|
-
const config = loadModelRegistry(defaultModel);
|
|
2558
|
-
const nextModel = params.model.trim();
|
|
2559
|
-
if (config.allowlist && config.allowlist.length > 0 && !config.allowlist.includes(nextModel)) {
|
|
2560
|
-
return { ok: false, error: 'Model not in allowlist' };
|
|
2561
|
-
}
|
|
2562
|
-
const scope = params.scope || 'global';
|
|
2563
|
-
const targetId = params.targetId;
|
|
2564
|
-
if (scope === 'user' && !targetId) {
|
|
2565
|
-
return { ok: false, error: 'Missing target_id for user scope' };
|
|
2566
|
-
}
|
|
2567
|
-
if (scope === 'group' && !targetId) {
|
|
2568
|
-
return { ok: false, error: 'Missing target_id for group scope' };
|
|
2569
|
-
}
|
|
2570
|
-
const nextConfig = { ...config };
|
|
2571
|
-
if (scope === 'global') {
|
|
2572
|
-
nextConfig.model = nextModel;
|
|
2573
|
-
}
|
|
2574
|
-
else if (scope === 'group') {
|
|
2575
|
-
nextConfig.per_group = nextConfig.per_group || {};
|
|
2576
|
-
nextConfig.per_group[targetId] = { model: nextModel };
|
|
2577
|
-
}
|
|
2578
|
-
else if (scope === 'user') {
|
|
2579
|
-
nextConfig.per_user = nextConfig.per_user || {};
|
|
2580
|
-
nextConfig.per_user[targetId] = { model: nextModel };
|
|
2581
|
-
}
|
|
2582
|
-
nextConfig.updated_at = new Date().toISOString();
|
|
2583
|
-
saveModelRegistry(nextConfig);
|
|
2584
|
-
return { ok: true };
|
|
2585
|
-
}
|
|
2586
|
-
async function handleAdminCommand(params) {
|
|
2587
|
-
const parsed = parseAdminCommand(params.content, params.botUsername);
|
|
2588
|
-
if (!parsed)
|
|
2589
|
-
return false;
|
|
2590
|
-
const reply = (text) => sendMessage(params.chatId, text, { messageThreadId: params.messageThreadId });
|
|
2591
|
-
const group = registeredGroups[params.chatId];
|
|
2592
|
-
if (!group) {
|
|
2593
|
-
await reply('This chat is not registered with DotClaw.');
|
|
2594
|
-
return true;
|
|
2595
|
-
}
|
|
2596
|
-
const isMain = group.folder === MAIN_GROUP_FOLDER;
|
|
2597
|
-
const command = parsed.command;
|
|
2598
|
-
const args = parsed.args;
|
|
2599
|
-
const requireMain = (name) => {
|
|
2600
|
-
if (isMain)
|
|
2601
|
-
return false;
|
|
2602
|
-
reply(`${name} is only available in the main group.`).catch(() => undefined);
|
|
2603
|
-
return true;
|
|
2604
|
-
};
|
|
2605
|
-
if (command === 'help') {
|
|
2606
|
-
await reply([
|
|
2607
|
-
'DotClaw admin commands:',
|
|
2608
|
-
'- `/dotclaw help`',
|
|
2609
|
-
'- `/dotclaw groups` (main only)',
|
|
2610
|
-
'- `/dotclaw add-group <chat_id> <name> [folder]` (main only)',
|
|
2611
|
-
'- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
|
|
2612
|
-
'- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
|
|
2613
|
-
'- `/dotclaw remember <fact>` (main only)',
|
|
2614
|
-
'- `/dotclaw style <concise|balanced|detailed>`',
|
|
2615
|
-
'- `/dotclaw tools <conservative|balanced|proactive>`',
|
|
2616
|
-
'- `/dotclaw caution <low|balanced|high>`',
|
|
2617
|
-
'- `/dotclaw memory <strict|balanced|loose>`'
|
|
2618
|
-
].join('\n'));
|
|
2619
|
-
return true;
|
|
2620
|
-
}
|
|
2621
|
-
if (command === 'groups') {
|
|
2622
|
-
if (requireMain('Listing groups'))
|
|
2623
|
-
return true;
|
|
2624
|
-
await reply(formatGroups(listRegisteredGroups()));
|
|
2625
|
-
return true;
|
|
2626
|
-
}
|
|
2627
|
-
if (command === 'add-group') {
|
|
2628
|
-
if (requireMain('Adding groups'))
|
|
2629
|
-
return true;
|
|
2630
|
-
if (args.length < 1) {
|
|
2631
|
-
await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
|
|
2632
|
-
return true;
|
|
2633
|
-
}
|
|
2634
|
-
const jid = args[0];
|
|
2635
|
-
if (registeredGroups[jid]) {
|
|
2636
|
-
await reply('That chat id is already registered.');
|
|
2637
|
-
return true;
|
|
2638
|
-
}
|
|
2639
|
-
const name = args[1] || `group-${jid}`;
|
|
2640
|
-
const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
2641
|
-
if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
|
|
2642
|
-
await reply('Invalid folder name. Use lowercase letters, numbers, and dashes only.');
|
|
2643
|
-
return true;
|
|
2644
|
-
}
|
|
2645
|
-
registerGroup(jid, {
|
|
2646
|
-
name,
|
|
2647
|
-
folder: folder || `group-${jid}`,
|
|
2648
|
-
added_at: new Date().toISOString()
|
|
2649
|
-
});
|
|
2650
|
-
await reply(`Registered group "${name}" with folder "${folder}".`);
|
|
2651
|
-
return true;
|
|
2652
|
-
}
|
|
2653
|
-
if (command === 'remove-group') {
|
|
2654
|
-
if (requireMain('Removing groups'))
|
|
2655
|
-
return true;
|
|
2656
|
-
if (args.length < 1) {
|
|
2657
|
-
await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
|
|
2658
|
-
return true;
|
|
2659
|
-
}
|
|
2660
|
-
const result = unregisterGroup(args.join(' '));
|
|
2661
|
-
if (!result.ok) {
|
|
2662
|
-
await reply(`Failed to remove group: ${result.error || 'unknown error'}`);
|
|
2663
|
-
return true;
|
|
2664
|
-
}
|
|
2665
|
-
await reply(`Removed group "${result.group?.name}" (${result.group?.folder}).`);
|
|
2666
|
-
return true;
|
|
2667
|
-
}
|
|
2668
|
-
if (command === 'set-model') {
|
|
2669
|
-
if (requireMain('Setting models'))
|
|
2670
|
-
return true;
|
|
2671
|
-
if (args.length < 1) {
|
|
2672
|
-
await reply('Usage: /dotclaw set-model <model> [global|group|user] [target_id]');
|
|
2673
|
-
return true;
|
|
2674
|
-
}
|
|
2675
|
-
const model = args[0];
|
|
2676
|
-
const scopeCandidate = (args[1] || '').toLowerCase();
|
|
2677
|
-
const scope = (scopeCandidate === 'global' || scopeCandidate === 'group' || scopeCandidate === 'user')
|
|
2678
|
-
? scopeCandidate
|
|
2679
|
-
: 'global';
|
|
2680
|
-
const targetId = args[2] || (scope === 'group' ? group.folder : scope === 'user' ? params.senderId : undefined);
|
|
2681
|
-
const result = applyModelOverride({ model, scope, targetId });
|
|
2682
|
-
if (!result.ok) {
|
|
2683
|
-
await reply(`Failed to set model: ${result.error || 'unknown error'}`);
|
|
2684
|
-
return true;
|
|
2685
|
-
}
|
|
2686
|
-
await reply(`Model set to ${model} (${scope}${targetId ? `:${targetId}` : ''}).`);
|
|
2687
|
-
return true;
|
|
2688
|
-
}
|
|
2689
|
-
if (command === 'remember') {
|
|
2690
|
-
if (requireMain('Remembering facts'))
|
|
2691
|
-
return true;
|
|
2692
|
-
const fact = args.join(' ').trim();
|
|
2693
|
-
if (!fact) {
|
|
2694
|
-
await reply('Usage: /dotclaw remember <fact>');
|
|
2695
|
-
return true;
|
|
639
|
+
responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
|
|
640
|
+
disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
|
|
641
|
+
profile: routingDecision.profile
|
|
642
|
+
});
|
|
643
|
+
output = execution.output;
|
|
644
|
+
context = execution.context;
|
|
645
|
+
if (output.status === 'error') {
|
|
646
|
+
errorMessage = output.error || 'Unknown error';
|
|
2696
647
|
}
|
|
2697
|
-
const items = [{
|
|
2698
|
-
scope: 'global',
|
|
2699
|
-
type: 'fact',
|
|
2700
|
-
content: fact,
|
|
2701
|
-
importance: 0.7,
|
|
2702
|
-
confidence: 0.8,
|
|
2703
|
-
tags: ['manual']
|
|
2704
|
-
}];
|
|
2705
|
-
upsertMemoryItems('global', items, 'admin-command');
|
|
2706
|
-
await reply('Saved to global memory.');
|
|
2707
|
-
return true;
|
|
2708
648
|
}
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
return true;
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (err instanceof AgentExecutionError) {
|
|
651
|
+
context = err.context;
|
|
652
|
+
errorMessage = err.message;
|
|
2714
653
|
}
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
subject_id: params.senderId,
|
|
2718
|
-
type: 'preference',
|
|
2719
|
-
conflict_key: 'response_style',
|
|
2720
|
-
content: `Prefers ${style} responses.`,
|
|
2721
|
-
importance: 0.7,
|
|
2722
|
-
confidence: 0.85,
|
|
2723
|
-
tags: [`response_style:${style}`],
|
|
2724
|
-
metadata: { response_style: style }
|
|
2725
|
-
}];
|
|
2726
|
-
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2727
|
-
await reply(`Response style set to ${style}.`);
|
|
2728
|
-
return true;
|
|
2729
|
-
}
|
|
2730
|
-
if (command === 'tools') {
|
|
2731
|
-
const level = (args[0] || '').toLowerCase();
|
|
2732
|
-
const bias = level === 'proactive' ? 0.7 : level === 'balanced' ? 0.5 : level === 'conservative' ? 0.3 : null;
|
|
2733
|
-
if (bias === null) {
|
|
2734
|
-
await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
|
|
2735
|
-
return true;
|
|
654
|
+
else {
|
|
655
|
+
errorMessage = err instanceof Error ? err.message : String(err);
|
|
2736
656
|
}
|
|
2737
|
-
|
|
2738
|
-
scope: 'user',
|
|
2739
|
-
subject_id: params.senderId,
|
|
2740
|
-
type: 'preference',
|
|
2741
|
-
conflict_key: 'tool_calling_bias',
|
|
2742
|
-
content: `Prefers ${level} tool usage.`,
|
|
2743
|
-
importance: 0.65,
|
|
2744
|
-
confidence: 0.8,
|
|
2745
|
-
tags: [`tool_calling_bias:${bias}`],
|
|
2746
|
-
metadata: { tool_calling_bias: bias, bias }
|
|
2747
|
-
}];
|
|
2748
|
-
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2749
|
-
await reply(`Tool usage bias set to ${level}.`);
|
|
2750
|
-
return true;
|
|
657
|
+
logger.error({ err }, 'Heartbeat run failed');
|
|
2751
658
|
}
|
|
2752
|
-
if (
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
subject_id: params.senderId,
|
|
2762
|
-
type: 'preference',
|
|
2763
|
-
conflict_key: 'caution_bias',
|
|
2764
|
-
content: `Prefers ${level} caution in responses.`,
|
|
2765
|
-
importance: 0.65,
|
|
2766
|
-
confidence: 0.8,
|
|
2767
|
-
tags: [`caution_bias:${bias}`],
|
|
2768
|
-
metadata: { caution_bias: bias, bias }
|
|
2769
|
-
}];
|
|
2770
|
-
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2771
|
-
await reply(`Caution bias set to ${level}.`);
|
|
2772
|
-
return true;
|
|
659
|
+
if (context) {
|
|
660
|
+
recordAgentTelemetry({
|
|
661
|
+
traceBase,
|
|
662
|
+
output,
|
|
663
|
+
context,
|
|
664
|
+
toolAuditSource: 'heartbeat',
|
|
665
|
+
errorMessage: errorMessage ?? undefined,
|
|
666
|
+
extraTimings: { router_ms: routerMs }
|
|
667
|
+
});
|
|
2773
668
|
}
|
|
2774
|
-
if (
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
tags: [`memory_importance_threshold:${threshold}`],
|
|
2790
|
-
metadata: { memory_importance_threshold: threshold, threshold }
|
|
2791
|
-
}];
|
|
2792
|
-
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2793
|
-
await reply(`Memory strictness set to ${level}.`);
|
|
2794
|
-
return true;
|
|
669
|
+
else if (errorMessage) {
|
|
670
|
+
writeTrace({
|
|
671
|
+
trace_id: traceBase.trace_id,
|
|
672
|
+
timestamp: traceBase.timestamp,
|
|
673
|
+
created_at: traceBase.created_at,
|
|
674
|
+
chat_id: traceBase.chat_id,
|
|
675
|
+
group_folder: traceBase.group_folder,
|
|
676
|
+
user_id: traceBase.user_id,
|
|
677
|
+
input_text: traceBase.input_text,
|
|
678
|
+
output_text: null,
|
|
679
|
+
model_id: 'unknown',
|
|
680
|
+
memory_recall: [],
|
|
681
|
+
error_code: errorMessage,
|
|
682
|
+
source: traceBase.source
|
|
683
|
+
});
|
|
2795
684
|
}
|
|
2796
|
-
await reply('Unknown command. Use `/dotclaw help` for options.');
|
|
2797
|
-
return true;
|
|
2798
685
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
686
|
+
let heartbeatStopped = false;
|
|
687
|
+
function stopHeartbeatLoop() {
|
|
688
|
+
heartbeatStopped = true;
|
|
689
|
+
}
|
|
690
|
+
function startHeartbeatLoop() {
|
|
691
|
+
if (!HEARTBEAT_ENABLED)
|
|
692
|
+
return;
|
|
693
|
+
heartbeatStopped = false;
|
|
694
|
+
const loop = async () => {
|
|
695
|
+
if (heartbeatStopped)
|
|
696
|
+
return;
|
|
2802
697
|
try {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
698
|
+
await runHeartbeatOnce();
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
logger.error({ err }, 'Heartbeat run failed');
|
|
702
|
+
}
|
|
703
|
+
if (!heartbeatStopped) {
|
|
704
|
+
setTimeout(loop, HEARTBEAT_INTERVAL_MS);
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
loop();
|
|
708
|
+
}
|
|
709
|
+
// ───────────────────────── Provider Event Handlers ─────────────────────────
|
|
710
|
+
function createProviderHandlers(registry, pipeline) {
|
|
711
|
+
return {
|
|
712
|
+
onMessage(incoming) {
|
|
713
|
+
const chatId = incoming.chatId;
|
|
714
|
+
const group = registeredGroups[chatId];
|
|
715
|
+
const groupFolder = group?.folder;
|
|
716
|
+
// Log & persist
|
|
717
|
+
const chatName = incoming.rawProviderData?.chatName || incoming.senderName;
|
|
718
|
+
try {
|
|
719
|
+
upsertChat({ chatId, name: chatName, lastMessageTime: incoming.timestamp });
|
|
720
|
+
const dbAttachments = incoming.attachments?.map(providerAttachmentToMessageAttachment);
|
|
721
|
+
storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, dbAttachments);
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
logger.error({ error, chatId }, 'Failed to persist message');
|
|
725
|
+
}
|
|
726
|
+
setLastMessageTime(new Date().toISOString());
|
|
727
|
+
recordMessage(ProviderRegistry.getPrefix(chatId));
|
|
728
|
+
// Admin commands (async, fire-and-forget with early return)
|
|
729
|
+
const providerName = ProviderRegistry.getPrefix(chatId);
|
|
730
|
+
const provider = registry.get(providerName);
|
|
731
|
+
const botUsername = provider && 'botUsername' in provider ? provider.botUsername : undefined;
|
|
732
|
+
void (async () => {
|
|
733
|
+
try {
|
|
734
|
+
if (incoming.content) {
|
|
735
|
+
const sendReply = async (cId, text, opts) => {
|
|
736
|
+
await registry.getProviderForChat(cId).sendMessage(cId, text, { threadId: opts?.threadId });
|
|
737
|
+
};
|
|
738
|
+
const adminHandled = await handleAdminCommand({
|
|
739
|
+
chatId,
|
|
740
|
+
senderId: incoming.senderId,
|
|
741
|
+
senderName: incoming.senderName,
|
|
742
|
+
content: incoming.content,
|
|
743
|
+
botUsername,
|
|
744
|
+
threadId: incoming.threadId,
|
|
745
|
+
}, sendReply);
|
|
746
|
+
if (adminHandled)
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
// Check trigger/mention/reply
|
|
750
|
+
const isPrivate = incoming.chatType === 'private' || incoming.chatType === 'dm';
|
|
751
|
+
const isGroup = incoming.isGroup;
|
|
752
|
+
const mentioned = provider ? provider.isBotMentioned(incoming) : false;
|
|
753
|
+
const replied = provider ? provider.isBotReplied(incoming) : false;
|
|
754
|
+
const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
|
|
755
|
+
const triggered = Boolean(triggerRegex && incoming.content && triggerRegex.test(incoming.content));
|
|
756
|
+
const shouldProcess = isPrivate || mentioned || replied || triggered;
|
|
757
|
+
if (!shouldProcess)
|
|
758
|
+
return;
|
|
759
|
+
// Rate limiting — qualify key by provider to avoid cross-provider collisions
|
|
760
|
+
const rateKey = `${ProviderRegistry.getPrefix(chatId)}:${incoming.senderId}`;
|
|
761
|
+
const rateCheck = checkRateLimit(rateKey);
|
|
762
|
+
if (!rateCheck.allowed) {
|
|
763
|
+
const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
|
|
764
|
+
logger.warn({ senderId: incoming.senderId, retryAfterSec }, 'Rate limit exceeded');
|
|
765
|
+
await registry.getProviderForChat(chatId).sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { threadId: incoming.threadId });
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
// Download attachments
|
|
769
|
+
const attachments = incoming.attachments?.map(providerAttachmentToMessageAttachment) ?? [];
|
|
770
|
+
if (attachments.length > 0 && groupFolder) {
|
|
771
|
+
let downloadedAny = false;
|
|
772
|
+
const failedAttachments = [];
|
|
773
|
+
for (const attachment of attachments) {
|
|
774
|
+
const fileRef = attachment.provider_file_ref;
|
|
775
|
+
if (!fileRef)
|
|
776
|
+
continue;
|
|
777
|
+
const filename = attachment.file_name || `${attachment.type}_${incoming.messageId}`;
|
|
778
|
+
const result = await provider.downloadFile(fileRef, groupFolder, filename);
|
|
779
|
+
if (result.path) {
|
|
780
|
+
attachment.local_path = result.path;
|
|
781
|
+
downloadedAny = true;
|
|
782
|
+
}
|
|
783
|
+
else if (result.error) {
|
|
784
|
+
failedAttachments.push({ name: attachment.file_name || attachment.type, error: result.error });
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (failedAttachments.length > 0) {
|
|
788
|
+
const maxMB = Math.floor(provider.capabilities.maxAttachmentBytes / (1024 * 1024));
|
|
789
|
+
const messages = failedAttachments.map(f => f.error === 'too_large'
|
|
790
|
+
? `"${f.name}" is too large (over ${maxMB} MB). Try sending a smaller version.`
|
|
791
|
+
: `I couldn't download "${f.name}". Please try sending it again.`);
|
|
792
|
+
void registry.getProviderForChat(chatId).sendMessage(chatId, messages.join('\n'), { threadId: incoming.threadId });
|
|
793
|
+
}
|
|
794
|
+
// Transcribe voice messages
|
|
795
|
+
for (const attachment of attachments) {
|
|
796
|
+
if (attachment.type === 'voice' && attachment.local_path) {
|
|
797
|
+
try {
|
|
798
|
+
const transcript = await transcribeVoice(attachment.local_path);
|
|
799
|
+
if (transcript) {
|
|
800
|
+
attachment.transcript = transcript;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
catch (err) {
|
|
804
|
+
logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Voice transcription failed');
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (downloadedAny) {
|
|
809
|
+
try {
|
|
810
|
+
storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, attachments);
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
void emitHook('message:received', {
|
|
818
|
+
chat_id: chatId,
|
|
819
|
+
message_id: incoming.messageId,
|
|
820
|
+
sender_id: incoming.senderId,
|
|
821
|
+
sender_name: incoming.senderName,
|
|
822
|
+
content: incoming.content.slice(0, 500),
|
|
823
|
+
is_group: isGroup,
|
|
824
|
+
has_attachments: attachments.length > 0,
|
|
825
|
+
has_transcript: attachments.some(a => !!a.transcript)
|
|
826
|
+
});
|
|
827
|
+
pipeline.enqueueMessage({
|
|
828
|
+
chatId,
|
|
829
|
+
messageId: incoming.messageId,
|
|
830
|
+
senderId: incoming.senderId,
|
|
831
|
+
senderName: incoming.senderName,
|
|
832
|
+
content: incoming.content,
|
|
833
|
+
timestamp: incoming.timestamp,
|
|
834
|
+
isGroup,
|
|
835
|
+
chatType: incoming.chatType,
|
|
836
|
+
threadId: incoming.threadId,
|
|
837
|
+
attachments: attachments.length > 0 ? attachments : undefined
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
logger.error({ err, chatId }, 'Error processing incoming message');
|
|
842
|
+
}
|
|
843
|
+
})();
|
|
844
|
+
},
|
|
845
|
+
onReaction(chatId, messageId, userId, emoji) {
|
|
846
|
+
if (emoji !== '👍' && emoji !== '👎')
|
|
2809
847
|
return;
|
|
2810
|
-
const chatId = String(reaction.chat.id);
|
|
2811
|
-
const messageId = String(reaction.message_id);
|
|
2812
|
-
const userId = reaction.user?.id ? String(reaction.user.id) : undefined;
|
|
2813
|
-
// Look up the trace ID for this message
|
|
2814
848
|
const traceId = getTraceIdForMessage(messageId, chatId);
|
|
2815
849
|
if (!traceId) {
|
|
2816
850
|
logger.debug({ chatId, messageId }, 'No trace found for reacted message');
|
|
2817
851
|
return;
|
|
2818
852
|
}
|
|
2819
|
-
// Record the feedback
|
|
2820
853
|
const feedbackType = emoji === '👍' ? 'positive' : 'negative';
|
|
2821
854
|
recordUserFeedback({
|
|
2822
855
|
trace_id: traceId,
|
|
@@ -2826,48 +859,19 @@ function setupTelegramHandlers() {
|
|
|
2826
859
|
user_id: userId
|
|
2827
860
|
});
|
|
2828
861
|
logger.info({ chatId, messageId, feedbackType, traceId }, 'User feedback recorded');
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
logger.debug({ err }, 'Error handling message reaction');
|
|
2832
|
-
}
|
|
2833
|
-
});
|
|
2834
|
-
// Handle callback queries from inline keyboard buttons
|
|
2835
|
-
telegrafBot.on('callback_query', async (ctx) => {
|
|
2836
|
-
try {
|
|
2837
|
-
const cbQuery = ctx.callbackQuery;
|
|
2838
|
-
if (!cbQuery || !('data' in cbQuery) || !cbQuery.data)
|
|
2839
|
-
return;
|
|
2840
|
-
const callbackId = cbQuery.data;
|
|
2841
|
-
const entry = callbackDataStore.get(callbackId);
|
|
2842
|
-
await ctx.answerCbQuery();
|
|
2843
|
-
if (!entry) {
|
|
2844
|
-
logger.debug({ callbackId }, 'Unknown callback data');
|
|
2845
|
-
return;
|
|
2846
|
-
}
|
|
2847
|
-
callbackDataStore.delete(callbackId);
|
|
2848
|
-
const callbackChatId = ctx.chat?.id ? String(ctx.chat.id) : '';
|
|
2849
|
-
if (callbackChatId && callbackChatId !== entry.chatJid) {
|
|
2850
|
-
logger.warn({ callbackChatId, expectedChatId: entry.chatJid }, 'Callback chat mismatch; ignoring callback');
|
|
2851
|
-
return;
|
|
2852
|
-
}
|
|
2853
|
-
const chatId = callbackChatId || entry.chatJid;
|
|
2854
|
-
const senderId = String(cbQuery.from?.id || '');
|
|
2855
|
-
const senderName = cbQuery.from?.first_name || cbQuery.from?.username || 'User';
|
|
2856
|
-
const timestamp = new Date().toISOString();
|
|
2857
|
-
const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
|
|
862
|
+
},
|
|
863
|
+
onButtonClick(chatId, senderId, senderName, label, data, threadId) {
|
|
2858
864
|
const group = registeredGroups[chatId];
|
|
2859
865
|
if (!group)
|
|
2860
866
|
return;
|
|
2861
|
-
const chatType =
|
|
2862
|
-
const isGroup =
|
|
2863
|
-
const
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
const messageThreadId = Number.isFinite(rawThreadId) ? Number(rawThreadId) : undefined;
|
|
2867
|
-
const syntheticContent = `[Button clicked: "${entry.label}"] callback_data: ${entry.data}`;
|
|
867
|
+
const chatType = 'private'; // Best guess for callback queries
|
|
868
|
+
const isGroup = false;
|
|
869
|
+
const timestamp = new Date().toISOString();
|
|
870
|
+
const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
|
|
871
|
+
const syntheticContent = `[Button clicked: "${label}"] callback_data: ${data}`;
|
|
2868
872
|
upsertChat({ chatId, lastMessageTime: timestamp });
|
|
2869
873
|
storeMessage(syntheticMessageId, chatId, senderId, senderName, syntheticContent, timestamp, false);
|
|
2870
|
-
enqueueMessage({
|
|
874
|
+
pipeline.enqueueMessage({
|
|
2871
875
|
chatId,
|
|
2872
876
|
messageId: syntheticMessageId,
|
|
2873
877
|
senderId,
|
|
@@ -2876,199 +880,72 @@ function setupTelegramHandlers() {
|
|
|
2876
880
|
timestamp,
|
|
2877
881
|
isGroup,
|
|
2878
882
|
chatType,
|
|
2879
|
-
|
|
2880
|
-
});
|
|
2881
|
-
}
|
|
2882
|
-
catch (err) {
|
|
2883
|
-
logger.debug({ err }, 'Error handling callback query');
|
|
2884
|
-
}
|
|
2885
|
-
});
|
|
2886
|
-
// Handle all messages (text + media)
|
|
2887
|
-
telegrafBot.on('message', async (ctx) => {
|
|
2888
|
-
if (!ctx.message)
|
|
2889
|
-
return;
|
|
2890
|
-
const msg = ctx.message;
|
|
2891
|
-
// Extract content from text or caption
|
|
2892
|
-
const content = (typeof msg.text === 'string' ? msg.text : '')
|
|
2893
|
-
|| (typeof msg.caption === 'string' ? msg.caption : '');
|
|
2894
|
-
// Build attachment metadata (downloaded lazily only when processing is triggered)
|
|
2895
|
-
const attachments = [];
|
|
2896
|
-
const chatId = String(ctx.chat.id);
|
|
2897
|
-
const group = registeredGroups[chatId];
|
|
2898
|
-
const groupFolder = group?.folder;
|
|
2899
|
-
const messageId = String(msg.message_id);
|
|
2900
|
-
if (Array.isArray(msg.photo) && msg.photo.length > 0) {
|
|
2901
|
-
const photos = msg.photo;
|
|
2902
|
-
const largest = photos[photos.length - 1];
|
|
2903
|
-
const filename = `photo_${messageId}.jpg`;
|
|
2904
|
-
attachments.push({
|
|
2905
|
-
type: 'photo',
|
|
2906
|
-
file_id: largest.file_id,
|
|
2907
|
-
file_unique_id: largest.file_unique_id,
|
|
2908
|
-
file_name: filename,
|
|
2909
|
-
mime_type: 'image/jpeg',
|
|
2910
|
-
file_size: largest.file_size,
|
|
2911
|
-
width: largest.width,
|
|
2912
|
-
height: largest.height
|
|
2913
|
-
});
|
|
2914
|
-
}
|
|
2915
|
-
if (msg.document && typeof msg.document === 'object') {
|
|
2916
|
-
const doc = msg.document;
|
|
2917
|
-
const filename = doc.file_name || `document_${messageId}`;
|
|
2918
|
-
attachments.push({
|
|
2919
|
-
type: 'document',
|
|
2920
|
-
file_id: doc.file_id,
|
|
2921
|
-
file_unique_id: doc.file_unique_id,
|
|
2922
|
-
file_name: filename,
|
|
2923
|
-
mime_type: doc.mime_type,
|
|
2924
|
-
file_size: doc.file_size
|
|
2925
|
-
});
|
|
2926
|
-
}
|
|
2927
|
-
if (msg.voice && typeof msg.voice === 'object') {
|
|
2928
|
-
const voice = msg.voice;
|
|
2929
|
-
const filename = `voice_${messageId}.ogg`;
|
|
2930
|
-
attachments.push({
|
|
2931
|
-
type: 'voice',
|
|
2932
|
-
file_id: voice.file_id,
|
|
2933
|
-
file_unique_id: voice.file_unique_id,
|
|
2934
|
-
file_name: filename,
|
|
2935
|
-
mime_type: voice.mime_type || 'audio/ogg',
|
|
2936
|
-
file_size: voice.file_size,
|
|
2937
|
-
duration: voice.duration
|
|
883
|
+
threadId,
|
|
2938
884
|
});
|
|
2939
885
|
}
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
});
|
|
2954
|
-
|
|
2955
|
-
if (msg.audio && typeof msg.audio === 'object') {
|
|
2956
|
-
const audio = msg.audio;
|
|
2957
|
-
const filename = audio.file_name || `audio_${messageId}.mp3`;
|
|
2958
|
-
attachments.push({
|
|
2959
|
-
type: 'audio',
|
|
2960
|
-
file_id: audio.file_id,
|
|
2961
|
-
file_unique_id: audio.file_unique_id,
|
|
2962
|
-
file_name: filename,
|
|
2963
|
-
mime_type: audio.mime_type,
|
|
2964
|
-
file_size: audio.file_size,
|
|
2965
|
-
duration: audio.duration
|
|
2966
|
-
});
|
|
2967
|
-
}
|
|
2968
|
-
// Skip messages with no text content AND no attachments (stickers, etc.)
|
|
2969
|
-
if (!content && attachments.length === 0) {
|
|
2970
|
-
return;
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
// ───────────────────────── Wake Recovery ─────────────────────────
|
|
889
|
+
let providerRegistry;
|
|
890
|
+
let messagePipeline;
|
|
891
|
+
async function onWakeRecovery(sleepDurationMs) {
|
|
892
|
+
logger.info({ sleepDurationMs }, 'Running wake recovery');
|
|
893
|
+
// 1. Suppress daemon health check kills for 60s
|
|
894
|
+
suppressHealthChecks(60_000);
|
|
895
|
+
resetUnhealthyDaemons();
|
|
896
|
+
// 2. Reconnect all providers (skip those that were never started)
|
|
897
|
+
for (const provider of providerRegistry.getAllProviders()) {
|
|
898
|
+
if (!provider.isConnected()) {
|
|
899
|
+
logger.debug({ provider: provider.name }, 'Skipping wake reconnect for inactive provider');
|
|
900
|
+
continue;
|
|
2971
901
|
}
|
|
2972
|
-
const chatType = ctx.chat.type;
|
|
2973
|
-
const isGroup = chatType === 'group' || chatType === 'supergroup';
|
|
2974
|
-
const isPrivate = chatType === 'private';
|
|
2975
|
-
const senderId = String(ctx.from?.id || ctx.chat.id);
|
|
2976
|
-
const senderName = ctx.from?.first_name || ctx.from?.username || 'User';
|
|
2977
|
-
const chatName = ('title' in ctx.chat && ctx.chat.title)
|
|
2978
|
-
|| ('username' in ctx.chat && ctx.chat.username)
|
|
2979
|
-
|| ctx.from?.first_name
|
|
2980
|
-
|| ctx.from?.username
|
|
2981
|
-
|| senderName;
|
|
2982
|
-
const timestamp = new Date(msg.date * 1000).toISOString();
|
|
2983
|
-
const rawThreadId = msg.message_thread_id;
|
|
2984
|
-
const messageThreadId = Number.isFinite(rawThreadId) ? Number(rawThreadId) : undefined;
|
|
2985
|
-
const storedContent = content || `[${attachments.map(a => a.type).join(', ')}]`;
|
|
2986
|
-
const logContent = storedContent;
|
|
2987
|
-
logger.info({ chatId, isGroup, senderName }, `Telegram message: ${logContent.substring(0, 50)}...`);
|
|
2988
902
|
try {
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
const botId = ctx.botInfo?.id;
|
|
2998
|
-
// Admin commands only apply to text messages
|
|
2999
|
-
if (content) {
|
|
3000
|
-
const adminHandled = await handleAdminCommand({
|
|
3001
|
-
chatId,
|
|
3002
|
-
senderId,
|
|
3003
|
-
senderName,
|
|
3004
|
-
content,
|
|
3005
|
-
botUsername,
|
|
3006
|
-
messageThreadId
|
|
3007
|
-
});
|
|
3008
|
-
if (adminHandled) {
|
|
3009
|
-
return;
|
|
3010
|
-
}
|
|
3011
|
-
}
|
|
3012
|
-
const entities = 'entities' in msg ? msg.entities : undefined;
|
|
3013
|
-
const mentioned = content ? isBotMentioned(content, entities, botUsername, botId) : false;
|
|
3014
|
-
const replied = isBotReplied(ctx.message, botId);
|
|
3015
|
-
const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
|
|
3016
|
-
const triggered = Boolean(triggerRegex && content && triggerRegex.test(content));
|
|
3017
|
-
const shouldProcess = isPrivate || mentioned || replied || triggered;
|
|
3018
|
-
if (!shouldProcess) {
|
|
3019
|
-
return;
|
|
903
|
+
if (provider.name === 'telegram')
|
|
904
|
+
setTelegramConnected(false);
|
|
905
|
+
await provider.stop();
|
|
906
|
+
await sleep(1_000);
|
|
907
|
+
await provider.start(createProviderHandlers(providerRegistry, messagePipeline));
|
|
908
|
+
if (provider.name === 'telegram')
|
|
909
|
+
setTelegramConnected(true);
|
|
910
|
+
logger.info({ provider: provider.name }, 'Provider reconnected after wake');
|
|
3020
911
|
}
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
if (!rateCheck.allowed) {
|
|
3024
|
-
const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
|
|
3025
|
-
logger.warn({ senderId, retryAfterSec }, 'Rate limit exceeded');
|
|
3026
|
-
await sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { messageThreadId });
|
|
3027
|
-
return;
|
|
912
|
+
catch (err) {
|
|
913
|
+
logger.error({ err, provider: provider.name }, 'Failed to reconnect provider after wake');
|
|
3028
914
|
}
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
}
|
|
915
|
+
}
|
|
916
|
+
// 3. Reset stalled messages
|
|
917
|
+
try {
|
|
918
|
+
const resetCount = resetStalledMessages(1_000);
|
|
919
|
+
if (resetCount > 0)
|
|
920
|
+
logger.info({ resetCount }, 'Reset stalled messages after wake');
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
logger.error({ err }, 'Failed to reset stalled messages after wake');
|
|
924
|
+
}
|
|
925
|
+
// 4. Reset stalled background jobs
|
|
926
|
+
try {
|
|
927
|
+
const resetJobCount = resetStalledBackgroundJobs();
|
|
928
|
+
if (resetJobCount > 0)
|
|
929
|
+
logger.info({ count: resetJobCount }, 'Re-queued stalled background jobs after wake');
|
|
930
|
+
}
|
|
931
|
+
catch (err) {
|
|
932
|
+
logger.error({ err }, 'Failed to reset stalled background jobs after wake');
|
|
933
|
+
}
|
|
934
|
+
// 5. Re-drain pending message queues
|
|
935
|
+
try {
|
|
936
|
+
const pendingChats = getChatsWithPendingMessages();
|
|
937
|
+
const activeDrains = getActiveDrains();
|
|
938
|
+
for (const chatId of pendingChats) {
|
|
939
|
+
if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
|
|
940
|
+
void messagePipeline.drainQueue(chatId);
|
|
3056
941
|
}
|
|
3057
942
|
}
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
senderName,
|
|
3063
|
-
content: storedContent,
|
|
3064
|
-
timestamp,
|
|
3065
|
-
isGroup,
|
|
3066
|
-
chatType,
|
|
3067
|
-
messageThreadId,
|
|
3068
|
-
attachments: attachments.length > 0 ? attachments : undefined
|
|
3069
|
-
});
|
|
3070
|
-
});
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
logger.error({ err }, 'Failed to resume message drains after wake');
|
|
946
|
+
}
|
|
3071
947
|
}
|
|
948
|
+
// ───────────────────────── Docker Check ─────────────────────────
|
|
3072
949
|
function ensureDockerRunning() {
|
|
3073
950
|
try {
|
|
3074
951
|
execSync('docker info', { stdio: 'pipe', timeout: 10000 });
|
|
@@ -3076,7 +953,6 @@ function ensureDockerRunning() {
|
|
|
3076
953
|
}
|
|
3077
954
|
catch {
|
|
3078
955
|
logger.error('Docker daemon is not running');
|
|
3079
|
-
// Intentionally using console.error for maximum visibility on fatal exit
|
|
3080
956
|
console.error('\n╔════════════════════════════════════════════════════════════════╗');
|
|
3081
957
|
console.error('║ FATAL: Docker is not running ║');
|
|
3082
958
|
console.error('║ ║');
|
|
@@ -3089,37 +965,29 @@ function ensureDockerRunning() {
|
|
|
3089
965
|
throw new Error('Docker is required but not running');
|
|
3090
966
|
}
|
|
3091
967
|
}
|
|
968
|
+
// ───────────────────────── Main ─────────────────────────
|
|
3092
969
|
async function main() {
|
|
3093
|
-
// Global error handlers — keep the process alive on unexpected errors
|
|
3094
970
|
process.on('unhandledRejection', (reason) => {
|
|
3095
971
|
logger.error({ err: reason }, 'Unhandled promise rejection');
|
|
3096
972
|
});
|
|
3097
973
|
process.on('uncaughtException', (err) => {
|
|
3098
974
|
logger.error({ err }, 'Uncaught exception');
|
|
3099
|
-
// Only exit for fatal system errors (out of memory, etc.)
|
|
3100
975
|
if (err instanceof RangeError || err instanceof TypeError) {
|
|
3101
976
|
logger.error('Fatal uncaught exception — exiting');
|
|
3102
977
|
process.exit(1);
|
|
3103
978
|
}
|
|
3104
979
|
});
|
|
3105
|
-
// Ensure directory structure exists before anything else
|
|
3106
980
|
const { ensureDirectoryStructure } = await import('./paths.js');
|
|
3107
981
|
ensureDirectoryStructure();
|
|
3108
982
|
try {
|
|
3109
983
|
const envStat = fs.existsSync(ENV_PATH) ? fs.statSync(ENV_PATH) : null;
|
|
3110
984
|
if (!envStat || envStat.size === 0) {
|
|
3111
|
-
logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; set
|
|
985
|
+
logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; run "dotclaw configure" to set up provider tokens and API keys');
|
|
3112
986
|
}
|
|
3113
987
|
}
|
|
3114
988
|
catch (err) {
|
|
3115
989
|
logger.warn({ envPath: ENV_PATH, err }, 'Failed to check .env file');
|
|
3116
990
|
}
|
|
3117
|
-
// Validate Telegram token
|
|
3118
|
-
if (!process.env.TELEGRAM_BOT_TOKEN) {
|
|
3119
|
-
throw new Error('TELEGRAM_BOT_TOKEN environment variable is required.\n' +
|
|
3120
|
-
'Create a bot with @BotFather and add the token to your .env file at: ' +
|
|
3121
|
-
ENV_PATH);
|
|
3122
|
-
}
|
|
3123
991
|
ensureDockerRunning();
|
|
3124
992
|
initDatabase();
|
|
3125
993
|
const resetCount = resetStalledMessages();
|
|
@@ -3142,6 +1010,41 @@ async function main() {
|
|
|
3142
1010
|
}
|
|
3143
1011
|
startMetricsServer();
|
|
3144
1012
|
loadState();
|
|
1013
|
+
// ──── Provider Registry ────
|
|
1014
|
+
providerRegistry = new ProviderRegistry();
|
|
1015
|
+
// Register Telegram provider (optional — only when enabled + token present)
|
|
1016
|
+
let telegramProvider = null;
|
|
1017
|
+
if (runtime.host.telegram.enabled && process.env.TELEGRAM_BOT_TOKEN) {
|
|
1018
|
+
telegramProvider = createTelegramProvider(runtime, GROUPS_DIR);
|
|
1019
|
+
providerRegistry.register(telegramProvider);
|
|
1020
|
+
logger.info('Telegram provider registered');
|
|
1021
|
+
}
|
|
1022
|
+
else if (runtime.host.telegram.enabled && !process.env.TELEGRAM_BOT_TOKEN) {
|
|
1023
|
+
logger.warn('Telegram is enabled in config but TELEGRAM_BOT_TOKEN is not set — skipping');
|
|
1024
|
+
}
|
|
1025
|
+
// Register Discord provider (optional — only when enabled + token present)
|
|
1026
|
+
let discordProvider = null;
|
|
1027
|
+
if (runtime.host.discord.enabled && process.env.DISCORD_BOT_TOKEN) {
|
|
1028
|
+
const { createDiscordProvider } = await import('./providers/discord/index.js');
|
|
1029
|
+
discordProvider = createDiscordProvider(runtime);
|
|
1030
|
+
providerRegistry.register(discordProvider);
|
|
1031
|
+
logger.info('Discord provider registered');
|
|
1032
|
+
}
|
|
1033
|
+
else if (runtime.host.discord.enabled && !process.env.DISCORD_BOT_TOKEN) {
|
|
1034
|
+
logger.warn('Discord is enabled in config but DISCORD_BOT_TOKEN is not set — skipping');
|
|
1035
|
+
}
|
|
1036
|
+
// ──── Message Pipeline ────
|
|
1037
|
+
messagePipeline = createMessagePipeline({
|
|
1038
|
+
registry: providerRegistry,
|
|
1039
|
+
registeredGroups: () => registeredGroups,
|
|
1040
|
+
sessions: () => sessions,
|
|
1041
|
+
setSession: (folder, id) => {
|
|
1042
|
+
sessions[folder] = id;
|
|
1043
|
+
setGroupSession(folder, id);
|
|
1044
|
+
},
|
|
1045
|
+
buildAvailableGroupsSnapshot,
|
|
1046
|
+
});
|
|
1047
|
+
// Warm containers
|
|
3145
1048
|
if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
|
|
3146
1049
|
const groups = Object.values(registeredGroups);
|
|
3147
1050
|
for (const group of groups) {
|
|
@@ -3154,23 +1057,31 @@ async function main() {
|
|
|
3154
1057
|
}
|
|
3155
1058
|
}
|
|
3156
1059
|
}
|
|
3157
|
-
// Resume
|
|
1060
|
+
// Resume pending message queues from before restart
|
|
3158
1061
|
const pendingChats = getChatsWithPendingMessages();
|
|
3159
1062
|
for (const chatId of pendingChats) {
|
|
3160
1063
|
if (registeredGroups[chatId]) {
|
|
3161
1064
|
logger.info({ chatId }, 'Resuming message queue drain after restart');
|
|
3162
|
-
void drainQueue(chatId);
|
|
1065
|
+
void messagePipeline.drainQueue(chatId);
|
|
3163
1066
|
}
|
|
3164
1067
|
}
|
|
3165
|
-
// Set up Telegram message handlers
|
|
3166
|
-
setupTelegramHandlers();
|
|
3167
1068
|
// Start dashboard
|
|
3168
1069
|
startDashboard();
|
|
3169
|
-
// Start
|
|
1070
|
+
// ──── Start Providers ────
|
|
1071
|
+
const handlers = createProviderHandlers(providerRegistry, messagePipeline);
|
|
3170
1072
|
try {
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
1073
|
+
if (telegramProvider) {
|
|
1074
|
+
await telegramProvider.start(handlers);
|
|
1075
|
+
setTelegramConnected(true);
|
|
1076
|
+
logger.info('Telegram bot started');
|
|
1077
|
+
}
|
|
1078
|
+
if (discordProvider) {
|
|
1079
|
+
await discordProvider.start(handlers);
|
|
1080
|
+
logger.info('Discord bot started');
|
|
1081
|
+
}
|
|
1082
|
+
if (!telegramProvider && !discordProvider) {
|
|
1083
|
+
throw new Error('No messaging providers configured. Set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN.');
|
|
1084
|
+
}
|
|
3174
1085
|
// Graceful shutdown
|
|
3175
1086
|
let shuttingDown = false;
|
|
3176
1087
|
const gracefulShutdown = async (signal) => {
|
|
@@ -3180,7 +1091,12 @@ async function main() {
|
|
|
3180
1091
|
logger.info({ signal }, 'Graceful shutdown initiated');
|
|
3181
1092
|
// 1. Stop accepting new work
|
|
3182
1093
|
setTelegramConnected(false);
|
|
3183
|
-
|
|
1094
|
+
for (const p of providerRegistry.getAllProviders()) {
|
|
1095
|
+
try {
|
|
1096
|
+
await p.stop();
|
|
1097
|
+
}
|
|
1098
|
+
catch { /* ignore */ }
|
|
1099
|
+
}
|
|
3184
1100
|
// 2. Stop all loops and watchers
|
|
3185
1101
|
clearInterval(rateLimiterInterval);
|
|
3186
1102
|
stopSchedulerLoop();
|
|
@@ -3190,16 +1106,18 @@ async function main() {
|
|
|
3190
1106
|
stopHeartbeatLoop();
|
|
3191
1107
|
stopDaemonHealthCheckLoop();
|
|
3192
1108
|
stopWakeDetector();
|
|
3193
|
-
stopEmbeddingWorker();
|
|
1109
|
+
await stopEmbeddingWorker();
|
|
3194
1110
|
// 3. Stop HTTP servers
|
|
3195
1111
|
stopMetricsServer();
|
|
3196
1112
|
stopDashboard();
|
|
3197
1113
|
// 4. Abort active agent runs so drain loops can finish quickly
|
|
1114
|
+
const activeRuns = getActiveRuns();
|
|
3198
1115
|
for (const [chatId, controller] of activeRuns.entries()) {
|
|
3199
1116
|
logger.info({ chatId }, 'Aborting active agent run for shutdown');
|
|
3200
1117
|
controller.abort();
|
|
3201
1118
|
}
|
|
3202
1119
|
// Wait for active drain loops to finish
|
|
1120
|
+
const activeDrains = getActiveDrains();
|
|
3203
1121
|
const drainDeadline = Date.now() + 30_000;
|
|
3204
1122
|
while (activeDrains.size > 0 && Date.now() < drainDeadline) {
|
|
3205
1123
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -3210,6 +1128,7 @@ async function main() {
|
|
|
3210
1128
|
// 5. Clean up Docker containers for this instance
|
|
3211
1129
|
cleanupInstanceContainers();
|
|
3212
1130
|
// 6. Close databases
|
|
1131
|
+
closeWorkflowStore();
|
|
3213
1132
|
closeMemoryStore();
|
|
3214
1133
|
closeDatabase();
|
|
3215
1134
|
logger.info('Shutdown complete');
|
|
@@ -3217,10 +1136,9 @@ async function main() {
|
|
|
3217
1136
|
};
|
|
3218
1137
|
process.once('SIGINT', () => void gracefulShutdown('SIGINT'));
|
|
3219
1138
|
process.once('SIGTERM', () => void gracefulShutdown('SIGTERM'));
|
|
3220
|
-
// Start
|
|
3221
|
-
// Wrapper that matches the scheduler's expected interface (Promise<void>)
|
|
1139
|
+
// ──── Start Services ────
|
|
3222
1140
|
const sendMessageForScheduler = async (jid, text) => {
|
|
3223
|
-
const result = await sendMessage(jid, text);
|
|
1141
|
+
const result = await providerRegistry.getProviderForChat(jid).sendMessage(jid, text);
|
|
3224
1142
|
if (!result.success) {
|
|
3225
1143
|
throw new Error(`Failed to send message to chat ${jid}`);
|
|
3226
1144
|
}
|
|
@@ -3243,15 +1161,26 @@ async function main() {
|
|
|
3243
1161
|
setGroupSession(groupFolder, sessionId);
|
|
3244
1162
|
}
|
|
3245
1163
|
});
|
|
3246
|
-
startIpcWatcher(
|
|
1164
|
+
startIpcWatcher({
|
|
1165
|
+
registry: providerRegistry,
|
|
1166
|
+
registeredGroups: () => registeredGroups,
|
|
1167
|
+
registerGroup,
|
|
1168
|
+
unregisterGroup,
|
|
1169
|
+
listRegisteredGroups,
|
|
1170
|
+
sessions: () => sessions,
|
|
1171
|
+
setSession: (folder, id) => {
|
|
1172
|
+
sessions[folder] = id;
|
|
1173
|
+
setGroupSession(folder, id);
|
|
1174
|
+
}
|
|
1175
|
+
});
|
|
3247
1176
|
startMaintenanceLoop();
|
|
3248
1177
|
startHeartbeatLoop();
|
|
3249
1178
|
startDaemonHealthCheckLoop(() => registeredGroups, MAIN_GROUP_FOLDER);
|
|
3250
1179
|
startWakeDetector((ms) => { void onWakeRecovery(ms); });
|
|
3251
|
-
logger.info('DotClaw running
|
|
1180
|
+
logger.info('DotClaw running (responds to DMs and group mentions/replies)');
|
|
3252
1181
|
}
|
|
3253
1182
|
catch (error) {
|
|
3254
|
-
logger.error({ error }, 'Failed to start
|
|
1183
|
+
logger.error({ err: error instanceof Error ? error : new Error(String(error)) }, 'Failed to start DotClaw');
|
|
3255
1184
|
process.exit(1);
|
|
3256
1185
|
}
|
|
3257
1186
|
}
|