@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.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/adapters/clock.d.ts +3 -0
- package/dist/adapters/clock.js +4 -0
- package/dist/adapters/console-log.d.ts +13 -0
- package/dist/adapters/console-log.js +41 -0
- package/dist/adapters/d1-db.d.ts +5 -0
- package/dist/adapters/d1-db.js +37 -0
- package/dist/adapters/ids.d.ts +5 -0
- package/dist/adapters/ids.js +25 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/keyring.d.ts +50 -0
- package/dist/adapters/keyring.js +174 -0
- package/dist/adapters/metrics.d.ts +28 -0
- package/dist/adapters/metrics.js +48 -0
- package/dist/adapters/r2-files.d.ts +20 -0
- package/dist/adapters/r2-files.js +53 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +45 -0
- package/dist/client/broadcasts.d.ts +35 -0
- package/dist/client/commands.d.ts +23 -0
- package/dist/client/endpoint.d.ts +34 -0
- package/dist/client/events.d.ts +33 -0
- package/dist/client/index.d.ts +11 -0
- package/dist/client/index.js +3 -0
- package/dist/client/schedules.d.ts +42 -0
- package/dist/client/schema.d.ts +181 -0
- package/dist/client/send.d.ts +15 -0
- package/dist/client/templates.d.ts +47 -0
- package/dist/contracts/bots.d.ts +129 -0
- package/dist/contracts/bots.js +70 -0
- package/dist/contracts/broadcasts.d.ts +116 -0
- package/dist/contracts/broadcasts.js +63 -0
- package/dist/contracts/chats.d.ts +97 -0
- package/dist/contracts/chats.js +66 -0
- package/dist/contracts/commands.d.ts +52 -0
- package/dist/contracts/commands.js +53 -0
- package/dist/contracts/common.d.ts +38 -0
- package/dist/contracts/common.js +41 -0
- package/dist/contracts/deliveries.d.ts +103 -0
- package/dist/contracts/deliveries.js +74 -0
- package/dist/contracts/destinations.d.ts +24 -0
- package/dist/contracts/destinations.js +28 -0
- package/dist/contracts/events.d.ts +165 -0
- package/dist/contracts/events.js +98 -0
- package/dist/contracts/features.d.ts +20 -0
- package/dist/contracts/features.js +21 -0
- package/dist/contracts/health.d.ts +55 -0
- package/dist/contracts/health.js +58 -0
- package/dist/contracts/index.d.ts +22 -0
- package/dist/contracts/index.js +22 -0
- package/dist/contracts/labels.d.ts +38 -0
- package/dist/contracts/labels.js +52 -0
- package/dist/contracts/links.d.ts +51 -0
- package/dist/contracts/links.js +50 -0
- package/dist/contracts/meta.d.ts +132 -0
- package/dist/contracts/meta.js +85 -0
- package/dist/contracts/permissions.d.ts +24 -0
- package/dist/contracts/permissions.js +27 -0
- package/dist/contracts/problems.d.ts +106 -0
- package/dist/contracts/problems.js +101 -0
- package/dist/contracts/recipients.d.ts +36 -0
- package/dist/contracts/recipients.js +22 -0
- package/dist/contracts/schedules.d.ts +276 -0
- package/dist/contracts/schedules.js +143 -0
- package/dist/contracts/send.d.ts +126 -0
- package/dist/contracts/send.js +69 -0
- package/dist/contracts/subscriptions.d.ts +38 -0
- package/dist/contracts/subscriptions.js +31 -0
- package/dist/contracts/templates.d.ts +358 -0
- package/dist/contracts/templates.js +174 -0
- package/dist/contracts/users.d.ts +15 -0
- package/dist/contracts/users.js +19 -0
- package/dist/core/guards.d.ts +22 -0
- package/dist/core/guards.js +47 -0
- package/dist/core/html/escape.d.ts +2 -0
- package/dist/core/html/escape.js +36 -0
- package/dist/core/html/tokenize.js +65 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/pacing.d.ts +1 -0
- package/dist/core/pacing.js +86 -0
- package/dist/core/random.d.ts +2 -0
- package/dist/core/random.js +38 -0
- package/dist/core/recipients.d.ts +1 -0
- package/dist/core/recipients.js +86 -0
- package/dist/core/schedule/describe.d.ts +3 -0
- package/dist/core/schedule/describe.js +74 -0
- package/dist/core/schedule/index.d.ts +4 -0
- package/dist/core/schedule/policy.d.ts +1 -0
- package/dist/core/schedule/policy.js +37 -0
- package/dist/core/schedule/rule.d.ts +25 -0
- package/dist/core/schedule/rule.js +142 -0
- package/dist/core/schedule/tz.d.ts +2 -0
- package/dist/core/schedule/tz.js +59 -0
- package/dist/core/split/plain.d.ts +2 -0
- package/dist/core/split/plain.js +23 -0
- package/dist/core/split/split.d.ts +15 -0
- package/dist/core/split/split.js +158 -0
- package/dist/core/template/ast.d.ts +58 -0
- package/dist/core/template/ast.js +5 -0
- package/dist/core/template/expr.js +108 -0
- package/dist/core/template/filters.d.ts +20 -0
- package/dist/core/template/filters.js +127 -0
- package/dist/core/template/format.d.ts +17 -0
- package/dist/core/template/format.js +88 -0
- package/dist/core/template/index.d.ts +20 -0
- package/dist/core/template/index.js +29 -0
- package/dist/core/template/parse.d.ts +8 -0
- package/dist/core/template/parse.js +369 -0
- package/dist/core/template/render.d.ts +17 -0
- package/dist/core/template/render.js +118 -0
- package/dist/core/template/shape.d.ts +28 -0
- package/dist/core/template/shape.js +151 -0
- package/dist/core/template/tags.js +105 -0
- package/dist/core/template/validate.d.ts +10 -0
- package/dist/core/template/validate.js +144 -0
- package/dist/core/template/variables.d.ts +15 -0
- package/dist/core/template/variables.js +41 -0
- package/dist/do/index.d.ts +3 -0
- package/dist/do/index.js +3 -0
- package/dist/do/telegram-bot-do.d.ts +45 -0
- package/dist/do/telegram-bot-do.js +219 -0
- package/dist/engine/actions/callback.js +256 -0
- package/dist/engine/actions/tokens.d.ts +2 -0
- package/dist/engine/actions/tokens.js +51 -0
- package/dist/engine/bot-api.js +20 -0
- package/dist/engine/bot-features.js +39 -0
- package/dist/engine/chat-state.js +66 -0
- package/dist/engine/content/audience.js +54 -0
- package/dist/engine/content/buttons.d.ts +1 -0
- package/dist/engine/content/buttons.js +113 -0
- package/dist/engine/content/fanout.js +284 -0
- package/dist/engine/content/intersect.d.ts +6 -0
- package/dist/engine/content/intersect.js +26 -0
- package/dist/engine/content/providers.d.ts +6 -0
- package/dist/engine/content/providers.js +142 -0
- package/dist/engine/content/recipients.js +128 -0
- package/dist/engine/content/render.js +138 -0
- package/dist/engine/content/reply.js +156 -0
- package/dist/engine/content/report-files.js +78 -0
- package/dist/engine/content/template-rows.js +74 -0
- package/dist/engine/dialogs/dialog.d.ts +2 -0
- package/dist/engine/dialogs/dialog.js +375 -0
- package/dist/engine/dispatch/builtins.js +198 -0
- package/dist/engine/dispatch/commands.js +194 -0
- package/dist/engine/dispatch/context.d.ts +2 -0
- package/dist/engine/dispatch/context.js +194 -0
- package/dist/engine/dispatch/dispatcher.js +66 -0
- package/dist/engine/dispatch/handlers.d.ts +1 -0
- package/dist/engine/dispatch/handlers.js +103 -0
- package/dist/engine/dispatch/lifecycle.js +85 -0
- package/dist/engine/dispatch/link-chat.js +31 -0
- package/dist/engine/dispatch/linking.js +273 -0
- package/dist/engine/errors.d.ts +60 -0
- package/dist/engine/errors.js +72 -0
- package/dist/engine/events/filters.d.ts +5 -0
- package/dist/engine/events/filters.js +82 -0
- package/dist/engine/events/kick.js +30 -0
- package/dist/engine/events/payload.d.ts +3 -0
- package/dist/engine/events/payload.js +16 -0
- package/dist/engine/events/tick.js +208 -0
- package/dist/engine/i18n.d.ts +12 -0
- package/dist/engine/i18n.js +55 -0
- package/dist/engine/inbound/files.js +121 -0
- package/dist/engine/inbound/handler.js +46 -0
- package/dist/engine/jobs.d.ts +32 -0
- package/dist/engine/jobs.js +85 -0
- package/dist/engine/link-codes.js +46 -0
- package/dist/engine/metrics.js +12 -0
- package/dist/engine/options.d.ts +369 -0
- package/dist/engine/options.js +81 -0
- package/dist/engine/outbox/dead-letter.js +78 -0
- package/dist/engine/outbox/do-registry.d.ts +10 -0
- package/dist/engine/outbox/do-registry.js +10 -0
- package/dist/engine/outbox/do-transport.d.ts +33 -0
- package/dist/engine/outbox/do-transport.js +73 -0
- package/dist/engine/outbox/drain.d.ts +13 -0
- package/dist/engine/outbox/drain.js +381 -0
- package/dist/engine/outbox/enqueue.d.ts +1 -0
- package/dist/engine/outbox/enqueue.js +63 -0
- package/dist/engine/outbox/select.js +24 -0
- package/dist/engine/outbox/send.js +362 -0
- package/dist/engine/outbox/transport.d.ts +35 -0
- package/dist/engine/outbox/transport.js +175 -0
- package/dist/engine/outbox/wake.js +28 -0
- package/dist/engine/registry.d.ts +502 -0
- package/dist/engine/registry.js +258 -0
- package/dist/engine/rows.d.ts +25 -0
- package/dist/engine/rows.js +131 -0
- package/dist/engine/runtime.d.ts +95 -0
- package/dist/engine/runtime.js +149 -0
- package/dist/engine/schedules/fire.js +208 -0
- package/dist/engine/schedules/resume.js +75 -0
- package/dist/engine/schedules/rows.js +69 -0
- package/dist/engine/schedules/tick.js +30 -0
- package/dist/engine/schedules/wake.js +50 -0
- package/dist/engine/state.d.ts +11 -0
- package/dist/engine/state.js +78 -0
- package/dist/locales/en.js +339 -0
- package/dist/locales/en.json +341 -0
- package/dist/locales/mr.js +339 -0
- package/dist/locales/mr.json +341 -0
- package/dist/ports/db.d.ts +22 -0
- package/dist/ports/index.d.ts +4 -0
- package/dist/ports/ports.d.ts +257 -0
- package/dist/ports/sql.d.ts +39 -0
- package/dist/ports/sql.js +76 -0
- package/dist/renderers/csv.d.ts +14 -0
- package/dist/renderers/csv.js +51 -0
- package/dist/renderers/html.d.ts +44 -0
- package/dist/renderers/html.js +81 -0
- package/dist/renderers/index.d.ts +5 -0
- package/dist/renderers/index.js +4 -0
- package/dist/renderers/table.d.ts +30 -0
- package/dist/renderers/table.js +114 -0
- package/dist/routes/admin.js +146 -0
- package/dist/routes/app.d.ts +3111 -0
- package/dist/routes/app.js +9 -0
- package/dist/routes/broadcasts.js +37 -0
- package/dist/routes/commands.js +35 -0
- package/dist/routes/content.js +12 -0
- package/dist/routes/events.js +36 -0
- package/dist/routes/http.d.ts +30 -0
- package/dist/routes/http.js +223 -0
- package/dist/routes/me.js +59 -0
- package/dist/routes/schedules.js +62 -0
- package/dist/routes/send.js +24 -0
- package/dist/routes/templates.js +70 -0
- package/dist/routes/webhook.js +147 -0
- package/dist/schema/columns.js +307 -0
- package/dist/schema/ddl.d.ts +7 -0
- package/dist/schema/ddl.js +45 -0
- package/dist/schema/index.d.ts +4255 -0
- package/dist/schema/index.js +44 -0
- package/dist/schema/reference.sql +359 -0
- package/dist/schema/tables.d.ts +4243 -0
- package/dist/schema/tables.js +327 -0
- package/dist/schema/version.d.ts +5 -0
- package/dist/schema/version.js +7 -0
- package/dist/server/index.d.ts +55 -0
- package/dist/server/index.js +46 -0
- package/dist/server/plugin.d.ts +81 -0
- package/dist/server/plugin.js +247 -0
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.js +4 -0
- package/dist/services/ai-tool.d.ts +26 -0
- package/dist/services/ai-tool.js +148 -0
- package/dist/services/bots.js +300 -0
- package/dist/services/broadcasts.js +390 -0
- package/dist/services/chats.js +83 -0
- package/dist/services/cleanup.js +83 -0
- package/dist/services/commands.js +140 -0
- package/dist/services/deliveries.js +53 -0
- package/dist/services/destinations.js +91 -0
- package/dist/services/event-rules.js +229 -0
- package/dist/services/events.js +20 -0
- package/dist/services/idempotency.js +64 -0
- package/dist/services/links.js +200 -0
- package/dist/services/menu.js +95 -0
- package/dist/services/ops.js +90 -0
- package/dist/services/rekey.js +63 -0
- package/dist/services/reports.js +87 -0
- package/dist/services/schedules.js +214 -0
- package/dist/services/send.d.ts +14 -0
- package/dist/services/send.js +421 -0
- package/dist/services/subscriptions.js +28 -0
- package/dist/services/templates.js +259 -0
- package/dist/telegram/classify.d.ts +30 -0
- package/dist/telegram/classify.js +164 -0
- package/dist/telegram/client.d.ts +73 -0
- package/dist/telegram/client.js +135 -0
- package/dist/telegram/errors.d.ts +37 -0
- package/dist/telegram/errors.js +62 -0
- package/dist/telegram/inbound.d.ts +40 -0
- package/dist/telegram/inbound.js +72 -0
- package/dist/telegram/index.d.ts +8 -0
- package/dist/telegram/multipart.d.ts +5 -0
- package/dist/telegram/multipart.js +47 -0
- package/dist/telegram/types.d.ts +271 -0
- package/dist/telegram/update.d.ts +833 -0
- package/dist/telegram/update.js +129 -0
- package/dist/testing/fake-html.d.ts +9 -0
- package/dist/testing/fake-html.js +83 -0
- package/dist/testing/fake-telegram.d.ts +206 -0
- package/dist/testing/fake-telegram.js +856 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/reference-sql.d.ts +4 -0
- package/dist/testing/reference-sql.js +8 -0
- package/dist/testing/test-plugin.d.ts +126 -0
- package/dist/testing/test-plugin.js +182 -0
- package/dist/testing/updates.d.ts +100 -0
- package/dist/testing/updates.js +212 -0
- package/package.json +113 -3
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { BotToken } from "../contracts/bots.js";
|
|
2
|
+
import { webhookSecret } from "../core/random.js";
|
|
3
|
+
import { sql } from "../ports/sql.js";
|
|
4
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
5
|
+
import { settle } from "../telegram/classify.js";
|
|
6
|
+
import { isBotUsable, loadBot, tokenAad, webhookAad, withToken } from "../engine/bot-api.js";
|
|
7
|
+
import { botDto } from "../engine/rows.js";
|
|
8
|
+
import { checkBotFeatures } from "../engine/bot-features.js";
|
|
9
|
+
import { STATE_KEYS, setStateSql } from "../engine/state.js";
|
|
10
|
+
import { syncCommandMenu } from "./menu.js";
|
|
11
|
+
// src/services/bots.ts
|
|
12
|
+
const ALLOWED_UPDATES = [
|
|
13
|
+
"message",
|
|
14
|
+
"edited_message",
|
|
15
|
+
"callback_query",
|
|
16
|
+
"my_chat_member"
|
|
17
|
+
];
|
|
18
|
+
function actorId(rt, actor) {
|
|
19
|
+
return rt.describe(actor).userId;
|
|
20
|
+
}
|
|
21
|
+
function webhookUrl(rt, botId) {
|
|
22
|
+
return rt.publicBaseUrl ? `${rt.publicBaseUrl}/webhook/${botId}` : null;
|
|
23
|
+
}
|
|
24
|
+
function unreachable(o) {
|
|
25
|
+
return o.kind === "network" || o.kind === "timeout" || o.kind === "server_error" || o.kind === "retry_after";
|
|
26
|
+
}
|
|
27
|
+
async function requireBot(rt, id) {
|
|
28
|
+
const bot = await loadBot(rt, id);
|
|
29
|
+
if (!bot) throw new TelegramPluginError("not_found", "bot not found");
|
|
30
|
+
return bot;
|
|
31
|
+
}
|
|
32
|
+
/** Bot API handle for a plaintext token (connect / rotate only, before it is stored). */
|
|
33
|
+
function withPlainToken(rt, token, fn) {
|
|
34
|
+
return rt.tg.withToken(() => token, fn);
|
|
35
|
+
}
|
|
36
|
+
/** Checks the format, calls getMe and checks that the id matches the token prefix. */
|
|
37
|
+
async function verifyToken(rt, token, mismatch, expectId) {
|
|
38
|
+
if (!BotToken.safeParse(token).success) throw new TelegramPluginError("bot_token_invalid", "token format is not valid");
|
|
39
|
+
const prefix = token.slice(0, token.indexOf(":"));
|
|
40
|
+
const r = await withPlainToken(rt, token, (api) => settle(api.getMe()));
|
|
41
|
+
if (r.outcome.kind !== "ok" || !r.value) {
|
|
42
|
+
if (unreachable(r.outcome)) throw new TelegramPluginError("bot_unreachable", "Telegram could not be reached");
|
|
43
|
+
throw new TelegramPluginError("bot_token_invalid", "Telegram rejected the token");
|
|
44
|
+
}
|
|
45
|
+
const me = r.value;
|
|
46
|
+
if (String(me.id) !== prefix) throw new TelegramPluginError(mismatch, "token does not match the bot id");
|
|
47
|
+
if (expectId !== void 0 && String(me.id) !== expectId) throw new TelegramPluginError("bot_token_mismatch", "this token belongs to a different bot");
|
|
48
|
+
return {
|
|
49
|
+
tgBotId: String(me.id),
|
|
50
|
+
username: me.username ?? ""
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** setWebhook (webhook mode) or deleteWebhook (polling mode). Returns the status to store. */
|
|
54
|
+
async function applyWebhook(rt, bot, secret, dropPending) {
|
|
55
|
+
const url = webhookUrl(rt, bot.id);
|
|
56
|
+
return withToken(rt, bot, async (api) => {
|
|
57
|
+
if (!url) {
|
|
58
|
+
const r = await settle(api.deleteWebhook({ drop_pending_updates: false }));
|
|
59
|
+
return {
|
|
60
|
+
mode: "polling",
|
|
61
|
+
status: "active",
|
|
62
|
+
detail: r.outcome.kind === "ok" ? null : r.outcome.description ?? null
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const r = await settle(api.setWebhook({
|
|
66
|
+
url,
|
|
67
|
+
secret_token: secret,
|
|
68
|
+
allowed_updates: ALLOWED_UPDATES,
|
|
69
|
+
drop_pending_updates: dropPending,
|
|
70
|
+
max_connections: 40
|
|
71
|
+
}));
|
|
72
|
+
if (r.outcome.kind === "ok") return {
|
|
73
|
+
mode: "webhook",
|
|
74
|
+
status: "active",
|
|
75
|
+
detail: null
|
|
76
|
+
};
|
|
77
|
+
rt.log.warn("tg.webhook_failed", {
|
|
78
|
+
botId: bot.id,
|
|
79
|
+
code: r.outcome.reason ?? r.outcome.kind
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
mode: "webhook",
|
|
83
|
+
status: "webhook_error",
|
|
84
|
+
detail: (r.outcome.description ?? "setWebhook failed").slice(0, 300)
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function alreadyConnected() {
|
|
89
|
+
return new TelegramPluginError("validation_failed", "this bot is already connected", { extensions: { issues: [{
|
|
90
|
+
path: "token",
|
|
91
|
+
message: "bot already connected"
|
|
92
|
+
}] } });
|
|
93
|
+
}
|
|
94
|
+
function isBotUniqueViolation(e) {
|
|
95
|
+
const msg = e instanceof Error ? `${e.message} ${e.cause instanceof Error ? e.cause.message : ""}` : "";
|
|
96
|
+
return /UNIQUE constraint failed: tg_bot\.|tg_bot_tg_bot_uq/.test(msg);
|
|
97
|
+
}
|
|
98
|
+
async function listBots(rt, opts) {
|
|
99
|
+
return await rt.db.all(sql`SELECT * FROM tg_bot WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
|
|
100
|
+
${opts.cursor ? sql`AND id < ${opts.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${opts.limit + 1}`);
|
|
101
|
+
}
|
|
102
|
+
async function getBot(rt, id) {
|
|
103
|
+
return botDto(await requireBot(rt, id));
|
|
104
|
+
}
|
|
105
|
+
async function connectBot(rt, actor, input) {
|
|
106
|
+
if (!rt.publicBaseUrl && rt.appEnv === "production") throw new TelegramPluginError("webhook_url_missing", "publicBaseUrl is required in production");
|
|
107
|
+
const me = await verifyToken(rt, input.token, "bot_token_invalid");
|
|
108
|
+
if (await rt.db.first(sql`SELECT id FROM tg_bot WHERE tenant = ${rt.tenant} AND tg_bot_id = ${me.tgBotId} AND deleted_at IS NULL`)) throw alreadyConnected();
|
|
109
|
+
const now = rt.clock.now();
|
|
110
|
+
const id = rt.ids.uuid();
|
|
111
|
+
const secret = webhookSecret(rt.rand);
|
|
112
|
+
const tokenEnc = await rt.keyring.encrypt(input.token, tokenAad(id));
|
|
113
|
+
const secretEnc = await rt.keyring.encrypt(secret, webhookAad(id));
|
|
114
|
+
const by = actorId(rt, actor);
|
|
115
|
+
const locale = rt.i18n.pick(input.defaultLocale);
|
|
116
|
+
await rt.db.batch([sql`INSERT INTO tg_bot (id, tenant, name, tg_bot_id, username, token_enc, webhook_secret_enc, mode, status,
|
|
117
|
+
default_locale, rate_per_sec, created_by, created_at, updated_at)
|
|
118
|
+
VALUES (${id}, ${rt.tenant}, ${input.name}, ${me.tgBotId}, ${me.username}, ${tokenEnc}, ${secretEnc},
|
|
119
|
+
${rt.publicBaseUrl ? "webhook" : "polling"}, 'active', ${locale}, ${rt.limits.perBotPerSec}, ${by}, ${now}, ${now})`, ...rt.audit({
|
|
120
|
+
actor: by,
|
|
121
|
+
entity: "telegram_bot",
|
|
122
|
+
entityId: id,
|
|
123
|
+
action: "connect",
|
|
124
|
+
diff: { username: me.username },
|
|
125
|
+
at: now
|
|
126
|
+
})]).catch((e) => {
|
|
127
|
+
throw isBotUniqueViolation(e) ? alreadyConnected() : e;
|
|
128
|
+
});
|
|
129
|
+
const hook = await applyWebhook(rt, {
|
|
130
|
+
id,
|
|
131
|
+
token_enc: tokenEnc
|
|
132
|
+
}, secret, true);
|
|
133
|
+
await rt.db.run(sql`UPDATE tg_bot SET mode = ${hook.mode}, status = ${hook.status}, status_detail = ${hook.detail},
|
|
134
|
+
checked_at = ${now}, updated_at = ${now} WHERE id = ${id}`);
|
|
135
|
+
const bot = await requireBot(rt, id);
|
|
136
|
+
await syncCommandMenu(rt, bot);
|
|
137
|
+
rt.log.info("tg.bot_connected", {
|
|
138
|
+
botId: id,
|
|
139
|
+
mode: hook.mode,
|
|
140
|
+
status: hook.status
|
|
141
|
+
});
|
|
142
|
+
return botDto(await loadBot(rt, id) ?? bot);
|
|
143
|
+
}
|
|
144
|
+
async function patchBot(rt, actor, id, patch) {
|
|
145
|
+
const bot = await requireBot(rt, id);
|
|
146
|
+
if (patch.features !== void 0) checkBotFeatures(rt.features, patch.features);
|
|
147
|
+
const now = rt.clock.now();
|
|
148
|
+
const sets = [];
|
|
149
|
+
if (patch.name !== void 0) sets.push(sql`name = ${patch.name}`);
|
|
150
|
+
if (patch.defaultLocale !== void 0) sets.push(sql`default_locale = ${rt.i18n.pick(patch.defaultLocale)}`);
|
|
151
|
+
if (patch.features !== void 0) sets.push(sql`features_json = ${JSON.stringify(patch.features)}`);
|
|
152
|
+
if (patch.ratePerSec !== void 0) sets.push(sql`rate_per_sec = ${patch.ratePerSec}`);
|
|
153
|
+
if (patch.enabled === false && bot.status !== "disabled") sets.push(sql`status = 'disabled'`);
|
|
154
|
+
if (patch.enabled === true && bot.status === "disabled") sets.push(sql`status = 'active', paused_until = NULL`);
|
|
155
|
+
if (sets.length > 0) {
|
|
156
|
+
const by = actorId(rt, actor);
|
|
157
|
+
await rt.db.batch([sql`UPDATE tg_bot SET ${sql.join(sets)}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
158
|
+
actor: by,
|
|
159
|
+
entity: "telegram_bot",
|
|
160
|
+
entityId: id,
|
|
161
|
+
action: "update",
|
|
162
|
+
diff: { fields: Object.keys(patch) },
|
|
163
|
+
at: now
|
|
164
|
+
})]);
|
|
165
|
+
}
|
|
166
|
+
const updated = await requireBot(rt, id);
|
|
167
|
+
if (updated.status !== bot.status) await rt.hook("onBotStatus", (fn) => fn(botDto(updated)));
|
|
168
|
+
if ((patch.defaultLocale !== void 0 || patch.features !== void 0) && isBotUsable(updated)) await syncCommandMenu(rt, updated);
|
|
169
|
+
return botDto(updated);
|
|
170
|
+
}
|
|
171
|
+
/** Soft delete: deleteWebhook (best effort), status disabled, queued deliveries cancelled. */
|
|
172
|
+
async function deleteBot(rt, actor, id) {
|
|
173
|
+
const bot = await requireBot(rt, id);
|
|
174
|
+
const now = rt.clock.now();
|
|
175
|
+
await withToken(rt, bot, (api) => settle(api.deleteWebhook({ drop_pending_updates: true }))).catch(() => null);
|
|
176
|
+
const by = actorId(rt, actor);
|
|
177
|
+
await rt.db.batch([
|
|
178
|
+
sql`UPDATE tg_bot SET status = 'disabled', deleted_at = ${now}, updated_at = ${now}, drain_lease_until = NULL,
|
|
179
|
+
drain_token = NULL WHERE tenant = ${rt.tenant} AND id = ${id}`,
|
|
180
|
+
sql`UPDATE tg_delivery SET status = 'cancelled', error_code = 'bot_deleted', updated_at = ${now}
|
|
181
|
+
WHERE tenant = ${rt.tenant} AND bot_id = ${id} AND status IN ('queued', 'sending')`,
|
|
182
|
+
...rt.audit({
|
|
183
|
+
actor: by,
|
|
184
|
+
entity: "telegram_bot",
|
|
185
|
+
entityId: id,
|
|
186
|
+
action: "delete",
|
|
187
|
+
at: now
|
|
188
|
+
})
|
|
189
|
+
]);
|
|
190
|
+
rt.log.info("tg.bot_deleted", { botId: id });
|
|
191
|
+
}
|
|
192
|
+
/** getMe + getWebhookInfo; records status and health (no secrets). */
|
|
193
|
+
async function testBot(rt, id) {
|
|
194
|
+
const bot = await requireBot(rt, id);
|
|
195
|
+
const now = rt.clock.now();
|
|
196
|
+
const expectedUrl = webhookUrl(rt, bot.id);
|
|
197
|
+
const out = await withToken(rt, bot, async (api) => {
|
|
198
|
+
const me = await settle(api.getMe());
|
|
199
|
+
return {
|
|
200
|
+
me,
|
|
201
|
+
info: me.outcome.kind === "ok" ? await settle(api.getWebhookInfo()) : null
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
let status = bot.status === "disabled" ? "disabled" : "active";
|
|
205
|
+
let detail = null;
|
|
206
|
+
const health = {
|
|
207
|
+
pendingUpdateCount: null,
|
|
208
|
+
lastErrorAt: null,
|
|
209
|
+
lastErrorMessage: null,
|
|
210
|
+
webhookUrlSet: false,
|
|
211
|
+
checkedAt: now
|
|
212
|
+
};
|
|
213
|
+
if (out.me.outcome.kind !== "ok") {
|
|
214
|
+
if (out.me.outcome.kind === "unauthorized") {
|
|
215
|
+
status = "invalid_token";
|
|
216
|
+
detail = out.me.outcome.description ?? "Unauthorized";
|
|
217
|
+
} else throw new TelegramPluginError("bot_unreachable", "Telegram could not be reached");
|
|
218
|
+
} else if (out.info?.value) {
|
|
219
|
+
const w = out.info.value;
|
|
220
|
+
health.pendingUpdateCount = w.pending_update_count ?? null;
|
|
221
|
+
health.lastErrorAt = w.last_error_date ? w.last_error_date * 1e3 : null;
|
|
222
|
+
health.lastErrorMessage = w.last_error_message ?? null;
|
|
223
|
+
health.webhookUrlSet = w.url !== "" && w.url === expectedUrl;
|
|
224
|
+
if (bot.mode === "webhook" && !health.webhookUrlSet && status !== "disabled") {
|
|
225
|
+
status = "webhook_error";
|
|
226
|
+
detail = "webhook is not set to this app";
|
|
227
|
+
} else detail = health.lastErrorMessage;
|
|
228
|
+
}
|
|
229
|
+
await rt.db.batch([sql`UPDATE tg_bot SET status = ${status}, status_detail = ${detail ? detail.slice(0, 300) : null},
|
|
230
|
+
checked_at = ${now}, updated_at = ${now},
|
|
231
|
+
paused_until = CASE WHEN ${status} = 'active' AND status = 'invalid_token' THEN NULL ELSE paused_until END
|
|
232
|
+
WHERE tenant = ${rt.tenant} AND id = ${id}`, setStateSql(rt, STATE_KEYS.botHealth(id), JSON.stringify(health), now)]);
|
|
233
|
+
if (status !== bot.status) await rt.hook("onBotStatus", (fn) => fn({
|
|
234
|
+
...botDto(bot),
|
|
235
|
+
status,
|
|
236
|
+
updatedAt: now
|
|
237
|
+
}));
|
|
238
|
+
return {
|
|
239
|
+
ok: status === "active",
|
|
240
|
+
status,
|
|
241
|
+
username: out.me.value?.username ?? null,
|
|
242
|
+
webhookUrlSet: health.webhookUrlSet,
|
|
243
|
+
pendingUpdateCount: health.pendingUpdateCount,
|
|
244
|
+
lastErrorAt: health.lastErrorAt,
|
|
245
|
+
lastErrorMessage: health.lastErrorMessage
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** New token from BotFather for the same bot (same `tg_bot_id`); re-encrypt and set the webhook again. */
|
|
249
|
+
async function rotateToken(rt, actor, id, token) {
|
|
250
|
+
const bot = await requireBot(rt, id);
|
|
251
|
+
const me = await verifyToken(rt, token, "bot_token_mismatch", bot.tg_bot_id);
|
|
252
|
+
const now = rt.clock.now();
|
|
253
|
+
const tokenEnc = await rt.keyring.encrypt(token, tokenAad(id));
|
|
254
|
+
const secret = bot.webhook_secret_enc ? await rt.keyring.decrypt(bot.webhook_secret_enc, webhookAad(id)) : webhookSecret(rt.rand);
|
|
255
|
+
const secretEnc = await rt.keyring.encrypt(secret, webhookAad(id));
|
|
256
|
+
const by = actorId(rt, actor);
|
|
257
|
+
await rt.db.batch([sql`UPDATE tg_bot SET token_enc = ${tokenEnc}, webhook_secret_enc = ${secretEnc}, username = ${me.username},
|
|
258
|
+
status = CASE WHEN status = 'disabled' THEN 'disabled' ELSE 'active' END, status_detail = NULL,
|
|
259
|
+
paused_until = NULL, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
260
|
+
actor: by,
|
|
261
|
+
entity: "telegram_bot",
|
|
262
|
+
entityId: id,
|
|
263
|
+
action: "rotate_token",
|
|
264
|
+
at: now
|
|
265
|
+
})]);
|
|
266
|
+
const hook = await applyWebhook(rt, {
|
|
267
|
+
id,
|
|
268
|
+
token_enc: tokenEnc
|
|
269
|
+
}, secret, false);
|
|
270
|
+
await rt.db.run(sql`UPDATE tg_bot SET mode = ${hook.mode},
|
|
271
|
+
status = CASE WHEN status = 'disabled' THEN 'disabled' ELSE ${hook.status} END,
|
|
272
|
+
status_detail = ${hook.detail}, checked_at = ${now} WHERE id = ${id}`);
|
|
273
|
+
const updated = await requireBot(rt, id);
|
|
274
|
+
if (updated.status !== bot.status) await rt.hook("onBotStatus", (fn) => fn(botDto(updated)));
|
|
275
|
+
await syncCommandMenu(rt, updated, { force: true });
|
|
276
|
+
return botDto(updated);
|
|
277
|
+
}
|
|
278
|
+
/** New webhook secret and setWebhook again (also switches polling → webhook once publicBaseUrl is set). */
|
|
279
|
+
async function resetWebhook(rt, actor, id) {
|
|
280
|
+
const bot = await requireBot(rt, id);
|
|
281
|
+
const now = rt.clock.now();
|
|
282
|
+
const secret = webhookSecret(rt.rand);
|
|
283
|
+
const secretEnc = await rt.keyring.encrypt(secret, webhookAad(id));
|
|
284
|
+
const by = actorId(rt, actor);
|
|
285
|
+
await rt.db.batch([sql`UPDATE tg_bot SET webhook_secret_enc = ${secretEnc}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
286
|
+
actor: by,
|
|
287
|
+
entity: "telegram_bot",
|
|
288
|
+
entityId: id,
|
|
289
|
+
action: "webhook_reset",
|
|
290
|
+
at: now
|
|
291
|
+
})]);
|
|
292
|
+
const hook = await applyWebhook(rt, bot, secret, false);
|
|
293
|
+
await rt.db.run(sql`UPDATE tg_bot SET mode = ${hook.mode},
|
|
294
|
+
status = CASE WHEN status IN ('disabled', 'invalid_token') THEN status ELSE ${hook.status} END,
|
|
295
|
+
status_detail = ${hook.detail}, checked_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`);
|
|
296
|
+
const updated = await requireBot(rt, id);
|
|
297
|
+
if (updated.status !== bot.status) await rt.hook("onBotStatus", (fn) => fn(botDto(updated)));
|
|
298
|
+
return botDto(updated);
|
|
299
|
+
}
|
|
300
|
+
export { ALLOWED_UPDATES, actorId, connectBot, deleteBot, getBot, listBots, patchBot, resetWebhook, rotateToken, testBot, webhookUrl };
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { LocalizedText } from "../contracts/common.js";
|
|
2
|
+
import { Recipients } from "../contracts/recipients.js";
|
|
3
|
+
import { BroadcastStats, FileRef } from "../contracts/broadcasts.js";
|
|
4
|
+
import { topics } from "../core/recipients.js";
|
|
5
|
+
import { sql } from "../ports/sql.js";
|
|
6
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
7
|
+
import { checkBody, renderBody, templateProblem } from "../engine/content/render.js";
|
|
8
|
+
import { hostCan, parseProviderParams, requireProvider, sampleData } from "../engine/content/providers.js";
|
|
9
|
+
import { parseJson, parseObject } from "../engine/rows.js";
|
|
10
|
+
import { buildButtons } from "../engine/content/buttons.js";
|
|
11
|
+
import { viewerData } from "./reports.js";
|
|
12
|
+
import { audienceForPreview, audienceOf } from "../engine/content/audience.js";
|
|
13
|
+
import { templateById, templateModel } from "../engine/content/template-rows.js";
|
|
14
|
+
import { deliveryFragments } from "../engine/outbox/enqueue.js";
|
|
15
|
+
import { actorId } from "./bots.js";
|
|
16
|
+
import { fanOut } from "../engine/content/fanout.js";
|
|
17
|
+
import { resolveRecipients } from "../engine/content/recipients.js";
|
|
18
|
+
// src/services/broadcasts.ts
|
|
19
|
+
const STATUSES = [
|
|
20
|
+
"draft",
|
|
21
|
+
"queued",
|
|
22
|
+
"sending",
|
|
23
|
+
"done"
|
|
24
|
+
];
|
|
25
|
+
function broadcastDto(r) {
|
|
26
|
+
return {
|
|
27
|
+
id: r.id,
|
|
28
|
+
title: r.title,
|
|
29
|
+
templateId: r.template_id,
|
|
30
|
+
body: parseJson(r.body_json, LocalizedText.nullable(), null),
|
|
31
|
+
params: parseObject(r.params_json),
|
|
32
|
+
file: parseJson(r.file_ref, FileRef.nullable(), null),
|
|
33
|
+
recipients: parseJson(r.recipients_json, Recipients, []),
|
|
34
|
+
status: STATUSES.includes(r.status) ? r.status : "draft",
|
|
35
|
+
stats: parseJson(r.stats_json, BroadcastStats.nullable(), null),
|
|
36
|
+
createdBy: r.created_by,
|
|
37
|
+
createdAt: r.created_at,
|
|
38
|
+
sentAt: r.sent_at
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function issue(path, message) {
|
|
42
|
+
return new TelegramPluginError("validation_failed", message, { extensions: { issues: [{
|
|
43
|
+
path,
|
|
44
|
+
message
|
|
45
|
+
}] } });
|
|
46
|
+
}
|
|
47
|
+
const json = (v) => v === null || v === void 0 ? null : JSON.stringify(v);
|
|
48
|
+
async function validateSave(rt, save) {
|
|
49
|
+
if (save.templateId !== null) {
|
|
50
|
+
if (save.file !== null) throw issue("body.file", "a file broadcast takes an inline caption body, not a template");
|
|
51
|
+
const row = await templateById(rt, save.templateId);
|
|
52
|
+
if (!row) throw issue("body.templateId", "template not found");
|
|
53
|
+
const t = templateModel(row);
|
|
54
|
+
if (t.kind === "report") throw issue("body.templateId", "broadcasts use message templates");
|
|
55
|
+
if (t.provider) parseProviderParams(requireProvider(rt, t.provider), {
|
|
56
|
+
...t.providerParams,
|
|
57
|
+
...save.params
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (save.body !== null) {
|
|
61
|
+
const errors = checkBody(save.body, "html", { filters: rt.filters });
|
|
62
|
+
if (errors.length > 0) throw templateProblem(errors);
|
|
63
|
+
}
|
|
64
|
+
if (save.file !== null && !rt.registry.fileSources.has(save.file.source)) {
|
|
65
|
+
if (save.file.source !== "files" || !rt.files) throw issue("body.file.source", `unknown file source "${save.file.source}"`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function listBroadcasts(rt, q, page) {
|
|
69
|
+
return rt.db.all(sql`SELECT * FROM tg_broadcast WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
|
|
70
|
+
${q.status ? sql`AND status = ${q.status}` : sql.empty}
|
|
71
|
+
${page.cursor ? sql`AND id < ${page.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${page.limit + 1}`);
|
|
72
|
+
}
|
|
73
|
+
async function requireBroadcast(rt, id) {
|
|
74
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_broadcast WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`);
|
|
75
|
+
if (!row) throw new TelegramPluginError("not_found", "broadcast not found");
|
|
76
|
+
return row;
|
|
77
|
+
}
|
|
78
|
+
async function requireDraft(rt, id) {
|
|
79
|
+
const row = await requireBroadcast(rt, id);
|
|
80
|
+
if (row.status !== "draft") throw new TelegramPluginError("validation_failed", "only drafts can be changed");
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
async function createBroadcast(rt, actor, save) {
|
|
84
|
+
await validateSave(rt, save);
|
|
85
|
+
const now = rt.clock.now();
|
|
86
|
+
const by = actorId(rt, actor);
|
|
87
|
+
const id = rt.ids.uuid();
|
|
88
|
+
await rt.db.batch([sql`INSERT INTO tg_broadcast (id, tenant, title, template_id, body_json, params_json, file_ref, recipients_json, status,
|
|
89
|
+
created_by, created_at, updated_at)
|
|
90
|
+
VALUES (${id}, ${rt.tenant}, ${save.title}, ${save.templateId}, ${json(save.body)}, ${json(save.params)},
|
|
91
|
+
${json(save.file)}, ${JSON.stringify(save.recipients)}, 'draft', ${by}, ${now}, ${now})`, ...rt.audit({
|
|
92
|
+
actor: by,
|
|
93
|
+
entity: "telegram_broadcast",
|
|
94
|
+
entityId: id,
|
|
95
|
+
action: "create",
|
|
96
|
+
at: now
|
|
97
|
+
})]);
|
|
98
|
+
return broadcastDto(await requireBroadcast(rt, id));
|
|
99
|
+
}
|
|
100
|
+
async function updateBroadcast(rt, actor, id, save) {
|
|
101
|
+
await requireDraft(rt, id);
|
|
102
|
+
await validateSave(rt, save);
|
|
103
|
+
const now = rt.clock.now();
|
|
104
|
+
const by = actorId(rt, actor);
|
|
105
|
+
if ((await rt.db.batch([sql`UPDATE tg_broadcast SET title = ${save.title}, template_id = ${save.templateId}, body_json = ${json(save.body)},
|
|
106
|
+
params_json = ${json(save.params)}, file_ref = ${json(save.file)}, recipients_json = ${JSON.stringify(save.recipients)},
|
|
107
|
+
updated_at = ${now}
|
|
108
|
+
WHERE tenant = ${rt.tenant} AND id = ${id} AND status = 'draft' AND deleted_at IS NULL`, ...rt.audit({
|
|
109
|
+
actor: by,
|
|
110
|
+
entity: "telegram_broadcast",
|
|
111
|
+
entityId: id,
|
|
112
|
+
action: "update",
|
|
113
|
+
at: now
|
|
114
|
+
})]))[0]?.changes === 0) throw new TelegramPluginError("validation_failed", "only drafts can be changed");
|
|
115
|
+
return broadcastDto(await requireBroadcast(rt, id));
|
|
116
|
+
}
|
|
117
|
+
async function deleteBroadcast(rt, actor, id) {
|
|
118
|
+
await requireDraft(rt, id);
|
|
119
|
+
const now = rt.clock.now();
|
|
120
|
+
const by = actorId(rt, actor);
|
|
121
|
+
await rt.db.batch([sql`UPDATE tg_broadcast SET deleted_at = ${now}, updated_at = ${now}
|
|
122
|
+
WHERE tenant = ${rt.tenant} AND id = ${id} AND status = 'draft'`, ...rt.audit({
|
|
123
|
+
actor: by,
|
|
124
|
+
entity: "telegram_broadcast",
|
|
125
|
+
entityId: id,
|
|
126
|
+
action: "delete",
|
|
127
|
+
at: now
|
|
128
|
+
})]);
|
|
129
|
+
}
|
|
130
|
+
/** Opens a file through its source as `actor` (null at send time re-opens without the check). */
|
|
131
|
+
async function openFile(rt, actor, ref) {
|
|
132
|
+
let file = null;
|
|
133
|
+
const def = rt.registry.fileSources.get(ref.source);
|
|
134
|
+
if (def) file = await def.resolve({
|
|
135
|
+
actor,
|
|
136
|
+
ref: ref.ref,
|
|
137
|
+
env: rt.env
|
|
138
|
+
});
|
|
139
|
+
else if (ref.source === "files" && rt.files) {
|
|
140
|
+
if (!rt.files.canRead || !await rt.files.canRead(actor, ref.ref)) throw new TelegramPluginError("forbidden", "file not readable");
|
|
141
|
+
file = await rt.files.open(ref.ref, actor);
|
|
142
|
+
} else throw new TelegramPluginError("validation_failed", `unknown file source "${ref.source}"`);
|
|
143
|
+
if (!file) throw new TelegramPluginError("not_found", "file not found");
|
|
144
|
+
await file.body.cancel().catch(() => {});
|
|
145
|
+
return file;
|
|
146
|
+
}
|
|
147
|
+
/** The message content: a saved template, or an inline body as an ad-hoc template (no provider). */
|
|
148
|
+
async function contentModel(rt, b) {
|
|
149
|
+
if (b.templateId) {
|
|
150
|
+
const row = await templateById(rt, b.templateId);
|
|
151
|
+
if (!row) throw new TelegramPluginError("not_found", "the broadcast's template is gone");
|
|
152
|
+
return templateModel(row);
|
|
153
|
+
}
|
|
154
|
+
if (!b.body) return null;
|
|
155
|
+
return {
|
|
156
|
+
id: `broadcast:${b.id}`,
|
|
157
|
+
key: `broadcast:${b.id}`,
|
|
158
|
+
name: b.title,
|
|
159
|
+
kind: "message",
|
|
160
|
+
provider: null,
|
|
161
|
+
providerParams: null,
|
|
162
|
+
body: b.body,
|
|
163
|
+
format: "html",
|
|
164
|
+
report: null,
|
|
165
|
+
layout: null,
|
|
166
|
+
buttons: null,
|
|
167
|
+
options: {},
|
|
168
|
+
version: 1
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** What the role / user would get (live provider data, or the provider's sample). Writes nothing. */
|
|
172
|
+
async function previewBroadcast(rt, actor, id, req) {
|
|
173
|
+
const b = broadcastDto(await requireBroadcast(rt, id));
|
|
174
|
+
const t = await contentModel(rt, b);
|
|
175
|
+
const aud = await audienceForPreview(rt, req.as);
|
|
176
|
+
const tz = rt.settings.timezone;
|
|
177
|
+
const now = rt.clock.now();
|
|
178
|
+
let vars = b.templateId ? {} : { ...b.params };
|
|
179
|
+
if (t?.provider) {
|
|
180
|
+
const def = requireProvider(rt, t.provider);
|
|
181
|
+
if (req.sample) vars = sampleData(rt, def);
|
|
182
|
+
else {
|
|
183
|
+
if (!await hostCan(rt, aud, def.permission)) throw new TelegramPluginError("provider_forbidden", `this audience may not read "${def.key}"`);
|
|
184
|
+
const params = parseProviderParams(def, {
|
|
185
|
+
...t.providerParams,
|
|
186
|
+
...b.params
|
|
187
|
+
});
|
|
188
|
+
vars = await viewerData(rt, {
|
|
189
|
+
def,
|
|
190
|
+
actor: aud,
|
|
191
|
+
viewer: actor,
|
|
192
|
+
params,
|
|
193
|
+
now,
|
|
194
|
+
tz,
|
|
195
|
+
locale: req.locale
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const body = t ? renderBody(rt, {
|
|
200
|
+
body: t.body,
|
|
201
|
+
format: t.format,
|
|
202
|
+
vars,
|
|
203
|
+
locales: [req.locale],
|
|
204
|
+
tz
|
|
205
|
+
}) : null;
|
|
206
|
+
const buttons = await buildButtons(rt, t?.buttons ?? null, {
|
|
207
|
+
vars,
|
|
208
|
+
locale: body?.locale ?? req.locale,
|
|
209
|
+
tz,
|
|
210
|
+
botId: "",
|
|
211
|
+
chatRef: null,
|
|
212
|
+
userId: null,
|
|
213
|
+
now
|
|
214
|
+
});
|
|
215
|
+
let file = null;
|
|
216
|
+
if (b.file) {
|
|
217
|
+
const f = await openFile(rt, actor, b.file);
|
|
218
|
+
file = {
|
|
219
|
+
name: f.name,
|
|
220
|
+
mime: f.mime,
|
|
221
|
+
size: f.size
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
parts: body?.parts ?? [],
|
|
226
|
+
buttons: buttons.preview,
|
|
227
|
+
file,
|
|
228
|
+
warnings: body?.warnings ?? []
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function dedupeBase(id, chat) {
|
|
232
|
+
return `bc:${id}:${chat.chatRef}${chat.messageThreadId ? `#${chat.messageThreadId}` : ""}`;
|
|
233
|
+
}
|
|
234
|
+
const BATCH_STATEMENTS = 50;
|
|
235
|
+
/** File broadcast: one document per chat (caption = the inline body in the chat's locale). */
|
|
236
|
+
async function fanOutFile(rt, b, file, source, targets) {
|
|
237
|
+
const now = rt.clock.now();
|
|
238
|
+
let deliveries = 0;
|
|
239
|
+
let chats = 0;
|
|
240
|
+
let skipped = 0;
|
|
241
|
+
let batch = [];
|
|
242
|
+
const flush = async () => {
|
|
243
|
+
if (batch.length === 0) return;
|
|
244
|
+
for (const r of await rt.db.batch(batch)) deliveries += r.changes;
|
|
245
|
+
batch = [];
|
|
246
|
+
};
|
|
247
|
+
for (const { chat, notBefore } of targets) {
|
|
248
|
+
const aud = await audienceOf(rt, chat.row, false);
|
|
249
|
+
if (!aud) {
|
|
250
|
+
skipped++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const tz = chat.row.timezone ?? rt.settings.timezone;
|
|
254
|
+
const caption = b.body ? renderBody(rt, {
|
|
255
|
+
body: b.body,
|
|
256
|
+
format: "html",
|
|
257
|
+
vars: { ...b.params },
|
|
258
|
+
locales: [
|
|
259
|
+
chat.row.locale,
|
|
260
|
+
aud.info.locale,
|
|
261
|
+
chat.bot.default_locale
|
|
262
|
+
],
|
|
263
|
+
tz,
|
|
264
|
+
noSplit: true
|
|
265
|
+
}).html : "";
|
|
266
|
+
const rows = deliveryFragments(rt, {
|
|
267
|
+
botId: chat.botId,
|
|
268
|
+
chatRef: chat.chatRef,
|
|
269
|
+
threadId: chat.messageThreadId ?? null,
|
|
270
|
+
source: "broadcast",
|
|
271
|
+
sourceId: b.id,
|
|
272
|
+
kind: "document",
|
|
273
|
+
html: caption,
|
|
274
|
+
file: {
|
|
275
|
+
ref: file.ref,
|
|
276
|
+
name: file.name,
|
|
277
|
+
mime: file.mime,
|
|
278
|
+
source
|
|
279
|
+
},
|
|
280
|
+
dedupeKey: dedupeBase(b.id, chat),
|
|
281
|
+
notBefore,
|
|
282
|
+
render: { broadcast: b.id }
|
|
283
|
+
}, now);
|
|
284
|
+
if (batch.length + rows.length > BATCH_STATEMENTS) await flush();
|
|
285
|
+
batch.push(...rows);
|
|
286
|
+
chats++;
|
|
287
|
+
}
|
|
288
|
+
await flush();
|
|
289
|
+
return {
|
|
290
|
+
deliveries,
|
|
291
|
+
chats,
|
|
292
|
+
skipped
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Sends a broadcast: draft → sending → done. A broadcast left in `sending` (a crash mid-way) can be sent again
|
|
297
|
+
* and resumes; dedupe keys make that safe. Sending a `done` broadcast again changes nothing.
|
|
298
|
+
*/
|
|
299
|
+
async function sendBroadcast(rt, actor, id) {
|
|
300
|
+
const current = await requireBroadcast(rt, id);
|
|
301
|
+
if (current.status === "done") return broadcastDto(current);
|
|
302
|
+
const b = broadcastDto(current);
|
|
303
|
+
const template = await contentModel(rt, b);
|
|
304
|
+
const file = b.file ? await openFile(rt, actor, b.file) : null;
|
|
305
|
+
if (file && file.size !== null && file.size > 52428800) throw new TelegramPluginError("file_too_large", "the file is over Telegram's 50 MB upload limit");
|
|
306
|
+
const now = rt.clock.now();
|
|
307
|
+
const by = actorId(rt, actor);
|
|
308
|
+
const [claimed] = await rt.db.batch([sql`UPDATE tg_broadcast SET status = 'sending', updated_at = ${now}
|
|
309
|
+
WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL AND status IN ('draft', 'sending')`, ...rt.audit({
|
|
310
|
+
actor: by,
|
|
311
|
+
entity: "telegram_broadcast",
|
|
312
|
+
entityId: id,
|
|
313
|
+
action: "send",
|
|
314
|
+
at: now
|
|
315
|
+
})]);
|
|
316
|
+
if (!claimed || claimed.changes === 0) return broadcastDto(await requireBroadcast(rt, id));
|
|
317
|
+
const recipients = await resolveRecipients(rt, b.recipients, {
|
|
318
|
+
topic: topics.broadcast(),
|
|
319
|
+
now
|
|
320
|
+
});
|
|
321
|
+
const targets = [...recipients.send.map((chat) => ({
|
|
322
|
+
chat,
|
|
323
|
+
notBefore: void 0
|
|
324
|
+
})), ...recipients.held.map((h) => ({
|
|
325
|
+
chat: h.chat,
|
|
326
|
+
notBefore: h.until
|
|
327
|
+
}))];
|
|
328
|
+
if (file && b.file) await fanOutFile(rt, b, file, b.file.source, targets);
|
|
329
|
+
else if (template) await fanOut(rt, {
|
|
330
|
+
template,
|
|
331
|
+
params: b.templateId ? b.params : null,
|
|
332
|
+
...b.templateId ? {} : {
|
|
333
|
+
provider: null,
|
|
334
|
+
vars: () => ({ ...b.params })
|
|
335
|
+
},
|
|
336
|
+
recipients,
|
|
337
|
+
personalize: true,
|
|
338
|
+
source: "broadcast",
|
|
339
|
+
sourceId: b.id,
|
|
340
|
+
dedupeKey: (chat) => dedupeBase(b.id, chat),
|
|
341
|
+
now,
|
|
342
|
+
tz: rt.settings.timezone,
|
|
343
|
+
meta: { broadcast: b.id }
|
|
344
|
+
});
|
|
345
|
+
const stats = await broadcastStats(rt, id);
|
|
346
|
+
const done = rt.clock.now();
|
|
347
|
+
await rt.db.run(sql`UPDATE tg_broadcast SET status = 'done', sent_at = COALESCE(sent_at, ${done}), stats_json = ${JSON.stringify(stats)},
|
|
348
|
+
updated_at = ${done}
|
|
349
|
+
WHERE tenant = ${rt.tenant} AND id = ${id}`);
|
|
350
|
+
const bots = [...new Set(targets.map((t) => t.chat.botId))];
|
|
351
|
+
if (bots.length > 0) rt.waitUntil(rt.transport.kick(bots));
|
|
352
|
+
return broadcastDto(await requireBroadcast(rt, id));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Per-chat outcome from `tg_delivery` (source `broadcast`, source_id = the broadcast). Counts chats (a message to
|
|
356
|
+
* one chat + topic thread), not parts: any failed part → failed, any dropped / cancelled part → dropped, all
|
|
357
|
+
* parts sent → sent, otherwise queued.
|
|
358
|
+
*/
|
|
359
|
+
async function broadcastStats(rt, id) {
|
|
360
|
+
const rows = await rt.db.all(sql`SELECT COUNT(*) AS n, SUM(status = 'sent') AS sent, SUM(status = 'failed') AS failed,
|
|
361
|
+
SUM(status IN ('dropped', 'cancelled')) AS dropped
|
|
362
|
+
FROM tg_delivery WHERE tenant = ${rt.tenant} AND source = 'broadcast' AND source_id = ${id}
|
|
363
|
+
GROUP BY chat_ref, message_thread_id`);
|
|
364
|
+
const stats = {
|
|
365
|
+
total: rows.length,
|
|
366
|
+
queued: 0,
|
|
367
|
+
sent: 0,
|
|
368
|
+
failed: 0,
|
|
369
|
+
dropped: 0
|
|
370
|
+
};
|
|
371
|
+
for (const r of rows) if (r.failed > 0) stats.failed++;
|
|
372
|
+
else if (r.dropped > 0) stats.dropped++;
|
|
373
|
+
else if (r.sent === r.n) stats.sent++;
|
|
374
|
+
else stats.queued++;
|
|
375
|
+
return stats;
|
|
376
|
+
}
|
|
377
|
+
/** `GET /admin/broadcasts/:id/report`: fresh stats, also stored on the row. */
|
|
378
|
+
async function broadcastReport(rt, id) {
|
|
379
|
+
if ((await requireBroadcast(rt, id)).status === "draft") return {
|
|
380
|
+
total: 0,
|
|
381
|
+
queued: 0,
|
|
382
|
+
sent: 0,
|
|
383
|
+
failed: 0,
|
|
384
|
+
dropped: 0
|
|
385
|
+
};
|
|
386
|
+
const stats = await broadcastStats(rt, id);
|
|
387
|
+
await rt.db.run(sql`UPDATE tg_broadcast SET stats_json = ${JSON.stringify(stats)} WHERE tenant = ${rt.tenant} AND id = ${id}`);
|
|
388
|
+
return stats;
|
|
389
|
+
}
|
|
390
|
+
export { broadcastDto, broadcastReport, createBroadcast, deleteBroadcast, listBroadcasts, previewBroadcast, requireBroadcast, sendBroadcast, updateBroadcast };
|