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 + 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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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)}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
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");
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user