fix: add provider retry policy
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Discord/Telegram: add per-request retry policy with configurable delays and docs.
|
||||||
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
|
||||||
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
|
||||||
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
|
||||||
|
|||||||
58
docs/concepts/retry.md
Normal file
58
docs/concepts/retry.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
summary: "Retry policy for outbound provider calls"
|
||||||
|
read_when:
|
||||||
|
- Updating provider retry behavior or defaults
|
||||||
|
- Debugging provider send errors or rate limits
|
||||||
|
---
|
||||||
|
# Retry policy
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Retry per HTTP request, not per multi-step flow.
|
||||||
|
- Preserve ordering by retrying only the current step.
|
||||||
|
- Avoid duplicating non-idempotent operations.
|
||||||
|
|
||||||
|
## Defaults
|
||||||
|
- Attempts: 3
|
||||||
|
- Max delay cap: 30000 ms
|
||||||
|
- Jitter: 0.1 (10 percent)
|
||||||
|
- Provider defaults:
|
||||||
|
- Telegram min delay: 400 ms
|
||||||
|
- Discord min delay: 500 ms
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
### Discord
|
||||||
|
- Retries only on rate-limit errors (HTTP 429).
|
||||||
|
- Uses Discord `retry_after` when available, otherwise exponential backoff.
|
||||||
|
|
||||||
|
### Telegram
|
||||||
|
- Retries on transient errors (429, timeout, connect/reset/closed, temporarily unavailable).
|
||||||
|
- Uses `retry_after` when available, otherwise exponential backoff.
|
||||||
|
- Markdown parse errors are not retried; they fall back to plain text.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
Set retry policy per provider in `~/.clawdbot/clawdbot.json`:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
telegram: {
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 400,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discord: {
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Retries apply per request (message send, media upload, reaction, poll, sticker).
|
||||||
|
- Composite flows do not retry completed steps.
|
||||||
@@ -493,6 +493,12 @@ Set `telegram.enabled: false` to disable automatic startup.
|
|||||||
streamMode: "partial", // off | partial | block (draft streaming)
|
streamMode: "partial", // off | partial | block (draft streaming)
|
||||||
actions: { reactions: true }, // tool action gates (false disables)
|
actions: { reactions: true }, // tool action gates (false disables)
|
||||||
mediaMaxMb: 5,
|
mediaMaxMb: 5,
|
||||||
|
retry: { // outbound retry policy
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 400,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
},
|
||||||
proxy: "socks5://localhost:9050",
|
proxy: "socks5://localhost:9050",
|
||||||
webhookUrl: "https://example.com/telegram-webhook",
|
webhookUrl: "https://example.com/telegram-webhook",
|
||||||
webhookSecret: "secret",
|
webhookSecret: "secret",
|
||||||
@@ -505,6 +511,7 @@ Draft streaming notes:
|
|||||||
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
|
||||||
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
|
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
|
||||||
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
|
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.
|
||||||
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `discord` (bot transport)
|
### `discord` (bot transport)
|
||||||
|
|
||||||
@@ -559,7 +566,13 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
historyLimit: 20 // include last N guild messages as context
|
historyLimit: 20, // include last N guild messages as context
|
||||||
|
retry: { // outbound retry policy
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -571,6 +584,7 @@ Reaction notification modes:
|
|||||||
- `own`: reactions on the bot's own messages (default).
|
- `own`: reactions on the bot's own messages (default).
|
||||||
- `all`: all reactions on all messages.
|
- `all`: all reactions on all messages.
|
||||||
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
|
||||||
|
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
### `slack` (socket mode)
|
### `slack` (socket mode)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Discord (Bot API)
|
# Discord (Bot API)
|
||||||
|
|
||||||
Updated: 2025-12-07
|
Updated: 2026-01-07
|
||||||
|
|
||||||
Status: ready for DM and guild text channels via the official Discord bot gateway.
|
Status: ready for DM and guild text channels via the official Discord bot gateway.
|
||||||
|
|
||||||
@@ -122,6 +122,12 @@ Example “single server, only allow me, only allow #help”:
|
|||||||
help: { allow: true, requireMention: true }
|
help: { allow: true, requireMention: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30000,
|
||||||
|
jitter: 0.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +160,9 @@ Notes:
|
|||||||
- Reply context is injected when a message references another message (quoted content + ids).
|
- Reply context is injected when a message references another message (quoted content + ids).
|
||||||
- Native reply threading is **off by default**; enable with `discord.replyToMode` and reply tags.
|
- Native reply threading is **off by default**; enable with `discord.replyToMode` and reply tags.
|
||||||
|
|
||||||
|
## Retry policy
|
||||||
|
Outbound Discord API calls retry on rate limits (429) using Discord `retry_after` when available, with exponential backoff and jitter. Configure via `discord.retry`. See [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -235,6 +244,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
|
|||||||
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `mediaMaxMb`: clamp inbound media saved to disk.
|
||||||
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables).
|
||||||
|
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
|
||||||
- `reactions` (covers react + read reactions)
|
- `reactions` (covers react + read reactions)
|
||||||
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
- `stickers`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ Reasoning stream (Telegram only):
|
|||||||
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
|
- If `telegram.streamMode` is `off`, reasoning stream is disabled.
|
||||||
More context: [Streaming + chunking](/concepts/streaming).
|
More context: [Streaming + chunking](/concepts/streaming).
|
||||||
|
|
||||||
|
## Retry policy
|
||||||
|
Outbound Telegram API calls retry on transient network/429 errors with exponential backoff and jitter. Configure via `telegram.retry`. See [Retry policy](/concepts/retry).
|
||||||
|
|
||||||
## Agent tool (reactions)
|
## Agent tool (reactions)
|
||||||
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
|
||||||
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
|
||||||
@@ -215,6 +218,7 @@ Provider options:
|
|||||||
- `telegram.textChunkLimit`: outbound chunk size (chars).
|
- `telegram.textChunkLimit`: outbound chunk size (chars).
|
||||||
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
- `telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||||
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
- `telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||||
|
- `telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||||
- `telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
- `telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||||
- `telegram.webhookUrl`: enable webhook mode.
|
- `telegram.webhookUrl`: enable webhook mode.
|
||||||
- `telegram.webhookSecret`: webhook secret (optional).
|
- `telegram.webhookSecret`: webhook secret (optional).
|
||||||
|
|||||||
@@ -108,10 +108,18 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"telegram.botToken": "Telegram Bot Token",
|
"telegram.botToken": "Telegram Bot Token",
|
||||||
"telegram.dmPolicy": "Telegram DM Policy",
|
"telegram.dmPolicy": "Telegram DM Policy",
|
||||||
"telegram.streamMode": "Telegram Stream Mode",
|
"telegram.streamMode": "Telegram Stream Mode",
|
||||||
|
"telegram.retry.attempts": "Telegram Retry Attempts",
|
||||||
|
"telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||||
|
"telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||||
|
"telegram.retry.jitter": "Telegram Retry Jitter",
|
||||||
"whatsapp.dmPolicy": "WhatsApp DM Policy",
|
"whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||||
"signal.dmPolicy": "Signal DM Policy",
|
"signal.dmPolicy": "Signal DM Policy",
|
||||||
"imessage.dmPolicy": "iMessage DM Policy",
|
"imessage.dmPolicy": "iMessage DM Policy",
|
||||||
"discord.dm.policy": "Discord DM Policy",
|
"discord.dm.policy": "Discord DM Policy",
|
||||||
|
"discord.retry.attempts": "Discord Retry Attempts",
|
||||||
|
"discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
|
||||||
|
"discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
||||||
|
"discord.retry.jitter": "Discord Retry Jitter",
|
||||||
"slack.dm.policy": "Slack DM Policy",
|
"slack.dm.policy": "Slack DM Policy",
|
||||||
"discord.token": "Discord Bot Token",
|
"discord.token": "Discord Bot Token",
|
||||||
"slack.botToken": "Slack Bot Token",
|
"slack.botToken": "Slack Bot Token",
|
||||||
@@ -158,6 +166,14 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires telegram.allowFrom=["*"].',
|
||||||
"telegram.streamMode":
|
"telegram.streamMode":
|
||||||
"Draft streaming mode for Telegram replies (off | partial | block). Requires private topics + sendMessageDraft.",
|
"Draft streaming mode for Telegram replies (off | partial | block). Requires private topics + sendMessageDraft.",
|
||||||
|
"telegram.retry.attempts":
|
||||||
|
"Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||||
|
"telegram.retry.minDelayMs":
|
||||||
|
"Minimum retry delay in ms for Telegram outbound calls.",
|
||||||
|
"telegram.retry.maxDelayMs":
|
||||||
|
"Maximum retry delay cap in ms for Telegram outbound calls.",
|
||||||
|
"telegram.retry.jitter":
|
||||||
|
"Jitter factor (0-1) applied to Telegram retry delays.",
|
||||||
"whatsapp.dmPolicy":
|
"whatsapp.dmPolicy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires whatsapp.allowFrom=["*"].',
|
||||||
"signal.dmPolicy":
|
"signal.dmPolicy":
|
||||||
@@ -166,6 +182,14 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires imessage.allowFrom=["*"].',
|
||||||
"discord.dm.policy":
|
"discord.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires discord.dm.allowFrom=["*"].',
|
||||||
|
"discord.retry.attempts":
|
||||||
|
"Max retry attempts for outbound Discord API calls (default: 3).",
|
||||||
|
"discord.retry.minDelayMs":
|
||||||
|
"Minimum retry delay in ms for Discord outbound calls.",
|
||||||
|
"discord.retry.maxDelayMs":
|
||||||
|
"Maximum retry delay cap in ms for Discord outbound calls.",
|
||||||
|
"discord.retry.jitter":
|
||||||
|
"Jitter factor (0-1) applied to Discord retry delays.",
|
||||||
"slack.dm.policy":
|
"slack.dm.policy":
|
||||||
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
|
'Direct message access control ("pairing" recommended). "open" requires slack.dm.allowFrom=["*"].',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ export type ReplyToMode = "off" | "first" | "all";
|
|||||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||||
|
|
||||||
|
export type OutboundRetryConfig = {
|
||||||
|
/** Max retry attempts for outbound requests (default: 3). */
|
||||||
|
attempts?: number;
|
||||||
|
/** Minimum retry delay in ms (default: 300-500ms depending on provider). */
|
||||||
|
minDelayMs?: number;
|
||||||
|
/** Maximum retry delay cap in ms (default: 30000). */
|
||||||
|
maxDelayMs?: number;
|
||||||
|
/** Jitter factor (0-1) applied to delays (default: 0.1). */
|
||||||
|
jitter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionSendPolicyAction = "allow" | "deny";
|
export type SessionSendPolicyAction = "allow" | "deny";
|
||||||
export type SessionSendPolicyMatch = {
|
export type SessionSendPolicyMatch = {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
@@ -294,6 +305,8 @@ export type TelegramConfig = {
|
|||||||
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
/** Draft streaming mode for Telegram (off|partial|block). Default: partial. */
|
||||||
streamMode?: "off" | "partial" | "block";
|
streamMode?: "off" | "partial" | "block";
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
|
/** Retry policy for outbound Telegram API calls. */
|
||||||
|
retry?: OutboundRetryConfig;
|
||||||
proxy?: string;
|
proxy?: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
webhookSecret?: string;
|
webhookSecret?: string;
|
||||||
@@ -378,6 +391,8 @@ export type DiscordConfig = {
|
|||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
|
/** Retry policy for outbound Discord API calls. */
|
||||||
|
retry?: OutboundRetryConfig;
|
||||||
/** Per-action tool gating (default: true for all). */
|
/** Per-action tool gating (default: true for all). */
|
||||||
actions?: DiscordActionConfig;
|
actions?: DiscordActionConfig;
|
||||||
/** Control reply threading when reply tags are present (off|first|all). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ const GroupPolicySchema = z.enum(["open", "disabled", "allowlist"]);
|
|||||||
|
|
||||||
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
const DmPolicySchema = z.enum(["pairing", "allowlist", "open", "disabled"]);
|
||||||
|
|
||||||
|
const RetryConfigSchema = z
|
||||||
|
.object({
|
||||||
|
attempts: z.number().int().min(1).optional(),
|
||||||
|
minDelayMs: z.number().int().min(0).optional(),
|
||||||
|
maxDelayMs: z.number().int().min(0).optional(),
|
||||||
|
jitter: z.number().min(0).max(1).optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
const QueueModeBySurfaceSchema = z
|
const QueueModeBySurfaceSchema = z
|
||||||
.object({
|
.object({
|
||||||
whatsapp: QueueModeSchema.optional(),
|
whatsapp: QueueModeSchema.optional(),
|
||||||
@@ -867,6 +876,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.default("partial"),
|
.default("partial"),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
|
retry: RetryConfigSchema,
|
||||||
proxy: z.string().optional(),
|
proxy: z.string().optional(),
|
||||||
webhookUrl: z.string().optional(),
|
webhookUrl: z.string().optional(),
|
||||||
webhookSecret: z.string().optional(),
|
webhookSecret: z.string().optional(),
|
||||||
@@ -899,6 +909,7 @@ export const ClawdbotSchema = z.object({
|
|||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
|
retry: RetryConfigSchema,
|
||||||
actions: z
|
actions: z
|
||||||
.object({
|
.object({
|
||||||
reactions: z.boolean().optional(),
|
reactions: z.boolean().optional(),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { RateLimitError } from "@buape/carbon";
|
||||||
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
|
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@@ -662,3 +663,133 @@ describe("sendPollDiscord", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createMockRateLimitError(retryAfter = 0.001): RateLimitError {
|
||||||
|
const response = new Response(null, {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
"X-RateLimit-Scope": "user",
|
||||||
|
"X-RateLimit-Bucket": "test-bucket",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new RateLimitError(response, {
|
||||||
|
message: "You are being rate limited.",
|
||||||
|
retry_after: retryAfter,
|
||||||
|
global: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("retry rate limits", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries on Discord rate limits", async () => {
|
||||||
|
const { rest, postMock } = makeRest();
|
||||||
|
const rateLimitError = createMockRateLimitError(0);
|
||||||
|
|
||||||
|
postMock
|
||||||
|
.mockRejectedValueOnce(rateLimitError)
|
||||||
|
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||||
|
|
||||||
|
const res = await sendMessageDiscord("channel:789", "hello", {
|
||||||
|
rest,
|
||||||
|
token: "t",
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.messageId).toBe("msg1");
|
||||||
|
expect(postMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses retry_after delays when rate limited", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||||
|
const { rest, postMock } = makeRest();
|
||||||
|
const rateLimitError = createMockRateLimitError(0.5);
|
||||||
|
|
||||||
|
postMock
|
||||||
|
.mockRejectedValueOnce(rateLimitError)
|
||||||
|
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" });
|
||||||
|
|
||||||
|
const promise = sendMessageDiscord("channel:789", "hello", {
|
||||||
|
rest,
|
||||||
|
token: "t",
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise).resolves.toEqual({
|
||||||
|
messageId: "msg1",
|
||||||
|
channelId: "789",
|
||||||
|
});
|
||||||
|
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops after max retry attempts", async () => {
|
||||||
|
const { rest, postMock } = makeRest();
|
||||||
|
const rateLimitError = createMockRateLimitError(0);
|
||||||
|
|
||||||
|
postMock.mockRejectedValue(rateLimitError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageDiscord("channel:789", "hello", {
|
||||||
|
rest,
|
||||||
|
token: "t",
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(RateLimitError);
|
||||||
|
expect(postMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry non-rate-limit errors", async () => {
|
||||||
|
const { rest, postMock } = makeRest();
|
||||||
|
postMock.mockRejectedValueOnce(new Error("network error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageDiscord("channel:789", "hello", { rest, token: "t" }),
|
||||||
|
).rejects.toThrow("network error");
|
||||||
|
expect(postMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries reactions on rate limits", async () => {
|
||||||
|
const { rest, putMock } = makeRest();
|
||||||
|
const rateLimitError = createMockRateLimitError(0);
|
||||||
|
|
||||||
|
putMock
|
||||||
|
.mockRejectedValueOnce(rateLimitError)
|
||||||
|
.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
const res = await reactMessageDiscord("chan1", "msg1", "ok", {
|
||||||
|
rest,
|
||||||
|
token: "t",
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(putMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries media upload without duplicating overflow text", async () => {
|
||||||
|
const { rest, postMock } = makeRest();
|
||||||
|
const rateLimitError = createMockRateLimitError(0);
|
||||||
|
const text = "a".repeat(2005);
|
||||||
|
|
||||||
|
postMock
|
||||||
|
.mockRejectedValueOnce(rateLimitError)
|
||||||
|
.mockResolvedValueOnce({ id: "msg1", channel_id: "789" })
|
||||||
|
.mockResolvedValueOnce({ id: "msg2", channel_id: "789" });
|
||||||
|
|
||||||
|
const res = await sendMessageDiscord("channel:789", text, {
|
||||||
|
rest,
|
||||||
|
token: "t",
|
||||||
|
mediaUrl: "https://example.com/photo.jpg",
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.messageId).toBe("msg1");
|
||||||
|
expect(postMock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
|
|
||||||
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
import { chunkMarkdownText } from "../auto-reply/chunk.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
|
import {
|
||||||
|
createDiscordRetryRunner,
|
||||||
|
type RetryRunner,
|
||||||
|
} from "../infra/retry-policy.js";
|
||||||
import {
|
import {
|
||||||
normalizePollDurationHours,
|
normalizePollDurationHours,
|
||||||
normalizePollInput,
|
normalizePollInput,
|
||||||
@@ -35,6 +40,7 @@ const DISCORD_POLL_MAX_ANSWERS = 10;
|
|||||||
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
const DISCORD_POLL_MAX_DURATION_HOURS = 32 * 24;
|
||||||
const DISCORD_MISSING_PERMISSIONS = 50013;
|
const DISCORD_MISSING_PERMISSIONS = 50013;
|
||||||
const DISCORD_CANNOT_DM = 50007;
|
const DISCORD_CANNOT_DM = 50007;
|
||||||
|
type DiscordRequest = RetryRunner;
|
||||||
|
|
||||||
export class DiscordSendError extends Error {
|
export class DiscordSendError extends Error {
|
||||||
kind?: "missing-permissions" | "dm-blocked";
|
kind?: "missing-permissions" | "dm-blocked";
|
||||||
@@ -72,6 +78,7 @@ type DiscordSendOpts = {
|
|||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
replyTo?: string;
|
replyTo?: string;
|
||||||
|
retry?: RetryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordSendResult = {
|
export type DiscordSendResult = {
|
||||||
@@ -82,6 +89,8 @@ export type DiscordSendResult = {
|
|||||||
export type DiscordReactOpts = {
|
export type DiscordReactOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
rest?: RequestClient;
|
rest?: RequestClient;
|
||||||
|
verbose?: boolean;
|
||||||
|
retry?: RetryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordReactionUser = {
|
export type DiscordReactionUser = {
|
||||||
@@ -187,6 +196,24 @@ function resolveRest(token: string, rest?: RequestClient) {
|
|||||||
return rest ?? new RequestClient(token);
|
return rest ?? new RequestClient(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiscordClientOpts = {
|
||||||
|
token?: string;
|
||||||
|
rest?: RequestClient;
|
||||||
|
retry?: RetryConfig;
|
||||||
|
verbose?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDiscordClient(opts: DiscordClientOpts, cfg = loadConfig()) {
|
||||||
|
const token = resolveToken(opts.token);
|
||||||
|
const rest = resolveRest(token, opts.rest);
|
||||||
|
const request = createDiscordRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: cfg.discord?.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
return { token, rest, request };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeReactionEmoji(raw: string) {
|
function normalizeReactionEmoji(raw: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -358,13 +385,18 @@ async function buildDiscordSendError(
|
|||||||
async function resolveChannelId(
|
async function resolveChannelId(
|
||||||
rest: RequestClient,
|
rest: RequestClient,
|
||||||
recipient: DiscordRecipient,
|
recipient: DiscordRecipient,
|
||||||
|
request: DiscordRequest,
|
||||||
): Promise<{ channelId: string; dm?: boolean }> {
|
): Promise<{ channelId: string; dm?: boolean }> {
|
||||||
if (recipient.kind === "channel") {
|
if (recipient.kind === "channel") {
|
||||||
return { channelId: recipient.id };
|
return { channelId: recipient.id };
|
||||||
}
|
}
|
||||||
const dmChannel = (await rest.post(Routes.userChannels(), {
|
const dmChannel = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.userChannels(), {
|
||||||
body: { recipient_id: recipient.id },
|
body: { recipient_id: recipient.id },
|
||||||
})) as { id: string };
|
}) as Promise<{ id: string }>,
|
||||||
|
"dm-channel",
|
||||||
|
)) as { id: string };
|
||||||
if (!dmChannel?.id) {
|
if (!dmChannel?.id) {
|
||||||
throw new Error("Failed to create Discord DM channel");
|
throw new Error("Failed to create Discord DM channel");
|
||||||
}
|
}
|
||||||
@@ -375,7 +407,8 @@ async function sendDiscordText(
|
|||||||
rest: RequestClient,
|
rest: RequestClient,
|
||||||
channelId: string,
|
channelId: string,
|
||||||
text: string,
|
text: string,
|
||||||
replyTo?: string,
|
replyTo: string | undefined,
|
||||||
|
request: DiscordRequest,
|
||||||
) {
|
) {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
throw new Error("Message must be non-empty for Discord sends");
|
throw new Error("Message must be non-empty for Discord sends");
|
||||||
@@ -384,21 +417,29 @@ async function sendDiscordText(
|
|||||||
? { message_id: replyTo, fail_if_not_exists: false }
|
? { message_id: replyTo, fail_if_not_exists: false }
|
||||||
: undefined;
|
: undefined;
|
||||||
if (text.length <= DISCORD_TEXT_LIMIT) {
|
if (text.length <= DISCORD_TEXT_LIMIT) {
|
||||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
const res = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: { content: text, message_reference: messageReference },
|
body: { content: text, message_reference: messageReference },
|
||||||
})) as { id: string; channel_id: string };
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
|
"text",
|
||||||
|
)) as { id: string; channel_id: string };
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT);
|
const chunks = chunkMarkdownText(text, DISCORD_TEXT_LIMIT);
|
||||||
let last: { id: string; channel_id: string } | null = null;
|
let last: { id: string; channel_id: string } | null = null;
|
||||||
let isFirst = true;
|
let isFirst = true;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
last = (await rest.post(Routes.channelMessages(channelId), {
|
last = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: {
|
||||||
content: chunk,
|
content: chunk,
|
||||||
message_reference: isFirst ? messageReference : undefined,
|
message_reference: isFirst ? messageReference : undefined,
|
||||||
},
|
},
|
||||||
})) as { id: string; channel_id: string };
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
|
"text",
|
||||||
|
)) as { id: string; channel_id: string };
|
||||||
isFirst = false;
|
isFirst = false;
|
||||||
}
|
}
|
||||||
if (!last) {
|
if (!last) {
|
||||||
@@ -412,7 +453,8 @@ async function sendDiscordMedia(
|
|||||||
channelId: string,
|
channelId: string,
|
||||||
text: string,
|
text: string,
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
replyTo?: string,
|
replyTo: string | undefined,
|
||||||
|
request: DiscordRequest,
|
||||||
) {
|
) {
|
||||||
const media = await loadWebMedia(mediaUrl);
|
const media = await loadWebMedia(mediaUrl);
|
||||||
const caption =
|
const caption =
|
||||||
@@ -420,7 +462,9 @@ async function sendDiscordMedia(
|
|||||||
const messageReference = replyTo
|
const messageReference = replyTo
|
||||||
? { message_id: replyTo, fail_if_not_exists: false }
|
? { message_id: replyTo, fail_if_not_exists: false }
|
||||||
: undefined;
|
: undefined;
|
||||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
const res = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: {
|
||||||
content: caption || undefined,
|
content: caption || undefined,
|
||||||
message_reference: messageReference,
|
message_reference: messageReference,
|
||||||
@@ -431,11 +475,13 @@ async function sendDiscordMedia(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})) as { id: string; channel_id: string };
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
|
"media",
|
||||||
|
)) as { id: string; channel_id: string };
|
||||||
if (text.length > DISCORD_TEXT_LIMIT) {
|
if (text.length > DISCORD_TEXT_LIMIT) {
|
||||||
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
|
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
|
||||||
if (remaining) {
|
if (remaining) {
|
||||||
await sendDiscordText(rest, channelId, remaining);
|
await sendDiscordText(rest, channelId, remaining, undefined, request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
@@ -471,10 +517,10 @@ export async function sendMessageDiscord(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: DiscordSendOpts = {},
|
opts: DiscordSendOpts = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
const rest = resolveRest(token, opts.rest);
|
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
let result:
|
let result:
|
||||||
| { id: string; channel_id: string }
|
| { id: string; channel_id: string }
|
||||||
| { id: string | null; channel_id: string };
|
| { id: string | null; channel_id: string };
|
||||||
@@ -486,9 +532,16 @@ export async function sendMessageDiscord(
|
|||||||
text,
|
text,
|
||||||
opts.mediaUrl,
|
opts.mediaUrl,
|
||||||
opts.replyTo,
|
opts.replyTo,
|
||||||
|
request,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await sendDiscordText(rest, channelId, text, opts.replyTo);
|
result = await sendDiscordText(
|
||||||
|
rest,
|
||||||
|
channelId,
|
||||||
|
text,
|
||||||
|
opts.replyTo,
|
||||||
|
request,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw await buildDiscordSendError(err, {
|
throw await buildDiscordSendError(err, {
|
||||||
@@ -510,18 +563,22 @@ export async function sendStickerDiscord(
|
|||||||
stickerIds: string[],
|
stickerIds: string[],
|
||||||
opts: DiscordSendOpts & { content?: string } = {},
|
opts: DiscordSendOpts & { content?: string } = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
const rest = resolveRest(token, opts.rest);
|
const { rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
const content = opts.content?.trim();
|
const content = opts.content?.trim();
|
||||||
const stickers = normalizeStickerIds(stickerIds);
|
const stickers = normalizeStickerIds(stickerIds);
|
||||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
const res = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: {
|
||||||
content: content || undefined,
|
content: content || undefined,
|
||||||
sticker_ids: stickers,
|
sticker_ids: stickers,
|
||||||
},
|
},
|
||||||
})) as { id: string; channel_id: string };
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
|
"sticker",
|
||||||
|
)) as { id: string; channel_id: string };
|
||||||
return {
|
return {
|
||||||
messageId: res.id ? String(res.id) : "unknown",
|
messageId: res.id ? String(res.id) : "unknown",
|
||||||
channelId: String(res.channel_id ?? channelId),
|
channelId: String(res.channel_id ?? channelId),
|
||||||
@@ -533,18 +590,22 @@ export async function sendPollDiscord(
|
|||||||
poll: PollInput,
|
poll: PollInput,
|
||||||
opts: DiscordSendOpts & { content?: string } = {},
|
opts: DiscordSendOpts & { content?: string } = {},
|
||||||
): Promise<DiscordSendResult> {
|
): Promise<DiscordSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
const rest = resolveRest(token, opts.rest);
|
const { rest, request } = createDiscordClient(opts, cfg);
|
||||||
const recipient = parseRecipient(to);
|
const recipient = parseRecipient(to);
|
||||||
const { channelId } = await resolveChannelId(rest, recipient);
|
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||||
const content = opts.content?.trim();
|
const content = opts.content?.trim();
|
||||||
const payload = normalizeDiscordPollInput(poll);
|
const payload = normalizeDiscordPollInput(poll);
|
||||||
const res = (await rest.post(Routes.channelMessages(channelId), {
|
const res = (await request(
|
||||||
|
() =>
|
||||||
|
rest.post(Routes.channelMessages(channelId), {
|
||||||
body: {
|
body: {
|
||||||
content: content || undefined,
|
content: content || undefined,
|
||||||
poll: payload,
|
poll: payload,
|
||||||
},
|
},
|
||||||
})) as { id: string; channel_id: string };
|
}) as Promise<{ id: string; channel_id: string }>,
|
||||||
|
"poll",
|
||||||
|
)) as { id: string; channel_id: string };
|
||||||
return {
|
return {
|
||||||
messageId: res.id ? String(res.id) : "unknown",
|
messageId: res.id ? String(res.id) : "unknown",
|
||||||
channelId: String(res.channel_id ?? channelId),
|
channelId: String(res.channel_id ?? channelId),
|
||||||
@@ -557,11 +618,13 @@ export async function reactMessageDiscord(
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
opts: DiscordReactOpts = {},
|
opts: DiscordReactOpts = {},
|
||||||
) {
|
) {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
const rest = resolveRest(token, opts.rest);
|
const { rest, request } = createDiscordClient(opts, cfg);
|
||||||
const encoded = normalizeReactionEmoji(emoji);
|
const encoded = normalizeReactionEmoji(emoji);
|
||||||
await rest.put(
|
await request(
|
||||||
Routes.channelMessageOwnReaction(channelId, messageId, encoded),
|
() =>
|
||||||
|
rest.put(Routes.channelMessageOwnReaction(channelId, messageId, encoded)),
|
||||||
|
"react",
|
||||||
);
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/infra/retry-policy.ts
Normal file
106
src/infra/retry-policy.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { RateLimitError } from "@buape/carbon";
|
||||||
|
|
||||||
|
import { formatErrorMessage } from "./errors.js";
|
||||||
|
import { type RetryConfig, resolveRetryConfig, retryAsync } from "./retry.js";
|
||||||
|
|
||||||
|
export type RetryRunner = <T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
label?: string,
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
export const DISCORD_RETRY_DEFAULTS = {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 500,
|
||||||
|
maxDelayMs: 30_000,
|
||||||
|
jitter: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TELEGRAM_RETRY_DEFAULTS = {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 400,
|
||||||
|
maxDelayMs: 30_000,
|
||||||
|
jitter: 0.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TELEGRAM_RETRY_RE =
|
||||||
|
/429|timeout|connect|reset|closed|unavailable|temporarily/i;
|
||||||
|
|
||||||
|
function getTelegramRetryAfterMs(err: unknown): number | undefined {
|
||||||
|
if (!err || typeof err !== "object") return undefined;
|
||||||
|
const candidate =
|
||||||
|
"parameters" in err && err.parameters && typeof err.parameters === "object"
|
||||||
|
? (err.parameters as { retry_after?: unknown }).retry_after
|
||||||
|
: "response" in err &&
|
||||||
|
err.response &&
|
||||||
|
typeof err.response === "object" &&
|
||||||
|
"parameters" in err.response
|
||||||
|
? (
|
||||||
|
err.response as {
|
||||||
|
parameters?: { retry_after?: unknown };
|
||||||
|
}
|
||||||
|
).parameters?.retry_after
|
||||||
|
: "error" in err &&
|
||||||
|
err.error &&
|
||||||
|
typeof err.error === "object" &&
|
||||||
|
"parameters" in err.error
|
||||||
|
? (err.error as { parameters?: { retry_after?: unknown } }).parameters
|
||||||
|
?.retry_after
|
||||||
|
: undefined;
|
||||||
|
return typeof candidate === "number" && Number.isFinite(candidate)
|
||||||
|
? candidate * 1000
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDiscordRetryRunner(params: {
|
||||||
|
retry?: RetryConfig;
|
||||||
|
configRetry?: RetryConfig;
|
||||||
|
verbose?: boolean;
|
||||||
|
}): RetryRunner {
|
||||||
|
const retryConfig = resolveRetryConfig(DISCORD_RETRY_DEFAULTS, {
|
||||||
|
...params.configRetry,
|
||||||
|
...params.retry,
|
||||||
|
});
|
||||||
|
return <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
|
retryAsync(fn, {
|
||||||
|
...retryConfig,
|
||||||
|
label,
|
||||||
|
shouldRetry: (err) => err instanceof RateLimitError,
|
||||||
|
retryAfterMs: (err) =>
|
||||||
|
err instanceof RateLimitError ? err.retryAfter * 1000 : undefined,
|
||||||
|
onRetry: params.verbose
|
||||||
|
? (info) => {
|
||||||
|
const labelText = info.label ?? "request";
|
||||||
|
const maxRetries = Math.max(1, info.maxAttempts - 1);
|
||||||
|
console.warn(
|
||||||
|
`discord ${labelText} rate limited, retry ${info.attempt}/${maxRetries} in ${info.delayMs}ms`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTelegramRetryRunner(params: {
|
||||||
|
retry?: RetryConfig;
|
||||||
|
configRetry?: RetryConfig;
|
||||||
|
verbose?: boolean;
|
||||||
|
}): RetryRunner {
|
||||||
|
const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, {
|
||||||
|
...params.configRetry,
|
||||||
|
...params.retry,
|
||||||
|
});
|
||||||
|
return <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
|
retryAsync(fn, {
|
||||||
|
...retryConfig,
|
||||||
|
label,
|
||||||
|
shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)),
|
||||||
|
retryAfterMs: getTelegramRetryAfterMs,
|
||||||
|
onRetry: params.verbose
|
||||||
|
? (info) => {
|
||||||
|
const maxRetries = Math.max(1, info.maxAttempts - 1);
|
||||||
|
console.warn(
|
||||||
|
`telegram send retry ${info.attempt}/${maxRetries} for ${info.label ?? label ?? "request"} in ${info.delayMs}ms: ${formatErrorMessage(info.err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -25,4 +25,80 @@ describe("retryAsync", () => {
|
|||||||
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
await expect(retryAsync(fn, 2, 1)).rejects.toThrow("boom");
|
||||||
expect(fn).toHaveBeenCalledTimes(2);
|
expect(fn).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops when shouldRetry returns false", async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
|
await expect(
|
||||||
|
retryAsync(fn, { attempts: 3, shouldRetry: () => false }),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onRetry before retrying", async () => {
|
||||||
|
const fn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("boom"))
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
const res = await retryAsync(fn, {
|
||||||
|
attempts: 2,
|
||||||
|
minDelayMs: 0,
|
||||||
|
maxDelayMs: 0,
|
||||||
|
onRetry,
|
||||||
|
});
|
||||||
|
expect(res).toBe("ok");
|
||||||
|
expect(onRetry).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ attempt: 1, maxAttempts: 2 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps attempts to at least 1", async () => {
|
||||||
|
const fn = vi.fn().mockRejectedValue(new Error("boom"));
|
||||||
|
await expect(
|
||||||
|
retryAsync(fn, { attempts: 0, minDelayMs: 0, maxDelayMs: 0 }),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses retryAfterMs when provided", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("boom"))
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
const delays: number[] = [];
|
||||||
|
const promise = retryAsync(fn, {
|
||||||
|
attempts: 2,
|
||||||
|
minDelayMs: 0,
|
||||||
|
maxDelayMs: 1000,
|
||||||
|
jitter: 0,
|
||||||
|
retryAfterMs: () => 500,
|
||||||
|
onRetry: (info) => delays.push(info.delayMs),
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise).resolves.toBe("ok");
|
||||||
|
expect(delays[0]).toBe(500);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps retryAfterMs to maxDelayMs", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fn = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("boom"))
|
||||||
|
.mockResolvedValueOnce("ok");
|
||||||
|
const delays: number[] = [];
|
||||||
|
const promise = retryAsync(fn, {
|
||||||
|
attempts: 2,
|
||||||
|
minDelayMs: 0,
|
||||||
|
maxDelayMs: 100,
|
||||||
|
jitter: 0,
|
||||||
|
retryAfterMs: () => 500,
|
||||||
|
onRetry: (info) => delays.push(info.delayMs),
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise).resolves.toBe("ok");
|
||||||
|
expect(delays[0]).toBe(100);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,83 @@
|
|||||||
|
export type RetryConfig = {
|
||||||
|
attempts?: number;
|
||||||
|
minDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
jitter?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetryInfo = {
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
delayMs: number;
|
||||||
|
err: unknown;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RetryOptions = RetryConfig & {
|
||||||
|
label?: string;
|
||||||
|
shouldRetry?: (err: unknown, attempt: number) => boolean;
|
||||||
|
retryAfterMs?: (err: unknown) => number | undefined;
|
||||||
|
onRetry?: (info: RetryInfo) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_RETRY_CONFIG = {
|
||||||
|
attempts: 3,
|
||||||
|
minDelayMs: 300,
|
||||||
|
maxDelayMs: 30_000,
|
||||||
|
jitter: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const asFiniteNumber = (value: unknown): number | undefined =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
|
||||||
|
const clampNumber = (
|
||||||
|
value: unknown,
|
||||||
|
fallback: number,
|
||||||
|
min?: number,
|
||||||
|
max?: number,
|
||||||
|
) => {
|
||||||
|
const next = asFiniteNumber(value);
|
||||||
|
if (next === undefined) return fallback;
|
||||||
|
const floor = typeof min === "number" ? min : Number.NEGATIVE_INFINITY;
|
||||||
|
const ceiling = typeof max === "number" ? max : Number.POSITIVE_INFINITY;
|
||||||
|
return Math.min(Math.max(next, floor), ceiling);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveRetryConfig(
|
||||||
|
defaults: Required<RetryConfig> = DEFAULT_RETRY_CONFIG,
|
||||||
|
overrides?: RetryConfig,
|
||||||
|
): Required<RetryConfig> {
|
||||||
|
const attempts = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(clampNumber(overrides?.attempts, defaults.attempts, 1)),
|
||||||
|
);
|
||||||
|
const minDelayMs = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round(clampNumber(overrides?.minDelayMs, defaults.minDelayMs, 0)),
|
||||||
|
);
|
||||||
|
const maxDelayMs = Math.max(
|
||||||
|
minDelayMs,
|
||||||
|
Math.round(clampNumber(overrides?.maxDelayMs, defaults.maxDelayMs, 0)),
|
||||||
|
);
|
||||||
|
const jitter = clampNumber(overrides?.jitter, defaults.jitter, 0, 1);
|
||||||
|
return { attempts, minDelayMs, maxDelayMs, jitter };
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJitter(delayMs: number, jitter: number): number {
|
||||||
|
if (jitter <= 0) return delayMs;
|
||||||
|
const offset = (Math.random() * 2 - 1) * jitter;
|
||||||
|
return Math.max(0, Math.round(delayMs * (1 + offset)));
|
||||||
|
}
|
||||||
|
|
||||||
export async function retryAsync<T>(
|
export async function retryAsync<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
attempts = 3,
|
attemptsOrOptions: number | RetryOptions = 3,
|
||||||
initialDelayMs = 300,
|
initialDelayMs = 300,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
if (typeof attemptsOrOptions === "number") {
|
||||||
|
const attempts = Math.max(1, Math.round(attemptsOrOptions));
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let i = 0; i < attempts; i += 1) {
|
for (let i = 0; i < attempts; i += 1) {
|
||||||
try {
|
try {
|
||||||
@@ -11,8 +86,52 @@ export async function retryAsync<T>(
|
|||||||
lastErr = err;
|
lastErr = err;
|
||||||
if (i === attempts - 1) break;
|
if (i === attempts - 1) break;
|
||||||
const delay = initialDelayMs * 2 ** i;
|
const delay = initialDelayMs * 2 ** i;
|
||||||
await new Promise((r) => setTimeout(r, delay));
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastErr;
|
throw lastErr ?? new Error("Retry failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = attemptsOrOptions;
|
||||||
|
|
||||||
|
const resolved = resolveRetryConfig(DEFAULT_RETRY_CONFIG, options);
|
||||||
|
const maxAttempts = resolved.attempts;
|
||||||
|
const minDelayMs = resolved.minDelayMs;
|
||||||
|
const maxDelayMs =
|
||||||
|
Number.isFinite(resolved.maxDelayMs) && resolved.maxDelayMs > 0
|
||||||
|
? resolved.maxDelayMs
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const jitter = resolved.jitter;
|
||||||
|
const shouldRetry = options.shouldRetry ?? (() => true);
|
||||||
|
let lastErr: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
if (attempt >= maxAttempts || !shouldRetry(err, attempt)) break;
|
||||||
|
|
||||||
|
const retryAfterMs = options.retryAfterMs?.(err);
|
||||||
|
const hasRetryAfter =
|
||||||
|
typeof retryAfterMs === "number" && Number.isFinite(retryAfterMs);
|
||||||
|
const baseDelay = hasRetryAfter
|
||||||
|
? Math.max(retryAfterMs, minDelayMs)
|
||||||
|
: minDelayMs * 2 ** (attempt - 1);
|
||||||
|
let delay = Math.min(baseDelay, maxDelayMs);
|
||||||
|
delay = applyJitter(delay, jitter);
|
||||||
|
delay = Math.min(Math.max(delay, minDelayMs), maxDelayMs);
|
||||||
|
|
||||||
|
options.onRetry?.({
|
||||||
|
attempt,
|
||||||
|
maxAttempts,
|
||||||
|
delayMs: delay,
|
||||||
|
err,
|
||||||
|
label: options.label,
|
||||||
|
});
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastErr ?? new Error("Retry failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,56 @@ describe("sendMessageTelegram", () => {
|
|||||||
).rejects.toThrow(/chat_id=123/);
|
).rejects.toThrow(/chat_id=123/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("retries on transient errors with retry_after", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const chatId = "123";
|
||||||
|
const err = Object.assign(new Error("429"), {
|
||||||
|
parameters: { retry_after: 0.5 },
|
||||||
|
});
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(err)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
message_id: 1,
|
||||||
|
chat: { id: chatId },
|
||||||
|
});
|
||||||
|
const api = { sendMessage } as unknown as {
|
||||||
|
sendMessage: typeof sendMessage;
|
||||||
|
};
|
||||||
|
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||||
|
|
||||||
|
const promise = sendMessageTelegram(chatId, "hi", {
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(promise).resolves.toEqual({ messageId: "1", chatId });
|
||||||
|
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
|
||||||
|
setTimeoutSpy.mockRestore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry on non-transient errors", async () => {
|
||||||
|
const chatId = "123";
|
||||||
|
const sendMessage = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("400: Bad Request"));
|
||||||
|
const api = { sendMessage } as unknown as {
|
||||||
|
sendMessage: typeof sendMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageTelegram(chatId, "hi", {
|
||||||
|
token: "tok",
|
||||||
|
api,
|
||||||
|
retry: { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Bad Request/);
|
||||||
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("sends GIF media as animation", async () => {
|
it("sends GIF media as animation", async () => {
|
||||||
const chatId = "123";
|
const chatId = "123";
|
||||||
const sendAnimation = vi.fn().mockResolvedValue({
|
const sendAnimation = vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { Bot, InputFile } from "grammy";
|
import { Bot, InputFile } from "grammy";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import type { ClawdbotConfig } from "../config/types.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import type { RetryConfig } from "../infra/retry.js";
|
||||||
|
import { createTelegramRetryRunner } from "../infra/retry-policy.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
import { isGifMedia } from "../media/mime.js";
|
import { isGifMedia } from "../media/mime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
import { resolveTelegramToken } from "./token.js";
|
||||||
|
|
||||||
type TelegramSendOpts = {
|
type TelegramSendOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -12,6 +17,7 @@ type TelegramSendOpts = {
|
|||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
api?: Bot["api"];
|
api?: Bot["api"];
|
||||||
|
retry?: RetryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TelegramSendResult = {
|
type TelegramSendResult = {
|
||||||
@@ -23,16 +29,19 @@ type TelegramReactionOpts = {
|
|||||||
token?: string;
|
token?: string;
|
||||||
api?: Bot["api"];
|
api?: Bot["api"];
|
||||||
remove?: boolean;
|
remove?: boolean;
|
||||||
|
verbose?: boolean;
|
||||||
|
retry?: RetryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PARSE_ERR_RE =
|
const PARSE_ERR_RE =
|
||||||
/can't parse entities|parse entities|find end of the entity/i;
|
/can't parse entities|parse entities|find end of the entity/i;
|
||||||
|
|
||||||
function resolveToken(explicit?: string): string {
|
function resolveToken(explicit?: string, cfg?: ClawdbotConfig): string {
|
||||||
const token = explicit ?? process.env.TELEGRAM_BOT_TOKEN;
|
if (explicit?.trim()) return explicit.trim();
|
||||||
|
const { token } = resolveTelegramToken(cfg);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"TELEGRAM_BOT_TOKEN is required for Telegram sends (Bot API)",
|
"TELEGRAM_BOT_TOKEN (or telegram.botToken/tokenFile) is required for Telegram sends (Bot API)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return token.trim();
|
return token.trim();
|
||||||
@@ -84,7 +93,8 @@ export async function sendMessageTelegram(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: TelegramSendOpts = {},
|
opts: TelegramSendOpts = {},
|
||||||
): Promise<TelegramSendResult> {
|
): Promise<TelegramSendResult> {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
|
const token = resolveToken(opts.token, cfg);
|
||||||
const chatId = normalizeChatId(to);
|
const chatId = normalizeChatId(to);
|
||||||
const bot = opts.api ? null : new Bot(token);
|
const bot = opts.api ? null : new Bot(token);
|
||||||
const api = opts.api ?? bot?.api;
|
const api = opts.api ?? bot?.api;
|
||||||
@@ -93,34 +103,11 @@ export async function sendMessageTelegram(
|
|||||||
typeof opts.messageThreadId === "number"
|
typeof opts.messageThreadId === "number"
|
||||||
? { message_thread_id: Math.trunc(opts.messageThreadId) }
|
? { message_thread_id: Math.trunc(opts.messageThreadId) }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
const sleep = (ms: number) =>
|
retry: opts.retry,
|
||||||
new Promise((resolve) => setTimeout(resolve, ms));
|
configRetry: cfg.telegram?.retry,
|
||||||
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string) => {
|
verbose: opts.verbose,
|
||||||
let lastErr: unknown;
|
});
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (err) {
|
|
||||||
lastErr = err;
|
|
||||||
const errText = formatErrorMessage(err);
|
|
||||||
const terminal =
|
|
||||||
attempt === 3 ||
|
|
||||||
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(
|
|
||||||
errText,
|
|
||||||
);
|
|
||||||
if (terminal) break;
|
|
||||||
const backoff = 400 * attempt;
|
|
||||||
if (opts.verbose) {
|
|
||||||
console.warn(
|
|
||||||
`telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${errText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sleep(backoff);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw lastErr ?? new Error(`Telegram send failed (${label})`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const wrapChatNotFound = (err: unknown) => {
|
const wrapChatNotFound = (err: unknown) => {
|
||||||
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err)))
|
if (!/400: Bad Request: chat not found/i.test(formatErrorMessage(err)))
|
||||||
@@ -154,35 +141,35 @@ export async function sendMessageTelegram(
|
|||||||
| Awaited<ReturnType<typeof api.sendAnimation>>
|
| Awaited<ReturnType<typeof api.sendAnimation>>
|
||||||
| Awaited<ReturnType<typeof api.sendDocument>>;
|
| Awaited<ReturnType<typeof api.sendDocument>>;
|
||||||
if (isGif) {
|
if (isGif) {
|
||||||
result = await sendWithRetry(
|
result = await request(
|
||||||
() => api.sendAnimation(chatId, file, { caption, ...threadParams }),
|
() => api.sendAnimation(chatId, file, { caption, ...threadParams }),
|
||||||
"animation",
|
"animation",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "image") {
|
} else if (kind === "image") {
|
||||||
result = await sendWithRetry(
|
result = await request(
|
||||||
() => api.sendPhoto(chatId, file, { caption, ...threadParams }),
|
() => api.sendPhoto(chatId, file, { caption, ...threadParams }),
|
||||||
"photo",
|
"photo",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
result = await sendWithRetry(
|
result = await request(
|
||||||
() => api.sendVideo(chatId, file, { caption, ...threadParams }),
|
() => api.sendVideo(chatId, file, { caption, ...threadParams }),
|
||||||
"video",
|
"video",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else if (kind === "audio") {
|
} else if (kind === "audio") {
|
||||||
result = await sendWithRetry(
|
result = await request(
|
||||||
() => api.sendAudio(chatId, file, { caption, ...threadParams }),
|
() => api.sendAudio(chatId, file, { caption, ...threadParams }),
|
||||||
"audio",
|
"audio",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
throw wrapChatNotFound(err);
|
throw wrapChatNotFound(err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
result = await sendWithRetry(
|
result = await request(
|
||||||
() => api.sendDocument(chatId, file, { caption, ...threadParams }),
|
() => api.sendDocument(chatId, file, { caption, ...threadParams }),
|
||||||
"document",
|
"document",
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
@@ -196,7 +183,7 @@ export async function sendMessageTelegram(
|
|||||||
if (!text || !text.trim()) {
|
if (!text || !text.trim()) {
|
||||||
throw new Error("Message must be non-empty for Telegram sends");
|
throw new Error("Message must be non-empty for Telegram sends");
|
||||||
}
|
}
|
||||||
const res = await sendWithRetry(
|
const res = await request(
|
||||||
() =>
|
() =>
|
||||||
api.sendMessage(chatId, text, {
|
api.sendMessage(chatId, text, {
|
||||||
parse_mode: "Markdown",
|
parse_mode: "Markdown",
|
||||||
@@ -213,7 +200,7 @@ export async function sendMessageTelegram(
|
|||||||
`telegram markdown parse failed, retrying as plain text: ${errText}`,
|
`telegram markdown parse failed, retrying as plain text: ${errText}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return await sendWithRetry(
|
return await request(
|
||||||
() =>
|
() =>
|
||||||
threadParams
|
threadParams
|
||||||
? api.sendMessage(chatId, text, threadParams)
|
? api.sendMessage(chatId, text, threadParams)
|
||||||
@@ -235,11 +222,17 @@ export async function reactMessageTelegram(
|
|||||||
emoji: string,
|
emoji: string,
|
||||||
opts: TelegramReactionOpts = {},
|
opts: TelegramReactionOpts = {},
|
||||||
): Promise<{ ok: true }> {
|
): Promise<{ ok: true }> {
|
||||||
const token = resolveToken(opts.token);
|
const cfg = loadConfig();
|
||||||
|
const token = resolveToken(opts.token, cfg);
|
||||||
const chatId = normalizeChatId(String(chatIdInput));
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
const messageId = normalizeMessageId(messageIdInput);
|
const messageId = normalizeMessageId(messageIdInput);
|
||||||
const bot = opts.api ? null : new Bot(token);
|
const bot = opts.api ? null : new Bot(token);
|
||||||
const api = opts.api ?? bot?.api;
|
const api = opts.api ?? bot?.api;
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: cfg.telegram?.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
const remove = opts.remove === true;
|
const remove = opts.remove === true;
|
||||||
const trimmedEmoji = emoji.trim();
|
const trimmedEmoji = emoji.trim();
|
||||||
const reactions =
|
const reactions =
|
||||||
@@ -247,7 +240,10 @@ export async function reactMessageTelegram(
|
|||||||
if (typeof api.setMessageReaction !== "function") {
|
if (typeof api.setMessageReaction !== "function") {
|
||||||
throw new Error("Telegram reactions are unavailable in this bot API.");
|
throw new Error("Telegram reactions are unavailable in this bot API.");
|
||||||
}
|
}
|
||||||
await api.setMessageReaction(chatId, messageId, reactions);
|
await request(
|
||||||
|
() => api.setMessageReaction(chatId, messageId, reactions),
|
||||||
|
"reaction",
|
||||||
|
);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user