@@ -16,6 +16,11 @@ const sendMocks = vi.hoisted(() => ({
1616 ( channelId : string , messageId : string , emoji : string , opts ?: unknown ) => Promise < void >
1717 > ( async ( ) => { } ) ,
1818} ) ) ;
19+ const typingMocks = vi . hoisted ( ( ) => ( {
20+ sendTyping : vi . fn < ( params : { rest : unknown ; channelId : string } ) => Promise < void > > (
21+ async ( ) => { } ,
22+ ) ,
23+ } ) ) ;
1924function createMockDraftStream ( ) {
2025 let messageId : string | undefined = "preview-1" ;
2126 return {
@@ -71,6 +76,10 @@ vi.mock("../send.js", () => ({
7176 } ,
7277} ) ) ;
7378
79+ vi . mock ( "./typing.js" , ( ) => ( {
80+ sendTyping : ( params : { rest : unknown ; channelId : string } ) => typingMocks . sendTyping ( params ) ,
81+ } ) ) ;
82+
7483const discordTargetMocks = vi . hoisted ( ( ) => ( {
7584 resolveDiscordTargetChannelId : vi . fn ( async ( target : string , _opts ?: unknown ) => ( {
7685 channelId : target === "user:u1" ? "dm-u1" : target ,
@@ -139,6 +148,7 @@ type DispatchInboundParams = {
139148 } ) => Promise < void > | void ;
140149 onReplyStart ?: ( ) => Promise < void > | void ;
141150 sourceReplyDeliveryMode ?: "automatic" | "message_tool_only" ;
151+ typingKeepalive ?: boolean ;
142152 disableBlockStreaming ?: boolean ;
143153 suppressDefaultToolProgressMessages ?: boolean ;
144154 queuedDeliveryCorrelations ?: Array < { begin : ( ) => ( ) => void } > ;
@@ -380,6 +390,7 @@ beforeEach(() => {
380390 vi . useRealTimers ( ) ;
381391 sendMocks . reactMessageDiscord . mockClear ( ) ;
382392 sendMocks . removeReactionDiscord . mockClear ( ) ;
393+ typingMocks . sendTyping . mockClear ( ) ;
383394 discordTargetMocks . resolveDiscordTargetChannelId . mockClear ( ) ;
384395 editMessageDiscord . mockClear ( ) ;
385396 deliverDiscordReply . mockClear ( ) ;
@@ -650,6 +661,27 @@ function expectSinglePreviewEdit() {
650661 expect ( deliverDiscordReply ) . not . toHaveBeenCalled ( ) ;
651662}
652663
664+ describe ( "processDiscordMessage typing" , ( ) => {
665+ it ( "does not start a nested Discord typing keepalive from channel callbacks" , async ( ) => {
666+ vi . useFakeTimers ( ) ;
667+ try {
668+ dispatchInboundMessage . mockImplementationOnce ( async ( params ?: DispatchInboundParams ) => {
669+ await params ?. replyOptions ?. onReplyStart ?.( ) ;
670+ await vi . advanceTimersByTimeAsync ( 3_500 ) ;
671+ return createNoQueuedDispatchResult ( ) ;
672+ } ) ;
673+
674+ const ctx = await createAutomaticSourceDeliveryContext ( ) ;
675+
676+ await runProcessDiscordMessage ( ctx ) ;
677+
678+ expect ( typingMocks . sendTyping ) . toHaveBeenCalledTimes ( 1 ) ;
679+ } finally {
680+ vi . useRealTimers ( ) ;
681+ }
682+ } ) ;
683+ } ) ;
684+
653685describe ( "processDiscordMessage ack reactions" , ( ) => {
654686 it ( "drops bot-loop-suppressed messages before Discord side effects" , async ( ) => {
655687 const botLoopProtection : ChannelBotLoopProtectionFacts = {
@@ -1275,11 +1307,34 @@ describe("processDiscordMessage session routing", () => {
12751307
12761308 expectRecordFields ( requireRecord ( getLastDispatchReplyOptions ( ) , "dispatch reply options" ) , {
12771309 sourceReplyDeliveryMode : "message_tool_only" ,
1310+ typingKeepalive : false ,
12781311 disableBlockStreaming : true ,
12791312 } ) ;
12801313 expect ( createDiscordDraftStream ) . not . toHaveBeenCalled ( ) ;
12811314 } ) ;
12821315
1316+ it ( "preserves core typing keepalive when message-tool guild replies configure typing mode" , async ( ) => {
1317+ const ctx = await createBaseContext ( {
1318+ shouldRequireMention : false ,
1319+ effectiveWasMentioned : false ,
1320+ cfg : {
1321+ messages : {
1322+ groupChat : { visibleReplies : "message_tool" } ,
1323+ } ,
1324+ session : {
1325+ store : "/tmp/openclaw-discord-process-test-sessions.json" ,
1326+ typingMode : "message" ,
1327+ } ,
1328+ } ,
1329+ route : BASE_CHANNEL_ROUTE ,
1330+ } ) ;
1331+
1332+ await runProcessDiscordMessage ( ctx ) ;
1333+
1334+ expect ( getLastDispatchReplyOptions ( ) ?. sourceReplyDeliveryMode ) . toBe ( "message_tool_only" ) ;
1335+ expect ( getLastDispatchReplyOptions ( ) ?. typingKeepalive ) . toBeUndefined ( ) ;
1336+ } ) ;
1337+
12831338 it ( "sends the configured ack while suppressing automatic status reactions for always-on guild replies" , async ( ) => {
12841339 const ctx = await createBaseContext ( {
12851340 shouldRequireMention : false ,
0 commit comments