meta-sovereign 0.53.0 → 0.53.2

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 (61) hide show
  1. package/README.md +1 -1
  2. package/js/src/crm/reengage.js +18 -5
  3. package/js/src/replies/reply-cache.js +30 -26
  4. package/js/src/server/live-subset.js +46 -0
  5. package/js/src/server/local-tls.js +14 -3
  6. package/js/src/server/routes-automations.js +9 -1
  7. package/js/src/server/routes-derived.js +36 -20
  8. package/js/src/server/routes-events.js +1 -1
  9. package/js/src/server/routes-telegram.js +7 -0
  10. package/js/src/sources/telegram-account.js +8 -3
  11. package/js/src/sources/telegram-backfill.js +5 -4
  12. package/js/src/sources/telegram-business-bots.js +1 -1
  13. package/js/src/sources/telegram-chat-info.js +6 -1
  14. package/js/src/sources/telegram-chat-url.js +34 -0
  15. package/js/src/sources/telegram-dialog-state.js +60 -0
  16. package/js/src/sources/telegram-forum.js +48 -1
  17. package/js/src/storage/app-directory.js +4 -3
  18. package/js/src/storage/dual-store.js +6 -0
  19. package/js/src/storage/secret-store.js +6 -1
  20. package/js/src/web/api-errors.js +4 -1
  21. package/js/src/web/app.css +51 -26
  22. package/js/src/web/app.min.js +95 -95
  23. package/js/src/web/chat-automation.js +8 -1
  24. package/js/src/web/chat-business-bots.js +9 -2
  25. package/js/src/web/chat-drag.js +26 -2
  26. package/js/src/web/chat-feed.js +165 -0
  27. package/js/src/web/chat-follow.js +22 -6
  28. package/js/src/web/chat-live.js +23 -140
  29. package/js/src/web/chat-model.js +27 -5
  30. package/js/src/web/chat-row.js +3 -1
  31. package/js/src/web/chat-sidebar.js +6 -4
  32. package/js/src/web/chat-state.js +9 -3
  33. package/js/src/web/chat-suggest.js +4 -7
  34. package/js/src/web/chat-view.js +126 -71
  35. package/js/src/web/client.js +33 -83
  36. package/js/src/web/connections/index.js +1 -1
  37. package/js/src/web/discover.js +19 -9
  38. package/js/src/web/dom.js +14 -5
  39. package/js/src/web/locales/en-chat.js +15 -11
  40. package/js/src/web/locales/en.js +17 -0
  41. package/js/src/web/locales/hi-chat.js +16 -10
  42. package/js/src/web/locales/hi.js +15 -0
  43. package/js/src/web/locales/ru-chat.js +16 -10
  44. package/js/src/web/locales/ru.js +17 -0
  45. package/js/src/web/locales/zh-chat.js +15 -9
  46. package/js/src/web/locales/zh.js +15 -0
  47. package/js/src/web/navigation-counts.js +7 -5
  48. package/js/src/web/operator-model.js +7 -3
  49. package/js/src/web/operator-prefs.js +58 -0
  50. package/js/src/web/operator-unread.js +97 -0
  51. package/js/src/web/operator-view.js +64 -40
  52. package/js/src/web/person-panel.js +20 -26
  53. package/js/src/web/profile-stories.js +5 -2
  54. package/js/src/web/server-availability.js +71 -0
  55. package/js/src/web/settings-view.js +38 -0
  56. package/js/src/web/shell/app-shell.js +5 -1
  57. package/js/src/web/stories-pref.js +5 -0
  58. package/js/src/web/stories.js +1 -1
  59. package/js/src/web/telegram-status.js +36 -0
  60. package/package.json +1 -1
  61. package/js/src/web/chat-history-status.js +0 -94
package/README.md CHANGED
@@ -61,7 +61,7 @@ The hosted app is the default UI: `serve` prints its address, <https://link-foun
61
61
 
62
62
  On macOS, interactive `serve` now handles the full setup: it protects this installation’s TLS key with Secure Enclave, detects existing certificate trust, requests trust only when needed, and opens the GitHub Pages app. For a headless launch use `node js/bin/meta-sovereign.js serve --trust=true --open=true`. `--trust=false` and `--open=false` opt out. The Swift helper needs the macOS Command Line Tools; `--hardware-key=false` explicitly opts into a file key on unsupported Macs. Hardware protection applies to the key on disk; Node uses it in memory. See [local server setup and security](docs/LOCAL-SERVER-SECURITY.md) for the exact access rules and limits.
63
63
 
64
- Open conversations load first and show loading or retry status until their history is ready. All / Private / Groups / Channels filters remain available inside every Telegram folder. Drag the vertical dividers to resize columns; widths are remembered. Calls is in the main menu after Chat, and Telegram account editing is under Profile. The default dark controls use white line icons. The JS server keeps its store under your OS user: `~/Library/Application Support/Meta Sovereign/store` on macOS, `%APPDATA%/Meta Sovereign/store` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/Meta Sovereign/store` on Linux. Starting `serve` from an older checkout migrates its `.meta-sovereign` store once, preserving messages, contacts, sessions, media and certificate; a compatibility link preserves old paths. Restarts and a different working directory reuse the same store. `--store` or `META_SOVEREIGN_STORE_DIR` override it. See [chat readiness and data storage](docs/CHAT-READINESS.md).
64
+ Open conversations load first. History loads automatically on scroll, with a compact indicator inside the message list while requesting or retrying; no history status panel or manual loading button is shown. Earlier history loads automatically on scroll in Chat and Operator. Message selection and menus work across the full row; unread cards use refreshed Telegram counters and retain cached data during connection failures. All / Private / Groups / Channels filters remain available inside every Telegram folder. Drag the vertical dividers to resize columns; widths are remembered. Calls is in the main menu after Chat, and Telegram account editing is under Profile. The default dark controls use white line icons. Buttons and action links have nonzero spacing and aligned control padding. Stories is off by default; Settings → Stories enables its display and requests. The JS server keeps its store under your OS user: `~/Library/Application Support/Meta Sovereign/store` on macOS, `%APPDATA%/Meta Sovereign/store` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/Meta Sovereign/store` on Linux. Starting `serve` from an older checkout migrates its `.meta-sovereign` store once, preserving messages, contacts, sessions, media and certificate; a compatibility link preserves old paths. Restarts and a different working directory reuse the same store. `--store` or `META_SOVEREIGN_STORE_DIR` override it. See [chat readiness and data storage](docs/CHAT-READINESS.md).
65
65
 
66
66
  ## Connect the SPA to your server
67
67
 
@@ -22,6 +22,10 @@ import {
22
22
  definedOnly,
23
23
  defaultAddressRules,
24
24
  } from './address-form.js';
25
+ import {
26
+ telegramChatUrl,
27
+ isTelegramWebUrl,
28
+ } from '../sources/telegram-chat-url.js';
25
29
  import { addressFormOf } from './person-learn.js';
26
30
  import {
27
31
  batchStopReason,
@@ -300,12 +304,18 @@ export const templateFor = (templates, form) =>
300
304
  * where the platform documents one; `null` elsewhere (copy the text).
301
305
  *
302
306
  * - e-mail: RFC 6068 `mailto:<addr>?body=` (line breaks as %0D%0A).
303
- * - Telegram: `tg://user?id=<id>` opens the chat with that user (Bot
304
- * API docs); the text still has to be pasted, because Telegram has no
305
- * pre-fill parameter for a user chat.
307
+ * - Telegram: documented username/phone links can prefill text; numeric-only
308
+ * peers open the specific chat in Telegram Web (sign-in may be required).
306
309
  * - VK: `https://vk.com/im?sel=<peer>` opens the dialog with a user.
307
310
  */
308
- export const prefillLinkFor = ({ source, contact, chat, text }) => {
311
+ export const prefillLinkFor = ({
312
+ source,
313
+ contact,
314
+ chat,
315
+ text,
316
+ username,
317
+ phone,
318
+ }) => {
309
319
  const id = String(contact ?? '');
310
320
  const peer = String(chat ?? id);
311
321
  if (source === 'email' && /^[^\s@]+@[^\s@]+$/.test(id)) {
@@ -319,7 +329,10 @@ export const prefillLinkFor = ({ source, contact, chat, text }) => {
319
329
  }
320
330
  const tg = source === 'telegram' ? id.match(/^(?:user)?(\d+)$/) : null;
321
331
  if (tg) {
322
- return { url: `tg://user?id=${tg[1]}`, prefillsText: false };
332
+ const url = telegramChatUrl({ source, chat: tg[1], username, phone, text });
333
+ return url
334
+ ? { url, prefillsText: Boolean(text) && !isTelegramWebUrl(url) }
335
+ : null;
323
336
  }
324
337
  return null;
325
338
  };
@@ -289,30 +289,34 @@ export const createReplyCache = ({
289
289
  * none — three at most, as the strip shows them.
290
290
  */
291
291
  export const mergeSuggestions = (local, remote, { limit = 8 } = {}) => {
292
- if (!local) {
293
- return remote ?? { stage: 'none', suggestions: [] };
294
- }
295
- if (!remote?.suggestions?.length) {
296
- return local;
297
- }
298
- const whole = (list) => list.filter((s) => s.kind !== 'word');
299
- const shown = new Set(
300
- whole(local.suggestions).map((s) => normalizeMessage(s.text))
301
- );
302
- const added = whole(remote.suggestions).filter(
303
- (s) => !shown.has(normalizeMessage(s.text))
304
- );
305
- const wholes = [...whole(local.suggestions), ...added].slice(0, limit);
306
- const taken = new Set(wholes.map((s) => normalizeMessage(s.text)));
307
- const wordsOf = (result) =>
308
- result.suggestions.filter(
309
- (s) => s.kind === 'word' && !taken.has(normalizeMessage(s.text))
310
- );
311
- const known = wordsOf(local);
312
- const words = (known.length > 0 ? known : wordsOf(remote)).slice(
313
- 0,
314
- Math.min(3, limit - wholes.length)
315
- );
316
- const suggestions = [...wholes, ...words];
317
- return { ...local, stage: suggestions[0]?.kind ?? 'none', suggestions };
292
+ const base = local ?? remote ?? { stage: 'none', suggestions: [] };
293
+ const seen = new Set();
294
+ const unique = (list) =>
295
+ list.filter((suggestion) => {
296
+ const key = normalizeMessage(suggestion.text);
297
+ if (!key || seen.has(key)) {
298
+ return false;
299
+ }
300
+ seen.add(key);
301
+ return true;
302
+ });
303
+ const localItems = local?.suggestions ?? [];
304
+ const remoteItems = remote?.suggestions ?? [];
305
+ const whole = unique(
306
+ [...localItems, ...remoteItems].filter((s) => s.kind !== 'word')
307
+ ).slice(0, limit);
308
+ const knownWords = localItems.filter((s) => s.kind === 'word');
309
+ const words = unique(
310
+ knownWords.length
311
+ ? knownWords
312
+ : remoteItems.filter((s) => s.kind === 'word')
313
+ ).slice(0, Math.min(3, limit - whole.length));
314
+ const suggestions = [...whole, ...words];
315
+ return {
316
+ ...base,
317
+ stage: suggestions.length
318
+ ? (suggestions[0]?.kind ?? base.stage ?? 'none')
319
+ : 'none',
320
+ suggestions,
321
+ };
318
322
  };
@@ -0,0 +1,46 @@
1
+ /** Keep a small derived set current without rescanning the entire archive. */
2
+ export const createLiveSubset = (store, matches) => {
3
+ if (typeof store.subscribe !== 'function') {
4
+ return () => store.query(matches);
5
+ }
6
+ const records = new Map();
7
+ let pending = null;
8
+ let loaded = false;
9
+ let during = [];
10
+ const apply = (event) => {
11
+ if (event.type === 'delete') {
12
+ records.delete(event.id ?? event.link?.id);
13
+ }
14
+ if (event.type === 'put') {
15
+ if (matches(event.link)) {
16
+ records.set(event.link.id, event.link);
17
+ } else {
18
+ records.delete(event.link.id);
19
+ }
20
+ }
21
+ };
22
+ store.subscribe?.((event) => {
23
+ if (loaded) {
24
+ apply(event);
25
+ } else {
26
+ during.push(event);
27
+ }
28
+ });
29
+ return async () => {
30
+ if (!loaded) {
31
+ pending ??= Promise.resolve()
32
+ .then(() => store.query(matches))
33
+ .then((links) => {
34
+ links.forEach((link) => records.set(link.id, link));
35
+ during.forEach(apply);
36
+ during = [];
37
+ loaded = true;
38
+ })
39
+ .finally(() => {
40
+ pending = null;
41
+ });
42
+ await pending;
43
+ }
44
+ return [...records.values()];
45
+ };
46
+ };
@@ -20,7 +20,7 @@ import crypto from 'node:crypto';
20
20
  import { promises as fs } from 'node:fs';
21
21
  import net from 'node:net';
22
22
  import path from 'node:path';
23
- import tls from 'node:tls';
23
+ import http2 from 'node:http2';
24
24
  import { DIR_MODE, tighten, writeFileDurable } from '../storage/durable.js';
25
25
  import {
26
26
  certificateFingerprint,
@@ -265,8 +265,19 @@ export const listenHttpAndHttps = (
265
265
  host = '127.0.0.1'
266
266
  ) =>
267
267
  new Promise((resolve, reject) => {
268
- const secure = tls.createServer({ key, cert }, (socket) =>
269
- httpServer.emit('connection', socket)
268
+ // Multiplex API calls, media and event streams over HTTPS. HTTP/1's six
269
+ // browser connections can otherwise all wait behind long media downloads.
270
+ const secure = http2.createSecureServer(
271
+ { key, cert, allowHTTP1: true },
272
+ (req, res) => {
273
+ // Keep the existing strict Host and Origin checks for HTTP/2 too.
274
+ req.headers.host ??= req.headers[':authority'];
275
+ httpServer.emit('request', req, res);
276
+ }
277
+ );
278
+ secure.requestTimeout = httpServer.requestTimeout;
279
+ secure.on('upgrade', (req, socket, head) =>
280
+ httpServer.emit('upgrade', req, socket, head)
270
281
  );
271
282
  // A browser that does not trust the certificate yet hangs up.
272
283
  secure.on('tlsClientError', () => {});
@@ -41,6 +41,7 @@
41
41
  * chat menu can test an edited automation before saving it.
42
42
  */
43
43
 
44
+ import { createLiveSubset } from './live-subset.js';
44
45
  import { bodyFailure, json, readBody } from './util.js';
45
46
  import { confirmed } from './confirmations.js';
46
47
  import { previewFields } from '../sources/telegram-link-preview.js';
@@ -441,9 +442,16 @@ const POST = {
441
442
 
442
443
  // Only typing links are read (asked every few seconds: copying the
443
444
  // whole store each time cost memory and time).
445
+ const typingByStore = new WeakMap();
444
446
  const typingNow = async (store, q) => {
447
+ if (!typingByStore.has(store)) {
448
+ typingByStore.set(
449
+ store,
450
+ createLiveSubset(store, (link) => link?.id?.startsWith('typing:'))
451
+ );
452
+ }
445
453
  const now = Date.now();
446
- return (await store.query((l) => l?.id?.startsWith('typing:'))).filter(
454
+ return (await typingByStore.get(store)()).filter(
447
455
  (l) =>
448
456
  (!q.get('source') || l.source === q.get('source')) &&
449
457
  (!q.has('chat') || String(l.chat) === q.get('chat')) &&
@@ -71,40 +71,56 @@ const facts = async (store) => {
71
71
  return extractFacts(messages, patterns);
72
72
  };
73
73
 
74
- // Every page that looks for the server asks /api/status, and reading
75
- // and verifying the whole store takes seconds on a big one: the answer
76
- // is kept until the store changes, and requests that come while it is
77
- // being worked out share it.
74
+ // Full statistics may take seconds on a large store. Discovery uses the
75
+ // constant-cost probe below; statistics callers share one in-flight snapshot,
76
+ // even when background sync writes while that snapshot is being computed.
78
77
  const statuses = new WeakMap();
79
78
  const cachedStatus = (store) => {
80
79
  let entry = statuses.get(store);
81
80
  if (!entry) {
82
- entry = { value: null, watched: typeof store.subscribe === 'function' };
81
+ entry = {
82
+ value: null,
83
+ pending: null,
84
+ revision: 0,
85
+ watched: typeof store.subscribe === 'function',
86
+ };
83
87
  statuses.set(store, entry);
84
88
  store.subscribe?.(() => {
85
89
  entry.value = null;
90
+ entry.revision += 1;
86
91
  });
87
92
  }
88
- if (!entry.value || !entry.watched) {
89
- const value = (async () =>
90
- computeStatus(await store.query(), (await store.verify?.()) ?? []))();
91
- entry.value = value;
92
- value.catch(() => {
93
- if (entry.value === value) {
94
- entry.value = null;
95
- }
96
- });
93
+ if (entry.pending) {
94
+ return entry.pending;
97
95
  }
98
- return entry.value;
96
+ if (entry.value && entry.watched) {
97
+ return entry.value;
98
+ }
99
+ const revision = entry.revision;
100
+ entry.pending = (async () => {
101
+ try {
102
+ const value = computeStatus(
103
+ await store.query(),
104
+ (await store.verify?.()) ?? []
105
+ );
106
+ if (entry.revision === revision) {
107
+ entry.value = value;
108
+ }
109
+ return value;
110
+ } finally {
111
+ entry.pending = null;
112
+ }
113
+ })();
114
+ return entry.pending;
99
115
  };
100
116
 
101
117
  const HANDLERS = {
102
118
  '/api/contacts': async (store) =>
103
119
  aggregateContacts(await store.query(), { people: true }),
104
- '/api/status': async (store) => ({
105
- service: 'meta-sovereign',
106
- ...(await cachedStatus(store)),
107
- }),
120
+ '/api/status': async (store, url) =>
121
+ url.searchParams.get('probe') === '1'
122
+ ? { service: 'meta-sovereign', discovery: true }
123
+ : { service: 'meta-sovereign', ...(await cachedStatus(store)) },
108
124
  '/api/autocomplete': async (store, url) =>
109
125
  completionsFor(
110
126
  await store.query(),
@@ -135,7 +151,7 @@ const HANDLERS = {
135
151
  } = ctx.storeHealth?.() ?? {};
136
152
  return {
137
153
  ok: true,
138
- links: (await store.query()).length,
154
+ links: (await store.size?.()) ?? (await store.query()).length,
139
155
  time: new Date().toISOString(),
140
156
  disk: await diskHealth(ctx.storeDir ?? '.', ctx.diskFree),
141
157
  store: { degraded, queuedBytes },
@@ -69,7 +69,7 @@ export const handleEventRoutes = (store, req, res, p, ctx) => {
69
69
  res.writeHead(200, {
70
70
  'content-type': 'text/event-stream; charset=utf-8',
71
71
  'cache-control': 'no-cache, no-transform',
72
- connection: 'keep-alive',
72
+ ...(req.httpVersionMajor === 2 ? {} : { connection: 'keep-alive' }),
73
73
  'x-accel-buffering': 'no',
74
74
  });
75
75
  // Reconnect quickly after the server restarts.
@@ -502,6 +502,13 @@ const searchWords = (q) =>
502
502
  const LANG = /^[a-z]{2}$/;
503
503
 
504
504
  const POSTS = {
505
+ 'dialog-state': async (account, body) => [
506
+ 200,
507
+ (await account.dialogState?.({ chat: body.chat })) ?? {
508
+ ok: false,
509
+ supported: false,
510
+ },
511
+ ],
505
512
  sync: async (account, body) => [
506
513
  200,
507
514
  await account.sync({ full: Boolean(body.full), reason: 'manual' }),
@@ -28,6 +28,7 @@
28
28
  * chat ids and the account's public name only.
29
29
  */
30
30
 
31
+ import { createDialogState } from './telegram-dialog-state.js';
31
32
  import { buildMessageLink } from './link.js';
32
33
  import { personLink } from './live-links.js';
33
34
  import { accountOf, gramjsClientFactory } from './telegram-mtproto.js';
@@ -583,7 +584,6 @@ export const pinnedRanks = (listed) => {
583
584
  return ranks;
584
585
  };
585
586
 
586
- // What Telegram knows about a chat's reading and draft, as listed.
587
587
  const dialogReadLink = (chat, dialog, rank) => {
588
588
  const state = dialog.dialog ?? {};
589
589
  const draft = state.draft;
@@ -1123,10 +1123,15 @@ const createMessaging = (env) => {
1123
1123
  return { ok: true };
1124
1124
  };
1125
1125
 
1126
- return { post, setTyping, markRead, saveDraft };
1126
+ return {
1127
+ post,
1128
+ setTyping,
1129
+ markRead,
1130
+ saveDraft,
1131
+ dialogState: createDialogState(env, userChat),
1132
+ };
1127
1133
  };
1128
1134
 
1129
- // The user's connection, opened again if it was dropped.
1130
1135
  const reopenerOf = (store, connection) => async () => {
1131
1136
  const creds = await readTelegramCredentials(store);
1132
1137
  if (creds?.kind === 'user') {
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { getHeapStatistics } from 'node:v8';
26
- import { historyLinkId } from './telegram-history.js';
26
+ import { historyLinkId, floodSeconds } from './telegram-history.js';
27
27
 
28
28
  export const BACKFILL_EVERY_MS = 5000;
29
29
  export const BACKFILL_PAGE = 100;
@@ -271,10 +271,11 @@ export const createBackfill = ({
271
271
  // Nothing to read now; new chats may come with the list.
272
272
  base = paceAfter(did, everyMs);
273
273
  } catch (error) {
274
- failures += 1;
274
+ const wait = floodSeconds(error);
275
+ failures = wait ? 0 : failures + 1;
275
276
  log({
276
- level: 'warn',
277
- event: 'telegram_backfill_failed',
277
+ level: wait ? 'info' : 'warn',
278
+ event: wait ? 'telegram_backfill_waits' : 'telegram_backfill_failed',
278
279
  code: error?.code ?? null,
279
280
  error: error?.message,
280
281
  });
@@ -90,7 +90,7 @@ const botOf = (connectedBot, users) => {
90
90
 
91
91
  // Telegram's PeerSettings, as the bar over a managed chat reads them.
92
92
  export const barOf = (settings, users) => {
93
- if (settings?.businessBotId === undefined) {
93
+ if (!/^[1-9]\d*$/.test(String(settings?.businessBotId ?? ''))) {
94
94
  return null;
95
95
  }
96
96
  const user = (users ?? []).find(
@@ -377,6 +377,11 @@ const extrasOf = (user, full) => ({
377
377
  const botRevenueOf = (user, full) =>
378
378
  user.bot && full.canViewRevenue ? { canViewStarsRevenue: true } : {};
379
379
 
380
+ const contactReach = (user) => ({
381
+ username: user.username ?? null,
382
+ phone: user.phone ?? null,
383
+ });
384
+
380
385
  const fetchUser = async (client, Api, helpers, inputPeer, peers, chat) => {
381
386
  const storedPeer = inputPeer ?? (await peers.input(chat));
382
387
  const id = inputUserOf(Api, helpers, storedPeer, chat);
@@ -389,7 +394,7 @@ const fetchUser = async (client, Api, helpers, inputPeer, peers, chat) => {
389
394
  title: nameOf(user),
390
395
  members: 1,
391
396
  online,
392
- username: user.username ?? null,
397
+ ...contactReach(user),
393
398
  about: full.about ?? null,
394
399
  photo: hasPhoto(user.photo),
395
400
  pinned: full.pinnedMsgId ?? null,
@@ -0,0 +1,34 @@
1
+ // Normal Telegram clients ignore tg://user?id (a Bot API abstraction).
2
+ // Use documented username/phone/message links; numeric-only peers use Web A's
3
+ // signed-in chat route instead of launching a native app without a destination.
4
+ // References: core.telegram.org/api/links; Ajaxy/telegram-tt src/util/routing.ts.
5
+ export const telegramChatUrl = ({
6
+ source,
7
+ chat,
8
+ username,
9
+ phone,
10
+ lastId,
11
+ text,
12
+ } = {}) => {
13
+ if (source !== 'telegram' || !/^-?[1-9]\d*$/.test(String(chat))) {
14
+ return null;
15
+ }
16
+ const handle = String(username ?? '').replace(/^@/, '');
17
+ const number = String(phone ?? '').replace(/[\s()+-]/g, '');
18
+ const native = /^[a-zA-Z][a-zA-Z0-9_]{3,31}$/.test(handle)
19
+ ? `https://t.me/${handle}`
20
+ : /^[1-9]\d{6,14}$/.test(number)
21
+ ? `https://t.me/+${number}`
22
+ : null;
23
+ if (native) {
24
+ return text ? `${native}?text=${encodeURIComponent(text)}` : native;
25
+ }
26
+ const id = String(chat);
27
+ if (/^-100\d+$/.test(id) && /^[1-9]\d*$/.test(String(lastId))) {
28
+ return `https://t.me/c/${id.slice(4)}/${lastId}`;
29
+ }
30
+ return `https://web.telegram.org/a/#${id}`;
31
+ };
32
+
33
+ export const isTelegramWebUrl = (url) =>
34
+ String(url ?? '').startsWith('https://web.telegram.org/a/#');
@@ -0,0 +1,60 @@
1
+ import { readLink } from './telegram-account-live.js';
2
+
3
+ const refreshForum = async (env, chat, read) => {
4
+ if (!read?.forum) {
5
+ return;
6
+ }
7
+ await env.actions?.forumTopics?.({ chat: String(chat), fresh: true });
8
+ };
9
+
10
+ /** Refresh one peer's counters without sending any read acknowledgement. */
11
+ export const createDialogState =
12
+ (env, userChat) =>
13
+ async ({ chat } = {}) => {
14
+ if (!/^-?\d+$/.test(String(chat ?? ''))) {
15
+ return { ok: false, code: 'bad-request' };
16
+ }
17
+ const target = await userChat(chat);
18
+ if (!target) {
19
+ return { ok: false, supported: false };
20
+ }
21
+ const { client, Api } = target;
22
+ const id = `read:telegram:${chat}`;
23
+ const before = await env.store.get(id);
24
+ const result = await env.queue.run(`dialog-state:${chat}`, 'view', () =>
25
+ env.connection.withPeer(client, String(chat), (peer) =>
26
+ client.invoke(
27
+ new Api.messages.GetPeerDialogs({
28
+ peers: [new Api.InputDialogPeer({ peer })],
29
+ })
30
+ )
31
+ )
32
+ );
33
+ const dialog = result?.dialogs?.[0];
34
+ if (!dialog || !Number.isFinite(dialog.unreadCount)) {
35
+ return { ok: false, code: 'dialog-unavailable' };
36
+ }
37
+ await refreshForum(env, chat, before);
38
+ const latest = await env.store.get(id);
39
+ // An update that arrived while Telegram answered is newer than this request.
40
+ if (JSON.stringify(latest) !== JSON.stringify(before)) {
41
+ return { ok: true, read: latest, changed: true };
42
+ }
43
+ const read = {
44
+ ...before,
45
+ ...readLink(String(chat), {
46
+ inboxMaxId: dialog.readInboxMaxId,
47
+ outboxMaxId: dialog.readOutboxMaxId,
48
+ unreadCount: dialog.unreadCount,
49
+ unreadTopId: dialog.topMessage,
50
+ unreadMark: Boolean(dialog.unreadMark),
51
+ unreadMentions: dialog.unreadMentionsCount,
52
+ unreadReactions: dialog.unreadReactionsCount,
53
+ muteUntil: Number(dialog.notifySettings?.muteUntil ?? 0),
54
+ folder: Number(dialog.folderId ?? 0),
55
+ }),
56
+ checkedAt: new Date(env.now()).toISOString(),
57
+ };
58
+ await env.store.put(read);
59
+ return { ok: true, read };
60
+ };
@@ -73,6 +73,44 @@ const lastOf = (link) =>
73
73
  }
74
74
  : null;
75
75
 
76
+ // Forum dialogs can retain a nonzero aggregate after every topic was read.
77
+ // Only a complete topic list can replace that aggregate with an exact count.
78
+ const saveTopicUnread = async (store, chat, topics, count, baseline) => {
79
+ if (!Number.isFinite(count) || count < 0 || count > topics.length) {
80
+ return false;
81
+ }
82
+ const before = await store.get(`read:telegram:${chat}`);
83
+ if (
84
+ ['inboxMaxId', 'unreadTopId', 'unreadCount'].some(
85
+ (key) => before?.[key] !== baseline?.[key]
86
+ )
87
+ ) {
88
+ return false;
89
+ }
90
+ const unreadCount = topics.reduce(
91
+ (sum, topic) => sum + Math.max(0, topic.unread),
92
+ 0
93
+ );
94
+ const top = Math.max(
95
+ Number(before?.unreadTopId ?? 0),
96
+ ...topics.map((topic) => topic.top),
97
+ 0
98
+ );
99
+ await store.put({
100
+ ...before,
101
+ id: `read:telegram:${chat}`,
102
+ tokens: ['read', 'telegram', chat],
103
+ source: 'telegram',
104
+ chat,
105
+ forum: 1,
106
+ forumUnreadCount: unreadCount,
107
+ forumUnreadTopId: top,
108
+ forumDialogUnreadCount: Number(before?.unreadCount ?? 0),
109
+ forumReadInboxMaxId: Number(before?.inboxMaxId ?? 0),
110
+ });
111
+ return true;
112
+ };
113
+
76
114
  /**
77
115
  * `env` is the account's; `userClient()` the user client or null;
78
116
  * `dialogOf(chat)` the chat as listed; `tools` its `toLinks` and
@@ -92,6 +130,7 @@ export const createForum = (
92
130
  return { ok: false, supported: false };
93
131
  }
94
132
  const { Api } = await env.importer();
133
+ const baseline = await env.store.get(`read:telegram:${target}`);
95
134
  let answer;
96
135
  try {
97
136
  answer = await env.queue.run(`forum-topics:${target}`, 'view', () =>
@@ -126,7 +165,15 @@ export const createForum = (
126
165
  .map((t) => topicOf(t, lastOf(byId.get(Number(t?.topMessage)))))
127
166
  .filter(Boolean)
128
167
  );
129
- const result = { ok: true, forum: true, topics };
168
+ const count = Number(answer?.count);
169
+ const complete = await saveTopicUnread(
170
+ env.store,
171
+ target,
172
+ topics,
173
+ count,
174
+ baseline
175
+ );
176
+ const result = { ok: true, forum: true, topics, count, complete };
130
177
  kept.set(target, { at: now(), answer: result });
131
178
  return result;
132
179
  };
@@ -2,6 +2,7 @@
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { promises as fs } from 'node:fs';
5
+ import { renameDurable } from './durable.js';
5
6
  import { acquireStoreLock } from './store-lock.js';
6
7
 
7
8
  export const defaultStoreDir = ({
@@ -55,9 +56,9 @@ export const resolveUserStore = async ({
55
56
  await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
56
57
  if (await exists(target)) {
57
58
  // Preserve even an unused desktop folder; never delete user files.
58
- await fs.rename(target, `${target}.before-migration-${Date.now()}`);
59
+ await renameDurable(target, `${target}.before-migration-${Date.now()}`);
59
60
  }
60
- await fs.rename(legacy, target);
61
+ await renameDurable(legacy, target);
61
62
  try {
62
63
  await fs.symlink(
63
64
  target,
@@ -66,7 +67,7 @@ export const resolveUserStore = async ({
66
67
  );
67
68
  } catch (error) {
68
69
  // Restore the old location when a compatibility link cannot be made.
69
- await fs.rename(target, legacy);
70
+ await renameDurable(target, legacy);
70
71
  throw error;
71
72
  }
72
73
  await fs.chmod(target, 0o700);
@@ -94,6 +94,12 @@ export const createDualStore = ({ binary, text }) => ({
94
94
  return { put: stale.length, deleted: extra.length };
95
95
  },
96
96
  async verify() {
97
+ // A live mirror delegates both reads to this very same text store, as
98
+ // reconcile() recognises above. There cannot be a logical difference;
99
+ // serialising both copies blocks large stores for seconds for no benefit.
100
+ if (binary.mirrors === text) {
101
+ return [];
102
+ }
97
103
  const a = await binary.query();
98
104
  const b = await text.query();
99
105
  const aMap = new Map(a.map((l) => [l.id, l]));
@@ -61,6 +61,7 @@ export const wrapSecretStore = ({ inner, passphrase }) => {
61
61
  rawStore: inner,
62
62
  logFile: inner.logFile,
63
63
  flushLog: inner.flushLog,
64
+ size: inner.size ? () => inner.size() : undefined,
64
65
  async put(link) {
65
66
  if (isSecret(link)) {
66
67
  const sealed = sealLink(link, passphrase);
@@ -83,7 +84,11 @@ export const wrapSecretStore = ({ inner, passphrase }) => {
83
84
  return inner.delete(id);
84
85
  },
85
86
  async query(filter) {
86
- const all = await inner.query();
87
+ // Apply non-secret filters before cloning the archive. A typing or health
88
+ // poll must not allocate a copy of every stored message.
89
+ const all = await inner.query(
90
+ filter ? (link) => isSecret(link) || filter(link) : undefined
91
+ );
87
92
  const opened = all.map((l) =>
88
93
  l.id.startsWith(SECRET_PREFIX) && l.enc ? openLink(l, passphrase) : l
89
94
  );