@prajwalshete/telegram-plugin 0.0.0-stage → 0.1.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.
Files changed (295) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +109 -2
  4. package/dist/adapters/clock.d.ts +3 -0
  5. package/dist/adapters/clock.js +4 -0
  6. package/dist/adapters/console-log.d.ts +13 -0
  7. package/dist/adapters/console-log.js +41 -0
  8. package/dist/adapters/d1-db.d.ts +5 -0
  9. package/dist/adapters/d1-db.js +37 -0
  10. package/dist/adapters/ids.d.ts +5 -0
  11. package/dist/adapters/ids.js +25 -0
  12. package/dist/adapters/index.d.ts +7 -0
  13. package/dist/adapters/keyring.d.ts +50 -0
  14. package/dist/adapters/keyring.js +174 -0
  15. package/dist/adapters/metrics.d.ts +28 -0
  16. package/dist/adapters/metrics.js +48 -0
  17. package/dist/adapters/r2-files.d.ts +20 -0
  18. package/dist/adapters/r2-files.js +53 -0
  19. package/dist/bin/cli.d.ts +1 -0
  20. package/dist/bin/cli.js +45 -0
  21. package/dist/client/broadcasts.d.ts +35 -0
  22. package/dist/client/commands.d.ts +23 -0
  23. package/dist/client/endpoint.d.ts +34 -0
  24. package/dist/client/events.d.ts +33 -0
  25. package/dist/client/index.d.ts +11 -0
  26. package/dist/client/index.js +3 -0
  27. package/dist/client/schedules.d.ts +42 -0
  28. package/dist/client/schema.d.ts +181 -0
  29. package/dist/client/send.d.ts +15 -0
  30. package/dist/client/templates.d.ts +47 -0
  31. package/dist/contracts/bots.d.ts +129 -0
  32. package/dist/contracts/bots.js +70 -0
  33. package/dist/contracts/broadcasts.d.ts +116 -0
  34. package/dist/contracts/broadcasts.js +63 -0
  35. package/dist/contracts/chats.d.ts +97 -0
  36. package/dist/contracts/chats.js +66 -0
  37. package/dist/contracts/commands.d.ts +52 -0
  38. package/dist/contracts/commands.js +53 -0
  39. package/dist/contracts/common.d.ts +38 -0
  40. package/dist/contracts/common.js +41 -0
  41. package/dist/contracts/deliveries.d.ts +103 -0
  42. package/dist/contracts/deliveries.js +74 -0
  43. package/dist/contracts/destinations.d.ts +24 -0
  44. package/dist/contracts/destinations.js +28 -0
  45. package/dist/contracts/events.d.ts +165 -0
  46. package/dist/contracts/events.js +98 -0
  47. package/dist/contracts/features.d.ts +20 -0
  48. package/dist/contracts/features.js +21 -0
  49. package/dist/contracts/health.d.ts +55 -0
  50. package/dist/contracts/health.js +58 -0
  51. package/dist/contracts/index.d.ts +22 -0
  52. package/dist/contracts/index.js +22 -0
  53. package/dist/contracts/labels.d.ts +38 -0
  54. package/dist/contracts/labels.js +52 -0
  55. package/dist/contracts/links.d.ts +51 -0
  56. package/dist/contracts/links.js +50 -0
  57. package/dist/contracts/meta.d.ts +132 -0
  58. package/dist/contracts/meta.js +85 -0
  59. package/dist/contracts/permissions.d.ts +24 -0
  60. package/dist/contracts/permissions.js +27 -0
  61. package/dist/contracts/problems.d.ts +106 -0
  62. package/dist/contracts/problems.js +101 -0
  63. package/dist/contracts/recipients.d.ts +36 -0
  64. package/dist/contracts/recipients.js +22 -0
  65. package/dist/contracts/schedules.d.ts +276 -0
  66. package/dist/contracts/schedules.js +143 -0
  67. package/dist/contracts/send.d.ts +126 -0
  68. package/dist/contracts/send.js +69 -0
  69. package/dist/contracts/subscriptions.d.ts +38 -0
  70. package/dist/contracts/subscriptions.js +31 -0
  71. package/dist/contracts/templates.d.ts +358 -0
  72. package/dist/contracts/templates.js +174 -0
  73. package/dist/contracts/users.d.ts +15 -0
  74. package/dist/contracts/users.js +19 -0
  75. package/dist/core/guards.d.ts +22 -0
  76. package/dist/core/guards.js +47 -0
  77. package/dist/core/html/escape.d.ts +2 -0
  78. package/dist/core/html/escape.js +36 -0
  79. package/dist/core/html/tokenize.js +65 -0
  80. package/dist/core/index.d.ts +18 -0
  81. package/dist/core/pacing.d.ts +1 -0
  82. package/dist/core/pacing.js +86 -0
  83. package/dist/core/random.d.ts +2 -0
  84. package/dist/core/random.js +38 -0
  85. package/dist/core/recipients.d.ts +1 -0
  86. package/dist/core/recipients.js +86 -0
  87. package/dist/core/schedule/describe.d.ts +3 -0
  88. package/dist/core/schedule/describe.js +74 -0
  89. package/dist/core/schedule/index.d.ts +4 -0
  90. package/dist/core/schedule/policy.d.ts +1 -0
  91. package/dist/core/schedule/policy.js +37 -0
  92. package/dist/core/schedule/rule.d.ts +25 -0
  93. package/dist/core/schedule/rule.js +142 -0
  94. package/dist/core/schedule/tz.d.ts +2 -0
  95. package/dist/core/schedule/tz.js +59 -0
  96. package/dist/core/split/plain.d.ts +2 -0
  97. package/dist/core/split/plain.js +23 -0
  98. package/dist/core/split/split.d.ts +15 -0
  99. package/dist/core/split/split.js +158 -0
  100. package/dist/core/template/ast.d.ts +58 -0
  101. package/dist/core/template/ast.js +5 -0
  102. package/dist/core/template/expr.js +108 -0
  103. package/dist/core/template/filters.d.ts +20 -0
  104. package/dist/core/template/filters.js +127 -0
  105. package/dist/core/template/format.d.ts +17 -0
  106. package/dist/core/template/format.js +88 -0
  107. package/dist/core/template/index.d.ts +20 -0
  108. package/dist/core/template/index.js +29 -0
  109. package/dist/core/template/parse.d.ts +8 -0
  110. package/dist/core/template/parse.js +369 -0
  111. package/dist/core/template/render.d.ts +17 -0
  112. package/dist/core/template/render.js +118 -0
  113. package/dist/core/template/shape.d.ts +28 -0
  114. package/dist/core/template/shape.js +151 -0
  115. package/dist/core/template/tags.js +105 -0
  116. package/dist/core/template/validate.d.ts +10 -0
  117. package/dist/core/template/validate.js +144 -0
  118. package/dist/core/template/variables.d.ts +15 -0
  119. package/dist/core/template/variables.js +41 -0
  120. package/dist/do/index.d.ts +3 -0
  121. package/dist/do/index.js +3 -0
  122. package/dist/do/telegram-bot-do.d.ts +45 -0
  123. package/dist/do/telegram-bot-do.js +219 -0
  124. package/dist/engine/actions/callback.js +256 -0
  125. package/dist/engine/actions/tokens.d.ts +2 -0
  126. package/dist/engine/actions/tokens.js +51 -0
  127. package/dist/engine/bot-api.js +20 -0
  128. package/dist/engine/bot-features.js +39 -0
  129. package/dist/engine/chat-state.js +66 -0
  130. package/dist/engine/content/audience.js +54 -0
  131. package/dist/engine/content/buttons.d.ts +1 -0
  132. package/dist/engine/content/buttons.js +113 -0
  133. package/dist/engine/content/fanout.js +284 -0
  134. package/dist/engine/content/intersect.d.ts +6 -0
  135. package/dist/engine/content/intersect.js +26 -0
  136. package/dist/engine/content/providers.d.ts +6 -0
  137. package/dist/engine/content/providers.js +142 -0
  138. package/dist/engine/content/recipients.js +128 -0
  139. package/dist/engine/content/render.js +138 -0
  140. package/dist/engine/content/reply.js +156 -0
  141. package/dist/engine/content/report-files.js +78 -0
  142. package/dist/engine/content/template-rows.js +74 -0
  143. package/dist/engine/dialogs/dialog.d.ts +2 -0
  144. package/dist/engine/dialogs/dialog.js +375 -0
  145. package/dist/engine/dispatch/builtins.js +198 -0
  146. package/dist/engine/dispatch/commands.js +194 -0
  147. package/dist/engine/dispatch/context.d.ts +2 -0
  148. package/dist/engine/dispatch/context.js +194 -0
  149. package/dist/engine/dispatch/dispatcher.js +66 -0
  150. package/dist/engine/dispatch/handlers.d.ts +1 -0
  151. package/dist/engine/dispatch/handlers.js +103 -0
  152. package/dist/engine/dispatch/lifecycle.js +85 -0
  153. package/dist/engine/dispatch/link-chat.js +31 -0
  154. package/dist/engine/dispatch/linking.js +273 -0
  155. package/dist/engine/errors.d.ts +60 -0
  156. package/dist/engine/errors.js +72 -0
  157. package/dist/engine/events/filters.d.ts +5 -0
  158. package/dist/engine/events/filters.js +82 -0
  159. package/dist/engine/events/kick.js +30 -0
  160. package/dist/engine/events/payload.d.ts +3 -0
  161. package/dist/engine/events/payload.js +16 -0
  162. package/dist/engine/events/tick.js +208 -0
  163. package/dist/engine/i18n.d.ts +12 -0
  164. package/dist/engine/i18n.js +55 -0
  165. package/dist/engine/inbound/files.js +121 -0
  166. package/dist/engine/inbound/handler.js +46 -0
  167. package/dist/engine/jobs.d.ts +32 -0
  168. package/dist/engine/jobs.js +85 -0
  169. package/dist/engine/link-codes.js +46 -0
  170. package/dist/engine/metrics.js +12 -0
  171. package/dist/engine/options.d.ts +369 -0
  172. package/dist/engine/options.js +81 -0
  173. package/dist/engine/outbox/dead-letter.js +78 -0
  174. package/dist/engine/outbox/do-registry.d.ts +10 -0
  175. package/dist/engine/outbox/do-registry.js +10 -0
  176. package/dist/engine/outbox/do-transport.d.ts +33 -0
  177. package/dist/engine/outbox/do-transport.js +73 -0
  178. package/dist/engine/outbox/drain.d.ts +13 -0
  179. package/dist/engine/outbox/drain.js +381 -0
  180. package/dist/engine/outbox/enqueue.d.ts +1 -0
  181. package/dist/engine/outbox/enqueue.js +63 -0
  182. package/dist/engine/outbox/select.js +24 -0
  183. package/dist/engine/outbox/send.js +362 -0
  184. package/dist/engine/outbox/transport.d.ts +35 -0
  185. package/dist/engine/outbox/transport.js +175 -0
  186. package/dist/engine/outbox/wake.js +28 -0
  187. package/dist/engine/registry.d.ts +502 -0
  188. package/dist/engine/registry.js +258 -0
  189. package/dist/engine/rows.d.ts +25 -0
  190. package/dist/engine/rows.js +131 -0
  191. package/dist/engine/runtime.d.ts +95 -0
  192. package/dist/engine/runtime.js +149 -0
  193. package/dist/engine/schedules/fire.js +208 -0
  194. package/dist/engine/schedules/resume.js +75 -0
  195. package/dist/engine/schedules/rows.js +69 -0
  196. package/dist/engine/schedules/tick.js +30 -0
  197. package/dist/engine/schedules/wake.js +50 -0
  198. package/dist/engine/state.d.ts +11 -0
  199. package/dist/engine/state.js +78 -0
  200. package/dist/locales/en.js +339 -0
  201. package/dist/locales/en.json +341 -0
  202. package/dist/locales/mr.js +339 -0
  203. package/dist/locales/mr.json +341 -0
  204. package/dist/ports/db.d.ts +22 -0
  205. package/dist/ports/index.d.ts +4 -0
  206. package/dist/ports/ports.d.ts +257 -0
  207. package/dist/ports/sql.d.ts +39 -0
  208. package/dist/ports/sql.js +76 -0
  209. package/dist/renderers/csv.d.ts +14 -0
  210. package/dist/renderers/csv.js +51 -0
  211. package/dist/renderers/html.d.ts +44 -0
  212. package/dist/renderers/html.js +81 -0
  213. package/dist/renderers/index.d.ts +5 -0
  214. package/dist/renderers/index.js +4 -0
  215. package/dist/renderers/table.d.ts +30 -0
  216. package/dist/renderers/table.js +114 -0
  217. package/dist/routes/admin.js +146 -0
  218. package/dist/routes/app.d.ts +3111 -0
  219. package/dist/routes/app.js +9 -0
  220. package/dist/routes/broadcasts.js +37 -0
  221. package/dist/routes/commands.js +35 -0
  222. package/dist/routes/content.js +12 -0
  223. package/dist/routes/events.js +36 -0
  224. package/dist/routes/http.d.ts +30 -0
  225. package/dist/routes/http.js +223 -0
  226. package/dist/routes/me.js +59 -0
  227. package/dist/routes/schedules.js +62 -0
  228. package/dist/routes/send.js +24 -0
  229. package/dist/routes/templates.js +70 -0
  230. package/dist/routes/webhook.js +147 -0
  231. package/dist/schema/columns.js +307 -0
  232. package/dist/schema/ddl.d.ts +7 -0
  233. package/dist/schema/ddl.js +45 -0
  234. package/dist/schema/index.d.ts +4255 -0
  235. package/dist/schema/index.js +44 -0
  236. package/dist/schema/reference.sql +359 -0
  237. package/dist/schema/tables.d.ts +4243 -0
  238. package/dist/schema/tables.js +327 -0
  239. package/dist/schema/version.d.ts +5 -0
  240. package/dist/schema/version.js +7 -0
  241. package/dist/server/index.d.ts +55 -0
  242. package/dist/server/index.js +46 -0
  243. package/dist/server/plugin.d.ts +81 -0
  244. package/dist/server/plugin.js +247 -0
  245. package/dist/server/version.d.ts +2 -0
  246. package/dist/server/version.js +4 -0
  247. package/dist/services/ai-tool.d.ts +26 -0
  248. package/dist/services/ai-tool.js +148 -0
  249. package/dist/services/bots.js +300 -0
  250. package/dist/services/broadcasts.js +390 -0
  251. package/dist/services/chats.js +83 -0
  252. package/dist/services/cleanup.js +83 -0
  253. package/dist/services/commands.js +140 -0
  254. package/dist/services/deliveries.js +53 -0
  255. package/dist/services/destinations.js +91 -0
  256. package/dist/services/event-rules.js +229 -0
  257. package/dist/services/events.js +20 -0
  258. package/dist/services/idempotency.js +64 -0
  259. package/dist/services/links.js +200 -0
  260. package/dist/services/menu.js +95 -0
  261. package/dist/services/ops.js +90 -0
  262. package/dist/services/rekey.js +63 -0
  263. package/dist/services/reports.js +87 -0
  264. package/dist/services/schedules.js +214 -0
  265. package/dist/services/send.d.ts +14 -0
  266. package/dist/services/send.js +421 -0
  267. package/dist/services/subscriptions.js +28 -0
  268. package/dist/services/templates.js +259 -0
  269. package/dist/telegram/classify.d.ts +30 -0
  270. package/dist/telegram/classify.js +164 -0
  271. package/dist/telegram/client.d.ts +73 -0
  272. package/dist/telegram/client.js +135 -0
  273. package/dist/telegram/errors.d.ts +37 -0
  274. package/dist/telegram/errors.js +62 -0
  275. package/dist/telegram/inbound.d.ts +40 -0
  276. package/dist/telegram/inbound.js +72 -0
  277. package/dist/telegram/index.d.ts +8 -0
  278. package/dist/telegram/multipart.d.ts +5 -0
  279. package/dist/telegram/multipart.js +47 -0
  280. package/dist/telegram/types.d.ts +271 -0
  281. package/dist/telegram/update.d.ts +833 -0
  282. package/dist/telegram/update.js +129 -0
  283. package/dist/testing/fake-html.d.ts +9 -0
  284. package/dist/testing/fake-html.js +83 -0
  285. package/dist/testing/fake-telegram.d.ts +206 -0
  286. package/dist/testing/fake-telegram.js +856 -0
  287. package/dist/testing/index.d.ts +7 -0
  288. package/dist/testing/index.js +7 -0
  289. package/dist/testing/reference-sql.d.ts +4 -0
  290. package/dist/testing/reference-sql.js +8 -0
  291. package/dist/testing/test-plugin.d.ts +126 -0
  292. package/dist/testing/test-plugin.js +182 -0
  293. package/dist/testing/updates.d.ts +100 -0
  294. package/dist/testing/updates.js +212 -0
  295. package/package.json +113 -3
@@ -0,0 +1,381 @@
1
+ import { takeChatSlot, takeToken } from "../../core/pacing.js";
2
+ import { sql } from "../../ports/sql.js";
3
+ import { isBotUsable } from "../bot-api.js";
4
+ import { metricCount, metricTiming } from "../metrics.js";
5
+ import { applyOutcome, releaseSql, sendDelivery } from "./send.js";
6
+ // src/engine/outbox/drain.ts
7
+ /** Longest the loop waits for the next due row (pacing gap, retry) before it stops. */
8
+ const MAX_IDLE_WAIT_MS = 5e3;
9
+ /** How often the D1 lease is renewed (and the bot row re-read) during a drain. */
10
+ const LEASE_RENEW_EVERY_MS = 5e3;
11
+ /** Longest a successful send's result write waits for its batch. */
12
+ const FLUSH_EVERY_MS = 1e3;
13
+ const CHAT_KINDS = [
14
+ "private",
15
+ "group",
16
+ "supergroup",
17
+ "channel"
18
+ ];
19
+ function chatKind(kind) {
20
+ return CHAT_KINDS.includes(kind) ? kind : "private";
21
+ }
22
+ async function acquireLease(rt, botId, token, now) {
23
+ return rt.db.first(sql`UPDATE tg_bot SET drain_lease_until = ${now + rt.limits.drainLeaseMs}, drain_token = ${token}
24
+ WHERE tenant = ${rt.tenant} AND id = ${botId} AND deleted_at IS NULL
25
+ AND status IN ('active', 'webhook_error')
26
+ AND (drain_lease_until IS NULL OR drain_lease_until < ${now})
27
+ AND (paused_until IS NULL OR paused_until < ${now})
28
+ RETURNING *`);
29
+ }
30
+ async function renewLease(rt, botId, token, now) {
31
+ return rt.db.first(sql`UPDATE tg_bot SET drain_lease_until = ${now + rt.limits.drainLeaseMs}
32
+ WHERE tenant = ${rt.tenant} AND id = ${botId} AND drain_token = ${token} RETURNING *`);
33
+ }
34
+ async function releaseLease(rt, botId, token) {
35
+ await rt.db.run(sql`UPDATE tg_bot SET drain_lease_until = NULL, drain_token = NULL
36
+ WHERE tenant = ${rt.tenant} AND id = ${botId} AND drain_token = ${token}`);
37
+ }
38
+ /** The default lease: a row lease on `tg_bot` with a fencing token (docs/06 §8 step 2). */
39
+ function d1Lease(rt, botId, token) {
40
+ let bot = null;
41
+ let renewedAt = Number.NEGATIVE_INFINITY;
42
+ const keep = (b, now) => {
43
+ bot = b;
44
+ renewedAt = now;
45
+ return b;
46
+ };
47
+ return {
48
+ acquire: async (now) => keep(await acquireLease(rt, botId, token, now), now),
49
+ renew: async (now) => now - renewedAt < LEASE_RENEW_EVERY_MS && bot ? bot : keep(await renewLease(rt, botId, token, now), now),
50
+ release: () => releaseLease(rt, botId, token)
51
+ };
52
+ }
53
+ /**
54
+ * Claims the head of each chat's queue: within a message (same chat + source + source id) only the lowest unsent
55
+ * part, then one row per chat whose pacing allows a send now. Expired `sending` leases are re-claimed.
56
+ */
57
+ /** Within a message (same chat + source + source id) only the lowest unsent part is a "head". */
58
+ function headsCte(rt, botId) {
59
+ return sql`heads AS (
60
+ SELECT d.id, d.chat_ref, d.status, d.next_attempt_at, d.lease_until, d.created_at, d.rowid AS seq,
61
+ ROW_NUMBER() OVER (PARTITION BY d.chat_ref, d.source, COALESCE(d.source_id, '')
62
+ ORDER BY d.part, d.created_at, d.rowid) AS rn
63
+ FROM tg_delivery d
64
+ WHERE d.tenant = ${rt.tenant} AND d.bot_id = ${botId} AND d.status IN ('queued', 'sending')
65
+ )`;
66
+ }
67
+ /**
68
+ * Claims the head of each chat's queue: one row per chat (oldest first, insert order breaks ties) whose pacing
69
+ * allows a send now. Expired `sending` leases are re-claimed.
70
+ */
71
+ function claimSql(rt, botId, token, now, limit) {
72
+ return sql`WITH ${headsCte(rt, botId)},
73
+ ready AS (
74
+ SELECT h.id, h.next_attempt_at, h.created_at, h.seq,
75
+ ROW_NUMBER() OVER (PARTITION BY h.chat_ref ORDER BY h.next_attempt_at, h.created_at, h.seq) AS crn
76
+ FROM heads h LEFT JOIN tg_chat c ON c.id = h.chat_ref
77
+ WHERE h.rn = 1
78
+ AND ((h.status = 'queued' AND h.next_attempt_at <= ${now})
79
+ OR (h.status = 'sending' AND (h.lease_until IS NULL OR h.lease_until < ${now})))
80
+ AND (c.next_allowed_at IS NULL OR c.next_allowed_at <= ${now})
81
+ )
82
+ UPDATE tg_delivery SET status = 'sending', lease_until = ${now + rt.limits.drainLeaseMs},
83
+ lease_token = ${token}, updated_at = ${now}
84
+ WHERE id IN (SELECT id FROM ready WHERE crn = 1 ORDER BY next_attempt_at, created_at, seq LIMIT ${limit})
85
+ RETURNING *`;
86
+ }
87
+ /**
88
+ * Claims a slice and, in the same D1 call, reads the next due time (used when nothing was claimable, e.g. the
89
+ * only chat is waiting for its pacing slot).
90
+ */
91
+ async function claimSlice(rt, botId, token, now, limit) {
92
+ const [claimed, due] = await rt.db.batch([claimSql(rt, botId, token, now, limit), nextDueSql(rt, botId)]);
93
+ return {
94
+ rows: claimed?.rows ?? [],
95
+ due: (due?.rows[0])?.due ?? null
96
+ };
97
+ }
98
+ /** Earliest time a head row of this bot can be claimed (due time, live lease, chat pacing); null = empty. */
99
+ async function nextDueAt(rt, botId) {
100
+ return (await rt.db.first(nextDueSql(rt, botId)))?.due ?? null;
101
+ }
102
+ function nextDueSql(rt, botId) {
103
+ return sql`WITH ${headsCte(rt, botId)}
104
+ SELECT MIN(MAX(CASE WHEN h.status = 'sending' THEN COALESCE(h.lease_until, 0) ELSE h.next_attempt_at END,
105
+ COALESCE(c.next_allowed_at, 0))) AS due
106
+ FROM heads h LEFT JOIN tg_chat c ON c.id = h.chat_ref WHERE h.rn = 1`;
107
+ }
108
+ /** Bots with work due now (or within `aheadMs`). */
109
+ async function botsWithDueWork(rt, now, aheadMs = 0) {
110
+ return (await rt.db.all(sql`SELECT DISTINCT d.bot_id FROM tg_delivery d JOIN tg_bot b ON b.id = d.bot_id
111
+ WHERE d.tenant = ${rt.tenant} AND b.deleted_at IS NULL AND b.status IN ('active', 'webhook_error')
112
+ AND (b.paused_until IS NULL OR b.paused_until < ${now})
113
+ AND ((d.status = 'queued' AND d.next_attempt_at <= ${now + aheadMs})
114
+ OR (d.status = 'sending' AND d.lease_until < ${now}))`)).map((r) => r.bot_id);
115
+ }
116
+ async function pool(items, size, fn) {
117
+ let next = 0;
118
+ const workers = Array.from({ length: Math.max(1, Math.min(size, items.length)) }, async () => {
119
+ while (next < items.length) {
120
+ const item = items[next++];
121
+ await fn(item);
122
+ }
123
+ });
124
+ await Promise.all(workers);
125
+ }
126
+ /**
127
+ * Drains one bot until its queue is idle, the budget is spent, or the bot must stop (401 / repeated 429).
128
+ * After releasing the lease it looks once more: a row queued while this drain was finishing (whose kick lost the
129
+ * lease race) is sent now instead of waiting for the next tick.
130
+ */
131
+ async function drainBot(rt, botId, opts = {}) {
132
+ const started = rt.clock.now();
133
+ const budget = opts.budgetMs ?? rt.limits.tickBudgetMs;
134
+ let total = await drainOnce(rt, botId, opts);
135
+ for (let round = 0; round < 3; round++) {
136
+ const now = rt.clock.now();
137
+ if (total.stoppedBy !== "idle" || total.nextDueAt === null || total.nextDueAt > now) break;
138
+ const left = budget - (now - started);
139
+ if (left <= 0) break;
140
+ const capLeft = sendCap(rt, opts.maxSends) - total.claimed;
141
+ if (capLeft <= 0) break;
142
+ const more = await drainOnce(rt, botId, {
143
+ ...opts,
144
+ budgetMs: left,
145
+ ...Number.isFinite(capLeft) ? { maxSends: capLeft } : {}
146
+ });
147
+ if (more.stoppedBy === "lease") break;
148
+ total = {
149
+ ...more,
150
+ claimed: total.claimed + more.claimed,
151
+ sent: total.sent + more.sent,
152
+ retried: total.retried + more.retried,
153
+ failed: total.failed + more.failed,
154
+ dropped: total.dropped + more.dropped
155
+ };
156
+ }
157
+ return total;
158
+ }
159
+ /** Claims one drain may make: `maxSends`, else 40 on the Free plan, else unlimited. */
160
+ function sendCap(rt, maxSends) {
161
+ return maxSends ?? (rt.limits.plan === "free" ? 40 : Number.POSITIVE_INFINITY);
162
+ }
163
+ /** Bot-level pacing: a token bucket (default) or exact spacing between send starts. */
164
+ function botPacer(rt, ratePerSec, strict) {
165
+ if (strict) {
166
+ const interval = 1e3 / ratePerSec;
167
+ let last = Number.NEGATIVE_INFINITY;
168
+ return () => {
169
+ const now = rt.clock.now();
170
+ const wait = Math.ceil(last + interval - now);
171
+ if (wait > 0) return wait;
172
+ last = now;
173
+ return 0;
174
+ };
175
+ }
176
+ let bucket = null;
177
+ return () => {
178
+ const r = takeToken(bucket, rt.clock.now(), { ratePerSec });
179
+ bucket = r.state;
180
+ return r.ok ? 0 : r.waitMs;
181
+ };
182
+ }
183
+ async function drainOnce(rt, botId, opts) {
184
+ const started = rt.clock.now();
185
+ const budget = opts.budgetMs ?? rt.limits.tickBudgetMs;
186
+ const cap = sendCap(rt, opts.maxSends);
187
+ const maxIdle = opts.maxIdleWaitMs ?? MAX_IDLE_WAIT_MS;
188
+ const token = rt.ids.uuid();
189
+ const lease = opts.lease ? opts.lease(token) : d1Lease(rt, botId, token);
190
+ const result = {
191
+ botId,
192
+ claimed: 0,
193
+ sent: 0,
194
+ retried: 0,
195
+ failed: 0,
196
+ dropped: 0,
197
+ stoppedBy: "idle",
198
+ nextDueAt: null
199
+ };
200
+ let bot = await lease.acquire(started);
201
+ if (!bot) {
202
+ result.stoppedBy = "lease";
203
+ return result;
204
+ }
205
+ let spins = 0;
206
+ const takeBotToken = botPacer(rt, Math.max(1, Math.min(bot.rate_per_sec, rt.limits.perBotPerSec)), opts.strictPacing === true);
207
+ try {
208
+ for (;;) {
209
+ const now = rt.clock.now();
210
+ if (now - started >= budget) {
211
+ result.stoppedBy = "budget";
212
+ break;
213
+ }
214
+ if (result.claimed >= cap) {
215
+ result.stoppedBy = "cap";
216
+ break;
217
+ }
218
+ const renewed = await lease.renew(now);
219
+ if (!renewed) {
220
+ result.stoppedBy = "lease";
221
+ break;
222
+ }
223
+ bot = renewed;
224
+ if (!isBotUsable(bot)) {
225
+ result.stoppedBy = "unavailable";
226
+ break;
227
+ }
228
+ const { rows, due } = await claimSlice(rt, botId, token, now, Math.max(1, Math.min(rt.limits.drainSlice, cap - result.claimed)));
229
+ if (rows.length === 0) {
230
+ const wait = due === null ? null : due - rt.clock.now();
231
+ if (wait !== null && wait <= maxIdle && rt.clock.now() - started + wait < budget && spins < 50) {
232
+ if (wait <= 0) spins++;
233
+ await rt.sleep(Math.max(wait, wait <= 0 ? 100 : 1));
234
+ continue;
235
+ }
236
+ result.nextDueAt = due;
237
+ result.stoppedBy = "idle";
238
+ break;
239
+ }
240
+ result.claimed += rows.length;
241
+ const stop = await sendSlice(rt, bot, rows, token, result, takeBotToken);
242
+ if (stop) {
243
+ result.stoppedBy = stop;
244
+ break;
245
+ }
246
+ }
247
+ } finally {
248
+ await lease.release();
249
+ }
250
+ result.nextDueAt = await nextDueAt(rt, botId);
251
+ const ms = rt.clock.now() - started;
252
+ metricCount(rt, "tg.drain", 1, {
253
+ botId,
254
+ outcome: result.stoppedBy
255
+ });
256
+ metricTiming(rt, "tg.drain_ms", ms, { botId });
257
+ rt.log.info("tg.drain", {
258
+ botId,
259
+ claimed: result.claimed,
260
+ sent: result.sent,
261
+ failed: result.failed,
262
+ retried: result.retried,
263
+ dropped: result.dropped,
264
+ stoppedBy: result.stoppedBy,
265
+ ms
266
+ });
267
+ return result;
268
+ }
269
+ /**
270
+ * Commits buffered result writes; one retry, then a loud error log (the rows stay `sending` and are re-sent once
271
+ * their lease expires) and the error is rethrown so the drain stops instead of sending more it cannot record.
272
+ */
273
+ async function commitWrites(rt, botId, writes, sends) {
274
+ try {
275
+ await rt.db.batch(writes);
276
+ return;
277
+ } catch (e) {
278
+ rt.log.warn("tg.drain_flush_retry", {
279
+ botId,
280
+ sends,
281
+ error: e instanceof Error ? e.name : "error"
282
+ });
283
+ }
284
+ try {
285
+ await rt.db.batch(writes);
286
+ } catch (e) {
287
+ rt.log.error("tg.drain_flush_failed", {
288
+ botId,
289
+ sends,
290
+ error: e instanceof Error ? e.name : "error"
291
+ });
292
+ throw e;
293
+ }
294
+ }
295
+ async function sendSlice(rt, bot, rows, token, result, takeBotToken) {
296
+ const chats = /* @__PURE__ */ new Map();
297
+ const refs = [...new Set(rows.map((r) => r.chat_ref))];
298
+ for (let i = 0; i < refs.length; i += 50) {
299
+ const chunk = refs.slice(i, i + 50);
300
+ const found = await rt.db.all(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id IN (${sql.join(chunk)})`);
301
+ for (const c of found) chats.set(c.id, c);
302
+ }
303
+ let stop = null;
304
+ const retryAfters = [];
305
+ const done = /* @__PURE__ */ new Set();
306
+ let pending = [];
307
+ let pendingSends = 0;
308
+ let lastFlush = rt.clock.now();
309
+ let flushFailed = false;
310
+ const flush = async (extra = []) => {
311
+ const writes = [...pending, ...extra];
312
+ const sends = pendingSends;
313
+ pending = [];
314
+ pendingSends = 0;
315
+ lastFlush = rt.clock.now();
316
+ if (writes.length === 0) return;
317
+ await commitWrites(rt, bot.id, writes, sends).catch((e) => {
318
+ flushFailed = true;
319
+ throw e;
320
+ });
321
+ };
322
+ try {
323
+ await pool(rows, rt.limits.drainConcurrency, async (row) => {
324
+ if (stop || flushFailed) return;
325
+ const chat = chats.get(row.chat_ref) ?? null;
326
+ let now = rt.clock.now();
327
+ if (chat) {
328
+ const pace = {
329
+ nextAllowedAt: chat.next_allowed_at,
330
+ windowStart: chat.minute_window_start,
331
+ windowCount: chat.minute_count
332
+ };
333
+ const slot = takeChatSlot(pace, now, chatKind(chat.kind), {
334
+ perChatIntervalMs: rt.limits.perChatIntervalMs,
335
+ perGroupPerMin: rt.limits.perGroupPerMin
336
+ });
337
+ if (!slot.ok) {
338
+ done.add(row.id);
339
+ await rt.db.batch([releaseSql(rt, row, token, now), sql`UPDATE tg_chat SET next_allowed_at = ${now + slot.waitMs} WHERE id = ${chat.id}`]);
340
+ return;
341
+ }
342
+ chat.next_allowed_at = slot.state.nextAllowedAt;
343
+ chat.minute_window_start = slot.state.windowStart;
344
+ chat.minute_count = slot.state.windowCount;
345
+ }
346
+ for (let wait = takeBotToken(); wait > 0; wait = takeBotToken()) await rt.sleep(wait);
347
+ now = rt.clock.now();
348
+ const summary = await sendDelivery(rt, bot, chat, row, now);
349
+ done.add(row.id);
350
+ const ended = rt.clock.now();
351
+ metricTiming(rt, "tg.send_ms", ended - now, { botId: bot.id });
352
+ const writes = [];
353
+ const effect = await applyOutcome(rt, bot, chat, row, token, summary, ended, writes);
354
+ if (writes.length > 0) {
355
+ pending.push(...writes);
356
+ pendingSends++;
357
+ if (pendingSends >= 5 || rt.clock.now() - lastFlush >= 1e3) await flush();
358
+ }
359
+ if (effect.result === "sent") result.sent++;
360
+ else if (effect.result === "retry") result.retried++;
361
+ else if (effect.result === "failed") result.failed++;
362
+ else result.dropped++;
363
+ if (effect.retryAfterMs !== void 0) retryAfters.push(effect.retryAfterMs);
364
+ if (effect.unauthorized) stop = "unauthorized";
365
+ });
366
+ } finally {
367
+ if (!flushFailed) await flush(rows.filter((r) => !done.has(r.id)).map((r) => releaseSql(rt, r, token, rt.clock.now())));
368
+ }
369
+ if (!stop && retryAfters.length >= 2) {
370
+ const until = rt.clock.now() + Math.max(...retryAfters);
371
+ await rt.db.run(sql`UPDATE tg_bot SET paused_until = ${until} WHERE tenant = ${rt.tenant} AND id = ${bot.id}`);
372
+ rt.log.warn("tg.bot_paused", {
373
+ botId: bot.id,
374
+ reason: "rate_limited",
375
+ ms: until - rt.clock.now()
376
+ });
377
+ stop = "paused";
378
+ }
379
+ return stop;
380
+ }
381
+ export { FLUSH_EVERY_MS, botsWithDueWork, d1Lease, drainBot, nextDueAt, sendCap };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { fitCaption, splitMessage } from "../../core/split/split.js";
2
+ import { sql } from "../../ports/sql.js";
3
+ // src/engine/outbox/enqueue.ts
4
+ function partsOf(input) {
5
+ const html = input.html ?? "";
6
+ if (input.kind === "document" || input.kind === "photo") {
7
+ if (!input.file) throw new TypeError("document / photo delivery needs a file");
8
+ const { caption, rest } = html === "" ? {
9
+ caption: "",
10
+ rest: []
11
+ } : fitCaption(html);
12
+ return [{
13
+ kind: input.kind,
14
+ html: caption === "" ? null : caption,
15
+ file: input.file
16
+ }, ...rest.map((p) => ({
17
+ kind: "text",
18
+ html: p,
19
+ file: null
20
+ }))];
21
+ }
22
+ if (input.kind === "delete") return [{
23
+ kind: "delete",
24
+ html: null,
25
+ file: null
26
+ }];
27
+ if (input.kind === "edit") return [{
28
+ kind: "edit",
29
+ html,
30
+ file: null
31
+ }];
32
+ const parts = splitMessage(html);
33
+ if (parts.length === 0) throw new TypeError("text delivery is empty");
34
+ return parts.map((p) => ({
35
+ kind: "text",
36
+ html: p,
37
+ file: null
38
+ }));
39
+ }
40
+ /** One `INSERT … ON CONFLICT DO NOTHING` per part. `ids` gives each row its id. */
41
+ function deliveryFragments(ctx, input, now) {
42
+ const parts = partsOf(input);
43
+ const sourceId = input.sourceId ?? input.dedupeKey;
44
+ const options = input.options && Object.keys(input.options).length > 0 ? JSON.stringify(input.options) : null;
45
+ const render = input.render ? JSON.stringify(input.render) : null;
46
+ const notBefore = input.notBefore ?? now;
47
+ return parts.map((p, i) => {
48
+ const markup = i === parts.length - 1 && input.replyMarkup ? JSON.stringify(input.replyMarkup) : null;
49
+ const fileRender = p.file?.source ? JSON.stringify({
50
+ ...input.render,
51
+ fileSource: p.file.source
52
+ }) : render;
53
+ return sql`INSERT INTO tg_delivery (id, tenant, bot_id, chat_ref, message_thread_id, source, source_id, kind,
54
+ body_html, part, parts, file_ref, file_name, file_mime, reply_markup_json, options_json, render_json,
55
+ target_message_id, dedupe_key, status, attempts, next_attempt_at, created_at, updated_at)
56
+ VALUES (${ctx.ids.uuid()}, ${ctx.tenant}, ${input.botId}, ${input.chatRef}, ${input.threadId ?? null},
57
+ ${input.source}, ${sourceId}, ${p.kind}, ${p.html}, ${i}, ${parts.length}, ${p.file?.ref ?? null},
58
+ ${p.file?.name ?? null}, ${p.file?.mime ?? null}, ${markup}, ${options}, ${fileRender},
59
+ ${input.targetMessageId ?? null}, ${`${input.dedupeKey}:${i}`}, 'queued', 0, ${notBefore}, ${now}, ${now})
60
+ ON CONFLICT (tenant, dedupe_key) DO NOTHING`;
61
+ });
62
+ }
63
+ export { deliveryFragments };
@@ -0,0 +1,24 @@
1
+ import { TelegramConfigError } from "../errors.js";
2
+ import { doTransport } from "./do-transport.js";
3
+ import { cronTransport, queueTransport } from "./transport.js";
4
+ // src/engine/outbox/select.ts
5
+ /**
6
+ * Builds the drain transport for `options.transport` (see `TransportChoice`). Throws `TelegramConfigError` when
7
+ * `queue` / `do` is chosen and its binding is missing.
8
+ */
9
+ function selectTransport(rt, mapped, choice) {
10
+ if (typeof choice === "function") return choice(rt);
11
+ switch (choice ?? "auto") {
12
+ case "cron": return cronTransport(rt);
13
+ case "queue":
14
+ if (!mapped.queue) throw new TelegramConfigError("transport \"queue\" needs env.queue");
15
+ return queueTransport(rt, mapped.queue);
16
+ case "do":
17
+ if (!mapped.botDO) throw new TelegramConfigError("transport \"do\" needs env.botDO (a TelegramBotDO namespace)");
18
+ return doTransport(rt, mapped.botDO);
19
+ default:
20
+ if (mapped.botDO) return doTransport(rt, mapped.botDO);
21
+ return mapped.queue ? queueTransport(rt, mapped.queue) : cronTransport(rt);
22
+ }
23
+ }
24
+ export { selectTransport };