feat: enhance BlueBubbles channel integration with new messaging target normalization and typing indicator improvements

This commit is contained in:
Tyler Yust
2026-01-19 21:55:09 -08:00
committed by Peter Steinberger
parent 61907ddf3e
commit a5d89e6eb1
8 changed files with 193 additions and 18 deletions

View File

@@ -26,7 +26,11 @@ import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
} from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
@@ -153,6 +157,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
];
},
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>

View File

@@ -1001,6 +1001,7 @@ async function processMessage(
CommandAuthorized: commandAuthorized,
};
let sentMessage = false;
if (chatGuidForActions && baseUrl && password) {
logVerbose(core, runtime, `typing start (pre-dispatch) chatGuid=${chatGuidForActions}`);
try {
@@ -1031,6 +1032,7 @@ async function processMessage(
cfg: config,
accountId: account.accountId,
});
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
},
@@ -1048,15 +1050,7 @@ async function processMessage(
}
},
onIdle: () => {
if (!chatGuidForActions) return;
if (!baseUrl || !password) return;
logVerbose(core, runtime, `typing stop chatGuid=${chatGuidForActions}`);
void sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
});
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -1070,14 +1064,8 @@ async function processMessage(
},
});
} finally {
if (chatGuidForActions && baseUrl && password) {
logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`);
void sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config,
accountId: account.accountId,
}).catch((err) => {
runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`);
});
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
}
}
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
} from "./targets.js";
describe("normalizeBlueBubblesMessagingTarget", () => {
it("normalizes chat_guid targets", () => {
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
});
it("normalizes group numeric targets to chat_id", () => {
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
});
it("strips provider prefix and normalizes handles", () => {
expect(
normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com"),
).toBe("imessage:user@example.com");
});
});
describe("looksLikeBlueBubblesTargetId", () => {
it("accepts chat targets", () => {
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
});
it("accepts email handles", () => {
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
});
it("accepts phone numbers with punctuation", () => {
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
});
it("rejects display names", () => {
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
});
});

View File

@@ -25,6 +25,13 @@ function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function stripBlueBubblesPrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed;
return trimmed.slice("bluebubbles:".length).trim();
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
@@ -36,6 +43,55 @@ export function normalizeBlueBubblesHandle(raw: string): string {
return trimmed.replace(/\s+/g, "");
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) return undefined;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`;
if (parsed.kind === "chat_guid") return `chat_guid:${parsed.chatGuid}`;
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`;
const handle = normalizeBlueBubblesHandle(parsed.to);
if (!handle) return undefined;
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch {
return trimmed;
}
}
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) return false;
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) return true;
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
if (candidate.includes("@")) return true;
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) return false;
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = raw.trim();
if (!trimmed) throw new Error("BlueBubbles target is required");