create-ironclaws 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { readEnvFile } from './env.js';
|
|
7
|
+
|
|
8
|
+
import { OneCLI } from '@onecli-sh/sdk';
|
|
9
|
+
import YAML from 'yaml';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
ASSISTANT_NAME,
|
|
14
|
+
DEFAULT_TRIGGER,
|
|
15
|
+
getTriggerPattern,
|
|
16
|
+
GROUPS_DIR,
|
|
17
|
+
IDLE_TIMEOUT,
|
|
18
|
+
ONECLI_URL,
|
|
19
|
+
POLL_INTERVAL,
|
|
20
|
+
SENDER_ALLOWLIST_PATH,
|
|
21
|
+
STORE_DIR,
|
|
22
|
+
TIMEZONE,
|
|
23
|
+
} from './config.js';
|
|
24
|
+
import './channels/index.js';
|
|
25
|
+
import {
|
|
26
|
+
getChannelFactory,
|
|
27
|
+
getRegisteredChannelNames,
|
|
28
|
+
} from './channels/registry.js';
|
|
29
|
+
import {
|
|
30
|
+
ContainerOutput,
|
|
31
|
+
runContainerAgent,
|
|
32
|
+
writeGroupsSnapshot,
|
|
33
|
+
writeTasksSnapshot,
|
|
34
|
+
} from './container-runner.js';
|
|
35
|
+
import {
|
|
36
|
+
cleanupOrphans,
|
|
37
|
+
cleanupStaleIptablesRules,
|
|
38
|
+
ensureContainerRuntimeRunning,
|
|
39
|
+
} from './container-runtime.js';
|
|
40
|
+
import {
|
|
41
|
+
getAllChats,
|
|
42
|
+
getAllRegisteredGroups,
|
|
43
|
+
clearSession,
|
|
44
|
+
getAllSessions,
|
|
45
|
+
getAllTasks,
|
|
46
|
+
getMessagesSince,
|
|
47
|
+
getNewMessages,
|
|
48
|
+
getRouterState,
|
|
49
|
+
initDatabase,
|
|
50
|
+
setRegisteredGroup,
|
|
51
|
+
setRouterState,
|
|
52
|
+
setSession,
|
|
53
|
+
storeChatMetadata,
|
|
54
|
+
storeMessage,
|
|
55
|
+
} from './db.js';
|
|
56
|
+
import { GroupQueue } from './group-queue.js';
|
|
57
|
+
import { resolveGroupFolderPath } from './group-folder.js';
|
|
58
|
+
import { startHttpServer } from './http-server.js';
|
|
59
|
+
import { startIpcWatcher } from './ipc.js';
|
|
60
|
+
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
|
61
|
+
import {
|
|
62
|
+
restoreRemoteControl,
|
|
63
|
+
startRemoteControl,
|
|
64
|
+
stopRemoteControl,
|
|
65
|
+
} from './remote-control.js';
|
|
66
|
+
import {
|
|
67
|
+
isSenderAllowed,
|
|
68
|
+
isTriggerAllowed,
|
|
69
|
+
loadSenderAllowlist,
|
|
70
|
+
shouldDropMessage,
|
|
71
|
+
} from './sender-allowlist.js';
|
|
72
|
+
import { startSchedulerLoop } from './task-scheduler.js';
|
|
73
|
+
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
|
74
|
+
import { logger } from './logger.js';
|
|
75
|
+
|
|
76
|
+
// Re-export for backwards compatibility during refactor
|
|
77
|
+
export { escapeXml, formatMessages } from './router.js';
|
|
78
|
+
|
|
79
|
+
let lastTimestamp = '';
|
|
80
|
+
let sessions: Record<string, string> = {};
|
|
81
|
+
let registeredGroups: Record<string, RegisteredGroup> = {};
|
|
82
|
+
let lastAgentTimestamp: Record<string, string> = {};
|
|
83
|
+
let messageLoopRunning = false;
|
|
84
|
+
|
|
85
|
+
const channels: Channel[] = [];
|
|
86
|
+
const queue = new GroupQueue();
|
|
87
|
+
|
|
88
|
+
const onecli = new OneCLI({ url: ONECLI_URL });
|
|
89
|
+
|
|
90
|
+
function ensureOneCLIAgent(jid: string, group: RegisteredGroup): void {
|
|
91
|
+
if (group.isMain) return;
|
|
92
|
+
const identifier = group.folder.toLowerCase().replace(/_/g, '-');
|
|
93
|
+
onecli.ensureAgent({ name: group.name, identifier }).then(
|
|
94
|
+
(res) => {
|
|
95
|
+
logger.info(
|
|
96
|
+
{ jid, identifier, created: res.created },
|
|
97
|
+
'OneCLI agent ensured',
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
(err) => {
|
|
101
|
+
logger.debug(
|
|
102
|
+
{ jid, identifier, err: String(err) },
|
|
103
|
+
'OneCLI agent ensure skipped',
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function loadState(): void {
|
|
110
|
+
lastTimestamp = getRouterState('last_timestamp') || '';
|
|
111
|
+
const agentTs = getRouterState('last_agent_timestamp');
|
|
112
|
+
try {
|
|
113
|
+
lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
|
|
114
|
+
} catch {
|
|
115
|
+
logger.warn('Corrupted last_agent_timestamp in DB, resetting');
|
|
116
|
+
lastAgentTimestamp = {};
|
|
117
|
+
}
|
|
118
|
+
sessions = getAllSessions();
|
|
119
|
+
registeredGroups = getAllRegisteredGroups();
|
|
120
|
+
logger.info(
|
|
121
|
+
{ groupCount: Object.keys(registeredGroups).length },
|
|
122
|
+
'State loaded',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function saveState(): void {
|
|
127
|
+
setRouterState('last_timestamp', lastTimestamp);
|
|
128
|
+
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function registerGroup(jid: string, group: RegisteredGroup): void {
|
|
132
|
+
let groupDir: string;
|
|
133
|
+
try {
|
|
134
|
+
groupDir = resolveGroupFolderPath(group.folder);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
logger.warn(
|
|
137
|
+
{ jid, folder: group.folder, err },
|
|
138
|
+
'Rejecting group registration with invalid folder',
|
|
139
|
+
);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
registeredGroups[jid] = group;
|
|
144
|
+
setRegisteredGroup(jid, group);
|
|
145
|
+
|
|
146
|
+
// Create group folder
|
|
147
|
+
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
|
|
148
|
+
|
|
149
|
+
// Copy CLAUDE.md template into the new group folder so agents have
|
|
150
|
+
// identity and instructions from the first run. (Fixes #1391)
|
|
151
|
+
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
|
|
152
|
+
if (!fs.existsSync(groupMdFile)) {
|
|
153
|
+
const templateFile = path.join(
|
|
154
|
+
GROUPS_DIR,
|
|
155
|
+
group.isMain ? 'main' : 'global',
|
|
156
|
+
'CLAUDE.md',
|
|
157
|
+
);
|
|
158
|
+
if (fs.existsSync(templateFile)) {
|
|
159
|
+
let content = fs.readFileSync(templateFile, 'utf-8');
|
|
160
|
+
if (ASSISTANT_NAME !== 'Andy') {
|
|
161
|
+
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
|
|
162
|
+
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
|
|
163
|
+
}
|
|
164
|
+
fs.writeFileSync(groupMdFile, content);
|
|
165
|
+
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
|
|
170
|
+
ensureOneCLIAgent(jid, group);
|
|
171
|
+
|
|
172
|
+
logger.info(
|
|
173
|
+
{ jid, name: group.name, folder: group.folder },
|
|
174
|
+
'Group registered',
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get available groups list for the agent.
|
|
180
|
+
* Returns groups ordered by most recent activity.
|
|
181
|
+
*/
|
|
182
|
+
export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
|
|
183
|
+
const chats = getAllChats();
|
|
184
|
+
const registeredJids = new Set(Object.keys(registeredGroups));
|
|
185
|
+
|
|
186
|
+
return chats
|
|
187
|
+
.filter((c) => c.jid !== '__group_sync__' && c.is_group)
|
|
188
|
+
.map((c) => ({
|
|
189
|
+
jid: c.jid,
|
|
190
|
+
name: c.name,
|
|
191
|
+
lastActivity: c.last_message_time,
|
|
192
|
+
isRegistered: registeredJids.has(c.jid),
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** @internal - exported for testing */
|
|
197
|
+
export function _setRegisteredGroups(
|
|
198
|
+
groups: Record<string, RegisteredGroup>,
|
|
199
|
+
): void {
|
|
200
|
+
registeredGroups = groups;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Process all pending messages for a group.
|
|
205
|
+
* Called by the GroupQueue when it's this group's turn.
|
|
206
|
+
*/
|
|
207
|
+
async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|
208
|
+
const group = registeredGroups[chatJid];
|
|
209
|
+
if (!group) return true;
|
|
210
|
+
|
|
211
|
+
const channel = findChannel(channels, chatJid);
|
|
212
|
+
if (!channel) {
|
|
213
|
+
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const isMainGroup = group.isMain === true;
|
|
218
|
+
|
|
219
|
+
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
|
220
|
+
const missedMessages = getMessagesSince(
|
|
221
|
+
chatJid,
|
|
222
|
+
sinceTimestamp,
|
|
223
|
+
ASSISTANT_NAME,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (missedMessages.length === 0) return true;
|
|
227
|
+
|
|
228
|
+
// For non-main groups, check if trigger is required and present
|
|
229
|
+
if (!isMainGroup && group.requiresTrigger !== false) {
|
|
230
|
+
const triggerPattern = getTriggerPattern(group.trigger);
|
|
231
|
+
const allowlistCfg = loadSenderAllowlist();
|
|
232
|
+
const hasTrigger = missedMessages.some(
|
|
233
|
+
(m) =>
|
|
234
|
+
triggerPattern.test(m.content.trim()) &&
|
|
235
|
+
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
236
|
+
);
|
|
237
|
+
if (!hasTrigger) return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check STATUS.md — if the agent is disabled, send an offline reply and stop.
|
|
241
|
+
// isMain groups (Global Claw) are never subject to this check.
|
|
242
|
+
if (!isMainGroup) {
|
|
243
|
+
const statusFile = path.join(resolveGroupFolderPath(group.folder), 'STATUS.md');
|
|
244
|
+
if (fs.existsSync(statusFile)) {
|
|
245
|
+
const statusContent = fs.readFileSync(statusFile, 'utf-8').trim();
|
|
246
|
+
if (statusContent.toLowerCase().startsWith('disabled')) {
|
|
247
|
+
const reasonMatch = statusContent.match(/^Reason:\s*(.+)$/im);
|
|
248
|
+
const reason = reasonMatch ? reasonMatch[1].trim() : 'no reason given';
|
|
249
|
+
await channel.sendMessage(
|
|
250
|
+
chatJid,
|
|
251
|
+
`I'm currently offline. Reason: ${reason}`,
|
|
252
|
+
);
|
|
253
|
+
logger.info({ chatJid, group: group.name, reason }, 'Agent disabled — offline reply sent');
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let prompt = formatMessages(missedMessages, TIMEZONE);
|
|
260
|
+
|
|
261
|
+
// Inject channel member list so agents can @mention users with <@UXXXX> format.
|
|
262
|
+
// Only includes members of this specific channel, not the whole workspace.
|
|
263
|
+
if (channel.getChannelMembers) {
|
|
264
|
+
const members = await channel.getChannelMembers(chatJid);
|
|
265
|
+
if (members.length > 0) {
|
|
266
|
+
const memberList = members
|
|
267
|
+
.map((m) => ` - ${m.name}: <@${m.id}>`)
|
|
268
|
+
.join('\n');
|
|
269
|
+
prompt += `\n\n<channel-members>\nTo @mention someone, use their mention tag exactly as shown:\n${memberList}\nOnly mention someone when they are directly relevant to the conversation. Do not mention people unnecessarily.\n</channel-members>`;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Extract sender email from the last human message — used by agents that
|
|
274
|
+
// need to map the Slack user to an external system (e.g. Jira reporter).
|
|
275
|
+
const lastHumanMessage = [...missedMessages].reverse().find((m) => !m.is_from_me);
|
|
276
|
+
const senderEmail = lastHumanMessage?.sender_email;
|
|
277
|
+
|
|
278
|
+
// Advance cursor so the piping path in startMessageLoop won't re-fetch
|
|
279
|
+
// these messages. Save the old cursor so we can roll back on error.
|
|
280
|
+
const previousCursor = lastAgentTimestamp[chatJid] || '';
|
|
281
|
+
lastAgentTimestamp[chatJid] =
|
|
282
|
+
missedMessages[missedMessages.length - 1].timestamp;
|
|
283
|
+
saveState();
|
|
284
|
+
|
|
285
|
+
logger.info(
|
|
286
|
+
{ group: group.name, messageCount: missedMessages.length },
|
|
287
|
+
'Processing messages',
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// Track idle timer for closing stdin when agent is idle
|
|
291
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
292
|
+
|
|
293
|
+
const resetIdleTimer = () => {
|
|
294
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
295
|
+
idleTimer = setTimeout(() => {
|
|
296
|
+
logger.debug(
|
|
297
|
+
{ group: group.name },
|
|
298
|
+
'Idle timeout, closing container stdin',
|
|
299
|
+
);
|
|
300
|
+
queue.closeStdin(chatJid);
|
|
301
|
+
}, IDLE_TIMEOUT);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
await channel.setTyping?.(chatJid, true);
|
|
305
|
+
let hadError = false;
|
|
306
|
+
let outputSentToUser = false;
|
|
307
|
+
|
|
308
|
+
const output = await runAgent(group, prompt, chatJid, async (result) => {
|
|
309
|
+
// Streaming output callback — called for each agent result
|
|
310
|
+
if (result.result) {
|
|
311
|
+
const raw =
|
|
312
|
+
typeof result.result === 'string'
|
|
313
|
+
? result.result
|
|
314
|
+
: JSON.stringify(result.result);
|
|
315
|
+
// Strip <internal>...</internal> blocks — agent uses these for internal reasoning
|
|
316
|
+
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
|
317
|
+
logger.info({ group: group.name }, `Agent output: ${raw.length} chars`);
|
|
318
|
+
if (text) {
|
|
319
|
+
await channel.sendMessage(chatJid, text);
|
|
320
|
+
outputSentToUser = true;
|
|
321
|
+
}
|
|
322
|
+
// Only reset idle timer on actual results, not session-update markers (result: null)
|
|
323
|
+
resetIdleTimer();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (result.status === 'success') {
|
|
327
|
+
queue.notifyIdle(chatJid);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (result.status === 'error') {
|
|
331
|
+
hadError = true;
|
|
332
|
+
}
|
|
333
|
+
}, senderEmail);
|
|
334
|
+
|
|
335
|
+
await channel.setTyping?.(chatJid, false);
|
|
336
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
337
|
+
|
|
338
|
+
if (output === 'error' || hadError) {
|
|
339
|
+
// If we already sent output to the user, don't roll back the cursor —
|
|
340
|
+
// the user got their response and re-processing would send duplicates.
|
|
341
|
+
if (outputSentToUser) {
|
|
342
|
+
logger.warn(
|
|
343
|
+
{ group: group.name },
|
|
344
|
+
'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
|
|
345
|
+
);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
// Roll back cursor so retries can re-process these messages
|
|
349
|
+
lastAgentTimestamp[chatJid] = previousCursor;
|
|
350
|
+
saveState();
|
|
351
|
+
logger.warn(
|
|
352
|
+
{ group: group.name },
|
|
353
|
+
'Agent error, rolled back message cursor for retry',
|
|
354
|
+
);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function runAgent(
|
|
362
|
+
group: RegisteredGroup,
|
|
363
|
+
prompt: string,
|
|
364
|
+
chatJid: string,
|
|
365
|
+
onOutput?: (output: ContainerOutput) => Promise<void>,
|
|
366
|
+
senderEmail?: string,
|
|
367
|
+
): Promise<'success' | 'error'> {
|
|
368
|
+
const isMain = group.isMain === true;
|
|
369
|
+
const sessionId = sessions[group.folder];
|
|
370
|
+
|
|
371
|
+
// Update tasks snapshot for container to read (filtered by group)
|
|
372
|
+
const tasks = getAllTasks();
|
|
373
|
+
writeTasksSnapshot(
|
|
374
|
+
group.folder,
|
|
375
|
+
isMain,
|
|
376
|
+
tasks.map((t) => ({
|
|
377
|
+
id: t.id,
|
|
378
|
+
groupFolder: t.group_folder,
|
|
379
|
+
prompt: t.prompt,
|
|
380
|
+
script: t.script || undefined,
|
|
381
|
+
schedule_type: t.schedule_type,
|
|
382
|
+
schedule_value: t.schedule_value,
|
|
383
|
+
status: t.status,
|
|
384
|
+
next_run: t.next_run,
|
|
385
|
+
})),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Update available groups snapshot (main group only can see all groups)
|
|
389
|
+
const availableGroups = getAvailableGroups();
|
|
390
|
+
writeGroupsSnapshot(
|
|
391
|
+
group.folder,
|
|
392
|
+
isMain,
|
|
393
|
+
availableGroups,
|
|
394
|
+
new Set(Object.keys(registeredGroups)),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Wrap onOutput to track session ID from streamed results.
|
|
398
|
+
// Never persist newSessionId from an error result — the agent-runner echoes
|
|
399
|
+
// the incoming session ID in error payloads, which would re-save a broken session.
|
|
400
|
+
const wrappedOnOutput = onOutput
|
|
401
|
+
? async (output: ContainerOutput) => {
|
|
402
|
+
if (output.newSessionId && output.status !== 'error') {
|
|
403
|
+
sessions[group.folder] = output.newSessionId;
|
|
404
|
+
setSession(group.folder, output.newSessionId);
|
|
405
|
+
}
|
|
406
|
+
await onOutput(output);
|
|
407
|
+
}
|
|
408
|
+
: undefined;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const output = await runContainerAgent(
|
|
412
|
+
group,
|
|
413
|
+
{
|
|
414
|
+
prompt,
|
|
415
|
+
sessionId,
|
|
416
|
+
groupFolder: group.folder,
|
|
417
|
+
chatJid,
|
|
418
|
+
isMain,
|
|
419
|
+
assistantName: ASSISTANT_NAME,
|
|
420
|
+
senderEmail,
|
|
421
|
+
},
|
|
422
|
+
(proc, containerName) =>
|
|
423
|
+
queue.registerProcess(chatJid, proc, containerName, group.folder),
|
|
424
|
+
wrappedOnOutput,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
if (output.status === 'error') {
|
|
428
|
+
// If the session file no longer exists, clear it so the next run starts fresh
|
|
429
|
+
// rather than re-saving the broken session ID and failing on every retry.
|
|
430
|
+
if (output.error?.includes('No conversation found with session ID')) {
|
|
431
|
+
delete sessions[group.folder];
|
|
432
|
+
clearSession(group.folder);
|
|
433
|
+
logger.warn(
|
|
434
|
+
{ group: group.name, sessionId },
|
|
435
|
+
'Session not found — cleared for fresh start',
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
logger.error(
|
|
439
|
+
{ group: group.name, error: output.error },
|
|
440
|
+
'Container agent error',
|
|
441
|
+
);
|
|
442
|
+
return 'error';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (output.newSessionId) {
|
|
446
|
+
sessions[group.folder] = output.newSessionId;
|
|
447
|
+
setSession(group.folder, output.newSessionId);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return 'success';
|
|
451
|
+
} catch (err) {
|
|
452
|
+
logger.error({ group: group.name, err }, 'Agent error');
|
|
453
|
+
return 'error';
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function startMessageLoop(): Promise<void> {
|
|
458
|
+
if (messageLoopRunning) {
|
|
459
|
+
logger.debug('Message loop already running, skipping duplicate start');
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
messageLoopRunning = true;
|
|
463
|
+
|
|
464
|
+
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
|
|
465
|
+
|
|
466
|
+
while (true) {
|
|
467
|
+
try {
|
|
468
|
+
const jids = Object.keys(registeredGroups);
|
|
469
|
+
const { messages, newTimestamp } = getNewMessages(
|
|
470
|
+
jids,
|
|
471
|
+
lastTimestamp,
|
|
472
|
+
ASSISTANT_NAME,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
if (messages.length > 0) {
|
|
476
|
+
logger.info({ count: messages.length }, 'New messages');
|
|
477
|
+
|
|
478
|
+
// Advance the "seen" cursor for all messages immediately
|
|
479
|
+
lastTimestamp = newTimestamp;
|
|
480
|
+
saveState();
|
|
481
|
+
|
|
482
|
+
// Deduplicate by group
|
|
483
|
+
const messagesByGroup = new Map<string, NewMessage[]>();
|
|
484
|
+
for (const msg of messages) {
|
|
485
|
+
const existing = messagesByGroup.get(msg.chat_jid);
|
|
486
|
+
if (existing) {
|
|
487
|
+
existing.push(msg);
|
|
488
|
+
} else {
|
|
489
|
+
messagesByGroup.set(msg.chat_jid, [msg]);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
for (const [chatJid, groupMessages] of messagesByGroup) {
|
|
494
|
+
const group = registeredGroups[chatJid];
|
|
495
|
+
if (!group) continue;
|
|
496
|
+
|
|
497
|
+
const channel = findChannel(channels, chatJid);
|
|
498
|
+
if (!channel) {
|
|
499
|
+
logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const isMainGroup = group.isMain === true;
|
|
504
|
+
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
|
|
505
|
+
|
|
506
|
+
// For non-main groups, only act on trigger messages.
|
|
507
|
+
// Non-trigger messages accumulate in DB and get pulled as
|
|
508
|
+
// context when a trigger eventually arrives.
|
|
509
|
+
if (needsTrigger) {
|
|
510
|
+
const triggerPattern = getTriggerPattern(group.trigger);
|
|
511
|
+
const allowlistCfg = loadSenderAllowlist();
|
|
512
|
+
const hasTrigger = groupMessages.some(
|
|
513
|
+
(m) =>
|
|
514
|
+
triggerPattern.test(m.content.trim()) &&
|
|
515
|
+
(m.is_from_me ||
|
|
516
|
+
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
|
|
517
|
+
);
|
|
518
|
+
if (!hasTrigger) continue;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Pull all messages since lastAgentTimestamp so non-trigger
|
|
522
|
+
// context that accumulated between triggers is included.
|
|
523
|
+
const allPending = getMessagesSince(
|
|
524
|
+
chatJid,
|
|
525
|
+
lastAgentTimestamp[chatJid] || '',
|
|
526
|
+
ASSISTANT_NAME,
|
|
527
|
+
);
|
|
528
|
+
const messagesToSend =
|
|
529
|
+
allPending.length > 0 ? allPending : groupMessages;
|
|
530
|
+
const formatted = formatMessages(messagesToSend, TIMEZONE);
|
|
531
|
+
|
|
532
|
+
if (queue.sendMessage(chatJid, formatted)) {
|
|
533
|
+
logger.debug(
|
|
534
|
+
{ chatJid, count: messagesToSend.length },
|
|
535
|
+
'Piped messages to active container',
|
|
536
|
+
);
|
|
537
|
+
lastAgentTimestamp[chatJid] =
|
|
538
|
+
messagesToSend[messagesToSend.length - 1].timestamp;
|
|
539
|
+
saveState();
|
|
540
|
+
// Show typing indicator while the container processes the piped message
|
|
541
|
+
channel
|
|
542
|
+
.setTyping?.(chatJid, true)
|
|
543
|
+
?.catch((err) =>
|
|
544
|
+
logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
|
|
545
|
+
);
|
|
546
|
+
} else {
|
|
547
|
+
// No active container — enqueue for a new one
|
|
548
|
+
queue.enqueueMessageCheck(chatJid);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch (err) {
|
|
553
|
+
logger.error({ err }, 'Error in message loop');
|
|
554
|
+
}
|
|
555
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Startup recovery: check for unprocessed messages in registered groups.
|
|
561
|
+
* Handles crash between advancing lastTimestamp and processing messages.
|
|
562
|
+
*/
|
|
563
|
+
function recoverPendingMessages(): void {
|
|
564
|
+
for (const [chatJid, group] of Object.entries(registeredGroups)) {
|
|
565
|
+
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
|
|
566
|
+
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
|
|
567
|
+
if (pending.length > 0) {
|
|
568
|
+
logger.info(
|
|
569
|
+
{ group: group.name, pendingCount: pending.length },
|
|
570
|
+
'Recovery: found unprocessed messages',
|
|
571
|
+
);
|
|
572
|
+
queue.enqueueMessageCheck(chatJid);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function ensureContainerSystemRunning(): void {
|
|
578
|
+
ensureContainerRuntimeRunning();
|
|
579
|
+
cleanupOrphans();
|
|
580
|
+
cleanupStaleIptablesRules();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Auto-register agents from agents.yaml.
|
|
585
|
+
*
|
|
586
|
+
* On every startup, reads agents.yaml and registers any agent whose channel
|
|
587
|
+
* ID env var is set but isn't in the DB yet. This replaces the need to run
|
|
588
|
+
* setup-agents.sh manually on fresh installs.
|
|
589
|
+
*/
|
|
590
|
+
/**
|
|
591
|
+
* Ensure every agent from agents.yaml has an entry in the sender allowlist.
|
|
592
|
+
*
|
|
593
|
+
* The allowlist file only needs entries for OVERRIDES — e.g. restricting
|
|
594
|
+
* global-claw to a specific sender. Every other registered agent is
|
|
595
|
+
* automatically allowed with the default open policy so agents.yaml + .env
|
|
596
|
+
* is the single file to maintain.
|
|
597
|
+
*/
|
|
598
|
+
function ensureAllowlistEntries(agentDefs: Array<{ channel_env: string; requires_trigger?: boolean }>): void {
|
|
599
|
+
const allowlistPath = SENDER_ALLOWLIST_PATH;
|
|
600
|
+
|
|
601
|
+
// Read all channel env vars directly from .env (they are not in process.env —
|
|
602
|
+
// readEnvFile intentionally does not pollute the process environment)
|
|
603
|
+
const channelEnvKeys = agentDefs.map(d => d.channel_env);
|
|
604
|
+
const channelValues = readEnvFile(channelEnvKeys);
|
|
605
|
+
|
|
606
|
+
// Read current allowlist (or start with an empty chats map)
|
|
607
|
+
let config: { default?: unknown; chats: Record<string, unknown>; logDenied?: boolean };
|
|
608
|
+
try {
|
|
609
|
+
config = JSON.parse(fs.readFileSync(allowlistPath, 'utf-8'));
|
|
610
|
+
if (!config.chats || typeof config.chats !== 'object') config.chats = {};
|
|
611
|
+
} catch {
|
|
612
|
+
config = { chats: {} };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
let changed = false;
|
|
616
|
+
for (const def of agentDefs) {
|
|
617
|
+
const channelId = channelValues[def.channel_env]?.trim();
|
|
618
|
+
if (!channelId) continue;
|
|
619
|
+
const jid = channelId.includes(':') ? channelId : `slack:${channelId}`;
|
|
620
|
+
|
|
621
|
+
if (!config.chats[jid]) {
|
|
622
|
+
config.chats[jid] = { allow: '*', mode: 'trigger' };
|
|
623
|
+
changed = true;
|
|
624
|
+
logger.debug({ jid }, 'Added allowlist entry for agent');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (changed) {
|
|
629
|
+
try {
|
|
630
|
+
fs.mkdirSync(path.dirname(allowlistPath), { recursive: true });
|
|
631
|
+
fs.writeFileSync(allowlistPath, JSON.stringify(config, null, 2));
|
|
632
|
+
logger.info({ path: allowlistPath }, 'Sender allowlist updated with new agent channels');
|
|
633
|
+
} catch (err) {
|
|
634
|
+
logger.warn({ err, path: allowlistPath }, 'Could not update sender allowlist — add channel entries manually');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function autoRegisterAgentsFromYaml(): void {
|
|
640
|
+
const agentsFile = path.join(process.cwd(), 'agents.yaml');
|
|
641
|
+
if (!fs.existsSync(agentsFile)) return;
|
|
642
|
+
|
|
643
|
+
let agentDefs: Array<{
|
|
644
|
+
folder: string;
|
|
645
|
+
name: string;
|
|
646
|
+
trigger?: string;
|
|
647
|
+
channel_env: string;
|
|
648
|
+
requires_trigger?: boolean;
|
|
649
|
+
is_main?: boolean;
|
|
650
|
+
onecli_secrets?: string[];
|
|
651
|
+
onecli_id?: string;
|
|
652
|
+
}>;
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
agentDefs = YAML.parse(fs.readFileSync(agentsFile, 'utf-8')).agents || [];
|
|
656
|
+
} catch (err) {
|
|
657
|
+
logger.warn({ err }, 'autoRegisterAgents: could not parse agents.yaml');
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Read channel IDs from .env — they are intentionally NOT in process.env
|
|
662
|
+
const channelEnvKeys = agentDefs.map(d => d.channel_env);
|
|
663
|
+
const channelValues = readEnvFile(channelEnvKeys);
|
|
664
|
+
|
|
665
|
+
let added = 0;
|
|
666
|
+
for (const def of agentDefs) {
|
|
667
|
+
const channelId = channelValues[def.channel_env]?.trim();
|
|
668
|
+
if (!channelId) continue; // env var not set — skip
|
|
669
|
+
|
|
670
|
+
// Build JID — Slack channels start with C or D, HTTP channels are explicit
|
|
671
|
+
const jid = channelId.includes(':') ? channelId : `slack:${channelId}`;
|
|
672
|
+
if (registeredGroups[jid]) continue; // already registered
|
|
673
|
+
|
|
674
|
+
const group: RegisteredGroup = {
|
|
675
|
+
name: def.name,
|
|
676
|
+
folder: def.folder,
|
|
677
|
+
trigger: def.trigger || DEFAULT_TRIGGER,
|
|
678
|
+
added_at: new Date().toISOString(),
|
|
679
|
+
requiresTrigger: def.requires_trigger !== false,
|
|
680
|
+
isMain: def.is_main === true,
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
setRegisteredGroup(jid, group);
|
|
684
|
+
registeredGroups[jid] = group;
|
|
685
|
+
|
|
686
|
+
// Ensure the group folder and MEMORY.md exist
|
|
687
|
+
const groupDir = path.join(GROUPS_DIR, def.folder);
|
|
688
|
+
fs.mkdirSync(groupDir, { recursive: true });
|
|
689
|
+
const memoryFile = path.join(groupDir, 'MEMORY.md');
|
|
690
|
+
if (!fs.existsSync(memoryFile)) {
|
|
691
|
+
fs.writeFileSync(memoryFile, `# ${def.name} — Memory\n\nAgent initialized.\n`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
logger.info({ folder: def.folder, jid }, 'Auto-registered agent from agents.yaml');
|
|
695
|
+
added++;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Ensure every registered agent has an entry in the sender allowlist.
|
|
699
|
+
// The allowlist file only needs to exist for OVERRIDES (e.g. restricting
|
|
700
|
+
// global-claw to specific senders). All other agents are auto-added with
|
|
701
|
+
// the default open policy so agents.yaml + .env is the only file to maintain.
|
|
702
|
+
ensureAllowlistEntries(agentDefs);
|
|
703
|
+
|
|
704
|
+
if (added > 0) {
|
|
705
|
+
logger.info({ count: added }, 'Auto-registered agents from agents.yaml');
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Run setup-onecli-secrets.sh to ensure all credentials are registered.
|
|
711
|
+
*
|
|
712
|
+
* Uses a hash of the relevant .env values so the script only re-runs when
|
|
713
|
+
* credentials actually change — not on every startup.
|
|
714
|
+
*/
|
|
715
|
+
function ensureOneCLISecrets(): void {
|
|
716
|
+
const secretsScript = path.join(process.cwd(), 'scripts', 'setup-onecli-secrets.sh');
|
|
717
|
+
if (!fs.existsSync(secretsScript)) return;
|
|
718
|
+
|
|
719
|
+
// Hash the credential-bearing env vars that the secrets script uses
|
|
720
|
+
const credentialKeys = [
|
|
721
|
+
'ELASTIC_API_KEY', 'JIRA_EMAIL', 'JIRA_API_TOKEN',
|
|
722
|
+
'SLACK_BOT_TOKEN', 'INTERCOM_ACCESS_TOKEN', 'ARDOQ_API_KEY',
|
|
723
|
+
'ANTHROPIC_AUTH_TOKEN', 'CONFLUENCE_USERNAME', 'CONFLUENCE_PASSWORD',
|
|
724
|
+
];
|
|
725
|
+
const hashInput = credentialKeys.map(k => `${k}=${process.env[k] || ''}`).join('\n');
|
|
726
|
+
const currentHash = crypto.createHash('sha256').update(hashInput).digest('hex');
|
|
727
|
+
|
|
728
|
+
const hashFile = path.join(STORE_DIR, 'onecli-secrets.hash');
|
|
729
|
+
const storedHash = fs.existsSync(hashFile) ? fs.readFileSync(hashFile, 'utf-8').trim() : '';
|
|
730
|
+
|
|
731
|
+
if (currentHash === storedHash) {
|
|
732
|
+
logger.debug('OneCLI secrets unchanged — skipping setup');
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
logger.info('OneCLI credentials changed (or first run) — running secrets setup...');
|
|
737
|
+
try {
|
|
738
|
+
execSync(`bash "${secretsScript}"`, {
|
|
739
|
+
stdio: 'pipe',
|
|
740
|
+
timeout: 30000,
|
|
741
|
+
env: { ...process.env },
|
|
742
|
+
});
|
|
743
|
+
fs.writeFileSync(hashFile, currentHash);
|
|
744
|
+
logger.info('OneCLI secrets setup complete');
|
|
745
|
+
} catch (err) {
|
|
746
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
747
|
+
logger.warn({ error }, 'OneCLI secrets setup failed — agents may lack credentials');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function main(): Promise<void> {
|
|
752
|
+
ensureContainerSystemRunning();
|
|
753
|
+
initDatabase();
|
|
754
|
+
logger.info('Database initialized');
|
|
755
|
+
loadState();
|
|
756
|
+
|
|
757
|
+
// Auto-register any agents from agents.yaml whose channel env vars are set.
|
|
758
|
+
autoRegisterAgentsFromYaml();
|
|
759
|
+
|
|
760
|
+
// Ensure OneCLI secrets are up to date (re-runs only when credentials change).
|
|
761
|
+
ensureOneCLISecrets();
|
|
762
|
+
|
|
763
|
+
// Ensure OneCLI agents exist for all registered groups.
|
|
764
|
+
// Recovers from missed creates (e.g. OneCLI was down at registration time).
|
|
765
|
+
for (const [jid, group] of Object.entries(registeredGroups)) {
|
|
766
|
+
ensureOneCLIAgent(jid, group);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
restoreRemoteControl();
|
|
770
|
+
|
|
771
|
+
// Graceful shutdown handlers
|
|
772
|
+
const shutdown = async (signal: string) => {
|
|
773
|
+
logger.info({ signal }, 'Shutdown signal received');
|
|
774
|
+
await queue.shutdown(10000);
|
|
775
|
+
for (const ch of channels) await ch.disconnect();
|
|
776
|
+
process.exit(0);
|
|
777
|
+
};
|
|
778
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
779
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
780
|
+
|
|
781
|
+
// Handle /remote-control and /remote-control-end commands
|
|
782
|
+
async function handleRemoteControl(
|
|
783
|
+
command: string,
|
|
784
|
+
chatJid: string,
|
|
785
|
+
msg: NewMessage,
|
|
786
|
+
): Promise<void> {
|
|
787
|
+
const group = registeredGroups[chatJid];
|
|
788
|
+
if (!group?.isMain) {
|
|
789
|
+
logger.warn(
|
|
790
|
+
{ chatJid, sender: msg.sender },
|
|
791
|
+
'Remote control rejected: not main group',
|
|
792
|
+
);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const channel = findChannel(channels, chatJid);
|
|
797
|
+
if (!channel) return;
|
|
798
|
+
|
|
799
|
+
if (command === '/remote-control') {
|
|
800
|
+
const result = await startRemoteControl(
|
|
801
|
+
msg.sender,
|
|
802
|
+
chatJid,
|
|
803
|
+
process.cwd(),
|
|
804
|
+
);
|
|
805
|
+
if (result.ok) {
|
|
806
|
+
await channel.sendMessage(chatJid, result.url);
|
|
807
|
+
} else {
|
|
808
|
+
await channel.sendMessage(
|
|
809
|
+
chatJid,
|
|
810
|
+
`Remote Control failed: ${result.error}`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
const result = stopRemoteControl();
|
|
815
|
+
if (result.ok) {
|
|
816
|
+
await channel.sendMessage(chatJid, 'Remote Control session ended.');
|
|
817
|
+
} else {
|
|
818
|
+
await channel.sendMessage(chatJid, result.error);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Channel callbacks (shared by all channels)
|
|
824
|
+
const channelOpts = {
|
|
825
|
+
onMessage: (chatJid: string, msg: NewMessage) => {
|
|
826
|
+
// Remote control commands — intercept before storage
|
|
827
|
+
const trimmed = msg.content.trim();
|
|
828
|
+
if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
|
|
829
|
+
handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
|
|
830
|
+
logger.error({ err, chatJid }, 'Remote control command error'),
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Sender allowlist drop mode: discard messages from denied senders before storing
|
|
836
|
+
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
|
|
837
|
+
const cfg = loadSenderAllowlist();
|
|
838
|
+
if (
|
|
839
|
+
shouldDropMessage(chatJid, cfg) &&
|
|
840
|
+
!isSenderAllowed(chatJid, msg.sender, cfg)
|
|
841
|
+
) {
|
|
842
|
+
if (cfg.logDenied) {
|
|
843
|
+
logger.debug(
|
|
844
|
+
{ chatJid, sender: msg.sender },
|
|
845
|
+
'sender-allowlist: dropping message (drop mode)',
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
storeMessage(msg);
|
|
852
|
+
},
|
|
853
|
+
onChatMetadata: (
|
|
854
|
+
chatJid: string,
|
|
855
|
+
timestamp: string,
|
|
856
|
+
name?: string,
|
|
857
|
+
channel?: string,
|
|
858
|
+
isGroup?: boolean,
|
|
859
|
+
) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
|
|
860
|
+
registeredGroups: () => registeredGroups,
|
|
861
|
+
};
|
|
862
|
+
|
|
863
|
+
// Create and connect all registered channels.
|
|
864
|
+
// Each channel self-registers via the barrel import above.
|
|
865
|
+
// Factories return null when credentials are missing, so unconfigured channels are skipped.
|
|
866
|
+
for (const channelName of getRegisteredChannelNames()) {
|
|
867
|
+
const factory = getChannelFactory(channelName)!;
|
|
868
|
+
const channel = factory(channelOpts);
|
|
869
|
+
if (!channel) {
|
|
870
|
+
logger.warn(
|
|
871
|
+
{ channel: channelName },
|
|
872
|
+
'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
|
|
873
|
+
);
|
|
874
|
+
continue;
|
|
875
|
+
}
|
|
876
|
+
channels.push(channel);
|
|
877
|
+
await channel.connect();
|
|
878
|
+
}
|
|
879
|
+
if (channels.length === 0) {
|
|
880
|
+
logger.fatal('No channels connected');
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Start HTTP server for questionnaire API
|
|
885
|
+
startHttpServer({
|
|
886
|
+
registeredGroups: () => registeredGroups,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Start subsystems (independently of connection handler)
|
|
890
|
+
startSchedulerLoop({
|
|
891
|
+
registeredGroups: () => registeredGroups,
|
|
892
|
+
getSessions: () => sessions,
|
|
893
|
+
queue,
|
|
894
|
+
onProcess: (groupJid, proc, containerName, groupFolder) =>
|
|
895
|
+
queue.registerProcess(groupJid, proc, containerName, groupFolder),
|
|
896
|
+
sendMessage: async (jid, rawText) => {
|
|
897
|
+
const channel = findChannel(channels, jid);
|
|
898
|
+
if (!channel) {
|
|
899
|
+
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const text = formatOutbound(rawText);
|
|
903
|
+
if (text) await channel.sendMessage(jid, text);
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
startIpcWatcher({
|
|
907
|
+
sendMessage: (jid, text) => {
|
|
908
|
+
const channel = findChannel(channels, jid);
|
|
909
|
+
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
|
910
|
+
return channel.sendMessage(jid, text);
|
|
911
|
+
},
|
|
912
|
+
registeredGroups: () => registeredGroups,
|
|
913
|
+
registerGroup,
|
|
914
|
+
syncGroups: async (force: boolean) => {
|
|
915
|
+
await Promise.all(
|
|
916
|
+
channels
|
|
917
|
+
.filter((ch) => ch.syncGroups)
|
|
918
|
+
.map((ch) => ch.syncGroups!(force)),
|
|
919
|
+
);
|
|
920
|
+
},
|
|
921
|
+
getAvailableGroups,
|
|
922
|
+
writeGroupsSnapshot: (gf, im, ag, rj) =>
|
|
923
|
+
writeGroupsSnapshot(gf, im, ag, rj),
|
|
924
|
+
onTasksChanged: () => {
|
|
925
|
+
const tasks = getAllTasks();
|
|
926
|
+
const taskRows = tasks.map((t) => ({
|
|
927
|
+
id: t.id,
|
|
928
|
+
groupFolder: t.group_folder,
|
|
929
|
+
prompt: t.prompt,
|
|
930
|
+
script: t.script || undefined,
|
|
931
|
+
schedule_type: t.schedule_type,
|
|
932
|
+
schedule_value: t.schedule_value,
|
|
933
|
+
status: t.status,
|
|
934
|
+
next_run: t.next_run,
|
|
935
|
+
}));
|
|
936
|
+
for (const group of Object.values(registeredGroups)) {
|
|
937
|
+
writeTasksSnapshot(group.folder, group.isMain === true, taskRows);
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
});
|
|
941
|
+
queue.setProcessMessagesFn(processGroupMessages);
|
|
942
|
+
recoverPendingMessages();
|
|
943
|
+
startMessageLoop().catch((err) => {
|
|
944
|
+
logger.fatal({ err }, 'Message loop crashed unexpectedly');
|
|
945
|
+
process.exit(1);
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Guard: only run when executed directly, not when imported by tests
|
|
950
|
+
const isDirectRun =
|
|
951
|
+
process.argv[1] &&
|
|
952
|
+
new URL(import.meta.url).pathname ===
|
|
953
|
+
new URL(`file://${process.argv[1]}`).pathname;
|
|
954
|
+
|
|
955
|
+
if (isDirectRun) {
|
|
956
|
+
main().catch((err) => {
|
|
957
|
+
logger.error({ err }, 'Failed to start NanoClaw');
|
|
958
|
+
process.exit(1);
|
|
959
|
+
});
|
|
960
|
+
}
|