ompclaw 0.3.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/NOTICE +10 -0
- package/README.md +152 -0
- package/SECURITY.md +61 -0
- package/config.example.json +45 -0
- package/docs/guide.md +360 -0
- package/docs/rpc-service.md +240 -0
- package/package.json +93 -0
- package/src/api.ts +556 -0
- package/src/gateway-app.ts +393 -0
- package/src/gateway-config.ts +379 -0
- package/src/gateway-core.ts +410 -0
- package/src/gateway-scheduler.ts +425 -0
- package/src/gateway-store.ts +947 -0
- package/src/gateway-tools.ts +443 -0
- package/src/gateway-types.ts +290 -0
- package/src/inbox.ts +77 -0
- package/src/index.ts +13 -0
- package/src/markdown.ts +156 -0
- package/src/outbound.ts +353 -0
- package/src/rpc-cli.ts +408 -0
- package/src/rpc-client.ts +308 -0
- package/src/rpc-config.ts +70 -0
- package/src/rpc-profile.ts +215 -0
- package/src/rpc-protocol.ts +326 -0
- package/src/rpc-runtime.ts +875 -0
- package/src/rpc-service.ts +191 -0
- package/src/rpc-ui.ts +218 -0
- package/src/transports/telegram/adapter.ts +829 -0
- package/src/transports/websocket/adapter.ts +704 -0
- package/src/transports/websocket/protocol.ts +256 -0
- package/src/type-guards.ts +4 -0
- package/tsconfig.json +13 -0
package/package.json
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ompclaw",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "OmpClaw: authenticated remote access to one persistent OMP session",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/wolfiesch/ompclaw.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/wolfiesch/ompclaw#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/wolfiesch/ompclaw/issues"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"default": "./src/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"ompclaw": "src/rpc-cli.ts"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"omp",
|
|
33
|
+
"oh-my-pi",
|
|
34
|
+
"ompclaw",
|
|
35
|
+
"gateway",
|
|
36
|
+
"telegram",
|
|
37
|
+
"websocket",
|
|
38
|
+
"rpc",
|
|
39
|
+
"coding-agent"
|
|
40
|
+
],
|
|
41
|
+
"files": [
|
|
42
|
+
"src/index.ts",
|
|
43
|
+
"src/gateway-app.ts",
|
|
44
|
+
"src/gateway-config.ts",
|
|
45
|
+
"src/gateway-core.ts",
|
|
46
|
+
"src/gateway-store.ts",
|
|
47
|
+
"src/gateway-scheduler.ts",
|
|
48
|
+
"src/gateway-tools.ts",
|
|
49
|
+
"src/gateway-types.ts",
|
|
50
|
+
"src/rpc-cli.ts",
|
|
51
|
+
"src/rpc-client.ts",
|
|
52
|
+
"src/rpc-config.ts",
|
|
53
|
+
"src/rpc-protocol.ts",
|
|
54
|
+
"src/rpc-profile.ts",
|
|
55
|
+
"src/rpc-runtime.ts",
|
|
56
|
+
"src/rpc-service.ts",
|
|
57
|
+
"src/rpc-ui.ts",
|
|
58
|
+
"src/transports/telegram/adapter.ts",
|
|
59
|
+
"src/transports/websocket/adapter.ts",
|
|
60
|
+
"src/transports/websocket/protocol.ts",
|
|
61
|
+
"src/api.ts",
|
|
62
|
+
"src/inbox.ts",
|
|
63
|
+
"src/markdown.ts",
|
|
64
|
+
"src/outbound.ts",
|
|
65
|
+
"src/type-guards.ts",
|
|
66
|
+
"README.md",
|
|
67
|
+
"config.example.json",
|
|
68
|
+
"SECURITY.md",
|
|
69
|
+
"docs",
|
|
70
|
+
"NOTICE",
|
|
71
|
+
"CHANGELOG.md",
|
|
72
|
+
"tsconfig.json",
|
|
73
|
+
"LICENSE"
|
|
74
|
+
],
|
|
75
|
+
"scripts": {
|
|
76
|
+
"typecheck": "tsc --noEmit",
|
|
77
|
+
"test": "bun test",
|
|
78
|
+
"check": "bun run typecheck && bun run test",
|
|
79
|
+
"ompclaw": "bun ./src/rpc-cli.ts"
|
|
80
|
+
},
|
|
81
|
+
"devDependencies": {
|
|
82
|
+
"@oh-my-pi/pi-coding-agent": "^17.0.0",
|
|
83
|
+
"typescript": "^5.7.0",
|
|
84
|
+
"@types/node": "^22.0.0",
|
|
85
|
+
"@types/bun": "^1.2.0"
|
|
86
|
+
},
|
|
87
|
+
"overrides": {
|
|
88
|
+
"@opentelemetry/core": "2.8.0"
|
|
89
|
+
},
|
|
90
|
+
"dependencies": {
|
|
91
|
+
"croner": "10.0.1"
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// Raw Telegram Bot API client over Node/Bun `fetch` + `FormData` (zero runtime
|
|
2
|
+
// deps). Provides the request primitives (`tg`, `tgUpload`), the long-poll
|
|
3
|
+
// `Poller`, and a single-poller PID lock. No filesystem-layout knowledge beyond
|
|
4
|
+
// the lock path handed in by the caller.
|
|
5
|
+
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { linkSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
const API_BASE = "https://api.telegram.org/bot";
|
|
12
|
+
/** Base for bot file downloads: `${FILE_API_BASE}${token}/${file_path}`. */
|
|
13
|
+
export const FILE_API_BASE = "https://api.telegram.org/file/bot";
|
|
14
|
+
|
|
15
|
+
/** Minimal structural logger, satisfied by `pi.logger`. */
|
|
16
|
+
export interface Logger {
|
|
17
|
+
debug(msg: string): void;
|
|
18
|
+
info(msg: string): void;
|
|
19
|
+
warn(msg: string): void;
|
|
20
|
+
error(msg: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TgResponse<T> {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
result?: T;
|
|
26
|
+
description?: string;
|
|
27
|
+
error_code?: number;
|
|
28
|
+
parameters?: { retry_after?: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TgError extends Error {
|
|
32
|
+
readonly code: number;
|
|
33
|
+
readonly retryAfter?: number;
|
|
34
|
+
constructor(message: string, code: number, retryAfter?: number) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "TgError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.retryAfter = retryAfter;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Telegram's definitive signal that a locally saved forum topic no longer exists.
|
|
44
|
+
* Supergroup forums answer a gone topic with "message thread not found"; DM
|
|
45
|
+
* forum-topic mode answers with "TOPIC_ID_INVALID". Both mean the same thing —
|
|
46
|
+
* the topic is gone — so the delete/tidy and outbound-recovery paths treat either
|
|
47
|
+
* as idempotent success rather than a hard failure.
|
|
48
|
+
*/
|
|
49
|
+
export function isMissingThreadError(err: unknown): boolean {
|
|
50
|
+
return err instanceof TgError && err.code === 400 && /message thread not found|topic_id_invalid/i.test(err.message);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Attempts to re-send a request Telegram rate-limited before giving up on it. */
|
|
54
|
+
const RATE_LIMIT_RETRIES = 3;
|
|
55
|
+
/** Upper bound on one `retry_after` wait, so a long ban cannot stall a turn indefinitely. */
|
|
56
|
+
const MAX_RETRY_WAIT_MS = 30_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Run a Telegram request, waiting out `retry_after` when Telegram rate-limits
|
|
60
|
+
* it. Every text delivery path goes through this: without it a 429 on part 2 of
|
|
61
|
+
* a split message throws, the caller gives up, and the reader silently keeps a
|
|
62
|
+
* truncated reply. `sleep` is a test seam.
|
|
63
|
+
*/
|
|
64
|
+
export async function withRateLimit<T>(
|
|
65
|
+
op: () => Promise<T>,
|
|
66
|
+
options: { sleep?: (ms: number) => Promise<void>; log?: Logger; signal?: AbortSignal } = {},
|
|
67
|
+
): Promise<T> {
|
|
68
|
+
const pause = options.sleep ?? sleep;
|
|
69
|
+
for (let attempt = 0; ; attempt++) {
|
|
70
|
+
options.signal?.throwIfAborted();
|
|
71
|
+
try {
|
|
72
|
+
return await op();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
options.signal?.throwIfAborted();
|
|
75
|
+
const retryAfter = err instanceof TgError && (err.retryAfter != null || err.code === 429) ? (err.retryAfter ?? 1) : undefined;
|
|
76
|
+
if (retryAfter == null || attempt >= RATE_LIMIT_RETRIES) throw err;
|
|
77
|
+
options.log?.debug(`[telegram] rate limited — retrying in ${retryAfter}s`);
|
|
78
|
+
const wait = pause(Math.min(retryAfter * 1000 + 250, MAX_RETRY_WAIT_MS));
|
|
79
|
+
if (!options.signal) await wait;
|
|
80
|
+
else {
|
|
81
|
+
await Promise.race([
|
|
82
|
+
wait,
|
|
83
|
+
new Promise<never>((_, reject) => options.signal!.addEventListener("abort", () => reject(options.signal!.reason), { once: true })),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- Wire types (only the fields we read) --------------------------------
|
|
91
|
+
|
|
92
|
+
export interface TgUser {
|
|
93
|
+
id: number;
|
|
94
|
+
is_bot?: boolean;
|
|
95
|
+
username?: string;
|
|
96
|
+
first_name?: string;
|
|
97
|
+
}
|
|
98
|
+
export interface TgChat {
|
|
99
|
+
id: number;
|
|
100
|
+
type: string;
|
|
101
|
+
title?: string;
|
|
102
|
+
}
|
|
103
|
+
export interface TgMessageEntity {
|
|
104
|
+
type: string;
|
|
105
|
+
offset: number;
|
|
106
|
+
length: number;
|
|
107
|
+
user?: TgUser;
|
|
108
|
+
}
|
|
109
|
+
export interface TgPhotoSize {
|
|
110
|
+
file_id: string;
|
|
111
|
+
file_unique_id: string;
|
|
112
|
+
width: number;
|
|
113
|
+
height: number;
|
|
114
|
+
file_size?: number;
|
|
115
|
+
}
|
|
116
|
+
interface TgFileBase {
|
|
117
|
+
file_id: string;
|
|
118
|
+
file_unique_id: string;
|
|
119
|
+
file_size?: number;
|
|
120
|
+
}
|
|
121
|
+
export interface TgDocument extends TgFileBase {
|
|
122
|
+
file_name?: string;
|
|
123
|
+
mime_type?: string;
|
|
124
|
+
}
|
|
125
|
+
export interface TgVoice extends TgFileBase {
|
|
126
|
+
mime_type?: string;
|
|
127
|
+
}
|
|
128
|
+
export interface TgAudio extends TgFileBase {
|
|
129
|
+
file_name?: string;
|
|
130
|
+
mime_type?: string;
|
|
131
|
+
title?: string;
|
|
132
|
+
}
|
|
133
|
+
export interface TgVideo extends TgFileBase {
|
|
134
|
+
file_name?: string;
|
|
135
|
+
mime_type?: string;
|
|
136
|
+
}
|
|
137
|
+
export type TgVideoNote = TgFileBase;
|
|
138
|
+
export interface TgSticker extends TgFileBase {
|
|
139
|
+
emoji?: string;
|
|
140
|
+
}
|
|
141
|
+
export interface TgMessage {
|
|
142
|
+
message_id: number;
|
|
143
|
+
date: number;
|
|
144
|
+
text?: string;
|
|
145
|
+
caption?: string;
|
|
146
|
+
entities?: TgMessageEntity[];
|
|
147
|
+
caption_entities?: TgMessageEntity[];
|
|
148
|
+
chat: TgChat;
|
|
149
|
+
from?: TgUser;
|
|
150
|
+
reply_to_message?: TgMessage;
|
|
151
|
+
photo?: TgPhotoSize[];
|
|
152
|
+
document?: TgDocument;
|
|
153
|
+
voice?: TgVoice;
|
|
154
|
+
audio?: TgAudio;
|
|
155
|
+
video?: TgVideo;
|
|
156
|
+
video_note?: TgVideoNote;
|
|
157
|
+
sticker?: TgSticker;
|
|
158
|
+
media_group_id?: string;
|
|
159
|
+
is_topic_message?: boolean;
|
|
160
|
+
message_thread_id?: number;
|
|
161
|
+
/** Internal spool sentinel; not a Bot API field. */
|
|
162
|
+
edited_flag?: true;
|
|
163
|
+
}
|
|
164
|
+
export interface TgCallbackQuery {
|
|
165
|
+
id: string;
|
|
166
|
+
from: TgUser;
|
|
167
|
+
message?: Pick<TgMessage, "message_id" | "chat" | "is_topic_message" | "message_thread_id">;
|
|
168
|
+
data?: string;
|
|
169
|
+
}
|
|
170
|
+
export interface TgUpdate {
|
|
171
|
+
update_id: number;
|
|
172
|
+
message?: TgMessage;
|
|
173
|
+
edited_message?: TgMessage;
|
|
174
|
+
callback_query?: TgCallbackQuery;
|
|
175
|
+
}
|
|
176
|
+
export interface TgFile {
|
|
177
|
+
file_id: string;
|
|
178
|
+
file_unique_id: string;
|
|
179
|
+
file_size?: number;
|
|
180
|
+
file_path?: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Requests ------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** POST a JSON Bot API method. Throws {@link TgError} on a non-ok response. */
|
|
186
|
+
export async function tg<T>(
|
|
187
|
+
token: string,
|
|
188
|
+
method: string,
|
|
189
|
+
payload?: Record<string, unknown>,
|
|
190
|
+
opts?: { timeoutMs?: number; signal?: AbortSignal },
|
|
191
|
+
): Promise<T> {
|
|
192
|
+
const timeout = AbortSignal.timeout(opts?.timeoutMs ?? 30_000);
|
|
193
|
+
const signal = opts?.signal ? AbortSignal.any([timeout, opts.signal]) : timeout;
|
|
194
|
+
const res = await fetch(`${API_BASE}${token}/${method}`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "content-type": "application/json" },
|
|
197
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
198
|
+
signal,
|
|
199
|
+
});
|
|
200
|
+
const data = (await res.json()) as TgResponse<T>;
|
|
201
|
+
if (!data.ok) {
|
|
202
|
+
throw new TgError(data.description ?? `Telegram ${method} failed`, data.error_code ?? res.status, data.parameters?.retry_after);
|
|
203
|
+
}
|
|
204
|
+
return data.result as T;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Diagnose persistent getUpdates conflicts without changing webhook state. */
|
|
208
|
+
export async function webhookConflictHint(token: string): Promise<string | undefined> {
|
|
209
|
+
const info = await tg<{ url?: string }>(token, "getWebhookInfo");
|
|
210
|
+
const url = info.url?.trim();
|
|
211
|
+
return url ? `a webhook is set on this token (${url}) — delete it (deleteWebhook) or use a different token` : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Multipart upload (sendPhoto/sendDocument). 120s default timeout. */
|
|
215
|
+
export async function tgUpload<T>(
|
|
216
|
+
token: string,
|
|
217
|
+
method: string,
|
|
218
|
+
fields: Record<string, string | number | undefined>,
|
|
219
|
+
file: { field: string; path: string; filename?: string },
|
|
220
|
+
timeoutMs = 120_000,
|
|
221
|
+
callerSignal?: AbortSignal,
|
|
222
|
+
): Promise<T> {
|
|
223
|
+
callerSignal?.throwIfAborted();
|
|
224
|
+
const form = new FormData();
|
|
225
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
226
|
+
if (v !== undefined) form.append(k, String(v));
|
|
227
|
+
}
|
|
228
|
+
const source = await readFile(file.path);
|
|
229
|
+
callerSignal?.throwIfAborted();
|
|
230
|
+
// `Buffer` may expose a SharedArrayBuffer-compatible backing store. Copy it
|
|
231
|
+
// once into an owned ArrayBuffer-backed view, which BlobPart accepts.
|
|
232
|
+
const bytes = new Uint8Array(source.byteLength);
|
|
233
|
+
bytes.set(source);
|
|
234
|
+
form.append(file.field, new Blob([bytes]), file.filename ?? basename(file.path));
|
|
235
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
236
|
+
const signal = callerSignal ? AbortSignal.any([timeout, callerSignal]) : timeout;
|
|
237
|
+
const res = await fetch(`${API_BASE}${token}/${method}`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
body: form,
|
|
240
|
+
signal,
|
|
241
|
+
});
|
|
242
|
+
const data = (await res.json()) as TgResponse<T>;
|
|
243
|
+
if (!data.ok) {
|
|
244
|
+
throw new TgError(data.description ?? `Telegram ${method} failed`, data.error_code ?? res.status, data.parameters?.retry_after);
|
|
245
|
+
}
|
|
246
|
+
return data.result as T;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Download a bot file's bytes by its Telegram `file_path`. */
|
|
250
|
+
export async function downloadFileBytes(token: string, filePath: string, timeoutMs = 120_000): Promise<Uint8Array> {
|
|
251
|
+
const res = await fetch(`${FILE_API_BASE}${token}/${filePath}`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
252
|
+
if (!res.ok) throw new TgError(`file download failed: HTTP ${res.status}`, res.status);
|
|
253
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- Single-poller lock --------------------------------------------------
|
|
257
|
+
|
|
258
|
+
/** Owner heartbeat cadence; refreshes lock mtime while polling. */
|
|
259
|
+
export const LOCK_HEARTBEAT_MS = 15_000;
|
|
260
|
+
/** A lock with mtime younger than this is live regardless of the pid probe. */
|
|
261
|
+
export const LOCK_FRESH_MS = 45_000;
|
|
262
|
+
/** Per-process identity that disambiguates equal numeric PIDs across namespaces. */
|
|
263
|
+
const PROCESS_LOCK_NONCE = randomBytes(16).toString("hex");
|
|
264
|
+
|
|
265
|
+
export interface LockOwner {
|
|
266
|
+
pid: number;
|
|
267
|
+
startedAt: number;
|
|
268
|
+
nonce?: string;
|
|
269
|
+
name?: string;
|
|
270
|
+
sessionId?: string;
|
|
271
|
+
sessionFile?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Whether a PID is a live process (EPERM means it exists but is owned elsewhere). */
|
|
275
|
+
function pidAlive(pid: number): boolean {
|
|
276
|
+
try {
|
|
277
|
+
process.kill(pid, 0);
|
|
278
|
+
return true;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function parseLockOwner(content: string): LockOwner | undefined {
|
|
285
|
+
const trimmed = content.trim();
|
|
286
|
+
if (!trimmed) return undefined;
|
|
287
|
+
const newline = trimmed.indexOf("\n");
|
|
288
|
+
const firstLine = newline === -1 ? trimmed : trimmed.slice(0, newline);
|
|
289
|
+
const pid = Number.parseInt(firstLine, 10) || 0;
|
|
290
|
+
if (pid <= 0) return undefined;
|
|
291
|
+
if (newline !== -1) {
|
|
292
|
+
try {
|
|
293
|
+
const owner: unknown = JSON.parse(trimmed.slice(newline + 1));
|
|
294
|
+
if (owner && typeof owner === "object" && (owner as LockOwner).pid === pid) return owner as LockOwner;
|
|
295
|
+
} catch {
|
|
296
|
+
// Fall back to the first-line pid for malformed v2 records.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { pid, startedAt: 0 };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Parse the lock's owner record. Legacy bare-pid locks yield `{ pid, startedAt: 0 }`. */
|
|
303
|
+
export function readLockOwner(lockPath: string): LockOwner | undefined {
|
|
304
|
+
try {
|
|
305
|
+
return parseLockOwner(readFileSync(lockPath, "utf8"));
|
|
306
|
+
} catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function lockOwnedBy(lockPath: string, pid: number, nonce: string): boolean {
|
|
312
|
+
const owner = readLockOwner(lockPath);
|
|
313
|
+
return owner?.pid === pid && owner.nonce === nonce;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function lockFresh(lockPath: string, freshMs: number): boolean {
|
|
317
|
+
try {
|
|
318
|
+
return Date.now() - statSync(lockPath).mtimeMs < freshMs;
|
|
319
|
+
} catch {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Atomically create `target` by hard-linking a pid-stamped temp into place.
|
|
326
|
+
* `link` fails with EEXIST if the target already exists, so the filesystem —
|
|
327
|
+
* not a racy read-then-write — decides the single winner, and the target is
|
|
328
|
+
* populated the instant it appears (no empty mid-write window).
|
|
329
|
+
*/
|
|
330
|
+
/**
|
|
331
|
+
* Create `target` atomically, or report that somebody else already has.
|
|
332
|
+
*
|
|
333
|
+
* Exported so the thread registry can serialise its read-modify-write on the
|
|
334
|
+
* same primitive the poll lock uses (#68): two writers racing a shared
|
|
335
|
+
* `threads.json.tmp` published each other's file and silently lost claims.
|
|
336
|
+
*/
|
|
337
|
+
export function linkClaim(target: string, pid: number, content: string = String(pid)): boolean {
|
|
338
|
+
const temp = `${target}.${pid}.${randomBytes(6).toString("hex")}`;
|
|
339
|
+
writeFileSync(temp, content, { mode: 0o600 });
|
|
340
|
+
try {
|
|
341
|
+
linkSync(temp, target);
|
|
342
|
+
return true;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
345
|
+
return false;
|
|
346
|
+
} finally {
|
|
347
|
+
rmSync(temp, { force: true });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A reaper lock older than this is treated as abandoned by a crashed reclaimer. */
|
|
352
|
+
const REAPER_TTL_MS = 10_000;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Claim the poll lock at `lockPath`. Fails when a foreign owner has a live PID
|
|
356
|
+
* or a fresh heartbeat; a crashed owner is reclaimable after `LOCK_FRESH_MS`.
|
|
357
|
+
* A per-process nonce disambiguates equal numeric PIDs across PID namespaces.
|
|
358
|
+
* DM chat_id == user_id, so exactly one poller per token is required — Telegram
|
|
359
|
+
* rejects concurrent getUpdates with 409.
|
|
360
|
+
*
|
|
361
|
+
* `pid`/`nonce`/`alive`/`freshMs` are injectable for tests; production uses this
|
|
362
|
+
* process, its random nonce, a real liveness probe, and the default freshness.
|
|
363
|
+
*/
|
|
364
|
+
export function acquireLock(
|
|
365
|
+
lockPath: string,
|
|
366
|
+
options: {
|
|
367
|
+
pid?: number;
|
|
368
|
+
nonce?: string;
|
|
369
|
+
alive?: (pid: number) => boolean;
|
|
370
|
+
identity?: { name?: string; sessionId?: string; sessionFile?: string };
|
|
371
|
+
freshMs?: number;
|
|
372
|
+
} = {},
|
|
373
|
+
): { ok: true } | { ok: false; holder: number; owner?: LockOwner } {
|
|
374
|
+
const pid = options.pid ?? process.pid;
|
|
375
|
+
const nonce = options.nonce ?? PROCESS_LOCK_NONCE;
|
|
376
|
+
const alive = options.alive ?? pidAlive;
|
|
377
|
+
const freshMs = options.freshMs ?? LOCK_FRESH_MS;
|
|
378
|
+
const content = `${pid}\n${JSON.stringify({ ...options.identity, pid, startedAt: Date.now(), nonce })}`;
|
|
379
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
380
|
+
|
|
381
|
+
// Fast path: atomically claim an absent lock. Simultaneous starters can never
|
|
382
|
+
// both win here (the previous read-then-write could).
|
|
383
|
+
if (linkClaim(lockPath, pid, content)) return { ok: true };
|
|
384
|
+
let owner = readLockOwner(lockPath);
|
|
385
|
+
let holder = owner?.pid ?? 0;
|
|
386
|
+
if (holder === pid && owner?.pid === pid && owner.nonce === nonce) return { ok: true };
|
|
387
|
+
if (holder > 0 && (alive(holder) || lockFresh(lockPath, freshMs))) {
|
|
388
|
+
return { ok: false, holder, owner };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Stale (dead holder) or garbage lock. Reclaim under an exclusive reaper so two
|
|
392
|
+
// starters can't both unlink and re-link, clobbering each other's fresh claim.
|
|
393
|
+
// A reaper abandoned by a crashed reclaimer is cleared by age; a fresh one means
|
|
394
|
+
// another starter is mid-reclaim, so report the lock as held.
|
|
395
|
+
const reapPath = `${lockPath}.reap`;
|
|
396
|
+
if (!linkClaim(reapPath, pid, content)) {
|
|
397
|
+
let mtime = 0;
|
|
398
|
+
try {
|
|
399
|
+
mtime = statSync(reapPath).mtimeMs;
|
|
400
|
+
} catch {
|
|
401
|
+
mtime = 0;
|
|
402
|
+
}
|
|
403
|
+
if (Date.now() - mtime < REAPER_TTL_MS) {
|
|
404
|
+
const current = holder > 0 ? holder : 0;
|
|
405
|
+
return { ok: false, holder: current, owner: readLockOwner(lockPath) };
|
|
406
|
+
}
|
|
407
|
+
rmSync(reapPath, { force: true });
|
|
408
|
+
if (!linkClaim(reapPath, pid, content)) {
|
|
409
|
+
owner = readLockOwner(lockPath);
|
|
410
|
+
const current = owner?.pid ?? (holder > 0 ? holder : 0);
|
|
411
|
+
return { ok: false, holder: current, owner };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
// Only the exact reaper owner may touch the main lock. If we lost the reaper,
|
|
416
|
+
// bail rather than race the new owner. Then re-validate the main owner in case
|
|
417
|
+
// it was reclaimed while we waited.
|
|
418
|
+
if (!lockOwnedBy(reapPath, pid, nonce)) {
|
|
419
|
+
owner = readLockOwner(lockPath);
|
|
420
|
+
const current = owner?.pid ?? (holder > 0 ? holder : 0);
|
|
421
|
+
return { ok: false, holder: current, owner };
|
|
422
|
+
}
|
|
423
|
+
owner = readLockOwner(lockPath);
|
|
424
|
+
holder = owner?.pid ?? 0;
|
|
425
|
+
if (holder === pid && owner?.pid === pid && owner.nonce === nonce) return { ok: true };
|
|
426
|
+
if (holder > 0 && (alive(holder) || lockFresh(lockPath, freshMs))) {
|
|
427
|
+
return { ok: false, holder, owner };
|
|
428
|
+
}
|
|
429
|
+
rmSync(lockPath, { force: true });
|
|
430
|
+
if (linkClaim(lockPath, pid, content)) return { ok: true };
|
|
431
|
+
owner = readLockOwner(lockPath);
|
|
432
|
+
return { ok: false, holder: owner?.pid ?? 0, owner };
|
|
433
|
+
} finally {
|
|
434
|
+
// Release the reaper only if this process still owns its exact nonce.
|
|
435
|
+
if (lockOwnedBy(reapPath, pid, nonce)) rmSync(reapPath, { force: true });
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Release the lock only if this exact process nonce still owns it. */
|
|
440
|
+
export function releaseLock(
|
|
441
|
+
lockPath: string,
|
|
442
|
+
pid: number = process.pid,
|
|
443
|
+
nonce: string = PROCESS_LOCK_NONCE,
|
|
444
|
+
): void {
|
|
445
|
+
if (lockOwnedBy(lockPath, pid, nonce)) rmSync(lockPath, { force: true });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Refresh the lock mtime while this exact process nonce owns it. */
|
|
449
|
+
export function startLockHeartbeat(
|
|
450
|
+
lockPath: string,
|
|
451
|
+
pid: number = process.pid,
|
|
452
|
+
intervalMs: number = LOCK_HEARTBEAT_MS,
|
|
453
|
+
nonce: string = PROCESS_LOCK_NONCE,
|
|
454
|
+
): () => void {
|
|
455
|
+
const timer = setInterval(() => {
|
|
456
|
+
if (!lockOwnedBy(lockPath, pid, nonce)) return;
|
|
457
|
+
try {
|
|
458
|
+
const now = new Date();
|
|
459
|
+
utimesSync(lockPath, now, now);
|
|
460
|
+
} catch {
|
|
461
|
+
// Lock vanished.
|
|
462
|
+
}
|
|
463
|
+
}, intervalMs);
|
|
464
|
+
timer.unref?.();
|
|
465
|
+
return () => clearInterval(timer);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
469
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
470
|
+
if (signal?.aborted) return Promise.resolve();
|
|
471
|
+
const timer = setTimeout(resolve, ms);
|
|
472
|
+
timer.unref?.();
|
|
473
|
+
signal?.addEventListener(
|
|
474
|
+
"abort",
|
|
475
|
+
() => {
|
|
476
|
+
clearTimeout(timer);
|
|
477
|
+
resolve();
|
|
478
|
+
},
|
|
479
|
+
{ once: true },
|
|
480
|
+
);
|
|
481
|
+
return promise;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---- Long-poll loop ------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
export type UpdateHandler = (update: TgUpdate) => void | Promise<void>;
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Long-poll `getUpdates`. A handler throw never stops polling (logged). Retry
|
|
490
|
+
* ladder: `delay = min(1000 * attempt, 15000)`, reset on success; persistent
|
|
491
|
+
* 409 (8 attempts) → `onFatal` and stop. `stop()` aborts the in-flight fetch.
|
|
492
|
+
*/
|
|
493
|
+
export class Poller {
|
|
494
|
+
#running = false;
|
|
495
|
+
#abort: AbortController | undefined;
|
|
496
|
+
#loop: Promise<void> | undefined;
|
|
497
|
+
|
|
498
|
+
get running(): boolean {
|
|
499
|
+
return this.#running;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
start(token: string, onUpdate: UpdateHandler, onFatal: (reason: string) => void, log?: Logger): void {
|
|
503
|
+
if (this.#running) return;
|
|
504
|
+
this.#running = true;
|
|
505
|
+
this.#abort = new AbortController();
|
|
506
|
+
this.#loop = this.#run(token, onUpdate, onFatal, log);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async #run(token: string, onUpdate: UpdateHandler, onFatal: (reason: string) => void, log?: Logger): Promise<void> {
|
|
510
|
+
let offset = 0;
|
|
511
|
+
let attempt = 0;
|
|
512
|
+
while (this.#running) {
|
|
513
|
+
try {
|
|
514
|
+
const updates = await tg<TgUpdate[]>(
|
|
515
|
+
token,
|
|
516
|
+
"getUpdates",
|
|
517
|
+
{ offset, timeout: 30, allowed_updates: ["message", "edited_message", "callback_query"] },
|
|
518
|
+
{ timeoutMs: 40_000, signal: this.#abort!.signal },
|
|
519
|
+
);
|
|
520
|
+
attempt = 0;
|
|
521
|
+
for (const u of updates) {
|
|
522
|
+
if (u.update_id >= offset) offset = u.update_id + 1;
|
|
523
|
+
try {
|
|
524
|
+
await onUpdate(u);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
log?.warn(`[telegram] update handler error: ${String(err)}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
if (!this.#running) return; // stop() aborted the fetch — clean exit
|
|
531
|
+
attempt += 1;
|
|
532
|
+
const is409 = err instanceof TgError && err.code === 409;
|
|
533
|
+
if (is409 && attempt >= 8) {
|
|
534
|
+
onFatal("409 Conflict — another poller holds this token");
|
|
535
|
+
this.#running = false;
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const delay = Math.min(1000 * attempt, 15_000);
|
|
539
|
+
log?.debug(`[telegram] poll error (attempt ${attempt}), retry in ${delay}ms: ${String(err)}`);
|
|
540
|
+
await sleep(delay, this.#abort!.signal);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Signal the loop to stop and abort any in-flight request. Awaitable via {@link done}. */
|
|
546
|
+
stop(): void {
|
|
547
|
+
if (!this.#running) return;
|
|
548
|
+
this.#running = false;
|
|
549
|
+
this.#abort?.abort();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Resolves when the loop has fully exited. */
|
|
553
|
+
done(): Promise<void> {
|
|
554
|
+
return this.#loop ?? Promise.resolve();
|
|
555
|
+
}
|
|
556
|
+
}
|