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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/commands/archive.d.ts +4 -0
- package/dist/commands/archive.d.ts.map +1 -0
- package/dist/commands/archive.js +71 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/doctor-all.d.ts +3 -0
- package/dist/commands/doctor-all.d.ts.map +1 -0
- package/dist/commands/doctor-all.js +193 -0
- package/dist/commands/doctor-all.js.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +74 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +8 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/init.d.ts +17 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +304 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +22 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/rename.d.ts +3 -0
- package/dist/commands/rename.d.ts.map +1 -0
- package/dist/commands/rename.js +115 -0
- package/dist/commands/rename.js.map +1 -0
- package/dist/commands/snooze.d.ts +3 -0
- package/dist/commands/snooze.d.ts.map +1 -0
- package/dist/commands/snooze.js +52 -0
- package/dist/commands/snooze.js.map +1 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +48 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +38 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/upgrade.d.ts +3 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +52 -0
- package/dist/commands/upgrade.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/audit.d.ts +12 -0
- package/dist/lib/audit.d.ts.map +1 -0
- package/dist/lib/audit.js +35 -0
- package/dist/lib/audit.js.map +1 -0
- package/dist/lib/auth.d.ts +26 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +73 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/capsule.d.ts +27 -0
- package/dist/lib/capsule.d.ts.map +1 -0
- package/dist/lib/capsule.js +130 -0
- package/dist/lib/capsule.js.map +1 -0
- package/dist/lib/config-restart.d.ts +23 -0
- package/dist/lib/config-restart.d.ts.map +1 -0
- package/dist/lib/config-restart.js +129 -0
- package/dist/lib/config-restart.js.map +1 -0
- package/dist/lib/doctor-checks.d.ts +50 -0
- package/dist/lib/doctor-checks.d.ts.map +1 -0
- package/dist/lib/doctor-checks.js +421 -0
- package/dist/lib/doctor-checks.js.map +1 -0
- package/dist/lib/include-generator.d.ts +35 -0
- package/dist/lib/include-generator.d.ts.map +1 -0
- package/dist/lib/include-generator.js +140 -0
- package/dist/lib/include-generator.js.map +1 -0
- package/dist/lib/registry.d.ts +27 -0
- package/dist/lib/registry.d.ts.map +1 -0
- package/dist/lib/registry.js +154 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/lib/security.d.ts +57 -0
- package/dist/lib/security.d.ts.map +1 -0
- package/dist/lib/security.js +133 -0
- package/dist/lib/security.js.map +1 -0
- package/dist/lib/telegram.d.ts +55 -0
- package/dist/lib/telegram.d.ts.map +1 -0
- package/dist/lib/telegram.js +254 -0
- package/dist/lib/telegram.js.map +1 -0
- package/dist/lib/types.d.ts +120 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +85 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/setup.d.ts +3 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +333 -0
- package/dist/setup.js.map +1 -0
- package/dist/tool.d.ts +15 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +201 -0
- package/dist/tool.js.map +1 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +48 -0
- package/skills/topic/SKILL.md +35 -0
- package/src/commands/archive.ts +89 -0
- package/src/commands/doctor-all.ts +243 -0
- package/src/commands/doctor.ts +100 -0
- package/src/commands/help.ts +11 -0
- package/src/commands/init.ts +376 -0
- package/src/commands/list.ts +28 -0
- package/src/commands/rename.ts +140 -0
- package/src/commands/snooze.ts +69 -0
- package/src/commands/status.ts +59 -0
- package/src/commands/sync.ts +46 -0
- package/src/commands/upgrade.ts +64 -0
- package/src/index.ts +54 -0
- package/src/lib/audit.ts +44 -0
- package/src/lib/auth.ts +96 -0
- package/src/lib/capsule.ts +206 -0
- package/src/lib/config-restart.ts +167 -0
- package/src/lib/doctor-checks.ts +639 -0
- package/src/lib/include-generator.ts +174 -0
- package/src/lib/registry.ts +197 -0
- package/src/lib/security.ts +174 -0
- package/src/lib/telegram.ts +311 -0
- package/src/lib/types.ts +172 -0
- package/src/setup.ts +402 -0
- package/src/templates/base/COMMANDS.md +3 -0
- package/src/templates/base/CRON.md +3 -0
- package/src/templates/base/LINKS.md +3 -0
- package/src/templates/base/NOTES.md +3 -0
- package/src/templates/base/README.md +3 -0
- package/src/templates/base/STATUS.md +13 -0
- package/src/templates/base/TODO.md +11 -0
- package/src/templates/overlays/coding/ARCHITECTURE.md +3 -0
- package/src/templates/overlays/coding/DEPLOY.md +3 -0
- package/src/templates/overlays/marketing/CAMPAIGNS.md +3 -0
- package/src/templates/overlays/marketing/METRICS.md +3 -0
- package/src/templates/overlays/research/FINDINGS.md +3 -0
- package/src/templates/overlays/research/SOURCES.md +3 -0
- 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
|
+
}
|
package/src/lib/audit.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -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
|
+
}
|