@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,194 @@
|
|
|
1
|
+
import { escapeHtml } from "../../core/html/escape.js";
|
|
2
|
+
import { sql } from "../../ports/sql.js";
|
|
3
|
+
import { isTelegramPluginError } from "../errors.js";
|
|
4
|
+
import { ProviderTimeout, hostCan } from "../content/providers.js";
|
|
5
|
+
import { botDto, chatDto } from "../rows.js";
|
|
6
|
+
import { templateModel } from "../content/template-rows.js";
|
|
7
|
+
import { commandRows, describe, effectiveCommands, findCommand, rolesAllow } from "../../services/commands.js";
|
|
8
|
+
import { replyReport } from "../content/reply.js";
|
|
9
|
+
import { hostContext, senderMayAct } from "./context.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
// src/engine/dispatch/commands.ts
|
|
12
|
+
function chatKind(ctx) {
|
|
13
|
+
const t = ctx.tgChat?.type;
|
|
14
|
+
return t === "group" || t === "supergroup" || t === "channel" ? t : "private";
|
|
15
|
+
}
|
|
16
|
+
/** Default args parser: tokens fill the object's keys in order; the last key takes the rest. */
|
|
17
|
+
function defaultParseArgs(schema, text) {
|
|
18
|
+
if (!(schema instanceof z.ZodObject)) return text.trim() === "" ? {} : { text: text.trim() };
|
|
19
|
+
const keys = Object.keys(schema.shape);
|
|
20
|
+
const tokens = text.trim() === "" ? [] : text.trim().split(/\s+/);
|
|
21
|
+
const out = {};
|
|
22
|
+
keys.forEach((k, i) => {
|
|
23
|
+
const v = i === keys.length - 1 ? tokens.slice(i).join(" ") : tokens[i];
|
|
24
|
+
if (v !== void 0 && v !== "") out[k] = v;
|
|
25
|
+
});
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function usageOf(schema) {
|
|
29
|
+
if (!(schema instanceof z.ZodObject)) return "";
|
|
30
|
+
return Object.entries(schema.shape).map(([k, s]) => s.safeParse(void 0).success ? `[${k}]` : `<${k}>`).join(" ");
|
|
31
|
+
}
|
|
32
|
+
/** Roles (`"*"` any, `[]` none) + host permission for an actor. Groups use their audience's role. */
|
|
33
|
+
async function commandAllowed(ctx, cmd, actor) {
|
|
34
|
+
if (!rolesAllow(cmd.roles, ctx.rt.describe(actor).role)) return false;
|
|
35
|
+
return hostCan(ctx.rt, actor, cmd.def.permission);
|
|
36
|
+
}
|
|
37
|
+
async function replyError(ctx, e, report) {
|
|
38
|
+
if (isTelegramPluginError(e) && (e.code === "provider_forbidden" || e.code === "forbidden")) {
|
|
39
|
+
await ctx.reply(await ctx.t("bot.error.forbidden"));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const code = isTelegramPluginError(e) ? e.code : e instanceof ProviderTimeout ? "provider_timeout" : "error";
|
|
43
|
+
ctx.rt.log.warn("tg.command_failed", {
|
|
44
|
+
botId: ctx.bot.id,
|
|
45
|
+
code
|
|
46
|
+
});
|
|
47
|
+
if (report) await ctx.reply(await ctx.t("bot.report.failed"));
|
|
48
|
+
else if (code === "provider_timeout" || code === "error") await ctx.reply(await ctx.t("bot.data.failed"));
|
|
49
|
+
else await ctx.reply(await ctx.t("bot.error.generic"));
|
|
50
|
+
}
|
|
51
|
+
/** `key=value` tokens → params (numbers stay strings; the provider's Zod params coerce them). */
|
|
52
|
+
function kvParams(tokens) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const t of tokens) {
|
|
55
|
+
const i = t.indexOf("=");
|
|
56
|
+
if (i > 0) out[t.slice(0, i)] = t.slice(i + 1);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
/** Up to {@link REPORT_LIST_MAX} report lines this actor may read (keyset pages, so hidden rows do not end it). */
|
|
61
|
+
async function reportLines(ctx, actor) {
|
|
62
|
+
const { rt } = ctx;
|
|
63
|
+
const lines = [];
|
|
64
|
+
let after = "";
|
|
65
|
+
for (;;) {
|
|
66
|
+
const page = await rt.db.all(sql`SELECT * FROM tg_template WHERE tenant = ${rt.tenant} AND kind = 'report' AND deleted_at IS NULL
|
|
67
|
+
AND key > ${after} ORDER BY key LIMIT 100`);
|
|
68
|
+
for (const r of page) {
|
|
69
|
+
const def = r.provider ? rt.registry.providers.get(r.provider) : void 0;
|
|
70
|
+
if (r.provider && (!def || !await hostCan(rt, actor, def.permission))) continue;
|
|
71
|
+
if (lines.length === 30) return {
|
|
72
|
+
lines,
|
|
73
|
+
more: true
|
|
74
|
+
};
|
|
75
|
+
lines.push(`/report ${escapeHtml(r.key)} — ${escapeHtml(r.name)}`);
|
|
76
|
+
}
|
|
77
|
+
const last = page.at(-1);
|
|
78
|
+
if (page.length < 100 || !last) return {
|
|
79
|
+
lines,
|
|
80
|
+
more: false
|
|
81
|
+
};
|
|
82
|
+
after = last.key;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** `/report` lists the report templates this audience may read; `/report <key> [k=v…]` sends one. */
|
|
86
|
+
async function reportCommand(ctx) {
|
|
87
|
+
const { rt } = ctx;
|
|
88
|
+
const actor = await ctx.actor();
|
|
89
|
+
if (actor === null) {
|
|
90
|
+
await ctx.reply(await ctx.t(chatKind(ctx) === "private" ? "bot.start.unlinked" : "bot.error.not_linked"));
|
|
91
|
+
return "handled";
|
|
92
|
+
}
|
|
93
|
+
if (!await senderMayAct(ctx)) {
|
|
94
|
+
await ctx.reply(await ctx.t("bot.error.sender_not_linked"));
|
|
95
|
+
return "handled";
|
|
96
|
+
}
|
|
97
|
+
const args = ctx.command?.args ?? [];
|
|
98
|
+
const name = args[0]?.toLowerCase();
|
|
99
|
+
if (!name) {
|
|
100
|
+
const { lines, more } = await reportLines(ctx, actor);
|
|
101
|
+
if (lines.length === 0) {
|
|
102
|
+
await ctx.reply(await ctx.t("bot.report.none"));
|
|
103
|
+
return "handled";
|
|
104
|
+
}
|
|
105
|
+
const tail = more ? `\n${await ctx.t("bot.report.more")}` : "";
|
|
106
|
+
await ctx.reply(`${await ctx.t("bot.report.list_header")}\n${lines.join("\n")}${tail}`);
|
|
107
|
+
return "handled";
|
|
108
|
+
}
|
|
109
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_template WHERE tenant = ${rt.tenant} AND kind = 'report' AND deleted_at IS NULL
|
|
110
|
+
AND key = ${name}`);
|
|
111
|
+
if (!row) {
|
|
112
|
+
await ctx.reply(await ctx.t("bot.report.unknown"));
|
|
113
|
+
return "handled";
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
await replyReport(ctx, templateModel(row), actor, kvParams(args.slice(1)));
|
|
117
|
+
} catch (e) {
|
|
118
|
+
await replyError(ctx, e, true);
|
|
119
|
+
}
|
|
120
|
+
return "handled";
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Update handler for host commands (features.commands) and the built-in `/report`: runs the checks in order
|
|
124
|
+
* (enabled, scope, linked audience, roles, permission, Zod args) and replies through the outbox. `"next"` when the
|
|
125
|
+
* update is not a host command.
|
|
126
|
+
*/
|
|
127
|
+
async function hostCommandsHandler(ctx) {
|
|
128
|
+
const { rt } = ctx;
|
|
129
|
+
const cmd = ctx.command;
|
|
130
|
+
if (!rt.features.commands || ctx.cls !== "command" || !cmd || cmd.mentionedOther) return "next";
|
|
131
|
+
if (cmd.command === "report" && rt.features.reports) return reportCommand(ctx);
|
|
132
|
+
if (rt.registry.commandList.length === 0) return "next";
|
|
133
|
+
const found = findCommand(effectiveCommands(rt, await commandRows(rt, ctx.bot.id)), cmd.command);
|
|
134
|
+
if (!found) return "next";
|
|
135
|
+
const kind = chatKind(ctx);
|
|
136
|
+
if (!found.def.scope.includes(kind)) {
|
|
137
|
+
if (kind === "private" || cmd.mention !== null) await ctx.reply(await ctx.t("bot.command.wrong_chat"));
|
|
138
|
+
return "handled";
|
|
139
|
+
}
|
|
140
|
+
const actor = await ctx.actor();
|
|
141
|
+
if (actor === null) {
|
|
142
|
+
await ctx.reply(await ctx.t(kind === "private" ? "bot.start.unlinked" : "bot.error.not_linked"));
|
|
143
|
+
return "handled";
|
|
144
|
+
}
|
|
145
|
+
if (!await senderMayAct(ctx, found.def.groupMembers)) {
|
|
146
|
+
await ctx.reply(await ctx.t("bot.error.sender_not_linked"));
|
|
147
|
+
return "handled";
|
|
148
|
+
}
|
|
149
|
+
if (!await commandAllowed(ctx, found, actor)) {
|
|
150
|
+
await ctx.reply(await ctx.t("bot.error.forbidden"));
|
|
151
|
+
return "handled";
|
|
152
|
+
}
|
|
153
|
+
const def = found.def;
|
|
154
|
+
const raw = def.parseArgs ? def.parseArgs(cmd.argsText) : defaultParseArgs(def.args, cmd.argsText);
|
|
155
|
+
let args = raw;
|
|
156
|
+
if (def.args) {
|
|
157
|
+
const parsed = def.args.safeParse(raw);
|
|
158
|
+
if (!parsed.success) {
|
|
159
|
+
const locale = await ctx.locale();
|
|
160
|
+
const usage = `/${def.command} ${usageOf(def.args)}`.trim();
|
|
161
|
+
await ctx.reply(`${rt.i18n.t(locale, "bot.command.usage", { usage })}\n${escapeHtml(describe(found.description, locale))}`);
|
|
162
|
+
return "handled";
|
|
163
|
+
}
|
|
164
|
+
args = parsed.data;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
if (def.template !== null) {
|
|
168
|
+
const host = await hostContext(ctx, ctx.message?.text ?? "");
|
|
169
|
+
const params = args !== null && typeof args === "object" ? args : {};
|
|
170
|
+
await host.reply.template(def.template, params);
|
|
171
|
+
} else if (def.handle) {
|
|
172
|
+
const host = await hostContext(ctx, ctx.message?.text ?? "");
|
|
173
|
+
const cctx = {
|
|
174
|
+
actor,
|
|
175
|
+
chat: ctx.chat ? chatDto(ctx.chat) : null,
|
|
176
|
+
bot: botDto(ctx.bot),
|
|
177
|
+
args,
|
|
178
|
+
argsText: cmd.argsText,
|
|
179
|
+
locale: host.locale,
|
|
180
|
+
t: host.t,
|
|
181
|
+
reply: host.reply,
|
|
182
|
+
startDialog: host.startDialog,
|
|
183
|
+
db: host.db,
|
|
184
|
+
now: host.now,
|
|
185
|
+
env: host.env
|
|
186
|
+
};
|
|
187
|
+
await def.handle(cctx);
|
|
188
|
+
}
|
|
189
|
+
} catch (e) {
|
|
190
|
+
await replyError(ctx, e, false);
|
|
191
|
+
}
|
|
192
|
+
return "handled";
|
|
193
|
+
}
|
|
194
|
+
export { commandAllowed, defaultParseArgs, hostCommandsHandler };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { Audience } from "../../contracts/chats.js";
|
|
2
|
+
import { escapeHtml } from "../../core/html/escape.js";
|
|
3
|
+
import { splitMessage } from "../../core/split/split.js";
|
|
4
|
+
import { sql } from "../../ports/sql.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 { startDialog } from "../dialogs/dialog.js";
|
|
9
|
+
import { updateChat, updateFrom, updateMessage } from "../../telegram/inbound.js";
|
|
10
|
+
import { buildButtons, orphanTokenGuard } from "../content/buttons.js";
|
|
11
|
+
import { deliveryFragments } from "../outbox/enqueue.js";
|
|
12
|
+
import { replyFile, replyTemplate } from "../content/reply.js";
|
|
13
|
+
// src/engine/dispatch/context.ts
|
|
14
|
+
/** Wraps chat text for an LLM prompt as clearly marked untrusted data. */
|
|
15
|
+
function untrustedWrap(text) {
|
|
16
|
+
return `<untrusted_user_message source="telegram">\n${text.replace(/[<>&]/g, (c) => c === "<" ? "<" : c === ">" ? ">" : "&")}\n</untrusted_user_message>`;
|
|
17
|
+
}
|
|
18
|
+
function createUpdateContext(rt, bot, update, cls, command, chat, now) {
|
|
19
|
+
const message = updateMessage(update);
|
|
20
|
+
const tgChat = updateChat(update);
|
|
21
|
+
const from = updateFrom(update);
|
|
22
|
+
let actorMemo;
|
|
23
|
+
let senderMemo;
|
|
24
|
+
let replies = 0;
|
|
25
|
+
let queued = false;
|
|
26
|
+
const ctx = {
|
|
27
|
+
rt,
|
|
28
|
+
bot,
|
|
29
|
+
update,
|
|
30
|
+
cls,
|
|
31
|
+
message,
|
|
32
|
+
tgChat,
|
|
33
|
+
from,
|
|
34
|
+
command,
|
|
35
|
+
now,
|
|
36
|
+
chat,
|
|
37
|
+
get queued() {
|
|
38
|
+
return queued;
|
|
39
|
+
},
|
|
40
|
+
actor() {
|
|
41
|
+
actorMemo ??= (async () => {
|
|
42
|
+
const c = ctx.chat;
|
|
43
|
+
if (c?.status !== "active") return null;
|
|
44
|
+
if (c.kind === "private") return c.user_id ? await rt.actor.forUser(c.user_id) : null;
|
|
45
|
+
const audience = parseJson(c.audience_json, Audience.nullable(), null);
|
|
46
|
+
return audience ? await rt.actor.forAudience(audience) : null;
|
|
47
|
+
})();
|
|
48
|
+
return actorMemo;
|
|
49
|
+
},
|
|
50
|
+
senderActor() {
|
|
51
|
+
senderMemo ??= (async () => {
|
|
52
|
+
if (!from) return null;
|
|
53
|
+
const own = ctx.chat?.kind === "private" ? ctx.chat : await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${bot.id}
|
|
54
|
+
AND chat_id = ${String(from.id)} AND kind = 'private'`);
|
|
55
|
+
if (own?.status !== "active" || !own.user_id) return null;
|
|
56
|
+
const actor = await rt.actor.forUser(own.user_id);
|
|
57
|
+
return actor === null ? null : {
|
|
58
|
+
actor,
|
|
59
|
+
userId: own.user_id
|
|
60
|
+
};
|
|
61
|
+
})();
|
|
62
|
+
return senderMemo;
|
|
63
|
+
},
|
|
64
|
+
async actorInfo() {
|
|
65
|
+
const a = await ctx.actor();
|
|
66
|
+
return a === null ? null : rt.describe(a);
|
|
67
|
+
},
|
|
68
|
+
async locale() {
|
|
69
|
+
const info = await ctx.actorInfo().catch(() => null);
|
|
70
|
+
return rt.i18n.pick(ctx.chat?.locale, info?.locale, from?.language_code, bot.default_locale);
|
|
71
|
+
},
|
|
72
|
+
async t(key, vars) {
|
|
73
|
+
return rt.i18n.t(await ctx.locale(), key, vars);
|
|
74
|
+
},
|
|
75
|
+
async replyDelivery(input) {
|
|
76
|
+
if (ctx.chat?.status !== "active") {
|
|
77
|
+
if (input.kind === "text" && input.html !== "") await ctx.reply(input.html);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const n = replies++;
|
|
81
|
+
const base = n === 0 ? `cmd:${bot.id}:${update.update_id}` : `cmd:${bot.id}:${update.update_id}:r${n}`;
|
|
82
|
+
const rows = deliveryFragments(rt, {
|
|
83
|
+
botId: bot.id,
|
|
84
|
+
chatRef: ctx.chat.id,
|
|
85
|
+
threadId: message?.message_thread_id ?? null,
|
|
86
|
+
source: "command",
|
|
87
|
+
sourceId: n === 0 ? String(update.update_id) : `${update.update_id}:${n}`,
|
|
88
|
+
kind: input.kind,
|
|
89
|
+
html: input.html,
|
|
90
|
+
file: input.file,
|
|
91
|
+
replyMarkup: input.replyMarkup ?? null,
|
|
92
|
+
options: input.options,
|
|
93
|
+
dedupeKey: base,
|
|
94
|
+
render: input.render
|
|
95
|
+
}, rt.clock.now());
|
|
96
|
+
const guard = orphanTokenGuard(rt, input.replyMarkup ?? null, `${base}:${rows.length - 1}`);
|
|
97
|
+
await rt.db.batch([
|
|
98
|
+
...input.sql ?? [],
|
|
99
|
+
...rows,
|
|
100
|
+
...guard
|
|
101
|
+
]);
|
|
102
|
+
queued = true;
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
async reply(html, opts) {
|
|
106
|
+
const body = opts?.plain ? escapeHtml(html) : html;
|
|
107
|
+
const threadId = message?.message_thread_id ?? null;
|
|
108
|
+
const options = {
|
|
109
|
+
...opts?.silent ? { silent: true } : {},
|
|
110
|
+
...opts?.protectContent ? { protectContent: true } : {}
|
|
111
|
+
};
|
|
112
|
+
const linked = ctx.chat?.status === "active" ? ctx.chat : null;
|
|
113
|
+
const built = opts?.buttons ? await buildButtons(rt, opts.buttons, {
|
|
114
|
+
vars: {},
|
|
115
|
+
locale: await ctx.locale(),
|
|
116
|
+
tz: linked?.timezone ?? rt.settings.timezone,
|
|
117
|
+
botId: bot.id,
|
|
118
|
+
chatRef: linked?.id ?? null,
|
|
119
|
+
userId: linked?.kind === "private" ? linked.user_id : null,
|
|
120
|
+
now: rt.clock.now()
|
|
121
|
+
}) : null;
|
|
122
|
+
const markup = built?.markup ?? null;
|
|
123
|
+
if (linked) {
|
|
124
|
+
await ctx.replyDelivery({
|
|
125
|
+
kind: "text",
|
|
126
|
+
html: body,
|
|
127
|
+
replyMarkup: markup,
|
|
128
|
+
options,
|
|
129
|
+
sql: built?.sql
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!tgChat) return;
|
|
134
|
+
await withToken(rt, bot, async (api) => {
|
|
135
|
+
for (const part of splitMessage(body)) {
|
|
136
|
+
const r = await settle(api.sendMessage({
|
|
137
|
+
chat_id: tgChat.id,
|
|
138
|
+
text: part,
|
|
139
|
+
parse_mode: "HTML",
|
|
140
|
+
link_preview_options: { is_disabled: true },
|
|
141
|
+
...threadId === null ? {} : { message_thread_id: threadId },
|
|
142
|
+
...markup ? { reply_markup: markup } : {}
|
|
143
|
+
}));
|
|
144
|
+
if (r.outcome.kind !== "ok") {
|
|
145
|
+
rt.log.warn("tg.reply_failed", {
|
|
146
|
+
botId: bot.id,
|
|
147
|
+
code: r.outcome.reason ?? r.outcome.kind
|
|
148
|
+
});
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
return ctx;
|
|
156
|
+
}
|
|
157
|
+
/** True for group and supergroup chats (where a linked chat acts as its audience). */
|
|
158
|
+
function isGroupChat(ctx) {
|
|
159
|
+
const t = ctx.tgChat?.type;
|
|
160
|
+
return t === "group" || t === "supergroup";
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* In a group, whether the sender may act as the group's audience: with `"linked"` (the default from
|
|
164
|
+
* `groups.requireLinkedSender`) only senders who linked their own Telegram on this bot. Always true outside groups.
|
|
165
|
+
*/
|
|
166
|
+
async function senderMayAct(ctx, mode) {
|
|
167
|
+
if (!isGroupChat(ctx)) return true;
|
|
168
|
+
if ((mode ?? (ctx.rt.groups.requireLinkedSender ? "linked" : "any")) === "any") return true;
|
|
169
|
+
return await ctx.senderActor() !== null;
|
|
170
|
+
}
|
|
171
|
+
/** The host-facing context for hooks (`onFreeText`, `onUnknownCommand`, `onInbound`) and command handlers. */
|
|
172
|
+
async function hostContext(ctx, text) {
|
|
173
|
+
const locale = await ctx.locale();
|
|
174
|
+
return {
|
|
175
|
+
actor: await ctx.actor(),
|
|
176
|
+
chat: ctx.chat ? chatDto(ctx.chat) : null,
|
|
177
|
+
bot: botDto(ctx.bot),
|
|
178
|
+
locale,
|
|
179
|
+
text,
|
|
180
|
+
untrustedText: untrustedWrap(text),
|
|
181
|
+
untrusted: true,
|
|
182
|
+
t: (key, vars) => ctx.rt.i18n.t(locale, key, vars),
|
|
183
|
+
reply: {
|
|
184
|
+
text: (html, opts) => ctx.reply(html, opts),
|
|
185
|
+
template: (key, params, opts) => replyTemplate(ctx, key, params, opts),
|
|
186
|
+
file: (file, opts) => replyFile(ctx, file, opts)
|
|
187
|
+
},
|
|
188
|
+
startDialog: (key, state) => startDialog(ctx, key, state),
|
|
189
|
+
db: ctx.rt.db,
|
|
190
|
+
now: ctx.now,
|
|
191
|
+
env: ctx.rt.env
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export { createUpdateContext, hostContext, isGroupChat, senderMayAct, untrustedWrap };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { sql } from "../../ports/sql.js";
|
|
2
|
+
import { classifyUpdate, parseCommand, updateChat, updateMessage, updateType } from "../../telegram/inbound.js";
|
|
3
|
+
import { botRuntime } from "../bot-features.js";
|
|
4
|
+
import { createUpdateContext } from "./context.js";
|
|
5
|
+
import { defaultHandlers } from "./handlers.js";
|
|
6
|
+
// src/engine/dispatch/dispatcher.ts
|
|
7
|
+
/** Inserts the update id. False when Telegram already delivered it (duplicate → drop). */
|
|
8
|
+
async function recordUpdate(rt, botId, update, now) {
|
|
9
|
+
const chat = updateChat(update);
|
|
10
|
+
return await rt.db.first(sql`INSERT INTO tg_update (tenant, bot_id, update_id, kind, chat_id, status, attempts, received_at)
|
|
11
|
+
VALUES (${rt.tenant}, ${botId}, ${update.update_id}, ${updateType(update)},
|
|
12
|
+
${chat ? String(chat.id) : null}, 'received', 0, ${now})
|
|
13
|
+
ON CONFLICT (bot_id, update_id) DO NOTHING RETURNING update_id`) !== null;
|
|
14
|
+
}
|
|
15
|
+
/** Runs the handler chain for one recorded update. Never throws. */
|
|
16
|
+
async function processUpdate(base, bot, update) {
|
|
17
|
+
const rt = botRuntime(base, bot);
|
|
18
|
+
const started = rt.clock.now();
|
|
19
|
+
const cls = classifyUpdate(update);
|
|
20
|
+
const message = updateMessage(update);
|
|
21
|
+
const command = cls === "command" ? parseCommand(message?.text, message?.entities, bot.username) : null;
|
|
22
|
+
let result = "ignored";
|
|
23
|
+
let error = null;
|
|
24
|
+
let queued = false;
|
|
25
|
+
try {
|
|
26
|
+
await rt.db.run(sql`UPDATE tg_update SET attempts = attempts + 1 WHERE bot_id = ${bot.id} AND update_id = ${update.update_id}`);
|
|
27
|
+
const tgChat = updateChat(update);
|
|
28
|
+
const chat = tgChat ? await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${bot.id} AND chat_id = ${String(tgChat.id)}`) : null;
|
|
29
|
+
const ctx = createUpdateContext(rt, bot, update, cls, command, chat, started);
|
|
30
|
+
let outcome = "ignored";
|
|
31
|
+
for (const h of defaultHandlers()) {
|
|
32
|
+
outcome = await h.handle(ctx);
|
|
33
|
+
if (outcome !== "next") break;
|
|
34
|
+
}
|
|
35
|
+
result = outcome === "handled" ? "done" : "ignored";
|
|
36
|
+
queued = ctx.queued;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
result = "failed";
|
|
39
|
+
error = e instanceof Error ? e.name : "error";
|
|
40
|
+
rt.log.error("tg.update_failed", {
|
|
41
|
+
botId: bot.id,
|
|
42
|
+
updateId: update.update_id,
|
|
43
|
+
error
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
await rt.db.run(sql`UPDATE tg_update SET status = ${result}, processed_at = ${rt.clock.now()}, error = ${error}
|
|
47
|
+
WHERE bot_id = ${bot.id} AND update_id = ${update.update_id}`).catch(() => {});
|
|
48
|
+
rt.log.info("tg.update", {
|
|
49
|
+
botId: bot.id,
|
|
50
|
+
updateId: update.update_id,
|
|
51
|
+
kind: cls,
|
|
52
|
+
status: result,
|
|
53
|
+
ms: rt.clock.now() - started
|
|
54
|
+
});
|
|
55
|
+
if (queued) await rt.transport.kick([bot.id]).catch(() => {});
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/** Updates stuck in `received` (the webhook's waitUntil was cut off). Their bodies are not stored, so they are
|
|
59
|
+
* marked `failed` (Queue mode retries them through the queue instead). */
|
|
60
|
+
async function sweepStuckUpdates(rt, now, olderThanMs = 12e4) {
|
|
61
|
+
const r = await rt.db.run(sql`UPDATE tg_update SET status = 'failed', error = 'stalled', processed_at = ${now}
|
|
62
|
+
WHERE tenant = ${rt.tenant} AND status = 'received' AND received_at < ${now - olderThanMs}`);
|
|
63
|
+
if (r.changes > 0) rt.log.warn("tg.updates_stalled", { count: r.changes });
|
|
64
|
+
return r.changes;
|
|
65
|
+
}
|
|
66
|
+
export { processUpdate, recordUpdate, sweepStuckUpdates };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { sql } from "../../ports/sql.js";
|
|
2
|
+
import { settle } from "../../telegram/classify.js";
|
|
3
|
+
import { withToken } from "../bot-api.js";
|
|
4
|
+
import { actionCallback, cutText } from "../actions/callback.js";
|
|
5
|
+
import { dialogHandler } from "../dialogs/dialog.js";
|
|
6
|
+
import { hostCommandsHandler } from "./commands.js";
|
|
7
|
+
import { builtinsHandler, fallbackHandler } from "./builtins.js";
|
|
8
|
+
import { hostContext, senderMayAct } from "./context.js";
|
|
9
|
+
import { inboundHandler } from "../inbound/handler.js";
|
|
10
|
+
import { lifecycleHandler } from "./lifecycle.js";
|
|
11
|
+
import { startHandler } from "./linking.js";
|
|
12
|
+
// src/engine/dispatch/handlers.ts
|
|
13
|
+
/** Inbound limit per chat (default 20/min): one "slow down" reply, then silence until the minute passes. */
|
|
14
|
+
async function rateLimitHandler(ctx) {
|
|
15
|
+
const { rt, tgChat } = ctx;
|
|
16
|
+
if (!tgChat || ctx.cls === "lifecycle") return "next";
|
|
17
|
+
const n = (await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_update WHERE bot_id = ${ctx.bot.id}
|
|
18
|
+
AND update_id > ${ctx.update.update_id - 5e3} AND update_id <= ${ctx.update.update_id}
|
|
19
|
+
AND chat_id = ${String(tgChat.id)} AND received_at > ${ctx.now - 6e4} AND kind != 'my_chat_member'`))?.n ?? 0;
|
|
20
|
+
const limit = rt.limits.inboundPerMin;
|
|
21
|
+
if (n <= limit) return "next";
|
|
22
|
+
if (n === limit + 1) {
|
|
23
|
+
rt.log.warn("tg.inbound_limited", { botId: ctx.bot.id });
|
|
24
|
+
if (ctx.update.callback_query) await answerCallback(ctx, await ctx.t("bot.error.rate_limited"));
|
|
25
|
+
else await ctx.reply(await ctx.t("bot.error.rate_limited"));
|
|
26
|
+
} else if (ctx.update.callback_query) await answerCallback(ctx);
|
|
27
|
+
return "ignored";
|
|
28
|
+
}
|
|
29
|
+
/** `answerCallbackQuery` is sent directly (time-sensitive), never through the outbox. */
|
|
30
|
+
async function answerCallback(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
|
+
/** `a:<id>` action tokens (features.actions). Any other data only stops the spinner with "expired". */
|
|
42
|
+
async function callbackHandler(ctx) {
|
|
43
|
+
if (ctx.cls !== "callback") return "next";
|
|
44
|
+
const data = ctx.update.callback_query?.data ?? "";
|
|
45
|
+
if (data.startsWith("a:")) return actionCallback(ctx, data);
|
|
46
|
+
await answerCallback(ctx, ctx.rt.i18n.plain(await ctx.locale(), "bot.action.expired"));
|
|
47
|
+
return "handled";
|
|
48
|
+
}
|
|
49
|
+
/** AI free text (features.ai.freeText): plain text in a linked chat goes to `hooks.onFreeText`. */
|
|
50
|
+
async function freeTextHandler(ctx) {
|
|
51
|
+
const { rt } = ctx;
|
|
52
|
+
if (ctx.cls !== "text" || !rt.features.aiFreeText || !rt.hooks.onFreeText) return "next";
|
|
53
|
+
if (ctx.chat?.status !== "active" || await ctx.actor() === null) return "next";
|
|
54
|
+
if (!await senderMayAct(ctx)) return "next";
|
|
55
|
+
const host = await hostContext(ctx, ctx.message?.text ?? "");
|
|
56
|
+
await rt.hook("onFreeText", (fn) => fn(host));
|
|
57
|
+
return "handled";
|
|
58
|
+
}
|
|
59
|
+
function defaultHandlers() {
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
name: "lifecycle",
|
|
63
|
+
handle: lifecycleHandler
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "rate_limit",
|
|
67
|
+
handle: rateLimitHandler
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "dialogs",
|
|
71
|
+
handle: dialogHandler
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "start",
|
|
75
|
+
handle: startHandler
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "builtins",
|
|
79
|
+
handle: builtinsHandler
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "commands",
|
|
83
|
+
handle: hostCommandsHandler
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "callbacks",
|
|
87
|
+
handle: callbackHandler
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "inbound",
|
|
91
|
+
handle: inboundHandler
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "free_text",
|
|
95
|
+
handle: freeTextHandler
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: "fallback",
|
|
99
|
+
handle: fallbackHandler
|
|
100
|
+
}
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
export { answerCallback, defaultHandlers };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { sql } from "../../ports/sql.js";
|
|
2
|
+
import { afterChatGone, chatGoneSql, migrateChat } from "../chat-state.js";
|
|
3
|
+
// src/engine/dispatch/lifecycle.ts
|
|
4
|
+
const PRESENT = /* @__PURE__ */ new Set([
|
|
5
|
+
"member",
|
|
6
|
+
"administrator",
|
|
7
|
+
"creator",
|
|
8
|
+
"restricted"
|
|
9
|
+
]);
|
|
10
|
+
const GONE = /* @__PURE__ */ new Set(["left", "kicked"]);
|
|
11
|
+
async function chatByTelegramId(ctx, chatId) {
|
|
12
|
+
return ctx.rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${ctx.rt.tenant} AND bot_id = ${ctx.bot.id} AND chat_id = ${String(chatId)}`);
|
|
13
|
+
}
|
|
14
|
+
async function markGone(ctx, row, status) {
|
|
15
|
+
if (row.status === status) return;
|
|
16
|
+
await ctx.rt.db.batch(chatGoneSql(ctx.rt, row, status, ctx.now));
|
|
17
|
+
ctx.chat = {
|
|
18
|
+
...row,
|
|
19
|
+
status,
|
|
20
|
+
updated_at: ctx.now
|
|
21
|
+
};
|
|
22
|
+
ctx.rt.log.info("tg.chat_status", {
|
|
23
|
+
botId: ctx.bot.id,
|
|
24
|
+
chatRef: row.id,
|
|
25
|
+
status
|
|
26
|
+
});
|
|
27
|
+
await afterChatGone(ctx.rt, row, status, ctx.now);
|
|
28
|
+
}
|
|
29
|
+
async function markBack(ctx, row, tgChat) {
|
|
30
|
+
if (row.status !== "blocked" && row.status !== "left") return;
|
|
31
|
+
const status = (row.kind === "private" ? row.user_id !== null : row.audience_json !== null) ? "active" : "pending";
|
|
32
|
+
const title = tgChat.title ?? row.title;
|
|
33
|
+
await ctx.rt.db.run(sql`UPDATE tg_chat SET status = ${status}, title = ${title}, updated_at = ${ctx.now}
|
|
34
|
+
WHERE tenant = ${ctx.rt.tenant} AND id = ${row.id}`);
|
|
35
|
+
ctx.chat = {
|
|
36
|
+
...row,
|
|
37
|
+
status,
|
|
38
|
+
title,
|
|
39
|
+
updated_at: ctx.now
|
|
40
|
+
};
|
|
41
|
+
ctx.rt.log.info("tg.chat_status", {
|
|
42
|
+
botId: ctx.bot.id,
|
|
43
|
+
chatRef: row.id,
|
|
44
|
+
status
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function lifecycleHandler(ctx) {
|
|
48
|
+
if (ctx.cls !== "lifecycle") return "next";
|
|
49
|
+
const { update, bot } = ctx;
|
|
50
|
+
const member = update.my_chat_member;
|
|
51
|
+
if (member) {
|
|
52
|
+
const row = ctx.chat ?? await chatByTelegramId(ctx, member.chat.id);
|
|
53
|
+
if (!row) return "ignored";
|
|
54
|
+
const status = member.new_chat_member.status;
|
|
55
|
+
if (GONE.has(status)) await markGone(ctx, row, member.chat.type === "private" ? "blocked" : "left");
|
|
56
|
+
else if (PRESENT.has(status)) await markBack(ctx, row, member.chat);
|
|
57
|
+
return "handled";
|
|
58
|
+
}
|
|
59
|
+
if (update.chat_member) return "ignored";
|
|
60
|
+
const m = ctx.message;
|
|
61
|
+
if (!m) return "ignored";
|
|
62
|
+
if (m.migrate_to_chat_id !== void 0) {
|
|
63
|
+
const row = ctx.chat ?? await chatByTelegramId(ctx, m.chat.id);
|
|
64
|
+
if (row) ctx.chat = await migrateChat(ctx.rt, row, String(m.migrate_to_chat_id), ctx.now);
|
|
65
|
+
return row ? "handled" : "ignored";
|
|
66
|
+
}
|
|
67
|
+
if (m.migrate_from_chat_id !== void 0) {
|
|
68
|
+
const old = await chatByTelegramId(ctx, m.migrate_from_chat_id);
|
|
69
|
+
if (old) ctx.chat = await migrateChat(ctx.rt, old, String(m.chat.id), ctx.now);
|
|
70
|
+
return old ? "handled" : "ignored";
|
|
71
|
+
}
|
|
72
|
+
const botTgId = Number(bot.tg_bot_id);
|
|
73
|
+
if (m.left_chat_member?.id === botTgId) {
|
|
74
|
+
const row = ctx.chat;
|
|
75
|
+
if (row) await markGone(ctx, row, "left");
|
|
76
|
+
return row ? "handled" : "ignored";
|
|
77
|
+
}
|
|
78
|
+
if (m.new_chat_members?.some((u) => u.id === botTgId)) {
|
|
79
|
+
const row = ctx.chat;
|
|
80
|
+
if (row) await markBack(ctx, row, m.chat);
|
|
81
|
+
return row ? "handled" : "ignored";
|
|
82
|
+
}
|
|
83
|
+
return "ignored";
|
|
84
|
+
}
|
|
85
|
+
export { lifecycleHandler };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sql } from "../../ports/sql.js";
|
|
2
|
+
import { cancelQueuedSql } from "../chat-state.js";
|
|
3
|
+
// src/engine/dispatch/link-chat.ts
|
|
4
|
+
/** Inserts the chat as active, or re-links the existing row (its locale is kept). */
|
|
5
|
+
function upsertChatSql(rt, id, f, now) {
|
|
6
|
+
return sql`INSERT INTO tg_chat (id, tenant, bot_id, chat_id, kind, title, username, user_id, audience_json, status,
|
|
7
|
+
locale, linked_by, linked_at, created_at, updated_at)
|
|
8
|
+
VALUES (${id}, ${rt.tenant}, ${f.botId}, ${f.chatId}, ${f.kind}, ${f.title}, ${f.username},
|
|
9
|
+
${f.userId}, ${f.audienceJson}, 'active', ${f.locale}, ${f.linkedBy}, ${now}, ${now}, ${now})
|
|
10
|
+
ON CONFLICT (tenant, bot_id, chat_id) DO UPDATE SET
|
|
11
|
+
kind = excluded.kind, title = excluded.title, username = excluded.username,
|
|
12
|
+
user_id = excluded.user_id, audience_json = excluded.audience_json, status = 'active',
|
|
13
|
+
locale = COALESCE(tg_chat.locale, excluded.locale), linked_by = excluded.linked_by,
|
|
14
|
+
linked_at = excluded.linked_at, updated_at = excluded.updated_at`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Without `allowMultipleChatsPerUser`, one Telegram account per user per bot: the user's other private chats on
|
|
18
|
+
* this bot are released (pending, queued messages cancelled).
|
|
19
|
+
*/
|
|
20
|
+
async function releaseOtherChatsSql(rt, botId, userId, keepId, now) {
|
|
21
|
+
if (rt.allowMultipleChatsPerUser) return [];
|
|
22
|
+
return (await rt.db.all(sql`SELECT id FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${botId} AND kind = 'private'
|
|
23
|
+
AND user_id = ${userId} AND id != ${keepId}`)).flatMap((o) => [sql`UPDATE tg_chat SET user_id = NULL, status = 'pending', updated_at = ${now}
|
|
24
|
+
WHERE tenant = ${rt.tenant} AND id = ${o.id}`, cancelQueuedSql(rt, o.id, "chat_unlinked", now)]);
|
|
25
|
+
}
|
|
26
|
+
/** Display name from Telegram first / last name (≤ 128 chars), or null. */
|
|
27
|
+
function displayName(first, last) {
|
|
28
|
+
const s = [first, last].filter(Boolean).join(" ").trim();
|
|
29
|
+
return s === "" ? null : s.slice(0, 128);
|
|
30
|
+
}
|
|
31
|
+
export { displayName, releaseOtherChatsSql, upsertChatSql };
|