@openclaw/bluebubbles 2026.2.15 → 2026.2.19
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/account-resolve.ts +29 -0
- package/src/actions.test.ts +47 -40
- package/src/actions.ts +1 -1
- package/src/attachments.test.ts +9 -29
- package/src/attachments.ts +3 -15
- package/src/chat.test.ts +46 -115
- package/src/chat.ts +3 -15
- package/src/media-send.test.ts +1 -1
- package/src/monitor-normalize.ts +1 -1
- package/src/monitor-processing.ts +204 -19
- package/src/monitor-shared.ts +1 -1
- package/src/monitor.test.ts +200 -14
- package/src/monitor.ts +14 -21
- package/src/onboarding.ts +2 -1
- package/src/reactions.ts +2 -14
- package/src/send-helpers.ts +1 -1
- package/src/send.test.ts +103 -320
- package/src/send.ts +1 -1
- package/src/targets.ts +10 -37
- package/src/test-harness.ts +50 -0
- package/src/test-mocks.ts +11 -0
package/src/media-send.test.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
2
|
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
8
8
|
import { setBlueBubblesRuntime } from "./runtime.js";
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { BlueBubblesAttachment } from "./types.js";
|
|
2
1
|
import { normalizeBlueBubblesHandle } from "./targets.js";
|
|
2
|
+
import type { BlueBubblesAttachment } from "./types.js";
|
|
3
3
|
|
|
4
4
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
5
5
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -6,12 +6,8 @@ import {
|
|
|
6
6
|
logTypingFailure,
|
|
7
7
|
resolveAckReaction,
|
|
8
8
|
resolveControlCommandGate,
|
|
9
|
+
stripMarkdown,
|
|
9
10
|
} from "openclaw/plugin-sdk";
|
|
10
|
-
import type {
|
|
11
|
-
BlueBubblesCoreRuntime,
|
|
12
|
-
BlueBubblesRuntimeEnv,
|
|
13
|
-
WebhookTarget,
|
|
14
|
-
} from "./monitor-shared.js";
|
|
15
11
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
|
16
12
|
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
|
17
13
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
@@ -32,6 +28,11 @@ import {
|
|
|
32
28
|
resolveBlueBubblesMessageId,
|
|
33
29
|
resolveReplyContextFromCache,
|
|
34
30
|
} from "./monitor-reply-cache.js";
|
|
31
|
+
import type {
|
|
32
|
+
BlueBubblesCoreRuntime,
|
|
33
|
+
BlueBubblesRuntimeEnv,
|
|
34
|
+
WebhookTarget,
|
|
35
|
+
} from "./monitor-shared.js";
|
|
35
36
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
36
37
|
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
|
37
38
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
@@ -40,6 +41,135 @@ import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targe
|
|
|
40
41
|
const DEFAULT_TEXT_LIMIT = 4000;
|
|
41
42
|
const invalidAckReactions = new Set<string>();
|
|
42
43
|
const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
|
|
44
|
+
const PENDING_OUTBOUND_MESSAGE_ID_TTL_MS = 2 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
type PendingOutboundMessageId = {
|
|
47
|
+
id: number;
|
|
48
|
+
accountId: string;
|
|
49
|
+
sessionKey: string;
|
|
50
|
+
outboundTarget: string;
|
|
51
|
+
chatGuid?: string;
|
|
52
|
+
chatIdentifier?: string;
|
|
53
|
+
chatId?: number;
|
|
54
|
+
snippetRaw: string;
|
|
55
|
+
snippetNorm: string;
|
|
56
|
+
isMediaSnippet: boolean;
|
|
57
|
+
createdAt: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const pendingOutboundMessageIds: PendingOutboundMessageId[] = [];
|
|
61
|
+
let pendingOutboundMessageIdCounter = 0;
|
|
62
|
+
|
|
63
|
+
function trimOrUndefined(value?: string | null): string | undefined {
|
|
64
|
+
const trimmed = value?.trim();
|
|
65
|
+
return trimmed ? trimmed : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeSnippet(value: string): string {
|
|
69
|
+
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prunePendingOutboundMessageIds(now = Date.now()): void {
|
|
73
|
+
const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
|
|
74
|
+
for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
|
|
75
|
+
if (pendingOutboundMessageIds[i].createdAt < cutoff) {
|
|
76
|
+
pendingOutboundMessageIds.splice(i, 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function rememberPendingOutboundMessageId(entry: {
|
|
82
|
+
accountId: string;
|
|
83
|
+
sessionKey: string;
|
|
84
|
+
outboundTarget: string;
|
|
85
|
+
chatGuid?: string;
|
|
86
|
+
chatIdentifier?: string;
|
|
87
|
+
chatId?: number;
|
|
88
|
+
snippet: string;
|
|
89
|
+
}): number {
|
|
90
|
+
prunePendingOutboundMessageIds();
|
|
91
|
+
pendingOutboundMessageIdCounter += 1;
|
|
92
|
+
const snippetRaw = entry.snippet.trim();
|
|
93
|
+
const snippetNorm = normalizeSnippet(snippetRaw);
|
|
94
|
+
pendingOutboundMessageIds.push({
|
|
95
|
+
id: pendingOutboundMessageIdCounter,
|
|
96
|
+
accountId: entry.accountId,
|
|
97
|
+
sessionKey: entry.sessionKey,
|
|
98
|
+
outboundTarget: entry.outboundTarget,
|
|
99
|
+
chatGuid: trimOrUndefined(entry.chatGuid),
|
|
100
|
+
chatIdentifier: trimOrUndefined(entry.chatIdentifier),
|
|
101
|
+
chatId: typeof entry.chatId === "number" ? entry.chatId : undefined,
|
|
102
|
+
snippetRaw,
|
|
103
|
+
snippetNorm,
|
|
104
|
+
isMediaSnippet: snippetRaw.toLowerCase().startsWith("<media:"),
|
|
105
|
+
createdAt: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
return pendingOutboundMessageIdCounter;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function forgetPendingOutboundMessageId(id: number): void {
|
|
111
|
+
const index = pendingOutboundMessageIds.findIndex((entry) => entry.id === id);
|
|
112
|
+
if (index >= 0) {
|
|
113
|
+
pendingOutboundMessageIds.splice(index, 1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function chatsMatch(
|
|
118
|
+
left: Pick<PendingOutboundMessageId, "chatGuid" | "chatIdentifier" | "chatId">,
|
|
119
|
+
right: { chatGuid?: string; chatIdentifier?: string; chatId?: number },
|
|
120
|
+
): boolean {
|
|
121
|
+
const leftGuid = trimOrUndefined(left.chatGuid);
|
|
122
|
+
const rightGuid = trimOrUndefined(right.chatGuid);
|
|
123
|
+
if (leftGuid && rightGuid) {
|
|
124
|
+
return leftGuid === rightGuid;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const leftIdentifier = trimOrUndefined(left.chatIdentifier);
|
|
128
|
+
const rightIdentifier = trimOrUndefined(right.chatIdentifier);
|
|
129
|
+
if (leftIdentifier && rightIdentifier) {
|
|
130
|
+
return leftIdentifier === rightIdentifier;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const leftChatId = typeof left.chatId === "number" ? left.chatId : undefined;
|
|
134
|
+
const rightChatId = typeof right.chatId === "number" ? right.chatId : undefined;
|
|
135
|
+
if (leftChatId !== undefined && rightChatId !== undefined) {
|
|
136
|
+
return leftChatId === rightChatId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function consumePendingOutboundMessageId(params: {
|
|
143
|
+
accountId: string;
|
|
144
|
+
chatGuid?: string;
|
|
145
|
+
chatIdentifier?: string;
|
|
146
|
+
chatId?: number;
|
|
147
|
+
body: string;
|
|
148
|
+
}): PendingOutboundMessageId | null {
|
|
149
|
+
prunePendingOutboundMessageIds();
|
|
150
|
+
const bodyNorm = normalizeSnippet(params.body);
|
|
151
|
+
const isMediaBody = params.body.trim().toLowerCase().startsWith("<media:");
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < pendingOutboundMessageIds.length; i++) {
|
|
154
|
+
const entry = pendingOutboundMessageIds[i];
|
|
155
|
+
if (entry.accountId !== params.accountId) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (!chatsMatch(entry, params)) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (entry.snippetNorm && entry.snippetNorm === bodyNorm) {
|
|
162
|
+
pendingOutboundMessageIds.splice(i, 1);
|
|
163
|
+
return entry;
|
|
164
|
+
}
|
|
165
|
+
if (entry.isMediaSnippet && isMediaBody) {
|
|
166
|
+
pendingOutboundMessageIds.splice(i, 1);
|
|
167
|
+
return entry;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
43
173
|
|
|
44
174
|
export function logVerbose(
|
|
45
175
|
core: BlueBubblesCoreRuntime,
|
|
@@ -158,6 +288,26 @@ export async function processMessage(
|
|
|
158
288
|
if (message.fromMe) {
|
|
159
289
|
// Cache from-me messages so reply context can resolve sender/body.
|
|
160
290
|
cacheInboundMessage();
|
|
291
|
+
if (cacheMessageId) {
|
|
292
|
+
const pending = consumePendingOutboundMessageId({
|
|
293
|
+
accountId: account.accountId,
|
|
294
|
+
chatGuid: message.chatGuid,
|
|
295
|
+
chatIdentifier: message.chatIdentifier,
|
|
296
|
+
chatId: message.chatId,
|
|
297
|
+
body: rawBody,
|
|
298
|
+
});
|
|
299
|
+
if (pending) {
|
|
300
|
+
const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId;
|
|
301
|
+
const previewSource = pending.snippetRaw || rawBody;
|
|
302
|
+
const preview = previewSource
|
|
303
|
+
? ` "${previewSource.slice(0, 12)}${previewSource.length > 12 ? "…" : ""}"`
|
|
304
|
+
: "";
|
|
305
|
+
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
|
306
|
+
sessionKey: pending.sessionKey,
|
|
307
|
+
contextKey: `bluebubbles:outbound:${pending.outboundTarget}:${cacheMessageId}`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
161
311
|
return;
|
|
162
312
|
}
|
|
163
313
|
|
|
@@ -629,10 +779,10 @@ export async function processMessage(
|
|
|
629
779
|
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
|
630
780
|
: message.senderId;
|
|
631
781
|
|
|
632
|
-
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
|
782
|
+
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string): boolean => {
|
|
633
783
|
const trimmed = messageId?.trim();
|
|
634
784
|
if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
|
|
635
|
-
return;
|
|
785
|
+
return false;
|
|
636
786
|
}
|
|
637
787
|
// Cache outbound message to get short ID
|
|
638
788
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
|
@@ -651,6 +801,7 @@ export async function processMessage(
|
|
|
651
801
|
sessionKey: route.sessionKey,
|
|
652
802
|
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
|
653
803
|
});
|
|
804
|
+
return true;
|
|
654
805
|
};
|
|
655
806
|
const sanitizeReplyDirectiveText = (value: string): string => {
|
|
656
807
|
if (privateApiEnabled) {
|
|
@@ -768,16 +919,33 @@ export async function processMessage(
|
|
|
768
919
|
for (const mediaUrl of mediaList) {
|
|
769
920
|
const caption = first ? text : undefined;
|
|
770
921
|
first = false;
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
to: outboundTarget,
|
|
774
|
-
mediaUrl,
|
|
775
|
-
caption: caption ?? undefined,
|
|
776
|
-
replyToId: replyToMessageGuid || null,
|
|
922
|
+
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
|
923
|
+
const pendingId = rememberPendingOutboundMessageId({
|
|
777
924
|
accountId: account.accountId,
|
|
925
|
+
sessionKey: route.sessionKey,
|
|
926
|
+
outboundTarget,
|
|
927
|
+
chatGuid: chatGuidForActions ?? chatGuid,
|
|
928
|
+
chatIdentifier,
|
|
929
|
+
chatId,
|
|
930
|
+
snippet: cachedBody,
|
|
778
931
|
});
|
|
779
|
-
|
|
780
|
-
|
|
932
|
+
let result: Awaited<ReturnType<typeof sendBlueBubblesMedia>>;
|
|
933
|
+
try {
|
|
934
|
+
result = await sendBlueBubblesMedia({
|
|
935
|
+
cfg: config,
|
|
936
|
+
to: outboundTarget,
|
|
937
|
+
mediaUrl,
|
|
938
|
+
caption: caption ?? undefined,
|
|
939
|
+
replyToId: replyToMessageGuid || null,
|
|
940
|
+
accountId: account.accountId,
|
|
941
|
+
});
|
|
942
|
+
} catch (err) {
|
|
943
|
+
forgetPendingOutboundMessageId(pendingId);
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) {
|
|
947
|
+
forgetPendingOutboundMessageId(pendingId);
|
|
948
|
+
}
|
|
781
949
|
sentMessage = true;
|
|
782
950
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
783
951
|
if (info.kind === "block") {
|
|
@@ -811,12 +979,29 @@ export async function processMessage(
|
|
|
811
979
|
return;
|
|
812
980
|
}
|
|
813
981
|
for (const chunk of chunks) {
|
|
814
|
-
const
|
|
815
|
-
cfg: config,
|
|
982
|
+
const pendingId = rememberPendingOutboundMessageId({
|
|
816
983
|
accountId: account.accountId,
|
|
817
|
-
|
|
984
|
+
sessionKey: route.sessionKey,
|
|
985
|
+
outboundTarget,
|
|
986
|
+
chatGuid: chatGuidForActions ?? chatGuid,
|
|
987
|
+
chatIdentifier,
|
|
988
|
+
chatId,
|
|
989
|
+
snippet: chunk,
|
|
818
990
|
});
|
|
819
|
-
|
|
991
|
+
let result: Awaited<ReturnType<typeof sendMessageBlueBubbles>>;
|
|
992
|
+
try {
|
|
993
|
+
result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
|
994
|
+
cfg: config,
|
|
995
|
+
accountId: account.accountId,
|
|
996
|
+
replyToMessageGuid: replyToMessageGuid || undefined,
|
|
997
|
+
});
|
|
998
|
+
} catch (err) {
|
|
999
|
+
forgetPendingOutboundMessageId(pendingId);
|
|
1000
|
+
throw err;
|
|
1001
|
+
}
|
|
1002
|
+
if (maybeEnqueueOutboundMessageId(result.messageId, chunk)) {
|
|
1003
|
+
forgetPendingOutboundMessageId(pendingId);
|
|
1004
|
+
}
|
|
820
1005
|
sentMessage = true;
|
|
821
1006
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
822
1007
|
if (info.kind === "block") {
|
package/src/monitor-shared.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
3
|
-
import type { BlueBubblesAccountConfig } from "./types.js";
|
|
4
3
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
|
4
|
+
import type { BlueBubblesAccountConfig } from "./types.js";
|
|
5
5
|
|
|
6
6
|
export type BlueBubblesRuntimeEnv = {
|
|
7
7
|
log?: (message: string) => void;
|
package/src/monitor.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
3
|
-
import { EventEmitter } from "node:events";
|
|
4
4
|
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
|
@@ -52,9 +52,22 @@ const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
|
|
52
52
|
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
|
53
53
|
regexes.some((r) => r.test(text)),
|
|
54
54
|
);
|
|
55
|
+
const mockMatchesMentionWithExplicit = vi.fn(
|
|
56
|
+
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
|
|
57
|
+
if (params.explicitWasMentioned) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return params.mentionRegexes.some((regex) => regex.test(params.text));
|
|
61
|
+
},
|
|
62
|
+
);
|
|
55
63
|
const mockResolveRequireMention = vi.fn(() => false);
|
|
56
64
|
const mockResolveGroupPolicy = vi.fn(() => "open");
|
|
57
|
-
|
|
65
|
+
type DispatchReplyParams = Parameters<
|
|
66
|
+
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
|
67
|
+
>[0];
|
|
68
|
+
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
|
69
|
+
async (_params: DispatchReplyParams): Promise<void> => undefined,
|
|
70
|
+
);
|
|
58
71
|
const mockHasControlCommand = vi.fn(() => false);
|
|
59
72
|
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
|
60
73
|
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
@@ -69,6 +82,10 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
|
|
69
82
|
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
70
83
|
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
|
71
84
|
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
|
85
|
+
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
|
86
|
+
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
87
|
+
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
|
88
|
+
const mockResolveChunkMode = vi.fn(() => "length");
|
|
72
89
|
|
|
73
90
|
function createMockRuntime(): PluginRuntime {
|
|
74
91
|
return {
|
|
@@ -81,6 +98,9 @@ function createMockRuntime(): PluginRuntime {
|
|
|
81
98
|
enqueueSystemEvent:
|
|
82
99
|
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
|
83
100
|
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
|
101
|
+
formatNativeDependencyHint: vi.fn(
|
|
102
|
+
() => "",
|
|
103
|
+
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
|
|
84
104
|
},
|
|
85
105
|
media: {
|
|
86
106
|
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
|
@@ -91,6 +111,9 @@ function createMockRuntime(): PluginRuntime {
|
|
|
91
111
|
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
|
92
112
|
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
|
93
113
|
},
|
|
114
|
+
tts: {
|
|
115
|
+
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
|
116
|
+
},
|
|
94
117
|
tools: {
|
|
95
118
|
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
|
96
119
|
createMemorySearchTool:
|
|
@@ -102,6 +125,14 @@ function createMockRuntime(): PluginRuntime {
|
|
|
102
125
|
chunkMarkdownText:
|
|
103
126
|
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
|
|
104
127
|
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
|
128
|
+
chunkByNewline:
|
|
129
|
+
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
|
|
130
|
+
chunkMarkdownTextWithMode:
|
|
131
|
+
mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
|
|
132
|
+
chunkTextWithMode:
|
|
133
|
+
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
|
|
134
|
+
resolveChunkMode:
|
|
135
|
+
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
|
105
136
|
resolveTextChunkLimit: vi.fn(
|
|
106
137
|
() => 4000,
|
|
107
138
|
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
|
@@ -170,6 +201,8 @@ function createMockRuntime(): PluginRuntime {
|
|
|
170
201
|
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
|
171
202
|
matchesMentionPatterns:
|
|
172
203
|
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
|
204
|
+
matchesMentionWithExplicit:
|
|
205
|
+
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
|
|
173
206
|
},
|
|
174
207
|
reactions: {
|
|
175
208
|
shouldAckReaction,
|
|
@@ -206,6 +239,8 @@ function createMockRuntime(): PluginRuntime {
|
|
|
206
239
|
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
|
207
240
|
},
|
|
208
241
|
discord: {} as PluginRuntime["channel"]["discord"],
|
|
242
|
+
activity: {} as PluginRuntime["channel"]["activity"],
|
|
243
|
+
line: {} as PluginRuntime["channel"]["line"],
|
|
209
244
|
slack: {} as PluginRuntime["channel"]["slack"],
|
|
210
245
|
telegram: {} as PluginRuntime["channel"]["telegram"],
|
|
211
246
|
signal: {} as PluginRuntime["channel"]["signal"],
|
|
@@ -305,6 +340,14 @@ const flushAsync = async () => {
|
|
|
305
340
|
}
|
|
306
341
|
};
|
|
307
342
|
|
|
343
|
+
function getFirstDispatchCall(): DispatchReplyParams {
|
|
344
|
+
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
|
345
|
+
if (!callArgs) {
|
|
346
|
+
throw new Error("expected dispatch call arguments");
|
|
347
|
+
}
|
|
348
|
+
return callArgs;
|
|
349
|
+
}
|
|
350
|
+
|
|
308
351
|
describe("BlueBubbles webhook monitor", () => {
|
|
309
352
|
let unregister: () => void;
|
|
310
353
|
|
|
@@ -1319,7 +1362,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1319
1362
|
await flushAsync();
|
|
1320
1363
|
|
|
1321
1364
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1322
|
-
const callArgs =
|
|
1365
|
+
const callArgs = getFirstDispatchCall();
|
|
1323
1366
|
expect(callArgs.ctx.WasMentioned).toBe(true);
|
|
1324
1367
|
});
|
|
1325
1368
|
|
|
@@ -1441,7 +1484,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1441
1484
|
await flushAsync();
|
|
1442
1485
|
|
|
1443
1486
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1444
|
-
const callArgs =
|
|
1487
|
+
const callArgs = getFirstDispatchCall();
|
|
1445
1488
|
expect(callArgs.ctx.GroupSubject).toBe("Family");
|
|
1446
1489
|
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
|
|
1447
1490
|
});
|
|
@@ -1492,7 +1535,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1492
1535
|
}),
|
|
1493
1536
|
);
|
|
1494
1537
|
// ConversationLabel should be the group label + id, not the sender
|
|
1495
|
-
const callArgs =
|
|
1538
|
+
const callArgs = getFirstDispatchCall();
|
|
1496
1539
|
expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
|
|
1497
1540
|
expect(callArgs.ctx.SenderName).toBe("Alice");
|
|
1498
1541
|
// BodyForAgent should be raw text, not the envelope-formatted body
|
|
@@ -1581,7 +1624,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1581
1624
|
sender: { name: "Alice", id: "+15551234567" },
|
|
1582
1625
|
}),
|
|
1583
1626
|
);
|
|
1584
|
-
const callArgs =
|
|
1627
|
+
const callArgs = getFirstDispatchCall();
|
|
1585
1628
|
expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
|
|
1586
1629
|
});
|
|
1587
1630
|
});
|
|
@@ -1716,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1716
1759
|
await vi.advanceTimersByTimeAsync(600);
|
|
1717
1760
|
|
|
1718
1761
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
1719
|
-
const callArgs =
|
|
1762
|
+
const callArgs = getFirstDispatchCall();
|
|
1720
1763
|
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
|
|
1721
1764
|
expect(callArgs.ctx.Body).toContain("hello");
|
|
1722
1765
|
} finally {
|
|
@@ -1765,7 +1808,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1765
1808
|
await flushAsync();
|
|
1766
1809
|
|
|
1767
1810
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1768
|
-
const callArgs =
|
|
1811
|
+
const callArgs = getFirstDispatchCall();
|
|
1769
1812
|
// ReplyToId is the full UUID since it wasn't previously cached
|
|
1770
1813
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
|
1771
1814
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
|
@@ -1813,7 +1856,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1813
1856
|
await flushAsync();
|
|
1814
1857
|
|
|
1815
1858
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1816
|
-
const callArgs =
|
|
1859
|
+
const callArgs = getFirstDispatchCall();
|
|
1817
1860
|
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
|
|
1818
1861
|
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
|
|
1819
1862
|
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
|
|
@@ -1879,7 +1922,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1879
1922
|
await flushAsync();
|
|
1880
1923
|
|
|
1881
1924
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1882
|
-
const callArgs =
|
|
1925
|
+
const callArgs = getFirstDispatchCall();
|
|
1883
1926
|
// ReplyToId uses short ID "1" (first cached message) for token savings
|
|
1884
1927
|
expect(callArgs.ctx.ReplyToId).toBe("1");
|
|
1885
1928
|
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
|
@@ -1924,7 +1967,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1924
1967
|
await flushAsync();
|
|
1925
1968
|
|
|
1926
1969
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1927
|
-
const callArgs =
|
|
1970
|
+
const callArgs = getFirstDispatchCall();
|
|
1928
1971
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
|
1929
1972
|
});
|
|
1930
1973
|
});
|
|
@@ -1964,7 +2007,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
1964
2007
|
await flushAsync();
|
|
1965
2008
|
|
|
1966
2009
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
1967
|
-
const callArgs =
|
|
2010
|
+
const callArgs = getFirstDispatchCall();
|
|
1968
2011
|
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
|
|
1969
2012
|
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
|
1970
2013
|
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
|
@@ -2004,7 +2047,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
2004
2047
|
await flushAsync();
|
|
2005
2048
|
|
|
2006
2049
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
2007
|
-
const callArgs =
|
|
2050
|
+
const callArgs = getFirstDispatchCall();
|
|
2008
2051
|
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
|
|
2009
2052
|
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
|
2010
2053
|
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
|
@@ -2427,6 +2470,149 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
2427
2470
|
}),
|
|
2428
2471
|
);
|
|
2429
2472
|
});
|
|
2473
|
+
|
|
2474
|
+
it("falls back to from-me webhook when send response has no message id", async () => {
|
|
2475
|
+
mockEnqueueSystemEvent.mockClear();
|
|
2476
|
+
|
|
2477
|
+
const { sendMessageBlueBubbles } = await import("./send.js");
|
|
2478
|
+
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
|
2479
|
+
|
|
2480
|
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
|
2481
|
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
|
2482
|
+
});
|
|
2483
|
+
|
|
2484
|
+
const account = createMockAccount();
|
|
2485
|
+
const config: OpenClawConfig = {};
|
|
2486
|
+
const core = createMockRuntime();
|
|
2487
|
+
setBlueBubblesRuntime(core);
|
|
2488
|
+
|
|
2489
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
2490
|
+
account,
|
|
2491
|
+
config,
|
|
2492
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
2493
|
+
core,
|
|
2494
|
+
path: "/bluebubbles-webhook",
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
const inboundPayload = {
|
|
2498
|
+
type: "new-message",
|
|
2499
|
+
data: {
|
|
2500
|
+
text: "hello",
|
|
2501
|
+
handle: { address: "+15551234567" },
|
|
2502
|
+
isGroup: false,
|
|
2503
|
+
isFromMe: false,
|
|
2504
|
+
guid: "msg-1",
|
|
2505
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
2506
|
+
date: Date.now(),
|
|
2507
|
+
},
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload);
|
|
2511
|
+
const inboundRes = createMockResponse();
|
|
2512
|
+
|
|
2513
|
+
await handleBlueBubblesWebhookRequest(inboundReq, inboundRes);
|
|
2514
|
+
await flushAsync();
|
|
2515
|
+
|
|
2516
|
+
// Send response did not include a message id, so nothing should be enqueued yet.
|
|
2517
|
+
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
|
2518
|
+
|
|
2519
|
+
const fromMePayload = {
|
|
2520
|
+
type: "new-message",
|
|
2521
|
+
data: {
|
|
2522
|
+
text: "replying now",
|
|
2523
|
+
handle: { address: "+15557654321" },
|
|
2524
|
+
isGroup: false,
|
|
2525
|
+
isFromMe: true,
|
|
2526
|
+
guid: "msg-out-456",
|
|
2527
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
2528
|
+
date: Date.now(),
|
|
2529
|
+
},
|
|
2530
|
+
};
|
|
2531
|
+
|
|
2532
|
+
const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload);
|
|
2533
|
+
const fromMeRes = createMockResponse();
|
|
2534
|
+
|
|
2535
|
+
await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes);
|
|
2536
|
+
await flushAsync();
|
|
2537
|
+
|
|
2538
|
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
|
2539
|
+
'Assistant sent "replying now" [message_id:2]',
|
|
2540
|
+
expect.objectContaining({
|
|
2541
|
+
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
|
2542
|
+
}),
|
|
2543
|
+
);
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
it("matches from-me fallback by chatIdentifier when chatGuid is missing", async () => {
|
|
2547
|
+
mockEnqueueSystemEvent.mockClear();
|
|
2548
|
+
|
|
2549
|
+
const { sendMessageBlueBubbles } = await import("./send.js");
|
|
2550
|
+
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
|
2551
|
+
|
|
2552
|
+
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
|
2553
|
+
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
const account = createMockAccount();
|
|
2557
|
+
const config: OpenClawConfig = {};
|
|
2558
|
+
const core = createMockRuntime();
|
|
2559
|
+
setBlueBubblesRuntime(core);
|
|
2560
|
+
|
|
2561
|
+
unregister = registerBlueBubblesWebhookTarget({
|
|
2562
|
+
account,
|
|
2563
|
+
config,
|
|
2564
|
+
runtime: { log: vi.fn(), error: vi.fn() },
|
|
2565
|
+
core,
|
|
2566
|
+
path: "/bluebubbles-webhook",
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
const inboundPayload = {
|
|
2570
|
+
type: "new-message",
|
|
2571
|
+
data: {
|
|
2572
|
+
text: "hello",
|
|
2573
|
+
handle: { address: "+15551234567" },
|
|
2574
|
+
isGroup: false,
|
|
2575
|
+
isFromMe: false,
|
|
2576
|
+
guid: "msg-1",
|
|
2577
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
2578
|
+
date: Date.now(),
|
|
2579
|
+
},
|
|
2580
|
+
};
|
|
2581
|
+
|
|
2582
|
+
const inboundReq = createMockRequest("POST", "/bluebubbles-webhook", inboundPayload);
|
|
2583
|
+
const inboundRes = createMockResponse();
|
|
2584
|
+
|
|
2585
|
+
await handleBlueBubblesWebhookRequest(inboundReq, inboundRes);
|
|
2586
|
+
await flushAsync();
|
|
2587
|
+
|
|
2588
|
+
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
|
2589
|
+
|
|
2590
|
+
const fromMePayload = {
|
|
2591
|
+
type: "new-message",
|
|
2592
|
+
data: {
|
|
2593
|
+
text: "replying now",
|
|
2594
|
+
handle: { address: "+15557654321" },
|
|
2595
|
+
isGroup: false,
|
|
2596
|
+
isFromMe: true,
|
|
2597
|
+
guid: "msg-out-789",
|
|
2598
|
+
chatIdentifier: "+15551234567",
|
|
2599
|
+
date: Date.now(),
|
|
2600
|
+
},
|
|
2601
|
+
};
|
|
2602
|
+
|
|
2603
|
+
const fromMeReq = createMockRequest("POST", "/bluebubbles-webhook", fromMePayload);
|
|
2604
|
+
const fromMeRes = createMockResponse();
|
|
2605
|
+
|
|
2606
|
+
await handleBlueBubblesWebhookRequest(fromMeReq, fromMeRes);
|
|
2607
|
+
await flushAsync();
|
|
2608
|
+
|
|
2609
|
+
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
|
2610
|
+
'Assistant sent "replying now" [message_id:2]',
|
|
2611
|
+
expect.objectContaining({
|
|
2612
|
+
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
|
2613
|
+
}),
|
|
2614
|
+
);
|
|
2615
|
+
});
|
|
2430
2616
|
});
|
|
2431
2617
|
|
|
2432
2618
|
describe("reaction events", () => {
|
|
@@ -2624,7 +2810,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|
|
2624
2810
|
await flushAsync();
|
|
2625
2811
|
|
|
2626
2812
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
|
2627
|
-
const callArgs =
|
|
2813
|
+
const callArgs = getFirstDispatchCall();
|
|
2628
2814
|
// MessageSid should be short ID "1" instead of full UUID
|
|
2629
2815
|
expect(callArgs.ctx.MessageSid).toBe("1");
|
|
2630
2816
|
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|