Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 11 additions & 0 deletions src/chat_sdk/adapters/google_chat/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,17 @@ def _node_to_gchat(self, node: Content) -> str:
url = node.get("url", "")
if link_text == url:
return url
# Collapse redundant mailto:/tel: autolink formatting
# (vercel/chat#553): when the visible label already equals the
# URL minus its scheme (e.g. an autolinked email address), emit
# the bare value as plain text instead of the verbose
# `<url|text>` form.
for scheme in ("mailto:", "tel:"):
if not url.startswith(scheme):
continue
bare_value = url[len(scheme) :]
if bare_value == link_text:
return bare_value
# An empty label can't round-trip in either `<url|text>` or
# `text (url)` form (the parser regex requires ≥1 char in the
# label, and "(url)" alone reads as a parenthetical). Emit the
Expand Down
62 changes: 38 additions & 24 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2642,15 +2642,25 @@ async def _resolve_and_process() -> None:

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

# Resolve display names from the cached users.info lookup so
# reaction handlers see real names instead of raw user IDs
# (vercel/chat#523). Falls back to the user ID on lookup failure.
user_info = await self._lookup_user(user_id)
display_name = user_info.get("display_name")
user_name = display_name if display_name is not None else user_id
real_name = user_info.get("real_name")
full_name = real_name if real_name is not None else user_name
is_bot = user_info.get("is_bot")

reaction_event = ReactionEvent(
emoji=normalized_emoji,
raw_emoji=raw_emoji,
added=event.get("type") == "reaction_added",
user=Author(
user_id=user_id,
user_name=user_id,
full_name=user_id,
is_bot=False,
user_name=user_name,
full_name=full_name,
is_bot=is_bot if is_bot is not None else False,
is_me=is_me,
),
message_id=message_id,
Expand Down Expand Up @@ -3561,10 +3571,12 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->

# Check for files to upload. ``files_upload_v2`` returns the
# Slack-confirmed file IDs; we surface them on ``RawMessage.raw``
# so consumers can gate on actual delivery (parity with
# discord/telegram, which upload inline and expose the platform
# response naturally). ``None`` means no upload happened; an empty
# list means Slack confirmed zero attachments (a real signal).
# under the camelCase ``uploadedFileIds`` key (upstream
# chat@4.30.0 adopted this same surface) so consumers can
# gate on actual delivery (parity with discord/telegram, which
# upload inline and expose the platform response naturally).
# ``None`` means no upload happened; an empty list means Slack
# confirmed zero attachments (a real signal).
uploaded_file_ids: list[str] | None = None
files = extract_files(message)
if files:
Expand Down Expand Up @@ -3641,14 +3653,19 @@ def _augment_raw_with_uploads(raw: Any, uploaded_file_ids: list[str] | None) ->

Returns ``raw`` unchanged when no upload occurred (``uploaded_file_ids``
is ``None``). Otherwise returns a NEW dict that merges the existing raw
(Slack never returns an ``uploaded_file_ids`` key, so this is additive
(Slack never returns an ``uploadedFileIds`` key, so this is additive
and non-breaking) with the confirmed IDs. An empty list is preserved —
it signals that Slack confirmed zero attachments.

The key is emitted in camelCase (``uploadedFileIds``) to match the
surface upstream adopted in chat@4.30.0; ``uploaded_file_ids`` is the
internal (snake_case) variable, camelCase only at this serialization
boundary.
"""
if uploaded_file_ids is None:
return raw
base = raw if isinstance(raw, dict) else {}
return {**base, "uploaded_file_ids": uploaded_file_ids}
return {**base, "uploadedFileIds": uploaded_file_ids}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve uploaded_file_ids for file uploads

For Slack posts that include files, this now returns only uploadedFileIds, removing the previously documented raw["uploaded_file_ids"] surface used for delivery-confirmation gating. Since this commit does not include a breaking-version migration, existing Python consumers that read the snake_case key will treat successful uploads as missing confirmations; add the camelCase key as an alias rather than replacing the old one.

Useful? React with 👍 / 👎.


async def edit_message(
self,
Expand Down Expand Up @@ -3859,7 +3876,6 @@ async def stream(

streamer = await client.chat_stream(**stream_kwargs)

first = True
last_appended = ""

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

# The resolved bot token is passed on EVERY append and on stop
# (vercel/chat#573). Passing it only on the first append left
# chat.startStream/chat.stopStream unauthenticated ("not_authed")
# whenever the stream reached stop() before a token-bearing append
# had flushed (e.g. fully buffered markdown). In multi-workspace
# mode `token` is the per-request installation token resolved by
# _get_token() at stream entry.
async def flush_markdown_delta(delta: str) -> None:
nonlocal first
if not delta:
return
if first:
await streamer.append(markdown_text=delta, token=token)
first = False
else:
await streamer.append(markdown_text=delta)
await streamer.append(markdown_text=delta, token=token)

async def send_structured_chunk(chunk: StreamChunk | dict[str, Any]) -> None:
nonlocal first, last_appended, structured_chunks_supported
nonlocal last_appended, structured_chunks_supported
if not structured_chunks_supported:
return
committable = renderer.get_committable_text()
Expand Down Expand Up @@ -3907,11 +3925,7 @@ def _read(name: str) -> Any:
if value is not None:
chunk_data[field_name] = value

if first:
await streamer.append(chunks=[chunk_data], token=token)
first = False
else:
await streamer.append(chunks=[chunk_data])
await streamer.append(chunks=[chunk_data], token=token)
except Exception as exc:
structured_chunks_supported = False
self._logger.warn(
Expand Down Expand Up @@ -3951,10 +3965,10 @@ async def push_text_and_flush(text: str) -> None:
final_delta = final_committable[len(last_appended) :]
await flush_markdown_delta(final_delta)

stop_kwargs: dict[str, Any] = {}
stop_kwargs: dict[str, Any] = {"token": token}
if options.stop_blocks:
stop_kwargs["blocks"] = options.stop_blocks
result = await streamer.stop(**stop_kwargs) if stop_kwargs else await streamer.stop()
result = await streamer.stop(**stop_kwargs)

message_ts = ""
if isinstance(result, dict):
Expand Down
68 changes: 63 additions & 5 deletions src/chat_sdk/adapters/whatsapp/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
from chat_sdk.emoji import convert_emoji_placeholders, emoji_to_unicode, get_emoji
from chat_sdk.logger import ConsoleLogger, Logger
from chat_sdk.shared.adapter_utils import extract_card
from chat_sdk.shared.errors import ValidationError
from chat_sdk.shared.errors import AdapterError, ValidationError
from chat_sdk.thread_history import ThreadHistoryCache
from chat_sdk.types import (
ActionEvent,
AdapterPostableMessage,
Expand All @@ -64,7 +65,7 @@
)

# Default Graph API version
DEFAULT_API_VERSION = "v21.0"
DEFAULT_API_VERSION = "v25.0"

# Maximum message length for WhatsApp Cloud API
WHATSAPP_MESSAGE_LIMIT = 4096
Expand Down Expand Up @@ -944,8 +945,48 @@ async def remove_reaction(
)

async def start_typing(self, thread_id: str, status: str | None = None) -> None:
"""Start typing indicator. Not supported by WhatsApp Cloud API."""
pass
"""Start typing indicator.

WhatsApp typing indicators require the most recent inbound message ID.
They also implicitly mark the referenced message as read.

See: https://developers.facebook.com/documentation/business-messaging/whatsapp/typing-indicators
"""
message_id = await self._resolve_typing_target_message_id(thread_id)
self._logger.debug(
"WhatsApp typing indicator requested",
{"messageId": message_id, "threadId": thread_id},
)

if not message_id:
self._logger.warn(
"WhatsApp typing indicator skipped - no inbound message context",
{"threadId": thread_id},
)
return

if status:
self._logger.warn(
"WhatsApp typing indicator ignores custom status text",
{"status": status, "threadId": thread_id, "messageId": message_id},
)

response = await self._graph_api_request(
f"/{self._phone_number_id}/messages",
{
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
"typing_indicator": {"type": "text"},
},
)

if not response.get("success"):
self._logger.error(
"WhatsApp typing indicator failed: API returned success=false",
{"messageId": message_id, "threadId": thread_id},
)
raise AdapterError("WhatsApp typing indicator failed", "whatsapp")

async def fetch_messages(
self,
Expand Down Expand Up @@ -1064,6 +1105,20 @@ async def mark_as_read(self, message_id: str) -> None:
# Private helpers
# =========================================================================

async def _resolve_typing_target_message_id(self, thread_id: str) -> str | None:
"""Resolve the latest inbound message ID for a thread."""
if not self._chat:
return None

state = self._chat.get_state()
history = await ThreadHistoryCache(state).get_messages(thread_id)

for message in reversed(history):
if not message.author.is_me:
return message.id

return None

async def _graph_api_request(self, path: str, body: Any) -> Any:
"""Make a request to the Meta Graph API."""
session = await self._get_http_session()
Expand All @@ -1085,7 +1140,10 @@ async def _graph_api_request(self, path: str, body: Any) -> Any:
"path": path,
},
)
raise RuntimeError(f"WhatsApp API error: {response.status} {error_body}")
raise AdapterError(
f"WhatsApp API error: {response.status} {error_body}",
"whatsapp",
)

return await response.json()

Expand Down
8 changes: 7 additions & 1 deletion src/chat_sdk/adapters/whatsapp/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class WhatsAppAdapterConfig:
user_name: str
# Verify token for webhook challenge-response verification
verify_token: str
# Meta Graph API version (default: "v21.0")
# Meta Graph API version (default: "v25.0")
api_version: str | None = None


Expand Down Expand Up @@ -209,6 +209,12 @@ class WhatsAppSendResponse(TypedDict):
messaging_product: str # "whatsapp"


class WhatsAppTypingIndicatorResponse(TypedDict):
"""Response from sending a typing indicator via the Cloud API."""

success: bool


class WhatsAppInteractiveButtonReply(TypedDict):
"""A single reply button for interactive messages."""

Expand Down
20 changes: 17 additions & 3 deletions tests/test_dispatch_key_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,14 @@ def _make_mock_chat() -> MagicMock:
mock.process_assistant_context_changed = MagicMock()
mock.process_app_home_opened = MagicMock()
mock.process_member_joined_channel = MagicMock()
# get_state needed by some adapters
# get_state needed by some adapters. All StateAdapter methods are
# async — configure them explicitly so adapter code paths that cache
# lookups (e.g. Slack `_lookup_user`) don't await MagicMock results.
mock_state = MagicMock()
mock_state.get = AsyncMock(return_value=None)
mock_state.set = AsyncMock()
mock_state.get_list = AsyncMock(return_value=[])
mock_state.append_to_list = AsyncMock()
mock.get_state = MagicMock(return_value=mock_state)
mock.get_logger = MagicMock(return_value=MagicMock())
mock.get_user_name = MagicMock(return_value="bot")
Expand Down Expand Up @@ -175,8 +180,11 @@ async def test_slack_reaction_dispatch_keys(self) -> None:
}

# The Slack reaction handler is async (it launches a task to resolve
# the parent thread_ts). We need to mock the Slack client so the
# async resolution succeeds.
# the parent thread_ts and the reacting user's display name). We
# need to mock the Slack client so the async resolution succeeds.
# The users.info mock mirrors the upstream test update for
# vercel/chat#523 — auto-children of a bare AsyncMock are async, so
# `result.get(...)` would otherwise return an orphaned coroutine.
mock_client = AsyncMock()
mock_client.conversations_replies = AsyncMock(
return_value={
Expand All @@ -185,6 +193,12 @@ async def test_slack_reaction_dispatch_keys(self) -> None:
],
}
)
mock_client.users_info = AsyncMock(
return_value={
"ok": True,
"user": {"name": "user", "profile": {"display_name": "User"}},
}
)
adapter._get_client = MagicMock(return_value=mock_client)

adapter._handle_reaction_event(event)
Expand Down
72 changes: 72 additions & 0 deletions tests/test_gchat_format_extended.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,78 @@ def test_link_different_text_and_url(self):
result = converter.from_ast(ast)
assert "<https://example.com|click here>" in result

def test_collapses_mailto_autolink_for_plain_email_text(self):
"""Port of upstream "collapses mailto autolink for plain email text"
(vercel/chat#553). Upstream's remark parser autolinks the bare input
"hello@example.com" into a `mailto:` link node; our subset parser
keeps bare emails as plain text (Known Limitations), so the same
autolink-shaped node is built through the explicit markdown form to
exercise the collapse."""
converter = _converter()
ast = converter.to_ast("[hello@example.com](mailto:hello@example.com)")

result = converter.from_ast(ast)

assert result == "hello@example.com"

def test_collapses_mailto_autolink_on_gchat_wire_round_trip(self):
"""Python-only composition with the documented `<url|text>` to_ast
divergence: a redundant `<mailto:addr|addr>` read back from the
Google Chat wire parses to a link node (upstream leaves it as raw
text) and must now collapse to the bare address on re-emit instead
of round-tripping the verbose form."""
converter = _converter()
ast = converter.to_ast("<mailto:hello@example.com|hello@example.com>")

result = converter.from_ast(ast)

assert result == "hello@example.com"

def test_preserves_custom_label_for_mailto_links(self):
"""Port of upstream "preserves custom label for mailto links"
(vercel/chat#553): a label that differs from the address keeps the
`<url|text>` form."""
converter = _converter()
ast = converter.to_ast("[contact](mailto:hello@example.com)")

result = converter.from_ast(ast)

assert result == "<mailto:hello@example.com|contact>"

def test_formats_http_links_correctly(self):
"""Port of upstream "formats http links correctly" (vercel/chat#553).
Upstream autolinks the bare URL to a link node whose text equals its
URL and re-emits it bare; our parser keeps bare URLs as plain text —
the output contract is identical either way."""
converter = _converter()
ast = converter.to_ast("https://example.com")

result = converter.from_ast(ast)

assert result == "https://example.com"

def test_keeps_phone_numbers_as_plain_text(self):
"""Port of upstream "keeps phone numbers as plain text"
(vercel/chat#553): plain phone numbers are not autolinked by either
parser and must survive unchanged."""
converter = _converter()
ast = converter.to_ast("+1555123456")

result = converter.from_ast(ast)

assert result == "+1555123456"

def test_collapses_tel_autolink_for_plain_phone_text(self):
"""Port of upstream "collapses tel autolink for plain phone text"
(vercel/chat#553): a tel: link whose label equals the number
collapses to the bare number."""
converter = _converter()
ast = converter.to_ast("[+1555123456](tel:+1555123456)")

result = converter.from_ast(ast)

assert result == "+1555123456"

def test_should_preserve_custom_link_labels_in_posted_messages(self):
# Matches the integration-tests parity case: a posted markdown link
# with a custom label must render as Google Chat's <url|text> syntax
Expand Down
Loading
Loading