diff --git a/CHANGELOG.md b/CHANGELOG.md index f65919e57..0119ff0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -232,6 +232,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong. - Agents/Subagents delivery: refactor subagent completion announce dispatch into an explicit queue/direct/fallback state machine, recover outbound channel-plugin resolution in cold/stale plugin-registry states across announce/message/gateway send paths, finalize cleanup bookkeeping when announce flow rejects, and treat Telegram sends without `message_id` as delivery failures (instead of false-success `"unknown"` IDs). (#26867, #25961, #26803, #25069, #26741) Thanks @SmithLabsLLC and @docaohieu2808. - Telegram/Webhook: pre-initialize webhook bots, switch webhook processing to callback-mode JSON handling, and preserve full near-limit payload reads under delayed handlers to prevent webhook request hangs and dropped updates. (#26156) - Slack/Session threads: prevent oversized parent-session inheritance from silently bricking new thread sessions, surface embedded context-overflow empty-result failures to users, and add configurable `session.parentForkMaxTokens` (default `100000`, `0` disables). (#26912) Thanks @markshields-tl. diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts index 35db7c2f7..b987585a2 100644 --- a/src/slack/monitor/message-handler/dispatch.ts +++ b/src/slack/monitor/message-handler/dispatch.ts @@ -9,6 +9,7 @@ import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js"; import { createTypingCallbacks } from "../../../channels/typing.js"; import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js"; +import { resolveAgentOutboundIdentity } from "../../../infra/outbound/identity.js"; import { removeSlackReaction } from "../../actions.js"; import { createSlackDraftStream } from "../../draft-stream.js"; import { @@ -70,6 +71,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag const cfg = ctx.cfg; const runtime = ctx.runtime; + // Resolve agent identity for Slack chat:write.customize overrides. + const outboundIdentity = resolveAgentOutboundIdentity(cfg, route.agentId); + const slackIdentity = outboundIdentity + ? { + username: outboundIdentity.name, + iconUrl: outboundIdentity.avatarUrl, + iconEmoji: outboundIdentity.emoji, + } + : undefined; + if (prepared.isDirectMessage) { const sessionCfg = cfg.session; const storePath = resolveStorePath(sessionCfg?.store, { @@ -190,6 +201,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag textLimit: ctx.textLimit, replyThreadTs, replyToMode: ctx.replyToMode, + ...(slackIdentity ? { identity: slackIdentity } : {}), }); replyPlan.markSent(); }; diff --git a/src/slack/monitor/replies.test.ts b/src/slack/monitor/replies.test.ts new file mode 100644 index 000000000..3d0c3e4fc --- /dev/null +++ b/src/slack/monitor/replies.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMock = vi.fn(); +vi.mock("../send.js", () => ({ + sendMessageSlack: (...args: unknown[]) => sendMock(...args), +})); + +import { deliverReplies } from "./replies.js"; + +function baseParams(overrides?: Record) { + return { + replies: [{ text: "hello" }], + target: "C123", + token: "xoxb-test", + runtime: { log: () => {}, error: () => {}, exit: () => {} }, + textLimit: 4000, + replyToMode: "off" as const, + ...overrides, + }; +} + +describe("deliverReplies identity passthrough", () => { + beforeEach(() => { + sendMock.mockReset(); + }); + it("passes identity to sendMessageSlack for text replies", async () => { + sendMock.mockResolvedValue(undefined); + const identity = { username: "Bot", iconEmoji: ":robot:" }; + await deliverReplies(baseParams({ identity })); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock.mock.calls[0][2]).toMatchObject({ identity }); + }); + + it("passes identity to sendMessageSlack for media replies", async () => { + sendMock.mockResolvedValue(undefined); + const identity = { username: "Bot", iconUrl: "https://example.com/icon.png" }; + await deliverReplies( + baseParams({ + identity, + replies: [{ text: "caption", mediaUrls: ["https://example.com/img.png"] }], + }), + ); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock.mock.calls[0][2]).toMatchObject({ identity }); + }); + + it("omits identity key when not provided", async () => { + sendMock.mockResolvedValue(undefined); + await deliverReplies(baseParams()); + + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock.mock.calls[0][2]).not.toHaveProperty("identity"); + }); +}); diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index bc89942d2..4c19ac962 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -6,7 +6,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; import type { RuntimeEnv } from "../../runtime.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; -import { sendMessageSlack } from "../send.js"; +import { sendMessageSlack, type SlackSendIdentity } from "../send.js"; export async function deliverReplies(params: { replies: ReplyPayload[]; @@ -17,6 +17,7 @@ export async function deliverReplies(params: { textLimit: number; replyThreadTs?: string; replyToMode: "off" | "first" | "all"; + identity?: SlackSendIdentity; }) { for (const payload of params.replies) { // Keep reply tags opt-in: when replyToMode is off, explicit reply tags @@ -38,6 +39,7 @@ export async function deliverReplies(params: { token: params.token, threadTs, accountId: params.accountId, + ...(params.identity ? { identity: params.identity } : {}), }); } else { let first = true; @@ -49,6 +51,7 @@ export async function deliverReplies(params: { mediaUrl, threadTs, accountId: params.accountId, + ...(params.identity ? { identity: params.identity } : {}), }); } }