chore: Lint extensions folder.

This commit is contained in:
cpojer
2026-01-31 22:13:48 +09:00
parent 4f2166c503
commit 230ca789e2
221 changed files with 4006 additions and 1583 deletions

View File

@@ -31,7 +31,6 @@
"src/canvas-host/a2ui/a2ui.bundle.js", "src/canvas-host/a2ui/a2ui.bundle.js",
"Swabble/", "Swabble/",
"vendor/", "vendor/",
"extensions/",
"ui/" "ui/"
] ]
} }

View File

@@ -50,20 +50,30 @@ export default function (pi: ExtensionAPI) {
const files: FileInfo[] = []; const files: FileInfo[] = [];
for (const line of lines) { for (const line of lines) {
if (line.length < 4) continue; // Need at least "XY f" if (line.length < 4) {
continue;
} // Need at least "XY f"
const status = line.slice(0, 2); const status = line.slice(0, 2);
const file = line.slice(2).trimStart(); const file = line.slice(2).trimStart();
// Translate status codes to short labels // Translate status codes to short labels
let statusLabel: string; let statusLabel: string;
if (status.includes("M")) statusLabel = "M"; if (status.includes("M")) {
else if (status.includes("A")) statusLabel = "A"; statusLabel = "M";
else if (status.includes("D")) statusLabel = "D"; } else if (status.includes("A")) {
else if (status.includes("?")) statusLabel = "?"; statusLabel = "A";
else if (status.includes("R")) statusLabel = "R"; } else if (status.includes("D")) {
else if (status.includes("C")) statusLabel = "C"; statusLabel = "D";
else statusLabel = status.trim() || "~"; } else if (status.includes("?")) {
statusLabel = "?";
} else if (status.includes("R")) {
statusLabel = "R";
} else if (status.includes("C")) {
statusLabel = "C";
} else {
statusLabel = status.trim() || "~";
}
files.push({ status: statusLabel, statusLabel, file }); files.push({ status: statusLabel, statusLabel, file });
} }

View File

@@ -40,7 +40,9 @@ export default function (pi: ExtensionAPI) {
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>(); const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
for (const entry of branch) { for (const entry of branch) {
if (entry.type !== "message") continue; if (entry.type !== "message") {
continue;
}
const msg = entry.message; const msg = entry.message;
if (msg.role === "assistant" && Array.isArray(msg.content)) { if (msg.role === "assistant" && Array.isArray(msg.content)) {
@@ -62,12 +64,16 @@ export default function (pi: ExtensionAPI) {
const fileMap = new Map<string, FileEntry>(); const fileMap = new Map<string, FileEntry>();
for (const entry of branch) { for (const entry of branch) {
if (entry.type !== "message") continue; if (entry.type !== "message") {
continue;
}
const msg = entry.message; const msg = entry.message;
if (msg.role === "toolResult") { if (msg.role === "toolResult") {
const toolCall = toolCalls.get(msg.toolCallId); const toolCall = toolCalls.get(msg.toolCallId);
if (!toolCall) continue; if (!toolCall) {
continue;
}
const { path, name } = toolCall; const { path, name } = toolCall;
const timestamp = msg.timestamp; const timestamp = msg.timestamp;
@@ -94,7 +100,9 @@ export default function (pi: ExtensionAPI) {
} }
// Sort by most recent first // Sort by most recent first
const files = Array.from(fileMap.values()).sort((a, b) => b.lastTimestamp - a.lastTimestamp); const files = Array.from(fileMap.values()).toSorted(
(a, b) => b.lastTimestamp - a.lastTimestamp,
);
const openSelected = async (file: FileEntry): Promise<void> => { const openSelected = async (file: FileEntry): Promise<void> => {
try { try {
@@ -118,9 +126,15 @@ export default function (pi: ExtensionAPI) {
// Build select items with colored operations // Build select items with colored operations
const items: SelectItem[] = files.map((f) => { const items: SelectItem[] = files.map((f) => {
const ops: string[] = []; const ops: string[] = [];
if (f.operations.has("read")) ops.push(theme.fg("muted", "R")); if (f.operations.has("read")) {
if (f.operations.has("write")) ops.push(theme.fg("success", "W")); ops.push(theme.fg("muted", "R"));
if (f.operations.has("edit")) ops.push(theme.fg("warning", "E")); }
if (f.operations.has("write")) {
ops.push(theme.fg("success", "W"));
}
if (f.operations.has("edit")) {
ops.push(theme.fg("warning", "E"));
}
const opsLabel = ops.join(""); const opsLabel = ops.join("");
return { return {
value: f, value: f,

View File

@@ -47,7 +47,9 @@ async function fetchGhMetadata(
try { try {
const result = await pi.exec("gh", args); const result = await pi.exec("gh", args);
if (result.code !== 0 || !result.stdout) return undefined; if (result.code !== 0 || !result.stdout) {
return undefined;
}
return JSON.parse(result.stdout) as GhMetadata; return JSON.parse(result.stdout) as GhMetadata;
} catch { } catch {
return undefined; return undefined;
@@ -55,12 +57,20 @@ async function fetchGhMetadata(
} }
function formatAuthor(author?: GhMetadata["author"]): string | undefined { function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) return undefined; if (!author) {
return undefined;
}
const name = author.name?.trim(); const name = author.name?.trim();
const login = author.login?.trim(); const login = author.login?.trim();
if (name && login) return `${name} (@${login})`; if (name && login) {
if (login) return `@${login}`; return `${name} (@${login})`;
if (name) return name; }
if (login) {
return `@${login}`;
}
if (name) {
return name;
}
return undefined; return undefined;
} }
@@ -77,7 +87,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const urlLine = thm.fg("dim", match.url); const urlLine = thm.fg("dim", match.url);
const lines = [titleText]; const lines = [titleText];
if (authorLine) lines.push(authorLine); if (authorLine) {
lines.push(authorLine);
}
lines.push(urlLine); lines.push(urlLine);
const container = new Container(); const container = new Container();
@@ -103,7 +115,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}; };
pi.on("before_agent_start", async (event, ctx) => { pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) {
return;
}
const match = extractPromptMatch(event.prompt); const match = extractPromptMatch(event.prompt);
if (!match) { if (!match) {
return; return;
@@ -124,8 +138,12 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}); });
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => { const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) return ""; if (!content) {
if (typeof content === "string") return content; return "";
}
if (typeof content === "string") {
return content;
}
return ( return (
content content
.filter((block): block is { type: "text"; text: string } => block.type === "text") .filter((block): block is { type: "text"; text: string } => block.type === "text")
@@ -135,11 +153,15 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}; };
const rebuildFromSession = (ctx: ExtensionContext) => { const rebuildFromSession = (ctx: ExtensionContext) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) {
return;
}
const entries = ctx.sessionManager.getEntries(); const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].reverse().find((entry) => { const lastMatch = [...entries].toReversed().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return false; if (entry.type !== "message" || entry.message.role !== "user") {
return false;
}
const text = getUserText(entry.message.content); const text = getUserText(entry.message.content);
return !!extractPromptMatch(text); return !!extractPromptMatch(text);
}); });

View File

@@ -11,7 +11,9 @@ export default function (pi: ExtensionAPI) {
pi.registerCommand("tui", { pi.registerCommand("tui", {
description: "Show TUI stats", description: "Show TUI stats",
handler: async (_args, ctx) => { handler: async (_args, ctx) => {
if (!ctx.hasUI) return; if (!ctx.hasUI) {
return;
}
let redraws = 0; let redraws = 0;
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => { await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
redraws = tui.fullRedraws; redraws = tui.fullRedraws;

View File

@@ -13,19 +13,25 @@ export type ResolvedBlueBubblesAccount = {
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.channels?.bluebubbles?.accounts; const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return []; if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean); return Object.keys(accounts).filter(Boolean);
} }
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] { export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg); const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; if (ids.length === 0) {
return ids.sort((a, b) => a.localeCompare(b)); return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
} }
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string { export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
const ids = listBlueBubblesAccountIds(cfg); const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID;
} }
@@ -34,7 +40,9 @@ function resolveAccountConfig(
accountId: string, accountId: string,
): BlueBubblesAccountConfig | undefined { ): BlueBubblesAccountConfig | undefined {
const accounts = cfg.channels?.bluebubbles?.accounts; const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return undefined; if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId] as BlueBubblesAccountConfig | undefined; return accounts[accountId] as BlueBubblesAccountConfig | undefined;
} }

View File

@@ -9,7 +9,6 @@ import {
type ChannelMessageActionAdapter, type ChannelMessageActionAdapter,
type ChannelMessageActionName, type ChannelMessageActionName,
type ChannelToolSend, type ChannelToolSend,
type OpenClawConfig,
} from "openclaw/plugin-sdk"; } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js"; import { resolveBlueBubblesAccount } from "./accounts.js";
@@ -34,8 +33,12 @@ const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget { function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw); const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid }; if (parsed.kind === "chat_guid") {
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId }; return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_identifier") { if (parsed.kind === "chat_identifier") {
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
} }
@@ -52,11 +55,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined { function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
const raw = params[key]; const raw = params[key];
if (typeof raw === "boolean") return raw; if (typeof raw === "boolean") {
return raw;
}
if (typeof raw === "string") { if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase(); const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") return true; if (trimmed === "true") {
if (trimmed === "false") return false; return true;
}
if (trimmed === "false") {
return false;
}
} }
return undefined; return undefined;
} }
@@ -66,41 +75,55 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_N
export const bluebubblesMessageActions: ChannelMessageActionAdapter = { export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig }); const account = resolveBlueBubblesAccount({ cfg: cfg });
if (!account.enabled || !account.configured) return []; if (!account.enabled || !account.configured) {
const gate = createActionGate((cfg as OpenClawConfig).channels?.bluebubbles?.actions); return [];
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>(); const actions = new Set<ChannelMessageActionName>();
const macOS26 = isMacOS26OrHigher(account.accountId); const macOS26 = isMacOS26OrHigher(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) { for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action]; const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) continue; if (!spec?.gate) {
if (spec.unsupportedOnMacOS26 && macOS26) continue; continue;
if (gate(spec.gate)) actions.add(action); }
if (spec.unsupportedOnMacOS26 && macOS26) {
continue;
}
if (gate(spec.gate)) {
actions.add(action);
}
} }
return Array.from(actions); return Array.from(actions);
}, },
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }): ChannelToolSend | null => { extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : ""; const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null; if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) {
return null;
}
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, toolContext }) => { handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const account = resolveBlueBubblesAccount({ const account = resolveBlueBubblesAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}); });
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 opts = { cfg: cfg as OpenClawConfig, accountId: accountId ?? undefined }; const opts = { cfg: cfg, accountId: accountId ?? undefined };
// Helper to resolve chatGuid from various params or session context // 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();
}
const chatIdentifier = readStringParam(params, "chatIdentifier"); const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true }); const chatId = readNumberParam(params, "chatId", { integer: true });
@@ -185,8 +208,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
readStringParam(params, "message"); readStringParam(params, "message");
if (!rawMessageId || !newText) { if (!rawMessageId || !newText) {
const missing: string[] = []; const missing: string[] = [];
if (!rawMessageId) missing.push("messageId (the message ID to edit)"); if (!rawMessageId) {
if (!newText) missing.push("text (the new message content)"); missing.push("messageId (the message ID to edit)");
}
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_id>, text=<new_content>.`, `Use action=edit with messageId=<message_id>, text=<new_content>.`,
@@ -234,9 +261,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const to = readStringParam(params, "to") ?? readStringParam(params, "target"); const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!rawMessageId || !text || !to) { if (!rawMessageId || !text || !to) {
const missing: string[] = []; const missing: string[] = [];
if (!rawMessageId) missing.push("messageId (the message ID to reply to)"); if (!rawMessageId) {
if (!text) missing.push("text or message (the reply message content)"); missing.push("messageId (the message ID to reply to)");
if (!to) missing.push("to or target (the chat target)"); }
if (!text) {
missing.push("text or message (the reply message content)");
}
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_id>, message=<your reply>, target=<chat_target>.`, `Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
@@ -262,12 +295,17 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
if (!text || !to || !effectId) { if (!text || !to || !effectId) {
const missing: string[] = []; const missing: string[] = [];
if (!text) missing.push("text or message (the message content)"); if (!text) {
if (!to) missing.push("to or target (the chat target)"); missing.push("text or message (the message content)");
if (!effectId) }
if (!to) {
missing.push("to or target (the chat target)");
}
if (!effectId) {
missing.push( missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)", "effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
); );
}
throw new Error( throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` + `BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`, `Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,

View File

@@ -31,7 +31,9 @@ function sanitizeFilename(input: string | undefined, fallback: string): string {
function ensureExtension(filename: string, extension: string, fallbackBase: string): string { function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
const currentExt = path.extname(filename); const currentExt = path.extname(filename);
if (currentExt.toLowerCase() === extension) return filename; if (currentExt.toLowerCase() === extension) {
return filename;
}
const base = currentExt ? filename.slice(0, -currentExt.length) : filename; const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
return `${base || fallbackBase}${extension}`; return `${base || fallbackBase}${extension}`;
} }
@@ -54,8 +56,12 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
}); });
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim(); const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); if (!baseUrl) {
if (!password) throw new Error("BlueBubbles password is required"); throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password }; return { baseUrl, password };
} }
@@ -64,7 +70,9 @@ export async function downloadBlueBubblesAttachment(
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
): Promise<{ buffer: Uint8Array; contentType?: string }> { ): Promise<{ buffer: Uint8Array; contentType?: string }> {
const guid = attachment.guid?.trim(); const guid = attachment.guid?.trim();
if (!guid) throw new Error("BlueBubbles attachment guid is required"); if (!guid) {
throw new Error("BlueBubbles attachment guid is required");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
baseUrl, baseUrl,
@@ -110,7 +118,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
} }
function extractMessageId(payload: unknown): string { function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown"; if (!payload || typeof payload !== "object") {
return "unknown";
}
const record = payload as Record<string, unknown>; const record = payload as Record<string, unknown>;
const data = const data =
record.data && typeof record.data === "object" record.data && typeof record.data === "object"
@@ -125,8 +135,12 @@ function extractMessageId(payload: unknown): string {
data?.id, data?.id,
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); if (typeof candidate === "string" && candidate.trim()) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate); return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
} }
return "unknown"; return "unknown";
} }
@@ -274,7 +288,9 @@ export async function sendBlueBubblesAttachment(params: {
} }
const responseBody = await res.text(); const responseBody = await res.text();
if (!responseBody) return { messageId: "ok" }; if (!responseBody) {
return { messageId: "ok" };
}
try { try {
const parsed = JSON.parse(responseBody) as unknown; const parsed = JSON.parse(responseBody) as unknown;
return { messageId: extractMessageId(parsed) }; return { messageId: extractMessageId(parsed) };

View File

@@ -78,13 +78,12 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
onboarding: blueBubblesOnboardingAdapter, onboarding: blueBubblesOnboardingAdapter,
config: { config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as OpenClawConfig), listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }), defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
sectionKey: "bluebubbles", sectionKey: "bluebubbles",
accountId, accountId,
enabled, enabled,
@@ -92,7 +91,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}), }),
deleteAccount: ({ cfg, accountId }) => deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({ deleteAccountFromConfigSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
sectionKey: "bluebubbles", sectionKey: "bluebubbles",
accountId, accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
@@ -106,9 +105,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
baseUrl: account.baseUrl, baseUrl: account.baseUrl,
}), }),
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFrom: ({ cfg, accountId }) =>
( (resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ?? [] String(entry),
).map((entry) => String(entry)), ),
formatAllowFrom: ({ allowFrom }) => formatAllowFrom: ({ allowFrom }) =>
allowFrom allowFrom
.map((entry) => String(entry).trim()) .map((entry) => String(entry).trim())
@@ -120,9 +119,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
security: { security: {
resolveDmPolicy: ({ cfg, accountId, account }) => { resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean( const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
(cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.` ? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles."; : "channels.bluebubbles.";
@@ -137,7 +134,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}, },
collectWarnings: ({ account }) => { collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`, `- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
]; ];
@@ -151,19 +150,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}, },
formatTargetDisplay: ({ target, display }) => { formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => { const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) return true; if (looksLikeBlueBubblesTargetId(value)) {
return true;
}
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value); return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
}; };
// Helper to extract a clean handle from any BlueBubbles target format // Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => { const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim(); const trimmed = value?.trim();
if (!trimmed) return null; if (!trimmed) {
return null;
}
try { try {
const parsed = parseBlueBubblesTarget(trimmed); const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") { if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid); const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle; if (handle) {
return handle;
}
} }
if (parsed.kind === "handle") { if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to); return normalizeBlueBubblesHandle(parsed.to);
@@ -178,9 +183,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
.replace(/^chat_id:/i, "") .replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, ""); .replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped); const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle; if (handle) {
return handle;
}
// Don't return raw chat_guid formats - they contain internal routing info // Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) return null; if (stripped.includes(";-;") || stripped.includes(";+;")) {
return null;
}
return stripped; return stripped;
}; };
@@ -191,12 +200,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return trimmedDisplay; return trimmedDisplay;
} }
const cleanDisplay = extractCleanDisplay(trimmedDisplay); const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay; if (cleanDisplay) {
return cleanDisplay;
}
} }
// Fall back to extracting from target // Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target); const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget; if (cleanTarget) {
return cleanTarget;
}
// Last resort: return display or target as-is // Last resort: return display or target as-is
return display?.trim() || target?.trim() || ""; return display?.trim() || target?.trim() || "";
@@ -206,7 +219,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({ applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
channelKey: "bluebubbles", channelKey: "bluebubbles",
accountId, accountId,
name, name,
@@ -215,13 +228,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
if (!input.httpUrl && !input.password) { if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password."; return "BlueBubbles requires --http-url and --password.";
} }
if (!input.httpUrl) return "BlueBubbles requires --http-url."; if (!input.httpUrl) {
if (!input.password) return "BlueBubbles requires --password."; return "BlueBubbles requires --http-url.";
}
if (!input.password) {
return "BlueBubbles requires --password.";
}
return null; return null;
}, },
applyAccountConfig: ({ cfg, accountId, input }) => { applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({ const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
channelKey: "bluebubbles", channelKey: "bluebubbles",
accountId, accountId,
name: input.name, name: input.name,
@@ -256,9 +273,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
...next.channels?.bluebubbles, ...next.channels?.bluebubbles,
enabled: true, enabled: true,
accounts: { accounts: {
...(next.channels?.bluebubbles?.accounts ?? {}), ...next.channels?.bluebubbles?.accounts,
[accountId]: { [accountId]: {
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}), ...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: true, enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}), ...(input.password ? { password: input.password } : {}),
@@ -275,7 +292,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => { notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg as OpenClawConfig, cfg: cfg,
}); });
}, },
}, },
@@ -299,7 +316,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: ""; : "";
const result = await sendMessageBlueBubbles(to, text, { const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined, replyToMessageGuid: replyToMessageGuid || undefined,
}); });
@@ -316,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}; };
const resolvedCaption = caption ?? text; const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({ const result = await sendBlueBubblesMedia({
cfg: cfg as OpenClawConfig, cfg: cfg,
to, to,
mediaUrl, mediaUrl,
mediaPath, mediaPath,
@@ -387,7 +404,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({ return monitorBlueBubblesProvider({
account, account,
config: ctx.cfg as OpenClawConfig, config: ctx.cfg,
runtime: ctx.runtime, runtime: ctx.runtime,
abortSignal: ctx.abortSignal, abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),

View File

@@ -18,8 +18,12 @@ function resolveAccount(params: BlueBubblesChatOpts) {
}); });
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim(); const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); if (!baseUrl) {
if (!password) throw new Error("BlueBubbles password is required"); throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password }; return { baseUrl, password };
} }
@@ -28,7 +32,9 @@ export async function markBlueBubblesChatRead(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmed = chatGuid.trim(); const trimmed = chatGuid.trim();
if (!trimmed) return; if (!trimmed) {
return;
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
baseUrl, baseUrl,
@@ -48,7 +54,9 @@ export async function sendBlueBubblesTyping(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmed = chatGuid.trim(); const trimmed = chatGuid.trim();
if (!trimmed) return; if (!trimmed) {
return;
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
baseUrl, baseUrl,
@@ -76,9 +84,13 @@ export async function editBlueBubblesMessage(
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {}, opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = messageGuid.trim(); const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles edit requires messageGuid");
}
const trimmedText = newText.trim(); const trimmedText = newText.trim();
if (!trimmedText) throw new Error("BlueBubbles edit requires newText"); if (!trimmedText) {
throw new Error("BlueBubbles edit requires newText");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -118,7 +130,9 @@ export async function unsendBlueBubblesMessage(
opts: BlueBubblesChatOpts & { partIndex?: number } = {}, opts: BlueBubblesChatOpts & { partIndex?: number } = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = messageGuid.trim(); const trimmedGuid = messageGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles unsend requires messageGuid");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -156,7 +170,9 @@ export async function renameBlueBubblesChat(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = chatGuid.trim(); const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles rename requires chatGuid");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -190,9 +206,13 @@ export async function addBlueBubblesParticipant(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = chatGuid.trim(); const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles addParticipant requires chatGuid");
}
const trimmedAddress = address.trim(); const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address"); if (!trimmedAddress) {
throw new Error("BlueBubbles addParticipant requires address");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -226,9 +246,13 @@ export async function removeBlueBubblesParticipant(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = chatGuid.trim(); const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles removeParticipant requires chatGuid");
}
const trimmedAddress = address.trim(); const trimmedAddress = address.trim();
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address"); if (!trimmedAddress) {
throw new Error("BlueBubbles removeParticipant requires address");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -263,7 +287,9 @@ export async function leaveBlueBubblesChat(
opts: BlueBubblesChatOpts = {}, opts: BlueBubblesChatOpts = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = chatGuid.trim(); const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles leaveChat requires chatGuid");
}
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({
@@ -291,7 +317,9 @@ export async function setGroupIconBlueBubbles(
opts: BlueBubblesChatOpts & { contentType?: string } = {}, opts: BlueBubblesChatOpts & { contentType?: string } = {},
): Promise<void> { ): Promise<void> {
const trimmedGuid = chatGuid.trim(); const trimmedGuid = chatGuid.trim();
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid"); if (!trimmedGuid) {
throw new Error("BlueBubbles setGroupIcon requires chatGuid");
}
if (!buffer || buffer.length === 0) { if (!buffer || buffer.length === 0) {
throw new Error("BlueBubbles setGroupIcon requires image buffer"); throw new Error("BlueBubbles setGroupIcon requires image buffer");
} }

View File

@@ -12,15 +12,21 @@ const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024; const MB = 1024 * 1024;
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void { function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
if (typeof maxBytes !== "number" || maxBytes <= 0) return; if (typeof maxBytes !== "number" || maxBytes <= 0) {
if (sizeBytes <= maxBytes) return; return;
}
if (sizeBytes <= maxBytes) {
return;
}
const maxLabel = (maxBytes / MB).toFixed(0); const maxLabel = (maxBytes / MB).toFixed(0);
const sizeLabel = (sizeBytes / MB).toFixed(2); const sizeLabel = (sizeBytes / MB).toFixed(2);
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`); throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
} }
function resolveLocalMediaPath(source: string): string { function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source; if (!source.startsWith("file://")) {
return source;
}
try { try {
return fileURLToPath(source); return fileURLToPath(source);
} catch { } catch {
@@ -29,7 +35,9 @@ function resolveLocalMediaPath(source: string): string {
} }
function resolveFilenameFromSource(source?: string): string | undefined { function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined; if (!source) {
return undefined;
}
if (source.startsWith("file://")) { if (source.startsWith("file://")) {
try { try {
return path.basename(fileURLToPath(source)) || undefined; return path.basename(fileURLToPath(source)) || undefined;

View File

@@ -262,6 +262,7 @@ function createMockRequest(
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
// Emit body data after a microtask // Emit body data after a microtask
// oxlint-disable-next-line no-floating-promises
Promise.resolve().then(() => { Promise.resolve().then(() => {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body); const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
req.emit("data", Buffer.from(bodyStr)); req.emit("data", Buffer.from(bodyStr));
@@ -1225,7 +1226,9 @@ describe("BlueBubbles webhook monitor", () => {
const flush = async (key: string) => { const flush = async (key: string) => {
const bucket = buckets.get(key); const bucket = buckets.get(key);
if (!bucket) return; if (!bucket) {
return;
}
if (bucket.timer) { if (bucket.timer) {
clearTimeout(bucket.timer); clearTimeout(bucket.timer);
bucket.timer = null; bucket.timer = null;
@@ -1253,7 +1256,9 @@ describe("BlueBubbles webhook monitor", () => {
const existing = buckets.get(key); const existing = buckets.get(key);
const bucket = existing ?? { items: [], timer: null }; const bucket = existing ?? { items: [], timer: null };
bucket.items.push(item); bucket.items.push(item);
if (bucket.timer) clearTimeout(bucket.timer); if (bucket.timer) {
clearTimeout(bucket.timer);
}
bucket.timer = setTimeout(async () => { bucket.timer = setTimeout(async () => {
await flush(key); await flush(key);
}, params.debounceMs); }, params.debounceMs);

View File

@@ -112,7 +112,9 @@ function rememberBlueBubblesReplyCache(
} }
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) { while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined; const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
if (!oldest) break; if (!oldest) {
break;
}
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest); const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
blueBubblesReplyCacheByMessageId.delete(oldest); blueBubblesReplyCacheByMessageId.delete(oldest);
// Clean up short ID mappings for evicted entries // Clean up short ID mappings for evicted entries
@@ -134,12 +136,16 @@ export function resolveBlueBubblesMessageId(
opts?: { requireKnownShortId?: boolean }, opts?: { requireKnownShortId?: boolean },
): string { ): string {
const trimmed = shortOrUuid.trim(); const trimmed = shortOrUuid.trim();
if (!trimmed) return trimmed; if (!trimmed) {
return trimmed;
}
// If it looks like a short ID (numeric), try to resolve it // If it looks like a short ID (numeric), try to resolve it
if (/^\d+$/.test(trimmed)) { if (/^\d+$/.test(trimmed)) {
const uuid = blueBubblesShortIdToUuid.get(trimmed); const uuid = blueBubblesShortIdToUuid.get(trimmed);
if (uuid) return uuid; if (uuid) {
return uuid;
}
if (opts?.requireKnownShortId) { if (opts?.requireKnownShortId) {
throw new Error( throw new Error(
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`, `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
@@ -177,11 +183,17 @@ function resolveReplyContextFromCache(params: {
chatId?: number; chatId?: number;
}): BlueBubblesReplyCacheEntry | null { }): BlueBubblesReplyCacheEntry | null {
const replyToId = params.replyToId.trim(); const replyToId = params.replyToId.trim();
if (!replyToId) return null; if (!replyToId) {
return null;
}
const cached = blueBubblesReplyCacheByMessageId.get(replyToId); const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
if (!cached) return null; if (!cached) {
if (cached.accountId !== params.accountId) return null; return null;
}
if (cached.accountId !== params.accountId) {
return null;
}
const cutoff = Date.now() - REPLY_CACHE_TTL_MS; const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
if (cached.timestamp < cutoff) { if (cached.timestamp < cutoff) {
@@ -197,7 +209,9 @@ function resolveReplyContextFromCache(params: {
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
// Avoid cross-chat collisions if we have identifiers. // Avoid cross-chat collisions if we have identifiers.
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null; if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
return null;
}
if ( if (
!chatGuid && !chatGuid &&
chatIdentifier && chatIdentifier &&
@@ -300,10 +314,14 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
for (const entry of entries) { for (const entry of entries) {
const text = entry.message.text.trim(); const text = entry.message.text.trim();
if (!text) continue; if (!text) {
continue;
}
// Skip duplicate text (URL might be in both text message and balloon) // Skip duplicate text (URL might be in both text message and balloon)
const normalizedText = text.toLowerCase(); const normalizedText = text.toLowerCase();
if (seenTexts.has(normalizedText)) continue; if (seenTexts.has(normalizedText)) {
continue;
}
seenTexts.add(normalizedText); seenTexts.add(normalizedText);
textParts.push(text); textParts.push(text);
} }
@@ -359,7 +377,9 @@ function resolveBlueBubblesDebounceMs(
const inbound = config.messages?.inbound; const inbound = config.messages?.inbound;
const hasExplicitDebounce = const hasExplicitDebounce =
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number"; typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS; if (!hasExplicitDebounce) {
return DEFAULT_INBOUND_DEBOUNCE_MS;
}
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" }); return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
} }
@@ -368,7 +388,9 @@ function resolveBlueBubblesDebounceMs(
*/ */
function getOrCreateDebouncer(target: WebhookTarget) { function getOrCreateDebouncer(target: WebhookTarget) {
const existing = targetDebouncers.get(target); const existing = targetDebouncers.get(target);
if (existing) return existing; if (existing) {
return existing;
}
const { account, config, runtime, core } = target; const { account, config, runtime, core } = target;
@@ -402,15 +424,21 @@ function getOrCreateDebouncer(target: WebhookTarget) {
shouldDebounce: (entry) => { shouldDebounce: (entry) => {
const msg = entry.message; const msg = entry.message;
// Skip debouncing for from-me messages (they're just cached, not processed) // Skip debouncing for from-me messages (they're just cached, not processed)
if (msg.fromMe) return false; if (msg.fromMe) {
return false;
}
// Skip debouncing for control commands - process immediately // Skip debouncing for control commands - process immediately
if (core.channel.text.hasControlCommand(msg.text, config)) return false; if (core.channel.text.hasControlCommand(msg.text, config)) {
return false;
}
// Debounce all other messages to coalesce rapid-fire webhook events // Debounce all other messages to coalesce rapid-fire webhook events
// (e.g., text+image arriving as separate webhooks for the same messageId) // (e.g., text+image arriving as separate webhooks for the same messageId)
return true; return true;
}, },
onFlush: async (entries) => { onFlush: async (entries) => {
if (entries.length === 0) return; if (entries.length === 0) {
return;
}
// Use target from first entry (all entries have same target due to key structure) // Use target from first entry (all entries have same target due to key structure)
const flushTarget = entries[0].target; const flushTarget = entries[0].target;
@@ -452,7 +480,9 @@ function removeDebouncer(target: WebhookTarget): void {
function normalizeWebhookPath(raw: string): string { function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return "/"; if (!trimmed) {
return "/";
}
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) { if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1); return withSlash.slice(0, -1);
@@ -527,30 +557,40 @@ function asRecord(value: unknown): Record<string, unknown> | null {
} }
function readString(record: Record<string, unknown> | null, key: string): string | undefined { function readString(record: Record<string, unknown> | null, key: string): string | undefined {
if (!record) return undefined; if (!record) {
return undefined;
}
const value = record[key]; const value = record[key];
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined { function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) return undefined; if (!record) {
return undefined;
}
const value = record[key]; const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined; return typeof value === "number" && Number.isFinite(value) ? value : undefined;
} }
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined { function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
if (!record) return undefined; if (!record) {
return undefined;
}
const value = record[key]; const value = record[key];
return typeof value === "boolean" ? value : undefined; return typeof value === "boolean" ? value : undefined;
} }
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] { function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
const raw = message["attachments"]; const raw = message["attachments"];
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) {
return [];
}
const out: BlueBubblesAttachment[] = []; const out: BlueBubblesAttachment[] = [];
for (const entry of raw) { for (const entry of raw) {
const record = asRecord(entry); const record = asRecord(entry);
if (!record) continue; if (!record) {
continue;
}
out.push({ out.push({
guid: readString(record, "guid"), guid: readString(record, "guid"),
uti: readString(record, "uti"), uti: readString(record, "uti"),
@@ -566,7 +606,9 @@ function extractAttachments(message: Record<string, unknown>): BlueBubblesAttach
} }
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string { function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
if (attachments.length === 0) return ""; if (attachments.length === 0) {
return "";
}
const mimeTypes = attachments.map((entry) => entry.mimeType ?? ""); const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
const allImages = mimeTypes.every((entry) => entry.startsWith("image/")); const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/")); const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
@@ -585,8 +627,12 @@ function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): strin
function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []); const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
if (attachmentPlaceholder) return attachmentPlaceholder; if (attachmentPlaceholder) {
if (message.balloonBundleId) return "<media:sticker>"; return attachmentPlaceholder;
}
if (message.balloonBundleId) {
return "<media:sticker>";
}
return ""; return "";
} }
@@ -594,17 +640,25 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null { function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
// Prefer short ID // Prefer short ID
const rawId = message.replyToShortId || message.replyToId; const rawId = message.replyToShortId || message.replyToId;
if (!rawId) return null; if (!rawId) {
return null;
}
return `[[reply_to:${rawId}]]`; return `[[reply_to:${rawId}]]`;
} }
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined { function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
if (!record) return undefined; if (!record) {
return undefined;
}
const value = record[key]; const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") { if (typeof value === "string") {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) return parsed; if (Number.isFinite(parsed)) {
return parsed;
}
} }
return undefined; return undefined;
} }
@@ -683,7 +737,9 @@ function extractReplyMetadata(message: Record<string, unknown>): {
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null { function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
const chats = message["chats"]; const chats = message["chats"];
if (!Array.isArray(chats) || chats.length === 0) return null; if (!Array.isArray(chats) || chats.length === 0) {
return null;
}
const first = chats[0]; const first = chats[0];
return asRecord(first); return asRecord(first);
} }
@@ -691,12 +747,16 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null { function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
if (typeof entry === "string" || typeof entry === "number") { if (typeof entry === "string" || typeof entry === "number") {
const raw = String(entry).trim(); const raw = String(entry).trim();
if (!raw) return null; if (!raw) {
return null;
}
const normalized = normalizeBlueBubblesHandle(raw) || raw; const normalized = normalizeBlueBubblesHandle(raw) || raw;
return normalized ? { id: normalized } : null; return normalized ? { id: normalized } : null;
} }
const record = asRecord(entry); const record = asRecord(entry);
if (!record) return null; if (!record) {
return null;
}
const nestedHandle = const nestedHandle =
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null; asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
const idRaw = const idRaw =
@@ -716,20 +776,28 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul
readString(nestedHandle, "displayName") ?? readString(nestedHandle, "displayName") ??
readString(nestedHandle, "name"); readString(nestedHandle, "name");
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : ""; const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
if (!normalizedId) return null; if (!normalizedId) {
return null;
}
const name = nameRaw?.trim() || undefined; const name = nameRaw?.trim() || undefined;
return { id: normalizedId, name }; return { id: normalizedId, name };
} }
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
if (!Array.isArray(raw) || raw.length === 0) return []; if (!Array.isArray(raw) || raw.length === 0) {
return [];
}
const seen = new Set<string>(); const seen = new Set<string>();
const output: BlueBubblesParticipant[] = []; const output: BlueBubblesParticipant[] = [];
for (const entry of raw) { for (const entry of raw) {
const normalized = normalizeParticipantEntry(entry); const normalized = normalizeParticipantEntry(entry);
if (!normalized?.id) continue; if (!normalized?.id) {
continue;
}
const key = normalized.id.toLowerCase(); const key = normalized.id.toLowerCase();
if (seen.has(key)) continue; if (seen.has(key)) {
continue;
}
seen.add(key); seen.add(key);
output.push(normalized); output.push(normalized);
} }
@@ -743,37 +811,57 @@ function formatGroupMembers(params: {
const seen = new Set<string>(); const seen = new Set<string>();
const ordered: BlueBubblesParticipant[] = []; const ordered: BlueBubblesParticipant[] = [];
for (const entry of params.participants ?? []) { for (const entry of params.participants ?? []) {
if (!entry?.id) continue; if (!entry?.id) {
continue;
}
const key = entry.id.toLowerCase(); const key = entry.id.toLowerCase();
if (seen.has(key)) continue; if (seen.has(key)) {
continue;
}
seen.add(key); seen.add(key);
ordered.push(entry); ordered.push(entry);
} }
if (ordered.length === 0 && params.fallback?.id) { if (ordered.length === 0 && params.fallback?.id) {
ordered.push(params.fallback); ordered.push(params.fallback);
} }
if (ordered.length === 0) return undefined; if (ordered.length === 0) {
return undefined;
}
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", "); return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
} }
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined { function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
const guid = chatGuid?.trim(); const guid = chatGuid?.trim();
if (!guid) return undefined; if (!guid) {
return undefined;
}
const parts = guid.split(";"); const parts = guid.split(";");
if (parts.length >= 3) { if (parts.length >= 3) {
if (parts[1] === "+") return true; if (parts[1] === "+") {
if (parts[1] === "-") return false; return true;
}
if (parts[1] === "-") {
return false;
}
}
if (guid.includes(";+;")) {
return true;
}
if (guid.includes(";-;")) {
return false;
} }
if (guid.includes(";+;")) return true;
if (guid.includes(";-;")) return false;
return undefined; return undefined;
} }
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined { function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
const guid = chatGuid?.trim(); const guid = chatGuid?.trim();
if (!guid) return undefined; if (!guid) {
return undefined;
}
const parts = guid.split(";"); const parts = guid.split(";");
if (parts.length < 3) return undefined; if (parts.length < 3) {
return undefined;
}
const identifier = parts[2]?.trim(); const identifier = parts[2]?.trim();
return identifier || undefined; return identifier || undefined;
} }
@@ -784,11 +872,17 @@ function formatGroupAllowlistEntry(params: {
chatIdentifier?: string; chatIdentifier?: string;
}): string | null { }): string | null {
const guid = params.chatGuid?.trim(); const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`; if (guid) {
return `chat_guid:${guid}`;
}
const chatId = params.chatId; const chatId = params.chatId;
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`; if (typeof chatId === "number" && Number.isFinite(chatId)) {
return `chat_id:${chatId}`;
}
const identifier = params.chatIdentifier?.trim(); const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`; if (identifier) {
return `chat_identifier:${identifier}`;
}
return null; return null;
} }
@@ -886,9 +980,15 @@ function isTapbackAssociatedType(type: number | undefined): boolean {
} }
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined { function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
if (typeof type !== "number" || !Number.isFinite(type)) return undefined; if (typeof type !== "number" || !Number.isFinite(type)) {
if (type >= 3000 && type < 4000) return "removed"; return undefined;
if (type >= 2000 && type < 3000) return "added"; }
if (type >= 3000 && type < 4000) {
return "removed";
}
if (type >= 2000 && type < 3000) {
return "added";
}
return undefined; return undefined;
} }
@@ -900,7 +1000,9 @@ function resolveTapbackContext(message: NormalizedWebhookMessage): {
const associatedType = message.associatedMessageType; const associatedType = message.associatedMessageType;
const hasTapbackType = isTapbackAssociatedType(associatedType); const hasTapbackType = isTapbackAssociatedType(associatedType);
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback); const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
if (!hasTapbackType && !hasTapbackMarker) return null; if (!hasTapbackType && !hasTapbackMarker) {
return null;
}
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined; const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType); const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint = const emojiHint =
@@ -921,7 +1023,9 @@ function parseTapbackText(params: {
} | null { } | null {
const trimmed = params.text.trim(); const trimmed = params.text.trim();
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
if (!trimmed) return null; if (!trimmed) {
return null;
}
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
if (lower.startsWith(pattern)) { if (lower.startsWith(pattern)) {
@@ -929,7 +1033,9 @@ function parseTapbackText(params: {
const afterPattern = trimmed.slice(pattern.length).trim(); const afterPattern = trimmed.slice(pattern.length).trim();
if (params.requireQuoted) { if (params.requireQuoted) {
const strictMatch = afterPattern.match(/^["](.+)["]$/s); const strictMatch = afterPattern.match(/^["](.+)["]$/s);
if (!strictMatch) return null; if (!strictMatch) {
return null;
}
return { emoji, action, quotedText: strictMatch[1] }; return { emoji, action, quotedText: strictMatch[1] };
} }
const quotedText = const quotedText =
@@ -940,18 +1046,26 @@ function parseTapbackText(params: {
if (lower.startsWith("reacted")) { if (lower.startsWith("reacted")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null; if (!emoji) {
return null;
}
const quotedText = extractQuotedTapbackText(trimmed); const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null; if (params.requireQuoted && !quotedText) {
return null;
}
const fallback = trimmed.slice("reacted".length).trim(); const fallback = trimmed.slice("reacted".length).trim();
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
} }
if (lower.startsWith("removed")) { if (lower.startsWith("removed")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null; if (!emoji) {
return null;
}
const quotedText = extractQuotedTapbackText(trimmed); const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null; if (params.requireQuoted && !quotedText) {
return null;
}
const fallback = trimmed.slice("removed".length).trim(); const fallback = trimmed.slice("removed".length).trim();
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
} }
@@ -959,7 +1073,9 @@ function parseTapbackText(params: {
} }
function maskSecret(value: string): string { function maskSecret(value: string): string {
if (value.length <= 6) return "***"; if (value.length <= 6) {
return "***";
}
return `${value.slice(0, 2)}***${value.slice(-2)}`; return `${value.slice(0, 2)}***${value.slice(-2)}`;
} }
@@ -970,7 +1086,9 @@ function resolveBlueBubblesAckReaction(params: {
runtime: BlueBubblesRuntimeEnv; runtime: BlueBubblesRuntimeEnv;
}): string | null { }): string | null {
const raw = resolveAckReaction(params.cfg, params.agentId).trim(); const raw = resolveAckReaction(params.cfg, params.agentId).trim();
if (!raw) return null; if (!raw) {
return null;
}
try { try {
normalizeBlueBubblesReactionInput(raw); normalizeBlueBubblesReactionInput(raw);
return raw; return raw;
@@ -997,7 +1115,9 @@ function extractMessagePayload(payload: Record<string, unknown>): Record<string,
const message = const message =
asRecord(messageRaw) ?? asRecord(messageRaw) ??
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
if (!message) return null; if (!message) {
return null;
}
return message; return message;
} }
@@ -1005,7 +1125,9 @@ function normalizeWebhookMessage(
payload: Record<string, unknown>, payload: Record<string, unknown>,
): NormalizedWebhookMessage | null { ): NormalizedWebhookMessage | null {
const message = extractMessagePayload(payload); const message = extractMessagePayload(payload);
if (!message) return null; if (!message) {
return null;
}
const text = const text =
readString(message, "text") ?? readString(message, "text") ??
@@ -1090,7 +1212,7 @@ function normalizeWebhookMessage(
const isGroup = const isGroup =
typeof groupFromChatGuid === "boolean" typeof groupFromChatGuid === "boolean"
? groupFromChatGuid ? groupFromChatGuid
: (explicitIsGroup ?? (participantsCount > 2 ? true : false)); : (explicitIsGroup ?? participantsCount > 2);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const messageId = const messageId =
@@ -1131,7 +1253,9 @@ function normalizeWebhookMessage(
: undefined; : undefined;
const normalizedSender = normalizeBlueBubblesHandle(senderId); const normalizedSender = normalizeBlueBubblesHandle(senderId);
if (!normalizedSender) return null; if (!normalizedSender) {
return null;
}
const replyMetadata = extractReplyMetadata(message); const replyMetadata = extractReplyMetadata(message);
return { return {
@@ -1163,7 +1287,9 @@ function normalizeWebhookReaction(
payload: Record<string, unknown>, payload: Record<string, unknown>,
): NormalizedWebhookReaction | null { ): NormalizedWebhookReaction | null {
const message = extractMessagePayload(payload); const message = extractMessagePayload(payload);
if (!message) return null; if (!message) {
return null;
}
const associatedGuid = const associatedGuid =
readString(message, "associatedMessageGuid") ?? readString(message, "associatedMessageGuid") ??
@@ -1172,7 +1298,9 @@ function normalizeWebhookReaction(
const associatedType = const associatedType =
readNumberLike(message, "associatedMessageType") ?? readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type"); readNumberLike(message, "associated_message_type");
if (!associatedGuid || associatedType === undefined) return null; if (!associatedGuid || associatedType === undefined) {
return null;
}
const mapping = REACTION_TYPE_MAP.get(associatedType); const mapping = REACTION_TYPE_MAP.get(associatedType);
const associatedEmoji = const associatedEmoji =
@@ -1258,7 +1386,7 @@ function normalizeWebhookReaction(
const isGroup = const isGroup =
typeof groupFromChatGuid === "boolean" typeof groupFromChatGuid === "boolean"
? groupFromChatGuid ? groupFromChatGuid
: (explicitIsGroup ?? (participantsCount > 2 ? true : false)); : (explicitIsGroup ?? participantsCount > 2);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const timestampRaw = const timestampRaw =
@@ -1273,7 +1401,9 @@ function normalizeWebhookReaction(
: undefined; : undefined;
const normalizedSender = normalizeBlueBubblesHandle(senderId); const normalizedSender = normalizeBlueBubblesHandle(senderId);
if (!normalizedSender) return null; if (!normalizedSender) {
return null;
}
return { return {
action, action,
@@ -1298,7 +1428,9 @@ export async function handleBlueBubblesWebhookRequest(
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname); const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path); const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false; if (!targets || targets.length === 0) {
return false;
}
if (req.method !== "POST") { if (req.method !== "POST") {
res.statusCode = 405; res.statusCode = 405;
@@ -1368,7 +1500,9 @@ export async function handleBlueBubblesWebhookRequest(
const matching = targets.filter((target) => { const matching = targets.filter((target) => {
const token = target.account.config.password?.trim(); const token = target.account.config.password?.trim();
if (!token) return true; if (!token) {
return true;
}
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
const headerToken = const headerToken =
req.headers["x-guid"] ?? req.headers["x-guid"] ??
@@ -1376,7 +1510,9 @@ export async function handleBlueBubblesWebhookRequest(
req.headers["x-bluebubbles-guid"] ?? req.headers["x-bluebubbles-guid"] ??
req.headers["authorization"]; req.headers["authorization"];
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
if (guid && guid.trim() === token) return true; if (guid && guid.trim() === token) {
return true;
}
const remote = req.socket?.remoteAddress ?? ""; const remote = req.socket?.remoteAddress ?? "";
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") { if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
return true; return true;
@@ -1466,7 +1602,9 @@ async function processMessage(
const cacheMessageId = message.messageId?.trim(); const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined; let messageShortId: string | undefined;
const cacheInboundMessage = () => { const cacheInboundMessage = () => {
if (!cacheMessageId) return; if (!cacheMessageId) {
return;
}
const cacheEntry = rememberBlueBubblesReplyCache({ const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId, accountId: account.accountId,
messageId: cacheMessageId, messageId: cacheMessageId,
@@ -1743,7 +1881,9 @@ async function processMessage(
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)"); logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
} else { } else {
for (const attachment of attachments) { for (const attachment of attachments) {
if (!attachment.guid) continue; if (!attachment.guid) {
continue;
}
if (attachment.totalBytes && attachment.totalBytes > maxBytes) { if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
logVerbose( logVerbose(
core, core,
@@ -1797,8 +1937,12 @@ async function processMessage(
chatId: message.chatId, chatId: message.chatId,
}); });
if (cached) { if (cached) {
if (!replyToBody && cached.body) replyToBody = cached.body; if (!replyToBody && cached.body) {
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel; replyToBody = cached.body;
}
if (!replyToSender && cached.senderLabel) {
replyToSender = cached.senderLabel;
}
replyToShortId = cached.shortId; replyToShortId = cached.shortId;
if (core.logging.shouldLogVerbose()) { if (core.logging.shouldLogVerbose()) {
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120); const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
@@ -1940,7 +2084,9 @@ async function processMessage(
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: 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;
}
// Cache outbound message to get short ID // Cache outbound message to get short ID
const cacheEntry = rememberBlueBubblesReplyCache({ const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId, accountId: account.accountId,
@@ -2059,8 +2205,12 @@ async function processMessage(
chunkMode === "newline" chunkMode === "newline"
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
: core.channel.text.chunkMarkdownText(text, textLimit); : core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) chunks.push(text); if (!chunks.length && text) {
if (!chunks.length) return; chunks.push(text);
}
if (!chunks.length) {
return;
}
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]; const chunk = chunks[i];
const result = await sendMessageBlueBubbles(outboundTarget, chunk, { const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
@@ -2085,8 +2235,12 @@ async function processMessage(
} }
}, },
onReplyStart: async () => { onReplyStart: async () => {
if (!chatGuidForActions) return; if (!chatGuidForActions) {
if (!baseUrl || !password) return; return;
}
if (!baseUrl || !password) {
return;
}
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`); logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
try { try {
await sendBlueBubblesTyping(chatGuidForActions, true, { await sendBlueBubblesTyping(chatGuidForActions, true, {
@@ -2098,8 +2252,12 @@ async function processMessage(
} }
}, },
onIdle: async () => { onIdle: async () => {
if (!chatGuidForActions) return; if (!chatGuidForActions) {
if (!baseUrl || !password) return; return;
}
if (!baseUrl || !password) {
return;
}
try { try {
await sendBlueBubblesTyping(chatGuidForActions, false, { await sendBlueBubblesTyping(chatGuidForActions, false, {
cfg: config, cfg: config,
@@ -2167,7 +2325,9 @@ async function processReaction(
target: WebhookTarget, target: WebhookTarget,
): Promise<void> { ): Promise<void> {
const { account, config, runtime, core } = target; const { account, config, runtime, core } = target;
if (reaction.fromMe) return; if (reaction.fromMe) {
return;
}
const dmPolicy = account.config.dmPolicy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const groupPolicy = account.config.groupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? "allowlist";
@@ -2187,9 +2347,13 @@ async function processReaction(
.filter(Boolean); .filter(Boolean);
if (reaction.isGroup) { if (reaction.isGroup) {
if (groupPolicy === "disabled") return; if (groupPolicy === "disabled") {
return;
}
if (groupPolicy === "allowlist") { if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) return; if (effectiveGroupAllowFrom.length === 0) {
return;
}
const allowed = isAllowedBlueBubblesSender({ const allowed = isAllowedBlueBubblesSender({
allowFrom: effectiveGroupAllowFrom, allowFrom: effectiveGroupAllowFrom,
sender: reaction.senderId, sender: reaction.senderId,
@@ -2197,10 +2361,14 @@ async function processReaction(
chatGuid: reaction.chatGuid ?? undefined, chatGuid: reaction.chatGuid ?? undefined,
chatIdentifier: reaction.chatIdentifier ?? undefined, chatIdentifier: reaction.chatIdentifier ?? undefined,
}); });
if (!allowed) return; if (!allowed) {
return;
}
} }
} else { } else {
if (dmPolicy === "disabled") return; if (dmPolicy === "disabled") {
return;
}
if (dmPolicy !== "open") { if (dmPolicy !== "open") {
const allowed = isAllowedBlueBubblesSender({ const allowed = isAllowedBlueBubblesSender({
allowFrom: effectiveAllowFrom, allowFrom: effectiveAllowFrom,
@@ -2209,7 +2377,9 @@ async function processReaction(
chatGuid: reaction.chatGuid ?? undefined, chatGuid: reaction.chatGuid ?? undefined,
chatIdentifier: reaction.chatIdentifier ?? undefined, chatIdentifier: reaction.chatIdentifier ?? undefined,
}); });
if (!allowed) return; if (!allowed) {
return;
}
} }
} }
@@ -2293,6 +2463,8 @@ export async function monitorBlueBubblesProvider(
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
const raw = config?.webhookPath?.trim(); const raw = config?.webhookPath?.trim();
if (raw) return normalizeWebhookPath(raw); if (raw) {
return normalizeWebhookPath(raw);
}
return DEFAULT_WEBHOOK_PATH; return DEFAULT_WEBHOOK_PATH;
} }

View File

@@ -18,7 +18,7 @@ import {
resolveDefaultBlueBubblesAccountId, resolveDefaultBlueBubblesAccountId,
} from "./accounts.js"; } from "./accounts.js";
import { normalizeBlueBubblesServerUrl } from "./types.js"; import { normalizeBlueBubblesServerUrl } from "./types.js";
import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js"; import { parseBlueBubblesAllowTarget } from "./targets.js";
const channel = "bluebubbles" as const; const channel = "bluebubbles" as const;
@@ -110,10 +110,14 @@ async function promptBlueBubblesAllowFrom(params: {
initialValue: existing[0] ? String(existing[0]) : undefined, initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => { validate: (value) => {
const raw = String(value ?? "").trim(); const raw = String(value ?? "").trim();
if (!raw) return "Required"; if (!raw) {
return "Required";
}
const parts = parseBlueBubblesAllowFromInput(raw); const parts = parseBlueBubblesAllowFromInput(raw);
for (const part of parts) { for (const part of parts) {
if (part === "*") continue; if (part === "*") {
continue;
}
const parsed = parseBlueBubblesAllowTarget(part); const parsed = parseBlueBubblesAllowTarget(part);
if (parsed.kind === "handle" && !parsed.handle) { if (parsed.kind === "handle" && !parsed.handle) {
return `Invalid entry: ${part}`; return `Invalid entry: ${part}`;
@@ -188,7 +192,9 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
placeholder: "http://192.168.1.100:1234", placeholder: "http://192.168.1.100:1234",
validate: (value) => { validate: (value) => {
const trimmed = String(value ?? "").trim(); const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required"; if (!trimmed) {
return "Required";
}
try { try {
const normalized = normalizeBlueBubblesServerUrl(trimmed); const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized); new URL(normalized);
@@ -211,7 +217,9 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: serverUrl, initialValue: serverUrl,
validate: (value) => { validate: (value) => {
const trimmed = String(value ?? "").trim(); const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required"; if (!trimmed) {
return "Required";
}
try { try {
const normalized = normalizeBlueBubblesServerUrl(trimmed); const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized); new URL(normalized);
@@ -268,8 +276,12 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: existingWebhookPath || "/bluebubbles-webhook", initialValue: existingWebhookPath || "/bluebubbles-webhook",
validate: (value) => { validate: (value) => {
const trimmed = String(value ?? "").trim(); const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required"; if (!trimmed) {
if (!trimmed.startsWith("/")) return "Path must start with /"; return "Required";
}
if (!trimmed.startsWith("/")) {
return "Path must start with /";
}
return undefined; return undefined;
}, },
}); });

View File

@@ -36,7 +36,9 @@ export async function fetchBlueBubblesServerInfo(params: {
}): Promise<BlueBubblesServerInfo | null> { }): Promise<BlueBubblesServerInfo | null> {
const baseUrl = params.baseUrl?.trim(); const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim(); const password = params.password?.trim();
if (!baseUrl || !password) return null; if (!baseUrl || !password) {
return null;
}
const cacheKey = buildCacheKey(params.accountId); const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey); const cached = serverInfoCache.get(cacheKey);
@@ -47,7 +49,9 @@ export async function fetchBlueBubblesServerInfo(params: {
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password }); const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
try { try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000); const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
if (!res.ok) return null; if (!res.ok) {
return null;
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null; const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload?.data as BlueBubblesServerInfo | undefined; const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) { if (data) {
@@ -76,7 +80,9 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
*/ */
export function parseMacOSMajorVersion(version?: string | null): number | null { export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) return null; if (!version) {
return null;
}
const match = /^(\d+)/.exec(version.trim()); const match = /^(\d+)/.exec(version.trim());
return match ? Number.parseInt(match[1], 10) : null; return match ? Number.parseInt(match[1], 10) : null;
} }
@@ -87,7 +93,9 @@ export function parseMacOSMajorVersion(version?: string | null): number | null {
*/ */
export function isMacOS26OrHigher(accountId?: string): boolean { export function isMacOS26OrHigher(accountId?: string): boolean {
const info = getCachedBlueBubblesServerInfo(accountId); const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) return false; if (!info?.os_version) {
return false;
}
const major = parseMacOSMajorVersion(info.os_version); const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26; return major !== null && major >= 26;
} }
@@ -104,8 +112,12 @@ export async function probeBlueBubbles(params: {
}): Promise<BlueBubblesProbe> { }): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim(); const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim(); const password = params.password?.trim();
if (!baseUrl) return { ok: false, error: "serverUrl not configured" }; if (!baseUrl) {
if (!password) return { ok: false, error: "password not configured" }; return { ok: false, error: "serverUrl not configured" };
}
if (!password) {
return { ok: false, error: "password not configured" };
}
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password }); const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
try { try {
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs); const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);

View File

@@ -117,16 +117,24 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
}); });
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim(); const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); if (!baseUrl) {
if (!password) throw new Error("BlueBubbles password is required"); throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password }; return { baseUrl, password };
} }
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim(); const trimmed = emoji.trim();
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name."); if (!trimmed) {
throw new Error("BlueBubbles reaction requires an emoji or name.");
}
let raw = trimmed.toLowerCase(); let raw = trimmed.toLowerCase();
if (raw.startsWith("-")) raw = raw.slice(1); if (raw.startsWith("-")) {
raw = raw.slice(1);
}
const aliased = REACTION_ALIASES.get(raw) ?? raw; const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased; const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) { if (!REACTION_TYPES.has(mapped)) {
@@ -145,8 +153,12 @@ export async function sendBlueBubblesReaction(params: {
}): Promise<void> { }): Promise<void> {
const chatGuid = params.chatGuid.trim(); const chatGuid = params.chatGuid.trim();
const messageGuid = params.messageGuid.trim(); const messageGuid = params.messageGuid.trim();
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid."); if (!chatGuid) {
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid."); throw new Error("BlueBubbles reaction requires chatGuid.");
}
if (!messageGuid) {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const { baseUrl, password } = resolveAccount(params.opts ?? {}); const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({ const url = buildBlueBubblesApiUrl({

View File

@@ -55,13 +55,21 @@ const EFFECT_MAP: Record<string, string> = {
}; };
function resolveEffectId(raw?: string): string | undefined { function resolveEffectId(raw?: string): string | undefined {
if (!raw) return undefined; if (!raw) {
return undefined;
}
const trimmed = raw.trim().toLowerCase(); const trimmed = raw.trim().toLowerCase();
if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed]; if (EFFECT_MAP[trimmed]) {
return EFFECT_MAP[trimmed];
}
const normalized = trimmed.replace(/[\s_]+/g, "-"); const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized]; if (EFFECT_MAP[normalized]) {
return EFFECT_MAP[normalized];
}
const compact = trimmed.replace(/[\s_-]+/g, ""); const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact]; if (EFFECT_MAP[compact]) {
return EFFECT_MAP[compact];
}
return raw; return raw;
} }
@@ -84,7 +92,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
} }
function extractMessageId(payload: unknown): string { function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown"; if (!payload || typeof payload !== "object") {
return "unknown";
}
const record = payload as Record<string, unknown>; const record = payload as Record<string, unknown>;
const data = const data =
record.data && typeof record.data === "object" record.data && typeof record.data === "object"
@@ -104,8 +114,12 @@ function extractMessageId(payload: unknown): string {
data?.id, data?.id,
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); if (typeof candidate === "string" && candidate.trim()) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate); return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
} }
return "unknown"; return "unknown";
} }
@@ -122,7 +136,9 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
chat.chat_identifier, chat.chat_identifier,
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim(); if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
} }
return null; return null;
} }
@@ -130,14 +146,18 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
function extractChatId(chat: BlueBubblesChatRecord): number | null { function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id]; const candidates = [chat.chatId, chat.id, chat.chat_id];
for (const candidate of candidates) { for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate; if (typeof candidate === "number" && Number.isFinite(candidate)) {
return candidate;
}
} }
return null; return null;
} }
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null { function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";"); const parts = chatGuid.split(";");
if (parts.length < 3) return null; if (parts.length < 3) {
return null;
}
const identifier = parts[2]?.trim(); const identifier = parts[2]?.trim();
return identifier ? identifier : null; return identifier ? identifier : null;
} }
@@ -147,7 +167,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
(Array.isArray(chat.participants) ? chat.participants : null) ?? (Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ?? (Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null); (Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) return []; if (!raw) {
return [];
}
const out: string[] = []; const out: string[] = [];
for (const entry of raw) { for (const entry of raw) {
if (typeof entry === "string") { if (typeof entry === "string") {
@@ -161,7 +183,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
(typeof record.handle === "string" && record.handle) || (typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) || (typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier); (typeof record.identifier === "string" && record.identifier);
if (candidate) out.push(candidate); if (candidate) {
out.push(candidate);
}
} }
} }
return out; return out;
@@ -192,7 +216,9 @@ async function queryChats(params: {
}, },
params.timeoutMs, params.timeoutMs,
); );
if (!res.ok) return []; if (!res.ok) {
return [];
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null; const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null; const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
@@ -204,7 +230,9 @@ export async function resolveChatGuidForTarget(params: {
timeoutMs?: number; timeoutMs?: number;
target: BlueBubblesSendTarget; target: BlueBubblesSendTarget;
}): Promise<string | null> { }): Promise<string | null> {
if (params.target.kind === "chat_guid") return params.target.chatGuid; if (params.target.kind === "chat_guid") {
return params.target.chatGuid;
}
const normalizedHandle = const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : ""; params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
@@ -222,7 +250,9 @@ export async function resolveChatGuidForTarget(params: {
offset, offset,
limit, limit,
}); });
if (chats.length === 0) break; if (chats.length === 0) {
break;
}
for (const chat of chats) { for (const chat of chats) {
if (targetChatId != null) { if (targetChatId != null) {
const chatId = extractChatId(chat); const chatId = extractChatId(chat);
@@ -234,12 +264,16 @@ export async function resolveChatGuidForTarget(params: {
const guid = extractChatGuid(chat); const guid = extractChatGuid(chat);
if (guid) { if (guid) {
// Back-compat: some callers might pass a full chat GUID. // Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) return guid; if (guid === targetChatIdentifier) {
return guid;
}
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the // Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`. // third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid); const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid; if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
return guid;
}
} }
const identifier = const identifier =
@@ -250,7 +284,9 @@ 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 guid ?? extractChatGuid(chat); if (identifier && identifier === targetChatIdentifier) {
return guid ?? extractChatGuid(chat);
}
} }
if (normalizedHandle) { if (normalizedHandle) {
const guid = extractChatGuid(chat); const guid = extractChatGuid(chat);
@@ -322,7 +358,9 @@ async function createNewChatWithMessage(params: {
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`); throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
} }
const body = await res.text(); const body = await res.text();
if (!body) return { messageId: "ok" }; if (!body) {
return { messageId: "ok" };
}
try { try {
const parsed = JSON.parse(body) as unknown; const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) }; return { messageId: extractMessageId(parsed) };
@@ -347,8 +385,12 @@ export async function sendMessageBlueBubbles(
}); });
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim(); const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = opts.password?.trim() || account.config.password?.trim(); const password = opts.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required"); if (!baseUrl) {
if (!password) throw new Error("BlueBubbles password is required"); throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
const target = resolveSendTarget(to); const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({ const chatGuid = await resolveChatGuidForTarget({
@@ -414,7 +456,9 @@ export async function sendMessageBlueBubbles(
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
} }
const body = await res.text(); const body = await res.text();
if (!body) return { messageId: "ok" }; if (!body) {
return { messageId: "ok" };
}
try { try {
const parsed = JSON.parse(body) as unknown; const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) }; return { messageId: extractMessageId(parsed) };

View File

@@ -25,14 +25,22 @@ const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
function parseRawChatGuid(value: string): string | null { function parseRawChatGuid(value: string): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return null; if (!trimmed) {
return null;
}
const parts = trimmed.split(";"); const parts = trimmed.split(";");
if (parts.length !== 3) return null; if (parts.length !== 3) {
return null;
}
const service = parts[0]?.trim(); const service = parts[0]?.trim();
const separator = parts[1]?.trim(); const separator = parts[1]?.trim();
const identifier = parts[2]?.trim(); const identifier = parts[2]?.trim();
if (!service || !identifier) return null; if (!service || !identifier) {
if (separator !== "+" && separator !== "-") return null; return null;
}
if (separator !== "+" && separator !== "-") {
return null;
}
return `${service};${separator};${identifier}`; return `${service};${separator};${identifier}`;
} }
@@ -42,26 +50,44 @@ function stripPrefix(value: string, prefix: string): string {
function stripBlueBubblesPrefix(value: string): string { function stripBlueBubblesPrefix(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return ""; if (!trimmed) {
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed; return "";
}
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) {
return trimmed;
}
return trimmed.slice("bluebubbles:".length).trim(); return trimmed.slice("bluebubbles:".length).trim();
} }
function looksLikeRawChatIdentifier(value: string): boolean { function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return false; if (!trimmed) {
if (/^chat\d+$/i.test(trimmed)) return true; return false;
}
if (/^chat\d+$/i.test(trimmed)) {
return true;
}
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
} }
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 "";
}
const lowered = trimmed.toLowerCase(); const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9)); if (lowered.startsWith("imessage:")) {
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4)); return normalizeBlueBubblesHandle(trimmed.slice(9));
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5)); }
if (trimmed.includes("@")) return trimmed.toLowerCase(); if (lowered.startsWith("sms:")) {
return normalizeBlueBubblesHandle(trimmed.slice(4));
}
if (lowered.startsWith("auto:")) {
return normalizeBlueBubblesHandle(trimmed.slice(5));
}
if (trimmed.includes("@")) {
return trimmed.toLowerCase();
}
return trimmed.replace(/\s+/g, ""); return trimmed.replace(/\s+/g, "");
} }
@@ -75,30 +101,44 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
// DM format: service;-;handle (3 parts, middle is "-") // DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") { if (parts.length === 3 && parts[1] === "-") {
const handle = parts[2]?.trim(); const handle = parts[2]?.trim();
if (handle) return normalizeBlueBubblesHandle(handle); if (handle) {
return normalizeBlueBubblesHandle(handle);
}
} }
return null; return null;
} }
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim(); let trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
trimmed = stripBlueBubblesPrefix(trimmed); trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
try { try {
const parsed = parseBlueBubblesTarget(trimmed); const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`; if (parsed.kind === "chat_id") {
return `chat_id:${parsed.chatId}`;
}
if (parsed.kind === "chat_guid") { if (parsed.kind === "chat_guid") {
// For DM chat_guids, normalize to just the handle for easier comparison. // For DM chat_guids, normalize to just the handle for easier comparison.
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890". // This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
const handle = extractHandleFromChatGuid(parsed.chatGuid); const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle; if (handle) {
return handle;
}
// For group chats or unrecognized formats, keep the full chat_guid // For group chats or unrecognized formats, keep the full chat_guid
return `chat_guid:${parsed.chatGuid}`; return `chat_guid:${parsed.chatGuid}`;
} }
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`; if (parsed.kind === "chat_identifier") {
return `chat_identifier:${parsed.chatIdentifier}`;
}
const handle = normalizeBlueBubblesHandle(parsed.to); const handle = normalizeBlueBubblesHandle(parsed.to);
if (!handle) return undefined; if (!handle) {
return undefined;
}
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`; return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch { } catch {
return trimmed; return trimmed;
@@ -107,12 +147,20 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean { export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed); const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) return false; if (!candidate) {
if (parseRawChatGuid(candidate)) return true; return false;
}
if (parseRawChatGuid(candidate)) {
return true;
}
const lowered = candidate.toLowerCase(); const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) return true; if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if ( if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered, lowered,
@@ -121,14 +169,24 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return true; return true;
} }
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs // Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) return true; if (/^chat\d+$/i.test(candidate)) {
if (looksLikeRawChatIdentifier(candidate)) return true; return true;
if (candidate.includes("@")) return true; }
if (looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (candidate.includes("@")) {
return true;
}
const digitsOnly = candidate.replace(/[\s().-]/g, ""); const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) return true; if (/^\+?\d{3,}$/.test(digitsOnly)) {
return true;
}
if (normalized) { if (normalized) {
const normalizedTrimmed = normalized.trim(); const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) return false; if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizedTrimmed.toLowerCase(); const normalizedLower = normalizedTrimmed.toLowerCase();
if ( if (
/^(imessage|sms|auto):/.test(normalizedLower) || /^(imessage|sms|auto):/.test(normalizedLower) ||
@@ -142,13 +200,17 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw); const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) throw new Error("BlueBubbles target is required"); if (!trimmed) {
throw new Error("BlueBubbles target is required");
}
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
for (const { prefix, service } of SERVICE_PREFIXES) { for (const { prefix, service } of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const remainder = stripPrefix(trimmed, prefix); const remainder = stripPrefix(trimmed, prefix);
if (!remainder) throw new Error(`${prefix} target is required`); if (!remainder) {
throw new Error(`${prefix} target is required`);
}
const remainderLower = remainder.toLowerCase(); const remainderLower = remainder.toLowerCase();
const isChatTarget = const isChatTarget =
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
@@ -176,7 +238,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
for (const prefix of CHAT_GUID_PREFIXES) { for (const prefix of CHAT_GUID_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix); const value = stripPrefix(trimmed, prefix);
if (!value) throw new Error("chat_guid is required"); if (!value) {
throw new Error("chat_guid is required");
}
return { kind: "chat_guid", chatGuid: value }; return { kind: "chat_guid", chatGuid: value };
} }
} }
@@ -184,7 +248,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
for (const prefix of CHAT_IDENTIFIER_PREFIXES) { for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix); const value = stripPrefix(trimmed, prefix);
if (!value) throw new Error("chat_identifier is required"); if (!value) {
throw new Error("chat_identifier is required");
}
return { kind: "chat_identifier", chatIdentifier: value }; return { kind: "chat_identifier", chatIdentifier: value };
} }
} }
@@ -195,7 +261,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
if (Number.isFinite(chatId)) { if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId }; return { kind: "chat_id", chatId };
} }
if (!value) throw new Error("group target is required"); if (!value) {
throw new Error("group target is required");
}
return { kind: "chat_guid", chatGuid: value }; return { kind: "chat_guid", chatGuid: value };
} }
@@ -220,13 +288,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget { export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return { kind: "handle", handle: "" }; if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
for (const { prefix } of SERVICE_PREFIXES) { for (const { prefix } of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const remainder = stripPrefix(trimmed, prefix); const remainder = stripPrefix(trimmed, prefix);
if (!remainder) return { kind: "handle", handle: "" }; if (!remainder) {
return { kind: "handle", handle: "" };
}
return parseBlueBubblesAllowTarget(remainder); return parseBlueBubblesAllowTarget(remainder);
} }
} }
@@ -235,29 +307,39 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix); const value = stripPrefix(trimmed, prefix);
const chatId = Number.parseInt(value, 10); const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
} }
} }
for (const prefix of CHAT_GUID_PREFIXES) { for (const prefix of CHAT_GUID_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix); const value = stripPrefix(trimmed, prefix);
if (value) return { kind: "chat_guid", chatGuid: value }; if (value) {
return { kind: "chat_guid", chatGuid: value };
}
} }
} }
for (const prefix of CHAT_IDENTIFIER_PREFIXES) { for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lower.startsWith(prefix)) { if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix); const value = stripPrefix(trimmed, prefix);
if (value) return { kind: "chat_identifier", chatIdentifier: value }; if (value) {
return { kind: "chat_identifier", chatIdentifier: value };
}
} }
} }
if (lower.startsWith("group:")) { if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:"); const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10); const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId }; if (Number.isFinite(chatId)) {
if (value) return { kind: "chat_guid", chatGuid: value }; return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
} }
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier // Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
@@ -282,8 +364,12 @@ export function isAllowedBlueBubblesSender(params: {
chatIdentifier?: string | null; chatIdentifier?: string | null;
}): boolean { }): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) return true; if (allowFrom.length === 0) {
if (allowFrom.includes("*")) return true; return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = normalizeBlueBubblesHandle(params.sender); const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined; const chatId = params.chatId ?? undefined;
@@ -291,16 +377,26 @@ export function isAllowedBlueBubblesSender(params: {
const chatIdentifier = params.chatIdentifier?.trim(); const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) { for (const entry of allowFrom) {
if (!entry) continue; if (!entry) {
continue;
}
const parsed = parseBlueBubblesAllowTarget(entry); const parsed = parseBlueBubblesAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) { if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) return true; if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) { } else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) return true; if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) { } else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) return true; if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) { } else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) return true; if (parsed.handle === senderNormalized) {
return true;
}
} }
} }
return false; return false;
@@ -315,8 +411,12 @@ export function formatBlueBubblesChatTarget(params: {
return `chat_id:${params.chatId}`; return `chat_id:${params.chatId}`;
} }
const guid = params.chatGuid?.trim(); const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`; if (guid) {
return `chat_guid:${guid}`;
}
const identifier = params.chatIdentifier?.trim(); const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`; if (identifier) {
return `chat_identifier:${identifier}`;
}
return ""; return "";
} }

View File

@@ -21,10 +21,16 @@ const DEFAULT_MODEL_IDS = [
function normalizeBaseUrl(value: string): string { function normalizeBaseUrl(value: string): string {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return DEFAULT_BASE_URL; if (!trimmed) {
return DEFAULT_BASE_URL;
}
let normalized = trimmed; let normalized = trimmed;
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1); while (normalized.endsWith("/")) {
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`; normalized = normalized.slice(0, -1);
}
if (!normalized.endsWith("/v1")) {
normalized = `${normalized}/v1`;
}
return normalized; return normalized;
} }

View File

@@ -21,14 +21,22 @@ function normalizeEndpoint(endpoint?: string): string | undefined {
} }
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined { function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
if (!endpoint) return undefined; if (!endpoint) {
if (endpoint.includes("/v1/")) return endpoint; return undefined;
}
if (endpoint.includes("/v1/")) {
return endpoint;
}
return `${endpoint}/${path}`; return `${endpoint}/${path}`;
} }
function resolveSampleRate(value: number | undefined): number | undefined { function resolveSampleRate(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined; if (typeof value !== "number" || !Number.isFinite(value)) {
if (value < 0 || value > 1) return undefined; return undefined;
}
if (value < 0 || value > 1) {
return undefined;
}
return value; return value;
} }
@@ -43,7 +51,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
async start(ctx) { async start(ctx) {
const cfg = ctx.config.diagnostics; const cfg = ctx.config.diagnostics;
const otel = cfg?.otel; const otel = cfg?.otel;
if (!cfg?.enabled || !otel?.enabled) return; if (!cfg?.enabled || !otel?.enabled) {
return;
}
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf"; const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
if (protocol !== "http/protobuf") { if (protocol !== "http/protobuf") {
@@ -60,7 +70,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const tracesEnabled = otel.traces !== false; const tracesEnabled = otel.traces !== false;
const metricsEnabled = otel.metrics !== false; const metricsEnabled = otel.metrics !== false;
const logsEnabled = otel.logs === true; const logsEnabled = otel.logs === true;
if (!tracesEnabled && !metricsEnabled && !logsEnabled) return; if (!tracesEnabled && !metricsEnabled && !logsEnabled) {
return;
}
const resource = new Resource({ const resource = new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: serviceName, [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
@@ -106,7 +118,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
: {}), : {}),
}); });
await sdk.start(); sdk.start();
} }
const logSeverityMap: Record<string, SeverityNumber> = { const logSeverityMap: Record<string, SeverityNumber> = {
@@ -201,11 +213,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}); });
logProvider = new LoggerProvider({ resource }); logProvider = new LoggerProvider({ resource });
logProvider.addLogRecordProcessor( logProvider.addLogRecordProcessor(
new BatchLogRecordProcessor(logExporter, { new BatchLogRecordProcessor(
...(typeof otel.flushIntervalMs === "number" logExporter,
typeof otel.flushIntervalMs === "number"
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) } ? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
: {}), : {},
}), ),
); );
const otelLogger = logProvider.getLogger("openclaw"); const otelLogger = logProvider.getLogger("openclaw");
@@ -237,7 +250,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const numericArgs = Object.entries(logObj) const numericArgs = Object.entries(logObj)
.filter(([key]) => /^\d+$/.test(key)) .filter(([key]) => /^\d+$/.test(key))
.sort((a, b) => Number(a[0]) - Number(b[0])) .toSorted((a, b) => Number(a[0]) - Number(b[0]))
.map(([, value]) => value); .map(([, value]) => value);
let bindings: Record<string, unknown> | undefined; let bindings: Record<string, unknown> | undefined;
@@ -267,7 +280,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
const attributes: Record<string, string | number | boolean> = { const attributes: Record<string, string | number | boolean> = {
"openclaw.log.level": logLevelName, "openclaw.log.level": logLevelName,
}; };
if (meta?.name) attributes["openclaw.logger"] = meta.name; if (meta?.name) {
attributes["openclaw.logger"] = meta.name;
}
if (meta?.parentNames?.length) { if (meta?.parentNames?.length) {
attributes["openclaw.logger.parents"] = meta.parentNames.join("."); attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
} }
@@ -287,9 +302,15 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (numericArgs.length > 0) { if (numericArgs.length > 0) {
attributes["openclaw.log.args"] = safeStringify(numericArgs); attributes["openclaw.log.args"] = safeStringify(numericArgs);
} }
if (meta?.path?.filePath) attributes["code.filepath"] = meta.path.filePath; if (meta?.path?.filePath) {
if (meta?.path?.fileLine) attributes["code.lineno"] = Number(meta.path.fileLine); attributes["code.filepath"] = meta.path.filePath;
if (meta?.path?.method) attributes["code.function"] = meta.path.method; }
if (meta?.path?.fileLine) {
attributes["code.lineno"] = Number(meta.path.fileLine);
}
if (meta?.path?.method) {
attributes["code.function"] = meta.path.method;
}
if (meta?.path?.filePathWithLine) { if (meta?.path?.filePathWithLine) {
attributes["openclaw.code.location"] = meta.path.filePathWithLine; attributes["openclaw.code.location"] = meta.path.filePathWithLine;
} }
@@ -326,30 +347,47 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}; };
const usage = evt.usage; const usage = evt.usage;
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" }); if (usage.input) {
if (usage.output) tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" }); tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
if (usage.cacheRead) }
if (usage.output) {
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
}
if (usage.cacheRead) {
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" }); tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
if (usage.cacheWrite) }
if (usage.cacheWrite) {
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" }); tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
if (usage.promptTokens) }
if (usage.promptTokens) {
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" }); tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
if (usage.total) tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" }); }
if (usage.total) {
tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
}
if (evt.costUsd) costCounter.add(evt.costUsd, attrs); if (evt.costUsd) {
if (evt.durationMs) durationHistogram.record(evt.durationMs, attrs); costCounter.add(evt.costUsd, attrs);
if (evt.context?.limit) }
if (evt.durationMs) {
durationHistogram.record(evt.durationMs, attrs);
}
if (evt.context?.limit) {
contextHistogram.record(evt.context.limit, { contextHistogram.record(evt.context.limit, {
...attrs, ...attrs,
"openclaw.context": "limit", "openclaw.context": "limit",
}); });
if (evt.context?.used) }
if (evt.context?.used) {
contextHistogram.record(evt.context.used, { contextHistogram.record(evt.context.used, {
...attrs, ...attrs,
"openclaw.context": "used", "openclaw.context": "used",
}); });
}
if (!tracesEnabled) return; if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { const spanAttrs: Record<string, string | number> = {
...attrs, ...attrs,
"openclaw.sessionKey": evt.sessionKey ?? "", "openclaw.sessionKey": evt.sessionKey ?? "",
@@ -385,9 +423,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (typeof evt.durationMs === "number") { if (typeof evt.durationMs === "number") {
webhookDurationHistogram.record(evt.durationMs, attrs); webhookDurationHistogram.record(evt.durationMs, attrs);
} }
if (!tracesEnabled) return; if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs }; const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId); if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs); const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
span.end(); span.end();
}; };
@@ -400,12 +442,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
"openclaw.webhook": evt.updateType ?? "unknown", "openclaw.webhook": evt.updateType ?? "unknown",
}; };
webhookErrorCounter.add(1, attrs); webhookErrorCounter.add(1, attrs);
if (!tracesEnabled) return; if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { const spanAttrs: Record<string, string | number> = {
...attrs, ...attrs,
"openclaw.error": evt.error, "openclaw.error": evt.error,
}; };
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId); if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
const span = tracer.startSpan("openclaw.webhook.error", { const span = tracer.startSpan("openclaw.webhook.error", {
attributes: spanAttrs, attributes: spanAttrs,
}); });
@@ -437,13 +483,25 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (typeof evt.durationMs === "number") { if (typeof evt.durationMs === "number") {
messageDurationHistogram.record(evt.durationMs, attrs); messageDurationHistogram.record(evt.durationMs, attrs);
} }
if (!tracesEnabled) return; if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs }; const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["openclaw.sessionKey"] = evt.sessionKey; if (evt.sessionKey) {
if (evt.sessionId) spanAttrs["openclaw.sessionId"] = evt.sessionId; spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId); }
if (evt.messageId !== undefined) spanAttrs["openclaw.messageId"] = String(evt.messageId); if (evt.sessionId) {
if (evt.reason) spanAttrs["openclaw.reason"] = evt.reason; spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
if (evt.messageId !== undefined) {
spanAttrs["openclaw.messageId"] = String(evt.messageId);
}
if (evt.reason) {
spanAttrs["openclaw.reason"] = evt.reason;
}
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
if (evt.outcome === "error") { if (evt.outcome === "error") {
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
@@ -474,7 +532,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>, evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
) => { ) => {
const attrs: Record<string, string> = { "openclaw.state": evt.state }; const attrs: Record<string, string> = { "openclaw.state": evt.state };
if (evt.reason) attrs["openclaw.reason"] = evt.reason; if (evt.reason) {
attrs["openclaw.reason"] = evt.reason;
}
sessionStateCounter.add(1, attrs); sessionStateCounter.add(1, attrs);
}; };
@@ -486,10 +546,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
if (typeof evt.ageMs === "number") { if (typeof evt.ageMs === "number") {
sessionStuckAgeHistogram.record(evt.ageMs, attrs); sessionStuckAgeHistogram.record(evt.ageMs, attrs);
} }
if (!tracesEnabled) return; if (!tracesEnabled) {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs }; const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) spanAttrs["openclaw.sessionKey"] = evt.sessionKey; if (evt.sessionKey) {
if (evt.sessionId) spanAttrs["openclaw.sessionId"] = evt.sessionId; spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
}
if (evt.sessionId) {
spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["openclaw.ageMs"] = evt.ageMs; spanAttrs["openclaw.ageMs"] = evt.ageMs;
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });

View File

@@ -331,7 +331,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
cfg, cfg,
accountId: account.accountId, accountId: account.accountId,
}); });
if (!channelIds.length && unresolvedChannels === 0) return undefined; if (!channelIds.length && unresolvedChannels === 0) {
return undefined;
}
const botToken = account.token?.trim(); const botToken = account.token?.trim();
if (!botToken) { if (!botToken) {
return { return {
@@ -383,7 +385,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
includeApplication: true, includeApplication: true,
}); });
const username = probe.ok ? probe.bot?.username?.trim() : null; const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`; if (username) {
discordBotLabel = ` (@${username})`;
}
ctx.setStatus({ ctx.setStatus({
accountId: account.accountId, accountId: account.accountId,
bot: probe.bot, bot: probe.bot,

View File

@@ -49,7 +49,9 @@ function generatePkce(): { verifier: string; challenge: string } {
} }
function isWSL(): boolean { function isWSL(): boolean {
if (process.platform !== "linux") return false; if (process.platform !== "linux") {
return false;
}
try { try {
const release = readFileSync("/proc/version", "utf8").toLowerCase(); const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl"); return release.includes("microsoft") || release.includes("wsl");
@@ -59,7 +61,9 @@ function isWSL(): boolean {
} }
function isWSL2(): boolean { function isWSL2(): boolean {
if (!isWSL()) return false; if (!isWSL()) {
return false;
}
try { try {
const version = readFileSync("/proc/version", "utf8").toLowerCase(); const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard"); return version.includes("wsl2") || version.includes("microsoft-standard");
@@ -88,14 +92,20 @@ function buildAuthUrl(params: { challenge: string; state: string }): string {
function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { function parseCallbackInput(input: string): { code: string; state: string } | { error: string } {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" }; if (!trimmed) {
return { error: "No input provided" };
}
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const state = url.searchParams.get("state"); const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" }; if (!code) {
if (!state) return { error: "Missing 'state' parameter in URL" }; return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter in URL" };
}
return { code, state }; return { code, state };
} catch { } catch {
return { error: "Paste the full redirect URL (not just the code)." }; return { error: "Paste the full redirect URL (not just the code)." };
@@ -112,12 +122,16 @@ async function startCallbackServer(params: { timeoutMs: number }) {
const callbackPromise = new Promise<URL>((resolve, reject) => { const callbackPromise = new Promise<URL>((resolve, reject) => {
resolveCallback = (url) => { resolveCallback = (url) => {
if (settled) return; if (settled) {
return;
}
settled = true; settled = true;
resolve(url); resolve(url);
}; };
rejectCallback = (err) => { rejectCallback = (err) => {
if (settled) return; if (settled) {
return;
}
settled = true; settled = true;
reject(err); reject(err);
}; };
@@ -204,8 +218,12 @@ async function exchangeCode(params: {
const refresh = data.refresh_token?.trim(); const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0; const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Token exchange returned no access_token"); if (!access) {
if (!refresh) throw new Error("Token exchange returned no refresh_token"); throw new Error("Token exchange returned no access_token");
}
if (!refresh) {
throw new Error("Token exchange returned no refresh_token");
}
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
return { access, refresh, expires }; return { access, refresh, expires };
@@ -216,7 +234,9 @@ async function fetchUserEmail(accessToken: string): Promise<string | undefined>
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}); });
if (!response.ok) return undefined; if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { email?: string }; const data = (await response.json()) as { email?: string };
return data.email; return data.email;
} catch { } catch {
@@ -251,7 +271,9 @@ async function fetchProjectId(accessToken: string): Promise<string> {
}), }),
}); });
if (!response.ok) continue; if (!response.ok) {
continue;
}
const data = (await response.json()) as { const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string }; cloudaicompanionProject?: string | { id?: string };
}; };
@@ -342,12 +364,16 @@ async function loginAntigravity(params: {
params.progress.update("Waiting for redirect URL…"); params.progress.update("Waiting for redirect URL…");
const input = await params.prompt("Paste the redirect URL: "); const input = await params.prompt("Paste the redirect URL: ");
const parsed = parseCallbackInput(input); const parsed = parseCallbackInput(input);
if ("error" in parsed) throw new Error(parsed.error); if ("error" in parsed) {
throw new Error(parsed.error);
}
code = parsed.code; code = parsed.code;
returnedState = parsed.state; returnedState = parsed.state;
} }
if (!code) throw new Error("Missing OAuth code"); if (!code) {
throw new Error("Missing OAuth code");
}
if (returnedState !== state) { if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again."); throw new Error("OAuth state mismatch. Please try again.");
} }

View File

@@ -83,8 +83,12 @@ describe("extractGeminiCliCredentials", () => {
mockExistsSync.mockImplementation((p: string) => { mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p); const normalized = normalizePath(p);
if (normalized === normalizePath(fakeGeminiPath)) return true; if (normalized === normalizePath(fakeGeminiPath)) {
if (normalized === normalizePath(fakeOauth2Path)) return true; return true;
}
if (normalized === normalizePath(fakeOauth2Path)) {
return true;
}
return false; return false;
}); });
mockRealpathSync.mockReturnValue(fakeResolvedPath); mockRealpathSync.mockReturnValue(fakeResolvedPath);
@@ -160,8 +164,12 @@ describe("extractGeminiCliCredentials", () => {
mockExistsSync.mockImplementation((p: string) => { mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p); const normalized = normalizePath(p);
if (normalized === normalizePath(fakeGeminiPath)) return true; if (normalized === normalizePath(fakeGeminiPath)) {
if (normalized === normalizePath(fakeOauth2Path)) return true; return true;
}
if (normalized === normalizePath(fakeOauth2Path)) {
return true;
}
return false; return false;
}); });
mockRealpathSync.mockReturnValue(fakeResolvedPath); mockRealpathSync.mockReturnValue(fakeResolvedPath);
@@ -205,8 +213,12 @@ describe("extractGeminiCliCredentials", () => {
mockExistsSync.mockImplementation((p: string) => { mockExistsSync.mockImplementation((p: string) => {
const normalized = normalizePath(p); const normalized = normalizePath(p);
if (normalized === normalizePath(fakeGeminiPath)) return true; if (normalized === normalizePath(fakeGeminiPath)) {
if (normalized === normalizePath(fakeOauth2Path)) return true; return true;
}
if (normalized === normalizePath(fakeOauth2Path)) {
return true;
}
return false; return false;
}); });
mockRealpathSync.mockReturnValue(fakeResolvedPath); mockRealpathSync.mockReturnValue(fakeResolvedPath);

View File

@@ -43,7 +43,9 @@ export type GeminiCliOAuthContext = {
function resolveEnv(keys: string[]): string | undefined { function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) { for (const key of keys) {
const value = process.env[key]?.trim(); const value = process.env[key]?.trim();
if (value) return value; if (value) {
return value;
}
} }
return undefined; return undefined;
} }
@@ -57,11 +59,15 @@ export function clearCredentialsCache(): void {
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ /** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials; if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try { try {
const geminiPath = findInPath("gemini"); const geminiPath = findInPath("gemini");
if (!geminiPath) return null; if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath); const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath)); const geminiCliDir = dirname(dirname(resolvedPath));
@@ -97,9 +103,13 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
} }
if (!content) { if (!content) {
const found = findFile(geminiCliDir, "oauth2.js", 10); const found = findFile(geminiCliDir, "oauth2.js", 10);
if (found) content = readFileSync(found, "utf8"); if (found) {
content = readFileSync(found, "utf8");
}
}
if (!content) {
return null;
} }
if (!content) return null;
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
@@ -118,21 +128,29 @@ function findInPath(name: string): string | null {
for (const dir of (process.env.PATH ?? "").split(delimiter)) { for (const dir of (process.env.PATH ?? "").split(delimiter)) {
for (const ext of exts) { for (const ext of exts) {
const p = join(dir, name + ext); const p = join(dir, name + ext);
if (existsSync(p)) return p; if (existsSync(p)) {
return p;
}
} }
} }
return null; return null;
} }
function findFile(dir: string, name: string, depth: number): string | null { function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) return null; if (depth <= 0) {
return null;
}
try { try {
for (const e of readdirSync(dir, { withFileTypes: true })) { for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name); const p = join(dir, e.name);
if (e.isFile() && e.name === name) return p; if (e.isFile() && e.name === name) {
return p;
}
if (e.isDirectory() && !e.name.startsWith(".")) { if (e.isDirectory() && !e.name.startsWith(".")) {
const found = findFile(p, name, depth - 1); const found = findFile(p, name, depth - 1);
if (found) return found; if (found) {
return found;
}
} }
} }
} catch {} } catch {}
@@ -160,7 +178,9 @@ function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string }
} }
function isWSL(): boolean { function isWSL(): boolean {
if (process.platform !== "linux") return false; if (process.platform !== "linux") {
return false;
}
try { try {
const release = readFileSync("/proc/version", "utf8").toLowerCase(); const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl"); return release.includes("microsoft") || release.includes("wsl");
@@ -170,7 +190,9 @@ function isWSL(): boolean {
} }
function isWSL2(): boolean { function isWSL2(): boolean {
if (!isWSL()) return false; if (!isWSL()) {
return false;
}
try { try {
const version = readFileSync("/proc/version", "utf8").toLowerCase(); const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard"); return version.includes("wsl2") || version.includes("microsoft-standard");
@@ -210,17 +232,25 @@ function parseCallbackInput(
expectedState: string, expectedState: string,
): { code: string; state: string } | { error: string } { ): { code: string; state: string } | { error: string } {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" }; if (!trimmed) {
return { error: "No input provided" };
}
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState; const state = url.searchParams.get("state") ?? expectedState;
if (!code) return { error: "Missing 'code' parameter in URL" }; if (!code) {
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." }; return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state }; return { code, state };
} catch { } catch {
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." }; if (!expectedState) {
return { error: "Paste the full redirect URL, not just the code." };
}
return { code: trimmed, state: expectedState }; return { code: trimmed, state: expectedState };
} }
} }
@@ -289,7 +319,9 @@ async function waitForLocalCallback(params: {
}); });
const finish = (err?: Error, result?: { code: string; state: string }) => { const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) clearTimeout(timeout); if (timeout) {
clearTimeout(timeout);
}
try { try {
server.close(); server.close();
} catch { } catch {
@@ -427,14 +459,20 @@ async function discoverProject(accessToken: string): Promise<string> {
if (err instanceof Error) { if (err instanceof Error) {
throw err; throw err;
} }
throw new Error("loadCodeAssist failed"); throw new Error("loadCodeAssist failed", { cause: err });
} }
if (data.currentTier) { if (data.currentTier) {
const project = data.cloudaicompanionProject; const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) return project; if (typeof project === "string" && project) {
if (typeof project === "object" && project?.id) return project.id; return project;
if (envProject) return envProject; }
if (typeof project === "object" && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
throw new Error( throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
); );
@@ -482,8 +520,12 @@ async function discoverProject(accessToken: string): Promise<string> {
} }
const projectId = lro.response?.cloudaicompanionProject?.id; const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) return projectId; if (projectId) {
if (envProject) return envProject; return projectId;
}
if (envProject) {
return envProject;
}
throw new Error( throw new Error(
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
@@ -491,11 +533,17 @@ async function discoverProject(accessToken: string): Promise<string> {
} }
function isVpcScAffected(payload: unknown): boolean { function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") return false; if (!payload || typeof payload !== "object") {
return false;
}
const error = (payload as { error?: unknown }).error; const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== "object") return false; if (!error || typeof error !== "object") {
return false;
}
const details = (error as { details?: unknown[] }).details; const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) return false; if (!Array.isArray(details)) {
return false;
}
return details.some( return details.some(
(item) => (item) =>
typeof item === "object" && typeof item === "object" &&
@@ -507,7 +555,9 @@ function isVpcScAffected(payload: unknown): boolean {
function getDefaultTier( function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined { ): { id?: string } | undefined {
if (!allowedTiers?.length) return { id: TIER_LEGACY }; if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
} }
@@ -520,12 +570,16 @@ async function pollOperation(
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
headers, headers,
}); });
if (!response.ok) continue; if (!response.ok) {
continue;
}
const data = (await response.json()) as { const data = (await response.json()) as {
done?: boolean; done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } }; response?: { cloudaicompanionProject?: { id?: string } };
}; };
if (data.done) return data; if (data.done) {
return data;
}
} }
throw new Error("Operation polling timeout"); throw new Error("Operation polling timeout");
} }
@@ -558,7 +612,9 @@ export async function loginGeminiCliOAuth(
ctx.progress.update("Waiting for you to paste the callback URL..."); ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: "); const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier); const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error); if ("error" in parsed) {
throw new Error(parsed.error);
}
if (parsed.state !== verifier) { if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again"); throw new Error("OAuth state mismatch - please try again");
} }
@@ -592,9 +648,11 @@ export async function loginGeminiCliOAuth(
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: "); const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier); const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error); if ("error" in parsed) {
throw new Error(parsed.error, { cause: err });
}
if (parsed.state !== verifier) { if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again"); throw new Error("OAuth state mismatch - please try again", { cause: err });
} }
ctx.progress.update("Exchanging authorization code for tokens..."); ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier); return exchangeCodeForTokens(parsed.code, verifier);

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk"; import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js"; import type { GoogleChatAccountConfig } from "./types.config.js";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none"; export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
@@ -19,22 +19,30 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts; const accounts = cfg.channels?.["googlechat"]?.accounts;
if (!accounts || typeof accounts !== "object") return []; if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean); return Object.keys(accounts).filter(Boolean);
} }
export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] { export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg); const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; if (ids.length === 0) {
return ids.sort((a, b) => a.localeCompare(b)); return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
} }
export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string { export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string {
const channel = cfg.channels?.["googlechat"] as GoogleChatConfig | undefined; const channel = cfg.channels?.["googlechat"];
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim(); if (channel?.defaultAccount?.trim()) {
return channel.defaultAccount.trim();
}
const ids = listGoogleChatAccountIds(cfg); const ids = listGoogleChatAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID;
} }
@@ -42,8 +50,10 @@ function resolveAccountConfig(
cfg: OpenClawConfig, cfg: OpenClawConfig,
accountId: string, accountId: string,
): GoogleChatAccountConfig | undefined { ): GoogleChatAccountConfig | undefined {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts; const accounts = cfg.channels?.["googlechat"]?.accounts;
if (!accounts || typeof accounts !== "object") return undefined; if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId] as GoogleChatAccountConfig | undefined; return accounts[accountId] as GoogleChatAccountConfig | undefined;
} }
@@ -51,17 +61,23 @@ function mergeGoogleChatAccountConfig(
cfg: OpenClawConfig, cfg: OpenClawConfig,
accountId: string, accountId: string,
): GoogleChatAccountConfig { ): GoogleChatAccountConfig {
const raw = (cfg.channels?.["googlechat"] ?? {}) as GoogleChatConfig; const raw = cfg.channels?.["googlechat"] ?? {};
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {}; const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account } as GoogleChatAccountConfig; return { ...base, ...account } as GoogleChatAccountConfig;
} }
function parseServiceAccount(value: unknown): Record<string, unknown> | null { function parseServiceAccount(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object") return value as Record<string, unknown>; if (value && typeof value === "object") {
if (typeof value !== "string") return null; return value as Record<string, unknown>;
}
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return null; if (!trimmed) {
return null;
}
try { try {
return JSON.parse(trimmed) as Record<string, unknown>; return JSON.parse(trimmed) as Record<string, unknown>;
} catch { } catch {
@@ -108,8 +124,7 @@ export function resolveGoogleChatAccount(params: {
accountId?: string | null; accountId?: string | null;
}): ResolvedGoogleChatAccount { }): ResolvedGoogleChatAccount {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const baseEnabled = const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
(params.cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.enabled !== false;
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId); const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false; const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled; const enabled = baseEnabled && accountEnabled;

View File

@@ -39,7 +39,9 @@ function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cf
boolean | undefined boolean | undefined
>, >,
); );
if (gate("reactions")) return true; if (gate("reactions")) {
return true;
}
} }
return false; return false;
} }
@@ -50,11 +52,13 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
export const googlechatMessageActions: ChannelMessageActionAdapter = { export const googlechatMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as OpenClawConfig); const accounts = listEnabledAccounts(cfg);
if (accounts.length === 0) return []; if (accounts.length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>([]); const actions = new Set<ChannelMessageActionName>([]);
actions.add("send"); actions.add("send");
if (isReactionsEnabled(accounts, cfg as OpenClawConfig)) { if (isReactionsEnabled(accounts, cfg)) {
actions.add("react"); actions.add("react");
actions.add("reactions"); actions.add("reactions");
} }
@@ -62,15 +66,19 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
}, },
extractToolSend: ({ args }) => { extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : ""; const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null; if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) {
return null;
}
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 }) => {
const account = resolveGoogleChatAccount({ const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}); });
if (account.credentialSource === "none") { if (account.credentialSource === "none") {
@@ -134,12 +142,18 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
const appUsers = resolveAppUserNames(account); const appUsers = resolveAppUserNames(account);
const toRemove = reactions.filter((reaction) => { const toRemove = reactions.filter((reaction) => {
const userName = reaction.user?.name?.trim(); const userName = reaction.user?.name?.trim();
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) return false; if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
if (emoji) return reaction.emoji?.unicode === emoji; return false;
}
if (emoji) {
return reaction.emoji?.unicode === emoji;
}
return true; return true;
}); });
for (const reaction of toRemove) { for (const reaction of toRemove) {
if (!reaction.name) continue; if (!reaction.name) {
continue;
}
await deleteGoogleChatReaction({ account, reactionName: reaction.name }); await deleteGoogleChatReaction({ account, reactionName: reaction.name });
} }
return jsonResult({ ok: true, removed: toRemove.length }); return jsonResult({ ok: true, removed: toRemove.length });

View File

@@ -7,6 +7,13 @@ import type { GoogleChatReaction } from "./types.js";
const CHAT_API_BASE = "https://chat.googleapis.com/v1"; const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"; const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
headers instanceof Headers
? Object.fromEntries(headers.entries())
: Array.isArray(headers)
? Object.fromEntries(headers)
: headers || {};
async function fetchJson<T>( async function fetchJson<T>(
account: ResolvedGoogleChatAccount, account: ResolvedGoogleChatAccount,
url: string, url: string,
@@ -16,9 +23,9 @@ async function fetchJson<T>(
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
headers: { headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
...(init.headers ?? {}),
}, },
}); });
if (!res.ok) { if (!res.ok) {
@@ -37,8 +44,8 @@ async function fetchOk(
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
headers: { headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
...(init.headers ?? {}),
}, },
}); });
if (!res.ok) { if (!res.ok) {
@@ -57,8 +64,8 @@ async function fetchBuffer(
const res = await fetch(url, { const res = await fetch(url, {
...init, ...init,
headers: { headers: {
...headersToObject(init?.headers),
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
}, },
}); });
if (!res.ok) { if (!res.ok) {
@@ -83,8 +90,12 @@ async function fetchBuffer(
let total = 0; let total = 0;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) {
if (!value) continue; break;
}
if (!value) {
continue;
}
total += value.length; total += value.length;
if (total > maxBytes) { if (total > maxBytes) {
await reader.cancel(); await reader.cancel();
@@ -106,8 +117,12 @@ export async function sendGoogleChatMessage(params: {
}): Promise<{ messageName?: string } | null> { }): Promise<{ messageName?: string } | null> {
const { account, space, text, thread, attachments } = params; const { account, space, text, thread, attachments } = params;
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (text) body.text = text; if (text) {
if (thread) body.thread = { name: thread }; body.text = text;
}
if (thread) {
body.thread = { name: thread };
}
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
body.attachment = attachments.map((item) => ({ body.attachment = attachments.map((item) => ({
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken }, attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
@@ -182,7 +197,9 @@ export async function uploadGoogleChatAttachment(params: {
const payload = (await res.json()) as { const payload = (await res.json()) as {
attachmentDataRef?: { attachmentUploadToken?: string }; attachmentDataRef?: { attachmentUploadToken?: string };
}; };
return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken }; return {
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
};
} }
export async function downloadGoogleChatMedia(params: { export async function downloadGoogleChatMedia(params: {
@@ -215,7 +232,9 @@ export async function listGoogleChatReactions(params: {
}): Promise<GoogleChatReaction[]> { }): Promise<GoogleChatReaction[]> {
const { account, messageName, limit } = params; const { account, messageName, limit } = params;
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`); const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
if (limit && limit > 0) url.searchParams.set("pageSize", String(limit)); if (limit && limit > 0) {
url.searchParams.set("pageSize", String(limit));
}
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), { const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
method: "GET", method: "GET",
}); });
@@ -251,9 +270,14 @@ export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promi
try { try {
const url = new URL(`${CHAT_API_BASE}/spaces`); const url = new URL(`${CHAT_API_BASE}/spaces`);
url.searchParams.set("pageSize", "1"); url.searchParams.set("pageSize", "1");
await fetchJson<Record<string, unknown>>(account, url.toString(), { method: "GET" }); await fetchJson<Record<string, unknown>>(account, url.toString(), {
method: "GET",
});
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }; return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
} }
} }

View File

@@ -15,15 +15,21 @@ const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null; let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
function buildAuthKey(account: ResolvedGoogleChatAccount): string { function buildAuthKey(account: ResolvedGoogleChatAccount): string {
if (account.credentialsFile) return `file:${account.credentialsFile}`; if (account.credentialsFile) {
if (account.credentials) return `inline:${JSON.stringify(account.credentials)}`; return `file:${account.credentialsFile}`;
}
if (account.credentials) {
return `inline:${JSON.stringify(account.credentials)}`;
}
return "none"; return "none";
} }
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
const key = buildAuthKey(account); const key = buildAuthKey(account);
const cached = authCache.get(account.accountId); const cached = authCache.get(account.accountId);
if (cached && cached.key === key) return cached.auth; if (cached && cached.key === key) {
return cached.auth;
}
if (account.credentialsFile) { if (account.credentialsFile) {
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] }); const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
@@ -77,9 +83,13 @@ export async function verifyGoogleChatRequest(params: {
audience?: string | null; audience?: string | null;
}): Promise<{ ok: boolean; reason?: string }> { }): Promise<{ ok: boolean; reason?: string }> {
const bearer = params.bearer?.trim(); const bearer = params.bearer?.trim();
if (!bearer) return { ok: false, reason: "missing token" }; if (!bearer) {
return { ok: false, reason: "missing token" };
}
const audience = params.audience?.trim(); const audience = params.audience?.trim();
if (!audience) return { ok: false, reason: "missing audience" }; if (!audience) {
return { ok: false, reason: "missing audience" };
}
const audienceType = params.audienceType ?? null; const audienceType = params.audienceType ?? null;
if (audienceType === "app-url") { if (audienceType === "app-url") {

View File

@@ -59,10 +59,9 @@ export const googlechatDock: ChannelDock = {
outbound: { textChunkLimit: 4000 }, outbound: { textChunkLimit: 4000 },
config: { config: {
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFrom: ({ cfg, accountId }) =>
( (resolveGoogleChatAccount({ cfg: cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig, accountId }).config.dm?.allowFrom ?? String(entry),
[] ),
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) => formatAllowFrom: ({ allowFrom }) =>
allowFrom allowFrom
.map((entry) => String(entry)) .map((entry) => String(entry))
@@ -104,8 +103,10 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
idLabel: "googlechatUserId", idLabel: "googlechatUserId",
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notifyApproval: async ({ cfg, id }) => { notifyApproval: async ({ cfg, id }) => {
const account = resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig }); const account = resolveGoogleChatAccount({ cfg: cfg });
if (account.credentialSource === "none") return; if (account.credentialSource === "none") {
return;
}
const user = normalizeGoogleChatTarget(id) ?? id; const user = normalizeGoogleChatTarget(id) ?? id;
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
const space = await resolveGoogleChatOutboundSpace({ account, target }); const space = await resolveGoogleChatOutboundSpace({ account, target });
@@ -130,13 +131,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
reload: { configPrefixes: ["channels.googlechat"] }, reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: { config: {
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg as OpenClawConfig), listAccountIds: (cfg) => listGoogleChatAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }),
resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig, accountId }), defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg),
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg as OpenClawConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({ setAccountEnabledInConfigSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
sectionKey: "googlechat", sectionKey: "googlechat",
accountId, accountId,
enabled, enabled,
@@ -144,7 +144,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}), }),
deleteAccount: ({ cfg, accountId }) => deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({ deleteAccountFromConfigSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
sectionKey: "googlechat", sectionKey: "googlechat",
accountId, accountId,
clearBaseFields: [ clearBaseFields: [
@@ -169,7 +169,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
resolveAllowFrom: ({ cfg, accountId }) => resolveAllowFrom: ({ cfg, accountId }) =>
( (
resolveGoogleChatAccount({ resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}).config.dm?.allowFrom ?? [] }).config.dm?.allowFrom ?? []
).map((entry) => String(entry)), ).map((entry) => String(entry)),
@@ -182,9 +182,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
security: { security: {
resolveDmPolicy: ({ cfg, accountId, account }) => { resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean( const useAccountPath = Boolean(cfg.channels?.["googlechat"]?.accounts?.[resolvedAccountId]);
(cfg as OpenClawConfig).channels?.["googlechat"]?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath const allowFromPath = useAccountPath
? `channels.googlechat.accounts.${resolvedAccountId}.dm.` ? `channels.googlechat.accounts.${resolvedAccountId}.dm.`
: "channels.googlechat.dm."; : "channels.googlechat.dm.";
@@ -233,7 +231,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
self: async () => null, self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => { listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({ const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}); });
const q = query?.trim().toLowerCase() || ""; const q = query?.trim().toLowerCase() || "";
@@ -253,7 +251,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}, },
listGroups: async ({ cfg, accountId, query, limit }) => { listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({ const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}); });
const groups = account.config.groups ?? {}; const groups = account.config.groups ?? {};
@@ -293,7 +291,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({ applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
channelKey: "googlechat", channelKey: "googlechat",
accountId, accountId,
name, name,
@@ -309,7 +307,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}, },
applyAccountConfig: ({ cfg, accountId, input }) => { applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({ const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig, cfg: cfg,
channelKey: "googlechat", channelKey: "googlechat",
accountId, accountId,
name: input.name, name: input.name,
@@ -317,7 +315,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
const next = const next =
accountId !== DEFAULT_ACCOUNT_ID accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({ ? migrateBaseNameToDefaultAccount({
cfg: namedConfig as OpenClawConfig, cfg: namedConfig,
channelKey: "googlechat", channelKey: "googlechat",
}) })
: namedConfig; : namedConfig;
@@ -345,7 +343,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
channels: { channels: {
...next.channels, ...next.channels,
googlechat: { googlechat: {
...(next.channels?.["googlechat"] ?? {}), ...next.channels?.["googlechat"],
enabled: true, enabled: true,
...configPatch, ...configPatch,
}, },
@@ -357,12 +355,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
channels: { channels: {
...next.channels, ...next.channels,
googlechat: { googlechat: {
...(next.channels?.["googlechat"] ?? {}), ...next.channels?.["googlechat"],
enabled: true, enabled: true,
accounts: { accounts: {
...(next.channels?.["googlechat"]?.accounts ?? {}), ...next.channels?.["googlechat"]?.accounts,
[accountId]: { [accountId]: {
...(next.channels?.["googlechat"]?.accounts?.[accountId] ?? {}), ...next.channels?.["googlechat"]?.accounts?.[accountId],
enabled: true, enabled: true,
...configPatch, ...configPatch,
}, },
@@ -415,7 +413,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}, },
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveGoogleChatAccount({ const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}); });
const space = await resolveGoogleChatOutboundSpace({ account, target: to }); const space = await resolveGoogleChatOutboundSpace({ account, target: to });
@@ -437,14 +435,14 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
throw new Error("Google Chat mediaUrl is required."); throw new Error("Google Chat mediaUrl is required.");
} }
const account = resolveGoogleChatAccount({ const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig, cfg: cfg,
accountId, accountId,
}); });
const space = await resolveGoogleChatOutboundSpace({ account, target: to }); const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined; const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const runtime = getGoogleChatRuntime(); const runtime = getGoogleChatRuntime();
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
cfg: cfg as OpenClawConfig, cfg: cfg,
resolveChannelLimitMb: ({ cfg, accountId }) => resolveChannelLimitMb: ({ cfg, accountId }) =>
( (
cfg.channels?.["googlechat"] as cfg.channels?.["googlechat"] as
@@ -493,7 +491,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
const enabled = entry.enabled !== false; const enabled = entry.enabled !== false;
const configured = entry.configured === true; const configured = entry.configured === true;
if (!enabled || !configured) return []; if (!enabled || !configured) {
return [];
}
const issues = []; const issues = [];
if (!entry.audience) { if (!entry.audience) {
issues.push({ issues.push({
@@ -564,7 +564,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
}); });
const unregister = await startGoogleChatMonitor({ const unregister = await startGoogleChatMonitor({
account, account,
config: ctx.cfg as OpenClawConfig, config: ctx.cfg,
runtime: ctx.runtime, runtime: ctx.runtime,
abortSignal: ctx.abortSignal, abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath, webhookPath: account.config.webhookPath,

View File

@@ -60,7 +60,9 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
function normalizeWebhookPath(raw: string): string { function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return "/"; if (!trimmed) {
return "/";
}
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) { if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1); return withSlash.slice(0, -1);
@@ -70,7 +72,9 @@ function normalizeWebhookPath(raw: string): string {
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
const trimmedPath = webhookPath?.trim(); const trimmedPath = webhookPath?.trim();
if (trimmedPath) return normalizeWebhookPath(trimmedPath); if (trimmedPath) {
return normalizeWebhookPath(trimmedPath);
}
if (webhookUrl?.trim()) { if (webhookUrl?.trim()) {
try { try {
const parsed = new URL(webhookUrl); const parsed = new URL(webhookUrl);
@@ -88,7 +92,9 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
let resolved = false; let resolved = false;
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => { const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
if (resolved) return; if (resolved) {
return;
}
resolved = true; resolved = true;
req.removeAllListeners(); req.removeAllListeners();
resolve(value); resolve(value);
@@ -158,7 +164,9 @@ export async function handleGoogleChatWebhookRequest(
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname); const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path); const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false; if (!targets || targets.length === 0) {
return false;
}
if (req.method !== "POST") { if (req.method !== "POST") {
res.statusCode = 405; res.statusCode = 405;
@@ -279,8 +287,12 @@ export async function handleGoogleChatWebhookRequest(
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) { async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
const eventType = event.type ?? (event as { eventType?: string }).eventType; const eventType = event.type ?? (event as { eventType?: string }).eventType;
if (eventType !== "MESSAGE") return; if (eventType !== "MESSAGE") {
if (!event.message || !event.space) return; return;
}
if (!event.message || !event.space) {
return;
}
await processMessageWithPipeline({ await processMessageWithPipeline({
event, event,
@@ -295,7 +307,9 @@ async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTar
function normalizeUserId(raw?: string | null): string { function normalizeUserId(raw?: string | null): string {
const trimmed = raw?.trim() ?? ""; const trimmed = raw?.trim() ?? "";
if (!trimmed) return ""; if (!trimmed) {
return "";
}
return trimmed.replace(/^users\//i, "").toLowerCase(); return trimmed.replace(/^users\//i, "").toLowerCase();
} }
@@ -304,16 +318,28 @@ export function isSenderAllowed(
senderEmail: string | undefined, senderEmail: string | undefined,
allowFrom: string[], allowFrom: string[],
) { ) {
if (allowFrom.includes("*")) return true; if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeUserId(senderId); const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
return allowFrom.some((entry) => { return allowFrom.some((entry) => {
const normalized = String(entry).trim().toLowerCase(); const normalized = String(entry).trim().toLowerCase();
if (!normalized) return false; if (!normalized) {
if (normalized === normalizedSenderId) return true; return false;
if (normalizedEmail && normalized === normalizedEmail) return true; }
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) return true; if (normalized === normalizedSenderId) {
if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true; return true;
}
if (normalizedEmail && normalized === normalizedEmail) {
return true;
}
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) {
return true;
}
if (normalized.replace(/^users\//i, "") === normalizedSenderId) {
return true;
}
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) { if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
return true; return true;
} }
@@ -357,8 +383,12 @@ function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: strin
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]); const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
const wasMentioned = mentionAnnotations.some((entry) => { const wasMentioned = mentionAnnotations.some((entry) => {
const userName = entry.userMention?.user?.name; const userName = entry.userMention?.user?.name;
if (!userName) return false; if (!userName) {
if (botTargets.has(userName)) return true; return false;
}
if (botTargets.has(userName)) {
return true;
}
return normalizeUserId(userName) === "app"; return normalizeUserId(userName) === "app";
}); });
return { hasAnyMention, wasMentioned }; return { hasAnyMention, wasMentioned };
@@ -376,9 +406,13 @@ function resolveBotDisplayName(params: {
config: OpenClawConfig; config: OpenClawConfig;
}): string { }): string {
const { accountName, agentId, config } = params; const { accountName, agentId, config } = params;
if (accountName?.trim()) return accountName.trim(); if (accountName?.trim()) {
return accountName.trim();
}
const agent = config.agents?.list?.find((a) => a.id === agentId); const agent = config.agents?.list?.find((a) => a.id === agentId);
if (agent?.name?.trim()) return agent.name.trim(); if (agent?.name?.trim()) {
return agent.name.trim();
}
return "OpenClaw"; return "OpenClaw";
} }
@@ -394,10 +428,14 @@ async function processMessageWithPipeline(params: {
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params; const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
const space = event.space; const space = event.space;
const message = event.message; const message = event.message;
if (!space || !message) return; if (!space || !message) {
return;
}
const spaceId = space.name ?? ""; const spaceId = space.name ?? "";
if (!spaceId) return; if (!spaceId) {
return;
}
const spaceType = (space.type ?? "").toUpperCase(); const spaceType = (space.type ?? "").toUpperCase();
const isGroup = spaceType !== "DM"; const isGroup = spaceType !== "DM";
const sender = message.sender ?? event.user; const sender = message.sender ?? event.user;
@@ -421,7 +459,9 @@ async function processMessageWithPipeline(params: {
const attachments = message.attachment ?? []; const attachments = message.attachment ?? [];
const hasMedia = attachments.length > 0; const hasMedia = attachments.length > 0;
const rawBody = messageText || (hasMedia ? "<media:attachment>" : ""); const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
if (!rawBody) return; if (!rawBody) {
return;
}
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
@@ -721,7 +761,9 @@ async function downloadAttachment(
core: GoogleChatCoreRuntime, core: GoogleChatCoreRuntime,
): Promise<{ path: string; contentType?: string } | null> { ): Promise<{ path: string; contentType?: string } | null> {
const resourceName = attachment.attachmentDataRef?.resourceName; const resourceName = attachment.attachmentDataRef?.resourceName;
if (!resourceName) return null; if (!resourceName) {
return null;
}
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes }); const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
const saved = await core.channel.media.saveMediaBuffer( const saved = await core.channel.media.saveMediaBuffer(

View File

@@ -32,9 +32,9 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
channels: { channels: {
...cfg.channels, ...cfg.channels,
googlechat: { googlechat: {
...(cfg.channels?.["googlechat"] ?? {}), ...cfg.channels?.["googlechat"],
dm: { dm: {
...(cfg.channels?.["googlechat"]?.dm ?? {}), ...cfg.channels?.["googlechat"]?.dm,
policy, policy,
...(allowFrom ? { allowFrom } : {}), ...(allowFrom ? { allowFrom } : {}),
}, },
@@ -68,10 +68,10 @@ async function promptAllowFrom(params: {
channels: { channels: {
...params.cfg.channels, ...params.cfg.channels,
googlechat: { googlechat: {
...(params.cfg.channels?.["googlechat"] ?? {}), ...params.cfg.channels?.["googlechat"],
enabled: true, enabled: true,
dm: { dm: {
...(params.cfg.channels?.["googlechat"]?.dm ?? {}), ...params.cfg.channels?.["googlechat"]?.dm,
policy: "allowlist", policy: "allowlist",
allowFrom: unique, allowFrom: unique,
}, },
@@ -102,7 +102,7 @@ function applyAccountConfig(params: {
channels: { channels: {
...cfg.channels, ...cfg.channels,
googlechat: { googlechat: {
...(cfg.channels?.["googlechat"] ?? {}), ...cfg.channels?.["googlechat"],
enabled: true, enabled: true,
...patch, ...patch,
}, },
@@ -114,12 +114,12 @@ function applyAccountConfig(params: {
channels: { channels: {
...cfg.channels, ...cfg.channels,
googlechat: { googlechat: {
...(cfg.channels?.["googlechat"] ?? {}), ...cfg.channels?.["googlechat"],
enabled: true, enabled: true,
accounts: { accounts: {
...(cfg.channels?.["googlechat"]?.accounts ?? {}), ...cfg.channels?.["googlechat"]?.accounts,
[accountId]: { [accountId]: {
...(cfg.channels?.["googlechat"]?.accounts?.[accountId] ?? {}), ...cfg.channels?.["googlechat"]?.accounts?.[accountId],
enabled: true, enabled: true,
...patch, ...patch,
}, },
@@ -193,14 +193,14 @@ async function promptAudience(params: {
}); });
const currentType = account.config.audienceType ?? "app-url"; const currentType = account.config.audienceType ?? "app-url";
const currentAudience = account.config.audience ?? ""; const currentAudience = account.config.audience ?? "";
const audienceType = (await params.prompter.select({ const audienceType = await params.prompter.select({
message: "Webhook audience type", message: "Webhook audience type",
options: [ options: [
{ value: "app-url", label: "App URL (recommended)" }, { value: "app-url", label: "App URL (recommended)" },
{ value: "project-number", label: "Project number" }, { value: "project-number", label: "Project number" },
], ],
initialValue: currentType === "project-number" ? "project-number" : "app-url", initialValue: currentType === "project-number" ? "project-number" : "app-url",
})) as "app-url" | "project-number"; });
const audience = await params.prompter.text({ const audience = await params.prompter.text({
message: audienceType === "project-number" ? "Project number" : "App URL", message: audienceType === "project-number" ? "Project number" : "App URL",
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat", placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",

View File

@@ -3,7 +3,9 @@ import { findGoogleChatDirectMessage } from "./api.js";
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined { export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, ""); const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
const normalized = withoutPrefix const normalized = withoutPrefix
.replace(/^user:(users\/)?/i, "users/") .replace(/^user:(users\/)?/i, "users/")
@@ -12,8 +14,12 @@ export function normalizeGoogleChatTarget(raw?: string | null): string | undefin
const suffix = normalized.slice("users/".length); const suffix = normalized.slice("users/".length);
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized; return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
} }
if (isGoogleChatSpaceTarget(normalized)) return normalized; if (isGoogleChatSpaceTarget(normalized)) {
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`; return normalized;
}
if (normalized.includes("@")) {
return `users/${normalized.toLowerCase()}`;
}
return normalized; return normalized;
} }
@@ -27,7 +33,9 @@ export function isGoogleChatSpaceTarget(value: string): boolean {
function stripMessageSuffix(target: string): string { function stripMessageSuffix(target: string): string {
const index = target.indexOf("/messages/"); const index = target.indexOf("/messages/");
if (index === -1) return target; if (index === -1) {
return target;
}
return target.slice(0, index); return target.slice(0, index);
} }
@@ -40,7 +48,9 @@ export async function resolveGoogleChatOutboundSpace(params: {
throw new Error("Missing Google Chat target."); throw new Error("Missing Google Chat target.");
} }
const base = stripMessageSuffix(normalized); const base = stripMessageSuffix(normalized);
if (isGoogleChatSpaceTarget(base)) return base; if (isGoogleChatSpaceTarget(base)) {
return base;
}
if (isGoogleChatUserTarget(base)) { if (isGoogleChatUserTarget(base)) {
const dm = await findGoogleChatDirectMessage({ const dm = await findGoogleChatDirectMessage({
account: params.account, account: params.account,

View File

@@ -98,7 +98,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
collectWarnings: ({ account, cfg }) => { collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`, `- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,
]; ];
@@ -227,7 +229,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
collectStatusIssues: (accounts) => collectStatusIssues: (accounts) =>
accounts.flatMap((account) => { accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return []; if (!lastError) {
return [];
}
return [ return [
{ {
channel: "imessage", channel: "imessage",

View File

@@ -38,7 +38,9 @@ function buildLineReply(lineData: LineChannelData): ReplyPayload {
* Data can be a URL (uri action) or plain text (message action) or key=value (postback) * Data can be a URL (uri action) or plain text (message action) or key=value (postback)
*/ */
function parseActions(actionsStr: string | undefined): CardAction[] { function parseActions(actionsStr: string | undefined): CardAction[] {
if (!actionsStr) return []; if (!actionsStr) {
return [];
}
const results: CardAction[] = []; const results: CardAction[] = [];
@@ -47,7 +49,9 @@ function parseActions(actionsStr: string | undefined): CardAction[] {
.trim() .trim()
.split("|") .split("|")
.map((s) => s.trim()); .map((s) => s.trim());
if (!label) continue; if (!label) {
continue;
}
const actionData = data || label; const actionData = data || label;
@@ -158,12 +162,16 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
requireAuth: false, requireAuth: false,
handler: async (ctx) => { handler: async (ctx) => {
const argsStr = ctx.args?.trim() ?? ""; const argsStr = ctx.args?.trim() ?? "";
if (!argsStr) return { text: CARD_USAGE }; if (!argsStr) {
return { text: CARD_USAGE };
}
const parsed = parseCardArgs(argsStr); const parsed = parseCardArgs(argsStr);
const { type, args, flags } = parsed; const { type, args, flags } = parsed;
if (!type) return { text: CARD_USAGE }; if (!type) {
return { text: CARD_USAGE };
}
// Only LINE supports rich cards; fallback to text elsewhere. // Only LINE supports rich cards; fallback to text elsewhere.
if (ctx.channel !== "line") { if (ctx.channel !== "line") {

View File

@@ -25,17 +25,6 @@ const meta = {
systemImage: "message.fill", systemImage: "message.fill",
}; };
function parseThreadId(threadId?: string | number | null): number | undefined {
if (threadId == null) return undefined;
if (typeof threadId === "number") {
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
}
const trimmed = threadId.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = { export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line", id: "line",
meta: { meta: {
@@ -108,7 +97,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
deleteAccount: ({ cfg, accountId }) => { deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) { if (accountId === DEFAULT_ACCOUNT_ID) {
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig; // oxlint-disable-next-line no-unused-vars
const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
return { return {
...cfg, ...cfg,
channels: { channels: {
@@ -173,7 +163,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined)
?.groupPolicy; ?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`, `- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
]; ];
@@ -183,7 +175,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
resolveRequireMention: ({ cfg, accountId, groupId }) => { resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }); const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups; const groups = account.config.groups;
if (!groups) return false; if (!groups) {
return false;
}
const groupConfig = groups[groupId] ?? groups["*"]; const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false; return groupConfig?.requireMention ?? false;
}, },
@@ -191,13 +185,17 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
messaging: { messaging: {
normalizeTarget: (target) => { normalizeTarget: (target) => {
const trimmed = target.trim(); const trimmed = target.trim();
if (!trimmed) return null; if (!trimmed) {
return null;
}
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, ""); return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
}, },
targetResolver: { targetResolver: {
looksLikeId: (id) => { looksLikeId: (id) => {
const trimmed = id?.trim(); const trimmed = id?.trim();
if (!trimmed) return false; if (!trimmed) {
return false;
}
// LINE user IDs are typically U followed by 32 hex characters // LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters // Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters // Room IDs are R followed by 32 hex characters
@@ -356,7 +354,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
: undefined; : undefined;
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => { const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
if (messages.length === 0) return; if (messages.length === 0) {
return;
}
for (let i = 0; i < messages.length; i += 5) { for (let i = 0; i < messages.length; i += 5) {
const result = await sendBatch(to, messages.slice(i, i + 5), { const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false, verbose: false,
@@ -434,12 +434,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
for (let i = 0; i < chunks.length; i += 1) { for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1; const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) { if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, { lastResult = await sendQuickReplies(to, chunks[i], lineData.quickReplies!, {
verbose: false, verbose: false,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}); });
} else { } else {
lastResult = await sendText(to, chunks[i]!, { lastResult = await sendText(to, chunks[i], {
verbose: false, verbose: false,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}); });
@@ -478,7 +478,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
} }
for (const url of mediaUrls) { for (const url of mediaUrls) {
const trimmed = url?.trim(); const trimmed = url?.trim();
if (!trimmed) continue; if (!trimmed) {
continue;
}
quickReplyMessages.push({ quickReplyMessages.push({
type: "image", type: "image",
originalContentUrl: trimmed, originalContentUrl: trimmed,
@@ -505,7 +507,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
} }
} }
if (lastResult) return { channel: "line", ...lastResult }; if (lastResult) {
return { channel: "line", ...lastResult };
}
return { channel: "line", messageId: "empty", chatId: to }; return { channel: "line", messageId: "empty", chatId: to };
}, },
sendText: async ({ to, text, accountId }) => { sendText: async ({ to, text, accountId }) => {
@@ -621,7 +625,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
try { try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500); const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null; const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`; if (displayName) {
lineBotLabel = ` (${displayName})`;
}
} catch (err) { } catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) { if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);

View File

@@ -36,7 +36,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }], payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
const res = await tool.execute("id", { prompt: "return foo" }); const res = await tool.execute("id", { prompt: "return foo" });
expect((res as any).details.json).toEqual({ foo: "bar" }); expect((res as any).details.json).toEqual({ foo: "bar" });
}); });
@@ -46,7 +46,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: '```json\n{"ok":true}\n```' }], payloads: [{ text: '```json\n{"ok":true}\n```' }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
const res = await tool.execute("id", { prompt: "return ok" }); const res = await tool.execute("id", { prompt: "return ok" });
expect((res as any).details.json).toEqual({ ok: true }); expect((res as any).details.json).toEqual({ ok: true });
}); });
@@ -56,7 +56,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: JSON.stringify({ foo: "bar" }) }], payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
const schema = { const schema = {
type: "object", type: "object",
properties: { foo: { type: "string" } }, properties: { foo: { type: "string" } },
@@ -72,7 +72,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: "not-json" }], payloads: [{ text: "not-json" }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i); await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
}); });
@@ -81,7 +81,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: JSON.stringify({ foo: 1 }) }], payloads: [{ text: JSON.stringify({ foo: 1 }) }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }; const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i); await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
}); });
@@ -91,7 +91,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }], payloads: [{ text: JSON.stringify({ ok: true }) }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }); await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.provider).toBe("anthropic"); expect(call.provider).toBe("anthropic");
@@ -104,7 +104,7 @@ describe("llm-task tool (json-only)", () => {
payloads: [{ text: JSON.stringify({ ok: true }) }], payloads: [{ text: JSON.stringify({ ok: true }) }],
}); });
const tool = createLlmTaskTool( const tool = createLlmTaskTool(
fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any, fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }),
); );
await expect( await expect(
tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }), tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }),
@@ -116,7 +116,7 @@ describe("llm-task tool (json-only)", () => {
meta: {}, meta: {},
payloads: [{ text: JSON.stringify({ ok: true }) }], payloads: [{ text: JSON.stringify({ ok: true }) }],
}); });
const tool = createLlmTaskTool(fakeApi() as any); const tool = createLlmTaskTool(fakeApi());
await tool.execute("id", { prompt: "x" }); await tool.execute("id", { prompt: "x" });
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
expect(call.disableTools).toBe(true); expect(call.disableTools).toBe(true);

View File

@@ -18,24 +18,27 @@ async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
// Source checkout (tests/dev) // Source checkout (tests/dev)
try { try {
const mod = await import("../../../src/agents/pi-embedded-runner.js"); const mod = await import("../../../src/agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent === "function") if (typeof (mod as any).runEmbeddedPiAgent === "function") {
return (mod as any).runEmbeddedPiAgent; return (mod as any).runEmbeddedPiAgent;
}
} catch { } catch {
// ignore // ignore
} }
// Bundled install (built) // Bundled install (built)
const mod = await import("../../../agents/pi-embedded-runner.js"); const mod = await import("../../../agents/pi-embedded-runner.js");
if (typeof (mod as any).runEmbeddedPiAgent !== "function") { if (typeof mod.runEmbeddedPiAgent !== "function") {
throw new Error("Internal error: runEmbeddedPiAgent not available"); throw new Error("Internal error: runEmbeddedPiAgent not available");
} }
return (mod as any).runEmbeddedPiAgent; return mod.runEmbeddedPiAgent;
} }
function stripCodeFences(s: string): string { function stripCodeFences(s: string): string {
const trimmed = s.trim(); const trimmed = s.trim();
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (m) return (m[1] ?? "").trim(); if (m) {
return (m[1] ?? "").trim();
}
return trimmed; return trimmed;
} }
@@ -49,7 +52,9 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und
function toModelKey(provider?: string, model?: string): string | undefined { function toModelKey(provider?: string, model?: string): string | undefined {
const p = provider?.trim(); const p = provider?.trim();
const m = model?.trim(); const m = model?.trim();
if (!p || !m) return undefined; if (!p || !m) {
return undefined;
}
return `${p}/${m}`; return `${p}/${m}`;
} }
@@ -84,8 +89,10 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
}), }),
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const prompt = String(params.prompt ?? ""); const prompt = typeof params.prompt === "string" ? params.prompt : "";
if (!prompt.trim()) throw new Error("prompt required"); if (!prompt.trim()) {
throw new Error("prompt required");
}
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
@@ -189,7 +196,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
}); });
const text = collectText((result as any).payloads); const text = collectText((result as any).payloads);
if (!text) throw new Error("LLM returned empty output"); if (!text) {
throw new Error("LLM returned empty output");
}
const raw = stripCodeFences(text); const raw = stripCodeFences(text);
let parsed: unknown; let parsed: unknown;

View File

@@ -5,7 +5,9 @@ import { createLobsterTool } from "./src/lobster-tool.js";
export default function register(api: OpenClawPluginApi) { export default function register(api: OpenClawPluginApi) {
api.registerTool( api.registerTool(
(ctx) => { (ctx) => {
if (ctx.sandboxed) return null; if (ctx.sandboxed) {
return null;
}
return createLobsterTool(api); return createLobsterTool(api);
}, },
{ optional: true }, { optional: true },

View File

@@ -133,7 +133,9 @@ describe("lobster plugin tool", () => {
it("can be gated off in sandboxed contexts", async () => { it("can be gated off in sandboxed contexts", async () => {
const api = fakeApi(); const api = fakeApi();
const factoryTool = (ctx: OpenClawPluginToolContext) => { const factoryTool = (ctx: OpenClawPluginToolContext) => {
if (ctx.sandboxed) return null; if (ctx.sandboxed) {
return null;
}
return createLobsterTool(api); return createLobsterTool(api);
}; };

View File

@@ -30,7 +30,9 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
} }
function isWindowsSpawnEINVAL(err: unknown) { function isWindowsSpawnEINVAL(err: unknown) {
if (!err || typeof err !== "object") return false; if (!err || typeof err !== "object") {
return false;
}
const code = (err as { code?: unknown }).code; const code = (err as { code?: unknown }).code;
return code === "EINVAL"; return code === "EINVAL";
} }
@@ -186,8 +188,10 @@ export function createLobsterTool(api: OpenClawPluginApi) {
maxStdoutBytes: Type.Optional(Type.Number()), maxStdoutBytes: Type.Optional(Type.Number()),
}), }),
async execute(_id: string, params: Record<string, unknown>) { async execute(_id: string, params: Record<string, unknown>) {
const action = String(params.action || "").trim(); const action = typeof params.action === "string" ? params.action.trim() : "";
if (!action) throw new Error("action required"); if (!action) {
throw new Error("action required");
}
const execPath = resolveExecutablePath( const execPath = resolveExecutablePath(
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined, typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
@@ -201,7 +205,9 @@ export function createLobsterTool(api: OpenClawPluginApi) {
const argv = (() => { const argv = (() => {
if (action === "run") { if (action === "run") {
const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
if (!pipeline.trim()) throw new Error("pipeline required"); if (!pipeline.trim()) {
throw new Error("pipeline required");
}
const argv = ["run", "--mode", "tool", pipeline]; const argv = ["run", "--mode", "tool", pipeline];
const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
if (argsJson.trim()) { if (argsJson.trim()) {
@@ -211,9 +217,13 @@ export function createLobsterTool(api: OpenClawPluginApi) {
} }
if (action === "resume") { if (action === "resume") {
const token = typeof params.token === "string" ? params.token : ""; const token = typeof params.token === "string" ? params.token : "";
if (!token.trim()) throw new Error("token required"); if (!token.trim()) {
throw new Error("token required");
}
const approve = params.approve; const approve = params.approve;
if (typeof approve !== "boolean") throw new Error("approve required"); if (typeof approve !== "boolean") {
throw new Error("approve required");
}
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
} }
throw new Error(`Unknown action: ${action}`); throw new Error(`Unknown action: ${action}`);

View File

@@ -14,7 +14,9 @@ import type { CoreConfig } from "./types.js";
export const matrixMessageActions: ChannelMessageActionAdapter = { export const matrixMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => { listActions: ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
if (!account.enabled || !account.configured) return []; if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions); const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
const actions = new Set<ChannelMessageActionName>(["send", "poll"]); const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
if (gate("reactions")) { if (gate("reactions")) {
@@ -31,16 +33,24 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
actions.add("unpin"); actions.add("unpin");
actions.add("list-pins"); actions.add("list-pins");
} }
if (gate("memberInfo")) actions.add("member-info"); if (gate("memberInfo")) {
if (gate("channelInfo")) actions.add("channel-info"); actions.add("member-info");
}
if (gate("channelInfo")) {
actions.add("channel-info");
}
return Array.from(actions); return Array.from(actions);
}, },
supportsAction: ({ action }) => action !== "poll", supportsAction: ({ action }) => action !== "poll",
extractToolSend: ({ args }): ChannelToolSend | null => { extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : ""; const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null; if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) {
return null;
}
return { to }; return { to };
}, },
handleAction: async (ctx: ChannelMessageActionContext) => { handleAction: async (ctx: ChannelMessageActionContext) => {

View File

@@ -45,7 +45,9 @@ const meta = {
function normalizeMatrixMessagingTarget(raw: string): string | undefined { function normalizeMatrixMessagingTarget(raw: string): string | undefined {
let normalized = raw.trim(); let normalized = raw.trim();
if (!normalized) return undefined; if (!normalized) {
return undefined;
}
const lowered = normalized.toLowerCase(); const lowered = normalized.toLowerCase();
if (lowered.startsWith("matrix:")) { if (lowered.startsWith("matrix:")) {
normalized = normalized.slice("matrix:".length).trim(); normalized = normalized.slice("matrix:".length).trim();
@@ -161,7 +163,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
collectWarnings: ({ account, cfg }) => { collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.', '- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
]; ];
@@ -188,8 +192,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
targetResolver: { targetResolver: {
looksLikeId: (raw) => { looksLikeId: (raw) => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) {
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true; return false;
}
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
return true;
}
return trimmed.includes(":"); return trimmed.includes(":");
}, },
hint: "<room|alias|user>", hint: "<room|alias|user>",
@@ -204,13 +212,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
for (const entry of account.config.dm?.allowFrom ?? []) { for (const entry of account.config.dm?.allowFrom ?? []) {
const raw = String(entry).trim(); const raw = String(entry).trim();
if (!raw || raw === "*") continue; if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, "")); ids.add(raw.replace(/^matrix:/i, ""));
} }
for (const entry of account.config.groupAllowFrom ?? []) { for (const entry of account.config.groupAllowFrom ?? []) {
const raw = String(entry).trim(); const raw = String(entry).trim();
if (!raw || raw === "*") continue; if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, "")); ids.add(raw.replace(/^matrix:/i, ""));
} }
@@ -218,7 +230,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
for (const room of Object.values(groups)) { for (const room of Object.values(groups)) {
for (const entry of room.users ?? []) { for (const entry of room.users ?? []) {
const raw = String(entry).trim(); const raw = String(entry).trim();
if (!raw || raw === "*") continue; if (!raw || raw === "*") {
continue;
}
ids.add(raw.replace(/^matrix:/i, "")); ids.add(raw.replace(/^matrix:/i, ""));
} }
} }
@@ -229,7 +243,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
.map((raw) => { .map((raw) => {
const lowered = raw.toLowerCase(); const lowered = raw.toLowerCase();
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
if (cleaned.startsWith("@")) return `user:${cleaned}`; if (cleaned.startsWith("@")) {
return `user:${cleaned}`;
}
return cleaned; return cleaned;
}) })
.filter((id) => (q ? id.toLowerCase().includes(q) : true)) .filter((id) => (q ? id.toLowerCase().includes(q) : true))
@@ -254,8 +270,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
.map((raw) => raw.replace(/^matrix:/i, "")) .map((raw) => raw.replace(/^matrix:/i, ""))
.map((raw) => { .map((raw) => {
const lowered = raw.toLowerCase(); const lowered = raw.toLowerCase();
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw; if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
if (raw.startsWith("!")) return `room:${raw}`; return raw;
}
if (raw.startsWith("!")) {
return `room:${raw}`;
}
return raw; return raw;
}) })
.filter((id) => (q ? id.toLowerCase().includes(q) : true)) .filter((id) => (q ? id.toLowerCase().includes(q) : true))
@@ -283,8 +303,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
name, name,
}), }),
validateInput: ({ input }) => { validateInput: ({ input }) => {
if (input.useEnv) return null; if (input.useEnv) {
if (!input.homeserver?.trim()) return "Matrix requires --homeserver"; return null;
}
if (!input.homeserver?.trim()) {
return "Matrix requires --homeserver";
}
const accessToken = input.accessToken?.trim(); const accessToken = input.accessToken?.trim();
const password = input.password?.trim(); const password = input.password?.trim();
const userId = input.userId?.trim(); const userId = input.userId?.trim();
@@ -292,8 +316,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
return "Matrix requires --access-token or --password"; return "Matrix requires --access-token or --password";
} }
if (!accessToken) { if (!accessToken) {
if (!userId) return "Matrix requires --user-id when using --password"; if (!userId) {
if (!password) return "Matrix requires --password when using --user-id"; return "Matrix requires --user-id when using --password";
}
if (!password) {
return "Matrix requires --password when using --user-id";
}
} }
return null; return null;
}, },
@@ -338,7 +366,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
collectStatusIssues: (accounts) => collectStatusIssues: (accounts) =>
accounts.flatMap((account) => { accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return []; if (!lastError) {
return [];
}
return [ return [
{ {
channel: "matrix", channel: "matrix",
@@ -358,7 +388,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
probe: snapshot.probe, probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null, lastProbeAt: snapshot.lastProbeAt ?? null,
}), }),
probeAccount: async ({ account, timeoutMs, cfg }) => { probeAccount: async ({ timeoutMs, cfg }) => {
try { try {
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig }); const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
return await probeMatrix({ return await probeMatrix({

View File

@@ -55,7 +55,9 @@ export async function listMatrixDirectoryPeersLive(params: {
limit?: number | null; limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> { }): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query); const query = normalizeQuery(params.query);
if (!query) return []; if (!query) {
return [];
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({ const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
homeserver: auth.homeserver, homeserver: auth.homeserver,
@@ -71,7 +73,9 @@ export async function listMatrixDirectoryPeersLive(params: {
return results return results
.map((entry) => { .map((entry) => {
const userId = entry.user_id?.trim(); const userId = entry.user_id?.trim();
if (!userId) return null; if (!userId) {
return null;
}
return { return {
kind: "user", kind: "user",
id: userId, id: userId,
@@ -123,13 +127,17 @@ export async function listMatrixDirectoryGroupsLive(params: {
limit?: number | null; limit?: number | null;
}): Promise<ChannelDirectoryEntry[]> { }): Promise<ChannelDirectoryEntry[]> {
const query = normalizeQuery(params.query); const query = normalizeQuery(params.query);
if (!query) return []; if (!query) {
return [];
}
const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
if (query.startsWith("#")) { if (query.startsWith("#")) {
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query); const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
if (!roomId) return []; if (!roomId) {
return [];
}
return [ return [
{ {
kind: "group", kind: "group",
@@ -160,15 +168,21 @@ export async function listMatrixDirectoryGroupsLive(params: {
for (const roomId of rooms) { for (const roomId of rooms) {
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId); const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
if (!name) continue; if (!name) {
if (!name.toLowerCase().includes(query)) continue; continue;
}
if (!name.toLowerCase().includes(query)) {
continue;
}
results.push({ results.push({
kind: "group", kind: "group",
id: roomId, id: roomId,
name, name,
handle: `#${name}`, handle: `#${name}`,
}); });
if (results.length >= limit) break; if (results.length >= limit) {
break;
}
} }
return results; return results;

View File

@@ -26,9 +26,15 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
name: groupChannel || undefined, name: groupChannel || undefined,
}).config; }).config;
if (resolved) { if (resolved) {
if (resolved.autoReply === true) return false; if (resolved.autoReply === true) {
if (resolved.autoReply === false) return true; return false;
if (typeof resolved.requireMention === "boolean") return resolved.requireMention; }
if (resolved.autoReply === false) {
return true;
}
if (typeof resolved.requireMention === "boolean") {
return resolved.requireMention;
}
} }
return true; return true;
} }

View File

@@ -19,7 +19,9 @@ export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
const ids = listMatrixAccountIds(cfg); const ids = listMatrixAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID;
} }
@@ -28,7 +30,7 @@ export function resolveMatrixAccount(params: {
accountId?: string | null; accountId?: string | null;
}): ResolvedMatrixAccount { }): ResolvedMatrixAccount {
const accountId = normalizeAccountId(params.accountId); const accountId = normalizeAccountId(params.accountId);
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig; const base = params.cfg.channels?.matrix ?? {};
const enabled = base.enabled !== false; const enabled = base.enabled !== false;
const resolved = resolveMatrixConfig(params.cfg, process.env); const resolved = resolveMatrixConfig(params.cfg, process.env);
const hasHomeserver = Boolean(resolved.homeserver); const hasHomeserver = Boolean(resolved.homeserver);

View File

@@ -19,9 +19,13 @@ export async function resolveActionClient(
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> { ): Promise<MatrixActionClient> {
ensureNodeRuntime(); ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false }; if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(); const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false }; if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) { if (shouldShareClient) {
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({

View File

@@ -36,7 +36,9 @@ export async function editMatrixMessage(
opts: MatrixActionClientOpts = {}, opts: MatrixActionClientOpts = {},
) { ) {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed) throw new Error("Matrix edit requires content"); if (!trimmed) {
throw new Error("Matrix edit requires content");
}
const { client, stopOnDone } = await resolveActionClient(opts); const { client, stopOnDone } = await resolveActionClient(opts);
try { try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
@@ -56,7 +58,9 @@ export async function editMatrixMessage(
const eventId = await client.sendMessage(resolvedRoom, payload); const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null }; return { eventId: eventId ?? null };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -70,7 +74,9 @@ export async function deleteMatrixMessage(
const resolvedRoom = await resolveMatrixRoomId(client, roomId); const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason); await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -115,6 +121,8 @@ export async function readMatrixMessages(
prevBatch: res.start ?? null, prevBatch: res.start ?? null,
}; };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }

View File

@@ -22,7 +22,9 @@ export async function pinMatrixMessage(
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next }; return { pinned: next };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -40,7 +42,9 @@ export async function unpinMatrixMessage(
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next }; return { pinned: next };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -65,6 +69,8 @@ export async function listMatrixPins(
).filter((event): event is MatrixMessageSummary => Boolean(event)); ).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events }; return { pinned, events };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }

View File

@@ -31,7 +31,9 @@ export async function listMatrixReactions(
for (const event of res.chunk) { for (const event of res.chunk) {
const content = event.content as ReactionEventContent; const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key; const key = content["m.relates_to"]?.key;
if (!key) continue; if (!key) {
continue;
}
const sender = event.sender ?? ""; const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? { const entry: MatrixReactionSummary = summaries.get(key) ?? {
key, key,
@@ -46,7 +48,9 @@ export async function listMatrixReactions(
} }
return Array.from(summaries.values()); return Array.from(summaries.values());
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -64,21 +68,29 @@ export async function removeMatrixReactions(
{ dir: "b", limit: 200 }, { dir: "b", limit: 200 },
)) as { chunk: MatrixRawEvent[] }; )) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId(); const userId = await client.getUserId();
if (!userId) return { removed: 0 }; if (!userId) {
return { removed: 0 };
}
const targetEmoji = opts.emoji?.trim(); const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk const toRemove = res.chunk
.filter((event) => event.sender === userId) .filter((event) => event.sender === userId)
.filter((event) => { .filter((event) => {
if (!targetEmoji) return true; if (!targetEmoji) {
return true;
}
const content = event.content as ReactionEventContent; const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji; return content["m.relates_to"]?.key === targetEmoji;
}) })
.map((event) => event.event_id) .map((event) => event.event_id)
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
if (toRemove.length === 0) return { removed: 0 }; if (toRemove.length === 0) {
return { removed: 0 };
}
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
return { removed: toRemove.length }; return { removed: toRemove.length };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }

View File

@@ -25,7 +25,9 @@ export async function getMatrixMemberInfo(
roomId: roomId ?? null, roomId: roomId ?? null,
}; };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }
@@ -76,6 +78,8 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
memberCount, memberCount,
}; };
} finally { } finally {
if (stopOnDone) client.stop(); if (stopOnDone) {
client.stop();
}
} }
} }

View File

@@ -65,7 +65,9 @@ export async function fetchEventSummary(
): Promise<MatrixMessageSummary | null> { ): Promise<MatrixMessageSummary | null> {
try { try {
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent; const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
if (raw.unsigned?.redacted_because) return null; if (raw.unsigned?.redacted_because) {
return null;
}
return summarizeMatrixRawEvent(raw); return summarizeMatrixRawEvent(raw);
} catch { } catch {
// Event not found, redacted, or inaccessible - return null // Event not found, redacted, or inaccessible - return null

View File

@@ -16,7 +16,9 @@ import {
} from "./storage.js"; } from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] { function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) return []; if (input == null) {
return [];
}
if (!Array.isArray(input)) { if (!Array.isArray(input)) {
LogService.warn( LogService.warn(
"MatrixClientLite", "MatrixClientLite",

View File

@@ -4,15 +4,21 @@ let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger(); const matrixSdkBaseLogger = new ConsoleLogger();
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
if (module !== "MatrixHttpClient") return false; if (module !== "MatrixHttpClient") {
return false;
}
return messageOrObject.some((entry) => { return messageOrObject.some((entry) => {
if (!entry || typeof entry !== "object") return false; if (!entry || typeof entry !== "object") {
return false;
}
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
}); });
} }
export function ensureMatrixSdkLoggingConfigured(): void { export function ensureMatrixSdkLoggingConfigured(): void {
if (matrixSdkLoggingConfigured) return; if (matrixSdkLoggingConfigured) {
return;
}
matrixSdkLoggingConfigured = true; matrixSdkLoggingConfigured = true;
LogService.setLogger({ LogService.setLogger({
@@ -21,7 +27,9 @@ export function ensureMatrixSdkLoggingConfigured(): void {
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
error: (module, ...messageOrObject) => { error: (module, ...messageOrObject) => {
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return; if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
return;
}
matrixSdkBaseLogger.error(module, ...messageOrObject); matrixSdkBaseLogger.error(module, ...messageOrObject);
}, },
}); });

View File

@@ -55,7 +55,9 @@ async function ensureSharedClientStarted(params: {
initialSyncLimit?: number; initialSyncLimit?: number;
encryption?: boolean; encryption?: boolean;
}): Promise<void> { }): Promise<void> {
if (params.state.started) return; if (params.state.started) {
return;
}
if (sharedClientStartPromise) { if (sharedClientStartPromise) {
await sharedClientStartPromise; await sharedClientStartPromise;
return; return;

View File

@@ -21,7 +21,9 @@ function sanitizePathSegment(value: string): string {
function resolveHomeserverKey(homeserver: string): string { function resolveHomeserverKey(homeserver: string): string {
try { try {
const url = new URL(homeserver); const url = new URL(homeserver);
if (url.host) return sanitizePathSegment(url.host); if (url.host) {
return sanitizePathSegment(url.host);
}
} catch { } catch {
// fall through // fall through
} }
@@ -84,8 +86,12 @@ export function maybeMigrateLegacyStorage(params: {
const hasNewStorage = const hasNewStorage =
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath); fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
if (!hasLegacyStorage && !hasLegacyCrypto) return; if (!hasLegacyStorage && !hasLegacyCrypto) {
if (hasNewStorage) return; return;
}
if (hasNewStorage) {
return;
}
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
if (hasLegacyStorage) { if (hasLegacyStorage) {

View File

@@ -33,7 +33,9 @@ export function loadMatrixCredentials(
): MatrixStoredCredentials | null { ): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env); const credPath = resolveMatrixCredentialsPath(env);
try { try {
if (!fs.existsSync(credPath)) return null; if (!fs.existsSync(credPath)) {
return null;
}
const raw = fs.readFileSync(credPath, "utf-8"); const raw = fs.readFileSync(credPath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>; const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if ( if (
@@ -72,7 +74,9 @@ export function saveMatrixCredentials(
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
const existing = loadMatrixCredentials(env); const existing = loadMatrixCredentials(env);
if (!existing) return; if (!existing) {
return;
}
existing.lastUsedAt = new Date().toISOString(); existing.lastUsedAt = new Date().toISOString();
const credPath = resolveMatrixCredentialsPath(env); const credPath = resolveMatrixCredentialsPath(env);

View File

@@ -27,7 +27,9 @@ export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>; confirm?: (message: string) => Promise<boolean>;
}): Promise<void> { }): Promise<void> {
if (isMatrixSdkAvailable()) return; if (isMatrixSdkAvailable()) {
return;
}
const confirm = params.confirm; const confirm = params.confirm;
if (confirm) { if (confirm) {
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?"); const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");

View File

@@ -22,7 +22,9 @@ export function resolveMatrixAllowListMatch(params: {
userName?: string; userName?: string;
}): MatrixAllowListMatch { }): MatrixAllowListMatch {
const allowList = params.allowList; const allowList = params.allowList;
if (allowList.length === 0) return { allowed: false }; if (allowList.length === 0) {
return { allowed: false };
}
if (allowList.includes("*")) { if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" }; return { allowed: true, matchKey: "*", matchSource: "wildcard" };
} }
@@ -37,7 +39,9 @@ export function resolveMatrixAllowListMatch(params: {
{ value: localPart, source: "localpart" }, { value: localPart, source: "localpart" },
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
if (!candidate.value) continue; if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) { if (allowList.includes(candidate.value)) {
return { return {
allowed: true, allowed: true,

View File

@@ -13,7 +13,9 @@ export function registerMatrixAutoJoin(params: {
const { client, cfg, runtime } = params; const { client, cfg, runtime } = params;
const core = getMatrixRuntime(); const core = getMatrixRuntime();
const logVerbose = (message: string) => { const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) return; if (!core.logging.shouldLogVerbose()) {
return;
}
runtime.log?.(message); runtime.log?.(message);
}; };
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
@@ -32,7 +34,9 @@ export function registerMatrixAutoJoin(params: {
// For "allowlist" mode, handle invites manually // For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return; if (autoJoin !== "allowlist") {
return;
}
// Get room alias if available // Get room alias if available
let alias: string | undefined; let alias: string | undefined;

View File

@@ -19,7 +19,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const memberCountCache = new Map<string, { count: number; ts: number }>(); const memberCountCache = new Map<string, { count: number; ts: number }>();
const ensureSelfUserId = async (): Promise<string | null> => { const ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) return cachedSelfUserId; if (cachedSelfUserId) {
return cachedSelfUserId;
}
try { try {
cachedSelfUserId = await client.getUserId(); cachedSelfUserId = await client.getUserId();
} catch { } catch {
@@ -30,7 +32,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const refreshDmCache = async (): Promise<void> => { const refreshDmCache = async (): Promise<void> => {
const now = Date.now(); const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return; if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
return;
}
lastDmUpdateMs = now; lastDmUpdateMs = now;
try { try {
await client.dms.update(); await client.dms.update();
@@ -58,7 +62,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => { const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
const target = userId?.trim(); const target = userId?.trim();
if (!target) return false; if (!target) {
return false;
}
try { try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target); const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true; return state?.is_direct === true;

View File

@@ -126,15 +126,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const isLocationEvent = const isLocationEvent =
eventType === EventType.Location || eventType === EventType.Location ||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return; if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
return;
}
logVerboseMessage( logVerboseMessage(
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
); );
if (event.unsigned?.redacted_because) return; if (event.unsigned?.redacted_because) {
return;
}
const senderId = event.sender; const senderId = event.sender;
if (!senderId) return; if (!senderId) {
return;
}
const selfUserId = await client.getUserId(); const selfUserId = await client.getUserId();
if (senderId === selfUserId) return; if (senderId === selfUserId) {
return;
}
const eventTs = event.origin_server_ts; const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age; const eventAge = event.unsigned?.age;
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
@@ -179,7 +187,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const relates = content["m.relates_to"]; const relates = content["m.relates_to"];
if (relates && "rel_type" in relates) { if (relates && "rel_type" in relates) {
if (relates.rel_type === RelationType.Replace) return; if (relates.rel_type === RelationType.Replace) {
return;
}
} }
const isDirectMessage = await directTracker.isDirectMessage({ const isDirectMessage = await directTracker.isDirectMessage({
@@ -189,7 +199,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}); });
const isRoom = !isDirectMessage; const isRoom = !isDirectMessage;
if (isRoom && groupPolicy === "disabled") return; if (isRoom && groupPolicy === "disabled") {
return;
}
const roomConfigInfo = isRoom const roomConfigInfo = isRoom
? resolveMatrixRoomConfig({ ? resolveMatrixRoomConfig({
@@ -234,7 +246,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
if (isDirectMessage) { if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") return; if (!dmEnabled || dmPolicy === "disabled") {
return;
}
if (dmPolicy !== "open") { if (dmPolicy !== "open") {
const allowMatch = resolveMatrixAllowListMatch({ const allowMatch = resolveMatrixAllowListMatch({
allowList: effectiveAllowFrom, allowList: effectiveAllowFrom,
@@ -356,7 +370,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} }
const bodyText = rawBody || media?.placeholder || ""; const bodyText = rawBody || media?.placeholder || "";
if (!bodyText) return; if (!bodyText) {
return;
}
const { wasMentioned, hasExplicitMention } = resolveMentions({ const { wasMentioned, hasExplicitMention } = resolveMentions({
content, content,
@@ -497,7 +513,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
MediaPath: media?.path, MediaPath: media?.path,
MediaType: media?.contentType, MediaType: media?.contentType,
MediaUrl: media?.path, MediaUrl: media?.path,
...(locationPayload?.context ?? {}), ...locationPayload?.context,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
CommandSource: "text" as const, CommandSource: "text" as const,
OriginatingChannel: "matrix" as const, OriginatingChannel: "matrix" as const,
@@ -633,7 +649,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}, },
}); });
markDispatchIdle(); markDispatchIdle();
if (!queuedFinal) return; if (!queuedFinal) {
return;
}
didSendReply = true; didSendReply = true;
const finalCount = counts.final; const finalCount = counts.final;
logVerboseMessage( logVerboseMessage(

View File

@@ -34,7 +34,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
} }
const core = getMatrixRuntime(); const core = getMatrixRuntime();
let cfg = core.config.loadConfig() as CoreConfig; let cfg = core.config.loadConfig() as CoreConfig;
if (cfg.channels?.matrix?.enabled === false) return; if (cfg.channels?.matrix?.enabled === false) {
return;
}
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args); const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
@@ -50,7 +52,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}, },
}; };
const logVerboseMessage = (message: string) => { const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return; if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug(message); logger.debug(message);
}; };
@@ -115,7 +119,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const pending: Array<{ input: string; query: string }> = []; const pending: Array<{ input: string; query: string }> = [];
for (const entry of entries) { for (const entry of entries) {
const trimmed = entry.trim(); const trimmed = entry.trim();
if (!trimmed) continue; if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed); const cleaned = normalizeRoomEntry(trimmed);
if (cleaned.startsWith("!") && cleaned.includes(":")) { if (cleaned.startsWith("!") && cleaned.includes(":")) {
if (!nextRooms[cleaned]) { if (!nextRooms[cleaned]) {
@@ -135,7 +141,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
resolved.forEach((entry, index) => { resolved.forEach((entry, index) => {
const source = pending[index]; const source = pending[index];
if (!source) return; if (!source) {
return;
}
if (entry.resolved && entry.id) { if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) { if (!nextRooms[entry.id]) {
nextRooms[entry.id] = roomsConfig[source.input]; nextRooms[entry.id] = roomsConfig[source.input];

View File

@@ -20,25 +20,37 @@ type GeoUriParams = {
function parseGeoUri(value: string): GeoUriParams | null { function parseGeoUri(value: string): GeoUriParams | null {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return null; if (!trimmed) {
if (!trimmed.toLowerCase().startsWith("geo:")) return null; return null;
}
if (!trimmed.toLowerCase().startsWith("geo:")) {
return null;
}
const payload = trimmed.slice(4); const payload = trimmed.slice(4);
const [coordsPart, ...paramParts] = payload.split(";"); const [coordsPart, ...paramParts] = payload.split(";");
const coords = coordsPart.split(","); const coords = coordsPart.split(",");
if (coords.length < 2) return null; if (coords.length < 2) {
return null;
}
const latitude = Number.parseFloat(coords[0] ?? ""); const latitude = Number.parseFloat(coords[0] ?? "");
const longitude = Number.parseFloat(coords[1] ?? ""); const longitude = Number.parseFloat(coords[1] ?? "");
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const params = new Map<string, string>(); const params = new Map<string, string>();
for (const part of paramParts) { for (const part of paramParts) {
const segment = part.trim(); const segment = part.trim();
if (!segment) continue; if (!segment) {
continue;
}
const eqIndex = segment.indexOf("="); const eqIndex = segment.indexOf("=");
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex); const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1); const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
const key = rawKey.trim().toLowerCase(); const key = rawKey.trim().toLowerCase();
if (!key) continue; if (!key) {
continue;
}
const valuePart = rawValue.trim(); const valuePart = rawValue.trim();
params.set(key, valuePart ? decodeURIComponent(valuePart) : ""); params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
} }
@@ -61,11 +73,17 @@ export function resolveMatrixLocation(params: {
const isLocation = const isLocation =
eventType === EventType.Location || eventType === EventType.Location ||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location); (eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
if (!isLocation) return null; if (!isLocation) {
return null;
}
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : ""; const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
if (!geoUri) return null; if (!geoUri) {
return null;
}
const parsed = parseGeoUri(geoUri); const parsed = parseGeoUri(geoUri);
if (!parsed) return null; if (!parsed) {
return null;
}
const caption = typeof content.body === "string" ? content.body.trim() : ""; const caption = typeof content.body === "string" ? content.body.trim() : "";
const location: NormalizedLocation = { const location: NormalizedLocation = {
latitude: parsed.latitude, latitude: parsed.latitude,

View File

@@ -24,7 +24,9 @@ async function fetchMatrixMediaBuffer(params: {
}): Promise<{ buffer: Buffer; headerType?: string } | null> { }): Promise<{ buffer: Buffer; headerType?: string } | null> {
// @vector-im/matrix-bot-sdk provides mxcToHttp helper // @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl); const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null; if (!url) {
return null;
}
// Use the client's download method which handles auth // Use the client's download method which handles auth
try { try {
@@ -34,7 +36,7 @@ async function fetchMatrixMediaBuffer(params: {
} }
return { buffer: Buffer.from(buffer) }; return { buffer: Buffer.from(buffer) };
} catch (err) { } catch (err) {
throw new Error(`Matrix media download failed: ${String(err)}`); throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
} }
} }
@@ -94,7 +96,9 @@ export async function downloadMatrixMedia(params: {
}); });
} }
if (!fetched) return null; if (!fetched) {
return null;
}
const headerType = fetched.headerType ?? params.contentType ?? undefined; const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer, fetched.buffer,

View File

@@ -62,7 +62,9 @@ export async function deliverMatrixReplies(params: {
chunkMode, chunkMode,
)) { )) {
const trimmed = chunk.trim(); const trimmed = chunk.trim();
if (!trimmed) continue; if (!trimmed) {
continue;
}
await sendMessageMatrix(params.roomId, trimmed, { await sendMessageMatrix(params.roomId, trimmed, {
client: params.client, client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,

View File

@@ -11,7 +11,9 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => { const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
const cached = roomInfoCache.get(roomId); const cached = roomInfoCache.get(roomId);
if (cached) return cached; if (cached) {
return cached;
}
let name: string | undefined; let name: string | undefined;
let canonicalAlias: string | undefined; let canonicalAlias: string | undefined;
let altAliases: string[] = []; let altAliases: string[] = [];

View File

@@ -28,7 +28,9 @@ export function resolveMatrixThreadTarget(params: {
isThreadRoot?: boolean; isThreadRoot?: boolean;
}): string | undefined { }): string | undefined {
const { threadReplies, messageId, threadRootId } = params; const { threadReplies, messageId, threadRootId } = params;
if (threadReplies === "off") return undefined; if (threadReplies === "off") {
return undefined;
}
const isThreadRoot = params.isThreadRoot === true; const isThreadRoot = params.isThreadRoot === true;
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot); const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
if (threadReplies === "inbound") { if (threadReplies === "inbound") {
@@ -45,7 +47,9 @@ export function resolveMatrixThreadRootId(params: {
content: RoomMessageEventContent; content: RoomMessageEventContent;
}): string | undefined { }): string | undefined {
const relates = params.content["m.relates_to"]; const relates = params.content["m.relates_to"];
if (!relates || typeof relates !== "object") return undefined; if (!relates || typeof relates !== "object") {
return undefined;
}
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) { if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
if ("event_id" in relates && typeof relates.event_id === "string") { if ("event_id" in relates && typeof relates.event_id === "string") {
return relates.event_id; return relates.event_id;

View File

@@ -77,7 +77,9 @@ export function isPollStartType(eventType: string): boolean {
} }
export function getTextContent(text?: TextContent): string { export function getTextContent(text?: TextContent): string {
if (!text) return ""; if (!text) {
return "";
}
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
} }
@@ -86,10 +88,14 @@ export function parsePollStartContent(content: PollStartContent): PollSummary |
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ?? (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)["m.poll"]; (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null; if (!poll) {
return null;
}
const question = getTextContent(poll.question); const question = getTextContent(poll.question);
if (!question) return null; if (!question) {
return null;
}
const answers = poll.answers const answers = poll.answers
.map((answer) => getTextContent(answer)) .map((answer) => getTextContent(answer))
@@ -125,7 +131,9 @@ function buildTextContent(body: string): TextContent {
} }
function buildPollFallbackText(question: string, answers: string[]): string { function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) return question; if (answers.length === 0) {
return question;
}
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`; return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
} }

View File

@@ -123,7 +123,9 @@ export async function sendMessageMatrix(
const followupRelation = threadId ? relation : undefined; const followupRelation = threadId ? relation : undefined;
for (const chunk of textChunks) { for (const chunk of textChunks) {
const text = chunk.trim(); const text = chunk.trim();
if (!text) continue; if (!text) {
continue;
}
const followup = buildTextContent(text, followupRelation); const followup = buildTextContent(text, followupRelation);
const followupEventId = await sendContent(followup); const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId; lastMessageId = followupEventId ?? lastMessageId;
@@ -131,7 +133,9 @@ export async function sendMessageMatrix(
} else { } else {
for (const chunk of chunks.length ? chunks : [""]) { for (const chunk of chunks.length ? chunks : [""]) {
const text = chunk.trim(); const text = chunk.trim();
if (!text) continue; if (!text) {
continue;
}
const content = buildTextContent(text, relation); const content = buildTextContent(text, relation);
const eventId = await sendContent(content); const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId; lastMessageId = eventId ?? lastMessageId;
@@ -211,7 +215,9 @@ export async function sendReadReceiptMatrix(
eventId: string, eventId: string,
client?: MatrixClient, client?: MatrixClient,
): Promise<void> { ): Promise<void> {
if (!eventId?.trim()) return; if (!eventId?.trim()) {
return;
}
const { client: resolved, stopOnDone } = await resolveMatrixClient({ const { client: resolved, stopOnDone } = await resolveMatrixClient({
client, client,
}); });

View File

@@ -31,9 +31,13 @@ export async function resolveMatrixClient(opts: {
timeoutMs?: number; timeoutMs?: number;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { }): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime(); ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false }; if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
const active = getActiveMatrixClient(); const active = getActiveMatrixClient();
if (active) return { client: active, stopOnDone: false }; if (active) {
return { client: active, stopOnDone: false };
}
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
if (shouldShareClient) { if (shouldShareClient) {
const client = await resolveSharedMatrixClient({ const client = await resolveSharedMatrixClient({

View File

@@ -30,14 +30,18 @@ export function buildTextContent(body: string, relation?: MatrixRelation): Matri
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
const formatted = markdownToMatrixHtml(body ?? ""); const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) return; if (!formatted) {
return;
}
content.format = "org.matrix.custom.html"; content.format = "org.matrix.custom.html";
content.formatted_body = formatted; content.formatted_body = formatted;
} }
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
const trimmed = replyToId?.trim(); const trimmed = replyToId?.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
return { "m.in_reply_to": { event_id: trimmed } }; return { "m.in_reply_to": { event_id: trimmed } };
} }
@@ -70,7 +74,9 @@ export function resolveMatrixVoiceDecision(opts: {
contentType?: string; contentType?: string;
fileName?: string; fileName?: string;
}): { useVoice: boolean } { }): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false }; if (!opts.wantsVoice) {
return { useVoice: false };
}
if ( if (
getCore().media.isVoiceCompatibleAudio({ getCore().media.isVoiceCompatibleAudio({
contentType: opts.contentType, contentType: opts.contentType,

View File

@@ -54,7 +54,9 @@ export function buildMatrixMediaInfo(params: {
}; };
return timedInfo; return timedInfo;
} }
if (Object.keys(base).length === 0) return undefined; if (Object.keys(base).length === 0) {
return undefined;
}
return base; return base;
} }
@@ -116,7 +118,9 @@ export async function prepareImageInfo(params: {
const meta = await getCore() const meta = await getCore()
.media.getImageMetadata(params.buffer) .media.getImageMetadata(params.buffer)
.catch(() => null); .catch(() => null);
if (!meta) return undefined; if (!meta) {
return undefined;
}
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height); const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) { if (maxDim > THUMBNAIL_MAX_SIDE) {
@@ -157,7 +161,9 @@ export async function resolveMediaDurationMs(params: {
fileName?: string; fileName?: string;
kind: MediaKind; kind: MediaKind;
}): Promise<number | undefined> { }): Promise<number | undefined> {
if (params.kind !== "audio" && params.kind !== "video") return undefined; if (params.kind !== "audio" && params.kind !== "video") {
return undefined;
}
try { try {
const fileInfo: IFileInfo | string | undefined = const fileInfo: IFileInfo | string | undefined =
params.contentType || params.fileName params.contentType || params.fileName

View File

@@ -26,7 +26,9 @@ describe("resolveMatrixRoomId", () => {
const roomId = await resolveMatrixRoomId(client, userId); const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org"); expect(roomId).toBe("!room:example.org");
// oxlint-disable-next-line typescript/unbound-method
expect(client.getJoinedRooms).not.toHaveBeenCalled(); expect(client.getJoinedRooms).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method
expect(client.setAccountData).not.toHaveBeenCalled(); expect(client.setAccountData).not.toHaveBeenCalled();
}); });

View File

@@ -11,7 +11,9 @@ function normalizeTarget(raw: string): string {
} }
export function normalizeThreadId(raw?: string | number | null): string | null { export function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null; if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim(); const trimmed = String(raw).trim();
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
@@ -25,15 +27,15 @@ async function persistDirectRoom(
): Promise<void> { ): Promise<void> {
let directContent: MatrixDirectAccountData | null = null; let directContent: MatrixDirectAccountData | null = null;
try { try {
directContent = (await client.getAccountData( directContent = await client.getAccountData(EventType.Direct);
EventType.Direct,
)) as MatrixDirectAccountData | null;
} catch { } catch {
// Ignore fetch errors and fall back to an empty map. // Ignore fetch errors and fall back to an empty map.
} }
const existing = directContent && !Array.isArray(directContent) ? directContent : {}; const existing = directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : []; const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) return; if (current[0] === roomId) {
return;
}
const next = [roomId, ...current.filter((id) => id !== roomId)]; const next = [roomId, ...current.filter((id) => id !== roomId)];
try { try {
await client.setAccountData(EventType.Direct, { await client.setAccountData(EventType.Direct, {
@@ -52,13 +54,13 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
} }
const cached = directRoomCache.get(trimmed); const cached = directRoomCache.get(trimmed);
if (cached) return cached; if (cached) {
return cached;
}
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot). // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try { try {
const directContent = (await client.getAccountData( const directContent = await client.getAccountData(EventType.Direct);
EventType.Direct,
)) as MatrixDirectAccountData | null;
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) { if (list.length > 0) {
directRoomCache.set(trimmed, list[0]); directRoomCache.set(trimmed, list[0]);
@@ -80,7 +82,9 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
} catch { } catch {
continue; continue;
} }
if (!members.includes(trimmed)) continue; if (!members.includes(trimmed)) {
continue;
}
// Prefer classic 1:1 rooms, but allow larger rooms if requested. // Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) { if (members.length === 2) {
directRoomCache.set(trimmed, roomId); directRoomCache.set(trimmed, roomId);

View File

@@ -249,8 +249,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: existing.homeserver ?? envHomeserver, initialValue: existing.homeserver ?? envHomeserver,
validate: (value) => { validate: (value) => {
const raw = String(value ?? "").trim(); const raw = String(value ?? "").trim();
if (!raw) return "Required"; if (!raw) {
if (!/^https?:\/\//i.test(raw)) return "Use a full URL (https://...)"; return "Required";
}
if (!/^https?:\/\//i.test(raw)) {
return "Use a full URL (https://...)";
}
return undefined; return undefined;
}, },
}), }),
@@ -274,13 +278,13 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
if (!accessToken && !password) { if (!accessToken && !password) {
// Ask auth method FIRST before asking for user ID // Ask auth method FIRST before asking for user ID
const authMode = (await prompter.select({ const authMode = await prompter.select({
message: "Matrix auth method", message: "Matrix auth method",
options: [ options: [
{ value: "token", label: "Access token (user ID fetched automatically)" }, { value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" }, { value: "password", label: "Password (requires user ID)" },
], ],
})) as "token" | "password"; });
if (authMode === "token") { if (authMode === "token") {
accessToken = String( accessToken = String(
@@ -300,9 +304,15 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
initialValue: existing.userId ?? envUserId, initialValue: existing.userId ?? envUserId,
validate: (value) => { validate: (value) => {
const raw = String(value ?? "").trim(); const raw = String(value ?? "").trim();
if (!raw) return "Required"; if (!raw) {
if (!raw.startsWith("@")) return "Matrix user IDs should start with @"; return "Required";
if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)"; }
if (!raw.startsWith("@")) {
return "Matrix user IDs should start with @";
}
if (!raw.includes(":")) {
return "Matrix user IDs should include a server (:server)";
}
return undefined; return undefined;
}, },
}), }),
@@ -370,7 +380,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
const unresolved: string[] = []; const unresolved: string[] = [];
for (const entry of accessConfig.entries) { for (const entry of accessConfig.entries) {
const trimmed = entry.trim(); const trimmed = entry.trim();
if (!trimmed) continue; if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) { if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned); resolvedIds.push(cleaned);

View File

@@ -11,7 +11,9 @@ function pickBestGroupMatch(
matches: ChannelDirectoryEntry[], matches: ChannelDirectoryEntry[],
query: string, query: string,
): ChannelDirectoryEntry | undefined { ): ChannelDirectoryEntry | undefined {
if (matches.length === 0) return undefined; if (matches.length === 0) {
return undefined;
}
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
if (normalized) { if (normalized) {
const exact = matches.find((match) => { const exact = matches.find((match) => {
@@ -20,7 +22,9 @@ function pickBestGroupMatch(
const id = match.id.trim().toLowerCase(); const id = match.id.trim().toLowerCase();
return name === normalized || handle === normalized || id === normalized; return name === normalized || handle === normalized || id === normalized;
}); });
if (exact) return exact; if (exact) {
return exact;
}
} }
return matches[0]; return matches[0];
} }

View File

@@ -29,8 +29,12 @@ const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
function readRoomId(params: Record<string, unknown>, required = true): string { function readRoomId(params: Record<string, unknown>, required = true): string {
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
if (direct) return direct; if (direct) {
if (!required) return readStringParam(params, "to") ?? ""; return direct;
}
if (!required) {
return readStringParam(params, "to") ?? "";
}
return readStringParam(params, "to", { required: true }); return readStringParam(params, "to", { required: true });
} }

View File

@@ -6,7 +6,9 @@ describe("mattermostPlugin", () => {
describe("messaging", () => { describe("messaging", () => {
it("keeps @username targets", () => { it("keeps @username targets", () => {
const normalize = mattermostPlugin.messaging?.normalizeTarget; const normalize = mattermostPlugin.messaging?.normalizeTarget;
if (!normalize) return; if (!normalize) {
return;
}
expect(normalize("@Alice")).toBe("@Alice"); expect(normalize("@Alice")).toBe("@Alice");
expect(normalize("@alice")).toBe("@alice"); expect(normalize("@alice")).toBe("@alice");
@@ -14,7 +16,9 @@ describe("mattermostPlugin", () => {
it("normalizes mattermost: prefix to user:", () => { it("normalizes mattermost: prefix to user:", () => {
const normalize = mattermostPlugin.messaging?.normalizeTarget; const normalize = mattermostPlugin.messaging?.normalizeTarget;
if (!normalize) return; if (!normalize) {
return;
}
expect(normalize("mattermost:USER123")).toBe("user:USER123"); expect(normalize("mattermost:USER123")).toBe("user:USER123");
}); });
@@ -23,7 +27,9 @@ describe("mattermostPlugin", () => {
describe("pairing", () => { describe("pairing", () => {
it("normalizes allowlist entries", () => { it("normalizes allowlist entries", () => {
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry; const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
if (!normalize) return; if (!normalize) {
return;
}
expect(normalize("@Alice")).toBe("alice"); expect(normalize("@Alice")).toBe("alice");
expect(normalize("user:USER123")).toBe("user123"); expect(normalize("user:USER123")).toBe("user123");

View File

@@ -49,7 +49,9 @@ function normalizeAllowEntry(entry: string): string {
function formatAllowEntry(entry: string): string { function formatAllowEntry(entry: string): string {
const trimmed = entry.trim(); const trimmed = entry.trim();
if (!trimmed) return ""; if (!trimmed) {
return "";
}
if (trimmed.startsWith("@")) { if (trimmed.startsWith("@")) {
const username = trimmed.slice(1).trim(); const username = trimmed.slice(1).trim();
return username ? `@${username.toLowerCase()}` : ""; return username ? `@${username.toLowerCase()}` : "";
@@ -134,7 +136,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
collectWarnings: ({ account, cfg }) => { collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`, `- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
]; ];

View File

@@ -9,6 +9,8 @@ export function resolveMattermostGroupRequireMention(
cfg: params.cfg, cfg: params.cfg,
accountId: params.accountId, accountId: params.accountId,
}); });
if (typeof account.requireMention === "boolean") return account.requireMention; if (typeof account.requireMention === "boolean") {
return account.requireMention;
}
return true; return true;
} }

View File

@@ -26,19 +26,25 @@ export type ResolvedMattermostAccount = {
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.channels?.mattermost?.accounts; const accounts = cfg.channels?.mattermost?.accounts;
if (!accounts || typeof accounts !== "object") return []; if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean); return Object.keys(accounts).filter(Boolean);
} }
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] { export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg); const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; if (ids.length === 0) {
return ids.sort((a, b) => a.localeCompare(b)); return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
} }
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string { export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
const ids = listMattermostAccountIds(cfg); const ids = listMattermostAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID; return ids[0] ?? DEFAULT_ACCOUNT_ID;
} }
@@ -47,7 +53,9 @@ function resolveAccountConfig(
accountId: string, accountId: string,
): MattermostAccountConfig | undefined { ): MattermostAccountConfig | undefined {
const accounts = cfg.channels?.mattermost?.accounts; const accounts = cfg.channels?.mattermost?.accounts;
if (!accounts || typeof accounts !== "object") return undefined; if (!accounts || typeof accounts !== "object") {
return undefined;
}
return accounts[accountId] as MattermostAccountConfig | undefined; return accounts[accountId] as MattermostAccountConfig | undefined;
} }
@@ -62,9 +70,15 @@ function mergeMattermostAccountConfig(
} }
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined { function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
if (config.chatmode === "oncall") return true; if (config.chatmode === "oncall") {
if (config.chatmode === "onmessage") return false; return true;
if (config.chatmode === "onchar") return true; }
if (config.chatmode === "onmessage") {
return false;
}
if (config.chatmode === "onchar") {
return true;
}
return config.requireMention; return config.requireMention;
} }

View File

@@ -42,14 +42,18 @@ export type MattermostFileInfo = {
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined { export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
const trimmed = raw?.trim(); const trimmed = raw?.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
const withoutTrailing = trimmed.replace(/\/+$/, ""); const withoutTrailing = trimmed.replace(/\/+$/, "");
return withoutTrailing.replace(/\/api\/v4$/i, ""); return withoutTrailing.replace(/\/api\/v4$/i, "");
} }
function buildMattermostApiUrl(baseUrl: string, path: string): string { function buildMattermostApiUrl(baseUrl: string, path: string): string {
const normalized = normalizeMattermostBaseUrl(baseUrl); const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) throw new Error("Mattermost baseUrl is required"); if (!normalized) {
throw new Error("Mattermost baseUrl is required");
}
const suffix = path.startsWith("/") ? path : `/${path}`; const suffix = path.startsWith("/") ? path : `/${path}`;
return `${normalized}/api/v4${suffix}`; return `${normalized}/api/v4${suffix}`;
} }
@@ -58,7 +62,9 @@ async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? ""; const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined; const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) return data.message; if (data?.message) {
return data.message;
}
return JSON.stringify(data); return JSON.stringify(data);
} }
return await res.text(); return await res.text();
@@ -70,7 +76,9 @@ export function createMattermostClient(params: {
fetchImpl?: typeof fetch; fetchImpl?: typeof fetch;
}): MattermostClient { }): MattermostClient {
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl); const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
if (!baseUrl) throw new Error("Mattermost baseUrl is required"); if (!baseUrl) {
throw new Error("Mattermost baseUrl is required");
}
const apiBaseUrl = `${baseUrl}/api/v4`; const apiBaseUrl = `${baseUrl}/api/v4`;
const token = params.botToken.trim(); const token = params.botToken.trim();
const fetchImpl = params.fetchImpl ?? fetch; const fetchImpl = params.fetchImpl ?? fetch;
@@ -128,7 +136,9 @@ export async function sendMattermostTyping(
channel_id: params.channelId, channel_id: params.channelId,
}; };
const parentId = params.parentId?.trim(); const parentId = params.parentId?.trim();
if (parentId) payload.parent_id = parentId; if (parentId) {
payload.parent_id = parentId;
}
await client.request<Record<string, unknown>>("/users/me/typing", { await client.request<Record<string, unknown>>("/users/me/typing", {
method: "POST", method: "POST",
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -158,7 +168,9 @@ export async function createMattermostPost(
channel_id: params.channelId, channel_id: params.channelId,
message: params.message, message: params.message,
}; };
if (params.rootId) payload.root_id = params.rootId; if (params.rootId) {
payload.root_id = params.rootId;
}
if (params.fileIds?.length) { if (params.fileIds?.length) {
(payload as Record<string, unknown>).file_ids = params.fileIds; (payload as Record<string, unknown>).file_ids = params.fileIds;
} }

View File

@@ -34,7 +34,9 @@ export function formatInboundFromLabel(params: {
const directLabel = params.directLabel.trim(); const directLabel = params.directLabel.trim();
const directId = params.directId?.trim(); const directId = params.directId?.trim();
if (!directId || directId === directLabel) return directLabel; if (!directId || directId === directLabel) {
return directLabel;
}
return `${directLabel} id:${directId}`; return `${directLabel} id:${directId}`;
} }
@@ -67,14 +69,18 @@ export function createDedupeCache(options: { ttlMs: number; maxSize: number }):
} }
while (cache.size > maxSize) { while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value as string | undefined; const oldestKey = cache.keys().next().value as string | undefined;
if (!oldestKey) break; if (!oldestKey) {
break;
}
cache.delete(oldestKey); cache.delete(oldestKey);
} }
}; };
return { return {
check: (key, now = Date.now()) => { check: (key, now = Date.now()) => {
if (!key) return false; if (!key) {
return false;
}
const existing = cache.get(key); const existing = cache.get(key);
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) { if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
touch(key, now); touch(key, now);
@@ -91,9 +97,15 @@ export function rawDataToString(
data: WebSocket.RawData, data: WebSocket.RawData,
encoding: BufferEncoding = "utf8", encoding: BufferEncoding = "utf8",
): string { ): string {
if (typeof data === "string") return data; if (typeof data === "string") {
if (Buffer.isBuffer(data)) return data.toString(encoding); return data;
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding); }
if (Buffer.isBuffer(data)) {
return data.toString(encoding);
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString(encoding);
}
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString(encoding); return Buffer.from(data).toString(encoding);
} }
@@ -102,8 +114,12 @@ export function rawDataToString(
function normalizeAgentId(value: string | undefined | null): string { function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim(); const trimmed = (value ?? "").trim();
if (!trimmed) return "main"; if (!trimmed) {
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed; return "main";
}
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
return trimmed;
}
return ( return (
trimmed trimmed
.toLowerCase() .toLowerCase()
@@ -118,7 +134,9 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[num
function listAgents(cfg: OpenClawConfig): AgentEntry[] { function listAgents(cfg: OpenClawConfig): AgentEntry[] {
const list = cfg.agents?.list; const list = cfg.agents?.list;
if (!Array.isArray(list)) return []; if (!Array.isArray(list)) {
return [];
}
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
} }

View File

@@ -96,7 +96,9 @@ function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
} }
function normalizeMention(text: string, mention: string | undefined): string { function normalizeMention(text: string, mention: string | undefined): string {
if (!mention) return text.trim(); if (!mention) {
return text.trim();
}
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`@${escaped}\\b`, "gi"); const re = new RegExp(`@${escaped}\\b`, "gi");
return text.replace(re, " ").replace(/\s+/g, " ").trim(); return text.replace(re, " ").replace(/\s+/g, " ").trim();
@@ -113,7 +115,9 @@ function stripOncharPrefix(
): { triggered: boolean; stripped: string } { ): { triggered: boolean; stripped: string } {
const trimmed = text.trimStart(); const trimmed = text.trimStart();
for (const prefix of prefixes) { for (const prefix of prefixes) {
if (!prefix) continue; if (!prefix) {
continue;
}
if (trimmed.startsWith(prefix)) { if (trimmed.startsWith(prefix)) {
return { return {
triggered: true, triggered: true,
@@ -130,23 +134,37 @@ function isSystemPost(post: MattermostPost): boolean {
} }
function channelKind(channelType?: string | null): "dm" | "group" | "channel" { function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
if (!channelType) return "channel"; if (!channelType) {
return "channel";
}
const normalized = channelType.trim().toUpperCase(); const normalized = channelType.trim().toUpperCase();
if (normalized === "D") return "dm"; if (normalized === "D") {
if (normalized === "G") return "group"; return "dm";
}
if (normalized === "G") {
return "group";
}
return "channel"; return "channel";
} }
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" { function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
if (kind === "dm") return "direct"; if (kind === "dm") {
if (kind === "group") return "group"; return "direct";
}
if (kind === "group") {
return "group";
}
return "channel"; return "channel";
} }
function normalizeAllowEntry(entry: string): string { function normalizeAllowEntry(entry: string): string {
const trimmed = entry.trim(); const trimmed = entry.trim();
if (!trimmed) return ""; if (!trimmed) {
if (trimmed === "*") return "*"; return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed return trimmed
.replace(/^(mattermost|user):/i, "") .replace(/^(mattermost|user):/i, "")
.replace(/^@/, "") .replace(/^@/, "")
@@ -164,8 +182,12 @@ function isSenderAllowed(params: {
allowFrom: string[]; allowFrom: string[];
}): boolean { }): boolean {
const allowFrom = params.allowFrom; const allowFrom = params.allowFrom;
if (allowFrom.length === 0) return false; if (allowFrom.length === 0) {
if (allowFrom.includes("*")) return true; return false;
}
if (allowFrom.includes("*")) {
return true;
}
const normalizedSenderId = normalizeAllowEntry(params.senderId); const normalizedSenderId = normalizeAllowEntry(params.senderId);
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
return allowFrom.some( return allowFrom.some(
@@ -181,7 +203,9 @@ type MattermostMediaInfo = {
}; };
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string { function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
if (mediaList.length === 0) return ""; if (mediaList.length === 0) {
return "";
}
if (mediaList.length === 1) { if (mediaList.length === 1) {
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind; const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
return `<media:${kind}>`; return `<media:${kind}>`;
@@ -216,7 +240,9 @@ function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
function buildMattermostWsUrl(baseUrl: string): string { function buildMattermostWsUrl(baseUrl: string): string {
const normalized = normalizeMattermostBaseUrl(baseUrl); const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) throw new Error("Mattermost baseUrl is required"); if (!normalized) {
throw new Error("Mattermost baseUrl is required");
}
const wsBase = normalized.replace(/^http/i, "ws"); const wsBase = normalized.replace(/^http/i, "ws");
return `${wsBase}/api/v4/websocket`; return `${wsBase}/api/v4/websocket`;
} }
@@ -252,7 +278,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>(); const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const logger = core.logging.getChildLogger({ module: "mattermost" }); const logger = core.logging.getChildLogger({ module: "mattermost" });
const logVerboseMessage = (message: string) => { const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return; if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug?.(message); logger.debug?.(message);
}; };
const mediaMaxBytes = const mediaMaxBytes =
@@ -276,8 +304,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const resolveMattermostMedia = async ( const resolveMattermostMedia = async (
fileIds?: string[] | null, fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => { ): Promise<MattermostMediaInfo[]> => {
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[]; const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
if (ids.length === 0) return []; if (ids.length === 0) {
return [];
}
const out: MattermostMediaInfo[] = []; const out: MattermostMediaInfo[] = [];
for (const fileId of ids) { for (const fileId of ids) {
try { try {
@@ -312,7 +342,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => { const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
const cached = channelCache.get(channelId); const cached = channelCache.get(channelId);
if (cached && cached.expiresAt > Date.now()) return cached.value; if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try { try {
const info = await fetchMattermostChannel(client, channelId); const info = await fetchMattermostChannel(client, channelId);
channelCache.set(channelId, { channelCache.set(channelId, {
@@ -332,7 +364,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => { const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const cached = userCache.get(userId); const cached = userCache.get(userId);
if (cached && cached.expiresAt > Date.now()) return cached.value; if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try { try {
const info = await fetchMattermostUser(client, userId); const info = await fetchMattermostUser(client, userId);
userCache.set(userId, { userCache.set(userId, {
@@ -356,19 +390,31 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
messageIds?: string[], messageIds?: string[],
) => { ) => {
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id; const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
if (!channelId) return; if (!channelId) {
return;
}
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : []; const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
if (allMessageIds.length === 0) return; if (allMessageIds.length === 0) {
return;
}
const dedupeEntries = allMessageIds.map((id) => const dedupeEntries = allMessageIds.map((id) =>
recentInboundMessages.check(`${account.accountId}:${id}`), recentInboundMessages.check(`${account.accountId}:${id}`),
); );
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return; if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) {
return;
}
const senderId = post.user_id ?? payload.broadcast?.user_id; const senderId = post.user_id ?? payload.broadcast?.user_id;
if (!senderId) return; if (!senderId) {
if (senderId === botUserId) return; return;
if (isSystemPost(post)) return; }
if (senderId === botUserId) {
return;
}
if (isSystemPost(post)) {
return;
}
const channelInfo = await resolveChannelInfo(channelId); const channelInfo = await resolveChannelInfo(channelId);
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined; const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
@@ -560,7 +606,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
channel: "mattermost", channel: "mattermost",
accountId: account.accountId, accountId: account.accountId,
groupId: channelId, groupId: channelId,
}) !== false; });
const shouldBypassMention = const shouldBypassMention =
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized; isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered; const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
@@ -582,7 +628,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const bodySource = oncharTriggered ? oncharResult.stripped : rawText; const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim(); const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
const bodyText = normalizeMention(baseText, botUsername); const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) return; if (!bodyText) {
return;
}
core.channel.activity.record({ core.channel.activity.record({
channel: "mattermost", channel: "mattermost",
@@ -743,7 +791,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
); );
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
for (const chunk of chunks.length > 0 ? chunks : [text]) { for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue; if (!chunk) {
continue;
}
await sendMessageMattermost(to, chunk, { await sendMessageMattermost(to, chunk, {
accountId: account.accountId, accountId: account.accountId,
replyToId: threadRootId, replyToId: threadRootId,
@@ -804,20 +854,28 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
entry.post.channel_id ?? entry.post.channel_id ??
entry.payload.data?.channel_id ?? entry.payload.data?.channel_id ??
entry.payload.broadcast?.channel_id; entry.payload.broadcast?.channel_id;
if (!channelId) return null; if (!channelId) {
return null;
}
const threadId = entry.post.root_id?.trim(); const threadId = entry.post.root_id?.trim();
const threadKey = threadId ? `thread:${threadId}` : "channel"; const threadKey = threadId ? `thread:${threadId}` : "channel";
return `mattermost:${account.accountId}:${channelId}:${threadKey}`; return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
}, },
shouldDebounce: (entry) => { shouldDebounce: (entry) => {
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false; if (entry.post.file_ids && entry.post.file_ids.length > 0) {
return false;
}
const text = entry.post.message?.trim() ?? ""; const text = entry.post.message?.trim() ?? "";
if (!text) return false; if (!text) {
return false;
}
return !core.channel.text.hasControlCommand(text, cfg); return !core.channel.text.hasControlCommand(text, cfg);
}, },
onFlush: async (entries) => { onFlush: async (entries) => {
const last = entries.at(-1); const last = entries.at(-1);
if (!last) return; if (!last) {
return;
}
if (entries.length === 1) { if (entries.length === 1) {
await handlePost(last.post, last.payload); await handlePost(last.post, last.payload);
return; return;
@@ -831,7 +889,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
message: combinedText, message: combinedText,
file_ids: [], file_ids: [],
}; };
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[]; const ids = entries.map((entry) => entry.post.id).filter(Boolean);
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined); await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
}, },
onError: (err) => { onError: (err) => {
@@ -871,9 +929,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} catch { } catch {
return; return;
} }
if (payload.event !== "posted") return; if (payload.event !== "posted") {
return;
}
const postData = payload.data?.post; const postData = payload.data?.post;
if (!postData) return; if (!postData) {
return;
}
let post: MattermostPost | null = null; let post: MattermostPost | null = null;
if (typeof postData === "string") { if (typeof postData === "string") {
try { try {
@@ -884,7 +946,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
} else if (typeof postData === "object") { } else if (typeof postData === "object") {
post = postData as MattermostPost; post = postData as MattermostPost;
} }
if (!post) return; if (!post) {
return;
}
try { try {
await debouncer.enqueue({ post, payload }); await debouncer.enqueue({ post, payload });
} catch (err) { } catch (err) {
@@ -917,7 +981,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
while (!opts.abortSignal?.aborted) { while (!opts.abortSignal?.aborted) {
await connectOnce(); await connectOnce();
if (opts.abortSignal?.aborted) return; if (opts.abortSignal?.aborted) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
} }

View File

@@ -12,7 +12,9 @@ async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? ""; const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) { if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined; const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) return data.message; if (data?.message) {
return data.message;
}
return JSON.stringify(data); return JSON.stringify(data);
} }
return await res.text(); return await res.text();
@@ -65,6 +67,8 @@ export async function probeMattermost(
elapsedMs: Date.now() - start, elapsedMs: Date.now() - start,
}; };
} finally { } finally {
if (timer) clearTimeout(timer); if (timer) {
clearTimeout(timer);
}
} }
} }

View File

@@ -49,21 +49,29 @@ function isHttpUrl(value: string): boolean {
function parseMattermostTarget(raw: string): MattermostTarget { function parseMattermostTarget(raw: string): MattermostTarget {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) throw new Error("Recipient is required for Mattermost sends"); if (!trimmed) {
throw new Error("Recipient is required for Mattermost sends");
}
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
if (lower.startsWith("channel:")) { if (lower.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim(); const id = trimmed.slice("channel:".length).trim();
if (!id) throw new Error("Channel id is required for Mattermost sends"); if (!id) {
throw new Error("Channel id is required for Mattermost sends");
}
return { kind: "channel", id }; return { kind: "channel", id };
} }
if (lower.startsWith("user:")) { if (lower.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim(); const id = trimmed.slice("user:".length).trim();
if (!id) throw new Error("User id is required for Mattermost sends"); if (!id) {
throw new Error("User id is required for Mattermost sends");
}
return { kind: "user", id }; return { kind: "user", id };
} }
if (lower.startsWith("mattermost:")) { if (lower.startsWith("mattermost:")) {
const id = trimmed.slice("mattermost:".length).trim(); const id = trimmed.slice("mattermost:".length).trim();
if (!id) throw new Error("User id is required for Mattermost sends"); if (!id) {
throw new Error("User id is required for Mattermost sends");
}
return { kind: "user", id }; return { kind: "user", id };
} }
if (trimmed.startsWith("@")) { if (trimmed.startsWith("@")) {
@@ -79,7 +87,9 @@ function parseMattermostTarget(raw: string): MattermostTarget {
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> { async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
const key = cacheKey(baseUrl, token); const key = cacheKey(baseUrl, token);
const cached = botUserCache.get(key); const cached = botUserCache.get(key);
if (cached) return cached; if (cached) {
return cached;
}
const client = createMattermostClient({ baseUrl, botToken: token }); const client = createMattermostClient({ baseUrl, botToken: token });
const user = await fetchMattermostMe(client); const user = await fetchMattermostMe(client);
botUserCache.set(key, user); botUserCache.set(key, user);
@@ -94,7 +104,9 @@ async function resolveUserIdByUsername(params: {
const { baseUrl, token, username } = params; const { baseUrl, token, username } = params;
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`; const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
const cached = userByNameCache.get(key); const cached = userByNameCache.get(key);
if (cached?.id) return cached.id; if (cached?.id) {
return cached.id;
}
const client = createMattermostClient({ baseUrl, botToken: token }); const client = createMattermostClient({ baseUrl, botToken: token });
const user = await fetchMattermostUserByUsername(client, username); const user = await fetchMattermostUserByUsername(client, username);
userByNameCache.set(key, user); userByNameCache.set(key, user);
@@ -106,7 +118,9 @@ async function resolveTargetChannelId(params: {
baseUrl: string; baseUrl: string;
token: string; token: string;
}): Promise<string> { }): Promise<string> {
if (params.target.kind === "channel") return params.target.id; if (params.target.kind === "channel") {
return params.target.id;
}
const userId = params.target.id const userId = params.target.id
? params.target.id ? params.target.id
: await resolveUserIdByUsername({ : await resolveUserIdByUsername({

View File

@@ -1,6 +1,8 @@
export function normalizeMattermostMessagingTarget(raw: string): string | undefined { export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return undefined; if (!trimmed) {
return undefined;
}
const lower = trimmed.toLowerCase(); const lower = trimmed.toLowerCase();
if (lower.startsWith("channel:")) { if (lower.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim(); const id = trimmed.slice("channel:".length).trim();
@@ -31,8 +33,14 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
export function looksLikeMattermostTargetId(raw: string): boolean { export function looksLikeMattermostTargetId(raw: string): boolean {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) {
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true; return false;
if (/^[@#]/.test(trimmed)) return true; }
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
return true;
}
if (/^[@#]/.test(trimmed)) {
return true;
}
return /^[a-z0-9]{8,}$/i.test(trimmed); return /^[a-z0-9]{8,}$/i.test(trimmed);
} }

View File

@@ -13,7 +13,7 @@ type PromptAccountIdParams = {
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> { export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
const existingIds = params.listAccountIds(params.cfg); const existingIds = params.listAccountIds(params.cfg);
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
const choice = (await params.prompter.select({ const choice = await params.prompter.select({
message: `${params.label} account`, message: `${params.label} account`,
options: [ options: [
...existingIds.map((id) => ({ ...existingIds.map((id) => ({
@@ -23,9 +23,11 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
{ value: "__new__", label: "Add a new account" }, { value: "__new__", label: "Add a new account" },
], ],
initialValue: initial, initialValue: initial,
})) as string; });
if (choice !== "__new__") return normalizeAccountId(choice); if (choice !== "__new__") {
return normalizeAccountId(choice);
}
const entered = await params.prompter.text({ const entered = await params.prompter.text({
message: `New ${params.label} account id`, message: `New ${params.label} account id`,

View File

@@ -18,7 +18,9 @@ const memoryCorePlugin = {
config: ctx.config, config: ctx.config,
agentSessionKey: ctx.sessionKey, agentSessionKey: ctx.sessionKey,
}); });
if (!memorySearchTool || !memoryGetTool) return null; if (!memorySearchTool || !memoryGetTool) {
return null;
}
return [memorySearchTool, memoryGetTool]; return [memorySearchTool, memoryGetTool];
}, },
{ names: ["memory_search", "memory_get"] }, { names: ["memory_search", "memory_get"] },

View File

@@ -1,4 +1,3 @@
import { Type } from "@sinclair/typebox";
import fs from "node:fs"; import fs from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -24,7 +23,9 @@ function resolveDefaultDbPath(): string {
const home = homedir(); const home = homedir();
const preferred = join(home, ".openclaw", "memory", "lancedb"); const preferred = join(home, ".openclaw", "memory", "lancedb");
try { try {
if (fs.existsSync(preferred)) return preferred; if (fs.existsSync(preferred)) {
return preferred;
}
} catch { } catch {
// best-effort // best-effort
} }
@@ -32,7 +33,9 @@ function resolveDefaultDbPath(): string {
for (const legacy of LEGACY_STATE_DIRS) { for (const legacy of LEGACY_STATE_DIRS) {
const candidate = join(home, legacy, "memory", "lancedb"); const candidate = join(home, legacy, "memory", "lancedb");
try { try {
if (fs.existsSync(candidate)) return candidate; if (fs.existsSync(candidate)) {
return candidate;
}
} catch { } catch {
// best-effort // best-effort
} }
@@ -50,7 +53,9 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) { function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
if (unknown.length === 0) return; if (unknown.length === 0) {
return;
}
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
} }

View File

@@ -9,7 +9,6 @@
*/ */
import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
@@ -42,6 +41,7 @@ describe("memory plugin e2e", () => {
expect(memoryPlugin.name).toBe("Memory (LanceDB)"); expect(memoryPlugin.name).toBe("Memory (LanceDB)");
expect(memoryPlugin.kind).toBe("memory"); expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined(); expect(memoryPlugin.configSchema).toBeDefined();
// oxlint-disable-next-line typescript/unbound-method
expect(memoryPlugin.register).toBeInstanceOf(Function); expect(memoryPlugin.register).toBeInstanceOf(Function);
}); });
@@ -217,14 +217,16 @@ describeLive("memory plugin live tests", () => {
registeredServices.push(service); registeredServices.push(service);
}, },
on: (hookName: string, handler: any) => { on: (hookName: string, handler: any) => {
if (!registeredHooks[hookName]) registeredHooks[hookName] = []; if (!registeredHooks[hookName]) {
registeredHooks[hookName] = [];
}
registeredHooks[hookName].push(handler); registeredHooks[hookName].push(handler);
}, },
resolvePath: (p: string) => p, resolvePath: (p: string) => p,
}; };
// Register plugin // Register plugin
await memoryPlugin.register(mockApi as any); memoryPlugin.register(mockApi as any);
// Check registration // Check registration
expect(registeredTools.length).toBe(3); expect(registeredTools.length).toBe(3);

View File

@@ -55,8 +55,12 @@ class MemoryDB {
) {} ) {}
private async ensureInitialized(): Promise<void> { private async ensureInitialized(): Promise<void> {
if (this.table) return; if (this.table) {
if (this.initPromise) return this.initPromise; return;
}
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doInitialize(); this.initPromise = this.doInitialize();
return this.initPromise; return this.initPromise;
@@ -73,7 +77,7 @@ class MemoryDB {
{ {
id: "__schema__", id: "__schema__",
text: "", text: "",
vector: new Array(this.vectorDim).fill(0), vector: Array.from({ length: this.vectorDim }).fill(0),
importance: 0, importance: 0,
category: "other", category: "other",
createdAt: 0, createdAt: 0,
@@ -179,25 +183,43 @@ const MEMORY_TRIGGERS = [
]; ];
function shouldCapture(text: string): boolean { function shouldCapture(text: string): boolean {
if (text.length < 10 || text.length > 500) return false; if (text.length < 10 || text.length > 500) {
return false;
}
// Skip injected context from memory recall // Skip injected context from memory recall
if (text.includes("<relevant-memories>")) return false; if (text.includes("<relevant-memories>")) {
return false;
}
// Skip system-generated content // Skip system-generated content
if (text.startsWith("<") && text.includes("</")) return false; if (text.startsWith("<") && text.includes("</")) {
return false;
}
// Skip agent summary responses (contain markdown formatting) // Skip agent summary responses (contain markdown formatting)
if (text.includes("**") && text.includes("\n-")) return false; if (text.includes("**") && text.includes("\n-")) {
return false;
}
// Skip emoji-heavy responses (likely agent output) // Skip emoji-heavy responses (likely agent output)
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length; const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
if (emojiCount > 3) return false; if (emojiCount > 3) {
return false;
}
return MEMORY_TRIGGERS.some((r) => r.test(text)); return MEMORY_TRIGGERS.some((r) => r.test(text));
} }
function detectCategory(text: string): MemoryCategory { function detectCategory(text: string): MemoryCategory {
const lower = text.toLowerCase(); const lower = text.toLowerCase();
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference"; if (/prefer|radši|like|love|hate|want/i.test(lower)) {
if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision"; return "preference";
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) return "entity"; }
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact"; if (/rozhodli|decided|will use|budeme/i.test(lower)) {
return "decision";
}
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
return "entity";
}
if (/is|are|has|have|je|má|jsou/i.test(lower)) {
return "fact";
}
return "other"; return "other";
} }
@@ -455,13 +477,17 @@ const memoryPlugin = {
// Auto-recall: inject relevant memories before agent starts // Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) { if (cfg.autoRecall) {
api.on("before_agent_start", async (event) => { api.on("before_agent_start", async (event) => {
if (!event.prompt || event.prompt.length < 5) return; if (!event.prompt || event.prompt.length < 5) {
return;
}
try { try {
const vector = await embeddings.embed(event.prompt); const vector = await embeddings.embed(event.prompt);
const results = await db.search(vector, 3, 0.3); const results = await db.search(vector, 3, 0.3);
if (results.length === 0) return; if (results.length === 0) {
return;
}
const memoryContext = results const memoryContext = results
.map((r) => `- [${r.entry.category}] ${r.entry.text}`) .map((r) => `- [${r.entry.category}] ${r.entry.text}`)
@@ -490,12 +516,16 @@ const memoryPlugin = {
const texts: string[] = []; const texts: string[] = [];
for (const msg of event.messages) { for (const msg of event.messages) {
// Type guard for message object // Type guard for message object
if (!msg || typeof msg !== "object") continue; if (!msg || typeof msg !== "object") {
continue;
}
const msgObj = msg as Record<string, unknown>; const msgObj = msg as Record<string, unknown>;
// Only process user and assistant messages // Only process user and assistant messages
const role = msgObj.role; const role = msgObj.role;
if (role !== "user" && role !== "assistant") continue; if (role !== "user" && role !== "assistant") {
continue;
}
const content = msgObj.content; const content = msgObj.content;
@@ -524,7 +554,9 @@ const memoryPlugin = {
// Filter for capturable content // Filter for capturable content
const toCapture = texts.filter((text) => text && shouldCapture(text)); const toCapture = texts.filter((text) => text && shouldCapture(text));
if (toCapture.length === 0) return; if (toCapture.length === 0) {
return;
}
// Store each capturable piece (limit to 3 per conversation) // Store each capturable piece (limit to 3 per conversation)
let stored = 0; let stored = 0;
@@ -534,7 +566,9 @@ const memoryPlugin = {
// Check for duplicates (high similarity threshold) // Check for duplicates (high similarity threshold)
const existing = await db.search(vector, 1, 0.95); const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) continue; if (existing.length > 0) {
continue;
}
await db.store({ await db.store({
text, text,

View File

@@ -26,10 +26,14 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
const name = typeof att.name === "string" ? att.name.trim() : ""; const name = typeof att.name === "string" ? att.name.trim() : "";
if (contentType === "application/vnd.microsoft.teams.file.download.info") { if (contentType === "application/vnd.microsoft.teams.file.download.info") {
if (!isRecord(att.content)) return null; if (!isRecord(att.content)) {
return null;
}
const downloadUrl = const downloadUrl =
typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : ""; typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
if (!downloadUrl) return null; if (!downloadUrl) {
return null;
}
const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : ""; const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : ""; const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
@@ -49,7 +53,9 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
} }
const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : ""; const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
if (!contentUrl) return null; if (!contentUrl) {
return null;
}
return { return {
url: contentUrl, url: contentUrl,
@@ -82,9 +88,15 @@ async function fetchWithAuthFallback(params: {
}): Promise<Response> { }): Promise<Response> {
const fetchFn = params.fetchFn ?? fetch; const fetchFn = params.fetchFn ?? fetch;
const firstAttempt = await fetchFn(params.url); const firstAttempt = await fetchFn(params.url);
if (firstAttempt.ok) return firstAttempt; if (firstAttempt.ok) {
if (!params.tokenProvider) return firstAttempt; return firstAttempt;
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt; }
if (!params.tokenProvider) {
return firstAttempt;
}
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
return firstAttempt;
}
const scopes = scopeCandidatesForUrl(params.url); const scopes = scopeCandidatesForUrl(params.url);
for (const scope of scopes) { for (const scope of scopes) {
@@ -93,7 +105,9 @@ async function fetchWithAuthFallback(params: {
const res = await fetchFn(params.url, { const res = await fetchFn(params.url, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
if (res.ok) return res; if (res.ok) {
return res;
}
} catch { } catch {
// Try the next scope. // Try the next scope.
} }
@@ -116,7 +130,9 @@ export async function downloadMSTeamsAttachments(params: {
preserveFilenames?: boolean; preserveFilenames?: boolean;
}): Promise<MSTeamsInboundMedia[]> { }): Promise<MSTeamsInboundMedia[]> {
const list = Array.isArray(params.attachments) ? params.attachments : []; const list = Array.isArray(params.attachments) ? params.attachments : [];
if (list.length === 0) return []; if (list.length === 0) {
return [];
}
const allowHosts = resolveAllowedHosts(params.allowHosts); const allowHosts = resolveAllowedHosts(params.allowHosts);
// Download ANY downloadable attachment (not just images) // Download ANY downloadable attachment (not just images)
@@ -130,8 +146,12 @@ export async function downloadMSTeamsAttachments(params: {
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
for (const inline of inlineCandidates) { for (const inline of inlineCandidates) {
if (inline.kind === "url") { if (inline.kind === "url") {
if (!isUrlAllowed(inline.url, allowHosts)) continue; if (!isUrlAllowed(inline.url, allowHosts)) {
if (seenUrls.has(inline.url)) continue; continue;
}
if (seenUrls.has(inline.url)) {
continue;
}
seenUrls.add(inline.url); seenUrls.add(inline.url);
candidates.push({ candidates.push({
url: inline.url, url: inline.url,
@@ -141,12 +161,18 @@ export async function downloadMSTeamsAttachments(params: {
}); });
} }
} }
if (candidates.length === 0 && inlineCandidates.length === 0) return []; if (candidates.length === 0 && inlineCandidates.length === 0) {
return [];
}
const out: MSTeamsInboundMedia[] = []; const out: MSTeamsInboundMedia[] = [];
for (const inline of inlineCandidates) { for (const inline of inlineCandidates) {
if (inline.kind !== "data") continue; if (inline.kind !== "data") {
if (inline.data.byteLength > params.maxBytes) continue; continue;
}
if (inline.data.byteLength > params.maxBytes) {
continue;
}
try { try {
// Data inline candidates (base64 data URLs) don't have original filenames // Data inline candidates (base64 data URLs) don't have original filenames
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
@@ -165,16 +191,22 @@ export async function downloadMSTeamsAttachments(params: {
} }
} }
for (const candidate of candidates) { for (const candidate of candidates) {
if (!isUrlAllowed(candidate.url, allowHosts)) continue; if (!isUrlAllowed(candidate.url, allowHosts)) {
continue;
}
try { try {
const res = await fetchWithAuthFallback({ const res = await fetchWithAuthFallback({
url: candidate.url, url: candidate.url,
tokenProvider: params.tokenProvider, tokenProvider: params.tokenProvider,
fetchFn: params.fetchFn, fetchFn: params.fetchFn,
}); });
if (!res.ok) continue; if (!res.ok) {
continue;
}
const buffer = Buffer.from(await res.arrayBuffer()); const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.byteLength > params.maxBytes) continue; if (buffer.byteLength > params.maxBytes) {
continue;
}
const mime = await getMSTeamsRuntime().media.detectMime({ const mime = await getMSTeamsRuntime().media.detectMime({
buffer, buffer,
headerMime: res.headers.get("content-type"), headerMime: res.headers.get("content-type"),

View File

@@ -32,7 +32,9 @@ type GraphAttachment = {
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined { function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
let current: unknown = value; let current: unknown = value;
for (const key of keys) { for (const key of keys) {
if (!isRecord(current)) return undefined; if (!isRecord(current)) {
return undefined;
}
current = current[key as keyof typeof current]; current = current[key as keyof typeof current];
} }
return typeof current === "string" && current.trim() ? current.trim() : undefined; return typeof current === "string" && current.trim() ? current.trim() : undefined;
@@ -50,7 +52,9 @@ export function buildMSTeamsGraphMessageUrls(params: {
const messageIdCandidates = new Set<string>(); const messageIdCandidates = new Set<string>();
const pushCandidate = (value: string | null | undefined) => { const pushCandidate = (value: string | null | undefined) => {
const trimmed = typeof value === "string" ? value.trim() : ""; const trimmed = typeof value === "string" ? value.trim() : "";
if (trimmed) messageIdCandidates.add(trimmed); if (trimmed) {
messageIdCandidates.add(trimmed);
}
}; };
pushCandidate(params.messageId); pushCandidate(params.messageId);
@@ -68,17 +72,23 @@ export function buildMSTeamsGraphMessageUrls(params: {
readNestedString(params.channelData, ["channel", "id"]) ?? readNestedString(params.channelData, ["channel", "id"]) ??
readNestedString(params.channelData, ["channelId"]) ?? readNestedString(params.channelData, ["channelId"]) ??
readNestedString(params.channelData, ["teamsChannelId"]); readNestedString(params.channelData, ["teamsChannelId"]);
if (!teamId || !channelId) return []; if (!teamId || !channelId) {
return [];
}
const urls: string[] = []; const urls: string[] = [];
if (replyToId) { if (replyToId) {
for (const candidate of messageIdCandidates) { for (const candidate of messageIdCandidates) {
if (candidate === replyToId) continue; if (candidate === replyToId) {
continue;
}
urls.push( urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`, `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
); );
} }
} }
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId); if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
for (const candidate of messageIdCandidates) { for (const candidate of messageIdCandidates) {
urls.push( urls.push(
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`, `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
@@ -88,8 +98,12 @@ export function buildMSTeamsGraphMessageUrls(params: {
} }
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]); const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
if (!chatId) return []; if (!chatId) {
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId); return [];
}
if (messageIdCandidates.size === 0 && replyToId) {
messageIdCandidates.add(replyToId);
}
const urls = Array.from(messageIdCandidates).map( const urls = Array.from(messageIdCandidates).map(
(candidate) => (candidate) =>
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`, `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
@@ -107,7 +121,9 @@ async function fetchGraphCollection<T>(params: {
headers: { Authorization: `Bearer ${params.accessToken}` }, headers: { Authorization: `Bearer ${params.accessToken}` },
}); });
const status = res.status; const status = res.status;
if (!res.ok) return { status, items: [] }; if (!res.ok) {
return { status, items: [] };
}
try { try {
const data = (await res.json()) as { value?: T[] }; const data = (await res.json()) as { value?: T[] };
return { status, items: Array.isArray(data.value) ? data.value : [] }; return { status, items: Array.isArray(data.value) ? data.value : [] };
@@ -157,14 +173,18 @@ async function downloadGraphHostedContent(params: {
const out: MSTeamsInboundMedia[] = []; const out: MSTeamsInboundMedia[] = [];
for (const item of hosted.items) { for (const item of hosted.items) {
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : ""; const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
if (!contentBytes) continue; if (!contentBytes) {
continue;
}
let buffer: Buffer; let buffer: Buffer;
try { try {
buffer = Buffer.from(contentBytes, "base64"); buffer = Buffer.from(contentBytes, "base64");
} catch { } catch {
continue; continue;
} }
if (buffer.byteLength > params.maxBytes) continue; if (buffer.byteLength > params.maxBytes) {
continue;
}
const mime = await getMSTeamsRuntime().media.detectMime({ const mime = await getMSTeamsRuntime().media.detectMime({
buffer, buffer,
headerMime: item.contentType ?? undefined, headerMime: item.contentType ?? undefined,
@@ -199,7 +219,9 @@ export async function downloadMSTeamsGraphMedia(params: {
/** When true, embeds original filename in stored path for later extraction. */ /** When true, embeds original filename in stored path for later extraction. */
preserveFilenames?: boolean; preserveFilenames?: boolean;
}): Promise<MSTeamsGraphMediaResult> { }): Promise<MSTeamsGraphMediaResult> {
if (!params.messageUrl || !params.tokenProvider) return { media: [] }; if (!params.messageUrl || !params.tokenProvider) {
return { media: [] };
}
const allowHosts = resolveAllowedHosts(params.allowHosts); const allowHosts = resolveAllowedHosts(params.allowHosts);
const messageUrl = params.messageUrl; const messageUrl = params.messageUrl;
let accessToken: string; let accessToken: string;
@@ -299,9 +321,13 @@ export async function downloadMSTeamsGraphMedia(params: {
sharePointMedia.length > 0 sharePointMedia.length > 0
? normalizedAttachments.filter((att) => { ? normalizedAttachments.filter((att) => {
const contentType = att.contentType?.toLowerCase(); const contentType = att.contentType?.toLowerCase();
if (contentType !== "reference") return true; if (contentType !== "reference") {
return true;
}
const url = typeof att.contentUrl === "string" ? att.contentUrl : ""; const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
if (!url) return true; if (!url) {
return true;
}
return !downloadedReferenceUrls.has(url); return !downloadedReferenceUrls.has(url);
}) })
: normalizedAttachments; : normalizedAttachments;

View File

@@ -12,7 +12,9 @@ export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined, attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined { ): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : []; const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return undefined; if (list.length === 0) {
return undefined;
}
let htmlAttachments = 0; let htmlAttachments = 0;
let imgTags = 0; let imgTags = 0;
let dataImages = 0; let dataImages = 0;
@@ -23,7 +25,9 @@ export function summarizeMSTeamsHtmlAttachments(
for (const att of list) { for (const att of list) {
const html = extractHtmlFromAttachment(att); const html = extractHtmlFromAttachment(att);
if (!html) continue; if (!html) {
continue;
}
htmlAttachments += 1; htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0; IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html); let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
@@ -31,9 +35,13 @@ export function summarizeMSTeamsHtmlAttachments(
imgTags += 1; imgTags += 1;
const src = match[1]?.trim(); const src = match[1]?.trim();
if (src) { if (src) {
if (src.startsWith("data:")) dataImages += 1; if (src.startsWith("data:")) {
else if (src.startsWith("cid:")) cidImages += 1; dataImages += 1;
else srcHosts.add(safeHostForUrl(src)); } else if (src.startsWith("cid:")) {
cidImages += 1;
} else {
srcHosts.add(safeHostForUrl(src));
}
} }
match = IMG_SRC_RE.exec(html); match = IMG_SRC_RE.exec(html);
} }
@@ -43,12 +51,16 @@ export function summarizeMSTeamsHtmlAttachments(
while (attachmentMatch) { while (attachmentMatch) {
attachmentTags += 1; attachmentTags += 1;
const id = attachmentMatch[1]?.trim(); const id = attachmentMatch[1]?.trim();
if (id) attachmentIds.add(id); if (id) {
attachmentIds.add(id);
}
attachmentMatch = ATTACHMENT_TAG_RE.exec(html); attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
} }
} }
if (htmlAttachments === 0) return undefined; if (htmlAttachments === 0) {
return undefined;
}
return { return {
htmlAttachments, htmlAttachments,
imgTags, imgTags,
@@ -64,7 +76,9 @@ export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined, attachments: MSTeamsAttachmentLike[] | undefined,
): string { ): string {
const list = Array.isArray(attachments) ? attachments : []; const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return ""; if (list.length === 0) {
return "";
}
const imageCount = list.filter(isLikelyImageAttachment).length; const imageCount = list.filter(isLikelyImageAttachment).length;
const inlineCount = extractInlineImageCandidates(list).length; const inlineCount = extractInlineImageCandidates(list).length;
const totalImages = imageCount + inlineCount; const totalImages = imageCount + inlineCount;

View File

@@ -55,7 +55,9 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
} }
export function normalizeContentType(value: unknown): string | undefined { export function normalizeContentType(value: unknown): string | undefined {
if (typeof value !== "string") return undefined; if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed ? trimmed : undefined; return trimmed ? trimmed : undefined;
} }
@@ -78,17 +80,25 @@ export function inferPlaceholder(params: {
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean { export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? ""; const contentType = normalizeContentType(att.contentType) ?? "";
const name = typeof att.name === "string" ? att.name : ""; const name = typeof att.name === "string" ? att.name : "";
if (contentType.startsWith("image/")) return true; if (contentType.startsWith("image/")) {
if (IMAGE_EXT_RE.test(name)) return true; return true;
}
if (IMAGE_EXT_RE.test(name)) {
return true;
}
if ( if (
contentType === "application/vnd.microsoft.teams.file.download.info" && contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content) isRecord(att.content)
) { ) {
const fileType = typeof att.content.fileType === "string" ? att.content.fileType : ""; const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true; if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) {
return true;
}
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : ""; const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
if (fileName && IMAGE_EXT_RE.test(fileName)) return true; if (fileName && IMAGE_EXT_RE.test(fileName)) {
return true;
}
} }
return false; return false;
@@ -124,9 +134,15 @@ function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
} }
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined { export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
if (!isHtmlAttachment(att)) return undefined; if (!isHtmlAttachment(att)) {
if (typeof att.content === "string") return att.content; return undefined;
if (!isRecord(att.content)) return undefined; }
if (typeof att.content === "string") {
return att.content;
}
if (!isRecord(att.content)) {
return undefined;
}
const text = const text =
typeof att.content.text === "string" typeof att.content.text === "string"
? att.content.text ? att.content.text
@@ -140,12 +156,18 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string |
function decodeDataImage(src: string): InlineImageCandidate | null { function decodeDataImage(src: string): InlineImageCandidate | null {
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src); const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
if (!match) return null; if (!match) {
return null;
}
const contentType = match[1]?.toLowerCase(); const contentType = match[1]?.toLowerCase();
const isBase64 = Boolean(match[2]); const isBase64 = Boolean(match[2]);
if (!isBase64) return null; if (!isBase64) {
return null;
}
const payload = match[3] ?? ""; const payload = match[3] ?? "";
if (!payload) return null; if (!payload) {
return null;
}
try { try {
const data = Buffer.from(payload, "base64"); const data = Buffer.from(payload, "base64");
return { kind: "data", data, contentType, placeholder: "<media:image>" }; return { kind: "data", data, contentType, placeholder: "<media:image>" };
@@ -170,7 +192,9 @@ export function extractInlineImageCandidates(
const out: InlineImageCandidate[] = []; const out: InlineImageCandidate[] = [];
for (const att of attachments) { for (const att of attachments) {
const html = extractHtmlFromAttachment(att); const html = extractHtmlFromAttachment(att);
if (!html) continue; if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0; IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html); let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) { while (match) {
@@ -178,7 +202,9 @@ export function extractInlineImageCandidates(
if (src && !src.startsWith("cid:")) { if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) { if (src.startsWith("data:")) {
const decoded = decodeDataImage(src); const decoded = decodeDataImage(src);
if (decoded) out.push(decoded); if (decoded) {
out.push(decoded);
}
} else { } else {
out.push({ out.push({
kind: "url", kind: "url",
@@ -204,8 +230,12 @@ export function safeHostForUrl(url: string): string {
function normalizeAllowHost(value: string): string { function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase(); const trimmed = value.trim().toLowerCase();
if (!trimmed) return ""; if (!trimmed) {
if (trimmed === "*") return "*"; return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed.replace(/^\*\.?/, ""); return trimmed.replace(/^\*\.?/, "");
} }
@@ -214,12 +244,16 @@ export function resolveAllowedHosts(input?: string[]): string[] {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice(); return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
} }
const normalized = input.map(normalizeAllowHost).filter(Boolean); const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) return ["*"]; if (normalized.includes("*")) {
return ["*"];
}
return normalized; return normalized;
} }
function isHostAllowed(host: string, allowlist: string[]): boolean { function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) return true; if (allowlist.includes("*")) {
return true;
}
const normalized = host.toLowerCase(); const normalized = host.toLowerCase();
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`)); return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
} }
@@ -227,7 +261,9 @@ function isHostAllowed(host: string, allowlist: string[]): boolean {
export function isUrlAllowed(url: string, allowlist: string[]): boolean { export function isUrlAllowed(url: string, allowlist: string[]): boolean {
try { try {
const parsed = new URL(url); const parsed = new URL(url);
if (parsed.protocol !== "https:") return false; if (parsed.protocol !== "https:") {
return false;
}
return isHostAllowed(parsed.hostname, allowlist); return isHostAllowed(parsed.hostname, allowlist);
} catch { } catch {
return false; return false;

View File

@@ -126,7 +126,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
collectWarnings: ({ cfg }) => { collectWarnings: ({ cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return []; if (groupPolicy !== "open") {
return [];
}
return [ return [
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`, `- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
]; ];
@@ -150,8 +152,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
targetResolver: { targetResolver: {
looksLikeId: (raw) => { looksLikeId: (raw) => {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return false; if (!trimmed) {
if (/^conversation:/i.test(trimmed)) return true; return false;
}
if (/^conversation:/i.test(trimmed)) {
return true;
}
if (/^user:/i.test(trimmed)) { if (/^user:/i.test(trimmed)) {
// Only treat as ID if the value after user: looks like a UUID // Only treat as ID if the value after user: looks like a UUID
const id = trimmed.slice("user:".length).trim(); const id = trimmed.slice("user:".length).trim();
@@ -169,11 +175,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
const ids = new Set<string>(); const ids = new Set<string>();
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) { for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
const trimmed = String(entry).trim(); const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") ids.add(trimmed); if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
} }
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) { for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (trimmed) ids.add(trimmed); if (trimmed) {
ids.add(trimmed);
}
} }
return Array.from(ids) return Array.from(ids)
.map((raw) => raw.trim()) .map((raw) => raw.trim())
@@ -181,8 +191,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw) .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
.map((raw) => { .map((raw) => {
const lowered = raw.toLowerCase(); const lowered = raw.toLowerCase();
if (lowered.startsWith("user:")) return raw; if (lowered.startsWith("user:")) {
if (lowered.startsWith("conversation:")) return raw; return raw;
}
if (lowered.startsWith("conversation:")) {
return raw;
}
return `user:${raw}`; return `user:${raw}`;
}) })
.filter((id) => (q ? id.toLowerCase().includes(q) : true)) .filter((id) => (q ? id.toLowerCase().includes(q) : true))
@@ -195,7 +209,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) { for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
for (const channelId of Object.keys(team.channels ?? {})) { for (const channelId of Object.keys(team.channels ?? {})) {
const trimmed = channelId.trim(); const trimmed = channelId.trim();
if (trimmed && trimmed !== "*") ids.add(trimmed); if (trimmed && trimmed !== "*") {
ids.add(trimmed);
}
} }
} }
return Array.from(ids) return Array.from(ids)
@@ -249,7 +265,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}); });
resolved.forEach((entry, idx) => { resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1]; const target = results[pending[idx]?.index ?? -1];
if (!target) return; if (!target) {
return;
}
target.resolved = entry.resolved; target.resolved = entry.resolved;
target.id = entry.id; target.id = entry.id;
target.name = entry.name; target.name = entry.name;
@@ -259,7 +277,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
runtime.error?.(`msteams resolve failed: ${String(err)}`); runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => { pending.forEach(({ index }) => {
const entry = results[index]; const entry = results[index];
if (entry) entry.note = "lookup failed"; if (entry) {
entry.note = "lookup failed";
}
}); });
} }
} }
@@ -298,7 +318,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}); });
resolved.forEach((entry, idx) => { resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1]; const target = results[pending[idx]?.index ?? -1];
if (!target) return; if (!target) {
return;
}
if (!entry.resolved || !entry.teamId) { if (!entry.resolved || !entry.teamId) {
target.resolved = false; target.resolved = false;
target.note = entry.note; target.note = entry.note;
@@ -316,13 +338,17 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
target.name = entry.teamName; target.name = entry.teamName;
target.note = "team id"; target.note = "team id";
} }
if (entry.note) target.note = entry.note; if (entry.note) {
target.note = entry.note;
}
}); });
} catch (err) { } catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`); runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => { pending.forEach(({ index }) => {
const entry = results[index]; const entry = results[index];
if (entry) entry.note = "lookup failed"; if (entry) {
entry.note = "lookup failed";
}
}); });
} }
} }
@@ -335,7 +361,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
const enabled = const enabled =
cfg.channels?.msteams?.enabled !== false && cfg.channels?.msteams?.enabled !== false &&
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
if (!enabled) return []; if (!enabled) {
return [];
}
return ["poll"] satisfies ChannelMessageActionName[]; return ["poll"] satisfies ChannelMessageActionName[];
}, },
supportsCards: ({ cfg }) => { supportsCards: ({ cfg }) => {

View File

@@ -13,7 +13,9 @@ const runtimeStub = {
state: { state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) return override; if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir(); const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw"); return path.join(resolvedHome, ".openclaw");
}, },
@@ -66,7 +68,7 @@ describe("msteams conversation store (fs)", () => {
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`); await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
const list = await store.list(); const list = await store.list();
const ids = list.map((e) => e.conversationId).sort(); const ids = list.map((e) => e.conversationId).toSorted();
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]); expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
expect(await store.get("19:old@thread.tacv2")).toBeNull(); expect(await store.get("19:old@thread.tacv2")).toBeNull();
@@ -79,7 +81,7 @@ describe("msteams conversation store (fs)", () => {
const rawAfter = await fs.promises.readFile(filePath, "utf-8"); const rawAfter = await fs.promises.readFile(filePath, "utf-8");
const jsonAfter = JSON.parse(rawAfter) as typeof json; const jsonAfter = JSON.parse(rawAfter) as typeof json;
expect(Object.keys(jsonAfter.conversations).sort()).toEqual([ expect(Object.keys(jsonAfter.conversations).toSorted()).toEqual([
"19:active@thread.tacv2", "19:active@thread.tacv2",
"19:legacy@thread.tacv2", "19:legacy@thread.tacv2",
"19:new@thread.tacv2", "19:new@thread.tacv2",

View File

@@ -16,9 +16,13 @@ const MAX_CONVERSATIONS = 1000;
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000; const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
function parseTimestamp(value: string | undefined): number | null { function parseTimestamp(value: string | undefined): number | null {
if (!value) return null; if (!value) {
return null;
}
const parsed = Date.parse(value); const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return null; if (!Number.isFinite(parsed)) {
return null;
}
return parsed; return parsed;
} }
@@ -26,7 +30,9 @@ function pruneToLimit(
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>, conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
) { ) {
const entries = Object.entries(conversations); const entries = Object.entries(conversations);
if (entries.length <= MAX_CONVERSATIONS) return conversations; if (entries.length <= MAX_CONVERSATIONS) {
return conversations;
}
entries.sort((a, b) => { entries.sort((a, b) => {
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0; const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
@@ -109,7 +115,9 @@ export function createMSTeamsConversationStoreFs(params?: {
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => { const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
const target = id.trim(); const target = id.trim();
if (!target) return null; if (!target) {
return null;
}
for (const entry of await list()) { for (const entry of await list()) {
const { conversationId, reference } = entry; const { conversationId, reference } = entry;
if (reference.user?.aadObjectId === target) { if (reference.user?.aadObjectId === target) {
@@ -144,7 +152,9 @@ export function createMSTeamsConversationStoreFs(params?: {
const normalizedId = normalizeConversationId(conversationId); const normalizedId = normalizeConversationId(conversationId);
return await withFileLock(filePath, empty, async () => { return await withFileLock(filePath, empty, async () => {
const store = await readStore(); const store = await readStore();
if (!(normalizedId in store.conversations)) return false; if (!(normalizedId in store.conversations)) {
return false;
}
delete store.conversations[normalizedId]; delete store.conversations[normalizedId];
await writeJsonFile(filePath, store); await writeJsonFile(filePath, store);
return true; return true;

View File

@@ -30,7 +30,9 @@ export function createMSTeamsConversationStoreMemory(
}, },
findByUserId: async (id) => { findByUserId: async (id) => {
const target = id.trim(); const target = id.trim();
if (!target) return null; if (!target) {
return null;
}
for (const [conversationId, reference] of map.entries()) { for (const [conversationId, reference] of map.entries()) {
if (reference.user?.aadObjectId === target) { if (reference.user?.aadObjectId === target) {
return { conversationId, reference }; return { conversationId, reference };

Some files were not shown because too many files have changed in this diff Show More