openclaw-telegram-manager 1.3.0 → 1.3.2
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/dist/lib/include-generator.d.ts +1 -1
- package/dist/lib/include-generator.d.ts.map +1 -1
- package/dist/lib/include-generator.js +33 -2
- package/dist/lib/include-generator.js.map +1 -1
- package/dist/plugin.js +29 -2
- package/dist/setup.js +33 -15
- package/dist/setup.js.map +1 -1
- package/package.json +2 -3
- package/src/commands/archive.ts +0 -89
- package/src/commands/doctor-all.ts +0 -243
- package/src/commands/doctor.ts +0 -100
- package/src/commands/help.ts +0 -11
- package/src/commands/init.ts +0 -376
- package/src/commands/list.ts +0 -28
- package/src/commands/rename.ts +0 -140
- package/src/commands/snooze.ts +0 -69
- package/src/commands/status.ts +0 -59
- package/src/commands/sync.ts +0 -46
- package/src/commands/upgrade.ts +0 -64
- package/src/index.ts +0 -91
- package/src/lib/audit.ts +0 -44
- package/src/lib/auth.ts +0 -96
- package/src/lib/capsule.ts +0 -206
- package/src/lib/config-restart.ts +0 -167
- package/src/lib/doctor-checks.ts +0 -639
- package/src/lib/include-generator.ts +0 -174
- package/src/lib/registry.ts +0 -197
- package/src/lib/security.ts +0 -174
- package/src/lib/telegram.ts +0 -311
- package/src/lib/types.ts +0 -172
- package/src/setup.ts +0 -475
- package/src/templates/base/COMMANDS.md +0 -3
- package/src/templates/base/CRON.md +0 -3
- package/src/templates/base/LINKS.md +0 -3
- package/src/templates/base/NOTES.md +0 -3
- package/src/templates/base/README.md +0 -3
- package/src/templates/base/TODO.md +0 -11
- package/src/templates/overlays/coding/ARCHITECTURE.md +0 -3
- package/src/templates/overlays/coding/DEPLOY.md +0 -3
- package/src/templates/overlays/marketing/CAMPAIGNS.md +0 -3
- package/src/templates/overlays/marketing/METRICS.md +0 -3
- package/src/templates/overlays/research/FINDINGS.md +0 -3
- package/src/templates/overlays/research/SOURCES.md +0 -3
- package/src/tool.ts +0 -282
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import * as crypto from 'node:crypto';
|
|
2
|
-
import * as fs from 'node:fs';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import JSON5 from 'json5';
|
|
5
|
-
import type { Registry, TopicEntry, TopicType } from './types.js';
|
|
6
|
-
|
|
7
|
-
// ── Constants ──────────────────────────────────────────────────────────
|
|
8
|
-
|
|
9
|
-
const INCLUDE_FILENAME = 'telegram-manager.generated.groups.json5';
|
|
10
|
-
const FILE_MODE = 0o600;
|
|
11
|
-
|
|
12
|
-
// ── System prompt template ─────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Build the per-topic systemPrompt using absolute paths resolved at generation time.
|
|
16
|
-
*/
|
|
17
|
-
export function getSystemPromptTemplate(slug: string, absoluteWorkspacePath: string): string {
|
|
18
|
-
return `You are the assistant for the Telegram topic: ${slug}.
|
|
19
|
-
|
|
20
|
-
Determinism rules:
|
|
21
|
-
- Source of truth is the project capsule at: ${absoluteWorkspacePath}/projects/${slug}/
|
|
22
|
-
- After /reset, /new, or context compaction: ALWAYS re-read STATUS.md,
|
|
23
|
-
then TODO.md, then COMMANDS.md before continuing work. Do not rely on
|
|
24
|
-
summarized memory for paths, commands, or task state.
|
|
25
|
-
- Before context compaction or when the conversation is long: proactively
|
|
26
|
-
flush current progress to STATUS.md (update "Last done (UTC)" and
|
|
27
|
-
"Next 3 actions") so compaction cannot erase critical state.
|
|
28
|
-
Use the standard file write tool directly — do not route through /topic.
|
|
29
|
-
- Keep STATUS.md accurate: always maintain "Last done (UTC)" and "Next 3 actions".
|
|
30
|
-
- When new commands appear, add them to COMMANDS.md (don't leave them only in chat).
|
|
31
|
-
- When new links/paths/services appear, add them to LINKS.md.
|
|
32
|
-
- If automation/cron is involved, record job IDs + schedules in CRON.md.
|
|
33
|
-
- Task IDs (e.g., [T-1]) must stay consistent between STATUS.md and TODO.md.
|
|
34
|
-
|
|
35
|
-
Separation:
|
|
36
|
-
- Do not mix in other topics' work unless explicitly requested.
|
|
37
|
-
- Ask one clarifying question if the next action is ambiguous.`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── Registry hash ──────────────────────────────────────────────────────
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Compute a SHA256 hash of the registry topics for drift detection.
|
|
44
|
-
*/
|
|
45
|
-
export function computeRegistryHash(topics: Registry['topics']): string {
|
|
46
|
-
const content = JSON.stringify(topics);
|
|
47
|
-
return crypto.createHash('sha256').update(content).digest('hex');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── Build include object ───────────────────────────────────────────────
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Build the JavaScript object for the generated include file.
|
|
54
|
-
* Groups topics by groupId, with each topic's config under its threadId.
|
|
55
|
-
*/
|
|
56
|
-
export function buildIncludeObject(
|
|
57
|
-
registry: Registry,
|
|
58
|
-
workspaceDir: string,
|
|
59
|
-
): Record<string, unknown> {
|
|
60
|
-
const absoluteWorkspacePath = path.resolve(workspaceDir);
|
|
61
|
-
const groups: Record<string, { topics: Record<string, unknown> }> = {};
|
|
62
|
-
|
|
63
|
-
for (const entry of Object.values(registry.topics)) {
|
|
64
|
-
const { groupId, threadId, slug, type, status } = entry;
|
|
65
|
-
|
|
66
|
-
if (!groups[groupId]) {
|
|
67
|
-
groups[groupId] = { topics: {} };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const isEnabled = status !== 'archived';
|
|
71
|
-
const skills = getSkillsForType(type);
|
|
72
|
-
const systemPrompt = getSystemPromptTemplate(slug, absoluteWorkspacePath);
|
|
73
|
-
|
|
74
|
-
groups[groupId].topics[threadId] = {
|
|
75
|
-
enabled: isEnabled,
|
|
76
|
-
skills,
|
|
77
|
-
systemPrompt,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return groups;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Get the default skills list for a topic type.
|
|
86
|
-
*/
|
|
87
|
-
function getSkillsForType(type: TopicType): string[] {
|
|
88
|
-
switch (type) {
|
|
89
|
-
case 'coding':
|
|
90
|
-
return ['coding-agent'];
|
|
91
|
-
case 'research':
|
|
92
|
-
return ['research-agent'];
|
|
93
|
-
case 'marketing':
|
|
94
|
-
return ['marketing-agent'];
|
|
95
|
-
default:
|
|
96
|
-
return [];
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ── Generate include file ──────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Generate the JSON5 include file from the registry.
|
|
104
|
-
*
|
|
105
|
-
* Steps:
|
|
106
|
-
* 1. Build JS object from registry entries
|
|
107
|
-
* 2. Serialize via JSON5.stringify (never string interpolation)
|
|
108
|
-
* 3. Parse back to verify round-trip integrity
|
|
109
|
-
* 4. Atomic write with .bak
|
|
110
|
-
* 5. Prepend registry-hash comment
|
|
111
|
-
*/
|
|
112
|
-
export function generateInclude(
|
|
113
|
-
workspaceDir: string,
|
|
114
|
-
registry: Registry,
|
|
115
|
-
configDir: string,
|
|
116
|
-
): void {
|
|
117
|
-
const includeObj = buildIncludeObject(registry, workspaceDir);
|
|
118
|
-
const hash = computeRegistryHash(registry.topics);
|
|
119
|
-
|
|
120
|
-
// Serialize via JSON5.stringify
|
|
121
|
-
const json5Content = JSON5.stringify(includeObj, null, 2);
|
|
122
|
-
|
|
123
|
-
// Round-trip validation: parse back to verify integrity
|
|
124
|
-
try {
|
|
125
|
-
JSON5.parse(json5Content);
|
|
126
|
-
} catch (err) {
|
|
127
|
-
throw new Error(
|
|
128
|
-
`Include generation failed: round-trip validation error. ${err instanceof Error ? err.message : String(err)}`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Build final content with header comment
|
|
133
|
-
const header = [
|
|
134
|
-
'// This file is generated by telegram-manager. Do not hand-edit.',
|
|
135
|
-
`// Rebuild from: ${path.resolve(workspaceDir)}/projects/topics.json`,
|
|
136
|
-
`// registry-hash: sha256:${hash}`,
|
|
137
|
-
].join('\n');
|
|
138
|
-
|
|
139
|
-
const finalContent = header + '\n' + json5Content + '\n';
|
|
140
|
-
|
|
141
|
-
// Atomic write
|
|
142
|
-
const includePath = path.join(configDir, INCLUDE_FILENAME);
|
|
143
|
-
const tmpPath = includePath + '.tmp';
|
|
144
|
-
const bakPath = includePath + '.bak';
|
|
145
|
-
|
|
146
|
-
// Backup existing file if it exists
|
|
147
|
-
if (fs.existsSync(includePath)) {
|
|
148
|
-
fs.copyFileSync(includePath, bakPath);
|
|
149
|
-
fs.chmodSync(bakPath, FILE_MODE);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Write to tmp then rename (atomic on POSIX)
|
|
153
|
-
fs.writeFileSync(tmpPath, finalContent, { mode: FILE_MODE });
|
|
154
|
-
fs.renameSync(tmpPath, includePath);
|
|
155
|
-
fs.chmodSync(includePath, FILE_MODE);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ── Extract registry hash from include file ────────────────────────────
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Extract the registry-hash from an existing include file's content.
|
|
162
|
-
* Returns the hash string or null if not found.
|
|
163
|
-
*/
|
|
164
|
-
export function extractRegistryHash(includeContent: string): string | null {
|
|
165
|
-
const match = includeContent.match(/^\/\/ registry-hash: sha256:([a-f0-9]+)$/m);
|
|
166
|
-
return match?.[1] ?? null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Get the path to the generated include file.
|
|
171
|
-
*/
|
|
172
|
-
export function includePath(configDir: string): string {
|
|
173
|
-
return path.join(configDir, INCLUDE_FILENAME);
|
|
174
|
-
}
|
package/src/lib/registry.ts
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs';
|
|
2
|
-
import * as path from 'node:path';
|
|
3
|
-
import lockfile from 'proper-lockfile';
|
|
4
|
-
import { Value } from '@sinclair/typebox/value';
|
|
5
|
-
import {
|
|
6
|
-
RegistrySchema,
|
|
7
|
-
TopicEntrySchema,
|
|
8
|
-
CURRENT_REGISTRY_VERSION,
|
|
9
|
-
MAX_TOPICS_DEFAULT,
|
|
10
|
-
} from './types.js';
|
|
11
|
-
import type { Registry, TopicEntry } from './types.js';
|
|
12
|
-
|
|
13
|
-
// ── Constants ──────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
const REGISTRY_FILENAME = 'topics.json';
|
|
16
|
-
const FILE_MODE = 0o600;
|
|
17
|
-
const LOCK_TIMEOUT = 5000;
|
|
18
|
-
const LOCK_RETRY_INTERVAL = 100;
|
|
19
|
-
|
|
20
|
-
// ── Path helpers ───────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
export function registryPath(workspaceDir: string): string {
|
|
23
|
-
return path.join(workspaceDir, 'projects', REGISTRY_FILENAME);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ── Schema migration pipeline ──────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
type MigrationFn = (data: Record<string, unknown>) => Record<string, unknown>;
|
|
29
|
-
|
|
30
|
-
const migrations: Record<string, MigrationFn> = {
|
|
31
|
-
// Add migrations here as needed:
|
|
32
|
-
// '1_to_2': (data) => { ... return data; },
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
function migrateRegistry(data: Record<string, unknown>): Record<string, unknown> {
|
|
36
|
-
const rawVersion = data['version'];
|
|
37
|
-
if (typeof rawVersion !== 'number') {
|
|
38
|
-
throw new Error('Registry missing or invalid version field in migration');
|
|
39
|
-
}
|
|
40
|
-
let version = rawVersion;
|
|
41
|
-
|
|
42
|
-
while (version < CURRENT_REGISTRY_VERSION) {
|
|
43
|
-
const key = `${version}_to_${version + 1}`;
|
|
44
|
-
const fn = migrations[key];
|
|
45
|
-
if (!fn) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
`No migration function found for ${key}. Cannot upgrade registry from v${version}.`,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
data = fn(data);
|
|
51
|
-
version++;
|
|
52
|
-
data['version'] = version;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return data;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ── Read ───────────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Read and validate the registry from disk.
|
|
62
|
-
* - Migrates if version is behind current
|
|
63
|
-
* - Rejects if version is ahead of current
|
|
64
|
-
* - Quarantines invalid topic entries (logs + excludes)
|
|
65
|
-
*/
|
|
66
|
-
export function readRegistry(workspaceDir: string): Registry {
|
|
67
|
-
const regPath = registryPath(workspaceDir);
|
|
68
|
-
const raw = fs.readFileSync(regPath, 'utf-8');
|
|
69
|
-
let data: Record<string, unknown>;
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
data = JSON.parse(raw) as Record<string, unknown>;
|
|
73
|
-
} catch {
|
|
74
|
-
throw new Error(`Failed to parse registry at ${regPath}: invalid JSON`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Version check
|
|
78
|
-
const version = data['version'];
|
|
79
|
-
if (typeof version !== 'number') {
|
|
80
|
-
throw new Error('Registry missing version field');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (version > CURRENT_REGISTRY_VERSION) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`Registry version ${version} is newer than this plugin supports (v${CURRENT_REGISTRY_VERSION}). Please upgrade openclaw-telegram-manager.`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Migrate if needed
|
|
90
|
-
if (version < CURRENT_REGISTRY_VERSION) {
|
|
91
|
-
data = migrateRegistry(data);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Quarantine invalid topic entries
|
|
95
|
-
const topics = data['topics'];
|
|
96
|
-
if (topics && typeof topics === 'object' && !Array.isArray(topics)) {
|
|
97
|
-
const validTopics: Record<string, TopicEntry> = {};
|
|
98
|
-
for (const [key, entry] of Object.entries(topics as Record<string, unknown>)) {
|
|
99
|
-
if (Value.Check(TopicEntrySchema, entry)) {
|
|
100
|
-
validTopics[key] = entry as TopicEntry;
|
|
101
|
-
} else {
|
|
102
|
-
const errors = [...Value.Errors(TopicEntrySchema, entry)];
|
|
103
|
-
const errorMsg = errors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
104
|
-
console.error(`[registry] Quarantined invalid entry "${key}": ${errorMsg}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
data['topics'] = validTopics;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// Validate the full registry schema
|
|
111
|
-
if (!Value.Check(RegistrySchema, data)) {
|
|
112
|
-
const errors = [...Value.Errors(RegistrySchema, data)];
|
|
113
|
-
const errorMsg = errors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
114
|
-
throw new Error(`Registry validation failed: ${errorMsg}`);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return data as Registry;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ── Atomic write ───────────────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Atomically write registry data to disk.
|
|
124
|
-
* Writes to a .tmp file then renames (atomic on POSIX).
|
|
125
|
-
*/
|
|
126
|
-
export function writeRegistryAtomic(filePath: string, data: Registry): void {
|
|
127
|
-
const tmpPath = filePath + '.tmp';
|
|
128
|
-
const content = JSON.stringify(data, null, 2) + '\n';
|
|
129
|
-
|
|
130
|
-
fs.writeFileSync(tmpPath, content, { mode: FILE_MODE });
|
|
131
|
-
fs.renameSync(tmpPath, filePath);
|
|
132
|
-
fs.chmodSync(filePath, FILE_MODE);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── withRegistry pattern ───────────────────────────────────────────────
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Lock the registry, read it, apply a mutation function, and write it back.
|
|
139
|
-
* The lock prevents concurrent writes from corrupting the registry.
|
|
140
|
-
*
|
|
141
|
-
* The mutation function receives the registry data and can modify it.
|
|
142
|
-
* Return value of the mutation function is passed through as the return value.
|
|
143
|
-
*/
|
|
144
|
-
export async function withRegistry<T>(
|
|
145
|
-
workspaceDir: string,
|
|
146
|
-
fn: (data: Registry) => T | Promise<T>,
|
|
147
|
-
): Promise<T> {
|
|
148
|
-
const regPath = registryPath(workspaceDir);
|
|
149
|
-
const lockDir = path.dirname(regPath);
|
|
150
|
-
|
|
151
|
-
// Ensure the registry file exists before locking
|
|
152
|
-
if (!fs.existsSync(regPath)) {
|
|
153
|
-
throw new Error(`Registry not found at ${regPath}. Run setup first.`);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
let release: (() => Promise<void>) | undefined;
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
release = await lockfile.lock(regPath, {
|
|
160
|
-
stale: LOCK_TIMEOUT * 2,
|
|
161
|
-
retries: {
|
|
162
|
-
retries: Math.ceil(LOCK_TIMEOUT / LOCK_RETRY_INTERVAL),
|
|
163
|
-
minTimeout: LOCK_RETRY_INTERVAL,
|
|
164
|
-
maxTimeout: LOCK_RETRY_INTERVAL,
|
|
165
|
-
},
|
|
166
|
-
lockfilePath: path.join(lockDir, REGISTRY_FILENAME + '.lock'),
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const data = readRegistry(workspaceDir);
|
|
170
|
-
const result = await fn(data);
|
|
171
|
-
|
|
172
|
-
// Write the (potentially mutated) registry back
|
|
173
|
-
writeRegistryAtomic(regPath, data);
|
|
174
|
-
|
|
175
|
-
return result;
|
|
176
|
-
} finally {
|
|
177
|
-
if (release) {
|
|
178
|
-
await release();
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ── Empty registry factory ─────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Create a new empty registry with default values.
|
|
187
|
-
*/
|
|
188
|
-
export function createEmptyRegistry(callbackSecret: string): Registry {
|
|
189
|
-
return {
|
|
190
|
-
version: CURRENT_REGISTRY_VERSION,
|
|
191
|
-
topicManagerAdmins: [],
|
|
192
|
-
callbackSecret,
|
|
193
|
-
lastDoctorAllRunAt: null,
|
|
194
|
-
maxTopics: MAX_TOPICS_DEFAULT,
|
|
195
|
-
topics: {},
|
|
196
|
-
};
|
|
197
|
-
}
|
package/src/lib/security.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import * as crypto from 'node:crypto';
|
|
2
|
-
import * as fs from 'node:fs';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
|
|
5
|
-
// ── Slug validation ────────────────────────────────────────────────────
|
|
6
|
-
|
|
7
|
-
const SLUG_RE = /^[a-z][a-z0-9-]{0,49}$/;
|
|
8
|
-
|
|
9
|
-
/** Validate a slug against the allowed pattern. */
|
|
10
|
-
export function validateSlug(slug: string): boolean {
|
|
11
|
-
return SLUG_RE.test(slug);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Sanitize a title into a valid slug.
|
|
16
|
-
* Strip non-alphanumeric (except hyphens), strip dots, collapse
|
|
17
|
-
* consecutive hyphens, trim leading/trailing hyphens, lowercase.
|
|
18
|
-
*/
|
|
19
|
-
export function sanitizeSlug(title: string): string {
|
|
20
|
-
return title
|
|
21
|
-
.toLowerCase()
|
|
22
|
-
.replace(/\./g, '') // strip dots explicitly
|
|
23
|
-
.replace(/[^a-z0-9-]/g, '-') // non-alphanum to hyphen
|
|
24
|
-
.replace(/-{2,}/g, '-') // collapse consecutive hyphens
|
|
25
|
-
.replace(/^-+|-+$/g, '') // trim leading/trailing hyphens
|
|
26
|
-
.slice(0, 50); // enforce max length
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// ── Path safety ────────────────────────────────────────────────────────
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Jail check: ensure `userPath` resolves within `base`.
|
|
33
|
-
* Returns true if safe, false if the path escapes the base.
|
|
34
|
-
*/
|
|
35
|
-
export function jailCheck(base: string, userPath: string): boolean {
|
|
36
|
-
const resolved = path.resolve(base, userPath);
|
|
37
|
-
const normalizedBase = path.resolve(base) + path.sep;
|
|
38
|
-
return resolved.startsWith(normalizedBase) || resolved === path.resolve(base);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Reject symlinks. Returns true if the path is a symlink (should be rejected).
|
|
43
|
-
* Returns false if not a symlink or path does not exist.
|
|
44
|
-
*/
|
|
45
|
-
export function rejectSymlink(filePath: string): boolean {
|
|
46
|
-
try {
|
|
47
|
-
return fs.lstatSync(filePath).isSymbolicLink();
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── HMAC signing / verification ────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Sign a payload with HMAC-SHA256, returning the first 16 hex chars.
|
|
57
|
-
* Truncated to 8 bytes (16 hex chars) to fit within Telegram's 64-byte
|
|
58
|
-
* callback_data limit. Online brute-force is infeasible due to Telegram rate limits.
|
|
59
|
-
*/
|
|
60
|
-
export function hmacSign(secret: string, payload: string): string {
|
|
61
|
-
return crypto
|
|
62
|
-
.createHmac('sha256', secret)
|
|
63
|
-
.update(payload)
|
|
64
|
-
.digest('hex')
|
|
65
|
-
.slice(0, 16);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Verify an HMAC signature using constant-time comparison.
|
|
70
|
-
* Returns true if the signature is valid.
|
|
71
|
-
*/
|
|
72
|
-
export function hmacVerify(secret: string, payload: string, signature: string): boolean {
|
|
73
|
-
const expected = hmacSign(secret, payload);
|
|
74
|
-
if (expected.length !== signature.length) return false;
|
|
75
|
-
try {
|
|
76
|
-
return crypto.timingSafeEqual(
|
|
77
|
-
Buffer.from(expected, 'utf8'),
|
|
78
|
-
Buffer.from(signature, 'utf8'),
|
|
79
|
-
);
|
|
80
|
-
} catch {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ── HTML escaping ──────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
-
const HTML_ESCAPE_MAP: Record<string, string> = {
|
|
88
|
-
'<': '<',
|
|
89
|
-
'>': '>',
|
|
90
|
-
'&': '&',
|
|
91
|
-
'"': '"',
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/** Escape HTML special characters for safe Telegram HTML output. */
|
|
95
|
-
export function htmlEscape(str: string): string {
|
|
96
|
-
return str.replace(/[<>&"]/g, (ch) => HTML_ESCAPE_MAP[ch] ?? ch);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ── ID validation ──────────────────────────────────────────────────────
|
|
100
|
-
|
|
101
|
-
const GROUP_ID_RE = /^-?\d+$/;
|
|
102
|
-
const THREAD_ID_RE = /^\d+$/;
|
|
103
|
-
|
|
104
|
-
/** Validate a Telegram group ID (may be negative). */
|
|
105
|
-
export function validateGroupId(id: string): boolean {
|
|
106
|
-
return GROUP_ID_RE.test(id);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Validate a Telegram thread ID (positive integer). */
|
|
110
|
-
export function validateThreadId(id: string): boolean {
|
|
111
|
-
return THREAD_ID_RE.test(id);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── Callback data handling ─────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
const CALLBACK_RE = /^tm:[a-z0-9]+:[a-z0-9-]+:-?\d+:\d+:[a-f0-9]+$/;
|
|
117
|
-
|
|
118
|
-
export interface CallbackData {
|
|
119
|
-
action: string;
|
|
120
|
-
slug: string;
|
|
121
|
-
groupId: string;
|
|
122
|
-
threadId: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Build callback data string with HMAC signature.
|
|
127
|
-
* Format: tm:<action>:<slug>:<groupId>:<threadId>:<hmac>
|
|
128
|
-
*/
|
|
129
|
-
export function buildCallbackData(
|
|
130
|
-
action: string,
|
|
131
|
-
slug: string,
|
|
132
|
-
groupId: string,
|
|
133
|
-
threadId: string,
|
|
134
|
-
secret: string,
|
|
135
|
-
): string {
|
|
136
|
-
const payload = `tm:${action}:${slug}:${groupId}:${threadId}`;
|
|
137
|
-
const sig = hmacSign(secret, payload);
|
|
138
|
-
return `${payload}:${sig}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Parse and verify callback data.
|
|
143
|
-
* Returns the parsed data or null if verification fails.
|
|
144
|
-
*
|
|
145
|
-
* Checks:
|
|
146
|
-
* 1. Format matches the expected regex
|
|
147
|
-
* 2. HMAC is valid
|
|
148
|
-
* 3. groupId and threadId match the context (prevents cross-topic tampering)
|
|
149
|
-
*/
|
|
150
|
-
export function parseAndVerifyCallback(
|
|
151
|
-
data: string,
|
|
152
|
-
secret: string,
|
|
153
|
-
contextGroupId: string,
|
|
154
|
-
contextThreadId: string,
|
|
155
|
-
): CallbackData | null {
|
|
156
|
-
if (!CALLBACK_RE.test(data)) return null;
|
|
157
|
-
|
|
158
|
-
const parts = data.split(':');
|
|
159
|
-
// tm : action : slug : groupId : threadId : hmac
|
|
160
|
-
if (parts.length !== 6) return null;
|
|
161
|
-
|
|
162
|
-
const [, action, slug, groupId, threadId, signature] = parts as [
|
|
163
|
-
string, string, string, string, string, string,
|
|
164
|
-
];
|
|
165
|
-
|
|
166
|
-
// Verify context match (prevent cross-topic tampering)
|
|
167
|
-
if (groupId !== contextGroupId || threadId !== contextThreadId) return null;
|
|
168
|
-
|
|
169
|
-
// Verify HMAC
|
|
170
|
-
const payload = `tm:${action}:${slug}:${groupId}:${threadId}`;
|
|
171
|
-
if (!hmacVerify(secret, payload, signature)) return null;
|
|
172
|
-
|
|
173
|
-
return { action, slug, groupId, threadId };
|
|
174
|
-
}
|