Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
31 changes: 31 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,36 @@ 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
display_name = user.get("displayName") or user.get("name") or user_id
return UserInfo(
user_id=user.get("id") or user_id,
user_name=display_name,
full_name=user.get("name") or display_name,
is_bot=False,
avatar_url=user.get("avatarUrl"),
email=user.get("email"),
)

async def handle_webhook(
self,
request: Any,
Expand Down
Loading
Loading