@openclaw/bluebubbles 2026.2.21 → 2026.2.23
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 +7 -1
- package/src/actions.test.ts +29 -46
- package/src/actions.ts +2 -13
- package/src/attachments.test.ts +156 -32
- package/src/attachments.ts +49 -16
- package/src/chat.test.ts +257 -57
- package/src/chat.ts +74 -124
- package/src/config-schema.ts +1 -0
- package/src/history.ts +177 -0
- package/src/monitor-normalize.test.ts +78 -0
- package/src/monitor-normalize.ts +41 -12
- package/src/monitor-processing.ts +383 -127
- package/src/monitor-shared.ts +3 -13
- package/src/monitor.test.ts +396 -4
- package/src/onboarding.ts +24 -37
- package/src/probe.ts +8 -0
- package/src/reactions.test.ts +27 -47
- package/src/request-url.ts +12 -0
- package/src/runtime.ts +20 -0
- package/src/send.test.ts +72 -31
- package/src/send.ts +49 -7
- package/src/targets.test.ts +19 -0
- package/src/targets.ts +46 -37
- package/src/test-harness.ts +31 -2
- package/src/types.ts +2 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
|
3
|
+
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export type BlueBubblesHistoryEntry = {
|
|
6
|
+
sender: string;
|
|
7
|
+
body: string;
|
|
8
|
+
timestamp?: number;
|
|
9
|
+
messageId?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type BlueBubblesHistoryFetchResult = {
|
|
13
|
+
entries: BlueBubblesHistoryEntry[];
|
|
14
|
+
/**
|
|
15
|
+
* True when at least one API path returned a recognized response shape.
|
|
16
|
+
* False means all attempts failed or returned unusable data.
|
|
17
|
+
*/
|
|
18
|
+
resolved: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type BlueBubblesMessageData = {
|
|
22
|
+
guid?: string;
|
|
23
|
+
text?: string;
|
|
24
|
+
handle_id?: string;
|
|
25
|
+
is_from_me?: boolean;
|
|
26
|
+
date_created?: number;
|
|
27
|
+
date_delivered?: number;
|
|
28
|
+
associated_message_guid?: string;
|
|
29
|
+
sender?: {
|
|
30
|
+
address?: string;
|
|
31
|
+
display_name?: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BlueBubblesChatOpts = {
|
|
36
|
+
serverUrl?: string;
|
|
37
|
+
password?: string;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
timeoutMs?: number;
|
|
40
|
+
cfg?: OpenClawConfig;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function resolveAccount(params: BlueBubblesChatOpts) {
|
|
44
|
+
return resolveBlueBubblesServerAccount(params);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const MAX_HISTORY_FETCH_LIMIT = 100;
|
|
48
|
+
const HISTORY_SCAN_MULTIPLIER = 8;
|
|
49
|
+
const MAX_HISTORY_SCAN_MESSAGES = 500;
|
|
50
|
+
const MAX_HISTORY_BODY_CHARS = 2_000;
|
|
51
|
+
|
|
52
|
+
function clampHistoryLimit(limit: number): number {
|
|
53
|
+
if (!Number.isFinite(limit)) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const normalized = Math.floor(limit);
|
|
57
|
+
if (normalized <= 0) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return Math.min(normalized, MAX_HISTORY_FETCH_LIMIT);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function truncateHistoryBody(text: string): string {
|
|
64
|
+
if (text.length <= MAX_HISTORY_BODY_CHARS) {
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
return `${text.slice(0, MAX_HISTORY_BODY_CHARS).trimEnd()}...`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fetch message history from BlueBubbles API for a specific chat.
|
|
72
|
+
* This provides the initial backfill for both group chats and DMs.
|
|
73
|
+
*/
|
|
74
|
+
export async function fetchBlueBubblesHistory(
|
|
75
|
+
chatIdentifier: string,
|
|
76
|
+
limit: number,
|
|
77
|
+
opts: BlueBubblesChatOpts = {},
|
|
78
|
+
): Promise<BlueBubblesHistoryFetchResult> {
|
|
79
|
+
const effectiveLimit = clampHistoryLimit(limit);
|
|
80
|
+
if (!chatIdentifier.trim() || effectiveLimit <= 0) {
|
|
81
|
+
return { entries: [], resolved: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let baseUrl: string;
|
|
85
|
+
let password: string;
|
|
86
|
+
try {
|
|
87
|
+
({ baseUrl, password } = resolveAccount(opts));
|
|
88
|
+
} catch {
|
|
89
|
+
return { entries: [], resolved: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Try different common API patterns for fetching messages
|
|
93
|
+
const possiblePaths = [
|
|
94
|
+
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/messages?limit=${effectiveLimit}&sort=DESC`,
|
|
95
|
+
`/api/v1/messages?chatGuid=${encodeURIComponent(chatIdentifier)}&limit=${effectiveLimit}`,
|
|
96
|
+
`/api/v1/chat/${encodeURIComponent(chatIdentifier)}/message?limit=${effectiveLimit}`,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const path of possiblePaths) {
|
|
100
|
+
try {
|
|
101
|
+
const url = buildBlueBubblesApiUrl({ baseUrl, path, password });
|
|
102
|
+
const res = await blueBubblesFetchWithTimeout(
|
|
103
|
+
url,
|
|
104
|
+
{ method: "GET" },
|
|
105
|
+
opts.timeoutMs ?? 10000,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (!res.ok) {
|
|
109
|
+
continue; // Try next path
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = await res.json().catch(() => null);
|
|
113
|
+
if (!data) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Handle different response structures
|
|
118
|
+
let messages: unknown[] = [];
|
|
119
|
+
if (Array.isArray(data)) {
|
|
120
|
+
messages = data;
|
|
121
|
+
} else if (data.data && Array.isArray(data.data)) {
|
|
122
|
+
messages = data.data;
|
|
123
|
+
} else if (data.messages && Array.isArray(data.messages)) {
|
|
124
|
+
messages = data.messages;
|
|
125
|
+
} else {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const historyEntries: BlueBubblesHistoryEntry[] = [];
|
|
130
|
+
|
|
131
|
+
const maxScannedMessages = Math.min(
|
|
132
|
+
Math.max(effectiveLimit * HISTORY_SCAN_MULTIPLIER, effectiveLimit),
|
|
133
|
+
MAX_HISTORY_SCAN_MESSAGES,
|
|
134
|
+
);
|
|
135
|
+
for (let i = 0; i < messages.length && i < maxScannedMessages; i++) {
|
|
136
|
+
const item = messages[i];
|
|
137
|
+
const msg = item as BlueBubblesMessageData;
|
|
138
|
+
|
|
139
|
+
// Skip messages without text content
|
|
140
|
+
const text = msg.text?.trim();
|
|
141
|
+
if (!text) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sender = msg.is_from_me
|
|
146
|
+
? "me"
|
|
147
|
+
: msg.sender?.display_name || msg.sender?.address || msg.handle_id || "Unknown";
|
|
148
|
+
const timestamp = msg.date_created || msg.date_delivered;
|
|
149
|
+
|
|
150
|
+
historyEntries.push({
|
|
151
|
+
sender,
|
|
152
|
+
body: truncateHistoryBody(text),
|
|
153
|
+
timestamp,
|
|
154
|
+
messageId: msg.guid,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sort by timestamp (oldest first for context)
|
|
159
|
+
historyEntries.sort((a, b) => {
|
|
160
|
+
const aTime = a.timestamp || 0;
|
|
161
|
+
const bTime = b.timestamp || 0;
|
|
162
|
+
return aTime - bTime;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
entries: historyEntries.slice(0, effectiveLimit), // Ensure we don't exceed the requested limit
|
|
167
|
+
resolved: true,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Continue to next path
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// If none of the API paths worked, return empty history
|
|
176
|
+
return { entries: [], resolved: false };
|
|
177
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
|
3
|
+
|
|
4
|
+
describe("normalizeWebhookMessage", () => {
|
|
5
|
+
it("falls back to DM chatGuid handle when sender handle is missing", () => {
|
|
6
|
+
const result = normalizeWebhookMessage({
|
|
7
|
+
type: "new-message",
|
|
8
|
+
data: {
|
|
9
|
+
guid: "msg-1",
|
|
10
|
+
text: "hello",
|
|
11
|
+
isGroup: false,
|
|
12
|
+
isFromMe: false,
|
|
13
|
+
handle: null,
|
|
14
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(result).not.toBeNull();
|
|
19
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
20
|
+
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not infer sender from group chatGuid when sender handle is missing", () => {
|
|
24
|
+
const result = normalizeWebhookMessage({
|
|
25
|
+
type: "new-message",
|
|
26
|
+
data: {
|
|
27
|
+
guid: "msg-1",
|
|
28
|
+
text: "hello group",
|
|
29
|
+
isGroup: true,
|
|
30
|
+
isFromMe: false,
|
|
31
|
+
handle: null,
|
|
32
|
+
chatGuid: "iMessage;+;chat123456",
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts array-wrapped payload data", () => {
|
|
40
|
+
const result = normalizeWebhookMessage({
|
|
41
|
+
type: "new-message",
|
|
42
|
+
data: [
|
|
43
|
+
{
|
|
44
|
+
guid: "msg-1",
|
|
45
|
+
text: "hello",
|
|
46
|
+
handle: { address: "+15551234567" },
|
|
47
|
+
isGroup: false,
|
|
48
|
+
isFromMe: false,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).not.toBeNull();
|
|
54
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("normalizeWebhookReaction", () => {
|
|
59
|
+
it("falls back to DM chatGuid handle when reaction sender handle is missing", () => {
|
|
60
|
+
const result = normalizeWebhookReaction({
|
|
61
|
+
type: "updated-message",
|
|
62
|
+
data: {
|
|
63
|
+
guid: "msg-2",
|
|
64
|
+
associatedMessageGuid: "p:0/msg-1",
|
|
65
|
+
associatedMessageType: 2000,
|
|
66
|
+
isGroup: false,
|
|
67
|
+
isFromMe: false,
|
|
68
|
+
handle: null,
|
|
69
|
+
chatGuid: "iMessage;-;+15551234567",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).not.toBeNull();
|
|
74
|
+
expect(result?.senderId).toBe("+15551234567");
|
|
75
|
+
expect(result?.messageId).toBe("p:0/msg-1");
|
|
76
|
+
expect(result?.action).toBe("added");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/monitor-normalize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeBlueBubblesHandle } from "./targets.js";
|
|
1
|
+
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
|
2
2
|
import type { BlueBubblesAttachment } from "./types.js";
|
|
3
3
|
|
|
4
4
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -629,18 +629,42 @@ export function parseTapbackText(params: {
|
|
|
629
629
|
}
|
|
630
630
|
|
|
631
631
|
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
632
|
+
const parseRecord = (value: unknown): Record<string, unknown> | null => {
|
|
633
|
+
const record = asRecord(value);
|
|
634
|
+
if (record) {
|
|
635
|
+
return record;
|
|
636
|
+
}
|
|
637
|
+
if (Array.isArray(value)) {
|
|
638
|
+
for (const entry of value) {
|
|
639
|
+
const parsedEntry = parseRecord(entry);
|
|
640
|
+
if (parsedEntry) {
|
|
641
|
+
return parsedEntry;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
if (typeof value !== "string") {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const trimmed = value.trim();
|
|
650
|
+
if (!trimmed) {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
return parseRecord(JSON.parse(trimmed));
|
|
655
|
+
} catch {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
632
660
|
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
|
633
|
-
const data =
|
|
634
|
-
asRecord(dataRaw) ??
|
|
635
|
-
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
|
661
|
+
const data = parseRecord(dataRaw);
|
|
636
662
|
const messageRaw = payload.message ?? data?.message ?? data;
|
|
637
|
-
const message =
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
if (!message) {
|
|
641
|
-
return null;
|
|
663
|
+
const message = parseRecord(messageRaw);
|
|
664
|
+
if (message) {
|
|
665
|
+
return message;
|
|
642
666
|
}
|
|
643
|
-
return
|
|
667
|
+
return null;
|
|
644
668
|
}
|
|
645
669
|
|
|
646
670
|
export function normalizeWebhookMessage(
|
|
@@ -700,7 +724,10 @@ export function normalizeWebhookMessage(
|
|
|
700
724
|
: timestampRaw * 1000
|
|
701
725
|
: undefined;
|
|
702
726
|
|
|
703
|
-
|
|
727
|
+
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
|
|
728
|
+
const senderFallbackFromChatGuid =
|
|
729
|
+
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
730
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
704
731
|
if (!normalizedSender) {
|
|
705
732
|
return null;
|
|
706
733
|
}
|
|
@@ -774,7 +801,9 @@ export function normalizeWebhookReaction(
|
|
|
774
801
|
: timestampRaw * 1000
|
|
775
802
|
: undefined;
|
|
776
803
|
|
|
777
|
-
const
|
|
804
|
+
const senderFallbackFromChatGuid =
|
|
805
|
+
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
|
|
806
|
+
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
|
|
778
807
|
if (!normalizedSender) {
|
|
779
808
|
return null;
|
|
780
809
|
}
|