clawgram 2.13.0 → 2.14.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/channel.js +44 -2
- package/dist/group-reply-address.js +17 -19
- package/dist/group-visible-reply-guard.js +65 -2
- package/dist/helpers.js +20 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/dist/channel.js
CHANGED
|
@@ -1305,7 +1305,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1305
1305
|
};
|
|
1306
1306
|
},
|
|
1307
1307
|
extractToolSend: ({ args }) => (0, tool_send_1.extractToolSend)(args, "sendMessage"),
|
|
1308
|
-
handleAction: async ({ action, params, cfg, accountId, dryRun, toolContext }) => {
|
|
1308
|
+
handleAction: async ({ action, params, cfg, accountId, dryRun: dryRunFlag, toolContext }) => {
|
|
1309
|
+
// Core passes the flag beside `params`; callers write it inside.
|
|
1310
|
+
// Both count, because a rehearsal flag that is silently ignored puts
|
|
1311
|
+
// a real message in a real chat — twice, so far (2.13.1).
|
|
1312
|
+
const dryRun = (0, helpers_1.resolveDryRun)(dryRunFlag, params);
|
|
1309
1313
|
// `read` is what OpenClaw core dispatches (`openclaw message read`,
|
|
1310
1314
|
// MCP `messages_read`). `list` is accepted as a synonym so a caller that
|
|
1311
1315
|
// guessed the other obvious name is not silently refused.
|
|
@@ -1830,10 +1834,15 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1830
1834
|
accountId: resolvedAccountId,
|
|
1831
1835
|
});
|
|
1832
1836
|
}
|
|
1837
|
+
// Whom to greet is decided by the message this turn is answering, not
|
|
1838
|
+
// by whoever spoke last. An agent replying to a request rarely passes
|
|
1839
|
+
// `replyToId`, and until 2026-08-10 that fell through to the most
|
|
1840
|
+
// recent sender: in an interleaved chat the owner's report went out
|
|
1841
|
+
// addressed to a colleague who had asked something else entirely.
|
|
1833
1842
|
const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1834
1843
|
accountId: resolvedAccountId,
|
|
1835
1844
|
chatId: to,
|
|
1836
|
-
replyToId,
|
|
1845
|
+
replyToId: replyToId ?? currentMessageId,
|
|
1837
1846
|
});
|
|
1838
1847
|
const requestedText = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
|
|
1839
1848
|
// `NO_REPLY` is OpenClaw's "say nothing" sentinel. The inbound pipeline
|
|
@@ -1892,6 +1901,17 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1892
1901
|
currentMessageId,
|
|
1893
1902
|
});
|
|
1894
1903
|
}
|
|
1904
|
+
// The turn has now spoken for itself. Recorded for every send into the
|
|
1905
|
+
// chat this turn came from — with or without an explicit replyToId —
|
|
1906
|
+
// so that core delivering the turn's final text a few seconds later
|
|
1907
|
+
// can be recognised as an echo of this same answer.
|
|
1908
|
+
if (currentMessageId !== null && currentMessageId !== undefined) {
|
|
1909
|
+
(0, group_visible_reply_guard_1.rememberTurnSend)({
|
|
1910
|
+
accountId: resolvedAccountId,
|
|
1911
|
+
chatId: to,
|
|
1912
|
+
currentMessageId,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1895
1915
|
actionLog.info("clawgram handleAction send completed", {
|
|
1896
1916
|
accountId: resolvedAccountId,
|
|
1897
1917
|
to,
|
|
@@ -1965,6 +1985,28 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
1965
1985
|
if (!gram) {
|
|
1966
1986
|
throw new Error(`clawgram: runtime not found for account ${ctx.accountId}`);
|
|
1967
1987
|
}
|
|
1988
|
+
// The agent already answered this message with its own `send`, and this
|
|
1989
|
+
// is core delivering the same turn's final text. Two messages for one
|
|
1990
|
+
// answer is how 2026-08-10 read in a work chat: every request reported
|
|
1991
|
+
// twice, in slightly different words, seconds apart.
|
|
1992
|
+
//
|
|
1993
|
+
// Core's own convention is that an agent which has sent a message
|
|
1994
|
+
// returns NO_REPLY; this catches the turns that forget. The window is
|
|
1995
|
+
// seconds wide, so a result the assistant comes back with later is
|
|
1996
|
+
// still delivered.
|
|
1997
|
+
if (ctx.replyToId !== null && ctx.replyToId !== undefined && (0, group_visible_reply_guard_1.hadTurnSendJustNow)({
|
|
1998
|
+
accountId: ctx.accountId,
|
|
1999
|
+
chatId: (0, helpers_1.normalizeOutboundTarget)(ctx.to),
|
|
2000
|
+
currentMessageId: ctx.replyToId,
|
|
2001
|
+
})) {
|
|
2002
|
+
actionLog.warn("clawgram suppressing echo of a turn that already sent", {
|
|
2003
|
+
accountId: ctx.accountId,
|
|
2004
|
+
rawTo: ctx.to,
|
|
2005
|
+
replyToId: ctx.replyToId,
|
|
2006
|
+
textLength: ctx.text.length,
|
|
2007
|
+
});
|
|
2008
|
+
return { skipped: "duplicate" };
|
|
2009
|
+
}
|
|
1968
2010
|
const groupReplyAddress = (0, group_reply_address_1.consumeGroupReplyAddress)({
|
|
1969
2011
|
accountId: ctx.accountId,
|
|
1970
2012
|
chatId: ctx.to,
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.rememberGroupReplyAddress = rememberGroupReplyAddress;
|
|
4
4
|
exports.consumeGroupReplyAddress = consumeGroupReplyAddress;
|
|
5
|
+
exports.resetGroupReplyAddresses = resetGroupReplyAddresses;
|
|
5
6
|
exports.buildGroupReplyAddress = buildGroupReplyAddress;
|
|
6
7
|
const helpers_1 = require("./helpers");
|
|
7
8
|
const groupReplyAddresses = new Map();
|
|
8
9
|
const GROUP_REPLY_ADDRESS_TTL_MS = 10 * 60 * 1000;
|
|
9
|
-
const GROUP_REPLY_LATEST_ID = "__latest__";
|
|
10
10
|
function normalizeGroupReplyTarget(rawTarget) {
|
|
11
11
|
if (typeof rawTarget !== "string") {
|
|
12
12
|
return String(rawTarget ?? "").trim();
|
|
@@ -33,23 +33,20 @@ function rememberGroupReplyAddress(input) {
|
|
|
33
33
|
address: input.address,
|
|
34
34
|
expiresAt: Date.now() + GROUP_REPLY_ADDRESS_TTL_MS,
|
|
35
35
|
});
|
|
36
|
-
const latestKey = buildGroupReplyAddressKey({
|
|
37
|
-
...input,
|
|
38
|
-
replyToId: GROUP_REPLY_LATEST_ID,
|
|
39
|
-
});
|
|
40
|
-
if (latestKey) {
|
|
41
|
-
groupReplyAddresses.set(latestKey, {
|
|
42
|
-
address: input.address,
|
|
43
|
-
expiresAt: Date.now() + GROUP_REPLY_ADDRESS_TTL_MS,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The address remembered for one specific incoming message.
|
|
39
|
+
*
|
|
40
|
+
* There used to be a `__latest__` entry as well, so that a send carrying no
|
|
41
|
+
* `replyToId` still greeted somebody. In a chat where requests interleave that
|
|
42
|
+
* "somebody" is whoever spoke last, which on 2026-08-10 put the owner's report
|
|
43
|
+
* out as "@colleague, готово": the colleague read a result they had not asked
|
|
44
|
+
* for, and the owner's own request looked unanswered. Recency is not an answer
|
|
45
|
+
* to "who am I replying to", so the fallback is gone — callers pass the message
|
|
46
|
+
* the turn is actually answering, and no message means no greeting.
|
|
47
|
+
*/
|
|
47
48
|
function consumeGroupReplyAddress(input) {
|
|
48
|
-
const
|
|
49
|
-
...input,
|
|
50
|
-
replyToId: GROUP_REPLY_LATEST_ID,
|
|
51
|
-
});
|
|
52
|
-
const key = buildGroupReplyAddressKey(input) ?? latestKey;
|
|
49
|
+
const key = buildGroupReplyAddressKey(input);
|
|
53
50
|
if (!key) {
|
|
54
51
|
return undefined;
|
|
55
52
|
}
|
|
@@ -58,14 +55,15 @@ function consumeGroupReplyAddress(input) {
|
|
|
58
55
|
return undefined;
|
|
59
56
|
}
|
|
60
57
|
groupReplyAddresses.delete(key);
|
|
61
|
-
if (latestKey) {
|
|
62
|
-
groupReplyAddresses.delete(latestKey);
|
|
63
|
-
}
|
|
64
58
|
if (stored.expiresAt < Date.now()) {
|
|
65
59
|
return undefined;
|
|
66
60
|
}
|
|
67
61
|
return stored.address;
|
|
68
62
|
}
|
|
63
|
+
/** Test seam: the map is module state, and suites must not leak into each other. */
|
|
64
|
+
function resetGroupReplyAddresses() {
|
|
65
|
+
groupReplyAddresses.clear();
|
|
66
|
+
}
|
|
69
67
|
function buildGroupReplyAddress(input) {
|
|
70
68
|
const username = input.senderUsername?.replace(/^@/, "").trim();
|
|
71
69
|
if (username) {
|
|
@@ -2,9 +2,23 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.hasRecentVisibleGroupReply = hasRecentVisibleGroupReply;
|
|
4
4
|
exports.rememberVisibleGroupReply = rememberVisibleGroupReply;
|
|
5
|
+
exports.rememberTurnSend = rememberTurnSend;
|
|
6
|
+
exports.hadTurnSendJustNow = hadTurnSendJustNow;
|
|
7
|
+
exports.resetVisibleGroupReplies = resetVisibleGroupReplies;
|
|
5
8
|
const helpers_1 = require("./helpers");
|
|
9
|
+
/** When each visible reply went out, keyed by account + chat + incoming message. */
|
|
6
10
|
const recentVisibleGroupReplies = new Map();
|
|
7
11
|
const GROUP_VISIBLE_REPLY_TTL_MS = 10 * 60 * 1000;
|
|
12
|
+
/**
|
|
13
|
+
* How long after the agent's own send core's delivery of the same turn's final
|
|
14
|
+
* text still counts as an echo rather than as something new.
|
|
15
|
+
*
|
|
16
|
+
* Measured on the live incident: `handleAction send` at 12:39:02, core's
|
|
17
|
+
* delivery at 12:39:09 — seven seconds, twice in a row. Work that produces
|
|
18
|
+
* genuinely new information takes far longer, and the window has to stay well
|
|
19
|
+
* under that: dropping a real result is worse than letting a duplicate through.
|
|
20
|
+
*/
|
|
21
|
+
const GROUP_TURN_ECHO_WINDOW_MS = 20 * 1000;
|
|
8
22
|
function buildVisibleGroupReplyKey(input) {
|
|
9
23
|
const chatId = (0, helpers_1.normalizeOutboundTarget)(String(input.chatId ?? "").trim());
|
|
10
24
|
const currentMessageId = input.currentMessageId === null || input.currentMessageId === undefined
|
|
@@ -39,10 +53,59 @@ function hasRecentVisibleGroupReply(input) {
|
|
|
39
53
|
}
|
|
40
54
|
return true;
|
|
41
55
|
}
|
|
42
|
-
function rememberVisibleGroupReply(input) {
|
|
56
|
+
function rememberVisibleGroupReply(input, sentAt = Date.now()) {
|
|
43
57
|
const key = buildVisibleGroupReplyKey(input);
|
|
44
58
|
if (!key) {
|
|
45
59
|
return;
|
|
46
60
|
}
|
|
47
|
-
recentVisibleGroupReplies.set(key,
|
|
61
|
+
recentVisibleGroupReplies.set(key, sentAt + GROUP_VISIBLE_REPLY_TTL_MS);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* When a turn last spoke for itself, keyed the same way.
|
|
65
|
+
*
|
|
66
|
+
* Kept apart from `recentVisibleGroupReplies` on purpose: that map drives a
|
|
67
|
+
* ten-minute suppression of repeated sends, and widening what feeds it would
|
|
68
|
+
* quietly make that rule stricter. This one answers a different question —
|
|
69
|
+
* did core just echo the turn that has only now finished.
|
|
70
|
+
*/
|
|
71
|
+
const lastTurnSends = new Map();
|
|
72
|
+
/** Records that the agent itself put a message in the chat during this turn. */
|
|
73
|
+
function rememberTurnSend(input, sentAt = Date.now()) {
|
|
74
|
+
const key = buildVisibleGroupReplyKey(input);
|
|
75
|
+
if (!key) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
lastTurnSends.set(key, sentAt);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* True when this turn already put a visible message in this chat moments ago.
|
|
82
|
+
*
|
|
83
|
+
* The case it exists for: the agent answers by calling `send`, then returns
|
|
84
|
+
* text as well, and core delivers that text as a second message. Both are the
|
|
85
|
+
* same answer — on 2026-08-10 every request in a work chat was reported twice,
|
|
86
|
+
* and the reader had to work out that the two messages were one event.
|
|
87
|
+
*
|
|
88
|
+
* Bounded by `GROUP_TURN_ECHO_WINDOW_MS` rather than by the ten-minute TTL:
|
|
89
|
+
* beyond a few seconds the assistant is coming back with something new, and
|
|
90
|
+
* dropping that would lose a real result.
|
|
91
|
+
*/
|
|
92
|
+
function hadTurnSendJustNow(input, now = Date.now()) {
|
|
93
|
+
const key = buildVisibleGroupReplyKey(input);
|
|
94
|
+
if (!key) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
/** Test seam: module state must not leak between suites. */
|
|
108
|
+
function resetVisibleGroupReplies() {
|
|
109
|
+
recentVisibleGroupReplies.clear();
|
|
110
|
+
lastTurnSends.clear();
|
|
48
111
|
}
|
package/dist/helpers.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.resolveSenderProfileWithTimeout = resolveSenderProfileWithTimeout;
|
|
|
38
38
|
exports.normalizeParseMode = normalizeParseMode;
|
|
39
39
|
exports.resolveReplyParseMode = resolveReplyParseMode;
|
|
40
40
|
exports.resolveOutboundParseMode = resolveOutboundParseMode;
|
|
41
|
+
exports.resolveDryRun = resolveDryRun;
|
|
41
42
|
const node_fs_1 = require("node:fs");
|
|
42
43
|
const node_path_1 = __importDefault(require("node:path"));
|
|
43
44
|
const core_1 = require("openclaw/plugin-sdk/core");
|
|
@@ -578,6 +579,25 @@ function resolveReplyParseMode(cfg, accountId) {
|
|
|
578
579
|
const account = channel?.accounts?.[accountId] ?? channel;
|
|
579
580
|
return normalizeParseMode(account?.replyParseMode);
|
|
580
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* Whether this call is a rehearsal, from either position the flag can arrive.
|
|
584
|
+
*
|
|
585
|
+
* Core passes `dryRun` as a sibling of `params`. Callers put it inside
|
|
586
|
+
* `params`, next to `to` and `text`, because that is where every other
|
|
587
|
+
* parameter lives — and there it used to be read by nobody: the flag vanished
|
|
588
|
+
* and the send happened for real.
|
|
589
|
+
*
|
|
590
|
+
* That is the worst possible failure mode for a safety flag, and it has cost
|
|
591
|
+
* two irreversible messages in a work chat (2026-08-08, note 0066; and
|
|
592
|
+
* 2026-08-10 at 02:49 UTC, a bare "ping" the agent then could not delete,
|
|
593
|
+
* because this channel has no delete action). Either position now counts, and
|
|
594
|
+
* a disagreement resolves toward **not** sending: a caller who wrote
|
|
595
|
+
* "dry run" anywhere meant it somewhere.
|
|
596
|
+
*/
|
|
597
|
+
function resolveDryRun(dryRun, params) {
|
|
598
|
+
const fromParams = params?.dryRun;
|
|
599
|
+
return dryRun === true || dryRun === "true" || fromParams === true || fromParams === "true";
|
|
600
|
+
}
|
|
581
601
|
/**
|
|
582
602
|
* Parse mode for the `send` action (2.13.0).
|
|
583
603
|
*
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "clawgram",
|
|
3
3
|
"name": "Clawgram",
|
|
4
4
|
"description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.14.0",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED