clawgram 2.21.1 → 2.23.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.
@@ -5,10 +5,11 @@ exports.rememberVisibleGroupReply = rememberVisibleGroupReply;
5
5
  exports.rememberTurnSend = rememberTurnSend;
6
6
  exports.hadTurnSendJustNow = hadTurnSendJustNow;
7
7
  exports.resetVisibleGroupReplies = resetVisibleGroupReplies;
8
+ const expiring_map_1 = require("./expiring-map");
8
9
  const helpers_1 = require("./helpers");
9
- /** When each visible reply went out, keyed by account + chat + incoming message. */
10
- const recentVisibleGroupReplies = new Map();
11
10
  const GROUP_VISIBLE_REPLY_TTL_MS = 10 * 60 * 1000;
11
+ /** When each visible reply went out, keyed by account + chat + incoming message. */
12
+ const recentVisibleGroupReplies = new expiring_map_1.ExpiringMap(GROUP_VISIBLE_REPLY_TTL_MS);
12
13
  /**
13
14
  * How long after the agent's own send core's delivery of the same turn's final
14
15
  * text still counts as an echo rather than as something new.
@@ -29,36 +30,19 @@ function buildVisibleGroupReplyKey(input) {
29
30
  }
30
31
  return [input.accountId ?? "", chatId, currentMessageId].join("\n");
31
32
  }
32
- function pruneExpiredEntries(now) {
33
- for (const [key, expiresAt] of recentVisibleGroupReplies.entries()) {
34
- if (expiresAt <= now) {
35
- recentVisibleGroupReplies.delete(key);
36
- }
37
- }
38
- }
39
33
  function hasRecentVisibleGroupReply(input) {
40
- const now = Date.now();
41
- pruneExpiredEntries(now);
42
34
  const key = buildVisibleGroupReplyKey(input);
43
35
  if (!key) {
44
36
  return false;
45
37
  }
46
- const expiresAt = recentVisibleGroupReplies.get(key);
47
- if (!expiresAt) {
48
- return false;
49
- }
50
- if (expiresAt <= now) {
51
- recentVisibleGroupReplies.delete(key);
52
- return false;
53
- }
54
- return true;
38
+ return recentVisibleGroupReplies.get(key) === true;
55
39
  }
56
40
  function rememberVisibleGroupReply(input, sentAt = Date.now()) {
57
41
  const key = buildVisibleGroupReplyKey(input);
58
42
  if (!key) {
59
43
  return;
60
44
  }
61
- recentVisibleGroupReplies.set(key, sentAt + GROUP_VISIBLE_REPLY_TTL_MS);
45
+ recentVisibleGroupReplies.set(key, true, sentAt);
62
46
  }
63
47
  /**
64
48
  * When a turn last spoke for itself, keyed the same way.
@@ -68,14 +52,16 @@ function rememberVisibleGroupReply(input, sentAt = Date.now()) {
68
52
  * quietly make that rule stricter. This one answers a different question —
69
53
  * did core just echo the turn that has only now finished.
70
54
  */
71
- const lastTurnSends = new Map();
55
+ // Та же машинерия, что у соседней карты: запись жила до чтения, а читают её
56
+ // только если ядро прислало эхо этого хода. Хода без эха — большинство (A6-16).
57
+ const lastTurnSends = new expiring_map_1.ExpiringMap(GROUP_TURN_ECHO_WINDOW_MS);
72
58
  /** Records that the agent itself put a message in the chat during this turn. */
73
59
  function rememberTurnSend(input, sentAt = Date.now()) {
74
60
  const key = buildVisibleGroupReplyKey(input);
75
61
  if (!key) {
76
62
  return;
77
63
  }
78
- lastTurnSends.set(key, sentAt);
64
+ lastTurnSends.set(key, true, sentAt);
79
65
  }
80
66
  /**
81
67
  * True when this turn already put a visible message in this chat moments ago.
@@ -94,15 +80,9 @@ function hadTurnSendJustNow(input, now = Date.now()) {
94
80
  if (!key) {
95
81
  return false;
96
82
  }
97
- const sentAt = lastTurnSends.get(key);
98
- if (sentAt === undefined) {
99
- return false;
100
- }
101
- if (now - sentAt > GROUP_TURN_ECHO_WINDOW_MS) {
102
- lastTurnSends.delete(key);
103
- return false;
104
- }
105
- return true;
83
+ // Окно эха и есть TTL записи, поэтому «свежесть» теперь спрашивается
84
+ // у карты: она же и вычистит просроченное.
85
+ return lastTurnSends.get(key, now) === true;
106
86
  }
107
87
  /** Test seam: module state must not leak between suites. */
108
88
  function resetVisibleGroupReplies() {
package/dist/helpers.js CHANGED
@@ -46,6 +46,7 @@ exports.normalizeParseMode = normalizeParseMode;
46
46
  exports.resolveReplyParseMode = resolveReplyParseMode;
47
47
  exports.resolveOutboundParseMode = resolveOutboundParseMode;
48
48
  exports.resolveDryRun = resolveDryRun;
49
+ exports.parseOptionalThreadId = parseOptionalThreadId;
49
50
  const node_fs_1 = require("node:fs");
50
51
  const node_path_1 = __importDefault(require("node:path"));
51
52
  const core_1 = require("openclaw/plugin-sdk/core");
@@ -135,6 +136,37 @@ function resolveTranscriptPathFromStoreEntry(input) {
135
136
  return undefined;
136
137
  }
137
138
  }
139
+ /**
140
+ * Хвост стенограммы, а не вся она.
141
+ *
142
+ * Читался весь файл целиком и синхронно — на каждом ходе, где ядро ничего не
143
+ * доставило. Стенограмма живой сессии растёт неограниченно, а нужна ровно
144
+ * последняя запись ассистента: всё, что дальше пары сотен килобайт назад, по
145
+ * определению не «только что» и проверку свежести всё равно не прошло бы
146
+ * (находка A6-15).
147
+ *
148
+ * Первая строка куска отбрасывается: чтение с произвольного смещения почти
149
+ * наверняка попадает в середину строки, а заодно — в середину UTF-8-символа.
150
+ * Целая строка перед ней нам не нужна, потому что ищем мы с конца.
151
+ */
152
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
153
+ function readTranscriptTail(sessionFile) {
154
+ const fd = (0, node_fs_1.openSync)(sessionFile, "r");
155
+ try {
156
+ const size = (0, node_fs_1.fstatSync)(fd).size;
157
+ if (size <= TRANSCRIPT_TAIL_BYTES) {
158
+ return (0, node_fs_1.readFileSync)(sessionFile, "utf8");
159
+ }
160
+ const buffer = Buffer.allocUnsafe(TRANSCRIPT_TAIL_BYTES);
161
+ const read = (0, node_fs_1.readSync)(fd, buffer, 0, TRANSCRIPT_TAIL_BYTES, size - TRANSCRIPT_TAIL_BYTES);
162
+ const text = buffer.subarray(0, read).toString("utf8");
163
+ const firstBreak = text.indexOf("\n");
164
+ return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
165
+ }
166
+ finally {
167
+ (0, node_fs_1.closeSync)(fd);
168
+ }
169
+ }
138
170
  /**
139
171
  * Salvages a reply that reached the transcript but not stdout.
140
172
  *
@@ -160,7 +192,7 @@ function readLatestAssistantFallbackFromTranscript(sessionKey, storePath, notBef
160
192
  if (!sessionFile) {
161
193
  return undefined;
162
194
  }
163
- const lines = (0, node_fs_1.readFileSync)(sessionFile, "utf8")
195
+ const lines = readTranscriptTail(sessionFile)
164
196
  .split("\n")
165
197
  .map((line) => line.trim())
166
198
  .filter(Boolean);
@@ -268,15 +300,29 @@ function resolveActionTarget(params, toolContext) {
268
300
  }
269
301
  throw new Error("clawgram: message target is required");
270
302
  }
303
+ /**
304
+ * Ответ на сообщение — во всех чатах, а не только в группах.
305
+ *
306
+ * Прежде `replyToId` молча отбрасывался, если тип цели не группа и не канал, а
307
+ * `inferOutboundTargetKind` возвращает `undefined` и для `@username`, и для
308
+ * положительного числового id — то есть для любой лички. Инструмент отвечал
309
+ * `ok: true`, человек получал сообщение вне ветки, и ничего об этом не
310
+ * сообщало. Приходящий путь лички при этом ветку проставлял, так что два пути
311
+ * расходились между собой (находка A6-05).
312
+ *
313
+ * Telegram поддерживает `reply_to` и в приватных чатах, поэтому чинится это
314
+ * не отказом, а тем, что ветка ставится везде. Нечисловой `replyToId` —
315
+ * ошибка вызова, а не повод молча отправить вне ветки.
316
+ */
271
317
  function resolveReplyToMessageIdForTarget(rawTarget, replyToId) {
272
318
  if (replyToId === null || replyToId === undefined || replyToId === "") {
273
319
  return undefined;
274
320
  }
275
- const targetKind = inferOutboundTargetKind(rawTarget);
276
- if (targetKind === "group" || targetKind === "channel") {
277
- return Number(replyToId);
321
+ const id = Number(replyToId);
322
+ if (!Number.isFinite(id) || id <= 0) {
323
+ throw new Error(`clawgram: replyToId must be a message id, got ${JSON.stringify(replyToId)}`);
278
324
  }
279
- return undefined;
325
+ return id;
280
326
  }
281
327
  /**
282
328
  * Разметка синтеза речи, которая не должна доехать до человека.
@@ -838,3 +884,23 @@ function resolveOutboundParseMode(params, cfg, accountId) {
838
884
  ? resolveReplyParseMode(cfg, accountId)
839
885
  : normalizeParseMode(raw);
840
886
  }
887
+ /**
888
+ * `messageThreadId` из параметров: число, строка из цифр — или ничего.
889
+ *
890
+ * Живёт здесь, а не в channel.ts: им пользуется и диспетчер, и исходящий
891
+ * контур, а импорт из channel.ts в outbound.ts замкнул бы круг.
892
+ */
893
+ function parseOptionalThreadId(value) {
894
+ if (typeof value === "number") {
895
+ return Number.isFinite(value) ? Math.trunc(value) : undefined;
896
+ }
897
+ if (typeof value !== "string") {
898
+ return undefined;
899
+ }
900
+ const trimmed = value.trim();
901
+ if (!trimmed || !/^\d+$/.test(trimmed)) {
902
+ return undefined;
903
+ }
904
+ const parsed = Number.parseInt(trimmed, 10);
905
+ return Number.isFinite(parsed) ? parsed : undefined;
906
+ }
package/dist/history.js CHANGED
@@ -20,6 +20,7 @@ exports.normalizeParticipants = normalizeParticipants;
20
20
  exports.parseListParticipantsParams = parseListParticipantsParams;
21
21
  exports.buildHistoryQuery = buildHistoryQuery;
22
22
  exports.normalizeChatKey = normalizeChatKey;
23
+ exports.chatKeyCandidates = chatKeyCandidates;
23
24
  exports.isChatReadable = isChatReadable;
24
25
  exports.isWithinWindow = isWithinWindow;
25
26
  exports.normalizeHistoryMessage = normalizeHistoryMessage;
@@ -29,6 +30,7 @@ exports.HISTORY_MAX_LIMIT = 500;
29
30
  const media_1 = require("./media");
30
31
  const helpers_1 = require("./helpers");
31
32
  const constants_1 = require("./constants");
33
+ const normalize_1 = require("./normalize");
32
34
  /**
33
35
  * Matches `normalize.ts` and `gramjs-client.ts` deliberately.
34
36
  *
@@ -41,17 +43,6 @@ const constants_1 = require("./constants");
41
43
  * Peer instead of the id inside it, which would otherwise produce a plausible
42
44
  * looking string.
43
45
  */
44
- function toStringId(value) {
45
- if (value === null || value === undefined)
46
- return undefined;
47
- try {
48
- const text = String(value);
49
- return text && text !== "[object Object]" ? text : undefined;
50
- }
51
- catch {
52
- return undefined;
53
- }
54
- }
55
46
  /**
56
47
  * Accepts Unix seconds or anything `Date` can parse (ISO 8601 in practice).
57
48
  *
@@ -103,7 +94,11 @@ function parseMessageId(value, field) {
103
94
  return undefined;
104
95
  const numeric = typeof value === "number" ? value : Number(value);
105
96
  if (!Number.isFinite(numeric) || !Number.isInteger(numeric) || numeric < 1) {
106
- throw new Error(`clawgram: ${field} must be a positive message id`);
97
+ // Значение в тексте из копии, которая жила в reactions.ts: без него
98
+ // «must be a positive message id» не говорит, что именно пришло, а
99
+ // приходит туда обычно чужой объект. Две копии расходились ещё и
100
+ // проверками (`Number.isFinite` была только здесь) — A12-05.
101
+ throw new Error(`clawgram: ${field} must be a positive message id, got ${JSON.stringify(value)}`);
107
102
  }
108
103
  return numeric;
109
104
  }
@@ -153,7 +148,7 @@ function normalizeParticipants(raw, options) {
153
148
  if (!entry || typeof entry !== "object")
154
149
  continue;
155
150
  const candidate = entry;
156
- const userId = toStringId(candidate.id);
151
+ const userId = (0, normalize_1.toStringId)(candidate.id);
157
152
  if (!userId)
158
153
  continue;
159
154
  const member = {
@@ -239,6 +234,29 @@ function buildHistoryQuery(args) {
239
234
  function normalizeChatKey(value) {
240
235
  return String(value ?? "").trim().replace(/^@/, "").toLowerCase();
241
236
  }
237
+ /**
238
+ * Все написания одной цели, по которым её ищут в списке доступа.
239
+ *
240
+ * Ворота сравнивали сырое написание, а адрес приходит в нескольких формах:
241
+ * `-1001234`, `clawgram:-1001234` (префикс канала от ядра), `-1001234:topic:5`
242
+ * (тема форума). Разбор этих форм живёт в `gramjs-client`, то есть ПОСЛЕ
243
+ * ворот, — и чат, честно перечисленный в `readChats`, получал отказ, стоило
244
+ * ядру адресовать его с префиксом (находка A5-17).
245
+ *
246
+ * Возвращается и полное написание, и «только чат»: запись списка вида
247
+ * `-1001234:topic:5` сегодня работает как область в одну тему, и сведение
248
+ * всего к чату молча расширило бы её на весь чат.
249
+ */
250
+ function chatKeyCandidates(target) {
251
+ const raw = String(target ?? "").trim();
252
+ const withoutChannel = raw.replace(/^(?:clawgram|tguserbot|telegram|tg):/i, "");
253
+ const withoutKind = withoutChannel.replace(/^(?:user|channel|group|conversation|room|dm):/i, "");
254
+ const chatOnly = withoutKind.replace(/:topic:\d+$/i, "");
255
+ const candidates = [raw, withoutKind, chatOnly]
256
+ .map(normalizeChatKey)
257
+ .filter(Boolean);
258
+ return [...new Set(candidates)];
259
+ }
242
260
  /**
243
261
  * Read scope for the account, checked before any history call.
244
262
  *
@@ -259,7 +277,8 @@ function isChatReadable(target, readChats) {
259
277
  // writes) or held `*`. The deny is unconditional on purpose: no deployment
260
278
  // has a reason to let the agent read its own login codes, and a config entry
261
279
  // that enabled it would be an account-takeover switch.
262
- if (normalizeChatKey(target) === constants_1.TELEGRAM_SERVICE_CHAT_ID)
280
+ const candidates = chatKeyCandidates(target);
281
+ if (candidates.includes(constants_1.TELEGRAM_SERVICE_CHAT_ID))
263
282
  return false;
264
283
  if (readChats === undefined || readChats === null)
265
284
  return true;
@@ -272,7 +291,7 @@ function isChatReadable(target, readChats) {
272
291
  return false;
273
292
  if (entries.includes("*"))
274
293
  return true;
275
- return entries.includes(normalizeChatKey(target));
294
+ return candidates.some((candidate) => entries.includes(candidate));
276
295
  }
277
296
  function isWithinWindow(timestamp, since, until) {
278
297
  // A message without a date cannot be placed in the window. Keeping it only
@@ -289,13 +308,26 @@ function readSender(msg, key) {
289
308
  const value = msg?.sender?.[key] ?? msg?._sender?.[key];
290
309
  return typeof value === "string" && value.length > 0 ? value : undefined;
291
310
  }
311
+ /**
312
+ * Хэндл отправителя — через `resolveActiveUsername`, а не с сырого поля.
313
+ *
314
+ * Telegram оставляет `username` пустым, как только у аккаунта больше одного
315
+ * хэндла: они переезжают в `usernames[]`. Разбор участников в этом же файле
316
+ * так и делает и объясняет почему, а разбор истории читал сырое поле — то есть
317
+ * терял хэндл ровно у владельца, у которого хэндлов несколько. Сводки
318
+ * подписывали его сообщения голым id, пока у всех остальных стоял `@`, а
319
+ * `allowFrom`, пересобранный из `read`, терял того же человека (A6-04).
320
+ */
321
+ function senderHandle(msg) {
322
+ return (0, helpers_1.resolveActiveUsername)(msg?.sender ?? msg?._sender);
323
+ }
292
324
  function resolveSenderDisplay(msg) {
293
325
  const first = readSender(msg, "firstName");
294
326
  const last = readSender(msg, "lastName");
295
327
  const joined = [first, last].filter(Boolean).join(" ").trim();
296
328
  if (joined)
297
329
  return joined;
298
- return readSender(msg, "title") ?? readSender(msg, "username");
330
+ return readSender(msg, "title") ?? senderHandle(msg);
299
331
  }
300
332
  /**
301
333
  * Same rule as the inbound path (`normalize.ts`): the text is the evidence,
@@ -312,7 +344,7 @@ function resolveHistoryReplyQuote(msg) {
312
344
  * into a prompt.
313
345
  */
314
346
  function normalizeHistoryMessage(msg, fallbackChatId) {
315
- const messageId = toStringId(msg?.id);
347
+ const messageId = (0, normalize_1.toStringId)(msg?.id);
316
348
  if (!messageId)
317
349
  return null;
318
350
  const text = typeof msg?.message === "string" ? msg.message :
@@ -328,16 +360,16 @@ function normalizeHistoryMessage(msg, fallbackChatId) {
328
360
  }
329
361
  return {
330
362
  messageId,
331
- chatId: toStringId(msg?.chatId) ?? fallbackChatId,
332
- senderId: toStringId(msg?.senderId) ?? toStringId(msg?.fromId?.userId) ?? toStringId(msg?.fromId?.channelId),
333
- senderUsername: readSender(msg, "username"),
363
+ chatId: (0, normalize_1.toStringId)(msg?.chatId) ?? fallbackChatId,
364
+ senderId: (0, normalize_1.toStringId)(msg?.senderId) ?? (0, normalize_1.toStringId)(msg?.fromId?.userId) ?? (0, normalize_1.toStringId)(msg?.fromId?.channelId),
365
+ senderUsername: senderHandle(msg),
334
366
  senderDisplay: resolveSenderDisplay(msg),
335
367
  text,
336
368
  timestamp,
337
369
  sentAt: timestamp === undefined ? undefined : new Date(timestamp * 1000).toISOString(),
338
- replyToMessageId: toStringId(msg?.replyTo?.replyToMsgId) ?? toStringId(msg?.replyToMsgId),
370
+ replyToMessageId: (0, normalize_1.toStringId)(msg?.replyTo?.replyToMsgId) ?? (0, normalize_1.toStringId)(msg?.replyToMsgId),
339
371
  replyQuoteText: resolveHistoryReplyQuote(msg),
340
- messageThreadId: toStringId(msg?.replyTo?.replyToTopId) ?? toStringId(msg?.replyToTopId),
372
+ messageThreadId: (0, normalize_1.toStringId)(msg?.replyTo?.replyToTopId) ?? (0, normalize_1.toStringId)(msg?.replyToTopId),
341
373
  media: (0, media_1.describeMedia)(msg?.media),
342
374
  isOutgoing: msg?.out === true,
343
375
  };
@@ -33,6 +33,7 @@
33
33
  * `<a href="…">` working.
34
34
  */
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.indexOfClosingTag = indexOfClosingTag;
36
37
  exports.renderTelegramHtml = renderTelegramHtml;
37
38
  /** Tags GramJS's HTML parser turns into entities, plus aliases it does not
38
39
  * know mapped onto ones it does. `canonical` is what gets emitted; `attrs`
@@ -73,6 +74,23 @@ const ATTR_RE = /([a-zA-Z-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
73
74
  /** A character reference the parser will decode; everything else is a bare `&`. */
74
75
  const ENTITY_RE = /^&(?:#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{1,31});/;
75
76
  const WORD_RE = /[\p{L}\p{N}_]/u;
77
+ /**
78
+ * Позиция закрывающего тега в ИСХОДНОЙ строке, без учёта регистра.
79
+ *
80
+ * `indexOf` по `s.toLowerCase()` возвращает смещение в другой строке.
81
+ * Сравниваем посимвольно в оригинале — тогда позиция всегда его собственная.
82
+ */
83
+ function indexOfClosingTag(s, name, from) {
84
+ const tag = `</${name}>`;
85
+ const len = tag.length;
86
+ for (let k = from; k + len <= s.length; k += 1) {
87
+ if (s[k] !== "<")
88
+ continue;
89
+ if (s.slice(k, k + len).toLowerCase() === tag)
90
+ return k;
91
+ }
92
+ return -1;
93
+ }
76
94
  /** GFM backslash-escapable punctuation, so `\*` means a literal asterisk. */
77
95
  const ESCAPABLE = new Set([..."\\`*_{}[]()#+-.!|~<>"]);
78
96
  const EMPHASIS = {
@@ -269,6 +287,8 @@ function tryEmphasis(s, i) {
269
287
  function renderInline(s) {
270
288
  let out = "";
271
289
  let i = 0;
290
+ // Сколько брошенных `tg-emoji` ждут своего закрывающего тега.
291
+ let droppedEmojiDepth = 0;
272
292
  while (i < s.length) {
273
293
  const c = s[i];
274
294
  if (c === "\\" && i + 1 < s.length && ESCAPABLE.has(s[i + 1])) {
@@ -291,14 +311,37 @@ function renderInline(s) {
291
311
  const spec = TELEGRAM_TAGS[name];
292
312
  if (spec) {
293
313
  if (closing) {
314
+ // Закрывающий тег брошенного `tg-emoji` тоже не выпускаем: иначе
315
+ // в тексте останется `</tg-emoji>` без пары.
316
+ if (spec.canonical === "tg-emoji" && droppedEmojiDepth > 0) {
317
+ droppedEmojiDepth -= 1;
318
+ i += m[0].length;
319
+ continue;
320
+ }
294
321
  out += `</${spec.canonical}>`;
295
322
  i += m[0].length;
296
323
  continue;
297
324
  }
325
+ // `tg-emoji` без пригодного `emoji-id` выбрасывается целиком, а
326
+ // текст внутри остаётся. Проверено на самом парсере GramJS: голый
327
+ // `<tg-emoji>` не падает при разборе, а даёт сущность
328
+ // `MessageEntityCustomEmoji` с `documentId: undefined` и нулевой
329
+ // длиной — то есть ломается всё сообщение, а не один значок
330
+ // (находка A5-18).
331
+ if (spec.canonical === "tg-emoji" && !/^\d+$/.test(parseAttrs(m[3]).get("emoji-id") ?? "")) {
332
+ droppedEmojiDepth += 1;
333
+ i += m[0].length;
334
+ continue;
335
+ }
298
336
  if (spec.canonical === "code" || spec.canonical === "pre") {
299
337
  // Code bodies pass through untouched by markdown: `**` inside
300
338
  // <code> is content, not emphasis.
301
- const close = s.toLowerCase().indexOf(`</${name}>`, i + m[0].length);
339
+ // Искать в `s.toLowerCase()`, а применять смещение к `s` нельзя:
340
+ // понижение регистра не сохраняет длину. `İ` (U+0130) даёт два
341
+ // кода, поэтому каждая такая буква перед блоком сдвигала позицию
342
+ // на единицу: тело кода обрезалось, а продолжение начиналось
343
+ // внутри `</code>` и выпускало наружу `<` и `/` (находка A6-13).
344
+ const close = indexOfClosingTag(s, name, i + m[0].length);
302
345
  if (close !== -1) {
303
346
  out += buildOpenTag(spec.canonical, spec.attrs, parseAttrs(m[3]))
304
347
  + escapeKeepEntities(s.slice(i + m[0].length, close))
package/dist/joins.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.JOINS_JOURNAL_MAX_RECORDS = exports.JOINS_MAX_LIMIT = exports.JOINS_DEFAULT_LIMIT = void 0;
3
+ exports.JOINS_MAX_LIMIT = exports.JOINS_DEFAULT_LIMIT = void 0;
4
4
  exports.parseJoinEvent = parseJoinEvent;
5
5
  exports.resolveJoinsJournalPath = resolveJoinsJournalPath;
6
6
  exports.readJoinRecords = readJoinRecords;
@@ -17,13 +17,15 @@ exports.parseJoinsParams = parseJoinsParams;
17
17
  * business and would turn the journal into surveillance.
18
18
  */
19
19
  const node_fs_1 = require("node:fs");
20
- const node_os_1 = require("node:os");
20
+ const state_dir_1 = require("./state-dir");
21
21
  const node_path_1 = require("node:path");
22
22
  const normalize_js_1 = require("./normalize.js");
23
23
  exports.JOINS_DEFAULT_LIMIT = 50;
24
24
  exports.JOINS_MAX_LIMIT = 500;
25
25
  /** The journal answers "recently", not "since the beginning of time". */
26
- exports.JOINS_JOURNAL_MAX_RECORDS = 2000;
26
+ // Модульная константа: снаружи её никто не читает, а `export` обещает
27
+ // публичную поверхность, которой нет (A6-21).
28
+ const JOINS_JOURNAL_MAX_RECORDS = 2000;
27
29
  function chatIdOf(rawMessage) {
28
30
  return (0, normalize_js_1.toStringId)(rawMessage?.chatId) ??
29
31
  (0, normalize_js_1.toPeerChannelId)(rawMessage?.peerId?.channelId) ??
@@ -89,7 +91,7 @@ function resolveJoinsJournalPath(accountCfg, accountId) {
89
91
  return configured.trim();
90
92
  }
91
93
  const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
92
- return (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "state", "clawgram", `joins-${safeAccount}.jsonl`);
94
+ return (0, node_path_1.join)((0, state_dir_1.resolveStateDir)(), "state", "clawgram", `joins-${safeAccount}.jsonl`);
93
95
  }
94
96
  function readJoinRecords(path) {
95
97
  if (!(0, node_fs_1.existsSync)(path))
@@ -115,8 +117,8 @@ function appendJoinRecord(path, event) {
115
117
  (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true });
116
118
  (0, node_fs_1.appendFileSync)(path, JSON.stringify(event) + "\n");
117
119
  const records = readJoinRecords(path);
118
- if (records.length > exports.JOINS_JOURNAL_MAX_RECORDS) {
119
- const kept = records.slice(records.length - exports.JOINS_JOURNAL_MAX_RECORDS);
120
+ if (records.length > JOINS_JOURNAL_MAX_RECORDS) {
121
+ const kept = records.slice(records.length - JOINS_JOURNAL_MAX_RECORDS);
120
122
  (0, node_fs_1.writeFileSync)(path, kept.map((entry) => JSON.stringify(entry)).join("\n") + "\n");
121
123
  }
122
124
  }
package/dist/manage.js CHANGED
@@ -33,23 +33,17 @@ exports.summarizeMissingInvitees = summarizeMissingInvitees;
33
33
  exports.readInviteLink = readInviteLink;
34
34
  const history_1 = require("./history");
35
35
  const helpers_1 = require("./helpers");
36
- function readString(value) {
37
- if (typeof value !== "string") {
38
- return undefined;
39
- }
40
- const trimmed = value.trim();
41
- return trimmed === "" ? undefined : trimmed;
42
- }
36
+ const util_1 = require("./util");
43
37
  /** A user reference: `@username` or a numeric id, as a trimmed string. */
44
38
  function readUserRef(value) {
45
39
  if (typeof value === "number" && Number.isFinite(value)) {
46
40
  return String(Math.trunc(value));
47
41
  }
48
- return readString(value);
42
+ return (0, util_1.readString)(value);
49
43
  }
50
44
  function readTarget(params, toolContext, action) {
51
45
  const raw = (0, helpers_1.readChatTargetParam)(params, toolContext);
52
- const target = readString(raw);
46
+ const target = (0, util_1.readString)(raw);
53
47
  if (!target) {
54
48
  throw new Error(`clawgram: ${action} requires a chatId`);
55
49
  }
@@ -74,13 +68,13 @@ function readFlag(value) {
74
68
  return value === true || value === "true";
75
69
  }
76
70
  function parseCreateGroupParams(params) {
77
- const title = readString(params.title ?? params.name);
71
+ const title = (0, util_1.readString)(params.title ?? params.name);
78
72
  if (!title) {
79
73
  throw new Error("clawgram: createGroup requires a title");
80
74
  }
81
75
  return {
82
76
  title,
83
- about: readString(params.about ?? params.description),
77
+ about: (0, util_1.readString)(params.about ?? params.description),
84
78
  users: readUserList(params),
85
79
  };
86
80
  }
@@ -150,7 +144,7 @@ function parsePromoteAdminParams(params, toolContext) {
150
144
  user,
151
145
  isAdmin: true,
152
146
  rights,
153
- rank: readString(params.rank ?? params.title),
147
+ rank: (0, util_1.readString)(params.rank ?? params.title),
154
148
  };
155
149
  }
156
150
  function parseDemoteAdminParams(params, toolContext) {
@@ -203,7 +197,7 @@ function parseInviteLinkParams(params, toolContext) {
203
197
  target: readTarget(params, toolContext, "inviteLink"),
204
198
  expireDate: parseExpireDate(params.expireDate ?? params.expiresAt),
205
199
  usageLimit: parseUsageLimit(params.usageLimit ?? params.memberLimit),
206
- title: readString(params.title ?? params.label),
200
+ title: (0, util_1.readString)(params.title ?? params.label),
207
201
  requestNeeded: readFlag(params.requestNeeded ?? params.requireApproval),
208
202
  };
209
203
  }
@@ -231,7 +225,7 @@ function isChatManageable(target, manageChats) {
231
225
  if (entries.includes("*")) {
232
226
  return true;
233
227
  }
234
- return entries.includes((0, history_1.normalizeChatKey)(target));
228
+ return (0, history_1.chatKeyCandidates)(target).some((candidate) => entries.includes(candidate));
235
229
  }
236
230
  /**
237
231
  * The id of the supergroup a `channels.CreateChannel` call just created, read
@@ -284,5 +278,5 @@ function summarizeMissingInvitees(result) {
284
278
  }
285
279
  /** The link out of a `TypeExportedChatInvite`, or nothing when there is none. */
286
280
  function readInviteLink(result) {
287
- return readString(result?.link);
281
+ return (0, util_1.readString)(result?.link);
288
282
  }
package/dist/media.js CHANGED
@@ -24,21 +24,12 @@ exports.isLocalMediaPath = isLocalMediaPath;
24
24
  exports.assertLocalMediaWithinRoots = assertLocalMediaWithinRoots;
25
25
  const node_fs_1 = require("node:fs");
26
26
  const node_path_1 = __importDefault(require("node:path"));
27
+ const util_1 = require("./util");
27
28
  /**
28
29
  * GramJS carries numbers as `big-integer` objects as often as native numbers —
29
30
  * the same shape that once made `senderId` silently undefined. Anything that
30
31
  * stringifies to digits is accepted.
31
32
  */
32
- function readNumber(value) {
33
- if (typeof value === "number") {
34
- return Number.isFinite(value) ? value : undefined;
35
- }
36
- if (value === undefined || value === null) {
37
- return undefined;
38
- }
39
- const parsed = Number(String(value));
40
- return Number.isFinite(parsed) ? parsed : undefined;
41
- }
42
33
  function readAttributes(document) {
43
34
  const attributes = document?.attributes;
44
35
  return Array.isArray(attributes) ? attributes : [];
@@ -60,7 +51,7 @@ const SIMPLE_KINDS = {
60
51
  */
61
52
  function describeDocument(document) {
62
53
  const mimeType = typeof document?.mimeType === "string" ? document.mimeType : undefined;
63
- const size = readNumber(document?.size);
54
+ const size = (0, util_1.readNumber)(document?.size);
64
55
  const fileName = findAttribute(document, "DocumentAttributeFilename")?.fileName;
65
56
  const base = {
66
57
  kind: "document",
@@ -74,7 +65,7 @@ function describeDocument(document) {
74
65
  }
75
66
  const audio = findAttribute(document, "DocumentAttributeAudio");
76
67
  if (audio) {
77
- const duration = readNumber(audio.duration);
68
+ const duration = (0, util_1.readNumber)(audio.duration);
78
69
  return {
79
70
  ...base,
80
71
  kind: audio.voice === true ? "voice" : "audio",
@@ -83,7 +74,7 @@ function describeDocument(document) {
83
74
  }
84
75
  const video = findAttribute(document, "DocumentAttributeVideo");
85
76
  if (video) {
86
- const duration = readNumber(video.duration);
77
+ const duration = (0, util_1.readNumber)(video.duration);
87
78
  return {
88
79
  ...base,
89
80
  kind: "video",
@@ -186,12 +177,20 @@ async function downloadMessageMediaToFile(params) {
186
177
  if (!buffer || !(buffer instanceof Buffer) || buffer.length === 0) {
187
178
  return undefined;
188
179
  }
189
- const { mkdir, writeFile } = await import("node:fs/promises");
180
+ const { mkdir, writeFile, chmod } = await import("node:fs/promises");
190
181
  const { join } = await import("node:path");
191
- await mkdir(params.dir, { recursive: true });
182
+ // Личная переписка на диске: каталог и файл принадлежат только агенту.
183
+ // По умолчанию (umask 022) выходило 0755/0644, то есть вложения из личных
184
+ // чатов читал любой локальный пользователь — на этом же хосте живёт
185
+ // gitlab-runner (A5-13). `mode` у mkdir и writeFile маскируется umask,
186
+ // поэтому права выставляются отдельным chmod, как это уже делается для
187
+ // конфига в update-config.ts.
188
+ await mkdir(params.dir, { recursive: true, mode: 0o700 });
189
+ await chmod(params.dir, 0o700).catch(() => undefined);
192
190
  const extension = extensionFor(described, understanding);
193
191
  const path = join(params.dir, params.fileNameFor({ media: described, extension }));
194
- await writeFile(path, buffer);
192
+ await writeFile(path, buffer, { mode: 0o600 });
193
+ await chmod(path, 0o600).catch(() => undefined);
195
194
  return { path, mimeType: described.mimeType, understanding, media: described };
196
195
  }
197
196
  /**
package/dist/normalize.js CHANGED
@@ -9,7 +9,12 @@ function toStringId(value) {
9
9
  if (value === null || value === undefined)
10
10
  return undefined;
11
11
  try {
12
- return String(value);
12
+ const text = String(value);
13
+ // `[object Object]` — это не id, а целый Peer, переданный вместо поля
14
+ // внутри него: строка выглядит правдоподобно и уезжает дальше как ключ.
15
+ // Guard жил только в history.ts, поэтому один и тот же peer у одного пути
16
+ // был «найден», а у другого «неизвестен» (находка A12-05).
17
+ return text && text !== "[object Object]" ? text : undefined;
13
18
  }
14
19
  catch {
15
20
  return undefined;