fix(slack): thread agent identity through channel reply path (openclaw#27134) thanks @hou-rong

Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: hou-rong <8758438+hou-rong@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
HouRong
2026-03-01 23:25:32 +08:00
committed by GitHub
parent 4ba0a4d4fb
commit b3f60a68a0
4 changed files with 73 additions and 1 deletions

View File

@@ -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.

View File

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

View File

@@ -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<string, unknown>) {
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");
});
});

View File

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