@tormentalabs/opencode-telegram-plugin 0.2.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.
@@ -0,0 +1,99 @@
1
+ import { GrammyError, HttpError } from "grammy";
2
+
3
+ export type SendResult =
4
+ | { ok: true; messageId?: number }
5
+ | { ok: false; retry: boolean; reason: string; retryAfterMs?: number };
6
+
7
+ /**
8
+ * Returns true when the error is a Telegram "message is not modified" error.
9
+ * This is treated as a successful no-op by `safeSend`.
10
+ */
11
+ export function isNotModifiedError(err: unknown): boolean {
12
+ return (
13
+ err instanceof GrammyError &&
14
+ err.description.includes("message is not modified")
15
+ );
16
+ }
17
+
18
+ /**
19
+ * Returns true when Telegram rejected the request due to malformed entities.
20
+ */
21
+ export function isParseError(err: unknown): boolean {
22
+ return (
23
+ err instanceof GrammyError &&
24
+ err.description.includes("can't parse entities")
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Returns true when the bot has been blocked by the user (HTTP 403).
30
+ */
31
+ export function isBotBlockedError(err: unknown): boolean {
32
+ return err instanceof GrammyError && err.error_code === 403;
33
+ }
34
+
35
+ /**
36
+ * Wrap any Telegram Bot API call with standardised error classification.
37
+ *
38
+ * | Condition | Result |
39
+ * |------------------------------------|------------------------------------------------|
40
+ * | Success | `{ ok: true, messageId? }` |
41
+ * | "message is not modified" | `{ ok: true }` — silent no-op |
42
+ * | HTTP 429 (rate limit) | `{ ok: false, retry: true, reason: "rate limited" }` |
43
+ * | "can't parse entities" | `{ ok: false, retry: true, reason: "parse error" }` |
44
+ * | HTTP 403 (bot blocked) | `{ ok: false, retry: false, reason: "bot blocked" }` |
45
+ * | "message to edit not found" | `{ ok: false, retry: false, reason: "message deleted" }` |
46
+ * | Other GrammyError / HttpError | `{ ok: false, retry: false, reason: <message> }` |
47
+ */
48
+ export async function safeSend(
49
+ fn: () => Promise<unknown>,
50
+ ): Promise<SendResult> {
51
+ try {
52
+ const result = await fn();
53
+
54
+ // Extract message_id when the API returns a Message object.
55
+ let messageId: number | undefined;
56
+ if (result !== null && typeof result === "object") {
57
+ const obj = result as Record<string, unknown>;
58
+ if ("message_id" in obj && typeof obj.message_id === "number") {
59
+ messageId = obj.message_id;
60
+ }
61
+ }
62
+
63
+ return { ok: true, messageId };
64
+ } catch (err: unknown) {
65
+ // "message is not modified" is not a real error — content was already
66
+ // up to date. Treat as success.
67
+ if (isNotModifiedError(err)) {
68
+ return { ok: true };
69
+ }
70
+
71
+ if (err instanceof GrammyError) {
72
+ if (err.error_code === 429) {
73
+ const retryAfter = err.parameters.retry_after;
74
+ const retryAfterMs = typeof retryAfter === "number" ? retryAfter * 1000 : undefined;
75
+ return { ok: false, retry: true, reason: "rate limited", retryAfterMs };
76
+ }
77
+ if (isParseError(err)) {
78
+ return { ok: false, retry: true, reason: "parse error" };
79
+ }
80
+ if (isBotBlockedError(err)) {
81
+ return { ok: false, retry: false, reason: "bot blocked" };
82
+ }
83
+ if (err.description.includes("message to edit not found")) {
84
+ return { ok: false, retry: false, reason: "message deleted" };
85
+ }
86
+ return { ok: false, retry: false, reason: err.description };
87
+ }
88
+
89
+ if (err instanceof HttpError) {
90
+ return { ok: false, retry: true, reason: err.message };
91
+ }
92
+
93
+ if (err instanceof Error) {
94
+ return { ok: false, retry: false, reason: err.message };
95
+ }
96
+
97
+ return { ok: false, retry: false, reason: String(err) };
98
+ }
99
+ }
@@ -0,0 +1,128 @@
1
+ export interface ThrottleOptions {
2
+ /** Minimum milliseconds between executions. */
3
+ intervalMs: number;
4
+ }
5
+
6
+ /**
7
+ * A callable throttle handle with `flush` and `cancel` control methods.
8
+ *
9
+ * Calling it schedules `fn` for execution according to the throttle rules.
10
+ * `flush()` runs any pending call immediately and returns its promise.
11
+ * `cancel()` silently drops the pending call.
12
+ */
13
+ export interface Throttle {
14
+ (fn: () => Promise<void>): Promise<void>;
15
+ flush(): Promise<void>;
16
+ cancel(): void;
17
+ }
18
+
19
+ /**
20
+ * Create a trailing-edge throttle:
21
+ *
22
+ * - The **first** call in an idle period executes immediately.
23
+ * - **Subsequent** calls within `intervalMs` replace the pending call;
24
+ * only the **latest** one runs (trailing edge).
25
+ * - The replaced call's promise is resolved as a silent no-op.
26
+ * - `flush()` executes any pending call immediately.
27
+ * - `cancel()` drops the pending call (resolved silently).
28
+ */
29
+ export function createThrottle(opts: ThrottleOptions): Throttle {
30
+ let lastExecutionTime = 0;
31
+ let pending: (() => Promise<void>) | null = null;
32
+ let pendingResolve: (() => void) | null = null;
33
+ let pendingReject: ((err: unknown) => void) | null = null;
34
+ let timerId: ReturnType<typeof setTimeout> | null = null;
35
+ let isRunning = false;
36
+
37
+ function clearTimer(): void {
38
+ if (timerId !== null) {
39
+ clearTimeout(timerId);
40
+ timerId = null;
41
+ }
42
+ }
43
+
44
+ /** Silently resolve and drop the pending call without executing it. */
45
+ function dropPending(): void {
46
+ pendingResolve?.();
47
+ pending = null;
48
+ pendingResolve = null;
49
+ pendingReject = null;
50
+ }
51
+
52
+ /**
53
+ * Execute the pending function immediately (if any), resolving or rejecting
54
+ * the promise that was returned to the original caller.
55
+ */
56
+ async function runPending(): Promise<void> {
57
+ // If a previous execution is still in progress, defer until it finishes.
58
+ if (isRunning) {
59
+ timerId = setTimeout(() => void runPending(), opts.intervalMs);
60
+ return;
61
+ }
62
+
63
+ const fn = pending;
64
+ const resolve = pendingResolve;
65
+ const reject = pendingReject;
66
+
67
+ pending = null;
68
+ pendingResolve = null;
69
+ pendingReject = null;
70
+ timerId = null;
71
+
72
+ if (fn === null) return;
73
+
74
+ isRunning = true;
75
+ try {
76
+ await fn();
77
+ resolve?.();
78
+ } catch (err: unknown) {
79
+ reject?.(err);
80
+ } finally {
81
+ isRunning = false;
82
+ lastExecutionTime = Date.now();
83
+ }
84
+ }
85
+
86
+ const throttleFn = (fn: () => Promise<void>): Promise<void> => {
87
+ const now = Date.now();
88
+ const elapsed = now - lastExecutionTime;
89
+
90
+ // First call ever, or the interval has already elapsed → run immediately.
91
+ if (!isRunning && (lastExecutionTime === 0 || elapsed >= opts.intervalMs)) {
92
+ lastExecutionTime = now;
93
+ isRunning = true;
94
+ return fn().finally(() => {
95
+ isRunning = false;
96
+ lastExecutionTime = Date.now();
97
+ });
98
+ }
99
+
100
+ // Drop any existing pending call (resolve it as a silent no-op) and
101
+ // schedule the new one to fire at the trailing edge of the interval.
102
+ clearTimer();
103
+ dropPending();
104
+
105
+ return new Promise<void>((resolve, reject) => {
106
+ pending = fn;
107
+ pendingResolve = resolve;
108
+ pendingReject = reject;
109
+
110
+ const delay = opts.intervalMs - elapsed;
111
+ timerId = setTimeout(() => {
112
+ void runPending();
113
+ }, delay);
114
+ });
115
+ };
116
+
117
+ throttleFn.flush = (): Promise<void> => {
118
+ clearTimer();
119
+ return runPending();
120
+ };
121
+
122
+ throttleFn.cancel = (): void => {
123
+ clearTimer();
124
+ dropPending();
125
+ };
126
+
127
+ return throttleFn as Throttle;
128
+ }
@@ -0,0 +1,30 @@
1
+ import type { Api, RawApi } from "grammy";
2
+
3
+ /**
4
+ * Send a "typing" chat action immediately, then repeat every 4 500 ms to keep
5
+ * the indicator alive while the bot is streaming a response.
6
+ *
7
+ * @param api The grammY `Api` instance.
8
+ * @param chatId The target chat ID.
9
+ * @returns A stop function — call it to clear the interval.
10
+ *
11
+ * All errors are silently swallowed so a transient Telegram hiccup never
12
+ * interrupts the main response flow.
13
+ */
14
+ export function startTyping(api: Api<RawApi>, chatId: number): () => void {
15
+ // Fire immediately so the indicator appears without delay.
16
+ void api.sendChatAction(chatId, "typing").catch(() => undefined);
17
+
18
+ const intervalId = setInterval(() => {
19
+ void api.sendChatAction(chatId, "typing").catch(() => undefined);
20
+ }, 4500);
21
+
22
+ // Ensure the timer doesn't prevent Node.js from exiting gracefully.
23
+ if (typeof intervalId === "object" && "unref" in intervalId) {
24
+ intervalId.unref();
25
+ }
26
+
27
+ return () => {
28
+ clearInterval(intervalId);
29
+ };
30
+ }