@@ -281,3 +281,188 @@ async def test_in_reply_to_id_routes_to_root_thread(self):
281281 assert decoded .review_comment_id == root_comment_id
282282 # The thread_id format should contain :rc:3000
283283 assert f":rc:{ root_comment_id } " in thread_id
284+
285+
286+ # =============================================================================
287+ # Tests -- eager bot-user-ID auto-detection (github slice of upstream 9824d33)
288+ # =============================================================================
289+
290+
291+ def _make_multi_tenant_adapter (** overrides : Any ) -> GitHubAdapter :
292+ """Create a multi-tenant GitHub App adapter (no installation_id)."""
293+ defaults : dict [str , Any ] = {
294+ "webhook_secret" : WEBHOOK_SECRET ,
295+ "app_id" : "12345" ,
296+ "private_key" : "-----BEGIN RSA PRIVATE KEY-----\n fake\n -----END RSA PRIVATE KEY-----" ,
297+ "logger" : ConsoleLogger ("error" ),
298+ }
299+ defaults .update (overrides )
300+ return GitHubAdapter (defaults )
301+
302+
303+ def _make_install_payload (* , sender_id : int = 100 , installation_id : int = 54321 ) -> dict [str , Any ]:
304+ """An issue_comment payload carrying an installation id (multi-tenant)."""
305+ payload = _make_issue_comment_payload (sender_id = sender_id , owner = "acme" , repo = "app" , pr_number = 42 )
306+ payload ["installation" ] = {"id" : installation_id , "node_id" : "MDIzOk" }
307+ return payload
308+
309+
310+ class TestEagerBotUserIdDetection :
311+ """Eager bot-user-ID detection so is_me works and self-reply loops are prevented.
312+
313+ Mirrors the github slice of upstream commit 9824d33: detection runs on the
314+ first webhook for an installation (so the very first reply has a populated
315+ bot id), is cached so subsequent webhooks don't re-fetch, and falls back
316+ from users.getAuthenticated (GET /user) to apps.getAuthenticated (GET /app)
317+ + users.getByUsername (GET /users/{slug}[bot]) for installation tokens.
318+ """
319+
320+ @pytest .mark .asyncio
321+ async def test_webhook_populates_bot_user_id_before_dispatch (self ):
322+ """First webhook for a new installation populates _bot_user_id before message handling."""
323+ adapter = _make_multi_tenant_adapter ()
324+ chat = MagicMock ()
325+ chat .process_message = MagicMock ()
326+ chat .get_state = MagicMock (return_value = MagicMock (set = AsyncMock (), get = AsyncMock (return_value = None )))
327+ adapter ._chat = chat
328+
329+ # Installation tokens: /user is unavailable, so detection falls back to
330+ # /app then /users/{slug}[bot].
331+ async def fake_api (method : str , path : str , body : Any = None , * , installation_id : int | None = None ) -> Any :
332+ if path == "/user" :
333+ raise RuntimeError ("404 /user not available for installation token" )
334+ if path == "/app" :
335+ return {"slug" : "my-bot" , "id" : 1 }
336+ if path == "/users/my-bot[bot]" :
337+ return {"id" : 7777 , "login" : "my-bot[bot]" }
338+ raise AssertionError (f"unexpected path { path } " )
339+
340+ api = AsyncMock (side_effect = fake_api )
341+ adapter ._github_api_request = api
342+
343+ # process_message must observe a populated bot id. Capture it at call time.
344+ observed : dict [str , Any ] = {}
345+ chat .process_message .side_effect = lambda * a , ** kw : observed .update ({"bot_id" : adapter ._bot_user_id })
346+
347+ payload = _make_install_payload (sender_id = 100 , installation_id = 54321 )
348+ body = json .dumps (payload )
349+ headers = {
350+ "x-hub-signature-256" : _sign (body ),
351+ "x-github-event" : "issue_comment" ,
352+ "content-type" : "application/json" ,
353+ }
354+
355+ result = await adapter .handle_webhook (FakeRequest (body , headers ))
356+
357+ assert result ["status" ] == 200
358+ assert adapter ._bot_user_id == 7777
359+ # Detection used the webhook's installation id for the API calls.
360+ assert api .await_args_list [0 ].kwargs ["installation_id" ] == 54321
361+ # process_message ran, and the bot id was already set when it did.
362+ chat .process_message .assert_called_once ()
363+ assert observed ["bot_id" ] == 7777
364+
365+ @pytest .mark .asyncio
366+ async def test_message_from_bot_filtered_as_is_me (self ):
367+ """A message authored by the bot itself is filtered (self-reply loop prevented)."""
368+ adapter = _make_multi_tenant_adapter ()
369+ chat = MagicMock ()
370+ chat .process_message = MagicMock ()
371+ chat .get_state = MagicMock (return_value = MagicMock (set = AsyncMock (), get = AsyncMock (return_value = None )))
372+ adapter ._chat = chat
373+
374+ async def fake_api (method : str , path : str , body : Any = None , * , installation_id : int | None = None ) -> Any :
375+ if path == "/user" :
376+ raise RuntimeError ("404" )
377+ if path == "/app" :
378+ return {"slug" : "my-bot" }
379+ if path == "/users/my-bot[bot]" :
380+ return {"id" : 8888 , "login" : "my-bot[bot]" }
381+ raise AssertionError (f"unexpected path { path } " )
382+
383+ adapter ._github_api_request = AsyncMock (side_effect = fake_api )
384+
385+ # Sender is the bot itself (id 8888) -> must be ignored.
386+ payload = _make_install_payload (sender_id = 8888 , installation_id = 54321 )
387+ body = json .dumps (payload )
388+ headers = {
389+ "x-hub-signature-256" : _sign (body ),
390+ "x-github-event" : "issue_comment" ,
391+ "content-type" : "application/json" ,
392+ }
393+
394+ result = await adapter .handle_webhook (FakeRequest (body , headers ))
395+
396+ assert result ["status" ] == 200
397+ assert adapter ._bot_user_id == 8888
398+ # Self-message: process_message NOT called -> no self-reply loop.
399+ chat .process_message .assert_not_called ()
400+
401+ @pytest .mark .asyncio
402+ async def test_pat_path_uses_users_get_authenticated (self ):
403+ """PAT path resolves the bot via GET /user (users.getAuthenticated), no /app fallback."""
404+ adapter = _make_adapter () # token-based (PAT) adapter
405+ chat = MagicMock ()
406+ chat .process_message = MagicMock ()
407+ chat .get_state = MagicMock (return_value = MagicMock (set = AsyncMock (), get = AsyncMock (return_value = None )))
408+ adapter ._chat = chat
409+
410+ api = AsyncMock (return_value = {"id" : 4242 , "login" : "pat-bot" })
411+ adapter ._github_api_request = api
412+
413+ payload = _make_issue_comment_payload (sender_id = 100 )
414+ body = json .dumps (payload )
415+ headers = {
416+ "x-hub-signature-256" : _sign (body ),
417+ "x-github-event" : "issue_comment" ,
418+ "content-type" : "application/json" ,
419+ }
420+
421+ result = await adapter .handle_webhook (FakeRequest (body , headers ))
422+
423+ assert result ["status" ] == 200
424+ assert adapter ._bot_user_id == 4242
425+ # Only GET /user was used; no apps.getAuthenticated fallback.
426+ called_paths = [c .args [1 ] for c in api .await_args_list ]
427+ assert called_paths == ["/user" ]
428+ chat .process_message .assert_called_once ()
429+
430+ @pytest .mark .asyncio
431+ async def test_second_webhook_does_not_refetch (self ):
432+ """A second webhook for the same installation does NOT re-fetch (cache hit)."""
433+ adapter = _make_multi_tenant_adapter ()
434+ chat = MagicMock ()
435+ chat .process_message = MagicMock ()
436+ chat .get_state = MagicMock (return_value = MagicMock (set = AsyncMock (), get = AsyncMock (return_value = None )))
437+ adapter ._chat = chat
438+
439+ async def fake_api (method : str , path : str , body : Any = None , * , installation_id : int | None = None ) -> Any :
440+ if path == "/user" :
441+ raise RuntimeError ("404" )
442+ if path == "/app" :
443+ return {"slug" : "my-bot" }
444+ if path == "/users/my-bot[bot]" :
445+ return {"id" : 9999 , "login" : "my-bot[bot]" }
446+ raise AssertionError (f"unexpected path { path } " )
447+
448+ api = AsyncMock (side_effect = fake_api )
449+ adapter ._github_api_request = api
450+
451+ headers_for = lambda b : { # noqa: E731
452+ "x-hub-signature-256" : _sign (b ),
453+ "x-github-event" : "issue_comment" ,
454+ "content-type" : "application/json" ,
455+ }
456+
457+ body1 = json .dumps (_make_install_payload (sender_id = 100 , installation_id = 54321 ))
458+ await adapter .handle_webhook (FakeRequest (body1 , headers_for (body1 )))
459+ assert adapter ._bot_user_id == 9999
460+
461+ detection_calls_after_first = len (api .await_args_list )
462+
463+ body2 = json .dumps (_make_install_payload (sender_id = 101 , installation_id = 54321 ))
464+ await adapter .handle_webhook (FakeRequest (body2 , headers_for (body2 )))
465+
466+ # No additional detection API calls on the second webhook (cache hit).
467+ assert len (api .await_args_list ) == detection_calls_after_first
468+ assert chat .process_message .call_count == 2
0 commit comments