Skip to content

Commit be2f68f

Browse files
committed
feat(chat): add People API sender resolution, rich links, threads, and reactions
Resolves several gaps in the Google Chat tools: **Sender resolution via People API:** - Chat API doesn't return displayName for service account access - New `_resolve_sender()` uses People API to look up user IDs - In-memory cache avoids repeated lookups - get_messages and search_messages now use `require_multiple_services` to inject both chat and people services **Rich link (smart chip) extraction:** - When users paste Workspace URLs that render as smart chips, the URL is stripped from the `text` field and only available in annotations - New `_extract_rich_links()` extracts RICH_LINK annotation URIs - Surfaced as `[linked: <url>]` in get_messages and search_messages **Thread support:** - get_messages shows `[thread: ...]` for threaded replies - send_message gains `thread_name` param for replying in existing threads using `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` - Fixes existing `thread_key` param which was missing messageReplyOption **Emoji reactions:** - get_messages shows `[reactions: 👍x3, 🎉x1]` from emojiReactionSummaries - New `create_reaction` tool to add emoji reactions to messages
1 parent 755aa55 commit be2f68f

1 file changed

Lines changed: 162 additions & 17 deletions

File tree

gchat/chat_tools.py

Lines changed: 162 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,87 @@
66

77
import logging
88
import asyncio
9-
from typing import Optional
9+
from typing import Dict, List, Optional
1010

1111
from googleapiclient.errors import HttpError
1212

1313
# Auth & server utilities
14-
from auth.service_decorator import require_google_service
14+
from auth.service_decorator import require_google_service, require_multiple_services
1515
from core.server import server
1616
from core.utils import handle_http_errors
1717

1818
logger = logging.getLogger(__name__)
1919

20+
# In-memory cache for user ID → display name (persists for the process lifetime)
21+
_sender_name_cache: Dict[str, str] = {}
22+
23+
24+
async def _resolve_sender(people_service, sender_obj: dict) -> str:
25+
"""Resolve a Chat message sender to a display name.
26+
27+
Fast path: use displayName if the API already provided it.
28+
Slow path: look up the user via the People API directory and cache the result.
29+
"""
30+
# Fast path — Chat API sometimes provides displayName directly
31+
display_name = sender_obj.get("displayName")
32+
if display_name:
33+
return display_name
34+
35+
user_id = sender_obj.get("name", "") # e.g. "users/123456789"
36+
if not user_id:
37+
return "Unknown Sender"
38+
39+
# Check cache
40+
if user_id in _sender_name_cache:
41+
return _sender_name_cache[user_id]
42+
43+
# Try People API directory lookup
44+
# Chat API uses "users/ID" but People API expects "people/ID"
45+
people_resource = user_id.replace("users/", "people/", 1)
46+
if people_service:
47+
try:
48+
person = await asyncio.to_thread(
49+
people_service.people()
50+
.get(resourceName=people_resource, personFields="names,emailAddresses")
51+
.execute
52+
)
53+
names = person.get("names", [])
54+
if names:
55+
resolved = names[0].get("displayName", user_id)
56+
_sender_name_cache[user_id] = resolved
57+
return resolved
58+
# Fall back to email if no name
59+
emails = person.get("emailAddresses", [])
60+
if emails:
61+
resolved = emails[0].get("value", user_id)
62+
_sender_name_cache[user_id] = resolved
63+
return resolved
64+
except HttpError as e:
65+
logger.debug(f"People API lookup failed for {user_id}: {e}")
66+
except Exception as e:
67+
logger.debug(f"Unexpected error resolving {user_id}: {e}")
68+
69+
# Final fallback
70+
_sender_name_cache[user_id] = user_id
71+
return user_id
72+
73+
74+
def _extract_rich_links(msg: dict) -> List[str]:
75+
"""Extract URLs from RICH_LINK annotations (smart chips).
76+
77+
When a user pastes a Google Workspace URL in Chat and it renders as a
78+
smart chip, the URL is NOT in the text field — it's only available in
79+
the annotations array as a RICH_LINK with richLinkMetadata.uri.
80+
"""
81+
text = msg.get("text", "")
82+
urls = []
83+
for ann in msg.get("annotations", []):
84+
if ann.get("type") == "RICH_LINK":
85+
uri = ann.get("richLinkMetadata", {}).get("uri", "")
86+
if uri and uri not in text:
87+
urls.append(uri)
88+
return urls
89+
2090

2191
@server.tool()
2292
@require_google_service("chat", "chat_read")
@@ -63,10 +133,14 @@ async def list_spaces(
63133

64134

65135
@server.tool()
66-
@require_google_service("chat", "chat_read")
67136
@handle_http_errors("get_messages", service_type="chat")
137+
@require_multiple_services([
138+
{"service_type": "chat", "scopes": "chat_read", "param_name": "chat_service"},
139+
{"service_type": "people", "scopes": "contacts_read", "param_name": "people_service"},
140+
])
68141
async def get_messages(
69-
service,
142+
chat_service,
143+
people_service,
70144
user_google_email: str,
71145
space_id: str,
72146
page_size: int = 50,
@@ -81,12 +155,12 @@ async def get_messages(
81155
logger.info(f"[get_messages] Space ID: '{space_id}' for user '{user_google_email}'")
82156

83157
# Get space info first
84-
space_info = await asyncio.to_thread(service.spaces().get(name=space_id).execute)
158+
space_info = await asyncio.to_thread(chat_service.spaces().get(name=space_id).execute)
85159
space_name = space_info.get("displayName", "Unknown Space")
86160

87161
# Get messages
88162
response = await asyncio.to_thread(
89-
service.spaces()
163+
chat_service.spaces()
90164
.messages()
91165
.list(parent=space_id, pageSize=page_size, orderBy=order_by)
92166
.execute
@@ -98,13 +172,33 @@ async def get_messages(
98172

99173
output = [f"Messages from '{space_name}' (ID: {space_id}):\n"]
100174
for msg in messages:
101-
sender = msg.get("sender", {}).get("displayName", "Unknown Sender")
175+
sender = await _resolve_sender(people_service, msg.get("sender", {}))
102176
create_time = msg.get("createTime", "Unknown Time")
103177
text_content = msg.get("text", "No text content")
104178
msg_name = msg.get("name", "")
105179

106180
output.append(f"[{create_time}] {sender}:")
107181
output.append(f" {text_content}")
182+
rich_links = _extract_rich_links(msg)
183+
for url in rich_links:
184+
output.append(f" [linked: {url}]")
185+
# Show thread info if this is a threaded reply
186+
thread = msg.get("thread", {})
187+
if msg.get("threadReply") and thread.get("name"):
188+
output.append(f" [thread: {thread['name']}]")
189+
# Show emoji reactions
190+
reactions = msg.get("emojiReactionSummaries", [])
191+
if reactions:
192+
parts = []
193+
for r in reactions:
194+
emoji = r.get("emoji", {})
195+
symbol = emoji.get("unicode", "")
196+
if not symbol:
197+
ce = emoji.get("customEmoji", {})
198+
symbol = f":{ce.get('uid', '?')}:"
199+
count = r.get("reactionCount", 0)
200+
parts.append(f"{symbol}x{count}")
201+
output.append(f" [reactions: {', '.join(parts)}]")
108202
output.append(f" (Message ID: {msg_name})\n")
109203

110204
return "\n".join(output)
@@ -119,21 +213,31 @@ async def send_message(
119213
space_id: str,
120214
message_text: str,
121215
thread_key: Optional[str] = None,
216+
thread_name: Optional[str] = None,
122217
) -> str:
123218
"""
124219
Sends a message to a Google Chat space.
125220
221+
Args:
222+
thread_name: Reply in an existing thread by its resource name (e.g. spaces/X/threads/Y).
223+
thread_key: Reply in a thread by app-defined key (creates thread if not found).
224+
126225
Returns:
127226
str: Confirmation message with sent message details.
128227
"""
129228
logger.info(f"[send_message] Email: '{user_google_email}', Space: '{space_id}'")
130229

131230
message_body = {"text": message_text}
132231

133-
# Add thread key if provided (for threaded replies)
134232
request_params = {"parent": space_id, "body": message_body}
135-
if thread_key:
136-
request_params["threadKey"] = thread_key
233+
234+
# Thread reply support
235+
if thread_name:
236+
message_body["thread"] = {"name": thread_name}
237+
request_params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
238+
elif thread_key:
239+
message_body["thread"] = {"threadKey": thread_key}
240+
request_params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
137241

138242
message = await asyncio.to_thread(
139243
service.spaces().messages().create(**request_params).execute
@@ -150,10 +254,14 @@ async def send_message(
150254

151255

152256
@server.tool()
153-
@require_google_service("chat", "chat_read")
154257
@handle_http_errors("search_messages", service_type="chat")
258+
@require_multiple_services([
259+
{"service_type": "chat", "scopes": "chat_read", "param_name": "chat_service"},
260+
{"service_type": "people", "scopes": "contacts_read", "param_name": "people_service"},
261+
])
155262
async def search_messages(
156-
service,
263+
chat_service,
264+
people_service,
157265
user_google_email: str,
158266
query: str,
159267
space_id: Optional[str] = None,
@@ -170,7 +278,7 @@ async def search_messages(
170278
# If specific space provided, search within that space
171279
if space_id:
172280
response = await asyncio.to_thread(
173-
service.spaces()
281+
chat_service.spaces()
174282
.messages()
175283
.list(parent=space_id, pageSize=page_size, filter=f'text:"{query}"')
176284
.execute
@@ -181,15 +289,15 @@ async def search_messages(
181289
# Search across all accessible spaces (this may require iterating through spaces)
182290
# For simplicity, we'll search the user's spaces first
183291
spaces_response = await asyncio.to_thread(
184-
service.spaces().list(pageSize=100).execute
292+
chat_service.spaces().list(pageSize=100).execute
185293
)
186294
spaces = spaces_response.get("spaces", [])
187295

188296
messages = []
189297
for space in spaces[:10]: # Limit to first 10 spaces to avoid timeout
190298
try:
191299
space_messages = await asyncio.to_thread(
192-
service.spaces()
300+
chat_service.spaces()
193301
.messages()
194302
.list(
195303
parent=space.get("name"), pageSize=5, filter=f'text:"{query}"'
@@ -209,7 +317,7 @@ async def search_messages(
209317

210318
output = [f"Found {len(messages)} messages matching '{query}' in {context}:"]
211319
for msg in messages:
212-
sender = msg.get("sender", {}).get("displayName", "Unknown Sender")
320+
sender = await _resolve_sender(people_service, msg.get("sender", {}))
213321
create_time = msg.get("createTime", "Unknown Time")
214322
text_content = msg.get("text", "No text content")
215323
space_name = msg.get("_space_name", "Unknown Space")
@@ -218,6 +326,43 @@ async def search_messages(
218326
if len(text_content) > 100:
219327
text_content = text_content[:100] + "..."
220328

221-
output.append(f"- [{create_time}] {sender} in '{space_name}': {text_content}")
329+
rich_links = _extract_rich_links(msg)
330+
links_suffix = "".join(f" [linked: {url}]" for url in rich_links)
331+
output.append(f"- [{create_time}] {sender} in '{space_name}': {text_content}{links_suffix}")
222332

223333
return "\n".join(output)
334+
335+
336+
@server.tool()
337+
@require_google_service("chat", "chat_write")
338+
@handle_http_errors("create_reaction", service_type="chat")
339+
async def create_reaction(
340+
service,
341+
user_google_email: str,
342+
message_id: str,
343+
emoji_unicode: str,
344+
) -> str:
345+
"""
346+
Adds an emoji reaction to a Google Chat message.
347+
348+
Args:
349+
message_id: The message resource name (e.g. spaces/X/messages/Y).
350+
emoji_unicode: The emoji character to react with (e.g. 👍).
351+
352+
Returns:
353+
str: Confirmation message.
354+
"""
355+
logger.info(f"[create_reaction] Message: '{message_id}', Emoji: '{emoji_unicode}'")
356+
357+
reaction = await asyncio.to_thread(
358+
service.spaces()
359+
.messages()
360+
.reactions()
361+
.create(
362+
parent=message_id,
363+
body={"emoji": {"unicode": emoji_unicode}},
364+
)
365+
.execute
366+
)
367+
368+
return f"Reacted with {emoji_unicode} on message {message_id}."

0 commit comments

Comments
 (0)