@dotsetlabs/dotclaw 1.9.0 → 2.1.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 +13 -8
- package/config-examples/groups/global/CLAUDE.md +6 -14
- package/config-examples/groups/main/CLAUDE.md +8 -39
- package/config-examples/runtime.json +16 -122
- package/config-examples/tool-policy.json +2 -15
- 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 +62 -47
- package/container/agent-runner/src/browser.ts +180 -0
- package/container/agent-runner/src/container-protocol.ts +4 -9
- package/container/agent-runner/src/id.ts +3 -2
- package/container/agent-runner/src/index.ts +331 -846
- package/container/agent-runner/src/ipc.ts +3 -33
- 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 +154 -184
- 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 +5 -9
- package/dist/agent-execution.d.ts.map +1 -1
- package/dist/agent-execution.js +32 -20
- package/dist/agent-execution.js.map +1 -1
- package/dist/cli.js +61 -16
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -5
- package/dist/config.js.map +1 -1
- package/dist/container-protocol.d.ts +4 -9
- 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 -6
- package/dist/dashboard.d.ts.map +1 -1
- package/dist/dashboard.js +12 -60
- package/dist/dashboard.js.map +1 -1
- package/dist/db.d.ts +1 -59
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +41 -262
- package/dist/db.js.map +1 -1
- package/dist/error-messages.d.ts.map +1 -1
- package/dist/error-messages.js +5 -1
- package/dist/error-messages.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 +673 -2790
- 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 +861 -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 +3 -7
- 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 +652 -0
- package/dist/message-pipeline.js.map +1 -0
- package/dist/metrics.d.ts +7 -10
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +2 -33
- 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/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 +607 -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 +9 -31
- package/dist/request-router.d.ts.map +1 -1
- package/dist/request-router.js +12 -142
- package/dist/request-router.js.map +1 -1
- package/dist/runtime-config.d.ts +79 -101
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +140 -208
- 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/streaming.d.ts +58 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +196 -0
- package/dist/streaming.js.map +1 -0
- package/dist/task-scheduler.d.ts.map +1 -1
- package/dist/task-scheduler.js +11 -45
- package/dist/task-scheduler.js.map +1 -1
- package/dist/tool-policy.d.ts.map +1 -1
- package/dist/tool-policy.js +13 -5
- 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 -50
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -4
- package/scripts/bootstrap.js +40 -4
- package/scripts/configure.js +129 -7
- package/scripts/doctor.js +30 -4
- package/scripts/init.js +13 -6
- package/scripts/install.sh +1 -1
- package/config-examples/plugin-http.json +0 -18
- package/container/skills/agent-browser.md +0 -159
- package/dist/background-job-classifier.d.ts +0 -20
- package/dist/background-job-classifier.d.ts.map +0 -1
- package/dist/background-job-classifier.js +0 -145
- package/dist/background-job-classifier.js.map +0 -1
- package/dist/background-jobs.d.ts +0 -56
- package/dist/background-jobs.d.ts.map +0 -1
- package/dist/background-jobs.js +0 -550
- package/dist/background-jobs.js.map +0 -1
- package/dist/planner-probe.d.ts +0 -14
- package/dist/planner-probe.d.ts.map +0 -1
- package/dist/planner-probe.js +0 -97
- package/dist/planner-probe.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
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, spawnBackgroundJob, getBackgroundJobStatus, listBackgroundJobsForGroup, cancelBackgroundJob, recordBackgroundJobUpdate } from './background-jobs.js';
|
|
8
|
+
import { initDatabase, closeDatabase, storeMessage, upsertChat, getChatState, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, getTraceIdForMessage, recordUserFeedback, getChatsWithPendingMessages, resetStalledMessages, } from './db.js';
|
|
9
|
+
import { startSchedulerLoop, stopSchedulerLoop } from './task-scheduler.js';
|
|
12
10
|
import { loadJson, saveJson, isSafeGroupFolder } from './utils.js';
|
|
13
|
-
import { hostPathToContainerGroupPath, resolveContainerGroupPathToHost } from './path-mapping.js';
|
|
14
11
|
import { writeTrace } from './trace-writer.js';
|
|
15
|
-
import {
|
|
16
|
-
import { initMemoryStore, closeMemoryStore, getMemoryStats, upsertMemoryItems, searchMemories, listMemories, forgetMemories, cleanupExpiredMemories } from './memory-store.js';
|
|
12
|
+
import { initMemoryStore, closeMemoryStore, cleanupExpiredMemories, upsertMemoryItems } from './memory-store.js';
|
|
17
13
|
import { startEmbeddingWorker, stopEmbeddingWorker } from './memory-embeddings.js';
|
|
18
|
-
import { createProgressManager, DEFAULT_PROGRESS_MESSAGES, DEFAULT_PROGRESS_STAGES, formatProgressWithPlan, formatPlanStepList } from './progress.js';
|
|
19
14
|
import { parseAdminCommand } from './admin-commands.js';
|
|
20
15
|
import { loadModelRegistry, saveModelRegistry } from './model-registry.js';
|
|
21
|
-
import { startMetricsServer, stopMetricsServer, recordMessage
|
|
16
|
+
import { startMetricsServer, stopMetricsServer, recordMessage } from './metrics.js';
|
|
22
17
|
import { startMaintenanceLoop, stopMaintenanceLoop } from './maintenance.js';
|
|
23
18
|
import { warmGroupContainer, startDaemonHealthCheckLoop, stopDaemonHealthCheckLoop, cleanupInstanceContainers, suppressHealthChecks, resetUnhealthyDaemons } from './container-runner.js';
|
|
24
19
|
import { startWakeDetector, stopWakeDetector } from './wake-detector.js';
|
|
25
20
|
import { loadRuntimeConfig } from './runtime-config.js';
|
|
21
|
+
import { transcribeVoice } from './transcription.js';
|
|
22
|
+
import { emitHook } from './hooks.js';
|
|
26
23
|
import { invalidatePersonalizationCache } from './personalization.js';
|
|
24
|
+
import { installSkill, removeSkill, listSkills, updateSkill } from './skill-manager.js';
|
|
27
25
|
import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionError } from './agent-execution.js';
|
|
28
26
|
import { logger } from './logger.js';
|
|
29
|
-
import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
27
|
+
import { startDashboard, stopDashboard, setTelegramConnected, setLastMessageTime } from './dashboard.js';
|
|
28
|
+
import { routePrompt } from './request-router.js';
|
|
29
|
+
// Provider system
|
|
30
|
+
import { ProviderRegistry } from './providers/registry.js';
|
|
31
|
+
import { createTelegramProvider } from './providers/telegram/index.js';
|
|
32
|
+
import { createMessagePipeline, getActiveDrains, getActiveRuns, providerAttachmentToMessageAttachment } from './message-pipeline.js';
|
|
33
|
+
import { startIpcWatcher, stopIpcWatcher } from './ipc-dispatcher.js';
|
|
36
34
|
const runtime = loadRuntimeConfig();
|
|
35
|
+
// ───────────────────────── State ─────────────────────────
|
|
36
|
+
let sessions = {};
|
|
37
|
+
let registeredGroups = {};
|
|
38
|
+
// ───────────────────────── Helpers ─────────────────────────
|
|
37
39
|
function buildTriggerRegex(pattern) {
|
|
38
40
|
if (!pattern)
|
|
39
41
|
return null;
|
|
@@ -52,87 +54,23 @@ function buildAvailableGroupsSnapshot() {
|
|
|
52
54
|
isRegistered: true
|
|
53
55
|
}));
|
|
54
56
|
}
|
|
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;
|
|
57
|
+
function sleep(ms) {
|
|
58
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
116
59
|
}
|
|
117
|
-
// Rate
|
|
118
|
-
const RATE_LIMIT_MAX_MESSAGES = 20;
|
|
119
|
-
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
60
|
+
// ───────────────────────── Rate Limiter ─────────────────────────
|
|
61
|
+
const RATE_LIMIT_MAX_MESSAGES = 20;
|
|
62
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
120
63
|
const rateLimiter = new Map();
|
|
121
|
-
const JOB_UPDATE_NOTIFY_DEDUP_WINDOW_MS = 120_000;
|
|
122
|
-
const lastJobUpdateNotifications = new Map();
|
|
123
64
|
function checkRateLimit(userId) {
|
|
124
65
|
const now = Date.now();
|
|
125
66
|
const entry = rateLimiter.get(userId);
|
|
126
67
|
if (!entry || now > entry.resetAt) {
|
|
127
|
-
// New window
|
|
128
68
|
rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
129
69
|
return { allowed: true };
|
|
130
70
|
}
|
|
131
71
|
if (entry.count >= RATE_LIMIT_MAX_MESSAGES) {
|
|
132
|
-
// Rate limited
|
|
133
72
|
return { allowed: false, retryAfterMs: entry.resetAt - now };
|
|
134
73
|
}
|
|
135
|
-
// Increment counter
|
|
136
74
|
entry.count += 1;
|
|
137
75
|
return { allowed: true };
|
|
138
76
|
}
|
|
@@ -144,171 +82,32 @@ function cleanupRateLimiter() {
|
|
|
144
82
|
}
|
|
145
83
|
}
|
|
146
84
|
}
|
|
147
|
-
// Clean up expired rate limit entries periodically
|
|
148
85
|
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;
|
|
152
|
-
const MEMORY_RECALL_MAX_RESULTS = runtime.host.memory.recall.maxResults;
|
|
153
|
-
const MEMORY_RECALL_MAX_TOKENS = runtime.host.memory.recall.maxTokens;
|
|
154
|
-
const INPUT_MESSAGE_MAX_CHARS = runtime.host.telegram.inputMessageMaxChars;
|
|
86
|
+
// ───────────────────────── Config Constants ─────────────────────────
|
|
155
87
|
const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
|
|
156
88
|
const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
|
|
157
89
|
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;
|
|
90
|
+
// ───────────────────────── State Management ─────────────────────────
|
|
91
|
+
function loadState() {
|
|
92
|
+
sessions = {};
|
|
93
|
+
const rawGroups = loadJson(path.join(DATA_DIR, 'registered_groups.json'), {});
|
|
94
|
+
// Migrate: prefix unprefixed chat IDs with 'telegram:'
|
|
95
|
+
let migrated = false;
|
|
96
|
+
const loadedGroups = {};
|
|
97
|
+
for (const [chatId, group] of Object.entries(rawGroups)) {
|
|
98
|
+
if (!chatId.includes(':')) {
|
|
99
|
+
// Unprefixed — add telegram: prefix
|
|
100
|
+
loadedGroups[ProviderRegistry.addPrefix('telegram', chatId)] = group;
|
|
101
|
+
migrated = true;
|
|
263
102
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (entity.type === 'bot_command') {
|
|
267
|
-
if (segment.toLowerCase().includes(`@${normalized}`))
|
|
268
|
-
return true;
|
|
103
|
+
else {
|
|
104
|
+
loadedGroups[chatId] = group;
|
|
269
105
|
}
|
|
270
106
|
}
|
|
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;
|
|
107
|
+
if (migrated) {
|
|
108
|
+
saveJson(path.join(DATA_DIR, 'registered_groups.json'), loadedGroups);
|
|
109
|
+
logger.info('Migrated registered_groups.json chat IDs with telegram: prefix');
|
|
295
110
|
}
|
|
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
111
|
const sanitizedGroups = {};
|
|
313
112
|
const usedFolders = new Set();
|
|
314
113
|
let invalidCount = 0;
|
|
@@ -327,2222 +126,97 @@ function loadState() {
|
|
|
327
126
|
if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
|
|
328
127
|
logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with invalid folder');
|
|
329
128
|
invalidCount += 1;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
if (usedFolders.has(group.folder)) {
|
|
333
|
-
logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with duplicate folder');
|
|
334
|
-
duplicateCount += 1;
|
|
335
|
-
continue;
|
|
336
|
-
}
|
|
337
|
-
usedFolders.add(group.folder);
|
|
338
|
-
sanitizedGroups[chatId] = group;
|
|
339
|
-
}
|
|
340
|
-
registeredGroups = sanitizedGroups;
|
|
341
|
-
if (invalidCount > 0 || duplicateCount > 0) {
|
|
342
|
-
logger.error({ invalidCount, duplicateCount }, 'Registered groups contained invalid or duplicate folders');
|
|
343
|
-
}
|
|
344
|
-
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
|
|
345
|
-
const finalSessions = getAllGroupSessions();
|
|
346
|
-
sessions = finalSessions.reduce((acc, row) => {
|
|
347
|
-
acc[row.group_folder] = row.session_id;
|
|
348
|
-
return acc;
|
|
349
|
-
}, {});
|
|
350
|
-
}
|
|
351
|
-
function registerGroup(chatId, group) {
|
|
352
|
-
if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
|
|
353
|
-
logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with invalid folder');
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
const folderCollision = Object.values(registeredGroups).some(g => g.folder === group.folder);
|
|
357
|
-
if (folderCollision) {
|
|
358
|
-
logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with duplicate folder');
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
registeredGroups[chatId] = group;
|
|
362
|
-
saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
|
|
363
|
-
// Create group folder
|
|
364
|
-
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
365
|
-
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
|
366
|
-
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
|
|
367
|
-
if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
|
|
368
|
-
try {
|
|
369
|
-
warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
|
|
370
|
-
logger.info({ group: group.folder }, 'Warmed daemon container for new group');
|
|
371
|
-
}
|
|
372
|
-
catch (err) {
|
|
373
|
-
logger.warn({ group: group.folder, err }, 'Failed to warm container for new group');
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
function listRegisteredGroups() {
|
|
378
|
-
return Object.entries(registeredGroups).map(([chatId, group]) => ({
|
|
379
|
-
chat_id: chatId,
|
|
380
|
-
name: group.name,
|
|
381
|
-
folder: group.folder,
|
|
382
|
-
trigger: group.trigger,
|
|
383
|
-
added_at: group.added_at
|
|
384
|
-
}));
|
|
385
|
-
}
|
|
386
|
-
function resolveGroupIdentifier(identifier) {
|
|
387
|
-
const trimmed = identifier.trim();
|
|
388
|
-
if (!trimmed)
|
|
389
|
-
return null;
|
|
390
|
-
const normalized = trimmed.toLowerCase();
|
|
391
|
-
for (const [chatId, group] of Object.entries(registeredGroups)) {
|
|
392
|
-
if (chatId === trimmed)
|
|
393
|
-
return chatId;
|
|
394
|
-
if (group.name.toLowerCase() === normalized)
|
|
395
|
-
return chatId;
|
|
396
|
-
if (group.folder.toLowerCase() === normalized)
|
|
397
|
-
return chatId;
|
|
398
|
-
}
|
|
399
|
-
return null;
|
|
400
|
-
}
|
|
401
|
-
function unregisterGroup(identifier) {
|
|
402
|
-
const chatId = resolveGroupIdentifier(identifier);
|
|
403
|
-
if (!chatId) {
|
|
404
|
-
return { ok: false, error: 'Group not found' };
|
|
405
|
-
}
|
|
406
|
-
const group = registeredGroups[chatId];
|
|
407
|
-
if (!group) {
|
|
408
|
-
return { ok: false, error: 'Group not found' };
|
|
409
|
-
}
|
|
410
|
-
if (group.folder === MAIN_GROUP_FOLDER) {
|
|
411
|
-
return { ok: false, error: 'Cannot remove main group' };
|
|
412
|
-
}
|
|
413
|
-
delete registeredGroups[chatId];
|
|
414
|
-
saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
|
|
415
|
-
delete sessions[group.folder];
|
|
416
|
-
deleteGroupSession(group.folder);
|
|
417
|
-
pauseTasksForGroup(group.folder);
|
|
418
|
-
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
|
|
419
|
-
return { ok: true, group: { ...group, chat_id: chatId } };
|
|
420
|
-
}
|
|
421
|
-
function splitPlainText(text, maxLength) {
|
|
422
|
-
if (text.length <= maxLength)
|
|
423
|
-
return [text];
|
|
424
|
-
const chunks = [];
|
|
425
|
-
for (let i = 0; i < text.length; i += maxLength) {
|
|
426
|
-
chunks.push(text.slice(i, i + maxLength));
|
|
427
|
-
}
|
|
428
|
-
return chunks;
|
|
429
|
-
}
|
|
430
|
-
async function sendMessage(chatId, text, options) {
|
|
431
|
-
const parseMode = options?.parseMode === undefined ? TELEGRAM_PARSE_MODE : options.parseMode;
|
|
432
|
-
const chunks = parseMode
|
|
433
|
-
? formatTelegramMessage(text, TELEGRAM_MAX_MESSAGE_LENGTH)
|
|
434
|
-
: splitPlainText(text, TELEGRAM_MAX_MESSAGE_LENGTH);
|
|
435
|
-
let firstMessageId;
|
|
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 };
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
function hostPathToContainerPath(hostPath, groupFolder) {
|
|
486
|
-
return hostPathToContainerGroupPath(hostPath, groupFolder, GROUPS_DIR);
|
|
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
|
-
}
|
|
514
|
-
}
|
|
515
|
-
return { success: false };
|
|
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
|
-
}
|
|
540
|
-
}
|
|
541
|
-
return { success: false };
|
|
542
|
-
}
|
|
543
|
-
async function downloadTelegramFile(fileId, groupFolder, filename) {
|
|
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 };
|
|
614
|
-
}
|
|
615
|
-
catch (err) {
|
|
616
|
-
const isTooLarge = err instanceof Error && err.message === 'STREAMING_TOO_LARGE';
|
|
617
|
-
if (isTooLarge) {
|
|
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' };
|
|
624
|
-
}
|
|
625
|
-
finally {
|
|
626
|
-
if (tmpPath && fs.existsSync(tmpPath)) {
|
|
627
|
-
try {
|
|
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
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
function buildAttachmentsXml(attachments, groupFolder) {
|
|
645
|
-
if (!attachments || attachments.length === 0)
|
|
646
|
-
return '';
|
|
647
|
-
const escapeXml = (s) => s
|
|
648
|
-
.replace(/&/g, '&')
|
|
649
|
-
.replace(/</g, '<')
|
|
650
|
-
.replace(/>/g, '>')
|
|
651
|
-
.replace(/"/g, '"');
|
|
652
|
-
return attachments.map(a => {
|
|
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
|
-
}
|
|
696
|
-
}
|
|
697
|
-
return { success: false };
|
|
698
|
-
}
|
|
699
|
-
async function sendAudio(chatId, filePath, options) {
|
|
700
|
-
for (let attempt = 1; attempt <= TELEGRAM_SEND_RETRIES; attempt += 1) {
|
|
701
|
-
try {
|
|
702
|
-
const payload = {};
|
|
703
|
-
if (options?.caption)
|
|
704
|
-
payload.caption = options.caption;
|
|
705
|
-
if (options?.duration)
|
|
706
|
-
payload.duration = options.duration;
|
|
707
|
-
if (options?.performer)
|
|
708
|
-
payload.performer = options.performer;
|
|
709
|
-
if (options?.title)
|
|
710
|
-
payload.title = options.title;
|
|
711
|
-
if (options?.replyToMessageId) {
|
|
712
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
713
|
-
}
|
|
714
|
-
await telegrafBot.telegram.sendAudio(chatId, { source: filePath }, payload);
|
|
715
|
-
logger.info({ chatId, filePath }, 'Audio sent');
|
|
716
|
-
return { success: true };
|
|
717
|
-
}
|
|
718
|
-
catch (err) {
|
|
719
|
-
if (!isRetryableTelegramError(err) || attempt === TELEGRAM_SEND_RETRIES) {
|
|
720
|
-
logger.error({ chatId, filePath, attempt, err }, 'Failed to send audio');
|
|
721
|
-
return { success: false };
|
|
722
|
-
}
|
|
723
|
-
const retryAfterMs = getTelegramRetryAfterMs(err);
|
|
724
|
-
const delayMs = retryAfterMs ?? (TELEGRAM_SEND_RETRY_DELAY_MS * attempt);
|
|
725
|
-
await sleep(delayMs);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
return { success: false };
|
|
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 };
|
|
735
|
-
}
|
|
736
|
-
await telegrafBot.telegram.sendLocation(chatId, latitude, longitude, payload);
|
|
737
|
-
logger.info({ chatId, latitude, longitude }, 'Location sent');
|
|
738
|
-
return { success: true };
|
|
739
|
-
}
|
|
740
|
-
catch (err) {
|
|
741
|
-
logger.error({ chatId, err }, 'Failed to send location');
|
|
742
|
-
return { success: false };
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
async function sendContact(chatId, phoneNumber, firstName, options) {
|
|
746
|
-
try {
|
|
747
|
-
const payload = {};
|
|
748
|
-
if (options?.lastName)
|
|
749
|
-
payload.last_name = options.lastName;
|
|
750
|
-
if (options?.replyToMessageId) {
|
|
751
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
752
|
-
}
|
|
753
|
-
await telegrafBot.telegram.sendContact(chatId, phoneNumber, firstName, payload);
|
|
754
|
-
logger.info({ chatId, phoneNumber }, 'Contact sent');
|
|
755
|
-
return { success: true };
|
|
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 };
|
|
775
|
-
}
|
|
776
|
-
const sent = await telegrafBot.telegram.sendPoll(chatId, question, pollOptions, payload);
|
|
777
|
-
logger.info({ chatId, question }, 'Poll sent');
|
|
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 };
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
async function sendInlineKeyboard(chatId, text, buttons, options) {
|
|
786
|
-
try {
|
|
787
|
-
const payload = {
|
|
788
|
-
reply_markup: { inline_keyboard: buttons }
|
|
789
|
-
};
|
|
790
|
-
if (options?.replyToMessageId) {
|
|
791
|
-
payload.reply_parameters = { message_id: options.replyToMessageId, allow_sending_without_reply: true };
|
|
792
|
-
}
|
|
793
|
-
const sent = await telegrafBot.telegram.sendMessage(chatId, text, payload);
|
|
794
|
-
logger.info({ chatId }, 'Inline keyboard sent');
|
|
795
|
-
return { success: true, messageId: sent.message_id };
|
|
796
|
-
}
|
|
797
|
-
catch (err) {
|
|
798
|
-
logger.error({ chatId, err }, 'Failed to send inline keyboard');
|
|
799
|
-
return { success: false };
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
// ── Callback query store (5-minute TTL for inline button callbacks) ───
|
|
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);
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
}, 60_000);
|
|
818
|
-
function normalizePollOptions(rawOptions) {
|
|
819
|
-
if (!Array.isArray(rawOptions))
|
|
820
|
-
return null;
|
|
821
|
-
const options = rawOptions
|
|
822
|
-
.filter((value) => typeof value === 'string')
|
|
823
|
-
.map(option => option.trim())
|
|
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 });
|
|
869
|
-
}
|
|
870
|
-
rows.push(row);
|
|
871
|
-
}
|
|
872
|
-
return rows;
|
|
873
|
-
}
|
|
874
|
-
class RetryableMessageProcessingError extends Error {
|
|
875
|
-
constructor(message) {
|
|
876
|
-
super(message);
|
|
877
|
-
this.name = 'RetryableMessageProcessingError';
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
function computeMessageQueueRetryDelayMs(attempt) {
|
|
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');
|
|
890
|
-
}
|
|
891
|
-
return { success: true, messageId: result.messageId };
|
|
892
|
-
}
|
|
893
|
-
function enqueueMessage(msg) {
|
|
894
|
-
if (isCancelMessage(msg.content)) {
|
|
895
|
-
const controller = activeRuns.get(msg.chatId);
|
|
896
|
-
if (controller) {
|
|
897
|
-
controller.abort();
|
|
898
|
-
activeRuns.delete(msg.chatId);
|
|
899
|
-
void sendMessage(msg.chatId, 'Canceled.', { messageThreadId: msg.messageThreadId });
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
void sendMessage(msg.chatId, "Nothing's running right now.", { messageThreadId: msg.messageThreadId });
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
enqueueMessageItem({
|
|
906
|
-
chat_jid: msg.chatId,
|
|
907
|
-
message_id: msg.messageId,
|
|
908
|
-
sender_id: msg.senderId,
|
|
909
|
-
sender_name: msg.senderName,
|
|
910
|
-
content: msg.content,
|
|
911
|
-
timestamp: msg.timestamp,
|
|
912
|
-
is_group: msg.isGroup,
|
|
913
|
-
chat_type: msg.chatType,
|
|
914
|
-
message_thread_id: msg.messageThreadId
|
|
915
|
-
});
|
|
916
|
-
setMessageQueueDepth(getPendingMessageCount());
|
|
917
|
-
if (!activeDrains.has(msg.chatId)) {
|
|
918
|
-
void drainQueue(msg.chatId);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
async function drainQueue(chatId) {
|
|
922
|
-
if (activeDrains.has(chatId))
|
|
923
|
-
return;
|
|
924
|
-
activeDrains.add(chatId);
|
|
925
|
-
setMessageQueueDepth(getPendingMessageCount());
|
|
926
|
-
let reschedule = false;
|
|
927
|
-
try {
|
|
928
|
-
let iterations = 0;
|
|
929
|
-
while (iterations < MAX_DRAIN_ITERATIONS) {
|
|
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);
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
finally {
|
|
985
|
-
if (!reschedule) {
|
|
986
|
-
activeDrains.delete(chatId);
|
|
987
|
-
}
|
|
988
|
-
setMessageQueueDepth(getPendingMessageCount());
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
async function processMessage(msg) {
|
|
992
|
-
const group = registeredGroups[msg.chatId];
|
|
993
|
-
if (!group) {
|
|
994
|
-
logger.debug({ chatId: msg.chatId }, 'Message from unregistered Telegram chat');
|
|
995
|
-
return false;
|
|
996
|
-
}
|
|
997
|
-
recordMessage('telegram');
|
|
998
|
-
setLastMessageTime(msg.timestamp);
|
|
999
|
-
// Get messages since last agent interaction, filtered to only include
|
|
1000
|
-
// messages up to and including the triggering message (not future queued ones)
|
|
1001
|
-
const chatState = getChatState(msg.chatId);
|
|
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)
|
|
1006
|
-
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
|
-
}
|
|
1013
|
-
return message.id <= msg.messageId;
|
|
1014
|
-
});
|
|
1015
|
-
if (missedMessages.length === 0) {
|
|
1016
|
-
logger.warn({ chatId: msg.chatId }, 'No missed messages found; falling back to current message');
|
|
1017
|
-
const fallbackAttachments = msg.attachments && msg.attachments.length > 0
|
|
1018
|
-
? JSON.stringify(msg.attachments)
|
|
1019
|
-
: null;
|
|
1020
|
-
missedMessages = [{
|
|
1021
|
-
id: msg.messageId,
|
|
1022
|
-
chat_jid: msg.chatId,
|
|
1023
|
-
sender: msg.senderId,
|
|
1024
|
-
sender_name: msg.senderName,
|
|
1025
|
-
content: msg.content,
|
|
1026
|
-
timestamp: msg.timestamp,
|
|
1027
|
-
attachments_json: fallbackAttachments
|
|
1028
|
-
}];
|
|
1029
|
-
}
|
|
1030
|
-
const lines = missedMessages.map(m => {
|
|
1031
|
-
// Escape XML special characters in content
|
|
1032
|
-
const escapeXml = (s) => s
|
|
1033
|
-
.replace(/&/g, '&')
|
|
1034
|
-
.replace(/</g, '<')
|
|
1035
|
-
.replace(/>/g, '>')
|
|
1036
|
-
.replace(/"/g, '"');
|
|
1037
|
-
const safeContent = clampInputMessage(m.content, INPUT_MESSAGE_MAX_CHARS);
|
|
1038
|
-
// Parse attachments from DB JSON if present
|
|
1039
|
-
let attachments = [];
|
|
1040
|
-
if (m.attachments_json) {
|
|
1041
|
-
try {
|
|
1042
|
-
attachments = JSON.parse(m.attachments_json);
|
|
1043
|
-
}
|
|
1044
|
-
catch { /* ignore */ }
|
|
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
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
return undefined;
|
|
1092
|
-
})();
|
|
1093
|
-
const routingStartedAt = Date.now();
|
|
1094
|
-
const routingDecision = routeRequest({
|
|
1095
|
-
prompt,
|
|
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);
|
|
1146
|
-
}
|
|
1147
|
-
if (routingDecision.estimatedMinutes) {
|
|
1148
|
-
tags.push(`eta:${routingDecision.estimatedMinutes}`);
|
|
1149
|
-
}
|
|
1150
|
-
const estimatedMs = typeof routingDecision.estimatedMinutes === 'number'
|
|
1151
|
-
? routingDecision.estimatedMinutes * 60_000
|
|
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;
|
|
1173
|
-
}
|
|
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
|
-
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
|
-
}
|
|
1218
|
-
if (routingDecision.shouldBackground) {
|
|
1219
|
-
const autoSpawned = await maybeAutoSpawn('router', routingDecision.reason);
|
|
1220
|
-
if (autoSpawned) {
|
|
1221
|
-
return true;
|
|
1222
|
-
}
|
|
1223
|
-
}
|
|
1224
|
-
let classifierMs = null;
|
|
1225
|
-
if (AUTO_SPAWN_ENABLED && AUTO_SPAWN_CLASSIFIER_ENABLED && lastMessage && routingDecision.shouldRunClassifier) {
|
|
1226
|
-
try {
|
|
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
|
-
}
|
|
1259
|
-
}
|
|
1260
|
-
catch (err) {
|
|
1261
|
-
logger.warn({ chatId: msg.chatId, err }, 'Background job classifier failed');
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
// Refresh typing indicator every 4s (Telegram expires it after ~5s)
|
|
1265
|
-
const typingInterval = setInterval(() => { void setTyping(msg.chatId); }, 4_000);
|
|
1266
|
-
const predictedStage = inferProgressStage({
|
|
1267
|
-
content: lastMessage?.content || prompt,
|
|
1268
|
-
plannerTools: plannerProbeTools,
|
|
1269
|
-
plannerSteps: plannerProbeSteps,
|
|
1270
|
-
enablePlanner: routingDecision.enablePlanner
|
|
1271
|
-
});
|
|
1272
|
-
const predictedMs = estimateForegroundMs({
|
|
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;
|
|
1303
|
-
}
|
|
1304
|
-
else {
|
|
1305
|
-
progressManager.notify(DEFAULT_PROGRESS_STAGES.ack);
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
if (!(sentPlan && predictedStage === 'planning')) {
|
|
1309
|
-
progressManager.setStage(predictedStage);
|
|
1310
|
-
}
|
|
1311
|
-
if (predictedStage === 'planning') {
|
|
1312
|
-
const followUpStage = inferProgressStage({
|
|
1313
|
-
content: lastMessage?.content || prompt,
|
|
1314
|
-
plannerTools: plannerProbeTools,
|
|
1315
|
-
plannerSteps: plannerProbeSteps,
|
|
1316
|
-
enablePlanner: false
|
|
1317
|
-
});
|
|
1318
|
-
if (followUpStage !== 'planning') {
|
|
1319
|
-
const delay = Math.min(15_000, Math.max(5_000, Math.floor(routingDecision.progress.initialMs / 2)));
|
|
1320
|
-
setTimeout(() => progressManager.setStage(followUpStage), delay);
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
const abortController = new AbortController();
|
|
1324
|
-
activeRuns.set(msg.chatId, abortController);
|
|
1325
|
-
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
|
-
const execution = await executeAgentRun({
|
|
1337
|
-
group,
|
|
1338
|
-
prompt,
|
|
1339
|
-
chatJid: msg.chatId,
|
|
1340
|
-
userId: msg.senderId,
|
|
1341
|
-
userName: msg.senderName,
|
|
1342
|
-
recallQuery: recallQuery || msg.content,
|
|
1343
|
-
recallMaxResults,
|
|
1344
|
-
recallMaxTokens,
|
|
1345
|
-
toolAllow: routingDecision.toolAllow,
|
|
1346
|
-
toolDeny: routingDecision.toolDeny,
|
|
1347
|
-
sessionId: sessions[group.folder],
|
|
1348
|
-
onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
|
|
1349
|
-
availableGroups: buildAvailableGroupsSnapshot(),
|
|
1350
|
-
modelOverride: routingDecision.modelOverride,
|
|
1351
|
-
modelMaxOutputTokens: routingDecision.maxOutputTokens,
|
|
1352
|
-
maxToolSteps: routingDecision.maxToolSteps,
|
|
1353
|
-
disablePlanner: !routingDecision.enablePlanner,
|
|
1354
|
-
disableResponseValidation: !routingDecision.enableResponseValidation,
|
|
1355
|
-
responseValidationMaxRetries: routingDecision.responseValidationMaxRetries,
|
|
1356
|
-
disableMemoryExtraction: !routingDecision.enableMemoryExtraction,
|
|
1357
|
-
attachments: containerAttachments,
|
|
1358
|
-
abortSignal: abortController.signal,
|
|
1359
|
-
timeoutMs: AUTO_SPAWN_ENABLED && AUTO_SPAWN_FOREGROUND_TIMEOUT_MS > 0
|
|
1360
|
-
? AUTO_SPAWN_FOREGROUND_TIMEOUT_MS
|
|
1361
|
-
: undefined
|
|
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');
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (usedFolders.has(group.folder)) {
|
|
132
|
+
logger.warn({ chatId, folder: group.folder }, 'Skipping registered group with duplicate folder');
|
|
133
|
+
duplicateCount += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
usedFolders.add(group.folder);
|
|
137
|
+
sanitizedGroups[chatId] = group;
|
|
2003
138
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
139
|
+
registeredGroups = sanitizedGroups;
|
|
140
|
+
if (invalidCount > 0 || duplicateCount > 0) {
|
|
141
|
+
logger.error({ invalidCount, duplicateCount }, 'Registered groups contained invalid or duplicate folders');
|
|
2006
142
|
}
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
143
|
+
logger.info({ groupCount: Object.keys(registeredGroups).length }, 'State loaded');
|
|
144
|
+
const finalSessions = getAllGroupSessions();
|
|
145
|
+
sessions = finalSessions.reduce((acc, row) => {
|
|
146
|
+
acc[row.group_folder] = row.session_id;
|
|
147
|
+
return acc;
|
|
148
|
+
}, {});
|
|
149
|
+
}
|
|
150
|
+
function registerGroup(chatId, group) {
|
|
151
|
+
if (!isSafeGroupFolder(group.folder, GROUPS_DIR)) {
|
|
152
|
+
logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with invalid folder');
|
|
153
|
+
return;
|
|
2012
154
|
}
|
|
2013
|
-
|
|
2014
|
-
|
|
155
|
+
const folderCollision = Object.values(registeredGroups).some(g => g.folder === group.folder);
|
|
156
|
+
if (folderCollision) {
|
|
157
|
+
logger.warn({ chatId, folder: group.folder }, 'Refusing to register group with duplicate folder');
|
|
158
|
+
return;
|
|
2015
159
|
}
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
160
|
+
registeredGroups[chatId] = group;
|
|
161
|
+
saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
|
|
162
|
+
const groupDir = path.join(GROUPS_DIR, group.folder);
|
|
163
|
+
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
|
164
|
+
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group registered');
|
|
165
|
+
if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
|
|
166
|
+
try {
|
|
167
|
+
warmGroupContainer(group, group.folder === MAIN_GROUP_FOLDER);
|
|
168
|
+
logger.info({ group: group.folder }, 'Warmed daemon container for new group');
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
logger.warn({ group: group.folder, err }, 'Failed to warm container for new group');
|
|
2023
172
|
}
|
|
2024
|
-
}
|
|
2025
|
-
catch (err) {
|
|
2026
|
-
logger.error({ err }, 'Failed to resume message drains after wake');
|
|
2027
173
|
}
|
|
2028
174
|
}
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
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');
|
|
175
|
+
function listRegisteredGroups() {
|
|
176
|
+
return Object.entries(registeredGroups).map(([chatId, group]) => ({
|
|
177
|
+
chat_id: chatId,
|
|
178
|
+
name: group.name,
|
|
179
|
+
folder: group.folder,
|
|
180
|
+
trigger: group.trigger,
|
|
181
|
+
added_at: group.added_at
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
function resolveGroupIdentifier(identifier) {
|
|
185
|
+
const trimmed = identifier.trim();
|
|
186
|
+
if (!trimmed)
|
|
187
|
+
return null;
|
|
188
|
+
const normalized = trimmed.toLowerCase();
|
|
189
|
+
for (const [chatId, group] of Object.entries(registeredGroups)) {
|
|
190
|
+
if (chatId === trimmed)
|
|
191
|
+
return chatId;
|
|
192
|
+
if (group.name.toLowerCase() === normalized)
|
|
193
|
+
return chatId;
|
|
194
|
+
if (group.folder.toLowerCase() === normalized)
|
|
195
|
+
return chatId;
|
|
2283
196
|
}
|
|
197
|
+
return null;
|
|
2284
198
|
}
|
|
2285
|
-
|
|
2286
|
-
const
|
|
2287
|
-
|
|
2288
|
-
|
|
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
|
-
}
|
|
199
|
+
function unregisterGroup(identifier) {
|
|
200
|
+
const chatId = resolveGroupIdentifier(identifier);
|
|
201
|
+
if (!chatId) {
|
|
202
|
+
return { ok: false, error: 'Group not found' };
|
|
2541
203
|
}
|
|
2542
|
-
|
|
2543
|
-
|
|
204
|
+
const group = registeredGroups[chatId];
|
|
205
|
+
if (!group) {
|
|
206
|
+
return { ok: false, error: 'Group not found' };
|
|
207
|
+
}
|
|
208
|
+
if (group.folder === MAIN_GROUP_FOLDER) {
|
|
209
|
+
return { ok: false, error: 'Cannot remove main group' };
|
|
2544
210
|
}
|
|
211
|
+
delete registeredGroups[chatId];
|
|
212
|
+
saveJson(path.join(DATA_DIR, 'registered_groups.json'), registeredGroups);
|
|
213
|
+
delete sessions[group.folder];
|
|
214
|
+
deleteGroupSession(group.folder);
|
|
215
|
+
pauseTasksForGroup(group.folder);
|
|
216
|
+
logger.info({ chatId, name: group.name, folder: group.folder }, 'Group removed');
|
|
217
|
+
return { ok: true, group: { ...group, chat_id: chatId } };
|
|
2545
218
|
}
|
|
219
|
+
// ───────────────────────── Admin Commands ─────────────────────────
|
|
2546
220
|
function formatGroups(groups) {
|
|
2547
221
|
if (groups.length === 0)
|
|
2548
222
|
return 'No registered groups.';
|
|
@@ -2583,11 +257,11 @@ function applyModelOverride(params) {
|
|
|
2583
257
|
saveModelRegistry(nextConfig);
|
|
2584
258
|
return { ok: true };
|
|
2585
259
|
}
|
|
2586
|
-
async function handleAdminCommand(params) {
|
|
260
|
+
async function handleAdminCommand(params, sendReply) {
|
|
2587
261
|
const parsed = parseAdminCommand(params.content, params.botUsername);
|
|
2588
262
|
if (!parsed)
|
|
2589
263
|
return false;
|
|
2590
|
-
const reply = (text) =>
|
|
264
|
+
const reply = (text) => sendReply(params.chatId, text, { threadId: params.threadId });
|
|
2591
265
|
const group = registeredGroups[params.chatId];
|
|
2592
266
|
if (!group) {
|
|
2593
267
|
await reply('This chat is not registered with DotClaw.');
|
|
@@ -2611,6 +285,10 @@ async function handleAdminCommand(params) {
|
|
|
2611
285
|
'- `/dotclaw remove-group <chat_id|name|folder>` (main only)',
|
|
2612
286
|
'- `/dotclaw set-model <model> [global|group|user] [target_id]` (main only)',
|
|
2613
287
|
'- `/dotclaw remember <fact>` (main only)',
|
|
288
|
+
'- `/dotclaw skill install <url> [--global]` (main only)',
|
|
289
|
+
'- `/dotclaw skill remove <name> [--global]` (main only)',
|
|
290
|
+
'- `/dotclaw skill list [--global]` (main only)',
|
|
291
|
+
'- `/dotclaw skill update <name> [--global]` (main only)',
|
|
2614
292
|
'- `/dotclaw style <concise|balanced|detailed>`',
|
|
2615
293
|
'- `/dotclaw tools <conservative|balanced|proactive>`',
|
|
2616
294
|
'- `/dotclaw caution <low|balanced|high>`',
|
|
@@ -2627,27 +305,28 @@ async function handleAdminCommand(params) {
|
|
|
2627
305
|
if (command === 'add-group') {
|
|
2628
306
|
if (requireMain('Adding groups'))
|
|
2629
307
|
return true;
|
|
2630
|
-
if (args.length <
|
|
308
|
+
if (args.length < 2) {
|
|
2631
309
|
await reply('Usage: /dotclaw add-group <chat_id> <name> [folder]');
|
|
2632
310
|
return true;
|
|
2633
311
|
}
|
|
2634
|
-
const
|
|
2635
|
-
|
|
2636
|
-
|
|
312
|
+
const newChatId = args[0];
|
|
313
|
+
const name = args[1];
|
|
314
|
+
const folder = args[2] || name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 50);
|
|
315
|
+
if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
|
|
316
|
+
await reply(`Invalid folder name: "${folder}"`);
|
|
2637
317
|
return true;
|
|
2638
318
|
}
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
if (!isSafeGroupFolder(folder, GROUPS_DIR)) {
|
|
2642
|
-
await reply('Invalid folder name. Use lowercase letters, numbers, and dashes only.');
|
|
319
|
+
if (registeredGroups[newChatId]) {
|
|
320
|
+
await reply(`Chat ${newChatId} is already registered.`);
|
|
2643
321
|
return true;
|
|
2644
322
|
}
|
|
2645
|
-
|
|
323
|
+
const newGroup = {
|
|
2646
324
|
name,
|
|
2647
|
-
folder
|
|
325
|
+
folder,
|
|
2648
326
|
added_at: new Date().toISOString()
|
|
2649
|
-
}
|
|
2650
|
-
|
|
327
|
+
};
|
|
328
|
+
registerGroup(newChatId, newGroup);
|
|
329
|
+
await reply(`Group "${name}" registered (folder: ${folder}).`);
|
|
2651
330
|
return true;
|
|
2652
331
|
}
|
|
2653
332
|
if (command === 'remove-group') {
|
|
@@ -2657,12 +336,12 @@ async function handleAdminCommand(params) {
|
|
|
2657
336
|
await reply('Usage: /dotclaw remove-group <chat_id|name|folder>');
|
|
2658
337
|
return true;
|
|
2659
338
|
}
|
|
2660
|
-
const result = unregisterGroup(args
|
|
339
|
+
const result = unregisterGroup(args[0]);
|
|
2661
340
|
if (!result.ok) {
|
|
2662
|
-
await reply(`Failed to remove group: ${result.error
|
|
341
|
+
await reply(`Failed to remove group: ${result.error}`);
|
|
2663
342
|
return true;
|
|
2664
343
|
}
|
|
2665
|
-
await reply(`
|
|
344
|
+
await reply(`Group "${result.group.name}" removed.`);
|
|
2666
345
|
return true;
|
|
2667
346
|
}
|
|
2668
347
|
if (command === 'set-model') {
|
|
@@ -2703,12 +382,17 @@ async function handleAdminCommand(params) {
|
|
|
2703
382
|
tags: ['manual']
|
|
2704
383
|
}];
|
|
2705
384
|
upsertMemoryItems('global', items, 'admin-command');
|
|
2706
|
-
await reply(
|
|
385
|
+
await reply(`Remembered: "${fact}"`);
|
|
2707
386
|
return true;
|
|
2708
387
|
}
|
|
2709
388
|
if (command === 'style') {
|
|
2710
|
-
const
|
|
2711
|
-
|
|
389
|
+
const level = (args[0] || '').toLowerCase();
|
|
390
|
+
const mapping = {
|
|
391
|
+
concise: 'Prefers concise, short responses.',
|
|
392
|
+
balanced: 'Prefers balanced-length responses.',
|
|
393
|
+
detailed: 'Prefers detailed, thorough responses.'
|
|
394
|
+
};
|
|
395
|
+
if (!mapping[level]) {
|
|
2712
396
|
await reply('Usage: /dotclaw style <concise|balanced|detailed>');
|
|
2713
397
|
return true;
|
|
2714
398
|
}
|
|
@@ -2717,20 +401,24 @@ async function handleAdminCommand(params) {
|
|
|
2717
401
|
subject_id: params.senderId,
|
|
2718
402
|
type: 'preference',
|
|
2719
403
|
conflict_key: 'response_style',
|
|
2720
|
-
content:
|
|
2721
|
-
importance: 0.
|
|
2722
|
-
confidence: 0.
|
|
2723
|
-
tags: [`response_style:${
|
|
2724
|
-
metadata: { response_style: style }
|
|
404
|
+
content: mapping[level],
|
|
405
|
+
importance: 0.6,
|
|
406
|
+
confidence: 0.8,
|
|
407
|
+
tags: [`response_style:${level}`]
|
|
2725
408
|
}];
|
|
2726
409
|
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2727
|
-
|
|
410
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
411
|
+
await reply(`Response style set to ${level}.`);
|
|
2728
412
|
return true;
|
|
2729
413
|
}
|
|
2730
414
|
if (command === 'tools') {
|
|
2731
415
|
const level = (args[0] || '').toLowerCase();
|
|
2732
|
-
const
|
|
2733
|
-
|
|
416
|
+
const mapping = {
|
|
417
|
+
conservative: 'Prefers conservative tool usage.',
|
|
418
|
+
balanced: 'Prefers balanced tool usage.',
|
|
419
|
+
proactive: 'Prefers proactive tool usage.'
|
|
420
|
+
};
|
|
421
|
+
if (!mapping[level]) {
|
|
2734
422
|
await reply('Usage: /dotclaw tools <conservative|balanced|proactive>');
|
|
2735
423
|
return true;
|
|
2736
424
|
}
|
|
@@ -2738,85 +426,408 @@ async function handleAdminCommand(params) {
|
|
|
2738
426
|
scope: 'user',
|
|
2739
427
|
subject_id: params.senderId,
|
|
2740
428
|
type: 'preference',
|
|
2741
|
-
conflict_key: '
|
|
2742
|
-
content:
|
|
2743
|
-
importance: 0.
|
|
429
|
+
conflict_key: 'tool_usage',
|
|
430
|
+
content: mapping[level],
|
|
431
|
+
importance: 0.6,
|
|
432
|
+
confidence: 0.8,
|
|
433
|
+
tags: [`tool_usage:${level}`]
|
|
434
|
+
}];
|
|
435
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
436
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
437
|
+
await reply(`Tool usage set to ${level}.`);
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
if (command === 'caution') {
|
|
441
|
+
const level = (args[0] || '').toLowerCase();
|
|
442
|
+
const mapping = {
|
|
443
|
+
low: 'Prefers low caution.',
|
|
444
|
+
balanced: 'Prefers balanced caution.',
|
|
445
|
+
high: 'Prefers high caution.'
|
|
446
|
+
};
|
|
447
|
+
if (!mapping[level]) {
|
|
448
|
+
await reply('Usage: /dotclaw caution <low|balanced|high>');
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
const items = [{
|
|
452
|
+
scope: 'user',
|
|
453
|
+
subject_id: params.senderId,
|
|
454
|
+
type: 'preference',
|
|
455
|
+
conflict_key: 'caution_level',
|
|
456
|
+
content: mapping[level],
|
|
457
|
+
importance: 0.6,
|
|
458
|
+
confidence: 0.8,
|
|
459
|
+
tags: [`caution_level:${level}`]
|
|
460
|
+
}];
|
|
461
|
+
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
462
|
+
invalidatePersonalizationCache(group.folder, params.senderId);
|
|
463
|
+
await reply(`Caution level set to ${level}.`);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (command === 'memory') {
|
|
467
|
+
const level = (args[0] || '').toLowerCase();
|
|
468
|
+
const threshold = level === 'strict' ? 0.7 : level === 'balanced' ? 0.55 : level === 'loose' ? 0.45 : null;
|
|
469
|
+
if (threshold === null) {
|
|
470
|
+
await reply('Usage: /dotclaw memory <strict|balanced|loose>');
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
const items = [{
|
|
474
|
+
scope: 'user',
|
|
475
|
+
subject_id: params.senderId,
|
|
476
|
+
type: 'preference',
|
|
477
|
+
conflict_key: 'memory_importance_threshold',
|
|
478
|
+
content: `Prefers memory strictness ${level}.`,
|
|
479
|
+
importance: 0.6,
|
|
2744
480
|
confidence: 0.8,
|
|
2745
|
-
tags: [`
|
|
2746
|
-
metadata: {
|
|
481
|
+
tags: [`memory_importance_threshold:${threshold}`],
|
|
482
|
+
metadata: { memory_importance_threshold: threshold, threshold }
|
|
2747
483
|
}];
|
|
2748
484
|
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2749
|
-
await reply(`
|
|
485
|
+
await reply(`Memory strictness set to ${level}.`);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
if (command === 'skill-help') {
|
|
489
|
+
await reply([
|
|
490
|
+
'Skill commands:',
|
|
491
|
+
'- `/dotclaw skill install <url> [--global]` — install from git repo or URL',
|
|
492
|
+
'- `/dotclaw skill remove <name> [--global]` — remove a skill',
|
|
493
|
+
'- `/dotclaw skill list [--global]` — list installed skills',
|
|
494
|
+
'- `/dotclaw skill update <name> [--global]` — re-pull from source'
|
|
495
|
+
].join('\n'));
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
if (command === 'skill-install') {
|
|
499
|
+
if (requireMain('Installing skills'))
|
|
500
|
+
return true;
|
|
501
|
+
if (!runtime.agent.skills.installEnabled) {
|
|
502
|
+
await reply('Skill installation is disabled in runtime config (`agent.skills.installEnabled`).');
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
const isGlobal = args.includes('--global');
|
|
506
|
+
const source = args.filter(a => a !== '--global')[0];
|
|
507
|
+
if (!source) {
|
|
508
|
+
await reply('Usage: /dotclaw skill install <url> [--global]');
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
512
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
513
|
+
await reply(`Installing skill from ${source}...`);
|
|
514
|
+
const result = await installSkill({ source, targetDir, scope });
|
|
515
|
+
if (!result.ok) {
|
|
516
|
+
await reply(`Failed to install skill: ${result.error}`);
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
await reply(`Skill "${result.name}" installed (${scope}). Available on next agent run.`);
|
|
520
|
+
}
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
if (command === 'skill-remove') {
|
|
524
|
+
if (requireMain('Removing skills'))
|
|
525
|
+
return true;
|
|
526
|
+
const isGlobal = args.includes('--global');
|
|
527
|
+
const name = args.filter(a => a !== '--global')[0];
|
|
528
|
+
if (!name) {
|
|
529
|
+
await reply('Usage: /dotclaw skill remove <name> [--global]');
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
533
|
+
const result = removeSkill({ name, targetDir });
|
|
534
|
+
if (!result.ok) {
|
|
535
|
+
await reply(`Failed to remove skill: ${result.error}`);
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
await reply(`Skill "${name}" removed.`);
|
|
539
|
+
}
|
|
2750
540
|
return true;
|
|
2751
541
|
}
|
|
2752
|
-
if (command === '
|
|
2753
|
-
|
|
2754
|
-
const bias = level === 'high' ? 0.7 : level === 'balanced' ? 0.5 : level === 'low' ? 0.35 : null;
|
|
2755
|
-
if (bias === null) {
|
|
2756
|
-
await reply('Usage: /dotclaw caution <low|balanced|high>');
|
|
542
|
+
if (command === 'skill-list') {
|
|
543
|
+
if (requireMain('Listing skills'))
|
|
2757
544
|
return true;
|
|
545
|
+
const isGlobal = args.includes('--global');
|
|
546
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
547
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
548
|
+
const skills = listSkills(targetDir, scope);
|
|
549
|
+
if (skills.length === 0) {
|
|
550
|
+
await reply(`No skills installed (${scope}).`);
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const lines = skills.map(s => `- ${s.name} (v${s.version}, source: ${s.source === 'local' ? 'local' : 'remote'})`);
|
|
554
|
+
await reply(`Installed skills (${scope}):\n${lines.join('\n')}`);
|
|
2758
555
|
}
|
|
2759
|
-
const items = [{
|
|
2760
|
-
scope: 'user',
|
|
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
556
|
return true;
|
|
2773
557
|
}
|
|
2774
|
-
if (command === '
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
558
|
+
if (command === 'skill-update') {
|
|
559
|
+
if (requireMain('Updating skills'))
|
|
560
|
+
return true;
|
|
561
|
+
const isGlobal = args.includes('--global');
|
|
562
|
+
const name = args.filter(a => a !== '--global')[0];
|
|
563
|
+
if (!name) {
|
|
564
|
+
await reply('Usage: /dotclaw skill update <name> [--global]');
|
|
2779
565
|
return true;
|
|
2780
566
|
}
|
|
2781
|
-
const
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
metadata: { memory_importance_threshold: threshold, threshold }
|
|
2791
|
-
}];
|
|
2792
|
-
upsertMemoryItems(group.folder, items, 'admin-command');
|
|
2793
|
-
await reply(`Memory strictness set to ${level}.`);
|
|
567
|
+
const scope = isGlobal ? 'global' : 'group';
|
|
568
|
+
const targetDir = path.join(GROUPS_DIR, isGlobal ? 'global' : 'main', 'skills');
|
|
569
|
+
const result = await updateSkill({ name, targetDir, scope });
|
|
570
|
+
if (!result.ok) {
|
|
571
|
+
await reply(`Failed to update skill: ${result.error}`);
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
await reply(`Skill "${name}" updated.`);
|
|
575
|
+
}
|
|
2794
576
|
return true;
|
|
2795
577
|
}
|
|
2796
578
|
await reply('Unknown command. Use `/dotclaw help` for options.');
|
|
2797
579
|
return true;
|
|
2798
580
|
}
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
581
|
+
// ───────────────────────── Heartbeat ─────────────────────────
|
|
582
|
+
async function runHeartbeatOnce() {
|
|
583
|
+
const entry = Object.entries(registeredGroups).find(([, group]) => group.folder === HEARTBEAT_GROUP_FOLDER);
|
|
584
|
+
if (!entry) {
|
|
585
|
+
logger.warn({ group: HEARTBEAT_GROUP_FOLDER }, 'Heartbeat group not registered');
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const [chatId, group] = entry;
|
|
589
|
+
const prompt = [
|
|
590
|
+
'[HEARTBEAT]',
|
|
591
|
+
'You are running automatically. Review scheduled tasks, pending reminders, and long-running work.',
|
|
592
|
+
'If you need to communicate, use mcp__dotclaw__send_message. Otherwise, take no user-visible action.'
|
|
593
|
+
].join('\n');
|
|
594
|
+
const traceBase = createTraceBase({
|
|
595
|
+
chatId,
|
|
596
|
+
groupFolder: group.folder,
|
|
597
|
+
userId: null,
|
|
598
|
+
inputText: prompt,
|
|
599
|
+
source: 'dotclaw-heartbeat'
|
|
600
|
+
});
|
|
601
|
+
const routingDecision = routePrompt(prompt);
|
|
602
|
+
let output = null;
|
|
603
|
+
let context = null;
|
|
604
|
+
let errorMessage = null;
|
|
605
|
+
try {
|
|
606
|
+
const execution = await executeAgentRun({
|
|
607
|
+
group,
|
|
608
|
+
prompt,
|
|
609
|
+
chatJid: chatId,
|
|
610
|
+
userId: null,
|
|
611
|
+
recallQuery: prompt,
|
|
612
|
+
recallMaxResults: Math.max(4, routingDecision.recallMaxResults - 2),
|
|
613
|
+
recallMaxTokens: Math.max(600, routingDecision.recallMaxTokens - 200),
|
|
614
|
+
sessionId: sessions[group.folder],
|
|
615
|
+
onSessionUpdate: (sessionId) => { sessions[group.folder] = sessionId; },
|
|
616
|
+
isScheduledTask: true,
|
|
617
|
+
availableGroups: buildAvailableGroupsSnapshot(),
|
|
618
|
+
modelOverride: routingDecision.model,
|
|
619
|
+
modelMaxOutputTokens: routingDecision.maxOutputTokens,
|
|
620
|
+
maxToolSteps: routingDecision.maxToolSteps,
|
|
621
|
+
});
|
|
622
|
+
output = execution.output;
|
|
623
|
+
context = execution.context;
|
|
624
|
+
if (output.status === 'error') {
|
|
625
|
+
errorMessage = output.error || 'Unknown error';
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
if (err instanceof AgentExecutionError) {
|
|
630
|
+
context = err.context;
|
|
631
|
+
errorMessage = err.message;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
errorMessage = err instanceof Error ? err.message : String(err);
|
|
635
|
+
}
|
|
636
|
+
logger.error({ err }, 'Heartbeat run failed');
|
|
637
|
+
}
|
|
638
|
+
if (context) {
|
|
639
|
+
recordAgentTelemetry({
|
|
640
|
+
traceBase,
|
|
641
|
+
output,
|
|
642
|
+
context,
|
|
643
|
+
toolAuditSource: 'heartbeat',
|
|
644
|
+
errorMessage: errorMessage ?? undefined,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
else if (errorMessage) {
|
|
648
|
+
writeTrace({
|
|
649
|
+
trace_id: traceBase.trace_id,
|
|
650
|
+
timestamp: traceBase.timestamp,
|
|
651
|
+
created_at: traceBase.created_at,
|
|
652
|
+
chat_id: traceBase.chat_id,
|
|
653
|
+
group_folder: traceBase.group_folder,
|
|
654
|
+
user_id: traceBase.user_id,
|
|
655
|
+
input_text: traceBase.input_text,
|
|
656
|
+
output_text: null,
|
|
657
|
+
model_id: 'unknown',
|
|
658
|
+
memory_recall: [],
|
|
659
|
+
error_code: errorMessage,
|
|
660
|
+
source: traceBase.source
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let heartbeatStopped = false;
|
|
665
|
+
function stopHeartbeatLoop() {
|
|
666
|
+
heartbeatStopped = true;
|
|
667
|
+
}
|
|
668
|
+
function startHeartbeatLoop() {
|
|
669
|
+
if (!HEARTBEAT_ENABLED)
|
|
670
|
+
return;
|
|
671
|
+
heartbeatStopped = false;
|
|
672
|
+
const loop = async () => {
|
|
673
|
+
if (heartbeatStopped)
|
|
674
|
+
return;
|
|
2802
675
|
try {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
676
|
+
await runHeartbeatOnce();
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
logger.error({ err }, 'Heartbeat run failed');
|
|
680
|
+
}
|
|
681
|
+
if (!heartbeatStopped) {
|
|
682
|
+
setTimeout(loop, HEARTBEAT_INTERVAL_MS);
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
loop();
|
|
686
|
+
}
|
|
687
|
+
// ───────────────────────── Provider Event Handlers ─────────────────────────
|
|
688
|
+
function createProviderHandlers(registry, pipeline) {
|
|
689
|
+
return {
|
|
690
|
+
onMessage(incoming) {
|
|
691
|
+
const chatId = incoming.chatId;
|
|
692
|
+
const group = registeredGroups[chatId];
|
|
693
|
+
const groupFolder = group?.folder;
|
|
694
|
+
// Log & persist
|
|
695
|
+
const chatName = incoming.rawProviderData?.chatName || incoming.senderName;
|
|
696
|
+
try {
|
|
697
|
+
upsertChat({ chatId, name: chatName, lastMessageTime: incoming.timestamp });
|
|
698
|
+
const dbAttachments = incoming.attachments?.map(providerAttachmentToMessageAttachment);
|
|
699
|
+
storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, dbAttachments);
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
logger.error({ error, chatId }, 'Failed to persist message');
|
|
703
|
+
}
|
|
704
|
+
setLastMessageTime(new Date().toISOString());
|
|
705
|
+
recordMessage(ProviderRegistry.getPrefix(chatId));
|
|
706
|
+
// Admin commands (async, fire-and-forget with early return)
|
|
707
|
+
const providerName = ProviderRegistry.getPrefix(chatId);
|
|
708
|
+
const provider = registry.get(providerName);
|
|
709
|
+
const botUsername = provider && 'botUsername' in provider ? provider.botUsername : undefined;
|
|
710
|
+
void (async () => {
|
|
711
|
+
try {
|
|
712
|
+
if (incoming.content) {
|
|
713
|
+
const sendReply = async (cId, text, opts) => {
|
|
714
|
+
await registry.getProviderForChat(cId).sendMessage(cId, text, { threadId: opts?.threadId });
|
|
715
|
+
};
|
|
716
|
+
const adminHandled = await handleAdminCommand({
|
|
717
|
+
chatId,
|
|
718
|
+
senderId: incoming.senderId,
|
|
719
|
+
senderName: incoming.senderName,
|
|
720
|
+
content: incoming.content,
|
|
721
|
+
botUsername,
|
|
722
|
+
threadId: incoming.threadId,
|
|
723
|
+
}, sendReply);
|
|
724
|
+
if (adminHandled)
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// Check trigger/mention/reply
|
|
728
|
+
const isPrivate = incoming.chatType === 'private' || incoming.chatType === 'dm';
|
|
729
|
+
const isGroup = incoming.isGroup;
|
|
730
|
+
const mentioned = provider ? provider.isBotMentioned(incoming) : false;
|
|
731
|
+
const replied = provider ? provider.isBotReplied(incoming) : false;
|
|
732
|
+
const triggerRegex = isGroup && group?.trigger ? buildTriggerRegex(group.trigger) : null;
|
|
733
|
+
const triggered = Boolean(triggerRegex && incoming.content && triggerRegex.test(incoming.content));
|
|
734
|
+
const shouldProcess = isPrivate || mentioned || replied || triggered;
|
|
735
|
+
if (!shouldProcess)
|
|
736
|
+
return;
|
|
737
|
+
// Rate limiting — qualify key by provider to avoid cross-provider collisions
|
|
738
|
+
const rateKey = `${ProviderRegistry.getPrefix(chatId)}:${incoming.senderId}`;
|
|
739
|
+
const rateCheck = checkRateLimit(rateKey);
|
|
740
|
+
if (!rateCheck.allowed) {
|
|
741
|
+
const retryAfterSec = Math.ceil((rateCheck.retryAfterMs || 60000) / 1000);
|
|
742
|
+
logger.warn({ senderId: incoming.senderId, retryAfterSec }, 'Rate limit exceeded');
|
|
743
|
+
await registry.getProviderForChat(chatId).sendMessage(chatId, `You're sending messages too quickly. Please wait ${retryAfterSec} seconds and try again.`, { threadId: incoming.threadId });
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
// Download attachments
|
|
747
|
+
const attachments = incoming.attachments?.map(providerAttachmentToMessageAttachment) ?? [];
|
|
748
|
+
if (attachments.length > 0 && groupFolder) {
|
|
749
|
+
let downloadedAny = false;
|
|
750
|
+
const failedAttachments = [];
|
|
751
|
+
for (const attachment of attachments) {
|
|
752
|
+
const fileRef = attachment.provider_file_ref;
|
|
753
|
+
if (!fileRef)
|
|
754
|
+
continue;
|
|
755
|
+
const filename = attachment.file_name || `${attachment.type}_${incoming.messageId}`;
|
|
756
|
+
const result = await provider.downloadFile(fileRef, groupFolder, filename);
|
|
757
|
+
if (result.path) {
|
|
758
|
+
attachment.local_path = result.path;
|
|
759
|
+
downloadedAny = true;
|
|
760
|
+
}
|
|
761
|
+
else if (result.error) {
|
|
762
|
+
failedAttachments.push({ name: attachment.file_name || attachment.type, error: result.error });
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (failedAttachments.length > 0) {
|
|
766
|
+
const maxMB = Math.floor(provider.capabilities.maxAttachmentBytes / (1024 * 1024));
|
|
767
|
+
const messages = failedAttachments.map(f => f.error === 'too_large'
|
|
768
|
+
? `"${f.name}" is too large (over ${maxMB} MB). Try sending a smaller version.`
|
|
769
|
+
: `I couldn't download "${f.name}". Please try sending it again.`);
|
|
770
|
+
void registry.getProviderForChat(chatId).sendMessage(chatId, messages.join('\n'), { threadId: incoming.threadId });
|
|
771
|
+
}
|
|
772
|
+
// Transcribe voice messages
|
|
773
|
+
for (const attachment of attachments) {
|
|
774
|
+
if (attachment.type === 'voice' && attachment.local_path) {
|
|
775
|
+
try {
|
|
776
|
+
const transcript = await transcribeVoice(attachment.local_path);
|
|
777
|
+
if (transcript) {
|
|
778
|
+
attachment.transcript = transcript;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
catch (err) {
|
|
782
|
+
logger.warn({ error: err instanceof Error ? err.message : String(err) }, 'Voice transcription failed');
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (downloadedAny) {
|
|
787
|
+
try {
|
|
788
|
+
storeMessage(incoming.messageId, chatId, incoming.senderId, incoming.senderName, incoming.content, incoming.timestamp, false, attachments);
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
void emitHook('message:received', {
|
|
796
|
+
chat_id: chatId,
|
|
797
|
+
message_id: incoming.messageId,
|
|
798
|
+
sender_id: incoming.senderId,
|
|
799
|
+
sender_name: incoming.senderName,
|
|
800
|
+
content: incoming.content.slice(0, 500),
|
|
801
|
+
is_group: isGroup,
|
|
802
|
+
has_attachments: attachments.length > 0,
|
|
803
|
+
has_transcript: attachments.some(a => !!a.transcript)
|
|
804
|
+
});
|
|
805
|
+
pipeline.enqueueMessage({
|
|
806
|
+
chatId,
|
|
807
|
+
messageId: incoming.messageId,
|
|
808
|
+
senderId: incoming.senderId,
|
|
809
|
+
senderName: incoming.senderName,
|
|
810
|
+
content: incoming.content,
|
|
811
|
+
timestamp: incoming.timestamp,
|
|
812
|
+
isGroup,
|
|
813
|
+
chatType: incoming.chatType,
|
|
814
|
+
threadId: incoming.threadId,
|
|
815
|
+
attachments: attachments.length > 0 ? attachments : undefined
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
logger.error({ err, chatId }, 'Error processing incoming message');
|
|
820
|
+
}
|
|
821
|
+
})();
|
|
822
|
+
},
|
|
823
|
+
onReaction(chatId, messageId, userId, emoji) {
|
|
824
|
+
if (emoji !== '👍' && emoji !== '👎')
|
|
2809
825
|
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
826
|
const traceId = getTraceIdForMessage(messageId, chatId);
|
|
2815
827
|
if (!traceId) {
|
|
2816
828
|
logger.debug({ chatId, messageId }, 'No trace found for reacted message');
|
|
2817
829
|
return;
|
|
2818
830
|
}
|
|
2819
|
-
// Record the feedback
|
|
2820
831
|
const feedbackType = emoji === '👍' ? 'positive' : 'negative';
|
|
2821
832
|
recordUserFeedback({
|
|
2822
833
|
trace_id: traceId,
|
|
@@ -2826,48 +837,19 @@ function setupTelegramHandlers() {
|
|
|
2826
837
|
user_id: userId
|
|
2827
838
|
});
|
|
2828
839
|
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));
|
|
840
|
+
},
|
|
841
|
+
onButtonClick(chatId, senderId, senderName, label, data, threadId) {
|
|
2858
842
|
const group = registeredGroups[chatId];
|
|
2859
843
|
if (!group)
|
|
2860
844
|
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}`;
|
|
845
|
+
const chatType = 'private'; // Best guess for callback queries
|
|
846
|
+
const isGroup = false;
|
|
847
|
+
const timestamp = new Date().toISOString();
|
|
848
|
+
const syntheticMessageId = String((Date.now() * 1000) + Math.floor(Math.random() * 1000));
|
|
849
|
+
const syntheticContent = `[Button clicked: "${label}"] callback_data: ${data}`;
|
|
2868
850
|
upsertChat({ chatId, lastMessageTime: timestamp });
|
|
2869
851
|
storeMessage(syntheticMessageId, chatId, senderId, senderName, syntheticContent, timestamp, false);
|
|
2870
|
-
enqueueMessage({
|
|
852
|
+
pipeline.enqueueMessage({
|
|
2871
853
|
chatId,
|
|
2872
854
|
messageId: syntheticMessageId,
|
|
2873
855
|
senderId,
|
|
@@ -2876,199 +858,63 @@ function setupTelegramHandlers() {
|
|
|
2876
858
|
timestamp,
|
|
2877
859
|
isGroup,
|
|
2878
860
|
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
|
|
2938
|
-
});
|
|
2939
|
-
}
|
|
2940
|
-
if (msg.video && typeof msg.video === 'object') {
|
|
2941
|
-
const video = msg.video;
|
|
2942
|
-
const filename = video.file_name || `video_${messageId}.mp4`;
|
|
2943
|
-
attachments.push({
|
|
2944
|
-
type: 'video',
|
|
2945
|
-
file_id: video.file_id,
|
|
2946
|
-
file_unique_id: video.file_unique_id,
|
|
2947
|
-
file_name: filename,
|
|
2948
|
-
mime_type: video.mime_type,
|
|
2949
|
-
file_size: video.file_size,
|
|
2950
|
-
duration: video.duration,
|
|
2951
|
-
width: video.width,
|
|
2952
|
-
height: video.height
|
|
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
|
|
861
|
+
threadId,
|
|
2966
862
|
});
|
|
2967
863
|
}
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
// ───────────────────────── Wake Recovery ─────────────────────────
|
|
867
|
+
let providerRegistry;
|
|
868
|
+
let messagePipeline;
|
|
869
|
+
async function onWakeRecovery(sleepDurationMs) {
|
|
870
|
+
logger.info({ sleepDurationMs }, 'Running wake recovery');
|
|
871
|
+
// 1. Suppress daemon health check kills for 60s
|
|
872
|
+
suppressHealthChecks(60_000);
|
|
873
|
+
resetUnhealthyDaemons();
|
|
874
|
+
// 2. Reconnect all providers (skip those that were never started)
|
|
875
|
+
for (const provider of providerRegistry.getAllProviders()) {
|
|
876
|
+
if (!provider.isConnected()) {
|
|
877
|
+
logger.debug({ provider: provider.name }, 'Skipping wake reconnect for inactive provider');
|
|
878
|
+
continue;
|
|
2971
879
|
}
|
|
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
880
|
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
|
-
}
|
|
881
|
+
if (provider.name === 'telegram')
|
|
882
|
+
setTelegramConnected(false);
|
|
883
|
+
await provider.stop();
|
|
884
|
+
await sleep(1_000);
|
|
885
|
+
await provider.start(createProviderHandlers(providerRegistry, messagePipeline));
|
|
886
|
+
if (provider.name === 'telegram')
|
|
887
|
+
setTelegramConnected(true);
|
|
888
|
+
logger.info({ provider: provider.name }, 'Provider reconnected after wake');
|
|
3011
889
|
}
|
|
3012
|
-
|
|
3013
|
-
|
|
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;
|
|
3020
|
-
}
|
|
3021
|
-
// Rate limiting check
|
|
3022
|
-
const rateCheck = checkRateLimit(senderId);
|
|
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;
|
|
890
|
+
catch (err) {
|
|
891
|
+
logger.error({ err, provider: provider.name }, 'Failed to reconnect provider after wake');
|
|
3028
892
|
}
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
: `I couldn't download "${f.name}". Please try sending it again.`);
|
|
3047
|
-
void sendMessage(chatId, messages.join('\n'), { messageThreadId });
|
|
3048
|
-
}
|
|
3049
|
-
if (downloadedAny) {
|
|
3050
|
-
try {
|
|
3051
|
-
storeMessage(messageId, chatId, senderId, senderName, storedContent, timestamp, false, attachments);
|
|
3052
|
-
}
|
|
3053
|
-
catch (error) {
|
|
3054
|
-
logger.error({ error, chatId }, 'Failed to persist downloaded attachment paths');
|
|
3055
|
-
}
|
|
893
|
+
}
|
|
894
|
+
// 3. Reset stalled messages
|
|
895
|
+
try {
|
|
896
|
+
const resetCount = resetStalledMessages(1_000);
|
|
897
|
+
if (resetCount > 0)
|
|
898
|
+
logger.info({ resetCount }, 'Reset stalled messages after wake');
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
logger.error({ err }, 'Failed to reset stalled messages after wake');
|
|
902
|
+
}
|
|
903
|
+
// 4. Re-drain pending message queues
|
|
904
|
+
try {
|
|
905
|
+
const pendingChats = getChatsWithPendingMessages();
|
|
906
|
+
const activeDrains = getActiveDrains();
|
|
907
|
+
for (const chatId of pendingChats) {
|
|
908
|
+
if (registeredGroups[chatId] && !activeDrains.has(chatId)) {
|
|
909
|
+
void messagePipeline.drainQueue(chatId);
|
|
3056
910
|
}
|
|
3057
911
|
}
|
|
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
|
-
});
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
logger.error({ err }, 'Failed to resume message drains after wake');
|
|
915
|
+
}
|
|
3071
916
|
}
|
|
917
|
+
// ───────────────────────── Docker Check ─────────────────────────
|
|
3072
918
|
function ensureDockerRunning() {
|
|
3073
919
|
try {
|
|
3074
920
|
execSync('docker info', { stdio: 'pipe', timeout: 10000 });
|
|
@@ -3076,7 +922,6 @@ function ensureDockerRunning() {
|
|
|
3076
922
|
}
|
|
3077
923
|
catch {
|
|
3078
924
|
logger.error('Docker daemon is not running');
|
|
3079
|
-
// Intentionally using console.error for maximum visibility on fatal exit
|
|
3080
925
|
console.error('\n╔════════════════════════════════════════════════════════════════╗');
|
|
3081
926
|
console.error('║ FATAL: Docker is not running ║');
|
|
3082
927
|
console.error('║ ║');
|
|
@@ -3089,47 +934,35 @@ function ensureDockerRunning() {
|
|
|
3089
934
|
throw new Error('Docker is required but not running');
|
|
3090
935
|
}
|
|
3091
936
|
}
|
|
937
|
+
// ───────────────────────── Main ─────────────────────────
|
|
3092
938
|
async function main() {
|
|
3093
|
-
// Global error handlers — keep the process alive on unexpected errors
|
|
3094
939
|
process.on('unhandledRejection', (reason) => {
|
|
3095
940
|
logger.error({ err: reason }, 'Unhandled promise rejection');
|
|
3096
941
|
});
|
|
3097
942
|
process.on('uncaughtException', (err) => {
|
|
3098
943
|
logger.error({ err }, 'Uncaught exception');
|
|
3099
|
-
// Only exit for fatal system errors (out of memory, etc.)
|
|
3100
944
|
if (err instanceof RangeError || err instanceof TypeError) {
|
|
3101
945
|
logger.error('Fatal uncaught exception — exiting');
|
|
3102
946
|
process.exit(1);
|
|
3103
947
|
}
|
|
3104
948
|
});
|
|
3105
|
-
// Ensure directory structure exists before anything else
|
|
3106
949
|
const { ensureDirectoryStructure } = await import('./paths.js');
|
|
3107
950
|
ensureDirectoryStructure();
|
|
3108
951
|
try {
|
|
3109
952
|
const envStat = fs.existsSync(ENV_PATH) ? fs.statSync(ENV_PATH) : null;
|
|
3110
953
|
if (!envStat || envStat.size === 0) {
|
|
3111
|
-
logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; set
|
|
954
|
+
logger.warn({ envPath: ENV_PATH }, '.env is missing or empty; run "dotclaw configure" to set up provider tokens and API keys');
|
|
3112
955
|
}
|
|
3113
956
|
}
|
|
3114
957
|
catch (err) {
|
|
3115
958
|
logger.warn({ envPath: ENV_PATH, err }, 'Failed to check .env file');
|
|
3116
959
|
}
|
|
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
960
|
ensureDockerRunning();
|
|
3124
961
|
initDatabase();
|
|
3125
962
|
const resetCount = resetStalledMessages();
|
|
3126
963
|
if (resetCount > 0) {
|
|
3127
964
|
logger.info({ resetCount }, 'Reset stalled queue messages to pending');
|
|
3128
965
|
}
|
|
3129
|
-
const resetJobCount = resetStalledBackgroundJobs();
|
|
3130
|
-
if (resetJobCount > 0) {
|
|
3131
|
-
logger.info({ count: resetJobCount }, 'Re-queued running background jobs after restart');
|
|
3132
|
-
}
|
|
3133
966
|
initMemoryStore();
|
|
3134
967
|
startEmbeddingWorker();
|
|
3135
968
|
const expiredMemories = cleanupExpiredMemories();
|
|
@@ -3142,6 +975,41 @@ async function main() {
|
|
|
3142
975
|
}
|
|
3143
976
|
startMetricsServer();
|
|
3144
977
|
loadState();
|
|
978
|
+
// ──── Provider Registry ────
|
|
979
|
+
providerRegistry = new ProviderRegistry();
|
|
980
|
+
// Register Telegram provider (optional — only when enabled + token present)
|
|
981
|
+
let telegramProvider = null;
|
|
982
|
+
if (runtime.host.telegram.enabled && process.env.TELEGRAM_BOT_TOKEN) {
|
|
983
|
+
telegramProvider = createTelegramProvider(runtime, GROUPS_DIR);
|
|
984
|
+
providerRegistry.register(telegramProvider);
|
|
985
|
+
logger.info('Telegram provider registered');
|
|
986
|
+
}
|
|
987
|
+
else if (runtime.host.telegram.enabled && !process.env.TELEGRAM_BOT_TOKEN) {
|
|
988
|
+
logger.warn('Telegram is enabled in config but TELEGRAM_BOT_TOKEN is not set — skipping');
|
|
989
|
+
}
|
|
990
|
+
// Register Discord provider (optional — only when enabled + token present)
|
|
991
|
+
let discordProvider = null;
|
|
992
|
+
if (runtime.host.discord.enabled && process.env.DISCORD_BOT_TOKEN) {
|
|
993
|
+
const { createDiscordProvider } = await import('./providers/discord/index.js');
|
|
994
|
+
discordProvider = createDiscordProvider(runtime);
|
|
995
|
+
providerRegistry.register(discordProvider);
|
|
996
|
+
logger.info('Discord provider registered');
|
|
997
|
+
}
|
|
998
|
+
else if (runtime.host.discord.enabled && !process.env.DISCORD_BOT_TOKEN) {
|
|
999
|
+
logger.warn('Discord is enabled in config but DISCORD_BOT_TOKEN is not set — skipping');
|
|
1000
|
+
}
|
|
1001
|
+
// ──── Message Pipeline ────
|
|
1002
|
+
messagePipeline = createMessagePipeline({
|
|
1003
|
+
registry: providerRegistry,
|
|
1004
|
+
registeredGroups: () => registeredGroups,
|
|
1005
|
+
sessions: () => sessions,
|
|
1006
|
+
setSession: (folder, id) => {
|
|
1007
|
+
sessions[folder] = id;
|
|
1008
|
+
setGroupSession(folder, id);
|
|
1009
|
+
},
|
|
1010
|
+
buildAvailableGroupsSnapshot,
|
|
1011
|
+
});
|
|
1012
|
+
// Warm containers
|
|
3145
1013
|
if (CONTAINER_MODE === 'daemon' && WARM_START_ENABLED) {
|
|
3146
1014
|
const groups = Object.values(registeredGroups);
|
|
3147
1015
|
for (const group of groups) {
|
|
@@ -3154,23 +1022,31 @@ async function main() {
|
|
|
3154
1022
|
}
|
|
3155
1023
|
}
|
|
3156
1024
|
}
|
|
3157
|
-
// Resume
|
|
1025
|
+
// Resume pending message queues from before restart
|
|
3158
1026
|
const pendingChats = getChatsWithPendingMessages();
|
|
3159
1027
|
for (const chatId of pendingChats) {
|
|
3160
1028
|
if (registeredGroups[chatId]) {
|
|
3161
1029
|
logger.info({ chatId }, 'Resuming message queue drain after restart');
|
|
3162
|
-
void drainQueue(chatId);
|
|
1030
|
+
void messagePipeline.drainQueue(chatId);
|
|
3163
1031
|
}
|
|
3164
1032
|
}
|
|
3165
|
-
// Set up Telegram message handlers
|
|
3166
|
-
setupTelegramHandlers();
|
|
3167
1033
|
// Start dashboard
|
|
3168
1034
|
startDashboard();
|
|
3169
|
-
// Start
|
|
1035
|
+
// ──── Start Providers ────
|
|
1036
|
+
const handlers = createProviderHandlers(providerRegistry, messagePipeline);
|
|
3170
1037
|
try {
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
1038
|
+
if (telegramProvider) {
|
|
1039
|
+
await telegramProvider.start(handlers);
|
|
1040
|
+
setTelegramConnected(true);
|
|
1041
|
+
logger.info('Telegram bot started');
|
|
1042
|
+
}
|
|
1043
|
+
if (discordProvider) {
|
|
1044
|
+
await discordProvider.start(handlers);
|
|
1045
|
+
logger.info('Discord bot started');
|
|
1046
|
+
}
|
|
1047
|
+
if (!telegramProvider && !discordProvider) {
|
|
1048
|
+
throw new Error('No messaging providers configured. Set TELEGRAM_BOT_TOKEN and/or DISCORD_BOT_TOKEN.');
|
|
1049
|
+
}
|
|
3174
1050
|
// Graceful shutdown
|
|
3175
1051
|
let shuttingDown = false;
|
|
3176
1052
|
const gracefulShutdown = async (signal) => {
|
|
@@ -3180,26 +1056,32 @@ async function main() {
|
|
|
3180
1056
|
logger.info({ signal }, 'Graceful shutdown initiated');
|
|
3181
1057
|
// 1. Stop accepting new work
|
|
3182
1058
|
setTelegramConnected(false);
|
|
3183
|
-
|
|
1059
|
+
for (const p of providerRegistry.getAllProviders()) {
|
|
1060
|
+
try {
|
|
1061
|
+
await p.stop();
|
|
1062
|
+
}
|
|
1063
|
+
catch { /* ignore */ }
|
|
1064
|
+
}
|
|
3184
1065
|
// 2. Stop all loops and watchers
|
|
3185
1066
|
clearInterval(rateLimiterInterval);
|
|
3186
1067
|
stopSchedulerLoop();
|
|
3187
|
-
await stopBackgroundJobLoop();
|
|
3188
1068
|
stopIpcWatcher();
|
|
3189
1069
|
stopMaintenanceLoop();
|
|
3190
1070
|
stopHeartbeatLoop();
|
|
3191
1071
|
stopDaemonHealthCheckLoop();
|
|
3192
1072
|
stopWakeDetector();
|
|
3193
|
-
stopEmbeddingWorker();
|
|
1073
|
+
await stopEmbeddingWorker();
|
|
3194
1074
|
// 3. Stop HTTP servers
|
|
3195
1075
|
stopMetricsServer();
|
|
3196
1076
|
stopDashboard();
|
|
3197
1077
|
// 4. Abort active agent runs so drain loops can finish quickly
|
|
1078
|
+
const activeRuns = getActiveRuns();
|
|
3198
1079
|
for (const [chatId, controller] of activeRuns.entries()) {
|
|
3199
1080
|
logger.info({ chatId }, 'Aborting active agent run for shutdown');
|
|
3200
1081
|
controller.abort();
|
|
3201
1082
|
}
|
|
3202
1083
|
// Wait for active drain loops to finish
|
|
1084
|
+
const activeDrains = getActiveDrains();
|
|
3203
1085
|
const drainDeadline = Date.now() + 30_000;
|
|
3204
1086
|
while (activeDrains.size > 0 && Date.now() < drainDeadline) {
|
|
3205
1087
|
await new Promise(r => setTimeout(r, 200));
|
|
@@ -3217,10 +1099,9 @@ async function main() {
|
|
|
3217
1099
|
};
|
|
3218
1100
|
process.once('SIGINT', () => void gracefulShutdown('SIGINT'));
|
|
3219
1101
|
process.once('SIGTERM', () => void gracefulShutdown('SIGTERM'));
|
|
3220
|
-
// Start
|
|
3221
|
-
// Wrapper that matches the scheduler's expected interface (Promise<void>)
|
|
1102
|
+
// ──── Start Services ────
|
|
3222
1103
|
const sendMessageForScheduler = async (jid, text) => {
|
|
3223
|
-
const result = await sendMessage(jid, text);
|
|
1104
|
+
const result = await providerRegistry.getProviderForChat(jid).sendMessage(jid, text);
|
|
3224
1105
|
if (!result.success) {
|
|
3225
1106
|
throw new Error(`Failed to send message to chat ${jid}`);
|
|
3226
1107
|
}
|
|
@@ -3234,24 +1115,26 @@ async function main() {
|
|
|
3234
1115
|
setGroupSession(groupFolder, sessionId);
|
|
3235
1116
|
}
|
|
3236
1117
|
});
|
|
3237
|
-
|
|
3238
|
-
|
|
1118
|
+
startIpcWatcher({
|
|
1119
|
+
registry: providerRegistry,
|
|
3239
1120
|
registeredGroups: () => registeredGroups,
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
1121
|
+
registerGroup,
|
|
1122
|
+
unregisterGroup,
|
|
1123
|
+
listRegisteredGroups,
|
|
1124
|
+
sessions: () => sessions,
|
|
1125
|
+
setSession: (folder, id) => {
|
|
1126
|
+
sessions[folder] = id;
|
|
1127
|
+
setGroupSession(folder, id);
|
|
3244
1128
|
}
|
|
3245
1129
|
});
|
|
3246
|
-
startIpcWatcher();
|
|
3247
1130
|
startMaintenanceLoop();
|
|
3248
1131
|
startHeartbeatLoop();
|
|
3249
1132
|
startDaemonHealthCheckLoop(() => registeredGroups, MAIN_GROUP_FOLDER);
|
|
3250
1133
|
startWakeDetector((ms) => { void onWakeRecovery(ms); });
|
|
3251
|
-
logger.info('DotClaw running
|
|
1134
|
+
logger.info('DotClaw running (responds to DMs and group mentions/replies)');
|
|
3252
1135
|
}
|
|
3253
1136
|
catch (error) {
|
|
3254
|
-
logger.error({ error }, 'Failed to start
|
|
1137
|
+
logger.error({ err: error instanceof Error ? error : new Error(String(error)) }, 'Failed to start DotClaw');
|
|
3255
1138
|
process.exit(1);
|
|
3256
1139
|
}
|
|
3257
1140
|
}
|