fix(cron): pass agent identity through delivery path (#16218) (#16242)

* fix(cron): pass agent identity through delivery path

Cron delivery messages now include agent identity (name, avatar) in
outbound messages. Identity fields are passed best-effort for Slack
(graceful fallback if chat:write.customize scope is missing).

Fixes #16218

* fix: fix Slack cron delivery identity (#16242) (thanks @robbyczgw-cla)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Robby
2026-02-14 16:08:51 +01:00
committed by GitHub
parent 497b060e49
commit 09e1cbc35d
8 changed files with 222 additions and 23 deletions
+1
View File
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc. - Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc. - Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc. - Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
## 2026.2.14 ## 2026.2.14
+1
View File
@@ -127,6 +127,7 @@ openclaw gateway
- Config tokens override env fallback. - Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account. - `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`). - `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
- Optional: add `chat:write.customize` if you want outgoing messages to use the active agent identity (custom `username` and icon). `icon_emoji` uses `:emoji_name:` syntax.
<Tip> <Tip>
For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable. For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable.
+40 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../slack/send.js", () => ({ vi.mock("../../../slack/send.js", () => ({
sendMessageSlack: vi.fn().mockResolvedValue({ ts: "1234.5678", channel: "C123" }), sendMessageSlack: vi.fn().mockResolvedValue({ messageId: "1234.5678", channelId: "C123" }),
})); }));
vi.mock("../../../plugins/hook-runner-global.js", () => ({ vi.mock("../../../plugins/hook-runner-global.js", () => ({
@@ -37,6 +37,45 @@ describe("slack outbound hook wiring", () => {
}); });
}); });
it("forwards identity opts when present", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
username: "My Agent",
icon_url: "https://example.com/avatar.png",
icon_emoji: ":should_not_send:",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
username: "My Agent",
icon_url: "https://example.com/avatar.png",
});
});
it("forwards icon_emoji only when icon_url is absent", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
icon_emoji: ":lobster:",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
icon_emoji: ":lobster:",
});
});
it("calls message_sending hook before sending", async () => { it("calls message_sending hook before sending", async () => {
const mockRunner = { const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true), hasHooks: vi.fn().mockReturnValue(true),
+29 -2
View File
@@ -6,7 +6,17 @@ export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => { sendText: async ({
to,
text,
accountId,
deps,
replyToId,
threadId,
username,
icon_url,
icon_emoji,
}) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread. // Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
@@ -35,10 +45,24 @@ export const slackOutbound: ChannelOutboundAdapter = {
const result = await send(to, finalText, { const result = await send(to, finalText, {
threadTs, threadTs,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(username ? { username } : {}),
...(icon_url ? { icon_url } : {}),
...(icon_emoji && !icon_url ? { icon_emoji } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => { sendMedia: async ({
to,
text,
mediaUrl,
accountId,
deps,
replyToId,
threadId,
username,
icon_url,
icon_emoji,
}) => {
const send = deps?.sendSlack ?? sendMessageSlack; const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread. // Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
@@ -68,6 +92,9 @@ export const slackOutbound: ChannelOutboundAdapter = {
mediaUrl, mediaUrl,
threadTs, threadTs,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(username ? { username } : {}),
...(icon_url ? { icon_url } : {}),
...(icon_emoji && !icon_url ? { icon_emoji } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
+3
View File
@@ -79,6 +79,9 @@ export type ChannelOutboundContext = {
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
accountId?: string | null; accountId?: string | null;
username?: string;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
silent?: boolean; silent?: boolean;
}; };
+36 -14
View File
@@ -14,6 +14,8 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js"; import { lookupContextTokens } from "../../agents/context.js";
import { resolveCronStyleNow } from "../../agents/current-time.js"; import { resolveCronStyleNow } from "../../agents/current-time.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { resolveAgentAvatar } from "../../agents/identity-avatar.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import { loadModelCatalog } from "../../agents/model-catalog.js"; import { loadModelCatalog } from "../../agents/model-catalog.js";
import { runWithModelFallback } from "../../agents/model-fallback.js"; import { runWithModelFallback } from "../../agents/model-fallback.js";
import { import {
@@ -555,21 +557,41 @@ export async function runCronIsolatedAgentTurn(params: {
logWarn(`[cron:${params.job.id}] ${message}`); logWarn(`[cron:${params.job.id}] ${message}`);
return withRunSession({ status: "ok", summary, outputText }); return withRunSession({ status: "ok", summary, outputText });
} }
// Shared subagent announce flow is text-based; keep direct outbound delivery const agentIdentity = resolveAgentIdentity(cfgWithAgentDefaults, agentId);
// for media/channel payloads so structured content is preserved. const avatar = resolveAgentAvatar(cfgWithAgentDefaults, agentId);
if (deliveryPayloadHasStructuredContent) { const icon_url = avatar.kind === "remote" ? avatar.url : undefined;
const username = agentIdentity?.name?.trim() || undefined;
const rawEmoji = agentIdentity?.emoji?.trim();
// Slack `icon_emoji` requires :emoji_name: (not a Unicode emoji).
const icon_emoji =
!icon_url && rawEmoji && /^:[^:\\s]+:$/.test(rawEmoji) ? rawEmoji : undefined;
// Shared subagent announce flow is text-based. When we have an explicit sender
// identity to preserve, prefer direct outbound delivery even for plain-text payloads.
if (deliveryPayloadHasStructuredContent || username || icon_url || icon_emoji) {
try { try {
const deliveryResults = await deliverOutboundPayloads({ const payloadsForDelivery =
cfg: cfgWithAgentDefaults, deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0
channel: resolvedDelivery.channel, ? deliveryPayloads
to: resolvedDelivery.to, : synthesizedText
accountId: resolvedDelivery.accountId, ? [{ text: synthesizedText }]
threadId: resolvedDelivery.threadId, : [];
payloads: deliveryPayloads, if (payloadsForDelivery.length > 0) {
bestEffort: deliveryBestEffort, const deliveryResults = await deliverOutboundPayloads({
deps: createOutboundSendDeps(params.deps), cfg: cfgWithAgentDefaults,
}); channel: resolvedDelivery.channel,
delivered = deliveryResults.length > 0; to: resolvedDelivery.to,
accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId,
payloads: payloadsForDelivery,
username,
icon_url,
icon_emoji,
bestEffort: deliveryBestEffort,
deps: createOutboundSendDeps(params.deps),
});
delivered = deliveryResults.length > 0;
}
} catch (err) { } catch (err) {
if (!deliveryBestEffort) { if (!deliveryBestEffort) {
return withRunSession({ status: "error", summary, outputText, error: String(err) }); return withRunSession({ status: "error", summary, outputText, error: String(err) });
+27
View File
@@ -85,6 +85,9 @@ async function createChannelHandler(params: {
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean; silent?: boolean;
@@ -101,6 +104,9 @@ async function createChannelHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
deps: params.deps, deps: params.deps,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
silent: params.silent, silent: params.silent,
@@ -119,6 +125,9 @@ function createPluginHandler(params: {
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean; silent?: boolean;
@@ -145,6 +154,9 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent, silent: params.silent,
@@ -159,6 +171,9 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent, silent: params.silent,
@@ -172,6 +187,9 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
silent: params.silent, silent: params.silent,
@@ -189,6 +207,9 @@ export async function deliverOutboundPayloads(params: {
payloads: ReplyPayload[]; payloads: ReplyPayload[];
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@@ -271,6 +292,9 @@ async function deliverOutboundPayloadsCore(params: {
payloads: ReplyPayload[]; payloads: ReplyPayload[];
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@@ -299,6 +323,9 @@ async function deliverOutboundPayloadsCore(params: {
accountId, accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
silent: params.silent, silent: params.silent,
}); });
+85 -6
View File
@@ -33,8 +33,83 @@ type SlackSendOpts = {
mediaUrl?: string; mediaUrl?: string;
client?: WebClient; client?: WebClient;
threadTs?: string; threadTs?: string;
username?: string;
icon_url?: string;
icon_emoji?: string;
}; };
function hasCustomIdentity(opts: SlackSendOpts): boolean {
return Boolean(opts.username || opts.icon_url || opts.icon_emoji);
}
function isSlackCustomizeScopeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const maybeData = err as Error & {
data?: {
error?: string;
needed?: string;
response_metadata?: { scopes?: string[]; acceptedScopes?: string[] };
};
};
const code = maybeData.data?.error?.toLowerCase();
if (code !== "missing_scope") {
return false;
}
const needed = maybeData.data?.needed?.toLowerCase();
if (needed?.includes("chat:write.customize")) {
return true;
}
const scopes = [
...(maybeData.data?.response_metadata?.scopes ?? []),
...(maybeData.data?.response_metadata?.acceptedScopes ?? []),
].map((scope) => scope.toLowerCase());
return scopes.includes("chat:write.customize");
}
async function postSlackMessageBestEffort(params: {
client: WebClient;
channelId: string;
text: string;
threadTs?: string;
opts: SlackSendOpts;
}) {
const basePayload = {
channel: params.channelId,
text: params.text,
thread_ts: params.threadTs,
};
try {
// Slack Web API types model icon_url and icon_emoji as mutually exclusive.
// Build payloads in explicit branches so TS and runtime stay aligned.
if (params.opts.icon_url) {
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
icon_url: params.opts.icon_url,
});
}
if (params.opts.icon_emoji) {
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
icon_emoji: params.opts.icon_emoji,
});
}
return await params.client.chat.postMessage({
...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}),
});
} catch (err) {
if (!hasCustomIdentity(params.opts) || !isSlackCustomizeScopeError(err)) {
throw err;
}
logVerbose("slack send: missing chat:write.customize, retrying without custom identity");
return params.client.chat.postMessage(basePayload);
}
}
export type SlackSendResult = { export type SlackSendResult = {
messageId: string; messageId: string;
channelId: string; channelId: string;
@@ -182,19 +257,23 @@ export async function sendMessageSlack(
maxBytes: mediaMaxBytes, maxBytes: mediaMaxBytes,
}); });
for (const chunk of rest) { for (const chunk of rest) {
const response = await client.chat.postMessage({ const response = await postSlackMessageBestEffort({
channel: channelId, client,
channelId,
text: chunk, text: chunk,
thread_ts: opts.threadTs, threadTs: opts.threadTs,
opts,
}); });
lastMessageId = response.ts ?? lastMessageId; lastMessageId = response.ts ?? lastMessageId;
} }
} else { } else {
for (const chunk of chunks.length ? chunks : [""]) { for (const chunk of chunks.length ? chunks : [""]) {
const response = await client.chat.postMessage({ const response = await postSlackMessageBestEffort({
channel: channelId, client,
channelId,
text: chunk, text: chunk,
thread_ts: opts.threadTs, threadTs: opts.threadTs,
opts,
}); });
lastMessageId = response.ts ?? lastMessageId; lastMessageId = response.ts ?? lastMessageId;
} }