feat: enhance BlueBubbles channel integration with new messaging target normalization and typing indicator improvements
This commit is contained in:
committed by
Peter Steinberger
parent
61907ddf3e
commit
a5d89e6eb1
@@ -98,6 +98,7 @@ Per-group configuration:
|
||||
## Typing + read receipts
|
||||
- **Typing indicators**: Sent automatically before and during response generation.
|
||||
- **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
|
||||
{
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
extensions/bluebubbles/src/targets.test.ts
Normal file
40
extensions/bluebubbles/src/targets.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -250,6 +250,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.slack": "Slack",
|
||||
"channels.signal": "Signal",
|
||||
"channels.imessage": "iMessage",
|
||||
"channels.bluebubbles": "BlueBubbles",
|
||||
"channels.msteams": "MS Teams",
|
||||
"channels.telegram.botToken": "Telegram Bot Token",
|
||||
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
||||
@@ -268,6 +269,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
|
||||
"channels.signal.dmPolicy": "Signal DM Policy",
|
||||
"channels.imessage.dmPolicy": "iMessage DM Policy",
|
||||
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
|
||||
"channels.discord.dm.policy": "Discord DM Policy",
|
||||
"channels.discord.retry.attempts": "Discord Retry Attempts",
|
||||
"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=["*"].',
|
||||
"channels.imessage.dmPolicy":
|
||||
'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":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
|
||||
"channels.discord.retry.attempts":
|
||||
|
||||
@@ -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
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BlueBubblesConfigSchema,
|
||||
DiscordConfigSchema,
|
||||
IMessageConfigSchema,
|
||||
MSTeamsConfigSchema,
|
||||
@@ -28,6 +29,7 @@ export const ChannelsSchema = z
|
||||
slack: SlackConfigSchema.optional(),
|
||||
signal: SignalConfigSchema.optional(),
|
||||
imessage: IMessageConfigSchema.optional(),
|
||||
bluebubbles: BlueBubblesConfigSchema.optional(),
|
||||
msteams: MSTeamsConfigSchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
Reference in New Issue
Block a user