@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.
- package/CHANGELOG.md +91 -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,256 @@
|
|
|
1
|
+
import { shortId } from "../../core/random.js";
|
|
2
|
+
import { sql } from "../../ports/sql.js";
|
|
3
|
+
import { pickVariant, renderInline } from "../content/render.js";
|
|
4
|
+
import { hostCan } from "../content/providers.js";
|
|
5
|
+
import { settle } from "../../telegram/classify.js";
|
|
6
|
+
import { withToken } from "../bot-api.js";
|
|
7
|
+
import { botDto, chatDto, parseJson } from "../rows.js";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
// src/engine/actions/callback.ts
|
|
10
|
+
const Markup = z.looseObject({ inline_keyboard: z.array(z.array(z.looseObject({ text: z.string() }))) });
|
|
11
|
+
const ActionResultShape = z.looseObject({
|
|
12
|
+
text: z.string().optional(),
|
|
13
|
+
sql: z.array(z.unknown()).optional()
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Cuts text to at most `max` UTF-16 code units without splitting a grapheme (emoji, combined Devanagari) or a
|
|
17
|
+
* surrogate pair. Uses `Intl.Segmenter` when available, else whole code points.
|
|
18
|
+
*/
|
|
19
|
+
function cutText(text, max = 200) {
|
|
20
|
+
if (text.length <= max) return text;
|
|
21
|
+
const pieces = typeof Intl.Segmenter === "function" ? Array.from(new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(text), (s) => s.segment) : Array.from(text);
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const p of pieces) {
|
|
24
|
+
if (out.length + p.length > max) break;
|
|
25
|
+
out += p;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
/** `answerCallbackQuery`, sent directly (never through the outbox), text cut to 200 chars (grapheme-safe). */
|
|
30
|
+
async function answer(ctx, text) {
|
|
31
|
+
const q = ctx.update.callback_query;
|
|
32
|
+
if (!q) return;
|
|
33
|
+
await withToken(ctx.rt, ctx.bot, async (api) => {
|
|
34
|
+
const r = await settle(api.answerCallbackQuery({
|
|
35
|
+
callback_query_id: q.id,
|
|
36
|
+
...text ? { text: cutText(text) } : {}
|
|
37
|
+
}));
|
|
38
|
+
if (r.outcome.kind !== "ok") ctx.rt.log.debug("tg.callback_answer_failed", { code: r.outcome.reason ?? r.outcome.kind });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function say(ctx, key) {
|
|
42
|
+
await answer(ctx, ctx.rt.i18n.plain(await ctx.locale(), key));
|
|
43
|
+
return "handled";
|
|
44
|
+
}
|
|
45
|
+
/** The keyboard the tapped message had: from the update, else from the delivery row that sent it. */
|
|
46
|
+
async function originalMarkup(ctx, fromDelivery) {
|
|
47
|
+
const msg = ctx.update.callback_query?.message;
|
|
48
|
+
if (!msg || !ctx.chat) return null;
|
|
49
|
+
if (!fromDelivery) {
|
|
50
|
+
const live = Markup.safeParse(msg.reply_markup);
|
|
51
|
+
if (live.success) return live.data;
|
|
52
|
+
}
|
|
53
|
+
const row = await ctx.rt.db.first(sql`SELECT reply_markup_json FROM tg_delivery WHERE tenant = ${ctx.rt.tenant} AND chat_ref = ${ctx.chat.id}
|
|
54
|
+
AND tg_message_id = ${msg.message_id} AND reply_markup_json IS NOT NULL ORDER BY part DESC LIMIT 1`);
|
|
55
|
+
const parsed = Markup.safeParse(parseJson(row?.reply_markup_json ?? null, z.unknown(), null));
|
|
56
|
+
return parsed.success ? parsed.data : null;
|
|
57
|
+
}
|
|
58
|
+
async function editMarkup(ctx, markup) {
|
|
59
|
+
const msg = ctx.update.callback_query?.message;
|
|
60
|
+
if (!msg || msg.date === 0) return false;
|
|
61
|
+
return withToken(ctx.rt, ctx.bot, async (api) => {
|
|
62
|
+
const r = await settle(api.editMessageReplyMarkup({
|
|
63
|
+
chat_id: msg.chat.id,
|
|
64
|
+
message_id: msg.message_id,
|
|
65
|
+
...markup ? { reply_markup: markup } : {}
|
|
66
|
+
}));
|
|
67
|
+
if (r.outcome.kind !== "ok") ctx.rt.log.debug("tg.callback_edit_failed", { code: r.outcome.reason ?? r.outcome.kind });
|
|
68
|
+
return r.outcome.kind === "ok";
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const isCallback = (b) => "callback_data" in b;
|
|
72
|
+
/** Every callback button disabled; the tapped one shows the result. Fallback: no keyboard. */
|
|
73
|
+
async function finishKeyboard(ctx, tokenId, label, original) {
|
|
74
|
+
if (original) {
|
|
75
|
+
if (await editMarkup(ctx, { inline_keyboard: original.inline_keyboard.map((row) => row.map((b) => {
|
|
76
|
+
if (!isCallback(b)) return b;
|
|
77
|
+
const mine = b.callback_data === `a:${tokenId}`;
|
|
78
|
+
return {
|
|
79
|
+
...b,
|
|
80
|
+
...mine ? { text: `✓ ${label}`.slice(0, 64) } : {},
|
|
81
|
+
disabled: true
|
|
82
|
+
};
|
|
83
|
+
})) })) return;
|
|
84
|
+
}
|
|
85
|
+
await editMarkup(ctx, null);
|
|
86
|
+
}
|
|
87
|
+
function localized(ctx, text, locale, vars) {
|
|
88
|
+
const src = pickVariant(text, [locale])?.source ?? "";
|
|
89
|
+
try {
|
|
90
|
+
return renderInline(ctx.rt, src, vars, {
|
|
91
|
+
locale,
|
|
92
|
+
tz: ctx.chat?.timezone ?? ctx.rt.settings.timezone
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
return src;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Who may tap: the token's user (private chats), or in groups any member whose own linked account may run it. */
|
|
99
|
+
async function tapper(ctx, token) {
|
|
100
|
+
const sender = await ctx.senderActor();
|
|
101
|
+
if (token.user_id !== null) {
|
|
102
|
+
if (!sender) return ctx.chat?.kind === "private" ? "not_yours" : "unlinked";
|
|
103
|
+
return sender.userId === token.user_id ? sender.actor : "not_yours";
|
|
104
|
+
}
|
|
105
|
+
return sender ? sender.actor : "unlinked";
|
|
106
|
+
}
|
|
107
|
+
/** How long a tap holds a single-use token while the host action runs. A crashed run frees it after this. */
|
|
108
|
+
const ACTION_LEASE_MS = 6e4;
|
|
109
|
+
const LEASE = "~lease:";
|
|
110
|
+
/** `~lease:<15-digit expiry>:<nonce>` in `tg_action_token.result` while a tap runs (no extra column). */
|
|
111
|
+
function leaseValue(until, nonce) {
|
|
112
|
+
return `${LEASE}${String(until).padStart(15, "0")}:${nonce}`;
|
|
113
|
+
}
|
|
114
|
+
/** A live lease on the token: another tap is running it right now. */
|
|
115
|
+
function leaseActive(result, now) {
|
|
116
|
+
if (!result?.startsWith(LEASE)) return false;
|
|
117
|
+
return Number(result.slice(7, 22)) > now;
|
|
118
|
+
}
|
|
119
|
+
/** The `a:<id>` tokens of the other callback buttons on the same message (siblings of the tapped one). */
|
|
120
|
+
function siblingTokenIds(markup, tokenId) {
|
|
121
|
+
if (!markup) return [];
|
|
122
|
+
const ids = /* @__PURE__ */ new Set();
|
|
123
|
+
for (const row of markup.inline_keyboard) for (const b of row) {
|
|
124
|
+
if (!isCallback(b) || !b.callback_data.startsWith("a:")) continue;
|
|
125
|
+
const id = b.callback_data.slice(2).split(":")[0] ?? "";
|
|
126
|
+
if (id !== tokenId && /^[A-Za-z0-9]{10}$/.test(id)) ids.add(id);
|
|
127
|
+
}
|
|
128
|
+
return [...ids];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Single-use flow: (1) lease the token (`used_at IS NULL`, no live lease), so two quick taps never run the action
|
|
132
|
+
* at the same time; (2) run the host action; (3) ONE batch: a guard that aborts the batch when the lease was lost,
|
|
133
|
+
* the token marked used, sibling tokens of the same message expired, and the host's `sql`. So the token claim and
|
|
134
|
+
* the host's SQL commit together: a crash before (3) leaves both undone and the lease frees the token again after
|
|
135
|
+
* `ACTION_LEASE_MS`. Actions that write on their own inside `run` (no returned `sql`) are at-least-once: a crash
|
|
136
|
+
* between their write and (3) lets a later tap run them again, so make those writes idempotent.
|
|
137
|
+
*/
|
|
138
|
+
async function runAction(ctx, def, token, actor, params) {
|
|
139
|
+
const { rt } = ctx;
|
|
140
|
+
const now = rt.clock.now();
|
|
141
|
+
const locale = await ctx.locale();
|
|
142
|
+
const chat = ctx.chat;
|
|
143
|
+
if (!chat) return say(ctx, "bot.action.expired");
|
|
144
|
+
const single = token.single_use === 1;
|
|
145
|
+
const lease = leaseValue(now + ACTION_LEASE_MS, shortId(rt.rand, 10));
|
|
146
|
+
if (single) {
|
|
147
|
+
if (!await rt.db.first(sql`UPDATE tg_action_token SET result = ${lease} WHERE tenant = ${rt.tenant} AND id = ${token.id}
|
|
148
|
+
AND used_at IS NULL AND (result IS NULL OR result NOT LIKE '~lease:%'
|
|
149
|
+
OR CAST(substr(result, 8, 15) AS INTEGER) <= ${now})
|
|
150
|
+
RETURNING id`)) return say(ctx, "bot.action.used");
|
|
151
|
+
}
|
|
152
|
+
const original = await originalMarkup(ctx, false);
|
|
153
|
+
const siblings = siblingTokenIds(original, token.id);
|
|
154
|
+
let result = {};
|
|
155
|
+
try {
|
|
156
|
+
const out = await def.run({
|
|
157
|
+
actor,
|
|
158
|
+
params,
|
|
159
|
+
chat: chatDto(chat),
|
|
160
|
+
bot: botDto(ctx.bot),
|
|
161
|
+
locale,
|
|
162
|
+
now,
|
|
163
|
+
db: rt.db,
|
|
164
|
+
env: rt.env
|
|
165
|
+
});
|
|
166
|
+
const shaped = ActionResultShape.safeParse(out);
|
|
167
|
+
if (shaped.success) result = shaped.data;
|
|
168
|
+
const label = def.result ? localized(ctx, def.result, locale, params) : rt.i18n.plain(locale, "bot.action.done");
|
|
169
|
+
const guardKey = `actguard:${token.id}`;
|
|
170
|
+
await rt.db.batch([
|
|
171
|
+
...single ? [sql`INSERT INTO tg_state (tenant, key, value, updated_at) VALUES (${rt.tenant}, ${guardKey},
|
|
172
|
+
(SELECT id FROM tg_action_token WHERE tenant = ${rt.tenant} AND id = ${token.id}
|
|
173
|
+
AND used_at IS NULL AND result = ${lease}), ${now})`, sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${guardKey}`] : [],
|
|
174
|
+
sql`UPDATE tg_action_token SET used_at = ${now}, result = ${cutText(label)}
|
|
175
|
+
WHERE tenant = ${rt.tenant} AND id = ${token.id}`,
|
|
176
|
+
...siblings.length > 0 ? [sql`UPDATE tg_action_token SET used_at = COALESCE(used_at, ${now}), expires_at = MIN(expires_at, ${now}),
|
|
177
|
+
result = COALESCE(result, 'superseded')
|
|
178
|
+
WHERE tenant = ${rt.tenant} AND bot_id = ${ctx.bot.id} AND chat_ref = ${chat.id}
|
|
179
|
+
AND id IN (${sql.join(siblings)})`] : [],
|
|
180
|
+
...result.sql ?? []
|
|
181
|
+
]);
|
|
182
|
+
rt.log.info("tg.action", {
|
|
183
|
+
botId: ctx.bot.id,
|
|
184
|
+
action: def.key,
|
|
185
|
+
outcome: "done"
|
|
186
|
+
});
|
|
187
|
+
await answer(ctx, label);
|
|
188
|
+
await finishKeyboard(ctx, token.id, label, original);
|
|
189
|
+
if (result.text) await ctx.reply(result.text);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
rt.log.warn("tg.action_failed", {
|
|
192
|
+
botId: ctx.bot.id,
|
|
193
|
+
action: def.key,
|
|
194
|
+
error: e instanceof Error ? e.name : "error"
|
|
195
|
+
});
|
|
196
|
+
if (single) await rt.db.run(sql`UPDATE tg_action_token SET result = NULL WHERE tenant = ${rt.tenant} AND id = ${token.id}
|
|
197
|
+
AND used_at IS NULL AND result = ${lease}`).catch(() => {});
|
|
198
|
+
await answer(ctx, rt.i18n.plain(locale, "bot.action.failed"));
|
|
199
|
+
}
|
|
200
|
+
return "handled";
|
|
201
|
+
}
|
|
202
|
+
/** First tap of a confirm action: the tapped button becomes Confirm (green) + Cancel (red). */
|
|
203
|
+
async function askConfirm(ctx, def, token, params) {
|
|
204
|
+
const { rt } = ctx;
|
|
205
|
+
const locale = await ctx.locale();
|
|
206
|
+
const now = rt.clock.now();
|
|
207
|
+
await rt.db.run(sql`UPDATE tg_action_token SET confirmed_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${token.id}
|
|
208
|
+
AND used_at IS NULL`);
|
|
209
|
+
const data = `a:${token.id}`;
|
|
210
|
+
const confirm = [{
|
|
211
|
+
text: rt.i18n.plain(locale, "bot.action.confirm_button"),
|
|
212
|
+
callback_data: data,
|
|
213
|
+
style: "success"
|
|
214
|
+
}, {
|
|
215
|
+
text: rt.i18n.plain(locale, "bot.action.cancel_button"),
|
|
216
|
+
callback_data: `${data}:x`,
|
|
217
|
+
style: "danger"
|
|
218
|
+
}];
|
|
219
|
+
const original = await originalMarkup(ctx, false);
|
|
220
|
+
await editMarkup(ctx, { inline_keyboard: original ? original.inline_keyboard.flatMap((row) => row.some((b) => isCallback(b) && b.callback_data === data) ? [confirm] : [row.map((b) => isCallback(b) ? {
|
|
221
|
+
...b,
|
|
222
|
+
disabled: true
|
|
223
|
+
} : b)]) : [confirm] });
|
|
224
|
+
await answer(ctx, (def.confirm ? localized(ctx, def.confirm, locale, params) : "") || rt.i18n.plain(locale, "bot.action.confirm"));
|
|
225
|
+
return "handled";
|
|
226
|
+
}
|
|
227
|
+
async function cancelConfirm(ctx, token) {
|
|
228
|
+
const { rt } = ctx;
|
|
229
|
+
await rt.db.run(sql`UPDATE tg_action_token SET confirmed_at = NULL WHERE tenant = ${rt.tenant} AND id = ${token.id}
|
|
230
|
+
AND used_at IS NULL`);
|
|
231
|
+
await editMarkup(ctx, await originalMarkup(ctx, true));
|
|
232
|
+
return say(ctx, "bot.action.cancelled");
|
|
233
|
+
}
|
|
234
|
+
async function actionCallback(ctx, data) {
|
|
235
|
+
const { rt } = ctx;
|
|
236
|
+
if (!rt.features.actions) return say(ctx, "bot.action.expired");
|
|
237
|
+
const cancel = data.endsWith(":x");
|
|
238
|
+
const id = data.slice(2, cancel ? -2 : void 0);
|
|
239
|
+
if (!/^[A-Za-z0-9]{10}$/.test(id)) return say(ctx, "bot.action.expired");
|
|
240
|
+
const token = await rt.db.first(sql`SELECT * FROM tg_action_token WHERE tenant = ${rt.tenant} AND id = ${id} AND bot_id = ${ctx.bot.id}`);
|
|
241
|
+
const now = rt.clock.now();
|
|
242
|
+
if (!token || token.expires_at <= now || !ctx.chat || token.chat_ref !== ctx.chat.id || ctx.chat.status !== "active") return say(ctx, "bot.action.expired");
|
|
243
|
+
if (token.single_use === 1 && (token.used_at !== null || leaseActive(token.result, now))) return say(ctx, "bot.action.used");
|
|
244
|
+
const def = rt.registry.actions.get(token.action);
|
|
245
|
+
if (!def) return say(ctx, "bot.action.expired");
|
|
246
|
+
const who = await tapper(ctx, token);
|
|
247
|
+
if (who === "unlinked") return say(ctx, "bot.action.link_first");
|
|
248
|
+
if (who === "not_yours") return say(ctx, "bot.action.not_yours");
|
|
249
|
+
if (!await hostCan(rt, who, def.permission)) return say(ctx, "bot.error.forbidden");
|
|
250
|
+
const params = def.params.safeParse(parseJson(token.params_json, z.unknown(), {}));
|
|
251
|
+
if (!params.success) return say(ctx, "bot.action.expired");
|
|
252
|
+
if (cancel) return cancelConfirm(ctx, token);
|
|
253
|
+
if (def.confirm !== null && token.confirmed_at === null) return askConfirm(ctx, def, token, params.data);
|
|
254
|
+
return runAction(ctx, def, token, who, params.data);
|
|
255
|
+
}
|
|
256
|
+
export { ACTION_LEASE_MS, actionCallback, answer, cutText, leaseActive };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { shortId } from "../../core/random.js";
|
|
2
|
+
import { sql } from "../../ports/sql.js";
|
|
3
|
+
import { renderInline } from "../content/render.js";
|
|
4
|
+
// src/engine/actions/tokens.ts
|
|
5
|
+
/** `callback_data` prefix of action buttons (`a:<token id>`); host buttons must not use it. */
|
|
6
|
+
const ACTION_PREFIX = "a:";
|
|
7
|
+
/** Renders `{{vars}}` inside string params (deep), so `{ key: "{{key}}" }` becomes the record key. */
|
|
8
|
+
function renderParams(rt, value, ctx) {
|
|
9
|
+
if (typeof value === "string") return value.includes("{{") ? renderInline(rt, value, ctx.vars, {
|
|
10
|
+
locale: ctx.locale,
|
|
11
|
+
tz: ctx.tz
|
|
12
|
+
}) : value;
|
|
13
|
+
if (Array.isArray(value)) return value.map((v) => renderParams(rt, v, ctx));
|
|
14
|
+
if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, renderParams(rt, v, ctx)]));
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
async function actionButton(rt, spec, text, ctx) {
|
|
18
|
+
if (!rt.features.actions || spec.action === void 0 || ctx.chatRef === null) return null;
|
|
19
|
+
const def = rt.registry.actions.get(spec.action);
|
|
20
|
+
if (!def) {
|
|
21
|
+
rt.log.warn("tg.action_unknown", { action: spec.action });
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let params;
|
|
25
|
+
try {
|
|
26
|
+
params = renderParams(rt, spec.params ?? {}, ctx);
|
|
27
|
+
} catch {
|
|
28
|
+
rt.log.warn("tg.action_params_invalid", { action: def.key });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const parsed = def.params.safeParse(params);
|
|
32
|
+
if (!parsed.success) {
|
|
33
|
+
rt.log.warn("tg.action_params_invalid", { action: def.key });
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const id = shortId(rt.rand, 10);
|
|
37
|
+
const expires = ctx.now + def.ttlMinutes * 6e4;
|
|
38
|
+
return {
|
|
39
|
+
button: {
|
|
40
|
+
text,
|
|
41
|
+
callback_data: `a:${id}`,
|
|
42
|
+
...spec.style ? { style: spec.style } : {},
|
|
43
|
+
...spec.disabled ? { disabled: true } : {}
|
|
44
|
+
},
|
|
45
|
+
sql: [sql`INSERT INTO tg_action_token (id, tenant, bot_id, action, params_json, chat_ref, user_id, single_use,
|
|
46
|
+
expires_at, created_at)
|
|
47
|
+
VALUES (${id}, ${rt.tenant}, ${ctx.botId}, ${def.key}, ${JSON.stringify(parsed.data ?? {})}, ${ctx.chatRef},
|
|
48
|
+
${ctx.userId}, ${def.singleUse}, ${expires}, ${ctx.now})`]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export { ACTION_PREFIX, actionButton };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
// src/engine/bot-api.ts
|
|
3
|
+
/** Keyring AAD for `tg_bot.token_enc`: binds the ciphertext to its row and column (never swappable). */
|
|
4
|
+
const tokenAad = (botId) => `tg_bot.token:${botId}`;
|
|
5
|
+
/** Keyring AAD for `tg_bot.webhook_secret_enc`. */
|
|
6
|
+
const webhookAad = (botId) => `tg_bot.webhook:${botId}`;
|
|
7
|
+
/** Runs `fn` with a Bot API handle for one bot. The token is decrypted per request (AAD = `tokenAad`), never kept. */
|
|
8
|
+
function withToken(rt, bot, fn) {
|
|
9
|
+
return rt.tg.withToken(() => rt.keyring.decrypt(bot.token_enc, tokenAad(bot.id)), fn);
|
|
10
|
+
}
|
|
11
|
+
/** A bot of this tenant (deleted bots only with `includeDeleted`). */
|
|
12
|
+
async function loadBot(rt, id, opts = {}) {
|
|
13
|
+
return rt.db.first(sql`SELECT * FROM tg_bot WHERE tenant = ${rt.tenant} AND id = ${id}
|
|
14
|
+
${opts.includeDeleted ? sql.empty : sql`AND deleted_at IS NULL`}`);
|
|
15
|
+
}
|
|
16
|
+
/** Bots that can send and receive (not disabled / deleted / invalid). */
|
|
17
|
+
function isBotUsable(bot) {
|
|
18
|
+
return bot.deleted_at === null && (bot.status === "active" || bot.status === "webhook_error");
|
|
19
|
+
}
|
|
20
|
+
export { isBotUsable, loadBot, tokenAad, webhookAad, withToken };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { FEATURE_KEYS } from "../contracts/features.js";
|
|
2
|
+
import { BotFeatures } from "../contracts/bots.js";
|
|
3
|
+
import { TelegramPluginError } from "./errors.js";
|
|
4
|
+
import { parseJson } from "./rows.js";
|
|
5
|
+
// src/engine/bot-features.ts
|
|
6
|
+
/** Global flags narrowed by one bot's overrides. */
|
|
7
|
+
function effectiveFeatures(global, overrides) {
|
|
8
|
+
const out = { ...global };
|
|
9
|
+
for (const k of FEATURE_KEYS) {
|
|
10
|
+
if (k === "core") continue;
|
|
11
|
+
out[k] = global[k] && overrides[k] !== false;
|
|
12
|
+
}
|
|
13
|
+
out.aiFreeText = global.aiFreeText && out.ai === true;
|
|
14
|
+
out.aiSendIntent = global.aiSendIntent && out.ai === true;
|
|
15
|
+
return Object.freeze(out);
|
|
16
|
+
}
|
|
17
|
+
/** The runtime as seen by one bot (same ports, features narrowed by `features_json`). */
|
|
18
|
+
function botRuntime(rt, bot) {
|
|
19
|
+
const overrides = parseJson(bot.features_json, BotFeatures, {});
|
|
20
|
+
if (!Object.values(overrides).includes(false)) return rt;
|
|
21
|
+
return {
|
|
22
|
+
...rt,
|
|
23
|
+
features: effectiveFeatures(rt.features, overrides)
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** `PATCH` input check: `core` cannot be switched off, and only globally enabled features can be switched on. */
|
|
27
|
+
function checkBotFeatures(global, features) {
|
|
28
|
+
const issues = [];
|
|
29
|
+
for (const [k, v] of Object.entries(features)) if (k === "core" && v === false) issues.push({
|
|
30
|
+
path: "body.features.core",
|
|
31
|
+
message: "core cannot be turned off"
|
|
32
|
+
});
|
|
33
|
+
else if (v === true && global[k] !== true) issues.push({
|
|
34
|
+
path: `body.features.${k}`,
|
|
35
|
+
message: `feature "${k}" is not enabled for this app`
|
|
36
|
+
});
|
|
37
|
+
if (issues.length > 0) throw new TelegramPluginError("validation_failed", "bot features are not valid", { extensions: { issues } });
|
|
38
|
+
}
|
|
39
|
+
export { botRuntime, checkBotFeatures, effectiveFeatures };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { chatDto } from "./rows.js";
|
|
3
|
+
// src/engine/chat-state.ts
|
|
4
|
+
/** Cancels queued deliveries of a chat (blocked, left, unlinked). `except` keeps the row being processed. */
|
|
5
|
+
function cancelQueuedSql(rt, chatRef, code, now, except) {
|
|
6
|
+
return sql`UPDATE tg_delivery SET status = 'cancelled', error_code = ${code}, lease_until = NULL,
|
|
7
|
+
lease_token = NULL, updated_at = ${now}
|
|
8
|
+
WHERE tenant = ${rt.tenant} AND chat_ref = ${chatRef} AND status = 'queued'
|
|
9
|
+
${except ? sql`AND id != ${except}` : sql.empty}`;
|
|
10
|
+
}
|
|
11
|
+
/** Status change for blocked / left (+ cancel the chat's queue). Hooks run after the batch. */
|
|
12
|
+
function chatGoneSql(rt, chat, status, now, except) {
|
|
13
|
+
return [sql`UPDATE tg_chat SET status = ${status}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${chat.id}`, cancelQueuedSql(rt, chat.id, `chat_${status}`, now, except)];
|
|
14
|
+
}
|
|
15
|
+
async function afterChatGone(rt, chat, status, now) {
|
|
16
|
+
const dto = chatDto({
|
|
17
|
+
...chat,
|
|
18
|
+
status,
|
|
19
|
+
updated_at: now
|
|
20
|
+
});
|
|
21
|
+
if (status === "blocked") await rt.hook("onBlocked", (fn) => fn(dto));
|
|
22
|
+
else await rt.hook("onUnlinked", (fn) => fn(dto));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A group became a supergroup (`migrate_to_chat_id`). The row keeps its id (destinations and subscriptions keep
|
|
26
|
+
* working) and gets the new chat id. If the new chat already has a row, the old one is marked `migrated` and its
|
|
27
|
+
* queue moves over. Returns the chat row that now owns the new id.
|
|
28
|
+
*/
|
|
29
|
+
async function migrateChat(rt, old, newChatId, now) {
|
|
30
|
+
if (old.chat_id === newChatId) return old;
|
|
31
|
+
const existing = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${old.bot_id} AND chat_id = ${newChatId}`);
|
|
32
|
+
if (!existing) {
|
|
33
|
+
await rt.db.run(sql`UPDATE tg_chat SET chat_id = ${newChatId}, kind = 'supergroup', updated_at = ${now}
|
|
34
|
+
WHERE tenant = ${rt.tenant} AND id = ${old.id}`);
|
|
35
|
+
rt.log.info("tg.chat_migrated", {
|
|
36
|
+
chatRef: old.id,
|
|
37
|
+
mode: "in_place"
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
...old,
|
|
41
|
+
chat_id: newChatId,
|
|
42
|
+
kind: "supergroup",
|
|
43
|
+
updated_at: now
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (existing.id === old.id) return existing;
|
|
47
|
+
await rt.db.batch([
|
|
48
|
+
sql`UPDATE tg_chat SET status = 'migrated', updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${old.id}`,
|
|
49
|
+
sql`UPDATE tg_chat SET status = CASE WHEN status IN ('pending', 'left') AND ${old.status} = 'active'
|
|
50
|
+
THEN 'active' ELSE status END,
|
|
51
|
+
audience_json = COALESCE(audience_json, ${old.audience_json}),
|
|
52
|
+
topics_json = COALESCE(topics_json, ${old.topics_json}),
|
|
53
|
+
kind = 'supergroup', updated_at = ${now}
|
|
54
|
+
WHERE tenant = ${rt.tenant} AND id = ${existing.id}`,
|
|
55
|
+
sql`UPDATE tg_delivery SET chat_ref = ${existing.id}, updated_at = ${now}
|
|
56
|
+
WHERE tenant = ${rt.tenant} AND chat_ref = ${old.id} AND status IN ('queued', 'sending')`,
|
|
57
|
+
sql`UPDATE tg_destination SET chat_ref = ${existing.id}, updated_at = ${now}
|
|
58
|
+
WHERE tenant = ${rt.tenant} AND chat_ref = ${old.id}`
|
|
59
|
+
]);
|
|
60
|
+
rt.log.info("tg.chat_migrated", {
|
|
61
|
+
chatRef: old.id,
|
|
62
|
+
mode: "merged"
|
|
63
|
+
});
|
|
64
|
+
return await rt.db.first(sql`SELECT * FROM tg_chat WHERE id = ${existing.id}`) ?? existing;
|
|
65
|
+
}
|
|
66
|
+
export { afterChatGone, cancelQueuedSql, chatGoneSql, migrateChat };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Audience } from "../../contracts/chats.js";
|
|
2
|
+
import { TelegramPluginError } from "../errors.js";
|
|
3
|
+
import { parseJson } from "../rows.js";
|
|
4
|
+
// src/engine/content/audience.ts
|
|
5
|
+
/**
|
|
6
|
+
* The audience of a chat. `byRole` groups by role (one provider run per role, as the role's audience actor);
|
|
7
|
+
* otherwise each linked user is their own audience. The user is always resolved live first, so a disabled user
|
|
8
|
+
* gets nothing even when data is shared per role.
|
|
9
|
+
*/
|
|
10
|
+
async function audienceOf(rt, chat, byRole) {
|
|
11
|
+
if (chat.status !== "active") return null;
|
|
12
|
+
if (chat.kind === "private") {
|
|
13
|
+
if (!chat.user_id) return null;
|
|
14
|
+
const actor = await rt.actor.forUser(chat.user_id);
|
|
15
|
+
if (actor === null) return null;
|
|
16
|
+
const info = rt.describe(actor);
|
|
17
|
+
if (byRole && info.role) {
|
|
18
|
+
const roleActor = await rt.actor.forAudience({ role: info.role });
|
|
19
|
+
if (roleActor !== null) return {
|
|
20
|
+
key: `r:${info.role}`,
|
|
21
|
+
actor: roleActor,
|
|
22
|
+
info: rt.describe(roleActor),
|
|
23
|
+
role: info.role,
|
|
24
|
+
userId: chat.user_id
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
key: `u:${chat.user_id}`,
|
|
29
|
+
actor,
|
|
30
|
+
info,
|
|
31
|
+
role: info.role,
|
|
32
|
+
userId: chat.user_id
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const audience = parseJson(chat.audience_json, Audience.nullable(), null);
|
|
36
|
+
if (!audience) return null;
|
|
37
|
+
const actor = await rt.actor.forAudience(audience);
|
|
38
|
+
if (actor === null) return null;
|
|
39
|
+
const info = rt.describe(actor);
|
|
40
|
+
return {
|
|
41
|
+
key: "role" in audience ? `r:${audience.role}` : `a:${[...audience.users].sort().join(",")}`,
|
|
42
|
+
actor,
|
|
43
|
+
info,
|
|
44
|
+
role: "role" in audience ? audience.role : info.role,
|
|
45
|
+
userId: null
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** The actor an admin previews as: a role's audience actor, or a user's live actor. */
|
|
49
|
+
async function audienceForPreview(rt, as) {
|
|
50
|
+
const actor = "role" in as ? await rt.actor.forAudience({ role: as.role }) : await rt.actor.forUser(as.userId);
|
|
51
|
+
if (actor === null) throw new TelegramPluginError("not_found", "role or user not found");
|
|
52
|
+
return actor;
|
|
53
|
+
}
|
|
54
|
+
export { audienceForPreview, audienceOf };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { SAFE_URL_RE } from "../../core/template/tags.js";
|
|
2
|
+
import { sql } from "../../ports/sql.js";
|
|
3
|
+
import { pickVariant, renderInline } from "./render.js";
|
|
4
|
+
import { actionButton } from "../actions/tokens.js";
|
|
5
|
+
// src/engine/content/buttons.ts
|
|
6
|
+
/**
|
|
7
|
+
* features.actions: an action button becomes a `callback_data: "a:<id>"` button plus the `tg_action_token` insert
|
|
8
|
+
* (engine/actions/tokens.ts). Null (button left out) when actions are off, the action is unknown or its params fail.
|
|
9
|
+
*/
|
|
10
|
+
async function buildActionButton(rt, spec, text, ctx) {
|
|
11
|
+
return actionButton(rt, spec, text, ctx);
|
|
12
|
+
}
|
|
13
|
+
function label(spec, locale) {
|
|
14
|
+
return (pickVariant(spec.text, [locale])?.source ?? "").slice(0, 64);
|
|
15
|
+
}
|
|
16
|
+
function renderUrl(rt, url, ctx) {
|
|
17
|
+
try {
|
|
18
|
+
const out = renderInline(rt, url, ctx.vars, {
|
|
19
|
+
locale: ctx.locale,
|
|
20
|
+
tz: ctx.tz
|
|
21
|
+
}).trim();
|
|
22
|
+
return SAFE_URL_RE.test(out) ? out : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Template button rows → inline keyboard for one recipient. URL buttons are rendered (unsafe URLs dropped); action
|
|
29
|
+
* buttons become `a:<token>` callbacks with their `tg_action_token` inserts in `sql` (none in previews, where
|
|
30
|
+
* `chatRef` is null). Writes nothing itself.
|
|
31
|
+
*/
|
|
32
|
+
async function buildButtons(rt, rows, ctx) {
|
|
33
|
+
if (!rows || rows.length === 0) return {
|
|
34
|
+
markup: null,
|
|
35
|
+
sql: [],
|
|
36
|
+
preview: null
|
|
37
|
+
};
|
|
38
|
+
const keyboard = [];
|
|
39
|
+
const preview = [];
|
|
40
|
+
const sqls = [];
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const pv = [];
|
|
44
|
+
for (const spec of row) {
|
|
45
|
+
const text = label(spec, ctx.locale);
|
|
46
|
+
if (text === "") continue;
|
|
47
|
+
const common = {
|
|
48
|
+
...spec.style ? { style: spec.style } : {},
|
|
49
|
+
...spec.disabled ? { disabled: true } : {}
|
|
50
|
+
};
|
|
51
|
+
if (spec.url !== void 0) {
|
|
52
|
+
const url = renderUrl(rt, spec.url, ctx);
|
|
53
|
+
if (url === null) {
|
|
54
|
+
rt.log.debug("tg.button_url_dropped", {});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
out.push({
|
|
58
|
+
text,
|
|
59
|
+
url,
|
|
60
|
+
...common
|
|
61
|
+
});
|
|
62
|
+
pv.push({
|
|
63
|
+
text,
|
|
64
|
+
kind: "url",
|
|
65
|
+
url,
|
|
66
|
+
style: spec.style ?? null,
|
|
67
|
+
disabled: spec.disabled === true
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
pv.push({
|
|
72
|
+
text,
|
|
73
|
+
kind: "action",
|
|
74
|
+
url: null,
|
|
75
|
+
style: spec.style ?? null,
|
|
76
|
+
disabled: spec.disabled === true
|
|
77
|
+
});
|
|
78
|
+
if (ctx.chatRef === null) continue;
|
|
79
|
+
const built = await buildActionButton(rt, spec, text, ctx);
|
|
80
|
+
if (built) {
|
|
81
|
+
out.push(built.button);
|
|
82
|
+
sqls.push(...built.sql);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (out.length > 0) keyboard.push(out);
|
|
86
|
+
if (pv.length > 0) preview.push(pv);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
markup: keyboard.length > 0 ? { inline_keyboard: keyboard } : null,
|
|
90
|
+
sql: sqls,
|
|
91
|
+
preview: preview.length > 0 ? preview : null
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Action token ids in a keyboard built by `buildButtons` (`callback_data: "a:<id>"`). */
|
|
95
|
+
function actionTokenIds(markup) {
|
|
96
|
+
if (!markup) return [];
|
|
97
|
+
const ids = [];
|
|
98
|
+
for (const row of markup.inline_keyboard) for (const b of row) if ("callback_data" in b && typeof b.callback_data === "string" && b.callback_data.startsWith("a:")) ids.push(b.callback_data.slice(2));
|
|
99
|
+
return ids;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Put after the delivery INSERTs in the same batch as `BuiltButtons.sql`: deletes this keyboard's action tokens
|
|
103
|
+
* unless the delivery row `lastPartDedupeKey` (the part that carries the keyboard) holds them, i.e. when the
|
|
104
|
+
* INSERT was a dedupe no-op because an earlier run made the row. Empty without action buttons.
|
|
105
|
+
*/
|
|
106
|
+
function orphanTokenGuard(rt, markup, lastPartDedupeKey) {
|
|
107
|
+
const ids = actionTokenIds(markup);
|
|
108
|
+
if (ids.length === 0) return [];
|
|
109
|
+
return [sql`DELETE FROM tg_action_token WHERE tenant = ${rt.tenant} AND id IN (${sql.join(ids)})
|
|
110
|
+
AND NOT EXISTS (SELECT 1 FROM tg_delivery d WHERE d.tenant = ${rt.tenant} AND d.dedupe_key = ${lastPartDedupeKey}
|
|
111
|
+
AND instr(d.reply_markup_json, '"' || ${"a:"} || tg_action_token.id || '"') > 0)`];
|
|
112
|
+
}
|
|
113
|
+
export { actionTokenIds, buildActionButton, buildButtons, orphanTokenGuard };
|