fix: preserve BlueBubbles reply tag GUIDs
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
- Heartbeat: normalize target identifiers for consistent routing.
|
||||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||||
|
|||||||
@@ -1193,6 +1193,51 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "replying now",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
replyTo: {
|
||||||
|
guid: "p:1/msg-0",
|
||||||
|
text: "original message",
|
||||||
|
handle: { address: "+15550000000", displayName: "Alice" },
|
||||||
|
},
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await flushAsync();
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
|
||||||
|
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
|
||||||
|
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
|
||||||
|
});
|
||||||
|
|
||||||
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||||
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
|
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
|
||||||
const config: ClawdbotConfig = {};
|
const config: ClawdbotConfig = {};
|
||||||
|
|||||||
@@ -70,33 +70,23 @@ function generateShortId(): string {
|
|||||||
return String(blueBubblesShortIdCounter);
|
return String(blueBubblesShortIdCounter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize message ID by stripping "p:N/" prefix for consistent cache keys
|
|
||||||
function normalizeMessageIdForCache(messageId: string): string {
|
|
||||||
const trimmed = messageId.trim();
|
|
||||||
// Strip "p:N/" prefix if present (e.g., "p:0/UUID" -> "UUID")
|
|
||||||
const match = trimmed.match(/^p:\d+\/(.+)$/);
|
|
||||||
return match ? match[1] : trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rememberBlueBubblesReplyCache(
|
function rememberBlueBubblesReplyCache(
|
||||||
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
||||||
): BlueBubblesReplyCacheEntry {
|
): BlueBubblesReplyCacheEntry {
|
||||||
const rawMessageId = entry.messageId.trim();
|
const messageId = entry.messageId.trim();
|
||||||
if (!rawMessageId) {
|
if (!messageId) {
|
||||||
return { ...entry, shortId: "" };
|
return { ...entry, shortId: "" };
|
||||||
}
|
}
|
||||||
// Normalize to strip "p:N/" prefix for consistent cache lookups
|
|
||||||
const messageId = normalizeMessageIdForCache(rawMessageId);
|
|
||||||
|
|
||||||
// Check if we already have a short ID for this GUID (keep "p:N/" prefix)
|
// Check if we already have a short ID for this GUID
|
||||||
let shortId = blueBubblesUuidToShortId.get(rawMessageId);
|
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||||
if (!shortId) {
|
if (!shortId) {
|
||||||
shortId = generateShortId();
|
shortId = generateShortId();
|
||||||
blueBubblesShortIdToUuid.set(shortId, rawMessageId);
|
blueBubblesShortIdToUuid.set(shortId, messageId);
|
||||||
blueBubblesUuidToShortId.set(rawMessageId, shortId);
|
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId: rawMessageId, shortId };
|
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
||||||
|
|
||||||
// Refresh insertion order.
|
// Refresh insertion order.
|
||||||
blueBubblesReplyCacheByMessageId.delete(messageId);
|
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||||
@@ -110,7 +100,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
// Clean up short ID mappings for expired entries
|
// Clean up short ID mappings for expired entries
|
||||||
if (value.shortId) {
|
if (value.shortId) {
|
||||||
blueBubblesShortIdToUuid.delete(value.shortId);
|
blueBubblesShortIdToUuid.delete(value.shortId);
|
||||||
blueBubblesUuidToShortId.delete(value.messageId.trim());
|
blueBubblesUuidToShortId.delete(key);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -124,7 +114,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
// Clean up short ID mappings for evicted entries
|
// Clean up short ID mappings for evicted entries
|
||||||
if (oldEntry?.shortId) {
|
if (oldEntry?.shortId) {
|
||||||
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
||||||
blueBubblesUuidToShortId.delete(oldEntry.messageId.trim());
|
blueBubblesUuidToShortId.delete(oldest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +162,7 @@ export function _resetBlueBubblesShortIdState(): void {
|
|||||||
* Gets the short ID for a message GUID, if one exists.
|
* Gets the short ID for a message GUID, if one exists.
|
||||||
*/
|
*/
|
||||||
function getShortIdForUuid(uuid: string): string | undefined {
|
function getShortIdForUuid(uuid: string): string | undefined {
|
||||||
const trimmed = uuid.trim();
|
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const direct = blueBubblesUuidToShortId.get(trimmed);
|
|
||||||
if (direct) return direct;
|
|
||||||
const normalized = normalizeMessageIdForCache(trimmed);
|
|
||||||
return normalized === trimmed ? undefined : blueBubblesUuidToShortId.get(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReplyContextFromCache(params: {
|
function resolveReplyContextFromCache(params: {
|
||||||
@@ -187,10 +172,8 @@ function resolveReplyContextFromCache(params: {
|
|||||||
chatIdentifier?: string;
|
chatIdentifier?: string;
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
}): BlueBubblesReplyCacheEntry | null {
|
}): BlueBubblesReplyCacheEntry | null {
|
||||||
const rawReplyToId = params.replyToId.trim();
|
const replyToId = params.replyToId.trim();
|
||||||
if (!rawReplyToId) return null;
|
if (!replyToId) return null;
|
||||||
// Normalize to strip "p:N/" prefix for consistent lookups
|
|
||||||
const replyToId = normalizeMessageIdForCache(rawReplyToId);
|
|
||||||
|
|
||||||
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
@@ -407,18 +390,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
|
||||||
|
|
||||||
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
||||||
function formatReplyTag(message: {
|
function formatReplyTag(message: {
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToShortId?: string;
|
replyToShortId?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
// Prefer short ID, strip "p:N/" part index prefix from full UUIDs
|
// Prefer short ID
|
||||||
const rawId = message.replyToShortId || message.replyToId;
|
const rawId = message.replyToShortId || message.replyToId;
|
||||||
if (!rawId) return null;
|
if (!rawId) return null;
|
||||||
const displayId = stripPartIndexPrefix(rawId);
|
return `[[reply_to:${rawId}]]`;
|
||||||
return `[[reply_to:${displayId}]]`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
@@ -782,13 +762,6 @@ function parseTapbackText(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strips the "p:N/" part index prefix from BlueBubbles message GUIDs
|
|
||||||
function stripPartIndexPrefix(guid: string): string {
|
|
||||||
// Format: "p:0/UUID" -> "UUID"
|
|
||||||
const match = guid.match(/^p:\d+\/(.+)$/);
|
|
||||||
return match ? match[1] : guid;
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskSecret(value: string): string {
|
function maskSecret(value: string): string {
|
||||||
if (value.length <= 6) return "***";
|
if (value.length <= 6) return "***";
|
||||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||||
@@ -2046,9 +2019,8 @@ async function processReaction(
|
|||||||
|
|
||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
// Use short ID for token savings, strip "p:N/" prefix
|
// Use short ID for token savings
|
||||||
const rawMessageId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||||
const messageDisplayId = stripPartIndexPrefix(rawMessageId);
|
|
||||||
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
||||||
const text =
|
const text =
|
||||||
reaction.action === "removed"
|
reaction.action === "removed"
|
||||||
|
|||||||
Reference in New Issue
Block a user