@chrysb/alphaclaw 0.1.25 → 0.2.1

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.
@@ -0,0 +1,407 @@
1
+ const fs = require("fs");
2
+ const { OPENCLAW_DIR } = require("../constants");
3
+ const { isDebugEnabled } = require("../helpers");
4
+ const topicRegistry = require("../topic-registry");
5
+ const { syncConfigForTelegram } = require("../telegram-workspace");
6
+
7
+ const getRequestBody = (req) => (req.body && typeof req.body === "object" ? req.body : {});
8
+ const getRequestQuery = (req) => (req.query && typeof req.query === "object" ? req.query : {});
9
+ const parseJsonString = (value) => {
10
+ if (typeof value !== "string" || !value.trim()) return null;
11
+ try {
12
+ return JSON.parse(value);
13
+ } catch {
14
+ return null;
15
+ }
16
+ };
17
+ const getRequestPayload = (req) => {
18
+ const body = getRequestBody(req);
19
+ const query = getRequestQuery(req);
20
+ const payloadFromQuery = parseJsonString(query.payload);
21
+ if (payloadFromQuery && typeof payloadFromQuery === "object" && !Array.isArray(payloadFromQuery)) {
22
+ return { ...payloadFromQuery, ...body };
23
+ }
24
+ return body;
25
+ };
26
+ const parseBooleanValue = (value, fallbackValue = false) => {
27
+ if (typeof value === "boolean") return value;
28
+ if (typeof value === "number") return value !== 0;
29
+ if (typeof value === "string") {
30
+ const normalized = value.trim().toLowerCase();
31
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
32
+ if (["false", "0", "no", "off", ""].includes(normalized)) return false;
33
+ }
34
+ return fallbackValue;
35
+ };
36
+ const resolveGroupId = (req) => {
37
+ const body = getRequestPayload(req);
38
+ const query = getRequestQuery(req);
39
+ const rawGroupId = body.groupId ?? body.chatId ?? query.groupId ?? query.chatId;
40
+ return rawGroupId == null ? "" : String(rawGroupId).trim();
41
+ };
42
+ const resolveAllowUserId = async ({ telegramApi, groupId, preferredUserId }) => {
43
+ const normalizedPreferred = String(preferredUserId || "").trim();
44
+ if (normalizedPreferred) return normalizedPreferred;
45
+ const admins = await telegramApi.getChatAdministrators(groupId);
46
+ const humanAdmins = admins.filter((entry) => !entry?.user?.is_bot);
47
+ if (humanAdmins.length === 0) return "";
48
+ const creator = humanAdmins.find((entry) => entry.status === "creator");
49
+ const targetAdmin = creator || humanAdmins[0];
50
+ return String(targetAdmin?.user?.id || "").trim();
51
+ };
52
+ const isMissingTopicError = (errorMessage) => {
53
+ const message = String(errorMessage || "").toLowerCase();
54
+ return [
55
+ "topic_id_invalid",
56
+ "message_thread_id_invalid",
57
+ "message_thread_not_found",
58
+ "topic_not_found",
59
+ "message thread not found",
60
+ "topic not found",
61
+ "invalid thread id",
62
+ "invalid topic id",
63
+ ].some((token) => message.includes(token));
64
+ };
65
+
66
+ const registerTelegramRoutes = ({ app, telegramApi, syncPromptFiles }) => {
67
+ // Verify bot token
68
+ app.get("/api/telegram/bot", async (req, res) => {
69
+ try {
70
+ const me = await telegramApi.getMe();
71
+ res.json({ ok: true, bot: me });
72
+ } catch (e) {
73
+ res.json({ ok: false, error: e.message });
74
+ }
75
+ });
76
+
77
+ // Verify group: checks bot membership, admin rights, topics enabled
78
+ app.post("/api/telegram/groups/verify", async (req, res) => {
79
+ const groupId = resolveGroupId(req);
80
+ if (!groupId) return res.status(400).json({ ok: false, error: "groupId is required" });
81
+
82
+ try {
83
+ const chat = await telegramApi.getChat(groupId);
84
+ const me = await telegramApi.getMe();
85
+ const member = await telegramApi.getChatMember(groupId, me.id);
86
+ const suggestedUserId = await resolveAllowUserId({
87
+ telegramApi,
88
+ groupId,
89
+ preferredUserId: "",
90
+ });
91
+
92
+ const isAdmin = member.status === "administrator" || member.status === "creator";
93
+ const isForum = !!chat.is_forum;
94
+
95
+ res.json({
96
+ ok: true,
97
+ chat: {
98
+ id: chat.id,
99
+ title: chat.title,
100
+ type: chat.type,
101
+ isForum,
102
+ },
103
+ bot: {
104
+ status: member.status,
105
+ isAdmin,
106
+ canManageTopics: isAdmin && (member.can_manage_topics !== false),
107
+ },
108
+ suggestedUserId: suggestedUserId || null,
109
+ });
110
+ } catch (e) {
111
+ res.json({ ok: false, error: e.message });
112
+ }
113
+ });
114
+
115
+ // List topics from registry
116
+ app.get("/api/telegram/groups/:groupId/topics", (req, res) => {
117
+ const group = topicRegistry.getGroup(req.params.groupId);
118
+ res.json({ ok: true, topics: group?.topics || {} });
119
+ });
120
+
121
+ // Create a topic via Telegram API + add to registry
122
+ app.post("/api/telegram/groups/:groupId/topics", async (req, res) => {
123
+ const { groupId } = req.params;
124
+ const payload = getRequestPayload(req);
125
+ const query = getRequestQuery(req);
126
+ const name = String(payload.name ?? query.name ?? "").trim();
127
+ const rawIconColor = payload.iconColor ?? query.iconColor;
128
+ const systemInstructions = String(
129
+ payload.systemInstructions ?? payload.systemPrompt ?? query.systemInstructions ?? query.systemPrompt ?? "",
130
+ ).trim();
131
+ const iconColorValue = rawIconColor == null ? null : Number.parseInt(String(rawIconColor), 10);
132
+ const iconColor = Number.isFinite(iconColorValue) ? iconColorValue : undefined;
133
+ if (!name) return res.status(400).json({ ok: false, error: "name is required" });
134
+
135
+ try {
136
+ const result = await telegramApi.createForumTopic(groupId, name, {
137
+ iconColor,
138
+ });
139
+ const threadId = result.message_thread_id;
140
+ topicRegistry.addTopic(groupId, threadId, {
141
+ name: result.name,
142
+ iconColor: result.icon_color,
143
+ ...(systemInstructions ? { systemInstructions } : {}),
144
+ });
145
+ syncConfigForTelegram({
146
+ fs,
147
+ openclawDir: OPENCLAW_DIR,
148
+ topicRegistry,
149
+ groupId,
150
+ requireMention: false,
151
+ resolvedUserId: "",
152
+ });
153
+ syncPromptFiles();
154
+ res.json({ ok: true, topic: { threadId, name: result.name, iconColor: result.icon_color } });
155
+ } catch (e) {
156
+ res.json({ ok: false, error: e.message });
157
+ }
158
+ });
159
+
160
+ // Bulk-create topics
161
+ app.post("/api/telegram/groups/:groupId/topics/bulk", async (req, res) => {
162
+ const { groupId } = req.params;
163
+ const payload = getRequestPayload(req);
164
+ const query = getRequestQuery(req);
165
+ const queryTopics = parseJsonString(query.topics);
166
+ const topics = Array.isArray(payload.topics)
167
+ ? payload.topics
168
+ : Array.isArray(queryTopics)
169
+ ? queryTopics
170
+ : [];
171
+ if (!Array.isArray(topics) || topics.length === 0) {
172
+ return res.status(400).json({ ok: false, error: "topics array is required" });
173
+ }
174
+
175
+ const results = [];
176
+ for (const t of topics) {
177
+ if (!t.name) {
178
+ results.push({ name: t.name, ok: false, error: "name is required" });
179
+ continue;
180
+ }
181
+ try {
182
+ const result = await telegramApi.createForumTopic(groupId, t.name, {
183
+ iconColor: t.iconColor || undefined,
184
+ });
185
+ const threadId = result.message_thread_id;
186
+ const systemInstructions = String(t.systemInstructions ?? t.systemPrompt ?? "").trim();
187
+ topicRegistry.addTopic(groupId, threadId, {
188
+ name: result.name,
189
+ iconColor: result.icon_color,
190
+ ...(systemInstructions ? { systemInstructions } : {}),
191
+ });
192
+ results.push({ name: result.name, threadId, ok: true });
193
+ } catch (e) {
194
+ results.push({ name: t.name, ok: false, error: e.message });
195
+ }
196
+ }
197
+ syncConfigForTelegram({
198
+ fs,
199
+ openclawDir: OPENCLAW_DIR,
200
+ topicRegistry,
201
+ groupId,
202
+ requireMention: false,
203
+ resolvedUserId: "",
204
+ });
205
+ syncPromptFiles();
206
+ res.json({ ok: true, results });
207
+ });
208
+
209
+ // Delete a topic
210
+ app.delete("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => {
211
+ const { groupId, topicId } = req.params;
212
+ try {
213
+ await telegramApi.deleteForumTopic(groupId, parseInt(topicId, 10));
214
+ topicRegistry.removeTopic(groupId, topicId);
215
+ syncConfigForTelegram({
216
+ fs,
217
+ openclawDir: OPENCLAW_DIR,
218
+ topicRegistry,
219
+ groupId,
220
+ requireMention: false,
221
+ resolvedUserId: "",
222
+ });
223
+ syncPromptFiles();
224
+ res.json({ ok: true });
225
+ } catch (e) {
226
+ if (!isMissingTopicError(e?.message)) {
227
+ return res.json({ ok: false, error: e.message });
228
+ }
229
+ topicRegistry.removeTopic(groupId, topicId);
230
+ syncConfigForTelegram({
231
+ fs,
232
+ openclawDir: OPENCLAW_DIR,
233
+ topicRegistry,
234
+ groupId,
235
+ requireMention: false,
236
+ resolvedUserId: "",
237
+ });
238
+ syncPromptFiles();
239
+ return res.json({
240
+ ok: true,
241
+ removedFromRegistryOnly: true,
242
+ warning: "Topic no longer exists in Telegram; removed stale registry entry.",
243
+ });
244
+ }
245
+ });
246
+
247
+ // Rename a topic
248
+ app.put("/api/telegram/groups/:groupId/topics/:topicId", async (req, res) => {
249
+ const { groupId, topicId } = req.params;
250
+ const payload = getRequestPayload(req);
251
+ const query = getRequestQuery(req);
252
+ const name = String(payload.name ?? query.name ?? "").trim();
253
+ const hasSystemInstructions = Object.prototype.hasOwnProperty.call(payload, "systemInstructions")
254
+ || Object.prototype.hasOwnProperty.call(payload, "systemPrompt")
255
+ || Object.prototype.hasOwnProperty.call(query, "systemInstructions")
256
+ || Object.prototype.hasOwnProperty.call(query, "systemPrompt");
257
+ const systemInstructions = String(
258
+ payload.systemInstructions ?? payload.systemPrompt ?? query.systemInstructions ?? query.systemPrompt ?? "",
259
+ ).trim();
260
+ if (!name) return res.status(400).json({ ok: false, error: "name is required" });
261
+ try {
262
+ const threadId = Number.parseInt(String(topicId), 10);
263
+ if (!Number.isFinite(threadId)) {
264
+ return res.status(400).json({ ok: false, error: "topicId must be numeric" });
265
+ }
266
+ const existingTopic = topicRegistry.getGroup(groupId)?.topics?.[String(threadId)] || {};
267
+ const existingName = String(existingTopic.name || "").trim();
268
+ const shouldRename = !existingName || existingName !== name;
269
+ if (shouldRename) {
270
+ try {
271
+ await telegramApi.editForumTopic(groupId, threadId, { name });
272
+ } catch (e) {
273
+ // Telegram returns TOPIC_NOT_MODIFIED when the name is unchanged.
274
+ if (!String(e.message || "").includes("TOPIC_NOT_MODIFIED")) {
275
+ throw e;
276
+ }
277
+ }
278
+ }
279
+ topicRegistry.updateTopic(groupId, threadId, {
280
+ ...existingTopic,
281
+ name,
282
+ ...(hasSystemInstructions ? { systemInstructions } : {}),
283
+ });
284
+ syncConfigForTelegram({
285
+ fs,
286
+ openclawDir: OPENCLAW_DIR,
287
+ topicRegistry,
288
+ groupId,
289
+ requireMention: false,
290
+ resolvedUserId: "",
291
+ });
292
+ syncPromptFiles();
293
+ return res.json({
294
+ ok: true,
295
+ topic: { threadId, name, ...(hasSystemInstructions ? { systemInstructions } : {}) },
296
+ });
297
+ } catch (e) {
298
+ return res.json({ ok: false, error: e.message });
299
+ }
300
+ });
301
+
302
+ // Configure openclaw.json for a group
303
+ app.post("/api/telegram/groups/:groupId/configure", async (req, res) => {
304
+ const { groupId } = req.params;
305
+ const payload = getRequestPayload(req);
306
+ const query = getRequestQuery(req);
307
+ const userId = payload.userId ?? query.userId ?? "";
308
+ const groupName = payload.groupName ?? query.groupName ?? "";
309
+ const requireMention = parseBooleanValue(payload.requireMention ?? query.requireMention, false);
310
+ try {
311
+ const resolvedUserId = await resolveAllowUserId({
312
+ telegramApi,
313
+ groupId,
314
+ preferredUserId: userId,
315
+ });
316
+ syncConfigForTelegram({
317
+ fs,
318
+ openclawDir: OPENCLAW_DIR,
319
+ topicRegistry,
320
+ groupId,
321
+ requireMention,
322
+ resolvedUserId,
323
+ });
324
+
325
+ // Save metadata in local topic registry only.
326
+ if (groupName) {
327
+ topicRegistry.setGroup(groupId, { name: groupName });
328
+ syncPromptFiles();
329
+ }
330
+
331
+ res.json({ ok: true, userId: resolvedUserId || null });
332
+ } catch (e) {
333
+ res.json({ ok: false, error: e.message });
334
+ }
335
+ });
336
+
337
+ // Get full topic registry
338
+ app.get("/api/telegram/topic-registry", (req, res) => {
339
+ res.json({ ok: true, registry: topicRegistry.readRegistry() });
340
+ });
341
+
342
+ // Workspace bootstrap info (lets UI jump straight to management)
343
+ app.get("/api/telegram/workspace", async (req, res) => {
344
+ try {
345
+ const debugEnabled = isDebugEnabled();
346
+ const configPath = `${OPENCLAW_DIR}/openclaw.json`;
347
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
348
+ const telegramConfig = cfg.channels?.telegram || {};
349
+ const configuredGroups = telegramConfig.groups || {};
350
+ const groupIds = Object.keys(configuredGroups);
351
+ if (groupIds.length === 0) {
352
+ return res.json({ ok: true, configured: false, debugEnabled });
353
+ }
354
+ const groupId = String(groupIds[0]);
355
+ const registryGroup = topicRegistry.getGroup(groupId);
356
+ let groupName = registryGroup?.name || groupId;
357
+ try {
358
+ const chat = await telegramApi.getChat(groupId);
359
+ if (chat?.title) groupName = chat.title;
360
+ } catch {}
361
+ return res.json({
362
+ ok: true,
363
+ configured: true,
364
+ groupId,
365
+ groupName,
366
+ topics: registryGroup?.topics || {},
367
+ debugEnabled,
368
+ concurrency: {
369
+ agentMaxConcurrent: cfg.agents?.defaults?.maxConcurrent ?? null,
370
+ subagentMaxConcurrent: cfg.agents?.defaults?.subagents?.maxConcurrent ?? null,
371
+ },
372
+ });
373
+ } catch (e) {
374
+ return res.json({ ok: false, error: e.message });
375
+ }
376
+ });
377
+
378
+ // Reset Telegram workspace onboarding state
379
+ app.post("/api/telegram/workspace/reset", (req, res) => {
380
+ try {
381
+ const configPath = `${OPENCLAW_DIR}/openclaw.json`;
382
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
383
+ const telegramGroups = Object.keys(cfg.channels?.telegram?.groups || {});
384
+ if (cfg.channels?.telegram) {
385
+ delete cfg.channels.telegram.groups;
386
+ delete cfg.channels.telegram.groupAllowFrom;
387
+ }
388
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
389
+
390
+ // Remove corresponding groups from topic registry
391
+ const registry = topicRegistry.readRegistry();
392
+ if (registry && registry.groups) {
393
+ for (const groupId of telegramGroups) {
394
+ delete registry.groups[groupId];
395
+ }
396
+ topicRegistry.writeRegistry(registry);
397
+ }
398
+
399
+ syncPromptFiles();
400
+ return res.json({ ok: true });
401
+ } catch (e) {
402
+ return res.json({ ok: false, error: e.message });
403
+ }
404
+ });
405
+ };
406
+
407
+ module.exports = { registerTelegramRoutes };
@@ -0,0 +1,65 @@
1
+ const kTelegramApiBase = "https://api.telegram.org";
2
+
3
+ const createTelegramApi = (getToken) => {
4
+ const call = async (method, params = {}) => {
5
+ const token = typeof getToken === "function" ? getToken() : getToken;
6
+ if (!token) throw new Error("TELEGRAM_BOT_TOKEN is not set");
7
+ const url = `${kTelegramApiBase}/bot${token}/${method}`;
8
+ const res = await fetch(url, {
9
+ method: "POST",
10
+ headers: { "Content-Type": "application/json" },
11
+ body: JSON.stringify(params),
12
+ });
13
+ const data = await res.json();
14
+ if (!data.ok) {
15
+ const err = new Error(data.description || `Telegram API error: ${method}`);
16
+ err.telegramErrorCode = data.error_code;
17
+ throw err;
18
+ }
19
+ return data.result;
20
+ };
21
+
22
+ const getMe = () => call("getMe");
23
+
24
+ const getChat = (chatId) => call("getChat", { chat_id: chatId });
25
+
26
+ const getChatMember = (chatId, userId) =>
27
+ call("getChatMember", { chat_id: chatId, user_id: userId });
28
+
29
+ const getChatAdministrators = (chatId) =>
30
+ call("getChatAdministrators", { chat_id: chatId });
31
+
32
+ const createForumTopic = (chatId, name, opts = {}) =>
33
+ call("createForumTopic", {
34
+ chat_id: chatId,
35
+ name,
36
+ ...(opts.iconColor != null && { icon_color: opts.iconColor }),
37
+ ...(opts.iconCustomEmojiId && { icon_custom_emoji_id: opts.iconCustomEmojiId }),
38
+ });
39
+
40
+ const deleteForumTopic = (chatId, messageThreadId) =>
41
+ call("deleteForumTopic", {
42
+ chat_id: chatId,
43
+ message_thread_id: messageThreadId,
44
+ });
45
+
46
+ const editForumTopic = (chatId, messageThreadId, opts = {}) =>
47
+ call("editForumTopic", {
48
+ chat_id: chatId,
49
+ message_thread_id: messageThreadId,
50
+ ...(opts.name && { name: opts.name }),
51
+ ...(opts.iconCustomEmojiId && { icon_custom_emoji_id: opts.iconCustomEmojiId }),
52
+ });
53
+
54
+ return {
55
+ getMe,
56
+ getChat,
57
+ getChatMember,
58
+ getChatAdministrators,
59
+ createForumTopic,
60
+ deleteForumTopic,
61
+ editForumTopic,
62
+ };
63
+ };
64
+
65
+ module.exports = { createTelegramApi };
@@ -0,0 +1,82 @@
1
+ const kTelegramTopicConcurrencyMultiplier = 3;
2
+ const kAgentConcurrencyFloor = 8;
3
+ const kSubagentConcurrencyFloor = 4;
4
+
5
+ const syncConfigForTelegram = ({
6
+ fs,
7
+ openclawDir,
8
+ topicRegistry,
9
+ groupId,
10
+ requireMention = false,
11
+ resolvedUserId = "",
12
+ }) => {
13
+ const configPath = `${openclawDir}/openclaw.json`;
14
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
15
+
16
+ // Remove legacy root keys from older setup flow.
17
+ delete cfg.sessions;
18
+ delete cfg.groups;
19
+ delete cfg.groupAllowFrom;
20
+
21
+ if (!cfg.channels) cfg.channels = {};
22
+ if (!cfg.channels.telegram) cfg.channels.telegram = {};
23
+ if (!cfg.channels.telegram.groups) cfg.channels.telegram.groups = {};
24
+ const existingGroupConfig = cfg.channels.telegram.groups[groupId] || {};
25
+ cfg.channels.telegram.groups[groupId] = {
26
+ ...existingGroupConfig,
27
+ requireMention,
28
+ };
29
+
30
+ const registryTopics = topicRegistry.getGroup(groupId)?.topics || {};
31
+ const promptTopics = {};
32
+ for (const [threadId, topic] of Object.entries(registryTopics)) {
33
+ const systemPrompt = String(topic?.systemInstructions || "").trim();
34
+ if (!systemPrompt) continue;
35
+ promptTopics[threadId] = { systemPrompt };
36
+ }
37
+ if (Object.keys(promptTopics).length > 0) {
38
+ cfg.channels.telegram.groups[groupId].topics = promptTopics;
39
+ } else {
40
+ delete cfg.channels.telegram.groups[groupId].topics;
41
+ }
42
+
43
+ cfg.channels.telegram.groupPolicy = "allowlist";
44
+ if (!Array.isArray(cfg.channels.telegram.groupAllowFrom)) {
45
+ cfg.channels.telegram.groupAllowFrom = [];
46
+ }
47
+ if (
48
+ resolvedUserId
49
+ && !cfg.channels.telegram.groupAllowFrom.includes(String(resolvedUserId))
50
+ ) {
51
+ cfg.channels.telegram.groupAllowFrom.push(String(resolvedUserId));
52
+ }
53
+
54
+ // Persist thread sessions and keep concurrency in schema-valid agent defaults.
55
+ if (!cfg.session) cfg.session = {};
56
+ if (!cfg.session.resetByType) cfg.session.resetByType = {};
57
+ cfg.session.resetByType.thread = { mode: "idle", idleMinutes: 525600 };
58
+
59
+ const totalTopics = topicRegistry.getTotalTopicCount();
60
+ const maxConcurrent = Math.max(
61
+ totalTopics * kTelegramTopicConcurrencyMultiplier,
62
+ kAgentConcurrencyFloor,
63
+ );
64
+ if (!cfg.agents) cfg.agents = {};
65
+ if (!cfg.agents.defaults) cfg.agents.defaults = {};
66
+ cfg.agents.defaults.maxConcurrent = maxConcurrent;
67
+ if (!cfg.agents.defaults.subagents) cfg.agents.defaults.subagents = {};
68
+ cfg.agents.defaults.subagents.maxConcurrent = Math.max(
69
+ maxConcurrent - 2,
70
+ kSubagentConcurrencyFloor,
71
+ );
72
+
73
+ fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
74
+
75
+ return {
76
+ totalTopics,
77
+ maxConcurrent: cfg.agents.defaults.maxConcurrent,
78
+ subagentMaxConcurrent: cfg.agents.defaults.subagents.maxConcurrent,
79
+ };
80
+ };
81
+
82
+ module.exports = { syncConfigForTelegram };
@@ -0,0 +1,152 @@
1
+ const fs = require("fs");
2
+ const { WORKSPACE_DIR } = require("./constants");
3
+
4
+ const kRegistryPath = `${WORKSPACE_DIR}/topic-registry.json`;
5
+
6
+ const readRegistry = () => {
7
+ try {
8
+ return JSON.parse(fs.readFileSync(kRegistryPath, "utf8"));
9
+ } catch {
10
+ return { groups: {} };
11
+ }
12
+ };
13
+
14
+ const writeRegistry = (registry) => {
15
+ fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
16
+ fs.writeFileSync(kRegistryPath, JSON.stringify(registry, null, 2));
17
+ };
18
+
19
+ const getGroup = (groupId) => {
20
+ const registry = readRegistry();
21
+ return registry.groups[groupId] || null;
22
+ };
23
+
24
+ const setGroup = (groupId, groupData) => {
25
+ const registry = readRegistry();
26
+ const existingGroup = registry.groups[groupId] || {
27
+ name: groupId,
28
+ topics: {},
29
+ };
30
+ registry.groups[groupId] = {
31
+ ...existingGroup,
32
+ ...groupData,
33
+ topics: existingGroup.topics || {},
34
+ };
35
+ writeRegistry(registry);
36
+ return registry;
37
+ };
38
+
39
+ const addTopic = (groupId, threadId, topicData) => {
40
+ const registry = readRegistry();
41
+ if (!registry.groups[groupId]) {
42
+ registry.groups[groupId] = { name: groupId, topics: {} };
43
+ }
44
+ if (
45
+ !registry.groups[groupId].topics ||
46
+ typeof registry.groups[groupId].topics !== "object"
47
+ ) {
48
+ registry.groups[groupId].topics = {};
49
+ }
50
+ registry.groups[groupId].topics[String(threadId)] = topicData;
51
+ writeRegistry(registry);
52
+ return registry;
53
+ };
54
+
55
+ const updateTopic = (groupId, threadId, topicData) => {
56
+ const registry = readRegistry();
57
+ if (!registry.groups[groupId]) {
58
+ registry.groups[groupId] = { name: groupId, topics: {} };
59
+ }
60
+ if (
61
+ !registry.groups[groupId].topics ||
62
+ typeof registry.groups[groupId].topics !== "object"
63
+ ) {
64
+ registry.groups[groupId].topics = {};
65
+ }
66
+ const existing = registry.groups[groupId].topics[String(threadId)] || {};
67
+ registry.groups[groupId].topics[String(threadId)] = {
68
+ ...existing,
69
+ ...topicData,
70
+ };
71
+ writeRegistry(registry);
72
+ return registry;
73
+ };
74
+
75
+ const removeTopic = (groupId, threadId) => {
76
+ const registry = readRegistry();
77
+ if (registry.groups[groupId]?.topics) {
78
+ delete registry.groups[groupId].topics[String(threadId)];
79
+ }
80
+ writeRegistry(registry);
81
+ return registry;
82
+ };
83
+
84
+ const getTotalTopicCount = () => {
85
+ const registry = readRegistry();
86
+ let count = 0;
87
+ for (const group of Object.values(registry.groups)) {
88
+ count += Object.keys(group.topics || {}).length;
89
+ }
90
+ return count;
91
+ };
92
+
93
+ // Render the topic registry as a markdown section for TOOLS.md
94
+ const renderTopicRegistryMarkdown = ({ includeSyncGuidance = false } = {}) => {
95
+ const registry = readRegistry();
96
+ const rows = [];
97
+ for (const [groupId, group] of Object.entries(registry.groups)) {
98
+ for (const [threadId, topic] of Object.entries(group.topics || {})) {
99
+ rows.push({
100
+ groupName: group.name || groupId,
101
+ groupId,
102
+ topicName: topic.name,
103
+ threadId,
104
+ });
105
+ }
106
+ }
107
+ if (rows.length === 0 && !includeSyncGuidance) return "";
108
+
109
+ const lines = [
110
+ "",
111
+ "## Topic Registry",
112
+ "",
113
+ "When sending messages to group topics, use these thread IDs:",
114
+ "",
115
+ "| Group | Topic | Thread ID |",
116
+ "| ----- | ----- | --------- |",
117
+ ];
118
+ for (const r of rows) {
119
+ lines.push(
120
+ `| ${r.groupName} (${r.groupId}) | ${r.topicName} | ${r.threadId} |`,
121
+ );
122
+ }
123
+ if (includeSyncGuidance) {
124
+ lines.push(
125
+ "",
126
+ "### Sync Rules",
127
+ "",
128
+ "When Telegram workspace is enabled, keep topic mappings in sync with real Telegram activity:",
129
+ "",
130
+ "- If a message arrives in an unregistered Telegram topic, ask the user to name it for addition to the registry.",
131
+ '- When adding a topic (new or missing) run `alphaclaw telegram topic add --thread <threadId> --name "<topicName>"` immediately, no confirmation needed.',
132
+ "- Never edit `hooks/bootstrap/TOOLS.md` directly for topic changes",
133
+ "",
134
+ );
135
+ } else {
136
+ lines.push("");
137
+ }
138
+ return lines.join("\n");
139
+ };
140
+
141
+ module.exports = {
142
+ kRegistryPath,
143
+ readRegistry,
144
+ writeRegistry,
145
+ getGroup,
146
+ setGroup,
147
+ addTopic,
148
+ updateTopic,
149
+ removeTopic,
150
+ getTotalTopicCount,
151
+ renderTopicRegistryMarkdown,
152
+ };