Skip to content

Commit 16b9e04

Browse files
feat(chat): add chat.get_user() for cross-platform user lookups (#90)
Ports vercel/chat#391 — adds chat.get_user(adapter, user_id) and per-adapter get_user implementations across all 8 platforms. - Chat.get_user(adapter: str | Adapter, user_id: str) -> User | None resolves string adapter names through registered adapters - Adapter Protocol gains async def get_user(user_id: str) -> User | None - User extended with optional email, display_name, avatar_url - Per-adapter implementations: Slack users.info, Discord GET /users/{id}, Google Chat users.get, GitHub GET /users/{login}, Linear GraphQL user(id:), Teams Graph /users/{aadObjectId} (uses #85's AAD cache), Telegram getChat (best-effort), WhatsApp (Cloud API has no separate lookup) - Slack get_user awaits _resolve_token_async() so it works under callable bot_token resolvers (#87) when called from background contexts (cron, etc.) - Lazy imports of platform SDKs inside each method (hazard #10) - Resolved merge conflicts with #85/#86/#87/#88/#89 — all surface areas coexist 26 new tests in test_get_user_adapters.py + 3 in test_chat_faithful.py.
1 parent d03cd61 commit 16b9e04

16 files changed

Lines changed: 1686 additions & 28 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,8 @@ stay explicit instead of being rediscovered in code review.
515515
| Google Chat file uploads | Ignored in message parse | Supported | API complexity; can add later |
516516
| Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection |
517517
| Teams `User-Agent: Vercel.ChatSDK` outbound header | Not set on `aiohttp` calls | Propagated by `botbuilder` 2.0.8 | Python Teams adapter doesn't use `botbuilder` (raw `aiohttp`). Upstream's vercel/chat#415 was a JS-only `botbuilder` SDK bump that flipped `X-User-Agent``User-Agent`. No equivalent dependency to bump on the Python side. Setting a `User-Agent` on the ~9 outbound `aiohttp` call sites would be a defense-in-depth nice-to-have; deferred to a follow-up. |
518+
| 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`. |
519+
| 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. |
518520

519521
### Serialization differences
520522

src/chat_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@
185185
Thread,
186186
ThreadInfo,
187187
ThreadSummary,
188+
UserInfo,
188189
WebhookOptions,
189190
WellKnownEmoji,
190191
)
@@ -395,6 +396,7 @@
395396
"Thread",
396397
"ThreadInfo",
397398
"ThreadSummary",
399+
"UserInfo",
398400
"WebhookOptions",
399401
"WellKnownEmoji",
400402
]

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
SlashCommandEvent,
6262
StreamOptions,
6363
ThreadInfo,
64+
UserInfo,
6465
WebhookOptions,
6566
_parse_iso,
6667
)
@@ -174,6 +175,39 @@ async def initialize(self, chat: ChatInstance) -> None:
174175
self._chat = chat
175176
self._logger.info("Discord adapter initialized")
176177

178+
async def get_user(self, user_id: str) -> UserInfo | None:
179+
"""Look up a Discord user via ``GET /users/{user_id}``.
180+
181+
Returns ``None`` on any failure (network error, 4xx/5xx, missing
182+
bot scope). Discord user IDs are 17-19 digit snowflakes — we
183+
validate the shape here both as a lightweight typo guard and to
184+
prevent path-segment injection (``/`` would escape the URL).
185+
186+
Mirrors upstream ``DiscordAdapter.getUser`` (vercel/chat#391).
187+
"""
188+
# Hazard #12: never let user input reach a URL path unvalidated.
189+
# Snowflakes are pure digits — anything else is rejected before
190+
# the network call so a crafted "../foo" can't pivot the request.
191+
if not user_id or not user_id.isdigit():
192+
return None
193+
try:
194+
user = await self._discord_fetch(f"/users/{quote(user_id, safe='')}", "GET")
195+
except Exception:
196+
return None
197+
if not isinstance(user, dict):
198+
return None
199+
avatar = user.get("avatar")
200+
avatar_url = f"https://cdn.discordapp.com/avatars/{user.get('id')}/{avatar}.png" if avatar else None
201+
username = user.get("username") or user_id
202+
return UserInfo(
203+
user_id=str(user.get("id") or user_id),
204+
user_name=username,
205+
full_name=user.get("global_name") or username,
206+
is_bot=bool(user.get("bot", False)),
207+
avatar_url=avatar_url,
208+
email=None,
209+
)
210+
177211
async def handle_webhook(
178212
self,
179213
request: Any,

src/chat_sdk/adapters/github/adapter.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
StreamOptions,
6060
ThreadInfo,
6161
ThreadSummary,
62+
UserInfo,
6263
WebhookOptions,
6364
_parse_iso,
6465
)
@@ -214,6 +215,40 @@ async def initialize(self, chat: ChatInstance) -> None:
214215
except Exception as error:
215216
self._logger.warn("Could not fetch bot user ID", {"error": str(error)})
216217

218+
async def get_user(self, user_id: str) -> UserInfo | None:
219+
"""Look up a GitHub user by numeric account ID.
220+
221+
Uses the ``GET /user/{account_id}`` endpoint, mirroring the
222+
upstream Octokit ``GET /user/{account_id}`` call.
223+
224+
``user_id`` must be a numeric string — GitHub account IDs are
225+
integers, so we reject anything else before issuing a request
226+
(defense against URL injection and a guard for callers that pass
227+
a login name by mistake).
228+
229+
Returns ``None`` when the user is not found, the API returns an
230+
error, or authentication is unavailable. Mirrors upstream
231+
``GitHubAdapter.getUser`` (vercel/chat#391).
232+
"""
233+
if not user_id or not user_id.isdigit():
234+
return None
235+
try:
236+
user = await self._github_api_request("GET", f"/user/{user_id}")
237+
except Exception as error:
238+
self._logger.debug("Failed to fetch user", {"userId": user_id, "error": str(error)})
239+
return None
240+
if not isinstance(user, dict):
241+
return None
242+
login = user.get("login") or user_id
243+
return UserInfo(
244+
user_id=str(user.get("id") if user.get("id") is not None else user_id),
245+
user_name=login,
246+
full_name=user.get("name") or login,
247+
is_bot=user.get("type") == "Bot",
248+
avatar_url=user.get("avatar_url"),
249+
email=user.get("email"),
250+
)
251+
217252
async def _get_http_session(self) -> Any:
218253
"""Return the shared aiohttp session, creating it lazily if needed."""
219254
import aiohttp

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
StreamOptions,
8282
ThreadInfo,
8383
ThreadSummary,
84+
UserInfo,
8485
WebhookOptions,
8586
_parse_iso,
8687
)
@@ -728,6 +729,36 @@ async def _verify_bearer_token(
728729
self._logger.warn("JWT verification failed", {"error": error})
729730
return False
730731

732+
# =========================================================================
733+
# Public user lookup (chat.get_user)
734+
# =========================================================================
735+
736+
async def get_user(self, user_id: str) -> UserInfo | None:
737+
"""Look up a Google Chat user from the cached webhook sender info.
738+
739+
Google Chat does not expose a direct ``users.get`` API for chat
740+
bots — display names, avatars, and emails are only available on
741+
inbound message ``sender`` payloads. We surface what we've cached
742+
from previous webhooks; callers see ``None`` until the user has
743+
interacted with the bot at least once.
744+
745+
Mirrors upstream ``GoogleChatAdapter.getUser`` (vercel/chat#391).
746+
"""
747+
try:
748+
cached = await self._user_info_cache.get(user_id)
749+
except Exception:
750+
return None
751+
if not cached:
752+
return None
753+
return UserInfo(
754+
user_id=user_id,
755+
user_name=cached.display_name,
756+
full_name=cached.display_name,
757+
is_bot=bool(cached.is_bot) if cached.is_bot is not None else False,
758+
email=cached.email,
759+
avatar_url=cached.avatar_url,
760+
)
761+
731762
# =========================================================================
732763
# Webhook handling
733764
# =========================================================================
@@ -1302,12 +1333,15 @@ def _parse_google_chat_message(
13021333

13031334
try:
13041335
loop = asyncio.get_running_loop()
1336+
sender = message.get("sender") or {}
13051337
_pin_task(
13061338
loop.create_task(
13071339
self._user_info_cache.set(
13081340
user_id,
13091341
display_name,
1310-
(message.get("sender") or {}).get("email"),
1342+
sender.get("email"),
1343+
sender.get("type") == "BOT",
1344+
sender.get("avatarUrl"),
13111345
)
13121346
)
13131347
)

src/chat_sdk/adapters/google_chat/user_info.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ class CachedUserInfo:
2626

2727
display_name: str
2828
email: str | None = None
29+
is_bot: bool | None = None
30+
avatar_url: str | None = None
2931

3032

3133
class UserInfoCache:
@@ -50,12 +52,19 @@ async def set(
5052
user_id: str,
5153
display_name: str,
5254
email: str | None = None,
55+
is_bot: bool | None = None,
56+
avatar_url: str | None = None,
5357
) -> None:
5458
"""Cache user info for later lookup."""
5559
if not display_name or display_name == "unknown":
5660
return
5761

58-
user_info = CachedUserInfo(display_name=display_name, email=email)
62+
user_info = CachedUserInfo(
63+
display_name=display_name,
64+
email=email,
65+
is_bot=is_bot,
66+
avatar_url=avatar_url,
67+
)
5968

6069
# Always update in-memory cache
6170
self._in_memory_cache[user_id] = user_info
@@ -73,7 +82,12 @@ async def set(
7382
cache_key = f"{USER_INFO_KEY_PREFIX}{user_id}"
7483
await self._state.set(
7584
cache_key,
76-
{"display_name": display_name, "email": email},
85+
{
86+
"display_name": display_name,
87+
"email": email,
88+
"is_bot": is_bot,
89+
"avatar_url": avatar_url,
90+
},
7791
USER_INFO_CACHE_TTL_MS,
7892
)
7993

@@ -96,12 +110,20 @@ async def get(self, user_id: str) -> CachedUserInfo | None:
96110

97111
# Populate in-memory cache if found in state
98112
if from_state:
99-
info = CachedUserInfo(
100-
display_name=from_state.get("display_name", "unknown")
101-
if isinstance(from_state, dict)
102-
else getattr(from_state, "display_name", "unknown"),
103-
email=from_state.get("email") if isinstance(from_state, dict) else getattr(from_state, "email", None),
104-
)
113+
if isinstance(from_state, dict):
114+
info = CachedUserInfo(
115+
display_name=from_state.get("display_name", "unknown"),
116+
email=from_state.get("email"),
117+
is_bot=from_state.get("is_bot"),
118+
avatar_url=from_state.get("avatar_url"),
119+
)
120+
else:
121+
info = CachedUserInfo(
122+
display_name=getattr(from_state, "display_name", "unknown"),
123+
email=getattr(from_state, "email", None),
124+
is_bot=getattr(from_state, "is_bot", None),
125+
avatar_url=getattr(from_state, "avatar_url", None),
126+
)
105127
self._in_memory_cache[user_id] = info
106128
return info
107129

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
RawMessage,
5656
StreamOptions,
5757
ThreadInfo,
58+
UserInfo,
5859
WebhookOptions,
5960
_parse_iso,
6061
)
@@ -258,6 +259,43 @@ async def _ensure_valid_token(self) -> None:
258259
self._logger.info("Linear access token expired, refreshing...")
259260
await self._refresh_client_credentials_token()
260261

262+
async def get_user(self, user_id: str) -> UserInfo | None:
263+
"""Look up a Linear user by UUID via the GraphQL ``user`` query.
264+
265+
Returns ``None`` on any failure (auth missing, user not found,
266+
network error). Mirrors upstream ``LinearAdapter.getUser``
267+
(vercel/chat#391), which uses the official Linear SDK; we issue
268+
the equivalent GraphQL query directly so we don't take a runtime
269+
dependency on the JS SDK.
270+
"""
271+
try:
272+
await self._ensure_valid_token()
273+
data = await self._graphql_query(
274+
"query GetUser($id: String!) { user(id: $id) { id displayName name email avatarUrl }}",
275+
{"id": user_id},
276+
)
277+
except Exception:
278+
return None
279+
user = (data.get("data") or {}).get("user") if isinstance(data, dict) else None
280+
if not user or not isinstance(user, dict):
281+
return None
282+
# Match upstream literally (vercel/chat#391):
283+
# userName: user.displayName, fullName: user.name
284+
# Fall back to `user_id` when either field is missing — matches
285+
# the convention used by every other adapter's `get_user`
286+
# (slack/discord/github/teams/telegram) and satisfies the
287+
# non-Optional `str` typing of ``UserInfo.user_name`` /
288+
# ``UserInfo.full_name`` that JS's ``undefined`` would otherwise
289+
# violate.
290+
return UserInfo(
291+
user_id=user.get("id") or user_id,
292+
user_name=user.get("displayName") or user_id,
293+
full_name=user.get("name") or user_id,
294+
is_bot=False,
295+
avatar_url=user.get("avatarUrl"),
296+
email=user.get("email"),
297+
)
298+
261299
async def handle_webhook(
262300
self,
263301
request: Any,

0 commit comments

Comments
 (0)