@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,83 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
3
|
+
import { chatDto } from "../engine/rows.js";
|
|
4
|
+
import { actorId } from "./bots.js";
|
|
5
|
+
import { cancelQueuedSql } from "../engine/chat-state.js";
|
|
6
|
+
// src/services/chats.ts
|
|
7
|
+
async function listChats(rt, q, page) {
|
|
8
|
+
const where = [sql`c.tenant = ${rt.tenant}`, sql`b.deleted_at IS NULL`];
|
|
9
|
+
if (q.bot) where.push(sql`c.bot_id = ${q.bot}`);
|
|
10
|
+
if (q.kind) where.push(sql`c.kind = ${q.kind}`);
|
|
11
|
+
if (q.status) where.push(sql`c.status = ${q.status}`);
|
|
12
|
+
if (q.user) where.push(sql`c.user_id = ${q.user}`);
|
|
13
|
+
if (page.cursor) where.push(sql`c.id < ${page.cursor}`);
|
|
14
|
+
return rt.db.all(sql`SELECT c.* FROM tg_chat c JOIN tg_bot b ON b.id = c.bot_id WHERE ${sql.join(where, " AND ")}
|
|
15
|
+
ORDER BY c.id DESC LIMIT ${page.limit + 1}`);
|
|
16
|
+
}
|
|
17
|
+
async function requireChat(rt, id) {
|
|
18
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id = ${id}`);
|
|
19
|
+
if (!row) throw new TelegramPluginError("chat_not_found", "chat not found");
|
|
20
|
+
return row;
|
|
21
|
+
}
|
|
22
|
+
async function patchChat(rt, actor, id, patch) {
|
|
23
|
+
const chat = await requireChat(rt, id);
|
|
24
|
+
const sets = [];
|
|
25
|
+
if (patch.audience !== void 0) {
|
|
26
|
+
if (chat.kind === "private" && patch.audience !== null) throw new TelegramPluginError("validation_failed", "private chats use their linked user", { extensions: { issues: [{
|
|
27
|
+
path: "audience",
|
|
28
|
+
message: "only groups have an audience"
|
|
29
|
+
}] } });
|
|
30
|
+
sets.push(sql`audience_json = ${patch.audience === null ? null : JSON.stringify(patch.audience)}`);
|
|
31
|
+
}
|
|
32
|
+
if (patch.locale !== void 0) sets.push(sql`locale = ${patch.locale === null ? null : rt.i18n.pick(patch.locale)}`);
|
|
33
|
+
if (patch.timezone !== void 0) sets.push(sql`timezone = ${patch.timezone}`);
|
|
34
|
+
if (patch.topics !== void 0) sets.push(sql`topics_json = ${JSON.stringify(patch.topics)}`);
|
|
35
|
+
if (sets.length > 0) {
|
|
36
|
+
const now = rt.clock.now();
|
|
37
|
+
await rt.db.batch([sql`UPDATE tg_chat SET ${sql.join(sets)}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
38
|
+
actor: actorId(rt, actor),
|
|
39
|
+
entity: "telegram_chat",
|
|
40
|
+
entityId: id,
|
|
41
|
+
action: "update",
|
|
42
|
+
diff: { fields: Object.keys(patch) },
|
|
43
|
+
at: now
|
|
44
|
+
})]);
|
|
45
|
+
}
|
|
46
|
+
return chatDto(await requireChat(rt, id));
|
|
47
|
+
}
|
|
48
|
+
/** Unlink: the row stays (pacing, history) as `pending` without user / audience; its queue is cancelled. */
|
|
49
|
+
async function unlinkChat(rt, actor, id) {
|
|
50
|
+
const chat = await requireChat(rt, id);
|
|
51
|
+
const now = rt.clock.now();
|
|
52
|
+
await rt.db.batch([
|
|
53
|
+
sql`UPDATE tg_chat SET user_id = NULL, audience_json = NULL, status = CASE WHEN status = 'active' THEN 'pending' ELSE status END,
|
|
54
|
+
updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`,
|
|
55
|
+
cancelQueuedSql(rt, id, "chat_unlinked", now),
|
|
56
|
+
...rt.audit({
|
|
57
|
+
actor: actorId(rt, actor),
|
|
58
|
+
entity: "telegram_chat",
|
|
59
|
+
entityId: id,
|
|
60
|
+
action: "unlink",
|
|
61
|
+
at: now
|
|
62
|
+
})
|
|
63
|
+
]);
|
|
64
|
+
const dto = chatDto({
|
|
65
|
+
...chat,
|
|
66
|
+
user_id: null,
|
|
67
|
+
audience_json: null,
|
|
68
|
+
status: "pending",
|
|
69
|
+
updated_at: now
|
|
70
|
+
});
|
|
71
|
+
await rt.hook("onUnlinked", (fn) => fn(dto));
|
|
72
|
+
}
|
|
73
|
+
/** Private chats linked to this user (`/me/chats`). */
|
|
74
|
+
async function myChats(rt, userId) {
|
|
75
|
+
return rt.db.all(sql`SELECT c.* FROM tg_chat c JOIN tg_bot b ON b.id = c.bot_id
|
|
76
|
+
WHERE c.tenant = ${rt.tenant} AND c.user_id = ${userId} AND b.deleted_at IS NULL ORDER BY c.id DESC`);
|
|
77
|
+
}
|
|
78
|
+
async function requireOwnChat(rt, userId, id) {
|
|
79
|
+
const chat = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id = ${id} AND user_id = ${userId}`);
|
|
80
|
+
if (!chat) throw new TelegramPluginError("chat_not_found", "chat not found");
|
|
81
|
+
return chat;
|
|
82
|
+
}
|
|
83
|
+
export { listChats, myChats, patchChat, requireChat, requireOwnChat, unlinkChat };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { isBotUsable } from "../engine/bot-api.js";
|
|
3
|
+
import { STATE_KEYS, setState } from "../engine/state.js";
|
|
4
|
+
import { syncCommandMenu } from "./menu.js";
|
|
5
|
+
import { testBot } from "./bots.js";
|
|
6
|
+
import { PENDING_LINK_PREFIX, PENDING_LINK_TTL_MS } from "../engine/link-codes.js";
|
|
7
|
+
import { IDEMPOTENCY_PREFIX, IDEMPOTENCY_TTL_MS } from "./idempotency.js";
|
|
8
|
+
// src/services/cleanup.ts
|
|
9
|
+
const DAY = 864e5;
|
|
10
|
+
/** Event throttle rows in `tg_state` (`thr:<rule>:<value>`, see engine/events/tick.ts). */
|
|
11
|
+
const THROTTLE_PREFIX = "thr:";
|
|
12
|
+
/** Longest throttle window a rule may set (contracts/events.ts `perMinutes` ≤ 10 080 = 7 days). */
|
|
13
|
+
const MAX_THROTTLE_MS = 7 * DAY;
|
|
14
|
+
const CHUNK = 500;
|
|
15
|
+
const MAX_PER_TABLE = 2e4;
|
|
16
|
+
/** Deletes in chunks of 500 rows (bounded per run) so one statement never holds D1 for long. */
|
|
17
|
+
async function deleteWhere(rt, table, where) {
|
|
18
|
+
let total = 0;
|
|
19
|
+
for (;;) {
|
|
20
|
+
const r = await rt.db.run(sql`DELETE FROM ${sql.ident(table)} WHERE rowid IN (SELECT rowid FROM ${sql.ident(table)} WHERE ${where} LIMIT ${CHUNK})`);
|
|
21
|
+
total += r.changes;
|
|
22
|
+
if (r.changes < CHUNK || total >= MAX_PER_TABLE) return total;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function cleanup(rt) {
|
|
26
|
+
const now = rt.clock.now();
|
|
27
|
+
const r = rt.retention;
|
|
28
|
+
const t = rt.tenant;
|
|
29
|
+
const deleted = {};
|
|
30
|
+
const add = (k, n) => {
|
|
31
|
+
deleted[k] = (deleted[k] ?? 0) + n;
|
|
32
|
+
};
|
|
33
|
+
add("delivery", await deleteWhere(rt, "tg_delivery", sql`tenant = ${t} AND status IN ('sent', 'dropped', 'cancelled') AND updated_at < ${now - r.sentDays * DAY}`));
|
|
34
|
+
add("delivery", await deleteWhere(rt, "tg_delivery", sql`tenant = ${t} AND status = 'failed' AND updated_at < ${now - r.failedDays * DAY}`));
|
|
35
|
+
add("update", await deleteWhere(rt, "tg_update", sql`tenant = ${t} AND received_at < ${now - r.updateDays * DAY}`));
|
|
36
|
+
add("event", await deleteWhere(rt, "tg_event", sql`tenant = ${t} AND status IN ('done', 'ignored', 'failed') AND COALESCE(processed_at, occurred_at) < ${now - r.eventDays * DAY}`));
|
|
37
|
+
add("schedule_run", await deleteWhere(rt, "tg_schedule_run", sql`tenant = ${t} AND started_at < ${now - r.runDays * DAY}`));
|
|
38
|
+
const expired = now - r.expiredDays * DAY;
|
|
39
|
+
add("link_code", await deleteWhere(rt, "tg_link_code", sql`tenant = ${t} AND expires_at < ${expired}`));
|
|
40
|
+
add("action_token", await deleteWhere(rt, "tg_action_token", sql`tenant = ${t} AND expires_at < ${expired}`));
|
|
41
|
+
add("dialog", await deleteWhere(rt, "tg_dialog", sql`tenant = ${t} AND expires_at < ${expired}`));
|
|
42
|
+
add("audit", await deleteWhere(rt, "tg_audit", sql`tenant = ${t} AND at < ${now - r.auditDays * DAY}`));
|
|
43
|
+
add("idempotency", await deleteWhere(rt, "tg_state", sql`tenant = ${t} AND substr(key, 1, ${IDEMPOTENCY_PREFIX.length}) = ${IDEMPOTENCY_PREFIX} AND updated_at < ${now - IDEMPOTENCY_TTL_MS}`));
|
|
44
|
+
add("pending_link", await deleteWhere(rt, "tg_state", sql`tenant = ${t} AND substr(key, 1, ${PENDING_LINK_PREFIX.length}) = ${PENDING_LINK_PREFIX} AND updated_at < ${now - PENDING_LINK_TTL_MS}`));
|
|
45
|
+
add("throttle", await deleteWhere(rt, "tg_state", sql`tenant = ${t} AND substr(key, 1, ${4}) = ${THROTTLE_PREFIX}
|
|
46
|
+
AND updated_at < ${now - Math.max(r.eventDays * DAY, MAX_THROTTLE_MS)}`));
|
|
47
|
+
let bodiesCleared = 0;
|
|
48
|
+
for (;;) {
|
|
49
|
+
const res = await rt.db.run(sql`UPDATE tg_delivery SET body_html = NULL WHERE rowid IN (SELECT rowid FROM tg_delivery
|
|
50
|
+
WHERE tenant = ${t} AND status IN ('sent', 'dropped', 'cancelled', 'failed') AND body_html IS NOT NULL
|
|
51
|
+
AND updated_at < ${now - r.bodyDays * DAY} LIMIT ${CHUNK})`);
|
|
52
|
+
bodiesCleared += res.changes;
|
|
53
|
+
if (res.changes < CHUNK || bodiesCleared >= MAX_PER_TABLE) break;
|
|
54
|
+
}
|
|
55
|
+
let botsChecked = 0;
|
|
56
|
+
const bots = await rt.db.all(sql`SELECT * FROM tg_bot WHERE tenant = ${t} AND deleted_at IS NULL`);
|
|
57
|
+
for (const bot of bots) {
|
|
58
|
+
if (!isBotUsable(bot)) continue;
|
|
59
|
+
try {
|
|
60
|
+
await testBot(rt, bot.id);
|
|
61
|
+
botsChecked++;
|
|
62
|
+
const fresh = await rt.db.first(sql`SELECT * FROM tg_bot WHERE id = ${bot.id}`);
|
|
63
|
+
if (fresh && isBotUsable(fresh)) await syncCommandMenu(rt, fresh);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
rt.log.warn("tg.cleanup_bot_check_failed", {
|
|
66
|
+
botId: bot.id,
|
|
67
|
+
error: e instanceof Error ? e.name : "error"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
await setState(rt, STATE_KEYS.lastCleanupAt, String(rt.clock.now()), rt.clock.now());
|
|
72
|
+
rt.log.info("tg.cleanup", {
|
|
73
|
+
...deleted,
|
|
74
|
+
bodiesCleared,
|
|
75
|
+
botsChecked
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
deleted,
|
|
79
|
+
bodiesCleared,
|
|
80
|
+
botsChecked
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export { cleanup };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { LocalizedText } from "../contracts/common.js";
|
|
2
|
+
import { BUILTIN_COMMANDS, CommandName } from "../contracts/commands.js";
|
|
3
|
+
import { sql } from "../ports/sql.js";
|
|
4
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
5
|
+
import { parseJson } from "../engine/rows.js";
|
|
6
|
+
import { botRuntime } from "../engine/bot-features.js";
|
|
7
|
+
import { RESERVED_COMMANDS } from "../engine/registry.js";
|
|
8
|
+
import { BUILTIN_MENU } from "../engine/dispatch/builtins.js";
|
|
9
|
+
import { actorId } from "./bots.js";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
// src/services/commands.ts
|
|
12
|
+
const Names = z.array(CommandName);
|
|
13
|
+
const Roles = z.array(z.string().min(1));
|
|
14
|
+
async function commandRows(rt, botId) {
|
|
15
|
+
return rt.db.all(sql`SELECT * FROM tg_command WHERE tenant = ${rt.tenant} AND bot_id = ${botId}`);
|
|
16
|
+
}
|
|
17
|
+
/** Registered host commands with this bot's configuration applied, menu-sorted (sort, then name). */
|
|
18
|
+
function effectiveCommands(rt, rows) {
|
|
19
|
+
const byName = new Map(rows.map((r) => [r.command, r]));
|
|
20
|
+
return rt.registry.commandList.map((def) => {
|
|
21
|
+
const row = byName.get(def.command);
|
|
22
|
+
const rolesOverride = row ? parseJson(row.roles_json, Roles.nullable(), null) : null;
|
|
23
|
+
return {
|
|
24
|
+
def,
|
|
25
|
+
enabled: row ? row.enabled === 1 : true,
|
|
26
|
+
roles: rolesOverride ?? def.defaultRoles,
|
|
27
|
+
rolesOverride,
|
|
28
|
+
aliases: row?.aliases_json ? parseJson(row.aliases_json, Names, [...def.aliases]) : def.aliases,
|
|
29
|
+
description: row?.description_json ? parseJson(row.description_json, LocalizedText, def.description) : def.description,
|
|
30
|
+
showInMenu: row ? row.show_in_menu === 1 : def.showInMenu,
|
|
31
|
+
sort: row ? row.sort : def.sort
|
|
32
|
+
};
|
|
33
|
+
}).sort((a, b) => a.sort - b.sort || a.def.command.localeCompare(b.def.command));
|
|
34
|
+
}
|
|
35
|
+
/** Whether a role list (`defaultRoles` / per-bot roles) admits `role`: `"*"` = any role, `[]` = none. */
|
|
36
|
+
function rolesAllow(roles, role) {
|
|
37
|
+
if (roles.includes("*")) return true;
|
|
38
|
+
return role !== null && roles.includes(role);
|
|
39
|
+
}
|
|
40
|
+
/** The enabled command for a name or alias. */
|
|
41
|
+
function findCommand(list, name) {
|
|
42
|
+
return list.find((c) => c.enabled && (c.def.command === name || c.aliases.includes(name))) ?? null;
|
|
43
|
+
}
|
|
44
|
+
function describe(description, locale) {
|
|
45
|
+
return description[locale] ?? description[locale.split("-")[0] ?? ""] ?? Object.values(description)[0] ?? "";
|
|
46
|
+
}
|
|
47
|
+
function builtinDto(rt, command) {
|
|
48
|
+
const groups = BUILTIN_MENU.group.includes(command) || command === "report";
|
|
49
|
+
return {
|
|
50
|
+
command,
|
|
51
|
+
builtin: true,
|
|
52
|
+
enabled: rt.features.commands || command === "start" || command === "help",
|
|
53
|
+
roles: null,
|
|
54
|
+
defaultRoles: ["*"],
|
|
55
|
+
aliases: [],
|
|
56
|
+
description: Object.fromEntries(rt.i18n.locales.map((l) => [l, rt.i18n.plain(l, `bot.cmd.${command}`)])),
|
|
57
|
+
showInMenu: true,
|
|
58
|
+
sort: 0,
|
|
59
|
+
scope: groups ? [
|
|
60
|
+
"private",
|
|
61
|
+
"group",
|
|
62
|
+
"supergroup"
|
|
63
|
+
] : ["private"],
|
|
64
|
+
permission: null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function hostDto(c) {
|
|
68
|
+
return {
|
|
69
|
+
command: c.def.command,
|
|
70
|
+
builtin: false,
|
|
71
|
+
enabled: c.enabled,
|
|
72
|
+
roles: c.rolesOverride,
|
|
73
|
+
defaultRoles: [...c.def.defaultRoles],
|
|
74
|
+
aliases: [...c.aliases],
|
|
75
|
+
description: { ...c.description },
|
|
76
|
+
showInMenu: c.showInMenu,
|
|
77
|
+
sort: c.sort,
|
|
78
|
+
scope: [...c.def.scope],
|
|
79
|
+
permission: c.def.permission
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** Built-ins (incl. `/report` with features.reports) and host commands for one bot. */
|
|
83
|
+
async function listCommandConfigs(base, bot) {
|
|
84
|
+
const rt = botRuntime(base, bot);
|
|
85
|
+
return [...[...BUILTIN_COMMANDS, ...rt.features.reports ? ["report"] : []].map((c) => builtinDto(rt, c)), ...effectiveCommands(rt, await commandRows(rt, bot.id)).map(hostDto)];
|
|
86
|
+
}
|
|
87
|
+
function issue(path, message) {
|
|
88
|
+
return new TelegramPluginError("validation_failed", message, { extensions: { issues: [{
|
|
89
|
+
path,
|
|
90
|
+
message
|
|
91
|
+
}] } });
|
|
92
|
+
}
|
|
93
|
+
/** Upserts a host command's per-bot config (fields not given keep their current value). */
|
|
94
|
+
async function saveCommandConfig(rt, actor, bot, command, save) {
|
|
95
|
+
if (RESERVED_COMMANDS.includes(command) || BUILTIN_COMMANDS.includes(command)) throw issue("command", "built-in commands cannot be configured per bot");
|
|
96
|
+
if (!rt.registry.commandList.find((c) => c.command === command)) throw new TelegramPluginError("not_found", `command /${command} is not registered`);
|
|
97
|
+
const rows = await commandRows(rt, bot.id);
|
|
98
|
+
const current = effectiveCommands(rt, rows).find((c) => c.def.command === command);
|
|
99
|
+
if (save.aliases) {
|
|
100
|
+
const taken = /* @__PURE__ */ new Set();
|
|
101
|
+
for (const c of effectiveCommands(rt, rows)) {
|
|
102
|
+
if (c.def.command === command) continue;
|
|
103
|
+
taken.add(c.def.command);
|
|
104
|
+
for (const a of c.aliases) taken.add(a);
|
|
105
|
+
}
|
|
106
|
+
for (const a of save.aliases) if (a === command || RESERVED_COMMANDS.includes(a) || a === "report" || taken.has(a)) throw issue("body.aliases", `alias "${a}" is taken`);
|
|
107
|
+
}
|
|
108
|
+
const now = rt.clock.now();
|
|
109
|
+
const by = actorId(rt, actor);
|
|
110
|
+
const next = {
|
|
111
|
+
enabled: save.enabled ?? current.enabled,
|
|
112
|
+
roles: save.roles !== void 0 ? save.roles : current.rolesOverride,
|
|
113
|
+
aliases: save.aliases ?? [...current.aliases],
|
|
114
|
+
description: save.description !== void 0 ? save.description : parseJson(rows.find((r) => r.command === command)?.description_json, LocalizedText.nullable(), null),
|
|
115
|
+
showInMenu: save.showInMenu ?? current.showInMenu,
|
|
116
|
+
sort: save.sort ?? current.sort
|
|
117
|
+
};
|
|
118
|
+
const json = (v) => v === null || v === void 0 ? null : JSON.stringify(v);
|
|
119
|
+
await rt.db.batch([sql`INSERT INTO tg_command (tenant, bot_id, command, enabled, roles_json, aliases_json, description_json, show_in_menu,
|
|
120
|
+
sort, updated_by, updated_at)
|
|
121
|
+
VALUES (${rt.tenant}, ${bot.id}, ${command}, ${next.enabled}, ${json(next.roles)}, ${json(next.aliases)},
|
|
122
|
+
${json(next.description)}, ${next.showInMenu}, ${next.sort}, ${by}, ${now})
|
|
123
|
+
ON CONFLICT (tenant, bot_id, command) DO UPDATE SET enabled = excluded.enabled, roles_json = excluded.roles_json,
|
|
124
|
+
aliases_json = excluded.aliases_json, description_json = excluded.description_json,
|
|
125
|
+
show_in_menu = excluded.show_in_menu, sort = excluded.sort, updated_by = excluded.updated_by,
|
|
126
|
+
updated_at = excluded.updated_at`, ...rt.audit({
|
|
127
|
+
actor: by,
|
|
128
|
+
entity: "telegram_command",
|
|
129
|
+
entityId: `${bot.id}:${command}`,
|
|
130
|
+
action: "update",
|
|
131
|
+
diff: {
|
|
132
|
+
enabled: next.enabled,
|
|
133
|
+
roles: next.roles,
|
|
134
|
+
aliases: next.aliases
|
|
135
|
+
},
|
|
136
|
+
at: now
|
|
137
|
+
})]);
|
|
138
|
+
return hostDto(effectiveCommands(rt, await commandRows(rt, bot.id)).find((c) => c.def.command === command));
|
|
139
|
+
}
|
|
140
|
+
export { commandRows, describe, effectiveCommands, findCommand, listCommandConfigs, rolesAllow, saveCommandConfig };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
3
|
+
import { actorId } from "./bots.js";
|
|
4
|
+
// src/services/deliveries.ts
|
|
5
|
+
async function listDeliveries(rt, q, page) {
|
|
6
|
+
const where = [sql`tenant = ${rt.tenant}`];
|
|
7
|
+
if (q.status) where.push(sql`status = ${q.status}`);
|
|
8
|
+
if (q.source) where.push(sql`source = ${q.source}`);
|
|
9
|
+
if (q.bot) where.push(sql`bot_id = ${q.bot}`);
|
|
10
|
+
if (page.cursor) where.push(sql`id < ${page.cursor}`);
|
|
11
|
+
return rt.db.all(sql`SELECT * FROM tg_delivery WHERE ${sql.join(where, " AND ")} ORDER BY id DESC LIMIT ${page.limit + 1}`);
|
|
12
|
+
}
|
|
13
|
+
async function requireDelivery(rt, id) {
|
|
14
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_delivery WHERE tenant = ${rt.tenant} AND id = ${id}`);
|
|
15
|
+
if (!row) throw new TelegramPluginError("not_found", "delivery not found");
|
|
16
|
+
return row;
|
|
17
|
+
}
|
|
18
|
+
async function retryDelivery(rt, actor, id) {
|
|
19
|
+
const row = await requireDelivery(rt, id);
|
|
20
|
+
if (![
|
|
21
|
+
"failed",
|
|
22
|
+
"dropped",
|
|
23
|
+
"cancelled"
|
|
24
|
+
].includes(row.status)) throw new TelegramPluginError("delivery_not_retryable", `a ${row.status} delivery cannot be retried`);
|
|
25
|
+
const now = rt.clock.now();
|
|
26
|
+
if (((await rt.db.batch([sql`UPDATE tg_delivery SET status = 'queued', attempts = 0, next_attempt_at = ${now}, error_code = NULL,
|
|
27
|
+
error_detail = NULL, lease_until = NULL, lease_token = NULL, updated_at = ${now}
|
|
28
|
+
WHERE tenant = ${rt.tenant} AND id = ${id} AND status = ${row.status}`, ...rt.audit({
|
|
29
|
+
actor: actorId(rt, actor),
|
|
30
|
+
entity: "telegram_delivery",
|
|
31
|
+
entityId: id,
|
|
32
|
+
action: "retry",
|
|
33
|
+
at: now
|
|
34
|
+
})]))[0]?.changes ?? 0) === 0) throw new TelegramPluginError("delivery_not_retryable", "the delivery changed meanwhile");
|
|
35
|
+
rt.waitUntil(rt.transport.kick([row.bot_id]));
|
|
36
|
+
return requireDelivery(rt, id);
|
|
37
|
+
}
|
|
38
|
+
async function cancelDelivery(rt, actor, id) {
|
|
39
|
+
const row = await requireDelivery(rt, id);
|
|
40
|
+
const now = rt.clock.now();
|
|
41
|
+
if (!(row.status === "queued" || row.status === "sending" && (row.lease_until ?? 0) < now)) throw new TelegramPluginError("delivery_not_retryable", `a ${row.status} delivery cannot be cancelled`);
|
|
42
|
+
if (((await rt.db.batch([sql`UPDATE tg_delivery SET status = 'cancelled', error_code = 'cancelled_by_admin', updated_at = ${now}
|
|
43
|
+
WHERE tenant = ${rt.tenant} AND id = ${id}
|
|
44
|
+
AND (status = 'queued' OR (status = 'sending' AND lease_until < ${now}))`, ...rt.audit({
|
|
45
|
+
actor: actorId(rt, actor),
|
|
46
|
+
entity: "telegram_delivery",
|
|
47
|
+
entityId: id,
|
|
48
|
+
action: "cancel",
|
|
49
|
+
at: now
|
|
50
|
+
})]))[0]?.changes ?? 0) === 0) throw new TelegramPluginError("delivery_not_retryable", `a ${row.status} delivery cannot be cancelled`);
|
|
51
|
+
return requireDelivery(rt, id);
|
|
52
|
+
}
|
|
53
|
+
export { cancelDelivery, listDeliveries, requireDelivery, retryDelivery };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
3
|
+
import { destinationDto } from "../engine/rows.js";
|
|
4
|
+
import { actorId } from "./bots.js";
|
|
5
|
+
import { requireChat } from "./chats.js";
|
|
6
|
+
// src/services/destinations.ts
|
|
7
|
+
async function listDestinations(rt, page) {
|
|
8
|
+
return rt.db.all(sql`SELECT * FROM tg_destination WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
|
|
9
|
+
${page.cursor ? sql`AND id < ${page.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${page.limit + 1}`);
|
|
10
|
+
}
|
|
11
|
+
async function requireDestination(rt, id) {
|
|
12
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_destination WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`);
|
|
13
|
+
if (!row) throw new TelegramPluginError("not_found", "destination not found");
|
|
14
|
+
return row;
|
|
15
|
+
}
|
|
16
|
+
async function destinationBySlug(rt, slug) {
|
|
17
|
+
return rt.db.first(sql`SELECT * FROM tg_destination WHERE tenant = ${rt.tenant} AND slug = ${slug} AND deleted_at IS NULL`);
|
|
18
|
+
}
|
|
19
|
+
async function checkSlugFree(rt, slug, exceptId) {
|
|
20
|
+
const taken = await rt.db.first(sql`SELECT id, deleted_at FROM tg_destination WHERE tenant = ${rt.tenant} AND slug = ${slug}`);
|
|
21
|
+
if (taken && taken.id !== exceptId && taken.deleted_at === null) throw new TelegramPluginError("validation_failed", "slug is taken", { extensions: { issues: [{
|
|
22
|
+
path: "slug",
|
|
23
|
+
message: "already used by another destination"
|
|
24
|
+
}] } });
|
|
25
|
+
return taken;
|
|
26
|
+
}
|
|
27
|
+
async function createDestination(rt, actor, input) {
|
|
28
|
+
const chat = await requireChat(rt, input.chatRef);
|
|
29
|
+
const taken = await checkSlugFree(rt, input.slug);
|
|
30
|
+
const now = rt.clock.now();
|
|
31
|
+
const by = actorId(rt, actor);
|
|
32
|
+
const free = taken ? [sql`UPDATE tg_destination SET slug = ${`${input.slug}~${taken.id}`.slice(0, 64)} WHERE id = ${taken.id}`] : [];
|
|
33
|
+
const id = rt.ids.uuid();
|
|
34
|
+
await rt.db.batch([
|
|
35
|
+
...free,
|
|
36
|
+
sql`INSERT INTO tg_destination (id, tenant, name, slug, bot_id, chat_ref, message_thread_id, ai_allowed, created_by,
|
|
37
|
+
created_at, updated_at)
|
|
38
|
+
VALUES (${id}, ${rt.tenant}, ${input.name}, ${input.slug}, ${chat.bot_id}, ${chat.id}, ${input.messageThreadId},
|
|
39
|
+
${input.aiAllowed}, ${by}, ${now}, ${now})`,
|
|
40
|
+
...rt.audit({
|
|
41
|
+
actor: by,
|
|
42
|
+
entity: "telegram_destination",
|
|
43
|
+
entityId: id,
|
|
44
|
+
action: "create",
|
|
45
|
+
diff: {
|
|
46
|
+
slug: input.slug,
|
|
47
|
+
aiAllowed: input.aiAllowed
|
|
48
|
+
},
|
|
49
|
+
at: now
|
|
50
|
+
})
|
|
51
|
+
]);
|
|
52
|
+
return destinationDto(await requireDestination(rt, id));
|
|
53
|
+
}
|
|
54
|
+
async function updateDestination(rt, actor, id, input) {
|
|
55
|
+
await requireDestination(rt, id);
|
|
56
|
+
const chat = await requireChat(rt, input.chatRef);
|
|
57
|
+
const taken = await checkSlugFree(rt, input.slug, id);
|
|
58
|
+
const now = rt.clock.now();
|
|
59
|
+
const by = actorId(rt, actor);
|
|
60
|
+
const free = taken && taken.id !== id ? [sql`UPDATE tg_destination SET slug = ${`${input.slug}~${taken.id}`.slice(0, 64)} WHERE id = ${taken.id}`] : [];
|
|
61
|
+
await rt.db.batch([
|
|
62
|
+
...free,
|
|
63
|
+
sql`UPDATE tg_destination SET name = ${input.name}, slug = ${input.slug}, bot_id = ${chat.bot_id}, chat_ref = ${chat.id},
|
|
64
|
+
message_thread_id = ${input.messageThreadId}, ai_allowed = ${input.aiAllowed}, updated_at = ${now}
|
|
65
|
+
WHERE tenant = ${rt.tenant} AND id = ${id}`,
|
|
66
|
+
...rt.audit({
|
|
67
|
+
actor: by,
|
|
68
|
+
entity: "telegram_destination",
|
|
69
|
+
entityId: id,
|
|
70
|
+
action: "update",
|
|
71
|
+
diff: {
|
|
72
|
+
slug: input.slug,
|
|
73
|
+
aiAllowed: input.aiAllowed
|
|
74
|
+
},
|
|
75
|
+
at: now
|
|
76
|
+
})
|
|
77
|
+
]);
|
|
78
|
+
return destinationDto(await requireDestination(rt, id));
|
|
79
|
+
}
|
|
80
|
+
async function deleteDestination(rt, actor, id) {
|
|
81
|
+
await requireDestination(rt, id);
|
|
82
|
+
const now = rt.clock.now();
|
|
83
|
+
await rt.db.batch([sql`UPDATE tg_destination SET deleted_at = ${now}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
84
|
+
actor: actorId(rt, actor),
|
|
85
|
+
entity: "telegram_destination",
|
|
86
|
+
entityId: id,
|
|
87
|
+
action: "delete",
|
|
88
|
+
at: now
|
|
89
|
+
})]);
|
|
90
|
+
}
|
|
91
|
+
export { createDestination, deleteDestination, destinationBySlug, listDestinations, requireDestination, updateDestination };
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Recipients } from "../contracts/recipients.js";
|
|
2
|
+
import { EventFilter, Throttle } from "../contracts/events.js";
|
|
3
|
+
import { zodToShape } from "../core/template/shape.js";
|
|
4
|
+
import { sql } from "../ports/sql.js";
|
|
5
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
6
|
+
import { renderBody } from "../engine/content/render.js";
|
|
7
|
+
import { hostCan, parseProviderParams, requireProvider, runProvider } from "../engine/content/providers.js";
|
|
8
|
+
import { parseJson } from "../engine/rows.js";
|
|
9
|
+
import { templateById, templateModel } from "../engine/content/template-rows.js";
|
|
10
|
+
import { jsonSchemaOf } from "../engine/registry.js";
|
|
11
|
+
import { actorId } from "./bots.js";
|
|
12
|
+
import { checkFilter, matchFilters } from "../engine/events/filters.js";
|
|
13
|
+
import { payloadVarsFor } from "../engine/events/payload.js";
|
|
14
|
+
import { fanOut } from "../engine/content/fanout.js";
|
|
15
|
+
import { resolveChats } from "../engine/content/recipients.js";
|
|
16
|
+
// src/services/event-rules.ts
|
|
17
|
+
function eventRuleModel(r) {
|
|
18
|
+
return {
|
|
19
|
+
id: r.id,
|
|
20
|
+
event: r.event,
|
|
21
|
+
name: r.name,
|
|
22
|
+
filters: parseJson(r.filter_json, EventFilter.array(), []),
|
|
23
|
+
templateId: r.template_id,
|
|
24
|
+
recipients: parseJson(r.recipients_json, Recipients, []),
|
|
25
|
+
throttle: parseJson(r.throttle_json, Throttle.nullable(), null),
|
|
26
|
+
enabled: r.enabled === 1
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function eventRuleDto(r) {
|
|
30
|
+
return {
|
|
31
|
+
...eventRuleModel(r),
|
|
32
|
+
createdBy: r.created_by,
|
|
33
|
+
updatedAt: r.updated_at
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The host's registered events as DTOs (payload JSON Schema, filterable / sensitive fields) for admin UIs. */
|
|
37
|
+
function registeredEvents(rt) {
|
|
38
|
+
return [...rt.registry.events.values()].map((e) => ({
|
|
39
|
+
key: e.key,
|
|
40
|
+
label: e.label,
|
|
41
|
+
filterable: [...e.filterable],
|
|
42
|
+
sensitive: [...e.sensitive],
|
|
43
|
+
provider: e.provider,
|
|
44
|
+
payloadSchema: jsonSchemaOf(e.payload)
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
function issue(path, message) {
|
|
48
|
+
return new TelegramPluginError("validation_failed", message, { extensions: { issues: [{
|
|
49
|
+
path,
|
|
50
|
+
message
|
|
51
|
+
}] } });
|
|
52
|
+
}
|
|
53
|
+
function requireEvent(rt, key, path = "body.event") {
|
|
54
|
+
const def = rt.registry.events.get(key);
|
|
55
|
+
if (!def) throw issue(path, `event "${key}" is not registered`);
|
|
56
|
+
return def;
|
|
57
|
+
}
|
|
58
|
+
/** Save-time checks: event registered, filters on `filterable` fields with fitting values, template, throttle key. */
|
|
59
|
+
async function validateRuleSave(rt, save) {
|
|
60
|
+
const def = requireEvent(rt, save.event);
|
|
61
|
+
const shape = zodToShape(def.payload);
|
|
62
|
+
for (const [i, f] of save.filters.entries()) {
|
|
63
|
+
const err = checkFilter(f, def.filterable, shape);
|
|
64
|
+
if (err) throw issue(`body.filters.${i}`, err);
|
|
65
|
+
}
|
|
66
|
+
if (save.throttle?.key !== void 0 && shape.kind === "object" && !Object.hasOwn(shape.fields, save.throttle.key)) throw issue("body.throttle.key", `"${save.throttle.key}" is not a payload field of "${def.key}"`);
|
|
67
|
+
if (!await templateById(rt, save.templateId)) throw issue("body.templateId", "template not found");
|
|
68
|
+
}
|
|
69
|
+
async function listRules(rt, q, page) {
|
|
70
|
+
return rt.db.all(sql`SELECT * FROM tg_event_rule WHERE tenant = ${rt.tenant} AND deleted_at IS NULL
|
|
71
|
+
${q.event ? sql`AND event = ${q.event}` : sql.empty}
|
|
72
|
+
${page.cursor ? sql`AND id < ${page.cursor}` : sql.empty} ORDER BY id DESC LIMIT ${page.limit + 1}`);
|
|
73
|
+
}
|
|
74
|
+
async function requireRule(rt, id) {
|
|
75
|
+
const row = await rt.db.first(sql`SELECT * FROM tg_event_rule WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`);
|
|
76
|
+
if (!row) throw new TelegramPluginError("not_found", "event rule not found");
|
|
77
|
+
return row;
|
|
78
|
+
}
|
|
79
|
+
const throttleJson = (t) => t ? JSON.stringify(t) : null;
|
|
80
|
+
async function createRule(rt, actor, save) {
|
|
81
|
+
await validateRuleSave(rt, save);
|
|
82
|
+
const now = rt.clock.now();
|
|
83
|
+
const by = actorId(rt, actor);
|
|
84
|
+
const id = rt.ids.uuid();
|
|
85
|
+
await rt.db.batch([sql`INSERT INTO tg_event_rule (id, tenant, event, name, filter_json, template_id, recipients_json, throttle_json,
|
|
86
|
+
enabled, created_by, created_at, updated_at)
|
|
87
|
+
VALUES (${id}, ${rt.tenant}, ${save.event}, ${save.name}, ${JSON.stringify(save.filters)}, ${save.templateId},
|
|
88
|
+
${JSON.stringify(save.recipients)}, ${throttleJson(save.throttle)}, ${save.enabled}, ${by}, ${now}, ${now})`, ...rt.audit({
|
|
89
|
+
actor: by,
|
|
90
|
+
entity: "telegram_event_rule",
|
|
91
|
+
entityId: id,
|
|
92
|
+
action: "create",
|
|
93
|
+
diff: { event: save.event },
|
|
94
|
+
at: now
|
|
95
|
+
})]);
|
|
96
|
+
return eventRuleDto(await requireRule(rt, id));
|
|
97
|
+
}
|
|
98
|
+
async function updateRule(rt, actor, id, save) {
|
|
99
|
+
await requireRule(rt, id);
|
|
100
|
+
await validateRuleSave(rt, save);
|
|
101
|
+
const now = rt.clock.now();
|
|
102
|
+
const by = actorId(rt, actor);
|
|
103
|
+
await rt.db.batch([sql`UPDATE tg_event_rule SET event = ${save.event}, name = ${save.name}, filter_json = ${JSON.stringify(save.filters)},
|
|
104
|
+
template_id = ${save.templateId}, recipients_json = ${JSON.stringify(save.recipients)},
|
|
105
|
+
throttle_json = ${throttleJson(save.throttle)}, enabled = ${save.enabled}, updated_at = ${now}
|
|
106
|
+
WHERE tenant = ${rt.tenant} AND id = ${id} AND deleted_at IS NULL`, ...rt.audit({
|
|
107
|
+
actor: by,
|
|
108
|
+
entity: "telegram_event_rule",
|
|
109
|
+
entityId: id,
|
|
110
|
+
action: "update",
|
|
111
|
+
diff: {
|
|
112
|
+
event: save.event,
|
|
113
|
+
enabled: save.enabled
|
|
114
|
+
},
|
|
115
|
+
at: now
|
|
116
|
+
})]);
|
|
117
|
+
return eventRuleDto(await requireRule(rt, id));
|
|
118
|
+
}
|
|
119
|
+
async function deleteRule(rt, actor, id) {
|
|
120
|
+
const old = await requireRule(rt, id);
|
|
121
|
+
const now = rt.clock.now();
|
|
122
|
+
const by = actorId(rt, actor);
|
|
123
|
+
await rt.db.batch([sql`UPDATE tg_event_rule SET deleted_at = ${now}, updated_at = ${now} WHERE tenant = ${rt.tenant} AND id = ${id}`, ...rt.audit({
|
|
124
|
+
actor: by,
|
|
125
|
+
entity: "telegram_event_rule",
|
|
126
|
+
entityId: id,
|
|
127
|
+
action: "delete",
|
|
128
|
+
diff: { event: old.event },
|
|
129
|
+
at: now
|
|
130
|
+
})]);
|
|
131
|
+
}
|
|
132
|
+
/** Parses an event payload with its schema; `validation_failed` with the issues (under `prefix`) otherwise. */
|
|
133
|
+
function parsePayload(def, payload, prefix = "body.payload") {
|
|
134
|
+
const r = def.payload.safeParse(payload);
|
|
135
|
+
if (r.success) return r.data;
|
|
136
|
+
throw new TelegramPluginError("validation_failed", `payload of "${def.key}" is not valid`, { extensions: { issues: r.error.issues.slice(0, 20).map((i) => ({
|
|
137
|
+
path: [prefix, ...i.path.map(String)].join("."),
|
|
138
|
+
message: i.message
|
|
139
|
+
})) } });
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Renders `payload` with the rule's template as the caller and queues it to the caller's own linked private chats
|
|
143
|
+
* (filters and throttle are reported, not applied; subscriptions are ignored). Nothing else is sent.
|
|
144
|
+
*/
|
|
145
|
+
async function testRule(rt, actor, id, payload) {
|
|
146
|
+
const rule = eventRuleModel(await requireRule(rt, id));
|
|
147
|
+
const def = rt.registry.events.get(rule.event);
|
|
148
|
+
if (!def) throw new TelegramPluginError("validation_failed", `event "${rule.event}" is no longer registered`);
|
|
149
|
+
const data = parsePayload(def, payload);
|
|
150
|
+
const tplRow = await templateById(rt, rule.templateId);
|
|
151
|
+
if (!tplRow) throw new TelegramPluginError("not_found", "the rule's template is gone");
|
|
152
|
+
const template = templateModel(tplRow);
|
|
153
|
+
const info = rt.describe(actor);
|
|
154
|
+
if (!info.userId) throw new TelegramPluginError("recipient_empty", "the caller has no user id");
|
|
155
|
+
const now = rt.clock.now();
|
|
156
|
+
const chats = await resolveChats(rt, [{ user: info.userId }], now);
|
|
157
|
+
if (chats.length === 0) throw new TelegramPluginError("recipient_empty", "you have no linked Telegram chat");
|
|
158
|
+
const tz = rt.settings.timezone;
|
|
159
|
+
const locale = rt.i18n.pick(info.locale);
|
|
160
|
+
let vars = {};
|
|
161
|
+
const providerKey = def.provider ?? template.provider;
|
|
162
|
+
if (providerKey) {
|
|
163
|
+
const p = requireProvider(rt, providerKey);
|
|
164
|
+
if (!await hostCan(rt, actor, p.permission)) throw new TelegramPluginError("provider_forbidden", `you may not read "${p.key}"`);
|
|
165
|
+
const params = parseProviderParams(p, def.provider ? data : { ...template.providerParams });
|
|
166
|
+
vars = await runProvider(rt, {
|
|
167
|
+
def: p,
|
|
168
|
+
actor,
|
|
169
|
+
params,
|
|
170
|
+
now,
|
|
171
|
+
tz,
|
|
172
|
+
locale,
|
|
173
|
+
checked: true
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
vars = {
|
|
177
|
+
...await payloadVarsFor(rt, def, data, actor),
|
|
178
|
+
...vars
|
|
179
|
+
};
|
|
180
|
+
const body = renderBody(rt, {
|
|
181
|
+
body: template.body,
|
|
182
|
+
format: template.format,
|
|
183
|
+
vars,
|
|
184
|
+
locales: [locale],
|
|
185
|
+
tz
|
|
186
|
+
});
|
|
187
|
+
const nonce = rt.ids.uuid();
|
|
188
|
+
const result = await fanOut(rt, {
|
|
189
|
+
template,
|
|
190
|
+
provider: eventProvider(def, template, data),
|
|
191
|
+
recipients: {
|
|
192
|
+
send: chats,
|
|
193
|
+
held: [],
|
|
194
|
+
muted: []
|
|
195
|
+
},
|
|
196
|
+
personalize: true,
|
|
197
|
+
source: "event",
|
|
198
|
+
sourceId: `test:${nonce}`,
|
|
199
|
+
dedupeKey: (chat) => `evtest:${nonce}:${chat.chatRef}`,
|
|
200
|
+
now,
|
|
201
|
+
tz,
|
|
202
|
+
vars: (aud) => payloadVarsFor(rt, def, data, aud.actor),
|
|
203
|
+
meta: {
|
|
204
|
+
event: def.key,
|
|
205
|
+
rule: rule.id,
|
|
206
|
+
test: true
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
if (result.deliveries > 0) rt.waitUntil(rt.transport.kick([...new Set(chats.map((c) => c.botId))]));
|
|
210
|
+
return {
|
|
211
|
+
filtersMatch: matchFilters(rule.filters, data),
|
|
212
|
+
chats: result.chats,
|
|
213
|
+
deliveries: result.deliveries,
|
|
214
|
+
parts: body.parts,
|
|
215
|
+
warnings: body.warnings
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Which provider an event fan-out runs: the event's own provider with the payload as params (data re-read as each
|
|
220
|
+
* audience); else the template's provider with its saved params; else none (payload variables only).
|
|
221
|
+
*/
|
|
222
|
+
function eventProvider(def, template, payload) {
|
|
223
|
+
if (def.provider) return {
|
|
224
|
+
key: def.provider,
|
|
225
|
+
params: payload
|
|
226
|
+
};
|
|
227
|
+
return template.provider ? void 0 : null;
|
|
228
|
+
}
|
|
229
|
+
export { createRule, deleteRule, eventProvider, eventRuleDto, eventRuleModel, listRules, parsePayload, registeredEvents, requireRule, testRule, updateRule, validateRuleSave };
|