stitchkit 0.113.3 → 0.114.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 +13 -0
- package/dist/entrypoints/telegram/screens.js +1 -1
- package/dist/entrypoints/telegram.d.ts +1 -0
- package/dist/entrypoints/telegram.d.ts.map +1 -1
- package/dist/entrypoints/telegram.js +241 -1
- package/dist/{index-xvj9h8yh.js → index-hs35ss3p.js} +11 -3
- package/dist/telegram/outbound-budget.d.ts +99 -0
- package/dist/telegram/outbound-budget.d.ts.map +1 -0
- package/dist/telegram/send-failure.d.ts +6 -1
- package/dist/telegram/send-failure.d.ts.map +1 -1
- package/llms/server.4.txt +5 -0
- package/llms/stitchkit.txt +4 -4
- package/llms/telegram.txt +64 -2
- package/llms/{upgrading-0.103.13-0.113.0.txt → upgrading-0.104.0-0.114.0.txt} +45 -84
- package/llms/upgrading-0.37.0-0.44.0.txt +444 -0
- package/llms/{upgrading-0.44.0-0.60.0.txt → upgrading-0.44.0-0.61.0.txt} +140 -271
- package/llms/{upgrading-0.61.0-0.74.0.txt → upgrading-0.62.0-0.78.0.txt} +137 -140
- package/llms/{upgrading-0.75.0-0.103.0.txt → upgrading-0.79.0-0.103.13.txt} +84 -137
- package/llms/upgrading.txt +5 -5
- package/llms-full.txt +115 -6
- package/llms.txt +8 -8
- package/package.json +1 -1
- package/llms/upgrading-0.37.0.txt +0 -176
package/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,19 @@ additive**; the first breaking change landed in 0.10.0. Grep the file for
|
|
|
15
15
|
|
|
16
16
|
## [Unreleased]
|
|
17
17
|
|
|
18
|
+
## [0.114.0] - 2026-10-30
|
|
19
|
+
|
|
20
|
+
### ⚠️ Breaking changes
|
|
21
|
+
|
|
22
|
+
**Who must act:** code with an exhaustive `switch` or `Record` over `TelegramSendFailureReason` or `TELEGRAM_SEND_FAILURE_REASONS`, or a consumer table that must handle every reason. Code that only reads `retryable` and `recipientUnreachable` needs no change.
|
|
23
|
+
|
|
24
|
+
- `stitchkit/telegram` — **`TelegramSendFailureReason` gains `bot-to-bot-disabled`.** A `400 USER_BOT_TO_BOT_DISABLED` is now a permanent refusal instead of `unknown`; it is not retried and does not mark the recipient unreachable, because Telegram reports it when either bot has not enabled Bot to Bot messaging. Before: the reason was `unknown` and an exhaustive table compiled. After: add the reason and decide how to report it (`retryable: false`, `recipientUnreachable: false`). → ADR 0274
|
|
25
|
+
**Affects:** `stitchkit/telegram` TelegramSendFailureReason, TELEGRAM_SEND_FAILURE_REASONS, classifyTelegramSendFailure(reason)
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `stitchkit/telegram` — `telegramOutboundBudget` gives one bot token one process-wide outbound budget: aliases of the token share it, different tokens are independent, `sendMediaGroup`/`sendPaidMedia` cost their media length, `copyMessages`/`forwardMessages` cost their message ids, and a 429 pauses every sender of the token. `budget.run(method, payload, signal, send)` is the transformer adapter; it paces, never retries and leaves a network/5xx outcome to the caller. The `stitchkit/telegram` surface ceiling rises from 98 to 102 names for the function and its three public types.
|
|
30
|
+
|
|
18
31
|
## [0.113.3] - 2026-10-30
|
|
19
32
|
|
|
20
33
|
### Fixed
|
|
@@ -6,6 +6,7 @@ export { createTelegramLocalFiles, TelegramLocalFileError, type TelegramLocalFil
|
|
|
6
6
|
export { TelegramNotDispatchedError, type TelegramNotDispatchedStage, } from '../telegram/not-dispatched.js';
|
|
7
7
|
export { createTelegramOperatorChannel, type TelegramChatId, type TelegramOperatorChannel, type TelegramOperatorChannelConfig, type TelegramOperatorDrop, type TelegramOperatorMessage, type TelegramOperatorSenderConfig, telegramOperatorSender, } from '../telegram/operator-channel.js';
|
|
8
8
|
export { createTelegramOperatorDedupe, type TelegramOperatorDedupe, type TelegramOperatorDedupeVerdict, } from '../telegram/operator-dedupe.js';
|
|
9
|
+
export { type TelegramOutboundBudget, type TelegramOutboundBudgetConfig, type TelegramOutboundBudgetStats, telegramOutboundBudget, } from '../telegram/outbound-budget.js';
|
|
9
10
|
export { classifyTelegramSendFailure, TELEGRAM_SEND_FAILURE_REASONS, type TelegramSendFailure, type TelegramSendFailureReason, } from '../telegram/send-failure.js';
|
|
10
11
|
export { createTelegramBotTransport, type TelegramBotTransportOpen, type TelegramBotTransportOptions, type TelegramBotTransportResolve, type TelegramBotTransportSocket, type TelegramFetch, } from '../telegram/transport.js';
|
|
11
12
|
export { createTelegramUpdateIntake, type TelegramUpdateAttemptContext, type TelegramUpdateAttemptIntakeConfig, type TelegramUpdateEnvelope, type TelegramUpdateExhaustion, type TelegramUpdateExhaustionFailure, type TelegramUpdateFailure, type TelegramUpdateIntake, type TelegramUpdateIntakeConfig, type TelegramUpdateIntakeOptions, TelegramUpdateOwnershipLostError, type TelegramUpdateStoreStep, } from '../telegram/update-intake.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telegram.d.ts","sourceRoot":"","sources":["../../src/entrypoints/telegram.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"telegram.d.ts","sourceRoot":"","sources":["../../src/entrypoints/telegram.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
isTelegramNotDispatchedError,
|
|
12
12
|
TELEGRAM_SEND_FAILURE_REASONS2,
|
|
13
13
|
classifyTelegramSendFailure2
|
|
14
|
-
} from "../index-
|
|
14
|
+
} from "../index-hs35ss3p.js";
|
|
15
15
|
import {
|
|
16
16
|
TELEGRAM_TEXT_LIMIT2,
|
|
17
17
|
truncateTelegramHtml2
|
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
import {
|
|
36
36
|
untilAborted
|
|
37
37
|
} from "../index-a5qr2rj6.js";
|
|
38
|
+
import {
|
|
39
|
+
argumentsDigest
|
|
40
|
+
} from "../index-r7a7r7ha.js";
|
|
38
41
|
import {
|
|
39
42
|
isRecord,
|
|
40
43
|
transportResult
|
|
@@ -898,6 +901,242 @@ function telegramOperatorSender(config) {
|
|
|
898
901
|
...config.fetch && { fetch: config.fetch }
|
|
899
902
|
});
|
|
900
903
|
}
|
|
904
|
+
// src/telegram/outbound-budget.ts
|
|
905
|
+
import { z as z5 } from "zod";
|
|
906
|
+
var DEFAULT_MESSAGES_PER_SECOND = 24;
|
|
907
|
+
var DEFAULT_RETRY_AFTER_FALLBACK_MS = 1000;
|
|
908
|
+
var TOKEN = z5.string().min(1, "token must not be empty");
|
|
909
|
+
var MESSAGES_PER_SECOND = z5.number().positive().max(1000);
|
|
910
|
+
var RETRY_AFTER_FALLBACK_MS = z5.number().int().nonnegative().max(MAX_TIMER_MS);
|
|
911
|
+
var SEND_METHODS = new Set([
|
|
912
|
+
"sendMessage",
|
|
913
|
+
"sendPhoto",
|
|
914
|
+
"sendAudio",
|
|
915
|
+
"sendDocument",
|
|
916
|
+
"sendVideo",
|
|
917
|
+
"sendAnimation",
|
|
918
|
+
"sendVoice",
|
|
919
|
+
"sendVideoNote",
|
|
920
|
+
"sendPaidMedia",
|
|
921
|
+
"sendMediaGroup",
|
|
922
|
+
"sendLocation",
|
|
923
|
+
"sendVenue",
|
|
924
|
+
"sendContact",
|
|
925
|
+
"sendPoll",
|
|
926
|
+
"sendChecklist",
|
|
927
|
+
"sendDice",
|
|
928
|
+
"sendMessageDraft",
|
|
929
|
+
"sendSticker",
|
|
930
|
+
"sendInvoice",
|
|
931
|
+
"sendGame",
|
|
932
|
+
"sendGift",
|
|
933
|
+
"sendGiveaway",
|
|
934
|
+
"sendLivePhoto",
|
|
935
|
+
"sendRichMessage",
|
|
936
|
+
"copyMessage",
|
|
937
|
+
"copyMessages",
|
|
938
|
+
"forwardMessage",
|
|
939
|
+
"forwardMessages",
|
|
940
|
+
"answerWebAppQuery"
|
|
941
|
+
]);
|
|
942
|
+
var WEIGHTED_METHODS = new Map([
|
|
943
|
+
["sendMediaGroup", "media"],
|
|
944
|
+
["sendPaidMedia", "media"],
|
|
945
|
+
["copyMessages", "message_ids"],
|
|
946
|
+
["forwardMessages", "message_ids"]
|
|
947
|
+
]);
|
|
948
|
+
function sendCost(method, payload) {
|
|
949
|
+
if (!SEND_METHODS.has(method))
|
|
950
|
+
return 0;
|
|
951
|
+
const field = WEIGHTED_METHODS.get(method);
|
|
952
|
+
if (field === undefined || !isRecord(payload))
|
|
953
|
+
return 1;
|
|
954
|
+
const value = payload[field];
|
|
955
|
+
return Array.isArray(value) ? Math.max(1, value.length) : 1;
|
|
956
|
+
}
|
|
957
|
+
function createBudget(rate, fallback, now, sleepFor) {
|
|
958
|
+
const interval = 1000 / rate;
|
|
959
|
+
const queue = [];
|
|
960
|
+
let nextGrantAt = now();
|
|
961
|
+
let pausedUntil = Number.NEGATIVE_INFINITY;
|
|
962
|
+
let granted = 0;
|
|
963
|
+
let messages = 0;
|
|
964
|
+
let draining = false;
|
|
965
|
+
let wake;
|
|
966
|
+
const wait = async (milliseconds, signal) => {
|
|
967
|
+
const delay = Math.min(MAX_TIMER_MS, Math.max(0, milliseconds));
|
|
968
|
+
if (delay <= 0)
|
|
969
|
+
return;
|
|
970
|
+
const pending = sleepFor(delay, signal);
|
|
971
|
+
if (signal)
|
|
972
|
+
await raceAbort(pending, signal);
|
|
973
|
+
else
|
|
974
|
+
await pending;
|
|
975
|
+
};
|
|
976
|
+
const wakeWaiting = () => {
|
|
977
|
+
const current = wake;
|
|
978
|
+
wake = undefined;
|
|
979
|
+
current?.abort();
|
|
980
|
+
};
|
|
981
|
+
const remove = (waiter) => {
|
|
982
|
+
const index = queue.indexOf(waiter);
|
|
983
|
+
if (index === -1)
|
|
984
|
+
return;
|
|
985
|
+
queue.splice(index, 1);
|
|
986
|
+
if (index === 0)
|
|
987
|
+
wakeWaiting();
|
|
988
|
+
};
|
|
989
|
+
const drain = async () => {
|
|
990
|
+
if (draining)
|
|
991
|
+
return;
|
|
992
|
+
draining = true;
|
|
993
|
+
try {
|
|
994
|
+
while (queue.length > 0) {
|
|
995
|
+
const head = queue[0];
|
|
996
|
+
if (head === undefined)
|
|
997
|
+
break;
|
|
998
|
+
if (head.signal?.aborted) {
|
|
999
|
+
remove(head);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const current = now();
|
|
1003
|
+
const at = Math.max(current, nextGrantAt, pausedUntil);
|
|
1004
|
+
if (at > current) {
|
|
1005
|
+
const controller = new AbortController;
|
|
1006
|
+
wake = controller;
|
|
1007
|
+
try {
|
|
1008
|
+
await wait(at - current, controller.signal);
|
|
1009
|
+
} catch (error) {
|
|
1010
|
+
if (!controller.signal.aborted) {
|
|
1011
|
+
for (const waiter of queue.splice(0)) {
|
|
1012
|
+
if (waiter.onAbort)
|
|
1013
|
+
waiter.signal?.removeEventListener("abort", waiter.onAbort);
|
|
1014
|
+
waiter.reject(error);
|
|
1015
|
+
}
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
} finally {
|
|
1019
|
+
if (wake === controller)
|
|
1020
|
+
wake = undefined;
|
|
1021
|
+
}
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
queue.shift();
|
|
1025
|
+
if (head.onAbort)
|
|
1026
|
+
head.signal?.removeEventListener("abort", head.onAbort);
|
|
1027
|
+
nextGrantAt = at + head.cost * interval;
|
|
1028
|
+
granted += 1;
|
|
1029
|
+
messages += head.cost;
|
|
1030
|
+
head.resolve();
|
|
1031
|
+
}
|
|
1032
|
+
} finally {
|
|
1033
|
+
draining = false;
|
|
1034
|
+
if (queue.length > 0)
|
|
1035
|
+
drain();
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
const acquire = (cost, signal) => {
|
|
1039
|
+
if (!Number.isSafeInteger(cost) || cost < 1) {
|
|
1040
|
+
return Promise.reject(new RangeError("cost must be an integer of at least one message"));
|
|
1041
|
+
}
|
|
1042
|
+
if (signal?.aborted)
|
|
1043
|
+
return Promise.reject(signal.reason);
|
|
1044
|
+
return new Promise((resolve, reject) => {
|
|
1045
|
+
const waiter = { cost, resolve, reject, ...signal && { signal } };
|
|
1046
|
+
if (signal) {
|
|
1047
|
+
const onAbort = () => {
|
|
1048
|
+
remove(waiter);
|
|
1049
|
+
reject(signal.reason);
|
|
1050
|
+
};
|
|
1051
|
+
waiter.onAbort = onAbort;
|
|
1052
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1053
|
+
}
|
|
1054
|
+
queue.push(waiter);
|
|
1055
|
+
drain();
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
const pauseFor = (milliseconds) => {
|
|
1059
|
+
if (Number.isNaN(milliseconds) || milliseconds < 0) {
|
|
1060
|
+
throw new RangeError("pause must be a non-negative number of milliseconds");
|
|
1061
|
+
}
|
|
1062
|
+
const duration = Number.isFinite(milliseconds) ? milliseconds : Number.MAX_SAFE_INTEGER;
|
|
1063
|
+
pausedUntil = Math.max(pausedUntil, now() + duration);
|
|
1064
|
+
wakeWaiting();
|
|
1065
|
+
};
|
|
1066
|
+
const pauseAfter = (error) => {
|
|
1067
|
+
const failure = classifyTelegramSendFailure2(error);
|
|
1068
|
+
if (failure.reason !== "rate-limited")
|
|
1069
|
+
return;
|
|
1070
|
+
pauseFor(failure.retryAfterSeconds === undefined ? fallback : failure.retryAfterSeconds * 1000);
|
|
1071
|
+
};
|
|
1072
|
+
const waitUntilResumed = async (signal) => {
|
|
1073
|
+
while (true) {
|
|
1074
|
+
if (signal?.aborted)
|
|
1075
|
+
throw signal.reason;
|
|
1076
|
+
const remaining = pausedUntil - now();
|
|
1077
|
+
if (remaining <= 0)
|
|
1078
|
+
return;
|
|
1079
|
+
await wait(Math.min(remaining, MAX_TIMER_MS), signal);
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const run = async (method, payload, signal, send) => {
|
|
1083
|
+
if (signal?.aborted)
|
|
1084
|
+
throw signal.reason;
|
|
1085
|
+
const cost = sendCost(method, payload);
|
|
1086
|
+
if (cost > 0)
|
|
1087
|
+
await acquire(cost, signal);
|
|
1088
|
+
if (signal?.aborted)
|
|
1089
|
+
throw signal.reason;
|
|
1090
|
+
try {
|
|
1091
|
+
return await send();
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
pauseAfter(error);
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
const budget = {
|
|
1098
|
+
run,
|
|
1099
|
+
acquire,
|
|
1100
|
+
pause: pauseFor,
|
|
1101
|
+
pauseAfter,
|
|
1102
|
+
waitUntilResumed,
|
|
1103
|
+
stats: () => ({
|
|
1104
|
+
granted,
|
|
1105
|
+
messages,
|
|
1106
|
+
queued: queue.length,
|
|
1107
|
+
messagesPerSecond: rate,
|
|
1108
|
+
pausedForMs: Math.max(0, pausedUntil - now())
|
|
1109
|
+
})
|
|
1110
|
+
};
|
|
1111
|
+
return budget;
|
|
1112
|
+
}
|
|
1113
|
+
var budgets = new Map;
|
|
1114
|
+
function keyOf(token) {
|
|
1115
|
+
return `telegram-outbound:${argumentsDigest({ token })}`;
|
|
1116
|
+
}
|
|
1117
|
+
function telegramOutboundBudget(config) {
|
|
1118
|
+
const token = TOKEN.parse(config.token);
|
|
1119
|
+
const rate = MESSAGES_PER_SECOND.parse(config.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND);
|
|
1120
|
+
const fallback = RETRY_AFTER_FALLBACK_MS.parse(config.retryAfterFallbackMs ?? DEFAULT_RETRY_AFTER_FALLBACK_MS);
|
|
1121
|
+
const key = keyOf(token);
|
|
1122
|
+
const existing = budgets.get(key);
|
|
1123
|
+
if (existing) {
|
|
1124
|
+
const same = existing.rate === rate && existing.fallback === fallback && existing.nowOption === config.now && existing.sleepOption === config.sleep;
|
|
1125
|
+
if (!same) {
|
|
1126
|
+
throw new Error("a Telegram outbound budget already exists for this token with different options");
|
|
1127
|
+
}
|
|
1128
|
+
return existing.budget;
|
|
1129
|
+
}
|
|
1130
|
+
const budget = createBudget(rate, fallback, config.now ?? (() => performance.now()), config.sleep ?? sleep);
|
|
1131
|
+
budgets.set(key, {
|
|
1132
|
+
budget,
|
|
1133
|
+
rate,
|
|
1134
|
+
fallback,
|
|
1135
|
+
nowOption: config.now,
|
|
1136
|
+
sleepOption: config.sleep
|
|
1137
|
+
});
|
|
1138
|
+
return budget;
|
|
1139
|
+
}
|
|
901
1140
|
// src/telegram/transport-connect.ts
|
|
902
1141
|
import { lookup } from "node:dns/promises";
|
|
903
1142
|
import { connect as connectTcp, isIP } from "node:net";
|
|
@@ -2609,6 +2848,7 @@ export {
|
|
|
2609
2848
|
sqliteTelegramUpdateStore,
|
|
2610
2849
|
telegramBroadcastSender,
|
|
2611
2850
|
telegramOperatorSender,
|
|
2851
|
+
telegramOutboundBudget,
|
|
2612
2852
|
telegramWebhookUrl,
|
|
2613
2853
|
verifyTelegramInitData
|
|
2614
2854
|
};
|
|
@@ -32,6 +32,10 @@ var BY_DESCRIPTION = [
|
|
|
32
32
|
[/user is deactivated|account is deactivated/i, "user-deactivated"],
|
|
33
33
|
[/can'?t initiate conversation|need to start a conversation/i, "not-started"],
|
|
34
34
|
[/chat not found|peer_id_invalid|user not found/i, "chat-not-found"],
|
|
35
|
+
[
|
|
36
|
+
/bot_to_bot_disabled|bot-to-bot messaging is disabled|bot to bot.*disabled/i,
|
|
37
|
+
"bot-to-bot-disabled"
|
|
38
|
+
],
|
|
35
39
|
[/too many requests|retry later|flood/i, "rate-limited"],
|
|
36
40
|
[
|
|
37
41
|
/message is too long|can'?t parse entities|message text is empty|wrong file identifier|button_url_invalid/i,
|
|
@@ -43,6 +47,7 @@ var TRAITS = {
|
|
|
43
47
|
"user-deactivated": { retryable: false, recipientUnreachable: true },
|
|
44
48
|
"chat-not-found": { retryable: false, recipientUnreachable: true },
|
|
45
49
|
"not-started": { retryable: false, recipientUnreachable: true },
|
|
50
|
+
"bot-to-bot-disabled": { retryable: false, recipientUnreachable: false },
|
|
46
51
|
"rate-limited": { retryable: true, recipientUnreachable: false },
|
|
47
52
|
"message-invalid": { retryable: false, recipientUnreachable: false },
|
|
48
53
|
"server-error": { retryable: true, recipientUnreachable: false },
|
|
@@ -82,13 +87,16 @@ function retryAfterOf(value, depth = 0) {
|
|
|
82
87
|
return nested === value ? undefined : retryAfterOf(nested, depth + 1);
|
|
83
88
|
}
|
|
84
89
|
function descriptionOf(value, depth = 0) {
|
|
90
|
+
if (depth > 4)
|
|
91
|
+
return "";
|
|
85
92
|
if (typeof value === "string")
|
|
86
93
|
return value;
|
|
87
94
|
if (value instanceof Error) {
|
|
88
|
-
const
|
|
89
|
-
|
|
95
|
+
const own = isRecord(value) && typeof value.description === "string" ? value.description : "";
|
|
96
|
+
const nested = descriptionOf(value.cause, depth + 1);
|
|
97
|
+
return [own, value.message, nested].filter(Boolean).join(" ");
|
|
90
98
|
}
|
|
91
|
-
if (!isRecord(value)
|
|
99
|
+
if (!isRecord(value))
|
|
92
100
|
return "";
|
|
93
101
|
const own = typeof value.description === "string" ? value.description : "";
|
|
94
102
|
const message = typeof value.message === "string" ? value.message : "";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One outbound budget for one Telegram bot token.
|
|
3
|
+
*
|
|
4
|
+
* Telegram counts a bot's bulk messages per token, not per subsystem. A bot
|
|
5
|
+
* that sends a marketing campaign, answers a person and posts an operator note
|
|
6
|
+
* through three senders has one limit, and three separate pacemakers cannot
|
|
7
|
+
* keep it: each of them is below the limit on its own and the sum is not.
|
|
8
|
+
*
|
|
9
|
+
* This is the one place that counts. The token names the budget; every alias
|
|
10
|
+
* of the same token in the process gets the same object, and another token is
|
|
11
|
+
* another budget. The unit is a Telegram message: `sendMediaGroup` and
|
|
12
|
+
* `sendPaidMedia` cost their media length, `copyMessages` and
|
|
13
|
+
* `forwardMessages` cost their message ids, and the follow-up call to action
|
|
14
|
+
* after an album is one more message.
|
|
15
|
+
*
|
|
16
|
+
* **It paces; it does not retry.** A 429 pauses every sender of the token for
|
|
17
|
+
* the wait Telegram stated (or a declared fallback when it stated none). A
|
|
18
|
+
* network failure or a 5xx is returned to the caller unchanged: repeating a
|
|
19
|
+
* send whose external outcome is unknown is the caller's decision, and a
|
|
20
|
+
* primitive that retried it would turn one unknown message into two.
|
|
21
|
+
*
|
|
22
|
+
* The schedule is a strict token budget, not a bucket with a full burst: the
|
|
23
|
+
* first call after an idle period is immediate, and every later call occupies
|
|
24
|
+
* its own `cost / messagesPerSecond` interval. A full bucket would release a
|
|
25
|
+
* saved burst at start and again after a 429 pause, which is exactly the
|
|
26
|
+
* stampede a shared budget exists to prevent.
|
|
27
|
+
*/
|
|
28
|
+
export interface TelegramOutboundBudgetConfig {
|
|
29
|
+
/** The bot token whose sends share this budget. It is never returned or logged. */
|
|
30
|
+
readonly token: string;
|
|
31
|
+
/** Telegram messages per second. Default 24. */
|
|
32
|
+
readonly messagesPerSecond?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How long a 429 without `retry_after` pauses the token. Default 1000 ms.
|
|
35
|
+
* Telegram's own `retry_after` is always used instead when present.
|
|
36
|
+
*/
|
|
37
|
+
readonly retryAfterFallbackMs?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Test seam: the monotonic clock. Default `performance.now`. A wall clock can
|
|
40
|
+
* jump backwards and turn a pause into a burst, so the default is monotonic.
|
|
41
|
+
*/
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
/**
|
|
44
|
+
* Test seam: how a wait is taken. Default the shared abortable sleep. A
|
|
45
|
+
* custom sleeper must reject with `signal.reason` when the signal aborts.
|
|
46
|
+
*/
|
|
47
|
+
readonly sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** What the budget has done, with no token and no message ids. */
|
|
50
|
+
export interface TelegramOutboundBudgetStats {
|
|
51
|
+
/** Calls admitted by `acquire` or `run`. */
|
|
52
|
+
readonly granted: number;
|
|
53
|
+
/** Weighted messages admitted: an album of three counts as three. */
|
|
54
|
+
readonly messages: number;
|
|
55
|
+
/** Calls waiting for their turn. */
|
|
56
|
+
readonly queued: number;
|
|
57
|
+
readonly messagesPerSecond: number;
|
|
58
|
+
/** How much of the current 429 pause is still ahead, in milliseconds. */
|
|
59
|
+
readonly pausedForMs: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A token's outbound budget. `run` is the narrow adapter a client transformer
|
|
63
|
+
* calls around its own send; it gates, pauses on 429 and never retries.
|
|
64
|
+
*/
|
|
65
|
+
export interface TelegramOutboundBudget {
|
|
66
|
+
/**
|
|
67
|
+
* Run one Bot API call under the budget. A non-send method passes through
|
|
68
|
+
* without spending a message. A 429 pauses every sender of the token; the
|
|
69
|
+
* original error is rethrown and the call is not repeated.
|
|
70
|
+
*/
|
|
71
|
+
run<T>(method: string, payload: unknown, signal: AbortSignal | undefined, send: () => Promise<T>): Promise<T>;
|
|
72
|
+
/**
|
|
73
|
+
* Wait until `cost` messages fit. The wait rejects with `signal.reason`
|
|
74
|
+
* without spending a token; a granted call owns its slots even if its later
|
|
75
|
+
* send is aborted, which the caller must not repeat as a definite failure.
|
|
76
|
+
*/
|
|
77
|
+
acquire(cost: number, signal?: AbortSignal): Promise<void>;
|
|
78
|
+
/** Pause every sender of this token for at least `milliseconds`. Never shortens an existing pause. */
|
|
79
|
+
pause(milliseconds: number): void;
|
|
80
|
+
/** Pause the token after a rate-limited refusal; another failure does nothing. */
|
|
81
|
+
pauseAfter(error: unknown): void;
|
|
82
|
+
/**
|
|
83
|
+
* Wait out the current pause without spending a token. For a retry path that
|
|
84
|
+
* is not a send (edit, delete) and does not go through `acquire`.
|
|
85
|
+
*/
|
|
86
|
+
waitUntilResumed(signal?: AbortSignal): Promise<void>;
|
|
87
|
+
/** Measured counters. It never contains the token. */
|
|
88
|
+
stats(): TelegramOutboundBudgetStats;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The one budget for `config.token`, created on first use.
|
|
92
|
+
*
|
|
93
|
+
* Two aliases of one token share it; two tokens never do. A second call for a
|
|
94
|
+
* token already in use must agree on the options, otherwise the process has
|
|
95
|
+
* two incompatible policies for one credential and the call is refused. The
|
|
96
|
+
* refusal never contains the token.
|
|
97
|
+
*/
|
|
98
|
+
export declare function telegramOutboundBudget(config: TelegramOutboundBudgetConfig): TelegramOutboundBudget;
|
|
99
|
+
//# sourceMappingURL=outbound-budget.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"outbound-budget.d.ts","sourceRoot":"","sources":["../../src/telegram/outbound-budget.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAwEH,MAAM,WAAW,4BAA4B;IAC3C,mFAAmF;IACnF,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,gDAAgD;IAChD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC;;;OAGG;IACH,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC;;;OAGG;IACH,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAChF;AAED,kEAAkE;AAClE,MAAM,WAAW,2BAA2B;IAC1C,4CAA4C;IAC5C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oCAAoC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IACnC,yEAAyE;IACzE,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED;;;GAGG;AACH,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,GAAG,CAAC,CAAC,EACH,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACrB,OAAO,CAAC,CAAC,CAAC,CAAC;IACd;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,sGAAsG;IACtG,KAAK,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,kFAAkF;IAClF,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC;;;OAGG;IACH,gBAAgB,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,sDAAsD;IACtD,KAAK,IAAI,2BAA2B,CAAC;CACtC;AAmMD;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,4BAA4B,GACnC,sBAAsB,CAsCxB"}
|
|
@@ -30,8 +30,13 @@
|
|
|
30
30
|
* user who never pressed Start needs an invitation rather than a retry, and a
|
|
31
31
|
* chat Telegram does not know is usually a bad identifier on our side — a
|
|
32
32
|
* defect in our data, not a fact about a person.
|
|
33
|
+
*
|
|
34
|
+
* `bot-to-bot-disabled` is permanent for this send but does not accuse the
|
|
35
|
+
* recipient: Telegram disables bot-to-bot messaging when *either* bot has not
|
|
36
|
+
* enabled its Bot to Bot setting, so the missing setting may be the sender's.
|
|
37
|
+
* It is never retried, and it never marks the recipient unreachable.
|
|
33
38
|
*/
|
|
34
|
-
export type TelegramSendFailureReason = 'blocked-by-user' | 'user-deactivated' | 'chat-not-found' | 'not-started' | 'rate-limited' | 'message-invalid' | 'server-error' | 'not-dispatched' | 'unknown';
|
|
39
|
+
export type TelegramSendFailureReason = 'blocked-by-user' | 'user-deactivated' | 'chat-not-found' | 'not-started' | 'bot-to-bot-disabled' | 'rate-limited' | 'message-invalid' | 'server-error' | 'not-dispatched' | 'unknown';
|
|
35
40
|
export interface TelegramSendFailure {
|
|
36
41
|
reason: TelegramSendFailureReason;
|
|
37
42
|
/** Telegram's `error_code`, when the failure carried one. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"send-failure.d.ts","sourceRoot":"","sources":["../../src/telegram/send-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH
|
|
1
|
+
{"version":3,"file":"send-failure.d.ts","sourceRoot":"","sources":["../../src/telegram/send-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,yBAAyB,GACjC,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,aAAa,GACb,qBAAqB,GACrB,cAAc,GACd,iBAAiB,GACjB,cAAc,GACd,gBAAgB,GAChB,SAAS,CAAC;AAEd,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,yBAAyB,CAAC;IAClC,6DAA6D;IAC7D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wDAAwD;IACxD,SAAS,EAAE,OAAO,CAAC;IACnB;;;;;;;;OAQG;IACH,oBAAoB,EAAE,OAAO,CAAC;IAC9B;;;;;OAKG;IACH,QAAQ,EAAE,YAAY,GAAG,QAAQ,GAAG,aAAa,GAAG,WAAW,GAAG,MAAM,CAAC;CAC1E;AAoDD;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,EAAE,SAAS,yBAAyB,EAC7B,CAAC;AA4ClD;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,CA4D/E;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,MAAM,mBAAmB,GAC3B,cAAc,GACd,cAAc,GACd,gBAAgB,GAChB,eAAe,GACf,OAAO,CAAC;AAeZ,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,mBAAmB,CAM/E"}
|
package/llms/server.4.txt
CHANGED
|
@@ -391,6 +391,11 @@ and our payload is wrong, which is the case a list of substrings quietly counts
|
|
|
391
391
|
against the user. An unrecognised refusal leaves the recipient reachable: losing
|
|
392
392
|
a working subscriber forever costs more than one wasted send.
|
|
393
393
|
|
|
394
|
+
`bot-to-bot-disabled` is permanent but deliberately not `recipientUnreachable`:
|
|
395
|
+
Telegram returns it when either bot has not enabled Bot to Bot messaging, so the
|
|
396
|
+
missing setting may be the sender's. Do not retry it, and do not close the
|
|
397
|
+
recipient's row on it.
|
|
398
|
+
|
|
394
399
|
`TELEGRAM_SEND_FAILURE_REASONS` lists every reason. A table keyed by
|
|
395
400
|
`TelegramSendFailureReason` — error codes, the text a person reads — fails to
|
|
396
401
|
compile when a release adds one, instead of falling through to a default:
|
package/llms/stitchkit.txt
CHANGED
|
@@ -75,7 +75,7 @@ own, recorded as an ADR.
|
|
|
75
75
|
| `stitchkit/process/owner-loss` | bundled server entry (Bun, POSIX) | evolving | narrow bootstrap that keeps the process runner lazy while carrying its owner-loss guard in a single-file bundle |
|
|
76
76
|
| `stitchkit/files/packaging` | build time (Bun or Node) | evolving | installed native asset graph, integrity, Bun packaging plugin and the artifact loader check; no runtime install policy |
|
|
77
77
|
| `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
|
|
78
|
-
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
|
|
78
|
+
| `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, one outbound budget per bot token with weighted albums and shared 429 pauses, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
|
|
79
79
|
| `stitchkit/telegram/html` | browser and server | evolving | Telegram's HTML as a tree — clean any markup to what Telegram accepts, cut long text into valid parts by what the reader sees, check markup before it is sent |
|
|
80
80
|
| `stitchkit/telegram/screens` | server (Bun or Node) | evolving | a bot's menus as declared screens over grammY — typed paths, actions and input, message reconciliation, state in any `StorageAdapter`, a test chat |
|
|
81
81
|
| `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
|
|
@@ -85,12 +85,12 @@ own, recorded as an ADR.
|
|
|
85
85
|
| `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
|
|
86
86
|
| `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
|
|
87
87
|
| `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
|
|
88
|
-
| `stitchkit/observability` | server | stable<br>_redefined in 2 of the
|
|
88
|
+
| `stitchkit/observability` | server | stable<br>_redefined in 2 of the 59 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
|
|
89
89
|
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, the managed-resource conformance kit and manual time for schedules and retries |
|
|
90
90
|
| `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
|
|
91
91
|
| `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
|
|
92
92
|
| `stitchkit/react/keyboard` | browser | evolving | which part of a screen gets a key — ordered layers, one Escape per level, list zones with real focus; see [keyboard guide](keyboard.md) |
|
|
93
|
-
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 24 of the
|
|
93
|
+
| `stitchkit/agent-runtime` | server | evolving<br>_redefined in 24 of the 59 minors since 0.56.2, most recently 0.113.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
|
|
94
94
|
| `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
|
|
95
95
|
| `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
|
|
96
96
|
| `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
|
|
@@ -102,7 +102,7 @@ own, recorded as an ADR.
|
|
|
102
102
|
| `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
|
|
103
103
|
| `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
|
|
104
104
|
| `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
|
|
105
|
-
| `stitchkit/application` | browser + server | evolving<br>_redefined in 14 of the
|
|
105
|
+
| `stitchkit/application` | browser + server | evolving<br>_redefined in 14 of the 59 minors since 0.56.2, most recently 0.110.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
|
|
106
106
|
| `stitchkit/application/grammy` | server | evolving | a grammY bot as application resources — configuration, then polling with updates admitted by batch — and the webhook adapter |
|
|
107
107
|
| `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
|
|
108
108
|
| `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
|
package/llms/telegram.txt
CHANGED
|
@@ -86,6 +86,67 @@ The error line contract, for whatever recognises a failure from the journal: a
|
|
|
86
86
|
line whose `level` is `50` or more is an error. There is no separate `fatal`;
|
|
87
87
|
an unrecoverable end is an `error` line followed by a non-zero exit.
|
|
88
88
|
|
|
89
|
+
## One token, one outbound budget
|
|
90
|
+
|
|
91
|
+
Telegram counts a bot's bulk messages per token, not per campaign, handler or
|
|
92
|
+
module. `telegramOutboundBudget` is that budget once: every alias of one token
|
|
93
|
+
in the process gets the same object, another token gets another, and every send
|
|
94
|
+
through it spends its weight. → [ADR 0274](../decisions/0274-a-telegram-token-has-one-outbound-budget.md).
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { telegramOutboundBudget } from 'stitchkit/telegram'
|
|
98
|
+
|
|
99
|
+
const budget = telegramOutboundBudget({
|
|
100
|
+
token: env.BOT_TOKEN,
|
|
101
|
+
messagesPerSecond: 24,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// The one transformer of the bot's client. No second limiter, no second
|
|
105
|
+
// scheduler: campaigns, replies and every other send of this token share it.
|
|
106
|
+
api.config.use((previous, method, payload, signal) =>
|
|
107
|
+
budget.run(method, payload, signal, () => previous(method, payload, signal)),
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- **The token is the identity, not the sender.** Two bot objects built from one
|
|
112
|
+
token share the budget; two different tokens do not. The registry keeps a
|
|
113
|
+
128-bit identity, never the credential, and `stats()` contains no token.
|
|
114
|
+
- **A message is the unit.** `sendMediaGroup` and `sendPaidMedia` cost their
|
|
115
|
+
media length, `copyMessages` and `forwardMessages` cost their message ids, and
|
|
116
|
+
a follow-up call to action after an album costs one more. Reads and edits
|
|
117
|
+
(`getMe`, `getFile`, `editMessage*`, `deleteMessage`, `sendChatAction`) do not
|
|
118
|
+
spend the budget.
|
|
119
|
+
- **The first send after idle is immediate; the rest are spaced.** This is a
|
|
120
|
+
strict budget, not a bucket that saves a burst: at 24/s the next send is due
|
|
121
|
+
one interval later. A 429 pauses the whole token; when it resumes, one send
|
|
122
|
+
is released at the deadline and the queue continues one interval at a time,
|
|
123
|
+
never as a stampede.
|
|
124
|
+
- **It paces; it does not retry.** `run` calls the send once. A 429 pauses the
|
|
125
|
+
token for Telegram's `retry_after` (or `retryAfterFallbackMs` when Telegram
|
|
126
|
+
gave none) and rethrows; the caller's retry policy decides what happens next.
|
|
127
|
+
A 429 from an edit or a read is still a fact about the token and pauses later
|
|
128
|
+
sends too. A network failure is rethrown unchanged and stays `unknown`; a 5xx
|
|
129
|
+
is rethrown unchanged and stays `server-error`. Neither is retried by the
|
|
130
|
+
budget or pauses the token — repeating a send whose outcome is unknown is a
|
|
131
|
+
decision, not a primitive's silent guess.
|
|
132
|
+
- **A wait can be cancelled.** `run`/`acquire` take an `AbortSignal`; a
|
|
133
|
+
cancelled waiter spends no slot and calls no provider. A granted send that is
|
|
134
|
+
then aborted is not evidence that Telegram did not accept it — keep the
|
|
135
|
+
outcome unknown rather than retrying it as a definite failure.
|
|
136
|
+
- **Non-send retries wait without spending.** An edit or delete retry that got
|
|
137
|
+
a 429 first records the pause with `budget.pauseAfter(error)` (the `run` path
|
|
138
|
+
does this itself), then uses `await budget.waitUntilResumed(signal)`; it shares
|
|
139
|
+
the token's pause but does not take a message slot.
|
|
140
|
+
|
|
141
|
+
`telegramBroadcastSender` and `telegramOperatorSender` still send through
|
|
142
|
+
`callTelegramBotApi`; they are not a second budget. Their returned function does
|
|
143
|
+
not expose the method and payload, so a product that uses one wraps the concrete
|
|
144
|
+
`sendMessage`/`copyMessage` call in `budget.run` where it knows both, or passes
|
|
145
|
+
its own sender. There is no generic wrapper around the opaque sender.
|
|
146
|
+
`runTelegramBroadcast` keeps its per-run `ratePerSecond` and the operator
|
|
147
|
+
channel keeps its per-chat `minIntervalMs`; those are caps of their own jobs,
|
|
148
|
+
not another token quota. The token budget is the binding shared cap.
|
|
149
|
+
|
|
89
150
|
## The operator channel
|
|
90
151
|
|
|
91
152
|
The chat where people read about new users, payments and failures is not the
|
|
@@ -519,7 +580,7 @@ can read its media.
|
|
|
519
580
|
Peer-free server-only Telegram platform primitives — the Bot API's own
|
|
520
581
|
protocols and the jobs every bot rewrote around them. Importing this resolves no
|
|
521
582
|
bot library; `stitchkit/application/grammy` remains the lifecycle adapter for an
|
|
522
|
-
injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
|
|
583
|
+
injected grammY bot. → ADR 0143, ADR 0201, ADR 0274 — [guide](../guide/telegram.md)
|
|
523
584
|
|
|
524
585
|
| Export | Kind | Summary |
|
|
525
586
|
|--------|------|---------|
|
|
@@ -534,7 +595,8 @@ injected grammY bot. → ADR 0143, ADR 0201 — [guide](../guide/telegram.md)
|
|
|
534
595
|
| `createTelegramBotTransport` / `TelegramBotTransportOptions` | function / _type_ | a `fetch` for the Bot API senders that connects before it writes, so a failure before the first byte is `TelegramNotDispatchedError`; connection attempt and budget, answer timeout and size, injectable resolver and socket factory — [guide](../guide/telegram.md#a-transport-that-knows-whether-a-request-left) |
|
|
535
596
|
| `TelegramFetch` / `TelegramBotTransportOpen` / `TelegramBotTransportResolve` / `TelegramBotTransportSocket` | _types_ | the `(url, init) => Promise<Response>` the senders take; the transport's socket factory, resolver and the structural socket it needs (a Node `net.Socket` or `tls.TLSSocket` fits) |
|
|
536
597
|
| `TelegramNotDispatchedError` / `TelegramNotDispatchedStage` | class / _type_ | a request that provably never left (`lookup`, `connect` or `request`), so repeating it cannot duplicate a message |
|
|
537
|
-
| `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `rate-limited` / `message-invalid` / `server-error` / `not-dispatched` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
|
|
598
|
+
| `TelegramSendFailureReason` / `TELEGRAM_SEND_FAILURE_REASONS` | _type_ / constant | `blocked-by-user` / `user-deactivated` / `chat-not-found` / `not-started` / `bot-to-bot-disabled` / `rate-limited` / `message-invalid` / `server-error` / `not-dispatched` / `unknown`, and the same as a list; a `Record<TelegramSendFailureReason, …>` over them fails to compile when a reason is added |
|
|
599
|
+
| `telegramOutboundBudget` / `TelegramOutboundBudget` / `TelegramOutboundBudgetConfig` / `TelegramOutboundBudgetStats` | function / _type_ | one token-wide outbound budget: one object per token in the process, strict 24 messages/s by default, weighted albums and multi-message copies, `run(method, payload, signal, send)` as the transformer adapter, `pause`/`pauseAfter` for 429, `waitUntilResumed` for non-send retries and `stats`; it paces and never retries, and no public value carries the token |
|
|
538
600
|
| `runTelegramBroadcast` | function | a resumable broadcast by `name` under a state `directory`: audience and journal durably recorded, pacing at `ratePerSecond` (25), exact retry delays within the finite declared budget, injected classifier, uncertain send outcomes never replayed, `dryRun`, one fenced runner per name |
|
|
539
601
|
| `TelegramBroadcastConfig` / `TelegramBroadcastSend` / `TelegramBroadcastReport` / `TelegramBroadcastRunOutcome` / `TelegramBroadcastOutcome` / `TelegramBroadcastRecipient` | _type_ | `name`, `directory`, `recipients()`, `send({ recipient, attempt })`, `maxAttempts`, `signal`, `onProgress`; counts `delivered` / `unreachable` / `failed` / `uncertain` / `pending` and `finished` / `stopped` / `halted` / `dry-run` |
|
|
540
602
|
| `TelegramBroadcastFailureSchema` / `TelegramBroadcastFailure` | schema / _type_ | injected `classify` answers retry-after/transient/permanent/ambiguous; exact provider delay is bounded by `maxRetryDelayMs` without shortening, unknown outcome remains uncertain and is never replayed |
|