mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
fix(gateway): harden message action channel fallback and startup grace
Take the safe, tested subset from #32367:\n- per-channel startup connect grace in health monitor\n- tool-context channel-provider fallback for message actions\n\nCo-authored-by: Munem Hashmi <munem.hashmi@gmail.com>
This commit is contained in:
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
|
||||
@@ -201,6 +201,7 @@ describe("channel-health-monitor", () => {
|
||||
});
|
||||
|
||||
it("restarts a stuck channel (running but not connected)", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
whatsapp: {
|
||||
default: {
|
||||
@@ -209,6 +210,7 @@ describe("channel-health-monitor", () => {
|
||||
enabled: true,
|
||||
configured: true,
|
||||
linked: true,
|
||||
lastStartAt: now - 300_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -219,6 +221,41 @@ describe("channel-health-monitor", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("skips recently-started channels while they are still connecting", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
discord: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: false,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - 5_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expectNoRestart(manager);
|
||||
});
|
||||
|
||||
it("respects custom per-channel startup grace", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
discord: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: false,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager, { channelStartupGraceMs: 60_000 });
|
||||
expect(manager.stopChannel).not.toHaveBeenCalled();
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("restarts a stopped channel that gave up (reconnectAttempts >= 10)", async () => {
|
||||
const manager = createSnapshotManager({
|
||||
discord: {
|
||||
|
||||
@@ -17,6 +17,7 @@ const ONE_HOUR_MS = 60 * 60_000;
|
||||
* alive (health checks pass) but Slack silently stops delivering events.
|
||||
*/
|
||||
const DEFAULT_STALE_EVENT_THRESHOLD_MS = 30 * 60_000;
|
||||
const DEFAULT_CHANNEL_STARTUP_GRACE_MS = 120_000;
|
||||
|
||||
export type ChannelHealthMonitorDeps = {
|
||||
channelManager: ChannelManager;
|
||||
@@ -25,6 +26,7 @@ export type ChannelHealthMonitorDeps = {
|
||||
cooldownCycles?: number;
|
||||
maxRestartsPerHour?: number;
|
||||
staleEventThresholdMs?: number;
|
||||
channelStartupGraceMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -50,7 +52,7 @@ function isChannelHealthy(
|
||||
lastEventAt?: number | null;
|
||||
lastStartAt?: number | null;
|
||||
},
|
||||
opts: { now: number; staleEventThresholdMs: number },
|
||||
opts: { now: number; staleEventThresholdMs: number; channelStartupGraceMs: number },
|
||||
): boolean {
|
||||
if (!isManagedAccount(snapshot)) {
|
||||
return true;
|
||||
@@ -58,6 +60,12 @@ function isChannelHealthy(
|
||||
if (!snapshot.running) {
|
||||
return false;
|
||||
}
|
||||
if (snapshot.lastStartAt != null) {
|
||||
const upDuration = opts.now - snapshot.lastStartAt;
|
||||
if (upDuration < opts.channelStartupGraceMs) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (snapshot.connected === false) {
|
||||
return false;
|
||||
}
|
||||
@@ -88,6 +96,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
|
||||
cooldownCycles = DEFAULT_COOLDOWN_CYCLES,
|
||||
maxRestartsPerHour = DEFAULT_MAX_RESTARTS_PER_HOUR,
|
||||
staleEventThresholdMs = DEFAULT_STALE_EVENT_THRESHOLD_MS,
|
||||
channelStartupGraceMs = DEFAULT_CHANNEL_STARTUP_GRACE_MS,
|
||||
abortSignal,
|
||||
} = deps;
|
||||
|
||||
@@ -132,7 +141,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
|
||||
if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) {
|
||||
continue;
|
||||
}
|
||||
if (isChannelHealthy(status, { now, staleEventThresholdMs })) {
|
||||
if (isChannelHealthy(status, { now, staleEventThresholdMs, channelStartupGraceMs })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -349,6 +349,37 @@ describe("runMessageAction context isolation", () => {
|
||||
expect(result.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("falls back to tool-context provider when channel param is an id", async () => {
|
||||
const result = await runDrySend({
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "C12345678",
|
||||
target: "#C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("send");
|
||||
expect(result.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("falls back to tool-context provider for broadcast channel ids", async () => {
|
||||
const result = await runDryAction({
|
||||
cfg: slackConfig,
|
||||
action: "broadcast",
|
||||
actionParams: {
|
||||
targets: ["channel:C12345678"],
|
||||
channel: "C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
toolContext: { currentChannelProvider: "slack" },
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("broadcast");
|
||||
expect(result.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("blocks cross-provider sends by default", async () => {
|
||||
await expect(
|
||||
runDrySend({
|
||||
|
||||
@@ -217,13 +217,28 @@ async function maybeApplyCrossContextMarker(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveChannel(cfg: OpenClawConfig, params: Record<string, unknown>) {
|
||||
async function resolveChannel(
|
||||
cfg: OpenClawConfig,
|
||||
params: Record<string, unknown>,
|
||||
toolContext?: { currentChannelProvider?: string },
|
||||
) {
|
||||
const channelHint = readStringParam(params, "channel");
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: channelHint,
|
||||
});
|
||||
return selection.channel;
|
||||
try {
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: channelHint,
|
||||
});
|
||||
return selection.channel;
|
||||
} catch (error) {
|
||||
if (channelHint && toolContext?.currentChannelProvider) {
|
||||
const fallback = normalizeMessageChannel(toolContext.currentChannelProvider);
|
||||
if (fallback && isDeliverableMessageChannel(fallback)) {
|
||||
params.channel = fallback;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveActionTarget(params: {
|
||||
@@ -317,7 +332,7 @@ async function handleBroadcastAction(
|
||||
}
|
||||
const targetChannels =
|
||||
channelHint && channelHint.trim().toLowerCase() !== "all"
|
||||
? [await resolveChannel(input.cfg, { channel: channelHint })]
|
||||
? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)]
|
||||
: configured;
|
||||
const results: Array<{
|
||||
channel: ChannelId;
|
||||
@@ -754,7 +769,7 @@ export async function runMessageAction(
|
||||
}
|
||||
}
|
||||
|
||||
const channel = await resolveChannel(cfg, params);
|
||||
const channel = await resolveChannel(cfg, params, input.toolContext);
|
||||
let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
|
||||
if (!accountId && resolvedAgentId) {
|
||||
const byAgent = buildChannelAccountBindings(cfg).get(channel);
|
||||
|
||||
Reference in New Issue
Block a user