Skip to content

Commit f7ac9d6

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 5331421 + 7c71255 commit f7ac9d6

125 files changed

Lines changed: 7245 additions & 1240 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Docs: https://docs.openclaw.ai
1010

1111
- Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin
1212

13+
- Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV
14+
- Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.
15+
- WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn.
16+
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
1317
## 2026.4.12
1418

1519
### Changes
@@ -119,6 +123,7 @@ Docs: https://docs.openclaw.ai
119123
- Telegram/sessions: keep topic-scoped session initialization on the canonical topic transcript path when inbound turns omit `MessageThreadId`, so one topic session no longer alternates between bare and topic-qualified transcript files. (#64869) Thanks @jalehman.
120124
- Agents/failover: scope assistant-side fallback classification and surfaced provider errors to the current attempt instead of stale session history, so cross-provider fallback runs stop inheriting the previous provider's failure. (#62907) Thanks @stainlu.
121125
- MiniMax/OAuth: write `api: "anthropic-messages"` and `authHeader: true` into the `minimax-portal` config patch during `openclaw configure`, so re-authenticated portal setups keep Bearer auth routing working. (#64964) Thanks @ryanlee666.
126+
- Agents/tools: stop repeated unavailable-tool retries from escaping loop detection when the model changes arguments, and rewrite over-threshold unknown tool calls into plain assistant text before dispatch. (#65922) Thanks @dutifulbob.
122127

123128
## 2026.4.10
124129

@@ -311,6 +316,7 @@ Docs: https://docs.openclaw.ai
311316
- Control UI/models: preserve provider-qualified refs for OpenRouter catalog models whose ids already contain slashes so picker selections submit allowlist-compatible model refs instead of dropping the `openrouter/` prefix. (#63416) Thanks @sallyom.
312317
- Plugin SDK/command auth: split command status builders onto the lightweight `openclaw/plugin-sdk/command-status` subpath while preserving deprecated `command-auth` compatibility exports, so auth-only plugin imports no longer pull status/context warmup into CLI onboarding paths. (#63174) Thanks @hxy91819.
313318
- Wizard/plugin config: coerce integer-typed plugin config fields from interactive text input so integer schema values persist as numbers instead of failing validation. (#63346) Thanks @jalehman.
319+
- npm packaging: derive required root runtime mirrors from bundled plugin manifests and built root chunks, then install packed release tarballs without the repo `node_modules` so release checks catch missing plugin deps before publish.
314320

315321
## 2026.4.8
316322

@@ -765,6 +771,24 @@ Docs: https://docs.openclaw.ai
765771
- Agents/MCP: dispose bundled MCP runtimes after one-shot `openclaw agent --local` runs finish, while preserving bundled MCP state across in-run retries so local JSON runs exit cleanly without restarting stateful MCP tools mid-run.
766772
- Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.
767773
- Gateway/attachments: offload large inbound images without leaking `media://` markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.
774+
- Agents/subagents: fix interim subagent runtime display so `/subagents list` and `/subagents info` stop inflating short runtimes and show second-level durations correctly. (#57739) Thanks @samzong.
775+
- Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.
776+
- Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.
777+
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
778+
- Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
779+
- xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana.
780+
- Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.
781+
- Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss.
782+
- Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc.
783+
- Tools/web_fetch: add an explicit trusted env-proxy path for proxy-only installs while keeping strict SSRF fetches on the pinned direct path, so trusted proxy routing does not weaken strict destination binding. (#50650) Thanks @kkav004.
784+
- Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.
785+
- Telegram/audio: transcode Telegram voice-note `.ogg` attachments before the local `whisper-cli` auto fallback runs, and keep mention-preflight transcription enabled in auto mode when `tools.media.audio` is unset.
786+
- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras.
787+
- TTS: Restore 3.28 schema compatibility and fallback observability. (#57953) Thanks @joshavant.
788+
- Telegram/forum topics: restore reply routing to the active topic and keep ACP `sessions_spawn(..., thread=true, mode="session")` bound to that same topic instead of falling back to root chat or losing follow-up routing. (#56060) Thanks @one27001.
789+
- Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.
790+
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
791+
- Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
768792
- Telegram/outbound chunking: use static markdown chunking when Telegram runtime state is unavailable so long outbound Telegram messages still split correctly after cold starts. (#57816) Thanks @ForestDengHK.
769793
- Update/Corepack: disable interactive Corepack download prompts during update preflight install unless `COREPACK_ENABLE_DOWNLOAD_PROMPT` is already explicitly set, so `openclaw update` can fetch the repo-pinned pnpm version non-interactively. (#61456) Thanks @p6l-richard.
770794

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
2+
import type { DiscordMessageEvent } from "./listeners.js";
3+
import { resolveDiscordMessageChannelId } from "./message-utils.js";
4+
5+
const RECENT_DISCORD_MESSAGE_TTL_MS = 5 * 60_000;
6+
const RECENT_DISCORD_MESSAGE_MAX = 5000;
7+
8+
export function createDiscordInboundReplayGuard(): ClaimableDedupe {
9+
return createClaimableDedupe({
10+
ttlMs: RECENT_DISCORD_MESSAGE_TTL_MS,
11+
memoryMaxSize: RECENT_DISCORD_MESSAGE_MAX,
12+
});
13+
}
14+
15+
export class DiscordRetryableInboundError extends Error {
16+
constructor(message: string, options?: ErrorOptions) {
17+
super(message, options);
18+
this.name = "DiscordRetryableInboundError";
19+
}
20+
}
21+
22+
export function buildDiscordInboundReplayKey(params: {
23+
accountId: string;
24+
data: DiscordMessageEvent;
25+
}): string | null {
26+
const messageId = params.data.message?.id?.trim();
27+
if (!messageId) {
28+
return null;
29+
}
30+
const channelId = resolveDiscordMessageChannelId({
31+
message: params.data.message,
32+
eventChannelId: params.data.channel_id,
33+
});
34+
if (!channelId) {
35+
return null;
36+
}
37+
return `${params.accountId}:${channelId}:${messageId}`;
38+
}
39+
40+
export async function claimDiscordInboundReplay(params: {
41+
replayKey?: string | null;
42+
replayGuard: ClaimableDedupe;
43+
}): Promise<boolean> {
44+
const replayKey = params.replayKey?.trim();
45+
if (!replayKey) {
46+
return true;
47+
}
48+
const claim = await params.replayGuard.claim(replayKey);
49+
return claim.kind === "claimed";
50+
}
51+
52+
export async function commitDiscordInboundReplay(params: {
53+
replayKeys?: readonly (string | null | undefined)[];
54+
replayGuard: ClaimableDedupe;
55+
}): Promise<void> {
56+
const replayKeys = normalizeDiscordInboundReplayKeys(params.replayKeys);
57+
await Promise.all(replayKeys.map((replayKey) => params.replayGuard.commit(replayKey)));
58+
}
59+
60+
export function releaseDiscordInboundReplay(params: {
61+
replayKeys?: readonly (string | null | undefined)[];
62+
replayGuard: ClaimableDedupe;
63+
error?: unknown;
64+
}): void {
65+
const replayKeys = normalizeDiscordInboundReplayKeys(params.replayKeys);
66+
replayKeys.forEach((replayKey) => params.replayGuard.release(replayKey, { error: params.error }));
67+
}
68+
69+
function normalizeDiscordInboundReplayKeys(
70+
replayKeys?: readonly (string | null | undefined)[],
71+
): string[] {
72+
return [...new Set((replayKeys ?? []).map((replayKey) => replayKey?.trim()).filter(Boolean))];
73+
}

extensions/discord/src/monitor/inbound-job.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ describe("buildDiscordInboundJob", () => {
9292

9393
it("re-materializes the process context with an overridden abort signal", async () => {
9494
const ctx = await createBaseDiscordMessageContext();
95-
const job = buildDiscordInboundJob(ctx);
95+
const job = buildDiscordInboundJob(ctx, { replayKeys: ["default:ch-1:m-1"] });
9696
const overrideAbortController = new AbortController();
9797

9898
const rematerialized = materializeDiscordInboundJob(job, overrideAbortController.signal);
@@ -103,6 +103,7 @@ describe("buildDiscordInboundJob", () => {
103103
expect(rematerialized.abortSignal).toBe(overrideAbortController.signal);
104104
expect(rematerialized.message).toEqual(job.payload.message);
105105
expect(rematerialized.data).toEqual(job.payload.data);
106+
expect(job.replayKeys).toEqual(["default:ch-1:m-1"]);
106107
});
107108

108109
it("preserves Carbon message getters across queued jobs", async () => {

extensions/discord/src/monitor/inbound-job.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type DiscordInboundJob = {
2222
queueKey: string;
2323
payload: DiscordInboundJobPayload;
2424
runtime: DiscordInboundJobRuntime;
25+
replayKeys?: string[];
2526
};
2627

2728
export function resolveDiscordInboundJobQueueKey(ctx: DiscordMessagePreflightContext): string {
@@ -36,7 +37,10 @@ export function resolveDiscordInboundJobQueueKey(ctx: DiscordMessagePreflightCon
3637
return ctx.messageChannelId;
3738
}
3839

39-
export function buildDiscordInboundJob(ctx: DiscordMessagePreflightContext): DiscordInboundJob {
40+
export function buildDiscordInboundJob(
41+
ctx: DiscordMessagePreflightContext,
42+
options?: { replayKeys?: readonly string[] },
43+
): DiscordInboundJob {
4044
const {
4145
runtime,
4246
abortSignal,
@@ -70,6 +74,7 @@ export function buildDiscordInboundJob(ctx: DiscordMessagePreflightContext): Dis
7074
threadBindings,
7175
discordRestFetch,
7276
},
77+
replayKeys: options?.replayKeys ? [...options.replayKeys] : undefined,
7378
};
7479
}
7580

extensions/discord/src/monitor/inbound-worker.ts

Lines changed: 73 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { createRunStateMachine } from "openclaw/plugin-sdk/channel-lifecycle";
22
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
3+
import type { ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
34
import { danger, formatDurationSeconds } from "openclaw/plugin-sdk/runtime-env";
45
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
6+
import {
7+
commitDiscordInboundReplay,
8+
createDiscordInboundReplayGuard,
9+
DiscordRetryableInboundError,
10+
releaseDiscordInboundReplay,
11+
} from "./inbound-dedupe.js";
512
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
613
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
714
import { processDiscordMessage } from "./message-handler.process.js";
@@ -15,6 +22,7 @@ type DiscordInboundWorkerParams = {
1522
setStatus?: DiscordMonitorStatusSink;
1623
abortSignal?: AbortSignal;
1724
runTimeoutMs?: number;
25+
replayGuard?: ClaimableDedupe;
1826
__testing?: DiscordInboundWorkerTestingHooks;
1927
};
2028

@@ -46,6 +54,7 @@ async function processDiscordInboundJob(params: {
4654
runtime: RuntimeEnv;
4755
lifecycleSignal?: AbortSignal;
4856
runTimeoutMs?: number;
57+
replayGuard: ClaimableDedupe;
4958
testing?: DiscordInboundWorkerTestingHooks;
5059
}) {
5160
const timeoutMs = normalizeDiscordInboundWorkerTimeoutMs(params.runTimeoutMs);
@@ -54,50 +63,70 @@ async function processDiscordInboundJob(params: {
5463
let createdThreadId: string | undefined;
5564
let sessionKey: string | undefined;
5665
const processDiscordMessageImpl = params.testing?.processDiscordMessage ?? processDiscordMessage;
57-
await runDiscordTaskWithTimeout({
58-
run: async (abortSignal) => {
59-
await processDiscordMessageImpl(materializeDiscordInboundJob(params.job, abortSignal), {
60-
onFinalReplyStart: () => {
61-
finalReplyStarted = true;
62-
},
63-
onFinalReplyDelivered: () => {
64-
finalReplyStarted = true;
65-
},
66-
onReplyPlanResolved: (resolved) => {
67-
createdThreadId = normalizeOptionalString(resolved.createdThreadId);
68-
sessionKey = normalizeOptionalString(resolved.sessionKey);
69-
},
66+
try {
67+
await runDiscordTaskWithTimeout({
68+
run: async (abortSignal) => {
69+
await processDiscordMessageImpl(materializeDiscordInboundJob(params.job, abortSignal), {
70+
onFinalReplyStart: () => {
71+
finalReplyStarted = true;
72+
},
73+
onFinalReplyDelivered: () => {
74+
finalReplyStarted = true;
75+
},
76+
onReplyPlanResolved: (resolved) => {
77+
createdThreadId = normalizeOptionalString(resolved.createdThreadId);
78+
sessionKey = normalizeOptionalString(resolved.sessionKey);
79+
},
80+
});
81+
},
82+
timeoutMs,
83+
abortSignals: [params.job.runtime.abortSignal, params.lifecycleSignal],
84+
onTimeout: async (resolvedTimeoutMs) => {
85+
params.runtime.error?.(
86+
danger(
87+
`discord inbound worker timed out after ${formatDurationSeconds(resolvedTimeoutMs, {
88+
decimals: 1,
89+
unit: "seconds",
90+
})}${contextSuffix}`,
91+
),
92+
);
93+
if (finalReplyStarted) {
94+
return;
95+
}
96+
await sendDiscordInboundWorkerTimeoutReply({
97+
job: params.job,
98+
runtime: params.runtime,
99+
contextSuffix,
100+
createdThreadId,
101+
sessionKey,
102+
deliverDiscordReplyImpl: params.testing?.deliverDiscordReply,
103+
});
104+
},
105+
onErrorAfterTimeout: (error) => {
106+
params.runtime.error?.(
107+
danger(`discord inbound worker failed after timeout: ${String(error)}${contextSuffix}`),
108+
);
109+
},
110+
});
111+
await commitDiscordInboundReplay({
112+
replayKeys: params.job.replayKeys,
113+
replayGuard: params.replayGuard,
114+
});
115+
} catch (error) {
116+
if (error instanceof DiscordRetryableInboundError) {
117+
releaseDiscordInboundReplay({
118+
replayKeys: params.job.replayKeys,
119+
error,
120+
replayGuard: params.replayGuard,
70121
});
71-
},
72-
timeoutMs,
73-
abortSignals: [params.job.runtime.abortSignal, params.lifecycleSignal],
74-
onTimeout: async (resolvedTimeoutMs) => {
75-
params.runtime.error?.(
76-
danger(
77-
`discord inbound worker timed out after ${formatDurationSeconds(resolvedTimeoutMs, {
78-
decimals: 1,
79-
unit: "seconds",
80-
})}${contextSuffix}`,
81-
),
82-
);
83-
if (finalReplyStarted) {
84-
return;
85-
}
86-
await sendDiscordInboundWorkerTimeoutReply({
87-
job: params.job,
88-
runtime: params.runtime,
89-
contextSuffix,
90-
createdThreadId,
91-
sessionKey,
92-
deliverDiscordReplyImpl: params.testing?.deliverDiscordReply,
122+
} else {
123+
await commitDiscordInboundReplay({
124+
replayKeys: params.job.replayKeys,
125+
replayGuard: params.replayGuard,
93126
});
94-
},
95-
onErrorAfterTimeout: (error) => {
96-
params.runtime.error?.(
97-
danger(`discord inbound worker failed after timeout: ${String(error)}${contextSuffix}`),
98-
);
99-
},
100-
});
127+
}
128+
throw error;
129+
}
101130
}
102131

103132
async function sendDiscordInboundWorkerTimeoutReply(params: {
@@ -163,6 +192,7 @@ export function createDiscordInboundWorker(
163192
setStatus: params.setStatus,
164193
abortSignal: params.abortSignal,
165194
});
195+
const replayGuard = params.replayGuard ?? createDiscordInboundReplayGuard();
166196

167197
return {
168198
enqueue(job) {
@@ -181,6 +211,7 @@ export function createDiscordInboundWorker(
181211
runtime: params.runtime,
182212
lifecycleSignal: params.abortSignal,
183213
runTimeoutMs: params.runTimeoutMs,
214+
replayGuard,
184215
testing: params.__testing,
185216
});
186217
} finally {

0 commit comments

Comments
 (0)