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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
@@ -135,6 +135,37 @@ function resolveTranscriptPathFromStoreEntry(input) {
|
|
|
135
135
|
return undefined;
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Хвост стенограммы, а не вся она.
|
|
140
|
+
*
|
|
141
|
+
* Читался весь файл целиком и синхронно — на каждом ходе, где ядро ничего не
|
|
142
|
+
* доставило. Стенограмма живой сессии растёт неограниченно, а нужна ровно
|
|
143
|
+
* последняя запись ассистента: всё, что дальше пары сотен килобайт назад, по
|
|
144
|
+
* определению не «только что» и проверку свежести всё равно не прошло бы
|
|
145
|
+
* (находка A6-15).
|
|
146
|
+
*
|
|
147
|
+
* Первая строка куска отбрасывается: чтение с произвольного смещения почти
|
|
148
|
+
* наверняка попадает в середину строки, а заодно — в середину UTF-8-символа.
|
|
149
|
+
* Целая строка перед ней нам не нужна, потому что ищем мы с конца.
|
|
150
|
+
*/
|
|
151
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
152
|
+
function readTranscriptTail(sessionFile) {
|
|
153
|
+
const fd = (0, node_fs_1.openSync)(sessionFile, "r");
|
|
154
|
+
try {
|
|
155
|
+
const size = (0, node_fs_1.fstatSync)(fd).size;
|
|
156
|
+
if (size <= TRANSCRIPT_TAIL_BYTES) {
|
|
157
|
+
return (0, node_fs_1.readFileSync)(sessionFile, "utf8");
|
|
158
|
+
}
|
|
159
|
+
const buffer = Buffer.allocUnsafe(TRANSCRIPT_TAIL_BYTES);
|
|
160
|
+
const read = (0, node_fs_1.readSync)(fd, buffer, 0, TRANSCRIPT_TAIL_BYTES, size - TRANSCRIPT_TAIL_BYTES);
|
|
161
|
+
const text = buffer.subarray(0, read).toString("utf8");
|
|
162
|
+
const firstBreak = text.indexOf("\n");
|
|
163
|
+
return firstBreak === -1 ? "" : text.slice(firstBreak + 1);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
(0, node_fs_1.closeSync)(fd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
138
169
|
/**
|
|
139
170
|
* Salvages a reply that reached the transcript but not stdout.
|
|
140
171
|
*
|
|
@@ -160,7 +191,7 @@ function readLatestAssistantFallbackFromTranscript(sessionKey, storePath, notBef
|
|
|
160
191
|
if (!sessionFile) {
|
|
161
192
|
return undefined;
|
|
162
193
|
}
|
|
163
|
-
const lines = (
|
|
194
|
+
const lines = readTranscriptTail(sessionFile)
|
|
164
195
|
.split("\n")
|
|
165
196
|
.map((line) => line.trim())
|
|
166
197
|
.filter(Boolean);
|
|
@@ -268,15 +299,29 @@ function resolveActionTarget(params, toolContext) {
|
|
|
268
299
|
}
|
|
269
300
|
throw new Error("clawgram: message target is required");
|
|
270
301
|
}
|
|
302
|
+
/**
|
|
303
|
+
* Ответ на сообщение — во всех чатах, а не только в группах.
|
|
304
|
+
*
|
|
305
|
+
* Прежде `replyToId` молча отбрасывался, если тип цели не группа и не канал, а
|
|
306
|
+
* `inferOutboundTargetKind` возвращает `undefined` и для `@username`, и для
|
|
307
|
+
* положительного числового id — то есть для любой лички. Инструмент отвечал
|
|
308
|
+
* `ok: true`, человек получал сообщение вне ветки, и ничего об этом не
|
|
309
|
+
* сообщало. Приходящий путь лички при этом ветку проставлял, так что два пути
|
|
310
|
+
* расходились между собой (находка A6-05).
|
|
311
|
+
*
|
|
312
|
+
* Telegram поддерживает `reply_to` и в приватных чатах, поэтому чинится это
|
|
313
|
+
* не отказом, а тем, что ветка ставится везде. Нечисловой `replyToId` —
|
|
314
|
+
* ошибка вызова, а не повод молча отправить вне ветки.
|
|
315
|
+
*/
|
|
271
316
|
function resolveReplyToMessageIdForTarget(rawTarget, replyToId) {
|
|
272
317
|
if (replyToId === null || replyToId === undefined || replyToId === "") {
|
|
273
318
|
return undefined;
|
|
274
319
|
}
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
|
|
320
|
+
const id = Number(replyToId);
|
|
321
|
+
if (!Number.isFinite(id) || id <= 0) {
|
|
322
|
+
throw new Error(`clawgram: replyToId must be a message id, got ${JSON.stringify(replyToId)}`);
|
|
278
323
|
}
|
|
279
|
-
return
|
|
324
|
+
return id;
|
|
280
325
|
}
|
|
281
326
|
/**
|
|
282
327
|
* Разметка синтеза речи, которая не должна доехать до человека.
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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") ??
|
|
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:
|
|
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
|
};
|
package/dist/html-render.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 >
|
|
119
|
-
const kept = records.slice(records.length -
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/proxy-config.js
CHANGED
|
@@ -3,10 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.resolveProxyConfig = resolveProxyConfig;
|
|
4
4
|
exports.buildTelegramClientOptions = buildTelegramClientOptions;
|
|
5
5
|
exports.describeProxy = describeProxy;
|
|
6
|
+
const util_1 = require("./util");
|
|
6
7
|
const CONNECTION_RETRIES = 5;
|
|
7
|
-
function isPlainObject(value) {
|
|
8
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
-
}
|
|
10
8
|
function toFiniteNumber(value) {
|
|
11
9
|
if (typeof value === "number") {
|
|
12
10
|
return value;
|
|
@@ -83,7 +81,7 @@ function resolveProxyConfig(value) {
|
|
|
83
81
|
if (value === undefined || value === null) {
|
|
84
82
|
return undefined;
|
|
85
83
|
}
|
|
86
|
-
if (!isPlainObject(value)) {
|
|
84
|
+
if (!(0, util_1.isPlainObject)(value)) {
|
|
87
85
|
throw new Error("clawgram: proxy must be an object.");
|
|
88
86
|
}
|
|
89
87
|
const ip = resolveProxyHost(value.ip);
|