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/src/outbound.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { TgError, tg, tgUpload, type Logger, withRateLimit } from "./api";
|
|
5
|
+
import type {
|
|
6
|
+
ConversationAddress,
|
|
7
|
+
DeliveryContext,
|
|
8
|
+
OutboundContent,
|
|
9
|
+
OutboundReceipt,
|
|
10
|
+
Reaction,
|
|
11
|
+
} from "./gateway-types";
|
|
12
|
+
import { TELEGRAM_MAX_CHARS, chunkLabeled, mdToMarkdownV2 } from "./markdown";
|
|
13
|
+
|
|
14
|
+
const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
|
|
15
|
+
const PHOTO_EXTENSIONS: Record<string, true> = {
|
|
16
|
+
".avif": true,
|
|
17
|
+
".gif": true,
|
|
18
|
+
".jpeg": true,
|
|
19
|
+
".jpg": true,
|
|
20
|
+
".png": true,
|
|
21
|
+
".webp": true,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface TelegramRequestOptions {
|
|
25
|
+
readonly signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Injectable Bot API boundary. Tests should inject this instead of replacing global fetch. */
|
|
29
|
+
export type TelegramCall = (
|
|
30
|
+
method: string,
|
|
31
|
+
payload?: Record<string, unknown>,
|
|
32
|
+
options?: TelegramRequestOptions,
|
|
33
|
+
) => Promise<unknown>;
|
|
34
|
+
|
|
35
|
+
export type TelegramUpload = (
|
|
36
|
+
method: string,
|
|
37
|
+
fields: Record<string, string | number | undefined>,
|
|
38
|
+
file: { readonly field: string; readonly path: string; readonly filename?: string },
|
|
39
|
+
options?: TelegramRequestOptions,
|
|
40
|
+
) => Promise<unknown>;
|
|
41
|
+
|
|
42
|
+
/** Server-side delivery guard; Telegram addresses must never be selected by tool input. */
|
|
43
|
+
export type TelegramAddressAuthorizer = (
|
|
44
|
+
address: ConversationAddress,
|
|
45
|
+
context: DeliveryContext,
|
|
46
|
+
) => boolean | void | Promise<boolean | void>;
|
|
47
|
+
|
|
48
|
+
export interface TelegramMessageOptions {
|
|
49
|
+
readonly replyMarkup?: Record<string, unknown>;
|
|
50
|
+
readonly replyTo?: OutboundReceipt;
|
|
51
|
+
readonly parseMode?: "MarkdownV2";
|
|
52
|
+
/** Unformatted source used only when Telegram rejects MarkdownV2 parsing. */
|
|
53
|
+
readonly plainFallbackText?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OutboundOptions {
|
|
57
|
+
readonly token: string;
|
|
58
|
+
readonly account?: string;
|
|
59
|
+
readonly authorizeAddress: TelegramAddressAuthorizer;
|
|
60
|
+
readonly logger?: Logger;
|
|
61
|
+
readonly callTelegram?: TelegramCall;
|
|
62
|
+
readonly uploadTelegram?: TelegramUpload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TelegramMessage {
|
|
66
|
+
readonly message_id: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function receipt(messageId: number | string): OutboundReceipt {
|
|
70
|
+
return { transport: "telegram", messageId: String(messageId) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isTelegramMessage(value: unknown): value is TelegramMessage {
|
|
74
|
+
if (typeof value !== "object" || value === null || !("message_id" in value)) return false;
|
|
75
|
+
return typeof value.message_id === "number";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function messageThread(address: ConversationAddress): number | undefined {
|
|
79
|
+
if (address.thread === undefined) return undefined;
|
|
80
|
+
const thread = Number(address.thread);
|
|
81
|
+
if (!Number.isSafeInteger(thread) || thread <= 0) throw new Error("Telegram thread must be a positive numeric identifier");
|
|
82
|
+
return thread;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function messageId(value: OutboundReceipt): number {
|
|
86
|
+
if (value.transport !== "telegram") throw new Error("Telegram delivery can only use Telegram receipts");
|
|
87
|
+
const id = Number(value.messageId);
|
|
88
|
+
if (!Number.isSafeInteger(id) || id <= 0) throw new Error("Telegram message receipt must contain a positive numeric id");
|
|
89
|
+
return id;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function contentText(content: OutboundContent): string | undefined {
|
|
93
|
+
if (content.text === undefined) return undefined;
|
|
94
|
+
if (typeof content.text !== "string") throw new Error("Outbound text must be a string");
|
|
95
|
+
return content.text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attachmentPath(url: string): string {
|
|
99
|
+
let path: string;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = new URL(url);
|
|
102
|
+
if (parsed.protocol !== "file:") throw new Error("not a file URL");
|
|
103
|
+
path = fileURLToPath(parsed);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error("Telegram attachments must use a local file:// URL");
|
|
106
|
+
}
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function attachmentFilename(path: string, name?: string): string | undefined {
|
|
111
|
+
if (name === undefined) return undefined;
|
|
112
|
+
const normalized = name.replaceAll("\\", "/").split("/").at(-1)?.trim();
|
|
113
|
+
if (!normalized) return undefined;
|
|
114
|
+
return normalized.slice(0, 255) || extname(path) || undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isPhoto(path: string, mediaType?: string): boolean {
|
|
118
|
+
return mediaType?.toLowerCase().startsWith("image/") ?? PHOTO_EXTENSIONS[extname(path).toLowerCase()] === true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isMarkdownParseError(error: unknown): boolean {
|
|
122
|
+
return error instanceof TgError && error.code === 400 && /parse entities|markdown/i.test(error.message);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Telegram-specific delivery implementation. It deliberately accepts a
|
|
127
|
+
* pre-authorized DeliveryContext, rather than any caller-controlled destination.
|
|
128
|
+
*/
|
|
129
|
+
export class Outbound {
|
|
130
|
+
readonly #token: string;
|
|
131
|
+
readonly #account: string;
|
|
132
|
+
readonly #authorizeAddress: TelegramAddressAuthorizer;
|
|
133
|
+
readonly #logger?: Logger;
|
|
134
|
+
readonly #callTelegram: TelegramCall;
|
|
135
|
+
readonly #uploadTelegram: TelegramUpload;
|
|
136
|
+
|
|
137
|
+
constructor(options: OutboundOptions) {
|
|
138
|
+
if (!options.token) throw new Error("Telegram bot token is required");
|
|
139
|
+
this.#token = options.token;
|
|
140
|
+
this.#account = options.account ?? "default";
|
|
141
|
+
this.#authorizeAddress = options.authorizeAddress;
|
|
142
|
+
this.#logger = options.logger;
|
|
143
|
+
this.#callTelegram =
|
|
144
|
+
options.callTelegram ??
|
|
145
|
+
((method, payload, requestOptions) => tg(this.#token, method, payload, { signal: requestOptions?.signal }));
|
|
146
|
+
this.#uploadTelegram =
|
|
147
|
+
options.uploadTelegram ??
|
|
148
|
+
((method, fields, file, requestOptions) =>
|
|
149
|
+
tgUpload(this.#token, method, fields, file, undefined, requestOptions?.signal));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async send(
|
|
153
|
+
address: ConversationAddress,
|
|
154
|
+
content: OutboundContent,
|
|
155
|
+
context: DeliveryContext,
|
|
156
|
+
signal?: AbortSignal,
|
|
157
|
+
): Promise<OutboundReceipt> {
|
|
158
|
+
await this.#assertAddress(address, context);
|
|
159
|
+
signal?.throwIfAborted();
|
|
160
|
+
|
|
161
|
+
const text = contentText(content);
|
|
162
|
+
let first: OutboundReceipt | undefined;
|
|
163
|
+
if (text !== undefined && text.length > 0) {
|
|
164
|
+
for (const part of this.#textParts(text, content.format)) {
|
|
165
|
+
const sent = await this.sendMessage(address, part.text, context, {
|
|
166
|
+
replyTo: first === undefined ? content.replyTo : undefined,
|
|
167
|
+
...(part.parseMode === "MarkdownV2" ? { parseMode: "MarkdownV2", plainFallbackText: part.plainFallbackText } : {}),
|
|
168
|
+
}, signal);
|
|
169
|
+
first ??= sent;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const attachment of content.attachments ?? []) {
|
|
174
|
+
const sent = await this.#sendAttachment(address, attachment, context, first === undefined ? content.replyTo : undefined, signal);
|
|
175
|
+
first ??= sent;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (first === undefined) throw new Error("Telegram delivery requires text or at least one attachment");
|
|
179
|
+
return first;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async update(
|
|
183
|
+
address: ConversationAddress,
|
|
184
|
+
target: OutboundReceipt,
|
|
185
|
+
content: OutboundContent,
|
|
186
|
+
context: DeliveryContext,
|
|
187
|
+
signal?: AbortSignal,
|
|
188
|
+
): Promise<OutboundReceipt> {
|
|
189
|
+
await this.#assertAddress(address, context);
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
if (content.attachments?.length) throw new Error("Telegram message updates cannot replace attachments");
|
|
192
|
+
const text = contentText(content);
|
|
193
|
+
if (text === undefined) throw new Error("Telegram message updates require text");
|
|
194
|
+
|
|
195
|
+
const markdown = content.format === "markdown";
|
|
196
|
+
const body = markdown ? mdToMarkdownV2(text) : text;
|
|
197
|
+
const part = chunkLabeled(body, TELEGRAM_MAX_CHARS, "newline")[0] ?? "";
|
|
198
|
+
const payload: Record<string, unknown> = {
|
|
199
|
+
chat_id: address.channel,
|
|
200
|
+
message_id: messageId(target),
|
|
201
|
+
text: part,
|
|
202
|
+
...(messageThread(address) === undefined ? {} : { message_thread_id: messageThread(address) }),
|
|
203
|
+
...(markdown ? { parse_mode: "MarkdownV2" } : {}),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await this.#request("editMessageText", payload, signal);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (!markdown || !isMarkdownParseError(error)) throw error;
|
|
210
|
+
const fallback = chunkLabeled(text, TELEGRAM_MAX_CHARS, "newline")[0] ?? "";
|
|
211
|
+
await this.#request(
|
|
212
|
+
"editMessageText",
|
|
213
|
+
{ ...payload, text: fallback, parse_mode: undefined },
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return target;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async react(
|
|
221
|
+
address: ConversationAddress,
|
|
222
|
+
target: OutboundReceipt,
|
|
223
|
+
reaction: Reaction,
|
|
224
|
+
context: DeliveryContext,
|
|
225
|
+
signal?: AbortSignal,
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
await this.#assertAddress(address, context);
|
|
228
|
+
signal?.throwIfAborted();
|
|
229
|
+
if (!reaction.emoji) throw new Error("Telegram reactions require an emoji");
|
|
230
|
+
await this.#request(
|
|
231
|
+
"setMessageReaction",
|
|
232
|
+
{
|
|
233
|
+
chat_id: address.channel,
|
|
234
|
+
message_id: messageId(target),
|
|
235
|
+
reaction: [{ type: "emoji", emoji: reaction.emoji }],
|
|
236
|
+
is_big: false,
|
|
237
|
+
},
|
|
238
|
+
signal,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Send a UI/control message while retaining the same authorization and retry rules as normal delivery. */
|
|
243
|
+
async sendMessage(
|
|
244
|
+
address: ConversationAddress,
|
|
245
|
+
text: string,
|
|
246
|
+
context: DeliveryContext,
|
|
247
|
+
options: TelegramMessageOptions = {},
|
|
248
|
+
signal?: AbortSignal,
|
|
249
|
+
): Promise<OutboundReceipt> {
|
|
250
|
+
await this.#assertAddress(address, context);
|
|
251
|
+
signal?.throwIfAborted();
|
|
252
|
+
const payload: Record<string, unknown> = {
|
|
253
|
+
chat_id: address.channel,
|
|
254
|
+
text: text.slice(0, TELEGRAM_MAX_CHARS),
|
|
255
|
+
...(messageThread(address) === undefined ? {} : { message_thread_id: messageThread(address) }),
|
|
256
|
+
...(options.replyTo === undefined ? {} : { reply_to_message_id: messageId(options.replyTo) }),
|
|
257
|
+
...(options.replyMarkup === undefined ? {} : { reply_markup: options.replyMarkup }),
|
|
258
|
+
...(options.parseMode === undefined ? {} : { parse_mode: options.parseMode }),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const sent = await this.#request("sendMessage", payload, signal);
|
|
263
|
+
if (!isTelegramMessage(sent)) throw new Error("Telegram sendMessage returned no message_id");
|
|
264
|
+
return receipt(sent.message_id);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (options.parseMode !== "MarkdownV2" || !isMarkdownParseError(error)) throw error;
|
|
267
|
+
const fallbackText = options.plainFallbackText ?? text;
|
|
268
|
+
const sent = await this.#request(
|
|
269
|
+
"sendMessage",
|
|
270
|
+
{ ...payload, parse_mode: undefined, text: fallbackText.slice(0, TELEGRAM_MAX_CHARS) },
|
|
271
|
+
signal,
|
|
272
|
+
);
|
|
273
|
+
if (!isTelegramMessage(sent)) throw new Error("Telegram sendMessage returned no message_id");
|
|
274
|
+
return receipt(sent.message_id);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async setReplyMarkup(
|
|
279
|
+
address: ConversationAddress,
|
|
280
|
+
target: OutboundReceipt,
|
|
281
|
+
replyMarkup: Record<string, unknown>,
|
|
282
|
+
context: DeliveryContext,
|
|
283
|
+
signal?: AbortSignal,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
await this.#assertAddress(address, context);
|
|
286
|
+
await this.#request(
|
|
287
|
+
"editMessageReplyMarkup",
|
|
288
|
+
{ chat_id: address.channel, message_id: messageId(target), reply_markup: replyMarkup },
|
|
289
|
+
signal,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async #sendAttachment(
|
|
294
|
+
address: ConversationAddress,
|
|
295
|
+
attachment: { readonly url: string; readonly name?: string; readonly mediaType?: string },
|
|
296
|
+
context: DeliveryContext,
|
|
297
|
+
replyTo: OutboundReceipt | undefined,
|
|
298
|
+
signal?: AbortSignal,
|
|
299
|
+
): Promise<OutboundReceipt> {
|
|
300
|
+
const path = attachmentPath(attachment.url);
|
|
301
|
+
const info = await stat(path);
|
|
302
|
+
if (!info.isFile()) throw new Error("Telegram attachments must point to a regular file");
|
|
303
|
+
if (info.size > MAX_ATTACHMENT_BYTES) {
|
|
304
|
+
throw new Error(`Telegram attachment is too large (${info.size} bytes, max ${MAX_ATTACHMENT_BYTES})`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const photo = isPhoto(path, attachment.mediaType);
|
|
308
|
+
const method = photo ? "sendPhoto" : "sendDocument";
|
|
309
|
+
const field = photo ? "photo" : "document";
|
|
310
|
+
const sent = await this.#upload(method, {
|
|
311
|
+
chat_id: address.channel,
|
|
312
|
+
message_thread_id: messageThread(address),
|
|
313
|
+
reply_to_message_id: replyTo === undefined ? undefined : messageId(replyTo),
|
|
314
|
+
}, { field, path, filename: attachmentFilename(path, attachment.name) }, signal);
|
|
315
|
+
if (!isTelegramMessage(sent)) throw new Error(`Telegram ${method} returned no message_id`);
|
|
316
|
+
return receipt(sent.message_id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#textParts(
|
|
320
|
+
text: string,
|
|
321
|
+
format: OutboundContent["format"],
|
|
322
|
+
): Array<{ readonly text: string; readonly parseMode?: "MarkdownV2"; readonly plainFallbackText?: string }> {
|
|
323
|
+
if (format !== "markdown") return chunkLabeled(text, TELEGRAM_MAX_CHARS, "newline").map((part) => ({ text: part }));
|
|
324
|
+
const plainParts = chunkLabeled(text, TELEGRAM_MAX_CHARS, "newline");
|
|
325
|
+
return chunkLabeled(mdToMarkdownV2(text), TELEGRAM_MAX_CHARS, "newline").map((part, index) => ({
|
|
326
|
+
text: part,
|
|
327
|
+
parseMode: "MarkdownV2",
|
|
328
|
+
plainFallbackText: plainParts[index] ?? part,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #assertAddress(address: ConversationAddress, context: DeliveryContext): Promise<void> {
|
|
333
|
+
if (address.transport !== "telegram") throw new Error("Telegram outbound requires a Telegram address");
|
|
334
|
+
if (address.account !== this.#account) throw new Error("Telegram outbound address belongs to another account");
|
|
335
|
+
const result = await this.#authorizeAddress(address, context);
|
|
336
|
+
if (result === false) throw new Error("Telegram delivery address is not authorized for this context");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async #request(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<unknown> {
|
|
340
|
+
signal?.throwIfAborted();
|
|
341
|
+
return withRateLimit(() => this.#callTelegram(method, payload, { signal }), { signal, log: this.#logger });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async #upload(
|
|
345
|
+
method: string,
|
|
346
|
+
fields: Record<string, string | number | undefined>,
|
|
347
|
+
file: { readonly field: string; readonly path: string; readonly filename?: string },
|
|
348
|
+
signal?: AbortSignal,
|
|
349
|
+
): Promise<unknown> {
|
|
350
|
+
signal?.throwIfAborted();
|
|
351
|
+
return withRateLimit(() => this.#uploadTelegram(method, fields, file, { signal }), { signal, log: this.#logger });
|
|
352
|
+
}
|
|
353
|
+
}
|