From 50645b905bc7af1b1552884b16e197f47d057486 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 16:44:06 +0100 Subject: [PATCH] refactor(outbound): centralize outbound identity --- src/channels/plugins/outbound/slack.test.ts | 15 +++--- src/channels/plugins/outbound/slack.ts | 52 +++++++++------------ src/channels/plugins/types.adapters.ts | 5 +- src/cron/isolated-agent/run.ts | 18 ++----- src/infra/outbound/deliver.ts | 37 ++++----------- src/infra/outbound/identity.ts | 37 +++++++++++++++ src/slack/send.ts | 36 +++++++------- 7 files changed, 103 insertions(+), 97 deletions(-) create mode 100644 src/infra/outbound/identity.ts diff --git a/src/channels/plugins/outbound/slack.test.ts b/src/channels/plugins/outbound/slack.test.ts index 318e5a5b837..d058f9e989f 100644 --- a/src/channels/plugins/outbound/slack.test.ts +++ b/src/channels/plugins/outbound/slack.test.ts @@ -45,16 +45,17 @@ describe("slack outbound hook wiring", () => { text: "hello", accountId: "default", replyToId: "1111.2222", - username: "My Agent", - icon_url: "https://example.com/avatar.png", - icon_emoji: ":should_not_send:", + identity: { + name: "My Agent", + avatarUrl: "https://example.com/avatar.png", + emoji: ":should_not_send:", + }, }); expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", { threadTs: "1111.2222", accountId: "default", - username: "My Agent", - icon_url: "https://example.com/avatar.png", + identity: { username: "My Agent", iconUrl: "https://example.com/avatar.png" }, }); }); @@ -66,13 +67,13 @@ describe("slack outbound hook wiring", () => { text: "hello", accountId: "default", replyToId: "1111.2222", - icon_emoji: ":lobster:", + identity: { emoji: ":lobster:" }, }); expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", { threadTs: "1111.2222", accountId: "default", - icon_emoji: ":lobster:", + identity: { iconEmoji: ":lobster:" }, }); }); diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index dbe8b0c931a..b4f09b0e876 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -1,22 +1,27 @@ +import type { OutboundIdentity } from "../../../infra/outbound/identity.js"; import type { ChannelOutboundAdapter } from "../types.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { sendMessageSlack } from "../../../slack/send.js"; +import { sendMessageSlack, type SlackSendIdentity } from "../../../slack/send.js"; + +function resolveSlackSendIdentity(identity?: OutboundIdentity): SlackSendIdentity | undefined { + if (!identity) { + return undefined; + } + const username = identity.name?.trim() || undefined; + const iconUrl = identity.avatarUrl?.trim() || undefined; + const rawEmoji = identity.emoji?.trim(); + const iconEmoji = !iconUrl && rawEmoji && /^:[^:\s]+:$/.test(rawEmoji) ? rawEmoji : undefined; + if (!username && !iconUrl && !iconEmoji) { + return undefined; + } + return { username, iconUrl, iconEmoji }; +} export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ - to, - text, - accountId, - deps, - replyToId, - threadId, - username, - icon_url, - icon_emoji, - }) => { + sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => { 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); @@ -42,27 +47,15 @@ export const slackOutbound: ChannelOutboundAdapter = { } } + const slackIdentity = resolveSlackSendIdentity(identity); const result = await send(to, finalText, { threadTs, accountId: accountId ?? undefined, - ...(username ? { username } : {}), - ...(icon_url ? { icon_url } : {}), - ...(icon_emoji && !icon_url ? { icon_emoji } : {}), + ...(slackIdentity ? { identity: slackIdentity } : {}), }); return { channel: "slack", ...result }; }, - sendMedia: async ({ - to, - text, - mediaUrl, - accountId, - deps, - replyToId, - threadId, - username, - icon_url, - icon_emoji, - }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, identity }) => { 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); @@ -88,13 +81,12 @@ export const slackOutbound: ChannelOutboundAdapter = { } } + const slackIdentity = resolveSlackSendIdentity(identity); const result = await send(to, finalText, { mediaUrl, threadTs, accountId: accountId ?? undefined, - ...(username ? { username } : {}), - ...(icon_url ? { icon_url } : {}), - ...(icon_emoji && !icon_url ? { icon_emoji } : {}), + ...(slackIdentity ? { identity: slackIdentity } : {}), }); return { channel: "slack", ...result }; }, diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 436ca82fa73..dc8ee43bab2 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -2,6 +2,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js"; +import type { OutboundIdentity } from "../../infra/outbound/identity.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { ChannelAccountSnapshot, @@ -79,9 +80,7 @@ export type ChannelOutboundContext = { replyToId?: string | null; threadId?: string | number | null; accountId?: string | null; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: OutboundIdentity; deps?: OutboundSendDeps; silent?: boolean; }; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4860c89d430..9cd0528796c 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -14,8 +14,6 @@ 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 { @@ -46,6 +44,7 @@ import { } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { logWarn } from "../../logger.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js"; @@ -557,18 +556,11 @@ export async function runCronIsolatedAgentTurn(params: { logWarn(`[cron:${params.job.id}] ${message}`); return withRunSession({ status: "ok", summary, outputText }); } - 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; + const identity = resolveAgentOutboundIdentity(cfgWithAgentDefaults, agentId); // 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) { + if (deliveryPayloadHasStructuredContent || identity) { try { const payloadsForDelivery = deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0 @@ -584,9 +576,7 @@ export async function runCronIsolatedAgentTurn(params: { accountId: resolvedDelivery.accountId, threadId: resolvedDelivery.threadId, payloads: payloadsForDelivery, - username, - icon_url, - icon_emoji, + identity, bestEffort: deliveryBestEffort, deps: createOutboundSendDeps(params.deps), }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 077247c914c..526c0b6b8c9 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -6,6 +6,7 @@ import type { sendMessageIMessage } from "../../imessage/send.js"; import type { sendMessageSlack } from "../../slack/send.js"; import type { sendMessageTelegram } from "../../telegram/send.js"; import type { sendMessageWhatsApp } from "../../web/outbound.js"; +import type { OutboundIdentity } from "./identity.js"; import type { NormalizedOutboundPayload } from "./payloads.js"; import type { OutboundChannel } from "./targets.js"; import { @@ -85,9 +86,7 @@ async function createChannelHandler(params: { accountId?: string; replyToId?: string | null; threadId?: string | number | null; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; @@ -104,9 +103,7 @@ 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, + identity: params.identity, deps: params.deps, gifPlayback: params.gifPlayback, silent: params.silent, @@ -125,9 +122,7 @@ function createPluginHandler(params: { accountId?: string; replyToId?: string | null; threadId?: string | number | null; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; @@ -154,9 +149,7 @@ 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, + identity: params.identity, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -171,9 +164,7 @@ 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, + identity: params.identity, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -187,9 +178,7 @@ 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, + identity: params.identity, gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, @@ -207,9 +196,7 @@ export async function deliverOutboundPayloads(params: { payloads: ReplyPayload[]; replyToId?: string | null; threadId?: string | number | null; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; abortSignal?: AbortSignal; @@ -292,9 +279,7 @@ async function deliverOutboundPayloadsCore(params: { payloads: ReplyPayload[]; replyToId?: string | null; threadId?: string | number | null; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: OutboundIdentity; deps?: OutboundSendDeps; gifPlayback?: boolean; abortSignal?: AbortSignal; @@ -323,9 +308,7 @@ async function deliverOutboundPayloadsCore(params: { accountId, replyToId: params.replyToId, threadId: params.threadId, - username: params.username, - icon_url: params.icon_url, - icon_emoji: params.icon_emoji, + identity: params.identity, gifPlayback: params.gifPlayback, silent: params.silent, }); diff --git a/src/infra/outbound/identity.ts b/src/infra/outbound/identity.ts new file mode 100644 index 00000000000..25de17a0603 --- /dev/null +++ b/src/infra/outbound/identity.ts @@ -0,0 +1,37 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveAgentAvatar } from "../../agents/identity-avatar.js"; +import { resolveAgentIdentity } from "../../agents/identity.js"; + +export type OutboundIdentity = { + name?: string; + avatarUrl?: string; + emoji?: string; +}; + +export function normalizeOutboundIdentity( + identity?: OutboundIdentity | null, +): OutboundIdentity | undefined { + if (!identity) { + return undefined; + } + const name = identity.name?.trim() || undefined; + const avatarUrl = identity.avatarUrl?.trim() || undefined; + const emoji = identity.emoji?.trim() || undefined; + if (!name && !avatarUrl && !emoji) { + return undefined; + } + return { name, avatarUrl, emoji }; +} + +export function resolveAgentOutboundIdentity( + cfg: OpenClawConfig, + agentId: string, +): OutboundIdentity | undefined { + const agentIdentity = resolveAgentIdentity(cfg, agentId); + const avatar = resolveAgentAvatar(cfg, agentId); + return normalizeOutboundIdentity({ + name: agentIdentity?.name, + emoji: agentIdentity?.emoji, + avatarUrl: avatar.kind === "remote" ? avatar.url : undefined, + }); +} diff --git a/src/slack/send.ts b/src/slack/send.ts index abbecb512c1..200b77bf32c 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -27,19 +27,23 @@ type SlackRecipient = id: string; }; +export type SlackSendIdentity = { + username?: string; + iconUrl?: string; + iconEmoji?: string; +}; + type SlackSendOpts = { token?: string; accountId?: string; mediaUrl?: string; client?: WebClient; threadTs?: string; - username?: string; - icon_url?: string; - icon_emoji?: string; + identity?: SlackSendIdentity; }; -function hasCustomIdentity(opts: SlackSendOpts): boolean { - return Boolean(opts.username || opts.icon_url || opts.icon_emoji); +function hasCustomIdentity(identity?: SlackSendIdentity): boolean { + return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji); } function isSlackCustomizeScopeError(err: unknown): boolean { @@ -73,7 +77,7 @@ async function postSlackMessageBestEffort(params: { channelId: string; text: string; threadTs?: string; - opts: SlackSendOpts; + identity?: SlackSendIdentity; }) { const basePayload = { channel: params.channelId, @@ -83,26 +87,26 @@ async function postSlackMessageBestEffort(params: { 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) { + if (params.identity?.iconUrl) { return await params.client.chat.postMessage({ ...basePayload, - ...(params.opts.username ? { username: params.opts.username } : {}), - icon_url: params.opts.icon_url, + ...(params.identity.username ? { username: params.identity.username } : {}), + icon_url: params.identity.iconUrl, }); } - if (params.opts.icon_emoji) { + if (params.identity?.iconEmoji) { return await params.client.chat.postMessage({ ...basePayload, - ...(params.opts.username ? { username: params.opts.username } : {}), - icon_emoji: params.opts.icon_emoji, + ...(params.identity.username ? { username: params.identity.username } : {}), + icon_emoji: params.identity.iconEmoji, }); } return await params.client.chat.postMessage({ ...basePayload, - ...(params.opts.username ? { username: params.opts.username } : {}), + ...(params.identity?.username ? { username: params.identity.username } : {}), }); } catch (err) { - if (!hasCustomIdentity(params.opts) || !isSlackCustomizeScopeError(err)) { + if (!hasCustomIdentity(params.identity) || !isSlackCustomizeScopeError(err)) { throw err; } logVerbose("slack send: missing chat:write.customize, retrying without custom identity"); @@ -262,7 +266,7 @@ export async function sendMessageSlack( channelId, text: chunk, threadTs: opts.threadTs, - opts, + identity: opts.identity, }); lastMessageId = response.ts ?? lastMessageId; } @@ -273,7 +277,7 @@ export async function sendMessageSlack( channelId, text: chunk, threadTs: opts.threadTs, - opts, + identity: opts.identity, }); lastMessageId = response.ts ?? lastMessageId; }