clawgram 2.29.2 → 2.30.1
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 +11 -0
- package/dist/channel.js +4 -0
- package/dist/inbound-pipeline.js +29 -4
- package/dist/nearby-conversation.js +110 -0
- package/dist/silent-reaction.js +21 -5
- package/npm-shrinkwrap.json +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -300,6 +300,17 @@ Two things are worth knowing before reaching for `open`:
|
|
|
300
300
|
Emoji reactions are unaffected by the rung: the channel leaves one only where
|
|
301
301
|
the agent was genuinely addressed, so background reading stays unmarked.
|
|
302
302
|
|
|
303
|
+
An isolated `@mention` waits up to 60 seconds for an explanation. Short
|
|
304
|
+
addresses wait for 6 seconds of quiet; consecutive messages by the same sender
|
|
305
|
+
in the same chat/topic are collected into one turn within a 2-minute window.
|
|
306
|
+
An unanswered bare tag can still receive a late continuation within that window.
|
|
307
|
+
Other senders and topics do not inherit the address. Detailed requests proceed
|
|
308
|
+
immediately. Before answering an address, the agent receives up to 12 recent
|
|
309
|
+
messages from the same chat/topic (3 minutes before the triggering message,
|
|
310
|
+
including explanations that arrived afterwards), subject to `readChats`.
|
|
311
|
+
History failures leave the original request usable. Attachment metadata includes
|
|
312
|
+
message ids so the agent can read a relevant image with `fetchMedia`.
|
|
313
|
+
|
|
303
314
|
### Per-group tools, skills and system prompt
|
|
304
315
|
|
|
305
316
|
Since 2.17.0 a group entry can narrow what the assistant does *in that chat*
|
package/dist/channel.js
CHANGED
|
@@ -62,6 +62,7 @@ const account_scopes_1 = require("./account-scopes");
|
|
|
62
62
|
Object.defineProperty(exports, "resolveAccountOperatorIds", { enumerable: true, get: function () { return account_scopes_1.resolveAccountOperatorIds; } });
|
|
63
63
|
const outbound_1 = require("./outbound");
|
|
64
64
|
const inbound_pipeline_1 = require("./inbound-pipeline");
|
|
65
|
+
const nearby_conversation_1 = require("./nearby-conversation");
|
|
65
66
|
const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
|
|
66
67
|
/**
|
|
67
68
|
* Хэндл в `allowFrom` — обещание, которое Telegram не держит.
|
|
@@ -271,9 +272,11 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
271
272
|
});
|
|
272
273
|
const client = gram.getClient();
|
|
273
274
|
const eventBuilder = new events_1.NewMessage({});
|
|
275
|
+
const nearbyConversation = new nearby_conversation_1.NearbyConversation();
|
|
274
276
|
const eventHandler = async (event) => (0, inbound_pipeline_1.handleInboundEvent)(event, {
|
|
275
277
|
accountId, cfg, channelRuntime, client, gram, log,
|
|
276
278
|
pluginRuntime, runtimes, selfId, selfLabel, selfUsername,
|
|
279
|
+
nearbyConversation,
|
|
277
280
|
});
|
|
278
281
|
client.addEventHandler(eventHandler, eventBuilder);
|
|
279
282
|
// Being added to a chat arrives as a service message, which `NewMessage`
|
|
@@ -307,6 +310,7 @@ const createChannelPlugin = (runtimes, pluginRuntime) => {
|
|
|
307
310
|
const joinEventBuilder = new events_1.Raw({});
|
|
308
311
|
client.addEventHandler(joinEventHandler, joinEventBuilder);
|
|
309
312
|
await (0, channel_runtime_1.waitUntilAbort)(ctx.abortSignal, async () => {
|
|
313
|
+
nearbyConversation.close();
|
|
310
314
|
client.removeEventHandler(eventHandler, eventBuilder);
|
|
311
315
|
client.removeEventHandler(joinEventHandler, joinEventBuilder);
|
|
312
316
|
(0, account_registry_1.forgetAccount)(accountId);
|
package/dist/inbound-pipeline.js
CHANGED
|
@@ -32,6 +32,8 @@ const account_registry_1 = require("./account-registry");
|
|
|
32
32
|
const group_reply_address_1 = require("./group-reply-address");
|
|
33
33
|
const helpers_1 = require("./helpers");
|
|
34
34
|
const constants_2 = require("./constants");
|
|
35
|
+
const account_scopes_1 = require("./account-scopes");
|
|
36
|
+
const nearby_conversation_1 = require("./nearby-conversation");
|
|
35
37
|
const attachments_1 = require("./attachments");
|
|
36
38
|
/**
|
|
37
39
|
* Wires `reactToSilentMention` to this account's runtime, config and log.
|
|
@@ -116,7 +118,7 @@ function visibleReplyText(params) {
|
|
|
116
118
|
return visibleText;
|
|
117
119
|
}
|
|
118
120
|
async function handleInboundEvent(event, ctx) {
|
|
119
|
-
const { accountId, cfg, channelRuntime, client, gram, log, pluginRuntime, runtimes, selfId, selfLabel, selfUsername } = ctx;
|
|
121
|
+
const { accountId, cfg, channelRuntime, client, gram, log, pluginRuntime, runtimes, selfId, selfLabel, selfUsername, nearbyConversation } = ctx;
|
|
120
122
|
try {
|
|
121
123
|
const rawMessage = event?.message;
|
|
122
124
|
const rawPeerUserId = rawMessage?.peerId?.userId;
|
|
@@ -457,10 +459,12 @@ async function handleInboundEvent(event, ctx) {
|
|
|
457
459
|
// reply does not carry on its own.
|
|
458
460
|
const replyParent = await (0, helpers_1.resolveReplyParent)(rawMessage, { selfId, selfLabel });
|
|
459
461
|
const wasReplyToSelf = replyParent.isSelf;
|
|
462
|
+
const nearbyKey = nearbyConversation?.key(normalized.chatId, normalized.messageThreadId, senderId);
|
|
463
|
+
const isContinuation = nearbyKey !== undefined && nearbyConversation.has(nearbyKey);
|
|
460
464
|
const mentionDecision = (0, channel_inbound_1.resolveInboundMentionDecision)({
|
|
461
465
|
facts: {
|
|
462
466
|
canDetectMention: true,
|
|
463
|
-
wasMentioned,
|
|
467
|
+
wasMentioned: wasMentioned || isContinuation,
|
|
464
468
|
hasAnyMention: /(^|\s)@[a-zA-Z0-9_]{5,}\b/.test(addressableText),
|
|
465
469
|
},
|
|
466
470
|
policy: {
|
|
@@ -493,6 +497,26 @@ async function handleInboundEvent(event, ctx) {
|
|
|
493
497
|
});
|
|
494
498
|
return;
|
|
495
499
|
}
|
|
500
|
+
let nearbyText;
|
|
501
|
+
if (nearbyConversation && (wasMentioned || wasReplyToSelf || isContinuation)) {
|
|
502
|
+
const collected = await nearbyConversation.collect(nearbyKey, text, wasMentioned || wasReplyToSelf);
|
|
503
|
+
if (collected === undefined)
|
|
504
|
+
return;
|
|
505
|
+
text = collected;
|
|
506
|
+
try {
|
|
507
|
+
nearbyText = await (0, nearby_conversation_1.readNearbyConversation)({
|
|
508
|
+
gram,
|
|
509
|
+
chatId: normalized.chatId,
|
|
510
|
+
messageId: normalized.messageId,
|
|
511
|
+
threadId: (0, helpers_1.parseOptionalThreadId)(normalized.messageThreadId),
|
|
512
|
+
timestamp: normalized.timestamp,
|
|
513
|
+
readChats: (0, account_scopes_1.resolveAccountReadChats)(cfg, accountId),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
log?.warn?.("clawgram nearby conversation read failed", { accountId, chatId: normalized.chatId });
|
|
518
|
+
}
|
|
519
|
+
}
|
|
496
520
|
// Who is speaking, resolved only now — past the group gate and the
|
|
497
521
|
// mention gate, so a message the agent will not even read costs no
|
|
498
522
|
// call (B5-04). GramJS attaches `_sender` only from its entity cache,
|
|
@@ -518,7 +542,8 @@ async function handleInboundEvent(event, ctx) {
|
|
|
518
542
|
const conversationRouteTarget = (0, helpers_1.buildConversationTarget)(normalized.chatId);
|
|
519
543
|
const ctxPayload = channelRuntime.reply.finalizeInboundContext({
|
|
520
544
|
Body: body,
|
|
521
|
-
BodyForAgent: agentFacingGroupBody({ address: groupReplyAddress, senderId, text })
|
|
545
|
+
BodyForAgent: agentFacingGroupBody({ address: groupReplyAddress, senderId, text }) +
|
|
546
|
+
(nearbyText ? `\n\n[Соседние сообщения чата — контекст]\n${nearbyText}` : ""),
|
|
522
547
|
RawBody: text,
|
|
523
548
|
CommandBody: text,
|
|
524
549
|
From: conversationRouteTarget,
|
|
@@ -555,7 +580,7 @@ async function handleInboundEvent(event, ctx) {
|
|
|
555
580
|
// Trusted per-group prompt block from `groups.<id>.systemPrompt`.
|
|
556
581
|
// Core normalizes it (`normalizeTrustedTextField`) and appends
|
|
557
582
|
// it to the system prompt for this turn. Undefined = no block.
|
|
558
|
-
GroupSystemPrompt: groupConfig.systemPrompt,
|
|
583
|
+
GroupSystemPrompt: [groupConfig.systemPrompt, nearby_conversation_1.NEARBY_GUIDANCE].filter(Boolean).join("\n\n"),
|
|
559
584
|
OriginatingChannel: "clawgram",
|
|
560
585
|
OriginatingTo: conversationRouteTarget,
|
|
561
586
|
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NEARBY_GUIDANCE = exports.NearbyConversation = void 0;
|
|
4
|
+
exports.addressWaitMs = addressWaitMs;
|
|
5
|
+
exports.readNearbyConversation = readNearbyConversation;
|
|
6
|
+
const history_1 = require("./history");
|
|
7
|
+
/** Short messages are a transport hint, not a verdict that a task is incomplete. */
|
|
8
|
+
function addressWaitMs(text) {
|
|
9
|
+
// A photo sent after the tag often identifies the subject; the actual task is still being typed.
|
|
10
|
+
if (text.startsWith("[изображение]"))
|
|
11
|
+
return 60_000;
|
|
12
|
+
const residual = text.replace(/@[a-zA-Z0-9_]+/g, "").replace(/[\p{P}\p{S}]/gu, "").trim();
|
|
13
|
+
if (!residual)
|
|
14
|
+
return 60_000;
|
|
15
|
+
return residual.split(/\s+/).length <= 8 ? 6_000 : 0;
|
|
16
|
+
}
|
|
17
|
+
/** One instance per running account; never carries addresses across chats/topics/senders. */
|
|
18
|
+
class NearbyConversation {
|
|
19
|
+
timing;
|
|
20
|
+
pending = new Map();
|
|
21
|
+
closed = false;
|
|
22
|
+
constructor(timing = { bareMs: 60_000, quietMs: 6_000, windowMs: 120_000 }) {
|
|
23
|
+
this.timing = timing;
|
|
24
|
+
}
|
|
25
|
+
key(chatId, threadId, senderId) {
|
|
26
|
+
return JSON.stringify([chatId, threadId ?? "", senderId]);
|
|
27
|
+
}
|
|
28
|
+
has(key) {
|
|
29
|
+
this.prune();
|
|
30
|
+
return !this.closed && this.pending.has(key);
|
|
31
|
+
}
|
|
32
|
+
prune() {
|
|
33
|
+
for (const [key, entry] of this.pending) {
|
|
34
|
+
if (entry.expires <= Date.now()) {
|
|
35
|
+
entry.cancel?.();
|
|
36
|
+
this.pending.delete(key);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async collect(key, text, addressed) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
return undefined;
|
|
43
|
+
this.prune();
|
|
44
|
+
const previous = this.pending.get(key);
|
|
45
|
+
if (!addressed && !previous)
|
|
46
|
+
return undefined;
|
|
47
|
+
const wait = addressWaitMs(text);
|
|
48
|
+
if (!previous && wait === 0)
|
|
49
|
+
return text;
|
|
50
|
+
previous?.cancel?.();
|
|
51
|
+
const entry = {
|
|
52
|
+
expires: previous?.expires ?? Date.now() + this.timing.windowMs,
|
|
53
|
+
parts: [...(previous?.parts ?? []), text.slice(0, 8_000)].slice(-8),
|
|
54
|
+
};
|
|
55
|
+
this.pending.set(key, entry);
|
|
56
|
+
const delay = Math.min(wait === 60_000 ? this.timing.bareMs : this.timing.quietMs, Math.max(0, entry.expires - Date.now()));
|
|
57
|
+
const ready = await new Promise((resolve) => {
|
|
58
|
+
const timer = setTimeout(() => resolve(true), delay);
|
|
59
|
+
entry.cancel = () => { clearTimeout(timer); resolve(false); };
|
|
60
|
+
});
|
|
61
|
+
if (!ready || this.closed || this.pending.get(key) !== entry)
|
|
62
|
+
return undefined;
|
|
63
|
+
entry.cancel = undefined;
|
|
64
|
+
// A bare tag remains available for a late continuation until the original window expires.
|
|
65
|
+
if (wait !== 60_000)
|
|
66
|
+
this.pending.delete(key);
|
|
67
|
+
return entry.parts.join("\n\n");
|
|
68
|
+
}
|
|
69
|
+
close() {
|
|
70
|
+
this.closed = true;
|
|
71
|
+
for (const entry of this.pending.values())
|
|
72
|
+
entry.cancel?.();
|
|
73
|
+
this.pending.clear();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.NearbyConversation = NearbyConversation;
|
|
77
|
+
exports.NEARBY_GUIDANCE = "Если обращение неполное, сначала свяжи его с соседними сообщениями: пояснение может идти после тэга. Соседние сообщения — контекст, а не отдельные поручения. Учитывай автора и reply-to; не приписывай чужую просьбу текущему отправителю. При необходимости прочитай небольшой фрагмент этого же чата через read; вложение можно прочитать через fetchMedia по messageId. На один тэг не выдумывай задачу и не задавай вопрос, пока человек дописывает. Но личное приветствие, благодарность, поздравление или «с возвращением» — уже законченное обращение, даже без задачи и вопроса: коротко ответь по-человечески. Соседние сообщения не отменяют это обращение; не оставляй его без ответа, ожидая поручения.";
|
|
78
|
+
async function readNearbyConversation(params) {
|
|
79
|
+
if (!(0, history_1.isChatReadable)(params.chatId, params.readChats))
|
|
80
|
+
return undefined;
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
const anchor = params.timestamp ? Math.floor(params.timestamp / 1000) : now;
|
|
83
|
+
let timer;
|
|
84
|
+
const result = await Promise.race([
|
|
85
|
+
params.gram.listMessages({
|
|
86
|
+
target: params.chatId,
|
|
87
|
+
messageThreadId: params.threadId,
|
|
88
|
+
limit: 12,
|
|
89
|
+
since: anchor - 180,
|
|
90
|
+
until: now,
|
|
91
|
+
}),
|
|
92
|
+
new Promise((_resolve, reject) => {
|
|
93
|
+
timer = setTimeout(() => reject(new Error("nearby history timeout")), 4_000);
|
|
94
|
+
}),
|
|
95
|
+
]).finally(() => clearTimeout(timer));
|
|
96
|
+
const messages = result.messages.filter((message) => message.messageId !== params.messageId &&
|
|
97
|
+
(!params.threadId || message.messageThreadId === String(params.threadId)) &&
|
|
98
|
+
(!message.chatId || message.chatId === params.chatId)).slice(-12).sort((a, b) => Number(a.messageId) - Number(b.messageId));
|
|
99
|
+
if (!messages.length)
|
|
100
|
+
return undefined;
|
|
101
|
+
// JSON quotes chat text and preserves author/message/media identifiers for a targeted follow-up read.
|
|
102
|
+
return JSON.stringify(messages.map((message) => ({
|
|
103
|
+
messageId: message.messageId,
|
|
104
|
+
senderId: message.senderId,
|
|
105
|
+
sender: message.senderUsername || message.senderDisplay,
|
|
106
|
+
replyToMessageId: message.replyToMessageId,
|
|
107
|
+
text: message.text?.slice(0, 1_000),
|
|
108
|
+
media: message.media,
|
|
109
|
+
})));
|
|
110
|
+
}
|
package/dist/silent-reaction.js
CHANGED
|
@@ -41,6 +41,7 @@ function buildEmojiSystemPrompt(appetite, allowed) {
|
|
|
41
41
|
// rather than being touched by it.
|
|
42
42
|
"Three situations have a fixed answer. Use it, and do not substitute a similar emoji:",
|
|
43
43
|
`- the assistant is praised or thanked, or someone speaks well of it → ❤ (never \u{1F44D} here)`,
|
|
44
|
+
"A greeting, welcome back or congratulations addressed to the assistant is also a complete social address: acknowledge it with ❤, not NONE. No task or question is needed.",
|
|
44
45
|
`- the assistant is asked or told to do something → \u{1FAE1}, or \u{1F44C} for a small routine request`,
|
|
45
46
|
`- the message is about producing something written — a text, a reply, a document, a draft → ✍`,
|
|
46
47
|
"Otherwise match the mood: a joke gets something amused, bad news something",
|
|
@@ -169,7 +170,7 @@ async function completeEmoji(params) {
|
|
|
169
170
|
const request = {
|
|
170
171
|
messages: [{ role: "user", content: params.content }],
|
|
171
172
|
systemPrompt: params.systemPrompt,
|
|
172
|
-
maxTokens: 8,
|
|
173
|
+
maxTokens: params.maxTokens ?? 8,
|
|
173
174
|
purpose: "clawgram: emoji reaction for a silent mention",
|
|
174
175
|
};
|
|
175
176
|
if (!params.model) {
|
|
@@ -215,16 +216,28 @@ async function reactToSilentMention(params) {
|
|
|
215
216
|
// Reactions switched off for the whole chat: nothing to pick from, and no
|
|
216
217
|
// reason to spend a model call finding that out.
|
|
217
218
|
if (allowed !== undefined && allowed.length === 0) {
|
|
218
|
-
params.deps.onDecision?.({ messageId, appetite, chose: "none", allowedCount: 0 });
|
|
219
|
+
params.deps.onDecision?.({ messageId, appetite, chose: "none", allowedCount: 0, reason: "reactions-disabled" });
|
|
219
220
|
return undefined;
|
|
220
221
|
}
|
|
221
|
-
const
|
|
222
|
+
const request = {
|
|
222
223
|
deps: params.deps,
|
|
223
224
|
model: params.model,
|
|
224
225
|
systemPrompt: buildEmojiSystemPrompt(appetite, allowed),
|
|
225
226
|
content: String(params.messageText ?? "").slice(0, MAX_JUDGED_CHARS),
|
|
226
|
-
}
|
|
227
|
-
|
|
227
|
+
};
|
|
228
|
+
let { answer, fellBack } = await completeEmoji(request);
|
|
229
|
+
let emoji = parseEmojiChoice(answer?.text, allowed);
|
|
230
|
+
let retried = false;
|
|
231
|
+
// NONE is a valid choice (for example, a conflict between colleagues).
|
|
232
|
+
// Empty/malformed completions are not that choice. Give them one bounded
|
|
233
|
+
// retry with enough room to finish an emoji; never invent a generic reaction.
|
|
234
|
+
if (!emoji && !/^none$/i.test(answer?.text?.trim() ?? "")) {
|
|
235
|
+
retried = true;
|
|
236
|
+
const retry = await completeEmoji({ ...request, maxTokens: 64 });
|
|
237
|
+
answer = retry.answer;
|
|
238
|
+
fellBack ||= retry.fellBack;
|
|
239
|
+
emoji = parseEmojiChoice(answer?.text, allowed);
|
|
240
|
+
}
|
|
228
241
|
params.deps.onDecision?.({
|
|
229
242
|
messageId,
|
|
230
243
|
appetite,
|
|
@@ -233,6 +246,9 @@ async function reactToSilentMention(params) {
|
|
|
233
246
|
allowedCount: allowed?.length,
|
|
234
247
|
...params.model ? { model: params.model } : {},
|
|
235
248
|
...fellBack ? { modelFellBack: true } : {},
|
|
249
|
+
...retried ? { retried: true } : {},
|
|
250
|
+
...!emoji ? { reason: /^none$/i.test(answer?.text?.trim() ?? "")
|
|
251
|
+
? "declined" : answer?.text?.trim() ? "invalid-answer" : "empty-answer" } : {},
|
|
236
252
|
});
|
|
237
253
|
if (!emoji) {
|
|
238
254
|
return undefined;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clawgram",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.30.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clawgram",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.30.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"json5": "2.2.3",
|
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.30.1",
|
|
6
6
|
"configSchema": {
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
package/package.json
CHANGED