mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:28:26 +00:00
feat(slack): land thread-ownership from @DarlingtonDeveloper (#15775)
Land PR #15775 by @DarlingtonDeveloper: - add thread-ownership plugin and Slack message_sending hook wiring - include regression tests and changelog update Co-authored-by: Mike <108890394+DarlingtonDeveloper@users.noreply.github.com>
This commit is contained in:
124
src/channels/plugins/outbound/slack.test.ts
Normal file
124
src/channels/plugins/outbound/slack.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../slack/send.js", () => ({
|
||||
sendMessageSlack: vi.fn().mockResolvedValue({ ts: "1234.5678", channel: "C123" }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { sendMessageSlack } from "../../../slack/send.js";
|
||||
import { slackOutbound } from "./slack.js";
|
||||
|
||||
describe("slack outbound hook wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("calls send without hooks when no hooks registered", async () => {
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
|
||||
|
||||
await slackOutbound.sendText({
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
|
||||
threadTs: "1111.2222",
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls message_sending hook before sending", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
runMessageSending: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
|
||||
|
||||
await slackOutbound.sendText({
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(mockRunner.hasHooks).toHaveBeenCalledWith("message_sending");
|
||||
expect(mockRunner.runMessageSending).toHaveBeenCalledWith(
|
||||
{ to: "C123", content: "hello", metadata: { threadTs: "1111.2222", channelId: "C123" } },
|
||||
{ channelId: "slack", accountId: "default" },
|
||||
);
|
||||
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
|
||||
threadTs: "1111.2222",
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels send when hook returns cancel:true", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
runMessageSending: vi.fn().mockResolvedValue({ cancel: true }),
|
||||
};
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
|
||||
|
||||
const result = await slackOutbound.sendText({
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(sendMessageSlack).not.toHaveBeenCalled();
|
||||
expect(result.channel).toBe("slack");
|
||||
});
|
||||
|
||||
it("modifies text when hook returns content", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(true),
|
||||
runMessageSending: vi.fn().mockResolvedValue({ content: "modified" }),
|
||||
};
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
|
||||
|
||||
await slackOutbound.sendText({
|
||||
to: "C123",
|
||||
text: "original",
|
||||
accountId: "default",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "modified", {
|
||||
threadTs: "1111.2222",
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips hooks when runner has no message_sending hooks", async () => {
|
||||
const mockRunner = {
|
||||
hasHooks: vi.fn().mockReturnValue(false),
|
||||
runMessageSending: vi.fn(),
|
||||
};
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
|
||||
|
||||
await slackOutbound.sendText({
|
||||
to: "C123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "1111.2222",
|
||||
});
|
||||
|
||||
expect(mockRunner.runMessageSending).not.toHaveBeenCalled();
|
||||
expect(sendMessageSlack).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ChannelOutboundAdapter } from "../types.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import { sendMessageSlack } from "../../../slack/send.js";
|
||||
|
||||
export const slackOutbound: ChannelOutboundAdapter = {
|
||||
@@ -9,7 +10,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
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);
|
||||
const result = await send(to, text, {
|
||||
let finalText = text;
|
||||
|
||||
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("message_sending")) {
|
||||
const hookResult = await hookRunner.runMessageSending(
|
||||
{ to, content: text, metadata: { threadTs, channelId: to } },
|
||||
{ channelId: "slack", accountId: accountId ?? undefined },
|
||||
);
|
||||
if (hookResult?.cancel) {
|
||||
return {
|
||||
channel: "slack",
|
||||
messageId: "cancelled-by-hook",
|
||||
channelId: to,
|
||||
meta: { cancelled: true },
|
||||
};
|
||||
}
|
||||
if (hookResult?.content) {
|
||||
finalText = hookResult.content;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await send(to, finalText, {
|
||||
threadTs,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
@@ -19,7 +42,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
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);
|
||||
const result = await send(to, text, {
|
||||
let finalText = text;
|
||||
|
||||
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
if (hookRunner?.hasHooks("message_sending")) {
|
||||
const hookResult = await hookRunner.runMessageSending(
|
||||
{ to, content: text, metadata: { threadTs, channelId: to, mediaUrl } },
|
||||
{ channelId: "slack", accountId: accountId ?? undefined },
|
||||
);
|
||||
if (hookResult?.cancel) {
|
||||
return {
|
||||
channel: "slack",
|
||||
messageId: "cancelled-by-hook",
|
||||
channelId: to,
|
||||
meta: { cancelled: true },
|
||||
};
|
||||
}
|
||||
if (hookResult?.content) {
|
||||
finalText = hookResult.content;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await send(to, finalText, {
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
accountId: accountId ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user