refactor(outbound): centralize outbound identity

This commit is contained in:
Peter Steinberger
2026-02-14 16:44:06 +01:00
parent 6084d13b95
commit 50645b905b
7 changed files with 103 additions and 97 deletions

View File

@@ -45,16 +45,17 @@ describe("slack outbound hook wiring", () => {
text: "hello", text: "hello",
accountId: "default", accountId: "default",
replyToId: "1111.2222", replyToId: "1111.2222",
username: "My Agent", identity: {
icon_url: "https://example.com/avatar.png", name: "My Agent",
icon_emoji: ":should_not_send:", avatarUrl: "https://example.com/avatar.png",
emoji: ":should_not_send:",
},
}); });
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", { expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222", threadTs: "1111.2222",
accountId: "default", accountId: "default",
username: "My Agent", identity: { username: "My Agent", iconUrl: "https://example.com/avatar.png" },
icon_url: "https://example.com/avatar.png",
}); });
}); });
@@ -66,13 +67,13 @@ describe("slack outbound hook wiring", () => {
text: "hello", text: "hello",
accountId: "default", accountId: "default",
replyToId: "1111.2222", replyToId: "1111.2222",
icon_emoji: ":lobster:", identity: { emoji: ":lobster:" },
}); });
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", { expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222", threadTs: "1111.2222",
accountId: "default", accountId: "default",
icon_emoji: ":lobster:", identity: { iconEmoji: ":lobster:" },
}); });
}); });

View File

@@ -1,22 +1,27 @@
import type { OutboundIdentity } from "../../../infra/outbound/identity.js";
import type { ChannelOutboundAdapter } from "../types.js"; import type { ChannelOutboundAdapter } from "../types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.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 = { export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: null, chunker: null,
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ sendText: async ({ to, text, accountId, deps, replyToId, threadId, identity }) => {
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);
@@ -42,27 +47,15 @@ export const slackOutbound: ChannelOutboundAdapter = {
} }
} }
const slackIdentity = resolveSlackSendIdentity(identity);
const result = await send(to, finalText, { const result = await send(to, finalText, {
threadTs, threadTs,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(username ? { username } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}),
...(icon_url ? { icon_url } : {}),
...(icon_emoji && !icon_url ? { icon_emoji } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },
sendMedia: async ({ sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, identity }) => {
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);
@@ -88,13 +81,12 @@ export const slackOutbound: ChannelOutboundAdapter = {
} }
} }
const slackIdentity = resolveSlackSendIdentity(identity);
const result = await send(to, finalText, { const result = await send(to, finalText, {
mediaUrl, mediaUrl,
threadTs, threadTs,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
...(username ? { username } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}),
...(icon_url ? { icon_url } : {}),
...(icon_emoji && !icon_url ? { icon_emoji } : {}),
}); });
return { channel: "slack", ...result }; return { channel: "slack", ...result };
}, },

View File

@@ -2,6 +2,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.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 { RuntimeEnv } from "../../runtime.js";
import type { import type {
ChannelAccountSnapshot, ChannelAccountSnapshot,
@@ -79,9 +80,7 @@ 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; identity?: OutboundIdentity;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
silent?: boolean; silent?: boolean;
}; };

View File

@@ -14,8 +14,6 @@ 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 {
@@ -46,6 +44,7 @@ import {
} from "../../config/sessions.js"; } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js"; import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { logWarn } from "../../logger.js"; import { logWarn } from "../../logger.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
@@ -557,18 +556,11 @@ 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 });
} }
const agentIdentity = resolveAgentIdentity(cfgWithAgentDefaults, agentId); const identity = resolveAgentOutboundIdentity(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 // 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. // identity to preserve, prefer direct outbound delivery even for plain-text payloads.
if (deliveryPayloadHasStructuredContent || username || icon_url || icon_emoji) { if (deliveryPayloadHasStructuredContent || identity) {
try { try {
const payloadsForDelivery = const payloadsForDelivery =
deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0 deliveryPayloadHasStructuredContent && deliveryPayloads.length > 0
@@ -584,9 +576,7 @@ export async function runCronIsolatedAgentTurn(params: {
accountId: resolvedDelivery.accountId, accountId: resolvedDelivery.accountId,
threadId: resolvedDelivery.threadId, threadId: resolvedDelivery.threadId,
payloads: payloadsForDelivery, payloads: payloadsForDelivery,
username, identity,
icon_url,
icon_emoji,
bestEffort: deliveryBestEffort, bestEffort: deliveryBestEffort,
deps: createOutboundSendDeps(params.deps), deps: createOutboundSendDeps(params.deps),
}); });

View File

@@ -6,6 +6,7 @@ import type { sendMessageIMessage } from "../../imessage/send.js";
import type { sendMessageSlack } from "../../slack/send.js"; import type { sendMessageSlack } from "../../slack/send.js";
import type { sendMessageTelegram } from "../../telegram/send.js"; import type { sendMessageTelegram } from "../../telegram/send.js";
import type { sendMessageWhatsApp } from "../../web/outbound.js"; import type { sendMessageWhatsApp } from "../../web/outbound.js";
import type { OutboundIdentity } from "./identity.js";
import type { NormalizedOutboundPayload } from "./payloads.js"; import type { NormalizedOutboundPayload } from "./payloads.js";
import type { OutboundChannel } from "./targets.js"; import type { OutboundChannel } from "./targets.js";
import { import {
@@ -85,9 +86,7 @@ async function createChannelHandler(params: {
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string; identity?: OutboundIdentity;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean; silent?: boolean;
@@ -104,9 +103,7 @@ 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, identity: params.identity,
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,
@@ -125,9 +122,7 @@ function createPluginHandler(params: {
accountId?: string; accountId?: string;
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string; identity?: OutboundIdentity;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
silent?: boolean; silent?: boolean;
@@ -154,9 +149,7 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username, identity: params.identity,
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,
@@ -171,9 +164,7 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username, identity: params.identity,
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,
@@ -187,9 +178,7 @@ function createPluginHandler(params: {
accountId: params.accountId, accountId: params.accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username, identity: params.identity,
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,
@@ -207,9 +196,7 @@ 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; identity?: OutboundIdentity;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@@ -292,9 +279,7 @@ async function deliverOutboundPayloadsCore(params: {
payloads: ReplyPayload[]; payloads: ReplyPayload[];
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
username?: string; identity?: OutboundIdentity;
icon_url?: string;
icon_emoji?: string;
deps?: OutboundSendDeps; deps?: OutboundSendDeps;
gifPlayback?: boolean; gifPlayback?: boolean;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
@@ -323,9 +308,7 @@ async function deliverOutboundPayloadsCore(params: {
accountId, accountId,
replyToId: params.replyToId, replyToId: params.replyToId,
threadId: params.threadId, threadId: params.threadId,
username: params.username, identity: params.identity,
icon_url: params.icon_url,
icon_emoji: params.icon_emoji,
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
silent: params.silent, silent: params.silent,
}); });

View File

@@ -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,
});
}

View File

@@ -27,19 +27,23 @@ type SlackRecipient =
id: string; id: string;
}; };
export type SlackSendIdentity = {
username?: string;
iconUrl?: string;
iconEmoji?: string;
};
type SlackSendOpts = { type SlackSendOpts = {
token?: string; token?: string;
accountId?: string; accountId?: string;
mediaUrl?: string; mediaUrl?: string;
client?: WebClient; client?: WebClient;
threadTs?: string; threadTs?: string;
username?: string; identity?: SlackSendIdentity;
icon_url?: string;
icon_emoji?: string;
}; };
function hasCustomIdentity(opts: SlackSendOpts): boolean { function hasCustomIdentity(identity?: SlackSendIdentity): boolean {
return Boolean(opts.username || opts.icon_url || opts.icon_emoji); return Boolean(identity?.username || identity?.iconUrl || identity?.iconEmoji);
} }
function isSlackCustomizeScopeError(err: unknown): boolean { function isSlackCustomizeScopeError(err: unknown): boolean {
@@ -73,7 +77,7 @@ async function postSlackMessageBestEffort(params: {
channelId: string; channelId: string;
text: string; text: string;
threadTs?: string; threadTs?: string;
opts: SlackSendOpts; identity?: SlackSendIdentity;
}) { }) {
const basePayload = { const basePayload = {
channel: params.channelId, channel: params.channelId,
@@ -83,26 +87,26 @@ async function postSlackMessageBestEffort(params: {
try { try {
// Slack Web API types model icon_url and icon_emoji as mutually exclusive. // Slack Web API types model icon_url and icon_emoji as mutually exclusive.
// Build payloads in explicit branches so TS and runtime stay aligned. // 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({ return await params.client.chat.postMessage({
...basePayload, ...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}), ...(params.identity.username ? { username: params.identity.username } : {}),
icon_url: params.opts.icon_url, icon_url: params.identity.iconUrl,
}); });
} }
if (params.opts.icon_emoji) { if (params.identity?.iconEmoji) {
return await params.client.chat.postMessage({ return await params.client.chat.postMessage({
...basePayload, ...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}), ...(params.identity.username ? { username: params.identity.username } : {}),
icon_emoji: params.opts.icon_emoji, icon_emoji: params.identity.iconEmoji,
}); });
} }
return await params.client.chat.postMessage({ return await params.client.chat.postMessage({
...basePayload, ...basePayload,
...(params.opts.username ? { username: params.opts.username } : {}), ...(params.identity?.username ? { username: params.identity.username } : {}),
}); });
} catch (err) { } catch (err) {
if (!hasCustomIdentity(params.opts) || !isSlackCustomizeScopeError(err)) { if (!hasCustomIdentity(params.identity) || !isSlackCustomizeScopeError(err)) {
throw err; throw err;
} }
logVerbose("slack send: missing chat:write.customize, retrying without custom identity"); logVerbose("slack send: missing chat:write.customize, retrying without custom identity");
@@ -262,7 +266,7 @@ export async function sendMessageSlack(
channelId, channelId,
text: chunk, text: chunk,
threadTs: opts.threadTs, threadTs: opts.threadTs,
opts, identity: opts.identity,
}); });
lastMessageId = response.ts ?? lastMessageId; lastMessageId = response.ts ?? lastMessageId;
} }
@@ -273,7 +277,7 @@ export async function sendMessageSlack(
channelId, channelId,
text: chunk, text: chunk,
threadTs: opts.threadTs, threadTs: opts.threadTs,
opts, identity: opts.identity,
}); });
lastMessageId = response.ts ?? lastMessageId; lastMessageId = response.ts ?? lastMessageId;
} }