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 @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/application/abstractions/i_gif_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
3 changes: 3 additions & 0 deletions src/application/abstractions/i_teams_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
5 changes: 1 addition & 4 deletions src/application/options/giphy_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions src/application/queries/search_gifs_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
107 changes: 64 additions & 43 deletions src/infrastructure/giphy/gif_search.py
Original file line number Diff line number Diff line change
@@ -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 `<img itemtype="http://schema.skype.com/Giphy" src="https://media*.giphy.com/...">`
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 `<img itemtype="http://schema.skype.com/Giphy" src="https://media*.giphy.com/...">` 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."""
Expand All @@ -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]
25 changes: 25 additions & 0 deletions src/infrastructure/teams/teams_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
3. GET {chatService}/v1/users/ME/conversations/{chatId}?view=msnp24Equivalent (Authentication: skypetoken=...)
4. GET <picture URL> (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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -88,13 +95,31 @@ 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

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):
Expand Down
8 changes: 1 addition & 7 deletions tests/test_outnote2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Loading
Loading