@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.
Files changed (90) hide show
  1. package/bin/commands/telegram/register-webhooks.js +93 -0
  2. package/dist/app-CMSW_AYG.js +6 -0
  3. package/dist/{app-BtNdUAqz.js → app-DYQdDMs8.js} +2249 -387
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BRTh1ii1.js +274 -0
  6. package/dist/client/_assets/client-CO4b-RKd.css +2 -0
  7. package/dist/client/_assets/{client-auth-DJ_5wx9N.js → client-auth-CSNcTJwP.js} +81 -81
  8. package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
  9. package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
  10. package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
  11. package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
  12. package/dist/index.js +4 -4
  13. package/dist/node.js +61 -5
  14. package/package.json +2 -1
  15. package/src/__tests__/helpers/app.ts +15 -2
  16. package/src/app.tsx +3 -0
  17. package/src/client/thread-context.ts +146 -2
  18. package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
  19. package/src/client/tiptap/bubble-menu.ts +1 -16
  20. package/src/client/tiptap/extensions.ts +2 -6
  21. package/src/client/tiptap/link-toolbar.ts +0 -21
  22. package/src/client/tiptap/toolbar-mode.ts +0 -43
  23. package/src/db/migrations/0022_old_gressill.sql +24 -0
  24. package/src/db/migrations/0023_broad_terror.sql +20 -0
  25. package/src/db/migrations/0024_red_the_twelve.sql +3 -0
  26. package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
  27. package/src/db/migrations/meta/0022_snapshot.json +2267 -0
  28. package/src/db/migrations/meta/0023_snapshot.json +2396 -0
  29. package/src/db/migrations/meta/0024_snapshot.json +2417 -0
  30. package/src/db/migrations/meta/0025_snapshot.json +2424 -0
  31. package/src/db/migrations/meta/_journal.json +28 -0
  32. package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
  33. package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
  34. package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
  35. package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
  36. package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
  37. package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
  38. package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
  39. package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
  40. package/src/db/migrations/pg/meta/_journal.json +28 -0
  41. package/src/db/pg/schema.ts +82 -0
  42. package/src/db/schema.ts +90 -0
  43. package/src/i18n/coverage.generated.ts +2 -2
  44. package/src/i18n/locales/public/en.po +8 -0
  45. package/src/i18n/locales/public/zh-Hans.po +8 -0
  46. package/src/i18n/locales/public/zh-Hant.po +8 -0
  47. package/src/i18n/locales/settings/en.po +135 -0
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +136 -1
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +136 -1
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/lib/__tests__/image-dimensions.test.ts +314 -0
  54. package/src/lib/__tests__/telegram-entities.test.ts +180 -0
  55. package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
  56. package/src/lib/env.ts +45 -0
  57. package/src/lib/ids.ts +3 -0
  58. package/src/lib/image-dimensions.ts +258 -0
  59. package/src/lib/telegram-entities.ts +240 -0
  60. package/src/lib/telegram-pool-webhooks.ts +86 -0
  61. package/src/lib/telegram-settings-status.tsx +109 -0
  62. package/src/lib/telegram.ts +363 -0
  63. package/src/node/runtime.ts +6 -0
  64. package/src/routes/api/__tests__/telegram.test.ts +612 -0
  65. package/src/routes/api/telegram.ts +782 -0
  66. package/src/routes/api/upload-multipart.ts +34 -12
  67. package/src/routes/api/upload.ts +23 -2
  68. package/src/routes/dash/settings.tsx +131 -1
  69. package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
  70. package/src/routes/pages/page.tsx +3 -2
  71. package/src/runtime/cloudflare.ts +20 -9
  72. package/src/runtime/node.ts +20 -9
  73. package/src/runtime/site.ts +2 -1
  74. package/src/services/__tests__/telegram.test.ts +148 -0
  75. package/src/services/index.ts +9 -0
  76. package/src/services/telegram.ts +613 -0
  77. package/src/services/upload-session.ts +39 -12
  78. package/src/styles/tokens.css +1 -0
  79. package/src/styles/ui.css +117 -38
  80. package/src/types/app-context.ts +6 -0
  81. package/src/types/bindings.ts +3 -0
  82. package/src/types/config.ts +40 -0
  83. package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
  84. package/src/ui/dash/settings/TelegramContent.tsx +549 -0
  85. package/src/ui/feed/ThreadPreview.tsx +90 -38
  86. package/src/ui/feed/__tests__/thread-preview.test.ts +66 -5
  87. package/src/ui/pages/PostPage.tsx +77 -15
  88. package/dist/app-DLINgGBd.js +0 -6
  89. package/dist/client/_assets/client-BErXNT6k.css +0 -2
  90. 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
+ }