Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/api/controllers/chats_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)))
5 changes: 5 additions & 0 deletions src/api/extensions/chats_extensions.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
11 changes: 11 additions & 0 deletions src/application/abstractions/i_chat_presence.py
Original file line number Diff line number Diff line change
@@ -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: ...
3 changes: 3 additions & 0 deletions src/application/abstractions/i_graph_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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): ...

Expand Down
23 changes: 23 additions & 0 deletions src/application/queries/get_chat_presence_query.py
Original file line number Diff line number Diff line change
@@ -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))
47 changes: 47 additions & 0 deletions src/infrastructure/teams/chat_presence.py
Original file line number Diff line number Diff line change
@@ -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
80 changes: 80 additions & 0 deletions tests/test_chat_presence.py
Original file line number Diff line number Diff line change
@@ -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 == []
Loading