chore: Lint extensions folder.
This commit is contained in:
@@ -31,7 +31,6 @@
|
|||||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||||
"Swabble/",
|
"Swabble/",
|
||||||
"vendor/",
|
"vendor/",
|
||||||
"extensions/",
|
|
||||||
"ui/"
|
"ui/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,20 +50,30 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const files: FileInfo[] = [];
|
const files: FileInfo[] = [];
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.length < 4) continue; // Need at least "XY f"
|
if (line.length < 4) {
|
||||||
|
continue;
|
||||||
|
} // Need at least "XY f"
|
||||||
|
|
||||||
const status = line.slice(0, 2);
|
const status = line.slice(0, 2);
|
||||||
const file = line.slice(2).trimStart();
|
const file = line.slice(2).trimStart();
|
||||||
|
|
||||||
// Translate status codes to short labels
|
// Translate status codes to short labels
|
||||||
let statusLabel: string;
|
let statusLabel: string;
|
||||||
if (status.includes("M")) statusLabel = "M";
|
if (status.includes("M")) {
|
||||||
else if (status.includes("A")) statusLabel = "A";
|
statusLabel = "M";
|
||||||
else if (status.includes("D")) statusLabel = "D";
|
} else if (status.includes("A")) {
|
||||||
else if (status.includes("?")) statusLabel = "?";
|
statusLabel = "A";
|
||||||
else if (status.includes("R")) statusLabel = "R";
|
} else if (status.includes("D")) {
|
||||||
else if (status.includes("C")) statusLabel = "C";
|
statusLabel = "D";
|
||||||
else statusLabel = status.trim() || "~";
|
} else if (status.includes("?")) {
|
||||||
|
statusLabel = "?";
|
||||||
|
} else if (status.includes("R")) {
|
||||||
|
statusLabel = "R";
|
||||||
|
} else if (status.includes("C")) {
|
||||||
|
statusLabel = "C";
|
||||||
|
} else {
|
||||||
|
statusLabel = status.trim() || "~";
|
||||||
|
}
|
||||||
|
|
||||||
files.push({ status: statusLabel, statusLabel, file });
|
files.push({ status: statusLabel, statusLabel, file });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
|
const toolCalls = new Map<string, { path: string; name: FileToolName; timestamp: number }>();
|
||||||
|
|
||||||
for (const entry of branch) {
|
for (const entry of branch) {
|
||||||
if (entry.type !== "message") continue;
|
if (entry.type !== "message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
|
|
||||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||||
@@ -62,12 +64,16 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const fileMap = new Map<string, FileEntry>();
|
const fileMap = new Map<string, FileEntry>();
|
||||||
|
|
||||||
for (const entry of branch) {
|
for (const entry of branch) {
|
||||||
if (entry.type !== "message") continue;
|
if (entry.type !== "message") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
|
|
||||||
if (msg.role === "toolResult") {
|
if (msg.role === "toolResult") {
|
||||||
const toolCall = toolCalls.get(msg.toolCallId);
|
const toolCall = toolCalls.get(msg.toolCallId);
|
||||||
if (!toolCall) continue;
|
if (!toolCall) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { path, name } = toolCall;
|
const { path, name } = toolCall;
|
||||||
const timestamp = msg.timestamp;
|
const timestamp = msg.timestamp;
|
||||||
@@ -94,7 +100,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by most recent first
|
// Sort by most recent first
|
||||||
const files = Array.from(fileMap.values()).sort((a, b) => b.lastTimestamp - a.lastTimestamp);
|
const files = Array.from(fileMap.values()).toSorted(
|
||||||
|
(a, b) => b.lastTimestamp - a.lastTimestamp,
|
||||||
|
);
|
||||||
|
|
||||||
const openSelected = async (file: FileEntry): Promise<void> => {
|
const openSelected = async (file: FileEntry): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -118,9 +126,15 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Build select items with colored operations
|
// Build select items with colored operations
|
||||||
const items: SelectItem[] = files.map((f) => {
|
const items: SelectItem[] = files.map((f) => {
|
||||||
const ops: string[] = [];
|
const ops: string[] = [];
|
||||||
if (f.operations.has("read")) ops.push(theme.fg("muted", "R"));
|
if (f.operations.has("read")) {
|
||||||
if (f.operations.has("write")) ops.push(theme.fg("success", "W"));
|
ops.push(theme.fg("muted", "R"));
|
||||||
if (f.operations.has("edit")) ops.push(theme.fg("warning", "E"));
|
}
|
||||||
|
if (f.operations.has("write")) {
|
||||||
|
ops.push(theme.fg("success", "W"));
|
||||||
|
}
|
||||||
|
if (f.operations.has("edit")) {
|
||||||
|
ops.push(theme.fg("warning", "E"));
|
||||||
|
}
|
||||||
const opsLabel = ops.join("");
|
const opsLabel = ops.join("");
|
||||||
return {
|
return {
|
||||||
value: f,
|
value: f,
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ async function fetchGhMetadata(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await pi.exec("gh", args);
|
const result = await pi.exec("gh", args);
|
||||||
if (result.code !== 0 || !result.stdout) return undefined;
|
if (result.code !== 0 || !result.stdout) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return JSON.parse(result.stdout) as GhMetadata;
|
return JSON.parse(result.stdout) as GhMetadata;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -55,12 +57,20 @@ async function fetchGhMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
function formatAuthor(author?: GhMetadata["author"]): string | undefined {
|
||||||
if (!author) return undefined;
|
if (!author) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const name = author.name?.trim();
|
const name = author.name?.trim();
|
||||||
const login = author.login?.trim();
|
const login = author.login?.trim();
|
||||||
if (name && login) return `${name} (@${login})`;
|
if (name && login) {
|
||||||
if (login) return `@${login}`;
|
return `${name} (@${login})`;
|
||||||
if (name) return name;
|
}
|
||||||
|
if (login) {
|
||||||
|
return `@${login}`;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +87,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
const urlLine = thm.fg("dim", match.url);
|
const urlLine = thm.fg("dim", match.url);
|
||||||
|
|
||||||
const lines = [titleText];
|
const lines = [titleText];
|
||||||
if (authorLine) lines.push(authorLine);
|
if (authorLine) {
|
||||||
|
lines.push(authorLine);
|
||||||
|
}
|
||||||
lines.push(urlLine);
|
lines.push(urlLine);
|
||||||
|
|
||||||
const container = new Container();
|
const container = new Container();
|
||||||
@@ -103,7 +115,9 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pi.on("before_agent_start", async (event, ctx) => {
|
pi.on("before_agent_start", async (event, ctx) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const match = extractPromptMatch(event.prompt);
|
const match = extractPromptMatch(event.prompt);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return;
|
return;
|
||||||
@@ -124,8 +138,12 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
const getUserText = (content: string | { type: string; text?: string }[] | undefined): string => {
|
||||||
if (!content) return "";
|
if (!content) {
|
||||||
if (typeof content === "string") return content;
|
return "";
|
||||||
|
}
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
content
|
content
|
||||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||||
@@ -135,11 +153,15 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const rebuildFromSession = (ctx: ExtensionContext) => {
|
const rebuildFromSession = (ctx: ExtensionContext) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
const entries = ctx.sessionManager.getEntries();
|
||||||
const lastMatch = [...entries].reverse().find((entry) => {
|
const lastMatch = [...entries].toReversed().find((entry) => {
|
||||||
if (entry.type !== "message" || entry.message.role !== "user") return false;
|
if (entry.type !== "message" || entry.message.role !== "user") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const text = getUserText(entry.message.content);
|
const text = getUserText(entry.message.content);
|
||||||
return !!extractPromptMatch(text);
|
return !!extractPromptMatch(text);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
pi.registerCommand("tui", {
|
pi.registerCommand("tui", {
|
||||||
description: "Show TUI stats",
|
description: "Show TUI stats",
|
||||||
handler: async (_args, ctx) => {
|
handler: async (_args, ctx) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let redraws = 0;
|
let redraws = 0;
|
||||||
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
|
||||||
redraws = tui.fullRedraws;
|
redraws = tui.fullRedraws;
|
||||||
|
|||||||
@@ -13,19 +13,25 @@ export type ResolvedBlueBubblesAccount = {
|
|||||||
|
|
||||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const accounts = cfg.channels?.bluebubbles?.accounts;
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Object.keys(accounts).filter(Boolean);
|
return Object.keys(accounts).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
|
export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) {
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
|
export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string {
|
||||||
const ids = listBlueBubblesAccountIds(cfg);
|
const ids = listBlueBubblesAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +40,9 @@ function resolveAccountConfig(
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
): BlueBubblesAccountConfig | undefined {
|
): BlueBubblesAccountConfig | undefined {
|
||||||
const accounts = cfg.channels?.bluebubbles?.accounts;
|
const accounts = cfg.channels?.bluebubbles?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return undefined;
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type ChannelMessageActionAdapter,
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
type ChannelToolSend,
|
type ChannelToolSend,
|
||||||
type OpenClawConfig,
|
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
@@ -34,8 +33,12 @@ const providerId = "bluebubbles";
|
|||||||
|
|
||||||
function mapTarget(raw: string): BlueBubblesSendTarget {
|
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||||
const parsed = parseBlueBubblesTarget(raw);
|
const parsed = parseBlueBubblesTarget(raw);
|
||||||
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
if (parsed.kind === "chat_guid") {
|
||||||
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
|
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
|
||||||
|
}
|
||||||
|
if (parsed.kind === "chat_id") {
|
||||||
|
return { kind: "chat_id", chatId: parsed.chatId };
|
||||||
|
}
|
||||||
if (parsed.kind === "chat_identifier") {
|
if (parsed.kind === "chat_identifier") {
|
||||||
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
|
||||||
}
|
}
|
||||||
@@ -52,11 +55,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
|
|
||||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
||||||
const raw = params[key];
|
const raw = params[key];
|
||||||
if (typeof raw === "boolean") return raw;
|
if (typeof raw === "boolean") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
if (typeof raw === "string") {
|
if (typeof raw === "string") {
|
||||||
const trimmed = raw.trim().toLowerCase();
|
const trimmed = raw.trim().toLowerCase();
|
||||||
if (trimmed === "true") return true;
|
if (trimmed === "true") {
|
||||||
if (trimmed === "false") return false;
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -66,41 +75,55 @@ const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_N
|
|||||||
|
|
||||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const account = resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig });
|
const account = resolveBlueBubblesAccount({ cfg: cfg });
|
||||||
if (!account.enabled || !account.configured) return [];
|
if (!account.enabled || !account.configured) {
|
||||||
const gate = createActionGate((cfg as OpenClawConfig).channels?.bluebubbles?.actions);
|
return [];
|
||||||
|
}
|
||||||
|
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
||||||
const actions = new Set<ChannelMessageActionName>();
|
const actions = new Set<ChannelMessageActionName>();
|
||||||
const macOS26 = isMacOS26OrHigher(account.accountId);
|
const macOS26 = isMacOS26OrHigher(account.accountId);
|
||||||
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
||||||
const spec = BLUEBUBBLES_ACTIONS[action];
|
const spec = BLUEBUBBLES_ACTIONS[action];
|
||||||
if (!spec?.gate) continue;
|
if (!spec?.gate) {
|
||||||
if (spec.unsupportedOnMacOS26 && macOS26) continue;
|
continue;
|
||||||
if (gate(spec.gate)) actions.add(action);
|
}
|
||||||
|
if (spec.unsupportedOnMacOS26 && macOS26) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (gate(spec.gate)) {
|
||||||
|
actions.add(action);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") return null;
|
if (action !== "sendMessage") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const to = typeof args.to === "string" ? args.to : undefined;
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
if (!to) return null;
|
if (!to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
const baseUrl = account.config.serverUrl?.trim();
|
const baseUrl = account.config.serverUrl?.trim();
|
||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const opts = { cfg: cfg as OpenClawConfig, accountId: accountId ?? undefined };
|
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
||||||
|
|
||||||
// Helper to resolve chatGuid from various params or session context
|
// Helper to resolve chatGuid from various params or session context
|
||||||
const resolveChatGuid = async (): Promise<string> => {
|
const resolveChatGuid = async (): Promise<string> => {
|
||||||
const chatGuid = readStringParam(params, "chatGuid");
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
if (chatGuid?.trim()) return chatGuid.trim();
|
if (chatGuid?.trim()) {
|
||||||
|
return chatGuid.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
@@ -185,8 +208,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
readStringParam(params, "message");
|
readStringParam(params, "message");
|
||||||
if (!rawMessageId || !newText) {
|
if (!rawMessageId || !newText) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!rawMessageId) missing.push("messageId (the message ID to edit)");
|
if (!rawMessageId) {
|
||||||
if (!newText) missing.push("text (the new message content)");
|
missing.push("messageId (the message ID to edit)");
|
||||||
|
}
|
||||||
|
if (!newText) {
|
||||||
|
missing.push("text (the new message content)");
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
|
||||||
@@ -234,9 +261,15 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||||
if (!rawMessageId || !text || !to) {
|
if (!rawMessageId || !text || !to) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
|
if (!rawMessageId) {
|
||||||
if (!text) missing.push("text or message (the reply message content)");
|
missing.push("messageId (the message ID to reply to)");
|
||||||
if (!to) missing.push("to or target (the chat target)");
|
}
|
||||||
|
if (!text) {
|
||||||
|
missing.push("text or message (the reply message content)");
|
||||||
|
}
|
||||||
|
if (!to) {
|
||||||
|
missing.push("to or target (the chat target)");
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
|
||||||
@@ -262,12 +295,17 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
||||||
if (!text || !to || !effectId) {
|
if (!text || !to || !effectId) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!text) missing.push("text or message (the message content)");
|
if (!text) {
|
||||||
if (!to) missing.push("to or target (the chat target)");
|
missing.push("text or message (the message content)");
|
||||||
if (!effectId)
|
}
|
||||||
|
if (!to) {
|
||||||
|
missing.push("to or target (the chat target)");
|
||||||
|
}
|
||||||
|
if (!effectId) {
|
||||||
missing.push(
|
missing.push(
|
||||||
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
|
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
|
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
|
||||||
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
|
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ function sanitizeFilename(input: string | undefined, fallback: string): string {
|
|||||||
|
|
||||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||||
const currentExt = path.extname(filename);
|
const currentExt = path.extname(filename);
|
||||||
if (currentExt.toLowerCase() === extension) return filename;
|
if (currentExt.toLowerCase() === extension) {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
||||||
return `${base || fallbackBase}${extension}`;
|
return `${base || fallbackBase}${extension}`;
|
||||||
}
|
}
|
||||||
@@ -54,8 +56,12 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
|||||||
});
|
});
|
||||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
const password = params.password?.trim() || account.config.password?.trim();
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
if (!baseUrl) {
|
||||||
if (!password) throw new Error("BlueBubbles password is required");
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error("BlueBubbles password is required");
|
||||||
|
}
|
||||||
return { baseUrl, password };
|
return { baseUrl, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +70,9 @@ export async function downloadBlueBubblesAttachment(
|
|||||||
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||||
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
): Promise<{ buffer: Uint8Array; contentType?: string }> {
|
||||||
const guid = attachment.guid?.trim();
|
const guid = attachment.guid?.trim();
|
||||||
if (!guid) throw new Error("BlueBubbles attachment guid is required");
|
if (!guid) {
|
||||||
|
throw new Error("BlueBubbles attachment guid is required");
|
||||||
|
}
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -110,7 +118,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractMessageId(payload: unknown): string {
|
function extractMessageId(payload: unknown): string {
|
||||||
if (!payload || typeof payload !== "object") return "unknown";
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
const record = payload as Record<string, unknown>;
|
const record = payload as Record<string, unknown>;
|
||||||
const data =
|
const data =
|
||||||
record.data && typeof record.data === "object"
|
record.data && typeof record.data === "object"
|
||||||
@@ -125,8 +135,12 @@ function extractMessageId(payload: unknown): string {
|
|||||||
data?.id,
|
data?.id,
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
if (typeof candidate === "string" && candidate.trim()) {
|
||||||
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||||
|
return String(candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -274,7 +288,9 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseBody = await res.text();
|
const responseBody = await res.text();
|
||||||
if (!responseBody) return { messageId: "ok" };
|
if (!responseBody) {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(responseBody) as unknown;
|
const parsed = JSON.parse(responseBody) as unknown;
|
||||||
return { messageId: extractMessageId(parsed) };
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
|||||||
@@ -78,13 +78,12 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||||
onboarding: blueBubblesOnboardingAdapter,
|
onboarding: blueBubblesOnboardingAdapter,
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as OpenClawConfig),
|
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
|
||||||
resolveAccount: (cfg, accountId) =>
|
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
|
||||||
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }),
|
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
|
||||||
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as OpenClawConfig),
|
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
setAccountEnabledInConfigSection({
|
setAccountEnabledInConfigSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
sectionKey: "bluebubbles",
|
sectionKey: "bluebubbles",
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -92,7 +91,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
}),
|
}),
|
||||||
deleteAccount: ({ cfg, accountId }) =>
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
deleteAccountFromConfigSection({
|
deleteAccountFromConfigSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
sectionKey: "bluebubbles",
|
sectionKey: "bluebubbles",
|
||||||
accountId,
|
accountId,
|
||||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||||
@@ -106,9 +105,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
}),
|
}),
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(
|
(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
|
||||||
resolveBlueBubblesAccount({ cfg: cfg as OpenClawConfig, accountId }).config.allowFrom ?? []
|
String(entry),
|
||||||
).map((entry) => String(entry)),
|
),
|
||||||
formatAllowFrom: ({ allowFrom }) =>
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
allowFrom
|
allowFrom
|
||||||
.map((entry) => String(entry).trim())
|
.map((entry) => String(entry).trim())
|
||||||
@@ -120,9 +119,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const useAccountPath = Boolean(
|
const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]);
|
||||||
(cfg as OpenClawConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
|
|
||||||
);
|
|
||||||
const basePath = useAccountPath
|
const basePath = useAccountPath
|
||||||
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
|
||||||
: "channels.bluebubbles.";
|
: "channels.bluebubbles.";
|
||||||
@@ -137,7 +134,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
},
|
},
|
||||||
collectWarnings: ({ account }) => {
|
collectWarnings: ({ account }) => {
|
||||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
@@ -151,19 +150,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
},
|
},
|
||||||
formatTargetDisplay: ({ target, display }) => {
|
formatTargetDisplay: ({ target, display }) => {
|
||||||
const shouldParseDisplay = (value: string): boolean => {
|
const shouldParseDisplay = (value: string): boolean => {
|
||||||
if (looksLikeBlueBubblesTargetId(value)) return true;
|
if (looksLikeBlueBubblesTargetId(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to extract a clean handle from any BlueBubbles target format
|
// Helper to extract a clean handle from any BlueBubbles target format
|
||||||
const extractCleanDisplay = (value: string | undefined): string | null => {
|
const extractCleanDisplay = (value: string | undefined): string | null => {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = parseBlueBubblesTarget(trimmed);
|
const parsed = parseBlueBubblesTarget(trimmed);
|
||||||
if (parsed.kind === "chat_guid") {
|
if (parsed.kind === "chat_guid") {
|
||||||
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||||
if (handle) return handle;
|
if (handle) {
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (parsed.kind === "handle") {
|
if (parsed.kind === "handle") {
|
||||||
return normalizeBlueBubblesHandle(parsed.to);
|
return normalizeBlueBubblesHandle(parsed.to);
|
||||||
@@ -178,9 +183,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
.replace(/^chat_id:/i, "")
|
.replace(/^chat_id:/i, "")
|
||||||
.replace(/^chat_identifier:/i, "");
|
.replace(/^chat_identifier:/i, "");
|
||||||
const handle = extractHandleFromChatGuid(stripped);
|
const handle = extractHandleFromChatGuid(stripped);
|
||||||
if (handle) return handle;
|
if (handle) {
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
// Don't return raw chat_guid formats - they contain internal routing info
|
// Don't return raw chat_guid formats - they contain internal routing info
|
||||||
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
|
if (stripped.includes(";-;") || stripped.includes(";+;")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return stripped;
|
return stripped;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,12 +200,16 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
return trimmedDisplay;
|
return trimmedDisplay;
|
||||||
}
|
}
|
||||||
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
|
||||||
if (cleanDisplay) return cleanDisplay;
|
if (cleanDisplay) {
|
||||||
|
return cleanDisplay;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to extracting from target
|
// Fall back to extracting from target
|
||||||
const cleanTarget = extractCleanDisplay(target);
|
const cleanTarget = extractCleanDisplay(target);
|
||||||
if (cleanTarget) return cleanTarget;
|
if (cleanTarget) {
|
||||||
|
return cleanTarget;
|
||||||
|
}
|
||||||
|
|
||||||
// Last resort: return display or target as-is
|
// Last resort: return display or target as-is
|
||||||
return display?.trim() || target?.trim() || "";
|
return display?.trim() || target?.trim() || "";
|
||||||
@@ -206,7 +219,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
applyAccountNameToChannelSection({
|
applyAccountNameToChannelSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
channelKey: "bluebubbles",
|
channelKey: "bluebubbles",
|
||||||
accountId,
|
accountId,
|
||||||
name,
|
name,
|
||||||
@@ -215,13 +228,17 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
if (!input.httpUrl && !input.password) {
|
if (!input.httpUrl && !input.password) {
|
||||||
return "BlueBubbles requires --http-url and --password.";
|
return "BlueBubbles requires --http-url and --password.";
|
||||||
}
|
}
|
||||||
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
|
if (!input.httpUrl) {
|
||||||
if (!input.password) return "BlueBubbles requires --password.";
|
return "BlueBubbles requires --http-url.";
|
||||||
|
}
|
||||||
|
if (!input.password) {
|
||||||
|
return "BlueBubbles requires --password.";
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
const namedConfig = applyAccountNameToChannelSection({
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
channelKey: "bluebubbles",
|
channelKey: "bluebubbles",
|
||||||
accountId,
|
accountId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -256,9 +273,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
...next.channels?.bluebubbles,
|
...next.channels?.bluebubbles,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...(next.channels?.bluebubbles?.accounts ?? {}),
|
...next.channels?.bluebubbles?.accounts,
|
||||||
[accountId]: {
|
[accountId]: {
|
||||||
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
|
...next.channels?.bluebubbles?.accounts?.[accountId],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
||||||
...(input.password ? { password: input.password } : {}),
|
...(input.password ? { password: input.password } : {}),
|
||||||
@@ -275,7 +292,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||||
notifyApproval: async ({ cfg, id }) => {
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -299,7 +316,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
: "";
|
: "";
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
@@ -316,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
};
|
};
|
||||||
const resolvedCaption = caption ?? text;
|
const resolvedCaption = caption ?? text;
|
||||||
const result = await sendBlueBubblesMedia({
|
const result = await sendBlueBubblesMedia({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
to,
|
to,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaPath,
|
mediaPath,
|
||||||
@@ -387,7 +404,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||||
return monitorBlueBubblesProvider({
|
return monitorBlueBubblesProvider({
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg as OpenClawConfig,
|
config: ctx.cfg,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
abortSignal: ctx.abortSignal,
|
abortSignal: ctx.abortSignal,
|
||||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ function resolveAccount(params: BlueBubblesChatOpts) {
|
|||||||
});
|
});
|
||||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
const password = params.password?.trim() || account.config.password?.trim();
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
if (!baseUrl) {
|
||||||
if (!password) throw new Error("BlueBubbles password is required");
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error("BlueBubbles password is required");
|
||||||
|
}
|
||||||
return { baseUrl, password };
|
return { baseUrl, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +32,9 @@ export async function markBlueBubblesChatRead(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmed = chatGuid.trim();
|
const trimmed = chatGuid.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -48,7 +54,9 @@ export async function sendBlueBubblesTyping(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmed = chatGuid.trim();
|
const trimmed = chatGuid.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -76,9 +84,13 @@ export async function editBlueBubblesMessage(
|
|||||||
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
|
opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = messageGuid.trim();
|
const trimmedGuid = messageGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles edit requires messageGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles edit requires messageGuid");
|
||||||
|
}
|
||||||
const trimmedText = newText.trim();
|
const trimmedText = newText.trim();
|
||||||
if (!trimmedText) throw new Error("BlueBubbles edit requires newText");
|
if (!trimmedText) {
|
||||||
|
throw new Error("BlueBubbles edit requires newText");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -118,7 +130,9 @@ export async function unsendBlueBubblesMessage(
|
|||||||
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
|
opts: BlueBubblesChatOpts & { partIndex?: number } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = messageGuid.trim();
|
const trimmedGuid = messageGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles unsend requires messageGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles unsend requires messageGuid");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -156,7 +170,9 @@ export async function renameBlueBubblesChat(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = chatGuid.trim();
|
const trimmedGuid = chatGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles rename requires chatGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles rename requires chatGuid");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -190,9 +206,13 @@ export async function addBlueBubblesParticipant(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = chatGuid.trim();
|
const trimmedGuid = chatGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles addParticipant requires chatGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles addParticipant requires chatGuid");
|
||||||
|
}
|
||||||
const trimmedAddress = address.trim();
|
const trimmedAddress = address.trim();
|
||||||
if (!trimmedAddress) throw new Error("BlueBubbles addParticipant requires address");
|
if (!trimmedAddress) {
|
||||||
|
throw new Error("BlueBubbles addParticipant requires address");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -226,9 +246,13 @@ export async function removeBlueBubblesParticipant(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = chatGuid.trim();
|
const trimmedGuid = chatGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles removeParticipant requires chatGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles removeParticipant requires chatGuid");
|
||||||
|
}
|
||||||
const trimmedAddress = address.trim();
|
const trimmedAddress = address.trim();
|
||||||
if (!trimmedAddress) throw new Error("BlueBubbles removeParticipant requires address");
|
if (!trimmedAddress) {
|
||||||
|
throw new Error("BlueBubbles removeParticipant requires address");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -263,7 +287,9 @@ export async function leaveBlueBubblesChat(
|
|||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = chatGuid.trim();
|
const trimmedGuid = chatGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles leaveChat requires chatGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
||||||
|
}
|
||||||
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
@@ -291,7 +317,9 @@ export async function setGroupIconBlueBubbles(
|
|||||||
opts: BlueBubblesChatOpts & { contentType?: string } = {},
|
opts: BlueBubblesChatOpts & { contentType?: string } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmedGuid = chatGuid.trim();
|
const trimmedGuid = chatGuid.trim();
|
||||||
if (!trimmedGuid) throw new Error("BlueBubbles setGroupIcon requires chatGuid");
|
if (!trimmedGuid) {
|
||||||
|
throw new Error("BlueBubbles setGroupIcon requires chatGuid");
|
||||||
|
}
|
||||||
if (!buffer || buffer.length === 0) {
|
if (!buffer || buffer.length === 0) {
|
||||||
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ const HTTP_URL_RE = /^https?:\/\//i;
|
|||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void {
|
||||||
if (typeof maxBytes !== "number" || maxBytes <= 0) return;
|
if (typeof maxBytes !== "number" || maxBytes <= 0) {
|
||||||
if (sizeBytes <= maxBytes) return;
|
return;
|
||||||
|
}
|
||||||
|
if (sizeBytes <= maxBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const maxLabel = (maxBytes / MB).toFixed(0);
|
const maxLabel = (maxBytes / MB).toFixed(0);
|
||||||
const sizeLabel = (sizeBytes / MB).toFixed(2);
|
const sizeLabel = (sizeBytes / MB).toFixed(2);
|
||||||
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveLocalMediaPath(source: string): string {
|
function resolveLocalMediaPath(source: string): string {
|
||||||
if (!source.startsWith("file://")) return source;
|
if (!source.startsWith("file://")) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return fileURLToPath(source);
|
return fileURLToPath(source);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -29,7 +35,9 @@ function resolveLocalMediaPath(source: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveFilenameFromSource(source?: string): string | undefined {
|
function resolveFilenameFromSource(source?: string): string | undefined {
|
||||||
if (!source) return undefined;
|
if (!source) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (source.startsWith("file://")) {
|
if (source.startsWith("file://")) {
|
||||||
try {
|
try {
|
||||||
return path.basename(fileURLToPath(source)) || undefined;
|
return path.basename(fileURLToPath(source)) || undefined;
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ function createMockRequest(
|
|||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
||||||
|
|
||||||
// Emit body data after a microtask
|
// Emit body data after a microtask
|
||||||
|
// oxlint-disable-next-line no-floating-promises
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
req.emit("data", Buffer.from(bodyStr));
|
req.emit("data", Buffer.from(bodyStr));
|
||||||
@@ -1225,7 +1226,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
const flush = async (key: string) => {
|
const flush = async (key: string) => {
|
||||||
const bucket = buckets.get(key);
|
const bucket = buckets.get(key);
|
||||||
if (!bucket) return;
|
if (!bucket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (bucket.timer) {
|
if (bucket.timer) {
|
||||||
clearTimeout(bucket.timer);
|
clearTimeout(bucket.timer);
|
||||||
bucket.timer = null;
|
bucket.timer = null;
|
||||||
@@ -1253,7 +1256,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const existing = buckets.get(key);
|
const existing = buckets.get(key);
|
||||||
const bucket = existing ?? { items: [], timer: null };
|
const bucket = existing ?? { items: [], timer: null };
|
||||||
bucket.items.push(item);
|
bucket.items.push(item);
|
||||||
if (bucket.timer) clearTimeout(bucket.timer);
|
if (bucket.timer) {
|
||||||
|
clearTimeout(bucket.timer);
|
||||||
|
}
|
||||||
bucket.timer = setTimeout(async () => {
|
bucket.timer = setTimeout(async () => {
|
||||||
await flush(key);
|
await flush(key);
|
||||||
}, params.debounceMs);
|
}, params.debounceMs);
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ function rememberBlueBubblesReplyCache(
|
|||||||
}
|
}
|
||||||
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
|
||||||
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
|
||||||
if (!oldest) break;
|
if (!oldest) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
|
||||||
blueBubblesReplyCacheByMessageId.delete(oldest);
|
blueBubblesReplyCacheByMessageId.delete(oldest);
|
||||||
// Clean up short ID mappings for evicted entries
|
// Clean up short ID mappings for evicted entries
|
||||||
@@ -134,12 +136,16 @@ export function resolveBlueBubblesMessageId(
|
|||||||
opts?: { requireKnownShortId?: boolean },
|
opts?: { requireKnownShortId?: boolean },
|
||||||
): string {
|
): string {
|
||||||
const trimmed = shortOrUuid.trim();
|
const trimmed = shortOrUuid.trim();
|
||||||
if (!trimmed) return trimmed;
|
if (!trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
// If it looks like a short ID (numeric), try to resolve it
|
// If it looks like a short ID (numeric), try to resolve it
|
||||||
if (/^\d+$/.test(trimmed)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
||||||
if (uuid) return uuid;
|
if (uuid) {
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
if (opts?.requireKnownShortId) {
|
if (opts?.requireKnownShortId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
|
||||||
@@ -177,11 +183,17 @@ function resolveReplyContextFromCache(params: {
|
|||||||
chatId?: number;
|
chatId?: number;
|
||||||
}): BlueBubblesReplyCacheEntry | null {
|
}): BlueBubblesReplyCacheEntry | null {
|
||||||
const replyToId = params.replyToId.trim();
|
const replyToId = params.replyToId.trim();
|
||||||
if (!replyToId) return null;
|
if (!replyToId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
|
||||||
if (!cached) return null;
|
if (!cached) {
|
||||||
if (cached.accountId !== params.accountId) return null;
|
return null;
|
||||||
|
}
|
||||||
|
if (cached.accountId !== params.accountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
|
||||||
if (cached.timestamp < cutoff) {
|
if (cached.timestamp < cutoff) {
|
||||||
@@ -197,7 +209,9 @@ function resolveReplyContextFromCache(params: {
|
|||||||
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
|
||||||
|
|
||||||
// Avoid cross-chat collisions if we have identifiers.
|
// Avoid cross-chat collisions if we have identifiers.
|
||||||
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
|
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!chatGuid &&
|
!chatGuid &&
|
||||||
chatIdentifier &&
|
chatIdentifier &&
|
||||||
@@ -300,10 +314,14 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const text = entry.message.text.trim();
|
const text = entry.message.text.trim();
|
||||||
if (!text) continue;
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Skip duplicate text (URL might be in both text message and balloon)
|
// Skip duplicate text (URL might be in both text message and balloon)
|
||||||
const normalizedText = text.toLowerCase();
|
const normalizedText = text.toLowerCase();
|
||||||
if (seenTexts.has(normalizedText)) continue;
|
if (seenTexts.has(normalizedText)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seenTexts.add(normalizedText);
|
seenTexts.add(normalizedText);
|
||||||
textParts.push(text);
|
textParts.push(text);
|
||||||
}
|
}
|
||||||
@@ -359,7 +377,9 @@ function resolveBlueBubblesDebounceMs(
|
|||||||
const inbound = config.messages?.inbound;
|
const inbound = config.messages?.inbound;
|
||||||
const hasExplicitDebounce =
|
const hasExplicitDebounce =
|
||||||
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
|
||||||
if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
|
if (!hasExplicitDebounce) {
|
||||||
|
return DEFAULT_INBOUND_DEBOUNCE_MS;
|
||||||
|
}
|
||||||
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +388,9 @@ function resolveBlueBubblesDebounceMs(
|
|||||||
*/
|
*/
|
||||||
function getOrCreateDebouncer(target: WebhookTarget) {
|
function getOrCreateDebouncer(target: WebhookTarget) {
|
||||||
const existing = targetDebouncers.get(target);
|
const existing = targetDebouncers.get(target);
|
||||||
if (existing) return existing;
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const { account, config, runtime, core } = target;
|
const { account, config, runtime, core } = target;
|
||||||
|
|
||||||
@@ -402,15 +424,21 @@ function getOrCreateDebouncer(target: WebhookTarget) {
|
|||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
// Skip debouncing for from-me messages (they're just cached, not processed)
|
// Skip debouncing for from-me messages (they're just cached, not processed)
|
||||||
if (msg.fromMe) return false;
|
if (msg.fromMe) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Skip debouncing for control commands - process immediately
|
// Skip debouncing for control commands - process immediately
|
||||||
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
|
if (core.channel.text.hasControlCommand(msg.text, config)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Debounce all other messages to coalesce rapid-fire webhook events
|
// Debounce all other messages to coalesce rapid-fire webhook events
|
||||||
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use target from first entry (all entries have same target due to key structure)
|
// Use target from first entry (all entries have same target due to key structure)
|
||||||
const flushTarget = entries[0].target;
|
const flushTarget = entries[0].target;
|
||||||
@@ -452,7 +480,9 @@ function removeDebouncer(target: WebhookTarget): void {
|
|||||||
|
|
||||||
function normalizeWebhookPath(raw: string): string {
|
function normalizeWebhookPath(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "/";
|
if (!trimmed) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||||
return withSlash.slice(0, -1);
|
return withSlash.slice(0, -1);
|
||||||
@@ -527,30 +557,40 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
|
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
|
||||||
if (!record) return undefined;
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
return typeof value === "string" ? value : undefined;
|
return typeof value === "string" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
if (!record) return undefined;
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
|
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
|
||||||
if (!record) return undefined;
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
return typeof value === "boolean" ? value : undefined;
|
return typeof value === "boolean" ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
||||||
const raw = message["attachments"];
|
const raw = message["attachments"];
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const out: BlueBubblesAttachment[] = [];
|
const out: BlueBubblesAttachment[] = [];
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
const record = asRecord(entry);
|
const record = asRecord(entry);
|
||||||
if (!record) continue;
|
if (!record) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
out.push({
|
out.push({
|
||||||
guid: readString(record, "guid"),
|
guid: readString(record, "guid"),
|
||||||
uti: readString(record, "uti"),
|
uti: readString(record, "uti"),
|
||||||
@@ -566,7 +606,9 @@ function extractAttachments(message: Record<string, unknown>): BlueBubblesAttach
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
||||||
if (attachments.length === 0) return "";
|
if (attachments.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
||||||
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
||||||
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
||||||
@@ -585,8 +627,12 @@ function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): strin
|
|||||||
|
|
||||||
function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||||
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
|
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
|
||||||
if (attachmentPlaceholder) return attachmentPlaceholder;
|
if (attachmentPlaceholder) {
|
||||||
if (message.balloonBundleId) return "<media:sticker>";
|
return attachmentPlaceholder;
|
||||||
|
}
|
||||||
|
if (message.balloonBundleId) {
|
||||||
|
return "<media:sticker>";
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,17 +640,25 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
|
function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null {
|
||||||
// Prefer short ID
|
// Prefer short ID
|
||||||
const rawId = message.replyToShortId || message.replyToId;
|
const rawId = message.replyToShortId || message.replyToId;
|
||||||
if (!rawId) return null;
|
if (!rawId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return `[[reply_to:${rawId}]]`;
|
return `[[reply_to:${rawId}]]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
if (!record) return undefined;
|
if (!record) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const parsed = Number.parseFloat(value);
|
const parsed = Number.parseFloat(value);
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
if (Number.isFinite(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -683,7 +737,9 @@ function extractReplyMetadata(message: Record<string, unknown>): {
|
|||||||
|
|
||||||
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||||
const chats = message["chats"];
|
const chats = message["chats"];
|
||||||
if (!Array.isArray(chats) || chats.length === 0) return null;
|
if (!Array.isArray(chats) || chats.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const first = chats[0];
|
const first = chats[0];
|
||||||
return asRecord(first);
|
return asRecord(first);
|
||||||
}
|
}
|
||||||
@@ -691,12 +747,16 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
|
|||||||
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
||||||
if (typeof entry === "string" || typeof entry === "number") {
|
if (typeof entry === "string" || typeof entry === "number") {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw) return null;
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
||||||
return normalized ? { id: normalized } : null;
|
return normalized ? { id: normalized } : null;
|
||||||
}
|
}
|
||||||
const record = asRecord(entry);
|
const record = asRecord(entry);
|
||||||
if (!record) return null;
|
if (!record) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const nestedHandle =
|
const nestedHandle =
|
||||||
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
||||||
const idRaw =
|
const idRaw =
|
||||||
@@ -716,20 +776,28 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul
|
|||||||
readString(nestedHandle, "displayName") ??
|
readString(nestedHandle, "displayName") ??
|
||||||
readString(nestedHandle, "name");
|
readString(nestedHandle, "name");
|
||||||
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
||||||
if (!normalizedId) return null;
|
if (!normalizedId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const name = nameRaw?.trim() || undefined;
|
const name = nameRaw?.trim() || undefined;
|
||||||
return { id: normalizedId, name };
|
return { id: normalizedId, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const output: BlueBubblesParticipant[] = [];
|
const output: BlueBubblesParticipant[] = [];
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
const normalized = normalizeParticipantEntry(entry);
|
const normalized = normalizeParticipantEntry(entry);
|
||||||
if (!normalized?.id) continue;
|
if (!normalized?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const key = normalized.id.toLowerCase();
|
const key = normalized.id.toLowerCase();
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
output.push(normalized);
|
output.push(normalized);
|
||||||
}
|
}
|
||||||
@@ -743,37 +811,57 @@ function formatGroupMembers(params: {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const ordered: BlueBubblesParticipant[] = [];
|
const ordered: BlueBubblesParticipant[] = [];
|
||||||
for (const entry of params.participants ?? []) {
|
for (const entry of params.participants ?? []) {
|
||||||
if (!entry?.id) continue;
|
if (!entry?.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const key = entry.id.toLowerCase();
|
const key = entry.id.toLowerCase();
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
ordered.push(entry);
|
ordered.push(entry);
|
||||||
}
|
}
|
||||||
if (ordered.length === 0 && params.fallback?.id) {
|
if (ordered.length === 0 && params.fallback?.id) {
|
||||||
ordered.push(params.fallback);
|
ordered.push(params.fallback);
|
||||||
}
|
}
|
||||||
if (ordered.length === 0) return undefined;
|
if (ordered.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
|
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
||||||
const guid = chatGuid?.trim();
|
const guid = chatGuid?.trim();
|
||||||
if (!guid) return undefined;
|
if (!guid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const parts = guid.split(";");
|
const parts = guid.split(";");
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
if (parts[1] === "+") return true;
|
if (parts[1] === "+") {
|
||||||
if (parts[1] === "-") return false;
|
return true;
|
||||||
|
}
|
||||||
|
if (parts[1] === "-") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (guid.includes(";+;")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (guid.includes(";-;")) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (guid.includes(";+;")) return true;
|
|
||||||
if (guid.includes(";-;")) return false;
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
||||||
const guid = chatGuid?.trim();
|
const guid = chatGuid?.trim();
|
||||||
if (!guid) return undefined;
|
if (!guid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const parts = guid.split(";");
|
const parts = guid.split(";");
|
||||||
if (parts.length < 3) return undefined;
|
if (parts.length < 3) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const identifier = parts[2]?.trim();
|
const identifier = parts[2]?.trim();
|
||||||
return identifier || undefined;
|
return identifier || undefined;
|
||||||
}
|
}
|
||||||
@@ -784,11 +872,17 @@ function formatGroupAllowlistEntry(params: {
|
|||||||
chatIdentifier?: string;
|
chatIdentifier?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const guid = params.chatGuid?.trim();
|
const guid = params.chatGuid?.trim();
|
||||||
if (guid) return `chat_guid:${guid}`;
|
if (guid) {
|
||||||
|
return `chat_guid:${guid}`;
|
||||||
|
}
|
||||||
const chatId = params.chatId;
|
const chatId = params.chatId;
|
||||||
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
|
if (typeof chatId === "number" && Number.isFinite(chatId)) {
|
||||||
|
return `chat_id:${chatId}`;
|
||||||
|
}
|
||||||
const identifier = params.chatIdentifier?.trim();
|
const identifier = params.chatIdentifier?.trim();
|
||||||
if (identifier) return `chat_identifier:${identifier}`;
|
if (identifier) {
|
||||||
|
return `chat_identifier:${identifier}`;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,9 +980,15 @@ function isTapbackAssociatedType(type: number | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
||||||
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
|
if (typeof type !== "number" || !Number.isFinite(type)) {
|
||||||
if (type >= 3000 && type < 4000) return "removed";
|
return undefined;
|
||||||
if (type >= 2000 && type < 3000) return "added";
|
}
|
||||||
|
if (type >= 3000 && type < 4000) {
|
||||||
|
return "removed";
|
||||||
|
}
|
||||||
|
if (type >= 2000 && type < 3000) {
|
||||||
|
return "added";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +1000,9 @@ function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
|||||||
const associatedType = message.associatedMessageType;
|
const associatedType = message.associatedMessageType;
|
||||||
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
||||||
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
||||||
if (!hasTapbackType && !hasTapbackMarker) return null;
|
if (!hasTapbackType && !hasTapbackMarker) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
||||||
const actionHint = resolveTapbackActionHint(associatedType);
|
const actionHint = resolveTapbackActionHint(associatedType);
|
||||||
const emojiHint =
|
const emojiHint =
|
||||||
@@ -921,7 +1023,9 @@ function parseTapbackText(params: {
|
|||||||
} | null {
|
} | null {
|
||||||
const trimmed = params.text.trim();
|
const trimmed = params.text.trim();
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||||
if (lower.startsWith(pattern)) {
|
if (lower.startsWith(pattern)) {
|
||||||
@@ -929,7 +1033,9 @@ function parseTapbackText(params: {
|
|||||||
const afterPattern = trimmed.slice(pattern.length).trim();
|
const afterPattern = trimmed.slice(pattern.length).trim();
|
||||||
if (params.requireQuoted) {
|
if (params.requireQuoted) {
|
||||||
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
||||||
if (!strictMatch) return null;
|
if (!strictMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { emoji, action, quotedText: strictMatch[1] };
|
return { emoji, action, quotedText: strictMatch[1] };
|
||||||
}
|
}
|
||||||
const quotedText =
|
const quotedText =
|
||||||
@@ -940,18 +1046,26 @@ function parseTapbackText(params: {
|
|||||||
|
|
||||||
if (lower.startsWith("reacted")) {
|
if (lower.startsWith("reacted")) {
|
||||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||||
if (!emoji) return null;
|
if (!emoji) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const quotedText = extractQuotedTapbackText(trimmed);
|
const quotedText = extractQuotedTapbackText(trimmed);
|
||||||
if (params.requireQuoted && !quotedText) return null;
|
if (params.requireQuoted && !quotedText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const fallback = trimmed.slice("reacted".length).trim();
|
const fallback = trimmed.slice("reacted".length).trim();
|
||||||
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lower.startsWith("removed")) {
|
if (lower.startsWith("removed")) {
|
||||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||||
if (!emoji) return null;
|
if (!emoji) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const quotedText = extractQuotedTapbackText(trimmed);
|
const quotedText = extractQuotedTapbackText(trimmed);
|
||||||
if (params.requireQuoted && !quotedText) return null;
|
if (params.requireQuoted && !quotedText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const fallback = trimmed.slice("removed".length).trim();
|
const fallback = trimmed.slice("removed".length).trim();
|
||||||
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||||
}
|
}
|
||||||
@@ -959,7 +1073,9 @@ function parseTapbackText(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maskSecret(value: string): string {
|
function maskSecret(value: string): string {
|
||||||
if (value.length <= 6) return "***";
|
if (value.length <= 6) {
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,7 +1086,9 @@ function resolveBlueBubblesAckReaction(params: {
|
|||||||
runtime: BlueBubblesRuntimeEnv;
|
runtime: BlueBubblesRuntimeEnv;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
||||||
if (!raw) return null;
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
normalizeBlueBubblesReactionInput(raw);
|
normalizeBlueBubblesReactionInput(raw);
|
||||||
return raw;
|
return raw;
|
||||||
@@ -997,7 +1115,9 @@ function extractMessagePayload(payload: Record<string, unknown>): Record<string,
|
|||||||
const message =
|
const message =
|
||||||
asRecord(messageRaw) ??
|
asRecord(messageRaw) ??
|
||||||
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
||||||
if (!message) return null;
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,7 +1125,9 @@ function normalizeWebhookMessage(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): NormalizedWebhookMessage | null {
|
): NormalizedWebhookMessage | null {
|
||||||
const message = extractMessagePayload(payload);
|
const message = extractMessagePayload(payload);
|
||||||
if (!message) return null;
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const text =
|
const text =
|
||||||
readString(message, "text") ??
|
readString(message, "text") ??
|
||||||
@@ -1090,7 +1212,7 @@ function normalizeWebhookMessage(
|
|||||||
const isGroup =
|
const isGroup =
|
||||||
typeof groupFromChatGuid === "boolean"
|
typeof groupFromChatGuid === "boolean"
|
||||||
? groupFromChatGuid
|
? groupFromChatGuid
|
||||||
: (explicitIsGroup ?? (participantsCount > 2 ? true : false));
|
: (explicitIsGroup ?? participantsCount > 2);
|
||||||
|
|
||||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||||
const messageId =
|
const messageId =
|
||||||
@@ -1131,7 +1253,9 @@ function normalizeWebhookMessage(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||||
if (!normalizedSender) return null;
|
if (!normalizedSender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const replyMetadata = extractReplyMetadata(message);
|
const replyMetadata = extractReplyMetadata(message);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1163,7 +1287,9 @@ function normalizeWebhookReaction(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): NormalizedWebhookReaction | null {
|
): NormalizedWebhookReaction | null {
|
||||||
const message = extractMessagePayload(payload);
|
const message = extractMessagePayload(payload);
|
||||||
if (!message) return null;
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const associatedGuid =
|
const associatedGuid =
|
||||||
readString(message, "associatedMessageGuid") ??
|
readString(message, "associatedMessageGuid") ??
|
||||||
@@ -1172,7 +1298,9 @@ function normalizeWebhookReaction(
|
|||||||
const associatedType =
|
const associatedType =
|
||||||
readNumberLike(message, "associatedMessageType") ??
|
readNumberLike(message, "associatedMessageType") ??
|
||||||
readNumberLike(message, "associated_message_type");
|
readNumberLike(message, "associated_message_type");
|
||||||
if (!associatedGuid || associatedType === undefined) return null;
|
if (!associatedGuid || associatedType === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||||
const associatedEmoji =
|
const associatedEmoji =
|
||||||
@@ -1258,7 +1386,7 @@ function normalizeWebhookReaction(
|
|||||||
const isGroup =
|
const isGroup =
|
||||||
typeof groupFromChatGuid === "boolean"
|
typeof groupFromChatGuid === "boolean"
|
||||||
? groupFromChatGuid
|
? groupFromChatGuid
|
||||||
: (explicitIsGroup ?? (participantsCount > 2 ? true : false));
|
: (explicitIsGroup ?? participantsCount > 2);
|
||||||
|
|
||||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||||
const timestampRaw =
|
const timestampRaw =
|
||||||
@@ -1273,7 +1401,9 @@ function normalizeWebhookReaction(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||||
if (!normalizedSender) return null;
|
if (!normalizedSender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action,
|
action,
|
||||||
@@ -1298,7 +1428,9 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const path = normalizeWebhookPath(url.pathname);
|
const path = normalizeWebhookPath(url.pathname);
|
||||||
const targets = webhookTargets.get(path);
|
const targets = webhookTargets.get(path);
|
||||||
if (!targets || targets.length === 0) return false;
|
if (!targets || targets.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
@@ -1368,7 +1500,9 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
|
|
||||||
const matching = targets.filter((target) => {
|
const matching = targets.filter((target) => {
|
||||||
const token = target.account.config.password?.trim();
|
const token = target.account.config.password?.trim();
|
||||||
if (!token) return true;
|
if (!token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
|
||||||
const headerToken =
|
const headerToken =
|
||||||
req.headers["x-guid"] ??
|
req.headers["x-guid"] ??
|
||||||
@@ -1376,7 +1510,9 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
req.headers["x-bluebubbles-guid"] ??
|
req.headers["x-bluebubbles-guid"] ??
|
||||||
req.headers["authorization"];
|
req.headers["authorization"];
|
||||||
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
|
||||||
if (guid && guid.trim() === token) return true;
|
if (guid && guid.trim() === token) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const remote = req.socket?.remoteAddress ?? "";
|
const remote = req.socket?.remoteAddress ?? "";
|
||||||
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
|
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
|
||||||
return true;
|
return true;
|
||||||
@@ -1466,7 +1602,9 @@ async function processMessage(
|
|||||||
const cacheMessageId = message.messageId?.trim();
|
const cacheMessageId = message.messageId?.trim();
|
||||||
let messageShortId: string | undefined;
|
let messageShortId: string | undefined;
|
||||||
const cacheInboundMessage = () => {
|
const cacheInboundMessage = () => {
|
||||||
if (!cacheMessageId) return;
|
if (!cacheMessageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cacheEntry = rememberBlueBubblesReplyCache({
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
messageId: cacheMessageId,
|
messageId: cacheMessageId,
|
||||||
@@ -1743,7 +1881,9 @@ async function processMessage(
|
|||||||
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
|
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
|
||||||
} else {
|
} else {
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
if (!attachment.guid) continue;
|
if (!attachment.guid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
|
if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
|
||||||
logVerbose(
|
logVerbose(
|
||||||
core,
|
core,
|
||||||
@@ -1797,8 +1937,12 @@ async function processMessage(
|
|||||||
chatId: message.chatId,
|
chatId: message.chatId,
|
||||||
});
|
});
|
||||||
if (cached) {
|
if (cached) {
|
||||||
if (!replyToBody && cached.body) replyToBody = cached.body;
|
if (!replyToBody && cached.body) {
|
||||||
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
|
replyToBody = cached.body;
|
||||||
|
}
|
||||||
|
if (!replyToSender && cached.senderLabel) {
|
||||||
|
replyToSender = cached.senderLabel;
|
||||||
|
}
|
||||||
replyToShortId = cached.shortId;
|
replyToShortId = cached.shortId;
|
||||||
if (core.logging.shouldLogVerbose()) {
|
if (core.logging.shouldLogVerbose()) {
|
||||||
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
||||||
@@ -1940,7 +2084,9 @@ async function processMessage(
|
|||||||
|
|
||||||
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||||
const trimmed = messageId?.trim();
|
const trimmed = messageId?.trim();
|
||||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Cache outbound message to get short ID
|
// Cache outbound message to get short ID
|
||||||
const cacheEntry = rememberBlueBubblesReplyCache({
|
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -2059,8 +2205,12 @@ async function processMessage(
|
|||||||
chunkMode === "newline"
|
chunkMode === "newline"
|
||||||
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
||||||
: core.channel.text.chunkMarkdownText(text, textLimit);
|
: core.channel.text.chunkMarkdownText(text, textLimit);
|
||||||
if (!chunks.length && text) chunks.push(text);
|
if (!chunks.length && text) {
|
||||||
if (!chunks.length) return;
|
chunks.push(text);
|
||||||
|
}
|
||||||
|
if (!chunks.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||||
@@ -2085,8 +2235,12 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onReplyStart: async () => {
|
onReplyStart: async () => {
|
||||||
if (!chatGuidForActions) return;
|
if (!chatGuidForActions) {
|
||||||
if (!baseUrl || !password) return;
|
return;
|
||||||
|
}
|
||||||
|
if (!baseUrl || !password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
|
logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`);
|
||||||
try {
|
try {
|
||||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||||
@@ -2098,8 +2252,12 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onIdle: async () => {
|
onIdle: async () => {
|
||||||
if (!chatGuidForActions) return;
|
if (!chatGuidForActions) {
|
||||||
if (!baseUrl || !password) return;
|
return;
|
||||||
|
}
|
||||||
|
if (!baseUrl || !password) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await sendBlueBubblesTyping(chatGuidForActions, false, {
|
await sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@@ -2167,7 +2325,9 @@ async function processReaction(
|
|||||||
target: WebhookTarget,
|
target: WebhookTarget,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, core } = target;
|
const { account, config, runtime, core } = target;
|
||||||
if (reaction.fromMe) return;
|
if (reaction.fromMe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||||
@@ -2187,9 +2347,13 @@ async function processReaction(
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (reaction.isGroup) {
|
if (reaction.isGroup) {
|
||||||
if (groupPolicy === "disabled") return;
|
if (groupPolicy === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (groupPolicy === "allowlist") {
|
if (groupPolicy === "allowlist") {
|
||||||
if (effectiveGroupAllowFrom.length === 0) return;
|
if (effectiveGroupAllowFrom.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const allowed = isAllowedBlueBubblesSender({
|
const allowed = isAllowedBlueBubblesSender({
|
||||||
allowFrom: effectiveGroupAllowFrom,
|
allowFrom: effectiveGroupAllowFrom,
|
||||||
sender: reaction.senderId,
|
sender: reaction.senderId,
|
||||||
@@ -2197,10 +2361,14 @@ async function processReaction(
|
|||||||
chatGuid: reaction.chatGuid ?? undefined,
|
chatGuid: reaction.chatGuid ?? undefined,
|
||||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||||
});
|
});
|
||||||
if (!allowed) return;
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (dmPolicy === "disabled") return;
|
if (dmPolicy === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const allowed = isAllowedBlueBubblesSender({
|
const allowed = isAllowedBlueBubblesSender({
|
||||||
allowFrom: effectiveAllowFrom,
|
allowFrom: effectiveAllowFrom,
|
||||||
@@ -2209,7 +2377,9 @@ async function processReaction(
|
|||||||
chatGuid: reaction.chatGuid ?? undefined,
|
chatGuid: reaction.chatGuid ?? undefined,
|
||||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||||
});
|
});
|
||||||
if (!allowed) return;
|
if (!allowed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2293,6 +2463,8 @@ export async function monitorBlueBubblesProvider(
|
|||||||
|
|
||||||
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {
|
||||||
const raw = config?.webhookPath?.trim();
|
const raw = config?.webhookPath?.trim();
|
||||||
if (raw) return normalizeWebhookPath(raw);
|
if (raw) {
|
||||||
|
return normalizeWebhookPath(raw);
|
||||||
|
}
|
||||||
return DEFAULT_WEBHOOK_PATH;
|
return DEFAULT_WEBHOOK_PATH;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||||
import { parseBlueBubblesAllowTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||||
|
|
||||||
const channel = "bluebubbles" as const;
|
const channel = "bluebubbles" as const;
|
||||||
|
|
||||||
@@ -110,10 +110,14 @@ async function promptBlueBubblesAllowFrom(params: {
|
|||||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) return "Required";
|
if (!raw) {
|
||||||
|
return "Required";
|
||||||
|
}
|
||||||
const parts = parseBlueBubblesAllowFromInput(raw);
|
const parts = parseBlueBubblesAllowFromInput(raw);
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part === "*") continue;
|
if (part === "*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parsed = parseBlueBubblesAllowTarget(part);
|
const parsed = parseBlueBubblesAllowTarget(part);
|
||||||
if (parsed.kind === "handle" && !parsed.handle) {
|
if (parsed.kind === "handle" && !parsed.handle) {
|
||||||
return `Invalid entry: ${part}`;
|
return `Invalid entry: ${part}`;
|
||||||
@@ -188,7 +192,9 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
placeholder: "http://192.168.1.100:1234",
|
placeholder: "http://192.168.1.100:1234",
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const trimmed = String(value ?? "").trim();
|
const trimmed = String(value ?? "").trim();
|
||||||
if (!trimmed) return "Required";
|
if (!trimmed) {
|
||||||
|
return "Required";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||||
new URL(normalized);
|
new URL(normalized);
|
||||||
@@ -211,7 +217,9 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
initialValue: serverUrl,
|
initialValue: serverUrl,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const trimmed = String(value ?? "").trim();
|
const trimmed = String(value ?? "").trim();
|
||||||
if (!trimmed) return "Required";
|
if (!trimmed) {
|
||||||
|
return "Required";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||||
new URL(normalized);
|
new URL(normalized);
|
||||||
@@ -268,8 +276,12 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
initialValue: existingWebhookPath || "/bluebubbles-webhook",
|
initialValue: existingWebhookPath || "/bluebubbles-webhook",
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const trimmed = String(value ?? "").trim();
|
const trimmed = String(value ?? "").trim();
|
||||||
if (!trimmed) return "Required";
|
if (!trimmed) {
|
||||||
if (!trimmed.startsWith("/")) return "Path must start with /";
|
return "Required";
|
||||||
|
}
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
return "Path must start with /";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export async function fetchBlueBubblesServerInfo(params: {
|
|||||||
}): Promise<BlueBubblesServerInfo | null> {
|
}): Promise<BlueBubblesServerInfo | null> {
|
||||||
const baseUrl = params.baseUrl?.trim();
|
const baseUrl = params.baseUrl?.trim();
|
||||||
const password = params.password?.trim();
|
const password = params.password?.trim();
|
||||||
if (!baseUrl || !password) return null;
|
if (!baseUrl || !password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const cacheKey = buildCacheKey(params.accountId);
|
const cacheKey = buildCacheKey(params.accountId);
|
||||||
const cached = serverInfoCache.get(cacheKey);
|
const cached = serverInfoCache.get(cacheKey);
|
||||||
@@ -47,7 +49,9 @@ export async function fetchBlueBubblesServerInfo(params: {
|
|||||||
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password });
|
||||||
try {
|
try {
|
||||||
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
|
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -76,7 +80,9 @@ export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesS
|
|||||||
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
* Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number.
|
||||||
*/
|
*/
|
||||||
export function parseMacOSMajorVersion(version?: string | null): number | null {
|
export function parseMacOSMajorVersion(version?: string | null): number | null {
|
||||||
if (!version) return null;
|
if (!version) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const match = /^(\d+)/.exec(version.trim());
|
const match = /^(\d+)/.exec(version.trim());
|
||||||
return match ? Number.parseInt(match[1], 10) : null;
|
return match ? Number.parseInt(match[1], 10) : null;
|
||||||
}
|
}
|
||||||
@@ -87,7 +93,9 @@ export function parseMacOSMajorVersion(version?: string | null): number | null {
|
|||||||
*/
|
*/
|
||||||
export function isMacOS26OrHigher(accountId?: string): boolean {
|
export function isMacOS26OrHigher(accountId?: string): boolean {
|
||||||
const info = getCachedBlueBubblesServerInfo(accountId);
|
const info = getCachedBlueBubblesServerInfo(accountId);
|
||||||
if (!info?.os_version) return false;
|
if (!info?.os_version) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const major = parseMacOSMajorVersion(info.os_version);
|
const major = parseMacOSMajorVersion(info.os_version);
|
||||||
return major !== null && major >= 26;
|
return major !== null && major >= 26;
|
||||||
}
|
}
|
||||||
@@ -104,8 +112,12 @@ export async function probeBlueBubbles(params: {
|
|||||||
}): Promise<BlueBubblesProbe> {
|
}): Promise<BlueBubblesProbe> {
|
||||||
const baseUrl = params.baseUrl?.trim();
|
const baseUrl = params.baseUrl?.trim();
|
||||||
const password = params.password?.trim();
|
const password = params.password?.trim();
|
||||||
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
|
if (!baseUrl) {
|
||||||
if (!password) return { ok: false, error: "password not configured" };
|
return { ok: false, error: "serverUrl not configured" };
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
return { ok: false, error: "password not configured" };
|
||||||
|
}
|
||||||
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
|
||||||
try {
|
try {
|
||||||
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
|
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs);
|
||||||
|
|||||||
@@ -117,16 +117,24 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
|
|||||||
});
|
});
|
||||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
const password = params.password?.trim() || account.config.password?.trim();
|
const password = params.password?.trim() || account.config.password?.trim();
|
||||||
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
if (!baseUrl) {
|
||||||
if (!password) throw new Error("BlueBubbles password is required");
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error("BlueBubbles password is required");
|
||||||
|
}
|
||||||
return { baseUrl, password };
|
return { baseUrl, password };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||||
const trimmed = emoji.trim();
|
const trimmed = emoji.trim();
|
||||||
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
|
if (!trimmed) {
|
||||||
|
throw new Error("BlueBubbles reaction requires an emoji or name.");
|
||||||
|
}
|
||||||
let raw = trimmed.toLowerCase();
|
let raw = trimmed.toLowerCase();
|
||||||
if (raw.startsWith("-")) raw = raw.slice(1);
|
if (raw.startsWith("-")) {
|
||||||
|
raw = raw.slice(1);
|
||||||
|
}
|
||||||
const aliased = REACTION_ALIASES.get(raw) ?? raw;
|
const aliased = REACTION_ALIASES.get(raw) ?? raw;
|
||||||
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
|
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
|
||||||
if (!REACTION_TYPES.has(mapped)) {
|
if (!REACTION_TYPES.has(mapped)) {
|
||||||
@@ -145,8 +153,12 @@ export async function sendBlueBubblesReaction(params: {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const chatGuid = params.chatGuid.trim();
|
const chatGuid = params.chatGuid.trim();
|
||||||
const messageGuid = params.messageGuid.trim();
|
const messageGuid = params.messageGuid.trim();
|
||||||
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
|
if (!chatGuid) {
|
||||||
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
|
throw new Error("BlueBubbles reaction requires chatGuid.");
|
||||||
|
}
|
||||||
|
if (!messageGuid) {
|
||||||
|
throw new Error("BlueBubbles reaction requires messageGuid.");
|
||||||
|
}
|
||||||
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
|
||||||
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
const { baseUrl, password } = resolveAccount(params.opts ?? {});
|
||||||
const url = buildBlueBubblesApiUrl({
|
const url = buildBlueBubblesApiUrl({
|
||||||
|
|||||||
@@ -55,13 +55,21 @@ const EFFECT_MAP: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function resolveEffectId(raw?: string): string | undefined {
|
function resolveEffectId(raw?: string): string | undefined {
|
||||||
if (!raw) return undefined;
|
if (!raw) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const trimmed = raw.trim().toLowerCase();
|
const trimmed = raw.trim().toLowerCase();
|
||||||
if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed];
|
if (EFFECT_MAP[trimmed]) {
|
||||||
|
return EFFECT_MAP[trimmed];
|
||||||
|
}
|
||||||
const normalized = trimmed.replace(/[\s_]+/g, "-");
|
const normalized = trimmed.replace(/[\s_]+/g, "-");
|
||||||
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
|
if (EFFECT_MAP[normalized]) {
|
||||||
|
return EFFECT_MAP[normalized];
|
||||||
|
}
|
||||||
const compact = trimmed.replace(/[\s_-]+/g, "");
|
const compact = trimmed.replace(/[\s_-]+/g, "");
|
||||||
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
|
if (EFFECT_MAP[compact]) {
|
||||||
|
return EFFECT_MAP[compact];
|
||||||
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +92,9 @@ function resolveSendTarget(raw: string): BlueBubblesSendTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractMessageId(payload: unknown): string {
|
function extractMessageId(payload: unknown): string {
|
||||||
if (!payload || typeof payload !== "object") return "unknown";
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
const record = payload as Record<string, unknown>;
|
const record = payload as Record<string, unknown>;
|
||||||
const data =
|
const data =
|
||||||
record.data && typeof record.data === "object"
|
record.data && typeof record.data === "object"
|
||||||
@@ -104,8 +114,12 @@ function extractMessageId(payload: unknown): string {
|
|||||||
data?.id,
|
data?.id,
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
if (typeof candidate === "string" && candidate.trim()) {
|
||||||
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
|
return candidate.trim();
|
||||||
|
}
|
||||||
|
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||||
|
return String(candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -122,7 +136,9 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
|||||||
chat.chat_identifier,
|
chat.chat_identifier,
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
if (typeof candidate === "string" && candidate.trim()) {
|
||||||
|
return candidate.trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -130,14 +146,18 @@ function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
|||||||
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
||||||
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
|
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
||||||
const parts = chatGuid.split(";");
|
const parts = chatGuid.split(";");
|
||||||
if (parts.length < 3) return null;
|
if (parts.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const identifier = parts[2]?.trim();
|
const identifier = parts[2]?.trim();
|
||||||
return identifier ? identifier : null;
|
return identifier ? identifier : null;
|
||||||
}
|
}
|
||||||
@@ -147,7 +167,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
|||||||
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
||||||
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
||||||
if (!raw) return [];
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
if (typeof entry === "string") {
|
if (typeof entry === "string") {
|
||||||
@@ -161,7 +183,9 @@ function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
|||||||
(typeof record.handle === "string" && record.handle) ||
|
(typeof record.handle === "string" && record.handle) ||
|
||||||
(typeof record.id === "string" && record.id) ||
|
(typeof record.id === "string" && record.id) ||
|
||||||
(typeof record.identifier === "string" && record.identifier);
|
(typeof record.identifier === "string" && record.identifier);
|
||||||
if (candidate) out.push(candidate);
|
if (candidate) {
|
||||||
|
out.push(candidate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -192,7 +216,9 @@ async function queryChats(params: {
|
|||||||
},
|
},
|
||||||
params.timeoutMs,
|
params.timeoutMs,
|
||||||
);
|
);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
||||||
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
||||||
@@ -204,7 +230,9 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
target: BlueBubblesSendTarget;
|
target: BlueBubblesSendTarget;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
if (params.target.kind === "chat_guid") return params.target.chatGuid;
|
if (params.target.kind === "chat_guid") {
|
||||||
|
return params.target.chatGuid;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedHandle =
|
const normalizedHandle =
|
||||||
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
||||||
@@ -222,7 +250,9 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
offset,
|
offset,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
if (chats.length === 0) break;
|
if (chats.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
for (const chat of chats) {
|
for (const chat of chats) {
|
||||||
if (targetChatId != null) {
|
if (targetChatId != null) {
|
||||||
const chatId = extractChatId(chat);
|
const chatId = extractChatId(chat);
|
||||||
@@ -234,12 +264,16 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
if (guid) {
|
if (guid) {
|
||||||
// Back-compat: some callers might pass a full chat GUID.
|
// Back-compat: some callers might pass a full chat GUID.
|
||||||
if (guid === targetChatIdentifier) return guid;
|
if (guid === targetChatIdentifier) {
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
|
||||||
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
||||||
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
||||||
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
||||||
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
|
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const identifier =
|
const identifier =
|
||||||
@@ -250,7 +284,9 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
: typeof chat.chat_identifier === "string"
|
: typeof chat.chat_identifier === "string"
|
||||||
? chat.chat_identifier
|
? chat.chat_identifier
|
||||||
: "";
|
: "";
|
||||||
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
|
if (identifier && identifier === targetChatIdentifier) {
|
||||||
|
return guid ?? extractChatGuid(chat);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (normalizedHandle) {
|
if (normalizedHandle) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
@@ -322,7 +358,9 @@ async function createNewChatWithMessage(params: {
|
|||||||
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
}
|
}
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
if (!body) return { messageId: "ok" };
|
if (!body) {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(body) as unknown;
|
const parsed = JSON.parse(body) as unknown;
|
||||||
return { messageId: extractMessageId(parsed) };
|
return { messageId: extractMessageId(parsed) };
|
||||||
@@ -347,8 +385,12 @@ export async function sendMessageBlueBubbles(
|
|||||||
});
|
});
|
||||||
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||||
const password = opts.password?.trim() || account.config.password?.trim();
|
const password = opts.password?.trim() || account.config.password?.trim();
|
||||||
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
|
if (!baseUrl) {
|
||||||
if (!password) throw new Error("BlueBubbles password is required");
|
throw new Error("BlueBubbles serverUrl is required");
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
throw new Error("BlueBubbles password is required");
|
||||||
|
}
|
||||||
|
|
||||||
const target = resolveSendTarget(to);
|
const target = resolveSendTarget(to);
|
||||||
const chatGuid = await resolveChatGuidForTarget({
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
@@ -414,7 +456,9 @@ export async function sendMessageBlueBubbles(
|
|||||||
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
}
|
}
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
if (!body) return { messageId: "ok" };
|
if (!body) {
|
||||||
|
return { messageId: "ok" };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(body) as unknown;
|
const parsed = JSON.parse(body) as unknown;
|
||||||
return { messageId: extractMessageId(parsed) };
|
return { messageId: extractMessageId(parsed) };
|
||||||
|
|||||||
@@ -25,14 +25,22 @@ const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i;
|
|||||||
|
|
||||||
function parseRawChatGuid(value: string): string | null {
|
function parseRawChatGuid(value: string): string | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const parts = trimmed.split(";");
|
const parts = trimmed.split(";");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const service = parts[0]?.trim();
|
const service = parts[0]?.trim();
|
||||||
const separator = parts[1]?.trim();
|
const separator = parts[1]?.trim();
|
||||||
const identifier = parts[2]?.trim();
|
const identifier = parts[2]?.trim();
|
||||||
if (!service || !identifier) return null;
|
if (!service || !identifier) {
|
||||||
if (separator !== "+" && separator !== "-") return null;
|
return null;
|
||||||
|
}
|
||||||
|
if (separator !== "+" && separator !== "-") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return `${service};${separator};${identifier}`;
|
return `${service};${separator};${identifier}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,26 +50,44 @@ function stripPrefix(value: string, prefix: string): string {
|
|||||||
|
|
||||||
function stripBlueBubblesPrefix(value: string): string {
|
function stripBlueBubblesPrefix(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed;
|
return "";
|
||||||
|
}
|
||||||
|
if (!trimmed.toLowerCase().startsWith("bluebubbles:")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
return trimmed.slice("bluebubbles:".length).trim();
|
return trimmed.slice("bluebubbles:".length).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeRawChatIdentifier(value: string): boolean {
|
function looksLikeRawChatIdentifier(value: string): boolean {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
if (/^chat\d+$/i.test(trimmed)) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (/^chat\d+$/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
|
return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBlueBubblesHandle(raw: string): string {
|
export function normalizeBlueBubblesHandle(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const lowered = trimmed.toLowerCase();
|
const lowered = trimmed.toLowerCase();
|
||||||
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9));
|
if (lowered.startsWith("imessage:")) {
|
||||||
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4));
|
return normalizeBlueBubblesHandle(trimmed.slice(9));
|
||||||
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5));
|
}
|
||||||
if (trimmed.includes("@")) return trimmed.toLowerCase();
|
if (lowered.startsWith("sms:")) {
|
||||||
|
return normalizeBlueBubblesHandle(trimmed.slice(4));
|
||||||
|
}
|
||||||
|
if (lowered.startsWith("auto:")) {
|
||||||
|
return normalizeBlueBubblesHandle(trimmed.slice(5));
|
||||||
|
}
|
||||||
|
if (trimmed.includes("@")) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
return trimmed.replace(/\s+/g, "");
|
return trimmed.replace(/\s+/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,30 +101,44 @@ export function extractHandleFromChatGuid(chatGuid: string): string | null {
|
|||||||
// DM format: service;-;handle (3 parts, middle is "-")
|
// DM format: service;-;handle (3 parts, middle is "-")
|
||||||
if (parts.length === 3 && parts[1] === "-") {
|
if (parts.length === 3 && parts[1] === "-") {
|
||||||
const handle = parts[2]?.trim();
|
const handle = parts[2]?.trim();
|
||||||
if (handle) return normalizeBlueBubblesHandle(handle);
|
if (handle) {
|
||||||
|
return normalizeBlueBubblesHandle(handle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
|
export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
trimmed = stripBlueBubblesPrefix(trimmed);
|
trimmed = stripBlueBubblesPrefix(trimmed);
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = parseBlueBubblesTarget(trimmed);
|
const parsed = parseBlueBubblesTarget(trimmed);
|
||||||
if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`;
|
if (parsed.kind === "chat_id") {
|
||||||
|
return `chat_id:${parsed.chatId}`;
|
||||||
|
}
|
||||||
if (parsed.kind === "chat_guid") {
|
if (parsed.kind === "chat_guid") {
|
||||||
// For DM chat_guids, normalize to just the handle for easier comparison.
|
// For DM chat_guids, normalize to just the handle for easier comparison.
|
||||||
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
|
// This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890".
|
||||||
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
const handle = extractHandleFromChatGuid(parsed.chatGuid);
|
||||||
if (handle) return handle;
|
if (handle) {
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
// For group chats or unrecognized formats, keep the full chat_guid
|
// For group chats or unrecognized formats, keep the full chat_guid
|
||||||
return `chat_guid:${parsed.chatGuid}`;
|
return `chat_guid:${parsed.chatGuid}`;
|
||||||
}
|
}
|
||||||
if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`;
|
if (parsed.kind === "chat_identifier") {
|
||||||
|
return `chat_identifier:${parsed.chatIdentifier}`;
|
||||||
|
}
|
||||||
const handle = normalizeBlueBubblesHandle(parsed.to);
|
const handle = normalizeBlueBubblesHandle(parsed.to);
|
||||||
if (!handle) return undefined;
|
if (!handle) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
|
return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`;
|
||||||
} catch {
|
} catch {
|
||||||
return trimmed;
|
return trimmed;
|
||||||
@@ -107,12 +147,20 @@ export function normalizeBlueBubblesMessagingTarget(raw: string): string | undef
|
|||||||
|
|
||||||
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
|
export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const candidate = stripBlueBubblesPrefix(trimmed);
|
const candidate = stripBlueBubblesPrefix(trimmed);
|
||||||
if (!candidate) return false;
|
if (!candidate) {
|
||||||
if (parseRawChatGuid(candidate)) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (parseRawChatGuid(candidate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const lowered = candidate.toLowerCase();
|
const lowered = candidate.toLowerCase();
|
||||||
if (/^(imessage|sms|auto):/.test(lowered)) return true;
|
if (/^(imessage|sms|auto):/.test(lowered)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
|
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
|
||||||
lowered,
|
lowered,
|
||||||
@@ -121,14 +169,24 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
|
// Recognize chat<digits> patterns (e.g., "chat660250192681427962") as chat IDs
|
||||||
if (/^chat\d+$/i.test(candidate)) return true;
|
if (/^chat\d+$/i.test(candidate)) {
|
||||||
if (looksLikeRawChatIdentifier(candidate)) return true;
|
return true;
|
||||||
if (candidate.includes("@")) return true;
|
}
|
||||||
|
if (looksLikeRawChatIdentifier(candidate)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (candidate.includes("@")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
const digitsOnly = candidate.replace(/[\s().-]/g, "");
|
||||||
if (/^\+?\d{3,}$/.test(digitsOnly)) return true;
|
if (/^\+?\d{3,}$/.test(digitsOnly)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
const normalizedTrimmed = normalized.trim();
|
const normalizedTrimmed = normalized.trim();
|
||||||
if (!normalizedTrimmed) return false;
|
if (!normalizedTrimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const normalizedLower = normalizedTrimmed.toLowerCase();
|
const normalizedLower = normalizedTrimmed.toLowerCase();
|
||||||
if (
|
if (
|
||||||
/^(imessage|sms|auto):/.test(normalizedLower) ||
|
/^(imessage|sms|auto):/.test(normalizedLower) ||
|
||||||
@@ -142,13 +200,17 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
|
|
||||||
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||||
const trimmed = stripBlueBubblesPrefix(raw);
|
const trimmed = stripBlueBubblesPrefix(raw);
|
||||||
if (!trimmed) throw new Error("BlueBubbles target is required");
|
if (!trimmed) {
|
||||||
|
throw new Error("BlueBubbles target is required");
|
||||||
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
for (const { prefix, service } of SERVICE_PREFIXES) {
|
for (const { prefix, service } of SERVICE_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const remainder = stripPrefix(trimmed, prefix);
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
if (!remainder) throw new Error(`${prefix} target is required`);
|
if (!remainder) {
|
||||||
|
throw new Error(`${prefix} target is required`);
|
||||||
|
}
|
||||||
const remainderLower = remainder.toLowerCase();
|
const remainderLower = remainder.toLowerCase();
|
||||||
const isChatTarget =
|
const isChatTarget =
|
||||||
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
||||||
@@ -176,7 +238,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
for (const prefix of CHAT_GUID_PREFIXES) {
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
if (!value) throw new Error("chat_guid is required");
|
if (!value) {
|
||||||
|
throw new Error("chat_guid is required");
|
||||||
|
}
|
||||||
return { kind: "chat_guid", chatGuid: value };
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +248,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
if (!value) throw new Error("chat_identifier is required");
|
if (!value) {
|
||||||
|
throw new Error("chat_identifier is required");
|
||||||
|
}
|
||||||
return { kind: "chat_identifier", chatIdentifier: value };
|
return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +261,9 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
if (Number.isFinite(chatId)) {
|
if (Number.isFinite(chatId)) {
|
||||||
return { kind: "chat_id", chatId };
|
return { kind: "chat_id", chatId };
|
||||||
}
|
}
|
||||||
if (!value) throw new Error("group target is required");
|
if (!value) {
|
||||||
|
throw new Error("group target is required");
|
||||||
|
}
|
||||||
return { kind: "chat_guid", chatGuid: value };
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,13 +288,17 @@ export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
|||||||
|
|
||||||
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
|
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return { kind: "handle", handle: "" };
|
if (!trimmed) {
|
||||||
|
return { kind: "handle", handle: "" };
|
||||||
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
for (const { prefix } of SERVICE_PREFIXES) {
|
for (const { prefix } of SERVICE_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const remainder = stripPrefix(trimmed, prefix);
|
const remainder = stripPrefix(trimmed, prefix);
|
||||||
if (!remainder) return { kind: "handle", handle: "" };
|
if (!remainder) {
|
||||||
|
return { kind: "handle", handle: "" };
|
||||||
|
}
|
||||||
return parseBlueBubblesAllowTarget(remainder);
|
return parseBlueBubblesAllowTarget(remainder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,29 +307,39 @@ export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget
|
|||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
const chatId = Number.parseInt(value, 10);
|
const chatId = Number.parseInt(value, 10);
|
||||||
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
if (Number.isFinite(chatId)) {
|
||||||
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const prefix of CHAT_GUID_PREFIXES) {
|
for (const prefix of CHAT_GUID_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
if (value) return { kind: "chat_guid", chatGuid: value };
|
if (value) {
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
|
||||||
if (lower.startsWith(prefix)) {
|
if (lower.startsWith(prefix)) {
|
||||||
const value = stripPrefix(trimmed, prefix);
|
const value = stripPrefix(trimmed, prefix);
|
||||||
if (value) return { kind: "chat_identifier", chatIdentifier: value };
|
if (value) {
|
||||||
|
return { kind: "chat_identifier", chatIdentifier: value };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lower.startsWith("group:")) {
|
if (lower.startsWith("group:")) {
|
||||||
const value = stripPrefix(trimmed, "group:");
|
const value = stripPrefix(trimmed, "group:");
|
||||||
const chatId = Number.parseInt(value, 10);
|
const chatId = Number.parseInt(value, 10);
|
||||||
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
|
if (Number.isFinite(chatId)) {
|
||||||
if (value) return { kind: "chat_guid", chatGuid: value };
|
return { kind: "chat_id", chatId };
|
||||||
|
}
|
||||||
|
if (value) {
|
||||||
|
return { kind: "chat_guid", chatGuid: value };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
// Handle chat<digits> pattern (e.g., "chat660250192681427962") as chat_identifier
|
||||||
@@ -282,8 +364,12 @@ export function isAllowedBlueBubblesSender(params: {
|
|||||||
chatIdentifier?: string | null;
|
chatIdentifier?: string | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
||||||
if (allowFrom.length === 0) return true;
|
if (allowFrom.length === 0) {
|
||||||
if (allowFrom.includes("*")) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (allowFrom.includes("*")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
||||||
const chatId = params.chatId ?? undefined;
|
const chatId = params.chatId ?? undefined;
|
||||||
@@ -291,16 +377,26 @@ export function isAllowedBlueBubblesSender(params: {
|
|||||||
const chatIdentifier = params.chatIdentifier?.trim();
|
const chatIdentifier = params.chatIdentifier?.trim();
|
||||||
|
|
||||||
for (const entry of allowFrom) {
|
for (const entry of allowFrom) {
|
||||||
if (!entry) continue;
|
if (!entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const parsed = parseBlueBubblesAllowTarget(entry);
|
const parsed = parseBlueBubblesAllowTarget(entry);
|
||||||
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
||||||
if (parsed.chatId === chatId) return true;
|
if (parsed.chatId === chatId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
||||||
if (parsed.chatGuid === chatGuid) return true;
|
if (parsed.chatGuid === chatGuid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
||||||
if (parsed.chatIdentifier === chatIdentifier) return true;
|
if (parsed.chatIdentifier === chatIdentifier) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} else if (parsed.kind === "handle" && senderNormalized) {
|
} else if (parsed.kind === "handle" && senderNormalized) {
|
||||||
if (parsed.handle === senderNormalized) return true;
|
if (parsed.handle === senderNormalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -315,8 +411,12 @@ export function formatBlueBubblesChatTarget(params: {
|
|||||||
return `chat_id:${params.chatId}`;
|
return `chat_id:${params.chatId}`;
|
||||||
}
|
}
|
||||||
const guid = params.chatGuid?.trim();
|
const guid = params.chatGuid?.trim();
|
||||||
if (guid) return `chat_guid:${guid}`;
|
if (guid) {
|
||||||
|
return `chat_guid:${guid}`;
|
||||||
|
}
|
||||||
const identifier = params.chatIdentifier?.trim();
|
const identifier = params.chatIdentifier?.trim();
|
||||||
if (identifier) return `chat_identifier:${identifier}`;
|
if (identifier) {
|
||||||
|
return `chat_identifier:${identifier}`;
|
||||||
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ const DEFAULT_MODEL_IDS = [
|
|||||||
|
|
||||||
function normalizeBaseUrl(value: string): string {
|
function normalizeBaseUrl(value: string): string {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return DEFAULT_BASE_URL;
|
if (!trimmed) {
|
||||||
|
return DEFAULT_BASE_URL;
|
||||||
|
}
|
||||||
let normalized = trimmed;
|
let normalized = trimmed;
|
||||||
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
while (normalized.endsWith("/")) {
|
||||||
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
|
normalized = normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (!normalized.endsWith("/v1")) {
|
||||||
|
normalized = `${normalized}/v1`;
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,22 @@ function normalizeEndpoint(endpoint?: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
|
function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined {
|
||||||
if (!endpoint) return undefined;
|
if (!endpoint) {
|
||||||
if (endpoint.includes("/v1/")) return endpoint;
|
return undefined;
|
||||||
|
}
|
||||||
|
if (endpoint.includes("/v1/")) {
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
return `${endpoint}/${path}`;
|
return `${endpoint}/${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveSampleRate(value: number | undefined): number | undefined {
|
function resolveSampleRate(value: number | undefined): number | undefined {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
if (value < 0 || value > 1) return undefined;
|
return undefined;
|
||||||
|
}
|
||||||
|
if (value < 0 || value > 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +51,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
async start(ctx) {
|
async start(ctx) {
|
||||||
const cfg = ctx.config.diagnostics;
|
const cfg = ctx.config.diagnostics;
|
||||||
const otel = cfg?.otel;
|
const otel = cfg?.otel;
|
||||||
if (!cfg?.enabled || !otel?.enabled) return;
|
if (!cfg?.enabled || !otel?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
|
const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf";
|
||||||
if (protocol !== "http/protobuf") {
|
if (protocol !== "http/protobuf") {
|
||||||
@@ -60,7 +70,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
const tracesEnabled = otel.traces !== false;
|
const tracesEnabled = otel.traces !== false;
|
||||||
const metricsEnabled = otel.metrics !== false;
|
const metricsEnabled = otel.metrics !== false;
|
||||||
const logsEnabled = otel.logs === true;
|
const logsEnabled = otel.logs === true;
|
||||||
if (!tracesEnabled && !metricsEnabled && !logsEnabled) return;
|
if (!tracesEnabled && !metricsEnabled && !logsEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const resource = new Resource({
|
const resource = new Resource({
|
||||||
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
[SemanticResourceAttributes.SERVICE_NAME]: serviceName,
|
||||||
@@ -106,7 +118,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
: {}),
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await sdk.start();
|
sdk.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
const logSeverityMap: Record<string, SeverityNumber> = {
|
const logSeverityMap: Record<string, SeverityNumber> = {
|
||||||
@@ -201,11 +213,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
});
|
});
|
||||||
logProvider = new LoggerProvider({ resource });
|
logProvider = new LoggerProvider({ resource });
|
||||||
logProvider.addLogRecordProcessor(
|
logProvider.addLogRecordProcessor(
|
||||||
new BatchLogRecordProcessor(logExporter, {
|
new BatchLogRecordProcessor(
|
||||||
...(typeof otel.flushIntervalMs === "number"
|
logExporter,
|
||||||
|
typeof otel.flushIntervalMs === "number"
|
||||||
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
|
? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) }
|
||||||
: {}),
|
: {},
|
||||||
}),
|
),
|
||||||
);
|
);
|
||||||
const otelLogger = logProvider.getLogger("openclaw");
|
const otelLogger = logProvider.getLogger("openclaw");
|
||||||
|
|
||||||
@@ -237,7 +250,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
|
|
||||||
const numericArgs = Object.entries(logObj)
|
const numericArgs = Object.entries(logObj)
|
||||||
.filter(([key]) => /^\d+$/.test(key))
|
.filter(([key]) => /^\d+$/.test(key))
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
.toSorted((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
.map(([, value]) => value);
|
.map(([, value]) => value);
|
||||||
|
|
||||||
let bindings: Record<string, unknown> | undefined;
|
let bindings: Record<string, unknown> | undefined;
|
||||||
@@ -267,7 +280,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
const attributes: Record<string, string | number | boolean> = {
|
const attributes: Record<string, string | number | boolean> = {
|
||||||
"openclaw.log.level": logLevelName,
|
"openclaw.log.level": logLevelName,
|
||||||
};
|
};
|
||||||
if (meta?.name) attributes["openclaw.logger"] = meta.name;
|
if (meta?.name) {
|
||||||
|
attributes["openclaw.logger"] = meta.name;
|
||||||
|
}
|
||||||
if (meta?.parentNames?.length) {
|
if (meta?.parentNames?.length) {
|
||||||
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
|
attributes["openclaw.logger.parents"] = meta.parentNames.join(".");
|
||||||
}
|
}
|
||||||
@@ -287,9 +302,15 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
if (numericArgs.length > 0) {
|
if (numericArgs.length > 0) {
|
||||||
attributes["openclaw.log.args"] = safeStringify(numericArgs);
|
attributes["openclaw.log.args"] = safeStringify(numericArgs);
|
||||||
}
|
}
|
||||||
if (meta?.path?.filePath) attributes["code.filepath"] = meta.path.filePath;
|
if (meta?.path?.filePath) {
|
||||||
if (meta?.path?.fileLine) attributes["code.lineno"] = Number(meta.path.fileLine);
|
attributes["code.filepath"] = meta.path.filePath;
|
||||||
if (meta?.path?.method) attributes["code.function"] = meta.path.method;
|
}
|
||||||
|
if (meta?.path?.fileLine) {
|
||||||
|
attributes["code.lineno"] = Number(meta.path.fileLine);
|
||||||
|
}
|
||||||
|
if (meta?.path?.method) {
|
||||||
|
attributes["code.function"] = meta.path.method;
|
||||||
|
}
|
||||||
if (meta?.path?.filePathWithLine) {
|
if (meta?.path?.filePathWithLine) {
|
||||||
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
attributes["openclaw.code.location"] = meta.path.filePathWithLine;
|
||||||
}
|
}
|
||||||
@@ -326,30 +347,47 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const usage = evt.usage;
|
const usage = evt.usage;
|
||||||
if (usage.input) tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
|
if (usage.input) {
|
||||||
if (usage.output) tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
|
tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" });
|
||||||
if (usage.cacheRead)
|
}
|
||||||
|
if (usage.output) {
|
||||||
|
tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" });
|
||||||
|
}
|
||||||
|
if (usage.cacheRead) {
|
||||||
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
|
tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" });
|
||||||
if (usage.cacheWrite)
|
}
|
||||||
|
if (usage.cacheWrite) {
|
||||||
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
|
tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" });
|
||||||
if (usage.promptTokens)
|
}
|
||||||
|
if (usage.promptTokens) {
|
||||||
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
|
tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" });
|
||||||
if (usage.total) tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
|
}
|
||||||
|
if (usage.total) {
|
||||||
|
tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" });
|
||||||
|
}
|
||||||
|
|
||||||
if (evt.costUsd) costCounter.add(evt.costUsd, attrs);
|
if (evt.costUsd) {
|
||||||
if (evt.durationMs) durationHistogram.record(evt.durationMs, attrs);
|
costCounter.add(evt.costUsd, attrs);
|
||||||
if (evt.context?.limit)
|
}
|
||||||
|
if (evt.durationMs) {
|
||||||
|
durationHistogram.record(evt.durationMs, attrs);
|
||||||
|
}
|
||||||
|
if (evt.context?.limit) {
|
||||||
contextHistogram.record(evt.context.limit, {
|
contextHistogram.record(evt.context.limit, {
|
||||||
...attrs,
|
...attrs,
|
||||||
"openclaw.context": "limit",
|
"openclaw.context": "limit",
|
||||||
});
|
});
|
||||||
if (evt.context?.used)
|
}
|
||||||
|
if (evt.context?.used) {
|
||||||
contextHistogram.record(evt.context.used, {
|
contextHistogram.record(evt.context.used, {
|
||||||
...attrs,
|
...attrs,
|
||||||
"openclaw.context": "used",
|
"openclaw.context": "used",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spanAttrs: Record<string, string | number> = {
|
const spanAttrs: Record<string, string | number> = {
|
||||||
...attrs,
|
...attrs,
|
||||||
"openclaw.sessionKey": evt.sessionKey ?? "",
|
"openclaw.sessionKey": evt.sessionKey ?? "",
|
||||||
@@ -385,9 +423,13 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
if (typeof evt.durationMs === "number") {
|
if (typeof evt.durationMs === "number") {
|
||||||
webhookDurationHistogram.record(evt.durationMs, attrs);
|
webhookDurationHistogram.record(evt.durationMs, attrs);
|
||||||
}
|
}
|
||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spanAttrs: Record<string, string | number> = { ...attrs };
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
if (evt.chatId !== undefined) {
|
||||||
|
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
||||||
|
}
|
||||||
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
|
const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs);
|
||||||
span.end();
|
span.end();
|
||||||
};
|
};
|
||||||
@@ -400,12 +442,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
"openclaw.webhook": evt.updateType ?? "unknown",
|
"openclaw.webhook": evt.updateType ?? "unknown",
|
||||||
};
|
};
|
||||||
webhookErrorCounter.add(1, attrs);
|
webhookErrorCounter.add(1, attrs);
|
||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spanAttrs: Record<string, string | number> = {
|
const spanAttrs: Record<string, string | number> = {
|
||||||
...attrs,
|
...attrs,
|
||||||
"openclaw.error": evt.error,
|
"openclaw.error": evt.error,
|
||||||
};
|
};
|
||||||
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
if (evt.chatId !== undefined) {
|
||||||
|
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
||||||
|
}
|
||||||
const span = tracer.startSpan("openclaw.webhook.error", {
|
const span = tracer.startSpan("openclaw.webhook.error", {
|
||||||
attributes: spanAttrs,
|
attributes: spanAttrs,
|
||||||
});
|
});
|
||||||
@@ -437,13 +483,25 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
if (typeof evt.durationMs === "number") {
|
if (typeof evt.durationMs === "number") {
|
||||||
messageDurationHistogram.record(evt.durationMs, attrs);
|
messageDurationHistogram.record(evt.durationMs, attrs);
|
||||||
}
|
}
|
||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spanAttrs: Record<string, string | number> = { ...attrs };
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
if (evt.sessionKey) spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
if (evt.sessionKey) {
|
||||||
if (evt.sessionId) spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
||||||
if (evt.chatId !== undefined) spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
}
|
||||||
if (evt.messageId !== undefined) spanAttrs["openclaw.messageId"] = String(evt.messageId);
|
if (evt.sessionId) {
|
||||||
if (evt.reason) spanAttrs["openclaw.reason"] = evt.reason;
|
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
||||||
|
}
|
||||||
|
if (evt.chatId !== undefined) {
|
||||||
|
spanAttrs["openclaw.chatId"] = String(evt.chatId);
|
||||||
|
}
|
||||||
|
if (evt.messageId !== undefined) {
|
||||||
|
spanAttrs["openclaw.messageId"] = String(evt.messageId);
|
||||||
|
}
|
||||||
|
if (evt.reason) {
|
||||||
|
spanAttrs["openclaw.reason"] = evt.reason;
|
||||||
|
}
|
||||||
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
|
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
|
||||||
if (evt.outcome === "error") {
|
if (evt.outcome === "error") {
|
||||||
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error });
|
||||||
@@ -474,7 +532,9 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
|
evt: Extract<DiagnosticEventPayload, { type: "session.state" }>,
|
||||||
) => {
|
) => {
|
||||||
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
||||||
if (evt.reason) attrs["openclaw.reason"] = evt.reason;
|
if (evt.reason) {
|
||||||
|
attrs["openclaw.reason"] = evt.reason;
|
||||||
|
}
|
||||||
sessionStateCounter.add(1, attrs);
|
sessionStateCounter.add(1, attrs);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -486,10 +546,16 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
|||||||
if (typeof evt.ageMs === "number") {
|
if (typeof evt.ageMs === "number") {
|
||||||
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
|
sessionStuckAgeHistogram.record(evt.ageMs, attrs);
|
||||||
}
|
}
|
||||||
if (!tracesEnabled) return;
|
if (!tracesEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spanAttrs: Record<string, string | number> = { ...attrs };
|
const spanAttrs: Record<string, string | number> = { ...attrs };
|
||||||
if (evt.sessionKey) spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
if (evt.sessionKey) {
|
||||||
if (evt.sessionId) spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
|
||||||
|
}
|
||||||
|
if (evt.sessionId) {
|
||||||
|
spanAttrs["openclaw.sessionId"] = evt.sessionId;
|
||||||
|
}
|
||||||
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
|
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
|
||||||
spanAttrs["openclaw.ageMs"] = evt.ageMs;
|
spanAttrs["openclaw.ageMs"] = evt.ageMs;
|
||||||
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
|
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });
|
||||||
|
|||||||
@@ -331,7 +331,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
cfg,
|
cfg,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
if (!channelIds.length && unresolvedChannels === 0) return undefined;
|
if (!channelIds.length && unresolvedChannels === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const botToken = account.token?.trim();
|
const botToken = account.token?.trim();
|
||||||
if (!botToken) {
|
if (!botToken) {
|
||||||
return {
|
return {
|
||||||
@@ -383,7 +385,9 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
includeApplication: true,
|
includeApplication: true,
|
||||||
});
|
});
|
||||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||||
if (username) discordBotLabel = ` (@${username})`;
|
if (username) {
|
||||||
|
discordBotLabel = ` (@${username})`;
|
||||||
|
}
|
||||||
ctx.setStatus({
|
ctx.setStatus({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
bot: probe.bot,
|
bot: probe.bot,
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ function generatePkce(): { verifier: string; challenge: string } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWSL(): boolean {
|
function isWSL(): boolean {
|
||||||
if (process.platform !== "linux") return false;
|
if (process.platform !== "linux") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||||
return release.includes("microsoft") || release.includes("wsl");
|
return release.includes("microsoft") || release.includes("wsl");
|
||||||
@@ -59,7 +61,9 @@ function isWSL(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWSL2(): boolean {
|
function isWSL2(): boolean {
|
||||||
if (!isWSL()) return false;
|
if (!isWSL()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||||
@@ -88,14 +92,20 @@ function buildAuthUrl(params: { challenge: string; state: string }): string {
|
|||||||
|
|
||||||
function parseCallbackInput(input: string): { code: string; state: string } | { error: string } {
|
function parseCallbackInput(input: string): { code: string; state: string } | { error: string } {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return { error: "No input provided" };
|
if (!trimmed) {
|
||||||
|
return { error: "No input provided" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(trimmed);
|
const url = new URL(trimmed);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const state = url.searchParams.get("state");
|
const state = url.searchParams.get("state");
|
||||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
if (!code) {
|
||||||
if (!state) return { error: "Missing 'state' parameter in URL" };
|
return { error: "Missing 'code' parameter in URL" };
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
return { error: "Missing 'state' parameter in URL" };
|
||||||
|
}
|
||||||
return { code, state };
|
return { code, state };
|
||||||
} catch {
|
} catch {
|
||||||
return { error: "Paste the full redirect URL (not just the code)." };
|
return { error: "Paste the full redirect URL (not just the code)." };
|
||||||
@@ -112,12 +122,16 @@ async function startCallbackServer(params: { timeoutMs: number }) {
|
|||||||
|
|
||||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||||
resolveCallback = (url) => {
|
resolveCallback = (url) => {
|
||||||
if (settled) return;
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
resolve(url);
|
resolve(url);
|
||||||
};
|
};
|
||||||
rejectCallback = (err) => {
|
rejectCallback = (err) => {
|
||||||
if (settled) return;
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
reject(err);
|
reject(err);
|
||||||
};
|
};
|
||||||
@@ -204,8 +218,12 @@ async function exchangeCode(params: {
|
|||||||
const refresh = data.refresh_token?.trim();
|
const refresh = data.refresh_token?.trim();
|
||||||
const expiresIn = data.expires_in ?? 0;
|
const expiresIn = data.expires_in ?? 0;
|
||||||
|
|
||||||
if (!access) throw new Error("Token exchange returned no access_token");
|
if (!access) {
|
||||||
if (!refresh) throw new Error("Token exchange returned no refresh_token");
|
throw new Error("Token exchange returned no access_token");
|
||||||
|
}
|
||||||
|
if (!refresh) {
|
||||||
|
throw new Error("Token exchange returned no refresh_token");
|
||||||
|
}
|
||||||
|
|
||||||
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
|
||||||
return { access, refresh, expires };
|
return { access, refresh, expires };
|
||||||
@@ -216,7 +234,9 @@ async function fetchUserEmail(accessToken: string): Promise<string | undefined>
|
|||||||
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (!response.ok) return undefined;
|
if (!response.ok) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const data = (await response.json()) as { email?: string };
|
const data = (await response.json()) as { email?: string };
|
||||||
return data.email;
|
return data.email;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -251,7 +271,9 @@ async function fetchProjectId(accessToken: string): Promise<string> {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) continue;
|
if (!response.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
cloudaicompanionProject?: string | { id?: string };
|
cloudaicompanionProject?: string | { id?: string };
|
||||||
};
|
};
|
||||||
@@ -342,12 +364,16 @@ async function loginAntigravity(params: {
|
|||||||
params.progress.update("Waiting for redirect URL…");
|
params.progress.update("Waiting for redirect URL…");
|
||||||
const input = await params.prompt("Paste the redirect URL: ");
|
const input = await params.prompt("Paste the redirect URL: ");
|
||||||
const parsed = parseCallbackInput(input);
|
const parsed = parseCallbackInput(input);
|
||||||
if ("error" in parsed) throw new Error(parsed.error);
|
if ("error" in parsed) {
|
||||||
|
throw new Error(parsed.error);
|
||||||
|
}
|
||||||
code = parsed.code;
|
code = parsed.code;
|
||||||
returnedState = parsed.state;
|
returnedState = parsed.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) throw new Error("Missing OAuth code");
|
if (!code) {
|
||||||
|
throw new Error("Missing OAuth code");
|
||||||
|
}
|
||||||
if (returnedState !== state) {
|
if (returnedState !== state) {
|
||||||
throw new Error("OAuth state mismatch. Please try again.");
|
throw new Error("OAuth state mismatch. Please try again.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ describe("extractGeminiCliCredentials", () => {
|
|||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
const normalized = normalizePath(p);
|
const normalized = normalizePath(p);
|
||||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
if (normalized === normalizePath(fakeGeminiPath)) {
|
||||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === normalizePath(fakeOauth2Path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||||
@@ -160,8 +164,12 @@ describe("extractGeminiCliCredentials", () => {
|
|||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
const normalized = normalizePath(p);
|
const normalized = normalizePath(p);
|
||||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
if (normalized === normalizePath(fakeGeminiPath)) {
|
||||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === normalizePath(fakeOauth2Path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||||
@@ -205,8 +213,12 @@ describe("extractGeminiCliCredentials", () => {
|
|||||||
|
|
||||||
mockExistsSync.mockImplementation((p: string) => {
|
mockExistsSync.mockImplementation((p: string) => {
|
||||||
const normalized = normalizePath(p);
|
const normalized = normalizePath(p);
|
||||||
if (normalized === normalizePath(fakeGeminiPath)) return true;
|
if (normalized === normalizePath(fakeGeminiPath)) {
|
||||||
if (normalized === normalizePath(fakeOauth2Path)) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === normalizePath(fakeOauth2Path)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
mockRealpathSync.mockReturnValue(fakeResolvedPath);
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export type GeminiCliOAuthContext = {
|
|||||||
function resolveEnv(keys: string[]): string | undefined {
|
function resolveEnv(keys: string[]): string | undefined {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = process.env[key]?.trim();
|
const value = process.env[key]?.trim();
|
||||||
if (value) return value;
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -57,11 +59,15 @@ export function clearCredentialsCache(): void {
|
|||||||
|
|
||||||
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */
|
||||||
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
|
||||||
if (cachedGeminiCliCredentials) return cachedGeminiCliCredentials;
|
if (cachedGeminiCliCredentials) {
|
||||||
|
return cachedGeminiCliCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const geminiPath = findInPath("gemini");
|
const geminiPath = findInPath("gemini");
|
||||||
if (!geminiPath) return null;
|
if (!geminiPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedPath = realpathSync(geminiPath);
|
const resolvedPath = realpathSync(geminiPath);
|
||||||
const geminiCliDir = dirname(dirname(resolvedPath));
|
const geminiCliDir = dirname(dirname(resolvedPath));
|
||||||
@@ -97,9 +103,13 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
|||||||
}
|
}
|
||||||
if (!content) {
|
if (!content) {
|
||||||
const found = findFile(geminiCliDir, "oauth2.js", 10);
|
const found = findFile(geminiCliDir, "oauth2.js", 10);
|
||||||
if (found) content = readFileSync(found, "utf8");
|
if (found) {
|
||||||
|
content = readFileSync(found, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (!content) return null;
|
|
||||||
|
|
||||||
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
|
||||||
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
|
||||||
@@ -118,21 +128,29 @@ function findInPath(name: string): string | null {
|
|||||||
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
||||||
for (const ext of exts) {
|
for (const ext of exts) {
|
||||||
const p = join(dir, name + ext);
|
const p = join(dir, name + ext);
|
||||||
if (existsSync(p)) return p;
|
if (existsSync(p)) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFile(dir: string, name: string, depth: number): string | null {
|
function findFile(dir: string, name: string, depth: number): string | null {
|
||||||
if (depth <= 0) return null;
|
if (depth <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||||
const p = join(dir, e.name);
|
const p = join(dir, e.name);
|
||||||
if (e.isFile() && e.name === name) return p;
|
if (e.isFile() && e.name === name) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
if (e.isDirectory() && !e.name.startsWith(".")) {
|
if (e.isDirectory() && !e.name.startsWith(".")) {
|
||||||
const found = findFile(p, name, depth - 1);
|
const found = findFile(p, name, depth - 1);
|
||||||
if (found) return found;
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -160,7 +178,9 @@ function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWSL(): boolean {
|
function isWSL(): boolean {
|
||||||
if (process.platform !== "linux") return false;
|
if (process.platform !== "linux") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
const release = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||||
return release.includes("microsoft") || release.includes("wsl");
|
return release.includes("microsoft") || release.includes("wsl");
|
||||||
@@ -170,7 +190,9 @@ function isWSL(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWSL2(): boolean {
|
function isWSL2(): boolean {
|
||||||
if (!isWSL()) return false;
|
if (!isWSL()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
const version = readFileSync("/proc/version", "utf8").toLowerCase();
|
||||||
return version.includes("wsl2") || version.includes("microsoft-standard");
|
return version.includes("wsl2") || version.includes("microsoft-standard");
|
||||||
@@ -210,17 +232,25 @@ function parseCallbackInput(
|
|||||||
expectedState: string,
|
expectedState: string,
|
||||||
): { code: string; state: string } | { error: string } {
|
): { code: string; state: string } | { error: string } {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (!trimmed) return { error: "No input provided" };
|
if (!trimmed) {
|
||||||
|
return { error: "No input provided" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(trimmed);
|
const url = new URL(trimmed);
|
||||||
const code = url.searchParams.get("code");
|
const code = url.searchParams.get("code");
|
||||||
const state = url.searchParams.get("state") ?? expectedState;
|
const state = url.searchParams.get("state") ?? expectedState;
|
||||||
if (!code) return { error: "Missing 'code' parameter in URL" };
|
if (!code) {
|
||||||
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
|
return { error: "Missing 'code' parameter in URL" };
|
||||||
|
}
|
||||||
|
if (!state) {
|
||||||
|
return { error: "Missing 'state' parameter. Paste the full URL." };
|
||||||
|
}
|
||||||
return { code, state };
|
return { code, state };
|
||||||
} catch {
|
} catch {
|
||||||
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
|
if (!expectedState) {
|
||||||
|
return { error: "Paste the full redirect URL, not just the code." };
|
||||||
|
}
|
||||||
return { code: trimmed, state: expectedState };
|
return { code: trimmed, state: expectedState };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,7 +319,9 @@ async function waitForLocalCallback(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
const finish = (err?: Error, result?: { code: string; state: string }) => {
|
||||||
if (timeout) clearTimeout(timeout);
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
server.close();
|
server.close();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -427,14 +459,20 @@ async function discoverProject(accessToken: string): Promise<string> {
|
|||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
throw new Error("loadCodeAssist failed");
|
throw new Error("loadCodeAssist failed", { cause: err });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.currentTier) {
|
if (data.currentTier) {
|
||||||
const project = data.cloudaicompanionProject;
|
const project = data.cloudaicompanionProject;
|
||||||
if (typeof project === "string" && project) return project;
|
if (typeof project === "string" && project) {
|
||||||
if (typeof project === "object" && project?.id) return project.id;
|
return project;
|
||||||
if (envProject) return envProject;
|
}
|
||||||
|
if (typeof project === "object" && project?.id) {
|
||||||
|
return project.id;
|
||||||
|
}
|
||||||
|
if (envProject) {
|
||||||
|
return envProject;
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
|
||||||
);
|
);
|
||||||
@@ -482,8 +520,12 @@ async function discoverProject(accessToken: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectId = lro.response?.cloudaicompanionProject?.id;
|
const projectId = lro.response?.cloudaicompanionProject?.id;
|
||||||
if (projectId) return projectId;
|
if (projectId) {
|
||||||
if (envProject) return envProject;
|
return projectId;
|
||||||
|
}
|
||||||
|
if (envProject) {
|
||||||
|
return envProject;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
|
||||||
@@ -491,11 +533,17 @@ async function discoverProject(accessToken: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isVpcScAffected(payload: unknown): boolean {
|
function isVpcScAffected(payload: unknown): boolean {
|
||||||
if (!payload || typeof payload !== "object") return false;
|
if (!payload || typeof payload !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const error = (payload as { error?: unknown }).error;
|
const error = (payload as { error?: unknown }).error;
|
||||||
if (!error || typeof error !== "object") return false;
|
if (!error || typeof error !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const details = (error as { details?: unknown[] }).details;
|
const details = (error as { details?: unknown[] }).details;
|
||||||
if (!Array.isArray(details)) return false;
|
if (!Array.isArray(details)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return details.some(
|
return details.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
typeof item === "object" &&
|
typeof item === "object" &&
|
||||||
@@ -507,7 +555,9 @@ function isVpcScAffected(payload: unknown): boolean {
|
|||||||
function getDefaultTier(
|
function getDefaultTier(
|
||||||
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
|
||||||
): { id?: string } | undefined {
|
): { id?: string } | undefined {
|
||||||
if (!allowedTiers?.length) return { id: TIER_LEGACY };
|
if (!allowedTiers?.length) {
|
||||||
|
return { id: TIER_LEGACY };
|
||||||
|
}
|
||||||
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,12 +570,16 @@ async function pollOperation(
|
|||||||
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (!response.ok) continue;
|
if (!response.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
response?: { cloudaicompanionProject?: { id?: string } };
|
response?: { cloudaicompanionProject?: { id?: string } };
|
||||||
};
|
};
|
||||||
if (data.done) return data;
|
if (data.done) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error("Operation polling timeout");
|
throw new Error("Operation polling timeout");
|
||||||
}
|
}
|
||||||
@@ -558,7 +612,9 @@ export async function loginGeminiCliOAuth(
|
|||||||
ctx.progress.update("Waiting for you to paste the callback URL...");
|
ctx.progress.update("Waiting for you to paste the callback URL...");
|
||||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||||
if ("error" in parsed) throw new Error(parsed.error);
|
if ("error" in parsed) {
|
||||||
|
throw new Error(parsed.error);
|
||||||
|
}
|
||||||
if (parsed.state !== verifier) {
|
if (parsed.state !== verifier) {
|
||||||
throw new Error("OAuth state mismatch - please try again");
|
throw new Error("OAuth state mismatch - please try again");
|
||||||
}
|
}
|
||||||
@@ -592,9 +648,11 @@ export async function loginGeminiCliOAuth(
|
|||||||
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
|
||||||
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
|
||||||
const parsed = parseCallbackInput(callbackInput, verifier);
|
const parsed = parseCallbackInput(callbackInput, verifier);
|
||||||
if ("error" in parsed) throw new Error(parsed.error);
|
if ("error" in parsed) {
|
||||||
|
throw new Error(parsed.error, { cause: err });
|
||||||
|
}
|
||||||
if (parsed.state !== verifier) {
|
if (parsed.state !== verifier) {
|
||||||
throw new Error("OAuth state mismatch - please try again");
|
throw new Error("OAuth state mismatch - please try again", { cause: err });
|
||||||
}
|
}
|
||||||
ctx.progress.update("Exchanging authorization code for tokens...");
|
ctx.progress.update("Exchanging authorization code for tokens...");
|
||||||
return exchangeCodeForTokens(parsed.code, verifier);
|
return exchangeCodeForTokens(parsed.code, verifier);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||||
|
|
||||||
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
|
import type { GoogleChatAccountConfig } from "./types.config.js";
|
||||||
|
|
||||||
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
||||||
|
|
||||||
@@ -19,22 +19,30 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
|||||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||||
|
|
||||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
|
const accounts = cfg.channels?.["googlechat"]?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Object.keys(accounts).filter(Boolean);
|
return Object.keys(accounts).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] {
|
export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) {
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string {
|
export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string {
|
||||||
const channel = cfg.channels?.["googlechat"] as GoogleChatConfig | undefined;
|
const channel = cfg.channels?.["googlechat"];
|
||||||
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim();
|
if (channel?.defaultAccount?.trim()) {
|
||||||
|
return channel.defaultAccount.trim();
|
||||||
|
}
|
||||||
const ids = listGoogleChatAccountIds(cfg);
|
const ids = listGoogleChatAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +50,10 @@ function resolveAccountConfig(
|
|||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): GoogleChatAccountConfig | undefined {
|
): GoogleChatAccountConfig | undefined {
|
||||||
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
|
const accounts = cfg.channels?.["googlechat"]?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return undefined;
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return accounts[accountId] as GoogleChatAccountConfig | undefined;
|
return accounts[accountId] as GoogleChatAccountConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,17 +61,23 @@ function mergeGoogleChatAccountConfig(
|
|||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): GoogleChatAccountConfig {
|
): GoogleChatAccountConfig {
|
||||||
const raw = (cfg.channels?.["googlechat"] ?? {}) as GoogleChatConfig;
|
const raw = cfg.channels?.["googlechat"] ?? {};
|
||||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||||
return { ...base, ...account } as GoogleChatAccountConfig;
|
return { ...base, ...account } as GoogleChatAccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
||||||
if (value && typeof value === "object") return value as Record<string, unknown>;
|
if (value && typeof value === "object") {
|
||||||
if (typeof value !== "string") return null;
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return JSON.parse(trimmed) as Record<string, unknown>;
|
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -108,8 +124,7 @@ export function resolveGoogleChatAccount(params: {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedGoogleChatAccount {
|
}): ResolvedGoogleChatAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled =
|
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
|
||||||
(params.cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.enabled !== false;
|
|
||||||
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
||||||
const accountEnabled = merged.enabled !== false;
|
const accountEnabled = merged.enabled !== false;
|
||||||
const enabled = baseEnabled && accountEnabled;
|
const enabled = baseEnabled && accountEnabled;
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cf
|
|||||||
boolean | undefined
|
boolean | undefined
|
||||||
>,
|
>,
|
||||||
);
|
);
|
||||||
if (gate("reactions")) return true;
|
if (gate("reactions")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -50,11 +52,13 @@ function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
|
|||||||
|
|
||||||
export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const accounts = listEnabledAccounts(cfg as OpenClawConfig);
|
const accounts = listEnabledAccounts(cfg);
|
||||||
if (accounts.length === 0) return [];
|
if (accounts.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const actions = new Set<ChannelMessageActionName>([]);
|
const actions = new Set<ChannelMessageActionName>([]);
|
||||||
actions.add("send");
|
actions.add("send");
|
||||||
if (isReactionsEnabled(accounts, cfg as OpenClawConfig)) {
|
if (isReactionsEnabled(accounts, cfg)) {
|
||||||
actions.add("react");
|
actions.add("react");
|
||||||
actions.add("reactions");
|
actions.add("reactions");
|
||||||
}
|
}
|
||||||
@@ -62,15 +66,19 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
|||||||
},
|
},
|
||||||
extractToolSend: ({ args }) => {
|
extractToolSend: ({ args }) => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") return null;
|
if (action !== "sendMessage") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const to = typeof args.to === "string" ? args.to : undefined;
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
if (!to) return null;
|
if (!to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
if (account.credentialSource === "none") {
|
if (account.credentialSource === "none") {
|
||||||
@@ -134,12 +142,18 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const appUsers = resolveAppUserNames(account);
|
const appUsers = resolveAppUserNames(account);
|
||||||
const toRemove = reactions.filter((reaction) => {
|
const toRemove = reactions.filter((reaction) => {
|
||||||
const userName = reaction.user?.name?.trim();
|
const userName = reaction.user?.name?.trim();
|
||||||
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) return false;
|
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
|
||||||
if (emoji) return reaction.emoji?.unicode === emoji;
|
return false;
|
||||||
|
}
|
||||||
|
if (emoji) {
|
||||||
|
return reaction.emoji?.unicode === emoji;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
for (const reaction of toRemove) {
|
for (const reaction of toRemove) {
|
||||||
if (!reaction.name) continue;
|
if (!reaction.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
|
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
|
||||||
}
|
}
|
||||||
return jsonResult({ ok: true, removed: toRemove.length });
|
return jsonResult({ ok: true, removed: toRemove.length });
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import type { GoogleChatReaction } from "./types.js";
|
|||||||
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
|
||||||
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
|
||||||
|
|
||||||
|
const headersToObject = (headers?: HeadersInit): Record<string, string> =>
|
||||||
|
headers instanceof Headers
|
||||||
|
? Object.fromEntries(headers.entries())
|
||||||
|
: Array.isArray(headers)
|
||||||
|
? Object.fromEntries(headers)
|
||||||
|
: headers || {};
|
||||||
|
|
||||||
async function fetchJson<T>(
|
async function fetchJson<T>(
|
||||||
account: ResolvedGoogleChatAccount,
|
account: ResolvedGoogleChatAccount,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -16,9 +23,9 @@ async function fetchJson<T>(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
|
...headersToObject(init.headers),
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init.headers ?? {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -37,8 +44,8 @@ async function fetchOk(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
|
...headersToObject(init.headers),
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
...(init.headers ?? {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -57,8 +64,8 @@ async function fetchBuffer(
|
|||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
|
...headersToObject(init?.headers),
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -83,8 +90,12 @@ async function fetchBuffer(
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) {
|
||||||
if (!value) continue;
|
break;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
total += value.length;
|
total += value.length;
|
||||||
if (total > maxBytes) {
|
if (total > maxBytes) {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
@@ -106,8 +117,12 @@ export async function sendGoogleChatMessage(params: {
|
|||||||
}): Promise<{ messageName?: string } | null> {
|
}): Promise<{ messageName?: string } | null> {
|
||||||
const { account, space, text, thread, attachments } = params;
|
const { account, space, text, thread, attachments } = params;
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
if (text) body.text = text;
|
if (text) {
|
||||||
if (thread) body.thread = { name: thread };
|
body.text = text;
|
||||||
|
}
|
||||||
|
if (thread) {
|
||||||
|
body.thread = { name: thread };
|
||||||
|
}
|
||||||
if (attachments && attachments.length > 0) {
|
if (attachments && attachments.length > 0) {
|
||||||
body.attachment = attachments.map((item) => ({
|
body.attachment = attachments.map((item) => ({
|
||||||
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
|
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
|
||||||
@@ -182,7 +197,9 @@ export async function uploadGoogleChatAttachment(params: {
|
|||||||
const payload = (await res.json()) as {
|
const payload = (await res.json()) as {
|
||||||
attachmentDataRef?: { attachmentUploadToken?: string };
|
attachmentDataRef?: { attachmentUploadToken?: string };
|
||||||
};
|
};
|
||||||
return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken };
|
return {
|
||||||
|
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadGoogleChatMedia(params: {
|
export async function downloadGoogleChatMedia(params: {
|
||||||
@@ -215,7 +232,9 @@ export async function listGoogleChatReactions(params: {
|
|||||||
}): Promise<GoogleChatReaction[]> {
|
}): Promise<GoogleChatReaction[]> {
|
||||||
const { account, messageName, limit } = params;
|
const { account, messageName, limit } = params;
|
||||||
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
|
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
|
||||||
if (limit && limit > 0) url.searchParams.set("pageSize", String(limit));
|
if (limit && limit > 0) {
|
||||||
|
url.searchParams.set("pageSize", String(limit));
|
||||||
|
}
|
||||||
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
|
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
});
|
});
|
||||||
@@ -251,9 +270,14 @@ export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promi
|
|||||||
try {
|
try {
|
||||||
const url = new URL(`${CHAT_API_BASE}/spaces`);
|
const url = new URL(`${CHAT_API_BASE}/spaces`);
|
||||||
url.searchParams.set("pageSize", "1");
|
url.searchParams.set("pageSize", "1");
|
||||||
await fetchJson<Record<string, unknown>>(account, url.toString(), { method: "GET" });
|
await fetchJson<Record<string, unknown>>(account, url.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,21 @@ const verifyClient = new OAuth2Client();
|
|||||||
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
||||||
|
|
||||||
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
||||||
if (account.credentialsFile) return `file:${account.credentialsFile}`;
|
if (account.credentialsFile) {
|
||||||
if (account.credentials) return `inline:${JSON.stringify(account.credentials)}`;
|
return `file:${account.credentialsFile}`;
|
||||||
|
}
|
||||||
|
if (account.credentials) {
|
||||||
|
return `inline:${JSON.stringify(account.credentials)}`;
|
||||||
|
}
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
|
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
|
||||||
const key = buildAuthKey(account);
|
const key = buildAuthKey(account);
|
||||||
const cached = authCache.get(account.accountId);
|
const cached = authCache.get(account.accountId);
|
||||||
if (cached && cached.key === key) return cached.auth;
|
if (cached && cached.key === key) {
|
||||||
|
return cached.auth;
|
||||||
|
}
|
||||||
|
|
||||||
if (account.credentialsFile) {
|
if (account.credentialsFile) {
|
||||||
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
|
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
|
||||||
@@ -77,9 +83,13 @@ export async function verifyGoogleChatRequest(params: {
|
|||||||
audience?: string | null;
|
audience?: string | null;
|
||||||
}): Promise<{ ok: boolean; reason?: string }> {
|
}): Promise<{ ok: boolean; reason?: string }> {
|
||||||
const bearer = params.bearer?.trim();
|
const bearer = params.bearer?.trim();
|
||||||
if (!bearer) return { ok: false, reason: "missing token" };
|
if (!bearer) {
|
||||||
|
return { ok: false, reason: "missing token" };
|
||||||
|
}
|
||||||
const audience = params.audience?.trim();
|
const audience = params.audience?.trim();
|
||||||
if (!audience) return { ok: false, reason: "missing audience" };
|
if (!audience) {
|
||||||
|
return { ok: false, reason: "missing audience" };
|
||||||
|
}
|
||||||
const audienceType = params.audienceType ?? null;
|
const audienceType = params.audienceType ?? null;
|
||||||
|
|
||||||
if (audienceType === "app-url") {
|
if (audienceType === "app-url") {
|
||||||
|
|||||||
@@ -59,10 +59,9 @@ export const googlechatDock: ChannelDock = {
|
|||||||
outbound: { textChunkLimit: 4000 },
|
outbound: { textChunkLimit: 4000 },
|
||||||
config: {
|
config: {
|
||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(
|
(resolveGoogleChatAccount({ cfg: cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) =>
|
||||||
resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig, accountId }).config.dm?.allowFrom ??
|
String(entry),
|
||||||
[]
|
),
|
||||||
).map((entry) => String(entry)),
|
|
||||||
formatAllowFrom: ({ allowFrom }) =>
|
formatAllowFrom: ({ allowFrom }) =>
|
||||||
allowFrom
|
allowFrom
|
||||||
.map((entry) => String(entry))
|
.map((entry) => String(entry))
|
||||||
@@ -104,8 +103,10 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
idLabel: "googlechatUserId",
|
idLabel: "googlechatUserId",
|
||||||
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
||||||
notifyApproval: async ({ cfg, id }) => {
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
const account = resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig });
|
const account = resolveGoogleChatAccount({ cfg: cfg });
|
||||||
if (account.credentialSource === "none") return;
|
if (account.credentialSource === "none") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||||
@@ -130,13 +131,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.googlechat"] },
|
reload: { configPrefixes: ["channels.googlechat"] },
|
||||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg as OpenClawConfig),
|
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg),
|
||||||
resolveAccount: (cfg, accountId) =>
|
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }),
|
||||||
resolveGoogleChatAccount({ cfg: cfg as OpenClawConfig, accountId }),
|
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg),
|
||||||
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg as OpenClawConfig),
|
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||||
setAccountEnabledInConfigSection({
|
setAccountEnabledInConfigSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
sectionKey: "googlechat",
|
sectionKey: "googlechat",
|
||||||
accountId,
|
accountId,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -144,7 +144,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
}),
|
}),
|
||||||
deleteAccount: ({ cfg, accountId }) =>
|
deleteAccount: ({ cfg, accountId }) =>
|
||||||
deleteAccountFromConfigSection({
|
deleteAccountFromConfigSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
sectionKey: "googlechat",
|
sectionKey: "googlechat",
|
||||||
accountId,
|
accountId,
|
||||||
clearBaseFields: [
|
clearBaseFields: [
|
||||||
@@ -169,7 +169,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||||
(
|
(
|
||||||
resolveGoogleChatAccount({
|
resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
}).config.dm?.allowFrom ?? []
|
}).config.dm?.allowFrom ?? []
|
||||||
).map((entry) => String(entry)),
|
).map((entry) => String(entry)),
|
||||||
@@ -182,9 +182,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
const useAccountPath = Boolean(
|
const useAccountPath = Boolean(cfg.channels?.["googlechat"]?.accounts?.[resolvedAccountId]);
|
||||||
(cfg as OpenClawConfig).channels?.["googlechat"]?.accounts?.[resolvedAccountId],
|
|
||||||
);
|
|
||||||
const allowFromPath = useAccountPath
|
const allowFromPath = useAccountPath
|
||||||
? `channels.googlechat.accounts.${resolvedAccountId}.dm.`
|
? `channels.googlechat.accounts.${resolvedAccountId}.dm.`
|
||||||
: "channels.googlechat.dm.";
|
: "channels.googlechat.dm.";
|
||||||
@@ -233,7 +231,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
self: async () => null,
|
self: async () => null,
|
||||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
const q = query?.trim().toLowerCase() || "";
|
const q = query?.trim().toLowerCase() || "";
|
||||||
@@ -253,7 +251,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
},
|
},
|
||||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
const groups = account.config.groups ?? {};
|
const groups = account.config.groups ?? {};
|
||||||
@@ -293,7 +291,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) =>
|
applyAccountName: ({ cfg, accountId, name }) =>
|
||||||
applyAccountNameToChannelSection({
|
applyAccountNameToChannelSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
channelKey: "googlechat",
|
channelKey: "googlechat",
|
||||||
accountId,
|
accountId,
|
||||||
name,
|
name,
|
||||||
@@ -309,7 +307,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
},
|
},
|
||||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||||
const namedConfig = applyAccountNameToChannelSection({
|
const namedConfig = applyAccountNameToChannelSection({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
channelKey: "googlechat",
|
channelKey: "googlechat",
|
||||||
accountId,
|
accountId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -317,7 +315,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
const next =
|
const next =
|
||||||
accountId !== DEFAULT_ACCOUNT_ID
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
? migrateBaseNameToDefaultAccount({
|
? migrateBaseNameToDefaultAccount({
|
||||||
cfg: namedConfig as OpenClawConfig,
|
cfg: namedConfig,
|
||||||
channelKey: "googlechat",
|
channelKey: "googlechat",
|
||||||
})
|
})
|
||||||
: namedConfig;
|
: namedConfig;
|
||||||
@@ -345,7 +343,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
channels: {
|
channels: {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(next.channels?.["googlechat"] ?? {}),
|
...next.channels?.["googlechat"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...configPatch,
|
...configPatch,
|
||||||
},
|
},
|
||||||
@@ -357,12 +355,12 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
channels: {
|
channels: {
|
||||||
...next.channels,
|
...next.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(next.channels?.["googlechat"] ?? {}),
|
...next.channels?.["googlechat"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...(next.channels?.["googlechat"]?.accounts ?? {}),
|
...next.channels?.["googlechat"]?.accounts,
|
||||||
[accountId]: {
|
[accountId]: {
|
||||||
...(next.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
|
...next.channels?.["googlechat"]?.accounts?.[accountId],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...configPatch,
|
...configPatch,
|
||||||
},
|
},
|
||||||
@@ -415,7 +413,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
},
|
},
|
||||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||||
@@ -437,14 +435,14 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
throw new Error("Google Chat mediaUrl is required.");
|
throw new Error("Google Chat mediaUrl is required.");
|
||||||
}
|
}
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
|
||||||
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
|
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
|
||||||
const runtime = getGoogleChatRuntime();
|
const runtime = getGoogleChatRuntime();
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
cfg: cfg as OpenClawConfig,
|
cfg: cfg,
|
||||||
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
resolveChannelLimitMb: ({ cfg, accountId }) =>
|
||||||
(
|
(
|
||||||
cfg.channels?.["googlechat"] as
|
cfg.channels?.["googlechat"] as
|
||||||
@@ -493,7 +491,9 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||||
const enabled = entry.enabled !== false;
|
const enabled = entry.enabled !== false;
|
||||||
const configured = entry.configured === true;
|
const configured = entry.configured === true;
|
||||||
if (!enabled || !configured) return [];
|
if (!enabled || !configured) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const issues = [];
|
const issues = [];
|
||||||
if (!entry.audience) {
|
if (!entry.audience) {
|
||||||
issues.push({
|
issues.push({
|
||||||
@@ -564,7 +564,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
});
|
});
|
||||||
const unregister = await startGoogleChatMonitor({
|
const unregister = await startGoogleChatMonitor({
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg as OpenClawConfig,
|
config: ctx.cfg,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
abortSignal: ctx.abortSignal,
|
abortSignal: ctx.abortSignal,
|
||||||
webhookPath: account.config.webhookPath,
|
webhookPath: account.config.webhookPath,
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
|
|||||||
|
|
||||||
function normalizeWebhookPath(raw: string): string {
|
function normalizeWebhookPath(raw: string): string {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return "/";
|
if (!trimmed) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||||
return withSlash.slice(0, -1);
|
return withSlash.slice(0, -1);
|
||||||
@@ -70,7 +72,9 @@ function normalizeWebhookPath(raw: string): string {
|
|||||||
|
|
||||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||||
const trimmedPath = webhookPath?.trim();
|
const trimmedPath = webhookPath?.trim();
|
||||||
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
|
if (trimmedPath) {
|
||||||
|
return normalizeWebhookPath(trimmedPath);
|
||||||
|
}
|
||||||
if (webhookUrl?.trim()) {
|
if (webhookUrl?.trim()) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(webhookUrl);
|
const parsed = new URL(webhookUrl);
|
||||||
@@ -88,7 +92,9 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
|||||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
|
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
|
||||||
if (resolved) return;
|
if (resolved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
resolved = true;
|
resolved = true;
|
||||||
req.removeAllListeners();
|
req.removeAllListeners();
|
||||||
resolve(value);
|
resolve(value);
|
||||||
@@ -158,7 +164,9 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const path = normalizeWebhookPath(url.pathname);
|
const path = normalizeWebhookPath(url.pathname);
|
||||||
const targets = webhookTargets.get(path);
|
const targets = webhookTargets.get(path);
|
||||||
if (!targets || targets.length === 0) return false;
|
if (!targets || targets.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
res.statusCode = 405;
|
res.statusCode = 405;
|
||||||
@@ -279,8 +287,12 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
|
|
||||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||||
if (eventType !== "MESSAGE") return;
|
if (eventType !== "MESSAGE") {
|
||||||
if (!event.message || !event.space) return;
|
return;
|
||||||
|
}
|
||||||
|
if (!event.message || !event.space) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await processMessageWithPipeline({
|
await processMessageWithPipeline({
|
||||||
event,
|
event,
|
||||||
@@ -295,7 +307,9 @@ async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTar
|
|||||||
|
|
||||||
function normalizeUserId(raw?: string | null): string {
|
function normalizeUserId(raw?: string | null): string {
|
||||||
const trimmed = raw?.trim() ?? "";
|
const trimmed = raw?.trim() ?? "";
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
return trimmed.replace(/^users\//i, "").toLowerCase();
|
return trimmed.replace(/^users\//i, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,16 +318,28 @@ export function isSenderAllowed(
|
|||||||
senderEmail: string | undefined,
|
senderEmail: string | undefined,
|
||||||
allowFrom: string[],
|
allowFrom: string[],
|
||||||
) {
|
) {
|
||||||
if (allowFrom.includes("*")) return true;
|
if (allowFrom.includes("*")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalizedSenderId = normalizeUserId(senderId);
|
const normalizedSenderId = normalizeUserId(senderId);
|
||||||
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
|
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
|
||||||
return allowFrom.some((entry) => {
|
return allowFrom.some((entry) => {
|
||||||
const normalized = String(entry).trim().toLowerCase();
|
const normalized = String(entry).trim().toLowerCase();
|
||||||
if (!normalized) return false;
|
if (!normalized) {
|
||||||
if (normalized === normalizedSenderId) return true;
|
return false;
|
||||||
if (normalizedEmail && normalized === normalizedEmail) return true;
|
}
|
||||||
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) return true;
|
if (normalized === normalizedSenderId) {
|
||||||
if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (normalizedEmail && normalized === normalizedEmail) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized.replace(/^users\//i, "") === normalizedSenderId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
|
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -357,8 +383,12 @@ function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: strin
|
|||||||
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
|
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
|
||||||
const wasMentioned = mentionAnnotations.some((entry) => {
|
const wasMentioned = mentionAnnotations.some((entry) => {
|
||||||
const userName = entry.userMention?.user?.name;
|
const userName = entry.userMention?.user?.name;
|
||||||
if (!userName) return false;
|
if (!userName) {
|
||||||
if (botTargets.has(userName)) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (botTargets.has(userName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return normalizeUserId(userName) === "app";
|
return normalizeUserId(userName) === "app";
|
||||||
});
|
});
|
||||||
return { hasAnyMention, wasMentioned };
|
return { hasAnyMention, wasMentioned };
|
||||||
@@ -376,9 +406,13 @@ function resolveBotDisplayName(params: {
|
|||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
}): string {
|
}): string {
|
||||||
const { accountName, agentId, config } = params;
|
const { accountName, agentId, config } = params;
|
||||||
if (accountName?.trim()) return accountName.trim();
|
if (accountName?.trim()) {
|
||||||
|
return accountName.trim();
|
||||||
|
}
|
||||||
const agent = config.agents?.list?.find((a) => a.id === agentId);
|
const agent = config.agents?.list?.find((a) => a.id === agentId);
|
||||||
if (agent?.name?.trim()) return agent.name.trim();
|
if (agent?.name?.trim()) {
|
||||||
|
return agent.name.trim();
|
||||||
|
}
|
||||||
return "OpenClaw";
|
return "OpenClaw";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,10 +428,14 @@ async function processMessageWithPipeline(params: {
|
|||||||
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
|
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
|
||||||
const space = event.space;
|
const space = event.space;
|
||||||
const message = event.message;
|
const message = event.message;
|
||||||
if (!space || !message) return;
|
if (!space || !message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const spaceId = space.name ?? "";
|
const spaceId = space.name ?? "";
|
||||||
if (!spaceId) return;
|
if (!spaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const spaceType = (space.type ?? "").toUpperCase();
|
const spaceType = (space.type ?? "").toUpperCase();
|
||||||
const isGroup = spaceType !== "DM";
|
const isGroup = spaceType !== "DM";
|
||||||
const sender = message.sender ?? event.user;
|
const sender = message.sender ?? event.user;
|
||||||
@@ -421,7 +459,9 @@ async function processMessageWithPipeline(params: {
|
|||||||
const attachments = message.attachment ?? [];
|
const attachments = message.attachment ?? [];
|
||||||
const hasMedia = attachments.length > 0;
|
const hasMedia = attachments.length > 0;
|
||||||
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
|
||||||
if (!rawBody) return;
|
if (!rawBody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
@@ -721,7 +761,9 @@ async function downloadAttachment(
|
|||||||
core: GoogleChatCoreRuntime,
|
core: GoogleChatCoreRuntime,
|
||||||
): Promise<{ path: string; contentType?: string } | null> {
|
): Promise<{ path: string; contentType?: string } | null> {
|
||||||
const resourceName = attachment.attachmentDataRef?.resourceName;
|
const resourceName = attachment.attachmentDataRef?.resourceName;
|
||||||
if (!resourceName) return null;
|
if (!resourceName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||||
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
|
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
|
||||||
const saved = await core.channel.media.saveMediaBuffer(
|
const saved = await core.channel.media.saveMediaBuffer(
|
||||||
|
|||||||
@@ -32,9 +32,9 @@ function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) {
|
|||||||
channels: {
|
channels: {
|
||||||
...cfg.channels,
|
...cfg.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(cfg.channels?.["googlechat"] ?? {}),
|
...cfg.channels?.["googlechat"],
|
||||||
dm: {
|
dm: {
|
||||||
...(cfg.channels?.["googlechat"]?.dm ?? {}),
|
...cfg.channels?.["googlechat"]?.dm,
|
||||||
policy,
|
policy,
|
||||||
...(allowFrom ? { allowFrom } : {}),
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
},
|
},
|
||||||
@@ -68,10 +68,10 @@ async function promptAllowFrom(params: {
|
|||||||
channels: {
|
channels: {
|
||||||
...params.cfg.channels,
|
...params.cfg.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(params.cfg.channels?.["googlechat"] ?? {}),
|
...params.cfg.channels?.["googlechat"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
dm: {
|
dm: {
|
||||||
...(params.cfg.channels?.["googlechat"]?.dm ?? {}),
|
...params.cfg.channels?.["googlechat"]?.dm,
|
||||||
policy: "allowlist",
|
policy: "allowlist",
|
||||||
allowFrom: unique,
|
allowFrom: unique,
|
||||||
},
|
},
|
||||||
@@ -102,7 +102,7 @@ function applyAccountConfig(params: {
|
|||||||
channels: {
|
channels: {
|
||||||
...cfg.channels,
|
...cfg.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(cfg.channels?.["googlechat"] ?? {}),
|
...cfg.channels?.["googlechat"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...patch,
|
...patch,
|
||||||
},
|
},
|
||||||
@@ -114,12 +114,12 @@ function applyAccountConfig(params: {
|
|||||||
channels: {
|
channels: {
|
||||||
...cfg.channels,
|
...cfg.channels,
|
||||||
googlechat: {
|
googlechat: {
|
||||||
...(cfg.channels?.["googlechat"] ?? {}),
|
...cfg.channels?.["googlechat"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
...(cfg.channels?.["googlechat"]?.accounts ?? {}),
|
...cfg.channels?.["googlechat"]?.accounts,
|
||||||
[accountId]: {
|
[accountId]: {
|
||||||
...(cfg.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
|
...cfg.channels?.["googlechat"]?.accounts?.[accountId],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
...patch,
|
...patch,
|
||||||
},
|
},
|
||||||
@@ -193,14 +193,14 @@ async function promptAudience(params: {
|
|||||||
});
|
});
|
||||||
const currentType = account.config.audienceType ?? "app-url";
|
const currentType = account.config.audienceType ?? "app-url";
|
||||||
const currentAudience = account.config.audience ?? "";
|
const currentAudience = account.config.audience ?? "";
|
||||||
const audienceType = (await params.prompter.select({
|
const audienceType = await params.prompter.select({
|
||||||
message: "Webhook audience type",
|
message: "Webhook audience type",
|
||||||
options: [
|
options: [
|
||||||
{ value: "app-url", label: "App URL (recommended)" },
|
{ value: "app-url", label: "App URL (recommended)" },
|
||||||
{ value: "project-number", label: "Project number" },
|
{ value: "project-number", label: "Project number" },
|
||||||
],
|
],
|
||||||
initialValue: currentType === "project-number" ? "project-number" : "app-url",
|
initialValue: currentType === "project-number" ? "project-number" : "app-url",
|
||||||
})) as "app-url" | "project-number";
|
});
|
||||||
const audience = await params.prompter.text({
|
const audience = await params.prompter.text({
|
||||||
message: audienceType === "project-number" ? "Project number" : "App URL",
|
message: audienceType === "project-number" ? "Project number" : "App URL",
|
||||||
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
|
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { findGoogleChatDirectMessage } from "./api.js";
|
|||||||
|
|
||||||
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||||
const normalized = withoutPrefix
|
const normalized = withoutPrefix
|
||||||
.replace(/^user:(users\/)?/i, "users/")
|
.replace(/^user:(users\/)?/i, "users/")
|
||||||
@@ -12,8 +14,12 @@ export function normalizeGoogleChatTarget(raw?: string | null): string | undefin
|
|||||||
const suffix = normalized.slice("users/".length);
|
const suffix = normalized.slice("users/".length);
|
||||||
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
|
||||||
}
|
}
|
||||||
if (isGoogleChatSpaceTarget(normalized)) return normalized;
|
if (isGoogleChatSpaceTarget(normalized)) {
|
||||||
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`;
|
return normalized;
|
||||||
|
}
|
||||||
|
if (normalized.includes("@")) {
|
||||||
|
return `users/${normalized.toLowerCase()}`;
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +33,9 @@ export function isGoogleChatSpaceTarget(value: string): boolean {
|
|||||||
|
|
||||||
function stripMessageSuffix(target: string): string {
|
function stripMessageSuffix(target: string): string {
|
||||||
const index = target.indexOf("/messages/");
|
const index = target.indexOf("/messages/");
|
||||||
if (index === -1) return target;
|
if (index === -1) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
return target.slice(0, index);
|
return target.slice(0, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +48,9 @@ export async function resolveGoogleChatOutboundSpace(params: {
|
|||||||
throw new Error("Missing Google Chat target.");
|
throw new Error("Missing Google Chat target.");
|
||||||
}
|
}
|
||||||
const base = stripMessageSuffix(normalized);
|
const base = stripMessageSuffix(normalized);
|
||||||
if (isGoogleChatSpaceTarget(base)) return base;
|
if (isGoogleChatSpaceTarget(base)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
if (isGoogleChatUserTarget(base)) {
|
if (isGoogleChatUserTarget(base)) {
|
||||||
const dm = await findGoogleChatDirectMessage({
|
const dm = await findGoogleChatDirectMessage({
|
||||||
account: params.account,
|
account: params.account,
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,
|
`- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
@@ -227,7 +229,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: (accounts) =>
|
||||||
accounts.flatMap((account) => {
|
accounts.flatMap((account) => {
|
||||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
if (!lastError) return [];
|
if (!lastError) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
channel: "imessage",
|
channel: "imessage",
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ function buildLineReply(lineData: LineChannelData): ReplyPayload {
|
|||||||
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
* Data can be a URL (uri action) or plain text (message action) or key=value (postback)
|
||||||
*/
|
*/
|
||||||
function parseActions(actionsStr: string | undefined): CardAction[] {
|
function parseActions(actionsStr: string | undefined): CardAction[] {
|
||||||
if (!actionsStr) return [];
|
if (!actionsStr) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const results: CardAction[] = [];
|
const results: CardAction[] = [];
|
||||||
|
|
||||||
@@ -47,7 +49,9 @@ function parseActions(actionsStr: string | undefined): CardAction[] {
|
|||||||
.trim()
|
.trim()
|
||||||
.split("|")
|
.split("|")
|
||||||
.map((s) => s.trim());
|
.map((s) => s.trim());
|
||||||
if (!label) continue;
|
if (!label) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const actionData = data || label;
|
const actionData = data || label;
|
||||||
|
|
||||||
@@ -158,12 +162,16 @@ export function registerLineCardCommand(api: OpenClawPluginApi): void {
|
|||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const argsStr = ctx.args?.trim() ?? "";
|
const argsStr = ctx.args?.trim() ?? "";
|
||||||
if (!argsStr) return { text: CARD_USAGE };
|
if (!argsStr) {
|
||||||
|
return { text: CARD_USAGE };
|
||||||
|
}
|
||||||
|
|
||||||
const parsed = parseCardArgs(argsStr);
|
const parsed = parseCardArgs(argsStr);
|
||||||
const { type, args, flags } = parsed;
|
const { type, args, flags } = parsed;
|
||||||
|
|
||||||
if (!type) return { text: CARD_USAGE };
|
if (!type) {
|
||||||
|
return { text: CARD_USAGE };
|
||||||
|
}
|
||||||
|
|
||||||
// Only LINE supports rich cards; fallback to text elsewhere.
|
// Only LINE supports rich cards; fallback to text elsewhere.
|
||||||
if (ctx.channel !== "line") {
|
if (ctx.channel !== "line") {
|
||||||
|
|||||||
@@ -25,17 +25,6 @@ const meta = {
|
|||||||
systemImage: "message.fill",
|
systemImage: "message.fill",
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseThreadId(threadId?: string | number | null): number | undefined {
|
|
||||||
if (threadId == null) return undefined;
|
|
||||||
if (typeof threadId === "number") {
|
|
||||||
return Number.isFinite(threadId) ? Math.trunc(threadId) : undefined;
|
|
||||||
}
|
|
||||||
const trimmed = threadId.trim();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
const parsed = Number.parseInt(trimmed, 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||||
id: "line",
|
id: "line",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -108,7 +97,8 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
deleteAccount: ({ cfg, accountId }) => {
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
const { channelAccessToken, channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
// oxlint-disable-next-line no-unused-vars
|
||||||
|
const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig;
|
||||||
return {
|
return {
|
||||||
...cfg,
|
...cfg,
|
||||||
channels: {
|
channels: {
|
||||||
@@ -173,7 +163,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined)
|
const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined)
|
||||||
?.groupPolicy;
|
?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
`- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
@@ -183,7 +175,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||||
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId });
|
||||||
const groups = account.config.groups;
|
const groups = account.config.groups;
|
||||||
if (!groups) return false;
|
if (!groups) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const groupConfig = groups[groupId] ?? groups["*"];
|
const groupConfig = groups[groupId] ?? groups["*"];
|
||||||
return groupConfig?.requireMention ?? false;
|
return groupConfig?.requireMention ?? false;
|
||||||
},
|
},
|
||||||
@@ -191,13 +185,17 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: (target) => {
|
normalizeTarget: (target) => {
|
||||||
const trimmed = target.trim();
|
const trimmed = target.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, "");
|
||||||
},
|
},
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (id) => {
|
looksLikeId: (id) => {
|
||||||
const trimmed = id?.trim();
|
const trimmed = id?.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// LINE user IDs are typically U followed by 32 hex characters
|
// LINE user IDs are typically U followed by 32 hex characters
|
||||||
// Group IDs are C followed by 32 hex characters
|
// Group IDs are C followed by 32 hex characters
|
||||||
// Room IDs are R followed by 32 hex characters
|
// Room IDs are R followed by 32 hex characters
|
||||||
@@ -356,7 +354,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
const sendMessageBatch = async (messages: Array<Record<string, unknown>>) => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let i = 0; i < messages.length; i += 5) {
|
for (let i = 0; i < messages.length; i += 5) {
|
||||||
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
const result = await sendBatch(to, messages.slice(i, i + 5), {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -434,12 +434,12 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
for (let i = 0; i < chunks.length; i += 1) {
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
const isLast = i === chunks.length - 1;
|
const isLast = i === chunks.length - 1;
|
||||||
if (isLast && hasQuickReplies) {
|
if (isLast && hasQuickReplies) {
|
||||||
lastResult = await sendQuickReplies(to, chunks[i]!, lineData.quickReplies!, {
|
lastResult = await sendQuickReplies(to, chunks[i], lineData.quickReplies!, {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
lastResult = await sendText(to, chunks[i]!, {
|
lastResult = await sendText(to, chunks[i], {
|
||||||
verbose: false,
|
verbose: false,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -478,7 +478,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
}
|
}
|
||||||
for (const url of mediaUrls) {
|
for (const url of mediaUrls) {
|
||||||
const trimmed = url?.trim();
|
const trimmed = url?.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
quickReplyMessages.push({
|
quickReplyMessages.push({
|
||||||
type: "image",
|
type: "image",
|
||||||
originalContentUrl: trimmed,
|
originalContentUrl: trimmed,
|
||||||
@@ -505,7 +507,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastResult) return { channel: "line", ...lastResult };
|
if (lastResult) {
|
||||||
|
return { channel: "line", ...lastResult };
|
||||||
|
}
|
||||||
return { channel: "line", messageId: "empty", chatId: to };
|
return { channel: "line", messageId: "empty", chatId: to };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId }) => {
|
sendText: async ({ to, text, accountId }) => {
|
||||||
@@ -621,7 +625,9 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
try {
|
try {
|
||||||
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500);
|
||||||
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
const displayName = probe.ok ? probe.bot?.displayName?.trim() : null;
|
||||||
if (displayName) lineBotLabel = ` (${displayName})`;
|
if (displayName) {
|
||||||
|
lineBotLabel = ` (${displayName})`;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (getLineRuntime().logging.shouldLogVerbose()) {
|
if (getLineRuntime().logging.shouldLogVerbose()) {
|
||||||
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
const res = await tool.execute("id", { prompt: "return foo" });
|
const res = await tool.execute("id", { prompt: "return foo" });
|
||||||
expect((res as any).details.json).toEqual({ foo: "bar" });
|
expect((res as any).details.json).toEqual({ foo: "bar" });
|
||||||
});
|
});
|
||||||
@@ -46,7 +46,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: '```json\n{"ok":true}\n```' }],
|
payloads: [{ text: '```json\n{"ok":true}\n```' }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
const res = await tool.execute("id", { prompt: "return ok" });
|
const res = await tool.execute("id", { prompt: "return ok" });
|
||||||
expect((res as any).details.json).toEqual({ ok: true });
|
expect((res as any).details.json).toEqual({ ok: true });
|
||||||
});
|
});
|
||||||
@@ -56,7 +56,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
payloads: [{ text: JSON.stringify({ foo: "bar" }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: { foo: { type: "string" } },
|
properties: { foo: { type: "string" } },
|
||||||
@@ -72,7 +72,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: "not-json" }],
|
payloads: [{ text: "not-json" }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
|
await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
|
payloads: [{ text: JSON.stringify({ foo: 1 }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
|
const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] };
|
||||||
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
|
await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i);
|
||||||
});
|
});
|
||||||
@@ -91,7 +91,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
|
await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" });
|
||||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||||
expect(call.provider).toBe("anthropic");
|
expect(call.provider).toBe("anthropic");
|
||||||
@@ -104,7 +104,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(
|
const tool = createLlmTaskTool(
|
||||||
fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }) as any,
|
fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }),
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }),
|
tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }),
|
||||||
@@ -116,7 +116,7 @@ describe("llm-task tool (json-only)", () => {
|
|||||||
meta: {},
|
meta: {},
|
||||||
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
});
|
});
|
||||||
const tool = createLlmTaskTool(fakeApi() as any);
|
const tool = createLlmTaskTool(fakeApi());
|
||||||
await tool.execute("id", { prompt: "x" });
|
await tool.execute("id", { prompt: "x" });
|
||||||
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0];
|
||||||
expect(call.disableTools).toBe(true);
|
expect(call.disableTools).toBe(true);
|
||||||
|
|||||||
@@ -18,24 +18,27 @@ async function loadRunEmbeddedPiAgent(): Promise<RunEmbeddedPiAgentFn> {
|
|||||||
// Source checkout (tests/dev)
|
// Source checkout (tests/dev)
|
||||||
try {
|
try {
|
||||||
const mod = await import("../../../src/agents/pi-embedded-runner.js");
|
const mod = await import("../../../src/agents/pi-embedded-runner.js");
|
||||||
if (typeof (mod as any).runEmbeddedPiAgent === "function")
|
if (typeof (mod as any).runEmbeddedPiAgent === "function") {
|
||||||
return (mod as any).runEmbeddedPiAgent;
|
return (mod as any).runEmbeddedPiAgent;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bundled install (built)
|
// Bundled install (built)
|
||||||
const mod = await import("../../../agents/pi-embedded-runner.js");
|
const mod = await import("../../../agents/pi-embedded-runner.js");
|
||||||
if (typeof (mod as any).runEmbeddedPiAgent !== "function") {
|
if (typeof mod.runEmbeddedPiAgent !== "function") {
|
||||||
throw new Error("Internal error: runEmbeddedPiAgent not available");
|
throw new Error("Internal error: runEmbeddedPiAgent not available");
|
||||||
}
|
}
|
||||||
return (mod as any).runEmbeddedPiAgent;
|
return mod.runEmbeddedPiAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripCodeFences(s: string): string {
|
function stripCodeFences(s: string): string {
|
||||||
const trimmed = s.trim();
|
const trimmed = s.trim();
|
||||||
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||||
if (m) return (m[1] ?? "").trim();
|
if (m) {
|
||||||
|
return (m[1] ?? "").trim();
|
||||||
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +52,9 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und
|
|||||||
function toModelKey(provider?: string, model?: string): string | undefined {
|
function toModelKey(provider?: string, model?: string): string | undefined {
|
||||||
const p = provider?.trim();
|
const p = provider?.trim();
|
||||||
const m = model?.trim();
|
const m = model?.trim();
|
||||||
if (!p || !m) return undefined;
|
if (!p || !m) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return `${p}/${m}`;
|
return `${p}/${m}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +89,10 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const prompt = String(params.prompt ?? "");
|
const prompt = typeof params.prompt === "string" ? params.prompt : "";
|
||||||
if (!prompt.trim()) throw new Error("prompt required");
|
if (!prompt.trim()) {
|
||||||
|
throw new Error("prompt required");
|
||||||
|
}
|
||||||
|
|
||||||
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
|
const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg;
|
||||||
|
|
||||||
@@ -189,7 +196,9 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const text = collectText((result as any).payloads);
|
const text = collectText((result as any).payloads);
|
||||||
if (!text) throw new Error("LLM returned empty output");
|
if (!text) {
|
||||||
|
throw new Error("LLM returned empty output");
|
||||||
|
}
|
||||||
|
|
||||||
const raw = stripCodeFences(text);
|
const raw = stripCodeFences(text);
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { createLobsterTool } from "./src/lobster-tool.js";
|
|||||||
export default function register(api: OpenClawPluginApi) {
|
export default function register(api: OpenClawPluginApi) {
|
||||||
api.registerTool(
|
api.registerTool(
|
||||||
(ctx) => {
|
(ctx) => {
|
||||||
if (ctx.sandboxed) return null;
|
if (ctx.sandboxed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return createLobsterTool(api);
|
return createLobsterTool(api);
|
||||||
},
|
},
|
||||||
{ optional: true },
|
{ optional: true },
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ describe("lobster plugin tool", () => {
|
|||||||
it("can be gated off in sandboxed contexts", async () => {
|
it("can be gated off in sandboxed contexts", async () => {
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
||||||
if (ctx.sandboxed) return null;
|
if (ctx.sandboxed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return createLobsterTool(api);
|
return createLobsterTool(api);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsSpawnEINVAL(err: unknown) {
|
function isWindowsSpawnEINVAL(err: unknown) {
|
||||||
if (!err || typeof err !== "object") return false;
|
if (!err || typeof err !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const code = (err as { code?: unknown }).code;
|
const code = (err as { code?: unknown }).code;
|
||||||
return code === "EINVAL";
|
return code === "EINVAL";
|
||||||
}
|
}
|
||||||
@@ -186,8 +188,10 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
|||||||
maxStdoutBytes: Type.Optional(Type.Number()),
|
maxStdoutBytes: Type.Optional(Type.Number()),
|
||||||
}),
|
}),
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>) {
|
||||||
const action = String(params.action || "").trim();
|
const action = typeof params.action === "string" ? params.action.trim() : "";
|
||||||
if (!action) throw new Error("action required");
|
if (!action) {
|
||||||
|
throw new Error("action required");
|
||||||
|
}
|
||||||
|
|
||||||
const execPath = resolveExecutablePath(
|
const execPath = resolveExecutablePath(
|
||||||
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
|
typeof params.lobsterPath === "string" ? params.lobsterPath : undefined,
|
||||||
@@ -201,7 +205,9 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
|||||||
const argv = (() => {
|
const argv = (() => {
|
||||||
if (action === "run") {
|
if (action === "run") {
|
||||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||||
if (!pipeline.trim()) throw new Error("pipeline required");
|
if (!pipeline.trim()) {
|
||||||
|
throw new Error("pipeline required");
|
||||||
|
}
|
||||||
const argv = ["run", "--mode", "tool", pipeline];
|
const argv = ["run", "--mode", "tool", pipeline];
|
||||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
||||||
if (argsJson.trim()) {
|
if (argsJson.trim()) {
|
||||||
@@ -211,9 +217,13 @@ export function createLobsterTool(api: OpenClawPluginApi) {
|
|||||||
}
|
}
|
||||||
if (action === "resume") {
|
if (action === "resume") {
|
||||||
const token = typeof params.token === "string" ? params.token : "";
|
const token = typeof params.token === "string" ? params.token : "";
|
||||||
if (!token.trim()) throw new Error("token required");
|
if (!token.trim()) {
|
||||||
|
throw new Error("token required");
|
||||||
|
}
|
||||||
const approve = params.approve;
|
const approve = params.approve;
|
||||||
if (typeof approve !== "boolean") throw new Error("approve required");
|
if (typeof approve !== "boolean") {
|
||||||
|
throw new Error("approve required");
|
||||||
|
}
|
||||||
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
return ["resume", "--token", token, "--approve", approve ? "yes" : "no"];
|
||||||
}
|
}
|
||||||
throw new Error(`Unknown action: ${action}`);
|
throw new Error(`Unknown action: ${action}`);
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import type { CoreConfig } from "./types.js";
|
|||||||
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
export const matrixMessageActions: ChannelMessageActionAdapter = {
|
||||||
listActions: ({ cfg }) => {
|
listActions: ({ cfg }) => {
|
||||||
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
|
||||||
if (!account.enabled || !account.configured) return [];
|
if (!account.enabled || !account.configured) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions);
|
||||||
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
const actions = new Set<ChannelMessageActionName>(["send", "poll"]);
|
||||||
if (gate("reactions")) {
|
if (gate("reactions")) {
|
||||||
@@ -31,16 +33,24 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
|
|||||||
actions.add("unpin");
|
actions.add("unpin");
|
||||||
actions.add("list-pins");
|
actions.add("list-pins");
|
||||||
}
|
}
|
||||||
if (gate("memberInfo")) actions.add("member-info");
|
if (gate("memberInfo")) {
|
||||||
if (gate("channelInfo")) actions.add("channel-info");
|
actions.add("member-info");
|
||||||
|
}
|
||||||
|
if (gate("channelInfo")) {
|
||||||
|
actions.add("channel-info");
|
||||||
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsAction: ({ action }) => action !== "poll",
|
supportsAction: ({ action }) => action !== "poll",
|
||||||
extractToolSend: ({ args }): ChannelToolSend | null => {
|
extractToolSend: ({ args }): ChannelToolSend | null => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||||
if (action !== "sendMessage") return null;
|
if (action !== "sendMessage") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const to = typeof args.to === "string" ? args.to : undefined;
|
const to = typeof args.to === "string" ? args.to : undefined;
|
||||||
if (!to) return null;
|
if (!to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return { to };
|
return { to };
|
||||||
},
|
},
|
||||||
handleAction: async (ctx: ChannelMessageActionContext) => {
|
handleAction: async (ctx: ChannelMessageActionContext) => {
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ const meta = {
|
|||||||
|
|
||||||
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
||||||
let normalized = raw.trim();
|
let normalized = raw.trim();
|
||||||
if (!normalized) return undefined;
|
if (!normalized) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const lowered = normalized.toLowerCase();
|
const lowered = normalized.toLowerCase();
|
||||||
if (lowered.startsWith("matrix:")) {
|
if (lowered.startsWith("matrix:")) {
|
||||||
normalized = normalized.slice("matrix:".length).trim();
|
normalized = normalized.slice("matrix:".length).trim();
|
||||||
@@ -161,7 +163,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
|
'- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.',
|
||||||
];
|
];
|
||||||
@@ -188,8 +192,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (raw) => {
|
looksLikeId: (raw) => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (/^(matrix:)?[!#@]/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return trimmed.includes(":");
|
return trimmed.includes(":");
|
||||||
},
|
},
|
||||||
hint: "<room|alias|user>",
|
hint: "<room|alias|user>",
|
||||||
@@ -204,13 +212,17 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
|
|
||||||
for (const entry of account.config.dm?.allowFrom ?? []) {
|
for (const entry of account.config.dm?.allowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") continue;
|
if (!raw || raw === "*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") continue;
|
if (!raw || raw === "*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +230,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
for (const room of Object.values(groups)) {
|
for (const room of Object.values(groups)) {
|
||||||
for (const entry of room.users ?? []) {
|
for (const entry of room.users ?? []) {
|
||||||
const raw = String(entry).trim();
|
const raw = String(entry).trim();
|
||||||
if (!raw || raw === "*") continue;
|
if (!raw || raw === "*") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
ids.add(raw.replace(/^matrix:/i, ""));
|
ids.add(raw.replace(/^matrix:/i, ""));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +243,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
.map((raw) => {
|
.map((raw) => {
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw;
|
||||||
if (cleaned.startsWith("@")) return `user:${cleaned}`;
|
if (cleaned.startsWith("@")) {
|
||||||
|
return `user:${cleaned}`;
|
||||||
|
}
|
||||||
return cleaned;
|
return cleaned;
|
||||||
})
|
})
|
||||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
@@ -254,8 +270,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
.map((raw) => raw.replace(/^matrix:/i, ""))
|
.map((raw) => raw.replace(/^matrix:/i, ""))
|
||||||
.map((raw) => {
|
.map((raw) => {
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) return raw;
|
if (lowered.startsWith("room:") || lowered.startsWith("channel:")) {
|
||||||
if (raw.startsWith("!")) return `room:${raw}`;
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw.startsWith("!")) {
|
||||||
|
return `room:${raw}`;
|
||||||
|
}
|
||||||
return raw;
|
return raw;
|
||||||
})
|
})
|
||||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
@@ -283,8 +303,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
name,
|
name,
|
||||||
}),
|
}),
|
||||||
validateInput: ({ input }) => {
|
validateInput: ({ input }) => {
|
||||||
if (input.useEnv) return null;
|
if (input.useEnv) {
|
||||||
if (!input.homeserver?.trim()) return "Matrix requires --homeserver";
|
return null;
|
||||||
|
}
|
||||||
|
if (!input.homeserver?.trim()) {
|
||||||
|
return "Matrix requires --homeserver";
|
||||||
|
}
|
||||||
const accessToken = input.accessToken?.trim();
|
const accessToken = input.accessToken?.trim();
|
||||||
const password = input.password?.trim();
|
const password = input.password?.trim();
|
||||||
const userId = input.userId?.trim();
|
const userId = input.userId?.trim();
|
||||||
@@ -292,8 +316,12 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
return "Matrix requires --access-token or --password";
|
return "Matrix requires --access-token or --password";
|
||||||
}
|
}
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
if (!userId) return "Matrix requires --user-id when using --password";
|
if (!userId) {
|
||||||
if (!password) return "Matrix requires --password when using --user-id";
|
return "Matrix requires --user-id when using --password";
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
return "Matrix requires --password when using --user-id";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -338,7 +366,9 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: (accounts) =>
|
||||||
accounts.flatMap((account) => {
|
accounts.flatMap((account) => {
|
||||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
||||||
if (!lastError) return [];
|
if (!lastError) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
channel: "matrix",
|
channel: "matrix",
|
||||||
@@ -358,7 +388,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
probeAccount: async ({ timeoutMs, cfg }) => {
|
||||||
try {
|
try {
|
||||||
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
|
const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig });
|
||||||
return await probeMatrix({
|
return await probeMatrix({
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export async function listMatrixDirectoryPeersLive(params: {
|
|||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
}): Promise<ChannelDirectoryEntry[]> {
|
}): Promise<ChannelDirectoryEntry[]> {
|
||||||
const query = normalizeQuery(params.query);
|
const query = normalizeQuery(params.query);
|
||||||
if (!query) return [];
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||||
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
const res = await fetchMatrixJson<MatrixUserDirectoryResponse>({
|
||||||
homeserver: auth.homeserver,
|
homeserver: auth.homeserver,
|
||||||
@@ -71,7 +73,9 @@ export async function listMatrixDirectoryPeersLive(params: {
|
|||||||
return results
|
return results
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const userId = entry.user_id?.trim();
|
const userId = entry.user_id?.trim();
|
||||||
if (!userId) return null;
|
if (!userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -123,13 +127,17 @@ export async function listMatrixDirectoryGroupsLive(params: {
|
|||||||
limit?: number | null;
|
limit?: number | null;
|
||||||
}): Promise<ChannelDirectoryEntry[]> {
|
}): Promise<ChannelDirectoryEntry[]> {
|
||||||
const query = normalizeQuery(params.query);
|
const query = normalizeQuery(params.query);
|
||||||
if (!query) return [];
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
const auth = await resolveMatrixAuth({ cfg: params.cfg as never });
|
||||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20;
|
||||||
|
|
||||||
if (query.startsWith("#")) {
|
if (query.startsWith("#")) {
|
||||||
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query);
|
||||||
if (!roomId) return [];
|
if (!roomId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: "group",
|
kind: "group",
|
||||||
@@ -160,15 +168,21 @@ export async function listMatrixDirectoryGroupsLive(params: {
|
|||||||
|
|
||||||
for (const roomId of rooms) {
|
for (const roomId of rooms) {
|
||||||
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId);
|
||||||
if (!name) continue;
|
if (!name) {
|
||||||
if (!name.toLowerCase().includes(query)) continue;
|
continue;
|
||||||
|
}
|
||||||
|
if (!name.toLowerCase().includes(query)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
results.push({
|
results.push({
|
||||||
kind: "group",
|
kind: "group",
|
||||||
id: roomId,
|
id: roomId,
|
||||||
name,
|
name,
|
||||||
handle: `#${name}`,
|
handle: `#${name}`,
|
||||||
});
|
});
|
||||||
if (results.length >= limit) break;
|
if (results.length >= limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
|||||||
name: groupChannel || undefined,
|
name: groupChannel || undefined,
|
||||||
}).config;
|
}).config;
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
if (resolved.autoReply === true) return false;
|
if (resolved.autoReply === true) {
|
||||||
if (resolved.autoReply === false) return true;
|
return false;
|
||||||
if (typeof resolved.requireMention === "boolean") return resolved.requireMention;
|
}
|
||||||
|
if (resolved.autoReply === false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof resolved.requireMention === "boolean") {
|
||||||
|
return resolved.requireMention;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export function listMatrixAccountIds(_cfg: CoreConfig): string[] {
|
|||||||
|
|
||||||
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string {
|
||||||
const ids = listMatrixAccountIds(cfg);
|
const ids = listMatrixAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ export function resolveMatrixAccount(params: {
|
|||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
}): ResolvedMatrixAccount {
|
}): ResolvedMatrixAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const base = (params.cfg.channels?.matrix ?? {}) as MatrixConfig;
|
const base = params.cfg.channels?.matrix ?? {};
|
||||||
const enabled = base.enabled !== false;
|
const enabled = base.enabled !== false;
|
||||||
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
const resolved = resolveMatrixConfig(params.cfg, process.env);
|
||||||
const hasHomeserver = Boolean(resolved.homeserver);
|
const hasHomeserver = Boolean(resolved.homeserver);
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ export async function resolveActionClient(
|
|||||||
opts: MatrixActionClientOpts = {},
|
opts: MatrixActionClientOpts = {},
|
||||||
): Promise<MatrixActionClient> {
|
): Promise<MatrixActionClient> {
|
||||||
ensureNodeRuntime();
|
ensureNodeRuntime();
|
||||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
if (opts.client) {
|
||||||
|
return { client: opts.client, stopOnDone: false };
|
||||||
|
}
|
||||||
const active = getActiveMatrixClient();
|
const active = getActiveMatrixClient();
|
||||||
if (active) return { client: active, stopOnDone: false };
|
if (active) {
|
||||||
|
return { client: active, stopOnDone: false };
|
||||||
|
}
|
||||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export async function editMatrixMessage(
|
|||||||
opts: MatrixActionClientOpts = {},
|
opts: MatrixActionClientOpts = {},
|
||||||
) {
|
) {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed) throw new Error("Matrix edit requires content");
|
if (!trimmed) {
|
||||||
|
throw new Error("Matrix edit requires content");
|
||||||
|
}
|
||||||
const { client, stopOnDone } = await resolveActionClient(opts);
|
const { client, stopOnDone } = await resolveActionClient(opts);
|
||||||
try {
|
try {
|
||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
@@ -56,7 +58,9 @@ export async function editMatrixMessage(
|
|||||||
const eventId = await client.sendMessage(resolvedRoom, payload);
|
const eventId = await client.sendMessage(resolvedRoom, payload);
|
||||||
return { eventId: eventId ?? null };
|
return { eventId: eventId ?? null };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +74,9 @@ export async function deleteMatrixMessage(
|
|||||||
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
|
||||||
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
await client.redactEvent(resolvedRoom, messageId, opts.reason);
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +121,8 @@ export async function readMatrixMessages(
|
|||||||
prevBatch: res.start ?? null,
|
prevBatch: res.start ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export async function pinMatrixMessage(
|
|||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +42,9 @@ export async function unpinMatrixMessage(
|
|||||||
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload);
|
||||||
return { pinned: next };
|
return { pinned: next };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +69,8 @@ export async function listMatrixPins(
|
|||||||
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
).filter((event): event is MatrixMessageSummary => Boolean(event));
|
||||||
return { pinned, events };
|
return { pinned, events };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export async function listMatrixReactions(
|
|||||||
for (const event of res.chunk) {
|
for (const event of res.chunk) {
|
||||||
const content = event.content as ReactionEventContent;
|
const content = event.content as ReactionEventContent;
|
||||||
const key = content["m.relates_to"]?.key;
|
const key = content["m.relates_to"]?.key;
|
||||||
if (!key) continue;
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const sender = event.sender ?? "";
|
const sender = event.sender ?? "";
|
||||||
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
const entry: MatrixReactionSummary = summaries.get(key) ?? {
|
||||||
key,
|
key,
|
||||||
@@ -46,7 +48,9 @@ export async function listMatrixReactions(
|
|||||||
}
|
}
|
||||||
return Array.from(summaries.values());
|
return Array.from(summaries.values());
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,21 +68,29 @@ export async function removeMatrixReactions(
|
|||||||
{ dir: "b", limit: 200 },
|
{ dir: "b", limit: 200 },
|
||||||
)) as { chunk: MatrixRawEvent[] };
|
)) as { chunk: MatrixRawEvent[] };
|
||||||
const userId = await client.getUserId();
|
const userId = await client.getUserId();
|
||||||
if (!userId) return { removed: 0 };
|
if (!userId) {
|
||||||
|
return { removed: 0 };
|
||||||
|
}
|
||||||
const targetEmoji = opts.emoji?.trim();
|
const targetEmoji = opts.emoji?.trim();
|
||||||
const toRemove = res.chunk
|
const toRemove = res.chunk
|
||||||
.filter((event) => event.sender === userId)
|
.filter((event) => event.sender === userId)
|
||||||
.filter((event) => {
|
.filter((event) => {
|
||||||
if (!targetEmoji) return true;
|
if (!targetEmoji) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const content = event.content as ReactionEventContent;
|
const content = event.content as ReactionEventContent;
|
||||||
return content["m.relates_to"]?.key === targetEmoji;
|
return content["m.relates_to"]?.key === targetEmoji;
|
||||||
})
|
})
|
||||||
.map((event) => event.event_id)
|
.map((event) => event.event_id)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter((id): id is string => Boolean(id));
|
||||||
if (toRemove.length === 0) return { removed: 0 };
|
if (toRemove.length === 0) {
|
||||||
|
return { removed: 0 };
|
||||||
|
}
|
||||||
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id)));
|
||||||
return { removed: toRemove.length };
|
return { removed: toRemove.length };
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ export async function getMatrixMemberInfo(
|
|||||||
roomId: roomId ?? null,
|
roomId: roomId ?? null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,8 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
|
|||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (stopOnDone) client.stop();
|
if (stopOnDone) {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ export async function fetchEventSummary(
|
|||||||
): Promise<MatrixMessageSummary | null> {
|
): Promise<MatrixMessageSummary | null> {
|
||||||
try {
|
try {
|
||||||
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent;
|
||||||
if (raw.unsigned?.redacted_because) return null;
|
if (raw.unsigned?.redacted_because) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return summarizeMatrixRawEvent(raw);
|
return summarizeMatrixRawEvent(raw);
|
||||||
} catch {
|
} catch {
|
||||||
// Event not found, redacted, or inaccessible - return null
|
// Event not found, redacted, or inaccessible - return null
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
} from "./storage.js";
|
} from "./storage.js";
|
||||||
|
|
||||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||||
if (input == null) return [];
|
if (input == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (!Array.isArray(input)) {
|
if (!Array.isArray(input)) {
|
||||||
LogService.warn(
|
LogService.warn(
|
||||||
"MatrixClientLite",
|
"MatrixClientLite",
|
||||||
|
|||||||
@@ -4,15 +4,21 @@ let matrixSdkLoggingConfigured = false;
|
|||||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||||
|
|
||||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||||
if (module !== "MatrixHttpClient") return false;
|
if (module !== "MatrixHttpClient") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return messageOrObject.some((entry) => {
|
return messageOrObject.some((entry) => {
|
||||||
if (!entry || typeof entry !== "object") return false;
|
if (!entry || typeof entry !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
return (entry as { errcode?: string }).errcode === "M_NOT_FOUND";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureMatrixSdkLoggingConfigured(): void {
|
export function ensureMatrixSdkLoggingConfigured(): void {
|
||||||
if (matrixSdkLoggingConfigured) return;
|
if (matrixSdkLoggingConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
matrixSdkLoggingConfigured = true;
|
matrixSdkLoggingConfigured = true;
|
||||||
|
|
||||||
LogService.setLogger({
|
LogService.setLogger({
|
||||||
@@ -21,7 +27,9 @@ export function ensureMatrixSdkLoggingConfigured(): void {
|
|||||||
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject),
|
||||||
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject),
|
||||||
error: (module, ...messageOrObject) => {
|
error: (module, ...messageOrObject) => {
|
||||||
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) return;
|
if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
matrixSdkBaseLogger.error(module, ...messageOrObject);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ async function ensureSharedClientStarted(params: {
|
|||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
encryption?: boolean;
|
encryption?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (params.state.started) return;
|
if (params.state.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (sharedClientStartPromise) {
|
if (sharedClientStartPromise) {
|
||||||
await sharedClientStartPromise;
|
await sharedClientStartPromise;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ function sanitizePathSegment(value: string): string {
|
|||||||
function resolveHomeserverKey(homeserver: string): string {
|
function resolveHomeserverKey(homeserver: string): string {
|
||||||
try {
|
try {
|
||||||
const url = new URL(homeserver);
|
const url = new URL(homeserver);
|
||||||
if (url.host) return sanitizePathSegment(url.host);
|
if (url.host) {
|
||||||
|
return sanitizePathSegment(url.host);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
@@ -84,8 +86,12 @@ export function maybeMigrateLegacyStorage(params: {
|
|||||||
const hasNewStorage =
|
const hasNewStorage =
|
||||||
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
|
fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath);
|
||||||
|
|
||||||
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
if (!hasLegacyStorage && !hasLegacyCrypto) {
|
||||||
if (hasNewStorage) return;
|
return;
|
||||||
|
}
|
||||||
|
if (hasNewStorage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
if (hasLegacyStorage) {
|
if (hasLegacyStorage) {
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export function loadMatrixCredentials(
|
|||||||
): MatrixStoredCredentials | null {
|
): MatrixStoredCredentials | null {
|
||||||
const credPath = resolveMatrixCredentialsPath(env);
|
const credPath = resolveMatrixCredentialsPath(env);
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(credPath)) return null;
|
if (!fs.existsSync(credPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const raw = fs.readFileSync(credPath, "utf-8");
|
const raw = fs.readFileSync(credPath, "utf-8");
|
||||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||||
if (
|
if (
|
||||||
@@ -72,7 +74,9 @@ export function saveMatrixCredentials(
|
|||||||
|
|
||||||
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void {
|
||||||
const existing = loadMatrixCredentials(env);
|
const existing = loadMatrixCredentials(env);
|
||||||
if (!existing) return;
|
if (!existing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
existing.lastUsedAt = new Date().toISOString();
|
existing.lastUsedAt = new Date().toISOString();
|
||||||
const credPath = resolveMatrixCredentialsPath(env);
|
const credPath = resolveMatrixCredentialsPath(env);
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export async function ensureMatrixSdkInstalled(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
confirm?: (message: string) => Promise<boolean>;
|
confirm?: (message: string) => Promise<boolean>;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (isMatrixSdkAvailable()) return;
|
if (isMatrixSdkAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const confirm = params.confirm;
|
const confirm = params.confirm;
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export function resolveMatrixAllowListMatch(params: {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
}): MatrixAllowListMatch {
|
}): MatrixAllowListMatch {
|
||||||
const allowList = params.allowList;
|
const allowList = params.allowList;
|
||||||
if (allowList.length === 0) return { allowed: false };
|
if (allowList.length === 0) {
|
||||||
|
return { allowed: false };
|
||||||
|
}
|
||||||
if (allowList.includes("*")) {
|
if (allowList.includes("*")) {
|
||||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,9 @@ export function resolveMatrixAllowListMatch(params: {
|
|||||||
{ value: localPart, source: "localpart" },
|
{ value: localPart, source: "localpart" },
|
||||||
];
|
];
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!candidate.value) continue;
|
if (!candidate.value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (allowList.includes(candidate.value)) {
|
if (allowList.includes(candidate.value)) {
|
||||||
return {
|
return {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
const { client, cfg, runtime } = params;
|
const { client, cfg, runtime } = params;
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
const logVerbose = (message: string) => {
|
const logVerbose = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) return;
|
if (!core.logging.shouldLogVerbose()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
runtime.log?.(message);
|
runtime.log?.(message);
|
||||||
};
|
};
|
||||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||||
@@ -32,7 +34,9 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
|
|
||||||
// For "allowlist" mode, handle invites manually
|
// For "allowlist" mode, handle invites manually
|
||||||
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
|
||||||
if (autoJoin !== "allowlist") return;
|
if (autoJoin !== "allowlist") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get room alias if available
|
// Get room alias if available
|
||||||
let alias: string | undefined;
|
let alias: string | undefined;
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|||||||
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||||
|
|
||||||
const ensureSelfUserId = async (): Promise<string | null> => {
|
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||||
if (cachedSelfUserId) return cachedSelfUserId;
|
if (cachedSelfUserId) {
|
||||||
|
return cachedSelfUserId;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
cachedSelfUserId = await client.getUserId();
|
cachedSelfUserId = await client.getUserId();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -30,7 +32,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|||||||
|
|
||||||
const refreshDmCache = async (): Promise<void> => {
|
const refreshDmCache = async (): Promise<void> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) return;
|
if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
lastDmUpdateMs = now;
|
lastDmUpdateMs = now;
|
||||||
try {
|
try {
|
||||||
await client.dms.update();
|
await client.dms.update();
|
||||||
@@ -58,7 +62,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|||||||
|
|
||||||
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
|
||||||
const target = userId?.trim();
|
const target = userId?.trim();
|
||||||
if (!target) return false;
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
|
||||||
return state?.is_direct === true;
|
return state?.is_direct === true;
|
||||||
|
|||||||
@@ -126,15 +126,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
const isLocationEvent =
|
const isLocationEvent =
|
||||||
eventType === EventType.Location ||
|
eventType === EventType.Location ||
|
||||||
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
(eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
|
||||||
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) return;
|
if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
`matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
|
||||||
);
|
);
|
||||||
if (event.unsigned?.redacted_because) return;
|
if (event.unsigned?.redacted_because) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const senderId = event.sender;
|
const senderId = event.sender;
|
||||||
if (!senderId) return;
|
if (!senderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selfUserId = await client.getUserId();
|
const selfUserId = await client.getUserId();
|
||||||
if (senderId === selfUserId) return;
|
if (senderId === selfUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const eventTs = event.origin_server_ts;
|
const eventTs = event.origin_server_ts;
|
||||||
const eventAge = event.unsigned?.age;
|
const eventAge = event.unsigned?.age;
|
||||||
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
|
||||||
@@ -179,7 +187,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
|
|
||||||
const relates = content["m.relates_to"];
|
const relates = content["m.relates_to"];
|
||||||
if (relates && "rel_type" in relates) {
|
if (relates && "rel_type" in relates) {
|
||||||
if (relates.rel_type === RelationType.Replace) return;
|
if (relates.rel_type === RelationType.Replace) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDirectMessage = await directTracker.isDirectMessage({
|
const isDirectMessage = await directTracker.isDirectMessage({
|
||||||
@@ -189,7 +199,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
});
|
});
|
||||||
const isRoom = !isDirectMessage;
|
const isRoom = !isDirectMessage;
|
||||||
|
|
||||||
if (isRoom && groupPolicy === "disabled") return;
|
if (isRoom && groupPolicy === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const roomConfigInfo = isRoom
|
const roomConfigInfo = isRoom
|
||||||
? resolveMatrixRoomConfig({
|
? resolveMatrixRoomConfig({
|
||||||
@@ -234,7 +246,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
const groupAllowConfigured = effectiveGroupAllowFrom.length > 0;
|
||||||
|
|
||||||
if (isDirectMessage) {
|
if (isDirectMessage) {
|
||||||
if (!dmEnabled || dmPolicy === "disabled") return;
|
if (!dmEnabled || dmPolicy === "disabled") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const allowMatch = resolveMatrixAllowListMatch({
|
const allowMatch = resolveMatrixAllowListMatch({
|
||||||
allowList: effectiveAllowFrom,
|
allowList: effectiveAllowFrom,
|
||||||
@@ -356,7 +370,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bodyText = rawBody || media?.placeholder || "";
|
const bodyText = rawBody || media?.placeholder || "";
|
||||||
if (!bodyText) return;
|
if (!bodyText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
const { wasMentioned, hasExplicitMention } = resolveMentions({
|
||||||
content,
|
content,
|
||||||
@@ -497,7 +513,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
MediaType: media?.contentType,
|
MediaType: media?.contentType,
|
||||||
MediaUrl: media?.path,
|
MediaUrl: media?.path,
|
||||||
...(locationPayload?.context ?? {}),
|
...locationPayload?.context,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
CommandSource: "text" as const,
|
CommandSource: "text" as const,
|
||||||
OriginatingChannel: "matrix" as const,
|
OriginatingChannel: "matrix" as const,
|
||||||
@@ -633,7 +649,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
if (!queuedFinal) return;
|
if (!queuedFinal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
didSendReply = true;
|
didSendReply = true;
|
||||||
const finalCount = counts.final;
|
const finalCount = counts.final;
|
||||||
logVerboseMessage(
|
logVerboseMessage(
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
const core = getMatrixRuntime();
|
const core = getMatrixRuntime();
|
||||||
let cfg = core.config.loadConfig() as CoreConfig;
|
let cfg = core.config.loadConfig() as CoreConfig;
|
||||||
if (cfg.channels?.matrix?.enabled === false) return;
|
if (cfg.channels?.matrix?.enabled === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
|
||||||
@@ -50,7 +52,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const logVerboseMessage = (message: string) => {
|
const logVerboseMessage = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) return;
|
if (!core.logging.shouldLogVerbose()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.debug(message);
|
logger.debug(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +119,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
const pending: Array<{ input: string; query: string }> = [];
|
const pending: Array<{ input: string; query: string }> = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const cleaned = normalizeRoomEntry(trimmed);
|
const cleaned = normalizeRoomEntry(trimmed);
|
||||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||||
if (!nextRooms[cleaned]) {
|
if (!nextRooms[cleaned]) {
|
||||||
@@ -135,7 +141,9 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
});
|
});
|
||||||
resolved.forEach((entry, index) => {
|
resolved.forEach((entry, index) => {
|
||||||
const source = pending[index];
|
const source = pending[index];
|
||||||
if (!source) return;
|
if (!source) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (entry.resolved && entry.id) {
|
if (entry.resolved && entry.id) {
|
||||||
if (!nextRooms[entry.id]) {
|
if (!nextRooms[entry.id]) {
|
||||||
nextRooms[entry.id] = roomsConfig[source.input];
|
nextRooms[entry.id] = roomsConfig[source.input];
|
||||||
|
|||||||
@@ -20,25 +20,37 @@ type GeoUriParams = {
|
|||||||
|
|
||||||
function parseGeoUri(value: string): GeoUriParams | null {
|
function parseGeoUri(value: string): GeoUriParams | null {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) {
|
||||||
if (!trimmed.toLowerCase().startsWith("geo:")) return null;
|
return null;
|
||||||
|
}
|
||||||
|
if (!trimmed.toLowerCase().startsWith("geo:")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const payload = trimmed.slice(4);
|
const payload = trimmed.slice(4);
|
||||||
const [coordsPart, ...paramParts] = payload.split(";");
|
const [coordsPart, ...paramParts] = payload.split(";");
|
||||||
const coords = coordsPart.split(",");
|
const coords = coordsPart.split(",");
|
||||||
if (coords.length < 2) return null;
|
if (coords.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const latitude = Number.parseFloat(coords[0] ?? "");
|
const latitude = Number.parseFloat(coords[0] ?? "");
|
||||||
const longitude = Number.parseFloat(coords[1] ?? "");
|
const longitude = Number.parseFloat(coords[1] ?? "");
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const params = new Map<string, string>();
|
const params = new Map<string, string>();
|
||||||
for (const part of paramParts) {
|
for (const part of paramParts) {
|
||||||
const segment = part.trim();
|
const segment = part.trim();
|
||||||
if (!segment) continue;
|
if (!segment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const eqIndex = segment.indexOf("=");
|
const eqIndex = segment.indexOf("=");
|
||||||
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex);
|
||||||
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1);
|
||||||
const key = rawKey.trim().toLowerCase();
|
const key = rawKey.trim().toLowerCase();
|
||||||
if (!key) continue;
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const valuePart = rawValue.trim();
|
const valuePart = rawValue.trim();
|
||||||
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
params.set(key, valuePart ? decodeURIComponent(valuePart) : "");
|
||||||
}
|
}
|
||||||
@@ -61,11 +73,17 @@ export function resolveMatrixLocation(params: {
|
|||||||
const isLocation =
|
const isLocation =
|
||||||
eventType === EventType.Location ||
|
eventType === EventType.Location ||
|
||||||
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
(eventType === EventType.RoomMessage && content.msgtype === EventType.Location);
|
||||||
if (!isLocation) return null;
|
if (!isLocation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : "";
|
||||||
if (!geoUri) return null;
|
if (!geoUri) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const parsed = parseGeoUri(geoUri);
|
const parsed = parseGeoUri(geoUri);
|
||||||
if (!parsed) return null;
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
const caption = typeof content.body === "string" ? content.body.trim() : "";
|
||||||
const location: NormalizedLocation = {
|
const location: NormalizedLocation = {
|
||||||
latitude: parsed.latitude,
|
latitude: parsed.latitude,
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
|
||||||
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
|
||||||
const url = params.client.mxcToHttp(params.mxcUrl);
|
const url = params.client.mxcToHttp(params.mxcUrl);
|
||||||
if (!url) return null;
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Use the client's download method which handles auth
|
// Use the client's download method which handles auth
|
||||||
try {
|
try {
|
||||||
@@ -34,7 +36,7 @@ async function fetchMatrixMediaBuffer(params: {
|
|||||||
}
|
}
|
||||||
return { buffer: Buffer.from(buffer) };
|
return { buffer: Buffer.from(buffer) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(`Matrix media download failed: ${String(err)}`);
|
throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +96,9 @@ export async function downloadMatrixMedia(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fetched) return null;
|
if (!fetched) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ export async function deliverMatrixReplies(params: {
|
|||||||
chunkMode,
|
chunkMode,
|
||||||
)) {
|
)) {
|
||||||
const trimmed = chunk.trim();
|
const trimmed = chunk.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await sendMessageMatrix(params.roomId, trimmed, {
|
await sendMessageMatrix(params.roomId, trimmed, {
|
||||||
client: params.client,
|
client: params.client,
|
||||||
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
|
|||||||
|
|
||||||
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
|
const getRoomInfo = async (roomId: string): Promise<MatrixRoomInfo> => {
|
||||||
const cached = roomInfoCache.get(roomId);
|
const cached = roomInfoCache.get(roomId);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
let name: string | undefined;
|
let name: string | undefined;
|
||||||
let canonicalAlias: string | undefined;
|
let canonicalAlias: string | undefined;
|
||||||
let altAliases: string[] = [];
|
let altAliases: string[] = [];
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export function resolveMatrixThreadTarget(params: {
|
|||||||
isThreadRoot?: boolean;
|
isThreadRoot?: boolean;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const { threadReplies, messageId, threadRootId } = params;
|
const { threadReplies, messageId, threadRootId } = params;
|
||||||
if (threadReplies === "off") return undefined;
|
if (threadReplies === "off") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const isThreadRoot = params.isThreadRoot === true;
|
const isThreadRoot = params.isThreadRoot === true;
|
||||||
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
|
const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot);
|
||||||
if (threadReplies === "inbound") {
|
if (threadReplies === "inbound") {
|
||||||
@@ -45,7 +47,9 @@ export function resolveMatrixThreadRootId(params: {
|
|||||||
content: RoomMessageEventContent;
|
content: RoomMessageEventContent;
|
||||||
}): string | undefined {
|
}): string | undefined {
|
||||||
const relates = params.content["m.relates_to"];
|
const relates = params.content["m.relates_to"];
|
||||||
if (!relates || typeof relates !== "object") return undefined;
|
if (!relates || typeof relates !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
if ("rel_type" in relates && relates.rel_type === RelationType.Thread) {
|
||||||
if ("event_id" in relates && typeof relates.event_id === "string") {
|
if ("event_id" in relates && typeof relates.event_id === "string") {
|
||||||
return relates.event_id;
|
return relates.event_id;
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export function isPollStartType(eventType: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getTextContent(text?: TextContent): string {
|
export function getTextContent(text?: TextContent): string {
|
||||||
if (!text) return "";
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,10 +88,14 @@ export function parsePollStartContent(content: PollStartContent): PollSummary |
|
|||||||
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
(content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] ??
|
||||||
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
|
(content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START] ??
|
||||||
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
(content as Record<string, PollStartSubtype | undefined>)["m.poll"];
|
||||||
if (!poll) return null;
|
if (!poll) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const question = getTextContent(poll.question);
|
const question = getTextContent(poll.question);
|
||||||
if (!question) return null;
|
if (!question) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const answers = poll.answers
|
const answers = poll.answers
|
||||||
.map((answer) => getTextContent(answer))
|
.map((answer) => getTextContent(answer))
|
||||||
@@ -125,7 +131,9 @@ function buildTextContent(body: string): TextContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPollFallbackText(question: string, answers: string[]): string {
|
function buildPollFallbackText(question: string, answers: string[]): string {
|
||||||
if (answers.length === 0) return question;
|
if (answers.length === 0) {
|
||||||
|
return question;
|
||||||
|
}
|
||||||
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ export async function sendMessageMatrix(
|
|||||||
const followupRelation = threadId ? relation : undefined;
|
const followupRelation = threadId ? relation : undefined;
|
||||||
for (const chunk of textChunks) {
|
for (const chunk of textChunks) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const followup = buildTextContent(text, followupRelation);
|
const followup = buildTextContent(text, followupRelation);
|
||||||
const followupEventId = await sendContent(followup);
|
const followupEventId = await sendContent(followup);
|
||||||
lastMessageId = followupEventId ?? lastMessageId;
|
lastMessageId = followupEventId ?? lastMessageId;
|
||||||
@@ -131,7 +133,9 @@ export async function sendMessageMatrix(
|
|||||||
} else {
|
} else {
|
||||||
for (const chunk of chunks.length ? chunks : [""]) {
|
for (const chunk of chunks.length ? chunks : [""]) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const content = buildTextContent(text, relation);
|
const content = buildTextContent(text, relation);
|
||||||
const eventId = await sendContent(content);
|
const eventId = await sendContent(content);
|
||||||
lastMessageId = eventId ?? lastMessageId;
|
lastMessageId = eventId ?? lastMessageId;
|
||||||
@@ -211,7 +215,9 @@ export async function sendReadReceiptMatrix(
|
|||||||
eventId: string,
|
eventId: string,
|
||||||
client?: MatrixClient,
|
client?: MatrixClient,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!eventId?.trim()) return;
|
if (!eventId?.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
const { client: resolved, stopOnDone } = await resolveMatrixClient({
|
||||||
client,
|
client,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ export async function resolveMatrixClient(opts: {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
}): Promise<{ client: MatrixClient; stopOnDone: boolean }> {
|
||||||
ensureNodeRuntime();
|
ensureNodeRuntime();
|
||||||
if (opts.client) return { client: opts.client, stopOnDone: false };
|
if (opts.client) {
|
||||||
|
return { client: opts.client, stopOnDone: false };
|
||||||
|
}
|
||||||
const active = getActiveMatrixClient();
|
const active = getActiveMatrixClient();
|
||||||
if (active) return { client: active, stopOnDone: false };
|
if (active) {
|
||||||
|
return { client: active, stopOnDone: false };
|
||||||
|
}
|
||||||
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT);
|
||||||
if (shouldShareClient) {
|
if (shouldShareClient) {
|
||||||
const client = await resolveSharedMatrixClient({
|
const client = await resolveSharedMatrixClient({
|
||||||
|
|||||||
@@ -30,14 +30,18 @@ export function buildTextContent(body: string, relation?: MatrixRelation): Matri
|
|||||||
|
|
||||||
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
|
||||||
const formatted = markdownToMatrixHtml(body ?? "");
|
const formatted = markdownToMatrixHtml(body ?? "");
|
||||||
if (!formatted) return;
|
if (!formatted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
content.format = "org.matrix.custom.html";
|
content.format = "org.matrix.custom.html";
|
||||||
content.formatted_body = formatted;
|
content.formatted_body = formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {
|
||||||
const trimmed = replyToId?.trim();
|
const trimmed = replyToId?.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return { "m.in_reply_to": { event_id: trimmed } };
|
return { "m.in_reply_to": { event_id: trimmed } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +74,9 @@ export function resolveMatrixVoiceDecision(opts: {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
}): { useVoice: boolean } {
|
}): { useVoice: boolean } {
|
||||||
if (!opts.wantsVoice) return { useVoice: false };
|
if (!opts.wantsVoice) {
|
||||||
|
return { useVoice: false };
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
getCore().media.isVoiceCompatibleAudio({
|
getCore().media.isVoiceCompatibleAudio({
|
||||||
contentType: opts.contentType,
|
contentType: opts.contentType,
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function buildMatrixMediaInfo(params: {
|
|||||||
};
|
};
|
||||||
return timedInfo;
|
return timedInfo;
|
||||||
}
|
}
|
||||||
if (Object.keys(base).length === 0) return undefined;
|
if (Object.keys(base).length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +118,9 @@ export async function prepareImageInfo(params: {
|
|||||||
const meta = await getCore()
|
const meta = await getCore()
|
||||||
.media.getImageMetadata(params.buffer)
|
.media.getImageMetadata(params.buffer)
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (!meta) return undefined;
|
if (!meta) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height };
|
||||||
const maxDim = Math.max(meta.width, meta.height);
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||||
@@ -157,7 +161,9 @@ export async function resolveMediaDurationMs(params: {
|
|||||||
fileName?: string;
|
fileName?: string;
|
||||||
kind: MediaKind;
|
kind: MediaKind;
|
||||||
}): Promise<number | undefined> {
|
}): Promise<number | undefined> {
|
||||||
if (params.kind !== "audio" && params.kind !== "video") return undefined;
|
if (params.kind !== "audio" && params.kind !== "video") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fileInfo: IFileInfo | string | undefined =
|
const fileInfo: IFileInfo | string | undefined =
|
||||||
params.contentType || params.fileName
|
params.contentType || params.fileName
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ describe("resolveMatrixRoomId", () => {
|
|||||||
const roomId = await resolveMatrixRoomId(client, userId);
|
const roomId = await resolveMatrixRoomId(client, userId);
|
||||||
|
|
||||||
expect(roomId).toBe("!room:example.org");
|
expect(roomId).toBe("!room:example.org");
|
||||||
|
// oxlint-disable-next-line typescript/unbound-method
|
||||||
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
expect(client.getJoinedRooms).not.toHaveBeenCalled();
|
||||||
|
// oxlint-disable-next-line typescript/unbound-method
|
||||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ function normalizeTarget(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeThreadId(raw?: string | number | null): string | null {
|
export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||||
if (raw === undefined || raw === null) return null;
|
if (raw === undefined || raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const trimmed = String(raw).trim();
|
const trimmed = String(raw).trim();
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
@@ -25,15 +27,15 @@ async function persistDirectRoom(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let directContent: MatrixDirectAccountData | null = null;
|
let directContent: MatrixDirectAccountData | null = null;
|
||||||
try {
|
try {
|
||||||
directContent = (await client.getAccountData(
|
directContent = await client.getAccountData(EventType.Direct);
|
||||||
EventType.Direct,
|
|
||||||
)) as MatrixDirectAccountData | null;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore fetch errors and fall back to an empty map.
|
// Ignore fetch errors and fall back to an empty map.
|
||||||
}
|
}
|
||||||
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
|
const existing = directContent && !Array.isArray(directContent) ? directContent : {};
|
||||||
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
|
||||||
if (current[0] === roomId) return;
|
if (current[0] === roomId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
const next = [roomId, ...current.filter((id) => id !== roomId)];
|
||||||
try {
|
try {
|
||||||
await client.setAccountData(EventType.Direct, {
|
await client.setAccountData(EventType.Direct, {
|
||||||
@@ -52,13 +54,13 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cached = directRoomCache.get(trimmed);
|
const cached = directRoomCache.get(trimmed);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
|
||||||
try {
|
try {
|
||||||
const directContent = (await client.getAccountData(
|
const directContent = await client.getAccountData(EventType.Direct);
|
||||||
EventType.Direct,
|
|
||||||
)) as MatrixDirectAccountData | null;
|
|
||||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
directRoomCache.set(trimmed, list[0]);
|
directRoomCache.set(trimmed, list[0]);
|
||||||
@@ -80,7 +82,9 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!members.includes(trimmed)) continue;
|
if (!members.includes(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||||
if (members.length === 2) {
|
if (members.length === 2) {
|
||||||
directRoomCache.set(trimmed, roomId);
|
directRoomCache.set(trimmed, roomId);
|
||||||
|
|||||||
@@ -249,8 +249,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
initialValue: existing.homeserver ?? envHomeserver,
|
initialValue: existing.homeserver ?? envHomeserver,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) return "Required";
|
if (!raw) {
|
||||||
if (!/^https?:\/\//i.test(raw)) return "Use a full URL (https://...)";
|
return "Required";
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(raw)) {
|
||||||
|
return "Use a full URL (https://...)";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -274,13 +278,13 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
|
|
||||||
if (!accessToken && !password) {
|
if (!accessToken && !password) {
|
||||||
// Ask auth method FIRST before asking for user ID
|
// Ask auth method FIRST before asking for user ID
|
||||||
const authMode = (await prompter.select({
|
const authMode = await prompter.select({
|
||||||
message: "Matrix auth method",
|
message: "Matrix auth method",
|
||||||
options: [
|
options: [
|
||||||
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
{ value: "token", label: "Access token (user ID fetched automatically)" },
|
||||||
{ value: "password", label: "Password (requires user ID)" },
|
{ value: "password", label: "Password (requires user ID)" },
|
||||||
],
|
],
|
||||||
})) as "token" | "password";
|
});
|
||||||
|
|
||||||
if (authMode === "token") {
|
if (authMode === "token") {
|
||||||
accessToken = String(
|
accessToken = String(
|
||||||
@@ -300,9 +304,15 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
initialValue: existing.userId ?? envUserId,
|
initialValue: existing.userId ?? envUserId,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const raw = String(value ?? "").trim();
|
const raw = String(value ?? "").trim();
|
||||||
if (!raw) return "Required";
|
if (!raw) {
|
||||||
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
|
return "Required";
|
||||||
if (!raw.includes(":")) return "Matrix user IDs should include a server (:server)";
|
}
|
||||||
|
if (!raw.startsWith("@")) {
|
||||||
|
return "Matrix user IDs should start with @";
|
||||||
|
}
|
||||||
|
if (!raw.includes(":")) {
|
||||||
|
return "Matrix user IDs should include a server (:server)";
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -370,7 +380,9 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
const unresolved: string[] = [];
|
const unresolved: string[] = [];
|
||||||
for (const entry of accessConfig.entries) {
|
for (const entry of accessConfig.entries) {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
|
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
|
||||||
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
if (cleaned.startsWith("!") && cleaned.includes(":")) {
|
||||||
resolvedIds.push(cleaned);
|
resolvedIds.push(cleaned);
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ function pickBestGroupMatch(
|
|||||||
matches: ChannelDirectoryEntry[],
|
matches: ChannelDirectoryEntry[],
|
||||||
query: string,
|
query: string,
|
||||||
): ChannelDirectoryEntry | undefined {
|
): ChannelDirectoryEntry | undefined {
|
||||||
if (matches.length === 0) return undefined;
|
if (matches.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const normalized = query.trim().toLowerCase();
|
const normalized = query.trim().toLowerCase();
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
const exact = matches.find((match) => {
|
const exact = matches.find((match) => {
|
||||||
@@ -20,7 +22,9 @@ function pickBestGroupMatch(
|
|||||||
const id = match.id.trim().toLowerCase();
|
const id = match.id.trim().toLowerCase();
|
||||||
return name === normalized || handle === normalized || id === normalized;
|
return name === normalized || handle === normalized || id === normalized;
|
||||||
});
|
});
|
||||||
if (exact) return exact;
|
if (exact) {
|
||||||
|
return exact;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return matches[0];
|
return matches[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
|||||||
|
|
||||||
function readRoomId(params: Record<string, unknown>, required = true): string {
|
function readRoomId(params: Record<string, unknown>, required = true): string {
|
||||||
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
|
||||||
if (direct) return direct;
|
if (direct) {
|
||||||
if (!required) return readStringParam(params, "to") ?? "";
|
return direct;
|
||||||
|
}
|
||||||
|
if (!required) {
|
||||||
|
return readStringParam(params, "to") ?? "";
|
||||||
|
}
|
||||||
return readStringParam(params, "to", { required: true });
|
return readStringParam(params, "to", { required: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ describe("mattermostPlugin", () => {
|
|||||||
describe("messaging", () => {
|
describe("messaging", () => {
|
||||||
it("keeps @username targets", () => {
|
it("keeps @username targets", () => {
|
||||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||||
if (!normalize) return;
|
if (!normalize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
expect(normalize("@Alice")).toBe("@Alice");
|
expect(normalize("@Alice")).toBe("@Alice");
|
||||||
expect(normalize("@alice")).toBe("@alice");
|
expect(normalize("@alice")).toBe("@alice");
|
||||||
@@ -14,7 +16,9 @@ describe("mattermostPlugin", () => {
|
|||||||
|
|
||||||
it("normalizes mattermost: prefix to user:", () => {
|
it("normalizes mattermost: prefix to user:", () => {
|
||||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||||
if (!normalize) return;
|
if (!normalize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||||
});
|
});
|
||||||
@@ -23,7 +27,9 @@ describe("mattermostPlugin", () => {
|
|||||||
describe("pairing", () => {
|
describe("pairing", () => {
|
||||||
it("normalizes allowlist entries", () => {
|
it("normalizes allowlist entries", () => {
|
||||||
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||||
if (!normalize) return;
|
if (!normalize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
expect(normalize("@Alice")).toBe("alice");
|
expect(normalize("@Alice")).toBe("alice");
|
||||||
expect(normalize("user:USER123")).toBe("user123");
|
expect(normalize("user:USER123")).toBe("user123");
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ function normalizeAllowEntry(entry: string): string {
|
|||||||
|
|
||||||
function formatAllowEntry(entry: string): string {
|
function formatAllowEntry(entry: string): string {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
if (trimmed.startsWith("@")) {
|
if (trimmed.startsWith("@")) {
|
||||||
const username = trimmed.slice(1).trim();
|
const username = trimmed.slice(1).trim();
|
||||||
return username ? `@${username.toLowerCase()}` : "";
|
return username ? `@${username.toLowerCase()}` : "";
|
||||||
@@ -134,7 +136,9 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
collectWarnings: ({ account, cfg }) => {
|
collectWarnings: ({ account, cfg }) => {
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
`- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export function resolveMattermostGroupRequireMention(
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
if (typeof account.requireMention === "boolean") {
|
||||||
|
return account.requireMention;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,19 +26,25 @@ export type ResolvedMattermostAccount = {
|
|||||||
|
|
||||||
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const accounts = cfg.channels?.mattermost?.accounts;
|
const accounts = cfg.channels?.mattermost?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return [];
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return Object.keys(accounts).filter(Boolean);
|
return Object.keys(accounts).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
||||||
const ids = listConfiguredAccountIds(cfg);
|
const ids = listConfiguredAccountIds(cfg);
|
||||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
if (ids.length === 0) {
|
||||||
return ids.sort((a, b) => a.localeCompare(b));
|
return [DEFAULT_ACCOUNT_ID];
|
||||||
|
}
|
||||||
|
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
||||||
const ids = listMattermostAccountIds(cfg);
|
const ids = listMattermostAccountIds(cfg);
|
||||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||||
|
return DEFAULT_ACCOUNT_ID;
|
||||||
|
}
|
||||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +53,9 @@ function resolveAccountConfig(
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
): MattermostAccountConfig | undefined {
|
): MattermostAccountConfig | undefined {
|
||||||
const accounts = cfg.channels?.mattermost?.accounts;
|
const accounts = cfg.channels?.mattermost?.accounts;
|
||||||
if (!accounts || typeof accounts !== "object") return undefined;
|
if (!accounts || typeof accounts !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return accounts[accountId] as MattermostAccountConfig | undefined;
|
return accounts[accountId] as MattermostAccountConfig | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,9 +70,15 @@ function mergeMattermostAccountConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||||
if (config.chatmode === "oncall") return true;
|
if (config.chatmode === "oncall") {
|
||||||
if (config.chatmode === "onmessage") return false;
|
return true;
|
||||||
if (config.chatmode === "onchar") return true;
|
}
|
||||||
|
if (config.chatmode === "onmessage") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (config.chatmode === "onchar") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return config.requireMention;
|
return config.requireMention;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,14 +42,18 @@ export type MattermostFileInfo = {
|
|||||||
|
|
||||||
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
||||||
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
||||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
if (!normalized) {
|
||||||
|
throw new Error("Mattermost baseUrl is required");
|
||||||
|
}
|
||||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||||
return `${normalized}/api/v4${suffix}`;
|
return `${normalized}/api/v4${suffix}`;
|
||||||
}
|
}
|
||||||
@@ -58,7 +62,9 @@ async function readMattermostError(res: Response): Promise<string> {
|
|||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
const data = (await res.json()) as { message?: string } | undefined;
|
const data = (await res.json()) as { message?: string } | undefined;
|
||||||
if (data?.message) return data.message;
|
if (data?.message) {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
return JSON.stringify(data);
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
return await res.text();
|
return await res.text();
|
||||||
@@ -70,7 +76,9 @@ export function createMattermostClient(params: {
|
|||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
}): MattermostClient {
|
}): MattermostClient {
|
||||||
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
||||||
if (!baseUrl) throw new Error("Mattermost baseUrl is required");
|
if (!baseUrl) {
|
||||||
|
throw new Error("Mattermost baseUrl is required");
|
||||||
|
}
|
||||||
const apiBaseUrl = `${baseUrl}/api/v4`;
|
const apiBaseUrl = `${baseUrl}/api/v4`;
|
||||||
const token = params.botToken.trim();
|
const token = params.botToken.trim();
|
||||||
const fetchImpl = params.fetchImpl ?? fetch;
|
const fetchImpl = params.fetchImpl ?? fetch;
|
||||||
@@ -128,7 +136,9 @@ export async function sendMattermostTyping(
|
|||||||
channel_id: params.channelId,
|
channel_id: params.channelId,
|
||||||
};
|
};
|
||||||
const parentId = params.parentId?.trim();
|
const parentId = params.parentId?.trim();
|
||||||
if (parentId) payload.parent_id = parentId;
|
if (parentId) {
|
||||||
|
payload.parent_id = parentId;
|
||||||
|
}
|
||||||
await client.request<Record<string, unknown>>("/users/me/typing", {
|
await client.request<Record<string, unknown>>("/users/me/typing", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -158,7 +168,9 @@ export async function createMattermostPost(
|
|||||||
channel_id: params.channelId,
|
channel_id: params.channelId,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
};
|
};
|
||||||
if (params.rootId) payload.root_id = params.rootId;
|
if (params.rootId) {
|
||||||
|
payload.root_id = params.rootId;
|
||||||
|
}
|
||||||
if (params.fileIds?.length) {
|
if (params.fileIds?.length) {
|
||||||
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function formatInboundFromLabel(params: {
|
|||||||
|
|
||||||
const directLabel = params.directLabel.trim();
|
const directLabel = params.directLabel.trim();
|
||||||
const directId = params.directId?.trim();
|
const directId = params.directId?.trim();
|
||||||
if (!directId || directId === directLabel) return directLabel;
|
if (!directId || directId === directLabel) {
|
||||||
|
return directLabel;
|
||||||
|
}
|
||||||
return `${directLabel} id:${directId}`;
|
return `${directLabel} id:${directId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +69,18 @@ export function createDedupeCache(options: { ttlMs: number; maxSize: number }):
|
|||||||
}
|
}
|
||||||
while (cache.size > maxSize) {
|
while (cache.size > maxSize) {
|
||||||
const oldestKey = cache.keys().next().value as string | undefined;
|
const oldestKey = cache.keys().next().value as string | undefined;
|
||||||
if (!oldestKey) break;
|
if (!oldestKey) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
cache.delete(oldestKey);
|
cache.delete(oldestKey);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
check: (key, now = Date.now()) => {
|
check: (key, now = Date.now()) => {
|
||||||
if (!key) return false;
|
if (!key) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const existing = cache.get(key);
|
const existing = cache.get(key);
|
||||||
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
||||||
touch(key, now);
|
touch(key, now);
|
||||||
@@ -91,9 +97,15 @@ export function rawDataToString(
|
|||||||
data: WebSocket.RawData,
|
data: WebSocket.RawData,
|
||||||
encoding: BufferEncoding = "utf8",
|
encoding: BufferEncoding = "utf8",
|
||||||
): string {
|
): string {
|
||||||
if (typeof data === "string") return data;
|
if (typeof data === "string") {
|
||||||
if (Buffer.isBuffer(data)) return data.toString(encoding);
|
return data;
|
||||||
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
|
}
|
||||||
|
if (Buffer.isBuffer(data)) {
|
||||||
|
return data.toString(encoding);
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return Buffer.concat(data).toString(encoding);
|
||||||
|
}
|
||||||
if (data instanceof ArrayBuffer) {
|
if (data instanceof ArrayBuffer) {
|
||||||
return Buffer.from(data).toString(encoding);
|
return Buffer.from(data).toString(encoding);
|
||||||
}
|
}
|
||||||
@@ -102,8 +114,12 @@ export function rawDataToString(
|
|||||||
|
|
||||||
function normalizeAgentId(value: string | undefined | null): string {
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
const trimmed = (value ?? "").trim();
|
const trimmed = (value ?? "").trim();
|
||||||
if (!trimmed) return "main";
|
if (!trimmed) {
|
||||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
return "main";
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
trimmed
|
trimmed
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -118,7 +134,9 @@ type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[num
|
|||||||
|
|
||||||
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
||||||
const list = cfg.agents?.list;
|
const list = cfg.agents?.list;
|
||||||
if (!Array.isArray(list)) return [];
|
if (!Array.isArray(list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMention(text: string, mention: string | undefined): string {
|
function normalizeMention(text: string, mention: string | undefined): string {
|
||||||
if (!mention) return text.trim();
|
if (!mention) {
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
const re = new RegExp(`@${escaped}\\b`, "gi");
|
const re = new RegExp(`@${escaped}\\b`, "gi");
|
||||||
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
return text.replace(re, " ").replace(/\s+/g, " ").trim();
|
||||||
@@ -113,7 +115,9 @@ function stripOncharPrefix(
|
|||||||
): { triggered: boolean; stripped: string } {
|
): { triggered: boolean; stripped: string } {
|
||||||
const trimmed = text.trimStart();
|
const trimmed = text.trimStart();
|
||||||
for (const prefix of prefixes) {
|
for (const prefix of prefixes) {
|
||||||
if (!prefix) continue;
|
if (!prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (trimmed.startsWith(prefix)) {
|
if (trimmed.startsWith(prefix)) {
|
||||||
return {
|
return {
|
||||||
triggered: true,
|
triggered: true,
|
||||||
@@ -130,23 +134,37 @@ function isSystemPost(post: MattermostPost): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
|
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
|
||||||
if (!channelType) return "channel";
|
if (!channelType) {
|
||||||
|
return "channel";
|
||||||
|
}
|
||||||
const normalized = channelType.trim().toUpperCase();
|
const normalized = channelType.trim().toUpperCase();
|
||||||
if (normalized === "D") return "dm";
|
if (normalized === "D") {
|
||||||
if (normalized === "G") return "group";
|
return "dm";
|
||||||
|
}
|
||||||
|
if (normalized === "G") {
|
||||||
|
return "group";
|
||||||
|
}
|
||||||
return "channel";
|
return "channel";
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
|
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
|
||||||
if (kind === "dm") return "direct";
|
if (kind === "dm") {
|
||||||
if (kind === "group") return "group";
|
return "direct";
|
||||||
|
}
|
||||||
|
if (kind === "group") {
|
||||||
|
return "group";
|
||||||
|
}
|
||||||
return "channel";
|
return "channel";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAllowEntry(entry: string): string {
|
function normalizeAllowEntry(entry: string): string {
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
if (trimmed === "*") return "*";
|
return "";
|
||||||
|
}
|
||||||
|
if (trimmed === "*") {
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
return trimmed
|
return trimmed
|
||||||
.replace(/^(mattermost|user):/i, "")
|
.replace(/^(mattermost|user):/i, "")
|
||||||
.replace(/^@/, "")
|
.replace(/^@/, "")
|
||||||
@@ -164,8 +182,12 @@ function isSenderAllowed(params: {
|
|||||||
allowFrom: string[];
|
allowFrom: string[];
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const allowFrom = params.allowFrom;
|
const allowFrom = params.allowFrom;
|
||||||
if (allowFrom.length === 0) return false;
|
if (allowFrom.length === 0) {
|
||||||
if (allowFrom.includes("*")) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (allowFrom.includes("*")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
const normalizedSenderId = normalizeAllowEntry(params.senderId);
|
||||||
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
||||||
return allowFrom.some(
|
return allowFrom.some(
|
||||||
@@ -181,7 +203,9 @@ type MattermostMediaInfo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
||||||
if (mediaList.length === 0) return "";
|
if (mediaList.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
if (mediaList.length === 1) {
|
if (mediaList.length === 1) {
|
||||||
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
|
||||||
return `<media:${kind}>`;
|
return `<media:${kind}>`;
|
||||||
@@ -216,7 +240,9 @@ function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
|
|||||||
|
|
||||||
function buildMattermostWsUrl(baseUrl: string): string {
|
function buildMattermostWsUrl(baseUrl: string): string {
|
||||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||||
if (!normalized) throw new Error("Mattermost baseUrl is required");
|
if (!normalized) {
|
||||||
|
throw new Error("Mattermost baseUrl is required");
|
||||||
|
}
|
||||||
const wsBase = normalized.replace(/^http/i, "ws");
|
const wsBase = normalized.replace(/^http/i, "ws");
|
||||||
return `${wsBase}/api/v4/websocket`;
|
return `${wsBase}/api/v4/websocket`;
|
||||||
}
|
}
|
||||||
@@ -252,7 +278,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
const logVerboseMessage = (message: string) => {
|
const logVerboseMessage = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) return;
|
if (!core.logging.shouldLogVerbose()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.debug?.(message);
|
logger.debug?.(message);
|
||||||
};
|
};
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
@@ -276,8 +304,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const resolveMattermostMedia = async (
|
const resolveMattermostMedia = async (
|
||||||
fileIds?: string[] | null,
|
fileIds?: string[] | null,
|
||||||
): Promise<MattermostMediaInfo[]> => {
|
): Promise<MattermostMediaInfo[]> => {
|
||||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
|
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const out: MattermostMediaInfo[] = [];
|
const out: MattermostMediaInfo[] = [];
|
||||||
for (const fileId of ids) {
|
for (const fileId of ids) {
|
||||||
try {
|
try {
|
||||||
@@ -312,7 +342,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||||
const cached = channelCache.get(channelId);
|
const cached = channelCache.get(channelId);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const info = await fetchMattermostChannel(client, channelId);
|
const info = await fetchMattermostChannel(client, channelId);
|
||||||
channelCache.set(channelId, {
|
channelCache.set(channelId, {
|
||||||
@@ -332,7 +364,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||||
const cached = userCache.get(userId);
|
const cached = userCache.get(userId);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached.value;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const info = await fetchMattermostUser(client, userId);
|
const info = await fetchMattermostUser(client, userId);
|
||||||
userCache.set(userId, {
|
userCache.set(userId, {
|
||||||
@@ -356,19 +390,31 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
messageIds?: string[],
|
messageIds?: string[],
|
||||||
) => {
|
) => {
|
||||||
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
|
||||||
if (!channelId) return;
|
if (!channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
|
||||||
if (allMessageIds.length === 0) return;
|
if (allMessageIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const dedupeEntries = allMessageIds.map((id) =>
|
const dedupeEntries = allMessageIds.map((id) =>
|
||||||
recentInboundMessages.check(`${account.accountId}:${id}`),
|
recentInboundMessages.check(`${account.accountId}:${id}`),
|
||||||
);
|
);
|
||||||
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
|
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
const senderId = post.user_id ?? payload.broadcast?.user_id;
|
||||||
if (!senderId) return;
|
if (!senderId) {
|
||||||
if (senderId === botUserId) return;
|
return;
|
||||||
if (isSystemPost(post)) return;
|
}
|
||||||
|
if (senderId === botUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isSystemPost(post)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const channelInfo = await resolveChannelInfo(channelId);
|
const channelInfo = await resolveChannelInfo(channelId);
|
||||||
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
|
||||||
@@ -560,7 +606,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
groupId: channelId,
|
groupId: channelId,
|
||||||
}) !== false;
|
});
|
||||||
const shouldBypassMention =
|
const shouldBypassMention =
|
||||||
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
|
||||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
|
||||||
@@ -582,7 +628,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
|
||||||
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
|
||||||
const bodyText = normalizeMention(baseText, botUsername);
|
const bodyText = normalizeMention(baseText, botUsername);
|
||||||
if (!bodyText) return;
|
if (!bodyText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
core.channel.activity.record({
|
core.channel.activity.record({
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
@@ -743,7 +791,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
);
|
);
|
||||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode);
|
||||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
if (!chunk) continue;
|
if (!chunk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToId: threadRootId,
|
replyToId: threadRootId,
|
||||||
@@ -804,20 +854,28 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
entry.post.channel_id ??
|
entry.post.channel_id ??
|
||||||
entry.payload.data?.channel_id ??
|
entry.payload.data?.channel_id ??
|
||||||
entry.payload.broadcast?.channel_id;
|
entry.payload.broadcast?.channel_id;
|
||||||
if (!channelId) return null;
|
if (!channelId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const threadId = entry.post.root_id?.trim();
|
const threadId = entry.post.root_id?.trim();
|
||||||
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
const threadKey = threadId ? `thread:${threadId}` : "channel";
|
||||||
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
|
||||||
},
|
},
|
||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
|
if (entry.post.file_ids && entry.post.file_ids.length > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const text = entry.post.message?.trim() ?? "";
|
const text = entry.post.message?.trim() ?? "";
|
||||||
if (!text) return false;
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return !core.channel.text.hasControlCommand(text, cfg);
|
return !core.channel.text.hasControlCommand(text, cfg);
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
const last = entries.at(-1);
|
const last = entries.at(-1);
|
||||||
if (!last) return;
|
if (!last) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (entries.length === 1) {
|
if (entries.length === 1) {
|
||||||
await handlePost(last.post, last.payload);
|
await handlePost(last.post, last.payload);
|
||||||
return;
|
return;
|
||||||
@@ -831,7 +889,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
message: combinedText,
|
message: combinedText,
|
||||||
file_ids: [],
|
file_ids: [],
|
||||||
};
|
};
|
||||||
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
|
const ids = entries.map((entry) => entry.post.id).filter(Boolean);
|
||||||
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -871,9 +929,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.event !== "posted") return;
|
if (payload.event !== "posted") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const postData = payload.data?.post;
|
const postData = payload.data?.post;
|
||||||
if (!postData) return;
|
if (!postData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let post: MattermostPost | null = null;
|
let post: MattermostPost | null = null;
|
||||||
if (typeof postData === "string") {
|
if (typeof postData === "string") {
|
||||||
try {
|
try {
|
||||||
@@ -884,7 +946,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
} else if (typeof postData === "object") {
|
} else if (typeof postData === "object") {
|
||||||
post = postData as MattermostPost;
|
post = postData as MattermostPost;
|
||||||
}
|
}
|
||||||
if (!post) return;
|
if (!post) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await debouncer.enqueue({ post, payload });
|
await debouncer.enqueue({ post, payload });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -917,7 +981,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
while (!opts.abortSignal?.aborted) {
|
while (!opts.abortSignal?.aborted) {
|
||||||
await connectOnce();
|
await connectOnce();
|
||||||
if (opts.abortSignal?.aborted) return;
|
if (opts.abortSignal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ async function readMattermostError(res: Response): Promise<string> {
|
|||||||
const contentType = res.headers.get("content-type") ?? "";
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
const data = (await res.json()) as { message?: string } | undefined;
|
const data = (await res.json()) as { message?: string } | undefined;
|
||||||
if (data?.message) return data.message;
|
if (data?.message) {
|
||||||
|
return data.message;
|
||||||
|
}
|
||||||
return JSON.stringify(data);
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
return await res.text();
|
return await res.text();
|
||||||
@@ -65,6 +67,8 @@ export async function probeMattermost(
|
|||||||
elapsedMs: Date.now() - start,
|
elapsedMs: Date.now() - start,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,21 +49,29 @@ function isHttpUrl(value: string): boolean {
|
|||||||
|
|
||||||
function parseMattermostTarget(raw: string): MattermostTarget {
|
function parseMattermostTarget(raw: string): MattermostTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
|
if (!trimmed) {
|
||||||
|
throw new Error("Recipient is required for Mattermost sends");
|
||||||
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
if (lower.startsWith("channel:")) {
|
if (lower.startsWith("channel:")) {
|
||||||
const id = trimmed.slice("channel:".length).trim();
|
const id = trimmed.slice("channel:".length).trim();
|
||||||
if (!id) throw new Error("Channel id is required for Mattermost sends");
|
if (!id) {
|
||||||
|
throw new Error("Channel id is required for Mattermost sends");
|
||||||
|
}
|
||||||
return { kind: "channel", id };
|
return { kind: "channel", id };
|
||||||
}
|
}
|
||||||
if (lower.startsWith("user:")) {
|
if (lower.startsWith("user:")) {
|
||||||
const id = trimmed.slice("user:".length).trim();
|
const id = trimmed.slice("user:".length).trim();
|
||||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
if (!id) {
|
||||||
|
throw new Error("User id is required for Mattermost sends");
|
||||||
|
}
|
||||||
return { kind: "user", id };
|
return { kind: "user", id };
|
||||||
}
|
}
|
||||||
if (lower.startsWith("mattermost:")) {
|
if (lower.startsWith("mattermost:")) {
|
||||||
const id = trimmed.slice("mattermost:".length).trim();
|
const id = trimmed.slice("mattermost:".length).trim();
|
||||||
if (!id) throw new Error("User id is required for Mattermost sends");
|
if (!id) {
|
||||||
|
throw new Error("User id is required for Mattermost sends");
|
||||||
|
}
|
||||||
return { kind: "user", id };
|
return { kind: "user", id };
|
||||||
}
|
}
|
||||||
if (trimmed.startsWith("@")) {
|
if (trimmed.startsWith("@")) {
|
||||||
@@ -79,7 +87,9 @@ function parseMattermostTarget(raw: string): MattermostTarget {
|
|||||||
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
|
||||||
const key = cacheKey(baseUrl, token);
|
const key = cacheKey(baseUrl, token);
|
||||||
const cached = botUserCache.get(key);
|
const cached = botUserCache.get(key);
|
||||||
if (cached) return cached;
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||||
const user = await fetchMattermostMe(client);
|
const user = await fetchMattermostMe(client);
|
||||||
botUserCache.set(key, user);
|
botUserCache.set(key, user);
|
||||||
@@ -94,7 +104,9 @@ async function resolveUserIdByUsername(params: {
|
|||||||
const { baseUrl, token, username } = params;
|
const { baseUrl, token, username } = params;
|
||||||
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
|
||||||
const cached = userByNameCache.get(key);
|
const cached = userByNameCache.get(key);
|
||||||
if (cached?.id) return cached.id;
|
if (cached?.id) {
|
||||||
|
return cached.id;
|
||||||
|
}
|
||||||
const client = createMattermostClient({ baseUrl, botToken: token });
|
const client = createMattermostClient({ baseUrl, botToken: token });
|
||||||
const user = await fetchMattermostUserByUsername(client, username);
|
const user = await fetchMattermostUserByUsername(client, username);
|
||||||
userByNameCache.set(key, user);
|
userByNameCache.set(key, user);
|
||||||
@@ -106,7 +118,9 @@ async function resolveTargetChannelId(params: {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
token: string;
|
token: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
if (params.target.kind === "channel") return params.target.id;
|
if (params.target.kind === "channel") {
|
||||||
|
return params.target.id;
|
||||||
|
}
|
||||||
const userId = params.target.id
|
const userId = params.target.id
|
||||||
? params.target.id
|
? params.target.id
|
||||||
: await resolveUserIdByUsername({
|
: await resolveUserIdByUsername({
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return undefined;
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
if (lower.startsWith("channel:")) {
|
if (lower.startsWith("channel:")) {
|
||||||
const id = trimmed.slice("channel:".length).trim();
|
const id = trimmed.slice("channel:".length).trim();
|
||||||
@@ -31,8 +33,14 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
|
|||||||
|
|
||||||
export function looksLikeMattermostTargetId(raw: string): boolean {
|
export function looksLikeMattermostTargetId(raw: string): boolean {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
|
return false;
|
||||||
if (/^[@#]/.test(trimmed)) return true;
|
}
|
||||||
|
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (/^[@#]/.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type PromptAccountIdParams = {
|
|||||||
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
|
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
|
||||||
const existingIds = params.listAccountIds(params.cfg);
|
const existingIds = params.listAccountIds(params.cfg);
|
||||||
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||||
const choice = (await params.prompter.select({
|
const choice = await params.prompter.select({
|
||||||
message: `${params.label} account`,
|
message: `${params.label} account`,
|
||||||
options: [
|
options: [
|
||||||
...existingIds.map((id) => ({
|
...existingIds.map((id) => ({
|
||||||
@@ -23,9 +23,11 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
|
|||||||
{ value: "__new__", label: "Add a new account" },
|
{ value: "__new__", label: "Add a new account" },
|
||||||
],
|
],
|
||||||
initialValue: initial,
|
initialValue: initial,
|
||||||
})) as string;
|
});
|
||||||
|
|
||||||
if (choice !== "__new__") return normalizeAccountId(choice);
|
if (choice !== "__new__") {
|
||||||
|
return normalizeAccountId(choice);
|
||||||
|
}
|
||||||
|
|
||||||
const entered = await params.prompter.text({
|
const entered = await params.prompter.text({
|
||||||
message: `New ${params.label} account id`,
|
message: `New ${params.label} account id`,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const memoryCorePlugin = {
|
|||||||
config: ctx.config,
|
config: ctx.config,
|
||||||
agentSessionKey: ctx.sessionKey,
|
agentSessionKey: ctx.sessionKey,
|
||||||
});
|
});
|
||||||
if (!memorySearchTool || !memoryGetTool) return null;
|
if (!memorySearchTool || !memoryGetTool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return [memorySearchTool, memoryGetTool];
|
return [memorySearchTool, memoryGetTool];
|
||||||
},
|
},
|
||||||
{ names: ["memory_search", "memory_get"] },
|
{ names: ["memory_search", "memory_get"] },
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -24,7 +23,9 @@ function resolveDefaultDbPath(): string {
|
|||||||
const home = homedir();
|
const home = homedir();
|
||||||
const preferred = join(home, ".openclaw", "memory", "lancedb");
|
const preferred = join(home, ".openclaw", "memory", "lancedb");
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(preferred)) return preferred;
|
if (fs.existsSync(preferred)) {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,9 @@ function resolveDefaultDbPath(): string {
|
|||||||
for (const legacy of LEGACY_STATE_DIRS) {
|
for (const legacy of LEGACY_STATE_DIRS) {
|
||||||
const candidate = join(home, legacy, "memory", "lancedb");
|
const candidate = join(home, legacy, "memory", "lancedb");
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(candidate)) return candidate;
|
if (fs.existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
@@ -50,7 +53,9 @@ const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
|||||||
|
|
||||||
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
|
function assertAllowedKeys(value: Record<string, unknown>, allowed: string[], label: string) {
|
||||||
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
||||||
if (unknown.length === 0) return;
|
if (unknown.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -42,6 +41,7 @@ describe("memory plugin e2e", () => {
|
|||||||
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
||||||
expect(memoryPlugin.kind).toBe("memory");
|
expect(memoryPlugin.kind).toBe("memory");
|
||||||
expect(memoryPlugin.configSchema).toBeDefined();
|
expect(memoryPlugin.configSchema).toBeDefined();
|
||||||
|
// oxlint-disable-next-line typescript/unbound-method
|
||||||
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,14 +217,16 @@ describeLive("memory plugin live tests", () => {
|
|||||||
registeredServices.push(service);
|
registeredServices.push(service);
|
||||||
},
|
},
|
||||||
on: (hookName: string, handler: any) => {
|
on: (hookName: string, handler: any) => {
|
||||||
if (!registeredHooks[hookName]) registeredHooks[hookName] = [];
|
if (!registeredHooks[hookName]) {
|
||||||
|
registeredHooks[hookName] = [];
|
||||||
|
}
|
||||||
registeredHooks[hookName].push(handler);
|
registeredHooks[hookName].push(handler);
|
||||||
},
|
},
|
||||||
resolvePath: (p: string) => p,
|
resolvePath: (p: string) => p,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register plugin
|
// Register plugin
|
||||||
await memoryPlugin.register(mockApi as any);
|
memoryPlugin.register(mockApi as any);
|
||||||
|
|
||||||
// Check registration
|
// Check registration
|
||||||
expect(registeredTools.length).toBe(3);
|
expect(registeredTools.length).toBe(3);
|
||||||
|
|||||||
@@ -55,8 +55,12 @@ class MemoryDB {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async ensureInitialized(): Promise<void> {
|
private async ensureInitialized(): Promise<void> {
|
||||||
if (this.table) return;
|
if (this.table) {
|
||||||
if (this.initPromise) return this.initPromise;
|
return;
|
||||||
|
}
|
||||||
|
if (this.initPromise) {
|
||||||
|
return this.initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
this.initPromise = this.doInitialize();
|
this.initPromise = this.doInitialize();
|
||||||
return this.initPromise;
|
return this.initPromise;
|
||||||
@@ -73,7 +77,7 @@ class MemoryDB {
|
|||||||
{
|
{
|
||||||
id: "__schema__",
|
id: "__schema__",
|
||||||
text: "",
|
text: "",
|
||||||
vector: new Array(this.vectorDim).fill(0),
|
vector: Array.from({ length: this.vectorDim }).fill(0),
|
||||||
importance: 0,
|
importance: 0,
|
||||||
category: "other",
|
category: "other",
|
||||||
createdAt: 0,
|
createdAt: 0,
|
||||||
@@ -179,25 +183,43 @@ const MEMORY_TRIGGERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function shouldCapture(text: string): boolean {
|
function shouldCapture(text: string): boolean {
|
||||||
if (text.length < 10 || text.length > 500) return false;
|
if (text.length < 10 || text.length > 500) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Skip injected context from memory recall
|
// Skip injected context from memory recall
|
||||||
if (text.includes("<relevant-memories>")) return false;
|
if (text.includes("<relevant-memories>")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Skip system-generated content
|
// Skip system-generated content
|
||||||
if (text.startsWith("<") && text.includes("</")) return false;
|
if (text.startsWith("<") && text.includes("</")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Skip agent summary responses (contain markdown formatting)
|
// Skip agent summary responses (contain markdown formatting)
|
||||||
if (text.includes("**") && text.includes("\n-")) return false;
|
if (text.includes("**") && text.includes("\n-")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
// Skip emoji-heavy responses (likely agent output)
|
// Skip emoji-heavy responses (likely agent output)
|
||||||
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
const emojiCount = (text.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length;
|
||||||
if (emojiCount > 3) return false;
|
if (emojiCount > 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
return MEMORY_TRIGGERS.some((r) => r.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectCategory(text: string): MemoryCategory {
|
function detectCategory(text: string): MemoryCategory {
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
if (/prefer|radši|like|love|hate|want/i.test(lower)) return "preference";
|
if (/prefer|radši|like|love|hate|want/i.test(lower)) {
|
||||||
if (/rozhodli|decided|will use|budeme/i.test(lower)) return "decision";
|
return "preference";
|
||||||
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) return "entity";
|
}
|
||||||
if (/is|are|has|have|je|má|jsou/i.test(lower)) return "fact";
|
if (/rozhodli|decided|will use|budeme/i.test(lower)) {
|
||||||
|
return "decision";
|
||||||
|
}
|
||||||
|
if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) {
|
||||||
|
return "entity";
|
||||||
|
}
|
||||||
|
if (/is|are|has|have|je|má|jsou/i.test(lower)) {
|
||||||
|
return "fact";
|
||||||
|
}
|
||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,13 +477,17 @@ const memoryPlugin = {
|
|||||||
// Auto-recall: inject relevant memories before agent starts
|
// Auto-recall: inject relevant memories before agent starts
|
||||||
if (cfg.autoRecall) {
|
if (cfg.autoRecall) {
|
||||||
api.on("before_agent_start", async (event) => {
|
api.on("before_agent_start", async (event) => {
|
||||||
if (!event.prompt || event.prompt.length < 5) return;
|
if (!event.prompt || event.prompt.length < 5) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const vector = await embeddings.embed(event.prompt);
|
const vector = await embeddings.embed(event.prompt);
|
||||||
const results = await db.search(vector, 3, 0.3);
|
const results = await db.search(vector, 3, 0.3);
|
||||||
|
|
||||||
if (results.length === 0) return;
|
if (results.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const memoryContext = results
|
const memoryContext = results
|
||||||
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
|
.map((r) => `- [${r.entry.category}] ${r.entry.text}`)
|
||||||
@@ -490,12 +516,16 @@ const memoryPlugin = {
|
|||||||
const texts: string[] = [];
|
const texts: string[] = [];
|
||||||
for (const msg of event.messages) {
|
for (const msg of event.messages) {
|
||||||
// Type guard for message object
|
// Type guard for message object
|
||||||
if (!msg || typeof msg !== "object") continue;
|
if (!msg || typeof msg !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const msgObj = msg as Record<string, unknown>;
|
const msgObj = msg as Record<string, unknown>;
|
||||||
|
|
||||||
// Only process user and assistant messages
|
// Only process user and assistant messages
|
||||||
const role = msgObj.role;
|
const role = msgObj.role;
|
||||||
if (role !== "user" && role !== "assistant") continue;
|
if (role !== "user" && role !== "assistant") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const content = msgObj.content;
|
const content = msgObj.content;
|
||||||
|
|
||||||
@@ -524,7 +554,9 @@ const memoryPlugin = {
|
|||||||
|
|
||||||
// Filter for capturable content
|
// Filter for capturable content
|
||||||
const toCapture = texts.filter((text) => text && shouldCapture(text));
|
const toCapture = texts.filter((text) => text && shouldCapture(text));
|
||||||
if (toCapture.length === 0) return;
|
if (toCapture.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Store each capturable piece (limit to 3 per conversation)
|
// Store each capturable piece (limit to 3 per conversation)
|
||||||
let stored = 0;
|
let stored = 0;
|
||||||
@@ -534,7 +566,9 @@ const memoryPlugin = {
|
|||||||
|
|
||||||
// Check for duplicates (high similarity threshold)
|
// Check for duplicates (high similarity threshold)
|
||||||
const existing = await db.search(vector, 1, 0.95);
|
const existing = await db.search(vector, 1, 0.95);
|
||||||
if (existing.length > 0) continue;
|
if (existing.length > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await db.store({
|
await db.store({
|
||||||
text,
|
text,
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
|
|||||||
const name = typeof att.name === "string" ? att.name.trim() : "";
|
const name = typeof att.name === "string" ? att.name.trim() : "";
|
||||||
|
|
||||||
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
if (contentType === "application/vnd.microsoft.teams.file.download.info") {
|
||||||
if (!isRecord(att.content)) return null;
|
if (!isRecord(att.content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const downloadUrl =
|
const downloadUrl =
|
||||||
typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
|
typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : "";
|
||||||
if (!downloadUrl) return null;
|
if (!downloadUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
|
const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : "";
|
||||||
const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
|
const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : "";
|
||||||
@@ -49,7 +53,9 @@ function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : "";
|
||||||
if (!contentUrl) return null;
|
if (!contentUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: contentUrl,
|
url: contentUrl,
|
||||||
@@ -82,9 +88,15 @@ async function fetchWithAuthFallback(params: {
|
|||||||
}): Promise<Response> {
|
}): Promise<Response> {
|
||||||
const fetchFn = params.fetchFn ?? fetch;
|
const fetchFn = params.fetchFn ?? fetch;
|
||||||
const firstAttempt = await fetchFn(params.url);
|
const firstAttempt = await fetchFn(params.url);
|
||||||
if (firstAttempt.ok) return firstAttempt;
|
if (firstAttempt.ok) {
|
||||||
if (!params.tokenProvider) return firstAttempt;
|
return firstAttempt;
|
||||||
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) return firstAttempt;
|
}
|
||||||
|
if (!params.tokenProvider) {
|
||||||
|
return firstAttempt;
|
||||||
|
}
|
||||||
|
if (firstAttempt.status !== 401 && firstAttempt.status !== 403) {
|
||||||
|
return firstAttempt;
|
||||||
|
}
|
||||||
|
|
||||||
const scopes = scopeCandidatesForUrl(params.url);
|
const scopes = scopeCandidatesForUrl(params.url);
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
@@ -93,7 +105,9 @@ async function fetchWithAuthFallback(params: {
|
|||||||
const res = await fetchFn(params.url, {
|
const res = await fetchFn(params.url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
if (res.ok) return res;
|
if (res.ok) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Try the next scope.
|
// Try the next scope.
|
||||||
}
|
}
|
||||||
@@ -116,7 +130,9 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
preserveFilenames?: boolean;
|
preserveFilenames?: boolean;
|
||||||
}): Promise<MSTeamsInboundMedia[]> {
|
}): Promise<MSTeamsInboundMedia[]> {
|
||||||
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
const list = Array.isArray(params.attachments) ? params.attachments : [];
|
||||||
if (list.length === 0) return [];
|
if (list.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
|
|
||||||
// Download ANY downloadable attachment (not just images)
|
// Download ANY downloadable attachment (not just images)
|
||||||
@@ -130,8 +146,12 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
const seenUrls = new Set<string>();
|
const seenUrls = new Set<string>();
|
||||||
for (const inline of inlineCandidates) {
|
for (const inline of inlineCandidates) {
|
||||||
if (inline.kind === "url") {
|
if (inline.kind === "url") {
|
||||||
if (!isUrlAllowed(inline.url, allowHosts)) continue;
|
if (!isUrlAllowed(inline.url, allowHosts)) {
|
||||||
if (seenUrls.has(inline.url)) continue;
|
continue;
|
||||||
|
}
|
||||||
|
if (seenUrls.has(inline.url)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
seenUrls.add(inline.url);
|
seenUrls.add(inline.url);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
url: inline.url,
|
url: inline.url,
|
||||||
@@ -141,12 +161,18 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (candidates.length === 0 && inlineCandidates.length === 0) return [];
|
if (candidates.length === 0 && inlineCandidates.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const out: MSTeamsInboundMedia[] = [];
|
const out: MSTeamsInboundMedia[] = [];
|
||||||
for (const inline of inlineCandidates) {
|
for (const inline of inlineCandidates) {
|
||||||
if (inline.kind !== "data") continue;
|
if (inline.kind !== "data") {
|
||||||
if (inline.data.byteLength > params.maxBytes) continue;
|
continue;
|
||||||
|
}
|
||||||
|
if (inline.data.byteLength > params.maxBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Data inline candidates (base64 data URLs) don't have original filenames
|
// Data inline candidates (base64 data URLs) don't have original filenames
|
||||||
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer(
|
||||||
@@ -165,16 +191,22 @@ export async function downloadMSTeamsAttachments(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (!isUrlAllowed(candidate.url, allowHosts)) continue;
|
if (!isUrlAllowed(candidate.url, allowHosts)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithAuthFallback({
|
const res = await fetchWithAuthFallback({
|
||||||
url: candidate.url,
|
url: candidate.url,
|
||||||
tokenProvider: params.tokenProvider,
|
tokenProvider: params.tokenProvider,
|
||||||
fetchFn: params.fetchFn,
|
fetchFn: params.fetchFn,
|
||||||
});
|
});
|
||||||
if (!res.ok) continue;
|
if (!res.ok) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ type GraphAttachment = {
|
|||||||
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
function readNestedString(value: unknown, keys: Array<string | number>): string | undefined {
|
||||||
let current: unknown = value;
|
let current: unknown = value;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (!isRecord(current)) return undefined;
|
if (!isRecord(current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
current = current[key as keyof typeof current];
|
current = current[key as keyof typeof current];
|
||||||
}
|
}
|
||||||
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
return typeof current === "string" && current.trim() ? current.trim() : undefined;
|
||||||
@@ -50,7 +52,9 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
|||||||
const messageIdCandidates = new Set<string>();
|
const messageIdCandidates = new Set<string>();
|
||||||
const pushCandidate = (value: string | null | undefined) => {
|
const pushCandidate = (value: string | null | undefined) => {
|
||||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||||
if (trimmed) messageIdCandidates.add(trimmed);
|
if (trimmed) {
|
||||||
|
messageIdCandidates.add(trimmed);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
pushCandidate(params.messageId);
|
pushCandidate(params.messageId);
|
||||||
@@ -68,17 +72,23 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
|||||||
readNestedString(params.channelData, ["channel", "id"]) ??
|
readNestedString(params.channelData, ["channel", "id"]) ??
|
||||||
readNestedString(params.channelData, ["channelId"]) ??
|
readNestedString(params.channelData, ["channelId"]) ??
|
||||||
readNestedString(params.channelData, ["teamsChannelId"]);
|
readNestedString(params.channelData, ["teamsChannelId"]);
|
||||||
if (!teamId || !channelId) return [];
|
if (!teamId || !channelId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
if (replyToId) {
|
if (replyToId) {
|
||||||
for (const candidate of messageIdCandidates) {
|
for (const candidate of messageIdCandidates) {
|
||||||
if (candidate === replyToId) continue;
|
if (candidate === replyToId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
urls.push(
|
urls.push(
|
||||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
|
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
|
if (messageIdCandidates.size === 0 && replyToId) {
|
||||||
|
messageIdCandidates.add(replyToId);
|
||||||
|
}
|
||||||
for (const candidate of messageIdCandidates) {
|
for (const candidate of messageIdCandidates) {
|
||||||
urls.push(
|
urls.push(
|
||||||
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
|
`${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`,
|
||||||
@@ -88,8 +98,12 @@ export function buildMSTeamsGraphMessageUrls(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]);
|
||||||
if (!chatId) return [];
|
if (!chatId) {
|
||||||
if (messageIdCandidates.size === 0 && replyToId) messageIdCandidates.add(replyToId);
|
return [];
|
||||||
|
}
|
||||||
|
if (messageIdCandidates.size === 0 && replyToId) {
|
||||||
|
messageIdCandidates.add(replyToId);
|
||||||
|
}
|
||||||
const urls = Array.from(messageIdCandidates).map(
|
const urls = Array.from(messageIdCandidates).map(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
`${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`,
|
||||||
@@ -107,7 +121,9 @@ async function fetchGraphCollection<T>(params: {
|
|||||||
headers: { Authorization: `Bearer ${params.accessToken}` },
|
headers: { Authorization: `Bearer ${params.accessToken}` },
|
||||||
});
|
});
|
||||||
const status = res.status;
|
const status = res.status;
|
||||||
if (!res.ok) return { status, items: [] };
|
if (!res.ok) {
|
||||||
|
return { status, items: [] };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = (await res.json()) as { value?: T[] };
|
const data = (await res.json()) as { value?: T[] };
|
||||||
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
return { status, items: Array.isArray(data.value) ? data.value : [] };
|
||||||
@@ -157,14 +173,18 @@ async function downloadGraphHostedContent(params: {
|
|||||||
const out: MSTeamsInboundMedia[] = [];
|
const out: MSTeamsInboundMedia[] = [];
|
||||||
for (const item of hosted.items) {
|
for (const item of hosted.items) {
|
||||||
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
|
const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : "";
|
||||||
if (!contentBytes) continue;
|
if (!contentBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let buffer: Buffer;
|
let buffer: Buffer;
|
||||||
try {
|
try {
|
||||||
buffer = Buffer.from(contentBytes, "base64");
|
buffer = Buffer.from(contentBytes, "base64");
|
||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (buffer.byteLength > params.maxBytes) continue;
|
if (buffer.byteLength > params.maxBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const mime = await getMSTeamsRuntime().media.detectMime({
|
const mime = await getMSTeamsRuntime().media.detectMime({
|
||||||
buffer,
|
buffer,
|
||||||
headerMime: item.contentType ?? undefined,
|
headerMime: item.contentType ?? undefined,
|
||||||
@@ -199,7 +219,9 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
/** When true, embeds original filename in stored path for later extraction. */
|
/** When true, embeds original filename in stored path for later extraction. */
|
||||||
preserveFilenames?: boolean;
|
preserveFilenames?: boolean;
|
||||||
}): Promise<MSTeamsGraphMediaResult> {
|
}): Promise<MSTeamsGraphMediaResult> {
|
||||||
if (!params.messageUrl || !params.tokenProvider) return { media: [] };
|
if (!params.messageUrl || !params.tokenProvider) {
|
||||||
|
return { media: [] };
|
||||||
|
}
|
||||||
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
const allowHosts = resolveAllowedHosts(params.allowHosts);
|
||||||
const messageUrl = params.messageUrl;
|
const messageUrl = params.messageUrl;
|
||||||
let accessToken: string;
|
let accessToken: string;
|
||||||
@@ -299,9 +321,13 @@ export async function downloadMSTeamsGraphMedia(params: {
|
|||||||
sharePointMedia.length > 0
|
sharePointMedia.length > 0
|
||||||
? normalizedAttachments.filter((att) => {
|
? normalizedAttachments.filter((att) => {
|
||||||
const contentType = att.contentType?.toLowerCase();
|
const contentType = att.contentType?.toLowerCase();
|
||||||
if (contentType !== "reference") return true;
|
if (contentType !== "reference") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
const url = typeof att.contentUrl === "string" ? att.contentUrl : "";
|
||||||
if (!url) return true;
|
if (!url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return !downloadedReferenceUrls.has(url);
|
return !downloadedReferenceUrls.has(url);
|
||||||
})
|
})
|
||||||
: normalizedAttachments;
|
: normalizedAttachments;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export function summarizeMSTeamsHtmlAttachments(
|
|||||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||||
): MSTeamsHtmlAttachmentSummary | undefined {
|
): MSTeamsHtmlAttachmentSummary | undefined {
|
||||||
const list = Array.isArray(attachments) ? attachments : [];
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
if (list.length === 0) return undefined;
|
if (list.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
let htmlAttachments = 0;
|
let htmlAttachments = 0;
|
||||||
let imgTags = 0;
|
let imgTags = 0;
|
||||||
let dataImages = 0;
|
let dataImages = 0;
|
||||||
@@ -23,7 +25,9 @@ export function summarizeMSTeamsHtmlAttachments(
|
|||||||
|
|
||||||
for (const att of list) {
|
for (const att of list) {
|
||||||
const html = extractHtmlFromAttachment(att);
|
const html = extractHtmlFromAttachment(att);
|
||||||
if (!html) continue;
|
if (!html) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
htmlAttachments += 1;
|
htmlAttachments += 1;
|
||||||
IMG_SRC_RE.lastIndex = 0;
|
IMG_SRC_RE.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||||
@@ -31,9 +35,13 @@ export function summarizeMSTeamsHtmlAttachments(
|
|||||||
imgTags += 1;
|
imgTags += 1;
|
||||||
const src = match[1]?.trim();
|
const src = match[1]?.trim();
|
||||||
if (src) {
|
if (src) {
|
||||||
if (src.startsWith("data:")) dataImages += 1;
|
if (src.startsWith("data:")) {
|
||||||
else if (src.startsWith("cid:")) cidImages += 1;
|
dataImages += 1;
|
||||||
else srcHosts.add(safeHostForUrl(src));
|
} else if (src.startsWith("cid:")) {
|
||||||
|
cidImages += 1;
|
||||||
|
} else {
|
||||||
|
srcHosts.add(safeHostForUrl(src));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match = IMG_SRC_RE.exec(html);
|
match = IMG_SRC_RE.exec(html);
|
||||||
}
|
}
|
||||||
@@ -43,12 +51,16 @@ export function summarizeMSTeamsHtmlAttachments(
|
|||||||
while (attachmentMatch) {
|
while (attachmentMatch) {
|
||||||
attachmentTags += 1;
|
attachmentTags += 1;
|
||||||
const id = attachmentMatch[1]?.trim();
|
const id = attachmentMatch[1]?.trim();
|
||||||
if (id) attachmentIds.add(id);
|
if (id) {
|
||||||
|
attachmentIds.add(id);
|
||||||
|
}
|
||||||
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
attachmentMatch = ATTACHMENT_TAG_RE.exec(html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (htmlAttachments === 0) return undefined;
|
if (htmlAttachments === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
htmlAttachments,
|
htmlAttachments,
|
||||||
imgTags,
|
imgTags,
|
||||||
@@ -64,7 +76,9 @@ export function buildMSTeamsAttachmentPlaceholder(
|
|||||||
attachments: MSTeamsAttachmentLike[] | undefined,
|
attachments: MSTeamsAttachmentLike[] | undefined,
|
||||||
): string {
|
): string {
|
||||||
const list = Array.isArray(attachments) ? attachments : [];
|
const list = Array.isArray(attachments) ? attachments : [];
|
||||||
if (list.length === 0) return "";
|
if (list.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const imageCount = list.filter(isLikelyImageAttachment).length;
|
const imageCount = list.filter(isLikelyImageAttachment).length;
|
||||||
const inlineCount = extractInlineImageCandidates(list).length;
|
const inlineCount = extractInlineImageCandidates(list).length;
|
||||||
const totalImages = imageCount + inlineCount;
|
const totalImages = imageCount + inlineCount;
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ export function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeContentType(value: unknown): string | undefined {
|
export function normalizeContentType(value: unknown): string | undefined {
|
||||||
if (typeof value !== "string") return undefined;
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
@@ -78,17 +80,25 @@ export function inferPlaceholder(params: {
|
|||||||
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean {
|
||||||
const contentType = normalizeContentType(att.contentType) ?? "";
|
const contentType = normalizeContentType(att.contentType) ?? "";
|
||||||
const name = typeof att.name === "string" ? att.name : "";
|
const name = typeof att.name === "string" ? att.name : "";
|
||||||
if (contentType.startsWith("image/")) return true;
|
if (contentType.startsWith("image/")) {
|
||||||
if (IMAGE_EXT_RE.test(name)) return true;
|
return true;
|
||||||
|
}
|
||||||
|
if (IMAGE_EXT_RE.test(name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
contentType === "application/vnd.microsoft.teams.file.download.info" &&
|
||||||
isRecord(att.content)
|
isRecord(att.content)
|
||||||
) {
|
) {
|
||||||
const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
|
const fileType = typeof att.content.fileType === "string" ? att.content.fileType : "";
|
||||||
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) return true;
|
if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
|
const fileName = typeof att.content.fileName === "string" ? att.content.fileName : "";
|
||||||
if (fileName && IMAGE_EXT_RE.test(fileName)) return true;
|
if (fileName && IMAGE_EXT_RE.test(fileName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -124,9 +134,15 @@ function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
|
export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined {
|
||||||
if (!isHtmlAttachment(att)) return undefined;
|
if (!isHtmlAttachment(att)) {
|
||||||
if (typeof att.content === "string") return att.content;
|
return undefined;
|
||||||
if (!isRecord(att.content)) return undefined;
|
}
|
||||||
|
if (typeof att.content === "string") {
|
||||||
|
return att.content;
|
||||||
|
}
|
||||||
|
if (!isRecord(att.content)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
const text =
|
const text =
|
||||||
typeof att.content.text === "string"
|
typeof att.content.text === "string"
|
||||||
? att.content.text
|
? att.content.text
|
||||||
@@ -140,12 +156,18 @@ export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string |
|
|||||||
|
|
||||||
function decodeDataImage(src: string): InlineImageCandidate | null {
|
function decodeDataImage(src: string): InlineImageCandidate | null {
|
||||||
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
|
const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src);
|
||||||
if (!match) return null;
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const contentType = match[1]?.toLowerCase();
|
const contentType = match[1]?.toLowerCase();
|
||||||
const isBase64 = Boolean(match[2]);
|
const isBase64 = Boolean(match[2]);
|
||||||
if (!isBase64) return null;
|
if (!isBase64) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const payload = match[3] ?? "";
|
const payload = match[3] ?? "";
|
||||||
if (!payload) return null;
|
if (!payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = Buffer.from(payload, "base64");
|
const data = Buffer.from(payload, "base64");
|
||||||
return { kind: "data", data, contentType, placeholder: "<media:image>" };
|
return { kind: "data", data, contentType, placeholder: "<media:image>" };
|
||||||
@@ -170,7 +192,9 @@ export function extractInlineImageCandidates(
|
|||||||
const out: InlineImageCandidate[] = [];
|
const out: InlineImageCandidate[] = [];
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
const html = extractHtmlFromAttachment(att);
|
const html = extractHtmlFromAttachment(att);
|
||||||
if (!html) continue;
|
if (!html) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
IMG_SRC_RE.lastIndex = 0;
|
IMG_SRC_RE.lastIndex = 0;
|
||||||
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
let match: RegExpExecArray | null = IMG_SRC_RE.exec(html);
|
||||||
while (match) {
|
while (match) {
|
||||||
@@ -178,7 +202,9 @@ export function extractInlineImageCandidates(
|
|||||||
if (src && !src.startsWith("cid:")) {
|
if (src && !src.startsWith("cid:")) {
|
||||||
if (src.startsWith("data:")) {
|
if (src.startsWith("data:")) {
|
||||||
const decoded = decodeDataImage(src);
|
const decoded = decodeDataImage(src);
|
||||||
if (decoded) out.push(decoded);
|
if (decoded) {
|
||||||
|
out.push(decoded);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
out.push({
|
out.push({
|
||||||
kind: "url",
|
kind: "url",
|
||||||
@@ -204,8 +230,12 @@ export function safeHostForUrl(url: string): string {
|
|||||||
|
|
||||||
function normalizeAllowHost(value: string): string {
|
function normalizeAllowHost(value: string): string {
|
||||||
const trimmed = value.trim().toLowerCase();
|
const trimmed = value.trim().toLowerCase();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) {
|
||||||
if (trimmed === "*") return "*";
|
return "";
|
||||||
|
}
|
||||||
|
if (trimmed === "*") {
|
||||||
|
return "*";
|
||||||
|
}
|
||||||
return trimmed.replace(/^\*\.?/, "");
|
return trimmed.replace(/^\*\.?/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +244,16 @@ export function resolveAllowedHosts(input?: string[]): string[] {
|
|||||||
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
|
return DEFAULT_MEDIA_HOST_ALLOWLIST.slice();
|
||||||
}
|
}
|
||||||
const normalized = input.map(normalizeAllowHost).filter(Boolean);
|
const normalized = input.map(normalizeAllowHost).filter(Boolean);
|
||||||
if (normalized.includes("*")) return ["*"];
|
if (normalized.includes("*")) {
|
||||||
|
return ["*"];
|
||||||
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHostAllowed(host: string, allowlist: string[]): boolean {
|
function isHostAllowed(host: string, allowlist: string[]): boolean {
|
||||||
if (allowlist.includes("*")) return true;
|
if (allowlist.includes("*")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalized = host.toLowerCase();
|
const normalized = host.toLowerCase();
|
||||||
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
|
return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`));
|
||||||
}
|
}
|
||||||
@@ -227,7 +261,9 @@ function isHostAllowed(host: string, allowlist: string[]): boolean {
|
|||||||
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
export function isUrlAllowed(url: string, allowlist: string[]): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
if (parsed.protocol !== "https:") return false;
|
if (parsed.protocol !== "https:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return isHostAllowed(parsed.hostname, allowlist);
|
return isHostAllowed(parsed.hostname, allowlist);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
collectWarnings: ({ cfg }) => {
|
collectWarnings: ({ cfg }) => {
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||||
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||||
if (groupPolicy !== "open") return [];
|
if (groupPolicy !== "open") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
|
`- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`,
|
||||||
];
|
];
|
||||||
@@ -150,8 +152,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (raw) => {
|
looksLikeId: (raw) => {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return false;
|
if (!trimmed) {
|
||||||
if (/^conversation:/i.test(trimmed)) return true;
|
return false;
|
||||||
|
}
|
||||||
|
if (/^conversation:/i.test(trimmed)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (/^user:/i.test(trimmed)) {
|
if (/^user:/i.test(trimmed)) {
|
||||||
// Only treat as ID if the value after user: looks like a UUID
|
// Only treat as ID if the value after user: looks like a UUID
|
||||||
const id = trimmed.slice("user:".length).trim();
|
const id = trimmed.slice("user:".length).trim();
|
||||||
@@ -169,11 +175,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
|
for (const entry of cfg.channels?.msteams?.allowFrom ?? []) {
|
||||||
const trimmed = String(entry).trim();
|
const trimmed = String(entry).trim();
|
||||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
if (trimmed && trimmed !== "*") {
|
||||||
|
ids.add(trimmed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
|
for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) {
|
||||||
const trimmed = userId.trim();
|
const trimmed = userId.trim();
|
||||||
if (trimmed) ids.add(trimmed);
|
if (trimmed) {
|
||||||
|
ids.add(trimmed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Array.from(ids)
|
return Array.from(ids)
|
||||||
.map((raw) => raw.trim())
|
.map((raw) => raw.trim())
|
||||||
@@ -181,8 +191,12 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
|
.map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw)
|
||||||
.map((raw) => {
|
.map((raw) => {
|
||||||
const lowered = raw.toLowerCase();
|
const lowered = raw.toLowerCase();
|
||||||
if (lowered.startsWith("user:")) return raw;
|
if (lowered.startsWith("user:")) {
|
||||||
if (lowered.startsWith("conversation:")) return raw;
|
return raw;
|
||||||
|
}
|
||||||
|
if (lowered.startsWith("conversation:")) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
return `user:${raw}`;
|
return `user:${raw}`;
|
||||||
})
|
})
|
||||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||||
@@ -195,7 +209,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
|
for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) {
|
||||||
for (const channelId of Object.keys(team.channels ?? {})) {
|
for (const channelId of Object.keys(team.channels ?? {})) {
|
||||||
const trimmed = channelId.trim();
|
const trimmed = channelId.trim();
|
||||||
if (trimmed && trimmed !== "*") ids.add(trimmed);
|
if (trimmed && trimmed !== "*") {
|
||||||
|
ids.add(trimmed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(ids)
|
return Array.from(ids)
|
||||||
@@ -249,7 +265,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
});
|
});
|
||||||
resolved.forEach((entry, idx) => {
|
resolved.forEach((entry, idx) => {
|
||||||
const target = results[pending[idx]?.index ?? -1];
|
const target = results[pending[idx]?.index ?? -1];
|
||||||
if (!target) return;
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
target.resolved = entry.resolved;
|
target.resolved = entry.resolved;
|
||||||
target.id = entry.id;
|
target.id = entry.id;
|
||||||
target.name = entry.name;
|
target.name = entry.name;
|
||||||
@@ -259,7 +277,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||||
pending.forEach(({ index }) => {
|
pending.forEach(({ index }) => {
|
||||||
const entry = results[index];
|
const entry = results[index];
|
||||||
if (entry) entry.note = "lookup failed";
|
if (entry) {
|
||||||
|
entry.note = "lookup failed";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,7 +318,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
});
|
});
|
||||||
resolved.forEach((entry, idx) => {
|
resolved.forEach((entry, idx) => {
|
||||||
const target = results[pending[idx]?.index ?? -1];
|
const target = results[pending[idx]?.index ?? -1];
|
||||||
if (!target) return;
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!entry.resolved || !entry.teamId) {
|
if (!entry.resolved || !entry.teamId) {
|
||||||
target.resolved = false;
|
target.resolved = false;
|
||||||
target.note = entry.note;
|
target.note = entry.note;
|
||||||
@@ -316,13 +338,17 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
target.name = entry.teamName;
|
target.name = entry.teamName;
|
||||||
target.note = "team id";
|
target.note = "team id";
|
||||||
}
|
}
|
||||||
if (entry.note) target.note = entry.note;
|
if (entry.note) {
|
||||||
|
target.note = entry.note;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
||||||
pending.forEach(({ index }) => {
|
pending.forEach(({ index }) => {
|
||||||
const entry = results[index];
|
const entry = results[index];
|
||||||
if (entry) entry.note = "lookup failed";
|
if (entry) {
|
||||||
|
entry.note = "lookup failed";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,7 +361,9 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
const enabled =
|
const enabled =
|
||||||
cfg.channels?.msteams?.enabled !== false &&
|
cfg.channels?.msteams?.enabled !== false &&
|
||||||
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams));
|
||||||
if (!enabled) return [];
|
if (!enabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return ["poll"] satisfies ChannelMessageActionName[];
|
return ["poll"] satisfies ChannelMessageActionName[];
|
||||||
},
|
},
|
||||||
supportsCards: ({ cfg }) => {
|
supportsCards: ({ cfg }) => {
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ const runtimeStub = {
|
|||||||
state: {
|
state: {
|
||||||
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
|
||||||
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
|
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
|
||||||
if (override) return override;
|
if (override) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
const resolvedHome = homedir ? homedir() : os.homedir();
|
const resolvedHome = homedir ? homedir() : os.homedir();
|
||||||
return path.join(resolvedHome, ".openclaw");
|
return path.join(resolvedHome, ".openclaw");
|
||||||
},
|
},
|
||||||
@@ -66,7 +68,7 @@ describe("msteams conversation store (fs)", () => {
|
|||||||
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`);
|
||||||
|
|
||||||
const list = await store.list();
|
const list = await store.list();
|
||||||
const ids = list.map((e) => e.conversationId).sort();
|
const ids = list.map((e) => e.conversationId).toSorted();
|
||||||
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
|
expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]);
|
||||||
|
|
||||||
expect(await store.get("19:old@thread.tacv2")).toBeNull();
|
expect(await store.get("19:old@thread.tacv2")).toBeNull();
|
||||||
@@ -79,7 +81,7 @@ describe("msteams conversation store (fs)", () => {
|
|||||||
|
|
||||||
const rawAfter = await fs.promises.readFile(filePath, "utf-8");
|
const rawAfter = await fs.promises.readFile(filePath, "utf-8");
|
||||||
const jsonAfter = JSON.parse(rawAfter) as typeof json;
|
const jsonAfter = JSON.parse(rawAfter) as typeof json;
|
||||||
expect(Object.keys(jsonAfter.conversations).sort()).toEqual([
|
expect(Object.keys(jsonAfter.conversations).toSorted()).toEqual([
|
||||||
"19:active@thread.tacv2",
|
"19:active@thread.tacv2",
|
||||||
"19:legacy@thread.tacv2",
|
"19:legacy@thread.tacv2",
|
||||||
"19:new@thread.tacv2",
|
"19:new@thread.tacv2",
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ const MAX_CONVERSATIONS = 1000;
|
|||||||
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function parseTimestamp(value: string | undefined): number | null {
|
function parseTimestamp(value: string | undefined): number | null {
|
||||||
if (!value) return null;
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const parsed = Date.parse(value);
|
const parsed = Date.parse(value);
|
||||||
if (!Number.isFinite(parsed)) return null;
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +30,9 @@ function pruneToLimit(
|
|||||||
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
conversations: Record<string, StoredConversationReference & { lastSeenAt?: string }>,
|
||||||
) {
|
) {
|
||||||
const entries = Object.entries(conversations);
|
const entries = Object.entries(conversations);
|
||||||
if (entries.length <= MAX_CONVERSATIONS) return conversations;
|
if (entries.length <= MAX_CONVERSATIONS) {
|
||||||
|
return conversations;
|
||||||
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => {
|
entries.sort((a, b) => {
|
||||||
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
|
const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0;
|
||||||
@@ -109,7 +115,9 @@ export function createMSTeamsConversationStoreFs(params?: {
|
|||||||
|
|
||||||
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
const findByUserId = async (id: string): Promise<MSTeamsConversationStoreEntry | null> => {
|
||||||
const target = id.trim();
|
const target = id.trim();
|
||||||
if (!target) return null;
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (const entry of await list()) {
|
for (const entry of await list()) {
|
||||||
const { conversationId, reference } = entry;
|
const { conversationId, reference } = entry;
|
||||||
if (reference.user?.aadObjectId === target) {
|
if (reference.user?.aadObjectId === target) {
|
||||||
@@ -144,7 +152,9 @@ export function createMSTeamsConversationStoreFs(params?: {
|
|||||||
const normalizedId = normalizeConversationId(conversationId);
|
const normalizedId = normalizeConversationId(conversationId);
|
||||||
return await withFileLock(filePath, empty, async () => {
|
return await withFileLock(filePath, empty, async () => {
|
||||||
const store = await readStore();
|
const store = await readStore();
|
||||||
if (!(normalizedId in store.conversations)) return false;
|
if (!(normalizedId in store.conversations)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
delete store.conversations[normalizedId];
|
delete store.conversations[normalizedId];
|
||||||
await writeJsonFile(filePath, store);
|
await writeJsonFile(filePath, store);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export function createMSTeamsConversationStoreMemory(
|
|||||||
},
|
},
|
||||||
findByUserId: async (id) => {
|
findByUserId: async (id) => {
|
||||||
const target = id.trim();
|
const target = id.trim();
|
||||||
if (!target) return null;
|
if (!target) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
for (const [conversationId, reference] of map.entries()) {
|
for (const [conversationId, reference] of map.entries()) {
|
||||||
if (reference.user?.aadObjectId === target) {
|
if (reference.user?.aadObjectId === target) {
|
||||||
return { conversationId, reference };
|
return { conversationId, reference };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user