@prajwalshete/telegram-plugin 0.0.0-stage → 0.1.1

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 +91 -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,20 @@
1
+ import { sql } from "../ports/sql.js";
2
+ import { TelegramPluginError, featureDisabled } from "../engine/errors.js";
3
+ // src/services/events.ts
4
+ function emitEvent(ctx, event, payload, opts = {}) {
5
+ if (!ctx.enabled) throw featureDisabled("events");
6
+ const def = ctx.events.get(event);
7
+ if (!def) throw new TelegramPluginError("validation_failed", `event "${event}" is not registered`);
8
+ const parsed = def.payload.safeParse(payload);
9
+ if (!parsed.success) throw new TelegramPluginError("validation_failed", `payload of "${event}" is not valid`, { extensions: { issues: parsed.error.issues.map((i) => ({
10
+ path: i.path.join("."),
11
+ message: i.message
12
+ })) } });
13
+ const id = ctx.ids.uuid();
14
+ const dedupe = opts.dedupeKey ?? `evt:${id}`;
15
+ if (dedupe.length > 200) throw new TelegramPluginError("validation_failed", "dedupeKey is too long (≤ 200)");
16
+ return [sql`INSERT INTO tg_event (id, tenant, event, payload_json, dedupe_key, occurred_at, status)
17
+ VALUES (${id}, ${ctx.tenant}, ${event}, ${JSON.stringify(parsed.data)}, ${dedupe}, ${ctx.now()}, 'pending')
18
+ ON CONFLICT (tenant, dedupe_key) DO NOTHING`];
19
+ }
20
+ export { emitEvent };
@@ -0,0 +1,64 @@
1
+ import { sha256Hex } from "../core/random.js";
2
+ import { sql } from "../ports/sql.js";
3
+ import { TelegramPluginError } from "../engine/errors.js";
4
+ // src/services/idempotency.ts
5
+ const IDEMPOTENCY_PREFIX = "idem:";
6
+ const IDEMPOTENCY_TTL_MS = 864e5;
7
+ /** Lease of an in-flight (`pending`) entry; a request whose lease ran out (not renewed) can be taken over. */
8
+ const IDEMPOTENCY_PENDING_TTL_MS = 6e4;
9
+ /** How often a running request renews its lease (well inside the TTL). */
10
+ const IDEMPOTENCY_RENEW_MS = 2e4;
11
+ /** Reserves the key. Throws `validation_failed` (409) for a body mismatch or a request still in flight. */
12
+ async function reserveIdempotency(rt, input) {
13
+ if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(input.key)) throw new TelegramPluginError("validation_failed", "Idempotency-Key must be 1–128 chars [A-Za-z0-9_.:-]");
14
+ const now = rt.clock.now();
15
+ const stateKey = IDEMPOTENCY_PREFIX + await sha256Hex(crypto.subtle, `${input.actor}|${input.method}|${input.path}|${input.key}`);
16
+ const hash = await sha256Hex(crypto.subtle, input.body);
17
+ const lease = crypto.randomUUID();
18
+ const pending = {
19
+ hash,
20
+ pending: true,
21
+ lease
22
+ };
23
+ const inserted = await rt.db.first(sql`INSERT INTO tg_state (tenant, key, value, updated_at) VALUES (${rt.tenant}, ${stateKey}, ${JSON.stringify(pending)}, ${now})
24
+ ON CONFLICT (tenant, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
25
+ WHERE tg_state.updated_at < ${now - IDEMPOTENCY_TTL_MS}
26
+ OR (json_extract(tg_state.value, '$.pending') = 1
27
+ AND tg_state.updated_at < ${now - IDEMPOTENCY_PENDING_TTL_MS})
28
+ RETURNING key`);
29
+ const mine = sql`tenant = ${rt.tenant} AND key = ${stateKey} AND json_extract(value, '$.lease') = ${lease}`;
30
+ const owned = {
31
+ replay: null,
32
+ async save(response) {
33
+ const value = JSON.stringify({
34
+ hash,
35
+ response
36
+ });
37
+ await rt.db.run(sql`UPDATE tg_state SET value = ${value}, updated_at = ${rt.clock.now()} WHERE ${mine}`);
38
+ },
39
+ async release() {
40
+ await rt.db.run(sql`DELETE FROM tg_state WHERE ${mine}`);
41
+ },
42
+ async renew() {
43
+ return await rt.db.first(sql`UPDATE tg_state SET updated_at = ${rt.clock.now()} WHERE ${mine} RETURNING key`) !== null;
44
+ }
45
+ };
46
+ if (inserted) return owned;
47
+ const row = await rt.db.first(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${stateKey}`);
48
+ let entry = null;
49
+ try {
50
+ entry = row ? JSON.parse(row.value) : null;
51
+ } catch {
52
+ entry = null;
53
+ }
54
+ if (!entry) return owned;
55
+ if (entry.hash !== hash) throw new TelegramPluginError("validation_failed", "Idempotency-Key was used with a different request", { status: 422 });
56
+ if (entry.pending || !entry.response) throw new TelegramPluginError("validation_failed", "a request with this Idempotency-Key is still in progress", { status: 409 });
57
+ return {
58
+ replay: entry.response,
59
+ save: async () => {},
60
+ release: async () => {},
61
+ renew: async () => false
62
+ };
63
+ }
64
+ export { IDEMPOTENCY_PENDING_TTL_MS, IDEMPOTENCY_PREFIX, IDEMPOTENCY_RENEW_MS, IDEMPOTENCY_TTL_MS, reserveIdempotency };
@@ -0,0 +1,200 @@
1
+ import { sql } from "../ports/sql.js";
2
+ import { TelegramPluginError } from "../engine/errors.js";
3
+ import { isBotUsable, loadBot } from "../engine/bot-api.js";
4
+ import { chatDto } from "../engine/rows.js";
5
+ import { deliveryFragments } from "../engine/outbox/enqueue.js";
6
+ import { actorId } from "./bots.js";
7
+ import { PENDING_LINK_PREFIX, PendingLinkState, hashLinkCode, newLinkCode, pendingLinkDto, pendingLinkKey } from "../engine/link-codes.js";
8
+ import { releaseOtherChatsSql, upsertChatSql } from "../engine/dispatch/link-chat.js";
9
+ // src/services/links.ts
10
+ async function create(rt, input) {
11
+ const bot = await loadBot(rt, input.bot);
12
+ if (!bot || !isBotUsable(bot)) throw new TelegramPluginError("not_found", "bot not found or not active");
13
+ const now = rt.clock.now();
14
+ const code = newLinkCode(rt.rand);
15
+ const expiresAt = now + rt.limits.linkCodeTtlMs;
16
+ await rt.db.batch([sql`INSERT INTO tg_link_code (code_hash, tenant, bot_id, purpose, user_id, audience_json, expires_at, created_by, created_at)
17
+ VALUES (${await hashLinkCode(code)}, ${rt.tenant}, ${bot.id}, ${input.purpose}, ${input.userId},
18
+ ${input.audience === null ? null : JSON.stringify(input.audience)}, ${expiresAt}, ${input.createdBy}, ${now})`, ...rt.audit({
19
+ actor: input.createdBy,
20
+ entity: "telegram_link",
21
+ entityId: bot.id,
22
+ action: `create_${input.purpose}`,
23
+ diff: input.userId ? { userId: input.userId } : {},
24
+ at: now
25
+ })]);
26
+ const param = input.purpose === "group" ? "startgroup" : "start";
27
+ const url = `https://t.me/${bot.username}?${param}=${code}`;
28
+ return {
29
+ url,
30
+ qrPayload: url,
31
+ expiresAt,
32
+ requiresConfirmation: input.purpose === "user" && input.createdBy !== input.userId
33
+ };
34
+ }
35
+ /**
36
+ * Admin: invite a user, or make a `startgroup` link for a group with an audience. An invite for another user than
37
+ * the caller links only after that user confirms it in the app (`confirmPendingLink`).
38
+ */
39
+ async function createLink(rt, actor, input) {
40
+ const by = actorId(rt, actor);
41
+ if (input.purpose === "user") {
42
+ if (rt.users) {
43
+ if (!await rt.users.find(input.userId)) throw new TelegramPluginError("not_found", "user not found");
44
+ }
45
+ return create(rt, {
46
+ bot: input.bot,
47
+ purpose: "user",
48
+ userId: input.userId,
49
+ audience: null,
50
+ createdBy: by
51
+ });
52
+ }
53
+ if (!by) throw new TelegramPluginError("forbidden", "group links need a signed-in user (they confirm the link in the group)");
54
+ return create(rt, {
55
+ bot: input.bot,
56
+ purpose: "group",
57
+ userId: null,
58
+ audience: input.audience,
59
+ createdBy: by
60
+ });
61
+ }
62
+ /** Any user links their own Telegram. */
63
+ async function createSelfLink(rt, actor, botId) {
64
+ const by = actorId(rt, actor);
65
+ if (!by) throw new TelegramPluginError("forbidden", "only a signed-in user can link their Telegram");
66
+ return create(rt, {
67
+ bot: botId,
68
+ purpose: "user",
69
+ userId: by,
70
+ audience: null,
71
+ createdBy: by
72
+ });
73
+ }
74
+ function signedIn(rt, actor) {
75
+ const by = actorId(rt, actor);
76
+ if (!by) throw new TelegramPluginError("forbidden", "only a signed-in user can confirm a Telegram link");
77
+ return by;
78
+ }
79
+ function parsePending(value) {
80
+ try {
81
+ const r = PendingLinkState.safeParse(JSON.parse(value));
82
+ return r.success ? r.data : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** Expired pending invites are removed lazily (they hold no chat yet). */
88
+ async function purgeExpired(rt, now) {
89
+ await rt.db.run(sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key LIKE ${`${PENDING_LINK_PREFIX}%`}
90
+ AND json_extract(value, '$.expiresAt') <= ${now}`);
91
+ }
92
+ /** Pending invites waiting for this user's confirmation (`GET /me/links/pending`). */
93
+ async function listPendingLinks(rt, actor) {
94
+ const userId = signedIn(rt, actor);
95
+ const now = rt.clock.now();
96
+ await purgeExpired(rt, now);
97
+ return (await rt.db.all(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key LIKE ${`${PENDING_LINK_PREFIX}%`}
98
+ AND json_extract(value, '$.userId') = ${userId} ORDER BY updated_at DESC LIMIT 50`)).flatMap((r) => {
99
+ const p = parsePending(r.value);
100
+ return p && p.userId === userId && p.expiresAt > now ? [pendingLinkDto(p)] : [];
101
+ });
102
+ }
103
+ /** The caller's own pending invite: not_found when missing, link_expired when expired, forbidden for others. */
104
+ async function ownPending(rt, userId, id, now) {
105
+ const row = await rt.db.first(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`);
106
+ const p = row ? parsePending(row.value) : null;
107
+ if (!p) throw new TelegramPluginError("not_found", "pending link not found");
108
+ if (p.userId !== userId) throw new TelegramPluginError("forbidden", "this link is for another user");
109
+ if (p.expiresAt <= now) {
110
+ await rt.db.run(sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`);
111
+ throw new TelegramPluginError("link_expired", "this link has expired; ask for a new invite");
112
+ }
113
+ return p;
114
+ }
115
+ /**
116
+ * The invited user confirms that the Telegram account is theirs (`POST /me/links/pending/:id/confirm`): the chat
117
+ * is linked to them (same rules as `/start <code>`), a "linked" message is queued, `onLinked` runs.
118
+ */
119
+ async function confirmPendingLink(rt, actor, id) {
120
+ const userId = signedIn(rt, actor);
121
+ const now = rt.clock.now();
122
+ const p = await ownPending(rt, userId, id, now);
123
+ const bot = await loadBot(rt, p.botId);
124
+ if (!bot || !isBotUsable(bot)) throw new TelegramPluginError("not_found", "bot not found or not active");
125
+ const existing = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${bot.id} AND chat_id = ${p.chatId}`);
126
+ if (existing?.status === "active" && existing.user_id !== null && existing.user_id !== userId) throw new TelegramPluginError("forbidden", "this Telegram account is already linked to someone else");
127
+ const chatRef = existing?.id ?? p.chatRef;
128
+ const info = rt.describe(actor);
129
+ const locale = existing?.locale ?? p.locale ?? bot.default_locale;
130
+ const linkedText = rt.i18n.t(locale, "bot.start.linked", {
131
+ name: info.name ?? "",
132
+ role: info.role ?? ""
133
+ });
134
+ await rt.db.batch([
135
+ sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`,
136
+ ...await releaseOtherChatsSql(rt, bot.id, userId, chatRef, now),
137
+ upsertChatSql(rt, chatRef, {
138
+ botId: bot.id,
139
+ chatId: p.chatId,
140
+ kind: "private",
141
+ title: p.tgName,
142
+ username: p.tgUsername,
143
+ userId,
144
+ audienceJson: null,
145
+ locale: p.locale,
146
+ linkedBy: userId
147
+ }, now),
148
+ ...rt.audit({
149
+ actor: userId,
150
+ entity: "telegram_chat",
151
+ entityId: chatRef,
152
+ action: "link",
153
+ diff: {
154
+ kind: "private",
155
+ userId,
156
+ botId: bot.id,
157
+ invitedBy: p.createdBy,
158
+ confirmed: true
159
+ },
160
+ at: now
161
+ }),
162
+ ...deliveryFragments(rt, {
163
+ botId: bot.id,
164
+ chatRef,
165
+ source: "system",
166
+ kind: "text",
167
+ html: `${linkedText}\n\n${rt.i18n.t(locale, "bot.link.confirmed_hint")}`,
168
+ dedupeKey: `link:${id}`
169
+ }, now)
170
+ ]);
171
+ const chat = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id = ${chatRef}`);
172
+ if (!chat) throw new TelegramPluginError("internal_error", "linked chat not found");
173
+ rt.log.info("tg.linked", {
174
+ botId: bot.id,
175
+ chatRef,
176
+ kind: "private"
177
+ });
178
+ await rt.transport.kick([bot.id]);
179
+ const dto = chatDto(chat);
180
+ await rt.hook("onLinked", (fn) => fn(dto, actor));
181
+ return dto;
182
+ }
183
+ /** The invited user declines (`POST /me/links/pending/:id/decline`): the pending invite is removed, nothing links. */
184
+ async function declinePendingLink(rt, actor, id) {
185
+ const userId = signedIn(rt, actor);
186
+ const now = rt.clock.now();
187
+ const p = await ownPending(rt, userId, id, now);
188
+ await rt.db.batch([sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`, ...rt.audit({
189
+ actor: userId,
190
+ entity: "telegram_link",
191
+ entityId: id,
192
+ action: "decline",
193
+ diff: {
194
+ botId: p.botId,
195
+ invitedBy: p.createdBy
196
+ },
197
+ at: now
198
+ })]);
199
+ }
200
+ export { confirmPendingLink, createLink, createSelfLink, declinePendingLink, listPendingLinks };
@@ -0,0 +1,95 @@
1
+ import { sha256Hex } from "../core/random.js";
2
+ import { sql } from "../ports/sql.js";
3
+ import { settle } from "../telegram/classify.js";
4
+ import { withToken } from "../engine/bot-api.js";
5
+ import { botRuntime } from "../engine/bot-features.js";
6
+ import { commandRows, describe, effectiveCommands } from "./commands.js";
7
+ import { builtinMenu } from "../engine/dispatch/builtins.js";
8
+ // src/services/menu.ts
9
+ /**
10
+ * Commands shown for a chat kind in a locale: built-ins, `/report` (features.reports), then host commands with
11
+ * this bot's `tg_command` config (enabled, show_in_menu, sort, description). Menus are cosmetic: every call is
12
+ * still checked. Telegram allows 100 commands per scope.
13
+ */
14
+ function menuCommands(rt, _bot, kind, locale, rows = []) {
15
+ const out = builtinMenu(rt, kind).map((command) => ({
16
+ command,
17
+ description: rt.i18n.plain(locale, `bot.cmd.${command}`).slice(0, 256)
18
+ }));
19
+ if (!rt.features.commands) return out;
20
+ if (rt.features.reports) out.push({
21
+ command: "report",
22
+ description: rt.i18n.plain(locale, "bot.cmd.report")
23
+ });
24
+ for (const c of effectiveCommands(rt, rows)) {
25
+ if (!c.enabled || !c.showInMenu) continue;
26
+ if (!c.def.scope.includes(kind === "private" ? "private" : "group")) continue;
27
+ const description = describe(c.description, locale).trim().slice(0, 256);
28
+ out.push({
29
+ command: c.def.command,
30
+ description: description === "" ? c.def.command : description
31
+ });
32
+ }
33
+ return out.slice(0, 100);
34
+ }
35
+ function buildMenu(base, bot, rows = []) {
36
+ const rt = botRuntime(base, bot);
37
+ const fallback = rt.i18n.pick(bot.default_locale);
38
+ const scopes = [
39
+ {
40
+ scope: { type: "default" },
41
+ kind: "private"
42
+ },
43
+ {
44
+ scope: { type: "all_private_chats" },
45
+ kind: "private"
46
+ },
47
+ {
48
+ scope: { type: "all_group_chats" },
49
+ kind: "group"
50
+ }
51
+ ];
52
+ const out = [];
53
+ for (const { scope, kind } of scopes) {
54
+ out.push({
55
+ scope,
56
+ commands: menuCommands(rt, bot, kind, fallback, rows)
57
+ });
58
+ for (const language of rt.i18n.locales) out.push({
59
+ scope,
60
+ language,
61
+ commands: menuCommands(rt, bot, kind, language, rows)
62
+ });
63
+ }
64
+ return out;
65
+ }
66
+ /** Sends the menu when its hash changed (or `force`). Failures are logged, never thrown. */
67
+ async function syncCommandMenu(rt, bot, opts = {}) {
68
+ const menu = buildMenu(rt, bot, botRuntime(rt, bot).features.commands ? await commandRows(rt, bot.id) : []);
69
+ const hash = await sha256Hex(crypto.subtle, JSON.stringify(menu));
70
+ if (!opts.force && bot.commands_synced_hash === hash) return {
71
+ synced: false,
72
+ calls: 0,
73
+ failed: 0
74
+ };
75
+ let failed = 0;
76
+ await withToken(rt, bot, async (api) => {
77
+ for (const entry of menu) if ((await settle(api.setMyCommands({
78
+ commands: entry.commands,
79
+ scope: entry.scope,
80
+ ...entry.language ? { language_code: entry.language } : {}
81
+ }))).outcome.kind !== "ok") failed++;
82
+ });
83
+ if (failed === 0) await rt.db.run(sql`UPDATE tg_bot SET commands_synced_hash = ${hash}, updated_at = ${rt.clock.now()}
84
+ WHERE tenant = ${rt.tenant} AND id = ${bot.id}`);
85
+ else rt.log.warn("tg.menu_sync_failed", {
86
+ botId: bot.id,
87
+ failed
88
+ });
89
+ return {
90
+ synced: failed === 0,
91
+ calls: menu.length,
92
+ failed
93
+ };
94
+ }
95
+ export { buildMenu, menuCommands, syncCommandMenu };
@@ -0,0 +1,90 @@
1
+ import { BotStatus } from "../contracts/bots.js";
2
+ import { sql } from "../ports/sql.js";
3
+ import { STATE_KEYS, ensureState, getState } from "../engine/state.js";
4
+ import { rowsByKid } from "./rekey.js";
5
+ import { STUCK_RUN_MS } from "../engine/schedules/resume.js";
6
+ // src/services/ops.ts
7
+ const TICK_STALE_MS = 3e5;
8
+ async function health(rt) {
9
+ const now = rt.clock.now();
10
+ const lastTick = await getState(rt, STATE_KEYS.lastTickAt);
11
+ const lastTickAt = lastTick === null ? null : Number(lastTick);
12
+ const q = await rt.db.first(sql`SELECT COUNT(*) AS queued, MIN(created_at) AS oldest FROM tg_delivery
13
+ WHERE tenant = ${rt.tenant} AND status IN ('queued', 'sending')`);
14
+ const f = await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_delivery WHERE tenant = ${rt.tenant} AND status = 'failed'
15
+ AND updated_at > ${now - 864e5}`);
16
+ const runs = await rt.db.first(sql`SELECT SUM(CASE WHEN status = 'running' AND started_at < ${now - STUCK_RUN_MS} THEN 1 ELSE 0 END) AS stuck,
17
+ SUM(CASE WHEN status = 'failed' AND error = 'run_stuck' AND finished_at > ${now - 864e5}
18
+ THEN 1 ELSE 0 END) AS failed
19
+ FROM tg_schedule_run WHERE tenant = ${rt.tenant} AND status IN ('running', 'failed')`);
20
+ const bots = await rt.db.all(sql`SELECT id, username, status FROM tg_bot WHERE tenant = ${rt.tenant} AND deleted_at IS NULL ORDER BY id`);
21
+ const botItems = [];
22
+ for (const b of bots) {
23
+ const raw = await getState(rt, STATE_KEYS.botHealth(b.id));
24
+ let pending = null;
25
+ if (raw) try {
26
+ pending = JSON.parse(raw).pendingUpdateCount ?? null;
27
+ } catch {
28
+ pending = null;
29
+ }
30
+ botItems.push({
31
+ id: b.id,
32
+ username: b.username,
33
+ status: BotStatus.safeParse(b.status).data ?? "active",
34
+ pendingUpdateCount: pending
35
+ });
36
+ }
37
+ const schema = await ensureState(rt);
38
+ const tickStale = lastTickAt === null || now - lastTickAt > TICK_STALE_MS;
39
+ return {
40
+ ok: schema.ok && !tickStale && botItems.every((b) => b.status === "active" || b.status === "disabled"),
41
+ lastTickAt,
42
+ tickStale,
43
+ deliveries: {
44
+ queued: q?.queued ?? 0,
45
+ failed24h: f?.n ?? 0,
46
+ oldestQueuedAgeMs: q?.oldest === null || q?.oldest === void 0 ? null : Math.max(0, now - q.oldest)
47
+ },
48
+ schedules: {
49
+ stuckRuns: runs?.stuck ?? 0,
50
+ runStuckFailed24h: runs?.failed ?? 0
51
+ },
52
+ bots: botItems,
53
+ keyring: {
54
+ activeKid: rt.keyring.activeKid(),
55
+ rowsByKid: await rowsByKid(rt)
56
+ },
57
+ schema: {
58
+ required: schema.required,
59
+ current: schema.current,
60
+ ok: schema.ok
61
+ }
62
+ };
63
+ }
64
+ /** Counts in [from, to). `rateLimited` counts deliveries whose last error was a 429. */
65
+ async function stats(rt, from, to) {
66
+ const rows = await rt.db.all(sql`SELECT status, source, error_code, COUNT(*) AS n FROM tg_delivery
67
+ WHERE tenant = ${rt.tenant} AND created_at >= ${from} AND created_at < ${to}
68
+ GROUP BY status, source, error_code`);
69
+ const out = {
70
+ from,
71
+ to,
72
+ sent: 0,
73
+ failed: 0,
74
+ dropped: 0,
75
+ rateLimited: 0,
76
+ bySource: {},
77
+ updates: 0
78
+ };
79
+ for (const r of rows) {
80
+ if (r.status === "sent") {
81
+ out.sent += r.n;
82
+ out.bySource[r.source] = (out.bySource[r.source] ?? 0) + r.n;
83
+ } else if (r.status === "failed") out.failed += r.n;
84
+ else if (r.status === "dropped") out.dropped += r.n;
85
+ if (r.error_code === "retry_after") out.rateLimited += r.n;
86
+ }
87
+ out.updates = (await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_update WHERE tenant = ${rt.tenant} AND received_at >= ${from} AND received_at < ${to}`))?.n ?? 0;
88
+ return out;
89
+ }
90
+ export { health, stats };
@@ -0,0 +1,63 @@
1
+ import { sql } from "../ports/sql.js";
2
+ import { tokenAad, webhookAad } from "../engine/bot-api.js";
3
+ import { STATE_KEYS, setState } from "../engine/state.js";
4
+ // src/services/rekey.ts
5
+ const BATCH = 20;
6
+ async function rekeyBots(rt, opts = {}) {
7
+ const started = rt.clock.now();
8
+ const budget = opts.budgetMs ?? rt.limits.tickBudgetMs;
9
+ const kid = rt.keyring.activeKid();
10
+ const prefix = `v1.${kid}.`;
11
+ let rekeyed = 0;
12
+ let failed = 0;
13
+ const skip = /* @__PURE__ */ new Set();
14
+ for (;;) {
15
+ if (rt.clock.now() - started > budget) break;
16
+ const todo = (await rt.db.all(sql`SELECT id, token_enc, webhook_secret_enc FROM tg_bot WHERE tenant = ${rt.tenant}
17
+ AND (substr(token_enc, 1, ${prefix.length}) != ${prefix}
18
+ OR (webhook_secret_enc IS NOT NULL AND substr(webhook_secret_enc, 1, ${prefix.length}) != ${prefix}))
19
+ ORDER BY id LIMIT ${BATCH + skip.size}`)).filter((r) => !skip.has(r.id)).slice(0, BATCH);
20
+ if (todo.length === 0) break;
21
+ for (const r of todo) try {
22
+ const token = await rt.keyring.decrypt(r.token_enc, tokenAad(r.id));
23
+ const secret = r.webhook_secret_enc ? await rt.keyring.decrypt(r.webhook_secret_enc, webhookAad(r.id)) : null;
24
+ const tokenEnc = await rt.keyring.encrypt(token, tokenAad(r.id));
25
+ const secretEnc = secret === null ? null : await rt.keyring.encrypt(secret, webhookAad(r.id));
26
+ if ((await rt.db.run(sql`UPDATE tg_bot SET token_enc = ${tokenEnc}, webhook_secret_enc = ${secretEnc}
27
+ WHERE id = ${r.id} AND token_enc = ${r.token_enc}
28
+ AND webhook_secret_enc IS ${r.webhook_secret_enc}`)).changes > 0) rekeyed++;
29
+ } catch {
30
+ failed++;
31
+ skip.add(r.id);
32
+ rt.log.error("tg.rekey_failed", { botId: r.id });
33
+ }
34
+ }
35
+ const left = await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_bot WHERE tenant = ${rt.tenant}
36
+ AND (substr(token_enc, 1, ${prefix.length}) != ${prefix}
37
+ OR (webhook_secret_enc IS NOT NULL AND substr(webhook_secret_enc, 1, ${prefix.length}) != ${prefix}))`);
38
+ const now = rt.clock.now();
39
+ await setState(rt, STATE_KEYS.keyringActiveKid, kid, now);
40
+ await setState(rt, STATE_KEYS.lastRekeyAt, String(now), now);
41
+ rt.log.info("tg.rekey", {
42
+ rekeyed,
43
+ failed,
44
+ remaining: left?.n ?? 0
45
+ });
46
+ return {
47
+ rekeyed,
48
+ failed,
49
+ remaining: left?.n ?? 0
50
+ };
51
+ }
52
+ /** Rows per key id (tokens and secrets), for the health page. */
53
+ async function rowsByKid(rt) {
54
+ const rows = await rt.db.all(sql`SELECT token_enc, webhook_secret_enc FROM tg_bot WHERE tenant = ${rt.tenant}`);
55
+ const out = {};
56
+ for (const r of rows) for (const blob of [r.token_enc, r.webhook_secret_enc]) {
57
+ if (!blob) continue;
58
+ const kid = rt.keyring.kidOf(blob) ?? "unknown";
59
+ out[kid] = (out[kid] ?? 0) + 1;
60
+ }
61
+ return out;
62
+ }
63
+ export { rekeyBots, rowsByKid };
@@ -0,0 +1,87 @@
1
+ import { TelegramPluginError } from "../engine/errors.js";
2
+ import { renderBody } from "../engine/content/render.js";
3
+ import { hostCan, parseProviderParams, requireProvider, runProvider } from "../engine/content/providers.js";
4
+ import { intersectData } from "../engine/content/intersect.js";
5
+ import { renderReportFile } from "../engine/content/report-files.js";
6
+ // src/services/reports.ts
7
+ /** Provider data as `actor` (permission checked), for a template's provider and params. */
8
+ async function templateData(rt, input) {
9
+ const t = input.template;
10
+ if (!t.provider) return {};
11
+ const def = requireProvider(rt, t.provider);
12
+ if (!await hostCan(rt, input.actor, def.permission)) throw new TelegramPluginError("provider_forbidden", `this audience may not read "${def.key}"`);
13
+ const base = {
14
+ def,
15
+ params: parseProviderParams(def, {
16
+ ...t.providerParams,
17
+ ...input.params
18
+ }),
19
+ now: input.now,
20
+ tz: input.tz,
21
+ locale: input.locale,
22
+ checked: true
23
+ };
24
+ if (input.viewer === void 0) return runProvider(rt, {
25
+ ...base,
26
+ actor: input.actor,
27
+ cache: input.cache
28
+ });
29
+ return viewerData(rt, {
30
+ ...base,
31
+ actor: input.actor,
32
+ viewer: input.viewer
33
+ });
34
+ }
35
+ /**
36
+ * Preview rule (docs/guide 06): the data a caller sees for another audience is the intersection of both views.
37
+ * Throws `provider_forbidden` when the viewer lacks the provider permission. One run when both are the same user.
38
+ */
39
+ async function viewerData(rt, input) {
40
+ const { viewer, ...run } = input;
41
+ if (!await hostCan(rt, viewer, run.def.permission)) throw new TelegramPluginError("provider_forbidden", `you may not read "${run.def.key}"`);
42
+ const audience = await runProvider(rt, {
43
+ ...run,
44
+ checked: true
45
+ });
46
+ const a = rt.describe(viewer).userId;
47
+ if (a !== null && a === rt.describe(input.actor).userId) return audience;
48
+ const mine = await runProvider(rt, {
49
+ ...run,
50
+ actor: viewer,
51
+ checked: true
52
+ });
53
+ return intersectData(mine, audience) ?? {};
54
+ }
55
+ /**
56
+ * Renders a report template to a file as `actor` (no storage, no Telegram call). Throws `validation_failed` (not a
57
+ * report), `provider_forbidden`, `provider_unknown`, `feature_disabled` (reports off) or `renderer_unavailable`.
58
+ */
59
+ async function buildReport(rt, input) {
60
+ const t = input.template;
61
+ if (t.kind !== "report") throw new TelegramPluginError("validation_failed", `template "${t.key}" is not a report`);
62
+ const locale = rt.i18n.pick(...input.locales);
63
+ const vars = await templateData(rt, {
64
+ ...input,
65
+ locale
66
+ });
67
+ const caption = renderBody(rt, {
68
+ body: t.body,
69
+ format: t.format,
70
+ vars,
71
+ locales: [...input.locales],
72
+ tz: input.tz
73
+ });
74
+ return {
75
+ file: await renderReportFile(rt, {
76
+ template: t,
77
+ vars,
78
+ locale: caption.locale,
79
+ tz: input.tz,
80
+ format: input.format,
81
+ html: caption.html
82
+ }),
83
+ caption,
84
+ vars
85
+ };
86
+ }
87
+ export { buildReport, templateData, viewerData };