@@ -10,10 +10,11 @@ import {
1010} from "openclaw/plugin-sdk/conversation-runtime" ;
1111import { resolveAgentRoute } from "openclaw/plugin-sdk/routing" ;
1212import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing" ;
13- import { afterAll , beforeAll , describe , expect , it , vi } from "vitest" ;
13+ import { afterAll , beforeAll , beforeEach , describe , expect , it , vi } from "vitest" ;
1414import type { ResolvedSlackAccount } from "../../accounts.js" ;
1515import type { SlackMessageEvent } from "../../types.js" ;
1616import type { SlackMonitorContext } from "../context.js" ;
17+ import { resetSlackThreadStarterCacheForTest } from "../media.js" ;
1718import { resolveSlackMessageContent } from "./prepare-content.js" ;
1819import { prepareSlackMessage } from "./prepare.js" ;
1920import {
@@ -29,6 +30,10 @@ describe("slack prepareSlackMessage inbound contract", () => {
2930 storeFixture . setup ( ) ;
3031 } ) ;
3132
33+ beforeEach ( ( ) => {
34+ resetSlackThreadStarterCacheForTest ( ) ;
35+ } ) ;
36+
3237 afterAll ( ( ) => {
3338 storeFixture . cleanup ( ) ;
3439 } ) ;
@@ -126,6 +131,102 @@ describe("slack prepareSlackMessage inbound contract", () => {
126131 return prepareMessageWith ( ctx , createThreadAccount ( ) , createThreadReplyMessage ( overrides ) ) ;
127132 }
128133
134+ type ThreadContextAllowlistCaseParams = {
135+ channel : string ;
136+ channelType : SlackMessageEvent [ "channel_type" ] ;
137+ user : string ;
138+ userName : string ;
139+ starterText : string ;
140+ followUpText : string ;
141+ startTs : string ;
142+ replyTs : string ;
143+ followUpTs : string ;
144+ currentTs : string ;
145+ channelsConfig ?: Parameters < typeof createInboundSlackCtx > [ 0 ] [ "channelsConfig" ] ;
146+ resolveChannelName ?: ( channelId : string ) => Promise < {
147+ name ?: string ;
148+ type ?: SlackMessageEvent [ "channel_type" ] ;
149+ topic ?: string ;
150+ purpose ?: string ;
151+ } > ;
152+ } ;
153+
154+ async function prepareThreadContextAllowlistCase ( params : ThreadContextAllowlistCaseParams ) {
155+ const { storePath } = storeFixture . makeTmpStorePath ( ) ;
156+ const replies = vi
157+ . fn ( )
158+ . mockResolvedValueOnce ( {
159+ messages : [ { text : params . starterText , user : params . user , ts : params . startTs } ] ,
160+ } )
161+ . mockResolvedValueOnce ( {
162+ messages : [
163+ { text : params . starterText , user : params . user , ts : params . startTs } ,
164+ { text : "assistant reply" , bot_id : "B1" , ts : params . replyTs } ,
165+ { text : params . followUpText , user : params . user , ts : params . followUpTs } ,
166+ { text : "current message" , user : params . user , ts : params . currentTs } ,
167+ ] ,
168+ response_metadata : { next_cursor : "" } ,
169+ } ) ;
170+ const ctx = createInboundSlackCtx ( {
171+ cfg : {
172+ session : { store : storePath } ,
173+ channels : {
174+ slack : {
175+ enabled : true ,
176+ replyToMode : "all" ,
177+ groupPolicy : "open" ,
178+ contextVisibility : "allowlist" ,
179+ } ,
180+ } ,
181+ } as OpenClawConfig ,
182+ appClient : { conversations : { replies } } as unknown as App [ "client" ] ,
183+ defaultRequireMention : false ,
184+ replyToMode : "all" ,
185+ channelsConfig : params . channelsConfig ,
186+ } ) ;
187+ ctx . allowFrom = [ "u-owner" ] ;
188+ ctx . resolveUserName = async ( id : string ) => ( {
189+ name : id === params . user ? params . userName : "Owner" ,
190+ } ) ;
191+ if ( params . resolveChannelName ) {
192+ ctx . resolveChannelName = params . resolveChannelName ;
193+ }
194+
195+ const prepared = await prepareSlackMessage ( {
196+ ctx,
197+ account : createSlackAccount ( {
198+ replyToMode : "all" ,
199+ thread : { initialHistoryLimit : 20 } ,
200+ } ) ,
201+ message : {
202+ channel : params . channel ,
203+ channel_type : params . channelType ,
204+ user : params . user ,
205+ text : "current message" ,
206+ ts : params . currentTs ,
207+ thread_ts : params . startTs ,
208+ } as SlackMessageEvent ,
209+ opts : { source : "message" } ,
210+ } ) ;
211+
212+ return { prepared, replies } ;
213+ }
214+
215+ function expectThreadContextAllowsHumanHistory (
216+ prepared : Awaited < ReturnType < typeof prepareSlackMessage > > ,
217+ replies : ReturnType < typeof vi . fn > ,
218+ starterText : string ,
219+ followUpText : string ,
220+ ) {
221+ expect ( prepared ) . toBeTruthy ( ) ;
222+ expect ( prepared ! . ctxPayload . ThreadStarterBody ) . toBe ( starterText ) ;
223+ expect ( prepared ! . ctxPayload . ThreadHistoryBody ) . toContain ( starterText ) ;
224+ expect ( prepared ! . ctxPayload . ThreadHistoryBody ) . toContain ( followUpText ) ;
225+ expect ( prepared ! . ctxPayload . ThreadHistoryBody ) . not . toContain ( "assistant reply" ) ;
226+ expect ( prepared ! . ctxPayload . ThreadHistoryBody ) . not . toContain ( "current message" ) ;
227+ expect ( replies ) . toHaveBeenCalledTimes ( 2 ) ;
228+ }
229+
129230 function createDmScopeMainSlackCtx ( ) : SlackMonitorContext {
130231 const slackCtx = createInboundSlackCtx ( {
131232 cfg : {
@@ -502,6 +603,102 @@ describe("slack prepareSlackMessage inbound contract", () => {
502603 expect ( replies ) . toHaveBeenCalledTimes ( 2 ) ;
503604 } ) ;
504605
606+ it ( "uses room users allowlist for thread context filtering" , async ( ) => {
607+ const { prepared, replies } = await prepareThreadContextAllowlistCase ( {
608+ channel : "C123" ,
609+ channelType : "channel" ,
610+ user : "U1" ,
611+ userName : "Alice" ,
612+ starterText : "starter from room user" ,
613+ followUpText : "allowed follow-up" ,
614+ startTs : "100.000" ,
615+ replyTs : "100.500" ,
616+ followUpTs : "100.800" ,
617+ currentTs : "101.000" ,
618+ channelsConfig : {
619+ C123 : {
620+ users : [ "U1" ] ,
621+ requireMention : false ,
622+ } ,
623+ } ,
624+ resolveChannelName : async ( ) => ( { name : "general" , type : "channel" } ) ,
625+ } ) ;
626+
627+ expectThreadContextAllowsHumanHistory (
628+ prepared ,
629+ replies ,
630+ "starter from room user" ,
631+ "allowed follow-up" ,
632+ ) ;
633+ } ) ;
634+
635+ it ( "does not apply the owner allowlist to open-room thread context" , async ( ) => {
636+ const { prepared, replies } = await prepareThreadContextAllowlistCase ( {
637+ channel : "C124" ,
638+ channelType : "channel" ,
639+ user : "U2" ,
640+ userName : "Bob" ,
641+ starterText : "starter from open room" ,
642+ followUpText : "open-room follow-up" ,
643+ startTs : "200.000" ,
644+ replyTs : "200.500" ,
645+ followUpTs : "200.800" ,
646+ currentTs : "201.000" ,
647+ channelsConfig : {
648+ C124 : {
649+ requireMention : false ,
650+ } ,
651+ } ,
652+ resolveChannelName : async ( ) => ( { name : "general" , type : "channel" } ) ,
653+ } ) ;
654+
655+ expectThreadContextAllowsHumanHistory (
656+ prepared ,
657+ replies ,
658+ "starter from open room" ,
659+ "open-room follow-up" ,
660+ ) ;
661+ } ) ;
662+
663+ it ( "does not apply the owner allowlist to open DMs when dmPolicy is open" , async ( ) => {
664+ const { prepared, replies } = await prepareThreadContextAllowlistCase ( {
665+ channel : "D300" ,
666+ channelType : "im" ,
667+ user : "U3" ,
668+ userName : "Dana" ,
669+ starterText : "starter from open dm" ,
670+ followUpText : "dm follow-up" ,
671+ startTs : "300.000" ,
672+ replyTs : "300.500" ,
673+ followUpTs : "300.800" ,
674+ currentTs : "301.000" ,
675+ } ) ;
676+
677+ expectThreadContextAllowsHumanHistory (
678+ prepared ,
679+ replies ,
680+ "starter from open dm" ,
681+ "dm follow-up" ,
682+ ) ;
683+ } ) ;
684+
685+ it ( "does not apply the owner allowlist to MPIM thread context" , async ( ) => {
686+ const { prepared, replies } = await prepareThreadContextAllowlistCase ( {
687+ channel : "G400" ,
688+ channelType : "mpim" ,
689+ user : "U4" ,
690+ userName : "Evan" ,
691+ starterText : "starter from mpim" ,
692+ followUpText : "mpim follow-up" ,
693+ startTs : "400.000" ,
694+ replyTs : "400.500" ,
695+ followUpTs : "400.800" ,
696+ currentTs : "401.000" ,
697+ } ) ;
698+
699+ expectThreadContextAllowsHumanHistory ( prepared , replies , "starter from mpim" , "mpim follow-up" ) ;
700+ } ) ;
701+
505702 it ( "skips loading thread history when thread session already exists in store (bloat fix)" , async ( ) => {
506703 const { storePath } = storeFixture . makeTmpStorePath ( ) ;
507704 const cfg = {
0 commit comments