diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index a905793..4b41642 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -503,6 +503,8 @@ stay explicit instead of being rediscovered in code review. | Teams `dialog_open_timeout_ms` config | Not implemented | Configurable | Low demand | | Google Chat file uploads | Ignored in message parse | Supported | API complexity; can add later | | Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection | +| WhatsApp `get_user` | Raises `ChatNotImplementedError` (`Chat.get_user` translates to "does not support get_user") | Not implemented upstream either (no `getUser` on the WhatsApp adapter) | WhatsApp Cloud API has no user lookup endpoint — phone numbers are the only stable identifier and there's no equivalent of `users.info` exposed to business apps. Documented explicitly so callers don't expect parity with Slack/Teams/Discord. | +| Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | ### Serialization differences diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 0e42337..5b4f0ce 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -180,6 +180,7 @@ Thread, ThreadInfo, ThreadSummary, + UserInfo, WebhookOptions, WellKnownEmoji, ) @@ -385,6 +386,7 @@ "Thread", "ThreadInfo", "ThreadSummary", + "UserInfo", "WebhookOptions", "WellKnownEmoji", ] diff --git a/src/chat_sdk/adapters/discord/adapter.py b/src/chat_sdk/adapters/discord/adapter.py index 3fd9b6a..4500ba5 100644 --- a/src/chat_sdk/adapters/discord/adapter.py +++ b/src/chat_sdk/adapters/discord/adapter.py @@ -62,6 +62,7 @@ SlashCommandEvent, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, _parse_iso, ) @@ -175,6 +176,39 @@ async def initialize(self, chat: ChatInstance) -> None: self._chat = chat self._logger.info("Discord adapter initialized") + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a Discord user via ``GET /users/{user_id}``. + + Returns ``None`` on any failure (network error, 4xx/5xx, missing + bot scope). Discord user IDs are 17-19 digit snowflakes — we + validate the shape here both as a lightweight typo guard and to + prevent path-segment injection (``/`` would escape the URL). + + Mirrors upstream ``DiscordAdapter.getUser`` (vercel/chat#391). + """ + # Hazard #12: never let user input reach a URL path unvalidated. + # Snowflakes are pure digits — anything else is rejected before + # the network call so a crafted "../foo" can't pivot the request. + if not user_id or not user_id.isdigit(): + return None + try: + user = await self._discord_fetch(f"/users/{quote(user_id, safe='')}", "GET") + except Exception: + return None + if not isinstance(user, dict): + return None + avatar = user.get("avatar") + avatar_url = f"https://cdn.discordapp.com/avatars/{user.get('id')}/{avatar}.png" if avatar else None + username = user.get("username") or user_id + return UserInfo( + user_id=str(user.get("id") or user_id), + user_name=username, + full_name=user.get("global_name") or username, + is_bot=bool(user.get("bot", False)), + avatar_url=avatar_url, + email=None, + ) + async def handle_webhook( self, request: Any, diff --git a/src/chat_sdk/adapters/github/adapter.py b/src/chat_sdk/adapters/github/adapter.py index 1f11849..69ce84f 100644 --- a/src/chat_sdk/adapters/github/adapter.py +++ b/src/chat_sdk/adapters/github/adapter.py @@ -59,6 +59,7 @@ StreamOptions, ThreadInfo, ThreadSummary, + UserInfo, WebhookOptions, _parse_iso, ) @@ -214,6 +215,40 @@ async def initialize(self, chat: ChatInstance) -> None: except Exception as error: self._logger.warn("Could not fetch bot user ID", {"error": str(error)}) + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a GitHub user by numeric account ID. + + Uses the ``GET /user/{account_id}`` endpoint, mirroring the + upstream Octokit ``GET /user/{account_id}`` call. + + ``user_id`` must be a numeric string — GitHub account IDs are + integers, so we reject anything else before issuing a request + (defense against URL injection and a guard for callers that pass + a login name by mistake). + + Returns ``None`` when the user is not found, the API returns an + error, or authentication is unavailable. Mirrors upstream + ``GitHubAdapter.getUser`` (vercel/chat#391). + """ + if not user_id or not user_id.isdigit(): + return None + try: + user = await self._github_api_request("GET", f"/user/{user_id}") + except Exception as error: + self._logger.debug("Failed to fetch user", {"userId": user_id, "error": str(error)}) + return None + if not isinstance(user, dict): + return None + login = user.get("login") or user_id + return UserInfo( + user_id=str(user.get("id") if user.get("id") is not None else user_id), + user_name=login, + full_name=user.get("name") or login, + is_bot=user.get("type") == "Bot", + avatar_url=user.get("avatar_url"), + email=user.get("email"), + ) + async def _get_http_session(self) -> Any: """Return the shared aiohttp session, creating it lazily if needed.""" import aiohttp diff --git a/src/chat_sdk/adapters/google_chat/adapter.py b/src/chat_sdk/adapters/google_chat/adapter.py index 10503b3..d9d54ac 100644 --- a/src/chat_sdk/adapters/google_chat/adapter.py +++ b/src/chat_sdk/adapters/google_chat/adapter.py @@ -81,6 +81,7 @@ StreamOptions, ThreadInfo, ThreadSummary, + UserInfo, WebhookOptions, _parse_iso, ) @@ -728,6 +729,36 @@ async def _verify_bearer_token( self._logger.warn("JWT verification failed", {"error": error}) return False + # ========================================================================= + # Public user lookup (chat.get_user) + # ========================================================================= + + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a Google Chat user from the cached webhook sender info. + + Google Chat does not expose a direct ``users.get`` API for chat + bots — display names, avatars, and emails are only available on + inbound message ``sender`` payloads. We surface what we've cached + from previous webhooks; callers see ``None`` until the user has + interacted with the bot at least once. + + Mirrors upstream ``GoogleChatAdapter.getUser`` (vercel/chat#391). + """ + try: + cached = await self._user_info_cache.get(user_id) + except Exception: + return None + if not cached: + return None + return UserInfo( + user_id=user_id, + user_name=cached.display_name, + full_name=cached.display_name, + is_bot=bool(cached.is_bot) if cached.is_bot is not None else False, + email=cached.email, + avatar_url=cached.avatar_url, + ) + # ========================================================================= # Webhook handling # ========================================================================= @@ -1302,12 +1333,15 @@ def _parse_google_chat_message( try: loop = asyncio.get_running_loop() + sender = message.get("sender") or {} _pin_task( loop.create_task( self._user_info_cache.set( user_id, display_name, - (message.get("sender") or {}).get("email"), + sender.get("email"), + sender.get("type") == "BOT", + sender.get("avatarUrl"), ) ) ) diff --git a/src/chat_sdk/adapters/google_chat/user_info.py b/src/chat_sdk/adapters/google_chat/user_info.py index 1fd70b7..e58fd02 100644 --- a/src/chat_sdk/adapters/google_chat/user_info.py +++ b/src/chat_sdk/adapters/google_chat/user_info.py @@ -26,6 +26,8 @@ class CachedUserInfo: display_name: str email: str | None = None + is_bot: bool | None = None + avatar_url: str | None = None class UserInfoCache: @@ -50,12 +52,19 @@ async def set( user_id: str, display_name: str, email: str | None = None, + is_bot: bool | None = None, + avatar_url: str | None = None, ) -> None: """Cache user info for later lookup.""" if not display_name or display_name == "unknown": return - user_info = CachedUserInfo(display_name=display_name, email=email) + user_info = CachedUserInfo( + display_name=display_name, + email=email, + is_bot=is_bot, + avatar_url=avatar_url, + ) # Always update in-memory cache self._in_memory_cache[user_id] = user_info @@ -73,7 +82,12 @@ async def set( cache_key = f"{USER_INFO_KEY_PREFIX}{user_id}" await self._state.set( cache_key, - {"display_name": display_name, "email": email}, + { + "display_name": display_name, + "email": email, + "is_bot": is_bot, + "avatar_url": avatar_url, + }, USER_INFO_CACHE_TTL_MS, ) @@ -96,12 +110,20 @@ async def get(self, user_id: str) -> CachedUserInfo | None: # Populate in-memory cache if found in state if from_state: - info = CachedUserInfo( - display_name=from_state.get("display_name", "unknown") - if isinstance(from_state, dict) - else getattr(from_state, "display_name", "unknown"), - email=from_state.get("email") if isinstance(from_state, dict) else getattr(from_state, "email", None), - ) + if isinstance(from_state, dict): + info = CachedUserInfo( + display_name=from_state.get("display_name", "unknown"), + email=from_state.get("email"), + is_bot=from_state.get("is_bot"), + avatar_url=from_state.get("avatar_url"), + ) + else: + info = CachedUserInfo( + display_name=getattr(from_state, "display_name", "unknown"), + email=getattr(from_state, "email", None), + is_bot=getattr(from_state, "is_bot", None), + avatar_url=getattr(from_state, "avatar_url", None), + ) self._in_memory_cache[user_id] = info return info diff --git a/src/chat_sdk/adapters/linear/adapter.py b/src/chat_sdk/adapters/linear/adapter.py index ea724f6..31b4301 100644 --- a/src/chat_sdk/adapters/linear/adapter.py +++ b/src/chat_sdk/adapters/linear/adapter.py @@ -55,6 +55,7 @@ RawMessage, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, _parse_iso, ) @@ -258,6 +259,40 @@ async def _ensure_valid_token(self) -> None: self._logger.info("Linear access token expired, refreshing...") await self._refresh_client_credentials_token() + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a Linear user by UUID via the GraphQL ``user`` query. + + Returns ``None`` on any failure (auth missing, user not found, + network error). Mirrors upstream ``LinearAdapter.getUser`` + (vercel/chat#391), which uses the official Linear SDK; we issue + the equivalent GraphQL query directly so we don't take a runtime + dependency on the JS SDK. + """ + try: + await self._ensure_valid_token() + data = await self._graphql_query( + "query GetUser($id: String!) { user(id: $id) { id displayName name email avatarUrl }}", + {"id": user_id}, + ) + except Exception: + return None + user = (data.get("data") or {}).get("user") if isinstance(data, dict) else None + if not user or not isinstance(user, dict): + return None + # Match upstream literally (vercel/chat#391): + # userName: user.displayName, fullName: user.name + # No defensive `or` fallbacks — drift from upstream's exact field + # mapping creates cross-SDK inconsistency for callers building on + # `user_name` / `full_name` semantics. + return UserInfo( + user_id=user.get("id") or user_id, + user_name=user.get("displayName"), + full_name=user.get("name"), + is_bot=False, + avatar_url=user.get("avatarUrl"), + email=user.get("email"), + ) + async def handle_webhook( self, request: Any, diff --git a/src/chat_sdk/adapters/slack/adapter.py b/src/chat_sdk/adapters/slack/adapter.py index 7fc74cf..e84b466 100644 --- a/src/chat_sdk/adapters/slack/adapter.py +++ b/src/chat_sdk/adapters/slack/adapter.py @@ -22,7 +22,7 @@ from collections.abc import AsyncIterable, Awaitable, Callable from contextvars import ContextVar from datetime import datetime, timezone -from typing import Any, NoReturn, cast +from typing import Any, NoReturn, TypedDict, cast from urllib.parse import parse_qs, urlparse from chat_sdk.adapters.slack.cards import ( @@ -91,6 +91,7 @@ StreamOptions, ThreadInfo, ThreadSummary, + UserInfo, WebhookOptions, ) @@ -122,6 +123,43 @@ def _pin_task(task: asyncio.Task[Any]) -> None: _CHANNEL_CACHE_TTL_MS = 8 * 24 * 60 * 60 * 1000 _REVERSE_INDEX_TTL_MS = 8 * 24 * 60 * 60 * 1000 + +class SlackUserCacheEntry(TypedDict, total=False): + """Cached user shape returned by :meth:`SlackAdapter._lookup_user`. + + The first five keys always exist on a successful lookup or + cache hit. ``_lookup_failed`` appears only on the failure path + (API exception or empty user payload) — callers like + :meth:`SlackAdapter.get_user` use it to return ``None`` instead of + a fallback ``UserInfo``. ``total=False`` because both the cache hit + branch and the failure branch omit ``_lookup_failed``. + """ + + display_name: str + real_name: str + email: str | None + avatar_url: str | None + is_bot: bool | None + _lookup_failed: bool + + +def _make_slack_lookup_failed(user_id: str) -> SlackUserCacheEntry: + """Build the sentinel cache entry for a failed Slack user lookup. + + Shared between the ``except`` path and the empty-user-payload path + so both produce the exact same fallback shape (and neither caches + it — see :meth:`SlackAdapter._lookup_user`). + """ + return { + "display_name": user_id, + "real_name": user_id, + "email": None, + "avatar_url": None, + "is_bot": None, + "_lookup_failed": True, + } + + # Ignored message subtypes (system/meta events) _IGNORED_SUBTYPES = frozenset( { @@ -597,10 +635,21 @@ def _extract_team_id_from_interactive(self, body: str) -> str | None: # User / Channel lookup with caching # ================================================================== - async def _lookup_user(self, user_id: str) -> dict[str, str]: + async def _lookup_user(self, user_id: str) -> SlackUserCacheEntry: """Look up user info from Slack API with caching. - Returns ``{"display_name": ..., "real_name": ...}``. + Returns a dict with keys ``display_name``, ``real_name``, and + (when available from the Slack API or from a cached entry) the + optional fields ``email``, ``avatar_url``, ``is_bot``. + + On API failure — or when the API returns success but with an + empty/missing ``user`` payload — the returned dict is a fallback + shape (``display_name`` / ``real_name`` populated with the user + ID) and carries the private ``_lookup_failed: True`` sentinel so + callers that need to distinguish "really not found" from "fall + back to ID" — like :meth:`get_user` — can return ``None`` + instead. The fallback entry is **not** cached so a subsequent + call retries the lookup. """ cache_key = f"slack:user:{user_id}" @@ -610,12 +659,28 @@ async def _lookup_user(self, user_id: str) -> dict[str, str]: return { "display_name": cached.get("display_name", user_id), "real_name": cached.get("real_name", user_id), + "email": cached.get("email"), + "avatar_url": cached.get("avatar_url"), + "is_bot": cached.get("is_bot"), } try: client = self._get_client() result = await client.users_info(user=user_id) - user = result.get("user", {}) + user = result.get("user") or {} + # Slack can return `{"ok": True, "user": {}}` in some edge cases + # (rare, but observed when scopes are partial or the workspace + # rejects the lookup post-success). Treat a missing/empty user + # payload as a lookup failure so we don't poison the cache + # with a `UserInfo("Uxxx", "Uxxx", "Uxxx")` shape that + # `get_user` would then convert into a non-null fallback — + # diverging from the null-on-failure contract callers expect. + if not user: + self._logger.warn( + "Slack users.info returned empty user payload", + {"userId": user_id}, + ) + return _make_slack_lookup_failed(user_id) profile = user.get("profile", {}) display_name = ( @@ -626,11 +691,24 @@ async def _lookup_user(self, user_id: str) -> dict[str, str]: or user_id ) real_name = user.get("real_name") or profile.get("real_name") or display_name + email = profile.get("email") + # Upstream chose `image_192` (vs the older `image_72`) for + # better avatar quality — see vercel/chat#391. + avatar_url = profile.get("image_192") + is_bot = user.get("is_bot") + + cached_entry: SlackUserCacheEntry = { + "display_name": display_name, + "real_name": real_name, + "email": email, + "avatar_url": avatar_url, + "is_bot": is_bot, + } if self._chat: await self._chat.get_state().set( cache_key, - {"display_name": display_name, "real_name": real_name}, + cached_entry, _USER_CACHE_TTL_MS, ) # Reverse index: display name -> user IDs @@ -649,10 +727,16 @@ async def _lookup_user(self, user_id: str) -> dict[str, str]: "Fetched user info", {"userId": user_id, "displayName": display_name, "realName": real_name}, ) - return {"display_name": display_name, "real_name": real_name} + return cached_entry except Exception as exc: self._logger.warn("Could not fetch user info", {"userId": user_id, "error": exc}) - return {"display_name": user_id, "real_name": user_id} + # Keep the fallback dict shape so existing callers (mention + # resolution, slash command author binding, message parsing) + # don't change behavior on transient lookup failures — they + # already used `display_name`/`real_name` and would have + # received the user ID either way. The private sentinel lets + # `get_user` distinguish "API failed" from "API returned data". + return _make_slack_lookup_failed(user_id) async def _lookup_channel(self, channel_id: str) -> str: """Look up channel name from Slack API with caching.""" @@ -678,6 +762,35 @@ async def _lookup_channel(self, channel_id: str) -> str: self._logger.warn("Could not fetch channel info", {"channelId": channel_id, "error": exc}) return channel_id + # ================================================================== + # Public user lookup (chat.get_user) + # ================================================================== + + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up Slack user info via ``users.info``. + + Returns ``None`` when the Slack API call fails (network error, + rate limit, missing scopes, unknown user). ``email`` requires the + ``users:read.email`` scope; ``avatar_url`` is the high-quality + ``image_192`` from the user's Slack profile. + + Mirrors upstream ``SlackAdapter.getUser`` (vercel/chat#391). + """ + try: + cached = await self._lookup_user(user_id) + except Exception: + return None + if cached.get("_lookup_failed"): + return None + return UserInfo( + user_id=user_id, + user_name=cached.get("display_name") or user_id, + full_name=cached.get("real_name") or user_id, + is_bot=bool(cached.get("is_bot")) if cached.get("is_bot") is not None else False, + email=cached.get("email"), + avatar_url=cached.get("avatar_url"), + ) + # ================================================================== # Webhook handling # ================================================================== diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index 33bc85f..21e8c95 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -17,7 +17,7 @@ from collections.abc import Awaitable, Callable from datetime import datetime, timezone from typing import Any, Literal, NoReturn -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from chat_sdk.adapters.teams.cards import card_to_adaptive_card from chat_sdk.adapters.teams.format_converter import TeamsFormatConverter @@ -55,6 +55,7 @@ ReactionEvent, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, _parse_iso, ) @@ -213,6 +214,76 @@ async def initialize(self, chat: ChatInstance) -> None: self._chat = chat self._logger.info("Teams adapter initialized") + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a Teams user via Microsoft Graph ``GET /users/{id}``. + + Teams Bot Framework user IDs (``29:...``) are not directly usable + by Graph — Graph needs the tenant-scoped AAD object ID. We cache + the ``aadObjectId`` from each inbound activity in + :meth:`_cache_user_context`, so this call only succeeds for users + that have interacted with the bot since the cache TTL. + + Returns ``None`` when the user has never interacted (no cached + ``aadObjectId``), the chat instance isn't initialized, or the + Graph API call fails. Requires the ``User.Read.All`` application + permission on the bot's app registration. + + Mirrors upstream ``TeamsAdapter.getUser`` (vercel/chat#404). + """ + if not self._chat: + return None + try: + aad_object_id = await self._chat.get_state().get(f"teams:aadObjectId:{user_id}") + except Exception: + return None + if not aad_object_id: + self._logger.debug("No cached aadObjectId for user", {"userId": user_id}) + return None + # Defense in depth: aadObjectId came from a webhook so it's already + # platform-trusted, but reject obvious junk before issuing a Graph + # call (avoids URL injection if the cache is ever populated from + # an attacker-controlled path). Reject the structural splitters + # that change URL semantics outright (`/`, `?`, `#`), then + # percent-encode the remainder via `quote(safe="")` (matches + # Discord's pattern) so whitespace, `\\`, `;`, etc. cannot escape + # the `/users/{id}` path segment. + aad_str = str(aad_object_id) + if not aad_str or "/" in aad_str or "?" in aad_str or "#" in aad_str: + return None + try: + token = await self._get_graph_token() + session = await self._get_http_session() + url = f"https://graph.microsoft.com/v1.0/users/{quote(aad_str, safe='')}" + async with session.get( + url, + headers={"Authorization": f"Bearer {token}"}, + ) as response: + if not response.ok: + self._logger.warn( + "Failed to fetch user info from Graph API", + {"userId": user_id, "status": response.status}, + ) + return None + graph_user = await response.json() + except Exception as error: + self._logger.warn( + "Failed to fetch user info from Graph API", + {"userId": user_id, "error": str(error)}, + ) + return None + if not isinstance(graph_user, dict): + return None + display_name = graph_user.get("displayName") or aad_str + user_principal = graph_user.get("userPrincipalName") + return UserInfo( + user_id=user_id, + user_name=user_principal or display_name or user_id, + full_name=display_name, + is_bot=False, + email=graph_user.get("mail"), + avatar_url=None, + ) + async def handle_webhook( self, request: Any, @@ -302,6 +373,14 @@ async def _cache_user_context(self, activity: dict[str, Any]) -> None: if tenant_id and state: await state.set(f"teams:tenantId:{user_id}", tenant_id, ttl) + # Cache aadObjectId for Microsoft Graph API user lookups (chat.get_user). + # Only Bot Framework user IDs ("29:...") are surfaced in incoming + # activities; the Graph API needs the tenant-scoped AAD object ID + # to call /users/{id}. Cache when present so get_user() can map. + aad_object_id = from_user.get("aadObjectId") + if aad_object_id and state: + await state.set(f"teams:aadObjectId:{user_id}", aad_object_id, ttl) + # Cache channel context team_aad_group_id = channel_data.get("team", {}).get("aadGroupId") conversation_id = conversation.get("id", "") diff --git a/src/chat_sdk/adapters/telegram/adapter.py b/src/chat_sdk/adapters/telegram/adapter.py index 48732ce..c198ffe 100644 --- a/src/chat_sdk/adapters/telegram/adapter.py +++ b/src/chat_sdk/adapters/telegram/adapter.py @@ -75,6 +75,7 @@ RawMessage, ReactionEvent, ThreadInfo, + UserInfo, WebhookOptions, ) @@ -375,6 +376,43 @@ async def initialize(self, chat: ChatInstance) -> None: else: await self.start_polling(polling_config) + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up a Telegram user via ``getChat``. + + Telegram has no public ``users.get`` API for bots — ``getChat`` + with a ``chat_id`` of the user is the closest equivalent and only + succeeds when the user has interacted with the bot at least once + (so the bot has a private chat record). We restrict resolution to + ``type == "private"`` chats so a group/supergroup ID never gets + misreported as a user. + + ``is_bot`` is always ``False`` because ``getChat`` does not expose + that field on the chat shape — callers needing bot detection + should use ``message.author.is_bot`` from incoming events. + + Mirrors upstream ``TelegramAdapter.getUser`` (vercel/chat#391). + """ + try: + chat = await self.telegram_fetch("getChat", {"chat_id": user_id}) + except Exception: + return None + if not isinstance(chat, dict) or chat.get("type") != "private": + return None + first = chat.get("first_name") or "" + last = chat.get("last_name") or "" + full_name = " ".join(part for part in (first, last) if part) + chat_id_str = str(chat.get("id", user_id)) + return UserInfo( + user_id=chat_id_str, + user_name=chat.get("username") or chat.get("first_name") or chat_id_str, + full_name=full_name or chat_id_str, + # Documented divergence from upstream parity: getChat doesn't + # expose is_bot. See docstring above. + is_bot=False, + email=None, + avatar_url=None, + ) + async def handle_webhook( self, request: Any, diff --git a/src/chat_sdk/adapters/whatsapp/adapter.py b/src/chat_sdk/adapters/whatsapp/adapter.py index 9f2fcd2..08b7489 100644 --- a/src/chat_sdk/adapters/whatsapp/adapter.py +++ b/src/chat_sdk/adapters/whatsapp/adapter.py @@ -59,6 +59,7 @@ StreamChunk, StreamOptions, ThreadInfo, + UserInfo, WebhookOptions, ) @@ -153,6 +154,21 @@ async def initialize(self, chat: ChatInstance) -> None: self._bot_user_id = self._phone_number_id self._logger.info("WhatsApp adapter initialized", {"phoneNumberId": self._phone_number_id}) + async def get_user(self, user_id: str) -> UserInfo | None: + """Not implemented — see docs/UPSTREAM_SYNC.md non-parity table. + + WhatsApp Cloud API has no user lookup endpoint; the only stable + identifier is the phone number, and there's no equivalent of + ``users.info`` exposed to business apps. Raising + :class:`~chat_sdk.errors.ChatNotImplementedError` lets + :meth:`Chat.get_user` translate this into a ``"does not support + get_user"`` error rather than returning ``None`` (which would + falsely imply "user not found"). + """ + from chat_sdk.errors import ChatNotImplementedError + + raise ChatNotImplementedError("whatsapp", "getUser") + async def _get_http_session(self) -> Any: """Return the shared aiohttp session, creating it lazily if needed.""" import aiohttp diff --git a/src/chat_sdk/chat.py b/src/chat_sdk/chat.py index 6911563..9068290 100644 --- a/src/chat_sdk/chat.py +++ b/src/chat_sdk/chat.py @@ -19,7 +19,7 @@ from typing import Any from chat_sdk.channel import ChannelImpl, _ChannelImplConfigWithAdapter -from chat_sdk.errors import ChatError, LockError +from chat_sdk.errors import ChatError, ChatNotImplementedError, LockError from chat_sdk.logger import ConsoleLogger, Logger from chat_sdk.modals import SelectOptionElement from chat_sdk.thread import ( @@ -60,6 +60,7 @@ ReactionEvent, SlashCommandEvent, StateAdapter, + UserInfo, WebhookOptions, _parse_iso, ) @@ -72,8 +73,13 @@ DEDUPE_TTL_MS = 5 * 60 * 1000 # 5 minutes MODAL_CONTEXT_TTL_MS = 24 * 60 * 60 * 1000 # 24 hours -SLACK_USER_ID_REGEX = re.compile(r"^U[A-Z0-9]+$", re.IGNORECASE) +SLACK_USER_ID_REGEX = re.compile(r"^[UW][A-Z0-9]+$") DISCORD_SNOWFLAKE_REGEX = re.compile(r"^\d{17,19}$") +LINEAR_UUID_REGEX = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) +NUMERIC_REGEX = re.compile(r"^\d+$") # --------------------------------------------------------------------------- # Handler type aliases @@ -1494,6 +1500,57 @@ async def open_dm(self, user: str | Author) -> ThreadImpl: False, ) + async def get_user(self, user: str | Author) -> UserInfo | None: + """Look up user information by user ID. + + The adapter is automatically inferred from the user ID format + (Slack ``U.../W...``, Teams ``29:...``, Google Chat ``users/...``, + Linear UUID, or numeric for Discord/Telegram/GitHub — disambiguated + by which adapters are registered). + + Returns user details including ``email`` and ``avatar_url`` when + available — both require appropriate scopes on some platforms (for + example ``users:read.email`` on Slack). + + Parameters + ---------- + user: + Platform-specific user ID string, or an :class:`Author` object. + + Returns + ------- + :class:`UserInfo` or ``None`` + ``None`` is returned when the user is not found. + + Raises + ------ + :class:`~chat_sdk.errors.ChatError` + * ``Cannot infer adapter from userId "..."`` — the user ID does + not match any of the supported platform formats, or the + inferred adapter is not registered on this Chat instance. + * ``Numeric userId "..." is ambiguous between adapters: ...`` — + multiple registered adapters could resolve a numeric ID; call + the platform adapter's ``get_user`` directly instead. + * ``Adapter "" does not support get_user`` — the resolved + adapter does not implement user lookup (e.g. WhatsApp). + + Mirrors ``chat.getUser`` from the upstream TS SDK + (``vercel/chat#391``). + + Examples + -------- + :: + + user = await chat.get_user("U123456") + print(user.email if user else "") + """ + user_id = user if isinstance(user, str) else user.user_id + adapter = self._infer_adapter_from_user_id(user_id) + try: + return await adapter.get_user(user_id) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter.name}" does not support get_user') from exc + def channel(self, channel_id: str) -> ChannelImpl: """Get a Channel by its channel ID (e.g. 'slack:C123ABC').""" adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" @@ -1593,33 +1650,61 @@ def thread( # ======================================================================== def _infer_adapter_from_user_id(self, user_id: str) -> Adapter: - # Google Chat: users/... + # ── Unique-prefix formats — no collision possible across adapters ── + + # Google Chat: "users/123456789" if user_id.startswith("users/"): adapter = self._adapters.get("gchat") if adapter: return adapter - # Teams: 29:... + # Teams: "29:base64string..." if user_id.startswith("29:"): adapter = self._adapters.get("teams") if adapter: return adapter - # Slack: U followed by alphanumeric - if SLACK_USER_ID_REGEX.match(user_id): - adapter = self._adapters.get("slack") + # Linear: UUID v4 (e.g. "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b") + if LINEAR_UUID_REGEX.match(user_id): + adapter = self._adapters.get("linear") if adapter: return adapter - # Discord: snowflake - if DISCORD_SNOWFLAKE_REGEX.match(user_id): - adapter = self._adapters.get("discord") + # Slack: "U..." or "W..." (uppercase only, alphanumeric, 7+ chars). + # Case-sensitive on purpose — lowercase strings like "user123" are + # GitHub logins, not Slack IDs. + if SLACK_USER_ID_REGEX.match(user_id): + adapter = self._adapters.get("slack") if adapter: return adapter + # Numeric IDs: shared by Discord (17-19 digit snowflakes), Telegram + # (positive integer up to 52 bits), and GitHub (numeric account_id). + # Disambiguate by which adapters the caller actually registered. + if NUMERIC_REGEX.match(user_id): + candidates: list[str] = [] + if DISCORD_SNOWFLAKE_REGEX.match(user_id) and "discord" in self._adapters: + candidates.append("discord") + if "telegram" in self._adapters: + candidates.append("telegram") + if "github" in self._adapters: + candidates.append("github") + + if len(candidates) == 1: + adapter = self._adapters.get(candidates[0]) + if adapter: + return adapter + if len(candidates) > 1: + raise ChatError( + f'Numeric userId "{user_id}" is ambiguous between adapters: ' + f"{', '.join(candidates)}. Call the platform's adapter " + "directly (e.g. `adapter.get_user(user_id)`)." + ) + raise ChatError( f'Cannot infer adapter from userId "{user_id}". ' - "Expected: Slack (U...), Teams (29:...), Google Chat (users/...), Discord (numeric)." + 'Expected: Slack ("U..."), Teams ("29:..."), Google Chat ("users/..."), ' + "Linear (UUID), or Discord/Telegram/GitHub (numeric)." ) # ======================================================================== diff --git a/src/chat_sdk/shared/mock_adapter.py b/src/chat_sdk/shared/mock_adapter.py index ba75254..f7fcef4 100644 --- a/src/chat_sdk/shared/mock_adapter.py +++ b/src/chat_sdk/shared/mock_adapter.py @@ -195,6 +195,19 @@ async def post_channel_message(self, channel_id: str, message: AdapterPostableMe # `thread_id` can still route by string key. return RawMessage(id="msg-1", thread_id=channel_id, raw={}) + async def get_user(self, user_id: str) -> Any: + """Default mock implementation raises ``ChatNotImplementedError``. + + Tests that exercise :meth:`Chat.get_user` should override this on + a per-instance basis (e.g. ``adapter.get_user = AsyncMock(...)``) + to mirror the upstream pattern of attaching ``vi.fn()`` per test. + Mirrors the upstream Vitest mock, where ``getUser`` is undefined + until the test explicitly sets it. + """ + from chat_sdk.errors import ChatNotImplementedError + + raise ChatNotImplementedError(self.name, "getUser") + # --------------------------------------------------------------------------- # Mock State Adapter diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 6cdfe29..0797405 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -238,6 +238,23 @@ class Author: user_name: str +@dataclass +class UserInfo: + """User information returned by :meth:`Adapter.get_user`. + + Mirrors upstream ``UserInfo`` (``packages/chat/src/types.ts``). Fields + that aren't universally available across platforms (``email``, + ``avatar_url``) are optional. + """ + + full_name: str + is_bot: bool + user_id: str + user_name: str + avatar_url: str | None = None + email: str | None = None + + @dataclass class MessageMetadata: """Message metadata.""" @@ -1196,6 +1213,14 @@ def render_formatted(self, content: FormattedContent) -> str: ... async def handle_webhook(self, request: Any, options: WebhookOptions | None = None) -> Any: ... async def initialize(self, chat: ChatInstance) -> None: ... + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up user information by user ID. + + Optional — not all platforms support this. Returns ``None`` when the + user is not found or the lookup fails. + """ + ... + class BaseAdapter: """Base adapter with default implementations for optional methods. @@ -1343,6 +1368,17 @@ async def disconnect(self) -> None: """Cleanup hook called when the Chat instance is shut down.""" raise ChatNotImplementedError(self.name, "disconnect") + async def get_user(self, user_id: str) -> UserInfo | None: + """Look up user information by user ID. + + Optional — not all platforms support this. Concrete adapters that + can resolve users via a platform API should override this. The + default raises :class:`~chat_sdk.errors.ChatNotImplementedError`, + which :meth:`~chat_sdk.chat.Chat.get_user` translates into a + ``"does not support get_user"`` :class:`~chat_sdk.errors.ChatError`. + """ + raise ChatNotImplementedError(self.name, "getUser") + def rehydrate_attachment(self, attachment: Attachment) -> Attachment: """Reconstruct ``fetch_data`` on an attachment after deserialization. diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 500970f..85a1128 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -1426,6 +1426,188 @@ async def test_should_allow_posting_to_dm_thread(self): assert any(tid == "slack:DU123456:" and content == "Hello via DM!" for tid, content in adapter._post_calls) +# ============================================================================ +# 13b. getUser (vercel/chat#391) +# ============================================================================ + + +class TestGetUser: + """describe("getUser") — chat.get_user(user_id) cross-platform user lookup.""" + + # TS: "should return user info from adapter" + async def test_should_return_user_info_from_adapter(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + chat, adapter, state = await _init_chat() + adapter.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="U123456", + user_name="alice", + full_name="Alice Smith", + email="alice@example.com", + avatar_url="https://example.com/alice.png", + is_bot=False, + ) + ) + + user = await chat.get_user("U123456") + assert user is not None + assert user.email == "alice@example.com" + assert user.full_name == "Alice Smith" + adapter.get_user.assert_awaited_once_with("U123456") + + # TS: "should accept Author object" + async def test_should_accept_author_object(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + chat, adapter, state = await _init_chat() + adapter.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="U789", + user_name="bob", + full_name="Bob Jones", + is_bot=False, + ) + ) + + author = _make_author(user_id="U789", user_name="bob", full_name="Bob Jones") + user = await chat.get_user(author) + adapter.get_user.assert_awaited_once_with("U789") + assert user is not None + assert user.full_name == "Bob Jones" + + # TS: "should throw when adapter does not support getUser" + async def test_should_throw_when_adapter_does_not_support_get_user(self): + chat, adapter, state = await _init_chat() + # MockAdapter.get_user raises ChatNotImplementedError by default + with pytest.raises(ChatError, match="does not support get_user"): + await chat.get_user("U123456") + + # TS: "should return null when user is not found" + async def test_should_return_none_when_user_is_not_found(self): + from unittest.mock import AsyncMock + + chat, adapter, state = await _init_chat() + adapter.get_user = AsyncMock(return_value=None) # type: ignore[method-assign] + user = await chat.get_user("U999999") + assert user is None + + # TS: "should throw error for unknown userId format" + async def test_should_throw_error_for_unknown_userid_format(self): + chat, adapter, state = await _init_chat() + with pytest.raises(ChatError, match='Cannot infer adapter from userId "invalid-user-id"'): + await chat.get_user("invalid-user-id") + + # TS: "should infer linear adapter from a UUID" + async def test_should_infer_linear_adapter_from_a_uuid(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + slack = create_mock_adapter("slack") + linear = create_mock_adapter("linear") + linear.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b", + user_name="ben", + full_name="Ben Sabic", + is_bot=False, + ) + ) + chat, _ = await _init_multi_chat({"slack": slack, "linear": linear}) + + user = await chat.get_user("8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b") + assert user is not None + assert user.full_name == "Ben Sabic" + linear.get_user.assert_awaited_once_with("8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b") + + # TS: "should infer telegram from numeric id when only telegram is registered" + async def test_should_infer_telegram_from_numeric_id_when_only_telegram_registered(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + telegram = create_mock_adapter("telegram") + telegram.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="987654321", + user_name="alice", + full_name="Alice", + is_bot=False, + ) + ) + chat, _ = await _init_multi_chat({"telegram": telegram}) + + user = await chat.get_user("987654321") + assert user is not None + assert user.user_name == "alice" + telegram.get_user.assert_awaited_once_with("987654321") + + # TS: "should infer github from numeric id when only github is registered" + async def test_should_infer_github_from_numeric_id_when_only_github_registered(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + github = create_mock_adapter("github") + github.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="12345", + user_name="octocat", + full_name="The Octocat", + is_bot=False, + ) + ) + chat, _ = await _init_multi_chat({"github": github}) + + user = await chat.get_user("12345") + assert user is not None + assert user.user_name == "octocat" + + # TS: "should infer discord for 17-19 digit snowflake when only discord is registered" + async def test_should_infer_discord_for_snowflake_when_only_discord_registered(self): + from unittest.mock import AsyncMock + + from chat_sdk.types import UserInfo + + discord = create_mock_adapter("discord") + discord.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="175928847299117063", + user_name="discordbot", + full_name="Discord User", + is_bot=False, + ) + ) + chat, _ = await _init_multi_chat({"discord": discord}) + + user = await chat.get_user("175928847299117063") + assert user is not None + assert user.full_name == "Discord User" + + # TS: "should throw AMBIGUOUS_USER_ID when numeric id matches multiple registered adapters" + async def test_should_throw_ambiguous_when_numeric_matches_multiple_registered(self): + discord = create_mock_adapter("discord") + telegram = create_mock_adapter("telegram") + chat, _ = await _init_multi_chat({"discord": discord, "telegram": telegram}) + + with pytest.raises(ChatError, match="ambiguous"): + await chat.get_user("175928847299117063") + + # TS: "should not match GitHub-style logins as Slack ids (case sensitivity)" + async def test_should_not_match_github_style_logins_as_slack_ids(self): + slack = create_mock_adapter("slack") + github = create_mock_adapter("github") + chat, _ = await _init_multi_chat({"slack": slack, "github": github}) + + with pytest.raises(ChatError, match='Cannot infer adapter from userId "user123"'): + await chat.get_user("user123") + + # ============================================================================ # thread() factory # ============================================================================ diff --git a/tests/test_get_user_adapters.py b/tests/test_get_user_adapters.py new file mode 100644 index 0000000..f9dd124 --- /dev/null +++ b/tests/test_get_user_adapters.py @@ -0,0 +1,746 @@ +"""Per-adapter `get_user` integration tests for the chat.get_user port. + +Mirrors the per-adapter `getUser` tests added in vercel/chat#391: + +* packages/adapter-slack/src/index.test.ts (Slack getUser describe block) +* packages/adapter-discord/src/index.test.ts (Discord getUser) +* packages/adapter-gchat/src/index.test.ts (Google Chat getUser) +* packages/adapter-github/src/index.test.ts (GitHub getUser) +* packages/adapter-linear/src/index.test.ts (Linear getUser) +* packages/adapter-telegram/src/index.test.ts (Telegram getUser) +* packages/adapter-teams/src/index.test.ts (Teams getUser) + +Tests mock at the appropriate per-adapter HTTP boundary (Slack Web API +client, `_discord_fetch`, `_github_api_request`, `_graphql_query`, +`telegram_fetch`, etc.) so we exercise the full `get_user` codepath +including auth/token plumbing and response shape mapping. Each adapter +gets a happy-path test plus an error path (API failure or not-found), +and one adversarial test for inputs that could escape the URL or pivot +the request (Hazard #12). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# ============================================================================= +# Shared helpers +# ============================================================================= + + +def _mock_state() -> MagicMock: + cache: dict[str, Any] = {} + state = MagicMock() + state.get = AsyncMock(side_effect=lambda k: cache.get(k)) + state.set = AsyncMock(side_effect=lambda k, v, *a, **kw: cache.__setitem__(k, v)) + state.delete = AsyncMock(side_effect=lambda k: cache.pop(k, None)) + state.append_to_list = AsyncMock() + state.get_list = AsyncMock(return_value=[]) + state._cache = cache + return state + + +def _mock_chat(state: MagicMock) -> MagicMock: + chat = MagicMock() + chat.get_state = MagicMock(return_value=state) + chat.get_user_name = MagicMock(return_value="test-bot") + chat.get_logger = MagicMock(return_value=MagicMock()) + return chat + + +# ============================================================================= +# Slack +# ============================================================================= + + +class TestSlackGetUser: + @pytest.mark.asyncio + async def test_returns_user_info_with_email_and_avatar(self): + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + adapter = SlackAdapter( + SlackAdapterConfig(signing_secret="s", bot_token="xoxb-test"), + ) + state = _mock_state() + chat = _mock_chat(state) + await adapter.initialize(chat) + + client = MagicMock() + client.users_info = AsyncMock( + return_value={ + "user": { + "is_bot": False, + "name": "alice", + "real_name": "Alice Smith", + "profile": { + "display_name": "alice", + "real_name": "Alice Smith", + "email": "alice@example.com", + "image_192": "https://example.com/alice_192.png", + }, + } + } + ) + # Patch the per-call client factory. + adapter._get_client = lambda token=None: client # type: ignore[assignment] + + user = await adapter.get_user("U123") + assert user is not None + assert user.user_id == "U123" + assert user.user_name == "alice" + assert user.full_name == "Alice Smith" + assert user.email == "alice@example.com" + assert user.avatar_url == "https://example.com/alice_192.png" + assert user.is_bot is False + client.users_info.assert_awaited_once_with(user="U123") + + @pytest.mark.asyncio + async def test_returns_none_when_lookup_fails(self): + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + adapter = SlackAdapter( + SlackAdapterConfig(signing_secret="s", bot_token="xoxb-test"), + ) + state = _mock_state() + chat = _mock_chat(state) + await adapter.initialize(chat) + + client = MagicMock() + client.users_info = AsyncMock(side_effect=RuntimeError("user_not_found")) + adapter._get_client = lambda token=None: client # type: ignore[assignment] + + user = await adapter.get_user("U_DOES_NOT_EXIST") + assert user is None + + @pytest.mark.asyncio + async def test_empty_user_payload_is_not_cached(self): + """Slack ``users.info`` can return ``{"ok": True, "user": {}}`` + in edge cases (partial scopes, workspace policy). The lookup + must: + + 1. Return ``None`` from ``get_user`` (matches upstream + null-on-failure contract). + 2. Skip caching so a subsequent call retries the API instead + of serving a poisoned ``UserInfo("Uxxx", "Uxxx", "Uxxx")`` + shape forever. + """ + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + adapter = SlackAdapter( + SlackAdapterConfig(signing_secret="s", bot_token="xoxb-test"), + ) + state = _mock_state() + chat = _mock_chat(state) + await adapter.initialize(chat) + + client = MagicMock() + client.users_info = AsyncMock(return_value={"ok": True, "user": {}}) + adapter._get_client = lambda token=None: client # type: ignore[assignment] + + # (a) Empty success returns None, not a fallback UserInfo. + user = await adapter.get_user("U_EMPTY") + assert user is None + + # (b) The cache must NOT contain a poisoned entry. A second call + # should retry the API, not serve cached garbage. + assert "slack:user:U_EMPTY" not in state._cache + user_again = await adapter.get_user("U_EMPTY") + assert user_again is None + # users.info called *twice* (no cache short-circuit on attempt 2). + assert client.users_info.await_count == 2 + + @pytest.mark.asyncio + async def test_uses_image_192_not_image_72(self): + """Upstream cite: vercel/chat#391 — switched from image_72 to + image_192 for better avatar quality. Lock the field choice in. + """ + from chat_sdk.adapters.slack.adapter import SlackAdapter + from chat_sdk.adapters.slack.types import SlackAdapterConfig + + adapter = SlackAdapter( + SlackAdapterConfig(signing_secret="s", bot_token="xoxb-test"), + ) + state = _mock_state() + await adapter.initialize(_mock_chat(state)) + + client = MagicMock() + client.users_info = AsyncMock( + return_value={ + "user": { + "name": "bob", + "real_name": "Bob", + "profile": { + "display_name": "bob", + "real_name": "Bob", + "image_72": "https://example.com/bob_72.png", + "image_192": "https://example.com/bob_192.png", + }, + } + } + ) + adapter._get_client = lambda token=None: client # type: ignore[assignment] + + user = await adapter.get_user("U2") + assert user is not None + assert user.avatar_url == "https://example.com/bob_192.png" + + +# ============================================================================= +# Discord +# ============================================================================= + + +class TestDiscordGetUser: + def _make_adapter(self): + from chat_sdk.adapters.discord.adapter import DiscordAdapter + from chat_sdk.adapters.discord.types import DiscordAdapterConfig + + return DiscordAdapter( + DiscordAdapterConfig( + bot_token="bot-token", + public_key="0" * 64, + application_id="app-id", + ) + ) + + @pytest.mark.asyncio + async def test_returns_user_info(self): + adapter = self._make_adapter() + adapter._discord_fetch = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": "175928847299117063", + "username": "discordbot", + "global_name": "Discord User", + "bot": False, + "avatar": "abc123", + } + ) + user = await adapter.get_user("175928847299117063") + assert user is not None + assert user.user_id == "175928847299117063" + assert user.user_name == "discordbot" + assert user.full_name == "Discord User" + assert user.is_bot is False + assert user.avatar_url == "https://cdn.discordapp.com/avatars/175928847299117063/abc123.png" + # Hazard #12 — user_id reaches the URL path; ensure URL-encoded. + path_arg = adapter._discord_fetch.call_args.args[0] + assert path_arg == "/users/175928847299117063" + + @pytest.mark.asyncio + async def test_returns_none_on_api_failure(self): + adapter = self._make_adapter() + adapter._discord_fetch = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + user = await adapter.get_user("175928847299117063") + assert user is None + + @pytest.mark.asyncio + async def test_rejects_non_numeric_user_id(self): + """Hazard #12 — ``user_id`` containing a path separator must not + escape the URL and pivot the request.""" + adapter = self._make_adapter() + adapter._discord_fetch = AsyncMock(return_value={"id": "x", "username": "y"}) # type: ignore[method-assign] + user = await adapter.get_user("175928847299117063/../guilds/leak") + assert user is None + adapter._discord_fetch.assert_not_called() + + @pytest.mark.asyncio + async def test_falls_back_to_username_without_global_name(self): + adapter = self._make_adapter() + adapter._discord_fetch = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": "999", + "username": "legacy", + "global_name": None, + "bot": True, + } + ) + user = await adapter.get_user("999") + assert user is not None + assert user.full_name == "legacy" + assert user.is_bot is True + assert user.avatar_url is None + + +# ============================================================================= +# Google Chat +# ============================================================================= + + +class TestGoogleChatGetUser: + def _make_adapter(self): + from chat_sdk.adapters.google_chat.adapter import GoogleChatAdapter + from chat_sdk.adapters.google_chat.types import ( + GoogleChatAdapterConfig, + ServiceAccountCredentials, + ) + + return GoogleChatAdapter( + GoogleChatAdapterConfig( + credentials=ServiceAccountCredentials( + client_email="bot@example.iam.gserviceaccount.com", + private_key="-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----", + project_id="test-project", + ), + ) + ) + + @pytest.mark.asyncio + async def test_returns_cached_user_info(self): + adapter = self._make_adapter() + # Seed the cache via the new is_bot/avatar_url path. + await adapter._user_info_cache.set( + "users/123", + "Alice", + "alice@example.com", + False, + "https://lh3.googleusercontent.com/alice", + ) + user = await adapter.get_user("users/123") + assert user is not None + assert user.user_id == "users/123" + assert user.user_name == "Alice" + assert user.full_name == "Alice" + assert user.email == "alice@example.com" + assert user.avatar_url == "https://lh3.googleusercontent.com/alice" + assert user.is_bot is False + + @pytest.mark.asyncio + async def test_returns_none_when_user_not_cached(self): + adapter = self._make_adapter() + user = await adapter.get_user("users/has-never-interacted") + assert user is None + + @pytest.mark.asyncio + async def test_returns_none_when_cache_raises(self): + adapter = self._make_adapter() + adapter._user_info_cache.get = AsyncMock(side_effect=RuntimeError("state down")) # type: ignore[method-assign] + user = await adapter.get_user("users/123") + assert user is None + + +# ============================================================================= +# GitHub +# ============================================================================= + + +class TestGitHubGetUser: + def _make_adapter(self): + from chat_sdk.adapters.github.adapter import GitHubAdapter + + return GitHubAdapter( + { + "webhook_secret": "test-webhook-secret", + "token": "ghp_testtoken", + } + ) + + @pytest.mark.asyncio + async def test_returns_user_info(self): + adapter = self._make_adapter() + adapter._github_api_request = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": 583231, + "login": "octocat", + "name": "The Octocat", + "email": "octocat@github.com", + "avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4", + "type": "User", + } + ) + user = await adapter.get_user("583231") + assert user is not None + assert user.user_id == "583231" + assert user.user_name == "octocat" + assert user.full_name == "The Octocat" + assert user.email == "octocat@github.com" + assert user.avatar_url == "https://avatars.githubusercontent.com/u/583231?v=4" + assert user.is_bot is False + adapter._github_api_request.assert_awaited_once_with("GET", "/user/583231") + + @pytest.mark.asyncio + async def test_returns_none_on_api_failure(self): + adapter = self._make_adapter() + adapter._github_api_request = AsyncMock(side_effect=RuntimeError("404")) # type: ignore[method-assign] + user = await adapter.get_user("999999") + assert user is None + + @pytest.mark.asyncio + async def test_marks_bot_account_type(self): + adapter = self._make_adapter() + adapter._github_api_request = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": 12345, + "login": "dependabot[bot]", + "type": "Bot", + "avatar_url": "https://avatars.githubusercontent.com/in/29110", + } + ) + user = await adapter.get_user("12345") + assert user is not None + assert user.is_bot is True + + @pytest.mark.asyncio + async def test_rejects_non_numeric_user_id(self): + """Hazard #12 — `octocat` is a login, not an account_id; + passing it should not produce a `/user/octocat/../leak` request.""" + adapter = self._make_adapter() + adapter._github_api_request = AsyncMock() # type: ignore[method-assign] + user = await adapter.get_user("octocat") + assert user is None + adapter._github_api_request.assert_not_called() + + +# ============================================================================= +# Linear +# ============================================================================= + + +class TestLinearGetUser: + def _make_adapter(self): + from chat_sdk.adapters.linear.adapter import LinearAdapter + from chat_sdk.adapters.linear.types import LinearAdapterAPIKeyConfig + from chat_sdk.logger import ConsoleLogger + + return LinearAdapter( + LinearAdapterAPIKeyConfig( + api_key="test-api-key", + webhook_secret="test-secret", + user_name="test-bot", + logger=ConsoleLogger("error"), + ) + ) + + @pytest.mark.asyncio + async def test_returns_user_info(self): + adapter = self._make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={ + "data": { + "user": { + "id": "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b", + "displayName": "ben", + "name": "Ben Sabic", + "email": "ben@example.com", + "avatarUrl": "https://linear.app/avatar/ben.png", + } + } + } + ) + user = await adapter.get_user("8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b") + assert user is not None + assert user.user_id == "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b" + assert user.user_name == "ben" + assert user.full_name == "Ben Sabic" + assert user.email == "ben@example.com" + assert user.avatar_url == "https://linear.app/avatar/ben.png" + assert user.is_bot is False + # Verify variables actually included the user id (no string concat). + call = adapter._graphql_query.call_args + assert call.args[1] == {"id": "8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b"} + + @pytest.mark.asyncio + async def test_returns_none_when_user_missing(self): + adapter = self._make_adapter() + adapter._graphql_query = AsyncMock(return_value={"data": {"user": None}}) # type: ignore[method-assign] + user = await adapter.get_user("00000000-0000-0000-0000-000000000000") + assert user is None + + @pytest.mark.asyncio + async def test_returns_none_on_graphql_error(self): + adapter = self._make_adapter() + adapter._graphql_query = AsyncMock(side_effect=RuntimeError("403")) # type: ignore[method-assign] + user = await adapter.get_user("8f1f3c7e-d4e1-4f9a-bf2b-1c3d4e5f6a7b") + assert user is None + + +# ============================================================================= +# Telegram +# ============================================================================= + + +class TestTelegramGetUser: + def _make_adapter(self): + from chat_sdk.adapters.telegram.adapter import TelegramAdapter + from chat_sdk.adapters.telegram.types import TelegramAdapterConfig + + return TelegramAdapter( + TelegramAdapterConfig( + bot_token="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + ) + ) + + @pytest.mark.asyncio + async def test_returns_user_info_for_private_chat(self): + adapter = self._make_adapter() + adapter.telegram_fetch = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": 987654321, + "type": "private", + "first_name": "Alice", + "last_name": "Smith", + "username": "alice", + } + ) + user = await adapter.get_user("987654321") + assert user is not None + assert user.user_id == "987654321" + assert user.user_name == "alice" + assert user.full_name == "Alice Smith" + # Documented divergence: getChat does not expose is_bot. + assert user.is_bot is False + adapter.telegram_fetch.assert_awaited_once_with("getChat", {"chat_id": "987654321"}) + + @pytest.mark.asyncio + async def test_returns_none_for_group_chat(self): + """Telegram chat IDs can identify groups too; group chats are not users.""" + adapter = self._make_adapter() + adapter.telegram_fetch = AsyncMock( # type: ignore[method-assign] + return_value={ + "id": -100123, + "type": "supergroup", + "title": "Engineering", + } + ) + user = await adapter.get_user("-100123") + assert user is None + + @pytest.mark.asyncio + async def test_returns_none_on_api_error(self): + adapter = self._make_adapter() + adapter.telegram_fetch = AsyncMock(side_effect=RuntimeError("Bad Request")) # type: ignore[method-assign] + user = await adapter.get_user("not-a-real-user") + assert user is None + + +# ============================================================================= +# Teams +# ============================================================================= + + +class TestTeamsGetUser: + def _make_adapter(self): + from chat_sdk.adapters.teams.adapter import TeamsAdapter + from chat_sdk.adapters.teams.types import TeamsAdapterConfig + + return TeamsAdapter( + TeamsAdapterConfig( + app_id="11111111-2222-3333-4444-555555555555", + app_password="app-secret", + ) + ) + + def _seed_chat_state(self, adapter, mapping: dict[str, Any]) -> MagicMock: + state = _mock_state() + for k, v in mapping.items(): + state._cache[k] = v + chat = _mock_chat(state) + adapter._chat = chat + return state + + @pytest.mark.asyncio + async def test_returns_none_when_chat_not_initialized(self): + adapter = self._make_adapter() + adapter._chat = None + user = await adapter.get_user("29:abc") + assert user is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_cached_aad_object_id(self): + adapter = self._make_adapter() + self._seed_chat_state(adapter, {}) + user = await adapter.get_user("29:never-interacted") + assert user is None + + @pytest.mark.asyncio + async def test_returns_user_info_via_graph_api(self): + adapter = self._make_adapter() + self._seed_chat_state( + adapter, + {"teams:aadObjectId:29:abc": "aad-object-uuid"}, + ) + + # Mock the Graph token + HTTP session. + adapter._get_graph_token = AsyncMock(return_value="graph-token") # type: ignore[method-assign] + + class _Resp: + ok = True + status = 200 + + async def json(self): + return { + "displayName": "Carol Manager", + "userPrincipalName": "carol@contoso.com", + "mail": "carol@contoso.com", + } + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + # session.get is a sync method that returns an async context manager; + # use a plain lambda so audit_test_quality doesn't false-flag it as + # an unawaited async method. + session = MagicMock() + session.get = lambda *args, **kwargs: _Resp() + adapter._get_http_session = AsyncMock(return_value=session) # type: ignore[method-assign] + + user = await adapter.get_user("29:abc") + assert user is not None + assert user.user_id == "29:abc" + assert user.user_name == "carol@contoso.com" + assert user.full_name == "Carol Manager" + assert user.email == "carol@contoso.com" + assert user.is_bot is False + + @pytest.mark.asyncio + async def test_returns_none_when_graph_returns_4xx(self): + adapter = self._make_adapter() + self._seed_chat_state( + adapter, + {"teams:aadObjectId:29:abc": "aad-object-uuid"}, + ) + adapter._get_graph_token = AsyncMock(return_value="graph-token") # type: ignore[method-assign] + + class _Resp: + ok = False + status = 403 + + async def json(self): + return {} + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + # session.get is a sync method that returns an async context manager; + # use a plain lambda so audit_test_quality doesn't false-flag it as + # an unawaited async method. + session = MagicMock() + session.get = lambda *args, **kwargs: _Resp() + adapter._get_http_session = AsyncMock(return_value=session) # type: ignore[method-assign] + + user = await adapter.get_user("29:abc") + assert user is None + + @pytest.mark.asyncio + async def test_rejects_aad_object_id_with_path_separator(self): + """Defense in depth — a poisoned cache entry must not pivot the + Graph URL even though aadObjectId is normally platform-trusted.""" + adapter = self._make_adapter() + self._seed_chat_state( + adapter, + {"teams:aadObjectId:29:abc": "aad-uuid/../leak"}, + ) + adapter._get_graph_token = AsyncMock(return_value="graph-token") # type: ignore[method-assign] + # Should never reach the HTTP session. + adapter._get_http_session = AsyncMock( # type: ignore[method-assign] + side_effect=AssertionError("should not call HTTP") + ) + + user = await adapter.get_user("29:abc") + assert user is None + + @pytest.mark.parametrize( + "poisoned_aad", + [ + "aad\nLocation: http://evil", # CRLF / header injection + "aad\rLocation: http://evil", # bare CR + "aad\tfoo", # tab + "aad bar", # whitespace + "aad\\..\\leak", # backslash traversal + "aad%2F..%2Fleak", # already-percent-encoded slash + "aad;leak", # semicolon (path-param splitter on some servers) + "..", # bare traversal + ], + ) + @pytest.mark.asyncio + async def test_aad_object_id_adversarial_inputs_stay_in_users_segment(self, poisoned_aad: str): + """Adversarial inputs that slip past the `/`/`?`/`#` reject list + must either be rejected outright OR percent-encoded so the + resulting Graph URL stays under ``/v1.0/users/`` — they can't + pivot the request to a different host, path, or HTTP header. + """ + from urllib.parse import urlparse + + adapter = self._make_adapter() + self._seed_chat_state( + adapter, + {"teams:aadObjectId:29:abc": poisoned_aad}, + ) + adapter._get_graph_token = AsyncMock(return_value="graph-token") # type: ignore[method-assign] + + captured: dict[str, str] = {} + + class _Resp: + ok = True + status = 200 + + async def json(self): + return {"displayName": "x", "userPrincipalName": "x@y", "mail": "x@y"} + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return None + + def _capture_get(url, *args, **kwargs): + captured["url"] = url + return _Resp() + + session = MagicMock() + session.get = _capture_get + adapter._get_http_session = AsyncMock(return_value=session) # type: ignore[method-assign] + + # Either get_user returns None (rejected) OR the URL it issued + # stays inside the /users/ segment with the input percent-encoded. + await adapter.get_user("29:abc") + if "url" in captured: + url = captured["url"] + parsed = urlparse(url) + assert parsed.scheme == "https", f"scheme escaped: {url}" + assert parsed.netloc == "graph.microsoft.com", f"host escaped: {url}" + # Must remain a single segment under /v1.0/users/ + assert parsed.path.startswith("/v1.0/users/"), f"path escaped: {url}" + tail = parsed.path[len("/v1.0/users/") :] + assert "/" not in tail, f"path traversal not encoded: {url}" + # No raw whitespace / CR / LF / tab survived into the request URL. + for ch in ("\n", "\r", "\t", " "): + assert ch not in url, f"control character {ch!r} leaked into URL: {url!r}" + + +# ============================================================================= +# WhatsApp — explicitly unsupported +# ============================================================================= + + +class TestWhatsAppGetUser: + @pytest.mark.asyncio + async def test_raises_chat_not_implemented_error(self): + from chat_sdk.adapters.whatsapp.adapter import WhatsAppAdapter + from chat_sdk.adapters.whatsapp.types import WhatsAppAdapterConfig + from chat_sdk.errors import ChatNotImplementedError + from chat_sdk.logger import ConsoleLogger + + adapter = WhatsAppAdapter( + WhatsAppAdapterConfig( + access_token="t", + app_secret="s", + phone_number_id="123", + verify_token="v", + user_name="bot", + logger=ConsoleLogger("error"), + ) + ) + with pytest.raises(ChatNotImplementedError): + await adapter.get_user("4917612345678")