@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 { nextBackoff, throttleKey } from "../../core/pacing.js";
2
+ import { topics } from "../../core/recipients.js";
3
+ import { sql } from "../../ports/sql.js";
4
+ import { isTelegramPluginError } from "../errors.js";
5
+ import { templateById, templateModel } from "../content/template-rows.js";
6
+ import { matchFilters } from "./filters.js";
7
+ import { payloadVarsFor } from "./payload.js";
8
+ import { TRANSIENT_FANOUT_ERRORS, fanOut } from "../content/fanout.js";
9
+ import { resolveRecipients } from "../content/recipients.js";
10
+ import { eventProvider, eventRuleModel } from "../../services/event-rules.js";
11
+ // src/engine/events/tick.ts
12
+ /** How long a claimed event stays with one processor before another may re-claim it. */
13
+ const EVENT_CLAIM_MS = 12e4;
14
+ /** Pending events that are due (no backoff, or its time has come), or processing ones with an expired claim. */
15
+ function claimableEvents(rt, now) {
16
+ return sql`tenant = ${rt.tenant} AND ((status = 'pending' AND (claim_until IS NULL OR claim_until <= ${now}))
17
+ OR (status = 'processing' AND claim_until < ${now}))`;
18
+ }
19
+ /** Atomically claims up to `limits.eventClaim` due events. */
20
+ async function claimEvents(rt, now, token) {
21
+ const claimable = claimableEvents(rt, now);
22
+ return rt.db.all(sql`UPDATE tg_event SET status = 'processing', claim_until = ${now + EVENT_CLAIM_MS}, claim_token = ${token},
23
+ attempts = attempts + 1
24
+ WHERE id IN (SELECT id FROM tg_event WHERE ${claimable} ORDER BY occurred_at, id LIMIT ${rt.limits.eventClaim})
25
+ AND ${claimable}
26
+ RETURNING id, event, payload_json, occurred_at, attempts`);
27
+ }
28
+ /**
29
+ * Throttle as one conditional upsert in `tg_state` (`thr:<rule>:<value>` → last event id, `updated_at` = its
30
+ * time). It passes when the key is new, its window has passed, or this same event took it (re-processing after a
31
+ * crash), so two processors can never both pass in one window.
32
+ */
33
+ async function takeThrottle(rt, rule, ev, payload) {
34
+ if (!rule.throttle) return true;
35
+ const key = throttleKey(rule.id, rule.throttle, payload);
36
+ const since = ev.occurred_at - rule.throttle.perMinutes * 6e4;
37
+ return (await rt.db.all(sql`INSERT INTO tg_state (tenant, key, value, updated_at) VALUES (${rt.tenant}, ${key}, ${ev.id}, ${ev.occurred_at})
38
+ ON CONFLICT (tenant, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
39
+ WHERE tg_state.updated_at <= ${since} OR tg_state.value = excluded.value
40
+ RETURNING key`)).length > 0;
41
+ }
42
+ async function rulesFor(rt, events) {
43
+ const out = /* @__PURE__ */ new Map();
44
+ const keys = [...new Set(events)];
45
+ if (keys.length === 0) return out;
46
+ const rows = await rt.db.all(sql`SELECT * FROM tg_event_rule WHERE tenant = ${rt.tenant} AND event IN (${sql.join(keys)})
47
+ AND enabled = 1 AND deleted_at IS NULL ORDER BY created_at, id`);
48
+ for (const r of rows) out.set(r.event, [...out.get(r.event) ?? [], eventRuleModel(r)]);
49
+ return out;
50
+ }
51
+ const ignored = (error) => ({
52
+ status: "ignored",
53
+ error,
54
+ botIds: [],
55
+ deliveries: 0,
56
+ retry: false
57
+ });
58
+ async function processOne(rt, ev, rules, templates) {
59
+ const def = rt.registry.events.get(ev.event);
60
+ if (!def) return ignored("event_unknown");
61
+ let raw;
62
+ try {
63
+ raw = JSON.parse(ev.payload_json);
64
+ } catch {
65
+ return ignored("payload_invalid");
66
+ }
67
+ const parsed = def.payload.safeParse(raw);
68
+ if (!parsed.success) return ignored("payload_invalid");
69
+ const payload = parsed.data;
70
+ const errors = /* @__PURE__ */ new Set();
71
+ const botIds = /* @__PURE__ */ new Set();
72
+ let deliveries = 0;
73
+ for (const rule of rules) {
74
+ if (!matchFilters(rule.filters, payload)) continue;
75
+ let template = templates.get(rule.templateId);
76
+ if (template === void 0) {
77
+ const row = await templateById(rt, rule.templateId);
78
+ template = row ? templateModel(row) : null;
79
+ templates.set(rule.templateId, template);
80
+ }
81
+ if (!template) {
82
+ rt.log.warn("tg.event_rule_template_missing", {
83
+ rule: rule.id,
84
+ code: "not_found"
85
+ });
86
+ errors.add("template_missing");
87
+ continue;
88
+ }
89
+ if (!await takeThrottle(rt, rule, ev, payload)) continue;
90
+ const now = rt.clock.now();
91
+ const recipients = await resolveRecipients(rt, rule.recipients, {
92
+ topic: topics.event(def.key),
93
+ now
94
+ });
95
+ if (recipients.send.length + recipients.held.length === 0) continue;
96
+ const r = await fanOut(rt, {
97
+ template,
98
+ provider: eventProvider(def, template, payload),
99
+ recipients,
100
+ personalize: true,
101
+ source: "event",
102
+ sourceId: ev.id,
103
+ dedupeKey: (chat) => `evt:${ev.id}:${rule.id}:${chat.chatRef}${chat.messageThreadId ? `#${chat.messageThreadId}` : ""}`,
104
+ now,
105
+ tz: rt.settings.timezone,
106
+ vars: (aud) => payloadVarsFor(rt, def, payload, aud.actor),
107
+ meta: {
108
+ event: def.key,
109
+ rule: rule.id
110
+ }
111
+ });
112
+ deliveries += r.deliveries;
113
+ for (const e of r.errors) errors.add(e);
114
+ if (r.chats > 0) for (const c of [...recipients.send, ...recipients.held.map((h) => h.chat)]) botIds.add(c.botId);
115
+ }
116
+ const error = errors.size > 0 ? [...errors].join(",").slice(0, 200) : null;
117
+ const retry = [...errors].some((e) => TRANSIENT_FANOUT_ERRORS.includes(e));
118
+ return {
119
+ status: "done",
120
+ error,
121
+ botIds: [...botIds],
122
+ deliveries,
123
+ retry
124
+ };
125
+ }
126
+ /** Claims and processes due events. Safe to run concurrently (claims + dedupe keys + the throttle upsert). */
127
+ async function runEvents(rt, now) {
128
+ const result = {
129
+ claimed: 0,
130
+ done: 0,
131
+ ignored: 0,
132
+ failed: 0,
133
+ retried: 0,
134
+ deliveries: 0
135
+ };
136
+ if (!rt.features.events) return result;
137
+ const token = rt.ids.uuid();
138
+ const claimed = await claimEvents(rt, now, token);
139
+ result.claimed = claimed.length;
140
+ if (claimed.length === 0) return result;
141
+ const rules = await rulesFor(rt, claimed.map((e) => e.event));
142
+ const templates = /* @__PURE__ */ new Map();
143
+ const botIds = /* @__PURE__ */ new Set();
144
+ const started = rt.clock.now();
145
+ const budget = rt.limits.tickBudgetMs / 2;
146
+ const fenced = (id) => sql`WHERE tenant = ${rt.tenant} AND id = ${id} AND claim_token = ${token}`;
147
+ /** Puts the event back to `pending` with a backoff, or ends it as `finalStatus` after the last attempt. */
148
+ const backOff = async (ev, finalStatus, code) => {
149
+ const final = ev.attempts >= 5;
150
+ const at = rt.clock.now();
151
+ const notBefore = final ? null : at + nextBackoff(ev.attempts - 1, Math.random);
152
+ await rt.db.run(sql`UPDATE tg_event SET status = ${final ? finalStatus : "pending"}, error = ${code.slice(0, 200)},
153
+ claim_until = ${notBefore}, claim_token = NULL, processed_at = ${final ? at : null} ${fenced(ev.id)}`);
154
+ return final;
155
+ };
156
+ for (const [i, ev] of claimed.entries()) {
157
+ if (rt.clock.now() - started > budget) {
158
+ const rest = claimed.slice(i).map((e) => e.id);
159
+ await rt.db.run(sql`UPDATE tg_event SET status = 'pending', claim_until = NULL, claim_token = NULL, attempts = attempts - 1
160
+ WHERE tenant = ${rt.tenant} AND claim_token = ${token} AND id IN (${sql.join(rest)})`);
161
+ result.retried += rest.length;
162
+ break;
163
+ }
164
+ let outcome;
165
+ try {
166
+ outcome = await processOne(rt, ev, rules.get(ev.event) ?? [], templates);
167
+ } catch (e) {
168
+ const code = isTelegramPluginError(e) ? e.code : e instanceof Error ? e.name : "error";
169
+ rt.log.error("tg.event_failed", {
170
+ event: ev.event,
171
+ attempts: ev.attempts,
172
+ code
173
+ });
174
+ if (await backOff(ev, "failed", code)) result.failed++;
175
+ else result.retried++;
176
+ continue;
177
+ }
178
+ result.deliveries += outcome.deliveries;
179
+ for (const b of outcome.botIds) botIds.add(b);
180
+ if (outcome.retry && ev.attempts < 5) {
181
+ rt.log.warn("tg.event_retry", {
182
+ event: ev.event,
183
+ attempts: ev.attempts,
184
+ code: outcome.error ?? "error"
185
+ });
186
+ await backOff(ev, "done", outcome.error ?? "provider_error");
187
+ result.retried++;
188
+ continue;
189
+ }
190
+ if (outcome.retry) rt.log.warn("tg.event_retry_exhausted", {
191
+ event: ev.event,
192
+ attempts: ev.attempts
193
+ });
194
+ await rt.db.run(sql`UPDATE tg_event SET status = ${outcome.status}, error = ${outcome.error}, processed_at = ${rt.clock.now()},
195
+ claim_until = NULL, claim_token = NULL ${fenced(ev.id)}`);
196
+ if (outcome.status === "done") result.done++;
197
+ else result.ignored++;
198
+ }
199
+ if (botIds.size > 0 && rt.transport.name !== "outbox") await rt.transport.kick([...botIds]);
200
+ rt.log.info("tg.events", { ...result });
201
+ return result;
202
+ }
203
+ /** The tick stage (`tickStages()`): `runEvents` without the summary. */
204
+ async function processEvents(rt, now) {
205
+ if (!rt.features.events) return;
206
+ await runEvents(rt, now);
207
+ }
208
+ export { EVENT_CLAIM_MS, claimableEvents, processEvents, runEvents };
@@ -0,0 +1,12 @@
1
+ export interface Translator {
2
+ /** HTML-safe text (variables escaped). */
3
+ t(locale: string, key: string, vars?: Record<string, unknown>): string;
4
+ /** Raw text (variables not escaped), for menus and plain-text fields. */
5
+ plain(locale: string, key: string, vars?: Record<string, unknown>): string;
6
+ /** First supported locale out of the candidates (exact, then language part), else the default. */
7
+ pick(...candidates: (string | null | undefined)[]): string;
8
+ /** The supported locale for a tag (exact, then language part), or null. */
9
+ match(candidate: string | null | undefined): string | null;
10
+ readonly locales: readonly string[];
11
+ readonly defaultLocale: string;
12
+ }
@@ -0,0 +1,55 @@
1
+ import { escapeHtml } from "../core/html/escape.js";
2
+ import en_default from "../locales/en.js";
3
+ import mr_default from "../locales/mr.js";
4
+ // src/engine/i18n.ts
5
+ const BUILTIN = {
6
+ en: en_default,
7
+ mr: mr_default
8
+ };
9
+ Object.keys(BUILTIN);
10
+ function lookup(tree, key) {
11
+ let node = tree;
12
+ for (const part of key.split(".")) {
13
+ if (typeof node !== "object" || node === null) return void 0;
14
+ node = node[part];
15
+ }
16
+ return typeof node === "string" ? node : void 0;
17
+ }
18
+ function interpolate(text, vars, escapeVars) {
19
+ if (!vars) return text;
20
+ return text.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (_, name) => {
21
+ const v = Object.hasOwn(vars, name) ? vars[name] : void 0;
22
+ const s = v === void 0 || v === null ? "" : String(v);
23
+ return escapeVars ? escapeHtml(s) : s;
24
+ });
25
+ }
26
+ function createTranslator(port) {
27
+ const locales = port.locales.length > 0 ? port.locales : ["en"];
28
+ const supported = new Set(locales);
29
+ const base = (locale, key, vars, escapeVars) => {
30
+ const host = port.t?.(locale, key, vars);
31
+ if (host !== void 0) return host;
32
+ return interpolate(lookup(BUILTIN[locale], key) ?? lookup(BUILTIN[locale.split("-")[0] ?? ""], key) ?? lookup(en_default, key) ?? key, vars, escapeVars);
33
+ };
34
+ const match = (c) => {
35
+ if (!c) return null;
36
+ if (supported.has(c)) return c;
37
+ const lang = c.split("-")[0]?.toLowerCase();
38
+ return lang && supported.has(lang) ? lang : null;
39
+ };
40
+ return {
41
+ locales,
42
+ defaultLocale: port.defaultLocale,
43
+ t: (locale, key, vars) => base(locale, key, vars, true),
44
+ plain: (locale, key, vars) => base(locale, key, vars, false),
45
+ match,
46
+ pick(...candidates) {
47
+ for (const c of candidates) {
48
+ const m = match(c);
49
+ if (m) return m;
50
+ }
51
+ return port.defaultLocale;
52
+ }
53
+ };
54
+ }
55
+ export { createTranslator };
@@ -0,0 +1,121 @@
1
+ import { settle } from "../../telegram/classify.js";
2
+ import { withToken } from "../bot-api.js";
3
+ // src/engine/inbound/files.ts
4
+ function inboundKind(message) {
5
+ if (message?.photo && message.photo.length > 0) return "photo";
6
+ if (message?.document) return "document";
7
+ return null;
8
+ }
9
+ function mimeAllowed(allow, mime) {
10
+ const m = mime.toLowerCase();
11
+ return allow.some((a) => {
12
+ const p = a.toLowerCase();
13
+ return p.endsWith("*") ? m.startsWith(p.slice(0, -1)) : m === p;
14
+ });
15
+ }
16
+ /** No path separators or control characters in stored names. */
17
+ function safeName(name) {
18
+ return Array.from(name, (ch) => ch === "/" || ch === "\\" || ch.charCodeAt(0) < 32 ? "_" : ch).join("").slice(0, 128);
19
+ }
20
+ function pick(message, maxBytes) {
21
+ if (message.photo && message.photo.length > 0) {
22
+ const fit = [...message.photo].sort((a, b) => (b.file_size ?? 0) - (a.file_size ?? 0)).find((p) => (p.file_size ?? 0) <= maxBytes);
23
+ if (!fit) return "too_large";
24
+ return {
25
+ kind: "photo",
26
+ fileId: fit.file_id,
27
+ name: `photo-${message.message_id}.jpg`,
28
+ mime: "image/jpeg",
29
+ size: fit.file_size ?? null
30
+ };
31
+ }
32
+ const d = message.document;
33
+ if (!d) return null;
34
+ return {
35
+ kind: "document",
36
+ fileId: d.file_id,
37
+ name: safeName(d.file_name ?? `file-${message.message_id}`),
38
+ mime: d.mime_type ?? "application/octet-stream",
39
+ size: d.file_size ?? null
40
+ };
41
+ }
42
+ /** Downloads the message's photo / document and stores it as `actor`. Never throws for user-caused problems. */
43
+ async function storeInbound(ctx, actor, tags) {
44
+ const { rt } = ctx;
45
+ const message = ctx.message;
46
+ if (!message) return {
47
+ ok: false,
48
+ reason: "unavailable"
49
+ };
50
+ const files = rt.files;
51
+ if (!files) return {
52
+ ok: false,
53
+ reason: "unavailable"
54
+ };
55
+ const max = rt.inbound.maxBytes;
56
+ const p = pick(message, max);
57
+ if (p === null) return {
58
+ ok: false,
59
+ reason: "unavailable"
60
+ };
61
+ if (p === "too_large" || p.size !== null && p.size > max) return {
62
+ ok: false,
63
+ reason: "too_large"
64
+ };
65
+ if (p.kind === "document" && !mimeAllowed(rt.inbound.mimeTypes, p.mime)) return {
66
+ ok: false,
67
+ reason: "type_not_allowed"
68
+ };
69
+ const bytes = await withToken(rt, ctx.bot, async (api) => {
70
+ const info = await settle(api.getFile({ file_id: p.fileId }));
71
+ if (info.outcome.kind !== "ok" || !info.value?.file_path) return null;
72
+ if ((info.value.file_size ?? 0) > max) return "too_large";
73
+ try {
74
+ const res = await api.downloadFile(info.value.file_path);
75
+ if (!res.ok) return null;
76
+ const buf = new Uint8Array(await res.arrayBuffer());
77
+ return buf.byteLength > max ? "too_large" : buf;
78
+ } catch {
79
+ return null;
80
+ }
81
+ });
82
+ if (bytes === "too_large") return {
83
+ ok: false,
84
+ reason: "too_large"
85
+ };
86
+ if (bytes === null) {
87
+ rt.log.warn("tg.inbound_download_failed", { botId: ctx.bot.id });
88
+ return {
89
+ ok: false,
90
+ reason: "download_failed"
91
+ };
92
+ }
93
+ const stored = await files.put({
94
+ name: p.name,
95
+ mime: p.mime,
96
+ body: bytes,
97
+ tags: {
98
+ source: "telegram",
99
+ kind: p.kind,
100
+ ...tags
101
+ }
102
+ }, actor);
103
+ rt.log.info("tg.inbound_stored", {
104
+ botId: ctx.bot.id,
105
+ kind: p.kind,
106
+ size: bytes.byteLength
107
+ });
108
+ return {
109
+ ok: true,
110
+ file: {
111
+ ...stored,
112
+ kind: p.kind,
113
+ caption: message.caption ?? null
114
+ }
115
+ };
116
+ }
117
+ /** The bot string for a failed store. */
118
+ function storeFailureKey(reason) {
119
+ return `bot.inbound.${reason}`;
120
+ }
121
+ export { inboundKind, storeFailureKey, storeInbound };
@@ -0,0 +1,46 @@
1
+ import { hostCan } from "../content/providers.js";
2
+ import { storeFailureKey, storeInbound } from "./files.js";
3
+ import { hostContext, senderMayAct } from "../dispatch/context.js";
4
+ // src/engine/inbound/handler.ts
5
+ async function inboundHandler(ctx) {
6
+ const { rt } = ctx;
7
+ if (ctx.cls !== "media" || !rt.features.inbound) return "next";
8
+ const actor = await ctx.actor();
9
+ if (actor === null) {
10
+ await ctx.reply(await ctx.t(ctx.tgChat?.type === "private" ? "bot.start.unlinked" : "bot.error.not_linked"));
11
+ return "handled";
12
+ }
13
+ if (!await senderMayAct(ctx)) {
14
+ await ctx.reply(await ctx.t("bot.error.sender_not_linked"));
15
+ return "handled";
16
+ }
17
+ if (!await hostCan(rt, actor, rt.inbound.permission)) {
18
+ await ctx.reply(await ctx.t("bot.error.forbidden"));
19
+ return "handled";
20
+ }
21
+ const onInbound = rt.hooks.onInbound;
22
+ if (!onInbound) {
23
+ await ctx.reply(await ctx.t("bot.inbound.not_handled"));
24
+ return "handled";
25
+ }
26
+ const stored = await storeInbound(ctx, actor, { source_kind: "inbound" });
27
+ if (!stored.ok) {
28
+ await ctx.reply(await ctx.t(storeFailureKey(stored.reason)));
29
+ return "handled";
30
+ }
31
+ const host = await hostContext(ctx, stored.file.caption ?? "");
32
+ let handled = true;
33
+ try {
34
+ handled = await onInbound(host, stored.file) !== false;
35
+ } catch (e) {
36
+ rt.log.warn("tg.hook_error", {
37
+ hook: "onInbound",
38
+ error: e instanceof Error ? e.name : "unknown"
39
+ });
40
+ await ctx.reply(await ctx.t("bot.error.generic"));
41
+ return "handled";
42
+ }
43
+ if (!handled) await ctx.reply(await ctx.t("bot.inbound.not_handled"));
44
+ return "handled";
45
+ }
46
+ export { inboundHandler };
@@ -0,0 +1,32 @@
1
+ /** What a plugin job's `run` receives from the host's cron dispatcher. */
2
+ export interface TelegramJobContext<E> {
3
+ /** The Worker env of the scheduled invocation. */
4
+ env: E;
5
+ /** Scheduled time (ms or Date); default now. */
6
+ now?: number | Date;
7
+ /** `ctx.waitUntil` of the scheduled event, for hook and kick work. */
8
+ waitUntil?: (p: Promise<unknown>) => void;
9
+ /** The cron expression that fired (informational). */
10
+ cron?: string;
11
+ }
12
+ /** Outcome of one job run (for the host's job log). */
13
+ export interface TelegramJobResult {
14
+ /** `ok` = did work, `noop` = nothing to do, `skipped` = not run (for example `schema_outdated`). */
15
+ status: "ok" | "noop" | "skipped";
16
+ /** Short summary without personal data (`sent 3`, `deleted 12…`). */
17
+ detail?: string;
18
+ }
19
+ /**
20
+ * A plugin cron job (`telegram:tick`, `telegram:cleanup`, `telegram:rekey`), structural so it fits a host `Job`
21
+ * registry. Every job checks the schema version first and skips when it is outdated.
22
+ */
23
+ export interface TelegramJob<E> {
24
+ /** Job name (`telegram:tick`, …). */
25
+ readonly name: string;
26
+ /** Cron expression (`options.crons`); the Worker's `triggers.crons` must include it. */
27
+ readonly cron: string;
28
+ /** One-line description for admin job lists. */
29
+ readonly description: string;
30
+ /** Runs the job: D1 reads/writes and, for the tick, Telegram sends. */
31
+ run(ctx: TelegramJobContext<E>): Promise<TelegramJobResult>;
32
+ }
@@ -0,0 +1,85 @@
1
+ import { STATE_KEYS, ensureState, setState } from "./state.js";
2
+ import { sweepStuckUpdates } from "./dispatch/dispatcher.js";
3
+ import { processEvents } from "./events/tick.js";
4
+ import { drainBots } from "./outbox/transport.js";
5
+ import { cleanup } from "../services/cleanup.js";
6
+ import { rekeyBots } from "../services/rekey.js";
7
+ import { resumeStuckRuns } from "./schedules/resume.js";
8
+ import { fireDueSchedules } from "./schedules/tick.js";
9
+ import { armScheduleWake } from "./schedules/wake.js";
10
+ // src/engine/jobs.ts
11
+ /**
12
+ * Schedules stage: fire what is due (under the DO transport re-arm the scheduler bot after a fire), then resume
13
+ * runs cut off mid fan-out (`schedules/resume.ts`).
14
+ */
15
+ async function scheduleStage(rt, now) {
16
+ if (await fireDueSchedules(rt, now) > 0) await armScheduleWake(rt);
17
+ await resumeStuckRuns(rt, now);
18
+ }
19
+ function tickStages() {
20
+ return [scheduleStage, processEvents];
21
+ }
22
+ const DEFAULT_CRONS = {
23
+ tick: "* * * * *",
24
+ cleanup: "15 3 * * *",
25
+ rekey: "45 3 * * *"
26
+ };
27
+ async function runTick(rt) {
28
+ const started = rt.clock.now();
29
+ const schema = await ensureState(rt);
30
+ if (!schema.ok) {
31
+ rt.log.error("tg.schema_outdated", {
32
+ required: schema.required,
33
+ current: schema.current
34
+ });
35
+ return {
36
+ status: "skipped",
37
+ detail: "schema_outdated"
38
+ };
39
+ }
40
+ await setState(rt, STATE_KEYS.lastTickAt, String(started), started);
41
+ const swept = await sweepStuckUpdates(rt, started);
42
+ for (const stage of tickStages()) try {
43
+ await stage(rt, rt.clock.now());
44
+ } catch (e) {
45
+ rt.log.error("tg.tick_stage_failed", { error: e instanceof Error ? e.name : "error" });
46
+ }
47
+ let sent = 0;
48
+ if (rt.transport.name === "queue" || rt.transport.name === "do") await rt.transport.kick();
49
+ else {
50
+ const left = rt.limits.tickBudgetMs - (rt.clock.now() - started);
51
+ for (const r of await drainBots(rt, void 0, left)) sent += r.sent;
52
+ }
53
+ rt.log.info("tg.tick", {
54
+ swept,
55
+ sent,
56
+ ms: rt.clock.now() - started
57
+ });
58
+ return {
59
+ status: sent > 0 || swept > 0 ? "ok" : "noop",
60
+ detail: `sent ${sent}`
61
+ };
62
+ }
63
+ async function runCleanup(rt) {
64
+ if (!(await ensureState(rt)).ok) return {
65
+ status: "skipped",
66
+ detail: "schema_outdated"
67
+ };
68
+ const r = await cleanup(rt);
69
+ return {
70
+ status: "ok",
71
+ detail: `deleted ${Object.values(r.deleted).reduce((a, b) => a + b, 0)}, bodies ${r.bodiesCleared}, bots ${r.botsChecked}`
72
+ };
73
+ }
74
+ async function runRekey(rt) {
75
+ if (!(await ensureState(rt)).ok) return {
76
+ status: "skipped",
77
+ detail: "schema_outdated"
78
+ };
79
+ const r = await rekeyBots(rt);
80
+ return {
81
+ status: r.rekeyed > 0 ? "ok" : "noop",
82
+ detail: `rekeyed ${r.rekeyed}, remaining ${r.remaining}`
83
+ };
84
+ }
85
+ export { DEFAULT_CRONS, runCleanup, runRekey, runTick, tickStages };
@@ -0,0 +1,46 @@
1
+ import { randomCode, sha256Hex } from "../core/random.js";
2
+ import { z } from "zod";
3
+ // src/engine/link-codes.ts
4
+ function newLinkCode(rand) {
5
+ return randomCode(rand, 32);
6
+ }
7
+ function hashLinkCode(code) {
8
+ return sha256Hex(crypto.subtle, `tg-link:${code}`);
9
+ }
10
+ /** `tg_state` key prefix of pending invites. */
11
+ const PENDING_LINK_PREFIX = "link_pending:";
12
+ /** How long a redeemed invite waits for the target user's confirmation. */
13
+ const PENDING_LINK_TTL_MS = 864e5;
14
+ /** `tg_state` key of one pending invite. */
15
+ function pendingLinkKey(id) {
16
+ return `${PENDING_LINK_PREFIX}${id}`;
17
+ }
18
+ /** Stored value of a pending invite (`tg_state.value`, JSON). */
19
+ const PendingLinkState = z.object({
20
+ id: z.string(),
21
+ botId: z.string(),
22
+ /** Telegram chat id of the private chat that redeemed the code. */
23
+ chatId: z.string(),
24
+ /** `tg_chat.id` to use (the existing row's id, or a fresh one). */
25
+ chatRef: z.string(),
26
+ userId: z.string(),
27
+ createdBy: z.string().nullable(),
28
+ tgName: z.string().nullable(),
29
+ tgUsername: z.string().nullable(),
30
+ locale: z.string().nullable(),
31
+ createdAt: z.number(),
32
+ expiresAt: z.number()
33
+ });
34
+ /** API view of a pending invite (`GET /me/links/pending`, `hooks.onPendingLink`). */
35
+ function pendingLinkDto(p) {
36
+ return {
37
+ id: p.id,
38
+ bot: p.botId,
39
+ telegramName: p.tgName,
40
+ telegramUsername: p.tgUsername,
41
+ invitedBy: p.createdBy,
42
+ createdAt: p.createdAt,
43
+ expiresAt: p.expiresAt
44
+ };
45
+ }
46
+ export { PENDING_LINK_PREFIX, PENDING_LINK_TTL_MS, PendingLinkState, hashLinkCode, newLinkCode, pendingLinkDto, pendingLinkKey };
@@ -0,0 +1,12 @@
1
+ // src/engine/metrics.ts
2
+ function metricCount(rt, name, n = 1, tags = {}) {
3
+ try {
4
+ rt.metrics?.count(name, n, tags);
5
+ } catch {}
6
+ }
7
+ function metricTiming(rt, name, ms, tags = {}) {
8
+ try {
9
+ rt.metrics?.timing(name, ms, tags);
10
+ } catch {}
11
+ }
12
+ export { metricCount, metricTiming };