@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,612 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { createTestApp } from "../../../__tests__/helpers/app.js";
|
|
3
|
+
import { DEFAULT_TEST_SITE_ID } from "../../../__tests__/helpers/db.js";
|
|
4
|
+
import { telegramWebhookRoutes } from "../telegram.js";
|
|
5
|
+
import type { StorageDriver } from "../../../lib/storage.js";
|
|
6
|
+
|
|
7
|
+
const BOT_ID = "111111";
|
|
8
|
+
const BOT_TOKEN = `${BOT_ID}:AA-test-token`;
|
|
9
|
+
const SECRET = "test-webhook-secret";
|
|
10
|
+
const USER_ID = 999999;
|
|
11
|
+
|
|
12
|
+
/** Records every outbound Telegram API call so tests can assert on them. */
|
|
13
|
+
interface TelegramCall {
|
|
14
|
+
method: string;
|
|
15
|
+
body: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MockTelegramOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Map of `file_id` → bytes returned by the file CDN. `getFile` resolves a
|
|
21
|
+
* `file_id` to a synthetic `file_path` of the form `mock/<file_id>` so the
|
|
22
|
+
* download stub can locate the matching bytes by inspecting the URL.
|
|
23
|
+
*/
|
|
24
|
+
files?: Map<string, Uint8Array>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mockTelegramFetch(opts: MockTelegramOptions = {}): TelegramCall[] {
|
|
28
|
+
const calls: TelegramCall[] = [];
|
|
29
|
+
const files = opts.files ?? new Map<string, Uint8Array>();
|
|
30
|
+
vi.stubGlobal(
|
|
31
|
+
"fetch",
|
|
32
|
+
vi.fn(async (url: string, init?: { body?: unknown }) => {
|
|
33
|
+
const urlStr = String(url);
|
|
34
|
+
// File download: /file/bot<token>/<file_path>
|
|
35
|
+
if (urlStr.includes("/file/bot")) {
|
|
36
|
+
const filePath =
|
|
37
|
+
urlStr.split("/file/bot")[1]?.split("/").slice(1).join("/") ?? "";
|
|
38
|
+
const fileId = filePath.replace(/^mock\//, "");
|
|
39
|
+
const bytes =
|
|
40
|
+
files.get(fileId) ?? new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
|
41
|
+
return new Response(bytes);
|
|
42
|
+
}
|
|
43
|
+
// Method call: /bot<token>/<method>
|
|
44
|
+
const method = urlStr.split("/").pop() ?? "";
|
|
45
|
+
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
|
46
|
+
calls.push({ method, body });
|
|
47
|
+
let result: unknown = true;
|
|
48
|
+
if (method === "getMe") {
|
|
49
|
+
result = { id: Number(BOT_ID), username: "JantTestBot" };
|
|
50
|
+
} else if (method === "getFile") {
|
|
51
|
+
const fileId = String((body as { file_id?: string }).file_id ?? "");
|
|
52
|
+
const bytes = files.get(fileId);
|
|
53
|
+
result = {
|
|
54
|
+
file_id: fileId,
|
|
55
|
+
file_unique_id: fileId,
|
|
56
|
+
file_path: `mock/${fileId}`,
|
|
57
|
+
file_size: bytes?.byteLength,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return new Response(JSON.stringify({ ok: true, result }), {
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
});
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
return calls;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Minimal in-memory storage so the webhook can persist downloaded files. */
|
|
69
|
+
function createMockStorage(): StorageDriver & {
|
|
70
|
+
files: Map<string, { body: Uint8Array; contentType?: string }>;
|
|
71
|
+
} {
|
|
72
|
+
const files = new Map<string, { body: Uint8Array; contentType?: string }>();
|
|
73
|
+
return {
|
|
74
|
+
files,
|
|
75
|
+
async put(key, body, opts) {
|
|
76
|
+
const bytes =
|
|
77
|
+
body instanceof Uint8Array
|
|
78
|
+
? body
|
|
79
|
+
: new Uint8Array(await new Response(body).arrayBuffer());
|
|
80
|
+
files.set(key, { body: bytes, contentType: opts?.contentType });
|
|
81
|
+
},
|
|
82
|
+
async get(key) {
|
|
83
|
+
const file = files.get(key);
|
|
84
|
+
if (!file) return null;
|
|
85
|
+
return {
|
|
86
|
+
body: new Response(file.body).body as ReadableStream,
|
|
87
|
+
size: file.body.byteLength,
|
|
88
|
+
contentType: file.contentType,
|
|
89
|
+
etag: "",
|
|
90
|
+
uploaded: new Date(),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
async head(key) {
|
|
94
|
+
const file = files.get(key);
|
|
95
|
+
if (!file) return null;
|
|
96
|
+
return {
|
|
97
|
+
size: file.body.byteLength,
|
|
98
|
+
contentType: file.contentType,
|
|
99
|
+
etag: "",
|
|
100
|
+
uploaded: new Date(),
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
async delete(key) {
|
|
104
|
+
files.delete(key);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setup(
|
|
110
|
+
options: { storage?: ReturnType<typeof createMockStorage> | null } = {},
|
|
111
|
+
) {
|
|
112
|
+
const ctx = createTestApp({
|
|
113
|
+
telegramBotTokens: BOT_TOKEN,
|
|
114
|
+
telegramWebhookSecret: SECRET,
|
|
115
|
+
storage: options.storage ?? null,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// The webhook ACKs Telegram immediately and runs the album flush in
|
|
119
|
+
// detached promises so subsequent webhooks on the same chat aren't
|
|
120
|
+
// serialized behind it. Tests have no `executionCtx.waitUntil`, so the
|
|
121
|
+
// route falls back to writing each promise into `env.__telegramPending`
|
|
122
|
+
// — collect them here so tests can await the flush before asserting.
|
|
123
|
+
const pending: Promise<unknown>[] = [];
|
|
124
|
+
ctx.app.use("/api/telegram/*", async (c, next) => {
|
|
125
|
+
(c.env as { __telegramPending?: Promise<unknown>[] }).__telegramPending =
|
|
126
|
+
pending;
|
|
127
|
+
return next();
|
|
128
|
+
});
|
|
129
|
+
ctx.app.route("/api/telegram", telegramWebhookRoutes);
|
|
130
|
+
return { ...ctx, pending };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function post(
|
|
134
|
+
app: ReturnType<typeof setup>["app"],
|
|
135
|
+
botId: string,
|
|
136
|
+
secret: string | null,
|
|
137
|
+
update: unknown,
|
|
138
|
+
) {
|
|
139
|
+
return app.request(`/api/telegram/webhook/${botId}`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
...(secret === null ? {} : { "x-telegram-bot-api-secret-token": secret }),
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(update),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function textUpdate(updateId: number, text: string) {
|
|
150
|
+
return {
|
|
151
|
+
update_id: updateId,
|
|
152
|
+
message: {
|
|
153
|
+
message_id: updateId,
|
|
154
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al", username: "al" },
|
|
155
|
+
chat: { id: USER_ID },
|
|
156
|
+
text,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countPosts(sqlite: ReturnType<typeof setup>["sqlite"]): number {
|
|
162
|
+
return (
|
|
163
|
+
sqlite.prepare("SELECT COUNT(*) AS n FROM post").get() as { n: number }
|
|
164
|
+
).n;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
describe("Telegram webhook route", () => {
|
|
168
|
+
let calls: TelegramCall[];
|
|
169
|
+
|
|
170
|
+
beforeEach(() => {
|
|
171
|
+
calls = mockTelegramFetch();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
vi.unstubAllGlobals();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("rejects a request with a bad secret token", async () => {
|
|
179
|
+
const { app } = setup();
|
|
180
|
+
const res = await post(app, BOT_ID, "wrong-secret", textUpdate(1, "hi"));
|
|
181
|
+
expect(res.status).toBe(401);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("rejects a request for an unknown bot", async () => {
|
|
185
|
+
const { app } = setup();
|
|
186
|
+
const res = await post(app, "222222", SECRET, textUpdate(1, "hi"));
|
|
187
|
+
expect(res.status).toBe(404);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("publishes a note for a bound user's text message", async () => {
|
|
191
|
+
const { app, services, sqlite } = setup();
|
|
192
|
+
await services.telegram.bindAccount({
|
|
193
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
194
|
+
botId: BOT_ID,
|
|
195
|
+
telegramUserId: String(USER_ID),
|
|
196
|
+
telegramUsername: "al",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const res = await post(app, BOT_ID, SECRET, textUpdate(5, "hello world"));
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(countPosts(sqlite)).toBe(1);
|
|
202
|
+
expect(calls.some((c) => c.method === "sendMessage")).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("folds telegram entities into markdown on the saved post body", async () => {
|
|
206
|
+
const { app, services, sqlite } = setup();
|
|
207
|
+
await services.telegram.bindAccount({
|
|
208
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
209
|
+
botId: BOT_ID,
|
|
210
|
+
telegramUserId: String(USER_ID),
|
|
211
|
+
telegramUsername: "al",
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
215
|
+
update_id: 11,
|
|
216
|
+
message: {
|
|
217
|
+
message_id: 11,
|
|
218
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al", username: "al" },
|
|
219
|
+
chat: { id: USER_ID },
|
|
220
|
+
text: "hello bold world",
|
|
221
|
+
entities: [{ type: "bold", offset: 6, length: 4 }],
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
// The post body is stored as the parsed ProseMirror JSON, so the
|
|
226
|
+
// round-trip proof is that the word "bold" carries a `bold` mark — that
|
|
227
|
+
// can only happen if entitiesToMarkdown emitted `**bold**` for the
|
|
228
|
+
// markdown parser to pick up.
|
|
229
|
+
const row = sqlite
|
|
230
|
+
.prepare("SELECT body FROM post ORDER BY rowid DESC LIMIT 1")
|
|
231
|
+
.get() as { body: string };
|
|
232
|
+
const doc = JSON.parse(row.body) as {
|
|
233
|
+
content: Array<{
|
|
234
|
+
content: Array<{ text: string; marks?: Array<{ type: string }> }>;
|
|
235
|
+
}>;
|
|
236
|
+
};
|
|
237
|
+
const spans = doc.content[0].content;
|
|
238
|
+
expect(spans.find((s) => s.text === "bold")?.marks).toEqual([
|
|
239
|
+
{ type: "bold" },
|
|
240
|
+
]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("skips a duplicate update id", async () => {
|
|
244
|
+
const { app, services, sqlite } = setup();
|
|
245
|
+
await services.telegram.bindAccount({
|
|
246
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
247
|
+
botId: BOT_ID,
|
|
248
|
+
telegramUserId: String(USER_ID),
|
|
249
|
+
telegramUsername: "al",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
await post(app, BOT_ID, SECRET, textUpdate(7, "first"));
|
|
253
|
+
await post(app, BOT_ID, SECRET, textUpdate(7, "first again"));
|
|
254
|
+
expect(countPosts(sqlite)).toBe(1);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("declines an unsupported message type without posting", async () => {
|
|
258
|
+
const { app, services, sqlite } = setup();
|
|
259
|
+
await services.telegram.bindAccount({
|
|
260
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
261
|
+
botId: BOT_ID,
|
|
262
|
+
telegramUserId: String(USER_ID),
|
|
263
|
+
telegramUsername: "al",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
267
|
+
update_id: 9,
|
|
268
|
+
message: {
|
|
269
|
+
message_id: 9,
|
|
270
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
271
|
+
chat: { id: USER_ID },
|
|
272
|
+
// Voice notes aren't on the supported list; the handler should reply
|
|
273
|
+
// explaining what it accepts instead of posting anything.
|
|
274
|
+
voice: { file_id: "vf", file_unique_id: "vu", duration: 3 },
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
expect(res.status).toBe(200);
|
|
278
|
+
expect(countPosts(sqlite)).toBe(0);
|
|
279
|
+
const reply = calls.find((c) => c.method === "sendMessage");
|
|
280
|
+
expect(String(reply?.body.text)).toMatch(/photos, videos, and documents/);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("posts a photo as a note with the photo attached", async () => {
|
|
284
|
+
const photoBytes = new Uint8Array([1, 2, 3, 4, 5]);
|
|
285
|
+
calls = mockTelegramFetch({
|
|
286
|
+
files: new Map([["photo-file-id", photoBytes]]),
|
|
287
|
+
});
|
|
288
|
+
const storage = createMockStorage();
|
|
289
|
+
const { app, services, sqlite } = setup({ storage });
|
|
290
|
+
await services.telegram.bindAccount({
|
|
291
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
292
|
+
botId: BOT_ID,
|
|
293
|
+
telegramUserId: String(USER_ID),
|
|
294
|
+
telegramUsername: "al",
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
298
|
+
update_id: 50,
|
|
299
|
+
message: {
|
|
300
|
+
message_id: 50,
|
|
301
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
302
|
+
chat: { id: USER_ID },
|
|
303
|
+
photo: [
|
|
304
|
+
{ file_id: "small", file_unique_id: "su", width: 90, height: 90 },
|
|
305
|
+
{
|
|
306
|
+
file_id: "photo-file-id",
|
|
307
|
+
file_unique_id: "pu",
|
|
308
|
+
width: 1280,
|
|
309
|
+
height: 720,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(res.status).toBe(200);
|
|
316
|
+
expect(countPosts(sqlite)).toBe(1);
|
|
317
|
+
|
|
318
|
+
// The media row points at the stored object, and the post is wired to it
|
|
319
|
+
// via the post_id column.
|
|
320
|
+
const mediaRows = sqlite
|
|
321
|
+
.prepare("SELECT id, mime_type, media_kind, size, post_id FROM media")
|
|
322
|
+
.all() as Array<{
|
|
323
|
+
id: string;
|
|
324
|
+
mime_type: string;
|
|
325
|
+
media_kind: string;
|
|
326
|
+
size: number;
|
|
327
|
+
post_id: string | null;
|
|
328
|
+
}>;
|
|
329
|
+
expect(mediaRows.length).toBe(1);
|
|
330
|
+
expect(mediaRows[0]?.mime_type).toBe("image/jpeg");
|
|
331
|
+
expect(mediaRows[0]?.media_kind).toBe("image");
|
|
332
|
+
expect(mediaRows[0]?.size).toBe(photoBytes.byteLength);
|
|
333
|
+
expect(mediaRows[0]?.post_id).not.toBeNull();
|
|
334
|
+
expect(storage.files.size).toBe(1);
|
|
335
|
+
|
|
336
|
+
// `getFile` was called with the highest-resolution `file_id` from the
|
|
337
|
+
// photo array, never the smaller thumbnails.
|
|
338
|
+
const getFileCall = calls.find((c) => c.method === "getFile");
|
|
339
|
+
expect(getFileCall?.body.file_id).toBe("photo-file-id");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("uses the caption as the note body and folds caption entities to markdown", async () => {
|
|
343
|
+
calls = mockTelegramFetch({
|
|
344
|
+
files: new Map([["photo-file-id", new Uint8Array([9, 8, 7])]]),
|
|
345
|
+
});
|
|
346
|
+
const storage = createMockStorage();
|
|
347
|
+
const { app, services, sqlite } = setup({ storage });
|
|
348
|
+
await services.telegram.bindAccount({
|
|
349
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
350
|
+
botId: BOT_ID,
|
|
351
|
+
telegramUserId: String(USER_ID),
|
|
352
|
+
telegramUsername: "al",
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
356
|
+
update_id: 60,
|
|
357
|
+
message: {
|
|
358
|
+
message_id: 60,
|
|
359
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
360
|
+
chat: { id: USER_ID },
|
|
361
|
+
photo: [
|
|
362
|
+
{
|
|
363
|
+
file_id: "photo-file-id",
|
|
364
|
+
file_unique_id: "pu",
|
|
365
|
+
width: 800,
|
|
366
|
+
height: 600,
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
caption: "look bold here",
|
|
370
|
+
caption_entities: [{ type: "bold", offset: 5, length: 4 }],
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
expect(res.status).toBe(200);
|
|
374
|
+
|
|
375
|
+
const row = sqlite
|
|
376
|
+
.prepare("SELECT body FROM post ORDER BY rowid DESC LIMIT 1")
|
|
377
|
+
.get() as { body: string };
|
|
378
|
+
const doc = JSON.parse(row.body) as {
|
|
379
|
+
content: Array<{
|
|
380
|
+
content: Array<{ text: string; marks?: Array<{ type: string }> }>;
|
|
381
|
+
}>;
|
|
382
|
+
};
|
|
383
|
+
expect(
|
|
384
|
+
doc.content[0]?.content.find((s) => s.text === "bold")?.marks,
|
|
385
|
+
).toEqual([{ type: "bold" }]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("downloads the video thumbnail as the media poster", async () => {
|
|
389
|
+
const videoBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
390
|
+
const thumbBytes = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 9, 9, 9]);
|
|
391
|
+
calls = mockTelegramFetch({
|
|
392
|
+
files: new Map([
|
|
393
|
+
["video-file-id", videoBytes],
|
|
394
|
+
["video-thumb-id", thumbBytes],
|
|
395
|
+
]),
|
|
396
|
+
});
|
|
397
|
+
const storage = createMockStorage();
|
|
398
|
+
const { app, services, sqlite } = setup({ storage });
|
|
399
|
+
await services.telegram.bindAccount({
|
|
400
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
401
|
+
botId: BOT_ID,
|
|
402
|
+
telegramUserId: String(USER_ID),
|
|
403
|
+
telegramUsername: "al",
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
407
|
+
update_id: 80,
|
|
408
|
+
message: {
|
|
409
|
+
message_id: 80,
|
|
410
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
411
|
+
chat: { id: USER_ID },
|
|
412
|
+
video: {
|
|
413
|
+
file_id: "video-file-id",
|
|
414
|
+
file_unique_id: "vu",
|
|
415
|
+
width: 1920,
|
|
416
|
+
height: 1080,
|
|
417
|
+
duration: 12,
|
|
418
|
+
mime_type: "video/mp4",
|
|
419
|
+
thumbnail: {
|
|
420
|
+
file_id: "video-thumb-id",
|
|
421
|
+
file_unique_id: "tu",
|
|
422
|
+
width: 320,
|
|
423
|
+
height: 180,
|
|
424
|
+
file_size: thumbBytes.byteLength,
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
expect(res.status).toBe(200);
|
|
430
|
+
|
|
431
|
+
const row = sqlite
|
|
432
|
+
.prepare(
|
|
433
|
+
"SELECT poster_key, media_kind FROM media ORDER BY rowid DESC LIMIT 1",
|
|
434
|
+
)
|
|
435
|
+
.get() as { poster_key: string | null; media_kind: string };
|
|
436
|
+
expect(row.media_kind).toBe("video");
|
|
437
|
+
expect(row.poster_key).toBeTruthy();
|
|
438
|
+
expect(row.poster_key).toMatch(/\.jpg$/);
|
|
439
|
+
// The poster bytes actually landed in storage at the recorded key.
|
|
440
|
+
const stored = storage.files.get(row.poster_key as string);
|
|
441
|
+
expect(stored?.body).toEqual(thumbBytes);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("posts a video document with mime + filename preserved", async () => {
|
|
445
|
+
const docBytes = new Uint8Array([10, 20, 30, 40]);
|
|
446
|
+
calls = mockTelegramFetch({ files: new Map([["doc-id", docBytes]]) });
|
|
447
|
+
const storage = createMockStorage();
|
|
448
|
+
const { app, services, sqlite } = setup({ storage });
|
|
449
|
+
await services.telegram.bindAccount({
|
|
450
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
451
|
+
botId: BOT_ID,
|
|
452
|
+
telegramUserId: String(USER_ID),
|
|
453
|
+
telegramUsername: "al",
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const res = await post(app, BOT_ID, SECRET, {
|
|
457
|
+
update_id: 70,
|
|
458
|
+
message: {
|
|
459
|
+
message_id: 70,
|
|
460
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
461
|
+
chat: { id: USER_ID },
|
|
462
|
+
document: {
|
|
463
|
+
file_id: "doc-id",
|
|
464
|
+
file_unique_id: "du",
|
|
465
|
+
file_name: "notes.pdf",
|
|
466
|
+
mime_type: "application/pdf",
|
|
467
|
+
file_size: docBytes.byteLength,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
expect(res.status).toBe(200);
|
|
472
|
+
|
|
473
|
+
const mediaRow = sqlite
|
|
474
|
+
.prepare(
|
|
475
|
+
"SELECT mime_type, media_kind, original_name FROM media ORDER BY rowid DESC LIMIT 1",
|
|
476
|
+
)
|
|
477
|
+
.get() as {
|
|
478
|
+
mime_type: string;
|
|
479
|
+
media_kind: string;
|
|
480
|
+
original_name: string;
|
|
481
|
+
};
|
|
482
|
+
expect(mediaRow.mime_type).toBe("application/pdf");
|
|
483
|
+
expect(mediaRow.media_kind).toBe("document");
|
|
484
|
+
expect(mediaRow.original_name).toBe("notes.pdf");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("merges an album (media_group_id) into a single post with both photos", async () => {
|
|
488
|
+
vi.useFakeTimers();
|
|
489
|
+
const a = new Uint8Array([1, 1, 1]);
|
|
490
|
+
const b = new Uint8Array([2, 2, 2]);
|
|
491
|
+
calls = mockTelegramFetch({
|
|
492
|
+
files: new Map([
|
|
493
|
+
["file-a", a],
|
|
494
|
+
["file-b", b],
|
|
495
|
+
]),
|
|
496
|
+
});
|
|
497
|
+
const storage = createMockStorage();
|
|
498
|
+
const { app, services, sqlite, pending } = setup({ storage });
|
|
499
|
+
await services.telegram.bindAccount({
|
|
500
|
+
siteId: DEFAULT_TEST_SITE_ID,
|
|
501
|
+
botId: BOT_ID,
|
|
502
|
+
telegramUserId: String(USER_ID),
|
|
503
|
+
telegramUsername: "al",
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Each webhook ACKs Telegram before its background flush starts. Send
|
|
507
|
+
// both messages SEQUENTIALLY here to match Telegram's per-chat ordering
|
|
508
|
+
// in production — if the route serialized inline on the buffer wait,
|
|
509
|
+
// the second message would queue behind the first 2-second sleep and
|
|
510
|
+
// become its own post.
|
|
511
|
+
const r1 = await post(app, BOT_ID, SECRET, {
|
|
512
|
+
update_id: 100,
|
|
513
|
+
message: {
|
|
514
|
+
message_id: 100,
|
|
515
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
516
|
+
chat: { id: USER_ID },
|
|
517
|
+
media_group_id: "group-1",
|
|
518
|
+
photo: [
|
|
519
|
+
{ file_id: "file-a", file_unique_id: "ua", width: 800, height: 600 },
|
|
520
|
+
],
|
|
521
|
+
caption: "album caption",
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
const r2 = await post(app, BOT_ID, SECRET, {
|
|
525
|
+
update_id: 101,
|
|
526
|
+
message: {
|
|
527
|
+
message_id: 101,
|
|
528
|
+
from: { id: USER_ID, is_bot: false, first_name: "Al" },
|
|
529
|
+
chat: { id: USER_ID },
|
|
530
|
+
media_group_id: "group-1",
|
|
531
|
+
photo: [
|
|
532
|
+
{ file_id: "file-b", file_unique_id: "ub", width: 800, height: 600 },
|
|
533
|
+
],
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
expect(r1.status).toBe(200);
|
|
537
|
+
expect(r2.status).toBe(200);
|
|
538
|
+
|
|
539
|
+
// Advance past the deferred buffer window; both backgrounds wake and
|
|
540
|
+
// race for the group claim. Drain the deferred promises so we observe
|
|
541
|
+
// the final DB state before asserting.
|
|
542
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
543
|
+
await Promise.all(pending);
|
|
544
|
+
vi.useRealTimers();
|
|
545
|
+
|
|
546
|
+
expect(countPosts(sqlite)).toBe(1);
|
|
547
|
+
expect(
|
|
548
|
+
(
|
|
549
|
+
sqlite.prepare("SELECT COUNT(*) AS n FROM media").get() as {
|
|
550
|
+
n: number;
|
|
551
|
+
}
|
|
552
|
+
).n,
|
|
553
|
+
).toBe(2);
|
|
554
|
+
expect(
|
|
555
|
+
(
|
|
556
|
+
sqlite
|
|
557
|
+
.prepare("SELECT COUNT(*) AS n FROM telegram_media_group_item")
|
|
558
|
+
.get() as { n: number }
|
|
559
|
+
).n,
|
|
560
|
+
).toBe(0);
|
|
561
|
+
|
|
562
|
+
const post1 = sqlite.prepare("SELECT body FROM post LIMIT 1").get() as {
|
|
563
|
+
body: string;
|
|
564
|
+
};
|
|
565
|
+
const doc = JSON.parse(post1.body) as {
|
|
566
|
+
content: Array<{ content?: Array<{ text?: string }> }>;
|
|
567
|
+
};
|
|
568
|
+
const text = doc.content[0]?.content?.[0]?.text ?? "";
|
|
569
|
+
expect(text).toBe("album caption");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("prompts an unbound user instead of posting", async () => {
|
|
573
|
+
const { app, sqlite } = setup();
|
|
574
|
+
const res = await post(app, BOT_ID, SECRET, textUpdate(3, "stray message"));
|
|
575
|
+
expect(res.status).toBe(200);
|
|
576
|
+
expect(countPosts(sqlite)).toBe(0);
|
|
577
|
+
expect(calls.some((c) => c.method === "sendMessage")).toBe(true);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("binds an account via /start <code>", async () => {
|
|
581
|
+
const { app, services } = setup();
|
|
582
|
+
const code = await services.telegram.getOrCreateCode();
|
|
583
|
+
|
|
584
|
+
const res = await post(
|
|
585
|
+
app,
|
|
586
|
+
BOT_ID,
|
|
587
|
+
SECRET,
|
|
588
|
+
textUpdate(2, `/start ${code}`),
|
|
589
|
+
);
|
|
590
|
+
expect(res.status).toBe(200);
|
|
591
|
+
|
|
592
|
+
const binding = await services.telegram.findBindingByUser(
|
|
593
|
+
BOT_ID,
|
|
594
|
+
String(USER_ID),
|
|
595
|
+
);
|
|
596
|
+
expect(binding?.siteId).toBe(DEFAULT_TEST_SITE_ID);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("binds an account when an unbound user sends just the bare code", async () => {
|
|
600
|
+
const { app, services } = setup();
|
|
601
|
+
const code = await services.telegram.getOrCreateCode();
|
|
602
|
+
|
|
603
|
+
const res = await post(app, BOT_ID, SECRET, textUpdate(4, code));
|
|
604
|
+
expect(res.status).toBe(200);
|
|
605
|
+
|
|
606
|
+
const binding = await services.telegram.findBindingByUser(
|
|
607
|
+
BOT_ID,
|
|
608
|
+
String(USER_ID),
|
|
609
|
+
);
|
|
610
|
+
expect(binding?.siteId).toBe(DEFAULT_TEST_SITE_ID);
|
|
611
|
+
});
|
|
612
|
+
});
|