clawgram 2.21.1 → 2.22.0
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 +34 -8
- package/dist/channel.js +289 -57
- package/dist/chat-info.js +28 -22
- package/dist/cli-core.js +109 -20
- package/dist/expiring-map.js +96 -0
- package/dist/gramjs-client.js +104 -29
- package/dist/group-reply-address.js +6 -16
- package/dist/group-visible-reply-guard.js +12 -32
- package/dist/helpers.js +50 -5
- package/dist/history.js +54 -22
- package/dist/html-render.js +44 -1
- package/dist/joins.js +8 -6
- package/dist/manage.js +9 -15
- package/dist/media.js +15 -16
- package/dist/normalize.js +6 -1
- package/dist/proxy-config.js +2 -4
- package/dist/reactions.js +3 -12
- package/dist/secret-refs.js +1 -0
- package/dist/send-scope.js +97 -0
- package/dist/silent-reaction.js +9 -5
- package/dist/state-dir.js +24 -0
- package/dist/system-notice.js +49 -4
- package/dist/update-config.js +139 -33
- package/dist/util.js +36 -0
- package/npm-shrinkwrap.json +4531 -0
- package/openclaw.plugin.json +39 -5
- package/package.json +12 -4
package/dist/chat-info.js
CHANGED
|
@@ -16,8 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.parseChatInfoParams = parseChatInfoParams;
|
|
19
|
+
exports.markedChatId = markedChatId;
|
|
19
20
|
exports.describeChat = describeChat;
|
|
20
21
|
const helpers_1 = require("./helpers");
|
|
22
|
+
const normalize_1 = require("./normalize");
|
|
23
|
+
const util_1 = require("./util");
|
|
21
24
|
function parseChatInfoParams(params, toolContext) {
|
|
22
25
|
const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
|
|
23
26
|
const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
|
|
@@ -30,23 +33,6 @@ function parseChatInfoParams(params, toolContext) {
|
|
|
30
33
|
* GramJS carries ids and counts as `big-integer` objects as often as native
|
|
31
34
|
* numbers — the shape that once made `senderId` come back silently undefined.
|
|
32
35
|
*/
|
|
33
|
-
function readNumber(value) {
|
|
34
|
-
if (typeof value === "number") {
|
|
35
|
-
return Number.isFinite(value) ? value : undefined;
|
|
36
|
-
}
|
|
37
|
-
if (value === undefined || value === null) {
|
|
38
|
-
return undefined;
|
|
39
|
-
}
|
|
40
|
-
const parsed = Number(String(value));
|
|
41
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
42
|
-
}
|
|
43
|
-
function readString(value) {
|
|
44
|
-
if (typeof value !== "string") {
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
const trimmed = value.trim();
|
|
48
|
-
return trimmed === "" ? undefined : trimmed;
|
|
49
|
-
}
|
|
50
36
|
function readId(value) {
|
|
51
37
|
if (value === undefined || value === null) {
|
|
52
38
|
return undefined;
|
|
@@ -68,21 +54,41 @@ function resolveType(entity) {
|
|
|
68
54
|
}
|
|
69
55
|
/** A user has no title, so the displayed name is assembled from what exists. */
|
|
70
56
|
function resolveUserTitle(entity) {
|
|
71
|
-
const parts = [readString(entity?.firstName), readString(entity?.lastName)].filter(Boolean);
|
|
57
|
+
const parts = [(0, util_1.readString)(entity?.firstName), (0, util_1.readString)(entity?.lastName)].filter(Boolean);
|
|
72
58
|
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
73
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Id в той же форме, в какой его понимают конфиг и все остальные ответы.
|
|
62
|
+
*
|
|
63
|
+
* GramJS хранит `Channel.id` голым и положительным, а входящие, `dialogs`,
|
|
64
|
+
* `read`, `participants` и `topics` возвращают помеченный вид (`-100…`).
|
|
65
|
+
* `channel-info` отдавал голый, и агент на вопрос «в каком я чате» получал id,
|
|
66
|
+
* который не совпадает ни с ключами `groups`/`readChats`/`manageChats`, ни с
|
|
67
|
+
* тем, что надо передать в `send`. Подставленный обратно, он резолвится только
|
|
68
|
+
* через обход двухсот последних диалогов — а `getInputEntity("1234567890")`
|
|
69
|
+
* сначала пробуется как id ПОЛЬЗОВАТЕЛЯ (находка A6-09).
|
|
70
|
+
*/
|
|
71
|
+
function markedChatId(entity, raw) {
|
|
72
|
+
if (!raw)
|
|
73
|
+
return raw;
|
|
74
|
+
switch (entity?.className) {
|
|
75
|
+
case "Channel": return (0, normalize_1.toPeerChannelId)(raw);
|
|
76
|
+
case "Chat": return (0, normalize_1.toPeerChatId)(raw);
|
|
77
|
+
default: return raw;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
74
80
|
function describeChat(entity, full) {
|
|
75
81
|
const raw = entity;
|
|
76
82
|
const fullChat = full;
|
|
77
83
|
const type = resolveType(raw);
|
|
78
84
|
const info = {
|
|
79
|
-
chatId: readId(raw?.id),
|
|
85
|
+
chatId: markedChatId(raw, readId(raw?.id)),
|
|
80
86
|
type,
|
|
81
|
-
title: type === "direct" ? resolveUserTitle(raw) : readString(raw?.title),
|
|
87
|
+
title: type === "direct" ? resolveUserTitle(raw) : (0, util_1.readString)(raw?.title),
|
|
82
88
|
// Not `raw.username`: an account or chat holding more than one handle keeps
|
|
83
89
|
// them in `usernames[]` and leaves the legacy field empty.
|
|
84
90
|
username: (0, helpers_1.resolveActiveUsername)(raw),
|
|
85
|
-
about: readString(fullChat?.about),
|
|
91
|
+
about: (0, util_1.readString)(fullChat?.about),
|
|
86
92
|
pinnedMessageId: readId(fullChat?.pinnedMsgId),
|
|
87
93
|
};
|
|
88
94
|
if (type === "direct") {
|
|
@@ -95,6 +101,6 @@ function describeChat(entity, full) {
|
|
|
95
101
|
}
|
|
96
102
|
// The full object is fetched now; the entity may come from a cache that
|
|
97
103
|
// predates the last few joins, so prefer the fresher number.
|
|
98
|
-
info.memberCount = readNumber(fullChat?.participantsCount) ?? readNumber(raw?.participantsCount);
|
|
104
|
+
info.memberCount = (0, util_1.readNumber)(fullChat?.participantsCount) ?? (0, util_1.readNumber)(raw?.participantsCount);
|
|
99
105
|
return info;
|
|
100
106
|
}
|
package/dist/cli-core.js
CHANGED
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.buildAccountConfigFragment = buildAccountConfigFragment;
|
|
6
7
|
exports.runTelegramUserbotCliFlags = runTelegramUserbotCliFlags;
|
|
7
8
|
exports.runTelegramUserbotStandaloneCli = runTelegramUserbotStandaloneCli;
|
|
8
9
|
const node_readline_1 = __importDefault(require("node:readline"));
|
|
@@ -34,8 +35,49 @@ function createPrompt() {
|
|
|
34
35
|
const ask = (question) => new Promise((resolve) => {
|
|
35
36
|
rl.question(question, resolve);
|
|
36
37
|
});
|
|
38
|
+
/**
|
|
39
|
+
* Ввод без эха: код входа и пароль 2FA печатались в терминал как есть.
|
|
40
|
+
*
|
|
41
|
+
* Оба — учётные данные аккаунта: они остаются в прокрутке, в записи сессии
|
|
42
|
+
* терминала и в снимке экрана, который человек делает, чтобы прислать
|
|
43
|
+
* ошибку. Ниоткуда, кроме глаз рядом стоящего, они не защищены — и именно
|
|
44
|
+
* поэтому пароли нигде не эхоятся (находка A5-15).
|
|
45
|
+
*
|
|
46
|
+
* `_writeToOutput` — единственная точка, через которую readline печатает
|
|
47
|
+
* ввод; подменяем её на время вопроса и возвращаем обратно, чтобы
|
|
48
|
+
* последующие обычные вопросы снова были видны.
|
|
49
|
+
*/
|
|
50
|
+
const askSecret = (question) => new Promise((resolve) => {
|
|
51
|
+
const anyRl = rl;
|
|
52
|
+
const original = anyRl._writeToOutput?.bind(rl);
|
|
53
|
+
let armed = false;
|
|
54
|
+
anyRl._writeToOutput = (text) => {
|
|
55
|
+
if (!armed) {
|
|
56
|
+
// Сам вопрос печатается — молчащая подсказка означала бы «висит».
|
|
57
|
+
original?.(text);
|
|
58
|
+
armed = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (text.includes("\n")) {
|
|
62
|
+
original?.("\n");
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
rl.question(question, (answer) => {
|
|
66
|
+
anyRl._writeToOutput = original;
|
|
67
|
+
resolve(answer);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
37
70
|
return {
|
|
38
71
|
ask,
|
|
72
|
+
async askSecret(question) {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const answer = (await askSecret(question)).trim();
|
|
75
|
+
if (answer) {
|
|
76
|
+
return answer;
|
|
77
|
+
}
|
|
78
|
+
console.log("Value is required.");
|
|
79
|
+
}
|
|
80
|
+
},
|
|
39
81
|
async askRequired(question) {
|
|
40
82
|
for (;;) {
|
|
41
83
|
const answer = (await ask(question)).trim();
|
|
@@ -88,20 +130,29 @@ function resolveDefaultAccountId(config) {
|
|
|
88
130
|
const firstAccountId = Object.keys(accounts).find((accountId) => accountId.trim());
|
|
89
131
|
return firstAccountId?.trim() || "default";
|
|
90
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Первый конфиг закрыт, а не открыт.
|
|
135
|
+
*
|
|
136
|
+
* Раньше сюда садились `allowFrom: ["*"]` и групповая запись `"*"`, а
|
|
137
|
+
* `readChats` не задавался вовсе — то есть сразу после `--auth` написать
|
|
138
|
+
* агенту в личку мог кто угодно, добавить его в группу и позвать по @ — тоже,
|
|
139
|
+
* а `read` доставал историю любого чата, в котором состоит личный аккаунт.
|
|
140
|
+
* Первый запуск не должен быть дырой, которую оператор потом закрывает
|
|
141
|
+
* (находка A5-09).
|
|
142
|
+
*
|
|
143
|
+
* `allowFrom` — сам авторизовавшийся. Если его id узнать не удалось, список
|
|
144
|
+
* пуст: закрыто для всех, и об этом печатается строка. Групповой записи нет:
|
|
145
|
+
* группы добавляются осознанно. `readChats: []` — пустой массив, а не
|
|
146
|
+
* отсутствие ключа: у него это разные вещи, и нужен именно запрет.
|
|
147
|
+
*/
|
|
91
148
|
function buildAccountConfigFragment(auth) {
|
|
92
149
|
return {
|
|
93
150
|
enabled: true,
|
|
94
151
|
apiId: auth.apiId,
|
|
95
152
|
apiHash: auth.apiHash,
|
|
96
153
|
sessionString: auth.sessionString,
|
|
97
|
-
allowFrom: [
|
|
98
|
-
|
|
99
|
-
"*": {
|
|
100
|
-
enabled: true,
|
|
101
|
-
groupPolicy: "mention",
|
|
102
|
-
allowFrom: ["*"],
|
|
103
|
-
},
|
|
104
|
-
},
|
|
154
|
+
allowFrom: auth.selfId ? [auth.selfId] : [],
|
|
155
|
+
readChats: [],
|
|
105
156
|
};
|
|
106
157
|
}
|
|
107
158
|
function buildConfigFragment(accountId, auth) {
|
|
@@ -141,16 +192,30 @@ async function runTelegramAuthorization(prompt, proxy) {
|
|
|
141
192
|
try {
|
|
142
193
|
await client.start({
|
|
143
194
|
phoneNumber: async () => await prompt.askRequired("Please enter your number: "),
|
|
144
|
-
password: async () => await prompt.
|
|
145
|
-
phoneCode: async () => await prompt.
|
|
195
|
+
password: async () => await prompt.askSecret("Please enter your password: "),
|
|
196
|
+
phoneCode: async () => await prompt.askSecret("Please enter the code you received: "),
|
|
146
197
|
onError: (error) => {
|
|
147
198
|
console.log(error);
|
|
148
199
|
},
|
|
149
200
|
});
|
|
201
|
+
// Кто именно авторизовался — чтобы первый конфиг открывал доступ ему,
|
|
202
|
+
// а не всем. Сбой здесь не повод падать: без id набор будет закрытым,
|
|
203
|
+
// и оператору об этом скажут.
|
|
204
|
+
let selfId;
|
|
205
|
+
try {
|
|
206
|
+
const me = await client.getMe();
|
|
207
|
+
const id = me?.id;
|
|
208
|
+
if (id !== undefined && id !== null)
|
|
209
|
+
selfId = String(id);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
selfId = undefined;
|
|
213
|
+
}
|
|
150
214
|
return {
|
|
151
215
|
apiId,
|
|
152
216
|
apiHash,
|
|
153
217
|
sessionString: String(client.session.save()),
|
|
218
|
+
selfId,
|
|
154
219
|
};
|
|
155
220
|
}
|
|
156
221
|
finally {
|
|
@@ -196,10 +261,33 @@ async function runTelegramUserbotAuth(config) {
|
|
|
196
261
|
}
|
|
197
262
|
try {
|
|
198
263
|
const backupPath = await (0, update_config_1.createConfigBackup)(snapshot.path);
|
|
199
|
-
await (0, update_config_1.updateConfigFileDirectly)(snapshot.path, accountId, auth);
|
|
264
|
+
const keptRefs = await (0, update_config_1.updateConfigFileDirectly)(snapshot.path, accountId, auth);
|
|
200
265
|
console.log("");
|
|
201
266
|
console.log(`OpenClaw config updated: ${snapshot.path}`);
|
|
202
267
|
console.log(`Configured account id: ${accountId}`);
|
|
268
|
+
// Кто теперь может к нему обратиться — одной строкой, сразу.
|
|
269
|
+
// «Настроено» и «настроено так, как думает оператор» — разные вещи,
|
|
270
|
+
// и вторая проверяется только если её показать.
|
|
271
|
+
console.log("");
|
|
272
|
+
if (auth.selfId) {
|
|
273
|
+
console.log(`Who can talk to it: only telegram id ${auth.selfId} (you). Groups: none yet.`);
|
|
274
|
+
console.log("Reading chat history: denied everywhere (readChats: []).");
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
console.log("Who can talk to it: nobody — your own id could not be read, so allowFrom is empty.");
|
|
278
|
+
console.log("Add your telegram id to allowFrom before the first start, or it will answer no one.");
|
|
279
|
+
}
|
|
280
|
+
console.log("Widen either list deliberately; the first config is closed on purpose.");
|
|
281
|
+
if (keptRefs.length > 0) {
|
|
282
|
+
// Не записали — значит обязаны сказать. Иначе оператор уйдёт в
|
|
283
|
+
// уверенности, что новые значения в конфиге, и узнает обратное при
|
|
284
|
+
// следующем старте.
|
|
285
|
+
console.log("");
|
|
286
|
+
console.log(`Kept in the secret store, NOT overwritten: ${keptRefs.join(", ")}`);
|
|
287
|
+
console.log("The freshly issued values are not in the config. Put them into the");
|
|
288
|
+
console.log("secret store the references point at, or the account will start with");
|
|
289
|
+
console.log("the previous credentials.");
|
|
290
|
+
}
|
|
203
291
|
if (backupPath) {
|
|
204
292
|
console.log(`Config backup created: ${backupPath}`);
|
|
205
293
|
}
|
|
@@ -258,13 +346,14 @@ async function runTelegramUserbotStandaloneCli(argv, config) {
|
|
|
258
346
|
console.log("Usage: clawgram-cli <--hello|--auth>");
|
|
259
347
|
return 1;
|
|
260
348
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
349
|
+
// Здесь argv непуст (проверено выше) и не содержит ничего, кроме двух
|
|
350
|
+
// известных флагов, — значит хотя бы один из них есть. Прежде тут стоял
|
|
351
|
+
// ещё один `if` и за ним «Specify one flag», недостижимые по построению:
|
|
352
|
+
// код, который нельзя выполнить, читается как запасной путь и заставляет
|
|
353
|
+
// держать в голове случай, которого не бывает (находка A6-21).
|
|
354
|
+
await runTelegramUserbotCliFlags(config, {
|
|
355
|
+
hello: flags.has("--hello"),
|
|
356
|
+
auth: flags.has("--auth"),
|
|
357
|
+
});
|
|
358
|
+
return 0;
|
|
270
359
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ExpiringMap = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A Map whose entries stop existing on their own.
|
|
6
|
+
*
|
|
7
|
+
* Three module-level maps kept per-message state — the address a group reply
|
|
8
|
+
* greets, when a visible reply went out, when the turn last spoke for itself
|
|
9
|
+
* — and only one of them ever swept anything. The other two grew by one entry
|
|
10
|
+
* per dispatched group message and per agent send, and nothing removed an
|
|
11
|
+
* entry that was never read back: an unanswered mention, a send whose echo
|
|
12
|
+
* never came. The gateway is restarted rarely by design, so the leak is slow
|
|
13
|
+
* and permanent rather than dramatic (finding A6-16).
|
|
14
|
+
*
|
|
15
|
+
* Two bounds rather than one, because they fail differently:
|
|
16
|
+
*
|
|
17
|
+
* - **Time.** An entry is gone once its TTL passes, whether or not anyone
|
|
18
|
+
* asks for it. Sweeping happens on write, so a quiet process does no work
|
|
19
|
+
* and a busy one pays a little on each message.
|
|
20
|
+
* - **Count.** A cap covers the case time cannot: many distinct keys inside
|
|
21
|
+
* one TTL window. When it is hit the entry expiring soonest goes first —
|
|
22
|
+
* it was closest to worthless anyway.
|
|
23
|
+
*
|
|
24
|
+
* `now` is a parameter everywhere so tests can move time without sleeping,
|
|
25
|
+
* the way the callers already did.
|
|
26
|
+
*/
|
|
27
|
+
class ExpiringMap {
|
|
28
|
+
ttlMs;
|
|
29
|
+
maxEntries;
|
|
30
|
+
entries = new Map();
|
|
31
|
+
constructor(ttlMs, maxEntries = 1000) {
|
|
32
|
+
this.ttlMs = ttlMs;
|
|
33
|
+
this.maxEntries = maxEntries;
|
|
34
|
+
}
|
|
35
|
+
set(key, value, now = Date.now()) {
|
|
36
|
+
this.prune(now);
|
|
37
|
+
this.entries.set(key, { value, expiresAt: now + this.ttlMs });
|
|
38
|
+
this.evictOverflow();
|
|
39
|
+
}
|
|
40
|
+
/** The value while it is still alive; an expired entry is dropped, not returned. */
|
|
41
|
+
get(key, now = Date.now()) {
|
|
42
|
+
const stored = this.entries.get(key);
|
|
43
|
+
if (!stored) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
if (stored.expiresAt <= now) {
|
|
47
|
+
this.entries.delete(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return stored.value;
|
|
51
|
+
}
|
|
52
|
+
/** Reads and removes in one step — for state a single consumer owns. */
|
|
53
|
+
take(key, now = Date.now()) {
|
|
54
|
+
const value = this.get(key, now);
|
|
55
|
+
// The delete happens even when the entry was already expired: `get` has
|
|
56
|
+
// dropped it in that case, and deleting a missing key is a no-op.
|
|
57
|
+
this.entries.delete(key);
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
delete(key) {
|
|
61
|
+
this.entries.delete(key);
|
|
62
|
+
}
|
|
63
|
+
/** Test seam: module state must not leak between suites. */
|
|
64
|
+
clear() {
|
|
65
|
+
this.entries.clear();
|
|
66
|
+
}
|
|
67
|
+
get size() {
|
|
68
|
+
return this.entries.size;
|
|
69
|
+
}
|
|
70
|
+
prune(now = Date.now()) {
|
|
71
|
+
for (const [key, stored] of this.entries.entries()) {
|
|
72
|
+
if (stored.expiresAt <= now) {
|
|
73
|
+
this.entries.delete(key);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
evictOverflow() {
|
|
78
|
+
if (this.entries.size <= this.maxEntries) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Insertion order is not expiry order once a key is rewritten, so the
|
|
82
|
+
// victim is chosen by expiry rather than by being first in the map.
|
|
83
|
+
let oldestKey;
|
|
84
|
+
let oldestExpiry = Infinity;
|
|
85
|
+
for (const [key, stored] of this.entries.entries()) {
|
|
86
|
+
if (stored.expiresAt < oldestExpiry) {
|
|
87
|
+
oldestExpiry = stored.expiresAt;
|
|
88
|
+
oldestKey = key;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (oldestKey !== undefined) {
|
|
92
|
+
this.entries.delete(oldestKey);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.ExpiringMap = ExpiringMap;
|
package/dist/gramjs-client.js
CHANGED
|
@@ -16,16 +16,13 @@ const history_1 = require("./history");
|
|
|
16
16
|
const topics_1 = require("./topics");
|
|
17
17
|
const dialogs_1 = require("./dialogs");
|
|
18
18
|
const manage_1 = require("./manage");
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return undefined;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
19
|
+
const core_1 = require("openclaw/plugin-sdk/core");
|
|
20
|
+
const expiring_map_1 = require("./expiring-map");
|
|
21
|
+
const normalize_1 = require("./normalize");
|
|
22
|
+
/** Ten minutes: long enough to spare the repeat lookups of one turn,
|
|
23
|
+
* short enough that a replaced session or a vanished peer is re-resolved. */
|
|
24
|
+
const PEER_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
25
|
+
const peerLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
|
|
29
26
|
function inferChatTypeFromRaw(raw) {
|
|
30
27
|
if (raw.startsWith("-100"))
|
|
31
28
|
return "channel";
|
|
@@ -34,13 +31,13 @@ function inferChatTypeFromRaw(raw) {
|
|
|
34
31
|
return "direct";
|
|
35
32
|
}
|
|
36
33
|
function getChatIdFromPeer(peer, fallback) {
|
|
37
|
-
const userId = toStringId(peer?.userId);
|
|
34
|
+
const userId = (0, normalize_1.toStringId)(peer?.userId);
|
|
38
35
|
if (userId)
|
|
39
36
|
return userId;
|
|
40
|
-
const chatId = toStringId(peer?.chatId);
|
|
37
|
+
const chatId = (0, normalize_1.toStringId)(peer?.chatId);
|
|
41
38
|
if (chatId)
|
|
42
39
|
return `-${chatId.replace(/^-/, "")}`;
|
|
43
|
-
const channelId = toStringId(peer?.channelId);
|
|
40
|
+
const channelId = (0, normalize_1.toStringId)(peer?.channelId);
|
|
44
41
|
if (channelId)
|
|
45
42
|
return `-100${channelId.replace(/^-100|-/, "")}`;
|
|
46
43
|
return fallback;
|
|
@@ -161,7 +158,7 @@ function buildPeerCandidates(raw, kind) {
|
|
|
161
158
|
function collectDialogKeys(dialog) {
|
|
162
159
|
const keys = new Set();
|
|
163
160
|
const add = (value) => {
|
|
164
|
-
const id = toStringId(value);
|
|
161
|
+
const id = (0, normalize_1.toStringId)(value);
|
|
165
162
|
if (id) {
|
|
166
163
|
keys.add(id);
|
|
167
164
|
}
|
|
@@ -171,12 +168,12 @@ function collectDialogKeys(dialog) {
|
|
|
171
168
|
add(dialog?.entity?.id);
|
|
172
169
|
add(getChatIdFromPeer(dialog?.inputEntity));
|
|
173
170
|
add(getChatIdFromPeer(dialog?.entity));
|
|
174
|
-
const inputChatId = toStringId(dialog?.inputEntity?.chatId);
|
|
171
|
+
const inputChatId = (0, normalize_1.toStringId)(dialog?.inputEntity?.chatId);
|
|
175
172
|
if (inputChatId) {
|
|
176
173
|
keys.add(inputChatId);
|
|
177
174
|
keys.add(`-${inputChatId.replace(/^-/, "")}`);
|
|
178
175
|
}
|
|
179
|
-
const inputChannelId = toStringId(dialog?.inputEntity?.channelId);
|
|
176
|
+
const inputChannelId = (0, normalize_1.toStringId)(dialog?.inputEntity?.channelId);
|
|
180
177
|
if (inputChannelId) {
|
|
181
178
|
keys.add(inputChannelId);
|
|
182
179
|
keys.add(`-100${inputChannelId.replace(/^-100|-/, "")}`);
|
|
@@ -186,7 +183,7 @@ function collectDialogKeys(dialog) {
|
|
|
186
183
|
function buildTargetKeys(raw, kind) {
|
|
187
184
|
const keys = new Set();
|
|
188
185
|
const add = (value) => {
|
|
189
|
-
const id = toStringId(value);
|
|
186
|
+
const id = (0, normalize_1.toStringId)(value);
|
|
190
187
|
if (id) {
|
|
191
188
|
keys.add(id);
|
|
192
189
|
}
|
|
@@ -282,11 +279,43 @@ class GramJsClientManager {
|
|
|
282
279
|
getClient() {
|
|
283
280
|
return this.client;
|
|
284
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Peers already resolved by this client.
|
|
284
|
+
*
|
|
285
|
+
* `resolvePeer` is the entry of every call that touches Telegram — send,
|
|
286
|
+
* media, history, participants, topics, reactions, read marks, typing — and
|
|
287
|
+
* a target the session has not seen fell through to a scan of the 200 most
|
|
288
|
+
* recent dialogs. A DM to an id by number, the standard "write to this
|
|
289
|
+
* person" flow, paid that scan on the send and again on the read mark and
|
|
290
|
+
* the typing indicator, over a SOCKS proxy (finding A6-14).
|
|
291
|
+
*
|
|
292
|
+
* The TTL is short on purpose: a peer that stops existing, or a session
|
|
293
|
+
* replaced under the account, should not be remembered for the life of a
|
|
294
|
+
* process that is restarted rarely.
|
|
295
|
+
*/
|
|
296
|
+
peerCacheStore;
|
|
297
|
+
/**
|
|
298
|
+
* Ленивая инициализация, а не поле класса: тесты собирают менеджер через
|
|
299
|
+
* `Object.create(GramJsClientManager.prototype)` — конструктор там не
|
|
300
|
+
* выполняется, и поле осталось бы `undefined` у любого такого объекта.
|
|
301
|
+
*/
|
|
302
|
+
get peerCache() {
|
|
303
|
+
this.peerCacheStore ??= new expiring_map_1.ExpiringMap(PEER_CACHE_TTL_MS, 500);
|
|
304
|
+
return this.peerCacheStore;
|
|
305
|
+
}
|
|
285
306
|
async getMe() {
|
|
286
307
|
return this.client.getMe();
|
|
287
308
|
}
|
|
288
309
|
async resolveDialogPeer(raw, kind) {
|
|
289
310
|
const targetKeys = buildTargetKeys(raw, kind);
|
|
311
|
+
// Самый дорогой путь резолва: 200 диалогов по сети, через прокси — секунды.
|
|
312
|
+
// Он остаётся (без него `@username` без общей истории не находится вовсе),
|
|
313
|
+
// но теперь виден в логе: если он в логе частый, значит кэш не спасает и
|
|
314
|
+
// адресация идёт не тем ключом (A6-14).
|
|
315
|
+
peerLog.info("clawgram peer resolve falling back to dialog scan", {
|
|
316
|
+
target: raw,
|
|
317
|
+
kind: kind ?? null,
|
|
318
|
+
});
|
|
290
319
|
const dialogs = await this.client.getDialogs({ limit: 200 }).catch(() => []);
|
|
291
320
|
for (const dialog of dialogs) {
|
|
292
321
|
if (kind === "user" && !dialog?.isUser) {
|
|
@@ -300,7 +329,7 @@ class GramJsClientManager {
|
|
|
300
329
|
if (!matched) {
|
|
301
330
|
continue;
|
|
302
331
|
}
|
|
303
|
-
const chatId = getChatIdFromPeer(dialog?.inputEntity) ?? toStringId(dialog?.id) ?? raw;
|
|
332
|
+
const chatId = getChatIdFromPeer(dialog?.inputEntity) ?? (0, normalize_1.toStringId)(dialog?.id) ?? raw;
|
|
304
333
|
const chatType = kind === "group"
|
|
305
334
|
? "group"
|
|
306
335
|
: kind === "channel"
|
|
@@ -340,6 +369,13 @@ class GramJsClientManager {
|
|
|
340
369
|
chatType: "direct"
|
|
341
370
|
};
|
|
342
371
|
}
|
|
372
|
+
// Кэш спрашивается ПОСЛЕ «me»: тот и так не ходит в сеть, а класть его
|
|
373
|
+
// в карту значило бы держать запись, которая никогда не понадобится.
|
|
374
|
+
const cacheKey = `${chatLookupTarget}\u0000${kind ?? ""}`;
|
|
375
|
+
const cached = this.peerCache.get(cacheKey);
|
|
376
|
+
if (cached) {
|
|
377
|
+
return { ...cached, messageThreadId: parsedTarget.messageThreadId };
|
|
378
|
+
}
|
|
343
379
|
let entity;
|
|
344
380
|
for (const candidate of buildPeerCandidates(chatLookupTarget, kind)) {
|
|
345
381
|
entity = await this.client.getInputEntity(candidate).catch(() => undefined);
|
|
@@ -350,6 +386,7 @@ class GramJsClientManager {
|
|
|
350
386
|
if (!entity) {
|
|
351
387
|
const dialogResolved = await this.resolveDialogPeer(chatLookupTarget, kind);
|
|
352
388
|
if (dialogResolved) {
|
|
389
|
+
this.peerCache.set(cacheKey, { ...dialogResolved, messageThreadId: undefined });
|
|
353
390
|
dialogResolved.messageThreadId = parsedTarget.messageThreadId;
|
|
354
391
|
return dialogResolved;
|
|
355
392
|
}
|
|
@@ -358,13 +395,17 @@ class GramJsClientManager {
|
|
|
358
395
|
entity = await this.client.getInputEntity(chatLookupTarget);
|
|
359
396
|
}
|
|
360
397
|
const chatId = getChatIdFromPeer(entity, chatLookupTarget);
|
|
361
|
-
|
|
398
|
+
const resolved = {
|
|
362
399
|
raw,
|
|
363
400
|
peer: entity,
|
|
364
401
|
chatId,
|
|
365
402
|
messageThreadId: parsedTarget.messageThreadId,
|
|
366
403
|
chatType: inferChatTypeFromRaw(chatId ?? raw)
|
|
367
404
|
};
|
|
405
|
+
// В карту кладётся тема-независимая часть: `messageThreadId` приходит
|
|
406
|
+
// из адреса конкретного вызова, а пир у всех тем чата один.
|
|
407
|
+
this.peerCache.set(cacheKey, { ...resolved, messageThreadId: undefined });
|
|
408
|
+
return resolved;
|
|
368
409
|
}
|
|
369
410
|
/**
|
|
370
411
|
* Reply format for this account, validated once at read time.
|
|
@@ -571,19 +612,53 @@ class GramJsClientManager {
|
|
|
571
612
|
*/
|
|
572
613
|
async listTopics(args) {
|
|
573
614
|
const resolved = await this.resolvePeer(args.target, { kind: "channel" });
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
615
|
+
// Страницами, а не одним вызовом. `limit` принимался до 500, а сервер
|
|
616
|
+
// отдаёт страницу и ждёт смещений: всё, что не влезло в первую, просто
|
|
617
|
+
// терялось, и `truncated` при этом говорил «влезло». Соседние
|
|
618
|
+
// перечисления (`listMessages`, `listParticipants`, `listDialogs`) идут
|
|
619
|
+
// через пагинирующие помощники GramJS, а это — нет (находка A6-20).
|
|
620
|
+
const raw = [];
|
|
621
|
+
let offsetDate = 0;
|
|
622
|
+
let offsetId = 0;
|
|
623
|
+
let offsetTopic = 0;
|
|
624
|
+
let exhausted = false;
|
|
625
|
+
while (raw.length < args.limit) {
|
|
626
|
+
const page = await this.client.invoke(new telegram_1.Api.channels.GetForumTopics({
|
|
627
|
+
channel: resolved.peer,
|
|
628
|
+
...(args.query ? { q: args.query } : {}),
|
|
629
|
+
offsetDate,
|
|
630
|
+
offsetId,
|
|
631
|
+
offsetTopic,
|
|
632
|
+
limit: args.limit - raw.length,
|
|
633
|
+
}));
|
|
634
|
+
const pageTopics = Array.isArray(page?.topics) ? page.topics : [];
|
|
635
|
+
if (pageTopics.length === 0) {
|
|
636
|
+
exhausted = true;
|
|
637
|
+
break;
|
|
638
|
+
}
|
|
639
|
+
// Страница может прийти длиннее запрошенного — тогда лишнее режем сами,
|
|
640
|
+
// иначе `limit` вызывающего перестаёт быть пределом.
|
|
641
|
+
raw.push(...pageTopics.slice(0, args.limit - raw.length));
|
|
642
|
+
const last = pageTopics[pageTopics.length - 1];
|
|
643
|
+
const nextTopic = Number(last?.id ?? 0);
|
|
644
|
+
const nextId = Number(last?.topMessage ?? 0);
|
|
645
|
+
const nextDate = Number(last?.date ?? 0);
|
|
646
|
+
// Страница, не сдвинувшая смещение, сдвинет его и в следующий раз —
|
|
647
|
+
// выходим, вместо того чтобы просить одно и то же вечно.
|
|
648
|
+
if (!Number.isFinite(nextTopic) || nextTopic === 0 || nextTopic === offsetTopic) {
|
|
649
|
+
exhausted = true;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
offsetTopic = nextTopic;
|
|
653
|
+
offsetId = Number.isFinite(nextId) ? nextId : 0;
|
|
654
|
+
offsetDate = Number.isFinite(nextDate) ? nextDate : 0;
|
|
655
|
+
}
|
|
583
656
|
return {
|
|
584
657
|
chatId: resolved.chatId,
|
|
585
658
|
topics: (0, topics_1.normalizeForumTopics)(raw, { query: args.query }),
|
|
586
|
-
|
|
659
|
+
// «Обрезано» теперь означает именно это: набрали ровно столько,
|
|
660
|
+
// сколько просили, и форум не сказал, что тем больше нет.
|
|
661
|
+
truncated: !exhausted && raw.length >= args.limit,
|
|
587
662
|
};
|
|
588
663
|
}
|
|
589
664
|
async markRead(target, messageId, options) {
|
|
@@ -5,9 +5,12 @@ exports.consumeGroupReplyAddress = consumeGroupReplyAddress;
|
|
|
5
5
|
exports.peekGroupReplyAddress = peekGroupReplyAddress;
|
|
6
6
|
exports.resetGroupReplyAddresses = resetGroupReplyAddresses;
|
|
7
7
|
exports.buildGroupReplyAddress = buildGroupReplyAddress;
|
|
8
|
+
const expiring_map_1 = require("./expiring-map");
|
|
8
9
|
const helpers_1 = require("./helpers");
|
|
9
|
-
const groupReplyAddresses = new Map();
|
|
10
10
|
const GROUP_REPLY_ADDRESS_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
// Запись жила до тех пор, пока её не прочитают, а читают не всякую:
|
|
12
|
+
// упоминание без ответа оставляло адрес навсегда (A6-16).
|
|
13
|
+
const groupReplyAddresses = new expiring_map_1.ExpiringMap(GROUP_REPLY_ADDRESS_TTL_MS);
|
|
11
14
|
function normalizeGroupReplyTarget(rawTarget) {
|
|
12
15
|
if (typeof rawTarget !== "string") {
|
|
13
16
|
return String(rawTarget ?? "").trim();
|
|
@@ -30,10 +33,7 @@ function rememberGroupReplyAddress(input) {
|
|
|
30
33
|
if (!key) {
|
|
31
34
|
return;
|
|
32
35
|
}
|
|
33
|
-
groupReplyAddresses.set(key,
|
|
34
|
-
address: input.address,
|
|
35
|
-
expiresAt: Date.now() + GROUP_REPLY_ADDRESS_TTL_MS,
|
|
36
|
-
});
|
|
36
|
+
groupReplyAddresses.set(key, input.address);
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* The address remembered for one specific incoming message.
|
|
@@ -51,17 +51,7 @@ function readGroupReplyAddress(input, consume) {
|
|
|
51
51
|
if (!key) {
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
if (!stored) {
|
|
56
|
-
return undefined;
|
|
57
|
-
}
|
|
58
|
-
if (consume) {
|
|
59
|
-
groupReplyAddresses.delete(key);
|
|
60
|
-
}
|
|
61
|
-
if (stored.expiresAt < Date.now()) {
|
|
62
|
-
return undefined;
|
|
63
|
-
}
|
|
64
|
-
return stored.address;
|
|
54
|
+
return consume ? groupReplyAddresses.take(key) : groupReplyAddresses.get(key);
|
|
65
55
|
}
|
|
66
56
|
function consumeGroupReplyAddress(input) {
|
|
67
57
|
return readGroupReplyAddress(input, true);
|