clawgram 2.29.2 → 2.30.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 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);
@@ -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
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.29.2",
3
+ "version": "2.30.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clawgram",
9
- "version": "2.29.2",
9
+ "version": "2.30.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "json5": "2.2.3",
@@ -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.29.2",
5
+ "version": "2.30.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawgram",
3
- "version": "2.29.2",
3
+ "version": "2.30.0",
4
4
  "description": "Clawgram — personal Telegram (MTProto userbot) channel for OpenClaw. Your AI assistant reads and responds as you.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {