@prajwalshete/telegram-plugin 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +91 -0
- package/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/adapters/clock.d.ts +3 -0
- package/dist/adapters/clock.js +4 -0
- package/dist/adapters/console-log.d.ts +13 -0
- package/dist/adapters/console-log.js +41 -0
- package/dist/adapters/d1-db.d.ts +5 -0
- package/dist/adapters/d1-db.js +37 -0
- package/dist/adapters/ids.d.ts +5 -0
- package/dist/adapters/ids.js +25 -0
- package/dist/adapters/index.d.ts +7 -0
- package/dist/adapters/keyring.d.ts +50 -0
- package/dist/adapters/keyring.js +174 -0
- package/dist/adapters/metrics.d.ts +28 -0
- package/dist/adapters/metrics.js +48 -0
- package/dist/adapters/r2-files.d.ts +20 -0
- package/dist/adapters/r2-files.js +53 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.js +45 -0
- package/dist/client/broadcasts.d.ts +35 -0
- package/dist/client/commands.d.ts +23 -0
- package/dist/client/endpoint.d.ts +34 -0
- package/dist/client/events.d.ts +33 -0
- package/dist/client/index.d.ts +11 -0
- package/dist/client/index.js +3 -0
- package/dist/client/schedules.d.ts +42 -0
- package/dist/client/schema.d.ts +181 -0
- package/dist/client/send.d.ts +15 -0
- package/dist/client/templates.d.ts +47 -0
- package/dist/contracts/bots.d.ts +129 -0
- package/dist/contracts/bots.js +70 -0
- package/dist/contracts/broadcasts.d.ts +116 -0
- package/dist/contracts/broadcasts.js +63 -0
- package/dist/contracts/chats.d.ts +97 -0
- package/dist/contracts/chats.js +66 -0
- package/dist/contracts/commands.d.ts +52 -0
- package/dist/contracts/commands.js +53 -0
- package/dist/contracts/common.d.ts +38 -0
- package/dist/contracts/common.js +41 -0
- package/dist/contracts/deliveries.d.ts +103 -0
- package/dist/contracts/deliveries.js +74 -0
- package/dist/contracts/destinations.d.ts +24 -0
- package/dist/contracts/destinations.js +28 -0
- package/dist/contracts/events.d.ts +165 -0
- package/dist/contracts/events.js +98 -0
- package/dist/contracts/features.d.ts +20 -0
- package/dist/contracts/features.js +21 -0
- package/dist/contracts/health.d.ts +55 -0
- package/dist/contracts/health.js +58 -0
- package/dist/contracts/index.d.ts +22 -0
- package/dist/contracts/index.js +22 -0
- package/dist/contracts/labels.d.ts +38 -0
- package/dist/contracts/labels.js +52 -0
- package/dist/contracts/links.d.ts +51 -0
- package/dist/contracts/links.js +50 -0
- package/dist/contracts/meta.d.ts +132 -0
- package/dist/contracts/meta.js +85 -0
- package/dist/contracts/permissions.d.ts +24 -0
- package/dist/contracts/permissions.js +27 -0
- package/dist/contracts/problems.d.ts +106 -0
- package/dist/contracts/problems.js +101 -0
- package/dist/contracts/recipients.d.ts +36 -0
- package/dist/contracts/recipients.js +22 -0
- package/dist/contracts/schedules.d.ts +276 -0
- package/dist/contracts/schedules.js +143 -0
- package/dist/contracts/send.d.ts +126 -0
- package/dist/contracts/send.js +69 -0
- package/dist/contracts/subscriptions.d.ts +38 -0
- package/dist/contracts/subscriptions.js +31 -0
- package/dist/contracts/templates.d.ts +358 -0
- package/dist/contracts/templates.js +174 -0
- package/dist/contracts/users.d.ts +15 -0
- package/dist/contracts/users.js +19 -0
- package/dist/core/guards.d.ts +22 -0
- package/dist/core/guards.js +47 -0
- package/dist/core/html/escape.d.ts +2 -0
- package/dist/core/html/escape.js +36 -0
- package/dist/core/html/tokenize.js +65 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/pacing.d.ts +1 -0
- package/dist/core/pacing.js +86 -0
- package/dist/core/random.d.ts +2 -0
- package/dist/core/random.js +38 -0
- package/dist/core/recipients.d.ts +1 -0
- package/dist/core/recipients.js +86 -0
- package/dist/core/schedule/describe.d.ts +3 -0
- package/dist/core/schedule/describe.js +74 -0
- package/dist/core/schedule/index.d.ts +4 -0
- package/dist/core/schedule/policy.d.ts +1 -0
- package/dist/core/schedule/policy.js +37 -0
- package/dist/core/schedule/rule.d.ts +25 -0
- package/dist/core/schedule/rule.js +142 -0
- package/dist/core/schedule/tz.d.ts +2 -0
- package/dist/core/schedule/tz.js +59 -0
- package/dist/core/split/plain.d.ts +2 -0
- package/dist/core/split/plain.js +23 -0
- package/dist/core/split/split.d.ts +15 -0
- package/dist/core/split/split.js +158 -0
- package/dist/core/template/ast.d.ts +58 -0
- package/dist/core/template/ast.js +5 -0
- package/dist/core/template/expr.js +108 -0
- package/dist/core/template/filters.d.ts +20 -0
- package/dist/core/template/filters.js +127 -0
- package/dist/core/template/format.d.ts +17 -0
- package/dist/core/template/format.js +88 -0
- package/dist/core/template/index.d.ts +20 -0
- package/dist/core/template/index.js +29 -0
- package/dist/core/template/parse.d.ts +8 -0
- package/dist/core/template/parse.js +369 -0
- package/dist/core/template/render.d.ts +17 -0
- package/dist/core/template/render.js +118 -0
- package/dist/core/template/shape.d.ts +28 -0
- package/dist/core/template/shape.js +151 -0
- package/dist/core/template/tags.js +105 -0
- package/dist/core/template/validate.d.ts +10 -0
- package/dist/core/template/validate.js +144 -0
- package/dist/core/template/variables.d.ts +15 -0
- package/dist/core/template/variables.js +41 -0
- package/dist/do/index.d.ts +3 -0
- package/dist/do/index.js +3 -0
- package/dist/do/telegram-bot-do.d.ts +45 -0
- package/dist/do/telegram-bot-do.js +219 -0
- package/dist/engine/actions/callback.js +256 -0
- package/dist/engine/actions/tokens.d.ts +2 -0
- package/dist/engine/actions/tokens.js +51 -0
- package/dist/engine/bot-api.js +20 -0
- package/dist/engine/bot-features.js +39 -0
- package/dist/engine/chat-state.js +66 -0
- package/dist/engine/content/audience.js +54 -0
- package/dist/engine/content/buttons.d.ts +1 -0
- package/dist/engine/content/buttons.js +113 -0
- package/dist/engine/content/fanout.js +284 -0
- package/dist/engine/content/intersect.d.ts +6 -0
- package/dist/engine/content/intersect.js +26 -0
- package/dist/engine/content/providers.d.ts +6 -0
- package/dist/engine/content/providers.js +142 -0
- package/dist/engine/content/recipients.js +128 -0
- package/dist/engine/content/render.js +138 -0
- package/dist/engine/content/reply.js +156 -0
- package/dist/engine/content/report-files.js +78 -0
- package/dist/engine/content/template-rows.js +74 -0
- package/dist/engine/dialogs/dialog.d.ts +2 -0
- package/dist/engine/dialogs/dialog.js +375 -0
- package/dist/engine/dispatch/builtins.js +198 -0
- package/dist/engine/dispatch/commands.js +194 -0
- package/dist/engine/dispatch/context.d.ts +2 -0
- package/dist/engine/dispatch/context.js +194 -0
- package/dist/engine/dispatch/dispatcher.js +66 -0
- package/dist/engine/dispatch/handlers.d.ts +1 -0
- package/dist/engine/dispatch/handlers.js +103 -0
- package/dist/engine/dispatch/lifecycle.js +85 -0
- package/dist/engine/dispatch/link-chat.js +31 -0
- package/dist/engine/dispatch/linking.js +273 -0
- package/dist/engine/errors.d.ts +60 -0
- package/dist/engine/errors.js +72 -0
- package/dist/engine/events/filters.d.ts +5 -0
- package/dist/engine/events/filters.js +82 -0
- package/dist/engine/events/kick.js +30 -0
- package/dist/engine/events/payload.d.ts +3 -0
- package/dist/engine/events/payload.js +16 -0
- package/dist/engine/events/tick.js +208 -0
- package/dist/engine/i18n.d.ts +12 -0
- package/dist/engine/i18n.js +55 -0
- package/dist/engine/inbound/files.js +121 -0
- package/dist/engine/inbound/handler.js +46 -0
- package/dist/engine/jobs.d.ts +32 -0
- package/dist/engine/jobs.js +85 -0
- package/dist/engine/link-codes.js +46 -0
- package/dist/engine/metrics.js +12 -0
- package/dist/engine/options.d.ts +369 -0
- package/dist/engine/options.js +81 -0
- package/dist/engine/outbox/dead-letter.js +78 -0
- package/dist/engine/outbox/do-registry.d.ts +10 -0
- package/dist/engine/outbox/do-registry.js +10 -0
- package/dist/engine/outbox/do-transport.d.ts +33 -0
- package/dist/engine/outbox/do-transport.js +73 -0
- package/dist/engine/outbox/drain.d.ts +13 -0
- package/dist/engine/outbox/drain.js +381 -0
- package/dist/engine/outbox/enqueue.d.ts +1 -0
- package/dist/engine/outbox/enqueue.js +63 -0
- package/dist/engine/outbox/select.js +24 -0
- package/dist/engine/outbox/send.js +362 -0
- package/dist/engine/outbox/transport.d.ts +35 -0
- package/dist/engine/outbox/transport.js +175 -0
- package/dist/engine/outbox/wake.js +28 -0
- package/dist/engine/registry.d.ts +502 -0
- package/dist/engine/registry.js +258 -0
- package/dist/engine/rows.d.ts +25 -0
- package/dist/engine/rows.js +131 -0
- package/dist/engine/runtime.d.ts +95 -0
- package/dist/engine/runtime.js +149 -0
- package/dist/engine/schedules/fire.js +208 -0
- package/dist/engine/schedules/resume.js +75 -0
- package/dist/engine/schedules/rows.js +69 -0
- package/dist/engine/schedules/tick.js +30 -0
- package/dist/engine/schedules/wake.js +50 -0
- package/dist/engine/state.d.ts +11 -0
- package/dist/engine/state.js +78 -0
- package/dist/locales/en.js +339 -0
- package/dist/locales/en.json +341 -0
- package/dist/locales/mr.js +339 -0
- package/dist/locales/mr.json +341 -0
- package/dist/ports/db.d.ts +22 -0
- package/dist/ports/index.d.ts +4 -0
- package/dist/ports/ports.d.ts +257 -0
- package/dist/ports/sql.d.ts +39 -0
- package/dist/ports/sql.js +76 -0
- package/dist/renderers/csv.d.ts +14 -0
- package/dist/renderers/csv.js +51 -0
- package/dist/renderers/html.d.ts +44 -0
- package/dist/renderers/html.js +81 -0
- package/dist/renderers/index.d.ts +5 -0
- package/dist/renderers/index.js +4 -0
- package/dist/renderers/table.d.ts +30 -0
- package/dist/renderers/table.js +114 -0
- package/dist/routes/admin.js +146 -0
- package/dist/routes/app.d.ts +3111 -0
- package/dist/routes/app.js +9 -0
- package/dist/routes/broadcasts.js +37 -0
- package/dist/routes/commands.js +35 -0
- package/dist/routes/content.js +12 -0
- package/dist/routes/events.js +36 -0
- package/dist/routes/http.d.ts +30 -0
- package/dist/routes/http.js +223 -0
- package/dist/routes/me.js +59 -0
- package/dist/routes/schedules.js +62 -0
- package/dist/routes/send.js +24 -0
- package/dist/routes/templates.js +70 -0
- package/dist/routes/webhook.js +147 -0
- package/dist/schema/columns.js +307 -0
- package/dist/schema/ddl.d.ts +7 -0
- package/dist/schema/ddl.js +45 -0
- package/dist/schema/index.d.ts +4255 -0
- package/dist/schema/index.js +44 -0
- package/dist/schema/reference.sql +359 -0
- package/dist/schema/tables.d.ts +4243 -0
- package/dist/schema/tables.js +327 -0
- package/dist/schema/version.d.ts +5 -0
- package/dist/schema/version.js +7 -0
- package/dist/server/index.d.ts +55 -0
- package/dist/server/index.js +46 -0
- package/dist/server/plugin.d.ts +81 -0
- package/dist/server/plugin.js +247 -0
- package/dist/server/version.d.ts +2 -0
- package/dist/server/version.js +4 -0
- package/dist/services/ai-tool.d.ts +26 -0
- package/dist/services/ai-tool.js +148 -0
- package/dist/services/bots.js +300 -0
- package/dist/services/broadcasts.js +390 -0
- package/dist/services/chats.js +83 -0
- package/dist/services/cleanup.js +83 -0
- package/dist/services/commands.js +140 -0
- package/dist/services/deliveries.js +53 -0
- package/dist/services/destinations.js +91 -0
- package/dist/services/event-rules.js +229 -0
- package/dist/services/events.js +20 -0
- package/dist/services/idempotency.js +64 -0
- package/dist/services/links.js +200 -0
- package/dist/services/menu.js +95 -0
- package/dist/services/ops.js +90 -0
- package/dist/services/rekey.js +63 -0
- package/dist/services/reports.js +87 -0
- package/dist/services/schedules.js +214 -0
- package/dist/services/send.d.ts +14 -0
- package/dist/services/send.js +421 -0
- package/dist/services/subscriptions.js +28 -0
- package/dist/services/templates.js +259 -0
- package/dist/telegram/classify.d.ts +30 -0
- package/dist/telegram/classify.js +164 -0
- package/dist/telegram/client.d.ts +73 -0
- package/dist/telegram/client.js +135 -0
- package/dist/telegram/errors.d.ts +37 -0
- package/dist/telegram/errors.js +62 -0
- package/dist/telegram/inbound.d.ts +40 -0
- package/dist/telegram/inbound.js +72 -0
- package/dist/telegram/index.d.ts +8 -0
- package/dist/telegram/multipart.d.ts +5 -0
- package/dist/telegram/multipart.js +47 -0
- package/dist/telegram/types.d.ts +271 -0
- package/dist/telegram/update.d.ts +833 -0
- package/dist/telegram/update.js +129 -0
- package/dist/testing/fake-html.d.ts +9 -0
- package/dist/testing/fake-html.js +83 -0
- package/dist/testing/fake-telegram.d.ts +206 -0
- package/dist/testing/fake-telegram.js +856 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/reference-sql.d.ts +4 -0
- package/dist/testing/reference-sql.js +8 -0
- package/dist/testing/test-plugin.d.ts +126 -0
- package/dist/testing/test-plugin.js +182 -0
- package/dist/testing/updates.d.ts +100 -0
- package/dist/testing/updates.js +212 -0
- package/package.json +113 -3
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { TelegramPluginError, featureDisabled } from "../engine/errors.js";
|
|
3
|
+
// src/services/events.ts
|
|
4
|
+
function emitEvent(ctx, event, payload, opts = {}) {
|
|
5
|
+
if (!ctx.enabled) throw featureDisabled("events");
|
|
6
|
+
const def = ctx.events.get(event);
|
|
7
|
+
if (!def) throw new TelegramPluginError("validation_failed", `event "${event}" is not registered`);
|
|
8
|
+
const parsed = def.payload.safeParse(payload);
|
|
9
|
+
if (!parsed.success) throw new TelegramPluginError("validation_failed", `payload of "${event}" is not valid`, { extensions: { issues: parsed.error.issues.map((i) => ({
|
|
10
|
+
path: i.path.join("."),
|
|
11
|
+
message: i.message
|
|
12
|
+
})) } });
|
|
13
|
+
const id = ctx.ids.uuid();
|
|
14
|
+
const dedupe = opts.dedupeKey ?? `evt:${id}`;
|
|
15
|
+
if (dedupe.length > 200) throw new TelegramPluginError("validation_failed", "dedupeKey is too long (≤ 200)");
|
|
16
|
+
return [sql`INSERT INTO tg_event (id, tenant, event, payload_json, dedupe_key, occurred_at, status)
|
|
17
|
+
VALUES (${id}, ${ctx.tenant}, ${event}, ${JSON.stringify(parsed.data)}, ${dedupe}, ${ctx.now()}, 'pending')
|
|
18
|
+
ON CONFLICT (tenant, dedupe_key) DO NOTHING`];
|
|
19
|
+
}
|
|
20
|
+
export { emitEvent };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { sha256Hex } from "../core/random.js";
|
|
2
|
+
import { sql } from "../ports/sql.js";
|
|
3
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
4
|
+
// src/services/idempotency.ts
|
|
5
|
+
const IDEMPOTENCY_PREFIX = "idem:";
|
|
6
|
+
const IDEMPOTENCY_TTL_MS = 864e5;
|
|
7
|
+
/** Lease of an in-flight (`pending`) entry; a request whose lease ran out (not renewed) can be taken over. */
|
|
8
|
+
const IDEMPOTENCY_PENDING_TTL_MS = 6e4;
|
|
9
|
+
/** How often a running request renews its lease (well inside the TTL). */
|
|
10
|
+
const IDEMPOTENCY_RENEW_MS = 2e4;
|
|
11
|
+
/** Reserves the key. Throws `validation_failed` (409) for a body mismatch or a request still in flight. */
|
|
12
|
+
async function reserveIdempotency(rt, input) {
|
|
13
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(input.key)) throw new TelegramPluginError("validation_failed", "Idempotency-Key must be 1–128 chars [A-Za-z0-9_.:-]");
|
|
14
|
+
const now = rt.clock.now();
|
|
15
|
+
const stateKey = IDEMPOTENCY_PREFIX + await sha256Hex(crypto.subtle, `${input.actor}|${input.method}|${input.path}|${input.key}`);
|
|
16
|
+
const hash = await sha256Hex(crypto.subtle, input.body);
|
|
17
|
+
const lease = crypto.randomUUID();
|
|
18
|
+
const pending = {
|
|
19
|
+
hash,
|
|
20
|
+
pending: true,
|
|
21
|
+
lease
|
|
22
|
+
};
|
|
23
|
+
const inserted = await rt.db.first(sql`INSERT INTO tg_state (tenant, key, value, updated_at) VALUES (${rt.tenant}, ${stateKey}, ${JSON.stringify(pending)}, ${now})
|
|
24
|
+
ON CONFLICT (tenant, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
25
|
+
WHERE tg_state.updated_at < ${now - IDEMPOTENCY_TTL_MS}
|
|
26
|
+
OR (json_extract(tg_state.value, '$.pending') = 1
|
|
27
|
+
AND tg_state.updated_at < ${now - IDEMPOTENCY_PENDING_TTL_MS})
|
|
28
|
+
RETURNING key`);
|
|
29
|
+
const mine = sql`tenant = ${rt.tenant} AND key = ${stateKey} AND json_extract(value, '$.lease') = ${lease}`;
|
|
30
|
+
const owned = {
|
|
31
|
+
replay: null,
|
|
32
|
+
async save(response) {
|
|
33
|
+
const value = JSON.stringify({
|
|
34
|
+
hash,
|
|
35
|
+
response
|
|
36
|
+
});
|
|
37
|
+
await rt.db.run(sql`UPDATE tg_state SET value = ${value}, updated_at = ${rt.clock.now()} WHERE ${mine}`);
|
|
38
|
+
},
|
|
39
|
+
async release() {
|
|
40
|
+
await rt.db.run(sql`DELETE FROM tg_state WHERE ${mine}`);
|
|
41
|
+
},
|
|
42
|
+
async renew() {
|
|
43
|
+
return await rt.db.first(sql`UPDATE tg_state SET updated_at = ${rt.clock.now()} WHERE ${mine} RETURNING key`) !== null;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
if (inserted) return owned;
|
|
47
|
+
const row = await rt.db.first(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${stateKey}`);
|
|
48
|
+
let entry = null;
|
|
49
|
+
try {
|
|
50
|
+
entry = row ? JSON.parse(row.value) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
entry = null;
|
|
53
|
+
}
|
|
54
|
+
if (!entry) return owned;
|
|
55
|
+
if (entry.hash !== hash) throw new TelegramPluginError("validation_failed", "Idempotency-Key was used with a different request", { status: 422 });
|
|
56
|
+
if (entry.pending || !entry.response) throw new TelegramPluginError("validation_failed", "a request with this Idempotency-Key is still in progress", { status: 409 });
|
|
57
|
+
return {
|
|
58
|
+
replay: entry.response,
|
|
59
|
+
save: async () => {},
|
|
60
|
+
release: async () => {},
|
|
61
|
+
renew: async () => false
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export { IDEMPOTENCY_PENDING_TTL_MS, IDEMPOTENCY_PREFIX, IDEMPOTENCY_RENEW_MS, IDEMPOTENCY_TTL_MS, reserveIdempotency };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
3
|
+
import { isBotUsable, loadBot } from "../engine/bot-api.js";
|
|
4
|
+
import { chatDto } from "../engine/rows.js";
|
|
5
|
+
import { deliveryFragments } from "../engine/outbox/enqueue.js";
|
|
6
|
+
import { actorId } from "./bots.js";
|
|
7
|
+
import { PENDING_LINK_PREFIX, PendingLinkState, hashLinkCode, newLinkCode, pendingLinkDto, pendingLinkKey } from "../engine/link-codes.js";
|
|
8
|
+
import { releaseOtherChatsSql, upsertChatSql } from "../engine/dispatch/link-chat.js";
|
|
9
|
+
// src/services/links.ts
|
|
10
|
+
async function create(rt, input) {
|
|
11
|
+
const bot = await loadBot(rt, input.bot);
|
|
12
|
+
if (!bot || !isBotUsable(bot)) throw new TelegramPluginError("not_found", "bot not found or not active");
|
|
13
|
+
const now = rt.clock.now();
|
|
14
|
+
const code = newLinkCode(rt.rand);
|
|
15
|
+
const expiresAt = now + rt.limits.linkCodeTtlMs;
|
|
16
|
+
await rt.db.batch([sql`INSERT INTO tg_link_code (code_hash, tenant, bot_id, purpose, user_id, audience_json, expires_at, created_by, created_at)
|
|
17
|
+
VALUES (${await hashLinkCode(code)}, ${rt.tenant}, ${bot.id}, ${input.purpose}, ${input.userId},
|
|
18
|
+
${input.audience === null ? null : JSON.stringify(input.audience)}, ${expiresAt}, ${input.createdBy}, ${now})`, ...rt.audit({
|
|
19
|
+
actor: input.createdBy,
|
|
20
|
+
entity: "telegram_link",
|
|
21
|
+
entityId: bot.id,
|
|
22
|
+
action: `create_${input.purpose}`,
|
|
23
|
+
diff: input.userId ? { userId: input.userId } : {},
|
|
24
|
+
at: now
|
|
25
|
+
})]);
|
|
26
|
+
const param = input.purpose === "group" ? "startgroup" : "start";
|
|
27
|
+
const url = `https://t.me/${bot.username}?${param}=${code}`;
|
|
28
|
+
return {
|
|
29
|
+
url,
|
|
30
|
+
qrPayload: url,
|
|
31
|
+
expiresAt,
|
|
32
|
+
requiresConfirmation: input.purpose === "user" && input.createdBy !== input.userId
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Admin: invite a user, or make a `startgroup` link for a group with an audience. An invite for another user than
|
|
37
|
+
* the caller links only after that user confirms it in the app (`confirmPendingLink`).
|
|
38
|
+
*/
|
|
39
|
+
async function createLink(rt, actor, input) {
|
|
40
|
+
const by = actorId(rt, actor);
|
|
41
|
+
if (input.purpose === "user") {
|
|
42
|
+
if (rt.users) {
|
|
43
|
+
if (!await rt.users.find(input.userId)) throw new TelegramPluginError("not_found", "user not found");
|
|
44
|
+
}
|
|
45
|
+
return create(rt, {
|
|
46
|
+
bot: input.bot,
|
|
47
|
+
purpose: "user",
|
|
48
|
+
userId: input.userId,
|
|
49
|
+
audience: null,
|
|
50
|
+
createdBy: by
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (!by) throw new TelegramPluginError("forbidden", "group links need a signed-in user (they confirm the link in the group)");
|
|
54
|
+
return create(rt, {
|
|
55
|
+
bot: input.bot,
|
|
56
|
+
purpose: "group",
|
|
57
|
+
userId: null,
|
|
58
|
+
audience: input.audience,
|
|
59
|
+
createdBy: by
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Any user links their own Telegram. */
|
|
63
|
+
async function createSelfLink(rt, actor, botId) {
|
|
64
|
+
const by = actorId(rt, actor);
|
|
65
|
+
if (!by) throw new TelegramPluginError("forbidden", "only a signed-in user can link their Telegram");
|
|
66
|
+
return create(rt, {
|
|
67
|
+
bot: botId,
|
|
68
|
+
purpose: "user",
|
|
69
|
+
userId: by,
|
|
70
|
+
audience: null,
|
|
71
|
+
createdBy: by
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function signedIn(rt, actor) {
|
|
75
|
+
const by = actorId(rt, actor);
|
|
76
|
+
if (!by) throw new TelegramPluginError("forbidden", "only a signed-in user can confirm a Telegram link");
|
|
77
|
+
return by;
|
|
78
|
+
}
|
|
79
|
+
function parsePending(value) {
|
|
80
|
+
try {
|
|
81
|
+
const r = PendingLinkState.safeParse(JSON.parse(value));
|
|
82
|
+
return r.success ? r.data : null;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Expired pending invites are removed lazily (they hold no chat yet). */
|
|
88
|
+
async function purgeExpired(rt, now) {
|
|
89
|
+
await rt.db.run(sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key LIKE ${`${PENDING_LINK_PREFIX}%`}
|
|
90
|
+
AND json_extract(value, '$.expiresAt') <= ${now}`);
|
|
91
|
+
}
|
|
92
|
+
/** Pending invites waiting for this user's confirmation (`GET /me/links/pending`). */
|
|
93
|
+
async function listPendingLinks(rt, actor) {
|
|
94
|
+
const userId = signedIn(rt, actor);
|
|
95
|
+
const now = rt.clock.now();
|
|
96
|
+
await purgeExpired(rt, now);
|
|
97
|
+
return (await rt.db.all(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key LIKE ${`${PENDING_LINK_PREFIX}%`}
|
|
98
|
+
AND json_extract(value, '$.userId') = ${userId} ORDER BY updated_at DESC LIMIT 50`)).flatMap((r) => {
|
|
99
|
+
const p = parsePending(r.value);
|
|
100
|
+
return p && p.userId === userId && p.expiresAt > now ? [pendingLinkDto(p)] : [];
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/** The caller's own pending invite: not_found when missing, link_expired when expired, forbidden for others. */
|
|
104
|
+
async function ownPending(rt, userId, id, now) {
|
|
105
|
+
const row = await rt.db.first(sql`SELECT value FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`);
|
|
106
|
+
const p = row ? parsePending(row.value) : null;
|
|
107
|
+
if (!p) throw new TelegramPluginError("not_found", "pending link not found");
|
|
108
|
+
if (p.userId !== userId) throw new TelegramPluginError("forbidden", "this link is for another user");
|
|
109
|
+
if (p.expiresAt <= now) {
|
|
110
|
+
await rt.db.run(sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`);
|
|
111
|
+
throw new TelegramPluginError("link_expired", "this link has expired; ask for a new invite");
|
|
112
|
+
}
|
|
113
|
+
return p;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The invited user confirms that the Telegram account is theirs (`POST /me/links/pending/:id/confirm`): the chat
|
|
117
|
+
* is linked to them (same rules as `/start <code>`), a "linked" message is queued, `onLinked` runs.
|
|
118
|
+
*/
|
|
119
|
+
async function confirmPendingLink(rt, actor, id) {
|
|
120
|
+
const userId = signedIn(rt, actor);
|
|
121
|
+
const now = rt.clock.now();
|
|
122
|
+
const p = await ownPending(rt, userId, id, now);
|
|
123
|
+
const bot = await loadBot(rt, p.botId);
|
|
124
|
+
if (!bot || !isBotUsable(bot)) throw new TelegramPluginError("not_found", "bot not found or not active");
|
|
125
|
+
const existing = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND bot_id = ${bot.id} AND chat_id = ${p.chatId}`);
|
|
126
|
+
if (existing?.status === "active" && existing.user_id !== null && existing.user_id !== userId) throw new TelegramPluginError("forbidden", "this Telegram account is already linked to someone else");
|
|
127
|
+
const chatRef = existing?.id ?? p.chatRef;
|
|
128
|
+
const info = rt.describe(actor);
|
|
129
|
+
const locale = existing?.locale ?? p.locale ?? bot.default_locale;
|
|
130
|
+
const linkedText = rt.i18n.t(locale, "bot.start.linked", {
|
|
131
|
+
name: info.name ?? "",
|
|
132
|
+
role: info.role ?? ""
|
|
133
|
+
});
|
|
134
|
+
await rt.db.batch([
|
|
135
|
+
sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`,
|
|
136
|
+
...await releaseOtherChatsSql(rt, bot.id, userId, chatRef, now),
|
|
137
|
+
upsertChatSql(rt, chatRef, {
|
|
138
|
+
botId: bot.id,
|
|
139
|
+
chatId: p.chatId,
|
|
140
|
+
kind: "private",
|
|
141
|
+
title: p.tgName,
|
|
142
|
+
username: p.tgUsername,
|
|
143
|
+
userId,
|
|
144
|
+
audienceJson: null,
|
|
145
|
+
locale: p.locale,
|
|
146
|
+
linkedBy: userId
|
|
147
|
+
}, now),
|
|
148
|
+
...rt.audit({
|
|
149
|
+
actor: userId,
|
|
150
|
+
entity: "telegram_chat",
|
|
151
|
+
entityId: chatRef,
|
|
152
|
+
action: "link",
|
|
153
|
+
diff: {
|
|
154
|
+
kind: "private",
|
|
155
|
+
userId,
|
|
156
|
+
botId: bot.id,
|
|
157
|
+
invitedBy: p.createdBy,
|
|
158
|
+
confirmed: true
|
|
159
|
+
},
|
|
160
|
+
at: now
|
|
161
|
+
}),
|
|
162
|
+
...deliveryFragments(rt, {
|
|
163
|
+
botId: bot.id,
|
|
164
|
+
chatRef,
|
|
165
|
+
source: "system",
|
|
166
|
+
kind: "text",
|
|
167
|
+
html: `${linkedText}\n\n${rt.i18n.t(locale, "bot.link.confirmed_hint")}`,
|
|
168
|
+
dedupeKey: `link:${id}`
|
|
169
|
+
}, now)
|
|
170
|
+
]);
|
|
171
|
+
const chat = await rt.db.first(sql`SELECT * FROM tg_chat WHERE tenant = ${rt.tenant} AND id = ${chatRef}`);
|
|
172
|
+
if (!chat) throw new TelegramPluginError("internal_error", "linked chat not found");
|
|
173
|
+
rt.log.info("tg.linked", {
|
|
174
|
+
botId: bot.id,
|
|
175
|
+
chatRef,
|
|
176
|
+
kind: "private"
|
|
177
|
+
});
|
|
178
|
+
await rt.transport.kick([bot.id]);
|
|
179
|
+
const dto = chatDto(chat);
|
|
180
|
+
await rt.hook("onLinked", (fn) => fn(dto, actor));
|
|
181
|
+
return dto;
|
|
182
|
+
}
|
|
183
|
+
/** The invited user declines (`POST /me/links/pending/:id/decline`): the pending invite is removed, nothing links. */
|
|
184
|
+
async function declinePendingLink(rt, actor, id) {
|
|
185
|
+
const userId = signedIn(rt, actor);
|
|
186
|
+
const now = rt.clock.now();
|
|
187
|
+
const p = await ownPending(rt, userId, id, now);
|
|
188
|
+
await rt.db.batch([sql`DELETE FROM tg_state WHERE tenant = ${rt.tenant} AND key = ${pendingLinkKey(id)}`, ...rt.audit({
|
|
189
|
+
actor: userId,
|
|
190
|
+
entity: "telegram_link",
|
|
191
|
+
entityId: id,
|
|
192
|
+
action: "decline",
|
|
193
|
+
diff: {
|
|
194
|
+
botId: p.botId,
|
|
195
|
+
invitedBy: p.createdBy
|
|
196
|
+
},
|
|
197
|
+
at: now
|
|
198
|
+
})]);
|
|
199
|
+
}
|
|
200
|
+
export { confirmPendingLink, createLink, createSelfLink, declinePendingLink, listPendingLinks };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { sha256Hex } from "../core/random.js";
|
|
2
|
+
import { sql } from "../ports/sql.js";
|
|
3
|
+
import { settle } from "../telegram/classify.js";
|
|
4
|
+
import { withToken } from "../engine/bot-api.js";
|
|
5
|
+
import { botRuntime } from "../engine/bot-features.js";
|
|
6
|
+
import { commandRows, describe, effectiveCommands } from "./commands.js";
|
|
7
|
+
import { builtinMenu } from "../engine/dispatch/builtins.js";
|
|
8
|
+
// src/services/menu.ts
|
|
9
|
+
/**
|
|
10
|
+
* Commands shown for a chat kind in a locale: built-ins, `/report` (features.reports), then host commands with
|
|
11
|
+
* this bot's `tg_command` config (enabled, show_in_menu, sort, description). Menus are cosmetic: every call is
|
|
12
|
+
* still checked. Telegram allows 100 commands per scope.
|
|
13
|
+
*/
|
|
14
|
+
function menuCommands(rt, _bot, kind, locale, rows = []) {
|
|
15
|
+
const out = builtinMenu(rt, kind).map((command) => ({
|
|
16
|
+
command,
|
|
17
|
+
description: rt.i18n.plain(locale, `bot.cmd.${command}`).slice(0, 256)
|
|
18
|
+
}));
|
|
19
|
+
if (!rt.features.commands) return out;
|
|
20
|
+
if (rt.features.reports) out.push({
|
|
21
|
+
command: "report",
|
|
22
|
+
description: rt.i18n.plain(locale, "bot.cmd.report")
|
|
23
|
+
});
|
|
24
|
+
for (const c of effectiveCommands(rt, rows)) {
|
|
25
|
+
if (!c.enabled || !c.showInMenu) continue;
|
|
26
|
+
if (!c.def.scope.includes(kind === "private" ? "private" : "group")) continue;
|
|
27
|
+
const description = describe(c.description, locale).trim().slice(0, 256);
|
|
28
|
+
out.push({
|
|
29
|
+
command: c.def.command,
|
|
30
|
+
description: description === "" ? c.def.command : description
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return out.slice(0, 100);
|
|
34
|
+
}
|
|
35
|
+
function buildMenu(base, bot, rows = []) {
|
|
36
|
+
const rt = botRuntime(base, bot);
|
|
37
|
+
const fallback = rt.i18n.pick(bot.default_locale);
|
|
38
|
+
const scopes = [
|
|
39
|
+
{
|
|
40
|
+
scope: { type: "default" },
|
|
41
|
+
kind: "private"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
scope: { type: "all_private_chats" },
|
|
45
|
+
kind: "private"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
scope: { type: "all_group_chats" },
|
|
49
|
+
kind: "group"
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const { scope, kind } of scopes) {
|
|
54
|
+
out.push({
|
|
55
|
+
scope,
|
|
56
|
+
commands: menuCommands(rt, bot, kind, fallback, rows)
|
|
57
|
+
});
|
|
58
|
+
for (const language of rt.i18n.locales) out.push({
|
|
59
|
+
scope,
|
|
60
|
+
language,
|
|
61
|
+
commands: menuCommands(rt, bot, kind, language, rows)
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** Sends the menu when its hash changed (or `force`). Failures are logged, never thrown. */
|
|
67
|
+
async function syncCommandMenu(rt, bot, opts = {}) {
|
|
68
|
+
const menu = buildMenu(rt, bot, botRuntime(rt, bot).features.commands ? await commandRows(rt, bot.id) : []);
|
|
69
|
+
const hash = await sha256Hex(crypto.subtle, JSON.stringify(menu));
|
|
70
|
+
if (!opts.force && bot.commands_synced_hash === hash) return {
|
|
71
|
+
synced: false,
|
|
72
|
+
calls: 0,
|
|
73
|
+
failed: 0
|
|
74
|
+
};
|
|
75
|
+
let failed = 0;
|
|
76
|
+
await withToken(rt, bot, async (api) => {
|
|
77
|
+
for (const entry of menu) if ((await settle(api.setMyCommands({
|
|
78
|
+
commands: entry.commands,
|
|
79
|
+
scope: entry.scope,
|
|
80
|
+
...entry.language ? { language_code: entry.language } : {}
|
|
81
|
+
}))).outcome.kind !== "ok") failed++;
|
|
82
|
+
});
|
|
83
|
+
if (failed === 0) await rt.db.run(sql`UPDATE tg_bot SET commands_synced_hash = ${hash}, updated_at = ${rt.clock.now()}
|
|
84
|
+
WHERE tenant = ${rt.tenant} AND id = ${bot.id}`);
|
|
85
|
+
else rt.log.warn("tg.menu_sync_failed", {
|
|
86
|
+
botId: bot.id,
|
|
87
|
+
failed
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
synced: failed === 0,
|
|
91
|
+
calls: menu.length,
|
|
92
|
+
failed
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export { buildMenu, menuCommands, syncCommandMenu };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { BotStatus } from "../contracts/bots.js";
|
|
2
|
+
import { sql } from "../ports/sql.js";
|
|
3
|
+
import { STATE_KEYS, ensureState, getState } from "../engine/state.js";
|
|
4
|
+
import { rowsByKid } from "./rekey.js";
|
|
5
|
+
import { STUCK_RUN_MS } from "../engine/schedules/resume.js";
|
|
6
|
+
// src/services/ops.ts
|
|
7
|
+
const TICK_STALE_MS = 3e5;
|
|
8
|
+
async function health(rt) {
|
|
9
|
+
const now = rt.clock.now();
|
|
10
|
+
const lastTick = await getState(rt, STATE_KEYS.lastTickAt);
|
|
11
|
+
const lastTickAt = lastTick === null ? null : Number(lastTick);
|
|
12
|
+
const q = await rt.db.first(sql`SELECT COUNT(*) AS queued, MIN(created_at) AS oldest FROM tg_delivery
|
|
13
|
+
WHERE tenant = ${rt.tenant} AND status IN ('queued', 'sending')`);
|
|
14
|
+
const f = await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_delivery WHERE tenant = ${rt.tenant} AND status = 'failed'
|
|
15
|
+
AND updated_at > ${now - 864e5}`);
|
|
16
|
+
const runs = await rt.db.first(sql`SELECT SUM(CASE WHEN status = 'running' AND started_at < ${now - STUCK_RUN_MS} THEN 1 ELSE 0 END) AS stuck,
|
|
17
|
+
SUM(CASE WHEN status = 'failed' AND error = 'run_stuck' AND finished_at > ${now - 864e5}
|
|
18
|
+
THEN 1 ELSE 0 END) AS failed
|
|
19
|
+
FROM tg_schedule_run WHERE tenant = ${rt.tenant} AND status IN ('running', 'failed')`);
|
|
20
|
+
const bots = await rt.db.all(sql`SELECT id, username, status FROM tg_bot WHERE tenant = ${rt.tenant} AND deleted_at IS NULL ORDER BY id`);
|
|
21
|
+
const botItems = [];
|
|
22
|
+
for (const b of bots) {
|
|
23
|
+
const raw = await getState(rt, STATE_KEYS.botHealth(b.id));
|
|
24
|
+
let pending = null;
|
|
25
|
+
if (raw) try {
|
|
26
|
+
pending = JSON.parse(raw).pendingUpdateCount ?? null;
|
|
27
|
+
} catch {
|
|
28
|
+
pending = null;
|
|
29
|
+
}
|
|
30
|
+
botItems.push({
|
|
31
|
+
id: b.id,
|
|
32
|
+
username: b.username,
|
|
33
|
+
status: BotStatus.safeParse(b.status).data ?? "active",
|
|
34
|
+
pendingUpdateCount: pending
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const schema = await ensureState(rt);
|
|
38
|
+
const tickStale = lastTickAt === null || now - lastTickAt > TICK_STALE_MS;
|
|
39
|
+
return {
|
|
40
|
+
ok: schema.ok && !tickStale && botItems.every((b) => b.status === "active" || b.status === "disabled"),
|
|
41
|
+
lastTickAt,
|
|
42
|
+
tickStale,
|
|
43
|
+
deliveries: {
|
|
44
|
+
queued: q?.queued ?? 0,
|
|
45
|
+
failed24h: f?.n ?? 0,
|
|
46
|
+
oldestQueuedAgeMs: q?.oldest === null || q?.oldest === void 0 ? null : Math.max(0, now - q.oldest)
|
|
47
|
+
},
|
|
48
|
+
schedules: {
|
|
49
|
+
stuckRuns: runs?.stuck ?? 0,
|
|
50
|
+
runStuckFailed24h: runs?.failed ?? 0
|
|
51
|
+
},
|
|
52
|
+
bots: botItems,
|
|
53
|
+
keyring: {
|
|
54
|
+
activeKid: rt.keyring.activeKid(),
|
|
55
|
+
rowsByKid: await rowsByKid(rt)
|
|
56
|
+
},
|
|
57
|
+
schema: {
|
|
58
|
+
required: schema.required,
|
|
59
|
+
current: schema.current,
|
|
60
|
+
ok: schema.ok
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Counts in [from, to). `rateLimited` counts deliveries whose last error was a 429. */
|
|
65
|
+
async function stats(rt, from, to) {
|
|
66
|
+
const rows = await rt.db.all(sql`SELECT status, source, error_code, COUNT(*) AS n FROM tg_delivery
|
|
67
|
+
WHERE tenant = ${rt.tenant} AND created_at >= ${from} AND created_at < ${to}
|
|
68
|
+
GROUP BY status, source, error_code`);
|
|
69
|
+
const out = {
|
|
70
|
+
from,
|
|
71
|
+
to,
|
|
72
|
+
sent: 0,
|
|
73
|
+
failed: 0,
|
|
74
|
+
dropped: 0,
|
|
75
|
+
rateLimited: 0,
|
|
76
|
+
bySource: {},
|
|
77
|
+
updates: 0
|
|
78
|
+
};
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
if (r.status === "sent") {
|
|
81
|
+
out.sent += r.n;
|
|
82
|
+
out.bySource[r.source] = (out.bySource[r.source] ?? 0) + r.n;
|
|
83
|
+
} else if (r.status === "failed") out.failed += r.n;
|
|
84
|
+
else if (r.status === "dropped") out.dropped += r.n;
|
|
85
|
+
if (r.error_code === "retry_after") out.rateLimited += r.n;
|
|
86
|
+
}
|
|
87
|
+
out.updates = (await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_update WHERE tenant = ${rt.tenant} AND received_at >= ${from} AND received_at < ${to}`))?.n ?? 0;
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
export { health, stats };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { sql } from "../ports/sql.js";
|
|
2
|
+
import { tokenAad, webhookAad } from "../engine/bot-api.js";
|
|
3
|
+
import { STATE_KEYS, setState } from "../engine/state.js";
|
|
4
|
+
// src/services/rekey.ts
|
|
5
|
+
const BATCH = 20;
|
|
6
|
+
async function rekeyBots(rt, opts = {}) {
|
|
7
|
+
const started = rt.clock.now();
|
|
8
|
+
const budget = opts.budgetMs ?? rt.limits.tickBudgetMs;
|
|
9
|
+
const kid = rt.keyring.activeKid();
|
|
10
|
+
const prefix = `v1.${kid}.`;
|
|
11
|
+
let rekeyed = 0;
|
|
12
|
+
let failed = 0;
|
|
13
|
+
const skip = /* @__PURE__ */ new Set();
|
|
14
|
+
for (;;) {
|
|
15
|
+
if (rt.clock.now() - started > budget) break;
|
|
16
|
+
const todo = (await rt.db.all(sql`SELECT id, token_enc, webhook_secret_enc FROM tg_bot WHERE tenant = ${rt.tenant}
|
|
17
|
+
AND (substr(token_enc, 1, ${prefix.length}) != ${prefix}
|
|
18
|
+
OR (webhook_secret_enc IS NOT NULL AND substr(webhook_secret_enc, 1, ${prefix.length}) != ${prefix}))
|
|
19
|
+
ORDER BY id LIMIT ${BATCH + skip.size}`)).filter((r) => !skip.has(r.id)).slice(0, BATCH);
|
|
20
|
+
if (todo.length === 0) break;
|
|
21
|
+
for (const r of todo) try {
|
|
22
|
+
const token = await rt.keyring.decrypt(r.token_enc, tokenAad(r.id));
|
|
23
|
+
const secret = r.webhook_secret_enc ? await rt.keyring.decrypt(r.webhook_secret_enc, webhookAad(r.id)) : null;
|
|
24
|
+
const tokenEnc = await rt.keyring.encrypt(token, tokenAad(r.id));
|
|
25
|
+
const secretEnc = secret === null ? null : await rt.keyring.encrypt(secret, webhookAad(r.id));
|
|
26
|
+
if ((await rt.db.run(sql`UPDATE tg_bot SET token_enc = ${tokenEnc}, webhook_secret_enc = ${secretEnc}
|
|
27
|
+
WHERE id = ${r.id} AND token_enc = ${r.token_enc}
|
|
28
|
+
AND webhook_secret_enc IS ${r.webhook_secret_enc}`)).changes > 0) rekeyed++;
|
|
29
|
+
} catch {
|
|
30
|
+
failed++;
|
|
31
|
+
skip.add(r.id);
|
|
32
|
+
rt.log.error("tg.rekey_failed", { botId: r.id });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const left = await rt.db.first(sql`SELECT COUNT(*) AS n FROM tg_bot WHERE tenant = ${rt.tenant}
|
|
36
|
+
AND (substr(token_enc, 1, ${prefix.length}) != ${prefix}
|
|
37
|
+
OR (webhook_secret_enc IS NOT NULL AND substr(webhook_secret_enc, 1, ${prefix.length}) != ${prefix}))`);
|
|
38
|
+
const now = rt.clock.now();
|
|
39
|
+
await setState(rt, STATE_KEYS.keyringActiveKid, kid, now);
|
|
40
|
+
await setState(rt, STATE_KEYS.lastRekeyAt, String(now), now);
|
|
41
|
+
rt.log.info("tg.rekey", {
|
|
42
|
+
rekeyed,
|
|
43
|
+
failed,
|
|
44
|
+
remaining: left?.n ?? 0
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
rekeyed,
|
|
48
|
+
failed,
|
|
49
|
+
remaining: left?.n ?? 0
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Rows per key id (tokens and secrets), for the health page. */
|
|
53
|
+
async function rowsByKid(rt) {
|
|
54
|
+
const rows = await rt.db.all(sql`SELECT token_enc, webhook_secret_enc FROM tg_bot WHERE tenant = ${rt.tenant}`);
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const r of rows) for (const blob of [r.token_enc, r.webhook_secret_enc]) {
|
|
57
|
+
if (!blob) continue;
|
|
58
|
+
const kid = rt.keyring.kidOf(blob) ?? "unknown";
|
|
59
|
+
out[kid] = (out[kid] ?? 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
export { rekeyBots, rowsByKid };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { TelegramPluginError } from "../engine/errors.js";
|
|
2
|
+
import { renderBody } from "../engine/content/render.js";
|
|
3
|
+
import { hostCan, parseProviderParams, requireProvider, runProvider } from "../engine/content/providers.js";
|
|
4
|
+
import { intersectData } from "../engine/content/intersect.js";
|
|
5
|
+
import { renderReportFile } from "../engine/content/report-files.js";
|
|
6
|
+
// src/services/reports.ts
|
|
7
|
+
/** Provider data as `actor` (permission checked), for a template's provider and params. */
|
|
8
|
+
async function templateData(rt, input) {
|
|
9
|
+
const t = input.template;
|
|
10
|
+
if (!t.provider) return {};
|
|
11
|
+
const def = requireProvider(rt, t.provider);
|
|
12
|
+
if (!await hostCan(rt, input.actor, def.permission)) throw new TelegramPluginError("provider_forbidden", `this audience may not read "${def.key}"`);
|
|
13
|
+
const base = {
|
|
14
|
+
def,
|
|
15
|
+
params: parseProviderParams(def, {
|
|
16
|
+
...t.providerParams,
|
|
17
|
+
...input.params
|
|
18
|
+
}),
|
|
19
|
+
now: input.now,
|
|
20
|
+
tz: input.tz,
|
|
21
|
+
locale: input.locale,
|
|
22
|
+
checked: true
|
|
23
|
+
};
|
|
24
|
+
if (input.viewer === void 0) return runProvider(rt, {
|
|
25
|
+
...base,
|
|
26
|
+
actor: input.actor,
|
|
27
|
+
cache: input.cache
|
|
28
|
+
});
|
|
29
|
+
return viewerData(rt, {
|
|
30
|
+
...base,
|
|
31
|
+
actor: input.actor,
|
|
32
|
+
viewer: input.viewer
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Preview rule (docs/guide 06): the data a caller sees for another audience is the intersection of both views.
|
|
37
|
+
* Throws `provider_forbidden` when the viewer lacks the provider permission. One run when both are the same user.
|
|
38
|
+
*/
|
|
39
|
+
async function viewerData(rt, input) {
|
|
40
|
+
const { viewer, ...run } = input;
|
|
41
|
+
if (!await hostCan(rt, viewer, run.def.permission)) throw new TelegramPluginError("provider_forbidden", `you may not read "${run.def.key}"`);
|
|
42
|
+
const audience = await runProvider(rt, {
|
|
43
|
+
...run,
|
|
44
|
+
checked: true
|
|
45
|
+
});
|
|
46
|
+
const a = rt.describe(viewer).userId;
|
|
47
|
+
if (a !== null && a === rt.describe(input.actor).userId) return audience;
|
|
48
|
+
const mine = await runProvider(rt, {
|
|
49
|
+
...run,
|
|
50
|
+
actor: viewer,
|
|
51
|
+
checked: true
|
|
52
|
+
});
|
|
53
|
+
return intersectData(mine, audience) ?? {};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Renders a report template to a file as `actor` (no storage, no Telegram call). Throws `validation_failed` (not a
|
|
57
|
+
* report), `provider_forbidden`, `provider_unknown`, `feature_disabled` (reports off) or `renderer_unavailable`.
|
|
58
|
+
*/
|
|
59
|
+
async function buildReport(rt, input) {
|
|
60
|
+
const t = input.template;
|
|
61
|
+
if (t.kind !== "report") throw new TelegramPluginError("validation_failed", `template "${t.key}" is not a report`);
|
|
62
|
+
const locale = rt.i18n.pick(...input.locales);
|
|
63
|
+
const vars = await templateData(rt, {
|
|
64
|
+
...input,
|
|
65
|
+
locale
|
|
66
|
+
});
|
|
67
|
+
const caption = renderBody(rt, {
|
|
68
|
+
body: t.body,
|
|
69
|
+
format: t.format,
|
|
70
|
+
vars,
|
|
71
|
+
locales: [...input.locales],
|
|
72
|
+
tz: input.tz
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
file: await renderReportFile(rt, {
|
|
76
|
+
template: t,
|
|
77
|
+
vars,
|
|
78
|
+
locale: caption.locale,
|
|
79
|
+
tz: input.tz,
|
|
80
|
+
format: input.format,
|
|
81
|
+
html: caption.html
|
|
82
|
+
}),
|
|
83
|
+
caption,
|
|
84
|
+
vars
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export { buildReport, templateData, viewerData };
|