@openclaw/bluebubbles 2026.2.12 → 2026.2.13
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/package.json +1 -1
- package/src/monitor-normalize.ts +842 -0
- package/src/monitor-processing.ts +979 -0
- package/src/monitor-reply-cache.ts +185 -0
- package/src/monitor-shared.ts +51 -0
- package/src/monitor.test.ts +2 -2
- package/src/monitor.ts +25 -2025
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
createReplyPrefixOptions,
|
|
4
|
+
logAckFailure,
|
|
5
|
+
logInboundDrop,
|
|
6
|
+
logTypingFailure,
|
|
7
|
+
resolveAckReaction,
|
|
8
|
+
resolveControlCommandGate,
|
|
9
|
+
} from "openclaw/plugin-sdk";
|
|
10
|
+
import type {
|
|
11
|
+
BlueBubblesCoreRuntime,
|
|
12
|
+
BlueBubblesRuntimeEnv,
|
|
13
|
+
WebhookTarget,
|
|
14
|
+
} from "./monitor-shared.js";
|
|
15
|
+
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
|
16
|
+
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
|
17
|
+
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
18
|
+
import {
|
|
19
|
+
buildMessagePlaceholder,
|
|
20
|
+
formatGroupAllowlistEntry,
|
|
21
|
+
formatGroupMembers,
|
|
22
|
+
formatReplyTag,
|
|
23
|
+
parseTapbackText,
|
|
24
|
+
resolveGroupFlagFromChatGuid,
|
|
25
|
+
resolveTapbackContext,
|
|
26
|
+
type NormalizedWebhookMessage,
|
|
27
|
+
type NormalizedWebhookReaction,
|
|
28
|
+
} from "./monitor-normalize.js";
|
|
29
|
+
import {
|
|
30
|
+
getShortIdForUuid,
|
|
31
|
+
rememberBlueBubblesReplyCache,
|
|
32
|
+
resolveBlueBubblesMessageId,
|
|
33
|
+
resolveReplyContextFromCache,
|
|
34
|
+
} from "./monitor-reply-cache.js";
|
|
35
|
+
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
|
36
|
+
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
37
|
+
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TEXT_LIMIT = 4000;
|
|
40
|
+
const invalidAckReactions = new Set<string>();
|
|
41
|
+
|
|
42
|
+
export function logVerbose(
|
|
43
|
+
core: BlueBubblesCoreRuntime,
|
|
44
|
+
runtime: BlueBubblesRuntimeEnv,
|
|
45
|
+
message: string,
|
|
46
|
+
): void {
|
|
47
|
+
if (core.logging.shouldLogVerbose()) {
|
|
48
|
+
runtime.log?.(`[bluebubbles] ${message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function logGroupAllowlistHint(params: {
|
|
53
|
+
runtime: BlueBubblesRuntimeEnv;
|
|
54
|
+
reason: string;
|
|
55
|
+
entry: string | null;
|
|
56
|
+
chatName?: string;
|
|
57
|
+
accountId?: string;
|
|
58
|
+
}): void {
|
|
59
|
+
const log = params.runtime.log ?? console.log;
|
|
60
|
+
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
|
|
61
|
+
const accountHint = params.accountId
|
|
62
|
+
? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
|
|
63
|
+
: "";
|
|
64
|
+
if (params.entry) {
|
|
65
|
+
log(
|
|
66
|
+
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
|
|
67
|
+
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
|
68
|
+
);
|
|
69
|
+
log(
|
|
70
|
+
`[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
|
|
71
|
+
);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
log(
|
|
75
|
+
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
|
|
76
|
+
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
|
|
77
|
+
`channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveBlueBubblesAckReaction(params: {
|
|
82
|
+
cfg: OpenClawConfig;
|
|
83
|
+
agentId: string;
|
|
84
|
+
core: BlueBubblesCoreRuntime;
|
|
85
|
+
runtime: BlueBubblesRuntimeEnv;
|
|
86
|
+
}): string | null {
|
|
87
|
+
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
|
88
|
+
if (!raw) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
normalizeBlueBubblesReactionInput(raw);
|
|
93
|
+
return raw;
|
|
94
|
+
} catch {
|
|
95
|
+
const key = raw.toLowerCase();
|
|
96
|
+
if (!invalidAckReactions.has(key)) {
|
|
97
|
+
invalidAckReactions.add(key);
|
|
98
|
+
logVerbose(
|
|
99
|
+
params.core,
|
|
100
|
+
params.runtime,
|
|
101
|
+
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function processMessage(
|
|
109
|
+
message: NormalizedWebhookMessage,
|
|
110
|
+
target: WebhookTarget,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const { account, config, runtime, core, statusSink } = target;
|
|
113
|
+
|
|
114
|
+
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
|
115
|
+
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
|
116
|
+
|
|
117
|
+
const text = message.text.trim();
|
|
118
|
+
const attachments = message.attachments ?? [];
|
|
119
|
+
const placeholder = buildMessagePlaceholder(message);
|
|
120
|
+
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
|
121
|
+
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
|
122
|
+
const tapbackContext = resolveTapbackContext(message);
|
|
123
|
+
const tapbackParsed = parseTapbackText({
|
|
124
|
+
text,
|
|
125
|
+
emojiHint: tapbackContext?.emojiHint,
|
|
126
|
+
actionHint: tapbackContext?.actionHint,
|
|
127
|
+
requireQuoted: !tapbackContext,
|
|
128
|
+
});
|
|
129
|
+
const isTapbackMessage = Boolean(tapbackParsed);
|
|
130
|
+
const rawBody = tapbackParsed
|
|
131
|
+
? tapbackParsed.action === "removed"
|
|
132
|
+
? `removed ${tapbackParsed.emoji} reaction`
|
|
133
|
+
: `reacted with ${tapbackParsed.emoji}`
|
|
134
|
+
: text || placeholder;
|
|
135
|
+
|
|
136
|
+
const cacheMessageId = message.messageId?.trim();
|
|
137
|
+
let messageShortId: string | undefined;
|
|
138
|
+
const cacheInboundMessage = () => {
|
|
139
|
+
if (!cacheMessageId) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const cacheEntry = rememberBlueBubblesReplyCache({
|
|
143
|
+
accountId: account.accountId,
|
|
144
|
+
messageId: cacheMessageId,
|
|
145
|
+
chatGuid: message.chatGuid,
|
|
146
|
+
chatIdentifier: message.chatIdentifier,
|
|
147
|
+
chatId: message.chatId,
|
|
148
|
+
senderLabel: message.fromMe ? "me" : message.senderId,
|
|
149
|
+
body: rawBody,
|
|
150
|
+
timestamp: message.timestamp ?? Date.now(),
|
|
151
|
+
});
|
|
152
|
+
messageShortId = cacheEntry.shortId;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (message.fromMe) {
|
|
156
|
+
// Cache from-me messages so reply context can resolve sender/body.
|
|
157
|
+
cacheInboundMessage();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!rawBody) {
|
|
162
|
+
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
logVerbose(
|
|
166
|
+
core,
|
|
167
|
+
runtime,
|
|
168
|
+
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
172
|
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
|
173
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
174
|
+
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
|
175
|
+
const storeAllowFrom = await core.channel.pairing
|
|
176
|
+
.readAllowFromStore("bluebubbles")
|
|
177
|
+
.catch(() => []);
|
|
178
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
|
179
|
+
.map((entry) => String(entry).trim())
|
|
180
|
+
.filter(Boolean);
|
|
181
|
+
const effectiveGroupAllowFrom = [
|
|
182
|
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
|
183
|
+
...storeAllowFrom,
|
|
184
|
+
]
|
|
185
|
+
.map((entry) => String(entry).trim())
|
|
186
|
+
.filter(Boolean);
|
|
187
|
+
const groupAllowEntry = formatGroupAllowlistEntry({
|
|
188
|
+
chatGuid: message.chatGuid,
|
|
189
|
+
chatId: message.chatId ?? undefined,
|
|
190
|
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
const groupName = message.chatName?.trim() || undefined;
|
|
193
|
+
|
|
194
|
+
if (isGroup) {
|
|
195
|
+
if (groupPolicy === "disabled") {
|
|
196
|
+
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
|
197
|
+
logGroupAllowlistHint({
|
|
198
|
+
runtime,
|
|
199
|
+
reason: "groupPolicy=disabled",
|
|
200
|
+
entry: groupAllowEntry,
|
|
201
|
+
chatName: groupName,
|
|
202
|
+
accountId: account.accountId,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (groupPolicy === "allowlist") {
|
|
207
|
+
if (effectiveGroupAllowFrom.length === 0) {
|
|
208
|
+
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
|
209
|
+
logGroupAllowlistHint({
|
|
210
|
+
runtime,
|
|
211
|
+
reason: "groupPolicy=allowlist (empty allowlist)",
|
|
212
|
+
entry: groupAllowEntry,
|
|
213
|
+
chatName: groupName,
|
|
214
|
+
accountId: account.accountId,
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
219
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
220
|
+
sender: message.senderId,
|
|
221
|
+
chatId: message.chatId ?? undefined,
|
|
222
|
+
chatGuid: message.chatGuid ?? undefined,
|
|
223
|
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
|
224
|
+
});
|
|
225
|
+
if (!allowed) {
|
|
226
|
+
logVerbose(
|
|
227
|
+
core,
|
|
228
|
+
runtime,
|
|
229
|
+
`Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
|
|
230
|
+
);
|
|
231
|
+
logVerbose(
|
|
232
|
+
core,
|
|
233
|
+
runtime,
|
|
234
|
+
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
|
235
|
+
);
|
|
236
|
+
logGroupAllowlistHint({
|
|
237
|
+
runtime,
|
|
238
|
+
reason: "groupPolicy=allowlist (not allowlisted)",
|
|
239
|
+
entry: groupAllowEntry,
|
|
240
|
+
chatName: groupName,
|
|
241
|
+
accountId: account.accountId,
|
|
242
|
+
});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
if (dmPolicy === "disabled") {
|
|
248
|
+
logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
|
|
249
|
+
logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (dmPolicy !== "open") {
|
|
253
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
254
|
+
allowFrom: effectiveAllowFrom,
|
|
255
|
+
sender: message.senderId,
|
|
256
|
+
chatId: message.chatId ?? undefined,
|
|
257
|
+
chatGuid: message.chatGuid ?? undefined,
|
|
258
|
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
|
259
|
+
});
|
|
260
|
+
if (!allowed) {
|
|
261
|
+
if (dmPolicy === "pairing") {
|
|
262
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
263
|
+
channel: "bluebubbles",
|
|
264
|
+
id: message.senderId,
|
|
265
|
+
meta: { name: message.senderName },
|
|
266
|
+
});
|
|
267
|
+
runtime.log?.(
|
|
268
|
+
`[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
|
|
269
|
+
);
|
|
270
|
+
if (created) {
|
|
271
|
+
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
|
|
272
|
+
try {
|
|
273
|
+
await sendMessageBlueBubbles(
|
|
274
|
+
message.senderId,
|
|
275
|
+
core.channel.pairing.buildPairingReply({
|
|
276
|
+
channel: "bluebubbles",
|
|
277
|
+
idLine: `Your BlueBubbles sender id: ${message.senderId}`,
|
|
278
|
+
code,
|
|
279
|
+
}),
|
|
280
|
+
{ cfg: config, accountId: account.accountId },
|
|
281
|
+
);
|
|
282
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
283
|
+
} catch (err) {
|
|
284
|
+
logVerbose(
|
|
285
|
+
core,
|
|
286
|
+
runtime,
|
|
287
|
+
`bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
|
|
288
|
+
);
|
|
289
|
+
runtime.error?.(
|
|
290
|
+
`[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
} else {
|
|
295
|
+
logVerbose(
|
|
296
|
+
core,
|
|
297
|
+
runtime,
|
|
298
|
+
`Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
|
|
299
|
+
);
|
|
300
|
+
logVerbose(
|
|
301
|
+
core,
|
|
302
|
+
runtime,
|
|
303
|
+
`drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const chatId = message.chatId ?? undefined;
|
|
312
|
+
const chatGuid = message.chatGuid ?? undefined;
|
|
313
|
+
const chatIdentifier = message.chatIdentifier ?? undefined;
|
|
314
|
+
const peerId = isGroup
|
|
315
|
+
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
|
316
|
+
: message.senderId;
|
|
317
|
+
|
|
318
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
319
|
+
cfg: config,
|
|
320
|
+
channel: "bluebubbles",
|
|
321
|
+
accountId: account.accountId,
|
|
322
|
+
peer: {
|
|
323
|
+
kind: isGroup ? "group" : "direct",
|
|
324
|
+
id: peerId,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Mention gating for group chats (parity with iMessage/WhatsApp)
|
|
329
|
+
const messageText = text;
|
|
330
|
+
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
|
331
|
+
const wasMentioned = isGroup
|
|
332
|
+
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
|
|
333
|
+
: true;
|
|
334
|
+
const canDetectMention = mentionRegexes.length > 0;
|
|
335
|
+
const requireMention = core.channel.groups.resolveRequireMention({
|
|
336
|
+
cfg: config,
|
|
337
|
+
channel: "bluebubbles",
|
|
338
|
+
groupId: peerId,
|
|
339
|
+
accountId: account.accountId,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Command gating (parity with iMessage/WhatsApp)
|
|
343
|
+
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
344
|
+
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
|
|
345
|
+
const ownerAllowedForCommands =
|
|
346
|
+
effectiveAllowFrom.length > 0
|
|
347
|
+
? isAllowedBlueBubblesSender({
|
|
348
|
+
allowFrom: effectiveAllowFrom,
|
|
349
|
+
sender: message.senderId,
|
|
350
|
+
chatId: message.chatId ?? undefined,
|
|
351
|
+
chatGuid: message.chatGuid ?? undefined,
|
|
352
|
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
|
353
|
+
})
|
|
354
|
+
: false;
|
|
355
|
+
const groupAllowedForCommands =
|
|
356
|
+
effectiveGroupAllowFrom.length > 0
|
|
357
|
+
? isAllowedBlueBubblesSender({
|
|
358
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
359
|
+
sender: message.senderId,
|
|
360
|
+
chatId: message.chatId ?? undefined,
|
|
361
|
+
chatGuid: message.chatGuid ?? undefined,
|
|
362
|
+
chatIdentifier: message.chatIdentifier ?? undefined,
|
|
363
|
+
})
|
|
364
|
+
: false;
|
|
365
|
+
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
|
366
|
+
const commandGate = resolveControlCommandGate({
|
|
367
|
+
useAccessGroups,
|
|
368
|
+
authorizers: [
|
|
369
|
+
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
|
370
|
+
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
|
371
|
+
],
|
|
372
|
+
allowTextCommands: true,
|
|
373
|
+
hasControlCommand: hasControlCmd,
|
|
374
|
+
});
|
|
375
|
+
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
|
376
|
+
|
|
377
|
+
// Block control commands from unauthorized senders in groups
|
|
378
|
+
if (isGroup && commandGate.shouldBlock) {
|
|
379
|
+
logInboundDrop({
|
|
380
|
+
log: (msg) => logVerbose(core, runtime, msg),
|
|
381
|
+
channel: "bluebubbles",
|
|
382
|
+
reason: "control command (unauthorized)",
|
|
383
|
+
target: message.senderId,
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
|
|
389
|
+
const shouldBypassMention =
|
|
390
|
+
isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
|
|
391
|
+
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
|
392
|
+
|
|
393
|
+
// Skip group messages that require mention but weren't mentioned
|
|
394
|
+
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
|
395
|
+
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Cache allowed inbound messages so later replies can resolve sender/body without
|
|
400
|
+
// surfacing dropped content (allowlist/mention/command gating).
|
|
401
|
+
cacheInboundMessage();
|
|
402
|
+
|
|
403
|
+
const baseUrl = account.config.serverUrl?.trim();
|
|
404
|
+
const password = account.config.password?.trim();
|
|
405
|
+
const maxBytes =
|
|
406
|
+
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
|
407
|
+
? account.config.mediaMaxMb * 1024 * 1024
|
|
408
|
+
: 8 * 1024 * 1024;
|
|
409
|
+
|
|
410
|
+
let mediaUrls: string[] = [];
|
|
411
|
+
let mediaPaths: string[] = [];
|
|
412
|
+
let mediaTypes: string[] = [];
|
|
413
|
+
if (attachments.length > 0) {
|
|
414
|
+
if (!baseUrl || !password) {
|
|
415
|
+
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
|
|
416
|
+
} else {
|
|
417
|
+
for (const attachment of attachments) {
|
|
418
|
+
if (!attachment.guid) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
|
|
422
|
+
logVerbose(
|
|
423
|
+
core,
|
|
424
|
+
runtime,
|
|
425
|
+
`attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
|
|
426
|
+
);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
const downloaded = await downloadBlueBubblesAttachment(attachment, {
|
|
431
|
+
cfg: config,
|
|
432
|
+
accountId: account.accountId,
|
|
433
|
+
maxBytes,
|
|
434
|
+
});
|
|
435
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
436
|
+
Buffer.from(downloaded.buffer),
|
|
437
|
+
downloaded.contentType,
|
|
438
|
+
"inbound",
|
|
439
|
+
maxBytes,
|
|
440
|
+
);
|
|
441
|
+
mediaPaths.push(saved.path);
|
|
442
|
+
mediaUrls.push(saved.path);
|
|
443
|
+
if (saved.contentType) {
|
|
444
|
+
mediaTypes.push(saved.contentType);
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
logVerbose(
|
|
448
|
+
core,
|
|
449
|
+
runtime,
|
|
450
|
+
`attachment download failed guid=${attachment.guid} err=${String(err)}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
let replyToId = message.replyToId;
|
|
457
|
+
let replyToBody = message.replyToBody;
|
|
458
|
+
let replyToSender = message.replyToSender;
|
|
459
|
+
let replyToShortId: string | undefined;
|
|
460
|
+
|
|
461
|
+
if (isTapbackMessage && tapbackContext?.replyToId) {
|
|
462
|
+
replyToId = tapbackContext.replyToId;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (replyToId) {
|
|
466
|
+
const cached = resolveReplyContextFromCache({
|
|
467
|
+
accountId: account.accountId,
|
|
468
|
+
replyToId,
|
|
469
|
+
chatGuid: message.chatGuid,
|
|
470
|
+
chatIdentifier: message.chatIdentifier,
|
|
471
|
+
chatId: message.chatId,
|
|
472
|
+
});
|
|
473
|
+
if (cached) {
|
|
474
|
+
if (!replyToBody && cached.body) {
|
|
475
|
+
replyToBody = cached.body;
|
|
476
|
+
}
|
|
477
|
+
if (!replyToSender && cached.senderLabel) {
|
|
478
|
+
replyToSender = cached.senderLabel;
|
|
479
|
+
}
|
|
480
|
+
replyToShortId = cached.shortId;
|
|
481
|
+
if (core.logging.shouldLogVerbose()) {
|
|
482
|
+
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
|
483
|
+
logVerbose(
|
|
484
|
+
core,
|
|
485
|
+
runtime,
|
|
486
|
+
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// If no cached short ID, try to get one from the UUID directly
|
|
493
|
+
if (replyToId && !replyToShortId) {
|
|
494
|
+
replyToShortId = getShortIdForUuid(replyToId);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Use inline [[reply_to:N]] tag format
|
|
498
|
+
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
|
499
|
+
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
|
500
|
+
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
|
501
|
+
const baseBody = replyTag
|
|
502
|
+
? isTapbackMessage
|
|
503
|
+
? `${rawBody} ${replyTag}`
|
|
504
|
+
: `${replyTag} ${rawBody}`
|
|
505
|
+
: rawBody;
|
|
506
|
+
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
|
507
|
+
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
|
508
|
+
const groupMembers = isGroup
|
|
509
|
+
? formatGroupMembers({
|
|
510
|
+
participants: message.participants,
|
|
511
|
+
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
|
|
512
|
+
})
|
|
513
|
+
: undefined;
|
|
514
|
+
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
515
|
+
agentId: route.agentId,
|
|
516
|
+
});
|
|
517
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
518
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
519
|
+
storePath,
|
|
520
|
+
sessionKey: route.sessionKey,
|
|
521
|
+
});
|
|
522
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
523
|
+
channel: "BlueBubbles",
|
|
524
|
+
from: fromLabel,
|
|
525
|
+
timestamp: message.timestamp,
|
|
526
|
+
previousTimestamp,
|
|
527
|
+
envelope: envelopeOptions,
|
|
528
|
+
body: baseBody,
|
|
529
|
+
});
|
|
530
|
+
let chatGuidForActions = chatGuid;
|
|
531
|
+
if (!chatGuidForActions && baseUrl && password) {
|
|
532
|
+
const resolveTarget =
|
|
533
|
+
isGroup && (chatId || chatIdentifier)
|
|
534
|
+
? chatId
|
|
535
|
+
? ({ kind: "chat_id", chatId } as const)
|
|
536
|
+
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
|
537
|
+
: ({ kind: "handle", address: message.senderId } as const);
|
|
538
|
+
if (resolveTarget.kind !== "chat_identifier" || resolveTarget.chatIdentifier) {
|
|
539
|
+
chatGuidForActions =
|
|
540
|
+
(await resolveChatGuidForTarget({
|
|
541
|
+
baseUrl,
|
|
542
|
+
password,
|
|
543
|
+
target: resolveTarget,
|
|
544
|
+
})) ?? undefined;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
|
|
549
|
+
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
|
|
550
|
+
const ackReactionValue = resolveBlueBubblesAckReaction({
|
|
551
|
+
cfg: config,
|
|
552
|
+
agentId: route.agentId,
|
|
553
|
+
core,
|
|
554
|
+
runtime,
|
|
555
|
+
});
|
|
556
|
+
const shouldAckReaction = () =>
|
|
557
|
+
Boolean(
|
|
558
|
+
ackReactionValue &&
|
|
559
|
+
core.channel.reactions.shouldAckReaction({
|
|
560
|
+
scope: ackReactionScope,
|
|
561
|
+
isDirect: !isGroup,
|
|
562
|
+
isGroup,
|
|
563
|
+
isMentionableGroup: isGroup,
|
|
564
|
+
requireMention: Boolean(requireMention),
|
|
565
|
+
canDetectMention,
|
|
566
|
+
effectiveWasMentioned,
|
|
567
|
+
shouldBypassMention,
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
const ackMessageId = message.messageId?.trim() || "";
|
|
571
|
+
const ackReactionPromise =
|
|
572
|
+
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
|
573
|
+
? sendBlueBubblesReaction({
|
|
574
|
+
chatGuid: chatGuidForActions,
|
|
575
|
+
messageGuid: ackMessageId,
|
|
576
|
+
emoji: ackReactionValue,
|
|
577
|
+
opts: { cfg: config, accountId: account.accountId },
|
|
578
|
+
}).then(
|
|
579
|
+
() => true,
|
|
580
|
+
(err) => {
|
|
581
|
+
logVerbose(
|
|
582
|
+
core,
|
|
583
|
+
runtime,
|
|
584
|
+
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
|
585
|
+
);
|
|
586
|
+
return false;
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
: null;
|
|
590
|
+
|
|
591
|
+
// Respect sendReadReceipts config (parity with WhatsApp)
|
|
592
|
+
const sendReadReceipts = account.config.sendReadReceipts !== false;
|
|
593
|
+
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
|
|
594
|
+
try {
|
|
595
|
+
await markBlueBubblesChatRead(chatGuidForActions, {
|
|
596
|
+
cfg: config,
|
|
597
|
+
accountId: account.accountId,
|
|
598
|
+
});
|
|
599
|
+
logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
|
|
600
|
+
} catch (err) {
|
|
601
|
+
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
|
602
|
+
}
|
|
603
|
+
} else if (!sendReadReceipts) {
|
|
604
|
+
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
|
|
605
|
+
} else {
|
|
606
|
+
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const outboundTarget = isGroup
|
|
610
|
+
? formatBlueBubblesChatTarget({
|
|
611
|
+
chatId,
|
|
612
|
+
chatGuid: chatGuidForActions ?? chatGuid,
|
|
613
|
+
chatIdentifier,
|
|
614
|
+
}) || peerId
|
|
615
|
+
: chatGuidForActions
|
|
616
|
+
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
|
617
|
+
: message.senderId;
|
|
618
|
+
|
|
619
|
+
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
|
620
|
+
const trimmed = messageId?.trim();
|
|
621
|
+
if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
// Cache outbound message to get short ID
|
|
625
|
+
const cacheEntry = rememberBlueBubblesReplyCache({
|
|
626
|
+
accountId: account.accountId,
|
|
627
|
+
messageId: trimmed,
|
|
628
|
+
chatGuid: chatGuidForActions ?? chatGuid,
|
|
629
|
+
chatIdentifier,
|
|
630
|
+
chatId,
|
|
631
|
+
senderLabel: "me",
|
|
632
|
+
body: snippet ?? "",
|
|
633
|
+
timestamp: Date.now(),
|
|
634
|
+
});
|
|
635
|
+
const displayId = cacheEntry.shortId || trimmed;
|
|
636
|
+
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
|
|
637
|
+
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
|
638
|
+
sessionKey: route.sessionKey,
|
|
639
|
+
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
|
640
|
+
});
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const ctxPayload = {
|
|
644
|
+
Body: body,
|
|
645
|
+
BodyForAgent: body,
|
|
646
|
+
RawBody: rawBody,
|
|
647
|
+
CommandBody: rawBody,
|
|
648
|
+
BodyForCommands: rawBody,
|
|
649
|
+
MediaUrl: mediaUrls[0],
|
|
650
|
+
MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
|
651
|
+
MediaPath: mediaPaths[0],
|
|
652
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
653
|
+
MediaType: mediaTypes[0],
|
|
654
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
655
|
+
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
|
656
|
+
To: `bluebubbles:${outboundTarget}`,
|
|
657
|
+
SessionKey: route.sessionKey,
|
|
658
|
+
AccountId: route.accountId,
|
|
659
|
+
ChatType: isGroup ? "group" : "direct",
|
|
660
|
+
ConversationLabel: fromLabel,
|
|
661
|
+
// Use short ID for token savings (agent can use this to reference the message)
|
|
662
|
+
ReplyToId: replyToShortId || replyToId,
|
|
663
|
+
ReplyToIdFull: replyToId,
|
|
664
|
+
ReplyToBody: replyToBody,
|
|
665
|
+
ReplyToSender: replyToSender,
|
|
666
|
+
GroupSubject: groupSubject,
|
|
667
|
+
GroupMembers: groupMembers,
|
|
668
|
+
SenderName: message.senderName || undefined,
|
|
669
|
+
SenderId: message.senderId,
|
|
670
|
+
Provider: "bluebubbles",
|
|
671
|
+
Surface: "bluebubbles",
|
|
672
|
+
// Use short ID for token savings (agent can use this to reference the message)
|
|
673
|
+
MessageSid: messageShortId || message.messageId,
|
|
674
|
+
MessageSidFull: message.messageId,
|
|
675
|
+
Timestamp: message.timestamp,
|
|
676
|
+
OriginatingChannel: "bluebubbles",
|
|
677
|
+
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
|
678
|
+
WasMentioned: effectiveWasMentioned,
|
|
679
|
+
CommandAuthorized: commandAuthorized,
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
let sentMessage = false;
|
|
683
|
+
let streamingActive = false;
|
|
684
|
+
let typingRestartTimer: NodeJS.Timeout | undefined;
|
|
685
|
+
const typingRestartDelayMs = 150;
|
|
686
|
+
const clearTypingRestartTimer = () => {
|
|
687
|
+
if (typingRestartTimer) {
|
|
688
|
+
clearTimeout(typingRestartTimer);
|
|
689
|
+
typingRestartTimer = undefined;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
const restartTypingSoon = () => {
|
|
693
|
+
if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
clearTypingRestartTimer();
|
|
697
|
+
typingRestartTimer = setTimeout(() => {
|
|
698
|
+
typingRestartTimer = undefined;
|
|
699
|
+
if (!streamingActive) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
sendBlueBubblesTyping(chatGuidForActions, true, {
|
|
703
|
+
cfg: config,
|
|
704
|
+
accountId: account.accountId,
|
|
705
|
+
}).catch((err) => {
|
|
706
|
+
runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
|
|
707
|
+
});
|
|
708
|
+
}, typingRestartDelayMs);
|
|
709
|
+
};
|
|
710
|
+
try {
|
|
711
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
712
|
+
cfg: config,
|
|
713
|
+
agentId: route.agentId,
|
|
714
|
+
channel: "bluebubbles",
|
|
715
|
+
accountId: account.accountId,
|
|
716
|
+
});
|
|
717
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
718
|
+
ctx: ctxPayload,
|
|
719
|
+
cfg: config,
|
|
720
|
+
dispatcherOptions: {
|
|
721
|
+
...prefixOptions,
|
|
722
|
+
deliver: async (payload, info) => {
|
|
723
|
+
const rawReplyToId =
|
|
724
|
+
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
|
725
|
+
// Resolve short ID (e.g., "5") to full UUID
|
|
726
|
+
const replyToMessageGuid = rawReplyToId
|
|
727
|
+
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
|
728
|
+
: "";
|
|
729
|
+
const mediaList = payload.mediaUrls?.length
|
|
730
|
+
? payload.mediaUrls
|
|
731
|
+
: payload.mediaUrl
|
|
732
|
+
? [payload.mediaUrl]
|
|
733
|
+
: [];
|
|
734
|
+
if (mediaList.length > 0) {
|
|
735
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
736
|
+
cfg: config,
|
|
737
|
+
channel: "bluebubbles",
|
|
738
|
+
accountId: account.accountId,
|
|
739
|
+
});
|
|
740
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
741
|
+
let first = true;
|
|
742
|
+
for (const mediaUrl of mediaList) {
|
|
743
|
+
const caption = first ? text : undefined;
|
|
744
|
+
first = false;
|
|
745
|
+
const result = await sendBlueBubblesMedia({
|
|
746
|
+
cfg: config,
|
|
747
|
+
to: outboundTarget,
|
|
748
|
+
mediaUrl,
|
|
749
|
+
caption: caption ?? undefined,
|
|
750
|
+
replyToId: replyToMessageGuid || null,
|
|
751
|
+
accountId: account.accountId,
|
|
752
|
+
});
|
|
753
|
+
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
|
754
|
+
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
|
755
|
+
sentMessage = true;
|
|
756
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
757
|
+
if (info.kind === "block") {
|
|
758
|
+
restartTypingSoon();
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const textLimit =
|
|
765
|
+
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
|
766
|
+
? account.config.textChunkLimit
|
|
767
|
+
: DEFAULT_TEXT_LIMIT;
|
|
768
|
+
const chunkMode = account.config.chunkMode ?? "length";
|
|
769
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
770
|
+
cfg: config,
|
|
771
|
+
channel: "bluebubbles",
|
|
772
|
+
accountId: account.accountId,
|
|
773
|
+
});
|
|
774
|
+
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
775
|
+
const chunks =
|
|
776
|
+
chunkMode === "newline"
|
|
777
|
+
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
|
778
|
+
: core.channel.text.chunkMarkdownText(text, textLimit);
|
|
779
|
+
if (!chunks.length && text) {
|
|
780
|
+
chunks.push(text);
|
|
781
|
+
}
|
|
782
|
+
if (!chunks.length) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
for (const chunk of chunks) {
|
|
786
|
+
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
|
787
|
+
cfg: config,
|
|
788
|
+
accountId: account.accountId,
|
|
789
|
+
replyToMessageGuid: replyToMessageGuid || undefined,
|
|
790
|
+
});
|
|
791
|
+
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
|
792
|
+
sentMessage = true;
|
|
793
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
794
|
+
if (info.kind === "block") {
|
|
795
|
+
restartTypingSoon();
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
onReplyStart: async () => {
|
|
800
|
+
if (!chatGuidForActions) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!baseUrl || !password) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
streamingActive = true;
|
|
807
|
+
clearTypingRestartTimer();
|
|
808
|
+
try {
|
|
809
|
+
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
|
810
|
+
cfg: config,
|
|
811
|
+
accountId: account.accountId,
|
|
812
|
+
});
|
|
813
|
+
} catch (err) {
|
|
814
|
+
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
onIdle: async () => {
|
|
818
|
+
if (!chatGuidForActions) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (!baseUrl || !password) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
// Intentionally no-op for block streaming. We stop typing in finally
|
|
825
|
+
// after the run completes to avoid flicker between paragraph blocks.
|
|
826
|
+
},
|
|
827
|
+
onError: (err, info) => {
|
|
828
|
+
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
replyOptions: {
|
|
832
|
+
onModelSelected,
|
|
833
|
+
disableBlockStreaming:
|
|
834
|
+
typeof account.config.blockStreaming === "boolean"
|
|
835
|
+
? !account.config.blockStreaming
|
|
836
|
+
: undefined,
|
|
837
|
+
},
|
|
838
|
+
});
|
|
839
|
+
} finally {
|
|
840
|
+
const shouldStopTyping =
|
|
841
|
+
Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
|
|
842
|
+
streamingActive = false;
|
|
843
|
+
clearTypingRestartTimer();
|
|
844
|
+
if (sentMessage && chatGuidForActions && ackMessageId) {
|
|
845
|
+
core.channel.reactions.removeAckReactionAfterReply({
|
|
846
|
+
removeAfterReply: removeAckAfterReply,
|
|
847
|
+
ackReactionPromise,
|
|
848
|
+
ackReactionValue: ackReactionValue ?? null,
|
|
849
|
+
remove: () =>
|
|
850
|
+
sendBlueBubblesReaction({
|
|
851
|
+
chatGuid: chatGuidForActions,
|
|
852
|
+
messageGuid: ackMessageId,
|
|
853
|
+
emoji: ackReactionValue ?? "",
|
|
854
|
+
remove: true,
|
|
855
|
+
opts: { cfg: config, accountId: account.accountId },
|
|
856
|
+
}),
|
|
857
|
+
onError: (err) => {
|
|
858
|
+
logAckFailure({
|
|
859
|
+
log: (msg) => logVerbose(core, runtime, msg),
|
|
860
|
+
channel: "bluebubbles",
|
|
861
|
+
target: `${chatGuidForActions}/${ackMessageId}`,
|
|
862
|
+
error: err,
|
|
863
|
+
});
|
|
864
|
+
},
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
if (shouldStopTyping && chatGuidForActions) {
|
|
868
|
+
// Stop typing after streaming completes to avoid a stuck indicator.
|
|
869
|
+
sendBlueBubblesTyping(chatGuidForActions, false, {
|
|
870
|
+
cfg: config,
|
|
871
|
+
accountId: account.accountId,
|
|
872
|
+
}).catch((err) => {
|
|
873
|
+
logTypingFailure({
|
|
874
|
+
log: (msg) => logVerbose(core, runtime, msg),
|
|
875
|
+
channel: "bluebubbles",
|
|
876
|
+
action: "stop",
|
|
877
|
+
target: chatGuidForActions,
|
|
878
|
+
error: err,
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
export async function processReaction(
|
|
886
|
+
reaction: NormalizedWebhookReaction,
|
|
887
|
+
target: WebhookTarget,
|
|
888
|
+
): Promise<void> {
|
|
889
|
+
const { account, config, runtime, core } = target;
|
|
890
|
+
if (reaction.fromMe) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
|
895
|
+
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
|
896
|
+
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
|
897
|
+
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
|
898
|
+
const storeAllowFrom = await core.channel.pairing
|
|
899
|
+
.readAllowFromStore("bluebubbles")
|
|
900
|
+
.catch(() => []);
|
|
901
|
+
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
|
902
|
+
.map((entry) => String(entry).trim())
|
|
903
|
+
.filter(Boolean);
|
|
904
|
+
const effectiveGroupAllowFrom = [
|
|
905
|
+
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
|
906
|
+
...storeAllowFrom,
|
|
907
|
+
]
|
|
908
|
+
.map((entry) => String(entry).trim())
|
|
909
|
+
.filter(Boolean);
|
|
910
|
+
|
|
911
|
+
if (reaction.isGroup) {
|
|
912
|
+
if (groupPolicy === "disabled") {
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (groupPolicy === "allowlist") {
|
|
916
|
+
if (effectiveGroupAllowFrom.length === 0) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
920
|
+
allowFrom: effectiveGroupAllowFrom,
|
|
921
|
+
sender: reaction.senderId,
|
|
922
|
+
chatId: reaction.chatId ?? undefined,
|
|
923
|
+
chatGuid: reaction.chatGuid ?? undefined,
|
|
924
|
+
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
|
925
|
+
});
|
|
926
|
+
if (!allowed) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
} else {
|
|
931
|
+
if (dmPolicy === "disabled") {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (dmPolicy !== "open") {
|
|
935
|
+
const allowed = isAllowedBlueBubblesSender({
|
|
936
|
+
allowFrom: effectiveAllowFrom,
|
|
937
|
+
sender: reaction.senderId,
|
|
938
|
+
chatId: reaction.chatId ?? undefined,
|
|
939
|
+
chatGuid: reaction.chatGuid ?? undefined,
|
|
940
|
+
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
|
941
|
+
});
|
|
942
|
+
if (!allowed) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const chatId = reaction.chatId ?? undefined;
|
|
949
|
+
const chatGuid = reaction.chatGuid ?? undefined;
|
|
950
|
+
const chatIdentifier = reaction.chatIdentifier ?? undefined;
|
|
951
|
+
const peerId = reaction.isGroup
|
|
952
|
+
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
|
953
|
+
: reaction.senderId;
|
|
954
|
+
|
|
955
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
956
|
+
cfg: config,
|
|
957
|
+
channel: "bluebubbles",
|
|
958
|
+
accountId: account.accountId,
|
|
959
|
+
peer: {
|
|
960
|
+
kind: reaction.isGroup ? "group" : "direct",
|
|
961
|
+
id: peerId,
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
const senderLabel = reaction.senderName || reaction.senderId;
|
|
966
|
+
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
|
967
|
+
// Use short ID for token savings
|
|
968
|
+
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
|
969
|
+
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
|
970
|
+
const text =
|
|
971
|
+
reaction.action === "removed"
|
|
972
|
+
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
|
973
|
+
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
|
974
|
+
core.system.enqueueSystemEvent(text, {
|
|
975
|
+
sessionKey: route.sessionKey,
|
|
976
|
+
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
|
977
|
+
});
|
|
978
|
+
logVerbose(core, runtime, `reaction event enqueued: ${text}`);
|
|
979
|
+
}
|