@@ -58,6 +58,180 @@ describe("resolvePreflightMentionRequirement", () => {
5858} ) ;
5959
6060describe ( "preflightDiscordMessage" , ( ) => {
61+ it ( "drops bound-thread bot system messages to prevent ACP self-loop" , async ( ) => {
62+ const threadBinding = createThreadBinding ( {
63+ targetKind : "acp" ,
64+ targetSessionKey : "agent:main:acp:discord-thread-1" ,
65+ } ) ;
66+ const threadId = "thread-system-1" ;
67+ const parentId = "channel-parent-1" ;
68+ const client = {
69+ fetchChannel : async ( channelId : string ) => {
70+ if ( channelId === threadId ) {
71+ return {
72+ id : threadId ,
73+ type : ChannelType . PublicThread ,
74+ name : "focus" ,
75+ parentId,
76+ ownerId : "owner-1" ,
77+ } ;
78+ }
79+ if ( channelId === parentId ) {
80+ return {
81+ id : parentId ,
82+ type : ChannelType . GuildText ,
83+ name : "general" ,
84+ } ;
85+ }
86+ return null ;
87+ } ,
88+ } as unknown as import ( "@buape/carbon" ) . Client ;
89+ const message = {
90+ id : "m-system-1" ,
91+ content :
92+ "⚙️ codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session." ,
93+ timestamp : new Date ( ) . toISOString ( ) ,
94+ channelId : threadId ,
95+ attachments : [ ] ,
96+ mentionedUsers : [ ] ,
97+ mentionedRoles : [ ] ,
98+ mentionedEveryone : false ,
99+ author : {
100+ id : "relay-bot-1" ,
101+ bot : true ,
102+ username : "OpenClaw" ,
103+ } ,
104+ } as unknown as import ( "@buape/carbon" ) . Message ;
105+
106+ const result = await preflightDiscordMessage ( {
107+ cfg : {
108+ session : {
109+ mainKey : "main" ,
110+ scope : "per-sender" ,
111+ } ,
112+ } as import ( "../../config/config.js" ) . OpenClawConfig ,
113+ discordConfig : {
114+ allowBots : true ,
115+ } as NonNullable < import ( "../../config/config.js" ) . OpenClawConfig [ "channels" ] > [ "discord" ] ,
116+ accountId : "default" ,
117+ token : "token" ,
118+ runtime : { } as import ( "../../runtime.js" ) . RuntimeEnv ,
119+ botUserId : "openclaw-bot" ,
120+ guildHistories : new Map ( ) ,
121+ historyLimit : 0 ,
122+ mediaMaxBytes : 1_000_000 ,
123+ textLimit : 2_000 ,
124+ replyToMode : "all" ,
125+ dmEnabled : true ,
126+ groupDmEnabled : true ,
127+ ackReactionScope : "direct" ,
128+ groupPolicy : "open" ,
129+ threadBindings : {
130+ getByThreadId : ( id : string ) => ( id === threadId ? threadBinding : undefined ) ,
131+ } as import ( "./thread-bindings.js" ) . ThreadBindingManager ,
132+ data : {
133+ channel_id : threadId ,
134+ guild_id : "guild-1" ,
135+ guild : {
136+ id : "guild-1" ,
137+ name : "Guild One" ,
138+ } ,
139+ author : message . author ,
140+ message,
141+ } as unknown as import ( "./listeners.js" ) . DiscordMessageEvent ,
142+ client,
143+ } ) ;
144+
145+ expect ( result ) . toBeNull ( ) ;
146+ } ) ;
147+
148+ it ( "keeps bound-thread regular bot messages flowing when allowBots=true" , async ( ) => {
149+ const threadBinding = createThreadBinding ( {
150+ targetKind : "acp" ,
151+ targetSessionKey : "agent:main:acp:discord-thread-1" ,
152+ } ) ;
153+ const threadId = "thread-bot-regular-1" ;
154+ const parentId = "channel-parent-regular-1" ;
155+ const client = {
156+ fetchChannel : async ( channelId : string ) => {
157+ if ( channelId === threadId ) {
158+ return {
159+ id : threadId ,
160+ type : ChannelType . PublicThread ,
161+ name : "focus" ,
162+ parentId,
163+ ownerId : "owner-1" ,
164+ } ;
165+ }
166+ if ( channelId === parentId ) {
167+ return {
168+ id : parentId ,
169+ type : ChannelType . GuildText ,
170+ name : "general" ,
171+ } ;
172+ }
173+ return null ;
174+ } ,
175+ } as unknown as import ( "@buape/carbon" ) . Client ;
176+ const message = {
177+ id : "m-bot-regular-1" ,
178+ content : "here is tool output chunk" ,
179+ timestamp : new Date ( ) . toISOString ( ) ,
180+ channelId : threadId ,
181+ attachments : [ ] ,
182+ mentionedUsers : [ ] ,
183+ mentionedRoles : [ ] ,
184+ mentionedEveryone : false ,
185+ author : {
186+ id : "relay-bot-1" ,
187+ bot : true ,
188+ username : "Relay" ,
189+ } ,
190+ } as unknown as import ( "@buape/carbon" ) . Message ;
191+
192+ const result = await preflightDiscordMessage ( {
193+ cfg : {
194+ session : {
195+ mainKey : "main" ,
196+ scope : "per-sender" ,
197+ } ,
198+ } as import ( "../../config/config.js" ) . OpenClawConfig ,
199+ discordConfig : {
200+ allowBots : true ,
201+ } as NonNullable < import ( "../../config/config.js" ) . OpenClawConfig [ "channels" ] > [ "discord" ] ,
202+ accountId : "default" ,
203+ token : "token" ,
204+ runtime : { } as import ( "../../runtime.js" ) . RuntimeEnv ,
205+ botUserId : "openclaw-bot" ,
206+ guildHistories : new Map ( ) ,
207+ historyLimit : 0 ,
208+ mediaMaxBytes : 1_000_000 ,
209+ textLimit : 2_000 ,
210+ replyToMode : "all" ,
211+ dmEnabled : true ,
212+ groupDmEnabled : true ,
213+ ackReactionScope : "direct" ,
214+ groupPolicy : "open" ,
215+ threadBindings : {
216+ getByThreadId : ( id : string ) => ( id === threadId ? threadBinding : undefined ) ,
217+ } as import ( "./thread-bindings.js" ) . ThreadBindingManager ,
218+ data : {
219+ channel_id : threadId ,
220+ guild_id : "guild-1" ,
221+ guild : {
222+ id : "guild-1" ,
223+ name : "Guild One" ,
224+ } ,
225+ author : message . author ,
226+ message,
227+ } as unknown as import ( "./listeners.js" ) . DiscordMessageEvent ,
228+ client,
229+ } ) ;
230+
231+ expect ( result ) . not . toBeNull ( ) ;
232+ expect ( result ?. boundSessionKey ) . toBe ( threadBinding . targetSessionKey ) ;
233+ } ) ;
234+
61235 it ( "bypasses mention gating in bound threads for allowed bot senders" , async ( ) => {
62236 const threadBinding = createThreadBinding ( ) ;
63237 const threadId = "thread-bot-focus" ;
0 commit comments