fix(ci): harden telegram seams and cap job timeouts

This commit is contained in:
Vincent Koc
2026-03-22 21:38:26 -07:00
parent 6eafa2ec87
commit 09cb77ed38
8 changed files with 95 additions and 10 deletions

View File

@@ -21,6 +21,7 @@ jobs:
docs-scope:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
docs_only: ${{ steps.check.outputs.docs_only }}
docs_changed: ${{ steps.check.outputs.docs_changed }}
@@ -48,6 +49,7 @@ jobs:
needs: [docs-scope]
if: needs.docs-scope.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
run_node: ${{ steps.scope.outputs.run_node }}
run_macos: ${{ steps.scope.outputs.run_macos }}
@@ -86,6 +88,7 @@ jobs:
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
@@ -129,6 +132,7 @@ jobs:
secrets:
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -209,6 +213,7 @@ jobs:
needs: [docs-scope, changed-scope, changed-extensions, secrets]
if: always() && needs.docs-scope.result == 'success' && (needs.changed-scope.result == 'success' || needs.changed-scope.result == 'skipped') && (needs.changed-extensions.result == 'success' || needs.changed-extensions.result == 'skipped') && needs.secrets.result == 'success'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
outputs:
docs_only: ${{ needs.docs-scope.outputs.docs_only }}
docs_changed: ${{ needs.docs-scope.outputs.docs_changed }}
@@ -230,6 +235,7 @@ jobs:
needs: [preflight]
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -264,6 +270,7 @@ jobs:
needs: [preflight, build-artifacts]
if: github.event_name == 'push' && needs.preflight.outputs.docs_only != 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -289,6 +296,7 @@ jobs:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
@@ -384,6 +392,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && needs.preflight.outputs.has_changed_extensions == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.changed_extensions_matrix) }}
@@ -410,6 +419,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -433,6 +443,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -530,6 +541,7 @@ jobs:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -570,6 +582,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_changed == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -589,6 +602,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.preflight.outputs.run_skills_python == 'true')
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -615,7 +629,7 @@ jobs:
needs: [preflight, build-artifacts]
if: always() && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
runs-on: blacksmith-32vcpu-windows-2025
timeout-minutes: 45
timeout-minutes: 20
env:
NODE_OPTIONS: --max-old-space-size=6144
# Keep total concurrency predictable on the 32 vCPU runner.
@@ -755,6 +769,7 @@ jobs:
needs: [preflight]
if: github.event_name == 'pull_request' && needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_macos == 'true'
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -831,6 +846,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.docs_only != 'true' && needs.preflight.outputs.run_android == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:

View File

@@ -1,12 +1,17 @@
import { API_CONSTANTS } from "grammy";
import { describe, expect, it } from "vitest";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { DEFAULT_TELEGRAM_UPDATE_TYPES, resolveTelegramAllowedUpdates } from "./allowed-updates.js";
describe("resolveTelegramAllowedUpdates", () => {
it("includes the default update types plus reaction and channel post support", () => {
const updates = resolveTelegramAllowedUpdates();
expect(updates).toEqual(expect.arrayContaining([...API_CONSTANTS.DEFAULT_UPDATE_TYPES]));
expect(updates).toEqual(
expect.arrayContaining([
...DEFAULT_TELEGRAM_UPDATE_TYPES,
...(API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? []),
]),
);
expect(updates).toContain("message_reaction");
expect(updates).toContain("channel_post");
expect(new Set(updates).size).toBe(updates.length);

View File

@@ -1,9 +1,60 @@
import { API_CONSTANTS } from "grammy";
type TelegramUpdateType = (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
const FALLBACK_ALL_UPDATE_TYPES = [
"message",
"edited_message",
"channel_post",
"edited_channel_post",
"business_connection",
"business_message",
"edited_business_message",
"deleted_business_messages",
"message_reaction",
"message_reaction_count",
"inline_query",
"chosen_inline_result",
"callback_query",
"shipping_query",
"pre_checkout_query",
"poll",
"poll_answer",
"my_chat_member",
"chat_member",
"chat_join_request",
] as const;
const FALLBACK_DEFAULT_UPDATE_TYPES = [
"message",
"edited_message",
"channel_post",
"edited_channel_post",
"business_connection",
"business_message",
"edited_business_message",
"deleted_business_messages",
"message_reaction",
"message_reaction_count",
"inline_query",
"chosen_inline_result",
"callback_query",
"shipping_query",
"pre_checkout_query",
"poll",
"poll_answer",
"my_chat_member",
"chat_member",
"chat_join_request",
] as const;
export type TelegramUpdateType =
| (typeof FALLBACK_ALL_UPDATE_TYPES)[number]
| (typeof API_CONSTANTS.ALL_UPDATE_TYPES)[number];
export const DEFAULT_TELEGRAM_UPDATE_TYPES: ReadonlyArray<TelegramUpdateType> =
API_CONSTANTS?.DEFAULT_UPDATE_TYPES ?? FALLBACK_DEFAULT_UPDATE_TYPES;
export function resolveTelegramAllowedUpdates(): ReadonlyArray<TelegramUpdateType> {
const updates = [...API_CONSTANTS.DEFAULT_UPDATE_TYPES] as TelegramUpdateType[];
const updates = [...DEFAULT_TELEGRAM_UPDATE_TYPES] as TelegramUpdateType[];
if (!updates.includes("message_reaction")) {
updates.push("message_reaction");
}

View File

@@ -8,6 +8,12 @@ vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
Agent:
actual.Agent ??
class Agent {
close() {}
destroy() {}
},
fetch: undiciFetch,
};
});

View File

@@ -17,7 +17,7 @@ export type TelegramBotDeps = {
upsertChannelPairingRequest: typeof upsertChannelPairingRequest;
enqueueSystemEvent: typeof enqueueSystemEvent;
dispatchReplyWithBufferedBlockDispatcher: typeof dispatchReplyWithBufferedBlockDispatcher;
loadWebMedia: typeof loadWebMedia;
loadWebMedia?: typeof loadWebMedia;
buildModelsProviderData: typeof buildModelsProviderData;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
wasSentByBot: typeof wasSentByBot;

View File

@@ -43,6 +43,8 @@ import {
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
const CAPTION_TOO_LONG_RE = /caption is too long/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
type DeliveryProgress = ReplyThreadDeliveryProgress & {
deliveredCount: number;
@@ -175,14 +177,14 @@ async function sendPendingFollowUpText(params: {
}
function isVoiceMessagesForbidden(err: unknown): boolean {
if (err instanceof GrammyError) {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return VOICE_FORBIDDEN_RE.test(err.description);
}
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
}
function isCaptionTooLong(err: unknown): boolean {
if (err instanceof GrammyError) {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return CAPTION_TOO_LONG_RE.test(err.description);
}
return CAPTION_TOO_LONG_RE.test(formatErrorMessage(err));

View File

@@ -15,6 +15,9 @@ import { resolveTelegramMediaPlaceholder } from "./helpers.js";
import type { StickerMetadata, TelegramContext } from "./types.js";
const FILE_TOO_BIG_RE = /file is too big/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
const hostnames = ["api.telegram.org"];
if (apiRoot) {
@@ -41,7 +44,7 @@ function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
* Unlike network errors, this is a permanent error and should not be retried.
*/
function isFileTooBigError(err: unknown): boolean {
if (err instanceof GrammyError) {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return FILE_TOO_BIG_RE.test(err.description);
}
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));

View File

@@ -9,9 +9,11 @@ import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
const THREAD_NOT_FOUND_RE = /message thread not found/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
function isTelegramThreadNotFoundError(err: unknown): boolean {
if (err instanceof GrammyError) {
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
return THREAD_NOT_FOUND_RE.test(err.description);
}
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));