openclaw-telegram-manager 1.2.0 → 1.3.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.
Files changed (48) hide show
  1. package/README.md +5 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +34 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/include-generator.d.ts +1 -1
  6. package/dist/lib/include-generator.d.ts.map +1 -1
  7. package/dist/lib/include-generator.js +33 -2
  8. package/dist/lib/include-generator.js.map +1 -1
  9. package/dist/plugin.js +11106 -0
  10. package/dist/setup.js +29 -97
  11. package/dist/setup.js.map +1 -1
  12. package/package.json +4 -4
  13. package/src/commands/archive.ts +0 -89
  14. package/src/commands/doctor-all.ts +0 -243
  15. package/src/commands/doctor.ts +0 -100
  16. package/src/commands/help.ts +0 -11
  17. package/src/commands/init.ts +0 -376
  18. package/src/commands/list.ts +0 -28
  19. package/src/commands/rename.ts +0 -140
  20. package/src/commands/snooze.ts +0 -69
  21. package/src/commands/status.ts +0 -59
  22. package/src/commands/sync.ts +0 -46
  23. package/src/commands/upgrade.ts +0 -64
  24. package/src/index.ts +0 -54
  25. package/src/lib/audit.ts +0 -44
  26. package/src/lib/auth.ts +0 -96
  27. package/src/lib/capsule.ts +0 -206
  28. package/src/lib/config-restart.ts +0 -167
  29. package/src/lib/doctor-checks.ts +0 -639
  30. package/src/lib/include-generator.ts +0 -174
  31. package/src/lib/registry.ts +0 -197
  32. package/src/lib/security.ts +0 -174
  33. package/src/lib/telegram.ts +0 -311
  34. package/src/lib/types.ts +0 -172
  35. package/src/setup.ts +0 -558
  36. package/src/templates/base/COMMANDS.md +0 -3
  37. package/src/templates/base/CRON.md +0 -3
  38. package/src/templates/base/LINKS.md +0 -3
  39. package/src/templates/base/NOTES.md +0 -3
  40. package/src/templates/base/README.md +0 -3
  41. package/src/templates/base/TODO.md +0 -11
  42. package/src/templates/overlays/coding/ARCHITECTURE.md +0 -3
  43. package/src/templates/overlays/coding/DEPLOY.md +0 -3
  44. package/src/templates/overlays/marketing/CAMPAIGNS.md +0 -3
  45. package/src/templates/overlays/marketing/METRICS.md +0 -3
  46. package/src/templates/overlays/research/FINDINGS.md +0 -3
  47. package/src/templates/overlays/research/SOURCES.md +0 -3
  48. package/src/tool.ts +0 -282
@@ -1,100 +0,0 @@
1
- import * as fs from 'node:fs';
2
- import * as path from 'node:path';
3
- import { readRegistry, withRegistry } from '../lib/registry.js';
4
- import { checkAuthorization } from '../lib/auth.js';
5
- import { topicKey } from '../lib/types.js';
6
- import { jailCheck, rejectSymlink } from '../lib/security.js';
7
- import { buildDoctorReport, buildDoctorButtons } from '../lib/telegram.js';
8
- import { runAllChecksForTopic } from '../lib/doctor-checks.js';
9
- import { includePath } from '../lib/include-generator.js';
10
- import type { CommandContext, CommandResult } from './help.js';
11
-
12
- export async function handleDoctor(ctx: CommandContext): Promise<CommandResult> {
13
- const { workspaceDir, configDir, userId, groupId, threadId } = ctx;
14
-
15
- if (!userId || !groupId || !threadId) {
16
- return { text: 'Missing context: userId, groupId, or threadId not available.' };
17
- }
18
-
19
- const registry = readRegistry(workspaceDir);
20
-
21
- // Auth check (user tier)
22
- const auth = checkAuthorization(userId, 'doctor', registry);
23
- if (!auth.authorized) {
24
- return { text: auth.message ?? 'Not authorized.' };
25
- }
26
-
27
- const key = topicKey(groupId, threadId);
28
- const entry = registry.topics[key];
29
-
30
- if (!entry) {
31
- return { text: 'This topic is not registered. Run /topic init first.' };
32
- }
33
-
34
- const projectsBase = path.join(workspaceDir, 'projects');
35
-
36
- // Path safety
37
- if (!jailCheck(projectsBase, entry.slug)) {
38
- return { text: 'Path safety check failed.' };
39
- }
40
-
41
- const capsuleDir = path.join(projectsBase, entry.slug);
42
- if (rejectSymlink(capsuleDir)) {
43
- return { text: 'Capsule directory is a symlink. Aborting for security.' };
44
- }
45
-
46
- // Read include content for config checks (optional)
47
- let includeContent: string | undefined;
48
- const incPath = includePath(configDir);
49
- try {
50
- if (fs.existsSync(incPath)) {
51
- includeContent = fs.readFileSync(incPath, 'utf-8');
52
- }
53
- } catch {
54
- // Not critical
55
- }
56
-
57
- // Run all checks
58
- const cronJobsPath = path.join(configDir, 'cron', 'jobs.json');
59
- const results = runAllChecksForTopic(
60
- entry,
61
- projectsBase,
62
- includeContent,
63
- registry,
64
- cronJobsPath,
65
- );
66
-
67
- // Build report
68
- const reportText = buildDoctorReport(entry.slug, results);
69
-
70
- // Build inline keyboard with HMAC-signed callbacks
71
- const keyboard = buildDoctorButtons(
72
- entry.slug,
73
- groupId,
74
- threadId,
75
- registry.callbackSecret,
76
- );
77
-
78
- // Append text command equivalents
79
- const textCommands = [
80
- '',
81
- 'Or use text commands:',
82
- '/topic snooze 7d',
83
- '/topic snooze 30d',
84
- '/topic archive',
85
- ].join('\n');
86
-
87
- // Update lastDoctorReportAt
88
- await withRegistry(workspaceDir, (data) => {
89
- const topic = data.topics[key];
90
- if (topic) {
91
- topic.lastDoctorReportAt = new Date().toISOString();
92
- }
93
- });
94
-
95
- return {
96
- text: reportText + textCommands,
97
- parseMode: 'HTML',
98
- inlineKeyboard: keyboard,
99
- };
100
- }
@@ -1,11 +0,0 @@
1
- import { buildHelpCard } from '../lib/telegram.js';
2
- import type { CommandContext, CommandResult } from '../lib/types.js';
3
-
4
- export type { CommandContext, CommandResult };
5
-
6
- export function handleHelp(_ctx: CommandContext): CommandResult {
7
- return {
8
- text: buildHelpCard(),
9
- parseMode: 'HTML',
10
- };
11
- }
@@ -1,376 +0,0 @@
1
- import * as fs from 'node:fs';
2
- import * as path from 'node:path';
3
- import { readRegistry, withRegistry } from '../lib/registry.js';
4
- import { checkAuthorization } from '../lib/auth.js';
5
- import {
6
- topicKey,
7
- CAPSULE_VERSION,
8
- } from '../lib/types.js';
9
- import type { TopicType, TopicEntry } from '../lib/types.js';
10
- import {
11
- validateSlug,
12
- sanitizeSlug,
13
- jailCheck,
14
- rejectSymlink,
15
- htmlEscape,
16
- validateGroupId,
17
- validateThreadId,
18
- buildCallbackData,
19
- } from '../lib/security.js';
20
- import { scaffoldCapsule } from '../lib/capsule.js';
21
- import { buildTopicCard, buildInitSlugButtons, buildInitTypeButtons } from '../lib/telegram.js';
22
- import { generateInclude } from '../lib/include-generator.js';
23
- import { triggerRestart, getConfigWrites } from '../lib/config-restart.js';
24
- import { appendAudit, buildAuditEntry } from '../lib/audit.js';
25
- import type { CommandContext, CommandResult } from './help.js';
26
-
27
- const VALID_TYPES: ReadonlySet<string> = new Set<TopicType>(['coding', 'research', 'marketing', 'custom']);
28
-
29
- export async function handleInit(ctx: CommandContext, args: string): Promise<CommandResult> {
30
- const { workspaceDir, configDir, userId, groupId, threadId, rpc, logger, messageContext } = ctx;
31
-
32
- if (!userId || !groupId || !threadId) {
33
- return { text: 'Missing context: groupId, threadId, or userId not available. Run this command inside a Telegram forum topic.' };
34
- }
35
-
36
- // Validate IDs
37
- if (!validateGroupId(groupId)) {
38
- return { text: 'Invalid groupId format.' };
39
- }
40
-
41
- if (!validateThreadId(threadId)) {
42
- return { text: 'Invalid threadId format.' };
43
- }
44
-
45
- const registry = readRegistry(workspaceDir);
46
-
47
- // Auth check (user tier, with first-user bootstrap)
48
- const auth = checkAuthorization(userId, 'init', registry);
49
- if (!auth.authorized) {
50
- return { text: auth.message ?? 'Not authorized.' };
51
- }
52
-
53
- // Max topics check
54
- const topicCount = Object.keys(registry.topics).length;
55
- if (topicCount >= registry.maxTopics) {
56
- return {
57
- text: `Maximum number of topics (${registry.maxTopics}) reached. Archive or remove existing topics first.`,
58
- };
59
- }
60
-
61
- // Check if topic already registered
62
- const key = topicKey(groupId, threadId);
63
- if (registry.topics[key]) {
64
- return {
65
- text: `This topic is already registered as <code>${htmlEscape(registry.topics[key]!.slug)}</code>.`,
66
- parseMode: 'HTML',
67
- };
68
- }
69
-
70
- // Parse args: [slug] [type]
71
- const parts = args.trim().split(/\s+/);
72
- let slugArg = parts[0] ?? '';
73
- let typeArg = parts[1] ?? '';
74
-
75
- // Derive slug from topic title or args
76
- const topicTitle = (messageContext?.['topicTitle'] as string) ?? '';
77
- let slug: string;
78
-
79
- if (slugArg) {
80
- slug = slugArg;
81
- } else if (topicTitle) {
82
- slug = sanitizeSlug(topicTitle);
83
- } else {
84
- slug = `topic-${threadId}`;
85
- }
86
-
87
- // Ensure slug starts with a letter (sanitizeSlug may produce something starting with a digit)
88
- if (slug && !/^[a-z]/.test(slug)) {
89
- slug = 't-' + slug;
90
- }
91
-
92
- // Validate slug
93
- if (!validateSlug(slug)) {
94
- return {
95
- text: `Invalid slug "${htmlEscape(slug)}". Must start with a letter, lowercase alphanumeric + hyphens, max 50 chars.`,
96
- };
97
- }
98
-
99
- // Determine type
100
- let topicType: TopicType = 'coding';
101
- if (typeArg && VALID_TYPES.has(typeArg.toLowerCase())) {
102
- topicType = typeArg.toLowerCase() as TopicType;
103
- }
104
-
105
- const projectsBase = path.join(workspaceDir, 'projects');
106
-
107
- // Path jail check
108
- if (!jailCheck(projectsBase, slug)) {
109
- return { text: 'Path safety check failed. Slug may escape the projects directory.' };
110
- }
111
-
112
- // Symlink check on projects base
113
- if (rejectSymlink(projectsBase)) {
114
- return { text: 'Projects base is a symlink. Aborting for security.' };
115
- }
116
-
117
- // Collision detection (registry)
118
- const slugInUse = Object.values(registry.topics).some((t) => t.slug === slug);
119
- const diskExists = fs.existsSync(path.join(projectsBase, slug));
120
-
121
- let finalSlug = slug;
122
- if (slugInUse || diskExists) {
123
- // Append last 4 chars of groupId for uniqueness
124
- const suffix = groupId.replace(/^-/, '').slice(-4);
125
- finalSlug = `${slug}-${suffix}`.slice(0, 50);
126
-
127
- if (!validateSlug(finalSlug)) {
128
- return { text: `Could not generate a unique slug. Please provide one: /topic init &lt;slug&gt; [type]` };
129
- }
130
-
131
- // Check the fallback slug too
132
- const fallbackInUse = Object.values(registry.topics).some((t) => t.slug === finalSlug);
133
- if (fallbackInUse) {
134
- return {
135
- text: `Both <code>${htmlEscape(slug)}</code> and <code>${htmlEscape(finalSlug)}</code> are taken. Please provide a unique slug: /topic init &lt;slug&gt; [type]`,
136
- parseMode: 'HTML',
137
- };
138
- }
139
-
140
- // Re-check jail for fallback slug
141
- if (!jailCheck(projectsBase, finalSlug)) {
142
- return { text: 'Path safety check failed for fallback slug.' };
143
- }
144
- }
145
-
146
- // Symlink check on target path
147
- const targetPath = path.join(projectsBase, finalSlug);
148
- if (rejectSymlink(targetPath)) {
149
- return { text: 'Target path is a symlink. Aborting for security.' };
150
- }
151
-
152
- // Scaffold capsule and write registry entry atomically under lock
153
- const isFirstUser = registry.topicManagerAdmins.length === 0;
154
-
155
- try {
156
- await withRegistry(workspaceDir, (data) => {
157
- scaffoldCapsule(projectsBase, finalSlug, topicType);
158
-
159
- const newEntry: TopicEntry = {
160
- groupId,
161
- threadId,
162
- slug: finalSlug,
163
- type: topicType,
164
- status: 'active',
165
- capsuleVersion: CAPSULE_VERSION,
166
- lastMessageAt: new Date().toISOString(),
167
- lastDoctorReportAt: null,
168
- lastDoctorRunAt: null,
169
- snoozeUntil: null,
170
- ignoreChecks: [],
171
- consecutiveSilentDoctors: 0,
172
- lastPostError: null,
173
- extras: {},
174
- };
175
-
176
- data.topics[key] = newEntry;
177
-
178
- // First-user bootstrap: add as admin
179
- if (isFirstUser) {
180
- data.topicManagerAdmins.push(userId);
181
- }
182
- });
183
- } catch (err) {
184
- const msg = err instanceof Error ? err.message : String(err);
185
- return { text: `Failed to initialize topic: ${htmlEscape(msg)}` };
186
- }
187
-
188
- // If configWrites enabled: regenerate include + trigger restart
189
- let restartMsg = '';
190
- const configWritesEnabled = await getConfigWrites(ctx.rpc);
191
- if (configWritesEnabled) {
192
- try {
193
- const updatedRegistry = readRegistry(workspaceDir);
194
- generateInclude(workspaceDir, updatedRegistry, configDir);
195
- const result = await triggerRestart(rpc, logger);
196
- if (!result.success && result.fallbackMessage) {
197
- restartMsg = '\n' + result.fallbackMessage;
198
- }
199
- } catch (err) {
200
- const msg = err instanceof Error ? err.message : String(err);
201
- restartMsg = `\nWarning: include generation failed: ${htmlEscape(msg)}`;
202
- }
203
- }
204
-
205
- // Audit log
206
- appendAudit(
207
- workspaceDir,
208
- buildAuditEntry(userId, 'init', finalSlug, `Initialized topic type=${topicType} group=${groupId} thread=${threadId}`),
209
- );
210
-
211
- // Build topic card
212
- const topicCard = buildTopicCard(finalSlug, topicType, CAPSULE_VERSION);
213
-
214
- let adminNote = '';
215
- if (isFirstUser) {
216
- adminNote = '\n\nYou are the first user and have been added as a telegram-manager admin.';
217
- }
218
-
219
- let slugNote = '';
220
- if (finalSlug !== slug) {
221
- slugNote = `\n\nNote: slug <code>${htmlEscape(slug)}</code> was taken, using <code>${htmlEscape(finalSlug)}</code> instead.`;
222
- }
223
-
224
- return {
225
- text: `${topicCard}${slugNote}${adminNote}${restartMsg}`,
226
- parseMode: 'HTML',
227
- pin: true,
228
- };
229
- }
230
-
231
- // ── Callback data byte limit ──────────────────────────────────────────
232
-
233
- const CALLBACK_BYTE_LIMIT = 64;
234
-
235
- /**
236
- * Check whether a callback payload for the given slug fits within
237
- * Telegram's 64-byte callback_data limit.
238
- */
239
- function fitsCallbackLimit(action: string, slug: string, groupId: string, threadId: string, secret: string): boolean {
240
- const data = buildCallbackData(action, slug, groupId, threadId, secret);
241
- return Buffer.byteLength(data, 'utf-8') <= CALLBACK_BYTE_LIMIT;
242
- }
243
-
244
- // ── Interactive init flow ─────────────────────────────────────────────
245
-
246
- const INIT_TYPE_MAP: Record<string, TopicType> = {
247
- ic: 'coding',
248
- ir: 'research',
249
- im: 'marketing',
250
- ix: 'custom',
251
- };
252
-
253
- /**
254
- * Entry point for `/topic init`. If args are provided, delegates straight
255
- * to `handleInit`. Otherwise starts the interactive two-step flow.
256
- */
257
- export async function handleInitInteractive(ctx: CommandContext, args: string): Promise<CommandResult> {
258
- if (args.trim()) {
259
- return handleInit(ctx, args);
260
- }
261
- return buildSlugConfirmation(ctx);
262
- }
263
-
264
- /**
265
- * Step 1: derive slug and present a [Confirm] inline button.
266
- * Falls back to text instructions if the callback data would exceed 64 bytes.
267
- */
268
- async function buildSlugConfirmation(ctx: CommandContext): Promise<CommandResult> {
269
- const { workspaceDir, userId, groupId, threadId, messageContext } = ctx;
270
-
271
- if (!userId || !groupId || !threadId) {
272
- return { text: 'Missing context: groupId, threadId, or userId not available. Run this command inside a Telegram forum topic.' };
273
- }
274
-
275
- if (!validateGroupId(groupId)) {
276
- return { text: 'Invalid groupId format.' };
277
- }
278
- if (!validateThreadId(threadId)) {
279
- return { text: 'Invalid threadId format.' };
280
- }
281
-
282
- const registry = readRegistry(workspaceDir);
283
-
284
- const auth = checkAuthorization(userId, 'init', registry);
285
- if (!auth.authorized) {
286
- return { text: auth.message ?? 'Not authorized.' };
287
- }
288
-
289
- const topicCount = Object.keys(registry.topics).length;
290
- if (topicCount >= registry.maxTopics) {
291
- return { text: `Maximum number of topics (${registry.maxTopics}) reached. Archive or remove existing topics first.` };
292
- }
293
-
294
- const key = topicKey(groupId, threadId);
295
- if (registry.topics[key]) {
296
- return {
297
- text: `This topic is already registered as <code>${htmlEscape(registry.topics[key]!.slug)}</code>.`,
298
- parseMode: 'HTML',
299
- };
300
- }
301
-
302
- // Derive slug (same logic as handleInit lines 74–96)
303
- const topicTitle = (messageContext?.['topicTitle'] as string) ?? '';
304
- let slug: string;
305
- if (topicTitle) {
306
- slug = sanitizeSlug(topicTitle);
307
- } else {
308
- slug = `topic-${threadId}`;
309
- }
310
- if (slug && !/^[a-z]/.test(slug)) {
311
- slug = 't-' + slug;
312
- }
313
- if (!validateSlug(slug)) {
314
- return {
315
- text: `Invalid derived slug "${htmlEscape(slug)}". Please provide one: /topic init &lt;slug&gt; [type]`,
316
- };
317
- }
318
-
319
- // Check callback byte limit — if slug is too long, fall back to text
320
- if (!fitsCallbackLimit('is', slug, groupId, threadId, registry.callbackSecret)) {
321
- return {
322
- text: `Suggested slug: <code>${htmlEscape(slug)}</code>\n\nSlug is too long for inline buttons. Please run:\n<code>/topic init ${htmlEscape(slug)} [type]</code>`,
323
- parseMode: 'HTML',
324
- };
325
- }
326
-
327
- const keyboard = buildInitSlugButtons(slug, groupId, threadId, registry.callbackSecret);
328
-
329
- return {
330
- text: `Initialize this topic as <code>${htmlEscape(slug)}</code>?`,
331
- parseMode: 'HTML',
332
- inlineKeyboard: keyboard,
333
- };
334
- }
335
-
336
- /**
337
- * Step 2 (callback `is`): re-validate, then show the type picker.
338
- */
339
- export async function handleInitSlugConfirm(ctx: CommandContext, slug: string): Promise<CommandResult> {
340
- const { workspaceDir, userId, groupId, threadId } = ctx;
341
-
342
- if (!userId || !groupId || !threadId) {
343
- return { text: 'Missing context.' };
344
- }
345
-
346
- const registry = readRegistry(workspaceDir);
347
-
348
- const auth = checkAuthorization(userId, 'init', registry);
349
- if (!auth.authorized) {
350
- return { text: auth.message ?? 'Not authorized.' };
351
- }
352
-
353
- const key = topicKey(groupId, threadId);
354
- if (registry.topics[key]) {
355
- return {
356
- text: `This topic is already registered as <code>${htmlEscape(registry.topics[key]!.slug)}</code>.`,
357
- parseMode: 'HTML',
358
- };
359
- }
360
-
361
- const keyboard = buildInitTypeButtons(slug, groupId, threadId, registry.callbackSecret);
362
-
363
- return {
364
- text: `Slug: <code>${htmlEscape(slug)}</code>\n\nPick a topic type:`,
365
- parseMode: 'HTML',
366
- inlineKeyboard: keyboard,
367
- };
368
- }
369
-
370
- /**
371
- * Step 3 (callbacks `ic`/`ir`/`im`/`ix`): complete init with chosen type.
372
- */
373
- export async function handleInitTypeSelect(ctx: CommandContext, slug: string, type: TopicType): Promise<CommandResult> {
374
- return handleInit(ctx, `${slug} ${type}`);
375
- }
376
-
@@ -1,28 +0,0 @@
1
- import { readRegistry } from '../lib/registry.js';
2
- import { checkAuthorization } from '../lib/auth.js';
3
- import { buildListMessage } from '../lib/telegram.js';
4
- import type { CommandContext, CommandResult } from './help.js';
5
-
6
- export async function handleList(ctx: CommandContext): Promise<CommandResult> {
7
- const { workspaceDir, userId } = ctx;
8
-
9
- if (!userId) {
10
- return { text: 'Missing context: userId not available.' };
11
- }
12
-
13
- const registry = readRegistry(workspaceDir);
14
-
15
- // Auth check (admin tier)
16
- const auth = checkAuthorization(userId, 'list', registry);
17
- if (!auth.authorized) {
18
- return { text: auth.message ?? 'Not authorized.' };
19
- }
20
-
21
- const topics = Object.values(registry.topics);
22
- const text = buildListMessage(topics);
23
-
24
- return {
25
- text,
26
- parseMode: 'HTML',
27
- };
28
- }
@@ -1,140 +0,0 @@
1
- import * as fs from 'node:fs';
2
- import * as path from 'node:path';
3
- import { readRegistry, withRegistry } from '../lib/registry.js';
4
- import { checkAuthorization } from '../lib/auth.js';
5
- import { topicKey } from '../lib/types.js';
6
- import { validateSlug, jailCheck, rejectSymlink, htmlEscape } from '../lib/security.js';
7
- import { buildTopicCard } from '../lib/telegram.js';
8
- import { generateInclude } from '../lib/include-generator.js';
9
- import { triggerRestart, getConfigWrites } from '../lib/config-restart.js';
10
- import { appendAudit, buildAuditEntry } from '../lib/audit.js';
11
- import type { CommandContext, CommandResult } from './help.js';
12
-
13
- export async function handleRename(ctx: CommandContext, newSlug: string): Promise<CommandResult> {
14
- const { workspaceDir, configDir, userId, groupId, threadId, rpc, logger } = ctx;
15
-
16
- if (!userId || !groupId || !threadId) {
17
- return { text: 'Missing context: userId, groupId, or threadId not available.' };
18
- }
19
-
20
- const trimmedSlug = newSlug.trim();
21
- if (!trimmedSlug) {
22
- return { text: 'Usage: /topic rename &lt;new-slug&gt;' };
23
- }
24
-
25
- // Validate new slug
26
- if (!validateSlug(trimmedSlug)) {
27
- return {
28
- text: `Invalid slug "${htmlEscape(trimmedSlug)}". Must match: lowercase letter start, alphanumeric + hyphens, max 50 chars.`,
29
- };
30
- }
31
-
32
- const registry = readRegistry(workspaceDir);
33
-
34
- // Auth check (admin tier)
35
- const auth = checkAuthorization(userId, 'rename', registry);
36
- if (!auth.authorized) {
37
- return { text: auth.message ?? 'Not authorized.' };
38
- }
39
-
40
- const key = topicKey(groupId, threadId);
41
- const entry = registry.topics[key];
42
-
43
- if (!entry) {
44
- return { text: 'This topic is not registered. Run /topic init first.' };
45
- }
46
-
47
- const oldSlug = entry.slug;
48
- if (oldSlug === trimmedSlug) {
49
- return { text: `Topic is already named <code>${htmlEscape(oldSlug)}</code>.` };
50
- }
51
-
52
- const projectsBase = path.join(workspaceDir, 'projects');
53
-
54
- // Path jail check for both old and new paths
55
- if (!jailCheck(projectsBase, oldSlug)) {
56
- return { text: 'Path safety check failed for current slug.' };
57
- }
58
-
59
- if (!jailCheck(projectsBase, trimmedSlug)) {
60
- return { text: 'Path safety check failed for new slug.' };
61
- }
62
-
63
- const oldPath = path.join(projectsBase, oldSlug);
64
- const newPath = path.join(projectsBase, trimmedSlug);
65
-
66
- // Symlink check on both paths
67
- if (rejectSymlink(oldPath)) {
68
- return { text: 'Current capsule directory is a symlink. Aborting for security.' };
69
- }
70
-
71
- if (rejectSymlink(newPath)) {
72
- return { text: 'Target capsule directory is a symlink. Aborting for security.' };
73
- }
74
-
75
- // Collision check: new slug in registry
76
- const collisionInRegistry = Object.values(registry.topics).some((t) => t.slug === trimmedSlug);
77
- if (collisionInRegistry) {
78
- return {
79
- text: `Slug <code>${htmlEscape(trimmedSlug)}</code> is already used by another topic.`,
80
- parseMode: 'HTML',
81
- };
82
- }
83
-
84
- // Collision check: new path on disk
85
- if (fs.existsSync(newPath)) {
86
- return {
87
- text: `Directory projects/${htmlEscape(trimmedSlug)}/ already exists on disk.`,
88
- parseMode: 'HTML',
89
- };
90
- }
91
-
92
- // Rename folder on disk
93
- if (!fs.existsSync(oldPath)) {
94
- return { text: `Source capsule directory not found: projects/${htmlEscape(oldSlug)}/` };
95
- }
96
-
97
- // Rename folder and update registry atomically under lock
98
- try {
99
- await withRegistry(workspaceDir, (data) => {
100
- const topic = data.topics[key];
101
- if (topic) {
102
- fs.renameSync(oldPath, newPath);
103
- topic.slug = trimmedSlug;
104
- }
105
- });
106
- } catch (err) {
107
- const msg = err instanceof Error ? err.message : String(err);
108
- return { text: `Failed to rename capsule: ${htmlEscape(msg)}` };
109
- }
110
-
111
- // Regenerate include if configWrites enabled
112
- let restartMsg = '';
113
- const configWritesEnabled = await getConfigWrites(ctx.rpc);
114
- if (configWritesEnabled) {
115
- try {
116
- const updatedRegistry = readRegistry(workspaceDir);
117
- generateInclude(workspaceDir, updatedRegistry, configDir);
118
- } catch (err) {
119
- const msg = err instanceof Error ? err.message : String(err);
120
- restartMsg = `\nWarning: include generation failed: ${htmlEscape(msg)}`;
121
- }
122
- const result = await triggerRestart(rpc, logger);
123
- if (!result.success && result.fallbackMessage) {
124
- restartMsg += '\n' + result.fallbackMessage;
125
- }
126
- }
127
-
128
- appendAudit(
129
- workspaceDir,
130
- buildAuditEntry(userId, 'rename', trimmedSlug, `Renamed from ${oldSlug} to ${trimmedSlug}`),
131
- );
132
-
133
- const topicCard = buildTopicCard(trimmedSlug, entry.type, entry.capsuleVersion);
134
-
135
- return {
136
- text: `Topic renamed from <code>${htmlEscape(oldSlug)}</code> to <code>${htmlEscape(trimmedSlug)}</code>.\n\n${topicCard}${restartMsg}`,
137
- parseMode: 'HTML',
138
- };
139
- }
140
-