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/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
- groups: {
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.askRequired("Please enter your password: "),
145
- phoneCode: async () => await prompt.askRequired("Please enter the code you received: "),
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
- if (flags.has("--hello") || flags.has("--auth")) {
262
- await runTelegramUserbotCliFlags(config, {
263
- hello: flags.has("--hello"),
264
- auth: flags.has("--auth"),
265
- });
266
- return 0;
267
- }
268
- console.log("Specify one flag: --hello or --auth");
269
- return 1;
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;
@@ -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
- function toStringId(value) {
20
- if (value === null || value === undefined)
21
- return undefined;
22
- try {
23
- return String(value);
24
- }
25
- catch {
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
- return {
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
- const result = await this.client.invoke(new telegram_1.Api.channels.GetForumTopics({
575
- channel: resolved.peer,
576
- ...(args.query ? { q: args.query } : {}),
577
- offsetDate: 0,
578
- offsetId: 0,
579
- offsetTopic: 0,
580
- limit: args.limit,
581
- }));
582
- const raw = Array.isArray(result?.topics) ? result.topics : [];
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
- truncated: raw.length >= args.limit,
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
- const stored = groupReplyAddresses.get(key);
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);