diff --git a/CHANGELOG.md b/CHANGELOG.md index 05743166d61..d92081b6006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/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. +- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla. ## 2026.2.14 diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 93fd329070a..46ce2f7fe22 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -127,6 +127,7 @@ openclaw gateway - Config tokens override env fallback. - `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`). +- 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. 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. diff --git a/src/channels/plugins/outbound/slack.test.ts b/src/channels/plugins/outbound/slack.test.ts index 08863d24b7f..318e5a5b837 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/src/channels/plugins/outbound/slack.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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", () => ({ @@ -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 () => { const mockRunner = { hasHooks: vi.fn().mockReturnValue(true), diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index dde96245538..dbe8b0c931a 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -6,7 +6,17 @@ export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, 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; // Use threadId fallback so routed tool notifications stay in the Slack thread. const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); @@ -35,10 +45,24 @@ export const slackOutbound: ChannelOutboundAdapter = { const result = await send(to, finalText, { threadTs, accountId: accountId ?? undefined, + ...(username ? { username } : {}), + ...(icon_url ? { icon_url } : {}), + ...(icon_emoji && !icon_url ? { icon_emoji } : {}), }); 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; // Use threadId fallback so routed tool notifications stay in the Slack thread. const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); @@ -68,6 +92,9 @@ export const slackOutbound: ChannelOutboundAdapter = { mediaUrl, threadTs, accountId: accountId ?? undefined, + ...(username ? { username } : {}), + ...(icon_url ? { icon_url } : {}), + ...(icon_emoji && !icon_url ? { icon_emoji } : {}), }); return { channel: "slack", ...result }; }, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index d9ad5a0a527..436ca82fa73 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -79,6 +79,9 @@ export type ChannelOutboundContext = { replyToId?: string | null; threadId?: string | number | null; accountId?: string | null; + username?: string; + icon_url?: string; + icon_emoji?: string; deps?: OutboundSendDeps; silent?: boolean; }; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index aa97828f2b1..4860c89d430 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -14,6 +14,8 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js"; import { lookupContextTokens } from "../../agents/context.js"; import { resolveCronStyleNow } from "../../agents/current-time.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 { runWithModelFallback } from "../../agents/model-fallback.js"; import { @@ -555,21 +557,41 @@ export async function runCronIsolatedAgentTurn(params: { logWarn(`[cron:${params.job.id}] ${message}`); return withRunSession({ status: "ok", summary, outputText }); } - // Shared subagent announce flow is text-based; keep direct outbound delivery - // for media/channel payloads so structured content is preserved. - if (deliveryPayloadHasStructuredContent) { + const agentIdentity = resolveAgentIdentity(cfgWithAgentDefaults, agentId); + const avatar = resolveAgentAvatar(cfgWithAgentDefaults, agentId); + 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 { - const deliveryResults = await deliverOutboundPayloads({ - cfg: cfgWithAgentDefaults, - channel: resolvedDelivery.channel, - to: resolvedDelivery.to, - accountId: resolvedDelivery.accountId, - threadId: resolvedDelivery.threadId, - payloads: deliveryPayloads, - bestEffort: deliveryBestEffort, - deps: createOutboundSendDeps(params.deps), - }); - delivered = deliveryResults.length > 0; + const payloadsForDelivery = + deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0 + ? deliveryPayloads + : synthesizedText + ? [{ text: synthesizedText }] + : []; + if (payloadsForDelivery.length > 0) { + const deliveryResults = await deliverOutboundPayloads({ + cfg: cfgWithAgentDefaults, + channel: resolvedDelivery.channel, + 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) { if (!deliveryBestEffort) { return withRunSession({ status: "error", summary, outputText, error: String(err) }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index acbd4936907..077247c914c 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -85,6 +85,9 @@ async function createChannelHandler(params: { accountId?: string; replyToId?: string | null; threadId?: string | number | null; + username?: string; + icon_url?: string; + icon_emoji?: string; deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; @@ -101,6 +104,9 @@ async function createChannelHandler(params: { accountId: params.accountId, replyToId: params.replyToId, threadId: params.threadId, + username: params.username, + icon_url: params.icon_url, + icon_emoji: params.icon_emoji, deps: params.deps, gifPlayback: params.gifPlayback, silent: params.silent, @@ -119,6 +125,9 @@ function createPluginHandler(params: { accountId?: string; replyToId?: string | null; threadId?: string | number | null; + username?: string; + icon_url?: string; + icon_emoji?: string; deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; @@ -145,6 +154,9 @@ function createPluginHandler(params: { accountId: params.accountId, replyToId: params.replyToId, threadId: params.threadId, + username: params.username, + icon_url: params.icon_url, + icon_emoji: params.icon_emoji, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -159,6 +171,9 @@ function createPluginHandler(params: { accountId: params.accountId, replyToId: params.replyToId, threadId: params.threadId, + username: params.username, + icon_url: params.icon_url, + icon_emoji: params.icon_emoji, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -172,6 +187,9 @@ function createPluginHandler(params: { accountId: params.accountId, replyToId: params.replyToId, threadId: params.threadId, + username: params.username, + icon_url: params.icon_url, + icon_emoji: params.icon_emoji, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -189,6 +207,9 @@ export async function deliverOutboundPayloads(params: { payloads: ReplyPayload[]; replyToId?: string | null; threadId?: string | number | null; + username?: string; + icon_url?: string; + icon_emoji?: string; deps?: OutboundSendDeps; gifPlayback?: boolean; abortSignal?: AbortSignal; @@ -271,6 +292,9 @@ async function deliverOutboundPayloadsCore(params: { payloads: ReplyPayload[]; replyToId?: string | null; threadId?: string | number | null; + username?: string; + icon_url?: string; + icon_emoji?: string; deps?: OutboundSendDeps; gifPlayback?: boolean; abortSignal?: AbortSignal; @@ -299,6 +323,9 @@ async function deliverOutboundPayloadsCore(params: { accountId, replyToId: params.replyToId, threadId: params.threadId, + username: params.username, + icon_url: params.icon_url, + icon_emoji: params.icon_emoji, gifPlayback: params.gifPlayback, silent: params.silent, }); diff --git a/src/slack/send.ts b/src/slack/send.ts index 6bdf4ab2ffa..abbecb512c1 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -33,8 +33,83 @@ type SlackSendOpts = { mediaUrl?: string; client?: WebClient; 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 = { messageId: string; channelId: string; @@ -182,19 +257,23 @@ export async function sendMessageSlack( maxBytes: mediaMaxBytes, }); for (const chunk of rest) { - const response = await client.chat.postMessage({ - channel: channelId, + const response = await postSlackMessageBestEffort({ + client, + channelId, text: chunk, - thread_ts: opts.threadTs, + threadTs: opts.threadTs, + opts, }); lastMessageId = response.ts ?? lastMessageId; } } else { for (const chunk of chunks.length ? chunks : [""]) { - const response = await client.chat.postMessage({ - channel: channelId, + const response = await postSlackMessageBestEffort({ + client, + channelId, text: chunk, - thread_ts: opts.threadTs, + threadTs: opts.threadTs, + opts, }); lastMessageId = response.ts ?? lastMessageId; }