BlueBubbles/reactions: fall back unsupported reactions to love (#64693)

* bluebubbles: fall back unsupported reactions to love

iMessage tapback only supports love/like/dislike/laugh/emphasize/question.
Previously, `normalizeBlueBubblesReactionInput` threw when the input did
not map to one of those (e.g. a non-standard unicode emoji like 👀 used
to mean "seen, working on it"), which aborted the whole reaction request
and left the user with no feedback.

This splits the normalizer into a strict and lenient variant:

- `normalizeBlueBubblesReactionInputStrict` throws on unsupported input
  and is used by validator-style callers (e.g. `resolveBlueBubblesAckReaction`
  in monitor-processing.ts) that rely on the throw to detect misconfigured
  ack reactions and skip them cleanly. This preserves the previous silent-skip
  + warn-once behavior for ack reactions configured with an unsupported
  emoji.
- `normalizeBlueBubblesReactionInput` stays lenient and falls back to
  `love` (or `-love` when removing) on unsupported input, so agent-driven
  `sendBlueBubblesReaction` still produces a visible tapback instead of
  failing the whole reaction request. Contract errors (empty input)
  continue to bubble up.

`love` is chosen over `like` as the neutral default: `❤️` reads as a
general acknowledgment across chat norms, while `👍` carries an
agreement connotation that does not match the "seen, working on it"
semantic.

* CHANGELOG: note BlueBubbles reaction fallback

---------

Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com>
This commit is contained in:
zqchris
2026-04-20 11:52:36 +08:00
committed by GitHub
parent 97492cf602
commit 77b424b15e
4 changed files with 140 additions and 17 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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/,
);
});
});
});

View File

@@ -111,7 +111,18 @@ const REACTION_EMOJIS = new Map<string, string>([
["?", "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;