Skip to content
Draft
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: 2 additions & 0 deletions docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/chat_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
Thread,
ThreadInfo,
ThreadSummary,
UserInfo,
WebhookOptions,
WellKnownEmoji,
)
Expand Down Expand Up @@ -385,6 +386,7 @@
"Thread",
"ThreadInfo",
"ThreadSummary",
"UserInfo",
"WebhookOptions",
"WellKnownEmoji",
]
34 changes: 34 additions & 0 deletions src/chat_sdk/adapters/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
SlashCommandEvent,
StreamOptions,
ThreadInfo,
UserInfo,
WebhookOptions,
_parse_iso,
)
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions src/chat_sdk/adapters/github/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
StreamOptions,
ThreadInfo,
ThreadSummary,
UserInfo,
WebhookOptions,
_parse_iso,
)
Expand Down Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion src/chat_sdk/adapters/google_chat/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
StreamOptions,
ThreadInfo,
ThreadSummary,
UserInfo,
WebhookOptions,
_parse_iso,
)
Expand Down Expand Up @@ -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
# =========================================================================
Expand Down Expand Up @@ -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"),
)
)
)
Expand Down
38 changes: 30 additions & 8 deletions src/chat_sdk/adapters/google_chat/user_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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,
)

Expand All @@ -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

Expand Down
35 changes: 35 additions & 0 deletions src/chat_sdk/adapters/linear/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
RawMessage,
StreamOptions,
ThreadInfo,
UserInfo,
WebhookOptions,
_parse_iso,
)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading