mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user