mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 21:00:24 +00:00
* fix(extensions): route fetch calls through fetchWithSsrFGuard Replace raw fetch() with fetchWithSsrFGuard in BlueBubbles, Mattermost, Nextcloud Talk, and Thread Ownership extensions so outbound requests go through the shared DNS-pinning and network-policy layer. BlueBubbles: thread allowPrivateNetwork from account config through all fetch call sites (send, chat, reactions, history, probe, attachments, multipart). Add _setFetchGuardForTesting hook for test overrides. Mattermost: add guardedFetchImpl wrapper in createMattermostClient that buffers the response body before releasing the dispatcher. Handle null-body status codes (204/304). Nextcloud Talk: wrap both sendMessage and sendReaction with fetchWithSsrFGuard and try/finally release. Thread Ownership: add fetchWithSsrFGuard and ssrfPolicyFromAllowPrivateNetwork to the plugin SDK surface; use allowPrivateNetwork:true for the Docker-internal forwarder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(extensions): improve null-body handling and test harness cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(bluebubbles): default to strict SSRF policy when allowPrivateNetwork is unset Callers that omit allowPrivateNetwork previously got undefined policy, which caused blueBubblesFetchWithTimeout to fall through to raw fetch and bypass the SSRF guard entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(bluebubbles): thread allowPrivateNetwork through action and monitor call sites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(mattermost,nextcloud-talk): add allowPrivateNetwork config for self-hosted/LAN deployments * fix: regenerate config docs baseline for new allowPrivateNetwork fields --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
4.9 KiB
TypeScript
185 lines
4.9 KiB
TypeScript
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
|
import type { OpenClawConfig } from "./runtime-api.js";
|
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
|
|
|
export type BlueBubblesReactionOpts = {
|
|
serverUrl?: string;
|
|
password?: string;
|
|
accountId?: string;
|
|
timeoutMs?: number;
|
|
cfg?: OpenClawConfig;
|
|
};
|
|
|
|
const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]);
|
|
|
|
const REACTION_ALIASES = new Map<string, string>([
|
|
// General
|
|
["heart", "love"],
|
|
["love", "love"],
|
|
["❤", "love"],
|
|
["❤️", "love"],
|
|
["red_heart", "love"],
|
|
["thumbs_up", "like"],
|
|
["thumbsup", "like"],
|
|
["thumbs-up", "like"],
|
|
["thumbsup", "like"],
|
|
["like", "like"],
|
|
["thumb", "like"],
|
|
["ok", "like"],
|
|
["thumbs_down", "dislike"],
|
|
["thumbsdown", "dislike"],
|
|
["thumbs-down", "dislike"],
|
|
["dislike", "dislike"],
|
|
["boo", "dislike"],
|
|
["no", "dislike"],
|
|
// Laugh
|
|
["haha", "laugh"],
|
|
["lol", "laugh"],
|
|
["lmao", "laugh"],
|
|
["rofl", "laugh"],
|
|
["😂", "laugh"],
|
|
["🤣", "laugh"],
|
|
["xd", "laugh"],
|
|
["laugh", "laugh"],
|
|
// Emphasize / exclaim
|
|
["emphasis", "emphasize"],
|
|
["emphasize", "emphasize"],
|
|
["exclaim", "emphasize"],
|
|
["!!", "emphasize"],
|
|
["‼", "emphasize"],
|
|
["‼️", "emphasize"],
|
|
["❗", "emphasize"],
|
|
["important", "emphasize"],
|
|
["bang", "emphasize"],
|
|
// Question
|
|
["question", "question"],
|
|
["?", "question"],
|
|
["❓", "question"],
|
|
["❔", "question"],
|
|
["ask", "question"],
|
|
// Apple/Messages names
|
|
["loved", "love"],
|
|
["liked", "like"],
|
|
["disliked", "dislike"],
|
|
["laughed", "laugh"],
|
|
["emphasized", "emphasize"],
|
|
["questioned", "question"],
|
|
// Colloquial / informal
|
|
["fire", "love"],
|
|
["🔥", "love"],
|
|
["wow", "emphasize"],
|
|
["!", "emphasize"],
|
|
// Edge: generic emoji name forms
|
|
["heart_eyes", "love"],
|
|
["smile", "laugh"],
|
|
["smiley", "laugh"],
|
|
["happy", "laugh"],
|
|
["joy", "laugh"],
|
|
]);
|
|
|
|
const REACTION_EMOJIS = new Map<string, string>([
|
|
// Love
|
|
["❤️", "love"],
|
|
["❤", "love"],
|
|
["♥️", "love"],
|
|
["♥", "love"],
|
|
["😍", "love"],
|
|
["💕", "love"],
|
|
// Like
|
|
["👍", "like"],
|
|
["👌", "like"],
|
|
// Dislike
|
|
["👎", "dislike"],
|
|
["🙅", "dislike"],
|
|
// Laugh
|
|
["😂", "laugh"],
|
|
["🤣", "laugh"],
|
|
["😆", "laugh"],
|
|
["😁", "laugh"],
|
|
["😹", "laugh"],
|
|
// Emphasize
|
|
["‼️", "emphasize"],
|
|
["‼", "emphasize"],
|
|
["!!", "emphasize"],
|
|
["❗", "emphasize"],
|
|
["❕", "emphasize"],
|
|
["!", "emphasize"],
|
|
// Question
|
|
["❓", "question"],
|
|
["❔", "question"],
|
|
["?", "question"],
|
|
]);
|
|
|
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
|
return resolveBlueBubblesServerAccount(params);
|
|
}
|
|
|
|
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
|
const trimmed = emoji.trim();
|
|
if (!trimmed) {
|
|
throw new Error("BlueBubbles reaction requires an emoji or name.");
|
|
}
|
|
let raw = trimmed.toLowerCase();
|
|
if (raw.startsWith("-")) {
|
|
raw = raw.slice(1);
|
|
}
|
|
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}`);
|
|
}
|
|
return remove ? `-${mapped}` : mapped;
|
|
}
|
|
|
|
export async function sendBlueBubblesReaction(params: {
|
|
chatGuid: string;
|
|
messageGuid: string;
|
|
emoji: string;
|
|
remove?: boolean;
|
|
partIndex?: number;
|
|
opts?: BlueBubblesReactionOpts;
|
|
}): Promise<void> {
|
|
const chatGuid = params.chatGuid.trim();
|
|
const messageGuid = params.messageGuid.trim();
|
|
if (!chatGuid) {
|
|
throw new Error("BlueBubbles reaction requires chatGuid.");
|
|
}
|
|
if (!messageGuid) {
|
|
throw new Error("BlueBubbles reaction requires messageGuid.");
|
|
}
|
|
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
|
const { baseUrl, password, accountId, allowPrivateNetwork } = resolveAccount(params.opts ?? {});
|
|
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
|
throw new Error(
|
|
"BlueBubbles reaction requires Private API, but it is disabled on the BlueBubbles server.",
|
|
);
|
|
}
|
|
const url = buildBlueBubblesApiUrl({
|
|
baseUrl,
|
|
path: "/api/v1/message/react",
|
|
password,
|
|
});
|
|
const payload = {
|
|
chatGuid,
|
|
selectedMessageGuid: messageGuid,
|
|
reaction,
|
|
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
|
|
};
|
|
const ssrfPolicy = allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
url,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
},
|
|
params.opts?.timeoutMs,
|
|
ssrfPolicy,
|
|
);
|
|
if (!res.ok) {
|
|
const errorText = await res.text();
|
|
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
|
|
}
|
|
}
|