66
77import logging
88import asyncio
9- from typing import Optional
9+ from typing import Dict , List , Optional
1010
1111from 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
1515from core .server import server
1616from core .utils import handle_http_errors
1717
1818logger = 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+ ])
68141async 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+ ])
155262async 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