@jant/core 0.5.4 → 0.6.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/bin/commands/telegram/register-webhooks.js +93 -0
- package/dist/app-CMSW_AYG.js +6 -0
- package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BRTh1ii1.js +274 -0
- package/dist/client/_assets/client-CO4b-RKd.css +2 -0
- package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
- package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
- package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
- package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
- package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
- package/dist/index.js +4 -4
- package/dist/node.js +61 -5
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +15 -2
- package/src/app.tsx +3 -0
- package/src/client/thread-context.ts +146 -2
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
- package/src/client/tiptap/bubble-menu.ts +1 -16
- package/src/client/tiptap/extensions.ts +2 -6
- package/src/client/tiptap/link-toolbar.ts +0 -21
- package/src/client/tiptap/toolbar-mode.ts +0 -43
- package/src/db/migrations/0022_old_gressill.sql +24 -0
- package/src/db/migrations/0023_broad_terror.sql +20 -0
- package/src/db/migrations/0024_red_the_twelve.sql +3 -0
- package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
- package/src/db/migrations/meta/0022_snapshot.json +2267 -0
- package/src/db/migrations/meta/0023_snapshot.json +2396 -0
- package/src/db/migrations/meta/0024_snapshot.json +2417 -0
- package/src/db/migrations/meta/0025_snapshot.json +2424 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
- package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
- package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
- package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
- package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
- package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
- package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
- package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +82 -0
- package/src/db/schema.ts +90 -0
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/public/en.po +8 -0
- package/src/i18n/locales/public/zh-Hans.po +8 -0
- package/src/i18n/locales/public/zh-Hant.po +8 -0
- package/src/i18n/locales/settings/en.po +135 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +136 -1
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +136 -1
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/image-dimensions.test.ts +314 -0
- package/src/lib/__tests__/telegram-entities.test.ts +180 -0
- package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ids.ts +3 -0
- package/src/lib/image-dimensions.ts +258 -0
- package/src/lib/telegram-entities.ts +240 -0
- package/src/lib/telegram-pool-webhooks.ts +86 -0
- package/src/lib/telegram-settings-status.tsx +109 -0
- package/src/lib/telegram.ts +363 -0
- package/src/node/runtime.ts +6 -0
- package/src/routes/api/__tests__/telegram.test.ts +612 -0
- package/src/routes/api/telegram.ts +782 -0
- package/src/routes/api/upload-multipart.ts +34 -12
- package/src/routes/api/upload.ts +23 -2
- package/src/routes/dash/settings.tsx +131 -1
- package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
- package/src/routes/pages/page.tsx +3 -2
- package/src/runtime/cloudflare.ts +20 -9
- package/src/runtime/node.ts +20 -9
- package/src/runtime/site.ts +2 -1
- package/src/services/__tests__/telegram.test.ts +148 -0
- package/src/services/index.ts +9 -0
- package/src/services/telegram.ts +613 -0
- package/src/services/upload-session.ts +39 -12
- package/src/styles/tokens.css +1 -0
- package/src/styles/ui.css +117 -38
- package/src/types/app-context.ts +6 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +40 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
- package/src/ui/dash/settings/TelegramContent.tsx +549 -0
- package/src/ui/feed/ThreadPreview.tsx +90 -38
- package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
- package/src/ui/pages/PostPage.tsx +77 -15
- package/dist/app-DLINgGBd.js +0 -6
- package/dist/client/_assets/client-BErXNT6k.css +0 -2
- package/dist/client/_assets/client-CtAgWT8i.js +0 -274
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Webhook Route
|
|
3
|
+
*
|
|
4
|
+
* Receives Telegram bot updates and turns text messages into Notes.
|
|
5
|
+
*
|
|
6
|
+
* One route serves both deployment modes. It is host-agnostic: it never
|
|
7
|
+
* trusts the request hostname (in hosted mode the update is forwarded through
|
|
8
|
+
* the control plane and arrives without a tenant host). The target site is
|
|
9
|
+
* resolved from the binding tables — by `(botId, telegramUserId)` for a normal
|
|
10
|
+
* message, or by the pending binding `code` for a `/start <code>`.
|
|
11
|
+
*
|
|
12
|
+
* Authentication is the Telegram `secret_token` echoed in the
|
|
13
|
+
* `X-Telegram-Bot-Api-Secret-Token` header — the same auth model whether the
|
|
14
|
+
* webhook is delivered straight to a self-hosted site or forwarded by the
|
|
15
|
+
* hosted control plane.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Hono } from "hono";
|
|
19
|
+
import type { Bindings } from "../../types.js";
|
|
20
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
21
|
+
import {
|
|
22
|
+
getConfiguredStorageDriver,
|
|
23
|
+
getEnvString,
|
|
24
|
+
getTelegramBotPool,
|
|
25
|
+
getTelegramWebhookSecret,
|
|
26
|
+
} from "../../lib/env.js";
|
|
27
|
+
import { timingSafeEqualText } from "../../lib/crypto.js";
|
|
28
|
+
import {
|
|
29
|
+
answerCallbackQuery,
|
|
30
|
+
buildDeepLink,
|
|
31
|
+
getMe,
|
|
32
|
+
sendMessage,
|
|
33
|
+
type TelegramInlineButton,
|
|
34
|
+
type TelegramMessage,
|
|
35
|
+
type TelegramUpdate,
|
|
36
|
+
} from "../../lib/telegram.js";
|
|
37
|
+
import { entitiesToMarkdown } from "../../lib/telegram-entities.js";
|
|
38
|
+
import type { MediaKind, PostAttachmentInput } from "../../types.js";
|
|
39
|
+
import type {
|
|
40
|
+
IngestTelegramMediaInput,
|
|
41
|
+
TelegramMediaGroupKind,
|
|
42
|
+
} from "../../services/telegram.js";
|
|
43
|
+
|
|
44
|
+
/** Message-derived ingest payload; the bot token is added at the call site. */
|
|
45
|
+
type MessageMedia = Omit<IngestTelegramMediaInput, "botToken">;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long to hold each album item in the buffer before claiming the group.
|
|
49
|
+
*
|
|
50
|
+
* Telegram delivers album webhook updates within tens of milliseconds of one
|
|
51
|
+
* another, so 2 s is generous enough to collect every item without making the
|
|
52
|
+
* publish noticeably slow. The wait runs in-line on the webhook handler, so a
|
|
53
|
+
* shorter value risks splitting an album into multiple posts and a longer one
|
|
54
|
+
* delays the bot's "Posted." reply.
|
|
55
|
+
*/
|
|
56
|
+
const ALBUM_BUFFER_DELAY_MS = 2_000;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Telegram webhook intentionally bypasses the site-resolution middleware
|
|
60
|
+
* chain, so `c.var.appConfig` is not populated here. The two upload settings
|
|
61
|
+
* the media flow needs are env-driven anyway, so read them straight from the
|
|
62
|
+
* bindings without rebuilding the full appConfig.
|
|
63
|
+
*/
|
|
64
|
+
function uploadConfigFromEnv(env: Bindings): {
|
|
65
|
+
storageDriver: string;
|
|
66
|
+
maxFileSizeMB: number;
|
|
67
|
+
} {
|
|
68
|
+
const maxFileSizeMB =
|
|
69
|
+
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "500", 10) || 500;
|
|
70
|
+
return {
|
|
71
|
+
storageDriver: getConfiguredStorageDriver(env),
|
|
72
|
+
maxFileSizeMB,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
77
|
+
|
|
78
|
+
export const telegramWebhookRoutes = new Hono<Env>();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bot id → username cache. `getMe` results are stable for a bot's lifetime,
|
|
82
|
+
* so this avoids an API round-trip per webhook when building deep links.
|
|
83
|
+
*/
|
|
84
|
+
const botUsernameCache = new Map<string, string>();
|
|
85
|
+
|
|
86
|
+
async function resolveBotUsername(
|
|
87
|
+
botId: string,
|
|
88
|
+
token: string,
|
|
89
|
+
): Promise<string> {
|
|
90
|
+
const cached = botUsernameCache.get(botId);
|
|
91
|
+
if (cached) return cached;
|
|
92
|
+
try {
|
|
93
|
+
const identity = await getMe(token);
|
|
94
|
+
if (identity.username) {
|
|
95
|
+
botUsernameCache.set(botId, identity.username);
|
|
96
|
+
}
|
|
97
|
+
return identity.username;
|
|
98
|
+
} catch {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolves the bot token + expected webhook secret for an incoming request. */
|
|
104
|
+
async function resolveBot(
|
|
105
|
+
c: { env: Bindings; var: AppVariables },
|
|
106
|
+
botId: string,
|
|
107
|
+
): Promise<{ token: string; secret: string } | null> {
|
|
108
|
+
const pool = getTelegramBotPool(c.env);
|
|
109
|
+
if (pool.length > 0) {
|
|
110
|
+
const bot = pool.find((entry) => entry.botId === botId);
|
|
111
|
+
const secret = getTelegramWebhookSecret(c.env);
|
|
112
|
+
if (!bot || !secret) return null;
|
|
113
|
+
return { token: bot.token, secret };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Bring-your-own bot: config lives in the resolved site's settings. The
|
|
117
|
+
// webhook hits the site's own host in this mode, so `c.var.services` is
|
|
118
|
+
// already scoped to the right site.
|
|
119
|
+
const settings = c.var.services.settings;
|
|
120
|
+
const storedBotId = await settings.get("TELEGRAM_BOT_ID");
|
|
121
|
+
if (storedBotId !== botId) return null;
|
|
122
|
+
const token = await settings.get("TELEGRAM_BOT_TOKEN");
|
|
123
|
+
const secret = await settings.get("TELEGRAM_BOT_WEBHOOK_SECRET");
|
|
124
|
+
if (!token || !secret) return null;
|
|
125
|
+
return { token, secret };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function siteName(
|
|
129
|
+
c: { var: AppVariables },
|
|
130
|
+
siteId: string,
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
const name = await c.var.servicesForSite(siteId).settings.get("SITE_NAME");
|
|
133
|
+
return name && name.trim() ? name.trim() : "your site";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
telegramWebhookRoutes.post("/webhook/:botId", async (c) => {
|
|
137
|
+
const botId = c.req.param("botId");
|
|
138
|
+
const bot = await resolveBot(c, botId);
|
|
139
|
+
if (!bot) {
|
|
140
|
+
return c.json({ error: "Unknown bot" }, 404);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const providedSecret = c.req.header("X-Telegram-Bot-Api-Secret-Token") ?? "";
|
|
144
|
+
if (!timingSafeEqualText(providedSecret, bot.secret)) {
|
|
145
|
+
return c.json({ error: "Invalid secret token" }, 401);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let update: TelegramUpdate;
|
|
149
|
+
try {
|
|
150
|
+
update = (await c.req.json()) as TelegramUpdate;
|
|
151
|
+
} catch {
|
|
152
|
+
return c.json({ error: "Invalid JSON payload" }, 400);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Telegram retries failed deliveries; a slow handler causes duplicate
|
|
156
|
+
// posts. Process inline (posting a note is fast) but never let an error
|
|
157
|
+
// escape — Telegram only needs a 200, and the user-facing error goes back
|
|
158
|
+
// as a chat message so the sender knows something went wrong instead of
|
|
159
|
+
// staring at a silent client.
|
|
160
|
+
try {
|
|
161
|
+
await processUpdate(c, update, botId, bot.token);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
164
|
+
// eslint-disable-next-line no-console -- Webhook failures must be visible in server logs.
|
|
165
|
+
console.error(`[Jant] Telegram webhook error: ${message}`);
|
|
166
|
+
const chatId =
|
|
167
|
+
update.message?.chat.id ?? update.callback_query?.message?.chat.id;
|
|
168
|
+
if (chatId) {
|
|
169
|
+
await sendMessage(
|
|
170
|
+
bot.token,
|
|
171
|
+
chatId,
|
|
172
|
+
`Couldn't process that message: ${message}`,
|
|
173
|
+
).catch(() => undefined);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return c.json({ ok: true });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
async function processUpdate(
|
|
181
|
+
c: { env: Bindings; var: AppVariables },
|
|
182
|
+
update: TelegramUpdate,
|
|
183
|
+
botId: string,
|
|
184
|
+
botToken: string,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const telegram = c.var.services.telegram;
|
|
187
|
+
|
|
188
|
+
// --- Inline keyboard tap (ambiguous-bind resolution) ---
|
|
189
|
+
if (update.callback_query) {
|
|
190
|
+
const query = update.callback_query;
|
|
191
|
+
await answerCallbackQuery(botToken, query.id);
|
|
192
|
+
const chatId = query.message?.chat.id;
|
|
193
|
+
if (!chatId) return;
|
|
194
|
+
|
|
195
|
+
const [action, code] = (query.data ?? "").split(":");
|
|
196
|
+
const pending = code ? await telegram.resolvePendingCode(code) : null;
|
|
197
|
+
if (!pending) {
|
|
198
|
+
await sendMessage(
|
|
199
|
+
botToken,
|
|
200
|
+
chatId,
|
|
201
|
+
"That binding code expired. Open Telegram settings in Jant for a fresh one.",
|
|
202
|
+
);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (action === "rebind") {
|
|
206
|
+
await telegram.bindAccount({
|
|
207
|
+
siteId: pending.siteId,
|
|
208
|
+
botId,
|
|
209
|
+
telegramUserId: String(query.from.id),
|
|
210
|
+
telegramUsername: query.from.username ?? null,
|
|
211
|
+
});
|
|
212
|
+
await sendMessage(
|
|
213
|
+
botToken,
|
|
214
|
+
chatId,
|
|
215
|
+
`Connected to ${await siteName(c, pending.siteId)}. Send me any text and I'll post it as a note.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Message ---
|
|
222
|
+
const message = update.message;
|
|
223
|
+
if (!message?.from) return;
|
|
224
|
+
const chatId = message.chat.id;
|
|
225
|
+
const telegramUserId = String(message.from.id);
|
|
226
|
+
const text = (message.text ?? "").trim();
|
|
227
|
+
|
|
228
|
+
// `/start <code>` — binding flow.
|
|
229
|
+
if (text === "/start" || text.startsWith("/start ")) {
|
|
230
|
+
const code = text.slice("/start".length).trim();
|
|
231
|
+
if (!code) {
|
|
232
|
+
await sendMessage(
|
|
233
|
+
botToken,
|
|
234
|
+
chatId,
|
|
235
|
+
"Open Telegram settings in Jant and tap Connect to link this chat.",
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
await handleStart(c, {
|
|
240
|
+
botId,
|
|
241
|
+
botToken,
|
|
242
|
+
chatId,
|
|
243
|
+
code,
|
|
244
|
+
telegramUserId,
|
|
245
|
+
telegramUsername: message.from.username ?? null,
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Plain message — publish as a note.
|
|
251
|
+
const binding = await telegram.findBindingByUser(botId, telegramUserId);
|
|
252
|
+
if (!binding) {
|
|
253
|
+
// Defensive: users who copy the binding code without the `/start `
|
|
254
|
+
// prefix still get bound. The settings page formats the code as
|
|
255
|
+
// `/start CODE`, but a fraction of users will paste only the trailing
|
|
256
|
+
// token. Codes are lowercase alphanumeric (see lib/nanoid.ts) and
|
|
257
|
+
// short, so the false-positive surface is tiny.
|
|
258
|
+
if (/^[0-9a-z]+$/.test(text) && text.length <= 24) {
|
|
259
|
+
const pending = await telegram.resolvePendingCode(text);
|
|
260
|
+
if (pending) {
|
|
261
|
+
await handleStart(c, {
|
|
262
|
+
botId,
|
|
263
|
+
botToken,
|
|
264
|
+
chatId,
|
|
265
|
+
code: text,
|
|
266
|
+
telegramUserId,
|
|
267
|
+
telegramUsername: message.from.username ?? null,
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
await sendMessage(
|
|
273
|
+
botToken,
|
|
274
|
+
chatId,
|
|
275
|
+
"This chat isn't connected yet. Open Telegram settings in Jant to get a binding code.",
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Retry de-duplication: Telegram resends on a missed 200.
|
|
280
|
+
if (
|
|
281
|
+
binding.lastUpdateId !== null &&
|
|
282
|
+
update.update_id <= binding.lastUpdateId
|
|
283
|
+
) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const media = extractMediaIngestInput(message);
|
|
288
|
+
|
|
289
|
+
// --- Album item: buffer, ACK Telegram, then claim+publish in background. ---
|
|
290
|
+
//
|
|
291
|
+
// Telegram delivers webhooks for the SAME chat strictly sequentially — the
|
|
292
|
+
// next update isn't sent until the previous one's 200 lands. If we waited
|
|
293
|
+
// inline for the buffer window, the second album item couldn't arrive
|
|
294
|
+
// during the wait and we'd publish each item as its own post. Returning
|
|
295
|
+
// 200 immediately lets sibling items queue into the buffer while the first
|
|
296
|
+
// arrival's background job sleeps.
|
|
297
|
+
if (message.media_group_id && media) {
|
|
298
|
+
const mediaGroupId = message.media_group_id;
|
|
299
|
+
await telegram.bufferAlbumItem({
|
|
300
|
+
siteId: binding.siteId,
|
|
301
|
+
botId,
|
|
302
|
+
telegramUserId,
|
|
303
|
+
mediaGroupId,
|
|
304
|
+
chatId,
|
|
305
|
+
messageId: message.message_id,
|
|
306
|
+
updateId: update.update_id,
|
|
307
|
+
fileId: media.fileId,
|
|
308
|
+
mediaKind: mediaKindToAlbumKind(media.mediaKind),
|
|
309
|
+
mimeType: media.mimeType,
|
|
310
|
+
originalName: media.originalName,
|
|
311
|
+
captionMarkdown: captionMarkdown(message),
|
|
312
|
+
width: media.width ?? null,
|
|
313
|
+
height: media.height ?? null,
|
|
314
|
+
durationSeconds: media.durationSeconds ?? null,
|
|
315
|
+
posterFileId: media.posterFileId ?? null,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
runDeferred(c, async () => {
|
|
319
|
+
await sleep(ALBUM_BUFFER_DELAY_MS);
|
|
320
|
+
const claimed = await telegram.claimAlbumGroup(botId, mediaGroupId);
|
|
321
|
+
if (claimed.length === 0) return;
|
|
322
|
+
await publishAlbum(c, { botToken, chatId, binding, items: claimed });
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Single media item (no album). ---
|
|
328
|
+
if (media) {
|
|
329
|
+
await publishSingleMedia(c, {
|
|
330
|
+
botToken,
|
|
331
|
+
chatId,
|
|
332
|
+
binding,
|
|
333
|
+
media,
|
|
334
|
+
captionMarkdown: captionMarkdown(message),
|
|
335
|
+
updateId: update.update_id,
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- Plain text note. ---
|
|
341
|
+
if (text) {
|
|
342
|
+
// Fold Telegram's rich-text entities back into markdown so bold/italic/
|
|
343
|
+
// code/links typed in the Telegram client survive into the published note.
|
|
344
|
+
// Entity offsets index into the raw `message.text`, so convert first and
|
|
345
|
+
// trim the resulting markdown only at the very end.
|
|
346
|
+
const bodyMarkdown = entitiesToMarkdown(
|
|
347
|
+
message.text ?? "",
|
|
348
|
+
message.entities,
|
|
349
|
+
).trim();
|
|
350
|
+
await c.var.servicesForSite(binding.siteId).posts.create({
|
|
351
|
+
format: "note",
|
|
352
|
+
bodyMarkdown,
|
|
353
|
+
status: "published",
|
|
354
|
+
visibility: "public",
|
|
355
|
+
});
|
|
356
|
+
await telegram.markUpdateProcessed(binding.id, update.update_id);
|
|
357
|
+
await sendMessage(botToken, chatId, "Posted.");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Unsupported attachment kind (voice, sticker, animation, …).
|
|
362
|
+
await sendMessage(
|
|
363
|
+
botToken,
|
|
364
|
+
chatId,
|
|
365
|
+
"I can post text, photos, videos, and documents. Other message types aren't supported yet.",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sleep(ms: number): Promise<void> {
|
|
370
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Schedules background work that must outlive the current HTTP response so
|
|
375
|
+
* Telegram can deliver the next webhook in this chat without waiting.
|
|
376
|
+
*
|
|
377
|
+
* On Cloudflare Workers `c.executionCtx.waitUntil` keeps the worker alive
|
|
378
|
+
* until the promise settles. In Node / tests there's no such API, so we just
|
|
379
|
+
* let the promise float — Node's event loop keeps running it. Either way the
|
|
380
|
+
* promise has its own try/catch so unhandled rejections never bubble up.
|
|
381
|
+
*
|
|
382
|
+
* Background work also tracks the pending-promises array we register on the
|
|
383
|
+
* env binding for tests so a vitest can `await` them after advancing timers.
|
|
384
|
+
*/
|
|
385
|
+
function runDeferred(
|
|
386
|
+
c: {
|
|
387
|
+
env: Bindings;
|
|
388
|
+
executionCtx?: { waitUntil: (promise: Promise<unknown>) => void };
|
|
389
|
+
},
|
|
390
|
+
work: () => Promise<void>,
|
|
391
|
+
): void {
|
|
392
|
+
const promise = work().catch((err) => {
|
|
393
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
394
|
+
// eslint-disable-next-line no-console -- Background failures must be visible.
|
|
395
|
+
console.error(`[Jant] Telegram background error: ${message}`);
|
|
396
|
+
});
|
|
397
|
+
try {
|
|
398
|
+
c.executionCtx?.waitUntil(promise);
|
|
399
|
+
} catch {
|
|
400
|
+
// executionCtx not available (e.g. Node, tests) — promise still runs.
|
|
401
|
+
}
|
|
402
|
+
const pending = (
|
|
403
|
+
c.env as Bindings & {
|
|
404
|
+
__telegramPending?: Promise<unknown>[];
|
|
405
|
+
}
|
|
406
|
+
).__telegramPending;
|
|
407
|
+
if (Array.isArray(pending)) {
|
|
408
|
+
pending.push(promise);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Convert a message's caption + caption_entities into markdown, returning
|
|
414
|
+
* `null` when there's nothing to record. Centralized so single-media and album
|
|
415
|
+
* code paths can't drift apart on entity handling.
|
|
416
|
+
*/
|
|
417
|
+
function captionMarkdown(message: TelegramMessage): string | null {
|
|
418
|
+
if (!message.caption) return null;
|
|
419
|
+
const md = entitiesToMarkdown(
|
|
420
|
+
message.caption,
|
|
421
|
+
message.caption_entities,
|
|
422
|
+
).trim();
|
|
423
|
+
return md || null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Pick the single ingestable media payload from a Telegram message, if any.
|
|
428
|
+
*
|
|
429
|
+
* Telegram never sends multiple media kinds on one message, so a `switch`-like
|
|
430
|
+
* waterfall is sufficient — albums duplicate the message N times rather than
|
|
431
|
+
* stuffing arrays into one message.
|
|
432
|
+
*/
|
|
433
|
+
function extractMediaIngestInput(
|
|
434
|
+
message: TelegramMessage,
|
|
435
|
+
): MessageMedia | null {
|
|
436
|
+
// Photos arrive as an array of sizes ordered low → high; the last entry is
|
|
437
|
+
// the highest-resolution rendition Telegram chose for the recipient.
|
|
438
|
+
if (message.photo && message.photo.length > 0) {
|
|
439
|
+
const largest = message.photo[message.photo.length - 1];
|
|
440
|
+
if (!largest) return null;
|
|
441
|
+
return {
|
|
442
|
+
fileId: largest.file_id,
|
|
443
|
+
originalName: `telegram-photo-${message.message_id}.jpg`,
|
|
444
|
+
mimeType: "image/jpeg",
|
|
445
|
+
mediaKind: "image",
|
|
446
|
+
width: largest.width,
|
|
447
|
+
height: largest.height,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (message.video) {
|
|
451
|
+
const v = message.video;
|
|
452
|
+
return {
|
|
453
|
+
fileId: v.file_id,
|
|
454
|
+
originalName: v.file_name ?? `telegram-video-${message.message_id}.mp4`,
|
|
455
|
+
mimeType: v.mime_type ?? "video/mp4",
|
|
456
|
+
mediaKind: "video",
|
|
457
|
+
width: v.width,
|
|
458
|
+
height: v.height,
|
|
459
|
+
durationSeconds: v.duration,
|
|
460
|
+
// Telegram pre-renders a small JPEG thumbnail for every video. Saving
|
|
461
|
+
// it as the media poster gives the timeline a static frame to show
|
|
462
|
+
// before the full video element loads.
|
|
463
|
+
posterFileId: v.thumbnail?.file_id,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (message.document) {
|
|
467
|
+
const d = message.document;
|
|
468
|
+
const docKind = documentMediaKind(d.mime_type);
|
|
469
|
+
return {
|
|
470
|
+
fileId: d.file_id,
|
|
471
|
+
originalName:
|
|
472
|
+
d.file_name ?? `telegram-document-${message.message_id}.bin`,
|
|
473
|
+
mimeType: d.mime_type ?? "application/octet-stream",
|
|
474
|
+
mediaKind: docKind,
|
|
475
|
+
// Telegram only ships thumbnails for media-shaped documents (videos
|
|
476
|
+
// sent as files, large images). Documents like PDFs may not include
|
|
477
|
+
// one; the optional chain handles both cases.
|
|
478
|
+
posterFileId: docKind === "video" ? d.thumbnail?.file_id : undefined,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Decide which `mediaKind` slot a document belongs in. Telegram lets users
|
|
486
|
+
* send a photo as a "file" to skip compression, in which case the document's
|
|
487
|
+
* mime_type is still `image/*`; classify those as images so they render in the
|
|
488
|
+
* site's image flow rather than the attachment list.
|
|
489
|
+
*/
|
|
490
|
+
function documentMediaKind(mime: string | undefined): MediaKind {
|
|
491
|
+
if (!mime) return "document";
|
|
492
|
+
if (mime.startsWith("image/")) return "image";
|
|
493
|
+
if (mime.startsWith("video/")) return "video";
|
|
494
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
495
|
+
if (mime.startsWith("text/")) return "text";
|
|
496
|
+
return "document";
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function publishSingleMedia(
|
|
500
|
+
c: { env: Bindings; var: AppVariables },
|
|
501
|
+
input: {
|
|
502
|
+
botToken: string;
|
|
503
|
+
chatId: number;
|
|
504
|
+
binding: { id: string; siteId: string };
|
|
505
|
+
media: MessageMedia;
|
|
506
|
+
captionMarkdown: string | null;
|
|
507
|
+
updateId: number;
|
|
508
|
+
},
|
|
509
|
+
): Promise<void> {
|
|
510
|
+
const siteSvcs = c.var.servicesForSite(input.binding.siteId);
|
|
511
|
+
const storage = c.var.storage;
|
|
512
|
+
if (!storage) {
|
|
513
|
+
await sendMessage(
|
|
514
|
+
input.botToken,
|
|
515
|
+
input.chatId,
|
|
516
|
+
"File storage isn't set up on this site, so I can't accept attachments.",
|
|
517
|
+
);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const uploadConfig = uploadConfigFromEnv(c.env);
|
|
522
|
+
const ingested = await siteSvcs.telegram.ingestMediaFile(
|
|
523
|
+
{ ...input.media, botToken: input.botToken },
|
|
524
|
+
{
|
|
525
|
+
storage,
|
|
526
|
+
...uploadConfig,
|
|
527
|
+
media: siteSvcs.media,
|
|
528
|
+
},
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
const attachments: PostAttachmentInput[] = [
|
|
532
|
+
{ type: "media", mediaId: ingested.id },
|
|
533
|
+
];
|
|
534
|
+
|
|
535
|
+
await siteSvcs.posts.createWithAttachments(
|
|
536
|
+
{
|
|
537
|
+
format: "note",
|
|
538
|
+
bodyMarkdown: input.captionMarkdown ?? "",
|
|
539
|
+
status: "published",
|
|
540
|
+
visibility: "public",
|
|
541
|
+
},
|
|
542
|
+
attachments,
|
|
543
|
+
{
|
|
544
|
+
media: siteSvcs.media,
|
|
545
|
+
storage,
|
|
546
|
+
...uploadConfig,
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
await c.var.services.telegram.markUpdateProcessed(
|
|
551
|
+
input.binding.id,
|
|
552
|
+
input.updateId,
|
|
553
|
+
);
|
|
554
|
+
await sendMessage(input.botToken, input.chatId, "Posted.");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function publishAlbum(
|
|
558
|
+
c: { env: Bindings; var: AppVariables },
|
|
559
|
+
input: {
|
|
560
|
+
botToken: string;
|
|
561
|
+
chatId: number;
|
|
562
|
+
binding: { id: string; siteId: string };
|
|
563
|
+
items: Array<{
|
|
564
|
+
messageId: number;
|
|
565
|
+
updateId: number;
|
|
566
|
+
fileId: string;
|
|
567
|
+
mediaKind: TelegramMediaGroupKind;
|
|
568
|
+
mimeType: string | null;
|
|
569
|
+
originalName: string | null;
|
|
570
|
+
captionMarkdown: string | null;
|
|
571
|
+
width: number | null;
|
|
572
|
+
height: number | null;
|
|
573
|
+
durationSeconds: number | null;
|
|
574
|
+
posterFileId: string | null;
|
|
575
|
+
}>;
|
|
576
|
+
},
|
|
577
|
+
): Promise<void> {
|
|
578
|
+
const siteSvcs = c.var.servicesForSite(input.binding.siteId);
|
|
579
|
+
const storage = c.var.storage;
|
|
580
|
+
if (!storage) {
|
|
581
|
+
await sendMessage(
|
|
582
|
+
input.botToken,
|
|
583
|
+
input.chatId,
|
|
584
|
+
"File storage isn't set up on this site, so I can't accept attachments.",
|
|
585
|
+
);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Telegram only carries one caption per album (typically on the first item).
|
|
590
|
+
// Take the first non-empty one in message order so the post body reflects
|
|
591
|
+
// what the user actually typed.
|
|
592
|
+
const bodyMarkdown =
|
|
593
|
+
input.items.find((i) => i.captionMarkdown)?.captionMarkdown ?? "";
|
|
594
|
+
|
|
595
|
+
const uploadConfig = uploadConfigFromEnv(c.env);
|
|
596
|
+
|
|
597
|
+
// Run downloads in parallel — they're independent and disk/network bound;
|
|
598
|
+
// serializing would multiply the publish latency by the album size.
|
|
599
|
+
const mediaRecords = await Promise.all(
|
|
600
|
+
input.items.map((item) =>
|
|
601
|
+
siteSvcs.telegram.ingestMediaFile(
|
|
602
|
+
{
|
|
603
|
+
botToken: input.botToken,
|
|
604
|
+
fileId: item.fileId,
|
|
605
|
+
originalName:
|
|
606
|
+
item.originalName ??
|
|
607
|
+
defaultAlbumName(item.messageId, item.mediaKind),
|
|
608
|
+
mimeType: item.mimeType ?? defaultAlbumMime(item.mediaKind),
|
|
609
|
+
mediaKind: albumKindToMediaKind(item.mediaKind),
|
|
610
|
+
width: item.width ?? undefined,
|
|
611
|
+
height: item.height ?? undefined,
|
|
612
|
+
durationSeconds: item.durationSeconds ?? undefined,
|
|
613
|
+
posterFileId: item.posterFileId ?? undefined,
|
|
614
|
+
},
|
|
615
|
+
{
|
|
616
|
+
storage,
|
|
617
|
+
...uploadConfig,
|
|
618
|
+
media: siteSvcs.media,
|
|
619
|
+
},
|
|
620
|
+
),
|
|
621
|
+
),
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
const attachments: PostAttachmentInput[] = mediaRecords.map((m) => ({
|
|
625
|
+
type: "media",
|
|
626
|
+
mediaId: m.id,
|
|
627
|
+
}));
|
|
628
|
+
|
|
629
|
+
await siteSvcs.posts.createWithAttachments(
|
|
630
|
+
{
|
|
631
|
+
format: "note",
|
|
632
|
+
bodyMarkdown,
|
|
633
|
+
status: "published",
|
|
634
|
+
visibility: "public",
|
|
635
|
+
},
|
|
636
|
+
attachments,
|
|
637
|
+
{
|
|
638
|
+
media: siteSvcs.media,
|
|
639
|
+
storage,
|
|
640
|
+
...uploadConfig,
|
|
641
|
+
},
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// Mark the latest update_id as processed so a Telegram retry of any one
|
|
645
|
+
// item in the group is a no-op.
|
|
646
|
+
const maxUpdateId = Math.max(...input.items.map((i) => i.updateId));
|
|
647
|
+
await c.var.services.telegram.markUpdateProcessed(
|
|
648
|
+
input.binding.id,
|
|
649
|
+
maxUpdateId,
|
|
650
|
+
);
|
|
651
|
+
await sendMessage(input.botToken, input.chatId, "Posted.");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function defaultAlbumName(
|
|
655
|
+
messageId: number,
|
|
656
|
+
kind: TelegramMediaGroupKind,
|
|
657
|
+
): string {
|
|
658
|
+
switch (kind) {
|
|
659
|
+
case "image":
|
|
660
|
+
return `telegram-photo-${messageId}.jpg`;
|
|
661
|
+
case "video":
|
|
662
|
+
return `telegram-video-${messageId}.mp4`;
|
|
663
|
+
default:
|
|
664
|
+
return `telegram-document-${messageId}.bin`;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function defaultAlbumMime(kind: TelegramMediaGroupKind): string {
|
|
669
|
+
switch (kind) {
|
|
670
|
+
case "image":
|
|
671
|
+
return "image/jpeg";
|
|
672
|
+
case "video":
|
|
673
|
+
return "video/mp4";
|
|
674
|
+
default:
|
|
675
|
+
return "application/octet-stream";
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function albumKindToMediaKind(kind: TelegramMediaGroupKind): MediaKind {
|
|
680
|
+
// The buffered `media_kind` mirrors what we recorded at intake; documents
|
|
681
|
+
// have already been classified by mime by then, so the mapping is direct.
|
|
682
|
+
return kind === "image" ? "image" : kind === "video" ? "video" : "document";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Reduce a fine-grained `MediaKind` to the coarse `TelegramMediaGroupKind`
|
|
687
|
+
* the buffer table understands. `audio` and `text` documents both fold into
|
|
688
|
+
* `document` because the buffer only cares about which download path applies.
|
|
689
|
+
*/
|
|
690
|
+
function mediaKindToAlbumKind(kind: MediaKind): TelegramMediaGroupKind {
|
|
691
|
+
if (kind === "image") return "image";
|
|
692
|
+
if (kind === "video") return "video";
|
|
693
|
+
return "document";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function handleStart(
|
|
697
|
+
c: { env: Bindings; var: AppVariables },
|
|
698
|
+
input: {
|
|
699
|
+
botId: string;
|
|
700
|
+
botToken: string;
|
|
701
|
+
chatId: number;
|
|
702
|
+
code: string;
|
|
703
|
+
telegramUserId: string;
|
|
704
|
+
telegramUsername: string | null;
|
|
705
|
+
},
|
|
706
|
+
): Promise<void> {
|
|
707
|
+
const telegram = c.var.services.telegram;
|
|
708
|
+
const pending = await telegram.resolvePendingCode(input.code);
|
|
709
|
+
if (!pending) {
|
|
710
|
+
await sendMessage(
|
|
711
|
+
input.botToken,
|
|
712
|
+
input.chatId,
|
|
713
|
+
"That binding code is invalid or expired. Get a fresh one from Jant settings.",
|
|
714
|
+
);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const existing = await telegram.findBindingByUser(
|
|
719
|
+
input.botId,
|
|
720
|
+
input.telegramUserId,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
// Already connected through this bot to the same site — nothing to do.
|
|
724
|
+
if (existing && existing.siteId === pending.siteId) {
|
|
725
|
+
await sendMessage(
|
|
726
|
+
input.botToken,
|
|
727
|
+
input.chatId,
|
|
728
|
+
"This chat is already connected. Send me any text to post a note.",
|
|
729
|
+
);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// This bot is taken by a different site. The intent is ambiguous — the
|
|
734
|
+
// user might want to move this bot, or keep it and use a free pool bot for
|
|
735
|
+
// the new site — so offer an explicit choice instead of guessing.
|
|
736
|
+
if (existing) {
|
|
737
|
+
const buttons: TelegramInlineButton[][] = [
|
|
738
|
+
[
|
|
739
|
+
{
|
|
740
|
+
text: `Rebind this bot to ${await siteName(c, pending.siteId)}`,
|
|
741
|
+
callback_data: `rebind:${input.code}`,
|
|
742
|
+
},
|
|
743
|
+
],
|
|
744
|
+
];
|
|
745
|
+
for (const other of getTelegramBotPool(c.env)) {
|
|
746
|
+
if (other.botId === input.botId) continue;
|
|
747
|
+
const taken = await telegram.findBindingByUser(
|
|
748
|
+
other.botId,
|
|
749
|
+
input.telegramUserId,
|
|
750
|
+
);
|
|
751
|
+
if (taken) continue;
|
|
752
|
+
const username = await resolveBotUsername(other.botId, other.token);
|
|
753
|
+
if (!username) continue;
|
|
754
|
+
buttons.push([
|
|
755
|
+
{
|
|
756
|
+
text: `Connect to @${username} instead`,
|
|
757
|
+
url: buildDeepLink(username, input.code),
|
|
758
|
+
},
|
|
759
|
+
]);
|
|
760
|
+
}
|
|
761
|
+
await sendMessage(
|
|
762
|
+
input.botToken,
|
|
763
|
+
input.chatId,
|
|
764
|
+
`This bot is already connected to ${await siteName(c, existing.siteId)}. Choose how to connect ${await siteName(c, pending.siteId)}:`,
|
|
765
|
+
{ inline_keyboard: buttons },
|
|
766
|
+
);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Fresh bind.
|
|
771
|
+
await telegram.bindAccount({
|
|
772
|
+
siteId: pending.siteId,
|
|
773
|
+
botId: input.botId,
|
|
774
|
+
telegramUserId: input.telegramUserId,
|
|
775
|
+
telegramUsername: input.telegramUsername,
|
|
776
|
+
});
|
|
777
|
+
await sendMessage(
|
|
778
|
+
input.botToken,
|
|
779
|
+
input.chatId,
|
|
780
|
+
`Connected to ${await siteName(c, pending.siteId)}. Send me any text and I'll post it as a note.`,
|
|
781
|
+
);
|
|
782
|
+
}
|