diff --git a/CHANGELOG.md b/CHANGELOG.md index a93d0eb2155..e9bf624b9b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Cron/isolated-agent: preserve explicit `delivery.mode: "none"` message targets for isolated runs without inheriting implicit `last` routing, so agent-initiated Telegram sends keep their authored destination while bare `mode:none` jobs stay targetless. (#69153) Thanks @obviyus. - Cron/isolated-agent: keep `delivery.mode: "none"` account-only or thread-only configs from inheriting a stale implicit recipient, so isolated runs only resolve message routing when the job authored an explicit `to` target. (#69163) Thanks @obviyus. - Gateway/TUI: retry session history while the local gateway is still finishing startup, so `openclaw tui` reconnects no longer fail on transient `chat.history unavailable during gateway startup` errors. (#69164) Thanks @shakkernerd. +- BlueBubbles/reactions: fall back to `love` when an agent reacts with an emoji outside the iMessage tapback set (`love`/`like`/`dislike`/`laugh`/`emphasize`/`question`), so wider-vocabulary model reactions like `👀` still produce a visible tapback instead of failing the whole reaction request. Configured ack reactions still validate strictly via the new `normalizeBlueBubblesReactionInputStrict` path. (#64693) Thanks @zqchris. ## 2026.4.19-beta.2 diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 41254fe5abd..077f8724933 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -70,7 +70,10 @@ import type { } from "./monitor-shared.js"; import { enrichBlueBubblesParticipantsWithContactNames } from "./participant-contact-names.js"; import { isBlueBubblesPrivateApiEnabled } from "./probe.js"; -import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; +import { + normalizeBlueBubblesReactionInputStrict, + sendBlueBubblesReaction, +} from "./reactions.js"; import type { OpenClawConfig } from "./runtime-api.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; @@ -393,7 +396,7 @@ function resolveBlueBubblesAckReaction(params: { return null; } try { - normalizeBlueBubblesReactionInput(raw); + normalizeBlueBubblesReactionInputStrict(raw); return raw; } catch { const key = normalizeLowercaseStringOrEmpty(raw); diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 3a0dc949098..41d316d462a 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { sendBlueBubblesReaction } from "./reactions.js"; +import { + normalizeBlueBubblesReactionInput, + normalizeBlueBubblesReactionInputStrict, + sendBlueBubblesReaction, +} from "./reactions.js"; import { installBlueBubblesFetchTestHooks } from "./test-harness.js"; vi.mock("./accounts.js", async () => { @@ -106,18 +110,24 @@ describe("reactions", () => { ).rejects.toThrow("password is required"); }); - it("throws for unsupported reaction type", async () => { - await expect( - sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "unsupported", - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }), - ).rejects.toThrow("Unsupported BlueBubbles reaction"); + it("falls back to love for unsupported reaction type", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "👀", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("love"); }); describe("reaction type normalization", () => { @@ -236,6 +246,27 @@ describe("reactions", () => { await expectRemovedReaction("-love"); }); + it("falls back to removing love for unsupported removal reactions", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "👀", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + }); + it("uses custom partIndex when provided", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -335,4 +366,54 @@ describe("reactions", () => { }); }); }); + + describe("normalizeBlueBubblesReactionInputStrict", () => { + it("maps supported emoji to canonical type", () => { + expect(normalizeBlueBubblesReactionInputStrict("👍")).toBe("like"); + expect(normalizeBlueBubblesReactionInputStrict("❤️")).toBe("love"); + expect(normalizeBlueBubblesReactionInputStrict("😂")).toBe("laugh"); + }); + + it("throws on unsupported input so validators can detect misconfiguration", () => { + expect(() => normalizeBlueBubblesReactionInputStrict("👀")).toThrow( + /Unsupported BlueBubbles reaction/, + ); + expect(() => normalizeBlueBubblesReactionInputStrict("🎉")).toThrow( + /Unsupported BlueBubbles reaction/, + ); + }); + + it("throws on empty input", () => { + expect(() => normalizeBlueBubblesReactionInputStrict("")).toThrow( + /requires an emoji or name/, + ); + expect(() => normalizeBlueBubblesReactionInputStrict(" ")).toThrow( + /requires an emoji or name/, + ); + }); + }); + + describe("normalizeBlueBubblesReactionInput (lenient)", () => { + it("maps supported emoji to canonical type", () => { + expect(normalizeBlueBubblesReactionInput("👍")).toBe("like"); + expect(normalizeBlueBubblesReactionInput("❤️")).toBe("love"); + }); + + it("falls back to love when input is unsupported by iMessage tapback", () => { + expect(normalizeBlueBubblesReactionInput("👀")).toBe("love"); + expect(normalizeBlueBubblesReactionInput("🎉")).toBe("love"); + }); + + it("falls back to -love on unsupported remove", () => { + expect(normalizeBlueBubblesReactionInput("👀", true)).toBe("-love"); + }); + + it("still throws on empty input (strict error bubbles up unchanged)", () => { + // Empty input is a contract error from the caller, not a decorative + // emoji the model picked; we intentionally do not mask it. + expect(() => normalizeBlueBubblesReactionInput("")).toThrow( + /requires an emoji or name/, + ); + }); + }); }); diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index d124ad4589a..9087b0091cc 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -111,7 +111,18 @@ const REACTION_EMOJIS = new Map([ ["?", "question"], ]); -export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { +const UNSUPPORTED_REACTION_ERROR = "UnsupportedBlueBubblesReaction"; + +/** + * Strict normalizer: throws when the input does not map to a supported + * BlueBubbles reaction type. Use this for validator-style callers that + * need to detect unsupported input (e.g. config sanity checks) rather + * than gracefully substituting a fallback. + */ +export function normalizeBlueBubblesReactionInputStrict( + emoji: string, + remove?: boolean, +): string { const trimmed = emoji.trim(); if (!trimmed) { throw new Error("BlueBubbles reaction requires an emoji or name."); @@ -123,11 +134,38 @@ export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolea const aliased = REACTION_ALIASES.get(raw) ?? raw; const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased; if (!REACTION_TYPES.has(mapped)) { - throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`); + const error = new Error(`Unsupported BlueBubbles reaction: ${trimmed}`); + error.name = UNSUPPORTED_REACTION_ERROR; + throw error; } return remove ? `-${mapped}` : mapped; } +/** + * Lenient normalizer: when the input does not map to a supported + * BlueBubbles reaction type (iMessage tapback only supports + * love/like/dislike/laugh/emphasize/question), fall back to `love` + * so agents that react with a wider emoji vocabulary (e.g. 👀 to + * ack "seen, working on it") still produce a visible tapback instead + * of failing the whole reaction request. + * + * Contract errors (empty input) continue to bubble up so callers + * still catch misuse. + * + * Use this for model-facing paths. Callers that need to detect + * unsupported input should use {@link normalizeBlueBubblesReactionInputStrict}. + */ +export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { + try { + return normalizeBlueBubblesReactionInputStrict(emoji, remove); + } catch (error) { + if (error instanceof Error && error.name === UNSUPPORTED_REACTION_ERROR) { + return remove ? "-love" : "love"; + } + throw error; + } +} + export async function sendBlueBubblesReaction(params: { chatGuid: string; messageGuid: string;