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

@@ -98,6 +98,7 @@ Per-group configuration:
## Typing + read receipts ## Typing + read receipts
- **Typing indicators**: Sent automatically before and during response generation. - **Typing indicators**: Sent automatically before and during response generation.
- **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`). - **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`).
- **Typing indicators**: Clawdbot sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable).
```json5 ```json5
{ {

View File

@@ -26,7 +26,11 @@ import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js"; import { probeBlueBubbles } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { sendBlueBubblesAttachment } from "./attachments.js"; import { sendBlueBubblesAttachment } from "./attachments.js";
import { normalizeBlueBubblesHandle } from "./targets.js"; import {
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
} from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
import { blueBubblesOnboardingAdapter } from "./onboarding.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: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => applyAccountName: ({ cfg, accountId, name }) =>

View File

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

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(); 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 { export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return ""; if (!trimmed) return "";
@@ -36,6 +43,55 @@ export function normalizeBlueBubblesHandle(raw: string): string {
return trimmed.replace(/\s+/g, ""); 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 { export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) throw new Error("BlueBubbles target is required"); if (!trimmed) throw new Error("BlueBubbles target is required");

View File

@@ -250,6 +250,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.slack": "Slack", "channels.slack": "Slack",
"channels.signal": "Signal", "channels.signal": "Signal",
"channels.imessage": "iMessage", "channels.imessage": "iMessage",
"channels.bluebubbles": "BlueBubbles",
"channels.msteams": "MS Teams", "channels.msteams": "MS Teams",
"channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy", "channels.telegram.dmPolicy": "Telegram DM Policy",
@@ -268,6 +269,7 @@ const FIELD_LABELS: Record<string, string> = {
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.signal.dmPolicy": "Signal DM Policy", "channels.signal.dmPolicy": "Signal DM Policy",
"channels.imessage.dmPolicy": "iMessage DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
"channels.discord.dm.policy": "Discord DM Policy", "channels.discord.dm.policy": "Discord DM Policy",
"channels.discord.retry.attempts": "Discord Retry Attempts", "channels.discord.retry.attempts": "Discord Retry Attempts",
"channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
@@ -540,6 +542,8 @@ const FIELD_HELP: Record<string, string> = {
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
"channels.imessage.dmPolicy": "channels.imessage.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].',
"channels.bluebubbles.dmPolicy":
'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].',
"channels.discord.dm.policy": "channels.discord.dm.policy":
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
"channels.discord.retry.attempts": "channels.discord.retry.attempts":

View File

@@ -482,6 +482,79 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({
}); });
}); });
const BlueBubblesAllowFromEntry = z.union([z.string(), z.number()]);
const BlueBubblesActionSchema = z
.object({
reactions: z.boolean().optional(),
edit: z.boolean().optional(),
unsend: z.boolean().optional(),
reply: z.boolean().optional(),
sendWithEffect: z.boolean().optional(),
renameGroup: z.boolean().optional(),
addParticipant: z.boolean().optional(),
removeParticipant: z.boolean().optional(),
leaveGroup: z.boolean().optional(),
sendAttachment: z.boolean().optional(),
})
.strict()
.optional();
const BlueBubblesGroupConfigSchema = z
.object({
requireMention: z.boolean().optional(),
})
.strict();
export const BlueBubblesAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
configWrites: z.boolean().optional(),
enabled: z.boolean().optional(),
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
sendReadReceipts: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(),
})
.strict();
export const BlueBubblesAccountSchema = BlueBubblesAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.bluebubbles.accounts.*.dmPolicy="open" requires allowFrom to include "*"',
});
});
export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({
accounts: z.record(z.string(), BlueBubblesAccountSchema.optional()).optional(),
actions: BlueBubblesActionSchema,
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message:
'channels.bluebubbles.dmPolicy="open" requires channels.bluebubbles.allowFrom to include "*"',
});
});
export const MSTeamsChannelSchema = z export const MSTeamsChannelSchema = z
.object({ .object({
requireMention: z.boolean().optional(), requireMention: z.boolean().optional(),

View File

@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { import {
BlueBubblesConfigSchema,
DiscordConfigSchema, DiscordConfigSchema,
IMessageConfigSchema, IMessageConfigSchema,
MSTeamsConfigSchema, MSTeamsConfigSchema,
@@ -28,6 +29,7 @@ export const ChannelsSchema = z
slack: SlackConfigSchema.optional(), slack: SlackConfigSchema.optional(),
signal: SignalConfigSchema.optional(), signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(), imessage: IMessageConfigSchema.optional(),
bluebubbles: BlueBubblesConfigSchema.optional(),
msteams: MSTeamsConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(),
}) })
.passthrough() .passthrough()