@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,208 @@
1
+ import { decideMissed } from "../../core/schedule/policy.js";
2
+ import { topics } from "../../core/recipients.js";
3
+ import { nextRun, nextRuns } from "../../core/schedule/rule.js";
4
+ import { sql } from "../../ports/sql.js";
5
+ import { TelegramPluginError, isTelegramPluginError } from "../errors.js";
6
+ import { templateById, templateModel } from "../content/template-rows.js";
7
+ import { fanOut } from "../content/fanout.js";
8
+ import { resolveRecipients } from "../content/recipients.js";
9
+ import { missedPolicyOf, scheduleParams, scheduleRecipients, scheduleRule } from "./rows.js";
10
+ // src/engine/schedules/fire.ts
11
+ /** Claim lease (docs/06 §6.2). */
12
+ const CLAIM_MS = 12e4;
13
+ /** Base dedupe key of one chat (+ forum topic) for a slot; `:<part>` is appended per part. */
14
+ function scheduleDedupeKey(scheduleId, slotAt, chat) {
15
+ const base = `sched:${scheduleId}:${slotAt}:${chat.chatRef}`;
16
+ return chat.messageThreadId ? `${base}#${chat.messageThreadId}` : base;
17
+ }
18
+ /** The latest slot ≤ `now`, starting from the planned `first` one (catch-up runs only the latest). */
19
+ function latestSlot(rule, tz, first, now) {
20
+ let slot = first;
21
+ for (let i = 0; i < 100; i++) {
22
+ const more = nextRuns(rule, tz, slot, 500).filter((s) => s <= now);
23
+ const last = more.at(-1);
24
+ if (last === void 0) return slot;
25
+ slot = last;
26
+ if (more.length < 500) return slot;
27
+ }
28
+ return slot;
29
+ }
30
+ function codeOf(e) {
31
+ return isTelegramPluginError(e) ? e.code : "error";
32
+ }
33
+ async function runDeliveries(rt, runId) {
34
+ return (await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_delivery WHERE tenant = ${rt.tenant} AND source = 'schedule' AND source_id = ${runId}`))?.n ?? 0;
35
+ }
36
+ /** Fans one run out and records its outcome. Never throws: failures mark the run `failed`. */
37
+ async function executeRun(rt, schedule, run, opts = {}) {
38
+ let status = "done";
39
+ let deliveries = 0;
40
+ let error = null;
41
+ let templateVersion = null;
42
+ const bots = /* @__PURE__ */ new Set();
43
+ try {
44
+ const row = await templateById(rt, schedule.template_id);
45
+ if (!row) throw new TelegramPluginError("not_found", "template not found");
46
+ const template = templateModel(row);
47
+ templateVersion = template.version;
48
+ const now = rt.clock.now();
49
+ const recipients = await resolveRecipients(rt, scheduleRecipients(schedule), {
50
+ topic: topics.schedule(schedule.id),
51
+ now
52
+ });
53
+ if (recipients.send.length + recipients.held.length === 0) {
54
+ if (recipients.muted.length === 0) error = "recipient_empty";
55
+ } else {
56
+ const out = await fanOut(rt, {
57
+ template,
58
+ params: scheduleParams(schedule),
59
+ recipients,
60
+ personalize: schedule.personalize === 1,
61
+ source: "schedule",
62
+ sourceId: run.id,
63
+ dedupeKey: (chat) => scheduleDedupeKey(schedule.id, run.slotAt, chat),
64
+ now,
65
+ tz: schedule.timezone,
66
+ meta: {
67
+ schedule: schedule.id,
68
+ run: run.id
69
+ },
70
+ fileTags: {
71
+ schedule: schedule.id,
72
+ run: run.id
73
+ }
74
+ });
75
+ deliveries = opts.resumed ? await runDeliveries(rt, run.id) : out.deliveries;
76
+ if (out.errors.length > 0) {
77
+ status = deliveries > 0 ? "partial" : "failed";
78
+ error = out.errors.join(",");
79
+ }
80
+ for (const r of [...recipients.send, ...recipients.held.map((h) => h.chat)]) bots.add(r.botId);
81
+ }
82
+ } catch (e) {
83
+ rt.log.warn("tg.schedule_run_failed", {
84
+ schedule: schedule.id,
85
+ code: codeOf(e)
86
+ });
87
+ status = "failed";
88
+ error = codeOf(e);
89
+ }
90
+ const finishedAt = rt.clock.now();
91
+ await rt.db.batch([sql`UPDATE tg_schedule_run SET status = ${status}, deliveries = ${deliveries}, error = ${error},
92
+ finished_at = ${finishedAt}, template_version = ${templateVersion}
93
+ WHERE tenant = ${rt.tenant} AND id = ${run.id}`, sql`UPDATE tg_schedule SET last_run_at = ${run.startedAt}, last_status = ${status}
94
+ WHERE tenant = ${rt.tenant} AND id = ${schedule.id}`]);
95
+ rt.log.info("tg.schedule_run", {
96
+ schedule: schedule.id,
97
+ trigger: run.trigger,
98
+ status,
99
+ deliveries
100
+ });
101
+ if (opts.selfBotId !== void 0) bots.delete(opts.selfBotId);
102
+ if (deliveries > 0 && bots.size > 0) try {
103
+ await rt.transport.kick([...bots]);
104
+ } catch (e) {
105
+ rt.log.warn("tg.kick_failed", { error: e instanceof Error ? e.name : "error" });
106
+ }
107
+ return {
108
+ id: run.id,
109
+ tenant: rt.tenant,
110
+ schedule_id: schedule.id,
111
+ slot_at: run.slotAt,
112
+ trigger: run.trigger,
113
+ started_at: run.startedAt,
114
+ finished_at: finishedAt,
115
+ status,
116
+ deliveries,
117
+ error,
118
+ template_version: templateVersion
119
+ };
120
+ }
121
+ /**
122
+ * A claimed schedule: missed policy, then ONE batch guarded by the claim token that inserts the run row (unique
123
+ * per slot) and advances `next_run_at` past `max(slot, now)`, releasing the claim. Fan-out only follows a newly
124
+ * inserted run row. Returns the run, or null when the slot already ran or the claim was lost.
125
+ */
126
+ async function fireClaimed(rt, schedule, token, now, opts = {}) {
127
+ const planned = schedule.next_run_at;
128
+ if (planned === null) return null;
129
+ let slot;
130
+ let next;
131
+ try {
132
+ const rule = scheduleRule(schedule);
133
+ slot = latestSlot(rule, schedule.timezone, planned, now);
134
+ next = nextRun(rule, schedule.timezone, Math.max(slot, now));
135
+ } catch (e) {
136
+ rt.log.warn("tg.schedule_rule_broken", {
137
+ schedule: schedule.id,
138
+ code: e instanceof Error ? e.name : "error"
139
+ });
140
+ await rt.db.run(sql`UPDATE tg_schedule SET next_run_at = NULL, claim_until = NULL, claim_token = NULL, last_status = 'failed'
141
+ WHERE tenant = ${rt.tenant} AND id = ${schedule.id} AND claim_token = ${token}`);
142
+ return null;
143
+ }
144
+ const decision = decideMissed({
145
+ nextRunAt: slot,
146
+ now,
147
+ policy: missedPolicyOf(schedule),
148
+ graceMin: schedule.missed_grace_min
149
+ });
150
+ const runId = rt.ids.uuid();
151
+ const status = decision === "skip" ? "skipped" : "running";
152
+ const [inserted] = await rt.db.batch([sql`INSERT INTO tg_schedule_run (id, tenant, schedule_id, slot_at, trigger, started_at, finished_at, status, deliveries)
153
+ SELECT ${runId}, ${rt.tenant}, ${schedule.id}, ${slot}, 'schedule', ${now},
154
+ ${decision === "skip" ? now : null}, ${status}, 0
155
+ WHERE EXISTS (SELECT 1 FROM tg_schedule WHERE id = ${schedule.id} AND claim_token = ${token})
156
+ ON CONFLICT (schedule_id, slot_at) DO NOTHING`, sql`UPDATE tg_schedule SET next_run_at = ${next}, claim_until = NULL, claim_token = NULL,
157
+ last_status = ${status === "skipped" ? "skipped" : sql`last_status`}
158
+ WHERE tenant = ${rt.tenant} AND id = ${schedule.id} AND claim_token = ${token}`]);
159
+ if (!inserted || inserted.changes === 0) return null;
160
+ if (decision === "skip") {
161
+ rt.log.info("tg.schedule_skipped", {
162
+ schedule: schedule.id,
163
+ lateMs: now - slot
164
+ });
165
+ return {
166
+ id: runId,
167
+ tenant: rt.tenant,
168
+ schedule_id: schedule.id,
169
+ slot_at: slot,
170
+ trigger: "schedule",
171
+ started_at: now,
172
+ finished_at: now,
173
+ status: "skipped",
174
+ deliveries: 0,
175
+ error: null,
176
+ template_version: null
177
+ };
178
+ }
179
+ return executeRun(rt, schedule, {
180
+ id: runId,
181
+ slotAt: slot,
182
+ trigger: "schedule",
183
+ startedAt: now
184
+ }, opts);
185
+ }
186
+ /**
187
+ * `run-now`: a `manual` run with `slot_at = now` (moved forward by 1 ms on a collision), same fan-out path.
188
+ * `next_run_at` is not touched.
189
+ */
190
+ async function runManual(rt, schedule) {
191
+ const now = rt.clock.now();
192
+ for (let slot = now; slot < now + 5; slot++) {
193
+ const runId = rt.ids.uuid();
194
+ if ((await rt.db.run(sql`INSERT INTO tg_schedule_run (id, tenant, schedule_id, slot_at, trigger, started_at, status, deliveries)
195
+ SELECT ${runId}, ${rt.tenant}, ${schedule.id}, ${slot}, 'manual', ${now}, 'running', 0
196
+ WHERE EXISTS (SELECT 1 FROM tg_schedule WHERE tenant = ${rt.tenant} AND id = ${schedule.id}
197
+ AND deleted_at IS NULL)
198
+ ON CONFLICT (schedule_id, slot_at) DO NOTHING`)).changes === 1) return executeRun(rt, schedule, {
199
+ id: runId,
200
+ slotAt: slot,
201
+ trigger: "manual",
202
+ startedAt: now
203
+ });
204
+ if (!await rt.db.first(sql`SELECT 1 AS ok FROM tg_schedule WHERE tenant = ${rt.tenant} AND id = ${schedule.id} AND deleted_at IS NULL`)) throw new TelegramPluginError("not_found", "schedule not found");
205
+ }
206
+ throw new TelegramPluginError("rate_limited", "too many manual runs at once", { extensions: { retryAfter: 1 } });
207
+ }
208
+ export { CLAIM_MS, executeRun, fireClaimed, latestSlot, runManual, scheduleDedupeKey };
@@ -0,0 +1,75 @@
1
+ import { sql } from "../../ports/sql.js";
2
+ import { scheduleById } from "./rows.js";
3
+ import { executeRun } from "./fire.js";
4
+ // src/engine/schedules/resume.ts
5
+ /** A `running` run older than this is treated as cut off; attempt n is not tried before `started_at + n ×` this. */
6
+ const STUCK_RUN_MS = 6e5;
7
+ /** A stuck run older than this is never resumed (its messages would be stale), only marked `failed`. */
8
+ const RESUME_MAX_AGE_MS = 36e5;
9
+ /** Stuck runs looked at per tick. */
10
+ const SWEEP_LIMIT = 10;
11
+ const MARK = "resumed:";
12
+ function resumesOf(error) {
13
+ if (error === null || !error.startsWith(MARK)) return 0;
14
+ const n = Number(error.slice(8));
15
+ return Number.isInteger(n) && n >= 0 ? n : 0;
16
+ }
17
+ /** Marks a stuck run `failed` (guarded by its current `error`, so only one tick does it). */
18
+ async function giveUp(rt, run, code, now) {
19
+ const [res] = await rt.db.batch([sql`UPDATE tg_schedule_run SET status = 'failed', error = ${code}, finished_at = ${now}
20
+ WHERE tenant = ${rt.tenant} AND id = ${run.id} AND status = 'running' AND error IS ${run.error}`, sql`UPDATE tg_schedule SET last_run_at = ${run.started_at}, last_status = 'failed'
21
+ WHERE tenant = ${rt.tenant} AND id = ${run.schedule_id}
22
+ AND (last_run_at IS NULL OR last_run_at <= ${run.started_at})`]);
23
+ if (res && res.changes > 0) rt.log.warn("tg.schedule_run_stuck", {
24
+ schedule: run.schedule_id,
25
+ run: run.id,
26
+ code,
27
+ resumes: resumesOf(run.error)
28
+ });
29
+ }
30
+ /**
31
+ * Resumes (or gives up on) `running` schedule runs older than `STUCK_RUN_MS`. Each attempt is claimed by a
32
+ * compare-and-set on `error`, so concurrent ticks never resume one run twice at once. Returns runs resumed.
33
+ */
34
+ async function resumeStuckRuns(rt, now) {
35
+ if (!rt.features.schedules) return 0;
36
+ const stuck = await rt.db.all(sql`SELECT * FROM tg_schedule_run WHERE tenant = ${rt.tenant} AND status = 'running'
37
+ AND started_at < ${now - STUCK_RUN_MS}
38
+ ORDER BY started_at LIMIT ${SWEEP_LIMIT}`);
39
+ let resumed = 0;
40
+ for (const run of stuck) try {
41
+ const n = resumesOf(run.error);
42
+ const age = now - run.started_at;
43
+ if (age < (n + 1) * 6e5) continue;
44
+ if (n >= 3 || age > 36e5) {
45
+ await giveUp(rt, run, "run_stuck", now);
46
+ continue;
47
+ }
48
+ const schedule = await scheduleById(rt, run.schedule_id);
49
+ if (!schedule) {
50
+ await giveUp(rt, run, "not_found", now);
51
+ continue;
52
+ }
53
+ if ((await rt.db.run(sql`UPDATE tg_schedule_run SET error = ${`${MARK}${n + 1}`}
54
+ WHERE tenant = ${rt.tenant} AND id = ${run.id} AND status = 'running' AND error IS ${run.error}`)).changes === 0) continue;
55
+ rt.log.warn("tg.schedule_run_resumed", {
56
+ schedule: schedule.id,
57
+ run: run.id,
58
+ attempt: n + 1
59
+ });
60
+ await executeRun(rt, schedule, {
61
+ id: run.id,
62
+ slotAt: run.slot_at,
63
+ trigger: run.trigger === "manual" ? "manual" : "schedule",
64
+ startedAt: run.started_at
65
+ }, { resumed: true });
66
+ resumed++;
67
+ } catch (e) {
68
+ rt.log.error("tg.schedule_resume_failed", {
69
+ run: run.id,
70
+ error: e instanceof Error ? e.name : "error"
71
+ });
72
+ }
73
+ return resumed;
74
+ }
75
+ export { RESUME_MAX_AGE_MS, STUCK_RUN_MS, resumeStuckRuns };
@@ -0,0 +1,69 @@
1
+ import { JsonObject } from "../../contracts/common.js";
2
+ import { Recipients } from "../../contracts/recipients.js";
3
+ import { MissedPolicy, ScheduleRule, ScheduleRunStatus, ScheduleRunTrigger } from "../../contracts/schedules.js";
4
+ import { sql } from "../../ports/sql.js";
5
+ import { TelegramPluginError } from "../errors.js";
6
+ import { parseJson } from "../rows.js";
7
+ // src/engine/schedules/rows.ts
8
+ /** A stored rule that no longer parses never runs. */
9
+ const NEVER = {
10
+ kind: "once",
11
+ at: "1970-01-01T00:00"
12
+ };
13
+ function scheduleRule(r) {
14
+ return parseJson(r.rule_json, ScheduleRule, NEVER);
15
+ }
16
+ function scheduleParams(r) {
17
+ return parseJson(r.params_json, JsonObject.nullable(), null);
18
+ }
19
+ function scheduleRecipients(r) {
20
+ return parseJson(r.recipients_json, Recipients, []);
21
+ }
22
+ function missedPolicyOf(r) {
23
+ return MissedPolicy.safeParse(r.missed_policy).data ?? "run_once";
24
+ }
25
+ function scheduleDto(r) {
26
+ return {
27
+ id: r.id,
28
+ name: r.name,
29
+ rule: scheduleRule(r),
30
+ timezone: r.timezone,
31
+ templateId: r.template_id,
32
+ params: scheduleParams(r),
33
+ recipients: scheduleRecipients(r),
34
+ personalize: r.personalize === 1,
35
+ missedPolicy: missedPolicyOf(r),
36
+ missedGraceMin: r.missed_grace_min,
37
+ enabled: r.enabled === 1,
38
+ pausedUntil: r.paused_until,
39
+ nextRunAt: r.next_run_at,
40
+ lastRunAt: r.last_run_at,
41
+ lastStatus: ScheduleRunStatus.safeParse(r.last_status).data ?? null,
42
+ createdBy: r.created_by,
43
+ createdAt: r.created_at,
44
+ updatedAt: r.updated_at
45
+ };
46
+ }
47
+ function scheduleRunDto(r) {
48
+ return {
49
+ id: r.id,
50
+ scheduleId: r.schedule_id,
51
+ slotAt: r.slot_at,
52
+ trigger: ScheduleRunTrigger.safeParse(r.trigger).data ?? "schedule",
53
+ startedAt: r.started_at,
54
+ finishedAt: r.finished_at,
55
+ status: ScheduleRunStatus.safeParse(r.status).data ?? "failed",
56
+ deliveries: r.deliveries,
57
+ error: r.error,
58
+ templateVersion: r.template_version
59
+ };
60
+ }
61
+ async function scheduleById(rt, id) {
62
+ return rt.db.first(sql`SELECT * FROM tg_schedule WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`);
63
+ }
64
+ async function requireSchedule(rt, id) {
65
+ const row = await scheduleById(rt, id);
66
+ if (!row) throw new TelegramPluginError("not_found", "schedule not found");
67
+ return row;
68
+ }
69
+ export { missedPolicyOf, requireSchedule, scheduleById, scheduleDto, scheduleParams, scheduleRecipients, scheduleRule, scheduleRunDto };
@@ -0,0 +1,30 @@
1
+ import { sql } from "../../ports/sql.js";
2
+ import { CLAIM_MS, fireClaimed } from "./fire.js";
3
+ // src/engine/schedules/tick.ts
4
+ /** One UPDATE … RETURNING: due, enabled, not paused, not deleted, not leased by another tick. */
5
+ async function claimDueSchedules(rt, now, token) {
6
+ return rt.db.all(sql`UPDATE tg_schedule SET claim_until = ${now + CLAIM_MS}, claim_token = ${token}
7
+ WHERE id IN (SELECT id FROM tg_schedule
8
+ WHERE tenant = ${rt.tenant} AND enabled = 1 AND deleted_at IS NULL
9
+ AND (paused_until IS NULL OR paused_until <= ${now})
10
+ AND next_run_at IS NOT NULL AND next_run_at <= ${now}
11
+ AND (claim_until IS NULL OR claim_until < ${now})
12
+ ORDER BY next_run_at LIMIT ${rt.limits.scheduleClaim})
13
+ RETURNING *`);
14
+ }
15
+ /** Claims and fires due schedules. Returns how many were claimed. */
16
+ async function fireDueSchedules(rt, now, opts = {}) {
17
+ if (!rt.features.schedules) return 0;
18
+ const token = rt.ids.uuid();
19
+ const claimed = await claimDueSchedules(rt, now, token);
20
+ for (const row of claimed) try {
21
+ await fireClaimed(rt, row, token, now, opts);
22
+ } catch (e) {
23
+ rt.log.error("tg.schedule_fire_failed", {
24
+ schedule: row.id,
25
+ error: e instanceof Error ? e.name : "error"
26
+ });
27
+ }
28
+ return claimed.length;
29
+ }
30
+ export { claimDueSchedules, fireDueSchedules };
@@ -0,0 +1,50 @@
1
+ import { sql } from "../../ports/sql.js";
2
+ import { isDoTransport, scheduleBotWake } from "../outbox/do-transport.js";
3
+ import { fireDueSchedules } from "./tick.js";
4
+ // src/engine/schedules/wake.ts
5
+ /** Floor for a re-armed wake, so a held claim never makes an object spin on its alarm. */
6
+ const MIN_WAKE_GAP_MS = 1e3;
7
+ /** The bot whose object fires the tenant's schedules (null = no usable bot; the tick handles them). */
8
+ async function schedulerBotId(rt) {
9
+ return (await rt.db.first(sql`SELECT id FROM tg_bot WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
10
+ AND status IN ('active', 'webhook_error') ORDER BY created_at, id LIMIT 1`))?.id ?? null;
11
+ }
12
+ /**
13
+ * When the next schedule can be claimed: MIN over live schedules of max(next_run_at, end of a timed pause, end of
14
+ * a held claim). Indefinitely paused schedules never wake.
15
+ */
16
+ async function nextScheduleAt(rt) {
17
+ return (await rt.db.first(sql`SELECT MIN(MAX(next_run_at, COALESCE(paused_until, 0), COALESCE(claim_until, 0))) AS at FROM tg_schedule
18
+ WHERE tenant = ${rt.tenant} AND enabled = 1 AND deleted_at IS NULL AND next_run_at IS NOT NULL
19
+ AND (paused_until IS NULL OR paused_until < ${0xe677d21fdbff})`))?.at ?? null;
20
+ }
21
+ /**
22
+ * Sets the scheduler bot's object wake to the tenant's next schedule. Call it whenever a `next_run_at` changes
23
+ * (create, edit, pause, resume, delete, a fired run). The wake is replaced, not merged, so a schedule that was
24
+ * deleted or paused past its old slot does not leave a stale earlier alarm; no schedule left clears the wake.
25
+ * No-op unless the DO transport is active; never throws.
26
+ */
27
+ async function armScheduleWake(rt) {
28
+ if (!rt.features.schedules || !isDoTransport(rt.transport)) return false;
29
+ try {
30
+ const [botId, at] = await Promise.all([schedulerBotId(rt), nextScheduleAt(rt)]);
31
+ if (botId === null) return false;
32
+ await rt.transport.setWake(botId, at);
33
+ return at !== null;
34
+ } catch (e) {
35
+ rt.log.warn("tg.schedule_wake_failed", { error: e instanceof Error ? e.name : "error" });
36
+ return false;
37
+ }
38
+ }
39
+ /** The DO wake stage: fire due schedules, then return the next wake (scheduler bot) or hand it over (others). */
40
+ async function scheduleWakeStage(rt, botId, now) {
41
+ if (!rt.features.schedules) return null;
42
+ await fireDueSchedules(rt, now, { selfBotId: botId });
43
+ const [scheduler, at] = await Promise.all([schedulerBotId(rt), nextScheduleAt(rt)]);
44
+ if (at === null) return null;
45
+ const next = Math.max(at, rt.clock.now() + MIN_WAKE_GAP_MS);
46
+ if (scheduler === botId) return next;
47
+ if (scheduler !== null) await scheduleBotWake(rt, scheduler, next);
48
+ return null;
49
+ }
50
+ export { armScheduleWake, nextScheduleAt, scheduleWakeStage, schedulerBotId };
@@ -0,0 +1,11 @@
1
+ /** Result of `ensureState`: whether the plugin tables match this version. */
2
+ export interface SchemaState {
3
+ /** True when the schema is current; jobs and the webhook skip work otherwise (`schema_outdated`). */
4
+ ok: boolean;
5
+ /** `SCHEMA_VERSION` this build needs. */
6
+ required: number;
7
+ /** Version recorded in `tg_state`, or null when never written. */
8
+ current: number | null;
9
+ /** Tables the probe could not read and missing unique indexes as `index:<name>` (only when not ok). */
10
+ missing: string[];
11
+ }
@@ -0,0 +1,78 @@
1
+ import { sql } from "../ports/sql.js";
2
+ import { TABLE_COLUMNS, UNIQUE_INDEXES } from "../schema/columns.js";
3
+ // src/engine/state.ts
4
+ const STATE_KEYS = {
5
+ schemaVersion: "schema_version",
6
+ lastTickAt: "last_tick_at",
7
+ lastCleanupAt: "last_cleanup_at",
8
+ lastRekeyAt: "last_rekey_at",
9
+ keyringActiveKid: "keyring_active_kid",
10
+ pollOffset: (botId) => `poll_offset:${botId}`,
11
+ botHealth: (botId) => `bot_health:${botId}`
12
+ };
13
+ async function getState(rt, key) {
14
+ return (await rt.db.first(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${key}`))?.value ?? null;
15
+ }
16
+ function setStateSql(rt, key, value, now) {
17
+ return sql`INSERT INTO tg_state (tenant, key, value, updated_at) VALUES (${rt.tenant}, ${key}, ${value}, ${now})
18
+ ON CONFLICT (tenant, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`;
19
+ }
20
+ async function setState(rt, key, value, now) {
21
+ await rt.db.run(setStateSql(rt, key, value, now));
22
+ }
23
+ async function probe(db) {
24
+ const missing = [];
25
+ for (const [table, cols] of Object.entries(TABLE_COLUMNS)) try {
26
+ if (![table, ...cols].every((n) => /^[a-z_][a-z0-9_]*$/.test(n))) throw new Error("bad name");
27
+ await db.all(sql`SELECT ${sql.raw(cols.join(", "))} FROM ${sql.raw(table)} LIMIT 0`);
28
+ } catch {
29
+ missing.push(table);
30
+ }
31
+ try {
32
+ const rows = await db.all(sql`SELECT name FROM sqlite_master WHERE type = 'index'`);
33
+ const have = new Set(rows.map((r) => r.name));
34
+ for (const ix of UNIQUE_INDEXES) if (!have.has(ix)) missing.push(`index:${ix}`);
35
+ } catch {
36
+ missing.push("sqlite_master");
37
+ }
38
+ return missing;
39
+ }
40
+ /**
41
+ * Bootstrap and guard. When `tg_state.schema_version` is behind (or missing), probes every plugin table for the
42
+ * columns this version needs; if they all exist, records `SCHEMA_VERSION`. Safe to call on every cold start.
43
+ */
44
+ async function ensureState(rt) {
45
+ let current = null;
46
+ try {
47
+ const v = await getState(rt, STATE_KEYS.schemaVersion);
48
+ current = v === null ? null : Number(v);
49
+ } catch {
50
+ return {
51
+ ok: false,
52
+ required: 2,
53
+ current: null,
54
+ missing: ["tg_state"]
55
+ };
56
+ }
57
+ if (current !== null && current >= 2) return {
58
+ ok: true,
59
+ required: 2,
60
+ current,
61
+ missing: []
62
+ };
63
+ const missing = await probe(rt.db);
64
+ if (missing.length > 0) return {
65
+ ok: false,
66
+ required: 2,
67
+ current,
68
+ missing
69
+ };
70
+ await setState(rt, STATE_KEYS.schemaVersion, String(2), rt.clock.now());
71
+ return {
72
+ ok: true,
73
+ required: 2,
74
+ current: 2,
75
+ missing: []
76
+ };
77
+ }
78
+ export { STATE_KEYS, ensureState, getState, setState, setStateSql };