@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,214 @@
1
+ import { ScheduleError, nextRun, nextRuns, validateRule } from "../core/schedule/rule.js";
2
+ import { sql } from "../ports/sql.js";
3
+ import { TelegramPluginError } from "../engine/errors.js";
4
+ import { parseProviderParams, requireProvider } from "../engine/content/providers.js";
5
+ import { templateById } from "../engine/content/template-rows.js";
6
+ import { actorId } from "./bots.js";
7
+ import { requireSchedule, scheduleDto, scheduleRule, scheduleRunDto } from "../engine/schedules/rows.js";
8
+ import { runManual } from "../engine/schedules/fire.js";
9
+ import { armScheduleWake } from "../engine/schedules/wake.js";
10
+ // src/services/schedules.ts
11
+ const ENTITY = "telegram_schedule";
12
+ function issue(path, message) {
13
+ return new TelegramPluginError("validation_failed", message, { extensions: { issues: [{
14
+ path,
15
+ message
16
+ }] } });
17
+ }
18
+ function scheduleProblem(e) {
19
+ if (e instanceof ScheduleError) throw new TelegramPluginError(e.code, e.message);
20
+ throw e;
21
+ }
22
+ const sameRule = (a, b) => JSON.stringify(a) === JSON.stringify(b);
23
+ /**
24
+ * Validates a save and returns the zone and the first slot. A `once` rule in the past is rejected unless it is the
25
+ * rule already stored (editing a schedule that already ran), which keeps `next_run_at` null.
26
+ */
27
+ async function checkSave(rt, save, now, existing) {
28
+ const tz = save.timezone ?? rt.settings.timezone;
29
+ const check = validateRule(save.rule, tz);
30
+ if (!check.ok) throw new TelegramPluginError(check.code, check.message);
31
+ let next;
32
+ try {
33
+ next = nextRun(check.rule, tz, now);
34
+ } catch (e) {
35
+ scheduleProblem(e);
36
+ }
37
+ if (next === null) {
38
+ const unchanged = existing !== null && sameRule(scheduleRule(existing), check.rule) && existing.timezone === tz;
39
+ if (check.rule.kind !== "once") throw new TelegramPluginError("schedule_rule_invalid", "the rule never runs");
40
+ if (!unchanged) throw new TelegramPluginError("schedule_rule_invalid", "the time is in the past");
41
+ }
42
+ if (save.recipients.length === 0) throw new TelegramPluginError("recipient_empty", "pick at least one recipient");
43
+ const template = await templateById(rt, save.templateId);
44
+ if (!template) throw issue("body.templateId", "template not found");
45
+ if (template.provider) {
46
+ const def = requireProvider(rt, template.provider);
47
+ let base = {};
48
+ try {
49
+ base = template.provider_params_json ? JSON.parse(template.provider_params_json) : {};
50
+ } catch {
51
+ base = {};
52
+ }
53
+ parseProviderParams(def, {
54
+ ...base,
55
+ ...save.params
56
+ });
57
+ }
58
+ return {
59
+ tz,
60
+ next
61
+ };
62
+ }
63
+ const json = (v) => v === null || v === void 0 ? null : JSON.stringify(v);
64
+ async function listSchedules(rt, page) {
65
+ return rt.db.all(sql`SELECT * FROM tg_schedule WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
66
+ ${page.cursor ? sql`AND id < ${page.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${page.limit + 1}`);
67
+ }
68
+ async function getSchedule(rt, id) {
69
+ return scheduleDto(await requireSchedule(rt, id));
70
+ }
71
+ /**
72
+ * Creates a schedule (row + audit in one batch) and re-arms the DO scheduler wake if used. Throws
73
+ * `schedule_rule_invalid`, `timezone_invalid`, `recipient_empty` or `validation_failed` (unknown template). No
74
+ * permission check: the routes require `telegram.schedules.manage`.
75
+ */
76
+ async function createSchedule(rt, actor, save) {
77
+ const now = rt.clock.now();
78
+ const { tz, next } = await checkSave(rt, save, now, null);
79
+ const by = actorId(rt, actor);
80
+ const id = rt.ids.uuid();
81
+ await rt.db.batch([sql`INSERT INTO tg_schedule (id, tenant, name, rule_json, timezone, template_id, params_json, recipients_json,
82
+ personalize, missed_policy, missed_grace_min, enabled, next_run_at, created_by, created_at, updated_at)
83
+ VALUES (${id}, ${rt.tenant}, ${save.name}, ${JSON.stringify(save.rule)}, ${tz}, ${save.templateId},
84
+ ${json(save.params)}, ${JSON.stringify(save.recipients)}, ${save.personalize}, ${save.missedPolicy},
85
+ ${save.missedGraceMin}, ${save.enabled}, ${next}, ${by}, ${now}, ${now})`, ...rt.audit({
86
+ actor: by,
87
+ entity: ENTITY,
88
+ entityId: id,
89
+ action: "create",
90
+ diff: { name: save.name },
91
+ at: now
92
+ })]);
93
+ await armScheduleWake(rt);
94
+ return getSchedule(rt, id);
95
+ }
96
+ /** Replaces a schedule. `next_run_at` is recomputed from now (or from the end of a timed pause). */
97
+ async function updateSchedule(rt, actor, id, save) {
98
+ const old = await requireSchedule(rt, id);
99
+ const now = rt.clock.now();
100
+ const { tz } = await checkSave(rt, save, now, old);
101
+ const rule = validateRule(save.rule, tz);
102
+ const from = old.paused_until !== null && old.paused_until > now ? old.paused_until - 1 : now;
103
+ const next = rule.ok && old.paused_until !== 0xe677d21fdbff ? nextRun(rule.rule, tz, from) : old.next_run_at;
104
+ const by = actorId(rt, actor);
105
+ await rt.db.batch([sql`UPDATE tg_schedule SET name = ${save.name}, rule_json = ${JSON.stringify(save.rule)}, timezone = ${tz},
106
+ template_id = ${save.templateId}, params_json = ${json(save.params)},
107
+ recipients_json = ${JSON.stringify(save.recipients)}, personalize = ${save.personalize},
108
+ missed_policy = ${save.missedPolicy}, missed_grace_min = ${save.missedGraceMin}, enabled = ${save.enabled},
109
+ next_run_at = ${next}, updated_at = ${now}
110
+ WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`, ...rt.audit({
111
+ actor: by,
112
+ entity: ENTITY,
113
+ entityId: id,
114
+ action: "update",
115
+ diff: { name: save.name },
116
+ at: now
117
+ })]);
118
+ await armScheduleWake(rt);
119
+ return getSchedule(rt, id);
120
+ }
121
+ async function deleteSchedule(rt, actor, id) {
122
+ const old = await requireSchedule(rt, id);
123
+ const now = rt.clock.now();
124
+ const by = actorId(rt, actor);
125
+ await rt.db.batch([sql`UPDATE tg_schedule SET deleted_at = ${now}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
126
+ actor: by,
127
+ entity: ENTITY,
128
+ entityId: id,
129
+ action: "delete",
130
+ diff: { name: old.name },
131
+ at: now
132
+ })]);
133
+ await armScheduleWake(rt);
134
+ }
135
+ /**
136
+ * Pauses until `until`, or until resumed. A timed pause moves `next_run_at` to the first slot at or after `until`,
137
+ * so slots inside the pause are not caught up.
138
+ */
139
+ async function pauseSchedule(rt, actor, id, body) {
140
+ const old = await requireSchedule(rt, id);
141
+ const now = rt.clock.now();
142
+ if (body.until !== void 0 && body.until <= now) throw issue("body.until", "until must be in the future");
143
+ const until = body.until ?? 0xe677d21fdbff;
144
+ let next = old.next_run_at;
145
+ if (body.until !== void 0) try {
146
+ next = nextRun(scheduleRule(old), old.timezone, until - 1);
147
+ } catch (e) {
148
+ scheduleProblem(e);
149
+ }
150
+ const by = actorId(rt, actor);
151
+ await rt.db.batch([sql`UPDATE tg_schedule SET paused_until = ${until}, next_run_at = ${next}, updated_at = ${now}
152
+ WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`, ...rt.audit({
153
+ actor: by,
154
+ entity: ENTITY,
155
+ entityId: id,
156
+ action: "pause",
157
+ diff: { until },
158
+ at: now
159
+ })]);
160
+ await armScheduleWake(rt);
161
+ return getSchedule(rt, id);
162
+ }
163
+ /** Resumes from now: slots missed while paused are not replayed. */
164
+ async function resumeSchedule(rt, actor, id) {
165
+ const old = await requireSchedule(rt, id);
166
+ const now = rt.clock.now();
167
+ let next;
168
+ try {
169
+ next = nextRun(scheduleRule(old), old.timezone, now);
170
+ } catch (e) {
171
+ scheduleProblem(e);
172
+ }
173
+ const by = actorId(rt, actor);
174
+ await rt.db.batch([sql`UPDATE tg_schedule SET paused_until = NULL, next_run_at = ${next}, updated_at = ${now}
175
+ WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`, ...rt.audit({
176
+ actor: by,
177
+ entity: ENTITY,
178
+ entityId: id,
179
+ action: "resume",
180
+ at: now
181
+ })]);
182
+ await armScheduleWake(rt);
183
+ return getSchedule(rt, id);
184
+ }
185
+ /** Runs the schedule now (a `manual` run). Works on paused or disabled schedules; `next_run_at` is unchanged. */
186
+ async function runScheduleNow(rt, actor, id) {
187
+ const schedule = await requireSchedule(rt, id);
188
+ const run = await runManual(rt, schedule);
189
+ const by = actorId(rt, actor);
190
+ const audit = rt.audit({
191
+ actor: by,
192
+ entity: ENTITY,
193
+ entityId: id,
194
+ action: "run_now",
195
+ diff: { run: run.id },
196
+ at: run.started_at
197
+ });
198
+ if (audit.length > 0) await rt.db.batch(audit);
199
+ return scheduleRunDto(run);
200
+ }
201
+ async function listRuns(rt, id, page) {
202
+ await requireSchedule(rt, id);
203
+ return rt.db.all(sql`SELECT * FROM tg_schedule_run WHERE tenant = ${rt.tenant} AND schedule_id = ${id}
204
+ ${page.cursor ? sql`AND id < ${page.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${page.limit + 1}`);
205
+ }
206
+ /** Next-runs preview for an editor (strictly after `from`, default now). */
207
+ function previewNextRuns(rt, req) {
208
+ try {
209
+ return { runs: nextRuns(req.rule, req.tz, req.from ?? rt.clock.now(), req.count) };
210
+ } catch (e) {
211
+ scheduleProblem(e);
212
+ }
213
+ }
214
+ export { createSchedule, deleteSchedule, getSchedule, listRuns, listSchedules, pauseSchedule, previewNextRuns, resumeSchedule, runScheduleNow, updateSchedule };
@@ -0,0 +1,14 @@
1
+ import { DeliverySource } from "../contracts/deliveries.js";
2
+ import { intersectData } from "../engine/content/intersect.js";
3
+ /** Options of `enqueueIntent` / `plugin.enqueue()`. */
4
+ export interface EnqueueOptions {
5
+ /** Stable key for this send (for example `ai:<nonce>`); retries of the host batch never duplicate messages. */
6
+ dedupeKey: string;
7
+ /** Default `api`. `ai` adds the AI rules: ai_allowed destinations and the text checks (docs/07 §2). */
8
+ source?: DeliverySource;
9
+ /** Domains allowed in AI text links. */
10
+ appDomains?: string[];
11
+ }
12
+ /** Provider key convention for `record` sends (docs/04 §9). */
13
+ export declare const recordProviderKey: (type: string) => string;
14
+ export { intersectData };
@@ -0,0 +1,421 @@
1
+ import { Audience } from "../contracts/chats.js";
2
+ import { checkAiText } from "../core/guards.js";
3
+ import { escapeHtml } from "../core/html/escape.js";
4
+ import { splitMessage } from "../core/split/split.js";
5
+ import { sql } from "../ports/sql.js";
6
+ import { TelegramPluginError, featureDisabled } from "../engine/errors.js";
7
+ import { renderBody } from "../engine/content/render.js";
8
+ import { hostCan, parseProviderParams, requireProvider, runProvider } from "../engine/content/providers.js";
9
+ import { parseJson } from "../engine/rows.js";
10
+ import { buildButtons, orphanTokenGuard } from "../engine/content/buttons.js";
11
+ import { intersectData } from "../engine/content/intersect.js";
12
+ import { fileUrl, storeReportFile } from "../engine/content/report-files.js";
13
+ import { buildReport, templateData } from "./reports.js";
14
+ import { audienceOf } from "../engine/content/audience.js";
15
+ import { requireTemplateByKey, templateByKey, templateModel } from "../engine/content/template-rows.js";
16
+ import { deliveryFragments } from "../engine/outbox/enqueue.js";
17
+ import { destinationBySlug } from "./destinations.js";
18
+ // src/services/send.ts
19
+ /** The literal file source name for the host files port. */
20
+ const FILES_PORT_SOURCE = "files";
21
+ /** Provider key convention for `record` sends (docs/04 §9). */
22
+ const recordProviderKey = (type) => `record:${type}`;
23
+ async function activeChat(rt, id) {
24
+ return rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id = ${id}`);
25
+ }
26
+ async function botOf(rt, id) {
27
+ const bot = await rt.db.first(sql`SELECT * FROM tg_bot WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`);
28
+ if (!bot) throw new TelegramPluginError("chat_not_found", "the chat's bot is gone");
29
+ return bot;
30
+ }
31
+ async function resolveTargets(rt, actor, intent, ai) {
32
+ const to = intent.to;
33
+ if ("destination" in to) {
34
+ const dest = await destinationBySlug(rt, to.destination);
35
+ if (!dest) throw new TelegramPluginError("not_found", "destination not found");
36
+ if (ai && dest.ai_allowed !== 1) throw new TelegramPluginError("destination_not_allowed", "the AI may not send here");
37
+ const chat = await activeChat(rt, dest.chat_ref);
38
+ if (!chat) throw new TelegramPluginError("chat_not_found", "destination chat not found");
39
+ if (chat.status === "blocked") throw new TelegramPluginError("chat_blocked", "the chat blocked the bot");
40
+ if (chat.status !== "active") throw new TelegramPluginError("chat_not_found", "destination chat is not active");
41
+ return [{
42
+ chat,
43
+ bot: await botOf(rt, chat.bot_id),
44
+ destination: dest,
45
+ threadId: dest.message_thread_id
46
+ }];
47
+ }
48
+ if (ai && !("me" in to)) throw new TelegramPluginError("destination_not_allowed", "the AI sends only to ai_allowed destinations or to me");
49
+ const userId = "me" in to ? rt.describe(actor).userId : to.user;
50
+ if (!userId) throw new TelegramPluginError("recipient_empty", "the caller has no user id");
51
+ const chats = await rt.db.all(sql`SELECT c.* FROM tg_chat c JOIN tg_bot b ON b.id = c.bot_id
52
+ WHERE c.tenant = ${rt.tenant} AND c.user_id = ${userId} AND c.kind = 'private' AND c.status = 'active'
53
+ AND b.deleted_at IS NULL AND b.status IN ('active', 'webhook_error') ORDER BY c.id`);
54
+ if (chats.length === 0) throw new TelegramPluginError("recipient_empty", "this user has no linked Telegram chat");
55
+ const out = [];
56
+ for (const chat of chats) out.push({
57
+ chat,
58
+ bot: await botOf(rt, chat.bot_id),
59
+ destination: null,
60
+ threadId: null
61
+ });
62
+ return out;
63
+ }
64
+ /** Opens a file through a registered file source, or the files port (`source: "files"`), checking `actor`. */
65
+ async function openSource(rt, actor, source, ref) {
66
+ let file = null;
67
+ const def = rt.registry.fileSources.get(source);
68
+ if (def) file = await def.resolve({
69
+ actor,
70
+ ref,
71
+ env: rt.env
72
+ });
73
+ else if (source === "files" && rt.files) {
74
+ if (!rt.files.canRead || !await rt.files.canRead(actor, ref)) throw new TelegramPluginError("forbidden", "file not readable");
75
+ file = await rt.files.open(ref, actor);
76
+ } else throw new TelegramPluginError("validation_failed", `unknown file source "${source}"`);
77
+ if (!file) throw new TelegramPluginError("not_found", "file not found");
78
+ return file;
79
+ }
80
+ function text(html, extra = {}) {
81
+ return {
82
+ kind: "text",
83
+ html,
84
+ parts: html === "" ? [] : splitMessage(html),
85
+ markup: null,
86
+ sql: [],
87
+ buttons: null,
88
+ ...extra
89
+ };
90
+ }
91
+ async function audienceFor(rt, t) {
92
+ const aud = await audienceOf(rt, t.chat, false);
93
+ if (!aud) throw new TelegramPluginError("recipient_empty", "the destination chat has no audience");
94
+ return aud;
95
+ }
96
+ function localesOf(t, aud) {
97
+ return [
98
+ t.chat.locale,
99
+ aud.info.locale,
100
+ t.bot.default_locale
101
+ ];
102
+ }
103
+ function defaultRecordHtml(type, key, data) {
104
+ const lines = [`<b>${escapeHtml(type)} ${escapeHtml(key)}</b>`];
105
+ for (const [k, v] of Object.entries(data)) {
106
+ if (v === null || v === void 0 || typeof v === "object") continue;
107
+ lines.push(`${escapeHtml(k)}: ${escapeHtml(String(v))}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+ async function renderMessage(rt, t, vars, c, aud) {
112
+ const tz = c.target.chat.timezone ?? rt.settings.timezone;
113
+ const body = renderBody(rt, {
114
+ body: t.body,
115
+ format: t.format,
116
+ vars,
117
+ locales: localesOf(c.target, aud),
118
+ tz
119
+ });
120
+ const buttons = await buildButtons(rt, t.buttons, {
121
+ vars,
122
+ locale: body.locale,
123
+ tz,
124
+ botId: c.target.bot.id,
125
+ chatRef: c.store ? c.target.chat.id : null,
126
+ userId: aud.userId,
127
+ now: c.now
128
+ });
129
+ return text(body.html, {
130
+ markup: buttons.markup,
131
+ sql: buttons.sql,
132
+ buttons: buttons.preview
133
+ });
134
+ }
135
+ async function reportContent(rt, t, params, format, c) {
136
+ if (!rt.features.reports) throw featureDisabled("reports");
137
+ if (t.kind !== "report") throw new TelegramPluginError("validation_failed", `template "${t.key}" is not a report`);
138
+ const aud = await audienceFor(rt, c.target);
139
+ const built = await buildReport(rt, {
140
+ template: t,
141
+ actor: aud.actor,
142
+ params,
143
+ locales: localesOf(c.target, aud),
144
+ tz: c.target.chat.timezone ?? rt.settings.timezone,
145
+ format,
146
+ now: c.now,
147
+ viewer: c.store ? void 0 : c.sender
148
+ });
149
+ if (!c.store) {
150
+ if (built.file.body instanceof ReadableStream) await built.file.body.cancel().catch(() => {});
151
+ const size = built.file.size ?? (built.file.body instanceof Uint8Array ? built.file.body.byteLength : null);
152
+ return {
153
+ kind: "document",
154
+ html: built.caption.html,
155
+ file: {
156
+ ref: "",
157
+ name: built.file.name,
158
+ mime: built.file.mime,
159
+ size,
160
+ source: null
161
+ }
162
+ };
163
+ }
164
+ const stored = await storeReportFile(rt, built.file, {
165
+ template: t.key,
166
+ source_kind: "send"
167
+ });
168
+ if (stored.size !== null && stored.size > 52428800) {
169
+ rt.log.warn("tg.file_too_large", {
170
+ template: t.key,
171
+ code: "file_too_large"
172
+ });
173
+ const url = await fileUrl(rt, stored.ref);
174
+ const locale = built.caption.locale;
175
+ const link = url ? `\n${rt.i18n.t(locale, "bot.report.download")} <a href="${escapeHtml(url)}">${escapeHtml(stored.name)}</a>` : "";
176
+ return text([built.caption.html, `${rt.i18n.t(locale, "bot.report.too_large")}${link}`].filter(Boolean).join("\n\n"));
177
+ }
178
+ return {
179
+ kind: "document",
180
+ html: built.caption.html,
181
+ file: {
182
+ ref: stored.ref,
183
+ name: stored.name,
184
+ mime: stored.mime,
185
+ size: stored.size,
186
+ source: null
187
+ }
188
+ };
189
+ }
190
+ async function contentFor(rt, intent, c) {
191
+ const what = intent.what;
192
+ const note = intent.note ? escapeHtml(intent.note) : "";
193
+ if ("text" in what) return text(escapeHtml(what.text));
194
+ if ("file" in what) {
195
+ const file = await openSource(rt, c.sender, what.file.source, what.file.ref);
196
+ await file.body.cancel().catch(() => {});
197
+ if (file.size !== null && file.size > 52428800) throw new TelegramPluginError("file_too_large", "the file is over Telegram's 50 MB limit");
198
+ return {
199
+ kind: "document",
200
+ html: note,
201
+ file: {
202
+ ref: file.ref,
203
+ name: file.name,
204
+ mime: file.mime,
205
+ size: file.size,
206
+ source: what.file.source === "files" ? null : what.file.source
207
+ }
208
+ };
209
+ }
210
+ if ("template" in what || "report" in what) {
211
+ const key = "template" in what ? what.template : what.report;
212
+ if (!rt.features.templates) throw featureDisabled("templates");
213
+ const t = templateModel(await requireTemplateByKey(rt, key));
214
+ if (t.provider && !await hostCan(rt, c.sender, requireProvider(rt, t.provider).permission)) throw new TelegramPluginError("provider_forbidden", `you may not read "${t.provider}"`);
215
+ if ("report" in what || t.kind === "report") return reportContent(rt, t, what.params, "report" in what ? what.format : void 0, c);
216
+ const aud = await audienceFor(rt, c.target);
217
+ return renderMessage(rt, t, await templateData(rt, {
218
+ template: t,
219
+ actor: aud.actor,
220
+ params: what.params,
221
+ tz: c.target.chat.timezone ?? rt.settings.timezone,
222
+ now: c.now,
223
+ locale: rt.i18n.pick(...localesOf(c.target, aud)),
224
+ viewer: c.store ? void 0 : c.sender
225
+ }), c, aud);
226
+ }
227
+ const { type, key, fields } = what.record;
228
+ const def = rt.registry.providers.get(recordProviderKey(type));
229
+ if (!def) throw new TelegramPluginError("provider_unknown", `no provider "${recordProviderKey(type)}" for record sends`);
230
+ const params = parseProviderParams(def, {
231
+ key,
232
+ ...fields ? { fields } : {}
233
+ });
234
+ const aud = await audienceFor(rt, c.target);
235
+ if (!await hostCan(rt, c.sender, def.permission)) throw new TelegramPluginError("provider_forbidden", `you may not read this ${type}`);
236
+ if (!await hostCan(rt, aud.actor, def.permission)) throw new TelegramPluginError("provider_forbidden", `the destination may not see this ${type}`);
237
+ const tz = c.target.chat.timezone ?? rt.settings.timezone;
238
+ const locale = rt.i18n.pick(...localesOf(c.target, aud));
239
+ const base = {
240
+ def,
241
+ params,
242
+ now: c.now,
243
+ tz,
244
+ locale,
245
+ checked: true
246
+ };
247
+ const [mine, theirs] = await Promise.all([runProvider(rt, {
248
+ ...base,
249
+ actor: c.sender
250
+ }), runProvider(rt, {
251
+ ...base,
252
+ actor: aud.actor
253
+ })]);
254
+ let data = intersectData(mine, theirs) ?? {};
255
+ if (fields && fields.length > 0) data = Object.fromEntries(Object.entries(data).filter(([k]) => fields.includes(k)));
256
+ const row = await templateByKey(rt, recordProviderKey(type));
257
+ if (row && row.kind === "message") return renderMessage(rt, templateModel(row), {
258
+ ...data,
259
+ key,
260
+ type
261
+ }, c, aud);
262
+ return text([defaultRecordHtml(type, key, data), note].filter(Boolean).join("\n\n"));
263
+ }
264
+ /** AI-written `text` and `note` must pass the content checks for every audience the send reaches (docs/07 §2.4). */
265
+ async function checkAiIntent(rt, intent, targets, appDomains) {
266
+ const texts = [..."text" in intent.what ? [intent.what.text] : [], ...intent.note ? [intent.note] : []];
267
+ if (texts.length === 0) return;
268
+ const reasons = /* @__PURE__ */ new Set();
269
+ const domains = [...rt.ai.appDomains, ...appDomains];
270
+ for (const t of targets) {
271
+ const aud = await audienceFor(rt, t);
272
+ const money = rt.ai.moneyPermission;
273
+ const seesMoney = money !== null && await hostCan(rt, aud.actor, money);
274
+ for (const body of texts) {
275
+ const r = checkAiText(body, {
276
+ audienceSeesMoney: seesMoney,
277
+ allowContacts: rt.ai.allowContacts,
278
+ appDomains: domains,
279
+ maxLength: rt.ai.maxTextLength
280
+ });
281
+ if (!r.ok) for (const reason of r.reasons) reasons.add(reason);
282
+ }
283
+ }
284
+ if (reasons.size === 0) return;
285
+ const list = [...reasons];
286
+ rt.log.info("tg.ai_text_rejected", { reasons: list.join(",") });
287
+ throw new TelegramPluginError("send_text_rejected", `text rejected: ${list.join(", ")}`, { extensions: { reasons: list } });
288
+ }
289
+ async function prepare(rt, actor, intent, source, appDomains = []) {
290
+ const ai = source === "ai";
291
+ if (ai && (!rt.features.ai || !rt.features.aiSendIntent)) throw featureDisabled("ai");
292
+ if (!await rt.can(actor, "telegram.send")) throw new TelegramPluginError("forbidden", "missing telegram.send");
293
+ const targets = await resolveTargets(rt, actor, intent, ai);
294
+ if (ai) await checkAiIntent(rt, intent, targets, appDomains);
295
+ return {
296
+ ai,
297
+ targets
298
+ };
299
+ }
300
+ /** SQL for the host's batch: one row per part and chat. Call `kick(env)` after the batch commits. */
301
+ async function enqueueIntent(rt, actor, intent, opts) {
302
+ if (!opts.dedupeKey || opts.dedupeKey.length > 200) throw new TelegramPluginError("validation_failed", "dedupeKey is required (≤ 200 chars)");
303
+ const source = opts.source ?? "api";
304
+ const { ai, targets } = await prepare(rt, actor, intent, source, opts.appDomains);
305
+ const dedupeKey = ai && !opts.dedupeKey.startsWith("ai:") ? `ai:${opts.dedupeKey}` : opts.dedupeKey;
306
+ const now = rt.clock.now();
307
+ const out = [];
308
+ for (const t of targets) {
309
+ const c = await contentFor(rt, intent, {
310
+ sender: actor,
311
+ target: t,
312
+ ai,
313
+ appDomains: opts.appDomains ?? [],
314
+ store: true,
315
+ now
316
+ });
317
+ const base = targets.length > 1 ? `${dedupeKey}:${t.chat.id}` : dedupeKey;
318
+ const common = {
319
+ botId: t.bot.id,
320
+ chatRef: t.chat.id,
321
+ threadId: t.threadId,
322
+ source,
323
+ sourceId: base,
324
+ dedupeKey: base
325
+ };
326
+ if (c.kind === "text") {
327
+ if (c.html === "") continue;
328
+ const rows = deliveryFragments(rt, {
329
+ ...common,
330
+ kind: "text",
331
+ html: c.html,
332
+ replyMarkup: c.markup
333
+ }, now);
334
+ out.push(...c.sql, ...rows, ...orphanTokenGuard(rt, c.markup, `${base}:${rows.length - 1}`));
335
+ continue;
336
+ }
337
+ const input = {
338
+ ...common,
339
+ kind: "document",
340
+ html: c.html,
341
+ file: {
342
+ ref: c.file.ref,
343
+ name: c.file.name,
344
+ mime: c.file.mime,
345
+ source: c.file.source ?? void 0
346
+ }
347
+ };
348
+ out.push(...deliveryFragments(rt, input, now));
349
+ }
350
+ return out;
351
+ }
352
+ function kindOf(kind) {
353
+ return kind === "group" || kind === "supergroup" || kind === "channel" ? kind : "private";
354
+ }
355
+ /** What `enqueue` would send (rendered for the first target's audience), for the host's confirm card. */
356
+ async function previewIntent(rt, actor, intent, opts = {}) {
357
+ const { ai, targets } = await prepare(rt, actor, intent, opts.source ?? "api", opts.appDomains);
358
+ const first = targets[0];
359
+ const c = await contentFor(rt, intent, {
360
+ sender: actor,
361
+ target: first,
362
+ ai,
363
+ appDomains: opts.appDomains ?? [],
364
+ store: false,
365
+ now: rt.clock.now()
366
+ });
367
+ const warnings = [];
368
+ if (targets.length > 1) warnings.push(`sent to ${targets.length} chats`);
369
+ const file = c.kind === "document" ? {
370
+ name: c.file.name,
371
+ mime: c.file.mime,
372
+ size: c.file.size
373
+ } : null;
374
+ const name = first.destination?.name ?? first.chat.title ?? first.chat.username ?? "";
375
+ const locale = rt.i18n.pick(rt.describe(actor).locale);
376
+ const audience = await describeAudience(rt, first, locale);
377
+ const consequence = [rt.i18n.plain(locale, "bot.ai.consequence", {
378
+ what: describeWhat(rt, intent, file, locale),
379
+ name,
380
+ bot: first.bot.username,
381
+ audience
382
+ }), targets.length > 1 ? rt.i18n.plain(locale, "bot.ai.chats", { count: targets.length }) : ""].filter(Boolean).join(" ");
383
+ return {
384
+ to: {
385
+ name,
386
+ kind: kindOf(first.chat.kind),
387
+ botUsername: first.bot.username,
388
+ destination: first.destination?.slug ?? null
389
+ },
390
+ audience,
391
+ parts: c.kind === "text" ? c.parts : c.html === "" ? [] : splitMessage(c.html),
392
+ buttons: c.kind === "text" ? c.buttons : null,
393
+ file,
394
+ attachments: file ? [file] : [],
395
+ consequence,
396
+ chats: targets.length,
397
+ source: ai ? "ai" : "api",
398
+ warnings
399
+ };
400
+ }
401
+ /** "Only Asha will see it." / "Everyone in this group will see it (as pm)." in `locale`. */
402
+ async function describeAudience(rt, t, locale) {
403
+ if (t.chat.kind === "private") {
404
+ const who = (await audienceOf(rt, t.chat, false))?.info.name ?? t.chat.title ?? t.chat.username ?? "";
405
+ return rt.i18n.plain(locale, "bot.ai.audience.user", { name: who });
406
+ }
407
+ const a = parseJson(t.chat.audience_json, Audience.nullable(), null);
408
+ return a && "role" in a ? rt.i18n.plain(locale, "bot.ai.audience.role", { role: a.role }) : rt.i18n.plain(locale, "bot.ai.audience.group");
409
+ }
410
+ function describeWhat(rt, intent, file, locale) {
411
+ const w = intent.what;
412
+ if ("text" in w) return rt.i18n.plain(locale, "bot.ai.what.text");
413
+ if ("record" in w) return rt.i18n.plain(locale, "bot.ai.what.record", {
414
+ type: w.record.type,
415
+ key: w.record.key
416
+ });
417
+ if ("template" in w) return rt.i18n.plain(locale, "bot.ai.what.template", { name: w.template });
418
+ if ("report" in w) return rt.i18n.plain(locale, "bot.ai.what.report", { name: file?.name ?? w.report });
419
+ return rt.i18n.plain(locale, "bot.ai.what.file", { name: file?.name ?? w.file.ref });
420
+ }
421
+ export { FILES_PORT_SOURCE, enqueueIntent, intersectData, openSource, previewIntent, recordProviderKey };
@@ -0,0 +1,28 @@
1
+ import { sql } from "../ports/sql.js";
2
+ import { subscriptionDto } from "../engine/rows.js";
3
+ // src/services/subscriptions.ts
4
+ async function listSubscriptions(rt, chatRefs) {
5
+ if (chatRefs.length === 0) return [];
6
+ const out = [];
7
+ for (let i = 0; i < chatRefs.length; i += 50) {
8
+ const rows = await rt.db.all(sql`SELECT * FROM tg_subscription WHERE tenant = ${rt.tenant} AND chat_ref IN (${sql.join(chatRefs.slice(i, i + 50))})
9
+ ORDER BY chat_ref, topic`);
10
+ out.push(...rows.map(subscriptionDto));
11
+ }
12
+ return out;
13
+ }
14
+ async function replaceSubscriptions(rt, save) {
15
+ const now = rt.clock.now();
16
+ const seen = /* @__PURE__ */ new Set();
17
+ const inserts = [];
18
+ for (const item of save.items) {
19
+ if (seen.has(item.topic)) continue;
20
+ seen.add(item.topic);
21
+ inserts.push(sql`INSERT INTO tg_subscription (tenant, chat_ref, topic, muted, quiet_json, updated_at)
22
+ VALUES (${rt.tenant}, ${save.chatRef}, ${item.topic}, ${item.muted},
23
+ ${item.quiet === null ? null : JSON.stringify(item.quiet)}, ${now})`);
24
+ }
25
+ await rt.db.batch([sql`DELETE FROM tg_subscription WHERE tenant = ${rt.tenant} AND chat_ref = ${save.chatRef}`, ...inserts]);
26
+ return listSubscriptions(rt, [save.chatRef]);
27
+ }
28
+ export { listSubscriptions, replaceSubscriptions };