From 60836c224291189733fc891bc07828acb0b4c2f4 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:26:11 +0100 Subject: [PATCH 1/2] Added the Teams status of the other person in a 1:1 chat --- src/api/controllers/chats_controller.py | 6 ++ src/api/extensions/chats_extensions.py | 5 ++ .../abstractions/i_chat_presence.py | 11 +++ .../abstractions/i_graph_client.py | 3 + .../queries/get_chat_presence_query.py | 23 ++++++ src/infrastructure/teams/chat_presence.py | 47 +++++++++++ tests/test_chat_presence.py | 80 +++++++++++++++++++ 7 files changed, 175 insertions(+) create mode 100644 src/application/abstractions/i_chat_presence.py create mode 100644 src/application/queries/get_chat_presence_query.py create mode 100644 src/infrastructure/teams/chat_presence.py create mode 100644 tests/test_chat_presence.py diff --git a/src/api/controllers/chats_controller.py b/src/api/controllers/chats_controller.py index ad3a1b2..026d891 100644 --- a/src/api/controllers/chats_controller.py +++ b/src/api/controllers/chats_controller.py @@ -16,6 +16,7 @@ from src.application.queries.get_chat_file_thumbnail_query import GetChatFileThumbnailQuery from src.application.queries.get_chat_hosted_content_query import GetChatHostedContentQuery from src.application.queries.get_chat_photo_query import GetChatPhotoQuery +from src.application.queries.get_chat_presence_query import GetChatPresenceQuery from src.application.queries.list_chat_messages_query import ListChatMessagesQuery from src.application.queries.list_chats_query import ListChatsQuery @@ -67,6 +68,11 @@ async def mark_chat_read(self, chat_id: str, token: str = Header(..., alias=Http await self.mediator.send(MarkChatReadCommand(token, chat_id)) return Response(status_code=204) + @router.get("/chats/{chat_id}/presence", tags=["Microsoft 365"]) + async def chat_presence(self, chat_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): + """The Teams status of the other person in a 1:1 chat: `{availability, activity}`, both null when there is none to show.""" + return await self.mediator.send(GetChatPresenceQuery(token, chat_id)) + @router.get("/chats/{chat_id}/photo", tags=["Microsoft 365"], response_class=Response, responses={200: {"content": {"image/jpeg": {}}}}) async def chat_photo(self, chat_id: str, token: str = Header(..., alias=HttpHeaders.REFRESH_TOKEN_HEADER)): return FileResponses.image(await self.mediator.send(GetChatPhotoQuery(token, chat_id))) diff --git a/src/api/extensions/chats_extensions.py b/src/api/extensions/chats_extensions.py index ca8610e..e659e22 100644 --- a/src/api/extensions/chats_extensions.py +++ b/src/api/extensions/chats_extensions.py @@ -1,4 +1,5 @@ from src.api.di.service_collection import ServiceCollection +from src.application.abstractions.i_chat_presence import IChatPresence from src.application.abstractions.i_chat_reader import IChatReader from src.application.abstractions.i_chat_sender import IChatSender from src.application.abstractions.i_teams_chat import ITeamsChat @@ -11,8 +12,10 @@ from src.application.queries.get_chat_hosted_content_query import GetChatHostedContentQuery, GetChatHostedContentHandler from src.application.queries.get_chat_photo_query import GetChatPhotoQuery, GetChatPhotoHandler from src.application.queries.list_chat_messages_query import ListChatMessagesQuery, ListChatMessagesHandler +from src.application.queries.get_chat_presence_query import GetChatPresenceQuery, GetChatPresenceHandler from src.application.queries.list_chats_query import ListChatsQuery, ListChatsHandler from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.infrastructure.teams.chat_presence import ChatPresence from src.infrastructure.teams.chat_reader import ChatReader from src.infrastructure.teams.chat_sender import ChatSender from src.infrastructure.teams.teams_chat_client import TeamsChatClient @@ -24,6 +27,7 @@ def add_chats(services: ServiceCollection) -> ServiceCollection: services.add_singleton(ChatHtmlSanitizer) services.add_singleton(ITeamsChat, TeamsChatClient) services.add_singleton(IChatReader, ChatReader) + services.add_singleton(IChatPresence, ChatPresence) services.add_singleton(IChatSender, ChatSender) services.add_handler(ListChatsQuery, ListChatsHandler) @@ -36,4 +40,5 @@ def add_chats(services: ServiceCollection) -> ServiceCollection: services.add_handler(ScheduleChatMeetingCommand, ScheduleChatMeetingHandler) services.add_handler(MarkChatReadCommand, MarkChatReadHandler) services.add_handler(GetChatPhotoQuery, GetChatPhotoHandler) + services.add_handler(GetChatPresenceQuery, GetChatPresenceHandler) return services diff --git a/src/application/abstractions/i_chat_presence.py b/src/application/abstractions/i_chat_presence.py new file mode 100644 index 0000000..24193a5 --- /dev/null +++ b/src/application/abstractions/i_chat_presence.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from src.application.abstractions.i_account_broker import IAccountBroker +from src.application.abstractions.i_graph_client import IGraphClient + + +class IChatPresence(ABC): + """The Teams status of the other person in a 1:1 chat.""" + + @abstractmethod + def of_chat(self, note: IGraphClient, broker: IAccountBroker, chat_id: str) -> dict: ... diff --git a/src/application/abstractions/i_graph_client.py b/src/application/abstractions/i_graph_client.py index 65a2343..e1c4a68 100644 --- a/src/application/abstractions/i_graph_client.py +++ b/src/application/abstractions/i_graph_client.py @@ -13,6 +13,9 @@ def list(self, path, params=None, limit=None, client=None): ... @abstractmethod def post(self, path, body=None, client=None): ... + @abstractmethod + def presences(self, user_ids): ... + @abstractmethod def emails(self, folder="inbox", top=25, unread=False, search=None, select=None): ... diff --git a/src/application/queries/get_chat_presence_query.py b/src/application/queries/get_chat_presence_query.py new file mode 100644 index 0000000..23b77b3 --- /dev/null +++ b/src/application/queries/get_chat_presence_query.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from mediatorx import IQuery, IQueryHandler + +from src.application.abstractions.i_account_runner import IAccountRunner +from src.application.abstractions.i_chat_presence import IChatPresence +from src.domain.validation.graph_ids import GraphIds + + +@dataclass +class GetChatPresenceQuery(IQuery[dict]): + refresh_token: str = field(repr=False) + chat_id: str + + +class GetChatPresenceHandler(IQueryHandler[GetChatPresenceQuery, dict]): + def __init__(self, accounts: IAccountRunner, presence: IChatPresence): + self._accounts = accounts + self._presence = presence + + async def handle(self, query: GetChatPresenceQuery) -> dict: + GraphIds.check(query.chat_id, GraphIds.CHAT_ID_PATTERN, "chat_id") + return await self._accounts.run(query.refresh_token, lambda note, broker: self._presence.of_chat(note, broker, query.chat_id)) diff --git a/src/infrastructure/teams/chat_presence.py b/src/infrastructure/teams/chat_presence.py new file mode 100644 index 0000000..8765916 --- /dev/null +++ b/src/infrastructure/teams/chat_presence.py @@ -0,0 +1,47 @@ +import logging + +from outnote import GraphError + +from src.application.abstractions.i_chat_presence import IChatPresence +from src.application.abstractions.i_profile_reader import IProfileReader +from src.infrastructure.microsoft.account_cache import AccountCache + + +class ChatPresence(IChatPresence): + """The Teams status of the other person in a 1:1 chat. Asking by chat keeps it to people the account already chats with.""" + + log = logging.getLogger("schuly.microsoft") + TTL = 30 # however many tabs poll, Graph is asked this often per chat + PERSON_TTL = 3600 # who is in a 1:1 chat doesn't change + + def __init__(self, profile: IProfileReader): + self._profile = profile + + def _other_person(self, note, broker, cache, chat_id: str) -> str | None: + marker = object() + hit = cache.get(("chat_other_person", chat_id), marker) + if hit is not marker: + return hit + chat = note.get(f"chats/{chat_id}", {"$expand": "members"}, retry=False) + me = self._profile.get(note, broker).get("id") + others = [m.get("userId") for m in chat.get("members") or [] if m.get("userId") and m.get("userId") != me] + person = others[0] if chat.get("chatType") == "oneOnOne" and len(others) == 1 else None + cache.set(("chat_other_person", chat_id), person, self.PERSON_TTL) + return person + + def of_chat(self, note, broker, chat_id: str) -> dict: + cache = AccountCache.for_broker(broker) + hit = cache.get(("chat_presence", chat_id)) + if hit is not None: + return hit + result = {"availability": None, "activity": None} + try: + person = self._other_person(note, broker, cache, chat_id) + found = note.presences([person]) if person else [] + if found: + result = {"availability": found[0].get("availability"), "activity": found[0].get("activity")} + except GraphError as ex: + # A status is a nicety: without one the header just shows no dot, and the next ask comes after TTL. + self.log.warning("Teams status unavailable (%s)", ex.status) + cache.set(("chat_presence", chat_id), result, self.TTL) + return result diff --git a/tests/test_chat_presence.py b/tests/test_chat_presence.py new file mode 100644 index 0000000..116008f --- /dev/null +++ b/tests/test_chat_presence.py @@ -0,0 +1,80 @@ +import pytest +from fastapi.testclient import TestClient +from outnote import GraphError + +from src.api.app import app +from tests.services import config, outnote_client + +H = {config.REFRESH_TOKEN_HEADER: "frt-1"} +CHAT = "19:o@unq.gbl.spaces" + + +class Broker: + refresh_token = "frt-1" + + def __call__(self, *args): + return "token" + + +class Note: + def __init__(self): + self.chat = {"chatType": "oneOnOne", "members": [{"userId": "me-id"}, {"userId": "bo"}]} + self.asked = [] + self.chat_reads = 0 + self.refusal = None + + def me(self): + return {"id": "me-id"} + + def get(self, path, params=None, retry=True): + assert path == f"chats/{CHAT}" and params == {"$expand": "members"} and retry is False + self.chat_reads += 1 + return self.chat + + def presences(self, ids): + self.asked.append(ids) + if self.refusal: + raise GraphError(self.refusal, "no") + return [{"id": ids[0], "availability": "Busy", "activity": "InACall"}] + + +@pytest.fixture +def note(monkeypatch): + n, broker = Note(), Broker() + monkeypatch.setattr(outnote_client, "build_client", lambda frt: (n, broker)) + return n + + +def _get(): + return TestClient(app).get(f"/api/chats/{CHAT}/presence", headers=H) + + +def test_a_one_on_one_chat_answers_the_other_persons_status_and_caches_it(note): + assert _get().json() == {"availability": "Busy", "activity": "InACall"} + _get() + assert note.asked == [["bo"]] and note.chat_reads == 1 + + +@pytest.mark.parametrize("chat", [ + {"chatType": "group", "members": [{"userId": "me-id"}, {"userId": "bo"}]}, + {"chatType": "oneOnOne", "members": [{"userId": "me-id"}]}, + {"chatType": "oneOnOne", "members": [{"userId": "me-id"}, {"userId": "bo"}, {"userId": "al"}]}, +]) +def test_any_other_chat_asks_for_nobody(note, chat): + note.chat = chat + assert _get().json() == {"availability": None, "activity": None} + assert note.asked == [] + + +@pytest.mark.parametrize("status", [403, 429]) +def test_a_refusal_means_no_status_rather_than_an_error(note, status): + note.refusal = status + r = _get() + assert r.status_code == 200 and r.json() == {"availability": None, "activity": None} + _get() + assert len(note.asked) == 1 # not asked again right away + + +def test_a_bad_chat_id_is_refused(note): + assert TestClient(app).get("/api/chats/not%20a%20chat/presence", headers=H).status_code in (400, 404, 422) + assert note.asked == [] From 79f86715419be290d38918adf672b0d013d0665a Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:27:17 +0100 Subject: [PATCH 2/2] Added the Teams status of the other person in a 1:1 chat --- .claude/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 55ea31d..a2272d7 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -109,7 +109,7 @@ src/ logging_config.py # logger names microsoft/ # AccountRunner, OutNoteClientFactory, BrokerCache, SharedBroker, AccountCache, MicrosoftHttpClients, DeviceLoginClient, BrowserSignInClient, ProfileReader graph/ # TeamsFiles, GraphSearch, OneNoteClient, OneNoteRunner, PdfConverter, FileThumbnails, ChannelMessages, PeoplePhotos, MailFolderRoles - teams/ # TeamsChatClient (skype token, AMS), ChatReader, ChatSender, EmojiCatalogue + teams/ # TeamsChatClient (skype token, AMS), ChatReader, ChatSender, ChatPresence, EmojiCatalogue giphy/ # GifSearch (Teams' own GIF command with the account's sign-in; 503 when Teams refuses) files/ # FileTextExtractor (lazy imports, zip-bomb guard, caps, paging) password_signin/ # JobStore, SignInJob (worker thread per job, MfaPrompt bridge), RateLimiter