

























@@ -11,6 +11,10 @@ const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
1111const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
1212const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
1313const sendDiscordTextMock = vi.hoisted(() => vi.fn());
14+const messageHookRunner = vi.hoisted(() => ({
15+hasHooks: vi.fn<(name: string) => boolean>(() => false),
16+runMessageSending: vi.fn(),
17+}));
1418const buildDiscordSendErrorMock = vi.hoisted(() =>
1519vi.fn<(err: unknown, ctx?: unknown) => Promise<unknown>>(async (err: unknown) => err),
1620);
@@ -65,6 +69,10 @@ vi.mock("openclaw/plugin-sdk/retry-runtime", async () => {
6569};
6670});
677172+vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({
73+getGlobalHookRunner: () => messageHookRunner,
74+}));
75+6876let deliverDiscordReply: typeof import("./reply-delivery.js").deliverDiscordReply;
69777078describe("deliverDiscordReply", () => {
@@ -143,6 +151,8 @@ describe("deliverDiscordReply", () => {
143151});
144152buildDiscordSendErrorMock.mockClear().mockImplementation(async (err: unknown) => err);
145153retryAsyncMock.mockClear();
154+messageHookRunner.hasHooks.mockReset().mockReturnValue(false);
155+messageHookRunner.runMessageSending.mockReset();
146156threadBindingTesting.resetThreadBindingsForTests();
147157});
148158@@ -668,4 +678,201 @@ describe("deliverDiscordReply", () => {
668678expect.objectContaining({ token: "token", accountId: "default" }),
669679);
670680});
681+682+it("replaces reply text with message_sending hook content", async () => {
683+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
684+messageHookRunner.runMessageSending.mockResolvedValue({ content: "filtered text" });
685+686+await deliverDiscordReply({
687+replies: [{ text: "raw secret" }],
688+target: "channel:hook-1",
689+token: "token",
690+accountId: "acc-1",
691+ runtime,
692+ cfg,
693+textLimit: 2000,
694+});
695+696+expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
697+expect.objectContaining({
698+to: "hook-1",
699+content: "raw secret",
700+metadata: expect.objectContaining({ channel: "discord" }),
701+}),
702+expect.objectContaining({
703+channelId: "discord",
704+accountId: "acc-1",
705+conversationId: "hook-1",
706+}),
707+);
708+expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
709+expect(sendMessageDiscordMock).toHaveBeenCalledWith(
710+"channel:hook-1",
711+"filtered text",
712+expect.anything(),
713+);
714+});
715+716+it("uses the raw Discord target as hook destination for DMs", async () => {
717+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
718+messageHookRunner.runMessageSending.mockResolvedValue({ content: "dm filtered" });
719+720+await deliverDiscordReply({
721+replies: [{ text: "dm raw" }],
722+target: "user:U123",
723+token: "token",
724+accountId: "acc-1",
725+ runtime,
726+ cfg,
727+textLimit: 2000,
728+});
729+730+expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith(
731+expect.objectContaining({
732+to: "user:U123",
733+content: "dm raw",
734+}),
735+expect.objectContaining({
736+channelId: "discord",
737+accountId: "acc-1",
738+conversationId: "user:U123",
739+}),
740+);
741+expect(sendMessageDiscordMock).toHaveBeenCalledWith(
742+"user:U123",
743+"dm filtered",
744+expect.anything(),
745+);
746+});
747+748+it("reports replyToId to hooks only while a single-use fallback reply is still available", async () => {
749+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
750+messageHookRunner.runMessageSending.mockResolvedValue({});
751+752+await deliverDiscordReply({
753+replies: [{ text: "first" }, { text: "second" }],
754+target: "channel:hook-thread",
755+token: "token",
756+ runtime,
757+ cfg,
758+textLimit: 2000,
759+replyToId: "reply-1",
760+replyToMode: "first",
761+});
762+763+expect(messageHookRunner.runMessageSending).toHaveBeenCalledTimes(2);
764+expect(messageHookRunner.runMessageSending.mock.calls[0]?.[0]).toEqual(
765+expect.objectContaining({ replyToId: "reply-1" }),
766+);
767+expect(messageHookRunner.runMessageSending.mock.calls[1]?.[0]).toEqual(
768+expect.not.objectContaining({ replyToId: expect.anything() }),
769+);
770+expect(sendMessageDiscordMock.mock.calls[0]?.[2]).toEqual(
771+expect.objectContaining({ replyTo: "reply-1" }),
772+);
773+expect(sendMessageDiscordMock.mock.calls[1]?.[2]).toEqual(
774+expect.not.objectContaining({ replyTo: expect.anything() }),
775+);
776+});
777+778+it("reports explicit payload reply targets to hooks when replyToMode is off", async () => {
779+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
780+messageHookRunner.runMessageSending.mockResolvedValue({});
781+782+await deliverDiscordReply({
783+replies: [
784+{
785+text: "explicit",
786+replyToId: "reply-explicit-1",
787+replyToTag: true,
788+},
789+],
790+target: "channel:hook-explicit",
791+token: "token",
792+ runtime,
793+ cfg,
794+textLimit: 2000,
795+replyToMode: "off",
796+});
797+798+expect(messageHookRunner.runMessageSending.mock.calls[0]?.[0]).toEqual(
799+expect.objectContaining({ replyToId: "reply-explicit-1" }),
800+);
801+expect(sendMessageDiscordMock.mock.calls[0]?.[2]).toEqual(
802+expect.objectContaining({ replyTo: "reply-explicit-1" }),
803+);
804+});
805+806+it("skips delivery when message_sending hook cancels the payload", async () => {
807+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
808+messageHookRunner.runMessageSending.mockResolvedValue({ cancel: true });
809+810+await deliverDiscordReply({
811+replies: [{ text: "should not send" }],
812+target: "channel:hook-2",
813+token: "token",
814+ runtime,
815+ cfg,
816+textLimit: 2000,
817+});
818+819+expect(sendMessageDiscordMock).not.toHaveBeenCalled();
820+});
821+822+it("skips delivery when hook blanks out a text-only reply", async () => {
823+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
824+messageHookRunner.runMessageSending.mockResolvedValue({ content: " " });
825+826+await deliverDiscordReply({
827+replies: [{ text: "hello" }],
828+target: "channel:hook-3",
829+token: "token",
830+ runtime,
831+ cfg,
832+textLimit: 2000,
833+});
834+835+expect(sendMessageDiscordMock).not.toHaveBeenCalled();
836+});
837+838+it("continues delivery when message_sending hook throws", async () => {
839+messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending");
840+messageHookRunner.runMessageSending.mockRejectedValue(new Error("plugin exploded"));
841+const errorRuntime = { error: vi.fn() } as unknown as RuntimeEnv;
842+843+await deliverDiscordReply({
844+replies: [{ text: "should still send" }],
845+target: "channel:hook-4",
846+token: "token",
847+runtime: errorRuntime,
848+ cfg,
849+textLimit: 2000,
850+});
851+852+expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
853+expect(sendMessageDiscordMock).toHaveBeenCalledWith(
854+"channel:hook-4",
855+"should still send",
856+expect.anything(),
857+);
858+expect((errorRuntime.error as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toMatch(
859+/plugin exploded/,
860+);
861+});
862+863+it("skips hook resolution when no message_sending hooks are registered", async () => {
864+messageHookRunner.hasHooks.mockReturnValue(false);
865+866+await deliverDiscordReply({
867+replies: [{ text: "no hook path" }],
868+target: "channel:hook-5",
869+token: "token",
870+ runtime,
871+ cfg,
872+textLimit: 2000,
873+});
874+875+expect(messageHookRunner.runMessageSending).not.toHaveBeenCalled();
876+expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
877+});
671878});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。