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.
- package/README.md +1 -1
- package/js/src/crm/reengage.js +18 -5
- package/js/src/replies/reply-cache.js +30 -26
- package/js/src/server/live-subset.js +46 -0
- package/js/src/server/local-tls.js +14 -3
- package/js/src/server/routes-automations.js +9 -1
- package/js/src/server/routes-derived.js +36 -20
- package/js/src/server/routes-events.js +1 -1
- package/js/src/server/routes-telegram.js +7 -0
- package/js/src/sources/telegram-account.js +8 -3
- package/js/src/sources/telegram-backfill.js +5 -4
- package/js/src/sources/telegram-business-bots.js +1 -1
- package/js/src/sources/telegram-chat-info.js +6 -1
- package/js/src/sources/telegram-chat-url.js +34 -0
- package/js/src/sources/telegram-dialog-state.js +60 -0
- package/js/src/sources/telegram-forum.js +48 -1
- package/js/src/storage/app-directory.js +4 -3
- package/js/src/storage/dual-store.js +6 -0
- package/js/src/storage/secret-store.js +6 -1
- package/js/src/web/api-errors.js +4 -1
- package/js/src/web/app.css +51 -26
- package/js/src/web/app.min.js +95 -95
- package/js/src/web/chat-automation.js +8 -1
- package/js/src/web/chat-business-bots.js +9 -2
- package/js/src/web/chat-drag.js +26 -2
- package/js/src/web/chat-feed.js +165 -0
- package/js/src/web/chat-follow.js +22 -6
- package/js/src/web/chat-live.js +23 -140
- package/js/src/web/chat-model.js +27 -5
- package/js/src/web/chat-row.js +3 -1
- package/js/src/web/chat-sidebar.js +6 -4
- package/js/src/web/chat-state.js +9 -3
- package/js/src/web/chat-suggest.js +4 -7
- package/js/src/web/chat-view.js +126 -71
- package/js/src/web/client.js +33 -83
- package/js/src/web/connections/index.js +1 -1
- package/js/src/web/discover.js +19 -9
- package/js/src/web/dom.js +14 -5
- package/js/src/web/locales/en-chat.js +15 -11
- package/js/src/web/locales/en.js +17 -0
- package/js/src/web/locales/hi-chat.js +16 -10
- package/js/src/web/locales/hi.js +15 -0
- package/js/src/web/locales/ru-chat.js +16 -10
- package/js/src/web/locales/ru.js +17 -0
- package/js/src/web/locales/zh-chat.js +15 -9
- package/js/src/web/locales/zh.js +15 -0
- package/js/src/web/navigation-counts.js +7 -5
- package/js/src/web/operator-model.js +7 -3
- package/js/src/web/operator-prefs.js +58 -0
- package/js/src/web/operator-unread.js +97 -0
- package/js/src/web/operator-view.js +64 -40
- package/js/src/web/person-panel.js +20 -26
- package/js/src/web/profile-stories.js +5 -2
- package/js/src/web/server-availability.js +71 -0
- package/js/src/web/settings-view.js +38 -0
- package/js/src/web/shell/app-shell.js +5 -1
- package/js/src/web/stories-pref.js +5 -0
- package/js/src/web/stories.js +1 -1
- package/js/src/web/telegram-status.js +36 -0
- package/package.json +1 -1
- 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
|
|
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
|
|
package/js/src/crm/reengage.js
CHANGED
|
@@ -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:
|
|
304
|
-
*
|
|
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 = ({
|
|
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
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
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
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
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 = {
|
|
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 (
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
59
|
+
await renameDurable(target, `${target}.before-migration-${Date.now()}`);
|
|
59
60
|
}
|
|
60
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
);
|