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",
"Swabble/",
"vendor/",
"extensions/",
"ui/"
]
}

View File

@@ -50,20 +50,30 @@ export default function (pi: ExtensionAPI) {
const files: FileInfo[] = [];
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 file = line.slice(2).trimStart();
// Translate status codes to short labels
let statusLabel: string;
if (status.includes("M")) statusLabel = "M";
else if (status.includes("A")) statusLabel = "A";
else if (status.includes("D")) statusLabel = "D";
else if (status.includes("?")) statusLabel = "?";
else if (status.includes("R")) statusLabel = "R";
else if (status.includes("C")) statusLabel = "C";
else statusLabel = status.trim() || "~";
if (status.includes("M")) {
statusLabel = "M";
} else if (status.includes("A")) {
statusLabel = "A";
} else if (status.includes("D")) {
statusLabel = "D";
} 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 });
}

View File

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

View File

@@ -47,7 +47,9 @@ async function fetchGhMetadata(
try {
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;
} catch {
return undefined;
@@ -55,12 +57,20 @@ async function fetchGhMetadata(
}
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
if (!author) return undefined;
if (!author) {
return undefined;
}
const name = author.name?.trim();
const login = author.login?.trim();
if (name && login) return `${name} (@${login})`;
if (login) return `@${login}`;
if (name) return name;
if (name && login) {
return `${name} (@${login})`;
}
if (login) {
return `@${login}`;
}
if (name) {
return name;
}
return undefined;
}
@@ -77,7 +87,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
const urlLine = thm.fg("dim", match.url);
const lines = [titleText];
if (authorLine) lines.push(authorLine);
if (authorLine) {
lines.push(authorLine);
}
lines.push(urlLine);
const container = new Container();
@@ -103,7 +115,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
};
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) return;
if (!ctx.hasUI) {
return;
}
const match = extractPromptMatch(event.prompt);
if (!match) {
return;
@@ -124,8 +138,12 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
});
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
if (!content) return "";
if (typeof content === "string") return content;
if (!content) {
return "";
}
if (typeof content === "string") {
return content;
}
return (
content
.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) => {
if (!ctx.hasUI) return;
if (!ctx.hasUI) {
return;
}
const entries = ctx.sessionManager.getEntries();
const lastMatch = [...entries].reverse().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") return false;
const lastMatch = [...entries].toReversed().find((entry) => {
if (entry.type !== "message" || entry.message.role !== "user") {
return false;
}
const text = getUserText(entry.message.content);
return !!extractPromptMatch(text);
});

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelToolSend,
type OpenClawConfig,
} from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
@@ -34,8 +33,12 @@ const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_identifier") {
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 {
const raw = params[key];
if (typeof raw === "boolean") return raw;
if (typeof raw === "boolean") {
return raw;
}
if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (trimmed === "true") {
return true;
}
if (trimmed === "false") {
return false;
}
}
return undefined;
}
@@ -66,41 +75,55 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_N
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig });
if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as OpenClawConfig).channels?.bluebubbles?.actions);
const account = resolveBlueBubblesAccount({ cfg: cfg });
if (!account.enabled || !account.configured) {
return [];
}
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
const macOS26 = isMacOS26OrHigher(account.accountId);
for (const action of BLUEBUBBLES_ACTION_NAMES) {
const spec = BLUEBUBBLES_ACTIONS[action];
if (!spec?.gate) continue;
if (spec.unsupportedOnMacOS26 && macOS26) continue;
if (gate(spec.gate)) actions.add(action);
if (!spec?.gate) {
continue;
}
if (spec.unsupportedOnMacOS26 && macOS26) {
continue;
}
if (gate(spec.gate)) {
actions.add(action);
}
}
return Array.from(actions);
},
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
extractToolSend: ({ args }): ChannelToolSend | null => {
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;
if (!to) return null;
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const account = resolveBlueBubblesAccount({
cfg: cfg as OpenClawConfig,
cfg: cfg,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.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
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) return chatGuid.trim();
if (chatGuid?.trim()) {
return chatGuid.trim();
}
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
@@ -185,8 +208,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
readStringParam(params, "message");
if (!rawMessageId || !newText) {
const missing: string[] = [];
if (!rawMessageId) missing.push("messageId (the message ID to edit)");
if (!newText) missing.push("text (the new message content)");
if (!rawMessageId) {
missing.push("messageId (the message ID to edit)");
}
if (!newText) {
missing.push("text (the new message content)");
}
throw new Error(
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
`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");
if (!rawMessageId || !text || !to) {
const missing: string[] = [];
if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
if (!text) missing.push("text or message (the reply message content)");
if (!to) missing.push("to or target (the chat target)");
if (!rawMessageId) {
missing.push("messageId (the message ID to reply to)");
}
if (!text) {
missing.push("text or message (the reply message content)");
}
if (!to) {
missing.push("to or target (the chat target)");
}
throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
`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");
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) missing.push("text or message (the message content)");
if (!to) missing.push("to or target (the chat target)");
if (!effectId)
if (!text) {
missing.push("text or message (the message content)");
}
if (!to) {
missing.push("to or target (the chat target)");
}
if (!effectId) {
missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
);
}
throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
`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 {
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;
return `${base || fallbackBase}${extension}`;
}
@@ -54,8 +56,12 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password };
}
@@ -64,7 +70,9 @@ export async function downloadBlueBubblesAttachment(
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
): Promise<{ buffer: Uint8Array; contentType?: string }> {
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 url = buildBlueBubblesApiUrl({
baseUrl,
@@ -110,7 +118,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
}
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 data =
record.data && typeof record.data === "object"
@@ -125,8 +135,12 @@ function extractMessageId(payload: unknown): string {
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
}
return "unknown";
}
@@ -274,7 +288,9 @@ export async function sendBlueBubblesAttachment(params: {
}
const responseBody = await res.text();
if (!responseBody) return { messageId: "ok" };
if (!responseBody) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(responseBody) as unknown;
return { messageId: extractMessageId(parsed) };

View File

@@ -78,13 +78,12 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
onboarding: blueBubblesOnboardingAdapter,
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as OpenClawConfig),
resolveAccount: (cfg, accountId) =>
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as OpenClawConfig,
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
enabled,
@@ -92,7 +91,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as OpenClawConfig,
cfg: cfg,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
@@ -106,9 +105,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ?? []
).map((entry) => String(entry)),
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
@@ -120,9 +119,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
);
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles.";
@@ -137,7 +134,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
if (groupPolicy !== "open") {
return [];
}
return [
`- 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 }) => {
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);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
if (!trimmed) {
return null;
}
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
if (handle) {
return handle;
}
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
@@ -178,9 +183,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle;
if (handle) {
return handle;
}
// 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;
};
@@ -191,12 +200,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay;
if (cleanDisplay) {
return cleanDisplay;
}
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget;
if (cleanTarget) {
return cleanTarget;
}
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
@@ -206,7 +219,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig,
cfg: cfg,
channelKey: "bluebubbles",
accountId,
name,
@@ -215,13 +228,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password.";
}
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
if (!input.password) return "BlueBubbles requires --password.";
if (!input.httpUrl) {
return "BlueBubbles requires --http-url.";
}
if (!input.password) {
return "BlueBubbles requires --password.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as OpenClawConfig,
cfg: cfg,
channelKey: "bluebubbles",
accountId,
name: input.name,
@@ -256,9 +273,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...(next.channels?.bluebubbles?.accounts ?? {}),
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
@@ -275,7 +292,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
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 })
: "";
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as OpenClawConfig,
cfg: cfg,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
@@ -316,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
};
const resolvedCaption = caption ?? text;
const result = await sendBlueBubblesMedia({
cfg: cfg as OpenClawConfig,
cfg: cfg,
to,
mediaUrl,
mediaPath,
@@ -387,7 +404,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({
account,
config: ctx.cfg as OpenClawConfig,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
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 password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password };
}
@@ -28,7 +32,9 @@ export async function markBlueBubblesChatRead(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
if (!trimmed) {
return;
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
@@ -48,7 +54,9 @@ export async function sendBlueBubblesTyping(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
if (!trimmed) {
return;
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
@@ -76,9 +84,13 @@ export async function editBlueBubblesMessage(
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
): Promise<void> {
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();
if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
if (!trimmedText) {
throw new Error("BlueBubbles edit requires newText");
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
@@ -118,7 +130,9 @@ export async function unsendBlueBubblesMessage(
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
): Promise<void> {
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 url = buildBlueBubblesApiUrl({
@@ -156,7 +170,9 @@ export async function renameBlueBubblesChat(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
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 url = buildBlueBubblesApiUrl({
@@ -190,9 +206,13 @@ export async function addBlueBubblesParticipant(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
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();
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
if (!trimmedAddress) {
throw new Error("BlueBubbles addParticipant requires address");
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
@@ -226,9 +246,13 @@ export async function removeBlueBubblesParticipant(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
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();
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
if (!trimmedAddress) {
throw new Error("BlueBubbles removeParticipant requires address");
}
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
@@ -263,7 +287,9 @@ export async function leaveBlueBubblesChat(
opts: BlueBubblesChatOpts = {},
): Promise<void> {
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 url = buildBlueBubblesApiUrl({
@@ -291,7 +317,9 @@ export async function setGroupIconBlueBubbles(
opts: BlueBubblesChatOpts & { contentType?: string } = {},
): Promise<void> {
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) {
throw new Error("BlueBubbles setGroupIcon requires image buffer");
}

View File

@@ -12,15 +12,21 @@ const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024;
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
if (typeof maxBytes !== "number" || maxBytes <= 0) return;
if (sizeBytes <= maxBytes) return;
if (typeof maxBytes !== "number" || maxBytes <= 0) {
return;
}
if (sizeBytes <= maxBytes) {
return;
}
const maxLabel = (maxBytes / MB).toFixed(0);
const sizeLabel = (sizeBytes / MB).toFixed(2);
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
}
function resolveLocalMediaPath(source: string): string {
if (!source.startsWith("file://")) return source;
if (!source.startsWith("file://")) {
return source;
}
try {
return fileURLToPath(source);
} catch {
@@ -29,7 +35,9 @@ function resolveLocalMediaPath(source: string): string {
}
function resolveFilenameFromSource(source?: string): string | undefined {
if (!source) return undefined;
if (!source) {
return undefined;
}
if (source.startsWith("file://")) {
try {
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" };
// Emit body data after a microtask
// oxlint-disable-next-line no-floating-promises
Promise.resolve().then(() => {
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
req.emit("data", Buffer.from(bodyStr));
@@ -1225,7 +1226,9 @@ describe("BlueBubbles webhook monitor", () => {
const flush = async (key: string) => {
const bucket = buckets.get(key);
if (!bucket) return;
if (!bucket) {
return;
}
if (bucket.timer) {
clearTimeout(bucket.timer);
bucket.timer = null;
@@ -1253,7 +1256,9 @@ describe("BlueBubbles webhook monitor", () => {
const existing = buckets.get(key);
const bucket = existing ?? { items: [], timer: null };
bucket.items.push(item);
if (bucket.timer) clearTimeout(bucket.timer);
if (bucket.timer) {
clearTimeout(bucket.timer);
}
bucket.timer = setTimeout(async () => {
await flush(key);
}, params.debounceMs);

View File

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

View File

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

View File

@@ -36,7 +36,9 @@ export async function fetchBlueBubblesServerInfo(params: {
}): Promise<BlueBubblesServerInfo | null> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl || !password) return null;
if (!baseUrl || !password) {
return null;
}
const cacheKey = buildCacheKey(params.accountId);
const cached = serverInfoCache.get(cacheKey);
@@ -47,7 +49,9 @@ export async function fetchBlueBubblesServerInfo(params: {
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
try {
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 data = payload?.data as BlueBubblesServerInfo | undefined;
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.
*/
export function parseMacOSMajorVersion(version?: string | null): number | null {
if (!version) return null;
if (!version) {
return null;
}
const match = /^(\d+)/.exec(version.trim());
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 {
const info = getCachedBlueBubblesServerInfo(accountId);
if (!info?.os_version) return false;
if (!info?.os_version) {
return false;
}
const major = parseMacOSMajorVersion(info.os_version);
return major !== null && major >= 26;
}
@@ -104,8 +112,12 @@ export async function probeBlueBubbles(params: {
}): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
if (!password) return { ok: false, error: "password not configured" };
if (!baseUrl) {
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 });
try {
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 password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password };
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
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();
if (raw.startsWith("-")) raw = raw.slice(1);
if (raw.startsWith("-")) {
raw = raw.slice(1);
}
const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) {
@@ -145,8 +153,12 @@ export async function sendBlueBubblesReaction(params: {
}): Promise<void> {
const chatGuid = params.chatGuid.trim();
const messageGuid = params.messageGuid.trim();
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
if (!chatGuid) {
throw new Error("BlueBubbles reaction requires chatGuid.");
}
if (!messageGuid) {
throw new Error("BlueBubbles reaction requires messageGuid.");
}
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({

View File

@@ -55,13 +55,21 @@ const EFFECT_MAP: Record<string, string> = {
};
function resolveEffectId(raw?: string): string | undefined {
if (!raw) return undefined;
if (!raw) {
return undefined;
}
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, "-");
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
if (EFFECT_MAP[normalized]) {
return EFFECT_MAP[normalized];
}
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
if (EFFECT_MAP[compact]) {
return EFFECT_MAP[compact];
}
return raw;
}
@@ -84,7 +92,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
}
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 data =
record.data && typeof record.data === "object"
@@ -104,8 +114,12 @@ function extractMessageId(payload: unknown): string {
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
}
return "unknown";
}
@@ -122,7 +136,9 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
chat.chat_identifier,
];
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;
}
@@ -130,14 +146,18 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id];
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;
}
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) return null;
if (parts.length < 3) {
return null;
}
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
@@ -147,7 +167,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
(Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) return [];
if (!raw) {
return [];
}
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
@@ -161,7 +183,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
(typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier);
if (candidate) out.push(candidate);
if (candidate) {
out.push(candidate);
}
}
}
return out;
@@ -192,7 +216,9 @@ async function queryChats(params: {
},
params.timeoutMs,
);
if (!res.ok) return [];
if (!res.ok) {
return [];
}
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
@@ -204,7 +230,9 @@ export async function resolveChatGuidForTarget(params: {
timeoutMs?: number;
target: BlueBubblesSendTarget;
}): 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 =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
@@ -222,7 +250,9 @@ export async function resolveChatGuidForTarget(params: {
offset,
limit,
});
if (chats.length === 0) break;
if (chats.length === 0) {
break;
}
for (const chat of chats) {
if (targetChatId != null) {
const chatId = extractChatId(chat);
@@ -234,12 +264,16 @@ export async function resolveChatGuidForTarget(params: {
const guid = extractChatGuid(chat);
if (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
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
return guid;
}
}
const identifier =
@@ -250,7 +284,9 @@ export async function resolveChatGuidForTarget(params: {
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
if (identifier && identifier === targetChatIdentifier) {
return guid ?? extractChatGuid(chat);
}
}
if (normalizedHandle) {
const guid = extractChatGuid(chat);
@@ -322,7 +358,9 @@ async function createNewChatWithMessage(params: {
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
@@ -347,8 +385,12 @@ export async function sendMessageBlueBubbles(
});
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = opts.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
@@ -414,7 +456,9 @@ export async function sendMessageBlueBubbles(
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
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 {
const trimmed = value.trim();
if (!trimmed) return null;
if (!trimmed) {
return null;
}
const parts = trimmed.split(";");
if (parts.length !== 3) return null;
if (parts.length !== 3) {
return null;
}
const service = parts[0]?.trim();
const separator = parts[1]?.trim();
const identifier = parts[2]?.trim();
if (!service || !identifier) return null;
if (separator !== "+" && separator !== "-") return null;
if (!service || !identifier) {
return null;
}
if (separator !== "+" && separator !== "-") {
return null;
}
return `${service};${separator};${identifier}`;
}
@@ -42,26 +50,44 @@ function stripPrefix(value: string, prefix: string): string {
function stripBlueBubblesPrefix(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed;
if (!trimmed) {
return "";
}
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) {
return trimmed;
}
return trimmed.slice("bluebubbles:".length).trim();
}
function looksLikeRawChatIdentifier(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (/^chat\d+$/i.test(trimmed)) return true;
if (!trimmed) {
return false;
}
if (/^chat\d+$/i.test(trimmed)) {
return true;
}
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
if (!trimmed) {
return "";
}
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9));
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4));
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5));
if (trimmed.includes("@")) return trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) {
return normalizeBlueBubblesHandle(trimmed.slice(9));
}
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, "");
}
@@ -75,30 +101,44 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {
const handle = parts[2]?.trim();
if (handle) return normalizeBlueBubblesHandle(handle);
if (handle) {
return normalizeBlueBubblesHandle(handle);
}
}
return null;
}
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
let trimmed = raw.trim();
if (!trimmed) return undefined;
if (!trimmed) {
return undefined;
}
trimmed = stripBlueBubblesPrefix(trimmed);
if (!trimmed) return undefined;
if (!trimmed) {
return undefined;
}
try {
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") {
// For DM chat_guids, normalize to just the handle for easier comparison.
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
if (handle) {
return handle;
}
// For group chats or unrecognized formats, keep the full chat_guid
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);
if (!handle) return undefined;
if (!handle) {
return undefined;
}
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
} catch {
return trimmed;
@@ -107,12 +147,20 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) return false;
if (parseRawChatGuid(candidate)) return true;
if (!candidate) {
return false;
}
if (parseRawChatGuid(candidate)) {
return true;
}
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) return true;
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
@@ -121,14 +169,24 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return true;
}
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
if (/^chat\d+$/i.test(candidate)) return true;
if (looksLikeRawChatIdentifier(candidate)) return true;
if (candidate.includes("@")) return true;
if (/^chat\d+$/i.test(candidate)) {
return true;
}
if (looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (candidate.includes("@")) {
return true;
}
const digitsOnly = candidate.replace(/[\s().-]/g, "");
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
if (/^\+?\d{3,}$/.test(digitsOnly)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) return false;
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
@@ -142,13 +200,17 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
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();
for (const { prefix, service } of SERVICE_PREFIXES) {
if (lower.startsWith(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 isChatTarget =
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) {
if (lower.startsWith(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 };
}
}
@@ -184,7 +248,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lower.startsWith(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 };
}
}
@@ -195,7 +261,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
if (Number.isFinite(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 };
}
@@ -220,13 +288,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = raw.trim();
if (!trimmed) return { kind: "handle", handle: "" };
if (!trimmed) {
return { kind: "handle", handle: "" };
}
const lower = trimmed.toLowerCase();
for (const { prefix } of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) {
const remainder = stripPrefix(trimmed, prefix);
if (!remainder) return { kind: "handle", handle: "" };
if (!remainder) {
return { kind: "handle", handle: "" };
}
return parseBlueBubblesAllowTarget(remainder);
}
}
@@ -235,29 +307,39 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
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) {
if (lower.startsWith(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) {
if (lower.startsWith(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:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
if (value) return { kind: "chat_guid", chatGuid: value };
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (value) {
return { kind: "chat_guid", chatGuid: value };
}
}
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
@@ -282,8 +364,12 @@ export function isAllowedBlueBubblesSender(params: {
chatIdentifier?: string | null;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) return true;
if (allowFrom.includes("*")) return true;
if (allowFrom.length === 0) {
return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined;
@@ -291,16 +377,26 @@ export function isAllowedBlueBubblesSender(params: {
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) continue;
if (!entry) {
continue;
}
const parsed = parseBlueBubblesAllowTarget(entry);
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) {
if (parsed.chatGuid === chatGuid) return true;
if (parsed.chatGuid === chatGuid) {
return true;
}
} 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) {
if (parsed.handle === senderNormalized) return true;
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
@@ -315,8 +411,12 @@ export function formatBlueBubblesChatTarget(params: {
return `chat_id:${params.chatId}`;
}
const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`;
if (guid) {
return `chat_guid:${guid}`;
}
const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`;
if (identifier) {
return `chat_identifier:${identifier}`;
}
return "";
}

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,9 @@ function generatePkce(): { verifier: string; challenge: string } {
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
if (process.platform !== "linux") {
return false;
}
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
@@ -59,7 +61,9 @@ function isWSL(): boolean {
}
function isWSL2(): boolean {
if (!isWSL()) return false;
if (!isWSL()) {
return false;
}
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
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 } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
if (!trimmed) {
return { error: "No input provided" };
}
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter in URL" };
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter in URL" };
}
return { code, state };
} catch {
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) => {
resolveCallback = (url) => {
if (settled) return;
if (settled) {
return;
}
settled = true;
resolve(url);
};
rejectCallback = (err) => {
if (settled) return;
if (settled) {
return;
}
settled = true;
reject(err);
};
@@ -204,8 +218,12 @@ async function exchangeCode(params: {
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Token exchange returned no access_token");
if (!refresh) throw new Error("Token exchange returned no refresh_token");
if (!access) {
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;
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", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return undefined;
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { email?: string };
return data.email;
} 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 {
cloudaicompanionProject?: string | { id?: string };
};
@@ -342,12 +364,16 @@ async function loginAntigravity(params: {
params.progress.update("Waiting for redirect URL…");
const input = await params.prompt("Paste the redirect URL: ");
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;
returnedState = parsed.state;
}
if (!code) throw new Error("Missing OAuth code");
if (!code) {
throw new Error("Missing OAuth code");
}
if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again.");
}

View File

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

View File

@@ -43,7 +43,9 @@ export type GeminiCliOAuthContext = {
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
if (value) {
return value;
}
}
return undefined;
}
@@ -57,11 +59,15 @@ export function clearCredentialsCache(): void {
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath("gemini");
if (!geminiPath) return null;
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
@@ -97,9 +103,13 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
}
if (!content) {
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 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 ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) return p;
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) return null;
if (depth <= 0) {
return null;
}
try {
for (const e of readdirSync(dir, { withFileTypes: true })) {
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(".")) {
const found = findFile(p, name, depth - 1);
if (found) return found;
if (found) {
return found;
}
}
}
} catch {}
@@ -160,7 +178,9 @@ function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string }
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
if (process.platform !== "linux") {
return false;
}
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
@@ -170,7 +190,9 @@ function isWSL(): boolean {
}
function isWSL2(): boolean {
if (!isWSL()) return false;
if (!isWSL()) {
return false;
}
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
@@ -210,17 +232,25 @@ function parseCallbackInput(
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
if (!trimmed) {
return { error: "No input provided" };
}
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
if (!code) {
return { error: "Missing 'code' parameter in URL" };
}
if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." };
}
return { code, state };
} 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 };
}
}
@@ -289,7 +319,9 @@ async function waitForLocalCallback(params: {
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) clearTimeout(timeout);
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
@@ -427,14 +459,20 @@ async function discoverProject(accessToken: string): Promise<string> {
if (err instanceof Error) {
throw err;
}
throw new Error("loadCodeAssist failed");
throw new Error("loadCodeAssist failed", { cause: err });
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) return project;
if (typeof project === "object" && project?.id) return project.id;
if (envProject) return envProject;
if (typeof project === "string" && project) {
return project;
}
if (typeof project === "object" && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
throw new Error(
"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;
if (projectId) return projectId;
if (envProject) return envProject;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new Error(
"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 {
if (!payload || typeof payload !== "object") return false;
if (!payload || typeof payload !== "object") {
return false;
}
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;
if (!Array.isArray(details)) return false;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === "object" &&
@@ -507,7 +555,9 @@ function isVpcScAffected(payload: unknown): boolean {
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { 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 };
}
@@ -520,12 +570,16 @@ async function pollOperation(
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
headers,
});
if (!response.ok) continue;
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) return data;
if (data.done) {
return data;
}
}
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...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
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) {
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`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
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) {
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...");
return exchangeCodeForTokens(parsed.code, verifier);

View File

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

View File

@@ -39,7 +39,9 @@ function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cf
boolean | undefined
>,
);
if (gate("reactions")) return true;
if (gate("reactions")) {
return true;
}
}
return false;
}
@@ -50,11 +52,13 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
export const googlechatMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as OpenClawConfig);
if (accounts.length === 0) return [];
const accounts = listEnabledAccounts(cfg);
if (accounts.length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>([]);
actions.add("send");
if (isReactionsEnabled(accounts, cfg as OpenClawConfig)) {
if (isReactionsEnabled(accounts, cfg)) {
actions.add("react");
actions.add("reactions");
}
@@ -62,15 +66,19 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
},
extractToolSend: ({ args }) => {
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;
if (!to) return null;
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
const account = resolveGoogleChatAccount({
cfg: cfg as OpenClawConfig,
cfg: cfg,
accountId,
});
if (account.credentialSource === "none") {
@@ -134,12 +142,18 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
const appUsers = resolveAppUserNames(account);
const toRemove = reactions.filter((reaction) => {
const userName = reaction.user?.name?.trim();
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) return false;
if (emoji) return reaction.emoji?.unicode === emoji;
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
return false;
}
if (emoji) {
return reaction.emoji?.unicode === emoji;
}
return true;
});
for (const reaction of toRemove) {
if (!reaction.name) continue;
if (!reaction.name) {
continue;
}
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
}
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_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>(
account: ResolvedGoogleChatAccount,
url: string,
@@ -16,9 +23,9 @@ async function fetchJson<T>(
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
@@ -37,8 +44,8 @@ async function fetchOk(
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init.headers),
Authorization: `Bearer ${token}`,
...(init.headers ?? {}),
},
});
if (!res.ok) {
@@ -57,8 +64,8 @@ async function fetchBuffer(
const res = await fetch(url, {
...init,
headers: {
...headersToObject(init?.headers),
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
});
if (!res.ok) {
@@ -83,8 +90,12 @@ async function fetchBuffer(
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
if (done) {
break;
}
if (!value) {
continue;
}
total += value.length;
if (total > maxBytes) {
await reader.cancel();
@@ -106,8 +117,12 @@ export async function sendGoogleChatMessage(params: {
}): Promise<{ messageName?: string } | null> {
const { account, space, text, thread, attachments } = params;
const body: Record<string, unknown> = {};
if (text) body.text = text;
if (thread) body.thread = { name: thread };
if (text) {
body.text = text;
}
if (thread) {
body.thread = { name: thread };
}
if (attachments && attachments.length > 0) {
body.attachment = attachments.map((item) => ({
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
@@ -182,7 +197,9 @@ export async function uploadGoogleChatAttachment(params: {
const payload = (await res.json()) as {
attachmentDataRef?: { attachmentUploadToken?: string };
};
return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken };
return {
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
};
}
export async function downloadGoogleChatMedia(params: {
@@ -215,7 +232,9 @@ export async function listGoogleChatReactions(params: {
}): Promise<GoogleChatReaction[]> {
const { account, messageName, limit } = params;
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(), {
method: "GET",
});
@@ -251,9 +270,14 @@ export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promi
try {
const url = new URL(`${CHAT_API_BASE}/spaces`);
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 };
} 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;
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
if (account.credentialsFile) return `file:${account.credentialsFile}`;
if (account.credentials) return `inline:${JSON.stringify(account.credentials)}`;
if (account.credentialsFile) {
return `file:${account.credentialsFile}`;
}
if (account.credentials) {
return `inline:${JSON.stringify(account.credentials)}`;
}
return "none";
}
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
const key = buildAuthKey(account);
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) {
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
@@ -77,9 +83,13 @@ export async function verifyGoogleChatRequest(params: {
audience?: string | null;
}): Promise<{ ok: boolean; reason?: string }> {
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();
if (!audience) return { ok: false, reason: "missing audience" };
if (!audience) {
return { ok: false, reason: "missing audience" };
}
const audienceType = params.audienceType ?? null;
if (audienceType === "app-url") {

View File

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

View File

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

View File

@@ -32,9 +32,9 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
channels: {
...cfg.channels,
googlechat: {
...(cfg.channels?.["googlechat"] ?? {}),
...cfg.channels?.["googlechat"],
dm: {
...(cfg.channels?.["googlechat"]?.dm ?? {}),
...cfg.channels?.["googlechat"]?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
@@ -68,10 +68,10 @@ async function promptAllowFrom(params: {
channels: {
...params.cfg.channels,
googlechat: {
...(params.cfg.channels?.["googlechat"] ?? {}),
...params.cfg.channels?.["googlechat"],
enabled: true,
dm: {
...(params.cfg.channels?.["googlechat"]?.dm ?? {}),
...params.cfg.channels?.["googlechat"]?.dm,
policy: "allowlist",
allowFrom: unique,
},
@@ -102,7 +102,7 @@ function applyAccountConfig(params: {
channels: {
...cfg.channels,
googlechat: {
...(cfg.channels?.["googlechat"] ?? {}),
...cfg.channels?.["googlechat"],
enabled: true,
...patch,
},
@@ -114,12 +114,12 @@ function applyAccountConfig(params: {
channels: {
...cfg.channels,
googlechat: {
...(cfg.channels?.["googlechat"] ?? {}),
...cfg.channels?.["googlechat"],
enabled: true,
accounts: {
...(cfg.channels?.["googlechat"]?.accounts ?? {}),
...cfg.channels?.["googlechat"]?.accounts,
[accountId]: {
...(cfg.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
...cfg.channels?.["googlechat"]?.accounts?.[accountId],
enabled: true,
...patch,
},
@@ -193,14 +193,14 @@ async function promptAudience(params: {
});
const currentType = account.config.audienceType ?? "app-url";
const currentAudience = account.config.audience ?? "";
const audienceType = (await params.prompter.select({
const audienceType = await params.prompter.select({
message: "Webhook audience type",
options: [
{ value: "app-url", label: "App URL (recommended)" },
{ value: "project-number", label: "Project number" },
],
initialValue: currentType === "project-number" ? "project-number" : "app-url",
})) as "app-url" | "project-number";
});
const audience = await params.prompter.text({
message: audienceType === "project-number" ? "Project number" : "App URL",
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 {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
if (!trimmed) {
return undefined;
}
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
const normalized = withoutPrefix
.replace(/^user:(users\/)?/i, "users/")
@@ -12,8 +14,12 @@ export function normalizeGoogleChatTarget(raw?: string | null): string | undefin
const suffix = normalized.slice("users/".length);
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
}
if (isGoogleChatSpaceTarget(normalized)) return normalized;
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`;
if (isGoogleChatSpaceTarget(normalized)) {
return normalized;
}
if (normalized.includes("@")) {
return `users/${normalized.toLowerCase()}`;
}
return normalized;
}
@@ -27,7 +33,9 @@ export function isGoogleChatSpaceTarget(value: string): boolean {
function stripMessageSuffix(target: string): string {
const index = target.indexOf("/messages/");
if (index === -1) return target;
if (index === -1) {
return target;
}
return target.slice(0, index);
}
@@ -40,7 +48,9 @@ export async function resolveGoogleChatOutboundSpace(params: {
throw new Error("Missing Google Chat target.");
}
const base = stripMessageSuffix(normalized);
if (isGoogleChatSpaceTarget(base)) return base;
if (isGoogleChatSpaceTarget(base)) {
return base;
}
if (isGoogleChatUserTarget(base)) {
const dm = await findGoogleChatDirectMessage({
account: params.account,

View File

@@ -98,7 +98,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
if (groupPolicy !== "open") {
return [];
}
return [
`- 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) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
if (!lastError) {
return [];
}
return [
{
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)
*/
function parseActions(actionsStr: string | undefined): CardAction[] {
if (!actionsStr) return [];
if (!actionsStr) {
return [];
}
const results: CardAction[] = [];
@@ -47,7 +49,9 @@ function parseActions(actionsStr: string | undefined): CardAction[] {
.trim()
.split("|")
.map((s) => s.trim());
if (!label) continue;
if (!label) {
continue;
}
const actionData = data || label;
@@ -158,12 +162,16 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
requireAuth: false,
handler: async (ctx) => {
const argsStr = ctx.args?.trim() ?? "";
if (!argsStr) return { text: CARD_USAGE };
if (!argsStr) {
return { text: CARD_USAGE };
}
const parsed = parseCardArgs(argsStr);
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.
if (ctx.channel !== "line") {

View File

@@ -25,17 +25,6 @@ const meta = {
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> = {
id: "line",
meta: {
@@ -108,7 +97,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
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 {
...cfg,
channels: {
@@ -173,7 +163,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined)
?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
if (groupPolicy !== "open") {
return [];
}
return [
`- 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 }) => {
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
const groups = account.config.groups;
if (!groups) return false;
if (!groups) {
return false;
}
const groupConfig = groups[groupId] ?? groups["*"];
return groupConfig?.requireMention ?? false;
},
@@ -191,13 +185,17 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
messaging: {
normalizeTarget: (target) => {
const trimmed = target.trim();
if (!trimmed) return null;
if (!trimmed) {
return null;
}
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
},
targetResolver: {
looksLikeId: (id) => {
const trimmed = id?.trim();
if (!trimmed) return false;
if (!trimmed) {
return false;
}
// LINE user IDs are typically U followed by 32 hex characters
// Group IDs are C followed by 32 hex characters
// Room IDs are R followed by 32 hex characters
@@ -356,7 +354,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
: undefined;
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) {
const result = await sendBatch(to, messages.slice(i, i + 5), {
verbose: false,
@@ -434,12 +434,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
for (let i = 0; i < chunks.length; i += 1) {
const isLast = i === chunks.length - 1;
if (isLast && hasQuickReplies) {
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
lastResult = await sendQuickReplies(to, chunks[i], lineData.quickReplies!, {
verbose: false,
accountId: accountId ?? undefined,
});
} else {
lastResult = await sendText(to, chunks[i]!, {
lastResult = await sendText(to, chunks[i], {
verbose: false,
accountId: accountId ?? undefined,
});
@@ -478,7 +478,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
}
for (const url of mediaUrls) {
const trimmed = url?.trim();
if (!trimmed) continue;
if (!trimmed) {
continue;
}
quickReplyMessages.push({
type: "image",
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 };
},
sendText: async ({ to, text, accountId }) => {
@@ -621,7 +625,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
try {
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
if (displayName) lineBotLabel = ` (${displayName})`;
if (displayName) {
lineBotLabel = ` (${displayName})`;
}
} catch (err) {
if (getLineRuntime().logging.shouldLogVerbose()) {
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,9 @@ export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
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;
}
@@ -28,7 +30,7 @@ export function resolveMatrixAccount(params: {
accountId?: string | null;
}): ResolvedMatrixAccount {
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 resolved = resolveMatrixConfig(params.cfg, process.env);
const hasHomeserver = Boolean(resolved.homeserver);

View File

@@ -19,9 +19,13 @@ export async function resolveActionClient(
opts: MatrixActionClientOpts = {},
): Promise<MatrixActionClient> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
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);
if (shouldShareClient) {
const client = await resolveSharedMatrixClient({

View File

@@ -36,7 +36,9 @@ export async function editMatrixMessage(
opts: MatrixActionClientOpts = {},
) {
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);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
@@ -56,7 +58,9 @@ export async function editMatrixMessage(
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}
@@ -70,7 +74,9 @@ export async function deleteMatrixMessage(
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
await client.redactEvent(resolvedRoom, messageId, opts.reason);
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}
@@ -115,6 +121,8 @@ export async function readMatrixMessages(
prevBatch: res.start ?? null,
};
} 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);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}
@@ -40,7 +42,9 @@ export async function unpinMatrixMessage(
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
return { pinned: next };
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}
@@ -65,6 +69,8 @@ export async function listMatrixPins(
).filter((event): event is MatrixMessageSummary => Boolean(event));
return { pinned, events };
} 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) {
const content = event.content as ReactionEventContent;
const key = content["m.relates_to"]?.key;
if (!key) continue;
if (!key) {
continue;
}
const sender = event.sender ?? "";
const entry: MatrixReactionSummary = summaries.get(key) ?? {
key,
@@ -46,7 +48,9 @@ export async function listMatrixReactions(
}
return Array.from(summaries.values());
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}
@@ -64,21 +68,29 @@ export async function removeMatrixReactions(
{ dir: "b", limit: 200 },
)) as { chunk: MatrixRawEvent[] };
const userId = await client.getUserId();
if (!userId) return { removed: 0 };
if (!userId) {
return { removed: 0 };
}
const targetEmoji = opts.emoji?.trim();
const toRemove = res.chunk
.filter((event) => event.sender === userId)
.filter((event) => {
if (!targetEmoji) return true;
if (!targetEmoji) {
return true;
}
const content = event.content as ReactionEventContent;
return content["m.relates_to"]?.key === targetEmoji;
})
.map((event) => event.event_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)));
return { removed: toRemove.length };
} finally {
if (stopOnDone) client.stop();
if (stopOnDone) {
client.stop();
}
}
}

View File

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

View File

@@ -65,7 +65,9 @@ export async function fetchEventSummary(
): Promise<MatrixMessageSummary | null> {
try {
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);
} catch {
// Event not found, redacted, or inaccessible - return null

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,9 @@ export async function ensureMatrixSdkInstalled(params: {
runtime: RuntimeEnv;
confirm?: (message: string) => Promise<boolean>;
}): Promise<void> {
if (isMatrixSdkAvailable()) return;
if (isMatrixSdkAvailable()) {
return;
}
const confirm = params.confirm;
if (confirm) {
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;
}): MatrixAllowListMatch {
const allowList = params.allowList;
if (allowList.length === 0) return { allowed: false };
if (allowList.length === 0) {
return { allowed: false };
}
if (allowList.includes("*")) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
@@ -37,7 +39,9 @@ export function resolveMatrixAllowListMatch(params: {
{ value: localPart, source: "localpart" },
];
for (const candidate of candidates) {
if (!candidate.value) continue;
if (!candidate.value) {
continue;
}
if (allowList.includes(candidate.value)) {
return {
allowed: true,

View File

@@ -13,7 +13,9 @@ export function registerMatrixAutoJoin(params: {
const { client, cfg, runtime } = params;
const core = getMatrixRuntime();
const logVerbose = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
if (!core.logging.shouldLogVerbose()) {
return;
}
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
@@ -32,7 +34,9 @@ export function registerMatrixAutoJoin(params: {
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
if (autoJoin !== "allowlist") {
return;
}
// Get room alias if available
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 ensureSelfUserId = async (): Promise<string | null> => {
if (cachedSelfUserId) return cachedSelfUserId;
if (cachedSelfUserId) {
return cachedSelfUserId;
}
try {
cachedSelfUserId = await client.getUserId();
} catch {
@@ -30,7 +32,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const refreshDmCache = async (): Promise<void> => {
const now = Date.now();
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
return;
}
lastDmUpdateMs = now;
try {
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 target = userId?.trim();
if (!target) return false;
if (!target) {
return false;
}
try {
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
return state?.is_direct === true;

View File

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

View File

@@ -34,7 +34,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
const core = getMatrixRuntime();
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 formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
@@ -50,7 +52,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
},
};
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) return;
if (!core.logging.shouldLogVerbose()) {
return;
}
logger.debug(message);
};
@@ -115,7 +119,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const pending: Array<{ input: string; query: string }> = [];
for (const entry of entries) {
const trimmed = entry.trim();
if (!trimmed) continue;
if (!trimmed) {
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if (cleaned.startsWith("!") && cleaned.includes(":")) {
if (!nextRooms[cleaned]) {
@@ -135,7 +141,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
resolved.forEach((entry, index) => {
const source = pending[index];
if (!source) return;
if (!source) {
return;
}
if (entry.resolved && entry.id) {
if (!nextRooms[entry.id]) {
nextRooms[entry.id] = roomsConfig[source.input];

View File

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

View File

@@ -24,7 +24,9 @@ async function fetchMatrixMediaBuffer(params: {
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
if (!url) {
return null;
}
// Use the client's download method which handles auth
try {
@@ -34,7 +36,7 @@ async function fetchMatrixMediaBuffer(params: {
}
return { buffer: Buffer.from(buffer) };
} 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 saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
fetched.buffer,

View File

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

View File

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

View File

@@ -28,7 +28,9 @@ export function resolveMatrixThreadTarget(params: {
isThreadRoot?: boolean;
}): string | undefined {
const { threadReplies, messageId, threadRootId } = params;
if (threadReplies === "off") return undefined;
if (threadReplies === "off") {
return undefined;
}
const isThreadRoot = params.isThreadRoot === true;
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
if (threadReplies === "inbound") {
@@ -45,7 +47,9 @@ export function resolveMatrixThreadRootId(params: {
content: RoomMessageEventContent;
}): string | undefined {
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 ("event_id" in relates && typeof relates.event_id === "string") {
return relates.event_id;

View File

@@ -77,7 +77,9 @@ export function isPollStartType(eventType: string): boolean {
}
export function getTextContent(text?: TextContent): string {
if (!text) return "";
if (!text) {
return "";
}
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>)[ORG_POLL_START] ??
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null;
if (!poll) {
return null;
}
const question = getTextContent(poll.question);
if (!question) return null;
if (!question) {
return null;
}
const answers = poll.answers
.map((answer) => getTextContent(answer))
@@ -125,7 +131,9 @@ function buildTextContent(body: string): TextContent {
}
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")}`;
}

View File

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

View File

@@ -31,9 +31,13 @@ export async function resolveMatrixClient(opts: {
timeoutMs?: number;
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
ensureNodeRuntime();
if (opts.client) return { client: opts.client, stopOnDone: false };
if (opts.client) {
return { client: opts.client, stopOnDone: false };
}
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);
if (shouldShareClient) {
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 {
const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) return;
if (!formatted) {
return;
}
content.format = "org.matrix.custom.html";
content.formatted_body = formatted;
}
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
const trimmed = replyToId?.trim();
if (!trimmed) return undefined;
if (!trimmed) {
return undefined;
}
return { "m.in_reply_to": { event_id: trimmed } };
}
@@ -70,7 +74,9 @@ export function resolveMatrixVoiceDecision(opts: {
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (!opts.wantsVoice) {
return { useVoice: false };
}
if (
getCore().media.isVoiceCompatibleAudio({
contentType: opts.contentType,

View File

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

View File

@@ -26,7 +26,9 @@ describe("resolveMatrixRoomId", () => {
const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org");
// oxlint-disable-next-line typescript/unbound-method
expect(client.getJoinedRooms).not.toHaveBeenCalled();
// oxlint-disable-next-line typescript/unbound-method
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 {
if (raw === undefined || raw === null) return null;
if (raw === undefined || raw === null) {
return null;
}
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
@@ -25,15 +27,15 @@ async function persistDirectRoom(
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
directContent = await client.getAccountData(EventType.Direct);
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
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)];
try {
await client.setAccountData(EventType.Direct, {
@@ -52,13 +54,13 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
}
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).
try {
const directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
const directContent = await client.getAccountData(EventType.Direct);
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) {
directRoomCache.set(trimmed, list[0]);
@@ -80,7 +82,9 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
} catch {
continue;
}
if (!members.includes(trimmed)) continue;
if (!members.includes(trimmed)) {
continue;
}
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
directRoomCache.set(trimmed, roomId);

View File

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

View File

@@ -11,7 +11,9 @@ function pickBestGroupMatch(
matches: ChannelDirectoryEntry[],
query: string,
): ChannelDirectoryEntry | undefined {
if (matches.length === 0) return undefined;
if (matches.length === 0) {
return undefined;
}
const normalized = query.trim().toLowerCase();
if (normalized) {
const exact = matches.find((match) => {
@@ -20,7 +22,9 @@ function pickBestGroupMatch(
const id = match.id.trim().toLowerCase();
return name === normalized || handle === normalized || id === normalized;
});
if (exact) return exact;
if (exact) {
return exact;
}
}
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 {
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
if (direct) return direct;
if (!required) return readStringParam(params, "to") ?? "";
if (direct) {
return direct;
}
if (!required) {
return readStringParam(params, "to") ?? "";
}
return readStringParam(params, "to", { required: true });
}

View File

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

View File

@@ -49,7 +49,9 @@ function normalizeAllowEntry(entry: string): string {
function formatAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) return "";
if (!trimmed) {
return "";
}
if (trimmed.startsWith("@")) {
const username = trimmed.slice(1).trim();
return username ? `@${username.toLowerCase()}` : "";
@@ -134,7 +136,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
collectWarnings: ({ account, cfg }) => {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
if (groupPolicy !== "open") {
return [];
}
return [
`- 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,
accountId: params.accountId,
});
if (typeof account.requireMention === "boolean") return account.requireMention;
if (typeof account.requireMention === "boolean") {
return account.requireMention;
}
return true;
}

View File

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

View File

@@ -42,14 +42,18 @@ export type MattermostFileInfo = {
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
if (!trimmed) {
return undefined;
}
const withoutTrailing = trimmed.replace(/\/+$/, "");
return withoutTrailing.replace(/\/api\/v4$/i, "");
}
function buildMattermostApiUrl(baseUrl: string, path: string): string {
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}`;
return `${normalized}/api/v4${suffix}`;
}
@@ -58,7 +62,9 @@ async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
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 await res.text();
@@ -70,7 +76,9 @@ export function createMattermostClient(params: {
fetchImpl?: typeof fetch;
}): MattermostClient {
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 token = params.botToken.trim();
const fetchImpl = params.fetchImpl ?? fetch;
@@ -128,7 +136,9 @@ export async function sendMattermostTyping(
channel_id: params.channelId,
};
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", {
method: "POST",
body: JSON.stringify(payload),
@@ -158,7 +168,9 @@ export async function createMattermostPost(
channel_id: params.channelId,
message: params.message,
};
if (params.rootId) payload.root_id = params.rootId;
if (params.rootId) {
payload.root_id = params.rootId;
}
if (params.fileIds?.length) {
(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 directId = params.directId?.trim();
if (!directId || directId === directLabel) return directLabel;
if (!directId || directId === directLabel) {
return directLabel;
}
return `${directLabel} id:${directId}`;
}
@@ -67,14 +69,18 @@ export function createDedupeCache(options: { ttlMs: number; maxSize: number }):
}
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value as string | undefined;
if (!oldestKey) break;
if (!oldestKey) {
break;
}
cache.delete(oldestKey);
}
};
return {
check: (key, now = Date.now()) => {
if (!key) return false;
if (!key) {
return false;
}
const existing = cache.get(key);
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
touch(key, now);
@@ -91,9 +97,15 @@ export function rawDataToString(
data: WebSocket.RawData,
encoding: BufferEncoding = "utf8",
): string {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString(encoding);
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString(encoding);
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString(encoding);
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString(encoding);
}
@@ -102,8 +114,12 @@ export function rawDataToString(
function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return "main";
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
if (!trimmed) {
return "main";
}
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
return trimmed;
}
return (
trimmed
.toLowerCase()
@@ -118,7 +134,9 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[num
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
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"));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,8 +55,12 @@ class MemoryDB {
) {}
private async ensureInitialized(): Promise<void> {
if (this.table) return;
if (this.initPromise) return this.initPromise;
if (this.table) {
return;
}
if (this.initPromise) {
return this.initPromise;
}
this.initPromise = this.doInitialize();
return this.initPromise;
@@ -73,7 +77,7 @@ class MemoryDB {
{
id: "__schema__",
text: "",
vector: new Array(this.vectorDim).fill(0),
vector: Array.from({ length: this.vectorDim }).fill(0),
importance: 0,
category: "other",
createdAt: 0,
@@ -179,25 +183,43 @@ const MEMORY_TRIGGERS = [
];
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
if (text.includes("<relevant-memories>")) return false;
if (text.includes("<relevant-memories>")) {
return false;
}
// 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)
if (text.includes("**") && text.includes("\n-")) return false;
if (text.includes("**") && text.includes("\n-")) {
return false;
}
// Skip emoji-heavy responses (likely agent output)
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));
}
function detectCategory(text: string): MemoryCategory {
const lower = text.toLowerCase();
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
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";
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
return "preference";
}
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";
}
@@ -455,13 +477,17 @@ const memoryPlugin = {
// Auto-recall: inject relevant memories before agent starts
if (cfg.autoRecall) {
api.on("before_agent_start", async (event) => {
if (!event.prompt || event.prompt.length < 5) return;
if (!event.prompt || event.prompt.length < 5) {
return;
}
try {
const vector = await embeddings.embed(event.prompt);
const results = await db.search(vector, 3, 0.3);
if (results.length === 0) return;
if (results.length === 0) {
return;
}
const memoryContext = results
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
@@ -490,12 +516,16 @@ const memoryPlugin = {
const texts: string[] = [];
for (const msg of event.messages) {
// Type guard for message object
if (!msg || typeof msg !== "object") continue;
if (!msg || typeof msg !== "object") {
continue;
}
const msgObj = msg as Record<string, unknown>;
// Only process user and assistant messages
const role = msgObj.role;
if (role !== "user" && role !== "assistant") continue;
if (role !== "user" && role !== "assistant") {
continue;
}
const content = msgObj.content;
@@ -524,7 +554,9 @@ const memoryPlugin = {
// Filter for capturable content
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)
let stored = 0;
@@ -534,7 +566,9 @@ const memoryPlugin = {
// Check for duplicates (high similarity threshold)
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) continue;
if (existing.length > 0) {
continue;
}
await db.store({
text,

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ export function summarizeMSTeamsHtmlAttachments(
attachments: MSTeamsAttachmentLike[] | undefined,
): MSTeamsHtmlAttachmentSummary | undefined {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return undefined;
if (list.length === 0) {
return undefined;
}
let htmlAttachments = 0;
let imgTags = 0;
let dataImages = 0;
@@ -23,7 +25,9 @@ export function summarizeMSTeamsHtmlAttachments(
for (const att of list) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
if (!html) {
continue;
}
htmlAttachments += 1;
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
@@ -31,9 +35,13 @@ export function summarizeMSTeamsHtmlAttachments(
imgTags += 1;
const src = match[1]?.trim();
if (src) {
if (src.startsWith("data:")) dataImages += 1;
else if (src.startsWith("cid:")) cidImages += 1;
else srcHosts.add(safeHostForUrl(src));
if (src.startsWith("data:")) {
dataImages += 1;
} else if (src.startsWith("cid:")) {
cidImages += 1;
} else {
srcHosts.add(safeHostForUrl(src));
}
}
match = IMG_SRC_RE.exec(html);
}
@@ -43,12 +51,16 @@ export function summarizeMSTeamsHtmlAttachments(
while (attachmentMatch) {
attachmentTags += 1;
const id = attachmentMatch[1]?.trim();
if (id) attachmentIds.add(id);
if (id) {
attachmentIds.add(id);
}
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
}
}
if (htmlAttachments === 0) return undefined;
if (htmlAttachments === 0) {
return undefined;
}
return {
htmlAttachments,
imgTags,
@@ -64,7 +76,9 @@ export function buildMSTeamsAttachmentPlaceholder(
attachments: MSTeamsAttachmentLike[] | undefined,
): string {
const list = Array.isArray(attachments) ? attachments : [];
if (list.length === 0) return "";
if (list.length === 0) {
return "";
}
const imageCount = list.filter(isLikelyImageAttachment).length;
const inlineCount = extractInlineImageCandidates(list).length;
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 {
if (typeof value !== "string") return undefined;
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
@@ -78,17 +80,25 @@ export function inferPlaceholder(params: {
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
const contentType = normalizeContentType(att.contentType) ?? "";
const name = typeof att.name === "string" ? att.name : "";
if (contentType.startsWith("image/")) return true;
if (IMAGE_EXT_RE.test(name)) return true;
if (contentType.startsWith("image/")) {
return true;
}
if (IMAGE_EXT_RE.test(name)) {
return true;
}
if (
contentType === "application/vnd.microsoft.teams.file.download.info" &&
isRecord(att.content)
) {
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 : "";
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
if (fileName && IMAGE_EXT_RE.test(fileName)) {
return true;
}
}
return false;
@@ -124,9 +134,15 @@ function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
}
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
if (!isHtmlAttachment(att)) return undefined;
if (typeof att.content === "string") return att.content;
if (!isRecord(att.content)) return undefined;
if (!isHtmlAttachment(att)) {
return undefined;
}
if (typeof att.content === "string") {
return att.content;
}
if (!isRecord(att.content)) {
return undefined;
}
const text =
typeof att.content.text === "string"
? att.content.text
@@ -140,12 +156,18 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string |
function decodeDataImage(src: string): InlineImageCandidate | null {
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 isBase64 = Boolean(match[2]);
if (!isBase64) return null;
if (!isBase64) {
return null;
}
const payload = match[3] ?? "";
if (!payload) return null;
if (!payload) {
return null;
}
try {
const data = Buffer.from(payload, "base64");
return { kind: "data", data, contentType, placeholder: "<media:image>" };
@@ -170,7 +192,9 @@ export function extractInlineImageCandidates(
const out: InlineImageCandidate[] = [];
for (const att of attachments) {
const html = extractHtmlFromAttachment(att);
if (!html) continue;
if (!html) {
continue;
}
IMG_SRC_RE.lastIndex = 0;
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
while (match) {
@@ -178,7 +202,9 @@ export function extractInlineImageCandidates(
if (src && !src.startsWith("cid:")) {
if (src.startsWith("data:")) {
const decoded = decodeDataImage(src);
if (decoded) out.push(decoded);
if (decoded) {
out.push(decoded);
}
} else {
out.push({
kind: "url",
@@ -204,8 +230,12 @@ export function safeHostForUrl(url: string): string {
function normalizeAllowHost(value: string): string {
const trimmed = value.trim().toLowerCase();
if (!trimmed) return "";
if (trimmed === "*") return "*";
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed.replace(/^\*\.?/, "");
}
@@ -214,12 +244,16 @@ export function resolveAllowedHosts(input?: string[]): string[] {
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
}
const normalized = input.map(normalizeAllowHost).filter(Boolean);
if (normalized.includes("*")) return ["*"];
if (normalized.includes("*")) {
return ["*"];
}
return normalized;
}
function isHostAllowed(host: string, allowlist: string[]): boolean {
if (allowlist.includes("*")) return true;
if (allowlist.includes("*")) {
return true;
}
const normalized = host.toLowerCase();
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 {
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:") return false;
if (parsed.protocol !== "https:") {
return false;
}
return isHostAllowed(parsed.hostname, allowlist);
} catch {
return false;

View File

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

View File

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

View File

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

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