mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 20:34:32 +00:00
refactor: split inbound and reload pipelines into staged modules
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
|||||||
HARD_MAX_TOOL_RESULT_CHARS,
|
HARD_MAX_TOOL_RESULT_CHARS,
|
||||||
truncateToolResultMessage,
|
truncateToolResultMessage,
|
||||||
} from "./pi-embedded-runner/tool-result-truncation.js";
|
} from "./pi-embedded-runner/tool-result-truncation.js";
|
||||||
|
import { createPendingToolCallState } from "./session-tool-result-state.js";
|
||||||
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
|
import { makeMissingToolResult, sanitizeToolCallInputs } from "./session-transcript-repair.js";
|
||||||
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
|
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ export function installSessionToolResultGuard(
|
|||||||
getPendingIds: () => string[];
|
getPendingIds: () => string[];
|
||||||
} {
|
} {
|
||||||
const originalAppend = sessionManager.appendMessage.bind(sessionManager);
|
const originalAppend = sessionManager.appendMessage.bind(sessionManager);
|
||||||
const pending = new Map<string, string | undefined>();
|
const pendingState = createPendingToolCallState();
|
||||||
const persistMessage = (message: AgentMessage) => {
|
const persistMessage = (message: AgentMessage) => {
|
||||||
const transformer = opts?.transformMessageForPersistence;
|
const transformer = opts?.transformMessageForPersistence;
|
||||||
return transformer ? transformer(message) : message;
|
return transformer ? transformer(message) : message;
|
||||||
@@ -142,11 +143,11 @@ export function installSessionToolResultGuard(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const flushPendingToolResults = () => {
|
const flushPendingToolResults = () => {
|
||||||
if (pending.size === 0) {
|
if (pendingState.size() === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (allowSyntheticToolResults) {
|
if (allowSyntheticToolResults) {
|
||||||
for (const [id, name] of pending.entries()) {
|
for (const [id, name] of pendingState.entries()) {
|
||||||
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
const synthetic = makeMissingToolResult({ toolCallId: id, toolName: name });
|
||||||
const flushed = applyBeforeWriteHook(
|
const flushed = applyBeforeWriteHook(
|
||||||
persistToolResult(persistMessage(synthetic), {
|
persistToolResult(persistMessage(synthetic), {
|
||||||
@@ -160,7 +161,7 @@ export function installSessionToolResultGuard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pending.clear();
|
pendingState.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
const guardedAppend = (message: AgentMessage) => {
|
const guardedAppend = (message: AgentMessage) => {
|
||||||
@@ -171,7 +172,7 @@ export function installSessionToolResultGuard(
|
|||||||
allowedToolNames: opts?.allowedToolNames,
|
allowedToolNames: opts?.allowedToolNames,
|
||||||
});
|
});
|
||||||
if (sanitized.length === 0) {
|
if (sanitized.length === 0) {
|
||||||
if (pending.size > 0) {
|
if (pendingState.shouldFlushForSanitizedDrop()) {
|
||||||
flushPendingToolResults();
|
flushPendingToolResults();
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -182,9 +183,9 @@ export function installSessionToolResultGuard(
|
|||||||
|
|
||||||
if (nextRole === "toolResult") {
|
if (nextRole === "toolResult") {
|
||||||
const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>);
|
const id = extractToolResultId(nextMessage as Extract<AgentMessage, { role: "toolResult" }>);
|
||||||
const toolName = id ? pending.get(id) : undefined;
|
const toolName = id ? pendingState.getToolName(id) : undefined;
|
||||||
if (id) {
|
if (id) {
|
||||||
pending.delete(id);
|
pendingState.delete(id);
|
||||||
}
|
}
|
||||||
const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName);
|
const normalizedToolResult = normalizePersistedToolResultName(nextMessage, toolName);
|
||||||
// Apply hard size cap before persistence to prevent oversized tool results
|
// Apply hard size cap before persistence to prevent oversized tool results
|
||||||
@@ -221,11 +222,11 @@ export function installSessionToolResultGuard(
|
|||||||
// synthetic results (e.g. OpenAI) accumulate stale pending state when a user message
|
// synthetic results (e.g. OpenAI) accumulate stale pending state when a user message
|
||||||
// interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript
|
// interrupts in-flight tool calls, leaving orphaned tool_use blocks in the transcript
|
||||||
// that cause API 400 errors on subsequent requests.
|
// that cause API 400 errors on subsequent requests.
|
||||||
if (pending.size > 0 && (toolCalls.length === 0 || nextRole !== "assistant")) {
|
if (pendingState.shouldFlushBeforeNonToolResult(nextRole, toolCalls.length)) {
|
||||||
flushPendingToolResults();
|
flushPendingToolResults();
|
||||||
}
|
}
|
||||||
// If new tool calls arrive while older ones are pending, flush the old ones first.
|
// If new tool calls arrive while older ones are pending, flush the old ones first.
|
||||||
if (pending.size > 0 && toolCalls.length > 0) {
|
if (pendingState.shouldFlushBeforeNewToolCalls(toolCalls.length)) {
|
||||||
flushPendingToolResults();
|
flushPendingToolResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,9 +244,7 @@ export function installSessionToolResultGuard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
if (toolCalls.length > 0) {
|
||||||
for (const call of toolCalls) {
|
pendingState.trackToolCalls(toolCalls);
|
||||||
pending.set(call.id, call.name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -256,6 +255,6 @@ export function installSessionToolResultGuard(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
flushPendingToolResults,
|
flushPendingToolResults,
|
||||||
getPendingIds: () => Array.from(pending.keys()),
|
getPendingIds: pendingState.getPendingIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/agents/session-tool-result-state.ts
Normal file
40
src/agents/session-tool-result-state.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type PendingToolCall = { id: string; name?: string };
|
||||||
|
|
||||||
|
export type PendingToolCallState = {
|
||||||
|
size: () => number;
|
||||||
|
entries: () => IterableIterator<[string, string | undefined]>;
|
||||||
|
getToolName: (id: string) => string | undefined;
|
||||||
|
delete: (id: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
trackToolCalls: (calls: PendingToolCall[]) => void;
|
||||||
|
getPendingIds: () => string[];
|
||||||
|
shouldFlushForSanitizedDrop: () => boolean;
|
||||||
|
shouldFlushBeforeNonToolResult: (nextRole: unknown, toolCallCount: number) => boolean;
|
||||||
|
shouldFlushBeforeNewToolCalls: (toolCallCount: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createPendingToolCallState(): PendingToolCallState {
|
||||||
|
const pending = new Map<string, string | undefined>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: () => pending.size,
|
||||||
|
entries: () => pending.entries(),
|
||||||
|
getToolName: (id: string) => pending.get(id),
|
||||||
|
delete: (id: string) => {
|
||||||
|
pending.delete(id);
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
pending.clear();
|
||||||
|
},
|
||||||
|
trackToolCalls: (calls: PendingToolCall[]) => {
|
||||||
|
for (const call of calls) {
|
||||||
|
pending.set(call.id, call.name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getPendingIds: () => Array.from(pending.keys()),
|
||||||
|
shouldFlushForSanitizedDrop: () => pending.size > 0,
|
||||||
|
shouldFlushBeforeNonToolResult: (nextRole: unknown, toolCallCount: number) =>
|
||||||
|
pending.size > 0 && (toolCallCount === 0 || nextRole !== "assistant"),
|
||||||
|
shouldFlushBeforeNewToolCalls: (toolCallCount: number) => pending.size > 0 && toolCallCount > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
210
src/gateway/config-reload-plan.ts
Normal file
210
src/gateway/config-reload-plan.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
|
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
|
|
||||||
|
export type ChannelKind = ChannelId;
|
||||||
|
|
||||||
|
export type GatewayReloadPlan = {
|
||||||
|
changedPaths: string[];
|
||||||
|
restartGateway: boolean;
|
||||||
|
restartReasons: string[];
|
||||||
|
hotReasons: string[];
|
||||||
|
reloadHooks: boolean;
|
||||||
|
restartGmailWatcher: boolean;
|
||||||
|
restartBrowserControl: boolean;
|
||||||
|
restartCron: boolean;
|
||||||
|
restartHeartbeat: boolean;
|
||||||
|
restartHealthMonitor: boolean;
|
||||||
|
restartChannels: Set<ChannelKind>;
|
||||||
|
noopPaths: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReloadRule = {
|
||||||
|
prefix: string;
|
||||||
|
kind: "restart" | "hot" | "none";
|
||||||
|
actions?: ReloadAction[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReloadAction =
|
||||||
|
| "reload-hooks"
|
||||||
|
| "restart-gmail-watcher"
|
||||||
|
| "restart-browser-control"
|
||||||
|
| "restart-cron"
|
||||||
|
| "restart-heartbeat"
|
||||||
|
| "restart-health-monitor"
|
||||||
|
| `restart-channel:${ChannelId}`;
|
||||||
|
|
||||||
|
const BASE_RELOAD_RULES: ReloadRule[] = [
|
||||||
|
{ prefix: "gateway.remote", kind: "none" },
|
||||||
|
{ prefix: "gateway.reload", kind: "none" },
|
||||||
|
{
|
||||||
|
prefix: "gateway.channelHealthCheckMinutes",
|
||||||
|
kind: "hot",
|
||||||
|
actions: ["restart-health-monitor"],
|
||||||
|
},
|
||||||
|
// Stuck-session warning threshold is read by the diagnostics heartbeat loop.
|
||||||
|
{ prefix: "diagnostics.stuckSessionWarnMs", kind: "none" },
|
||||||
|
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
|
||||||
|
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
|
||||||
|
{
|
||||||
|
prefix: "agents.defaults.heartbeat",
|
||||||
|
kind: "hot",
|
||||||
|
actions: ["restart-heartbeat"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "agents.defaults.model",
|
||||||
|
kind: "hot",
|
||||||
|
actions: ["restart-heartbeat"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prefix: "models",
|
||||||
|
kind: "hot",
|
||||||
|
actions: ["restart-heartbeat"],
|
||||||
|
},
|
||||||
|
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
|
||||||
|
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
|
||||||
|
{
|
||||||
|
prefix: "browser",
|
||||||
|
kind: "hot",
|
||||||
|
actions: ["restart-browser-control"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
|
||||||
|
{ prefix: "meta", kind: "none" },
|
||||||
|
{ prefix: "identity", kind: "none" },
|
||||||
|
{ prefix: "wizard", kind: "none" },
|
||||||
|
{ prefix: "logging", kind: "none" },
|
||||||
|
{ prefix: "agents", kind: "none" },
|
||||||
|
{ prefix: "tools", kind: "none" },
|
||||||
|
{ prefix: "bindings", kind: "none" },
|
||||||
|
{ prefix: "audio", kind: "none" },
|
||||||
|
{ prefix: "agent", kind: "none" },
|
||||||
|
{ prefix: "routing", kind: "none" },
|
||||||
|
{ prefix: "messages", kind: "none" },
|
||||||
|
{ prefix: "session", kind: "none" },
|
||||||
|
{ prefix: "talk", kind: "none" },
|
||||||
|
{ prefix: "skills", kind: "none" },
|
||||||
|
{ prefix: "secrets", kind: "none" },
|
||||||
|
{ prefix: "plugins", kind: "restart" },
|
||||||
|
{ prefix: "ui", kind: "none" },
|
||||||
|
{ prefix: "gateway", kind: "restart" },
|
||||||
|
{ prefix: "discovery", kind: "restart" },
|
||||||
|
{ prefix: "canvasHost", kind: "restart" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedReloadRules: ReloadRule[] | null = null;
|
||||||
|
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||||
|
|
||||||
|
function listReloadRules(): ReloadRule[] {
|
||||||
|
const registry = getActivePluginRegistry();
|
||||||
|
if (registry !== cachedRegistry) {
|
||||||
|
cachedReloadRules = null;
|
||||||
|
cachedRegistry = registry;
|
||||||
|
}
|
||||||
|
if (cachedReloadRules) {
|
||||||
|
return cachedReloadRules;
|
||||||
|
}
|
||||||
|
// Channel docking: plugins contribute hot reload/no-op prefixes here.
|
||||||
|
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
|
||||||
|
...(plugin.reload?.configPrefixes ?? []).map(
|
||||||
|
(prefix): ReloadRule => ({
|
||||||
|
prefix,
|
||||||
|
kind: "hot",
|
||||||
|
actions: [`restart-channel:${plugin.id}` as ReloadAction],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...(plugin.reload?.noopPrefixes ?? []).map(
|
||||||
|
(prefix): ReloadRule => ({
|
||||||
|
prefix,
|
||||||
|
kind: "none",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
|
||||||
|
cachedReloadRules = rules;
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchRule(path: string): ReloadRule | null {
|
||||||
|
for (const rule of listReloadRules()) {
|
||||||
|
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) {
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
|
||||||
|
const plan: GatewayReloadPlan = {
|
||||||
|
changedPaths,
|
||||||
|
restartGateway: false,
|
||||||
|
restartReasons: [],
|
||||||
|
hotReasons: [],
|
||||||
|
reloadHooks: false,
|
||||||
|
restartGmailWatcher: false,
|
||||||
|
restartBrowserControl: false,
|
||||||
|
restartCron: false,
|
||||||
|
restartHeartbeat: false,
|
||||||
|
restartHealthMonitor: false,
|
||||||
|
restartChannels: new Set(),
|
||||||
|
noopPaths: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAction = (action: ReloadAction) => {
|
||||||
|
if (action.startsWith("restart-channel:")) {
|
||||||
|
const channel = action.slice("restart-channel:".length) as ChannelId;
|
||||||
|
plan.restartChannels.add(channel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (action) {
|
||||||
|
case "reload-hooks":
|
||||||
|
plan.reloadHooks = true;
|
||||||
|
break;
|
||||||
|
case "restart-gmail-watcher":
|
||||||
|
plan.restartGmailWatcher = true;
|
||||||
|
break;
|
||||||
|
case "restart-browser-control":
|
||||||
|
plan.restartBrowserControl = true;
|
||||||
|
break;
|
||||||
|
case "restart-cron":
|
||||||
|
plan.restartCron = true;
|
||||||
|
break;
|
||||||
|
case "restart-heartbeat":
|
||||||
|
plan.restartHeartbeat = true;
|
||||||
|
break;
|
||||||
|
case "restart-health-monitor":
|
||||||
|
plan.restartHealthMonitor = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const path of changedPaths) {
|
||||||
|
const rule = matchRule(path);
|
||||||
|
if (!rule) {
|
||||||
|
plan.restartGateway = true;
|
||||||
|
plan.restartReasons.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.kind === "restart") {
|
||||||
|
plan.restartGateway = true;
|
||||||
|
plan.restartReasons.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.kind === "none") {
|
||||||
|
plan.noopPaths.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
plan.hotReasons.push(path);
|
||||||
|
for (const action of rule.actions ?? []) {
|
||||||
|
applyAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.restartGmailWatcher) {
|
||||||
|
plan.reloadHooks = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
@@ -188,6 +188,53 @@ describe("buildGatewayReloadPlan", () => {
|
|||||||
const plan = buildGatewayReloadPlan(["unknownField"]);
|
const plan = buildGatewayReloadPlan(["unknownField"]);
|
||||||
expect(plan.restartGateway).toBe(true);
|
expect(plan.restartGateway).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
path: "gateway.channelHealthCheckMinutes",
|
||||||
|
expectRestartGateway: false,
|
||||||
|
expectHotPath: "gateway.channelHealthCheckMinutes",
|
||||||
|
expectRestartHealthMonitor: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "hooks.gmail.account",
|
||||||
|
expectRestartGateway: false,
|
||||||
|
expectHotPath: "hooks.gmail.account",
|
||||||
|
expectRestartGmailWatcher: true,
|
||||||
|
expectReloadHooks: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "gateway.remote.url",
|
||||||
|
expectRestartGateway: false,
|
||||||
|
expectNoopPath: "gateway.remote.url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "unknownField",
|
||||||
|
expectRestartGateway: true,
|
||||||
|
expectRestartReason: "unknownField",
|
||||||
|
},
|
||||||
|
])("classifies reload path: $path", (testCase) => {
|
||||||
|
const plan = buildGatewayReloadPlan([testCase.path]);
|
||||||
|
expect(plan.restartGateway).toBe(testCase.expectRestartGateway);
|
||||||
|
if (testCase.expectHotPath) {
|
||||||
|
expect(plan.hotReasons).toContain(testCase.expectHotPath);
|
||||||
|
}
|
||||||
|
if (testCase.expectNoopPath) {
|
||||||
|
expect(plan.noopPaths).toContain(testCase.expectNoopPath);
|
||||||
|
}
|
||||||
|
if (testCase.expectRestartReason) {
|
||||||
|
expect(plan.restartReasons).toContain(testCase.expectRestartReason);
|
||||||
|
}
|
||||||
|
if (testCase.expectRestartHealthMonitor) {
|
||||||
|
expect(plan.restartHealthMonitor).toBe(true);
|
||||||
|
}
|
||||||
|
if (testCase.expectRestartGmailWatcher) {
|
||||||
|
expect(plan.restartGmailWatcher).toBe(true);
|
||||||
|
}
|
||||||
|
if (testCase.expectReloadHooks) {
|
||||||
|
expect(plan.reloadHooks).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveGatewayReloadSettings", () => {
|
describe("resolveGatewayReloadSettings", () => {
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
import { isDeepStrictEqual } from "node:util";
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import chokidar from "chokidar";
|
import chokidar from "chokidar";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
|
||||||
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
|
import type { OpenClawConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
|
||||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
|
||||||
import { isPlainObject } from "../utils.js";
|
import { isPlainObject } from "../utils.js";
|
||||||
|
import { buildGatewayReloadPlan, type GatewayReloadPlan } from "./config-reload-plan.js";
|
||||||
|
|
||||||
|
export { buildGatewayReloadPlan };
|
||||||
|
export type { GatewayReloadPlan } from "./config-reload-plan.js";
|
||||||
|
|
||||||
export type GatewayReloadSettings = {
|
export type GatewayReloadSettings = {
|
||||||
mode: GatewayReloadMode;
|
mode: GatewayReloadMode;
|
||||||
debounceMs: number;
|
debounceMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChannelKind = ChannelId;
|
|
||||||
|
|
||||||
export type GatewayReloadPlan = {
|
|
||||||
changedPaths: string[];
|
|
||||||
restartGateway: boolean;
|
|
||||||
restartReasons: string[];
|
|
||||||
hotReasons: string[];
|
|
||||||
reloadHooks: boolean;
|
|
||||||
restartGmailWatcher: boolean;
|
|
||||||
restartBrowserControl: boolean;
|
|
||||||
restartCron: boolean;
|
|
||||||
restartHeartbeat: boolean;
|
|
||||||
restartHealthMonitor: boolean;
|
|
||||||
restartChannels: Set<ChannelKind>;
|
|
||||||
noopPaths: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReloadRule = {
|
|
||||||
prefix: string;
|
|
||||||
kind: "restart" | "hot" | "none";
|
|
||||||
actions?: ReloadAction[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ReloadAction =
|
|
||||||
| "reload-hooks"
|
|
||||||
| "restart-gmail-watcher"
|
|
||||||
| "restart-browser-control"
|
|
||||||
| "restart-cron"
|
|
||||||
| "restart-heartbeat"
|
|
||||||
| "restart-health-monitor"
|
|
||||||
| `restart-channel:${ChannelId}`;
|
|
||||||
|
|
||||||
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
|
const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
|
||||||
mode: "hybrid",
|
mode: "hybrid",
|
||||||
debounceMs: 300,
|
debounceMs: 300,
|
||||||
@@ -49,107 +19,6 @@ const DEFAULT_RELOAD_SETTINGS: GatewayReloadSettings = {
|
|||||||
const MISSING_CONFIG_RETRY_DELAY_MS = 150;
|
const MISSING_CONFIG_RETRY_DELAY_MS = 150;
|
||||||
const MISSING_CONFIG_MAX_RETRIES = 2;
|
const MISSING_CONFIG_MAX_RETRIES = 2;
|
||||||
|
|
||||||
const BASE_RELOAD_RULES: ReloadRule[] = [
|
|
||||||
{ prefix: "gateway.remote", kind: "none" },
|
|
||||||
{ prefix: "gateway.reload", kind: "none" },
|
|
||||||
{
|
|
||||||
prefix: "gateway.channelHealthCheckMinutes",
|
|
||||||
kind: "hot",
|
|
||||||
actions: ["restart-health-monitor"],
|
|
||||||
},
|
|
||||||
// Stuck-session warning threshold is read by the diagnostics heartbeat loop.
|
|
||||||
{ prefix: "diagnostics.stuckSessionWarnMs", kind: "none" },
|
|
||||||
{ prefix: "hooks.gmail", kind: "hot", actions: ["restart-gmail-watcher"] },
|
|
||||||
{ prefix: "hooks", kind: "hot", actions: ["reload-hooks"] },
|
|
||||||
{
|
|
||||||
prefix: "agents.defaults.heartbeat",
|
|
||||||
kind: "hot",
|
|
||||||
actions: ["restart-heartbeat"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefix: "agents.defaults.model",
|
|
||||||
kind: "hot",
|
|
||||||
actions: ["restart-heartbeat"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
prefix: "models",
|
|
||||||
kind: "hot",
|
|
||||||
actions: ["restart-heartbeat"],
|
|
||||||
},
|
|
||||||
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
|
|
||||||
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
|
|
||||||
{
|
|
||||||
prefix: "browser",
|
|
||||||
kind: "hot",
|
|
||||||
actions: ["restart-browser-control"],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
|
|
||||||
{ prefix: "meta", kind: "none" },
|
|
||||||
{ prefix: "identity", kind: "none" },
|
|
||||||
{ prefix: "wizard", kind: "none" },
|
|
||||||
{ prefix: "logging", kind: "none" },
|
|
||||||
{ prefix: "agents", kind: "none" },
|
|
||||||
{ prefix: "tools", kind: "none" },
|
|
||||||
{ prefix: "bindings", kind: "none" },
|
|
||||||
{ prefix: "audio", kind: "none" },
|
|
||||||
{ prefix: "agent", kind: "none" },
|
|
||||||
{ prefix: "routing", kind: "none" },
|
|
||||||
{ prefix: "messages", kind: "none" },
|
|
||||||
{ prefix: "session", kind: "none" },
|
|
||||||
{ prefix: "talk", kind: "none" },
|
|
||||||
{ prefix: "skills", kind: "none" },
|
|
||||||
{ prefix: "secrets", kind: "none" },
|
|
||||||
{ prefix: "plugins", kind: "restart" },
|
|
||||||
{ prefix: "ui", kind: "none" },
|
|
||||||
{ prefix: "gateway", kind: "restart" },
|
|
||||||
{ prefix: "discovery", kind: "restart" },
|
|
||||||
{ prefix: "canvasHost", kind: "restart" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let cachedReloadRules: ReloadRule[] | null = null;
|
|
||||||
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
|
||||||
|
|
||||||
function listReloadRules(): ReloadRule[] {
|
|
||||||
const registry = getActivePluginRegistry();
|
|
||||||
if (registry !== cachedRegistry) {
|
|
||||||
cachedReloadRules = null;
|
|
||||||
cachedRegistry = registry;
|
|
||||||
}
|
|
||||||
if (cachedReloadRules) {
|
|
||||||
return cachedReloadRules;
|
|
||||||
}
|
|
||||||
// Channel docking: plugins contribute hot reload/no-op prefixes here.
|
|
||||||
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
|
|
||||||
...(plugin.reload?.configPrefixes ?? []).map(
|
|
||||||
(prefix): ReloadRule => ({
|
|
||||||
prefix,
|
|
||||||
kind: "hot",
|
|
||||||
actions: [`restart-channel:${plugin.id}` as ReloadAction],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...(plugin.reload?.noopPrefixes ?? []).map(
|
|
||||||
(prefix): ReloadRule => ({
|
|
||||||
prefix,
|
|
||||||
kind: "none",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
|
|
||||||
cachedReloadRules = rules;
|
|
||||||
return rules;
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchRule(path: string): ReloadRule | null {
|
|
||||||
for (const rule of listReloadRules()) {
|
|
||||||
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) {
|
|
||||||
return rule;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
|
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
|
||||||
if (prev === next) {
|
if (prev === next) {
|
||||||
return [];
|
return [];
|
||||||
@@ -195,81 +64,6 @@ export function resolveGatewayReloadSettings(cfg: OpenClawConfig): GatewayReload
|
|||||||
return { mode, debounceMs };
|
return { mode, debounceMs };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
|
|
||||||
const plan: GatewayReloadPlan = {
|
|
||||||
changedPaths,
|
|
||||||
restartGateway: false,
|
|
||||||
restartReasons: [],
|
|
||||||
hotReasons: [],
|
|
||||||
reloadHooks: false,
|
|
||||||
restartGmailWatcher: false,
|
|
||||||
restartBrowserControl: false,
|
|
||||||
restartCron: false,
|
|
||||||
restartHeartbeat: false,
|
|
||||||
restartHealthMonitor: false,
|
|
||||||
restartChannels: new Set(),
|
|
||||||
noopPaths: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyAction = (action: ReloadAction) => {
|
|
||||||
if (action.startsWith("restart-channel:")) {
|
|
||||||
const channel = action.slice("restart-channel:".length) as ChannelId;
|
|
||||||
plan.restartChannels.add(channel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (action) {
|
|
||||||
case "reload-hooks":
|
|
||||||
plan.reloadHooks = true;
|
|
||||||
break;
|
|
||||||
case "restart-gmail-watcher":
|
|
||||||
plan.restartGmailWatcher = true;
|
|
||||||
break;
|
|
||||||
case "restart-browser-control":
|
|
||||||
plan.restartBrowserControl = true;
|
|
||||||
break;
|
|
||||||
case "restart-cron":
|
|
||||||
plan.restartCron = true;
|
|
||||||
break;
|
|
||||||
case "restart-heartbeat":
|
|
||||||
plan.restartHeartbeat = true;
|
|
||||||
break;
|
|
||||||
case "restart-health-monitor":
|
|
||||||
plan.restartHealthMonitor = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const path of changedPaths) {
|
|
||||||
const rule = matchRule(path);
|
|
||||||
if (!rule) {
|
|
||||||
plan.restartGateway = true;
|
|
||||||
plan.restartReasons.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (rule.kind === "restart") {
|
|
||||||
plan.restartGateway = true;
|
|
||||||
plan.restartReasons.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (rule.kind === "none") {
|
|
||||||
plan.noopPaths.push(path);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
plan.hotReasons.push(path);
|
|
||||||
for (const action of rule.actions ?? []) {
|
|
||||||
applyAction(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.restartGmailWatcher) {
|
|
||||||
plan.reloadHooks = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return plan;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GatewayConfigReloader = {
|
export type GatewayConfigReloader = {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
83
src/infra/exec-allowlist-pattern.ts
Normal file
83
src/infra/exec-allowlist-pattern.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { expandHomePrefix } from "./home-dir.js";
|
||||||
|
|
||||||
|
const GLOB_REGEX_CACHE_LIMIT = 512;
|
||||||
|
const globRegexCache = new Map<string, RegExp>();
|
||||||
|
|
||||||
|
function normalizeMatchTarget(value: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const stripped = value.replace(/^\\\\[?.]\\/, "");
|
||||||
|
return stripped.replace(/\\/g, "/").toLowerCase();
|
||||||
|
}
|
||||||
|
return value.replace(/\\\\/g, "/").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryRealpath(value: string): string | null {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExpLiteral(input: string): string {
|
||||||
|
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileGlobRegex(pattern: string): RegExp {
|
||||||
|
const cached = globRegexCache.get(pattern);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
let regex = "^";
|
||||||
|
let i = 0;
|
||||||
|
while (i < pattern.length) {
|
||||||
|
const ch = pattern[i];
|
||||||
|
if (ch === "*") {
|
||||||
|
const next = pattern[i + 1];
|
||||||
|
if (next === "*") {
|
||||||
|
regex += ".*";
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
regex += "[^/]*";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "?") {
|
||||||
|
regex += ".";
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
regex += escapeRegExpLiteral(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
regex += "$";
|
||||||
|
|
||||||
|
const compiled = new RegExp(regex, "i");
|
||||||
|
if (globRegexCache.size >= GLOB_REGEX_CACHE_LIMIT) {
|
||||||
|
globRegexCache.clear();
|
||||||
|
}
|
||||||
|
globRegexCache.set(pattern, compiled);
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesExecAllowlistPattern(pattern: string, target: string): boolean {
|
||||||
|
const trimmed = pattern.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed;
|
||||||
|
const hasWildcard = /[*?]/.test(expanded);
|
||||||
|
let normalizedPattern = expanded;
|
||||||
|
let normalizedTarget = target;
|
||||||
|
if (process.platform === "win32" && !hasWildcard) {
|
||||||
|
normalizedPattern = tryRealpath(expanded) ?? expanded;
|
||||||
|
normalizedTarget = tryRealpath(target) ?? target;
|
||||||
|
}
|
||||||
|
normalizedPattern = normalizeMatchTarget(normalizedPattern);
|
||||||
|
normalizedTarget = normalizeMatchTarget(normalizedTarget);
|
||||||
|
return compileGlobRegex(normalizedPattern).test(normalizedTarget);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { matchesExecAllowlistPattern } from "./exec-allowlist-pattern.js";
|
||||||
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
import type { ExecAllowlistEntry } from "./exec-approvals.js";
|
||||||
import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js";
|
import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js";
|
||||||
import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js";
|
import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js";
|
||||||
@@ -114,73 +115,6 @@ export function resolveCommandResolutionFromArgv(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMatchTarget(value: string): string {
|
|
||||||
if (process.platform === "win32") {
|
|
||||||
const stripped = value.replace(/^\\\\[?.]\\/, "");
|
|
||||||
return stripped.replace(/\\/g, "/").toLowerCase();
|
|
||||||
}
|
|
||||||
return value.replace(/\\\\/g, "/").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryRealpath(value: string): string | null {
|
|
||||||
try {
|
|
||||||
return fs.realpathSync(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegExpLiteral(input: string): string {
|
|
||||||
return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function globToRegExp(pattern: string): RegExp {
|
|
||||||
let regex = "^";
|
|
||||||
let i = 0;
|
|
||||||
while (i < pattern.length) {
|
|
||||||
const ch = pattern[i];
|
|
||||||
if (ch === "*") {
|
|
||||||
const next = pattern[i + 1];
|
|
||||||
if (next === "*") {
|
|
||||||
regex += ".*";
|
|
||||||
i += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
regex += "[^/]*";
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === "?") {
|
|
||||||
regex += ".";
|
|
||||||
i += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
regex += escapeRegExpLiteral(ch);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
regex += "$";
|
|
||||||
return new RegExp(regex, "i");
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesPattern(pattern: string, target: string): boolean {
|
|
||||||
const trimmed = pattern.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const expanded = trimmed.startsWith("~") ? expandHomePrefix(trimmed) : trimmed;
|
|
||||||
const hasWildcard = /[*?]/.test(expanded);
|
|
||||||
let normalizedPattern = expanded;
|
|
||||||
let normalizedTarget = target;
|
|
||||||
if (process.platform === "win32" && !hasWildcard) {
|
|
||||||
normalizedPattern = tryRealpath(expanded) ?? expanded;
|
|
||||||
normalizedTarget = tryRealpath(target) ?? target;
|
|
||||||
}
|
|
||||||
normalizedPattern = normalizeMatchTarget(normalizedPattern);
|
|
||||||
normalizedTarget = normalizeMatchTarget(normalizedTarget);
|
|
||||||
const regex = globToRegExp(normalizedPattern);
|
|
||||||
return regex.test(normalizedTarget);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveAllowlistCandidatePath(
|
export function resolveAllowlistCandidatePath(
|
||||||
resolution: CommandResolution | null,
|
resolution: CommandResolution | null,
|
||||||
cwd?: string,
|
cwd?: string,
|
||||||
@@ -233,7 +167,7 @@ export function matchAllowlist(
|
|||||||
if (!hasPath) {
|
if (!hasPath) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (matchesPattern(pattern, resolvedPath)) {
|
if (matchesExecAllowlistPattern(pattern, resolvedPath)) {
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,13 +77,47 @@ function resolveCachedMentionRegexes(
|
|||||||
return built;
|
return built;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function prepareSlackMessage(params: {
|
type SlackConversationContext = {
|
||||||
|
channelInfo: {
|
||||||
|
name?: string;
|
||||||
|
type?: SlackMessageEvent["channel_type"];
|
||||||
|
topic?: string;
|
||||||
|
purpose?: string;
|
||||||
|
};
|
||||||
|
channelName?: string;
|
||||||
|
resolvedChannelType: ReturnType<typeof normalizeSlackChannelType>;
|
||||||
|
isDirectMessage: boolean;
|
||||||
|
isGroupDm: boolean;
|
||||||
|
isRoom: boolean;
|
||||||
|
isRoomish: boolean;
|
||||||
|
channelConfig: ReturnType<typeof resolveSlackChannelConfig> | null;
|
||||||
|
allowBots: boolean;
|
||||||
|
isBotMessage: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlackAuthorizationContext = {
|
||||||
|
senderId: string;
|
||||||
|
allowFromLower: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlackRoutingContext = {
|
||||||
|
route: ReturnType<typeof resolveAgentRoute>;
|
||||||
|
chatType: "direct" | "group" | "channel";
|
||||||
|
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
|
||||||
|
threadContext: ReturnType<typeof resolveSlackThreadContext>;
|
||||||
|
threadTs: string | undefined;
|
||||||
|
isThreadReply: boolean;
|
||||||
|
threadKeys: ReturnType<typeof resolveThreadSessionKeys>;
|
||||||
|
sessionKey: string;
|
||||||
|
historyKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveSlackConversationContext(params: {
|
||||||
ctx: SlackMonitorContext;
|
ctx: SlackMonitorContext;
|
||||||
account: ResolvedSlackAccount;
|
account: ResolvedSlackAccount;
|
||||||
message: SlackMessageEvent;
|
message: SlackMessageEvent;
|
||||||
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
|
}): Promise<SlackConversationContext> {
|
||||||
}): Promise<PreparedSlackMessage | null> {
|
const { ctx, account, message } = params;
|
||||||
const { ctx, account, message, opts } = params;
|
|
||||||
const cfg = ctx.cfg;
|
const cfg = ctx.cfg;
|
||||||
|
|
||||||
let channelInfo: {
|
let channelInfo: {
|
||||||
@@ -107,7 +141,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
const isGroupDm = resolvedChannelType === "mpim";
|
const isGroupDm = resolvedChannelType === "mpim";
|
||||||
const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group";
|
const isRoom = resolvedChannelType === "channel" || resolvedChannelType === "group";
|
||||||
const isRoomish = isRoom || isGroupDm;
|
const isRoomish = isRoom || isGroupDm;
|
||||||
|
|
||||||
const channelConfig = isRoom
|
const channelConfig = isRoom
|
||||||
? resolveSlackChannelConfig({
|
? resolveSlackChannelConfig({
|
||||||
channelId: message.channel,
|
channelId: message.channel,
|
||||||
@@ -117,14 +150,36 @@ export async function prepareSlackMessage(params: {
|
|||||||
defaultRequireMention: ctx.defaultRequireMention,
|
defaultRequireMention: ctx.defaultRequireMention,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const allowBots =
|
const allowBots =
|
||||||
channelConfig?.allowBots ??
|
channelConfig?.allowBots ??
|
||||||
account.config?.allowBots ??
|
account.config?.allowBots ??
|
||||||
cfg.channels?.slack?.allowBots ??
|
cfg.channels?.slack?.allowBots ??
|
||||||
false;
|
false;
|
||||||
|
|
||||||
const isBotMessage = Boolean(message.bot_id);
|
return {
|
||||||
|
channelInfo,
|
||||||
|
channelName,
|
||||||
|
resolvedChannelType,
|
||||||
|
isDirectMessage,
|
||||||
|
isGroupDm,
|
||||||
|
isRoom,
|
||||||
|
isRoomish,
|
||||||
|
channelConfig,
|
||||||
|
allowBots,
|
||||||
|
isBotMessage: Boolean(message.bot_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeSlackInboundMessage(params: {
|
||||||
|
ctx: SlackMonitorContext;
|
||||||
|
account: ResolvedSlackAccount;
|
||||||
|
message: SlackMessageEvent;
|
||||||
|
conversation: SlackConversationContext;
|
||||||
|
}): Promise<SlackAuthorizationContext | null> {
|
||||||
|
const { ctx, account, message, conversation } = params;
|
||||||
|
const { isDirectMessage, channelName, resolvedChannelType, isBotMessage, allowBots } =
|
||||||
|
conversation;
|
||||||
|
|
||||||
if (isBotMessage) {
|
if (isBotMessage) {
|
||||||
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
|
if (message.user && ctx.botUserId && message.user === ctx.botUserId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -195,8 +250,24 @@ export async function prepareSlackMessage(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
senderId,
|
||||||
|
allowFromLower,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlackRoutingContext(params: {
|
||||||
|
ctx: SlackMonitorContext;
|
||||||
|
account: ResolvedSlackAccount;
|
||||||
|
message: SlackMessageEvent;
|
||||||
|
isDirectMessage: boolean;
|
||||||
|
isGroupDm: boolean;
|
||||||
|
isRoom: boolean;
|
||||||
|
isRoomish: boolean;
|
||||||
|
}): SlackRoutingContext {
|
||||||
|
const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg: ctx.cfg,
|
||||||
channel: "slack",
|
channel: "slack",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
teamId: ctx.teamId || undefined,
|
teamId: ctx.teamId || undefined,
|
||||||
@@ -206,7 +277,6 @@ export async function prepareSlackMessage(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseSessionKey = route.sessionKey;
|
|
||||||
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
|
const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel";
|
||||||
const replyToMode = resolveSlackReplyToMode(account, chatType);
|
const replyToMode = resolveSlackReplyToMode(account, chatType);
|
||||||
const threadContext = resolveSlackThreadContext({ message, replyToMode });
|
const threadContext = resolveSlackThreadContext({ message, replyToMode });
|
||||||
@@ -224,14 +294,76 @@ export async function prepareSlackMessage(params: {
|
|||||||
? threadTs
|
? threadTs
|
||||||
: autoThreadId;
|
: autoThreadId;
|
||||||
const threadKeys = resolveThreadSessionKeys({
|
const threadKeys = resolveThreadSessionKeys({
|
||||||
baseSessionKey,
|
baseSessionKey: route.sessionKey,
|
||||||
threadId: canonicalThreadId,
|
threadId: canonicalThreadId,
|
||||||
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? baseSessionKey : undefined,
|
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined,
|
||||||
});
|
});
|
||||||
const sessionKey = threadKeys.sessionKey;
|
const sessionKey = threadKeys.sessionKey;
|
||||||
const historyKey =
|
const historyKey =
|
||||||
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
|
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
|
||||||
|
|
||||||
|
return {
|
||||||
|
route,
|
||||||
|
chatType,
|
||||||
|
replyToMode,
|
||||||
|
threadContext,
|
||||||
|
threadTs,
|
||||||
|
isThreadReply,
|
||||||
|
threadKeys,
|
||||||
|
sessionKey,
|
||||||
|
historyKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareSlackMessage(params: {
|
||||||
|
ctx: SlackMonitorContext;
|
||||||
|
account: ResolvedSlackAccount;
|
||||||
|
message: SlackMessageEvent;
|
||||||
|
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
|
||||||
|
}): Promise<PreparedSlackMessage | null> {
|
||||||
|
const { ctx, account, message, opts } = params;
|
||||||
|
const cfg = ctx.cfg;
|
||||||
|
const conversation = await resolveSlackConversationContext({ ctx, account, message });
|
||||||
|
const {
|
||||||
|
channelInfo,
|
||||||
|
channelName,
|
||||||
|
isDirectMessage,
|
||||||
|
isGroupDm,
|
||||||
|
isRoom,
|
||||||
|
isRoomish,
|
||||||
|
channelConfig,
|
||||||
|
isBotMessage,
|
||||||
|
} = conversation;
|
||||||
|
const authorization = await authorizeSlackInboundMessage({
|
||||||
|
ctx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
if (!authorization) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const { senderId, allowFromLower } = authorization;
|
||||||
|
const routing = resolveSlackRoutingContext({
|
||||||
|
ctx,
|
||||||
|
account,
|
||||||
|
message,
|
||||||
|
isDirectMessage,
|
||||||
|
isGroupDm,
|
||||||
|
isRoom,
|
||||||
|
isRoomish,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
route,
|
||||||
|
replyToMode,
|
||||||
|
threadContext,
|
||||||
|
threadTs,
|
||||||
|
isThreadReply,
|
||||||
|
threadKeys,
|
||||||
|
sessionKey,
|
||||||
|
historyKey,
|
||||||
|
} = routing;
|
||||||
|
|
||||||
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
|
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
|
||||||
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
|
||||||
const explicitlyMentioned = Boolean(
|
const explicitlyMentioned = Boolean(
|
||||||
|
|||||||
@@ -151,36 +151,42 @@ export async function monitorWebInbox(options: {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMessagesUpsert = async (upsert: { type?: string; messages?: Array<WAMessage> }) => {
|
type NormalizedInboundMessage = {
|
||||||
if (upsert.type !== "notify" && upsert.type !== "append") {
|
id?: string;
|
||||||
return;
|
remoteJid: string;
|
||||||
}
|
group: boolean;
|
||||||
for (const msg of upsert.messages ?? []) {
|
participantJid?: string;
|
||||||
recordChannelActivity({
|
from: string;
|
||||||
channel: "whatsapp",
|
senderE164: string | null;
|
||||||
accountId: options.accountId,
|
groupSubject?: string;
|
||||||
direction: "inbound",
|
groupParticipants?: string[];
|
||||||
});
|
messageTimestampMs?: number;
|
||||||
|
access: Awaited<ReturnType<typeof checkInboundAccessControl>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeInboundMessage = async (
|
||||||
|
msg: WAMessage,
|
||||||
|
): Promise<NormalizedInboundMessage | null> => {
|
||||||
const id = msg.key?.id ?? undefined;
|
const id = msg.key?.id ?? undefined;
|
||||||
const remoteJid = msg.key?.remoteJid;
|
const remoteJid = msg.key?.remoteJid;
|
||||||
if (!remoteJid) {
|
if (!remoteJid) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) {
|
if (remoteJid.endsWith("@status") || remoteJid.endsWith("@broadcast")) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = isJidGroup(remoteJid) === true;
|
const group = isJidGroup(remoteJid) === true;
|
||||||
if (id) {
|
if (id) {
|
||||||
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
|
const dedupeKey = `${options.accountId}:${remoteJid}:${id}`;
|
||||||
if (isRecentInboundMessage(dedupeKey)) {
|
if (isRecentInboundMessage(dedupeKey)) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const participantJid = msg.key?.participant ?? undefined;
|
const participantJid = msg.key?.participant ?? undefined;
|
||||||
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
|
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
|
||||||
if (!from) {
|
if (!from) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
const senderE164 = group
|
const senderE164 = group
|
||||||
? participantJid
|
? participantJid
|
||||||
@@ -213,15 +219,30 @@ export async function monitorWebInbox(options: {
|
|||||||
remoteJid,
|
remoteJid,
|
||||||
});
|
});
|
||||||
if (!access.allowed) {
|
if (!access.allowed) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
remoteJid,
|
||||||
|
group,
|
||||||
|
participantJid,
|
||||||
|
from,
|
||||||
|
senderE164,
|
||||||
|
groupSubject,
|
||||||
|
groupParticipants,
|
||||||
|
messageTimestampMs,
|
||||||
|
access,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeMarkInboundAsRead = async (inbound: NormalizedInboundMessage) => {
|
||||||
|
const { id, remoteJid, participantJid, access } = inbound;
|
||||||
if (id && !access.isSelfChat && options.sendReadReceipts !== false) {
|
if (id && !access.isSelfChat && options.sendReadReceipts !== false) {
|
||||||
const participant = msg.key?.participant;
|
|
||||||
try {
|
try {
|
||||||
await sock.readMessages([{ remoteJid, id, participant, fromMe: false }]);
|
await sock.readMessages([{ remoteJid, id, participant: participantJid, fromMe: false }]);
|
||||||
if (shouldLogVerbose()) {
|
if (shouldLogVerbose()) {
|
||||||
const suffix = participant ? ` (participant ${participant})` : "";
|
const suffix = participantJid ? ` (participant ${participantJid})` : "";
|
||||||
logVerbose(`Marked message ${id} as read for ${remoteJid}${suffix}`);
|
logVerbose(`Marked message ${id} as read for ${remoteJid}${suffix}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -231,12 +252,18 @@ export async function monitorWebInbox(options: {
|
|||||||
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
|
// Self-chat mode: never auto-send read receipts (blue ticks) on behalf of the owner.
|
||||||
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
|
logVerbose(`Self-chat mode: skipping read receipt for ${id}`);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// If this is history/offline catch-up, mark read above but skip auto-reply.
|
type EnrichedInboundMessage = {
|
||||||
if (upsert.type === "append") {
|
body: string;
|
||||||
continue;
|
location?: ReturnType<typeof extractLocationData>;
|
||||||
}
|
replyContext?: ReturnType<typeof describeReplyContext>;
|
||||||
|
mediaPath?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
mediaFileName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichInboundMessage = async (msg: WAMessage): Promise<EnrichedInboundMessage | null> => {
|
||||||
const location = extractLocationData(msg.message ?? undefined);
|
const location = extractLocationData(msg.message ?? undefined);
|
||||||
const locationText = location ? formatLocationText(location) : undefined;
|
const locationText = location ? formatLocationText(location) : undefined;
|
||||||
let body = extractText(msg.message ?? undefined);
|
let body = extractText(msg.message ?? undefined);
|
||||||
@@ -246,7 +273,7 @@ export async function monitorWebInbox(options: {
|
|||||||
if (!body) {
|
if (!body) {
|
||||||
body = extractMediaPlaceholder(msg.message ?? undefined);
|
body = extractMediaPlaceholder(msg.message ?? undefined);
|
||||||
if (!body) {
|
if (!body) {
|
||||||
continue;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const replyContext = describeReplyContext(msg.message as proto.IMessage | undefined);
|
const replyContext = describeReplyContext(msg.message as proto.IMessage | undefined);
|
||||||
@@ -277,7 +304,22 @@ export async function monitorWebInbox(options: {
|
|||||||
logVerbose(`Inbound media download failed: ${String(err)}`);
|
logVerbose(`Inbound media download failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatJid = remoteJid;
|
return {
|
||||||
|
body,
|
||||||
|
location: location ?? undefined,
|
||||||
|
replyContext,
|
||||||
|
mediaPath,
|
||||||
|
mediaType,
|
||||||
|
mediaFileName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueueInboundMessage = async (
|
||||||
|
msg: WAMessage,
|
||||||
|
inbound: NormalizedInboundMessage,
|
||||||
|
enriched: EnrichedInboundMessage,
|
||||||
|
) => {
|
||||||
|
const chatJid = inbound.remoteJid;
|
||||||
const sendComposing = async () => {
|
const sendComposing = async () => {
|
||||||
try {
|
try {
|
||||||
await sock.sendPresenceUpdate("composing", chatJid);
|
await sock.sendPresenceUpdate("composing", chatJid);
|
||||||
@@ -291,46 +333,54 @@ export async function monitorWebInbox(options: {
|
|||||||
const sendMedia = async (payload: AnyMessageContent) => {
|
const sendMedia = async (payload: AnyMessageContent) => {
|
||||||
await sock.sendMessage(chatJid, payload);
|
await sock.sendMessage(chatJid, payload);
|
||||||
};
|
};
|
||||||
const timestamp = messageTimestampMs;
|
const timestamp = inbound.messageTimestampMs;
|
||||||
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
|
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
|
||||||
const senderName = msg.pushName ?? undefined;
|
const senderName = msg.pushName ?? undefined;
|
||||||
|
|
||||||
inboundLogger.info(
|
inboundLogger.info(
|
||||||
{ from, to: selfE164 ?? "me", body, mediaPath, mediaType, mediaFileName, timestamp },
|
{
|
||||||
|
from: inbound.from,
|
||||||
|
to: selfE164 ?? "me",
|
||||||
|
body: enriched.body,
|
||||||
|
mediaPath: enriched.mediaPath,
|
||||||
|
mediaType: enriched.mediaType,
|
||||||
|
mediaFileName: enriched.mediaFileName,
|
||||||
|
timestamp,
|
||||||
|
},
|
||||||
"inbound message",
|
"inbound message",
|
||||||
);
|
);
|
||||||
const inboundMessage: WebInboundMessage = {
|
const inboundMessage: WebInboundMessage = {
|
||||||
id,
|
id: inbound.id,
|
||||||
from,
|
from: inbound.from,
|
||||||
conversationId: from,
|
conversationId: inbound.from,
|
||||||
to: selfE164 ?? "me",
|
to: selfE164 ?? "me",
|
||||||
accountId: access.resolvedAccountId,
|
accountId: inbound.access.resolvedAccountId,
|
||||||
body,
|
body: enriched.body,
|
||||||
pushName: senderName,
|
pushName: senderName,
|
||||||
timestamp,
|
timestamp,
|
||||||
chatType: group ? "group" : "direct",
|
chatType: inbound.group ? "group" : "direct",
|
||||||
chatId: remoteJid,
|
chatId: inbound.remoteJid,
|
||||||
senderJid: participantJid,
|
senderJid: inbound.participantJid,
|
||||||
senderE164: senderE164 ?? undefined,
|
senderE164: inbound.senderE164 ?? undefined,
|
||||||
senderName,
|
senderName,
|
||||||
replyToId: replyContext?.id,
|
replyToId: enriched.replyContext?.id,
|
||||||
replyToBody: replyContext?.body,
|
replyToBody: enriched.replyContext?.body,
|
||||||
replyToSender: replyContext?.sender,
|
replyToSender: enriched.replyContext?.sender,
|
||||||
replyToSenderJid: replyContext?.senderJid,
|
replyToSenderJid: enriched.replyContext?.senderJid,
|
||||||
replyToSenderE164: replyContext?.senderE164,
|
replyToSenderE164: enriched.replyContext?.senderE164,
|
||||||
groupSubject,
|
groupSubject: inbound.groupSubject,
|
||||||
groupParticipants,
|
groupParticipants: inbound.groupParticipants,
|
||||||
mentionedJids: mentionedJids ?? undefined,
|
mentionedJids: mentionedJids ?? undefined,
|
||||||
selfJid,
|
selfJid,
|
||||||
selfE164,
|
selfE164,
|
||||||
fromMe: Boolean(msg.key?.fromMe),
|
fromMe: Boolean(msg.key?.fromMe),
|
||||||
location: location ?? undefined,
|
location: enriched.location ?? undefined,
|
||||||
sendComposing,
|
sendComposing,
|
||||||
reply,
|
reply,
|
||||||
sendMedia,
|
sendMedia,
|
||||||
mediaPath,
|
mediaPath: enriched.mediaPath,
|
||||||
mediaType,
|
mediaType: enriched.mediaType,
|
||||||
mediaFileName,
|
mediaFileName: enriched.mediaFileName,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const task = Promise.resolve(debouncer.enqueue(inboundMessage));
|
const task = Promise.resolve(debouncer.enqueue(inboundMessage));
|
||||||
@@ -342,6 +392,36 @@ export async function monitorWebInbox(options: {
|
|||||||
inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
|
inboundLogger.error({ error: String(err) }, "failed handling inbound web message");
|
||||||
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
|
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMessagesUpsert = async (upsert: { type?: string; messages?: Array<WAMessage> }) => {
|
||||||
|
if (upsert.type !== "notify" && upsert.type !== "append") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const msg of upsert.messages ?? []) {
|
||||||
|
recordChannelActivity({
|
||||||
|
channel: "whatsapp",
|
||||||
|
accountId: options.accountId,
|
||||||
|
direction: "inbound",
|
||||||
|
});
|
||||||
|
const inbound = await normalizeInboundMessage(msg);
|
||||||
|
if (!inbound) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await maybeMarkInboundAsRead(inbound);
|
||||||
|
|
||||||
|
// If this is history/offline catch-up, mark read above but skip auto-reply.
|
||||||
|
if (upsert.type === "append") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enriched = await enrichInboundMessage(msg);
|
||||||
|
if (!enriched) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await enqueueInboundMessage(msg, inbound, enriched);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sock.ev.on("messages.upsert", handleMessagesUpsert);
|
sock.ev.on("messages.upsert", handleMessagesUpsert);
|
||||||
|
|||||||
Reference in New Issue
Block a user