blun-king-cli 9.1.397 → 9.1.398
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/LIESMICH.txt +14 -0
- package/README.md +13 -0
- package/bin/telegram-private-conversation-policy.cjs +141 -0
- package/blun.mjs +18 -1
- package/package.json +1 -1
- package/telegram-plugin/bin/telegram-private-conversation-policy.cjs +141 -0
- package/telegram-plugin/dist/bridge.mjs +17 -1
- package/telegram-plugin/dist/mcp-server.mjs +10 -27
package/LIESMICH.txt
CHANGED
|
@@ -392,6 +392,20 @@ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
|
|
|
392
392
|
konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
|
|
393
393
|
gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
|
|
394
394
|
|
|
395
|
+
Keine doppelte Telegram-Antwort ohne neue Nachricht
|
|
396
|
+
---------------------------------------------------
|
|
397
|
+
|
|
398
|
+
Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
|
|
399
|
+
erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
|
|
400
|
+
danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
|
|
401
|
+
nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
|
|
402
|
+
den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
|
|
403
|
+
|
|
404
|
+
Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
|
|
405
|
+
Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
|
|
406
|
+
Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
|
|
407
|
+
zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
|
|
408
|
+
|
|
395
409
|
Angehängte Bilder werden weiterhin mit ReadMediaFile gelesen. Bild-, Video- und
|
|
396
410
|
Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
|
|
397
411
|
nutzbar bleibt. GenerateVideo kann außerdem einen abgeschlossenen Bildauftrag
|
package/README.md
CHANGED
|
@@ -401,6 +401,19 @@ eingestuft ist. Fehlt dieser Beleg, bleibt das Ziel aktiv und King erhält eine
|
|
|
401
401
|
konkrete Nachbesserung statt einer falschen Fertigmeldung. Ziele ohne ausdrücklich
|
|
402
402
|
gesetzte Abschlussbedingung behalten ihr bisheriges Verhalten.
|
|
403
403
|
|
|
404
|
+
## Keine doppelte Telegram-Antwort ohne neue Nachricht
|
|
405
|
+
|
|
406
|
+
Ab BLUN King 9.1.398 prüft jeder Text-Ausgangspfad vor dem Senden die jüngste
|
|
407
|
+
erfolgreiche Telegram-Antwort und den jüngsten Eingang desselben Chats. Solange
|
|
408
|
+
danach keine neue Nutzernachricht eingetroffen ist, wird eine wortgleiche oder
|
|
409
|
+
nahezu gleiche Wiederholung nicht erneut gesendet. Das gilt gleichermaßen für
|
|
410
|
+
den Telegram-Werkzeugpfad und beide automatischen Rückfallpfade.
|
|
411
|
+
|
|
412
|
+
Nach einer neuen Nutzernachricht ist dieselbe Antwort wieder zulässig. Andere
|
|
413
|
+
Ergebnisse und Datei-Anhänge bleiben unverändert. Die Prüfung liest nur begrenzte
|
|
414
|
+
Endbereiche der Ein- und Ausgangsprotokolle und fällt bei fehlendem Beleg offen
|
|
415
|
+
zurück, damit Telegram nicht wegen einer beschädigten Protokollzeile blockiert.
|
|
416
|
+
|
|
404
417
|
Angehängte Bilder werden weiterhin mit `ReadMediaFile` gelesen. Bild-, Video-
|
|
405
418
|
und Spracherzeugung laufen asynchron, sodass die Konsole während der Verarbeitung
|
|
406
419
|
nutzbar bleibt. `GenerateVideo` kann außerdem einen abgeschlossenen Bildauftrag
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { closeSync, openSync, readSync, statSync } = require('node:fs');
|
|
4
|
+
|
|
3
5
|
const PRIVATE_CHAT_ID = /^[1-9]\d*$/u;
|
|
4
6
|
const WORK_PERMISSION_QUESTION = /^(?:kann|darf|soll) ich\b.{0,100}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b/u;
|
|
5
7
|
const TRAILING_WORK_PERMISSION_QUESTION = /(?:^|\r?\n\s*\r?\n)(?:kann|darf|soll)\s+ich\b[^\r\n]{0,160}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b[^\r\n]*$/iu;
|
|
@@ -10,6 +12,12 @@ const MEDIA_PROGRESS_MARKER = /\b(?:bildgenerierung|bildjob|generateimage|getmed
|
|
|
10
12
|
const MEDIA_PENDING_STATE = /\b(?:auftrag wurde angenommen|job wurde angenommen|job angenommen|aufruf wurde abgesetzt|angestossen|angestoßen|processing|verarbeitung)\b/u;
|
|
11
13
|
const MEDIA_NO_RESULT = /\b(?:kein neues bild|noch kein bild|kein bild liefern|nichts angekommen|nichts neues gelandet|nicht verfuegbar|nicht verfügbar|getmedia fehlt)\b/u;
|
|
12
14
|
|
|
15
|
+
const DUPLICATE_LOG_TAIL_BYTES = 512 * 1024;
|
|
16
|
+
const DUPLICATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const NEAR_DUPLICATE_MIN_CHARS = 120;
|
|
18
|
+
const NEAR_DUPLICATE_SIMILARITY = 0.88;
|
|
19
|
+
const SUCCESSFUL_REPLY_KINDS = new Set(['reply', 'reply-fallback']);
|
|
20
|
+
|
|
13
21
|
function normalize(text) {
|
|
14
22
|
return String(text ?? '')
|
|
15
23
|
.normalize('NFKC')
|
|
@@ -19,6 +27,133 @@ function normalize(text) {
|
|
|
19
27
|
.trim();
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
function readJsonlTail(file, maxBytes = DUPLICATE_LOG_TAIL_BYTES) {
|
|
31
|
+
try {
|
|
32
|
+
const size = statSync(file).size;
|
|
33
|
+
if (size <= 0) return [];
|
|
34
|
+
const start = Math.max(0, size - maxBytes);
|
|
35
|
+
const length = size - start;
|
|
36
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
37
|
+
const descriptor = openSync(file, 'r');
|
|
38
|
+
let bytesRead = 0;
|
|
39
|
+
try {
|
|
40
|
+
while (bytesRead < length) {
|
|
41
|
+
const count = readSync(descriptor, buffer, bytesRead, length - bytesRead, start + bytesRead);
|
|
42
|
+
if (count === 0) break;
|
|
43
|
+
bytesRead += count;
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
closeSync(descriptor);
|
|
47
|
+
}
|
|
48
|
+
let raw = buffer.subarray(0, bytesRead).toString('utf8');
|
|
49
|
+
if (start > 0) {
|
|
50
|
+
const firstNewline = raw.indexOf('\n');
|
|
51
|
+
raw = firstNewline < 0 ? '' : raw.slice(firstNewline + 1);
|
|
52
|
+
}
|
|
53
|
+
return raw.split(/\r?\n/u).flatMap((line) => {
|
|
54
|
+
if (line.trim().length === 0) return [];
|
|
55
|
+
try {
|
|
56
|
+
const entry = JSON.parse(line);
|
|
57
|
+
return typeof entry === 'object' && entry !== null ? [entry] : [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizedTokens(text) {
|
|
68
|
+
return normalize(text).match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tokenBigrams(tokens) {
|
|
72
|
+
const bigrams = [];
|
|
73
|
+
for (let index = 0; index + 1 < tokens.length; index += 1) {
|
|
74
|
+
bigrams.push(`${tokens[index]}\u0000${tokens[index + 1]}`);
|
|
75
|
+
}
|
|
76
|
+
return bigrams;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diceSimilarity(leftItems, rightItems) {
|
|
80
|
+
const left = new Set(leftItems);
|
|
81
|
+
const right = new Set(rightItems);
|
|
82
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
83
|
+
let intersection = 0;
|
|
84
|
+
for (const item of left) {
|
|
85
|
+
if (right.has(item)) intersection += 1;
|
|
86
|
+
}
|
|
87
|
+
return (2 * intersection) / (left.size + right.size);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function outboundReplySimilarity(left, right) {
|
|
91
|
+
const leftNormalized = normalize(left);
|
|
92
|
+
const rightNormalized = normalize(right);
|
|
93
|
+
if (leftNormalized.length === 0 || rightNormalized.length === 0) return 0;
|
|
94
|
+
if (leftNormalized === rightNormalized) return 1;
|
|
95
|
+
if (leftNormalized.length < NEAR_DUPLICATE_MIN_CHARS || rightNormalized.length < NEAR_DUPLICATE_MIN_CHARS) return 0;
|
|
96
|
+
const leftTokens = normalizedTokens(leftNormalized);
|
|
97
|
+
const rightTokens = normalizedTokens(rightNormalized);
|
|
98
|
+
if (leftTokens.length < 12 || rightTokens.length < 12) return 0;
|
|
99
|
+
const tokenScore = diceSimilarity(leftTokens, rightTokens);
|
|
100
|
+
const bigramScore = diceSimilarity(tokenBigrams(leftTokens), tokenBigrams(rightTokens));
|
|
101
|
+
return (tokenScore + bigramScore) / 2;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function entryTime(entry) {
|
|
105
|
+
const raw = entry?.ts ?? entry?.meta?.timestamp;
|
|
106
|
+
const value = Date.parse(String(raw ?? ''));
|
|
107
|
+
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function entryChatId(entry) {
|
|
111
|
+
return String(entry?.chat_id ?? entry?.meta?.chat_id ?? '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findDuplicateReplyWithoutNewInbound({
|
|
115
|
+
chatId,
|
|
116
|
+
inboundFile,
|
|
117
|
+
now = Date.now(),
|
|
118
|
+
outboxFile,
|
|
119
|
+
text,
|
|
120
|
+
}) {
|
|
121
|
+
const wantedChat = String(chatId ?? '').trim();
|
|
122
|
+
const candidate = String(text ?? '').trim();
|
|
123
|
+
if (wantedChat.length === 0 || candidate.length === 0) return null;
|
|
124
|
+
|
|
125
|
+
const outbound = readJsonlTail(outboxFile).filter((entry) => (
|
|
126
|
+
SUCCESSFUL_REPLY_KINDS.has(entry.kind)
|
|
127
|
+
&& entryChatId(entry) === wantedChat
|
|
128
|
+
&& typeof entry.text === 'string'
|
|
129
|
+
));
|
|
130
|
+
if (outbound.length === 0) return null;
|
|
131
|
+
const latestOutboundAt = Math.max(...outbound.map(entryTime));
|
|
132
|
+
if (!Number.isFinite(latestOutboundAt)) return null;
|
|
133
|
+
|
|
134
|
+
const inboundTimes = readJsonlTail(inboundFile)
|
|
135
|
+
.filter((entry) => entryChatId(entry) === wantedChat)
|
|
136
|
+
.map(entryTime)
|
|
137
|
+
.filter(Number.isFinite);
|
|
138
|
+
if (inboundTimes.length === 0) return null;
|
|
139
|
+
if (Math.max(...inboundTimes) > latestOutboundAt) return null;
|
|
140
|
+
|
|
141
|
+
const cutoff = Number(now) - DUPLICATE_MAX_AGE_MS;
|
|
142
|
+
for (let index = outbound.length - 1; index >= 0; index -= 1) {
|
|
143
|
+
const previous = outbound[index];
|
|
144
|
+
const previousAt = entryTime(previous);
|
|
145
|
+
if (previousAt < cutoff || previousAt > Number(now)) continue;
|
|
146
|
+
const similarity = outboundReplySimilarity(previous.text, candidate);
|
|
147
|
+
if (similarity < NEAR_DUPLICATE_SIMILARITY) continue;
|
|
148
|
+
return {
|
|
149
|
+
kind: previous.kind,
|
|
150
|
+
similarity,
|
|
151
|
+
ts: previous.ts,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
22
157
|
function sanitizePrivateConversationReply(chatId, text) {
|
|
23
158
|
const value = String(text ?? '');
|
|
24
159
|
if (!PRIVATE_CHAT_ID.test(String(chatId ?? '').trim())) return value;
|
|
@@ -38,6 +173,12 @@ function isPrivateInternalStatusReply(chatId, text) {
|
|
|
38
173
|
}
|
|
39
174
|
|
|
40
175
|
module.exports = {
|
|
176
|
+
DUPLICATE_LOG_TAIL_BYTES,
|
|
177
|
+
DUPLICATE_MAX_AGE_MS,
|
|
178
|
+
NEAR_DUPLICATE_MIN_CHARS,
|
|
179
|
+
NEAR_DUPLICATE_SIMILARITY,
|
|
180
|
+
findDuplicateReplyWithoutNewInbound,
|
|
41
181
|
isPrivateInternalStatusReply,
|
|
182
|
+
outboundReplySimilarity,
|
|
42
183
|
sanitizePrivateConversationReply,
|
|
43
184
|
};
|
package/blun.mjs
CHANGED
|
@@ -514729,7 +514729,7 @@ var retryTelegramMediaDelivery, retryableTelegramMediaFailure;
|
|
|
514729
514729
|
var createMediaAutoRetrievalController, parseAcceptedMediaJob, validateCompletedMedia;
|
|
514730
514730
|
({ createMediaAutoRetrievalController, parseAcceptedMediaJob } = createRequire(import.meta.url)("./bin/media-auto-retrieval-policy.cjs"));
|
|
514731
514731
|
({ validateCompletedMedia } = createRequire(import.meta.url)("./bin/media-result-policy.cjs"));
|
|
514732
|
-
var { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
|
|
514732
|
+
var { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = createRequire(import.meta.url)("./bin/telegram-private-conversation-policy.cjs");
|
|
514733
514733
|
const TELEGRAM_TEXT_LIMIT = 4096;
|
|
514734
514734
|
const TELEGRAM_ATTACHMENT_LIMIT = 50 * 1024 * 1024;
|
|
514735
514735
|
function mediaTelegramTarget(filePath) {
|
|
@@ -514838,6 +514838,23 @@ async function sendReplyFallback(chatId, text, contextOnly = false) {
|
|
|
514838
514838
|
} catch {}
|
|
514839
514839
|
return true;
|
|
514840
514840
|
}
|
|
514841
|
+
if (findDuplicateReplyWithoutNewInbound({
|
|
514842
|
+
chatId,
|
|
514843
|
+
inboundFile: join$4(channelDir(), "inbox.jsonl"),
|
|
514844
|
+
outboxFile: outboxPath(),
|
|
514845
|
+
text
|
|
514846
|
+
}) !== null) {
|
|
514847
|
+
try {
|
|
514848
|
+
appendFileSync(outboxPath(), `${JSON.stringify({
|
|
514849
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
514850
|
+
direction: "out",
|
|
514851
|
+
kind: "reply-fallback-suppressed-duplicate-without-new-inbound",
|
|
514852
|
+
chat_id: String(chatId),
|
|
514853
|
+
text
|
|
514854
|
+
})}\n`);
|
|
514855
|
+
} catch {}
|
|
514856
|
+
return true;
|
|
514857
|
+
}
|
|
514841
514858
|
const privateSafeText = sanitizePrivateConversationReply(chatId, text);
|
|
514842
514859
|
if (isGroupChat(chatId) && isGroupSuppressed(text, contextOnly)) {
|
|
514843
514860
|
try {
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { closeSync, openSync, readSync, statSync } = require('node:fs');
|
|
4
|
+
|
|
3
5
|
const PRIVATE_CHAT_ID = /^[1-9]\d*$/u;
|
|
4
6
|
const WORK_PERMISSION_QUESTION = /^(?:kann|darf|soll) ich\b.{0,100}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b/u;
|
|
5
7
|
const TRAILING_WORK_PERMISSION_QUESTION = /(?:^|\r?\n\s*\r?\n)(?:kann|darf|soll)\s+ich\b[^\r\n]{0,160}\b(?:arbeit(?:en)?|weiterarbeiten|weitermachen|fortfahren|weiterbauen|umbau|bau)\b[^\r\n]*$/iu;
|
|
@@ -10,6 +12,12 @@ const MEDIA_PROGRESS_MARKER = /\b(?:bildgenerierung|bildjob|generateimage|getmed
|
|
|
10
12
|
const MEDIA_PENDING_STATE = /\b(?:auftrag wurde angenommen|job wurde angenommen|job angenommen|aufruf wurde abgesetzt|angestossen|angestoßen|processing|verarbeitung)\b/u;
|
|
11
13
|
const MEDIA_NO_RESULT = /\b(?:kein neues bild|noch kein bild|kein bild liefern|nichts angekommen|nichts neues gelandet|nicht verfuegbar|nicht verfügbar|getmedia fehlt)\b/u;
|
|
12
14
|
|
|
15
|
+
const DUPLICATE_LOG_TAIL_BYTES = 512 * 1024;
|
|
16
|
+
const DUPLICATE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const NEAR_DUPLICATE_MIN_CHARS = 120;
|
|
18
|
+
const NEAR_DUPLICATE_SIMILARITY = 0.88;
|
|
19
|
+
const SUCCESSFUL_REPLY_KINDS = new Set(['reply', 'reply-fallback']);
|
|
20
|
+
|
|
13
21
|
function normalize(text) {
|
|
14
22
|
return String(text ?? '')
|
|
15
23
|
.normalize('NFKC')
|
|
@@ -19,6 +27,133 @@ function normalize(text) {
|
|
|
19
27
|
.trim();
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
function readJsonlTail(file, maxBytes = DUPLICATE_LOG_TAIL_BYTES) {
|
|
31
|
+
try {
|
|
32
|
+
const size = statSync(file).size;
|
|
33
|
+
if (size <= 0) return [];
|
|
34
|
+
const start = Math.max(0, size - maxBytes);
|
|
35
|
+
const length = size - start;
|
|
36
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
37
|
+
const descriptor = openSync(file, 'r');
|
|
38
|
+
let bytesRead = 0;
|
|
39
|
+
try {
|
|
40
|
+
while (bytesRead < length) {
|
|
41
|
+
const count = readSync(descriptor, buffer, bytesRead, length - bytesRead, start + bytesRead);
|
|
42
|
+
if (count === 0) break;
|
|
43
|
+
bytesRead += count;
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
closeSync(descriptor);
|
|
47
|
+
}
|
|
48
|
+
let raw = buffer.subarray(0, bytesRead).toString('utf8');
|
|
49
|
+
if (start > 0) {
|
|
50
|
+
const firstNewline = raw.indexOf('\n');
|
|
51
|
+
raw = firstNewline < 0 ? '' : raw.slice(firstNewline + 1);
|
|
52
|
+
}
|
|
53
|
+
return raw.split(/\r?\n/u).flatMap((line) => {
|
|
54
|
+
if (line.trim().length === 0) return [];
|
|
55
|
+
try {
|
|
56
|
+
const entry = JSON.parse(line);
|
|
57
|
+
return typeof entry === 'object' && entry !== null ? [entry] : [];
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizedTokens(text) {
|
|
68
|
+
return normalize(text).match(/[\p{L}\p{N}_]+/gu) ?? [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tokenBigrams(tokens) {
|
|
72
|
+
const bigrams = [];
|
|
73
|
+
for (let index = 0; index + 1 < tokens.length; index += 1) {
|
|
74
|
+
bigrams.push(`${tokens[index]}\u0000${tokens[index + 1]}`);
|
|
75
|
+
}
|
|
76
|
+
return bigrams;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function diceSimilarity(leftItems, rightItems) {
|
|
80
|
+
const left = new Set(leftItems);
|
|
81
|
+
const right = new Set(rightItems);
|
|
82
|
+
if (left.size === 0 || right.size === 0) return 0;
|
|
83
|
+
let intersection = 0;
|
|
84
|
+
for (const item of left) {
|
|
85
|
+
if (right.has(item)) intersection += 1;
|
|
86
|
+
}
|
|
87
|
+
return (2 * intersection) / (left.size + right.size);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function outboundReplySimilarity(left, right) {
|
|
91
|
+
const leftNormalized = normalize(left);
|
|
92
|
+
const rightNormalized = normalize(right);
|
|
93
|
+
if (leftNormalized.length === 0 || rightNormalized.length === 0) return 0;
|
|
94
|
+
if (leftNormalized === rightNormalized) return 1;
|
|
95
|
+
if (leftNormalized.length < NEAR_DUPLICATE_MIN_CHARS || rightNormalized.length < NEAR_DUPLICATE_MIN_CHARS) return 0;
|
|
96
|
+
const leftTokens = normalizedTokens(leftNormalized);
|
|
97
|
+
const rightTokens = normalizedTokens(rightNormalized);
|
|
98
|
+
if (leftTokens.length < 12 || rightTokens.length < 12) return 0;
|
|
99
|
+
const tokenScore = diceSimilarity(leftTokens, rightTokens);
|
|
100
|
+
const bigramScore = diceSimilarity(tokenBigrams(leftTokens), tokenBigrams(rightTokens));
|
|
101
|
+
return (tokenScore + bigramScore) / 2;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function entryTime(entry) {
|
|
105
|
+
const raw = entry?.ts ?? entry?.meta?.timestamp;
|
|
106
|
+
const value = Date.parse(String(raw ?? ''));
|
|
107
|
+
return Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function entryChatId(entry) {
|
|
111
|
+
return String(entry?.chat_id ?? entry?.meta?.chat_id ?? '');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function findDuplicateReplyWithoutNewInbound({
|
|
115
|
+
chatId,
|
|
116
|
+
inboundFile,
|
|
117
|
+
now = Date.now(),
|
|
118
|
+
outboxFile,
|
|
119
|
+
text,
|
|
120
|
+
}) {
|
|
121
|
+
const wantedChat = String(chatId ?? '').trim();
|
|
122
|
+
const candidate = String(text ?? '').trim();
|
|
123
|
+
if (wantedChat.length === 0 || candidate.length === 0) return null;
|
|
124
|
+
|
|
125
|
+
const outbound = readJsonlTail(outboxFile).filter((entry) => (
|
|
126
|
+
SUCCESSFUL_REPLY_KINDS.has(entry.kind)
|
|
127
|
+
&& entryChatId(entry) === wantedChat
|
|
128
|
+
&& typeof entry.text === 'string'
|
|
129
|
+
));
|
|
130
|
+
if (outbound.length === 0) return null;
|
|
131
|
+
const latestOutboundAt = Math.max(...outbound.map(entryTime));
|
|
132
|
+
if (!Number.isFinite(latestOutboundAt)) return null;
|
|
133
|
+
|
|
134
|
+
const inboundTimes = readJsonlTail(inboundFile)
|
|
135
|
+
.filter((entry) => entryChatId(entry) === wantedChat)
|
|
136
|
+
.map(entryTime)
|
|
137
|
+
.filter(Number.isFinite);
|
|
138
|
+
if (inboundTimes.length === 0) return null;
|
|
139
|
+
if (Math.max(...inboundTimes) > latestOutboundAt) return null;
|
|
140
|
+
|
|
141
|
+
const cutoff = Number(now) - DUPLICATE_MAX_AGE_MS;
|
|
142
|
+
for (let index = outbound.length - 1; index >= 0; index -= 1) {
|
|
143
|
+
const previous = outbound[index];
|
|
144
|
+
const previousAt = entryTime(previous);
|
|
145
|
+
if (previousAt < cutoff || previousAt > Number(now)) continue;
|
|
146
|
+
const similarity = outboundReplySimilarity(previous.text, candidate);
|
|
147
|
+
if (similarity < NEAR_DUPLICATE_SIMILARITY) continue;
|
|
148
|
+
return {
|
|
149
|
+
kind: previous.kind,
|
|
150
|
+
similarity,
|
|
151
|
+
ts: previous.ts,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
22
157
|
function sanitizePrivateConversationReply(chatId, text) {
|
|
23
158
|
const value = String(text ?? '');
|
|
24
159
|
if (!PRIVATE_CHAT_ID.test(String(chatId ?? '').trim())) return value;
|
|
@@ -38,6 +173,12 @@ function isPrivateInternalStatusReply(chatId, text) {
|
|
|
38
173
|
}
|
|
39
174
|
|
|
40
175
|
module.exports = {
|
|
176
|
+
DUPLICATE_LOG_TAIL_BYTES,
|
|
177
|
+
DUPLICATE_MAX_AGE_MS,
|
|
178
|
+
NEAR_DUPLICATE_MIN_CHARS,
|
|
179
|
+
NEAR_DUPLICATE_SIMILARITY,
|
|
180
|
+
findDuplicateReplyWithoutNewInbound,
|
|
41
181
|
isPrivateInternalStatusReply,
|
|
182
|
+
outboundReplySimilarity,
|
|
42
183
|
sanitizePrivateConversationReply,
|
|
43
184
|
};
|
|
@@ -11,7 +11,7 @@ import telegramApprovalRelay from "../../bin/telegram-approval-relay.cjs";
|
|
|
11
11
|
import privateConversationPolicy from "../bin/telegram-private-conversation-policy.cjs";
|
|
12
12
|
const { buildTelegramRemoteStatus, resolveTelegramRemoteVersion } = remoteStatusPolicy;
|
|
13
13
|
const { buildTelegramApprovalCard, isAuthorizedTelegramApprovalCallback, listPendingTelegramApprovals, markTelegramApprovalSent, parseTelegramApprovalCallback, resolveTelegramApprovalTarget, wasTelegramApprovalSent, writeTelegramApprovalResponse } = telegramApprovalRelay;
|
|
14
|
-
const { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
|
|
14
|
+
const { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
|
|
15
15
|
//#region ../../node_modules/pathe/dist/shared/pathe.M-eThtNZ.mjs
|
|
16
16
|
const _DRIVE_LETTER_START_RE = /^[A-Za-z]:\//;
|
|
17
17
|
function normalizeWindowsPath(input = "") {
|
|
@@ -4199,6 +4199,22 @@ async function sendReplyFallback(chatId, rawText) {
|
|
|
4199
4199
|
logLine(`reply-fallback suppressed (private internal): ${rawText.slice(0, 60)}`);
|
|
4200
4200
|
return true;
|
|
4201
4201
|
}
|
|
4202
|
+
if (findDuplicateReplyWithoutNewInbound({
|
|
4203
|
+
chatId,
|
|
4204
|
+
inboundFile: inboxLog(),
|
|
4205
|
+
outboxFile: outboxLog(),
|
|
4206
|
+
text: rawText
|
|
4207
|
+
}) !== null) {
|
|
4208
|
+
appendFileSync(outboxLog(), `${JSON.stringify({
|
|
4209
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4210
|
+
direction: "out",
|
|
4211
|
+
kind: "reply-fallback-suppressed-duplicate-without-new-inbound",
|
|
4212
|
+
chat_id: String(chatId),
|
|
4213
|
+
text: rawText
|
|
4214
|
+
})}\n`);
|
|
4215
|
+
logLine(`reply-fallback suppressed (duplicate without new inbound): ${rawText.slice(0, 60)}`);
|
|
4216
|
+
return true;
|
|
4217
|
+
}
|
|
4202
4218
|
const privateSafeText = sanitizePrivateConversationReply(chatId, rawText);
|
|
4203
4219
|
if (isGroupChat(chatId) && isGroupNoiseReply(rawText)) {
|
|
4204
4220
|
logLine(`reply-fallback suppressed (group meta-noise): ${rawText.slice(0, 60)}`);
|
|
@@ -9667,7 +9667,7 @@ var StdioServerTransport = class {
|
|
|
9667
9667
|
* chats the inbound gate would deliver from.
|
|
9668
9668
|
*/
|
|
9669
9669
|
var import_out = require_out();
|
|
9670
|
-
const { isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
|
|
9670
|
+
const { findDuplicateReplyWithoutNewInbound, isPrivateInternalStatusReply, sanitizePrivateConversationReply } = privateConversationPolicy;
|
|
9671
9671
|
const TOOL_DEFINITIONS = [
|
|
9672
9672
|
{
|
|
9673
9673
|
name: "reply",
|
|
@@ -9787,34 +9787,17 @@ async function runTool(api, token, name, args) {
|
|
|
9787
9787
|
}
|
|
9788
9788
|
}
|
|
9789
9789
|
/**
|
|
9790
|
-
* Idempotency guard
|
|
9791
|
-
*
|
|
9792
|
-
*
|
|
9793
|
-
* erneut gesendet — das Tool meldet dem Modell "bereits zugestellt", damit es
|
|
9794
|
-
* nicht weiter retryt. Datei-Anhänge sind nie betroffen.
|
|
9790
|
+
* Idempotency guard for text-only replies. If no newer inbound message exists,
|
|
9791
|
+
* an exact or near-identical successful reply is not sent again. Attachments
|
|
9792
|
+
* are never affected.
|
|
9795
9793
|
*/
|
|
9796
|
-
const DUPLICATE_WINDOW_MS = 3e5;
|
|
9797
|
-
const OUTBOX_TAIL_BYTES = 32768;
|
|
9798
9794
|
function isRecentDuplicate(chatId, text) {
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
const cutoff = Date.now() - DUPLICATE_WINDOW_MS;
|
|
9806
|
-
for (const line of tail.split("\n")) {
|
|
9807
|
-
if (line.trim().length === 0) continue;
|
|
9808
|
-
try {
|
|
9809
|
-
const entry = JSON.parse(line);
|
|
9810
|
-
if (entry.kind !== "reply" && entry.kind !== "reply-fallback") continue;
|
|
9811
|
-
if (String(entry.chat_id) !== String(chatId)) continue;
|
|
9812
|
-
if (entry.ts === void 0 || Date.parse(entry.ts) < cutoff) continue;
|
|
9813
|
-
if ((entry.text ?? "").trim() === wanted) return true;
|
|
9814
|
-
} catch {}
|
|
9815
|
-
}
|
|
9816
|
-
} catch {}
|
|
9817
|
-
return false;
|
|
9795
|
+
return findDuplicateReplyWithoutNewInbound({
|
|
9796
|
+
chatId,
|
|
9797
|
+
inboundFile: inboxLog(),
|
|
9798
|
+
outboxFile: outboxLog(),
|
|
9799
|
+
text
|
|
9800
|
+
}) !== null;
|
|
9818
9801
|
}
|
|
9819
9802
|
function telegramErrorMessage(error) {
|
|
9820
9803
|
return error instanceof Error ? error.message : String(error);
|