openclaw-telegram-manager 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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/dist/commands/archive.d.ts +4 -0
  4. package/dist/commands/archive.d.ts.map +1 -0
  5. package/dist/commands/archive.js +71 -0
  6. package/dist/commands/archive.js.map +1 -0
  7. package/dist/commands/doctor-all.d.ts +3 -0
  8. package/dist/commands/doctor-all.d.ts.map +1 -0
  9. package/dist/commands/doctor-all.js +193 -0
  10. package/dist/commands/doctor-all.js.map +1 -0
  11. package/dist/commands/doctor.d.ts +3 -0
  12. package/dist/commands/doctor.d.ts.map +1 -0
  13. package/dist/commands/doctor.js +74 -0
  14. package/dist/commands/doctor.js.map +1 -0
  15. package/dist/commands/help.d.ts +4 -0
  16. package/dist/commands/help.d.ts.map +1 -0
  17. package/dist/commands/help.js +8 -0
  18. package/dist/commands/help.js.map +1 -0
  19. package/dist/commands/init.d.ts +17 -0
  20. package/dist/commands/init.d.ts.map +1 -0
  21. package/dist/commands/init.js +304 -0
  22. package/dist/commands/init.js.map +1 -0
  23. package/dist/commands/list.d.ts +3 -0
  24. package/dist/commands/list.d.ts.map +1 -0
  25. package/dist/commands/list.js +22 -0
  26. package/dist/commands/list.js.map +1 -0
  27. package/dist/commands/rename.d.ts +3 -0
  28. package/dist/commands/rename.d.ts.map +1 -0
  29. package/dist/commands/rename.js +115 -0
  30. package/dist/commands/rename.js.map +1 -0
  31. package/dist/commands/snooze.d.ts +3 -0
  32. package/dist/commands/snooze.d.ts.map +1 -0
  33. package/dist/commands/snooze.js +52 -0
  34. package/dist/commands/snooze.js.map +1 -0
  35. package/dist/commands/status.d.ts +3 -0
  36. package/dist/commands/status.d.ts.map +1 -0
  37. package/dist/commands/status.js +48 -0
  38. package/dist/commands/status.js.map +1 -0
  39. package/dist/commands/sync.d.ts +3 -0
  40. package/dist/commands/sync.d.ts.map +1 -0
  41. package/dist/commands/sync.js +38 -0
  42. package/dist/commands/sync.js.map +1 -0
  43. package/dist/commands/upgrade.d.ts +3 -0
  44. package/dist/commands/upgrade.d.ts.map +1 -0
  45. package/dist/commands/upgrade.js +52 -0
  46. package/dist/commands/upgrade.js.map +1 -0
  47. package/dist/index.d.ts +25 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +30 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/lib/audit.d.ts +12 -0
  52. package/dist/lib/audit.d.ts.map +1 -0
  53. package/dist/lib/audit.js +35 -0
  54. package/dist/lib/audit.js.map +1 -0
  55. package/dist/lib/auth.d.ts +26 -0
  56. package/dist/lib/auth.d.ts.map +1 -0
  57. package/dist/lib/auth.js +73 -0
  58. package/dist/lib/auth.js.map +1 -0
  59. package/dist/lib/capsule.d.ts +27 -0
  60. package/dist/lib/capsule.d.ts.map +1 -0
  61. package/dist/lib/capsule.js +130 -0
  62. package/dist/lib/capsule.js.map +1 -0
  63. package/dist/lib/config-restart.d.ts +23 -0
  64. package/dist/lib/config-restart.d.ts.map +1 -0
  65. package/dist/lib/config-restart.js +129 -0
  66. package/dist/lib/config-restart.js.map +1 -0
  67. package/dist/lib/doctor-checks.d.ts +50 -0
  68. package/dist/lib/doctor-checks.d.ts.map +1 -0
  69. package/dist/lib/doctor-checks.js +421 -0
  70. package/dist/lib/doctor-checks.js.map +1 -0
  71. package/dist/lib/include-generator.d.ts +35 -0
  72. package/dist/lib/include-generator.d.ts.map +1 -0
  73. package/dist/lib/include-generator.js +140 -0
  74. package/dist/lib/include-generator.js.map +1 -0
  75. package/dist/lib/registry.d.ts +27 -0
  76. package/dist/lib/registry.d.ts.map +1 -0
  77. package/dist/lib/registry.js +154 -0
  78. package/dist/lib/registry.js.map +1 -0
  79. package/dist/lib/security.d.ts +57 -0
  80. package/dist/lib/security.d.ts.map +1 -0
  81. package/dist/lib/security.js +133 -0
  82. package/dist/lib/security.js.map +1 -0
  83. package/dist/lib/telegram.d.ts +55 -0
  84. package/dist/lib/telegram.d.ts.map +1 -0
  85. package/dist/lib/telegram.js +254 -0
  86. package/dist/lib/telegram.js.map +1 -0
  87. package/dist/lib/types.d.ts +120 -0
  88. package/dist/lib/types.d.ts.map +1 -0
  89. package/dist/lib/types.js +85 -0
  90. package/dist/lib/types.js.map +1 -0
  91. package/dist/setup.d.ts +3 -0
  92. package/dist/setup.d.ts.map +1 -0
  93. package/dist/setup.js +333 -0
  94. package/dist/setup.js.map +1 -0
  95. package/dist/tool.d.ts +15 -0
  96. package/dist/tool.d.ts.map +1 -0
  97. package/dist/tool.js +201 -0
  98. package/dist/tool.js.map +1 -0
  99. package/openclaw.plugin.json +9 -0
  100. package/package.json +48 -0
  101. package/skills/topic/SKILL.md +35 -0
  102. package/src/commands/archive.ts +89 -0
  103. package/src/commands/doctor-all.ts +243 -0
  104. package/src/commands/doctor.ts +100 -0
  105. package/src/commands/help.ts +11 -0
  106. package/src/commands/init.ts +376 -0
  107. package/src/commands/list.ts +28 -0
  108. package/src/commands/rename.ts +140 -0
  109. package/src/commands/snooze.ts +69 -0
  110. package/src/commands/status.ts +59 -0
  111. package/src/commands/sync.ts +46 -0
  112. package/src/commands/upgrade.ts +64 -0
  113. package/src/index.ts +54 -0
  114. package/src/lib/audit.ts +44 -0
  115. package/src/lib/auth.ts +96 -0
  116. package/src/lib/capsule.ts +206 -0
  117. package/src/lib/config-restart.ts +167 -0
  118. package/src/lib/doctor-checks.ts +639 -0
  119. package/src/lib/include-generator.ts +174 -0
  120. package/src/lib/registry.ts +197 -0
  121. package/src/lib/security.ts +174 -0
  122. package/src/lib/telegram.ts +311 -0
  123. package/src/lib/types.ts +172 -0
  124. package/src/setup.ts +402 -0
  125. package/src/templates/base/COMMANDS.md +3 -0
  126. package/src/templates/base/CRON.md +3 -0
  127. package/src/templates/base/LINKS.md +3 -0
  128. package/src/templates/base/NOTES.md +3 -0
  129. package/src/templates/base/README.md +3 -0
  130. package/src/templates/base/STATUS.md +13 -0
  131. package/src/templates/base/TODO.md +11 -0
  132. package/src/templates/overlays/coding/ARCHITECTURE.md +3 -0
  133. package/src/templates/overlays/coding/DEPLOY.md +3 -0
  134. package/src/templates/overlays/marketing/CAMPAIGNS.md +3 -0
  135. package/src/templates/overlays/marketing/METRICS.md +3 -0
  136. package/src/templates/overlays/research/FINDINGS.md +3 -0
  137. package/src/templates/overlays/research/SOURCES.md +3 -0
  138. package/src/tool.ts +282 -0
@@ -0,0 +1,46 @@
1
+ import { readRegistry } from '../lib/registry.js';
2
+ import { checkAuthorization } from '../lib/auth.js';
3
+ import { htmlEscape } from '../lib/security.js';
4
+ import { generateInclude } from '../lib/include-generator.js';
5
+ import { triggerRestart } from '../lib/config-restart.js';
6
+ import type { CommandContext, CommandResult } from './help.js';
7
+
8
+ export async function handleSync(ctx: CommandContext): Promise<CommandResult> {
9
+ const { workspaceDir, configDir, userId, rpc, logger } = ctx;
10
+
11
+ if (!userId) {
12
+ return { text: 'Missing context: userId not available.' };
13
+ }
14
+
15
+ const registry = readRegistry(workspaceDir);
16
+
17
+ // Auth check (admin tier)
18
+ const auth = checkAuthorization(userId, 'sync', registry);
19
+ if (!auth.authorized) {
20
+ return { text: auth.message ?? 'Not authorized.' };
21
+ }
22
+
23
+ try {
24
+ generateInclude(workspaceDir, registry, configDir);
25
+ } catch (err) {
26
+ const msg = err instanceof Error ? err.message : String(err);
27
+ return {
28
+ text: `Sync failed: ${htmlEscape(msg)}`,
29
+ parseMode: 'HTML',
30
+ };
31
+ }
32
+
33
+ const restartResult = await triggerRestart(rpc, logger);
34
+
35
+ const topicCount = Object.keys(registry.topics).length;
36
+ let text = `Include regenerated from ${topicCount} topic(s). Config synced.`;
37
+
38
+ if (!restartResult.success && restartResult.fallbackMessage) {
39
+ text += '\n' + restartResult.fallbackMessage;
40
+ }
41
+
42
+ return {
43
+ text,
44
+ parseMode: 'HTML',
45
+ };
46
+ }
@@ -0,0 +1,64 @@
1
+ import * as path from 'node:path';
2
+ import { readRegistry, withRegistry } from '../lib/registry.js';
3
+ import { checkAuthorization } from '../lib/auth.js';
4
+ import { topicKey, CAPSULE_VERSION } from '../lib/types.js';
5
+ import { htmlEscape } from '../lib/security.js';
6
+ import { upgradeCapsule } from '../lib/capsule.js';
7
+ import type { CommandContext, CommandResult } from './help.js';
8
+
9
+ export async function handleUpgrade(ctx: CommandContext): Promise<CommandResult> {
10
+ const { workspaceDir, userId, groupId, threadId } = ctx;
11
+
12
+ if (!userId || !groupId || !threadId) {
13
+ return { text: 'Missing context: userId, groupId, or threadId not available.' };
14
+ }
15
+
16
+ const registry = readRegistry(workspaceDir);
17
+
18
+ // Auth check (user tier)
19
+ const auth = checkAuthorization(userId, 'upgrade', registry);
20
+ if (!auth.authorized) {
21
+ return { text: auth.message ?? 'Not authorized.' };
22
+ }
23
+
24
+ const key = topicKey(groupId, threadId);
25
+ const entry = registry.topics[key];
26
+
27
+ if (!entry) {
28
+ return { text: 'This topic is not registered. Run /topic init first.' };
29
+ }
30
+
31
+ if (entry.capsuleVersion >= CAPSULE_VERSION) {
32
+ return {
33
+ text: `Topic <code>${htmlEscape(entry.slug)}</code> is already at capsule version ${CAPSULE_VERSION}. No upgrade needed.`,
34
+ parseMode: 'HTML',
35
+ };
36
+ }
37
+
38
+ const projectsBase = path.join(workspaceDir, 'projects');
39
+ const result = upgradeCapsule(projectsBase, entry.slug, entry.type, entry.capsuleVersion);
40
+
41
+ if (!result.upgraded) {
42
+ return {
43
+ text: `No upgrade needed for <code>${htmlEscape(entry.slug)}</code>.`,
44
+ parseMode: 'HTML',
45
+ };
46
+ }
47
+
48
+ // Update capsule version in registry
49
+ await withRegistry(workspaceDir, (data) => {
50
+ const topic = data.topics[key];
51
+ if (topic) {
52
+ topic.capsuleVersion = result.newVersion;
53
+ }
54
+ });
55
+
56
+ const addedList = result.addedFiles.length > 0
57
+ ? `\nAdded files: ${result.addedFiles.map((f) => htmlEscape(f)).join(', ')}`
58
+ : '\nNo new files added.';
59
+
60
+ return {
61
+ text: `Topic <code>${htmlEscape(entry.slug)}</code> upgraded from v${entry.capsuleVersion} to v${result.newVersion}.${addedList}`,
62
+ parseMode: 'HTML',
63
+ };
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ import { createTopicManagerTool } from './tool.js';
3
+
4
+ export default function register(api: {
5
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
6
+ configDir?: string;
7
+ workspaceDir?: string;
8
+ rpc?: { call(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> } | null;
9
+ pluginConfig?: { configDir?: string; workspaceDir?: string };
10
+ registerTool(def: {
11
+ name: string;
12
+ description: string;
13
+ parameters: unknown;
14
+ execute(id: string, params: { command: string }, context?: Record<string, unknown>): Promise<unknown>;
15
+ }): void;
16
+ }): void {
17
+ const configDir = api.configDir ?? api.pluginConfig?.configDir;
18
+ const workspaceDir = api.workspaceDir ?? api.pluginConfig?.workspaceDir;
19
+
20
+ if (!configDir || !workspaceDir) {
21
+ api.logger.error(
22
+ 'telegram-manager: configDir or workspaceDir not available. Plugin cannot initialize.',
23
+ );
24
+ return;
25
+ }
26
+
27
+ const tool = createTopicManagerTool({
28
+ logger: api.logger,
29
+ configDir,
30
+ workspaceDir,
31
+ rpc: api.rpc,
32
+ });
33
+
34
+ api.registerTool({
35
+ name: 'topic_manager',
36
+ description:
37
+ 'Manage Telegram forum topics as deterministic workcells. Sub-commands: init, doctor, list, status, sync, rename, upgrade, snooze, archive, unarchive, help.',
38
+ parameters: Type.Object({
39
+ command: Type.String({
40
+ description:
41
+ "Sub-command and arguments (e.g., 'init', 'doctor --all', 'rename new-slug')",
42
+ }),
43
+ }),
44
+ async execute(
45
+ id: string,
46
+ params: { command: string },
47
+ context?: Record<string, unknown>,
48
+ ) {
49
+ return tool.execute(id, params, context);
50
+ },
51
+ });
52
+
53
+ api.logger.info('telegram-manager plugin loaded');
54
+ }
@@ -0,0 +1,44 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { AuditEntry } from './types.js';
4
+
5
+ const AUDIT_FILENAME = 'audit.jsonl';
6
+ const FILE_MODE = 0o600;
7
+
8
+ /**
9
+ * Append an audit entry to the audit.jsonl file.
10
+ * Creates the file if it does not exist. Each entry is a single JSON line.
11
+ * File permissions are set to 0600.
12
+ */
13
+ export function appendAudit(workspaceDir: string, entry: AuditEntry): void {
14
+ const auditPath = path.join(workspaceDir, 'projects', AUDIT_FILENAME);
15
+ const line = JSON.stringify(entry) + '\n';
16
+
17
+ const fd = fs.openSync(auditPath, 'a', FILE_MODE);
18
+ try {
19
+ fs.writeSync(fd, line);
20
+ } finally {
21
+ fs.closeSync(fd);
22
+ }
23
+
24
+ // Ensure permissions are correct even if file already existed
25
+ fs.chmodSync(auditPath, FILE_MODE);
26
+ }
27
+
28
+ /**
29
+ * Build an AuditEntry with the current timestamp.
30
+ */
31
+ export function buildAuditEntry(
32
+ userId: string,
33
+ cmd: string,
34
+ slug: string,
35
+ detail: string,
36
+ ): AuditEntry {
37
+ return {
38
+ ts: new Date().toISOString(),
39
+ userId,
40
+ cmd,
41
+ slug,
42
+ detail,
43
+ };
44
+ }
@@ -0,0 +1,96 @@
1
+ import type { Registry } from './types.js';
2
+
3
+ // ── Authorization tiers ────────────────────────────────────────────────
4
+
5
+ export const AuthTier = {
6
+ User: 'User',
7
+ Admin: 'Admin',
8
+ } as const;
9
+
10
+ export type AuthTier = (typeof AuthTier)[keyof typeof AuthTier];
11
+
12
+ // ── Command-to-tier mapping ────────────────────────────────────────────
13
+
14
+ const ADMIN_COMMANDS = new Set([
15
+ 'doctor --all',
16
+ 'doctor-all',
17
+ 'list',
18
+ 'sync',
19
+ 'rename',
20
+ 'archive',
21
+ 'unarchive',
22
+ ]);
23
+
24
+ const USER_COMMANDS = new Set([
25
+ 'init',
26
+ 'doctor',
27
+ 'status',
28
+ 'help',
29
+ 'upgrade',
30
+ 'snooze',
31
+ ]);
32
+
33
+ /**
34
+ * Get the authorization tier for a command.
35
+ * Defaults to Admin for unknown commands.
36
+ */
37
+ export function getCommandTier(command: string): AuthTier {
38
+ const normalized = command.toLowerCase().trim();
39
+ if (USER_COMMANDS.has(normalized)) return AuthTier.User;
40
+ if (ADMIN_COMMANDS.has(normalized)) return AuthTier.Admin;
41
+ // Unknown commands default to admin tier (principle of least privilege)
42
+ return AuthTier.Admin;
43
+ }
44
+
45
+ // ── Authorization check ────────────────────────────────────────────────
46
+
47
+ export interface AuthResult {
48
+ authorized: boolean;
49
+ message?: string;
50
+ }
51
+
52
+ /**
53
+ * Check if a user is authorized to run a command.
54
+ *
55
+ * Logic:
56
+ * - Admin-tier: user must be in topicManagerAdmins
57
+ * - User-tier: user must be in topicAllowFrom OR topicManagerAdmins
58
+ * - Special case for init: if topicManagerAdmins is empty (first-time setup),
59
+ * allow anyone — the first user to init becomes the default admin.
60
+ */
61
+ export function checkAuthorization(
62
+ userId: string,
63
+ command: string,
64
+ registry: Registry,
65
+ topicAllowFrom?: string[],
66
+ ): AuthResult {
67
+ const tier = getCommandTier(command);
68
+ const admins = registry.topicManagerAdmins;
69
+
70
+ // First-user bootstrap: if no admins set yet, allow anyone for init
71
+ if (command === 'init' && admins.length === 0) {
72
+ return { authorized: true };
73
+ }
74
+
75
+ const isAdmin = admins.includes(userId);
76
+
77
+ if (tier === AuthTier.Admin) {
78
+ if (isAdmin) return { authorized: true };
79
+ return {
80
+ authorized: false,
81
+ message: 'Not authorized. Ask a telegram-manager admin to run this command.',
82
+ };
83
+ }
84
+
85
+ // User tier: allowed if admin or in topicAllowFrom
86
+ if (isAdmin) return { authorized: true };
87
+
88
+ if (topicAllowFrom && topicAllowFrom.includes(userId)) {
89
+ return { authorized: true };
90
+ }
91
+
92
+ return {
93
+ authorized: false,
94
+ message: 'Not authorized. Ask a telegram-manager admin to run this command.',
95
+ };
96
+ }
@@ -0,0 +1,206 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { BASE_FILES, OVERLAY_FILES, CAPSULE_VERSION } from './types.js';
4
+ import type { TopicType } from './types.js';
5
+ import { jailCheck, rejectSymlink } from './security.js';
6
+
7
+ // ── Template content (embedded string constants) ───────────────────────
8
+ // These are the source-of-truth defaults, matching src/templates/.
9
+
10
+ const BASE_TEMPLATES: Record<string, (slug: string) => string> = {
11
+ 'README.md': (slug) =>
12
+ `# ${slug}\n\n_Describe what this topic is about._\n`,
13
+
14
+ 'STATUS.md': (slug) =>
15
+ `# Status: ${slug}\n\n## Last done (UTC)\n\n${new Date().toISOString()}\n\n_No work recorded yet._\n\n## Next 3 actions\n\n1. [T-1] _Define first task in TODO.md_\n2. [T-2] _Define second task in TODO.md_\n3. [T-3] _Define third task in TODO.md_\n`,
16
+
17
+ 'TODO.md': (slug) =>
18
+ `# TODO: ${slug}\n\n## Backlog\n\n- [T-1] _First task placeholder — replace with actual task_\n- [T-2] _Second task placeholder — replace with actual task_\n- [T-3] _Third task placeholder — replace with actual task_\n\n## Completed\n\n_None yet._\n`,
19
+
20
+ 'COMMANDS.md': (slug) =>
21
+ `# Commands: ${slug}\n\n_Build, deploy, test, and other commands for this topic. Kept here so they're not lost on reset._\n`,
22
+
23
+ 'LINKS.md': (slug) =>
24
+ `# Links: ${slug}\n\n_URLs, paths, and service endpoints for this topic._\n`,
25
+
26
+ 'CRON.md': (slug) =>
27
+ `# Cron: ${slug}\n\n_Cron job IDs and schedules for this topic._\n`,
28
+
29
+ 'NOTES.md': (slug) =>
30
+ `# Notes: ${slug}\n\n_Anything worth remembering about this topic._\n`,
31
+ };
32
+
33
+ const OVERLAY_TEMPLATES: Record<string, (slug: string) => string> = {
34
+ 'ARCHITECTURE.md': (slug) =>
35
+ `# Architecture: ${slug}\n\n_Components, data flow, dependencies, and design decisions._\n`,
36
+
37
+ 'DEPLOY.md': (slug) =>
38
+ `# Deployment: ${slug}\n\n_Environments, deployment steps, rollback procedures, and infra details._\n`,
39
+
40
+ 'SOURCES.md': (slug) =>
41
+ `# Sources: ${slug}\n\n_Papers, articles, datasets, APIs, and other reference material._\n`,
42
+
43
+ 'FINDINGS.md': (slug) =>
44
+ `# Findings: ${slug}\n\n_Conclusions, insights, data summaries, and recommendations._\n`,
45
+
46
+ 'CAMPAIGNS.md': (slug) =>
47
+ `# Campaigns: ${slug}\n\n_Active campaigns, target audiences, channels, timelines, and budgets._\n`,
48
+
49
+ 'METRICS.md': (slug) =>
50
+ `# Metrics: ${slug}\n\n_KPIs, conversion rates, engagement stats, and performance data._\n`,
51
+ };
52
+
53
+ // ── File permissions ───────────────────────────────────────────────────
54
+
55
+ const CAPSULE_FILE_MODE = 0o640;
56
+
57
+ // ── Scaffold ───────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Scaffold a new capsule directory with base kit + type overlays.
61
+ * Uses fs.mkdirSync with exclusive flag as the atomic reservation mechanism.
62
+ * Throws if the directory already exists (collision).
63
+ */
64
+ export function scaffoldCapsule(
65
+ projectsBase: string,
66
+ slug: string,
67
+ type: TopicType,
68
+ ): void {
69
+ const capsuleDir = path.join(projectsBase, slug);
70
+
71
+ // Path jail check
72
+ if (!jailCheck(projectsBase, slug)) {
73
+ throw new Error(`Path escapes projects directory: ${slug}`);
74
+ }
75
+
76
+ // Symlink check on parent
77
+ if (rejectSymlink(projectsBase)) {
78
+ throw new Error(`Projects base is a symlink: ${projectsBase}`);
79
+ }
80
+
81
+ // Atomic directory creation (exclusive)
82
+ fs.mkdirSync(capsuleDir, { recursive: false });
83
+
84
+ // Write base files
85
+ for (const file of BASE_FILES) {
86
+ const templateFn = BASE_TEMPLATES[file];
87
+ if (templateFn) {
88
+ const filePath = path.join(capsuleDir, file);
89
+ fs.writeFileSync(filePath, templateFn(slug), { mode: CAPSULE_FILE_MODE });
90
+ }
91
+ }
92
+
93
+ // Write overlay files for the type
94
+ const overlays = OVERLAY_FILES[type];
95
+ for (const file of overlays) {
96
+ const templateFn = OVERLAY_TEMPLATES[file];
97
+ if (templateFn) {
98
+ const filePath = path.join(capsuleDir, file);
99
+ fs.writeFileSync(filePath, templateFn(slug), { mode: CAPSULE_FILE_MODE });
100
+ }
101
+ }
102
+ }
103
+
104
+ // ── Upgrade ────────────────────────────────────────────────────────────
105
+
106
+ export interface UpgradeResult {
107
+ upgraded: boolean;
108
+ newVersion: number;
109
+ addedFiles: string[];
110
+ }
111
+
112
+ /**
113
+ * Upgrade an existing capsule to the latest template version.
114
+ * Adds missing files without overwriting existing ones.
115
+ */
116
+ export function upgradeCapsule(
117
+ projectsBase: string,
118
+ slug: string,
119
+ type: TopicType,
120
+ currentVersion: number,
121
+ ): UpgradeResult {
122
+ if (currentVersion >= CAPSULE_VERSION) {
123
+ return { upgraded: false, newVersion: currentVersion, addedFiles: [] };
124
+ }
125
+
126
+ const capsuleDir = path.join(projectsBase, slug);
127
+
128
+ if (!jailCheck(projectsBase, slug)) {
129
+ throw new Error(`Path escapes projects directory: ${slug}`);
130
+ }
131
+
132
+ if (rejectSymlink(capsuleDir)) {
133
+ throw new Error(`Capsule directory is a symlink: ${capsuleDir}`);
134
+ }
135
+
136
+ const addedFiles: string[] = [];
137
+
138
+ // Add missing base files
139
+ for (const file of BASE_FILES) {
140
+ const filePath = path.join(capsuleDir, file);
141
+ if (!fs.existsSync(filePath)) {
142
+ const templateFn = BASE_TEMPLATES[file];
143
+ if (templateFn) {
144
+ fs.writeFileSync(filePath, templateFn(slug), { mode: CAPSULE_FILE_MODE });
145
+ addedFiles.push(file);
146
+ }
147
+ }
148
+ }
149
+
150
+ // Add missing overlay files
151
+ const overlays = OVERLAY_FILES[type];
152
+ for (const file of overlays) {
153
+ const filePath = path.join(capsuleDir, file);
154
+ if (!fs.existsSync(filePath)) {
155
+ const templateFn = OVERLAY_TEMPLATES[file];
156
+ if (templateFn) {
157
+ fs.writeFileSync(filePath, templateFn(slug), { mode: CAPSULE_FILE_MODE });
158
+ addedFiles.push(file);
159
+ }
160
+ }
161
+ }
162
+
163
+ return {
164
+ upgraded: true,
165
+ newVersion: CAPSULE_VERSION,
166
+ addedFiles,
167
+ };
168
+ }
169
+
170
+ // ── Validate ───────────────────────────────────────────────────────────
171
+
172
+ export interface CapsuleValidation {
173
+ missing: string[];
174
+ present: string[];
175
+ }
176
+
177
+ /**
178
+ * Validate that a capsule has all expected files.
179
+ * Returns lists of present and missing files.
180
+ */
181
+ export function validateCapsule(
182
+ projectsBase: string,
183
+ slug: string,
184
+ type: TopicType,
185
+ ): CapsuleValidation {
186
+ const capsuleDir = path.join(projectsBase, slug);
187
+
188
+ if (!jailCheck(projectsBase, slug)) {
189
+ throw new Error(`Path escapes projects directory: ${slug}`);
190
+ }
191
+
192
+ const expectedFiles = [...BASE_FILES, ...OVERLAY_FILES[type]];
193
+ const missing: string[] = [];
194
+ const present: string[] = [];
195
+
196
+ for (const file of expectedFiles) {
197
+ const filePath = path.join(capsuleDir, file);
198
+ if (fs.existsSync(filePath)) {
199
+ present.push(file);
200
+ } else {
201
+ missing.push(file);
202
+ }
203
+ }
204
+
205
+ return { missing, present };
206
+ }
@@ -0,0 +1,167 @@
1
+ // ── Config restart via config.patch no-op ──────────────────────────────
2
+ //
3
+ // Triggers Gateway restart by patching a no-op value, which causes
4
+ // OpenClaw to reload all $include files.
5
+
6
+ // ── Types ──────────────────────────────────────────────────────────────
7
+
8
+ import type { RpcInterface, Logger } from './types.js';
9
+
10
+ export type { RpcInterface, Logger };
11
+
12
+ export interface RestartResult {
13
+ success: boolean;
14
+ fallbackMessage?: string;
15
+ }
16
+
17
+ // ── Cooldown tracking (module-level) ───────────────────────────────────
18
+
19
+ const COOLDOWN_MS = 60_000; // 60 seconds
20
+ let lastRestartTimestamp = 0;
21
+
22
+ // ── Backoff config ─────────────────────────────────────────────────────
23
+
24
+ const MAX_RETRIES = 3;
25
+ const BASE_DELAY_MS = 1000; // 1s, 2s, 4s exponential backoff
26
+
27
+ // ── Main function ──────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Trigger a Gateway restart via config.patch no-op.
31
+ *
32
+ * - Enforces a 60-second cooldown between calls
33
+ * - Retries up to 3 times on baseHash mismatch with exponential backoff
34
+ * - Falls back to a user message if RPC is unavailable
35
+ */
36
+ export async function triggerRestart(
37
+ rpc: RpcInterface | null | undefined,
38
+ logger: Logger,
39
+ ): Promise<RestartResult> {
40
+ // Cooldown check
41
+ const now = Date.now();
42
+ if (now - lastRestartTimestamp < COOLDOWN_MS) {
43
+ const remainingSec = Math.ceil((COOLDOWN_MS - (now - lastRestartTimestamp)) / 1000);
44
+ logger.info(`Config restart cooldown active. ${remainingSec}s remaining.`);
45
+ return {
46
+ success: true, // Not a failure — just throttled
47
+ };
48
+ }
49
+
50
+ // If RPC is not available, return fallback
51
+ if (!rpc) {
52
+ return {
53
+ success: false,
54
+ fallbackMessage:
55
+ 'Config updated. Run `openclaw gateway restart` or send SIGUSR1 to apply.',
56
+ };
57
+ }
58
+
59
+ // Retry loop with exponential backoff
60
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
61
+ try {
62
+ // Get current config to capture baseHash
63
+ const configResult = await rpc.call('config.get', {});
64
+ const baseHash = configResult['baseHash'] as string | undefined;
65
+
66
+ if (!baseHash) {
67
+ logger.warn('config.get did not return baseHash; attempting patch without it');
68
+ }
69
+
70
+ // Patch with no-op change to trigger restart
71
+ const patchParams: Record<string, unknown> = {
72
+ patch: {
73
+ skills: {
74
+ entries: {
75
+ 'telegram-manager': {
76
+ lastSync: new Date().toISOString(),
77
+ },
78
+ },
79
+ },
80
+ },
81
+ };
82
+
83
+ if (baseHash) {
84
+ patchParams['baseHash'] = baseHash;
85
+ }
86
+
87
+ await rpc.call('config.patch', patchParams);
88
+
89
+ // Success — update cooldown timestamp
90
+ lastRestartTimestamp = Date.now();
91
+ logger.info('Gateway restart triggered via config.patch');
92
+
93
+ return { success: true };
94
+ } catch (err: unknown) {
95
+ const isBaseHashMismatch = isHashMismatchError(err);
96
+
97
+ if (isBaseHashMismatch && attempt < MAX_RETRIES) {
98
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt);
99
+ logger.warn(
100
+ `config.patch baseHash mismatch (attempt ${attempt + 1}/${MAX_RETRIES + 1}). Retrying in ${delay}ms...`,
101
+ );
102
+ await sleep(delay);
103
+ continue;
104
+ }
105
+
106
+ // All retries exhausted or non-retryable error
107
+ const errMsg = err instanceof Error ? err.message : String(err);
108
+ logger.error(`config.patch failed after ${attempt + 1} attempt(s): ${errMsg}`);
109
+
110
+ return {
111
+ success: false,
112
+ fallbackMessage:
113
+ 'Config updated. Run `openclaw gateway restart` or send SIGUSR1 to apply.',
114
+ };
115
+ }
116
+ }
117
+
118
+ // Should not reach here, but just in case
119
+ return {
120
+ success: false,
121
+ fallbackMessage:
122
+ 'Config updated. Run `openclaw gateway restart` or send SIGUSR1 to apply.',
123
+ };
124
+ }
125
+
126
+ // ── Helpers ────────────────────────────────────────────────────────────
127
+
128
+ function isHashMismatchError(err: unknown): boolean {
129
+ if (err && typeof err === 'object') {
130
+ // Check for common error shape
131
+ if ('code' in err && (err as { code: string }).code === 'BASE_HASH_MISMATCH') {
132
+ return true;
133
+ }
134
+ if ('message' in err) {
135
+ const msg = (err as { message: string }).message;
136
+ return msg.toLowerCase().includes('basehash') || msg.toLowerCase().includes('hash mismatch');
137
+ }
138
+ }
139
+ return false;
140
+ }
141
+
142
+ function sleep(ms: number): Promise<void> {
143
+ return new Promise((resolve) => setTimeout(resolve, ms));
144
+ }
145
+
146
+ /**
147
+ * Check if configWrites is enabled in the plugin config via RPC.
148
+ */
149
+ export async function getConfigWrites(rpc: RpcInterface | null | undefined): Promise<boolean> {
150
+ if (!rpc) return false;
151
+ try {
152
+ const config = await rpc.call('config.get', {});
153
+ const skills = config['skills'] as Record<string, unknown> | undefined;
154
+ const entries = skills?.['entries'] as Record<string, unknown> | undefined;
155
+ const tmConfig = entries?.['telegram-manager'] as Record<string, unknown> | undefined;
156
+ return tmConfig?.['configWrites'] === true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Reset the cooldown timer (for testing purposes).
164
+ */
165
+ export function resetCooldown(): void {
166
+ lastRestartTimestamp = 0;
167
+ }