feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling
This commit is contained in:
committed by
Peter Steinberger
parent
2e6c58bf75
commit
574b848863
@@ -5,9 +5,11 @@ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import { resolveAckReaction } from "../../../src/agents/identity.js";
|
||||
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
|
||||
export type BlueBubblesRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
@@ -25,6 +27,7 @@ export type BlueBubblesMonitorOptions = {
|
||||
|
||||
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
|
||||
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
||||
|
||||
@@ -34,6 +37,29 @@ function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv
|
||||
}
|
||||
}
|
||||
|
||||
function logGroupAllowlistHint(params: {
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
reason: string;
|
||||
entry: string | null;
|
||||
chatName?: string;
|
||||
}): void {
|
||||
const logger = params.runtime.log;
|
||||
if (!logger) return;
|
||||
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
|
||||
if (params.entry) {
|
||||
logger(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
|
||||
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
logger(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
|
||||
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
|
||||
`channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
||||
);
|
||||
}
|
||||
|
||||
type WebhookTarget = {
|
||||
account: ResolvedBlueBubblesAccount;
|
||||
config: ClawdbotConfig;
|
||||
@@ -194,6 +220,71 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractReplyMetadata(message: Record<string, unknown>): {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
} {
|
||||
const replyRaw =
|
||||
message["replyTo"] ??
|
||||
message["reply_to"] ??
|
||||
message["replyToMessage"] ??
|
||||
message["reply_to_message"] ??
|
||||
message["repliedMessage"] ??
|
||||
message["quotedMessage"] ??
|
||||
message["associatedMessage"] ??
|
||||
message["reply"];
|
||||
const replyRecord = asRecord(replyRaw);
|
||||
const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
|
||||
const replySenderRaw =
|
||||
readString(replyHandle, "address") ??
|
||||
readString(replyHandle, "handle") ??
|
||||
readString(replyHandle, "id") ??
|
||||
readString(replyRecord, "senderId") ??
|
||||
readString(replyRecord, "sender") ??
|
||||
readString(replyRecord, "from");
|
||||
const normalizedSender = replySenderRaw
|
||||
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
|
||||
: undefined;
|
||||
|
||||
const replyToBody =
|
||||
readString(replyRecord, "text") ??
|
||||
readString(replyRecord, "body") ??
|
||||
readString(replyRecord, "message") ??
|
||||
readString(replyRecord, "subject") ??
|
||||
undefined;
|
||||
|
||||
const directReplyId =
|
||||
readString(message, "replyToMessageGuid") ??
|
||||
readString(message, "replyToGuid") ??
|
||||
readString(message, "replyGuid") ??
|
||||
readString(message, "selectedMessageGuid") ??
|
||||
readString(message, "selectedMessageId") ??
|
||||
readString(message, "replyToMessageId") ??
|
||||
readString(message, "replyId") ??
|
||||
readString(replyRecord, "guid") ??
|
||||
readString(replyRecord, "id") ??
|
||||
readString(replyRecord, "messageId");
|
||||
|
||||
const associatedType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId");
|
||||
const isReactionAssociation =
|
||||
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
||||
|
||||
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
||||
|
||||
return {
|
||||
replyToId: replyToId?.trim() || undefined,
|
||||
replyToBody: replyToBody?.trim() || undefined,
|
||||
replyToSender: normalizedSender || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const chats = message["chats"];
|
||||
if (!Array.isArray(chats) || chats.length === 0) return null;
|
||||
@@ -201,6 +292,108 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
|
||||
return asRecord(first);
|
||||
}
|
||||
|
||||
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
||||
if (typeof entry === "string" || typeof entry === "number") {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw) return null;
|
||||
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
||||
return normalized ? { id: normalized } : null;
|
||||
}
|
||||
const record = asRecord(entry);
|
||||
if (!record) return null;
|
||||
const nestedHandle =
|
||||
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
||||
const idRaw =
|
||||
readString(record, "address") ??
|
||||
readString(record, "handle") ??
|
||||
readString(record, "id") ??
|
||||
readString(record, "phoneNumber") ??
|
||||
readString(record, "phone_number") ??
|
||||
readString(record, "email") ??
|
||||
readString(nestedHandle, "address") ??
|
||||
readString(nestedHandle, "handle") ??
|
||||
readString(nestedHandle, "id");
|
||||
const nameRaw =
|
||||
readString(record, "displayName") ??
|
||||
readString(record, "name") ??
|
||||
readString(record, "title") ??
|
||||
readString(nestedHandle, "displayName") ??
|
||||
readString(nestedHandle, "name");
|
||||
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
||||
if (!normalizedId) return null;
|
||||
const name = nameRaw?.trim() || undefined;
|
||||
return { id: normalizedId, name };
|
||||
}
|
||||
|
||||
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||||
const seen = new Set<string>();
|
||||
const output: BlueBubblesParticipant[] = [];
|
||||
for (const entry of raw) {
|
||||
const normalized = normalizeParticipantEntry(entry);
|
||||
if (!normalized?.id) continue;
|
||||
const key = normalized.id.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function formatGroupMembers(params: {
|
||||
participants?: BlueBubblesParticipant[];
|
||||
fallback?: BlueBubblesParticipant;
|
||||
}): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
const ordered: BlueBubblesParticipant[] = [];
|
||||
for (const entry of params.participants ?? []) {
|
||||
if (!entry?.id) continue;
|
||||
const key = entry.id.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ordered.push(entry);
|
||||
}
|
||||
if (ordered.length === 0 && params.fallback?.id) {
|
||||
ordered.push(params.fallback);
|
||||
}
|
||||
if (ordered.length === 0) return undefined;
|
||||
return ordered
|
||||
.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
||||
const guid = chatGuid?.trim();
|
||||
if (!guid) return undefined;
|
||||
const parts = guid.split(";");
|
||||
if (parts.length >= 3) {
|
||||
if (parts[1] === "+") return true;
|
||||
if (parts[1] === "-") return false;
|
||||
}
|
||||
if (guid.includes(";+;")) return true;
|
||||
if (guid.includes(";-;")) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatGroupAllowlistEntry(params: {
|
||||
chatGuid?: string;
|
||||
chatId?: number;
|
||||
chatIdentifier?: string;
|
||||
}): string | null {
|
||||
const guid = params.chatGuid?.trim();
|
||||
if (guid) return `chat_guid:${guid}`;
|
||||
const chatId = params.chatId;
|
||||
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
|
||||
const identifier = params.chatIdentifier?.trim();
|
||||
if (identifier) return `chat_identifier:${identifier}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
type BlueBubblesParticipant = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type NormalizedWebhookMessage = {
|
||||
text: string;
|
||||
senderId: string;
|
||||
@@ -215,6 +408,10 @@ type NormalizedWebhookMessage = {
|
||||
fromMe?: boolean;
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
balloonBundleId?: string;
|
||||
participants?: BlueBubblesParticipant[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
};
|
||||
|
||||
type NormalizedWebhookReaction = {
|
||||
@@ -252,6 +449,31 @@ function maskSecret(value: string): string {
|
||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesAckReaction(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
core: BlueBubblesCoreRuntime;
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
}): string | null {
|
||||
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
normalizeBlueBubblesReactionInput(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
const key = raw.toLowerCase();
|
||||
if (!invalidAckReactions.has(key)) {
|
||||
invalidAckReactions.add(key);
|
||||
logVerbose(
|
||||
params.core,
|
||||
params.runtime,
|
||||
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
||||
const data =
|
||||
@@ -331,13 +553,18 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
: Array.isArray(chatsParticipants)
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const normalizedParticipants = normalizeParticipantList(participants);
|
||||
const participantsCount = participants.length;
|
||||
const isGroup =
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group") ??
|
||||
(participantsCount > 2 ? true : false);
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const messageId =
|
||||
@@ -360,6 +587,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
if (!normalizedSender) return null;
|
||||
const replyMetadata = extractReplyMetadata(message);
|
||||
|
||||
return {
|
||||
text,
|
||||
@@ -375,6 +603,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
fromMe,
|
||||
attachments: extractAttachments(message),
|
||||
balloonBundleId,
|
||||
participants: normalizedParticipants,
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
replyToSender: replyMetadata.replyToSender,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -451,12 +683,16 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const participantsCount = participants.length;
|
||||
const isGroup =
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group") ??
|
||||
(participantsCount > 2 ? true : false);
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const timestampRaw =
|
||||
@@ -637,6 +873,8 @@ async function processMessage(
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core, statusSink } = target;
|
||||
if (message.fromMe) return;
|
||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
@@ -648,7 +886,7 @@ async function processMessage(
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`msg sender=${message.senderId} group=${message.isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
@@ -667,15 +905,33 @@ async function processMessage(
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const groupAllowEntry = formatGroupAllowlistEntry({
|
||||
chatGuid: message.chatGuid,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
const groupName = message.chatName?.trim() || undefined;
|
||||
|
||||
if (message.isGroup) {
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=disabled",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
@@ -696,6 +952,12 @@ async function processMessage(
|
||||
runtime,
|
||||
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
||||
);
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (not allowlisted)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -767,7 +1029,7 @@ async function processMessage(
|
||||
const chatId = message.chatId ?? undefined;
|
||||
const chatGuid = message.chatGuid ?? undefined;
|
||||
const chatIdentifier = message.chatIdentifier ?? undefined;
|
||||
const peerId = message.isGroup
|
||||
const peerId = isGroup
|
||||
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
|
||||
: message.senderId;
|
||||
|
||||
@@ -776,7 +1038,7 @@ async function processMessage(
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: message.isGroup ? "group" : "dm",
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
@@ -784,7 +1046,7 @@ async function processMessage(
|
||||
// Mention gating for group chats (parity with iMessage/WhatsApp)
|
||||
const messageText = text;
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
||||
const wasMentioned = message.isGroup
|
||||
const wasMentioned = isGroup
|
||||
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
|
||||
: true;
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
@@ -819,7 +1081,7 @@ async function processMessage(
|
||||
})
|
||||
: false;
|
||||
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
||||
const commandAuthorized = message.isGroup
|
||||
const commandAuthorized = isGroup
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
@@ -830,7 +1092,7 @@ async function processMessage(
|
||||
: dmAuthorized;
|
||||
|
||||
// Block control commands from unauthorized senders in groups
|
||||
if (message.isGroup && hasControlCmd && !commandAuthorized) {
|
||||
if (isGroup && hasControlCmd && !commandAuthorized) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
@@ -841,7 +1103,7 @@ async function processMessage(
|
||||
|
||||
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
|
||||
const shouldBypassMention =
|
||||
message.isGroup &&
|
||||
isGroup &&
|
||||
requireMention &&
|
||||
!wasMentioned &&
|
||||
commandAuthorized &&
|
||||
@@ -849,7 +1111,7 @@ async function processMessage(
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
|
||||
// Skip group messages that require mention but weren't mentioned
|
||||
if (message.isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
||||
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
||||
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
|
||||
return;
|
||||
}
|
||||
@@ -906,9 +1168,16 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
const rawBody = text.trim() || placeholder;
|
||||
const fromLabel = message.isGroup
|
||||
const fromLabel = isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
? formatGroupMembers({
|
||||
participants: message.participants,
|
||||
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
|
||||
})
|
||||
: undefined;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
@@ -927,8 +1196,8 @@ async function processMessage(
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
if (!chatGuidForActions && baseUrl && password) {
|
||||
const target =
|
||||
message.isGroup && (chatId || chatIdentifier)
|
||||
const target =
|
||||
isGroup && (chatId || chatIdentifier)
|
||||
? chatId
|
||||
? { kind: "chat_id", chatId }
|
||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
||||
@@ -943,6 +1212,48 @@ async function processMessage(
|
||||
}
|
||||
}
|
||||
|
||||
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
|
||||
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
|
||||
const ackReactionValue = resolveBlueBubblesAckReaction({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
core,
|
||||
runtime,
|
||||
});
|
||||
const shouldAckReaction = () => {
|
||||
if (!ackReactionValue) return false;
|
||||
if (ackReactionScope === "all") return true;
|
||||
if (ackReactionScope === "direct") return !isGroup;
|
||||
if (ackReactionScope === "group-all") return isGroup;
|
||||
if (ackReactionScope === "group-mentions") {
|
||||
if (!isGroup) return false;
|
||||
if (!requireMention) return false;
|
||||
if (!canDetectMention) return false;
|
||||
return effectiveWasMentioned;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const ackMessageId = message.messageId?.trim() || "";
|
||||
const ackReactionPromise =
|
||||
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
||||
? sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
// Respect sendReadReceipts config (parity with WhatsApp)
|
||||
const sendReadReceipts = account.config.sendReadReceipts !== false;
|
||||
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
|
||||
@@ -961,7 +1272,7 @@ async function processMessage(
|
||||
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
||||
}
|
||||
|
||||
const outboundTarget = message.isGroup
|
||||
const outboundTarget = isGroup
|
||||
? formatBlueBubblesChatTarget({
|
||||
chatId,
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
@@ -983,12 +1294,17 @@ async function processMessage(
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaType: mediaTypes[0],
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
From: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||
To: `bluebubbles:${outboundTarget}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
ReplyToId: message.replyToId,
|
||||
ReplyToBody: message.replyToBody,
|
||||
ReplyToSender: message.replyToSender,
|
||||
GroupSubject: groupSubject,
|
||||
GroupMembers: groupMembers,
|
||||
SenderName: message.senderName || undefined,
|
||||
SenderId: message.senderId,
|
||||
Provider: "bluebubbles",
|
||||
@@ -1028,9 +1344,12 @@ async function processMessage(
|
||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||
if (!chunks.length) return;
|
||||
for (const chunk of chunks) {
|
||||
const replyToMessageGuid =
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
@@ -1064,6 +1383,31 @@ async function processMessage(
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (
|
||||
removeAckAfterReply &&
|
||||
sentMessage &&
|
||||
ackReactionPromise &&
|
||||
ackReactionValue &&
|
||||
chatGuidForActions &&
|
||||
ackMessageId
|
||||
) {
|
||||
void ackReactionPromise.then((didAck) => {
|
||||
if (!didAck) return;
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue,
|
||||
remove: true,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}).catch((err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (chatGuidForActions && baseUrl && password && !sentMessage) {
|
||||
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
|
||||
}
|
||||
@@ -1150,7 +1494,7 @@ async function processReaction(
|
||||
|
||||
export async function monitorBlueBubblesProvider(
|
||||
options: BlueBubblesMonitorOptions,
|
||||
): Promise<{ stop: () => void }> {
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, abortSignal, statusSink } = options;
|
||||
const core = getBlueBubblesRuntime();
|
||||
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
|
||||
@@ -1164,21 +1508,22 @@ export async function monitorBlueBubblesProvider(
|
||||
statusSink,
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
unregister();
|
||||
};
|
||||
return await new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
unregister();
|
||||
resolve();
|
||||
};
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortSignal?.aborted) {
|
||||
stop();
|
||||
} else {
|
||||
abortSignal?.addEventListener("abort", stop, { once: true });
|
||||
}
|
||||
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||
);
|
||||
|
||||
return { stop };
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
||||
|
||||
Reference in New Issue
Block a user