From 7c35b03b8142554d8f085a473c9b6013e0b50ff4 Mon Sep 17 00:00:00 2001 From: PianoNic <79938743+Pianonic@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:19:05 +0100 Subject: [PATCH] Moved GIF search to Teams' own command, without an API key --- .claude/CLAUDE.md | 2 +- src/application/abstractions/i_gif_search.py | 2 +- src/application/abstractions/i_teams_chat.py | 3 + src/application/options/giphy_options.py | 5 +- src/application/queries/search_gifs_query.py | 4 +- src/infrastructure/giphy/gif_search.py | 107 +++++++++++------- src/infrastructure/teams/teams_chat_client.py | 25 ++++ tests/test_outnote2.py | 8 +- tests/test_teams_extras.py | 90 ++++++++++----- 9 files changed, 159 insertions(+), 87 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 53c9dbd..55ea31d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -110,7 +110,7 @@ src/ 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 - giphy/ # GifSearch (needs GIPHY_API_KEY; 503 without) + 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 tests/ # pytest, fully mocked (no network); services.py resolves the singletons the tests patch diff --git a/src/application/abstractions/i_gif_search.py b/src/application/abstractions/i_gif_search.py index 6676557..3ff1173 100644 --- a/src/application/abstractions/i_gif_search.py +++ b/src/application/abstractions/i_gif_search.py @@ -10,4 +10,4 @@ class IGifSearch(ABC): def allow(self, broker: IAccountBroker) -> bool: ... @abstractmethod - def search(self, query: str | None, limit: int) -> list[dict]: ... + def search(self, broker: IAccountBroker, query: str | None, limit: int) -> list[dict]: ... diff --git a/src/application/abstractions/i_teams_chat.py b/src/application/abstractions/i_teams_chat.py index 48f752d..997b1f2 100644 --- a/src/application/abstractions/i_teams_chat.py +++ b/src/application/abstractions/i_teams_chat.py @@ -14,3 +14,6 @@ def picture(self, broker: IAccountBroker, chat_id: str) -> bytes | None: ... @abstractmethod def custom_emoji(self, broker: IAccountBroker, document_id: str, animated: bool) -> bytes | None: ... + + @abstractmethod + def gif_command(self, broker: IAccountBroker, query: str, limit: int, rating: str) -> list: ... diff --git a/src/application/options/giphy_options.py b/src/application/options/giphy_options.py index 2eac499..f71c8e3 100644 --- a/src/application/options/giphy_options.py +++ b/src/application/options/giphy_options.py @@ -4,13 +4,10 @@ @dataclass class GiphyOptions: - """Giphy's rating filter, and its key (free from developers.giphy.com), read at each search: without one the search answers 503.""" + """Giphy's rating filter for GIF search.""" rating: str @classmethod def from_environment(cls) -> "GiphyOptions": return cls(rating=os.getenv("GIPHY_RATING", "g")) - - def api_key(self) -> str | None: - return os.getenv("GIPHY_API_KEY") or None diff --git a/src/application/queries/search_gifs_query.py b/src/application/queries/search_gifs_query.py index b3218d2..4c6e07f 100644 --- a/src/application/queries/search_gifs_query.py +++ b/src/application/queries/search_gifs_query.py @@ -27,12 +27,12 @@ async def handle(self, query: SearchGifsQuery) -> list[dict]: RefreshTokenFormat.check(query.refresh_token) def run(note, broker): - # Only a live Microsoft sign-in may spend the GIF key: the token must mint a Graph token (cached after the first call), and each account is rate limited. + # Only a live Microsoft sign-in may search: the token must mint a Graph token (cached after the first call), and each account is rate limited. broker(self._options.device_client_id) if not self._gifs.allow(broker): raise ServiceError(429, "Too many GIF searches; wait a minute.", headers={"Retry-After": "60"}) try: - return self._gifs.search(query.q, query.limit) + return self._gifs.search(broker, query.q, query.limit) except GifSearchUnavailable as ex: raise ServiceError(503, str(ex)) except Exception as ex: diff --git a/src/infrastructure/giphy/gif_search.py b/src/infrastructure/giphy/gif_search.py index 2410953..e4688c6 100644 --- a/src/infrastructure/giphy/gif_search.py +++ b/src/infrastructure/giphy/gif_search.py @@ -1,57 +1,73 @@ -"""GIF search over Giphy's public API. +"""GIF search through Teams, with the account's own Microsoft sign-in and no key of our own. -Teams shows Giphy GIFs as a plain `` -in the message body, so a GIF picked here sends the same way. The Teams clients query Giphy with -Microsoft's own key; Schuly needs its own (`GIPHY_API_KEY`, free from developers.giphy.com). -Without one the search answers 503. +The Teams clients ask their middle tier for Giphy results (see `TeamsChatClient.gif_command`). The answer is a list of +`{composeType, composeValue, fieldValues: [{fieldName, fieldValue}]}`. The route is undocumented and the names in it were +read from Teams' client code, not from a live answer, so the GIF is found by its Giphy URL rather than by a field name. +A GIF sends as a plain `` in the message body. """ +import html +import logging +import re import time -from urllib.parse import urlencode -from entrance import http_client from src.application.abstractions.i_gif_search import IGifSearch +from src.application.abstractions.i_teams_chat import ITeamsChat from src.application.options.giphy_options import GiphyOptions from src.application.services.chat_html_sanitizer import ChatHtmlSanitizer +from src.domain.errors.chat_service_error import ChatServiceError from src.domain.errors.gif_search_unavailable import GifSearchUnavailable from src.infrastructure.microsoft.account_cache import AccountCache class GifSearch(IGifSearch): - """GIF search over Giphy's public API, with a per-account rate limit.""" + """GIF search over the Teams middle tier, cached and rate limited per account because each tenant has its own GIF policy.""" - API = "https://api.giphy.com/v1/gifs" + log = logging.getLogger("schuly.microsoft") MAX_LIMIT = 50 - RESULTS_TTL = 300 # identical searches within 5 minutes share one Giphy call + RESULTS_TTL = 300 # identical searches within 5 minutes share one call RATE_PER_MINUTE = 30 - http_client = staticmethod(http_client) + REFUSED = (401, 403, 404) # this account or tenant has no GIFs: the picker hides them + _URL = re.compile(r"""https://[^\s"'<>\\]+""") + _PREVIEW_FIELD = re.compile("thumb|preview|small|still", re.IGNORECASE) - def __init__(self, html: ChatHtmlSanitizer, options: GiphyOptions): - self._html = html + def __init__(self, html_sanitizer: ChatHtmlSanitizer, options: GiphyOptions, teams: ITeamsChat): + self._html = html_sanitizer self._options = options - self._results = AccountCache(max_items=500) + self._teams = teams - def _image(self, images: dict, *names: str) -> dict: + def _gif_urls(self, text: str) -> list[str]: + urls = (url.split("?")[0] for url in self._URL.findall(html.unescape(text))) + return [url for url in urls if self._html.is_gif_host(url)] + + def _field(self, fields: dict[str, str], compose: str, *names: str) -> str | None: + """A field whose name holds one of `names`, else that attribute of the compose value when it is markup.""" for name in names: - img = images.get(name) or {} - if img.get("url"): - return img - return {} + for key, value in fields.items(): + if name in key and value: + return value + m = re.search(rf'\b{name}="([^"]+)"', compose) + if m: + return html.unescape(m.group(1)) + return None def _int(self, value) -> int | None: try: - return int(value) + return int(float(value)) except (TypeError, ValueError): return None - def _shape(self, item: dict) -> dict | None: - images = item.get("images") or {} - full = self._image(images, "downsized_medium", "downsized", "original") - preview = self._image(images, "fixed_width_small", "fixed_width", "preview_gif") or full - url = (full.get("url") or "").split("?")[0] - if not self._html.is_gif_host(url): + def _shape(self, item) -> dict | None: + if not isinstance(item, dict): + return None + fields = {str(f.get("fieldName") or "").lower(): str(f.get("fieldValue") or "") for f in item.get("fieldValues") or [] if isinstance(f, dict)} + compose = str(item.get("composeValue") or "") + previews = [u for name, value in fields.items() if self._PREVIEW_FIELD.search(name) for u in self._gif_urls(value)] + full = self._gif_urls(compose) + [u for name, value in fields.items() if not self._PREVIEW_FIELD.search(name) for u in self._gif_urls(value)] + url = next(iter(full or previews), None) + if not url: return None - return {"id": item.get("id"), "title": item.get("title") or "GIF", "url": url, "previewUrl": (preview.get("url") or url).split("?")[0], "width": self._int(full.get("width")), "height": self._int(full.get("height"))} + return {"id": url, "title": self._field(fields, compose, "title", "alt") or "GIF", "url": url, "previewUrl": next(iter(previews), url), "width": self._int(self._field(fields, compose, "width")), "height": self._int(self._field(fields, compose, "height"))} def allow(self, broker) -> bool: """A fixed one-minute window per account: at most RATE_PER_MINUTE searches.""" @@ -64,23 +80,28 @@ def allow(self, broker) -> bool: cache.set(window, used + 1, 120) return True - def search(self, query: str | None, limit: int) -> list[dict]: - key = self._options.api_key() - if not key: - raise GifSearchUnavailable("GIF search needs a Giphy API key (GIPHY_API_KEY).") - cache_key = ((query or "").strip().lower() or None, limit) - hit = self._results.get(cache_key) + def search(self, broker, query: str | None, limit: int) -> list[dict]: + cache = AccountCache.for_broker(broker) + cache_key = ("gif_results", (query or "").strip().lower(), limit) + hit = cache.get(cache_key) if hit is not None: return hit - found = self._search(key, query, limit) - self._results.set(cache_key, found, self.RESULTS_TTL) + found = self._search(broker, (query or "").strip(), limit) + cache.set(cache_key, found, self.RESULTS_TTL) return found - def _search(self, key: str, query: str | None, limit: int) -> list[dict]: - params = {"api_key": key, "limit": max(1, min(limit, self.MAX_LIMIT)), "rating": self._options.rating} - if query: - params["q"] = query - r = self.http_client().request("GET", f"{self.API}/{'search' if query else 'trending'}?{urlencode(params)}", timeout=15) - if r.status_code != 200: - raise RuntimeError(f"Giphy answered {r.status_code}") - return [g for g in (self._shape(i) for i in r.json().get("data") or []) if g] + def _search(self, broker, query: str, limit: int) -> list[dict]: + limit = max(1, min(limit, self.MAX_LIMIT)) + try: + items = self._teams.gif_command(broker, query, limit, self._options.rating) + except ChatServiceError as ex: + if ex.status in self.REFUSED: + self.log.warning("Teams refused the GIF search (%s)", ex.status) + raise GifSearchUnavailable("Teams has no GIF search for this account.") from None + raise + found = [g for g in map(self._shape, items) if g] + if items and not found: + # Names only: this is how an unexpected answer gets noticed, since the route has no documentation. + first = items[0] if isinstance(items[0], dict) else {} + self.log.warning("Teams GIF answer had no Giphy URL; keys %s, fields %s", sorted(first), sorted(str(f.get("fieldName")) for f in first.get("fieldValues") or [] if isinstance(f, dict))) + return found[:limit] diff --git a/src/infrastructure/teams/teams_chat_client.py b/src/infrastructure/teams/teams_chat_client.py index a308f63..2a0bfcb 100644 --- a/src/infrastructure/teams/teams_chat_client.py +++ b/src/infrastructure/teams/teams_chat_client.py @@ -10,9 +10,13 @@ 3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...) 4. GET (Authorization: skype_token ...) -> image bytes +GIF search is a Teams command on the middle tier, also named by `authz` (`regionGtms.middleTier`). It takes the same access +token and skype token, and the tenant's GIF policy applies to it. It is undocumented; the route is what the Teams web client calls. + The skype token and each chat's picture URL are cached per account (see `AccountCache`). """ +import json import time from concurrent.futures import ThreadPoolExecutor, wait from urllib.parse import quote, urlsplit @@ -43,6 +47,9 @@ class TeamsChatClient(ITeamsChat): _STREAM_TIMEOUT = httpx.Timeout(15.0, connect=5.0) CUSTOM_EMOJI_VIEWS = {True: "imgt2_anim", False: "img_thumb_tiny"} MAX_EMOJI_BYTES = 2 * 1024 * 1024 + GIF_COMMAND = "beta/commands/com.microsoft.teamspace.inputextension.giphy/execute?version=1.0" + GIF_LANGUAGE = "en-us" + MIDDLE_TIER_HOSTS = ("teams.microsoft.com", "teams.cloud.microsoft") # both tokens ride on this request http_client = staticmethod(http_client) def __init__(self, http: IMicrosoftHttpClients): @@ -88,6 +95,7 @@ def _new_session(self, broker, cache) -> tuple[str, str]: session = (skype, service) ams = self._media_host((body.get("regionGtms") or {}).get("amsV2") or (body.get("regionGtms") or {}).get("ams")) cache.set("teams_ams", ams, max(60, lifetime - self.SESSION_MARGIN)) + cache.set("teams_middle_tier", ((body.get("regionGtms") or {}).get("middleTier") or "").rstrip("/"), max(60, lifetime - self.SESSION_MARGIN)) cache.set("teams_session", session, max(60, lifetime - self.SESSION_MARGIN)) return session @@ -95,6 +103,23 @@ def _drop_session(self, broker): # A skype token the chat service no longer accepts; the next call mints a fresh one. AccountCache.for_broker(broker).set("teams_session", None, 0) + def gif_command(self, broker, query: str, limit: int, rating: str) -> list: + """Giphy results from Teams' own GIF command; an empty query asks for what is trending.""" + skype, _ = self._session(broker) + base = AccountCache.for_broker(broker).get("teams_middle_tier") or "" + parts = urlsplit(base) + if parts.scheme != "https" or parts.port not in (None, 443) or (parts.hostname or "").lower() not in self.MIDDLE_TIER_HOSTS: + raise ChatServiceError(502, "authz returned no middle tier") + params = {"query": query, "rating": rating, "limit": str(limit), "lang": self.GIF_LANGUAGE} + headers = {"Authorization": f"Bearer {broker(self.TEAMS_CLIENT, self.SPACES_SCOPE)}", "X-Skypetoken": skype, "Content-Type": "application/json;charset=UTF-8"} + r = self.http_client().post(f"{base}/{self.GIF_COMMAND}", headers=headers, content=json.dumps([{"name": k, "value": v} for k, v in params.items()]).encode(), timeout=self._TIMEOUT) + if r.status_code != 200: + if r.status_code == 401: + self._drop_session(broker) + raise ChatServiceError(r.status_code, "GIF command refused") + result = r.json().get("result") + return result if isinstance(result, list) else [] + def _media_host(self, value) -> str | None: """The media service base URL when it is an https Microsoft media host, else None.""" if not isinstance(value, str): diff --git a/tests/test_outnote2.py b/tests/test_outnote2.py index f5d7c3e..a1b4910 100644 --- a/tests/test_outnote2.py +++ b/tests/test_outnote2.py @@ -13,7 +13,7 @@ from outnote import GraphError, OutNote from outnote._native import message_from_native -from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, gifs, http_clients, onenote, outnote_client, profile, teams_files +from tests.services import AccountRunner, FileFetchError, ResourceTooLarge, Upload, _BrokerCache, chat_send, config, http_clients, onenote, outnote_client, profile, teams_files from src.api.app import app from src.domain.errors.service_error import ServiceError @@ -113,12 +113,6 @@ def test_entrance_http_error_is_a_tuple_and_profile_catches_it_for_real(monkeypa assert me["id"] == "oid-1" and ttl == profile.CLAIMS_TTL -def test_a_gif_search_goes_through_the_real_client_with_its_query(monkeypatch, server): - monkeypatch.setattr(gifs, "API", server.url) - assert gifs._search("k", "red cat", 5) == [] - assert server.seen[0]["path"].startswith("/search?") and "q=red+cat" in server.seen[0]["path"] - - def test_an_upload_session_goes_through_the_real_client(server): size = chat_send.SIMPLE_UPLOAD_MAX + 7 diff --git a/tests/test_teams_extras.py b/tests/test_teams_extras.py index d14b078..7bd772d 100644 --- a/tests/test_teams_extras.py +++ b/tests/test_teams_extras.py @@ -382,14 +382,67 @@ def test_a_long_expanded_list_is_used_when_the_members_route_is_throttled(env): assert len(_invited(note)) == 20 -def test_gif_search_needs_a_giphy_key(env, monkeypatch): - monkeypatch.delenv("GIPHY_API_KEY", raising=False) - r = TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}) - assert r.status_code == 503 +def _gif_http(monkeypatch, status=200, result=None, middle_tier="https://teams.microsoft.com/api/mt/emea"): + from tests.services import teams_chat + calls = [] + + class Http: + def post(self, url, headers=None, content=None, timeout=None): + if url == teams_chat.AUTHZ_URL: + return FakeResponse(200, {"tokens": {"skypeToken": "skype", "expiresIn": 4000}, "regionGtms": {"chatService": "https://ch.ng.msg.teams.microsoft.com", "middleTier": middle_tier}}) + calls.append({"url": url, "headers": headers, "body": json.loads(content)}) + return FakeResponse(status, {"result": result or []}) + + monkeypatch.setattr(teams_chat, "http_client", lambda: Http()) + return calls + + +def _gif_item(url=GIF_URL): + return {"composeType": "img", "composeValue": f'Cat', "fieldValues": [{"fieldName": "thumbnailUrl", "fieldValue": url.replace("giphy.gif", "200w.gif")}]} + + +def test_gif_search_asks_teams_with_the_accounts_own_tokens(env, monkeypatch): + calls = _gif_http(monkeypatch, result=[_gif_item()]) + client = TestClient(app) + r = client.get("/api/gifs/search", headers=H, params={"q": "cat", "limit": 5}) + assert r.json() == [{"id": GIF_URL, "title": "Cat", "url": GIF_URL, "previewUrl": GIF_URL.replace("giphy.gif", "200w.gif"), "width": 200, "height": 150}] + assert calls[0]["url"] == "https://teams.microsoft.com/api/mt/emea/beta/commands/com.microsoft.teamspace.inputextension.giphy/execute?version=1.0" + assert calls[0]["headers"]["X-Skypetoken"] == "skype" and calls[0]["headers"]["Authorization"].startswith("Bearer ") + assert {"name": "query", "value": "cat"} in calls[0]["body"] and {"name": "limit", "value": "5"} in calls[0]["body"] + client.get("/api/gifs/search", headers=H, params={"q": "Cat", "limit": 5}) + assert len(calls) == 1 # cached by (query, limit) + + +def test_gif_trending_is_the_command_with_an_empty_query(env, monkeypatch): + calls = _gif_http(monkeypatch, result=[_gif_item()]) + assert TestClient(app).get("/api/gifs/trending", headers=H).status_code == 200 + assert {"name": "query", "value": ""} in calls[0]["body"] + +def test_gif_search_keeps_only_giphy_urls(env, monkeypatch): + _gif_http(monkeypatch, result=[_gif_item("https://evil.example/giphy.gif"), {"composeValue": 7, "fieldValues": ["x"]}, "nonsense", _gif_item()]) + assert [g["url"] for g in TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).json()] == [GIF_URL] -def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch): - monkeypatch.setenv("GIPHY_API_KEY", "k") + +@pytest.mark.parametrize("status", [401, 403, 404]) +def test_a_refusal_from_teams_reads_as_no_gifs_here(env, monkeypatch, status): + _gif_http(monkeypatch, status=status) + assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 503 + + +def test_a_failing_gif_command_stays_an_error(env, monkeypatch): + _gif_http(monkeypatch, status=500) + assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 502 + + +@pytest.mark.parametrize("host", ["https://evil.example/api/mt/emea", "http://teams.microsoft.com/api/mt/emea", "https://teams.microsoft.com.evil.example/mt", ""]) +def test_the_tokens_only_go_to_a_teams_middle_tier(env, monkeypatch, host): + calls = _gif_http(monkeypatch, middle_tier=host) + assert TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat"}).status_code == 502 + assert calls == [] + + +def test_gif_search_needs_a_microsoft_sign_in(env): client = TestClient(app) assert client.get("/api/gifs/search", params={"q": "cat"}).status_code == 422 assert client.get("/api/gifs/trending").status_code == 422 @@ -398,32 +451,12 @@ def test_gif_search_needs_a_microsoft_sign_in(env, monkeypatch): def test_gif_search_is_rate_limited_per_account(env, monkeypatch): from tests.services import gifs - monkeypatch.setenv("GIPHY_API_KEY", "k") - monkeypatch.setattr(gifs, "_search", lambda key, query, limit: []) + monkeypatch.setattr(gifs, "_search", lambda broker, query, limit: []) client = TestClient(app) codes = [client.get("/api/gifs/search", headers=H, params={"q": f"q{i}"}).status_code for i in range(gifs.RATE_PER_MINUTE + 1)] assert codes[:-1] == [200] * gifs.RATE_PER_MINUTE and codes[-1] == 429 -def test_gif_search_returns_only_giphy_urls(env, monkeypatch): - from tests.services import gifs - calls = [] - - class Http: - def request(self, method, url, timeout=None): - calls.append(url) - assert method == "GET" and "/search?" in url and "q=cat" in url - return FakeResponse(200, {"data": [{"id": "g1", "title": "Cat", "images": {"downsized_medium": {"url": GIF_URL + "?cid=1", "width": "200", "height": "150"}, "fixed_width_small": {"url": GIF_URL}}}, {"id": "bad", "images": {"original": {"url": "https://evil.example/x.gif"}}}]}) - - monkeypatch.setenv("GIPHY_API_KEY", "k") - monkeypatch.setattr(gifs, "http_client", lambda: Http()) - gifs._results.set(("cat", 5), None, 0) - r = TestClient(app).get("/api/gifs/search", headers=H, params={"q": "cat", "limit": 5}) - assert r.json() == [{"id": "g1", "title": "Cat", "url": GIF_URL, "previewUrl": GIF_URL, "width": 200, "height": 150}] - TestClient(app).get("/api/gifs/search", headers=H, params={"q": "Cat", "limit": 5}) - assert len(calls) == 1 # cached by (query, limit) - - def test_account_cache_expires_and_is_bounded(monkeypatch): clock = [100.0] monkeypatch.setattr(account_cache.time, "monotonic", lambda: clock[0]) @@ -441,8 +474,7 @@ def test_no_response_carrying_a_token_is_cacheable(env, http, monkeypatch): """Every route that can hand back a rotated refresh token must forbid storing the response.""" from tests.services import gifs note, _ = env - monkeypatch.setenv("GIPHY_API_KEY", "k") - monkeypatch.setattr(gifs, "_search", lambda key, query, limit: []) + monkeypatch.setattr(gifs, "_search", lambda broker, query, limit: []) monkeypatch.setattr(note, "get", lambda path, params=None, raw=False: PNG if raw == "bytes" else {"id": "me-id"}) client = TestClient(app) responses = [