@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,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse intrinsic pixel dimensions from a raw image header buffer.
|
|
3
|
+
*
|
|
4
|
+
* Used as a server-side fallback when an upload client does not provide
|
|
5
|
+
* `width` / `height`. Only the file header is needed — typically the first
|
|
6
|
+
* few hundred bytes for PNG/JPEG/GIF/WebP, and up to ~64 KB for AVIF where
|
|
7
|
+
* the `ispe` property can be nested inside a larger `meta` box.
|
|
8
|
+
*
|
|
9
|
+
* Returns `null` when the bytes are too short, the format is unsupported,
|
|
10
|
+
* or the header is malformed.
|
|
11
|
+
*
|
|
12
|
+
* @param mimeType MIME type the upload was declared as (e.g. `image/png`).
|
|
13
|
+
* @param bytes Bytes from the start of the file. Pass at least
|
|
14
|
+
* {@link IMAGE_DIMENSION_PEEK_BYTES} for AVIF reliability; smaller is fine
|
|
15
|
+
* for other formats.
|
|
16
|
+
*/
|
|
17
|
+
export function parseImageDimensions(
|
|
18
|
+
mimeType: string,
|
|
19
|
+
bytes: Uint8Array,
|
|
20
|
+
): { width: number; height: number } | null {
|
|
21
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
22
|
+
switch (mimeType) {
|
|
23
|
+
case "image/png":
|
|
24
|
+
return parsePng(view);
|
|
25
|
+
case "image/jpeg":
|
|
26
|
+
case "image/jpg":
|
|
27
|
+
return parseJpeg(view);
|
|
28
|
+
case "image/gif":
|
|
29
|
+
return parseGif(view);
|
|
30
|
+
case "image/webp":
|
|
31
|
+
return parseWebp(view);
|
|
32
|
+
case "image/avif":
|
|
33
|
+
return parseIsoBmff(view, new Set(["avif", "avis"]));
|
|
34
|
+
default:
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recommended number of header bytes to feed into {@link parseImageDimensions}.
|
|
41
|
+
*
|
|
42
|
+
* Covers AVIF files with EXIF/ICC properties placed before the `ispe` box.
|
|
43
|
+
* Smaller formats only inspect the first ~30 bytes.
|
|
44
|
+
*/
|
|
45
|
+
export const IMAGE_DIMENSION_PEEK_BYTES = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
function readChars(view: DataView, offset: number, length: number): string {
|
|
48
|
+
let out = "";
|
|
49
|
+
for (let i = 0; i < length; i += 1) {
|
|
50
|
+
out += String.fromCharCode(view.getUint8(offset + i));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parsePng(view: DataView): { width: number; height: number } | null {
|
|
56
|
+
if (view.byteLength < 24) return null;
|
|
57
|
+
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
58
|
+
for (let i = 0; i < 8; i += 1) {
|
|
59
|
+
if (view.getUint8(i) !== signature[i]) return null;
|
|
60
|
+
}
|
|
61
|
+
const width = view.getUint32(16, false);
|
|
62
|
+
const height = view.getUint32(20, false);
|
|
63
|
+
if (width === 0 || height === 0) return null;
|
|
64
|
+
return { width, height };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseGif(view: DataView): { width: number; height: number } | null {
|
|
68
|
+
if (view.byteLength < 10) return null;
|
|
69
|
+
if (
|
|
70
|
+
view.getUint8(0) !== 0x47 ||
|
|
71
|
+
view.getUint8(1) !== 0x49 ||
|
|
72
|
+
view.getUint8(2) !== 0x46
|
|
73
|
+
) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const width = view.getUint16(6, true);
|
|
77
|
+
const height = view.getUint16(8, true);
|
|
78
|
+
if (width === 0 || height === 0) return null;
|
|
79
|
+
return { width, height };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseWebp(view: DataView): { width: number; height: number } | null {
|
|
83
|
+
if (view.byteLength < 16) return null;
|
|
84
|
+
if (readChars(view, 0, 4) !== "RIFF") return null;
|
|
85
|
+
if (readChars(view, 8, 4) !== "WEBP") return null;
|
|
86
|
+
const chunk = readChars(view, 12, 4);
|
|
87
|
+
|
|
88
|
+
if (chunk === "VP8 ") {
|
|
89
|
+
if (view.byteLength < 30) return null;
|
|
90
|
+
if (
|
|
91
|
+
view.getUint8(23) !== 0x9d ||
|
|
92
|
+
view.getUint8(24) !== 0x01 ||
|
|
93
|
+
view.getUint8(25) !== 0x2a
|
|
94
|
+
) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const width = view.getUint16(26, true) & 0x3fff;
|
|
98
|
+
const height = view.getUint16(28, true) & 0x3fff;
|
|
99
|
+
if (width === 0 || height === 0) return null;
|
|
100
|
+
return { width, height };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (chunk === "VP8L") {
|
|
104
|
+
if (view.byteLength < 25) return null;
|
|
105
|
+
if (view.getUint8(20) !== 0x2f) return null;
|
|
106
|
+
const b0 = view.getUint8(21);
|
|
107
|
+
const b1 = view.getUint8(22);
|
|
108
|
+
const b2 = view.getUint8(23);
|
|
109
|
+
const b3 = view.getUint8(24);
|
|
110
|
+
const width = 1 + (((b1 & 0x3f) << 8) | b0);
|
|
111
|
+
const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
|
|
112
|
+
return { width, height };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (chunk === "VP8X") {
|
|
116
|
+
if (view.byteLength < 30) return null;
|
|
117
|
+
const width =
|
|
118
|
+
1 +
|
|
119
|
+
(view.getUint8(24) |
|
|
120
|
+
(view.getUint8(25) << 8) |
|
|
121
|
+
(view.getUint8(26) << 16));
|
|
122
|
+
const height =
|
|
123
|
+
1 +
|
|
124
|
+
(view.getUint8(27) |
|
|
125
|
+
(view.getUint8(28) << 8) |
|
|
126
|
+
(view.getUint8(29) << 16));
|
|
127
|
+
return { width, height };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseJpeg(view: DataView): { width: number; height: number } | null {
|
|
134
|
+
const length = view.byteLength;
|
|
135
|
+
if (length < 4) return null;
|
|
136
|
+
if (view.getUint8(0) !== 0xff || view.getUint8(1) !== 0xd8) return null;
|
|
137
|
+
|
|
138
|
+
let i = 2;
|
|
139
|
+
while (i < length) {
|
|
140
|
+
while (i < length && view.getUint8(i) !== 0xff) i += 1;
|
|
141
|
+
while (i < length && view.getUint8(i) === 0xff) i += 1;
|
|
142
|
+
if (i >= length) return null;
|
|
143
|
+
const marker = view.getUint8(i);
|
|
144
|
+
i += 1;
|
|
145
|
+
|
|
146
|
+
// Standalone markers without a payload length: 0x00 (escaped FF),
|
|
147
|
+
// 0x01 (TEM), 0xD0–0xD9 (RSTn / SOI / EOI).
|
|
148
|
+
if (marker === 0x00 || marker === 0x01) continue;
|
|
149
|
+
if (marker >= 0xd0 && marker <= 0xd9) continue;
|
|
150
|
+
|
|
151
|
+
if (i + 2 > length) return null;
|
|
152
|
+
const segLen = view.getUint16(i, false);
|
|
153
|
+
if (segLen < 2) return null;
|
|
154
|
+
|
|
155
|
+
const isStartOfFrame =
|
|
156
|
+
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
157
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
158
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
159
|
+
(marker >= 0xcd && marker <= 0xcf);
|
|
160
|
+
|
|
161
|
+
if (isStartOfFrame) {
|
|
162
|
+
// SOF payload: length(2) precision(1) height(2) width(2) components(1)
|
|
163
|
+
if (i + 7 > length) return null;
|
|
164
|
+
const height = view.getUint16(i + 3, false);
|
|
165
|
+
const width = view.getUint16(i + 5, false);
|
|
166
|
+
if (width === 0 || height === 0) return null;
|
|
167
|
+
return { width, height };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
i += segLen;
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface IsoBox {
|
|
176
|
+
size: number;
|
|
177
|
+
type: string;
|
|
178
|
+
payloadOffset: number;
|
|
179
|
+
end: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readBox(view: DataView, pos: number): IsoBox | null {
|
|
183
|
+
if (pos + 8 > view.byteLength) return null;
|
|
184
|
+
let size = view.getUint32(pos, false);
|
|
185
|
+
const type = readChars(view, pos + 4, 4);
|
|
186
|
+
if (size === 1) {
|
|
187
|
+
// 64-bit largesize: we don't bother — the dimension boxes we need fit in
|
|
188
|
+
// the first 64 KB of any sane image.
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (size === 0) {
|
|
192
|
+
size = view.byteLength - pos;
|
|
193
|
+
}
|
|
194
|
+
if (size < 8) return null;
|
|
195
|
+
return { size, type, payloadOffset: pos + 8, end: pos + size };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function findChild(
|
|
199
|
+
view: DataView,
|
|
200
|
+
start: number,
|
|
201
|
+
end: number,
|
|
202
|
+
type: string,
|
|
203
|
+
): IsoBox | null {
|
|
204
|
+
let pos = start;
|
|
205
|
+
while (pos < end) {
|
|
206
|
+
const box = readBox(view, pos);
|
|
207
|
+
if (!box) return null;
|
|
208
|
+
if (box.type === type) return box;
|
|
209
|
+
pos = box.end;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseIsoBmff(
|
|
215
|
+
view: DataView,
|
|
216
|
+
acceptedBrands: Set<string>,
|
|
217
|
+
): { width: number; height: number } | null {
|
|
218
|
+
const ftyp = readBox(view, 0);
|
|
219
|
+
if (!ftyp || ftyp.type !== "ftyp") return null;
|
|
220
|
+
|
|
221
|
+
// major_brand at payloadOffset, minor_version at +4, compatible_brands from +8
|
|
222
|
+
let isAccepted = false;
|
|
223
|
+
if (ftyp.payloadOffset + 4 <= ftyp.end) {
|
|
224
|
+
isAccepted = acceptedBrands.has(readChars(view, ftyp.payloadOffset, 4));
|
|
225
|
+
}
|
|
226
|
+
for (
|
|
227
|
+
let q = ftyp.payloadOffset + 8;
|
|
228
|
+
!isAccepted && q + 4 <= ftyp.end;
|
|
229
|
+
q += 4
|
|
230
|
+
) {
|
|
231
|
+
if (acceptedBrands.has(readChars(view, q, 4))) {
|
|
232
|
+
isAccepted = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!isAccepted) return null;
|
|
236
|
+
|
|
237
|
+
const meta = findChild(view, ftyp.end, view.byteLength, "meta");
|
|
238
|
+
if (!meta) return null;
|
|
239
|
+
|
|
240
|
+
// meta is a FullBox: skip the 4-byte version/flags before its children.
|
|
241
|
+
const iprp = findChild(view, meta.payloadOffset + 4, meta.end, "iprp");
|
|
242
|
+
if (!iprp) return null;
|
|
243
|
+
|
|
244
|
+
const ipco = findChild(view, iprp.payloadOffset, iprp.end, "ipco");
|
|
245
|
+
if (!ipco) return null;
|
|
246
|
+
|
|
247
|
+
// The first ispe inside ipco describes the primary image.
|
|
248
|
+
const ispe = findChild(view, ipco.payloadOffset, ipco.end, "ispe");
|
|
249
|
+
if (!ispe) return null;
|
|
250
|
+
|
|
251
|
+
// ispe is a FullBox: version(1) flags(3) width(4) height(4)
|
|
252
|
+
const widthOffset = ispe.payloadOffset + 4;
|
|
253
|
+
if (widthOffset + 8 > view.byteLength) return null;
|
|
254
|
+
const width = view.getUint32(widthOffset, false);
|
|
255
|
+
const height = view.getUint32(widthOffset + 4, false);
|
|
256
|
+
if (width === 0 || height === 0) return null;
|
|
257
|
+
return { width, height };
|
|
258
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram message entities → CommonMark.
|
|
3
|
+
*
|
|
4
|
+
* Telegram clients ship rich text as a plain `text` string plus an `entities`
|
|
5
|
+
* array of `{type, offset, length}` spans. Jant stores post bodies as
|
|
6
|
+
* CommonMark, so the webhook needs to fold the entity styling back into the
|
|
7
|
+
* text before saving.
|
|
8
|
+
*
|
|
9
|
+
* Two design choices worth knowing:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Top-level text passes through verbatim**, so anything the user typed as
|
|
12
|
+
* literal markdown (`**foo**`, `# Heading`, …) lands in the post unchanged.
|
|
13
|
+
* Only text *inside* an entity span is markdown-escaped, because that text
|
|
14
|
+
* is then wrapped in delimiters and we don't want stray `*` / `_` / `` ` ``
|
|
15
|
+
* to break out of the styled span.
|
|
16
|
+
* 2. **Unsupported entity types degrade to plain text** rather than throwing.
|
|
17
|
+
* Underline, spoiler, custom emoji, and the auto-detected `url` /
|
|
18
|
+
* `hashtag` / `mention` family have no CommonMark equivalent that adds
|
|
19
|
+
* information beyond the raw text — markdown auto-links bare URLs anyway.
|
|
20
|
+
*
|
|
21
|
+
* Telegram offsets are UTF-16 code units, which is exactly what JavaScript
|
|
22
|
+
* string indexing uses, so no transcoding is needed.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { TelegramMessageEntity } from "./telegram.js";
|
|
26
|
+
|
|
27
|
+
interface EntityNode {
|
|
28
|
+
entity: TelegramMessageEntity;
|
|
29
|
+
children: EntityNode[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Converts a Telegram message's `text` + `entities` into CommonMark.
|
|
34
|
+
*
|
|
35
|
+
* @param text - The raw `message.text` (or `message.caption`)
|
|
36
|
+
* @param entities - The parallel `message.entities` array, may be empty
|
|
37
|
+
* @returns The text rewritten as CommonMark; unchanged when `entities` is empty
|
|
38
|
+
* @example
|
|
39
|
+
* entitiesToMarkdown("hello world", [
|
|
40
|
+
* { type: "bold", offset: 6, length: 5 },
|
|
41
|
+
* ]); // "hello **world**"
|
|
42
|
+
*/
|
|
43
|
+
export function entitiesToMarkdown(
|
|
44
|
+
text: string,
|
|
45
|
+
entities: TelegramMessageEntity[] | undefined,
|
|
46
|
+
): string {
|
|
47
|
+
if (!entities || entities.length === 0) return text;
|
|
48
|
+
const roots = buildTree(entities);
|
|
49
|
+
return renderRoots(text, roots);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Groups entities into a forest by containment.
|
|
54
|
+
*
|
|
55
|
+
* Telegram guarantees entities are either nested or disjoint — they never
|
|
56
|
+
* partially overlap — so a simple "find the nearest enclosing entity" pass is
|
|
57
|
+
* enough to recover the tree.
|
|
58
|
+
*/
|
|
59
|
+
function buildTree(entities: TelegramMessageEntity[]): EntityNode[] {
|
|
60
|
+
// Sort parents before children: earlier start first, and for ties the
|
|
61
|
+
// longer (containing) span first.
|
|
62
|
+
const sorted = [...entities].sort(
|
|
63
|
+
(a, b) => a.offset - b.offset || b.length - a.length,
|
|
64
|
+
);
|
|
65
|
+
const nodes: EntityNode[] = sorted.map((entity) => ({
|
|
66
|
+
entity,
|
|
67
|
+
children: [],
|
|
68
|
+
}));
|
|
69
|
+
const roots: EntityNode[] = [];
|
|
70
|
+
for (const [i, node] of nodes.entries()) {
|
|
71
|
+
const nodeEnd = node.entity.offset + node.entity.length;
|
|
72
|
+
let parent: EntityNode | null = null;
|
|
73
|
+
// Walk previously-processed nodes from latest to earliest so we land on
|
|
74
|
+
// the smallest ancestor that still strictly contains this entity.
|
|
75
|
+
for (const cand of nodes.slice(0, i).reverse()) {
|
|
76
|
+
const candEnd = cand.entity.offset + cand.entity.length;
|
|
77
|
+
if (cand.entity.offset <= node.entity.offset && candEnd >= nodeEnd) {
|
|
78
|
+
parent = cand;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (parent) {
|
|
83
|
+
parent.children.push(node);
|
|
84
|
+
} else {
|
|
85
|
+
roots.push(node);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return roots;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderRoots(text: string, roots: EntityNode[]): string {
|
|
92
|
+
return renderRange(text, 0, text.length, roots, { escapeGaps: false });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Emits the substring `[start, end)` from `text`, splicing in rendered child
|
|
97
|
+
* entities and (optionally) escaping the gaps between them.
|
|
98
|
+
*
|
|
99
|
+
* @param escapeGaps - True when this range is itself inside an entity span,
|
|
100
|
+
* so any stray markdown chars would otherwise leak out of the wrapping
|
|
101
|
+
* delimiters. False at the top level so user-typed markdown is preserved.
|
|
102
|
+
*/
|
|
103
|
+
function renderRange(
|
|
104
|
+
text: string,
|
|
105
|
+
start: number,
|
|
106
|
+
end: number,
|
|
107
|
+
children: EntityNode[],
|
|
108
|
+
options: { escapeGaps: boolean },
|
|
109
|
+
): string {
|
|
110
|
+
const sorted = [...children].sort(
|
|
111
|
+
(a, b) => a.entity.offset - b.entity.offset,
|
|
112
|
+
);
|
|
113
|
+
let out = "";
|
|
114
|
+
let cursor = start;
|
|
115
|
+
for (const child of sorted) {
|
|
116
|
+
if (child.entity.offset > cursor) {
|
|
117
|
+
const gap = text.slice(cursor, child.entity.offset);
|
|
118
|
+
out += options.escapeGaps ? escapeInline(gap) : gap;
|
|
119
|
+
}
|
|
120
|
+
out += renderNode(text, child);
|
|
121
|
+
cursor = child.entity.offset + child.entity.length;
|
|
122
|
+
}
|
|
123
|
+
if (cursor < end) {
|
|
124
|
+
const tail = text.slice(cursor, end);
|
|
125
|
+
out += options.escapeGaps ? escapeInline(tail) : tail;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderNode(text: string, node: EntityNode): string {
|
|
131
|
+
const { entity, children } = node;
|
|
132
|
+
const spanStart = entity.offset;
|
|
133
|
+
const spanEnd = entity.offset + entity.length;
|
|
134
|
+
const raw = text.slice(spanStart, spanEnd);
|
|
135
|
+
|
|
136
|
+
switch (entity.type) {
|
|
137
|
+
case "bold":
|
|
138
|
+
return `**${renderInline(text, spanStart, spanEnd, children)}**`;
|
|
139
|
+
case "italic":
|
|
140
|
+
// `*` is safer than `_` because underscores inside words don't trigger
|
|
141
|
+
// emphasis in CommonMark.
|
|
142
|
+
return `*${renderInline(text, spanStart, spanEnd, children)}*`;
|
|
143
|
+
case "strikethrough":
|
|
144
|
+
return `~~${renderInline(text, spanStart, spanEnd, children)}~~`;
|
|
145
|
+
case "code":
|
|
146
|
+
return wrapInlineCode(raw);
|
|
147
|
+
case "pre":
|
|
148
|
+
return wrapCodeBlock(raw, entity.language);
|
|
149
|
+
case "text_link":
|
|
150
|
+
return renderTextLink(text, spanStart, spanEnd, children, entity.url);
|
|
151
|
+
case "blockquote":
|
|
152
|
+
case "expandable_blockquote":
|
|
153
|
+
return renderBlockquote(text, spanStart, spanEnd, children);
|
|
154
|
+
// Everything else (url, mention, hashtag, cashtag, bot_command, email,
|
|
155
|
+
// phone_number, text_mention, custom_emoji, underline, spoiler, …) has
|
|
156
|
+
// no clean CommonMark mapping or adds nothing over the plain text.
|
|
157
|
+
default:
|
|
158
|
+
return renderInline(text, spanStart, spanEnd, children);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderInline(
|
|
163
|
+
text: string,
|
|
164
|
+
start: number,
|
|
165
|
+
end: number,
|
|
166
|
+
children: EntityNode[],
|
|
167
|
+
): string {
|
|
168
|
+
return renderRange(text, start, end, children, { escapeGaps: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderTextLink(
|
|
172
|
+
text: string,
|
|
173
|
+
start: number,
|
|
174
|
+
end: number,
|
|
175
|
+
children: EntityNode[],
|
|
176
|
+
url: string | undefined,
|
|
177
|
+
): string {
|
|
178
|
+
const label = renderInline(text, start, end, children);
|
|
179
|
+
if (!url) return label;
|
|
180
|
+
return `[${label.replace(/[\\\]]/g, "\\$&")}](${escapeLinkUrl(url)})`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderBlockquote(
|
|
184
|
+
text: string,
|
|
185
|
+
start: number,
|
|
186
|
+
end: number,
|
|
187
|
+
children: EntityNode[],
|
|
188
|
+
): string {
|
|
189
|
+
const inner = renderInline(text, start, end, children);
|
|
190
|
+
return inner
|
|
191
|
+
.split("\n")
|
|
192
|
+
.map((line) => `> ${line}`)
|
|
193
|
+
.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Wraps content in the shortest backtick fence that doesn't collide with a
|
|
198
|
+
* backtick run already present in the content. Required for any `code` span
|
|
199
|
+
* containing backticks.
|
|
200
|
+
*/
|
|
201
|
+
function wrapInlineCode(content: string): string {
|
|
202
|
+
const longestRun = longestBacktickRun(content);
|
|
203
|
+
const fence = "`".repeat(longestRun + 1);
|
|
204
|
+
// CommonMark: a space pads the content when it would otherwise start or
|
|
205
|
+
// end with a backtick.
|
|
206
|
+
const pad = content.startsWith("`") || content.endsWith("`") ? " " : "";
|
|
207
|
+
return `${fence}${pad}${content}${pad}${fence}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function wrapCodeBlock(content: string, language: string | undefined): string {
|
|
211
|
+
const fenceLen = Math.max(3, longestBacktickRun(content) + 1);
|
|
212
|
+
const fence = "`".repeat(fenceLen);
|
|
213
|
+
const lang = language ? language : "";
|
|
214
|
+
return `${fence}${lang}\n${content}\n${fence}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function longestBacktickRun(s: string): number {
|
|
218
|
+
let max = 0;
|
|
219
|
+
const matches = s.match(/`+/g);
|
|
220
|
+
if (!matches) return 0;
|
|
221
|
+
for (const m of matches) {
|
|
222
|
+
if (m.length > max) max = m.length;
|
|
223
|
+
}
|
|
224
|
+
return max;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Escapes the markdown delimiters that would otherwise let the inner text
|
|
229
|
+
* break out of a styled span. We deliberately escape only the characters that
|
|
230
|
+
* carry inline meaning here — `*`, `_`, `` ` ``, `~`, `[`, `]`, `\` — rather
|
|
231
|
+
* than the full CommonMark punctuation set, so emoji-adjacent punctuation and
|
|
232
|
+
* other harmless characters stay readable.
|
|
233
|
+
*/
|
|
234
|
+
function escapeInline(s: string): string {
|
|
235
|
+
return s.replace(/[\\`*_~[\]]/g, "\\$&");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function escapeLinkUrl(url: string): string {
|
|
239
|
+
return url.replace(/[\\()]/g, "\\$&");
|
|
240
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram managed-pool webhook registration.
|
|
3
|
+
*
|
|
4
|
+
* In hosted mode the bot pool is platform-owned (`TELEGRAM_BOT_TOKENS`) and
|
|
5
|
+
* there is no settings-page action that would register its webhooks. Rather
|
|
6
|
+
* than make the operator run a CLI step, the Node server self-registers on
|
|
7
|
+
* startup: it derives the webhook URL from `HOSTED_CONTROL_PLANE_BASE_URL`
|
|
8
|
+
* (the public control-plane host that forwards to core) and points each pool
|
|
9
|
+
* bot at `<base>/api/telegram/webhook/<botId>`.
|
|
10
|
+
*
|
|
11
|
+
* This is gated on `HOSTED_CONTROL_PLANE_BASE_URL` being set, so a local dev
|
|
12
|
+
* box with `TELEGRAM_BOT_TOKENS` in its env never touches Telegram. It is
|
|
13
|
+
* idempotent — a `getWebhookInfo` check skips bots already pointed at the
|
|
14
|
+
* right URL, so a steady-state restart issues only cheap reads. Callers run
|
|
15
|
+
* it fire-and-forget; it must never block or fail startup.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
getHostedControlPlaneBaseUrl,
|
|
20
|
+
getTelegramBotPool,
|
|
21
|
+
getTelegramWebhookSecret,
|
|
22
|
+
} from "./env.js";
|
|
23
|
+
import { getWebhookUrl, setMyCommands, setWebhook } from "./telegram.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Registers webhooks for every managed-pool bot, skipping those already
|
|
27
|
+
* pointed at the correct URL. No-ops when the pool is unset, the deployment
|
|
28
|
+
* is not hosted, or no shared webhook secret is configured.
|
|
29
|
+
*
|
|
30
|
+
* @param env - Runtime environment bindings
|
|
31
|
+
*/
|
|
32
|
+
export async function registerTelegramPoolWebhooks(
|
|
33
|
+
env: object | undefined | null,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
const pool = getTelegramBotPool(env);
|
|
36
|
+
if (pool.length === 0) return;
|
|
37
|
+
|
|
38
|
+
const baseUrl = getHostedControlPlaneBaseUrl(env);
|
|
39
|
+
if (!baseUrl) {
|
|
40
|
+
// Not a hosted deployment — the pool, if present, is for local testing
|
|
41
|
+
// and must not have its webhooks touched automatically.
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const secret = getTelegramWebhookSecret(env);
|
|
46
|
+
if (!secret) {
|
|
47
|
+
// eslint-disable-next-line no-console -- Misconfiguration must be visible.
|
|
48
|
+
console.error(
|
|
49
|
+
"[Jant] TELEGRAM_BOT_TOKENS is set but TELEGRAM_WEBHOOK_SECRET is missing — skipping webhook registration.",
|
|
50
|
+
);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const origin = baseUrl.replace(/\/+$/, "");
|
|
55
|
+
for (const bot of pool) {
|
|
56
|
+
const webhookUrl = `${origin}/api/telegram/webhook/${bot.botId}`;
|
|
57
|
+
try {
|
|
58
|
+
const current = await getWebhookUrl(bot.token);
|
|
59
|
+
if (current !== webhookUrl) {
|
|
60
|
+
await setWebhook(bot.token, webhookUrl, secret);
|
|
61
|
+
// eslint-disable-next-line no-console -- One-line audit trail for a rare write.
|
|
62
|
+
console.log(`[Jant] Telegram webhook registered: bot=${bot.botId}`);
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
// eslint-disable-next-line no-console -- Registration failures must be visible.
|
|
67
|
+
console.error(
|
|
68
|
+
`[Jant] Telegram webhook registration failed: bot=${bot.botId} error=${message}`,
|
|
69
|
+
);
|
|
70
|
+
// Webhook failed — skip command sync too; the bot isn't usable anyway.
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Command list is independent of the webhook URL. Re-run unconditionally
|
|
74
|
+
// so existing deployments pick up command changes without a re-register,
|
|
75
|
+
// and so the `/` autocomplete reflects the latest list.
|
|
76
|
+
try {
|
|
77
|
+
await setMyCommands(bot.token);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
// eslint-disable-next-line no-console -- Polish failure — visible but non-fatal.
|
|
81
|
+
console.error(
|
|
82
|
+
`[Jant] Telegram setMyCommands failed: bot=${bot.botId} error=${message}`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for reading Telegram settings status and rendering the
|
|
3
|
+
* settings panel.
|
|
4
|
+
*
|
|
5
|
+
* Consumed by:
|
|
6
|
+
* - GET /settings/telegram — initial page render
|
|
7
|
+
* - GET /settings/telegram/status/stream — live status polling loop that
|
|
8
|
+
* swaps the connect view for the connected view the moment a binding lands
|
|
9
|
+
*
|
|
10
|
+
* Both call sites render the same `<TelegramContent>` through
|
|
11
|
+
* `renderTelegramContentHtml`, so any markup change flows to both
|
|
12
|
+
* automatically.
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from "hono";
|
|
15
|
+
import { renderSVG } from "uqr";
|
|
16
|
+
import type { Bindings } from "../types.js";
|
|
17
|
+
import type { AppVariables } from "../types/app-context.js";
|
|
18
|
+
import { getTelegramBotPool } from "./env.js";
|
|
19
|
+
import { buildDeepLink, getMe } from "./telegram.js";
|
|
20
|
+
import { toPublicPath } from "./url.js";
|
|
21
|
+
import { I18nProvider } from "../i18n/context.js";
|
|
22
|
+
import {
|
|
23
|
+
TelegramContent,
|
|
24
|
+
type TelegramSettingsView,
|
|
25
|
+
} from "../ui/dash/settings/TelegramContent.js";
|
|
26
|
+
|
|
27
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
28
|
+
|
|
29
|
+
export type { TelegramSettingsView };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the `TelegramSettingsView` for the current site. Mirrors what the
|
|
33
|
+
* `/settings/telegram` GET route does, factored out so the SSE stream can
|
|
34
|
+
* re-render exactly the same view.
|
|
35
|
+
*/
|
|
36
|
+
export async function readTelegramSettingsView(
|
|
37
|
+
c: Context<Env>,
|
|
38
|
+
): Promise<TelegramSettingsView> {
|
|
39
|
+
const pool = getTelegramBotPool(c.env);
|
|
40
|
+
const managed = pool.length > 0;
|
|
41
|
+
const status = await c.var.services.telegram.getStatus();
|
|
42
|
+
|
|
43
|
+
// Public-facing bot username for the deep link / QR. The managed pool's
|
|
44
|
+
// first bot is the public face; a bring-your-own bot already has its
|
|
45
|
+
// username cached from setup.
|
|
46
|
+
let botUsername = "";
|
|
47
|
+
const firstBot = pool[0];
|
|
48
|
+
if (firstBot) {
|
|
49
|
+
try {
|
|
50
|
+
const identity = await getMe(firstBot.token);
|
|
51
|
+
botUsername = identity.username;
|
|
52
|
+
} catch {
|
|
53
|
+
botUsername = "";
|
|
54
|
+
}
|
|
55
|
+
} else if (status.userBot) {
|
|
56
|
+
botUsername = status.userBot.username;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let connect: TelegramSettingsView["connect"] = null;
|
|
60
|
+
if (!status.binding && botUsername) {
|
|
61
|
+
const code = await c.var.services.telegram.getOrCreateCode();
|
|
62
|
+
const deepLink = buildDeepLink(botUsername, code);
|
|
63
|
+
connect = { code, deepLink, qrSvg: renderSVG(deepLink), botUsername };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
managed,
|
|
68
|
+
binding: status.binding
|
|
69
|
+
? {
|
|
70
|
+
telegramUsername: status.binding.telegramUsername,
|
|
71
|
+
boundAt: status.binding.boundAt,
|
|
72
|
+
}
|
|
73
|
+
: null,
|
|
74
|
+
userBotConfigured: status.userBot !== null,
|
|
75
|
+
connect,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Render the Telegram settings panel to an HTML string. The `streamUrl`,
|
|
81
|
+
* when provided, mounts a Datastar SSE subscription on the connect view so
|
|
82
|
+
* the page auto-swaps to the connected view the moment a binding lands.
|
|
83
|
+
*/
|
|
84
|
+
export function renderTelegramContentHtml(
|
|
85
|
+
c: Context<Env>,
|
|
86
|
+
view: TelegramSettingsView,
|
|
87
|
+
streamUrl: string,
|
|
88
|
+
): string {
|
|
89
|
+
// Hono JSX stringifies synchronously when the tree has no async children.
|
|
90
|
+
// `TelegramContent` is sync, so `String(...)` returns plain HTML. The
|
|
91
|
+
// I18nProvider binds the per-request i18n instance for `useLingui()`.
|
|
92
|
+
return String(
|
|
93
|
+
<I18nProvider c={c}>
|
|
94
|
+
<TelegramContent
|
|
95
|
+
view={view}
|
|
96
|
+
sitePathPrefix={c.var.appConfig.sitePathPrefix}
|
|
97
|
+
streamUrl={streamUrl}
|
|
98
|
+
/>
|
|
99
|
+
</I18nProvider>,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** URL of the SSE endpoint that streams settings-panel patches. */
|
|
104
|
+
export function getTelegramStatusStreamUrl(c: Context<Env>): string {
|
|
105
|
+
return toPublicPath(
|
|
106
|
+
"/settings/telegram/status/stream",
|
|
107
|
+
c.var.appConfig.sitePathPrefix,
|
|
108
|
+
);
|
|
109
|
+
}
|