clawgram 2.25.0 → 2.26.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.
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleSendAction = handleSendAction;
4
+ const core_1 = require("openclaw/plugin-sdk/core");
5
+ const param_readers_1 = require("openclaw/plugin-sdk/param-readers");
6
+ const account_scopes_1 = require("./account-scopes");
7
+ const group_reply_address_1 = require("./group-reply-address");
8
+ const group_visible_reply_guard_1 = require("./group-visible-reply-guard");
9
+ const helpers_1 = require("./helpers");
10
+ const media_1 = require("./media");
11
+ const reactions_1 = require("./reactions");
12
+ const send_scope_1 = require("./send-scope");
13
+ /**
14
+ * The outbound actions: `react`, `upload-file` (and a `send` carrying a
15
+ * file), and `send` itself — the last branch, which also refuses a name
16
+ * nobody claimed.
17
+ *
18
+ * Cut out of `handleAction` in `channel.ts` unchanged (audit B5-13, part 3);
19
+ * the probe is the proof.
20
+ */
21
+ const actionLog = (0, core_1.createSubsystemLogger)("channels/clawgram");
22
+ async function handleSendAction(ctx) {
23
+ const { action, canonical, params, cfg, accountId, dryRun, toolContext, allowedMediaRoots, readMedia, resolveRuntimeAccountId, requireRuntimeFor, } = ctx;
24
+ // A reaction is an outbound act on someone else's message, so it is
25
+ // gated like sending rather than like reading — and it respects
26
+ // `dryRun`, which reading does not need to.
27
+ if (canonical === "react") {
28
+ const reactionParams = (0, reactions_1.parseReactionParams)(params, toolContext);
29
+ const reactionAccountId = resolveRuntimeAccountId(cfg, accountId);
30
+ if (!reactionAccountId) {
31
+ throw new Error("clawgram: no configured account found");
32
+ }
33
+ // Реакция — видимое действие от имени владельца в чужом чате, и
34
+ // адресуется она так же, как сообщение: та же область (A5-12).
35
+ if (!(0, send_scope_1.isChatSendable)(reactionParams.target, (0, account_scopes_1.resolveAccountSendChats)(cfg, reactionAccountId))) {
36
+ (0, account_scopes_1.refuseOutboundOutsideScope)("react", reactionAccountId, String(reactionParams.target));
37
+ }
38
+ actionLog.info("clawgram handleAction react", {
39
+ accountId: reactionAccountId,
40
+ dryRun: dryRun === true,
41
+ target: reactionParams.target,
42
+ messageId: reactionParams.messageId,
43
+ remove: reactionParams.remove,
44
+ });
45
+ if (dryRun === true) {
46
+ return (0, core_1.jsonResult)({
47
+ ok: true,
48
+ dryRun: true,
49
+ accountId: reactionAccountId,
50
+ chatId: reactionParams.target,
51
+ messageId: reactionParams.messageId,
52
+ removed: reactionParams.remove,
53
+ });
54
+ }
55
+ const reactionGram = requireRuntimeFor(reactionAccountId);
56
+ await reactionGram.sendReaction(reactionParams);
57
+ return (0, core_1.jsonResult)({
58
+ ok: true,
59
+ accountId: reactionAccountId,
60
+ chatId: reactionParams.target,
61
+ messageId: reactionParams.messageId,
62
+ removed: reactionParams.remove,
63
+ });
64
+ }
65
+ // Core normalizes whichever of these it filled in to a local path (see
66
+ // `mediaSourceParams` above); `mediaUrl` stays a URL, which GramJS
67
+ // accepts as well.
68
+ const attachedFile = (0, param_readers_1.readStringParam)(params, "filePath")
69
+ ?? (0, param_readers_1.readStringParam)(params, "path")
70
+ ?? (0, param_readers_1.readStringParam)(params, "media")
71
+ ?? (0, param_readers_1.readStringParam)(params, "mediaUrl");
72
+ // Core dispatches `upload-file`; `sendAttachment` is its legacy alias
73
+ // and arrives from older callers. A plain `send` carrying a file lands
74
+ // here too — `openclaw message send --media` does exactly that, and
75
+ // routing it to the text path dropped the file without a word.
76
+ if (canonical === "upload-file" || (canonical === "send" && attachedFile)) {
77
+ const rawUploadTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
78
+ const uploadTo = (0, helpers_1.normalizeOutboundTarget)(rawUploadTo);
79
+ const uploadAccountId = resolveRuntimeAccountId(cfg, accountId);
80
+ if (!uploadAccountId) {
81
+ throw new Error("clawgram: no configured account found");
82
+ }
83
+ // Та же граница, что у `send`: файл наружу — такое же исходящее.
84
+ if (!(0, send_scope_1.isChatSendable)(uploadTo, (0, account_scopes_1.resolveAccountSendChats)(cfg, uploadAccountId))) {
85
+ (0, account_scopes_1.refuseOutboundOutsideScope)("upload-file", uploadAccountId, uploadTo);
86
+ }
87
+ if (!attachedFile) {
88
+ throw new Error("clawgram: upload-file requires filePath, path, media, or mediaUrl");
89
+ }
90
+ // Before anything else about the message is considered: an
91
+ // out-of-scope path is refused, not sent and then regretted.
92
+ (0, media_1.assertLocalMediaWithinRoots)(attachedFile, allowedMediaRoots);
93
+ const captionText = (0, helpers_1.readMessageText)(params) || ((0, param_readers_1.readStringParam)(params, "caption") ?? "");
94
+ // A caption is optional, but the silent-reply sentinel must never
95
+ // reach Telegram as one — same reasoning as the `send` path below.
96
+ const caption = captionText.trim() && (0, helpers_1.isSilentReplyText)(captionText)
97
+ ? ""
98
+ : captionText.replaceAll("\\n", "\n");
99
+ const uploadReplyToId = (0, param_readers_1.readStringOrNumberParam)(params, "replyToId") ?? (0, param_readers_1.readStringOrNumberParam)(params, "replyTo");
100
+ const uploadThreadId = (0, param_readers_1.readStringOrNumberParam)(params, "threadId");
101
+ const asVoice = (0, helpers_1.readVoiceNoteFlag)(params);
102
+ actionLog.info("clawgram handleAction upload-file", {
103
+ accountId: uploadAccountId,
104
+ dryRun: dryRun === true,
105
+ to: uploadTo,
106
+ hasCaption: Boolean(caption),
107
+ replyToId: uploadReplyToId ?? null,
108
+ threadId: uploadThreadId ?? null,
109
+ asVoice,
110
+ });
111
+ if (dryRun === true) {
112
+ return (0, core_1.jsonResult)({
113
+ ok: true,
114
+ dryRun: true,
115
+ to: uploadTo,
116
+ accountId: uploadAccountId,
117
+ });
118
+ }
119
+ const uploadGram = requireRuntimeFor(uploadAccountId);
120
+ // Read last, through core's scoped reader when it gave one: a dry
121
+ // run or a refusal above must not open the file.
122
+ const file = await (0, media_1.loadOutboundMedia)(attachedFile, allowedMediaRoots, readMedia);
123
+ const uploaded = await uploadGram.sendMedia({
124
+ target: uploadTo,
125
+ file,
126
+ caption: caption || undefined,
127
+ // Same resolution as the text `send`: per-call value wins, an
128
+ // omitted one inherits the account format (2.15.0). A caption is
129
+ // the same prose as a message and renders identically.
130
+ parseMode: (0, helpers_1.resolveOutboundParseMode)(params, cfg, uploadAccountId),
131
+ replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawUploadTo, uploadReplyToId),
132
+ messageThreadId: (0, helpers_1.parseOptionalThreadId)(uploadThreadId),
133
+ asVoice,
134
+ });
135
+ actionLog.info("clawgram handleAction upload-file completed", {
136
+ accountId: uploadAccountId,
137
+ to: uploadTo,
138
+ sentMessageId: String(uploaded?.id ?? ""),
139
+ });
140
+ return (0, core_1.jsonResult)({
141
+ ok: true,
142
+ to: uploadTo,
143
+ accountId: uploadAccountId,
144
+ messageId: String(uploaded?.id ?? ""),
145
+ });
146
+ }
147
+ if (action !== "send") {
148
+ throw new Error(`clawgram: unsupported message action ${action}`);
149
+ }
150
+ const rawTo = (0, helpers_1.resolveActionTarget)(params, toolContext);
151
+ const targetKind = (0, helpers_1.inferOutboundTargetKind)(rawTo);
152
+ const to = (0, helpers_1.normalizeOutboundTarget)(rawTo);
153
+ const replyToId = (0, param_readers_1.readStringOrNumberParam)(params, "replyToId") ?? (0, param_readers_1.readStringOrNumberParam)(params, "replyTo");
154
+ const threadId = (0, param_readers_1.readStringOrNumberParam)(params, "threadId");
155
+ const messageThreadId = (0, helpers_1.parseOptionalThreadId)(threadId);
156
+ // Omitting parseMode inherits the account's configured mode rather
157
+ // than falling back to plain text (2.13.0): an account set to `html`
158
+ // used to render replies as HTML and these sends as raw markup.
159
+ const parseMode = (0, helpers_1.resolveOutboundParseMode)(params, cfg, (0, helpers_1.resolveConfiguredAccountId)(cfg, accountId) ?? accountId ?? "default");
160
+ actionLog.info("clawgram handleAction send", {
161
+ requestedAccountId: accountId,
162
+ dryRun: dryRun === true,
163
+ rawTo,
164
+ to,
165
+ targetKind,
166
+ replyToId: replyToId ?? null,
167
+ threadId: threadId ?? null,
168
+ parseMode: parseMode ?? null,
169
+ toolContextCurrentChannelId: toolContext?.currentChannelId ?? null,
170
+ });
171
+ const resolvedAccountId = resolveRuntimeAccountId(cfg, accountId);
172
+ if (!resolvedAccountId) {
173
+ throw new Error("clawgram: no configured account found");
174
+ }
175
+ // Проверка ПОСЛЕ резолва аккаунта и ДО любой доставки: область задаётся
176
+ // на аккаунт, а отказ должен случиться раньше, чем цель разрешена в
177
+ // Telegram-сущность — resolve сам по себе виден собеседнику (A5-12).
178
+ if (!(0, send_scope_1.isChatSendable)(to, (0, account_scopes_1.resolveAccountSendChats)(cfg, resolvedAccountId))) {
179
+ (0, account_scopes_1.refuseOutboundOutsideScope)("send", resolvedAccountId, to);
180
+ }
181
+ const currentChannelId = toolContext?.currentChannelId?.trim() ?? "";
182
+ const currentMessageId = toolContext?.currentMessageId;
183
+ const currentChannelTarget = currentChannelId ? (0, helpers_1.normalizeOutboundTarget)(currentChannelId) : "";
184
+ const sendingToCurrentGroup = Boolean(currentChannelTarget &&
185
+ currentChannelTarget === to &&
186
+ targetKind === "group");
187
+ if (sendingToCurrentGroup &&
188
+ !replyToId &&
189
+ currentMessageId !== null &&
190
+ currentMessageId !== undefined &&
191
+ (0, group_visible_reply_guard_1.hasRecentVisibleGroupReply)({
192
+ accountId: resolvedAccountId,
193
+ chatId: to,
194
+ currentMessageId,
195
+ })) {
196
+ actionLog.warn("clawgram suppressing duplicate visible group reply", {
197
+ accountId: resolvedAccountId,
198
+ to,
199
+ currentMessageId: String(currentMessageId),
200
+ toolContextCurrentChannelId: currentChannelId || null,
201
+ });
202
+ // A dry run reports the suppression instead of impersonating it: the
203
+ // caller asked what would happen, and what would happen is nothing.
204
+ return (0, core_1.jsonResult)({
205
+ ok: true,
206
+ ...(dryRun ? { dryRun: true } : {}),
207
+ suppressedDuplicate: true,
208
+ to,
209
+ accountId: resolvedAccountId,
210
+ });
211
+ }
212
+ // Whom to greet is decided by the message this turn is answering, not
213
+ // by whoever spoke last. An agent replying to a request rarely passes
214
+ // `replyToId`, and until 2026-08-10 that fell through to the most
215
+ // recent sender: in an interleaved chat the owner's report went out
216
+ // addressed to a colleague who had asked something else entirely.
217
+ // A dry run peeks: consuming the address here left the real send with
218
+ // no greeting, so a rehearsal silently changed the message that went
219
+ // out afterwards.
220
+ const groupReplyAddress = (dryRun ? group_reply_address_1.peekGroupReplyAddress : group_reply_address_1.consumeGroupReplyAddress)({
221
+ accountId: resolvedAccountId,
222
+ chatId: to,
223
+ replyToId: replyToId ?? currentMessageId,
224
+ });
225
+ const requestedText = (0, helpers_1.readMessageText)(params).replaceAll("\\n", "\n");
226
+ // `NO_REPLY` is OpenClaw's "say nothing" sentinel. The inbound pipeline
227
+ // and core both strip it, but an explicit `message.action` call is
228
+ // neither path — and the SDK itself prompts agents to send a message
229
+ // and *then* answer NO_REPLY, so the two are one slip apart. Posting
230
+ // the token into a work chat looks like the assistant malfunctioning.
231
+ //
232
+ // Checked before the reply-address prefix on purpose: prefixing first
233
+ // leaves "Name: " behind, which is not empty, and the token goes out.
234
+ // That is precisely how it once reached the inbound path.
235
+ if (requestedText.trim() && (0, helpers_1.isSilentReplyText)(requestedText)) {
236
+ actionLog.info("clawgram suppressing silent send", {
237
+ accountId: resolvedAccountId,
238
+ to,
239
+ });
240
+ return (0, core_1.jsonResult)({
241
+ ok: true,
242
+ skipped: "silent",
243
+ sent: false,
244
+ to,
245
+ accountId: resolvedAccountId,
246
+ });
247
+ }
248
+ const text = (0, helpers_1.prefixReplyTextToAddress)(requestedText, groupReplyAddress);
249
+ if (!text) {
250
+ throw new Error("clawgram: message text is required");
251
+ }
252
+ if (dryRun) {
253
+ return (0, core_1.jsonResult)({
254
+ ok: true,
255
+ dryRun: true,
256
+ to,
257
+ accountId: resolvedAccountId,
258
+ });
259
+ }
260
+ const gram = requireRuntimeFor(resolvedAccountId);
261
+ const sent = await gram.sendText({
262
+ target: to,
263
+ text,
264
+ targetKind,
265
+ replyToMessageId: (0, helpers_1.resolveReplyToMessageIdForTarget)(rawTo, replyToId),
266
+ messageThreadId,
267
+ parseMode,
268
+ });
269
+ if (sendingToCurrentGroup &&
270
+ !replyToId &&
271
+ currentMessageId !== null &&
272
+ currentMessageId !== undefined) {
273
+ (0, group_visible_reply_guard_1.rememberVisibleGroupReply)({
274
+ accountId: resolvedAccountId,
275
+ chatId: to,
276
+ currentMessageId,
277
+ });
278
+ }
279
+ // The turn has now spoken for itself. Recorded for every send into the
280
+ // chat this turn came from — with or without an explicit replyToId —
281
+ // so that core delivering the turn's final text a few seconds later
282
+ // can be recognised as an echo of this same answer.
283
+ if (currentMessageId !== null && currentMessageId !== undefined) {
284
+ (0, group_visible_reply_guard_1.rememberTurnSend)({
285
+ accountId: resolvedAccountId,
286
+ chatId: to,
287
+ currentMessageId,
288
+ });
289
+ }
290
+ actionLog.info("clawgram handleAction send completed", {
291
+ accountId: resolvedAccountId,
292
+ to,
293
+ replyToId: replyToId ?? null,
294
+ sentMessageId: String(sent?.id ?? ""),
295
+ });
296
+ return (0, core_1.jsonResult)({
297
+ ok: true,
298
+ to,
299
+ accountId: resolvedAccountId,
300
+ messageId: String(sent?.id ?? ""),
301
+ });
302
+ }