fix(channels): clarify message target syntax

This commit is contained in:
Peter Steinberger
2026-04-27 13:17:57 +01:00
parent 6fe9285f64
commit 4bd356d03a
8 changed files with 63 additions and 47 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Local models: default custom providers with only `baseUrl` to the Chat Completions adapter and trust loopback model requests automatically, so local OpenAI-compatible proxies receive `/v1/chat/completions` without timing out. Fixes #40024. Thanks @parachuteshe.
- Channels/message tool: surface Discord, Slack, and Mattermost `user:`/`channel:` target syntax in the shared message target schema and Discord ambiguity errors, so DM sends by numeric id stop burning retries before finding `user:<id>`. Fixes #72401. Thanks @garyd9, @hclsys, and @praveen9354.
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.

View File

@@ -26,18 +26,13 @@ export async function parseAndResolveRecipient(
}
const resolvedCfg = requireRuntimeConfig(cfg, "Discord recipient resolution");
const accountInfo = resolveDiscordAccount({ cfg: resolvedCfg, accountId });
const trimmed = raw.trim();
const resolvedParseOptions = {
...parseOptions,
ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
};
const resolved = await parseAndResolveDiscordTarget(
raw,
{
cfg: resolvedCfg,
accountId: accountInfo.accountId,
},
resolvedParseOptions,
parseOptions,
);
return { kind: resolved.kind, id: resolved.id };
}

View File

@@ -41,7 +41,7 @@ export function parseDiscordTarget(
}
throw new Error(
options.ambiguousMessage ??
`Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
`Ambiguous Discord recipient "${trimmed}". For DMs use "user:${trimmed}" or "<@${trimmed}>"; for channels use "channel:${trimmed}".`,
);
}
return buildMessagingTarget("channel", trimmed, trimmed);

View File

@@ -62,6 +62,12 @@ describe("parseDiscordTarget", () => {
);
}
});
it("guides ambiguous numeric recipients with all supported explicit formats", () => {
expect(() => parseDiscordTarget("123456789")).toThrow(
'Ambiguous Discord recipient "123456789". For DMs use "user:123456789" or "<@123456789>"; for channels use "channel:123456789".',
);
});
});
describe("resolveDiscordChannelId", () => {

View File

@@ -749,6 +749,19 @@ describe("message tool description", () => {
},
});
it("surfaces explicit cross-channel target syntax in the target schema", () => {
const tool = createMessageTool({
config: {} as never,
});
const properties = getToolProperties(tool);
const target = properties.target as { description?: string } | undefined;
expect(target?.description).toContain(
"Discord/Slack/Mattermost <channelId|user:ID|channel:ID>",
);
expect(target?.description).toContain("Telegram chat id/@username");
});
it("hides BlueBubbles group actions for DM targets", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]),

View File

@@ -48,7 +48,7 @@ function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean {
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
target: Type.Optional(channelTargetSchema()),
targets: Type.Optional(channelTargetsSchema()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),

View File

@@ -1343,44 +1343,6 @@ export function attachGatewayWsMessageHandler(params: {
});
incrementPresenceVersion();
}
const snapshot = buildGatewaySnapshot({
includeSensitive: scopes.includes(ADMIN_SCOPE),
});
const cachedHealth = getHealthCache();
if (cachedHealth) {
snapshot.health = cachedHealth;
snapshot.stateVersion.health = getHealthVersion();
}
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
const helloOk = {
type: "hello-ok",
protocol: PROTOCOL_VERSION,
server: {
version: resolveRuntimeServiceVersion(process.env),
connId,
},
features: { methods: gatewayMethods, events },
snapshot,
canvasHostUrl: scopedCanvasHostUrl,
auth: {
role,
scopes: helloOkAuthScopes,
...(deviceToken
? {
deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
}
: {}),
},
policy: {
maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES,
tickIntervalMs: TICK_INTERVAL_MS,
},
};
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, {
@@ -1442,6 +1404,45 @@ export function attachGatewayWsMessageHandler(params: {
);
}
const snapshot = buildGatewaySnapshot({
includeSensitive: scopes.includes(ADMIN_SCOPE),
});
const cachedHealth = getHealthCache();
if (cachedHealth) {
snapshot.health = cachedHealth;
snapshot.stateVersion.health = getHealthVersion();
}
const helloOkAuthScopes = deviceToken ? deviceToken.scopes : scopes;
const helloOk = {
type: "hello-ok",
protocol: PROTOCOL_VERSION,
server: {
version: resolveRuntimeServiceVersion(process.env),
connId,
},
features: { methods: gatewayMethods, events },
snapshot,
canvasHostUrl: scopedCanvasHostUrl,
auth: {
role,
scopes: helloOkAuthScopes,
...(deviceToken
? {
deviceToken: deviceToken.token,
issuedAtMs: deviceToken.rotatedAtMs ?? deviceToken.createdAtMs,
...(bootstrapDeviceTokens.length > 1
? { deviceTokens: bootstrapDeviceTokens.slice(1) }
: {}),
}
: {}),
},
policy: {
maxPayload: MAX_PAYLOAD_BYTES,
maxBufferedBytes: MAX_BUFFERED_BYTES,
tickIntervalMs: TICK_INTERVAL_MS,
},
};
try {
await sendFrame({ type: "res", id: frame.id, ok: true, payload: helloOk });
} catch (err) {

View File

@@ -7,7 +7,7 @@ import { MESSAGE_ACTION_TARGET_MODE } from "./message-action-spec.js";
export const hasNonEmptyString = sharedHasNonEmptyString;
export const CHANNEL_TARGET_DESCRIPTION =
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack channel/user, or iMessage handle/chat_id";
"Recipient/channel: E.164 for WhatsApp/Signal, Telegram chat id/@username, Discord/Slack/Mattermost <channelId|user:ID|channel:ID>, or iMessage handle/chat_id";
export const CHANNEL_TARGETS_DESCRIPTION =
"Recipient/channel targets (same format as --target); accepts ids or names when the directory is available.";