Skip to content

Commit 576ecbf

Browse files
fix(adapters): whatsapp typing + slack/gchat 4.30 fixes (vercel/chat#320,523,553,573) (#141)
* feat(whatsapp): typing indicator support (vercel/chat#320) Port of upstream ffc43fc: - start_typing resolves the latest inbound message ID from the ThreadHistoryCache and posts a typing_indicator payload (which also marks the referenced message as read); no-ops with a warning when no inbound message context exists - Graph API default version bumped v21.0 -> v25.0 - _graph_api_request and the typing-indicator failure path raise AdapterError instead of RuntimeError/plain Error - new WhatsAppTypingIndicatorResponse TypedDict https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(gchat): collapse redundant autolink formatting for email links (vercel/chat#553) Port of upstream 177735a: when a link node's visible label equals its URL minus a mailto:/tel: scheme (e.g. an autolinked email address), from_ast emits the bare value as plain text instead of the verbose <url|text> form. Labels differing from the address keep <url|text>. The collapse sits between the linkText==url shortcut and this port's documented divergent branches (empty-label bare-URL emit and the text-(url) fallback for unsafe labels/URLs), matching upstream's check order; when it fires the output is plain text, so it never produces the malformed forms those divergences guard against. Our subset parser does not autolink bare emails/URLs (Known Limitations), so the ported collapse tests build the autolink-shaped node via the explicit markdown form, plus a Python-only round-trip test through the divergent <url|text> to_ast path. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(slack): resolve reaction user display names (vercel/chat#523) Port of upstream b63c042: reaction events now resolve event.user.user_name / full_name / is_bot from the cached _lookup_user() (users.info) path instead of echoing the raw Slack user ID, falling back to the user ID (and is_bot=False) when lookup fails. Also completes the dispatch-key test's mock client/state contract (users_info + async state methods), mirroring the upstream test update - a bare AsyncMock's auto-children made users.info results async and leaked an orphaned coroutine through the new lookup. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(slack): pass token through native stream stop (vercel/chat#573) Port of upstream 999d268: SlackAdapter.stream() now passes the resolved bot token on every streamer.append() (markdown deltas and structured chunks) and on streamer.stop(), instead of only on the first append. Fixes not_authed from chat.startStream/chat.stopStream when a stream reaches stop() before a token-bearing append has flushed (e.g. fully buffered markdown). Composes with the existing multi-workspace plumbing: token is resolved once at stream entry via _get_token() (request-context installation token -> per-request resolved default -> static cache), then threaded through all streamer calls; a Python-only regression test locks the request-context token flowing through append and stop. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(slack): emit uploadedFileIds (camelCase) on raw to match upstream chat@4.30.0 Our Python-only "file-ids" surface (Slack-confirmed upload IDs exposed on RawMessage.raw so consumers can gate on actual delivery) was adopted upstream in chat@4.30.0, which emits the synthetic key as camelCase `uploadedFileIds`. We were emitting snake_case `uploaded_file_ids`, leaving a divergence at the serialization boundary. Rename the emitted dict key to `uploadedFileIds` so consumers reading the raw payload match the upstream/TS surface. The local variable stays snake_case (`uploaded_file_ids`) per the "snake_case internal, camelCase at boundary" rule; only the key merged into `raw` changes. Behavior is otherwise identical (None -> raw unchanged; empty list preserved as the zero-attachments signal). Updates the three Python-only tests that asserted the old key name (test_slack_api file-upload tests and the test_thread_faithful raw- propagation test, which uses Slack's surface as its example payload). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aced743 commit 576ecbf

11 files changed

Lines changed: 518 additions & 47 deletions

src/chat_sdk/adapters/google_chat/format_converter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,17 @@ def _node_to_gchat(self, node: Content) -> str:
298298
url = node.get("url", "")
299299
if link_text == url:
300300
return url
301+
# Collapse redundant mailto:/tel: autolink formatting
302+
# (vercel/chat#553): when the visible label already equals the
303+
# URL minus its scheme (e.g. an autolinked email address), emit
304+
# the bare value as plain text instead of the verbose
305+
# `<url|text>` form.
306+
for scheme in ("mailto:", "tel:"):
307+
if not url.startswith(scheme):
308+
continue
309+
bare_value = url[len(scheme) :]
310+
if bare_value == link_text:
311+
return bare_value
301312
# An empty label can't round-trip in either `<url|text>` or
302313
# `text (url)` form (the parser regex requires ≥1 char in the
303314
# label, and "(url)" alone reads as a parenthetical). Emit the

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2642,15 +2642,25 @@ async def _resolve_and_process() -> None:
26422642

26432643
thread_id = self.encode_thread_id(SlackThreadId(channel=channel, thread_ts=parent_ts))
26442644

2645+
# Resolve display names from the cached users.info lookup so
2646+
# reaction handlers see real names instead of raw user IDs
2647+
# (vercel/chat#523). Falls back to the user ID on lookup failure.
2648+
user_info = await self._lookup_user(user_id)
2649+
display_name = user_info.get("display_name")
2650+
user_name = display_name if display_name is not None else user_id
2651+
real_name = user_info.get("real_name")
2652+
full_name = real_name if real_name is not None else user_name
2653+
is_bot = user_info.get("is_bot")
2654+
26452655
reaction_event = ReactionEvent(
26462656
emoji=normalized_emoji,
26472657
raw_emoji=raw_emoji,
26482658
added=event.get("type") == "reaction_added",
26492659
user=Author(
26502660
user_id=user_id,
2651-
user_name=user_id,
2652-
full_name=user_id,
2653-
is_bot=False,
2661+
user_name=user_name,
2662+
full_name=full_name,
2663+
is_bot=is_bot if is_bot is not None else False,
26542664
is_me=is_me,
26552665
),
26562666
message_id=message_id,
@@ -3561,10 +3571,12 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
35613571

35623572
# Check for files to upload. ``files_upload_v2`` returns the
35633573
# Slack-confirmed file IDs; we surface them on ``RawMessage.raw``
3564-
# so consumers can gate on actual delivery (parity with
3565-
# discord/telegram, which upload inline and expose the platform
3566-
# response naturally). ``None`` means no upload happened; an empty
3567-
# list means Slack confirmed zero attachments (a real signal).
3574+
# under the camelCase ``uploadedFileIds`` key (upstream
3575+
# chat@4.30.0 adopted this same surface) so consumers can
3576+
# gate on actual delivery (parity with discord/telegram, which
3577+
# upload inline and expose the platform response naturally).
3578+
# ``None`` means no upload happened; an empty list means Slack
3579+
# confirmed zero attachments (a real signal).
35683580
uploaded_file_ids: list[str] | None = None
35693581
files = extract_files(message)
35703582
if files:
@@ -3641,14 +3653,19 @@ def _augment_raw_with_uploads(raw: Any, uploaded_file_ids: list[str] | None) ->
36413653
36423654
Returns ``raw`` unchanged when no upload occurred (``uploaded_file_ids``
36433655
is ``None``). Otherwise returns a NEW dict that merges the existing raw
3644-
(Slack never returns an ``uploaded_file_ids`` key, so this is additive
3656+
(Slack never returns an ``uploadedFileIds`` key, so this is additive
36453657
and non-breaking) with the confirmed IDs. An empty list is preserved —
36463658
it signals that Slack confirmed zero attachments.
3659+
3660+
The key is emitted in camelCase (``uploadedFileIds``) to match the
3661+
surface upstream adopted in chat@4.30.0; ``uploaded_file_ids`` is the
3662+
internal (snake_case) variable, camelCase only at this serialization
3663+
boundary.
36473664
"""
36483665
if uploaded_file_ids is None:
36493666
return raw
36503667
base = raw if isinstance(raw, dict) else {}
3651-
return {**base, "uploaded_file_ids": uploaded_file_ids}
3668+
return {**base, "uploadedFileIds": uploaded_file_ids}
36523669

36533670
async def edit_message(
36543671
self,
@@ -3859,7 +3876,6 @@ async def stream(
38593876

38603877
streamer = await client.chat_stream(**stream_kwargs)
38613878

3862-
first = True
38633879
last_appended = ""
38643880

38653881
# Use StreamingMarkdownRenderer for safe incremental rendering
@@ -3868,18 +3884,20 @@ async def stream(
38683884
renderer = StreamingMarkdownRenderer(wrap_tables_for_append=False)
38693885
structured_chunks_supported = True
38703886

3887+
# The resolved bot token is passed on EVERY append and on stop
3888+
# (vercel/chat#573). Passing it only on the first append left
3889+
# chat.startStream/chat.stopStream unauthenticated ("not_authed")
3890+
# whenever the stream reached stop() before a token-bearing append
3891+
# had flushed (e.g. fully buffered markdown). In multi-workspace
3892+
# mode `token` is the per-request installation token resolved by
3893+
# _get_token() at stream entry.
38713894
async def flush_markdown_delta(delta: str) -> None:
3872-
nonlocal first
38733895
if not delta:
38743896
return
3875-
if first:
3876-
await streamer.append(markdown_text=delta, token=token)
3877-
first = False
3878-
else:
3879-
await streamer.append(markdown_text=delta)
3897+
await streamer.append(markdown_text=delta, token=token)
38803898

38813899
async def send_structured_chunk(chunk: StreamChunk | dict[str, Any]) -> None:
3882-
nonlocal first, last_appended, structured_chunks_supported
3900+
nonlocal last_appended, structured_chunks_supported
38833901
if not structured_chunks_supported:
38843902
return
38853903
committable = renderer.get_committable_text()
@@ -3907,11 +3925,7 @@ def _read(name: str) -> Any:
39073925
if value is not None:
39083926
chunk_data[field_name] = value
39093927

3910-
if first:
3911-
await streamer.append(chunks=[chunk_data], token=token)
3912-
first = False
3913-
else:
3914-
await streamer.append(chunks=[chunk_data])
3928+
await streamer.append(chunks=[chunk_data], token=token)
39153929
except Exception as exc:
39163930
structured_chunks_supported = False
39173931
self._logger.warn(
@@ -3951,10 +3965,10 @@ async def push_text_and_flush(text: str) -> None:
39513965
final_delta = final_committable[len(last_appended) :]
39523966
await flush_markdown_delta(final_delta)
39533967

3954-
stop_kwargs: dict[str, Any] = {}
3968+
stop_kwargs: dict[str, Any] = {"token": token}
39553969
if options.stop_blocks:
39563970
stop_kwargs["blocks"] = options.stop_blocks
3957-
result = await streamer.stop(**stop_kwargs) if stop_kwargs else await streamer.stop()
3971+
result = await streamer.stop(**stop_kwargs)
39583972

39593973
message_ts = ""
39603974
if isinstance(result, dict):

src/chat_sdk/adapters/whatsapp/adapter.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@
3939
from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_unicode, get_emoji
4040
from chat_sdk.logger import ConsoleLogger, Logger
4141
from chat_sdk.shared.adapter_utils import extract_card
42-
from chat_sdk.shared.errors import ValidationError
42+
from chat_sdk.shared.errors import AdapterError, ValidationError
43+
from chat_sdk.thread_history import ThreadHistoryCache
4344
from chat_sdk.types import (
4445
ActionEvent,
4546
AdapterPostableMessage,
@@ -64,7 +65,7 @@
6465
)
6566

6667
# Default Graph API version
67-
DEFAULT_API_VERSION = "v21.0"
68+
DEFAULT_API_VERSION = "v25.0"
6869

6970
# Maximum message length for WhatsApp Cloud API
7071
WHATSAPP_MESSAGE_LIMIT = 4096
@@ -944,8 +945,48 @@ async def remove_reaction(
944945
)
945946

946947
async def start_typing(self, thread_id: str, status: str | None = None) -> None:
947-
"""Start typing indicator. Not supported by WhatsApp Cloud API."""
948-
pass
948+
"""Start typing indicator.
949+
950+
WhatsApp typing indicators require the most recent inbound message ID.
951+
They also implicitly mark the referenced message as read.
952+
953+
See: https://developers.facebook.com/documentation/business-messaging/whatsapp/typing-indicators
954+
"""
955+
message_id = await self._resolve_typing_target_message_id(thread_id)
956+
self._logger.debug(
957+
"WhatsApp typing indicator requested",
958+
{"messageId": message_id, "threadId": thread_id},
959+
)
960+
961+
if not message_id:
962+
self._logger.warn(
963+
"WhatsApp typing indicator skipped - no inbound message context",
964+
{"threadId": thread_id},
965+
)
966+
return
967+
968+
if status:
969+
self._logger.warn(
970+
"WhatsApp typing indicator ignores custom status text",
971+
{"status": status, "threadId": thread_id, "messageId": message_id},
972+
)
973+
974+
response = await self._graph_api_request(
975+
f"/{self._phone_number_id}/messages",
976+
{
977+
"messaging_product": "whatsapp",
978+
"status": "read",
979+
"message_id": message_id,
980+
"typing_indicator": {"type": "text"},
981+
},
982+
)
983+
984+
if not response.get("success"):
985+
self._logger.error(
986+
"WhatsApp typing indicator failed: API returned success=false",
987+
{"messageId": message_id, "threadId": thread_id},
988+
)
989+
raise AdapterError("WhatsApp typing indicator failed", "whatsapp")
949990

950991
async def fetch_messages(
951992
self,
@@ -1064,6 +1105,20 @@ async def mark_as_read(self, message_id: str) -> None:
10641105
# Private helpers
10651106
# =========================================================================
10661107

1108+
async def _resolve_typing_target_message_id(self, thread_id: str) -> str | None:
1109+
"""Resolve the latest inbound message ID for a thread."""
1110+
if not self._chat:
1111+
return None
1112+
1113+
state = self._chat.get_state()
1114+
history = await ThreadHistoryCache(state).get_messages(thread_id)
1115+
1116+
for message in reversed(history):
1117+
if not message.author.is_me:
1118+
return message.id
1119+
1120+
return None
1121+
10671122
async def _graph_api_request(self, path: str, body: Any) -> Any:
10681123
"""Make a request to the Meta Graph API."""
10691124
session = await self._get_http_session()
@@ -1085,7 +1140,10 @@ async def _graph_api_request(self, path: str, body: Any) -> Any:
10851140
"path": path,
10861141
},
10871142
)
1088-
raise RuntimeError(f"WhatsApp API error: {response.status} {error_body}")
1143+
raise AdapterError(
1144+
f"WhatsApp API error: {response.status} {error_body}",
1145+
"whatsapp",
1146+
)
10891147

10901148
return await response.json()
10911149

src/chat_sdk/adapters/whatsapp/types.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class WhatsAppAdapterConfig:
3838
user_name: str
3939
# Verify token for webhook challenge-response verification
4040
verify_token: str
41-
# Meta Graph API version (default: "v21.0")
41+
# Meta Graph API version (default: "v25.0")
4242
api_version: str | None = None
4343

4444

@@ -209,6 +209,12 @@ class WhatsAppSendResponse(TypedDict):
209209
messaging_product: str # "whatsapp"
210210

211211

212+
class WhatsAppTypingIndicatorResponse(TypedDict):
213+
"""Response from sending a typing indicator via the Cloud API."""
214+
215+
success: bool
216+
217+
212218
class WhatsAppInteractiveButtonReply(TypedDict):
213219
"""A single reply button for interactive messages."""
214220

tests/test_dispatch_key_validation.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,14 @@ def _make_mock_chat() -> MagicMock:
9999
mock.process_assistant_context_changed = MagicMock()
100100
mock.process_app_home_opened = MagicMock()
101101
mock.process_member_joined_channel = MagicMock()
102-
# get_state needed by some adapters
102+
# get_state needed by some adapters. All StateAdapter methods are
103+
# async — configure them explicitly so adapter code paths that cache
104+
# lookups (e.g. Slack `_lookup_user`) don't await MagicMock results.
103105
mock_state = MagicMock()
104106
mock_state.get = AsyncMock(return_value=None)
107+
mock_state.set = AsyncMock()
108+
mock_state.get_list = AsyncMock(return_value=[])
109+
mock_state.append_to_list = AsyncMock()
105110
mock.get_state = MagicMock(return_value=mock_state)
106111
mock.get_logger = MagicMock(return_value=MagicMock())
107112
mock.get_user_name = MagicMock(return_value="bot")
@@ -175,8 +180,11 @@ async def test_slack_reaction_dispatch_keys(self) -> None:
175180
}
176181

177182
# The Slack reaction handler is async (it launches a task to resolve
178-
# the parent thread_ts). We need to mock the Slack client so the
179-
# async resolution succeeds.
183+
# the parent thread_ts and the reacting user's display name). We
184+
# need to mock the Slack client so the async resolution succeeds.
185+
# The users.info mock mirrors the upstream test update for
186+
# vercel/chat#523 — auto-children of a bare AsyncMock are async, so
187+
# `result.get(...)` would otherwise return an orphaned coroutine.
180188
mock_client = AsyncMock()
181189
mock_client.conversations_replies = AsyncMock(
182190
return_value={
@@ -185,6 +193,12 @@ async def test_slack_reaction_dispatch_keys(self) -> None:
185193
],
186194
}
187195
)
196+
mock_client.users_info = AsyncMock(
197+
return_value={
198+
"ok": True,
199+
"user": {"name": "user", "profile": {"display_name": "User"}},
200+
}
201+
)
188202
adapter._get_client = MagicMock(return_value=mock_client)
189203

190204
adapter._handle_reaction_event(event)

tests/test_gchat_format_extended.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,78 @@ def test_link_different_text_and_url(self):
325325
result = converter.from_ast(ast)
326326
assert "<https://example.com|click here>" in result
327327

328+
def test_collapses_mailto_autolink_for_plain_email_text(self):
329+
"""Port of upstream "collapses mailto autolink for plain email text"
330+
(vercel/chat#553). Upstream's remark parser autolinks the bare input
331+
"hello@example.com" into a `mailto:` link node; our subset parser
332+
keeps bare emails as plain text (Known Limitations), so the same
333+
autolink-shaped node is built through the explicit markdown form to
334+
exercise the collapse."""
335+
converter = _converter()
336+
ast = converter.to_ast("[hello@example.com](mailto:hello@example.com)")
337+
338+
result = converter.from_ast(ast)
339+
340+
assert result == "hello@example.com"
341+
342+
def test_collapses_mailto_autolink_on_gchat_wire_round_trip(self):
343+
"""Python-only composition with the documented `<url|text>` to_ast
344+
divergence: a redundant `<mailto:addr|addr>` read back from the
345+
Google Chat wire parses to a link node (upstream leaves it as raw
346+
text) and must now collapse to the bare address on re-emit instead
347+
of round-tripping the verbose form."""
348+
converter = _converter()
349+
ast = converter.to_ast("<mailto:hello@example.com|hello@example.com>")
350+
351+
result = converter.from_ast(ast)
352+
353+
assert result == "hello@example.com"
354+
355+
def test_preserves_custom_label_for_mailto_links(self):
356+
"""Port of upstream "preserves custom label for mailto links"
357+
(vercel/chat#553): a label that differs from the address keeps the
358+
`<url|text>` form."""
359+
converter = _converter()
360+
ast = converter.to_ast("[contact](mailto:hello@example.com)")
361+
362+
result = converter.from_ast(ast)
363+
364+
assert result == "<mailto:hello@example.com|contact>"
365+
366+
def test_formats_http_links_correctly(self):
367+
"""Port of upstream "formats http links correctly" (vercel/chat#553).
368+
Upstream autolinks the bare URL to a link node whose text equals its
369+
URL and re-emits it bare; our parser keeps bare URLs as plain text —
370+
the output contract is identical either way."""
371+
converter = _converter()
372+
ast = converter.to_ast("https://example.com")
373+
374+
result = converter.from_ast(ast)
375+
376+
assert result == "https://example.com"
377+
378+
def test_keeps_phone_numbers_as_plain_text(self):
379+
"""Port of upstream "keeps phone numbers as plain text"
380+
(vercel/chat#553): plain phone numbers are not autolinked by either
381+
parser and must survive unchanged."""
382+
converter = _converter()
383+
ast = converter.to_ast("+1555123456")
384+
385+
result = converter.from_ast(ast)
386+
387+
assert result == "+1555123456"
388+
389+
def test_collapses_tel_autolink_for_plain_phone_text(self):
390+
"""Port of upstream "collapses tel autolink for plain phone text"
391+
(vercel/chat#553): a tel: link whose label equals the number
392+
collapses to the bare number."""
393+
converter = _converter()
394+
ast = converter.to_ast("[+1555123456](tel:+1555123456)")
395+
396+
result = converter.from_ast(ast)
397+
398+
assert result == "+1555123456"
399+
328400
def test_should_preserve_custom_link_labels_in_posted_messages(self):
329401
# Matches the integration-tests parity case: a posted markdown link
330402
# with a custom label must render as Google Chat's <url|text> syntax

0 commit comments

Comments
 (0)