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
5 changes: 2 additions & 3 deletions src/chat_sdk/adapters/discord/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
SectionElement,
TextElement,
card_child_to_fallback_text,
table_element_to_ascii,
)
from chat_sdk.emoji import convert_emoji_placeholders
from chat_sdk.shared.card_utils import render_gfm_table
Expand Down Expand Up @@ -271,9 +272,7 @@ def _child_to_fallback_text(child: CardChild) -> str | None:
if child_type == "table":
headers = child.get("headers", []) # type: ignore[union-attr]
rows = child.get("rows", []) # type: ignore[union-attr]
from chat_sdk.shared.base_format_converter import table_to_ascii

return f"```\n{table_to_ascii({'type': 'table', 'children': [], 'headers': headers, 'rows': rows})}\n```"
return f"```\n{table_element_to_ascii(headers, rows)}\n```"
if child_type == "divider":
return "---"

Expand Down
19 changes: 17 additions & 2 deletions src/chat_sdk/adapters/discord/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ def to_ast(self, platform_text: str) -> Root:
return parse_markdown(markdown)

def render_postable(self, message: object) -> str:
"""Override renderPostable to convert @mentions in plain strings."""
"""Override renderPostable to convert @mentions in plain strings.

Extends the base implementation with Discord mention conversion
and dataclass-style message support.
"""
if isinstance(message, str):
return self._convert_mentions_to_discord(message)
if isinstance(message, dict):
Expand All @@ -72,7 +76,18 @@ def render_postable(self, message: object) -> str:
return self.from_ast(parse_markdown(message["markdown"]))
if "ast" in message:
return self.from_ast(message["ast"])
return ""
if "card" in message or message.get("type") == "card":
return super().render_postable(message)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of delegating to the generic base implementation, use the Discord-specific card_to_fallback_text and ensure mentions are converted. The base implementation uses a generic fallback that lacks emoji conversion and Discord-specific mention formatting.

Suggested change
return super().render_postable(message)
from chat_sdk.adapters.discord.cards import card_to_fallback_text as discord_card_fallback
return self._convert_mentions_to_discord(discord_card_fallback(message.get("card") or message))

return ""
# Dataclass / object-style messages
if hasattr(message, "raw"):
return self._convert_mentions_to_discord(message.raw)
if hasattr(message, "markdown"):
return self.from_ast(parse_markdown(message.markdown))
if hasattr(message, "ast"):
return self.from_ast(message.ast)
# Fall back to base implementation for remaining cases (e.g. card objects)
return super().render_postable(message)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

For object-style messages containing a card, use the Discord-specific fallback logic to ensure that emojis and mentions are correctly processed for the platform.

Suggested change
return super().render_postable(message)
if hasattr(message, "card"):
from chat_sdk.adapters.discord.cards import card_to_fallback_text as discord_card_fallback
return self._convert_mentions_to_discord(discord_card_fallback(message.card))
return super().render_postable(message)


def _convert_mentions_to_discord(self, text: str) -> str:
"""Convert @mentions to Discord format: @name -> <@name>."""
Expand Down
2 changes: 2 additions & 0 deletions src/chat_sdk/adapters/google_chat/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ def _node_to_gchat(self, node: Content) -> str:
return f"{link_text} ({url})"

if node_type == "heading":
# Intentional improvement over TS SDK: Google Chat has no heading
# syntax, so we wrap headings in bold (*...*) for visual emphasis.
children = node.get("children", [])
content = "".join(self._node_to_gchat(child) for child in children)
return f"*{content}*"
Expand Down
2 changes: 2 additions & 0 deletions src/chat_sdk/adapters/slack/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,8 @@ def _node_to_mrkdwn(self, node: Content) -> str:
return f"<{node.get('url', '')}|{link_text}>"

if node_type == "heading":
# Intentional improvement over TS SDK: Slack mrkdwn has no heading
# syntax, so we wrap headings in bold (*...*) for visual emphasis.
content = "".join(self._node_to_mrkdwn(c) for c in children)
return f"*{content}*"

Expand Down
19 changes: 4 additions & 15 deletions src/chat_sdk/adapters/teams/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,20 +301,9 @@ def card_to_fallback_text(card: CardElement) -> str:
"""Generate fallback text from a card element.

Used when adaptive cards aren't supported.
Delegates to the shared implementation which handles emoji conversion
and renders all child types (including tables, fields, etc.) correctly.
"""
parts: list[str] = []
from chat_sdk.shared.card_utils import card_to_fallback_text as shared_card_to_fallback_text

title = card.get("title")
if title:
parts.append(f"**{_convert_emoji(title)}**")

subtitle = card.get("subtitle")
if subtitle:
parts.append(_convert_emoji(subtitle))

for child in card.get("children", []):
text = card_child_to_fallback_text(child)
if text:
parts.append(text)

return "\n\n".join(parts)
return shared_card_to_fallback_text(card, bold_format="**", line_break="\n\n", platform="teams")
19 changes: 17 additions & 2 deletions src/chat_sdk/adapters/teams/format_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ def to_ast(self, platform_text: str) -> Root:
return parse_markdown(markdown)

def render_postable(self, message: object) -> str:
"""Override renderPostable to convert @mentions in plain strings."""
"""Override renderPostable to convert @mentions in plain strings.

Extends the base implementation with Teams mention conversion
and dataclass-style message support.
"""
if isinstance(message, str):
return self._convert_mentions_to_teams(message)
if isinstance(message, dict):
Expand All @@ -104,7 +108,18 @@ def render_postable(self, message: object) -> str:
return self.from_ast(parse_markdown(message["markdown"]))
if "ast" in message:
return self.from_ast(message["ast"])
return ""
if "card" in message or message.get("type") == "card":
return super().render_postable(message)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Delegating to super().render_postable for cards bypasses the Teams-specific fallback logic (which handles emoji conversion) and fails to convert @mentions to the <at>name</at> format required by Teams.

Suggested change
return super().render_postable(message)
from chat_sdk.adapters.teams.cards import card_to_fallback_text as teams_card_fallback
return self._convert_mentions_to_teams(teams_card_fallback(message.get("card") or message))

return ""
# Dataclass / object-style messages
if hasattr(message, "raw"):
return self._convert_mentions_to_teams(message.raw)
if hasattr(message, "markdown"):
return self.from_ast(parse_markdown(message.markdown))
if hasattr(message, "ast"):
return self.from_ast(message.ast)
# Fall back to base implementation for remaining cases (e.g. card objects)
return super().render_postable(message)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Apply Teams-specific card fallback and mention conversion for object-style messages to maintain consistency with other message types.

Suggested change
return super().render_postable(message)
if hasattr(message, "card"):
from chat_sdk.adapters.teams.cards import card_to_fallback_text as teams_card_fallback
return self._convert_mentions_to_teams(teams_card_fallback(message.card))
return super().render_postable(message)


def _convert_mentions_to_teams(self, text: str) -> str:
"""Convert @mentions to Teams format: @name -> <at>name</at>."""
Expand Down
2 changes: 1 addition & 1 deletion src/chat_sdk/adapters/whatsapp/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def encode_whatsapp_callback_data(action_id: str, value: str | None = None) -> s
payload: dict[str, str] = {"a": action_id}
if isinstance(value, str):
payload["v"] = value
return f"{CALLBACK_DATA_PREFIX}{json.dumps(payload)}"
return f"{CALLBACK_DATA_PREFIX}{json.dumps(payload, separators=(',', ':'))}"


def decode_whatsapp_callback_data(data: str | None = None) -> dict[str, str | None]:
Expand Down
11 changes: 8 additions & 3 deletions src/chat_sdk/emoji.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def get_emoji(name: str) -> EmojiValue:
"check": EmojiFormats(slack=["white_check_mark", "heavy_check_mark"], gchat=["✅", "✔️"]),
"x": EmojiFormats(slack=["x", "heavy_multiplication_x"], gchat=["❌", "✖️"]),
"question": EmojiFormats(slack="question", gchat=["❓", "?"]),
"exclamation": EmojiFormats(slack="exclamation", gchat=["❗", "!"]),
"exclamation": EmojiFormats(slack="exclamation", gchat="❗"),
"warning": EmojiFormats(slack="warning", gchat="⚠️"),
"stop": EmojiFormats(slack="octagonal_sign", gchat="🛑"),
"info": EmojiFormats(slack="information_source", gchat="ℹ️"),
Expand Down Expand Up @@ -104,7 +104,7 @@ def get_emoji(name: str) -> EmojiValue:
"key": EmojiFormats(slack="key", gchat="🔑"),
"pin": EmojiFormats(slack="pushpin", gchat="📌"),
"bell": EmojiFormats(slack="bell", gchat="🔔"),
"megaphone": EmojiFormats(slack="mega", gchat="📣"),
"megaphone": EmojiFormats(slack="mega", gchat="📢"),
"loudspeaker": EmojiFormats(slack="loudspeaker", gchat="📢"),
"speech_bubble": EmojiFormats(slack="speech_balloon", gchat="💬"),
"clipboard": EmojiFormats(slack="clipboard", gchat="📋"),
Expand Down Expand Up @@ -193,7 +193,12 @@ def from_slack(self, slack_emoji: str) -> EmojiValue:

Returns an EmojiValue for the raw emoji if no mapping exists.
"""
cleaned = slack_emoji.strip(":").lower()
cleaned = slack_emoji.lower()
# Strip at most one colon from each end (avoid stripping interior colons)
if cleaned.startswith(":"):
cleaned = cleaned[1:]
if cleaned.endswith(":"):
cleaned = cleaned[:-1]
normalized = self._slack_to_normalized.get(cleaned, slack_emoji)
return get_emoji(normalized)

Expand Down
9 changes: 8 additions & 1 deletion src/chat_sdk/state/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,10 +543,17 @@ async def dequeue(self, thread_id: str) -> QueueEntry | None:
return None

data = json.loads(row["value"])
msg_data = data["message"]
if isinstance(msg_data, dict) and msg_data.get("_type") == "chat:Message":
from chat_sdk.types import Message

msg = Message.from_json(msg_data)
else:
msg = msg_data
return QueueEntry(
enqueued_at=data["enqueued_at"],
expires_at=data["expires_at"],
message=data["message"],
message=msg,
)

async def queue_depth(self, thread_id: str) -> int:
Expand Down
9 changes: 8 additions & 1 deletion src/chat_sdk/state/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,17 @@ async def dequeue(self, thread_id: str) -> QueueEntry | None:
return None

data = json.loads(value)
msg_data = data["message"]
if isinstance(msg_data, dict) and msg_data.get("_type") == "chat:Message":
from chat_sdk.types import Message

msg = Message.from_json(msg_data)
else:
msg = msg_data
return QueueEntry(
enqueued_at=data["enqueued_at"],
expires_at=data["expires_at"],
message=data["message"],
message=msg,
)

async def queue_depth(self, thread_id: str) -> int:
Expand Down
10 changes: 10 additions & 0 deletions src/chat_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@
"question",
"exclamation",
"warning",
"stop",
"info",
"100",
"no_entry",
"green_circle",
"yellow_circle",
Expand All @@ -105,13 +107,15 @@
"fire",
"lightning",
"bulb",
"lightbulb",
"gear",
"wrench",
"hammer",
"link",
"lock",
"unlock",
"key",
"pin",
"bell",
"megaphone",
"loudspeaker",
Expand All @@ -125,8 +129,14 @@
"chart",
"bar_chart",
"folder",
"file",
"package",
"email",
"inbox",
"outbox",
"coffee",
"pizza",
"beer",
"arrow_up",
"arrow_down",
"arrow_left",
Expand Down
4 changes: 2 additions & 2 deletions tests/test_whatsapp_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,11 +308,11 @@ class TestWhatsAppCallbackData:

def test_encode_action_only(self):
result = encode_whatsapp_callback_data("my_action")
assert result == 'chat:{"a": "my_action"}'
assert result == 'chat:{"a":"my_action"}'

def test_encode_action_and_value(self):
result = encode_whatsapp_callback_data("my_action", "some_value")
assert result == 'chat:{"a": "my_action", "v": "some_value"}'
assert result == 'chat:{"a":"my_action","v":"some_value"}'

def test_decode_encoded_data(self):
encoded = encode_whatsapp_callback_data("my_action", "some_value")
Expand Down
Loading