Compare commits
4 Commits
main
...
fix/bluebu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56fab1df69 | ||
|
|
bf529f56b4 | ||
|
|
1ada67ffb3 | ||
|
|
c607dfc1d6 |
@ -18,6 +18,8 @@ Docs: https://docs.clawd.bot
|
|||||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||||
- Model picker: list the full catalog when no model allowlist is configured.
|
- Model picker: list the full catalog when no model allowlist is configured.
|
||||||
|
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204.
|
||||||
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
|
|||||||
@ -3022,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
|
|||||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
||||||
| `{{To}}` | Destination identifier |
|
| `{{To}}` | Destination identifier |
|
||||||
| `{{MessageSid}}` | Channel message id (when available) |
|
| `{{MessageSid}}` | Channel message id (when available) |
|
||||||
|
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
|
||||||
|
| `{{ReplyToId}}` | Reply-to message id (when available) |
|
||||||
|
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
|
||||||
| `{{SessionId}}` | Current session UUID |
|
| `{{SessionId}}` | Current session UUID |
|
||||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||||
|
|||||||
@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
|
|||||||
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./monitor.js", () => ({
|
||||||
|
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
||||||
|
}));
|
||||||
|
|
||||||
describe("bluebubblesMessageActions", () => {
|
describe("bluebubblesMessageActions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
|
||||||
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
|
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "👍",
|
||||||
|
messageId: "msg-456",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
toolContext: {
|
||||||
|
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
chatGuid: "iMessage;-;+15550001111",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short messageId before reacting", async () => {
|
||||||
|
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
||||||
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
|
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "❤️",
|
||||||
|
messageId: "1",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
|
||||||
|
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
messageGuid: "resolved-uuid",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates short-id errors from the resolver", async () => {
|
||||||
|
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
|
||||||
|
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
|
||||||
|
throw new Error("short id expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg: ClawdbotConfig = {
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test-password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bluebubblesMessageActions.handleAction({
|
||||||
|
action: "react",
|
||||||
|
params: {
|
||||||
|
emoji: "❤️",
|
||||||
|
messageId: "999",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
accountId: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("short id expired");
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts message param for edit action", async () => {
|
it("accepts message param for edit action", async () => {
|
||||||
const { editBlueBubblesMessage } = await import("./chat.js");
|
const { editBlueBubblesMessage } = await import("./chat.js");
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { isMacOS26OrHigher } from "./probe.js";
|
import { isMacOS26OrHigher } from "./probe.js";
|
||||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
@ -77,7 +78,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
||||||
|
|
||||||
// Helper to resolve chatGuid from various params
|
// Helper to resolve chatGuid from various params or session context
|
||||||
const resolveChatGuid = async (): Promise<string> => {
|
const resolveChatGuid = async (): Promise<string> => {
|
||||||
const chatGuid = readStringParam(params, "chatGuid");
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
if (chatGuid?.trim()) return chatGuid.trim();
|
if (chatGuid?.trim()) return chatGuid.trim();
|
||||||
@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
const to = readStringParam(params, "to");
|
const to = readStringParam(params, "to");
|
||||||
|
// Fall back to session context if no explicit target provided
|
||||||
|
const contextTarget = toolContext?.currentChannelId?.trim();
|
||||||
|
|
||||||
const target = chatIdentifier?.trim()
|
const target = chatIdentifier?.trim()
|
||||||
? ({
|
? ({
|
||||||
@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
: to
|
: to
|
||||||
? mapTarget(to)
|
? mapTarget(to)
|
||||||
: null;
|
: contextTarget
|
||||||
|
? mapTarget(contextTarget)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
||||||
@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
});
|
});
|
||||||
if (isEmpty && !remove) {
|
if (isEmpty && !remove) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.",
|
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
|
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
|
||||||
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
"Apple removed the ability to edit iMessages in this version.",
|
"Apple removed the ability to edit iMessages in this version.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const newText =
|
const newText =
|
||||||
readStringParam(params, "text") ??
|
readStringParam(params, "text") ??
|
||||||
readStringParam(params, "newText") ??
|
readStringParam(params, "newText") ??
|
||||||
readStringParam(params, "message");
|
readStringParam(params, "message");
|
||||||
if (!messageId || !newText) {
|
if (!rawMessageId || !newText) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!messageId) missing.push("messageId (the message GUID to edit)");
|
if (!rawMessageId) missing.push("messageId (the message ID to edit)");
|
||||||
if (!newText) missing.push("text (the new message content)");
|
if (!newText) missing.push("text (the new message content)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=edit with messageId=<message_guid>, text=<new_content>.`,
|
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
|
|
||||||
@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, edited: messageId });
|
return jsonResult({ ok: true, edited: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle unsend action
|
// Handle unsend action
|
||||||
if (action === "unsend") {
|
if (action === "unsend") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
|
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
|
||||||
"Use action=unsend with messageId=<message_guid>.",
|
"Use action=unsend with messageId=<message_id>.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await unsendBlueBubblesMessage(messageId, {
|
||||||
@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, unsent: messageId });
|
return jsonResult({ ok: true, unsent: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle reply action
|
// Handle reply action
|
||||||
if (action === "reply") {
|
if (action === "reply") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const text = readMessageText(params);
|
const text = readMessageText(params);
|
||||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||||
if (!messageId || !text || !to) {
|
if (!rawMessageId || !text || !to) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!messageId) missing.push("messageId (the message GUID to reply to)");
|
if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
|
||||||
if (!text) missing.push("text or message (the reply message content)");
|
if (!text) missing.push("text or message (the reply message content)");
|
||||||
if (!to) missing.push("to or target (the chat target)");
|
if (!to) missing.push("to or target (the chat target)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
|
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
|
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
|
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle sendWithEffect action
|
// Handle sendWithEffect action
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
import {
|
import {
|
||||||
@ -65,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
threading: {
|
threading: {
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||||
currentChannelId: context.To?.trim() || undefined,
|
currentChannelId: context.To?.trim() || undefined,
|
||||||
currentThreadTs: context.ReplyToId,
|
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
|
||||||
hasRepliedRef,
|
hasRepliedRef,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@ -237,7 +238,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : "";
|
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = rawReplyToId
|
||||||
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
|
: "";
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||||
|
import { sendMessageBlueBubbles } from "./send.js";
|
||||||
|
|
||||||
const HTTP_URL_RE = /^https?:\/\//i;
|
const HTTP_URL_RE = /^https?:\/\//i;
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
@ -134,12 +135,17 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = replyToId?.trim()
|
||||||
|
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const attachmentResult = await sendBlueBubblesAttachment({
|
const attachmentResult = await sendBlueBubblesAttachment({
|
||||||
to,
|
to,
|
||||||
buffer,
|
buffer,
|
||||||
filename: resolvedFilename ?? "attachment",
|
filename: resolvedFilename ?? "attachment",
|
||||||
contentType: resolvedContentType ?? undefined,
|
contentType: resolvedContentType ?? undefined,
|
||||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
replyToMessageGuid,
|
||||||
opts: {
|
opts: {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
await sendMessageBlueBubbles(to, trimmedCaption, {
|
await sendMessageBlueBubbles(to, trimmedCaption, {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
replyToMessageGuid: replyToId?.trim() || undefined,
|
replyToMessageGuid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
|||||||
import {
|
import {
|
||||||
handleBlueBubblesWebhookRequest,
|
handleBlueBubblesWebhookRequest,
|
||||||
registerBlueBubblesWebhookTarget,
|
registerBlueBubblesWebhookTarget,
|
||||||
|
resolveBlueBubblesMessageId,
|
||||||
|
_resetBlueBubblesShortIdState,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset short ID state between tests for predictable behavior
|
||||||
|
_resetBlueBubblesShortIdState();
|
||||||
mockReadAllowFromStore.mockResolvedValue([]);
|
mockReadAllowFromStore.mockResolvedValue([]);
|
||||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||||
mockResolveRequireMention.mockReturnValue(false);
|
mockResolveRequireMention.mockReturnValue(false);
|
||||||
@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(handled).toBe(false);
|
expect(handled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses chatId when provided as a string (webhook variant)", async () => {
|
||||||
|
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||||
|
|
||||||
|
const account = createMockAccount({ groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello from group",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chatId: "123",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
target: { kind: "chat_id", chatId: 123 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
||||||
|
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
||||||
|
vi.mocked(sendMessageBlueBubbles).mockClear();
|
||||||
|
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||||
|
|
||||||
|
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||||
|
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const account = createMockAccount({ groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello from group",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-1",
|
||||||
|
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||||
|
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||||
|
"chat_guid:iMessage;+;chat123456",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("DM pairing behavior vs allowFrom", () => {
|
describe("DM pairing behavior vs allowFrom", () => {
|
||||||
@ -1075,13 +1171,86 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// ReplyToId is the full UUID since it wasn't previously cached
|
||||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
|
// Body uses just the ID (no sender) for token savings
|
||||||
|
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message");
|
expect(callArgs.ctx.Body).toContain("original message");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const chatGuid = "iMessage;+;chat-reply-cache";
|
||||||
|
|
||||||
|
const originalPayload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "original message (cached)",
|
||||||
|
handle: { address: "+15550000000" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "cache-msg-0",
|
||||||
|
chatGuid,
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
|
||||||
|
const originalRes = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Only assert the reply message behavior below.
|
||||||
|
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
|
||||||
|
|
||||||
|
const replyPayload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "replying now",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: true,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "cache-msg-1",
|
||||||
|
chatGuid,
|
||||||
|
// Only the GUID is provided; sender/body must be hydrated.
|
||||||
|
replyToMessageGuid: "cache-msg-0",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
|
||||||
|
const replyRes = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// ReplyToId uses short ID "1" (first cached message) for token savings
|
||||||
|
expect(callArgs.ctx.ReplyToId).toBe("1");
|
||||||
|
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||||
|
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||||
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
|
// Body uses just the short ID (no sender) for token savings
|
||||||
|
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
||||||
|
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||||
const account = createMockAccount({ dmPolicy: "open" });
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
const config: ClawdbotConfig = {};
|
const config: ClawdbotConfig = {};
|
||||||
@ -1436,8 +1605,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
"BlueBubbles sent message id: msg-123",
|
'Assistant sent "replying now" [message_id:2]',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||||
}),
|
}),
|
||||||
@ -1605,6 +1775,99 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("short message ID mapping", () => {
|
||||||
|
it("assigns sequential short IDs to messages", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-uuid-12345",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
// MessageSid should be short ID "1" instead of full UUID
|
||||||
|
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||||
|
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short ID back to UUID", async () => {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: ClawdbotConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: "msg-uuid-12345",
|
||||||
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||||
|
const res = createMockResponse();
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// The short ID "1" should resolve back to the full UUID
|
||||||
|
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns UUID unchanged when not in cache", () => {
|
||||||
|
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns short ID unchanged when numeric but not in cache", () => {
|
||||||
|
expect(resolveBlueBubblesMessageId("999")).toBe("999");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveBlueBubblesMessageId("999", { requireKnownShortId: true }),
|
||||||
|
).toThrow(/short message id/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("fromMe messages", () => {
|
describe("fromMe messages", () => {
|
||||||
it("ignores messages from self (fromMe=true)", async () => {
|
it("ignores messages from self (fromMe=true)", async () => {
|
||||||
const account = createMockAccount();
|
const account = createMockAccount();
|
||||||
|
|||||||
@ -31,6 +31,173 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
|||||||
const DEFAULT_TEXT_LIMIT = 4000;
|
const DEFAULT_TEXT_LIMIT = 4000;
|
||||||
const invalidAckReactions = new Set<string>();
|
const invalidAckReactions = new Set<string>();
|
||||||
|
|
||||||
|
const REPLY_CACHE_MAX = 2000;
|
||||||
|
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type BlueBubblesReplyCacheEntry = {
|
||||||
|
accountId: string;
|
||||||
|
messageId: string;
|
||||||
|
shortId: string;
|
||||||
|
chatGuid?: string;
|
||||||
|
chatIdentifier?: string;
|
||||||
|
chatId?: number;
|
||||||
|
senderLabel?: string;
|
||||||
|
body?: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||||
|
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||||
|
|
||||||
|
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
||||||
|
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||||
|
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||||
|
let blueBubblesShortIdCounter = 0;
|
||||||
|
|
||||||
|
function trimOrUndefined(value?: string | null): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateShortId(): string {
|
||||||
|
blueBubblesShortIdCounter += 1;
|
||||||
|
return String(blueBubblesShortIdCounter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberBlueBubblesReplyCache(
|
||||||
|
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
|
||||||
|
): BlueBubblesReplyCacheEntry {
|
||||||
|
const messageId = entry.messageId.trim();
|
||||||
|
if (!messageId) {
|
||||||
|
return { ...entry, shortId: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we already have a short ID for this UUID
|
||||||
|
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||||
|
if (!shortId) {
|
||||||
|
shortId = generateShortId();
|
||||||
|
blueBubblesShortIdToUuid.set(shortId, messageId);
|
||||||
|
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
||||||
|
|
||||||
|
// Refresh insertion order.
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||||
|
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
|
||||||
|
|
||||||
|
// Opportunistic prune.
|
||||||
|
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||||
|
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
|
||||||
|
if (value.timestamp < cutoff) {
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(key);
|
||||||
|
// Clean up short ID mappings for expired entries
|
||||||
|
if (value.shortId) {
|
||||||
|
blueBubblesShortIdToUuid.delete(value.shortId);
|
||||||
|
blueBubblesUuidToShortId.delete(key);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
||||||
|
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
||||||
|
if (!oldest) break;
|
||||||
|
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(oldest);
|
||||||
|
// Clean up short ID mappings for evicted entries
|
||||||
|
if (oldEntry?.shortId) {
|
||||||
|
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
|
||||||
|
blueBubblesUuidToShortId.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
||||||
|
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
||||||
|
*/
|
||||||
|
export function resolveBlueBubblesMessageId(
|
||||||
|
shortOrUuid: string,
|
||||||
|
opts?: { requireKnownShortId?: boolean },
|
||||||
|
): string {
|
||||||
|
const trimmed = shortOrUuid.trim();
|
||||||
|
if (!trimmed) return trimmed;
|
||||||
|
|
||||||
|
// If it looks like a short ID (numeric), try to resolve it
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
||||||
|
if (uuid) return uuid;
|
||||||
|
if (opts?.requireKnownShortId) {
|
||||||
|
throw new Error(
|
||||||
|
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return as-is (either already a UUID or not found)
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the short ID state. Only use in tests.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function _resetBlueBubblesShortIdState(): void {
|
||||||
|
blueBubblesShortIdToUuid.clear();
|
||||||
|
blueBubblesUuidToShortId.clear();
|
||||||
|
blueBubblesReplyCacheByMessageId.clear();
|
||||||
|
blueBubblesShortIdCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the short ID for a UUID, if one exists.
|
||||||
|
*/
|
||||||
|
function getShortIdForUuid(uuid: string): string | undefined {
|
||||||
|
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReplyContextFromCache(params: {
|
||||||
|
accountId: string;
|
||||||
|
replyToId: string;
|
||||||
|
chatGuid?: string;
|
||||||
|
chatIdentifier?: string;
|
||||||
|
chatId?: number;
|
||||||
|
}): BlueBubblesReplyCacheEntry | null {
|
||||||
|
const replyToId = params.replyToId.trim();
|
||||||
|
if (!replyToId) return null;
|
||||||
|
|
||||||
|
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
||||||
|
if (!cached) return null;
|
||||||
|
if (cached.accountId !== params.accountId) return null;
|
||||||
|
|
||||||
|
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||||
|
if (cached.timestamp < cutoff) {
|
||||||
|
blueBubblesReplyCacheByMessageId.delete(replyToId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatGuid = trimOrUndefined(params.chatGuid);
|
||||||
|
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
|
||||||
|
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
|
||||||
|
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
|
||||||
|
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
|
||||||
|
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
||||||
|
|
||||||
|
// Avoid cross-chat collisions if we have identifiers.
|
||||||
|
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
|
||||||
|
if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
||||||
|
|
||||||
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
||||||
@ -217,19 +384,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||||
|
|
||||||
function formatReplyContext(message: {
|
function formatReplyContext(message: {
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
replyToShortId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
replyToSender?: string;
|
replyToSender?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||||
const sender = message.replyToSender?.trim() || "unknown sender";
|
// Prefer short ID for token savings
|
||||||
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
|
const displayId = message.replyToShortId || message.replyToId;
|
||||||
const body = message.replyToBody?.trim();
|
// Only include sender if we don't have an ID (fallback)
|
||||||
if (!body) {
|
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
||||||
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
const rawBody = message.replyToBody?.trim();
|
||||||
|
if (!rawBody) {
|
||||||
|
return `[Replying to ${label}]\n[/Replying]`;
|
||||||
}
|
}
|
||||||
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`;
|
// Truncate long reply bodies for token savings
|
||||||
|
const body =
|
||||||
|
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
|
||||||
|
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
|
||||||
|
: rawBody;
|
||||||
|
return `[Replying to ${label}]\n${body}\n[/Replying]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
@ -404,6 +581,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
||||||
|
const guid = chatGuid?.trim();
|
||||||
|
if (!guid) return undefined;
|
||||||
|
const parts = guid.split(";");
|
||||||
|
if (parts.length < 3) return undefined;
|
||||||
|
const identifier = parts[2]?.trim();
|
||||||
|
return identifier || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function formatGroupAllowlistEntry(params: {
|
function formatGroupAllowlistEntry(params: {
|
||||||
chatGuid?: string;
|
chatGuid?: string;
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
@ -550,20 +736,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumber(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumber(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
readNumber(chat, "id") ??
|
readNumberLike(chat, "chatId") ??
|
||||||
readNumber(chatFromList, "id");
|
readNumberLike(chat, "chat_id") ??
|
||||||
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
readString(chat, "displayName") ??
|
readString(chat, "displayName") ??
|
||||||
@ -679,19 +876,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumberLike(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumberLike(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
|
readNumberLike(chat, "chatId") ??
|
||||||
|
readNumberLike(chat, "chat_id") ??
|
||||||
readNumberLike(chat, "id") ??
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
readNumberLike(chatFromList, "id");
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
@ -901,14 +1109,39 @@ async function processMessage(
|
|||||||
target: WebhookTarget,
|
target: WebhookTarget,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, core, statusSink } = target;
|
const { account, config, runtime, core, statusSink } = target;
|
||||||
if (message.fromMe) return;
|
|
||||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
const placeholder = buildMessagePlaceholder(message);
|
const placeholder = buildMessagePlaceholder(message);
|
||||||
if (!text && !placeholder) {
|
const rawBody = text || placeholder;
|
||||||
|
|
||||||
|
const cacheMessageId = message.messageId?.trim();
|
||||||
|
let messageShortId: string | undefined;
|
||||||
|
const cacheInboundMessage = () => {
|
||||||
|
if (!cacheMessageId) return;
|
||||||
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
messageId: cacheMessageId,
|
||||||
|
chatGuid: message.chatGuid,
|
||||||
|
chatIdentifier: message.chatIdentifier,
|
||||||
|
chatId: message.chatId,
|
||||||
|
senderLabel: message.fromMe ? "me" : message.senderId,
|
||||||
|
body: rawBody,
|
||||||
|
timestamp: message.timestamp ?? Date.now(),
|
||||||
|
});
|
||||||
|
messageShortId = cacheEntry.shortId;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.fromMe) {
|
||||||
|
// Cache from-me messages so reply context can resolve sender/body.
|
||||||
|
cacheInboundMessage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawBody) {
|
||||||
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1148,6 +1381,10 @@ async function processMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache allowed inbound messages so later replies can resolve sender/body without
|
||||||
|
// surfacing dropped content (allowlist/mention/command gating).
|
||||||
|
cacheInboundMessage();
|
||||||
|
|
||||||
const baseUrl = account.config.serverUrl?.trim();
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const maxBytes =
|
const maxBytes =
|
||||||
@ -1199,12 +1436,42 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rawBody = text.trim() || placeholder;
|
let replyToId = message.replyToId;
|
||||||
const replyContext = formatReplyContext(message);
|
let replyToBody = message.replyToBody;
|
||||||
|
let replyToSender = message.replyToSender;
|
||||||
|
let replyToShortId: string | undefined;
|
||||||
|
|
||||||
|
if (replyToId && (!replyToBody || !replyToSender)) {
|
||||||
|
const cached = resolveReplyContextFromCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
replyToId,
|
||||||
|
chatGuid: message.chatGuid,
|
||||||
|
chatIdentifier: message.chatIdentifier,
|
||||||
|
chatId: message.chatId,
|
||||||
|
});
|
||||||
|
if (cached) {
|
||||||
|
if (!replyToBody && cached.body) replyToBody = cached.body;
|
||||||
|
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
|
||||||
|
replyToShortId = cached.shortId;
|
||||||
|
if (core.logging.shouldLogVerbose()) {
|
||||||
|
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
||||||
|
logVerbose(
|
||||||
|
core,
|
||||||
|
runtime,
|
||||||
|
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cached short ID, try to get one from the UUID directly
|
||||||
|
if (replyToId && !replyToShortId) {
|
||||||
|
replyToShortId = getShortIdForUuid(replyToId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
||||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||||
? `group:${peerId}`
|
|
||||||
: message.senderName || `user:${message.senderId}`;
|
|
||||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||||
const groupMembers = isGroup
|
const groupMembers = isGroup
|
||||||
? formatGroupMembers({
|
? formatGroupMembers({
|
||||||
@ -1230,12 +1497,12 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
if (!chatGuidForActions && baseUrl && password) {
|
if (!chatGuidForActions && baseUrl && password) {
|
||||||
const target =
|
const target =
|
||||||
isGroup && (chatId || chatIdentifier)
|
isGroup && (chatId || chatIdentifier)
|
||||||
? chatId
|
? chatId
|
||||||
? { kind: "chat_id", chatId }
|
? ({ kind: "chat_id", chatId } as const)
|
||||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
||||||
: { kind: "handle", address: message.senderId };
|
: ({ kind: "handle", address: message.senderId } as const);
|
||||||
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
||||||
chatGuidForActions =
|
chatGuidForActions =
|
||||||
(await resolveChatGuidForTarget({
|
(await resolveChatGuidForTarget({
|
||||||
@ -1316,10 +1583,23 @@ async function processMessage(
|
|||||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||||
: message.senderId;
|
: message.senderId;
|
||||||
|
|
||||||
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
|
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||||
const trimmed = messageId?.trim();
|
const trimmed = messageId?.trim();
|
||||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
||||||
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
|
// Cache outbound message to get short ID
|
||||||
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
|
accountId: account.accountId,
|
||||||
|
messageId: trimmed,
|
||||||
|
chatGuid: chatGuidForActions ?? chatGuid,
|
||||||
|
chatIdentifier,
|
||||||
|
chatId,
|
||||||
|
senderLabel: "me",
|
||||||
|
body: snippet ?? "",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
const displayId = cacheEntry.shortId || trimmed;
|
||||||
|
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
|
||||||
|
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||||
});
|
});
|
||||||
@ -1343,16 +1623,20 @@ async function processMessage(
|
|||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
ReplyToId: message.replyToId,
|
// Use short ID for token savings (agent can use this to reference the message)
|
||||||
ReplyToBody: message.replyToBody,
|
ReplyToId: replyToShortId || replyToId,
|
||||||
ReplyToSender: message.replyToSender,
|
ReplyToIdFull: replyToId,
|
||||||
|
ReplyToBody: replyToBody,
|
||||||
|
ReplyToSender: replyToSender,
|
||||||
GroupSubject: groupSubject,
|
GroupSubject: groupSubject,
|
||||||
GroupMembers: groupMembers,
|
GroupMembers: groupMembers,
|
||||||
SenderName: message.senderName || undefined,
|
SenderName: message.senderName || undefined,
|
||||||
SenderId: message.senderId,
|
SenderId: message.senderId,
|
||||||
Provider: "bluebubbles",
|
Provider: "bluebubbles",
|
||||||
Surface: "bluebubbles",
|
Surface: "bluebubbles",
|
||||||
MessageSid: message.messageId,
|
// Use short ID for token savings (agent can use this to reference the message)
|
||||||
|
MessageSid: messageShortId || message.messageId,
|
||||||
|
MessageSidFull: message.messageId,
|
||||||
Timestamp: message.timestamp,
|
Timestamp: message.timestamp,
|
||||||
OriginatingChannel: "bluebubbles",
|
OriginatingChannel: "bluebubbles",
|
||||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||||
@ -1367,6 +1651,11 @@ async function processMessage(
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||||
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
|
const replyToMessageGuid = rawReplyToId
|
||||||
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
|
: "";
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
: payload.mediaUrl
|
: payload.mediaUrl
|
||||||
@ -1382,10 +1671,11 @@ async function processMessage(
|
|||||||
to: outboundTarget,
|
to: outboundTarget,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
caption: caption ?? undefined,
|
caption: caption ?? undefined,
|
||||||
replyToId: payload.replyToId ?? null,
|
replyToId: replyToMessageGuid || null,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
||||||
|
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@ -1400,14 +1690,12 @@ async function processMessage(
|
|||||||
if (!chunks.length && payload.text) chunks.push(payload.text);
|
if (!chunks.length && payload.text) chunks.push(payload.text);
|
||||||
if (!chunks.length) return;
|
if (!chunks.length) return;
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
const replyToMessageGuid =
|
|
||||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
|
||||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||||
cfg: config,
|
cfg: config,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@ -1541,7 +1829,9 @@ async function processReaction(
|
|||||||
|
|
||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
// Use short ID for token savings
|
||||||
|
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||||
|
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
||||||
core.system.enqueueSystemEvent(text, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
|
|||||||
@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const REACTION_ALIASES = new Map<string, string>([
|
const REACTION_ALIASES = new Map<string, string>([
|
||||||
|
// General
|
||||||
["heart", "love"],
|
["heart", "love"],
|
||||||
|
["love", "love"],
|
||||||
|
["❤", "love"],
|
||||||
|
["❤️", "love"],
|
||||||
|
["red_heart", "love"],
|
||||||
["thumbs_up", "like"],
|
["thumbs_up", "like"],
|
||||||
["thumbs-down", "dislike"],
|
["thumbsup", "like"],
|
||||||
|
["thumbs-up", "like"],
|
||||||
|
["thumbsup", "like"],
|
||||||
|
["like", "like"],
|
||||||
|
["thumb", "like"],
|
||||||
|
["ok", "like"],
|
||||||
["thumbs_down", "dislike"],
|
["thumbs_down", "dislike"],
|
||||||
|
["thumbsdown", "dislike"],
|
||||||
|
["thumbs-down", "dislike"],
|
||||||
|
["dislike", "dislike"],
|
||||||
|
["boo", "dislike"],
|
||||||
|
["no", "dislike"],
|
||||||
|
// Laugh
|
||||||
["haha", "laugh"],
|
["haha", "laugh"],
|
||||||
["lol", "laugh"],
|
["lol", "laugh"],
|
||||||
|
["lmao", "laugh"],
|
||||||
|
["rofl", "laugh"],
|
||||||
|
["😂", "laugh"],
|
||||||
|
["🤣", "laugh"],
|
||||||
|
["xd", "laugh"],
|
||||||
|
["laugh", "laugh"],
|
||||||
|
// Emphasize / exclaim
|
||||||
["emphasis", "emphasize"],
|
["emphasis", "emphasize"],
|
||||||
|
["emphasize", "emphasize"],
|
||||||
["exclaim", "emphasize"],
|
["exclaim", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
|
["‼", "emphasize"],
|
||||||
|
["‼️", "emphasize"],
|
||||||
|
["❗", "emphasize"],
|
||||||
|
["important", "emphasize"],
|
||||||
|
["bang", "emphasize"],
|
||||||
|
// Question
|
||||||
["question", "question"],
|
["question", "question"],
|
||||||
|
["?", "question"],
|
||||||
|
["❓", "question"],
|
||||||
|
["❔", "question"],
|
||||||
|
["ask", "question"],
|
||||||
|
// Apple/Messages names
|
||||||
|
["loved", "love"],
|
||||||
|
["liked", "like"],
|
||||||
|
["disliked", "dislike"],
|
||||||
|
["laughed", "laugh"],
|
||||||
|
["emphasized", "emphasize"],
|
||||||
|
["questioned", "question"],
|
||||||
|
// Colloquial / informal
|
||||||
|
["fire", "love"],
|
||||||
|
["🔥", "love"],
|
||||||
|
["wow", "emphasize"],
|
||||||
|
["!", "emphasize"],
|
||||||
|
// Edge: generic emoji name forms
|
||||||
|
["heart_eyes", "love"],
|
||||||
|
["smile", "laugh"],
|
||||||
|
["smiley", "laugh"],
|
||||||
|
["happy", "laugh"],
|
||||||
|
["joy", "laugh"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const REACTION_EMOJIS = new Map<string, string>([
|
const REACTION_EMOJIS = new Map<string, string>([
|
||||||
|
// Love
|
||||||
["❤️", "love"],
|
["❤️", "love"],
|
||||||
["❤", "love"],
|
["❤", "love"],
|
||||||
["♥️", "love"],
|
["♥️", "love"],
|
||||||
|
["♥", "love"],
|
||||||
["😍", "love"],
|
["😍", "love"],
|
||||||
|
["💕", "love"],
|
||||||
|
// Like
|
||||||
["👍", "like"],
|
["👍", "like"],
|
||||||
|
["👌", "like"],
|
||||||
|
// Dislike
|
||||||
["👎", "dislike"],
|
["👎", "dislike"],
|
||||||
|
["🙅", "dislike"],
|
||||||
|
// Laugh
|
||||||
["😂", "laugh"],
|
["😂", "laugh"],
|
||||||
["🤣", "laugh"],
|
["🤣", "laugh"],
|
||||||
["😆", "laugh"],
|
["😆", "laugh"],
|
||||||
|
["😁", "laugh"],
|
||||||
|
["😹", "laugh"],
|
||||||
|
// Emphasize
|
||||||
["‼️", "emphasize"],
|
["‼️", "emphasize"],
|
||||||
["‼", "emphasize"],
|
["‼", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
["❗", "emphasize"],
|
["❗", "emphasize"],
|
||||||
|
["❕", "emphasize"],
|
||||||
|
["!", "emphasize"],
|
||||||
|
// Question
|
||||||
["❓", "question"],
|
["❓", "question"],
|
||||||
["❔", "question"],
|
["❔", "question"],
|
||||||
|
["?", "question"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
|
|||||||
@ -96,6 +96,33 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;chat123");
|
expect(result).toBe("iMessage;-;chat123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
guid: "iMessage;+;chat660250192681427962",
|
||||||
|
participants: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const target: BlueBubblesSendTarget = {
|
||||||
|
kind: "chat_identifier",
|
||||||
|
chatIdentifier: "chat660250192681427962",
|
||||||
|
};
|
||||||
|
const result = await resolveChatGuidForTarget({
|
||||||
|
baseUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe("iMessage;+;chat660250192681427962");
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves handle target by matching participant", async () => {
|
it("resolves handle target by matching participant", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
||||||
|
const parts = chatGuid.split(";");
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const identifier = parts[2]?.trim();
|
||||||
|
return identifier ? identifier : null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
||||||
const raw =
|
const raw =
|
||||||
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
}
|
}
|
||||||
if (targetChatIdentifier) {
|
if (targetChatIdentifier) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
if (guid && guid === targetChatIdentifier) return guid;
|
if (guid) {
|
||||||
|
// Back-compat: some callers might pass a full chat GUID.
|
||||||
|
if (guid === targetChatIdentifier) return guid;
|
||||||
|
|
||||||
|
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
||||||
|
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
||||||
|
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
||||||
|
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
|
||||||
|
}
|
||||||
|
|
||||||
const identifier =
|
const identifier =
|
||||||
typeof chat.identifier === "string"
|
typeof chat.identifier === "string"
|
||||||
? chat.identifier
|
? chat.identifier
|
||||||
@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
: typeof chat.chat_identifier === "string"
|
: typeof chat.chat_identifier === "string"
|
||||||
? chat.chat_identifier
|
? chat.chat_identifier
|
||||||
: "";
|
: "";
|
||||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
|
||||||
}
|
}
|
||||||
if (normalizedHandle) {
|
if (normalizedHandle) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
|
|||||||
@ -299,6 +299,8 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
const { text, skip } = normalizeStreamingText(payload);
|
const { text, skip } = normalizeStreamingText(payload);
|
||||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||||
if (skip && !hasPayloadMedia) return;
|
if (skip && !hasPayloadMedia) return;
|
||||||
|
const currentMessageId =
|
||||||
|
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
|
||||||
const taggedPayload = applyReplyTagsToPayload(
|
const taggedPayload = applyReplyTagsToPayload(
|
||||||
{
|
{
|
||||||
text,
|
text,
|
||||||
@ -308,12 +310,12 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
replyToTag: payload.replyToTag,
|
replyToTag: payload.replyToTag,
|
||||||
replyToCurrent: payload.replyToCurrent,
|
replyToCurrent: payload.replyToCurrent,
|
||||||
},
|
},
|
||||||
params.sessionCtx.MessageSid,
|
currentMessageId,
|
||||||
);
|
);
|
||||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||||
currentMessageId: params.sessionCtx.MessageSid,
|
currentMessageId,
|
||||||
silentToken: SILENT_REPLY_TOKEN,
|
silentToken: SILENT_REPLY_TOKEN,
|
||||||
});
|
});
|
||||||
const cleaned = parsed.text || undefined;
|
const cleaned = parsed.text || undefined;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { NormalizedUsage } from "../../agents/usage.js";
|
import type { NormalizedUsage } from "../../agents/usage.js";
|
||||||
import { getChannelDock } from "../../channels/dock.js";
|
import { getChannelDock } from "../../channels/dock.js";
|
||||||
import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js";
|
||||||
import { normalizeChannelId } from "../../channels/registry.js";
|
import { normalizeChannelId } from "../../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||||
@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: {
|
|||||||
}): ChannelThreadingToolContext {
|
}): ChannelThreadingToolContext {
|
||||||
const { sessionCtx, config, hasRepliedRef } = params;
|
const { sessionCtx, config, hasRepliedRef } = params;
|
||||||
if (!config) return {};
|
if (!config) return {};
|
||||||
const provider = normalizeChannelId(sessionCtx.Provider);
|
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||||
if (!provider) return {};
|
if (!rawProvider) return {};
|
||||||
const dock = getChannelDock(provider);
|
const provider = normalizeChannelId(rawProvider);
|
||||||
if (!dock?.threading?.buildToolContext) return {};
|
|
||||||
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
||||||
const threadingTo =
|
const threadingTo =
|
||||||
provider === "whatsapp"
|
rawProvider === "whatsapp"
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
? (sessionCtx.From ?? sessionCtx.To)
|
||||||
: provider === "imessage" && sessionCtx.ChatType === "direct"
|
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
? (sessionCtx.From ?? sessionCtx.To)
|
||||||
: sessionCtx.To;
|
: sessionCtx.To;
|
||||||
|
// Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init)
|
||||||
|
const dock = provider ? getChannelDock(provider) : undefined;
|
||||||
|
if (!dock?.threading?.buildToolContext) {
|
||||||
|
return {
|
||||||
|
currentChannelId: threadingTo?.trim() || undefined,
|
||||||
|
currentChannelProvider: provider ?? (rawProvider as ChannelId),
|
||||||
|
hasRepliedRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
const context =
|
const context =
|
||||||
dock.threading.buildToolContext({
|
dock.threading.buildToolContext({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: {
|
|||||||
}) ?? {};
|
}) ?? {};
|
||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
currentChannelProvider: provider,
|
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -377,7 +377,7 @@ export async function runReplyAgent(params: {
|
|||||||
directlySentBlockKeys,
|
directlySentBlockKeys,
|
||||||
replyToMode,
|
replyToMode,
|
||||||
replyToChannel,
|
replyToChannel,
|
||||||
currentMessageId: sessionCtx.MessageSid,
|
currentMessageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
|
||||||
messageProvider: followupRun.run.messageProvider,
|
messageProvider: followupRun.run.messageProvider,
|
||||||
messagingToolSentTexts: runResult.messagingToolSentTexts,
|
messagingToolSentTexts: runResult.messagingToolSentTexts,
|
||||||
messagingToolSentTargets: runResult.messagingToolSentTargets,
|
messagingToolSentTargets: runResult.messagingToolSentTargets,
|
||||||
|
|||||||
@ -350,7 +350,7 @@ export async function runPreparedReply(
|
|||||||
const authProfileIdSource = sessionEntry?.authProfileOverrideSource;
|
const authProfileIdSource = sessionEntry?.authProfileOverrideSource;
|
||||||
const followupRun = {
|
const followupRun = {
|
||||||
prompt: queuedBody,
|
prompt: queuedBody,
|
||||||
messageId: sessionCtx.MessageSid,
|
messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
|
||||||
summaryLine: baseBodyTrimmedRaw,
|
summaryLine: baseBodyTrimmedRaw,
|
||||||
enqueuedAt: Date.now(),
|
enqueuedAt: Date.now(),
|
||||||
// Originating channel for reply routing.
|
// Originating channel for reply routing.
|
||||||
|
|||||||
@ -140,7 +140,7 @@ describe("createTypingSignaler", () => {
|
|||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not start typing on tool start before text", async () => {
|
it("starts typing on tool start before text", async () => {
|
||||||
const typing = createMockTypingController();
|
const typing = createMockTypingController();
|
||||||
const signaler = createTypingSignaler({
|
const signaler = createTypingSignaler({
|
||||||
typing,
|
typing,
|
||||||
@ -150,8 +150,9 @@ describe("createTypingSignaler", () => {
|
|||||||
|
|
||||||
await signaler.signalToolStart();
|
await signaler.signalToolStart();
|
||||||
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
expect(typing.refreshTypingTtl).not.toHaveBeenCalled();
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes ttl on tool start when active after text", async () => {
|
it("refreshes ttl on tool start when active after text", async () => {
|
||||||
|
|||||||
@ -95,13 +95,13 @@ export function createTypingSignaler(params: {
|
|||||||
|
|
||||||
const signalToolStart = async () => {
|
const signalToolStart = async () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (!hasRenderableText) return;
|
// Start typing as soon as tools begin executing, even before the first text delta.
|
||||||
if (!typing.isActive()) {
|
if (!typing.isActive()) {
|
||||||
await typing.startTypingLoop();
|
await typing.startTypingLoop();
|
||||||
typing.refreshTypingTtl();
|
typing.refreshTypingTtl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Keep typing indicator alive during tool execution without changing mode semantics.
|
// Keep typing indicator alive during tool execution.
|
||||||
typing.refreshTypingTtl();
|
typing.refreshTypingTtl();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -38,10 +38,14 @@ export type MsgContext = {
|
|||||||
AccountId?: string;
|
AccountId?: string;
|
||||||
ParentSessionKey?: string;
|
ParentSessionKey?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
|
/** Provider-specific full message id when MessageSid is a shortened alias. */
|
||||||
|
MessageSidFull?: string;
|
||||||
MessageSids?: string[];
|
MessageSids?: string[];
|
||||||
MessageSidFirst?: string;
|
MessageSidFirst?: string;
|
||||||
MessageSidLast?: string;
|
MessageSidLast?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
|
/** Provider-specific full reply-to id when ReplyToId is a shortened alias. */
|
||||||
|
ReplyToIdFull?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
ReplyToSender?: string;
|
ReplyToSender?: string;
|
||||||
ForwardedFrom?: string;
|
ForwardedFrom?: string;
|
||||||
|
|||||||
@ -212,6 +212,7 @@ export type ChannelThreadingContext = {
|
|||||||
Channel?: string;
|
Channel?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
|
ReplyToIdFull?: string;
|
||||||
ThreadLabel?: string;
|
ThreadLabel?: string;
|
||||||
MessageThreadId?: string | number;
|
MessageThreadId?: string | number;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js";
|
|||||||
import {
|
import {
|
||||||
runNodeDaemonInstall,
|
runNodeDaemonInstall,
|
||||||
runNodeDaemonRestart,
|
runNodeDaemonRestart,
|
||||||
runNodeDaemonStart,
|
|
||||||
runNodeDaemonStatus,
|
runNodeDaemonStatus,
|
||||||
runNodeDaemonStop,
|
runNodeDaemonStop,
|
||||||
runNodeDaemonUninstall,
|
runNodeDaemonUninstall,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
|
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
|
||||||
return (input: RequestInfo | URL, init?: RequestInit) => {
|
const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const signal = init?.signal;
|
const signal = init?.signal;
|
||||||
if (!signal) return fetchImpl(input, init);
|
if (!signal) return fetchImpl(input, init);
|
||||||
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
|
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
|
||||||
@ -25,5 +25,6 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
};
|
}) as typeof fetch;
|
||||||
|
return Object.assign(wrapped, fetchImpl);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||||
unsend: ["messageId"],
|
unsend: ["messageId"],
|
||||||
edit: ["messageId"],
|
edit: ["messageId"],
|
||||||
|
react: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user