Compare commits
4 Commits
main
...
feat/react
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ba85f36a | ||
|
|
e516e923ff | ||
|
|
fd36e05bf3 | ||
|
|
e8e226ddf3 |
@ -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: preserve full message GUIDs in reply tags + reaction events (no part-prefix stripping). (#1641) Thanks @tyler6204.
|
||||||
- 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)
|
||||||
|
|||||||
@ -1189,9 +1189,8 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
// Body uses just the ID (no sender) for token savings
|
// Body uses inline [[reply_to:N]] tag format
|
||||||
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||||
@ -1260,9 +1259,8 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
// Body uses just the short ID (no sender) for token savings
|
// Body uses inline [[reply_to:N]] tag format with short ID
|
||||||
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||||
@ -1305,6 +1303,88 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("tapback text parsing", () => {
|
||||||
|
it("does not rewrite tapback-like text without metadata", 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: "Loved this idea",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
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.RawBody).toBe("Loved this idea");
|
||||||
|
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
||||||
|
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses tapback text with custom emoji when metadata is present", 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: 'Reacted 😅 to "nice one"',
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-2",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
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.RawBody).toBe("reacted with 😅");
|
||||||
|
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
||||||
|
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ack reactions", () => {
|
describe("ack reactions", () => {
|
||||||
it("sends ack reaction when configured", async () => {
|
it("sends ack reaction when configured", async () => {
|
||||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
@ -1759,7 +1839,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction added"),
|
expect.stringContaining("reacted with ❤️ [[reply_to:"),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -1799,7 +1879,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reaction removed"),
|
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -1905,7 +1985,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
handle: { address: "+15551234567" },
|
handle: { address: "+15551234567" },
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
guid: "msg-uuid-12345",
|
guid: "p:1/msg-uuid-12345",
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
},
|
||||||
@ -1921,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
// MessageSid should be short ID "1" instead of full UUID
|
// MessageSid should be short ID "1" instead of full UUID
|
||||||
expect(callArgs.ctx.MessageSid).toBe("1");
|
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||||
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves short ID back to UUID", async () => {
|
it("resolves short ID back to UUID", async () => {
|
||||||
@ -1945,7 +2025,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
handle: { address: "+15551234567" },
|
handle: { address: "+15551234567" },
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
guid: "msg-uuid-12345",
|
guid: "p:1/msg-uuid-12345",
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
},
|
||||||
@ -1958,7 +2038,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
// The short ID "1" should resolve back to the full UUID
|
// The short ID "1" should resolve back to the full UUID
|
||||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns UUID unchanged when not in cache", () => {
|
it("returns UUID unchanged when not in cache", () => {
|
||||||
|
|||||||
@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
|
|||||||
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||||
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||||
|
|
||||||
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
||||||
const blueBubblesShortIdToUuid = new Map<string, string>();
|
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||||
const blueBubblesUuidToShortId = new Map<string, string>();
|
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||||
let blueBubblesShortIdCounter = 0;
|
let blueBubblesShortIdCounter = 0;
|
||||||
@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
return { ...entry, shortId: "" };
|
return { ...entry, shortId: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we already have a short ID for this UUID
|
// Check if we already have a short ID for this GUID
|
||||||
let shortId = blueBubblesUuidToShortId.get(messageId);
|
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||||
if (!shortId) {
|
if (!shortId) {
|
||||||
shortId = generateShortId();
|
shortId = generateShortId();
|
||||||
@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
blueBubblesUuidToShortId.set(messageId, shortId);
|
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
||||||
|
|
||||||
// Refresh insertion order.
|
// Refresh insertion order.
|
||||||
blueBubblesReplyCacheByMessageId.delete(messageId);
|
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||||
@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
||||||
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
||||||
*/
|
*/
|
||||||
export function resolveBlueBubblesMessageId(
|
export function resolveBlueBubblesMessageId(
|
||||||
shortOrUuid: string,
|
shortOrUuid: string,
|
||||||
@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the short ID for a UUID, 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 {
|
||||||
return blueBubblesUuidToShortId.get(uuid.trim());
|
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||||
@ -390,29 +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
|
||||||
|
function formatReplyTag(message: {
|
||||||
function formatReplyContext(message: {
|
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToShortId?: string;
|
replyToShortId?: string;
|
||||||
replyToBody?: string;
|
|
||||||
replyToSender?: string;
|
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
// Prefer short ID
|
||||||
// Prefer short ID for token savings
|
const rawId = message.replyToShortId || message.replyToId;
|
||||||
const displayId = message.replyToShortId || message.replyToId;
|
if (!rawId) return null;
|
||||||
// Only include sender if we don't have an ID (fallback)
|
return `[[reply_to:${rawId}]]`;
|
||||||
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
|
||||||
const rawBody = message.replyToBody?.trim();
|
|
||||||
if (!rawBody) {
|
|
||||||
return `[Replying to ${label}]\n[/Replying]`;
|
|
||||||
}
|
|
||||||
// Truncate long reply bodies for token savings
|
|
||||||
const body =
|
|
||||||
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
|
|
||||||
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
|
|
||||||
: rawBody;
|
|
||||||
return `[Replying to ${label}]\n${body}\n[/Replying]`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
@ -629,6 +615,10 @@ type NormalizedWebhookMessage = {
|
|||||||
fromMe?: boolean;
|
fromMe?: boolean;
|
||||||
attachments?: BlueBubblesAttachment[];
|
attachments?: BlueBubblesAttachment[];
|
||||||
balloonBundleId?: string;
|
balloonBundleId?: string;
|
||||||
|
associatedMessageGuid?: string;
|
||||||
|
associatedMessageType?: number;
|
||||||
|
associatedMessageEmoji?: string;
|
||||||
|
isTapback?: boolean;
|
||||||
participants?: BlueBubblesParticipant[];
|
participants?: BlueBubblesParticipant[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
@ -665,6 +655,113 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
|
|||||||
[3005, { emoji: "❓", action: "removed" }],
|
[3005, { emoji: "❓", action: "removed" }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
||||||
|
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
||||||
|
["loved", { emoji: "❤️", action: "added" }],
|
||||||
|
["liked", { emoji: "👍", action: "added" }],
|
||||||
|
["disliked", { emoji: "👎", action: "added" }],
|
||||||
|
["laughed at", { emoji: "😂", action: "added" }],
|
||||||
|
["emphasized", { emoji: "‼️", action: "added" }],
|
||||||
|
["questioned", { emoji: "❓", action: "added" }],
|
||||||
|
// Removal patterns (e.g., "Removed a heart from")
|
||||||
|
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
||||||
|
["removed a like from", { emoji: "👍", action: "removed" }],
|
||||||
|
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
||||||
|
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
||||||
|
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
||||||
|
["removed a question from", { emoji: "❓", action: "removed" }],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const TAPBACK_EMOJI_REGEX =
|
||||||
|
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||||
|
|
||||||
|
function extractFirstEmoji(text: string): string | null {
|
||||||
|
const match = text.match(TAPBACK_EMOJI_REGEX);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractQuotedTapbackText(text: string): string | null {
|
||||||
|
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTapbackAssociatedType(type: number | undefined): boolean {
|
||||||
|
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
||||||
|
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
|
||||||
|
if (type >= 3000 && type < 4000) return "removed";
|
||||||
|
if (type >= 2000 && type < 3000) return "added";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
||||||
|
emojiHint?: string;
|
||||||
|
actionHint?: "added" | "removed";
|
||||||
|
replyToId?: string;
|
||||||
|
} | null {
|
||||||
|
const associatedType = message.associatedMessageType;
|
||||||
|
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
||||||
|
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
||||||
|
if (!hasTapbackType && !hasTapbackMarker) return null;
|
||||||
|
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
||||||
|
const actionHint = resolveTapbackActionHint(associatedType);
|
||||||
|
const emojiHint =
|
||||||
|
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
||||||
|
return { emojiHint, actionHint, replyToId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
||||||
|
function parseTapbackText(params: {
|
||||||
|
text: string;
|
||||||
|
emojiHint?: string;
|
||||||
|
actionHint?: "added" | "removed";
|
||||||
|
requireQuoted?: boolean;
|
||||||
|
}): {
|
||||||
|
emoji: string;
|
||||||
|
action: "added" | "removed";
|
||||||
|
quotedText: string;
|
||||||
|
} | null {
|
||||||
|
const trimmed = params.text.trim();
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||||
|
if (lower.startsWith(pattern)) {
|
||||||
|
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
||||||
|
const afterPattern = trimmed.slice(pattern.length).trim();
|
||||||
|
if (params.requireQuoted) {
|
||||||
|
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
||||||
|
if (!strictMatch) return null;
|
||||||
|
return { emoji, action, quotedText: strictMatch[1] };
|
||||||
|
}
|
||||||
|
const quotedText =
|
||||||
|
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
||||||
|
return { emoji, action, quotedText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("reacted")) {
|
||||||
|
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||||
|
if (!emoji) return null;
|
||||||
|
const quotedText = extractQuotedTapbackText(trimmed);
|
||||||
|
if (params.requireQuoted && !quotedText) return null;
|
||||||
|
const fallback = trimmed.slice("reacted".length).trim();
|
||||||
|
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.startsWith("removed")) {
|
||||||
|
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||||
|
if (!emoji) return null;
|
||||||
|
const quotedText = extractQuotedTapbackText(trimmed);
|
||||||
|
if (params.requireQuoted && !quotedText) return null;
|
||||||
|
const fallback = trimmed.slice("removed".length).trim();
|
||||||
|
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
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)}`;
|
||||||
@ -805,6 +902,25 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
readString(message, "messageId") ??
|
readString(message, "messageId") ??
|
||||||
undefined;
|
undefined;
|
||||||
const balloonBundleId = readString(message, "balloonBundleId");
|
const balloonBundleId = readString(message, "balloonBundleId");
|
||||||
|
const associatedMessageGuid =
|
||||||
|
readString(message, "associatedMessageGuid") ??
|
||||||
|
readString(message, "associated_message_guid") ??
|
||||||
|
readString(message, "associatedMessageId") ??
|
||||||
|
undefined;
|
||||||
|
const associatedMessageType =
|
||||||
|
readNumberLike(message, "associatedMessageType") ??
|
||||||
|
readNumberLike(message, "associated_message_type");
|
||||||
|
const associatedMessageEmoji =
|
||||||
|
readString(message, "associatedMessageEmoji") ??
|
||||||
|
readString(message, "associated_message_emoji") ??
|
||||||
|
readString(message, "reactionEmoji") ??
|
||||||
|
readString(message, "reaction_emoji") ??
|
||||||
|
undefined;
|
||||||
|
const isTapback =
|
||||||
|
readBoolean(message, "isTapback") ??
|
||||||
|
readBoolean(message, "is_tapback") ??
|
||||||
|
readBoolean(message, "tapback") ??
|
||||||
|
undefined;
|
||||||
|
|
||||||
const timestampRaw =
|
const timestampRaw =
|
||||||
readNumber(message, "date") ??
|
readNumber(message, "date") ??
|
||||||
@ -835,6 +951,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
fromMe,
|
fromMe,
|
||||||
attachments: extractAttachments(message),
|
attachments: extractAttachments(message),
|
||||||
balloonBundleId,
|
balloonBundleId,
|
||||||
|
associatedMessageGuid,
|
||||||
|
associatedMessageType,
|
||||||
|
associatedMessageEmoji,
|
||||||
|
isTapback,
|
||||||
participants: normalizedParticipants,
|
participants: normalizedParticipants,
|
||||||
replyToId: replyMetadata.replyToId,
|
replyToId: replyMetadata.replyToId,
|
||||||
replyToBody: replyMetadata.replyToBody,
|
replyToBody: replyMetadata.replyToBody,
|
||||||
@ -856,8 +976,13 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
if (!associatedGuid || associatedType === undefined) return null;
|
if (!associatedGuid || associatedType === undefined) return null;
|
||||||
|
|
||||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||||
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
|
const associatedEmoji =
|
||||||
const action = mapping?.action ?? "added";
|
readString(message, "associatedMessageEmoji") ??
|
||||||
|
readString(message, "associated_message_emoji") ??
|
||||||
|
readString(message, "reactionEmoji") ??
|
||||||
|
readString(message, "reaction_emoji");
|
||||||
|
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
||||||
|
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
||||||
|
|
||||||
const handleValue = message.handle ?? message.sender;
|
const handleValue = message.handle ?? message.sender;
|
||||||
const handle =
|
const handle =
|
||||||
@ -1122,7 +1247,21 @@ async function processMessage(
|
|||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
const placeholder = buildMessagePlaceholder(message);
|
const placeholder = buildMessagePlaceholder(message);
|
||||||
const rawBody = text || placeholder;
|
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
||||||
|
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
||||||
|
const tapbackContext = resolveTapbackContext(message);
|
||||||
|
const tapbackParsed = parseTapbackText({
|
||||||
|
text,
|
||||||
|
emojiHint: tapbackContext?.emojiHint,
|
||||||
|
actionHint: tapbackContext?.actionHint,
|
||||||
|
requireQuoted: !tapbackContext,
|
||||||
|
});
|
||||||
|
const isTapbackMessage = Boolean(tapbackParsed);
|
||||||
|
const rawBody = tapbackParsed
|
||||||
|
? tapbackParsed.action === "removed"
|
||||||
|
? `removed ${tapbackParsed.emoji} reaction`
|
||||||
|
: `reacted with ${tapbackParsed.emoji}`
|
||||||
|
: text || placeholder;
|
||||||
|
|
||||||
const cacheMessageId = message.messageId?.trim();
|
const cacheMessageId = message.messageId?.trim();
|
||||||
let messageShortId: string | undefined;
|
let messageShortId: string | undefined;
|
||||||
@ -1449,7 +1588,11 @@ async function processMessage(
|
|||||||
let replyToSender = message.replyToSender;
|
let replyToSender = message.replyToSender;
|
||||||
let replyToShortId: string | undefined;
|
let replyToShortId: string | undefined;
|
||||||
|
|
||||||
if (replyToId && (!replyToBody || !replyToSender)) {
|
if (isTapbackMessage && tapbackContext?.replyToId) {
|
||||||
|
replyToId = tapbackContext.replyToId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyToId) {
|
||||||
const cached = resolveReplyContextFromCache({
|
const cached = resolveReplyContextFromCache({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToId,
|
replyToId,
|
||||||
@ -1477,8 +1620,15 @@ async function processMessage(
|
|||||||
replyToShortId = getShortIdForUuid(replyToId);
|
replyToShortId = getShortIdForUuid(replyToId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
// Use inline [[reply_to:N]] tag format
|
||||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
||||||
|
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
||||||
|
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
||||||
|
const baseBody = replyTag
|
||||||
|
? isTapbackMessage
|
||||||
|
? `${rawBody} ${replyTag}`
|
||||||
|
: `${replyTag} ${rawBody}`
|
||||||
|
: rawBody;
|
||||||
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||||
const groupMembers = isGroup
|
const groupMembers = isGroup
|
||||||
@ -1871,7 +2021,11 @@ async function processReaction(
|
|||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
// Use short ID for token savings
|
// Use short ID for token savings
|
||||||
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
||||||
|
const text =
|
||||||
|
reaction.action === "removed"
|
||||||
|
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
||||||
|
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
||||||
core.system.enqueueSystemEvent(text, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user