Skip to content

Commit 9382910

Browse files
fix: resolve 10 port bugs found by systematic TS-vs-Python scan
HIGH: 1. Discord card fallback table: call table_element_to_ascii(headers, rows) instead of table_to_ascii with a synthetic dict 2. Teams card_to_fallback_text: delegate to shared implementation for proper emoji conversion and consistent child rendering 3. Megaphone emoji: correct gchat unicode from U+1F4E3 to U+1F4E2 4. Exclamation emoji: remove spurious "!" gchat alias causing false matches 5. Queue dequeue (redis + postgres): reconstruct Message objects from serialized dicts with _type == "chat:Message" 6. WhatsApp callback data: use compact JSON separators matching Telegram 7. Discord/Teams render_postable: add dataclass-style message support and fall back to super() instead of returning empty string MEDIUM: 8. Slack/GChat heading bold: document as intentional improvement (no-op) 9. from_slack strip: strip at most one colon from each end to avoid stripping interior colons in edge-case emoji names 10. WellKnownEmoji: add 10 missing entries (stop, 100, lightbulb, pin, inbox, outbox, file, coffee, pizza, beer) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c393499 commit 9382910

12 files changed

Lines changed: 81 additions & 30 deletions

File tree

src/chat_sdk/adapters/discord/cards.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
SectionElement,
2121
TextElement,
2222
card_child_to_fallback_text,
23+
table_element_to_ascii,
2324
)
2425
from chat_sdk.emoji import convert_emoji_placeholders
2526
from chat_sdk.shared.card_utils import render_gfm_table
@@ -271,9 +272,7 @@ def _child_to_fallback_text(child: CardChild) -> str | None:
271272
if child_type == "table":
272273
headers = child.get("headers", []) # type: ignore[union-attr]
273274
rows = child.get("rows", []) # type: ignore[union-attr]
274-
from chat_sdk.shared.base_format_converter import table_to_ascii
275-
276-
return f"```\n{table_to_ascii({'type': 'table', 'children': [], 'headers': headers, 'rows': rows})}\n```"
275+
return f"```\n{table_element_to_ascii(headers, rows)}\n```"
277276
if child_type == "divider":
278277
return "---"
279278

src/chat_sdk/adapters/discord/format_converter.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ def to_ast(self, platform_text: str) -> Root:
6262
return parse_markdown(markdown)
6363

6464
def render_postable(self, message: object) -> str:
65-
"""Override renderPostable to convert @mentions in plain strings."""
65+
"""Override renderPostable to convert @mentions in plain strings.
66+
67+
Extends the base implementation with Discord mention conversion
68+
and dataclass-style message support.
69+
"""
6670
if isinstance(message, str):
6771
return self._convert_mentions_to_discord(message)
6872
if isinstance(message, dict):
@@ -72,7 +76,18 @@ def render_postable(self, message: object) -> str:
7276
return self.from_ast(parse_markdown(message["markdown"]))
7377
if "ast" in message:
7478
return self.from_ast(message["ast"])
75-
return ""
79+
if "card" in message or message.get("type") == "card":
80+
return super().render_postable(message)
81+
return ""
82+
# Dataclass / object-style messages
83+
if hasattr(message, "raw"):
84+
return self._convert_mentions_to_discord(message.raw)
85+
if hasattr(message, "markdown"):
86+
return self.from_ast(parse_markdown(message.markdown))
87+
if hasattr(message, "ast"):
88+
return self.from_ast(message.ast)
89+
# Fall back to base implementation for remaining cases (e.g. card objects)
90+
return super().render_postable(message)
7691

7792
def _convert_mentions_to_discord(self, text: str) -> str:
7893
"""Convert @mentions to Discord format: @name -> <@name>."""

src/chat_sdk/adapters/google_chat/format_converter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def _node_to_gchat(self, node: Content) -> str:
112112
return f"{link_text} ({url})"
113113

114114
if node_type == "heading":
115+
# Intentional improvement over TS SDK: Google Chat has no heading
116+
# syntax, so we wrap headings in bold (*...*) for visual emphasis.
115117
children = node.get("children", [])
116118
content = "".join(self._node_to_gchat(child) for child in children)
117119
return f"*{content}*"

src/chat_sdk/adapters/slack/format_converter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ def _node_to_mrkdwn(self, node: Content) -> str:
242242
return f"<{node.get('url', '')}|{link_text}>"
243243

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

src/chat_sdk/adapters/teams/cards.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,20 +301,9 @@ def card_to_fallback_text(card: CardElement) -> str:
301301
"""Generate fallback text from a card element.
302302
303303
Used when adaptive cards aren't supported.
304+
Delegates to the shared implementation which handles emoji conversion
305+
and renders all child types (including tables, fields, etc.) correctly.
304306
"""
305-
parts: list[str] = []
307+
from chat_sdk.shared.card_utils import card_to_fallback_text as shared_card_to_fallback_text
306308

307-
title = card.get("title")
308-
if title:
309-
parts.append(f"**{_convert_emoji(title)}**")
310-
311-
subtitle = card.get("subtitle")
312-
if subtitle:
313-
parts.append(_convert_emoji(subtitle))
314-
315-
for child in card.get("children", []):
316-
text = card_child_to_fallback_text(child)
317-
if text:
318-
parts.append(text)
319-
320-
return "\n\n".join(parts)
309+
return shared_card_to_fallback_text(card, bold_format="**", line_break="\n\n", platform="teams")

src/chat_sdk/adapters/teams/format_converter.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,11 @@ def to_ast(self, platform_text: str) -> Root:
9494
return parse_markdown(markdown)
9595

9696
def render_postable(self, message: object) -> str:
97-
"""Override renderPostable to convert @mentions in plain strings."""
97+
"""Override renderPostable to convert @mentions in plain strings.
98+
99+
Extends the base implementation with Teams mention conversion
100+
and dataclass-style message support.
101+
"""
98102
if isinstance(message, str):
99103
return self._convert_mentions_to_teams(message)
100104
if isinstance(message, dict):
@@ -104,7 +108,18 @@ def render_postable(self, message: object) -> str:
104108
return self.from_ast(parse_markdown(message["markdown"]))
105109
if "ast" in message:
106110
return self.from_ast(message["ast"])
107-
return ""
111+
if "card" in message or message.get("type") == "card":
112+
return super().render_postable(message)
113+
return ""
114+
# Dataclass / object-style messages
115+
if hasattr(message, "raw"):
116+
return self._convert_mentions_to_teams(message.raw)
117+
if hasattr(message, "markdown"):
118+
return self.from_ast(parse_markdown(message.markdown))
119+
if hasattr(message, "ast"):
120+
return self.from_ast(message.ast)
121+
# Fall back to base implementation for remaining cases (e.g. card objects)
122+
return super().render_postable(message)
108123

109124
def _convert_mentions_to_teams(self, text: str) -> str:
110125
"""Convert @mentions to Teams format: @name -> <at>name</at>."""

src/chat_sdk/adapters/whatsapp/cards.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def encode_whatsapp_callback_data(action_id: str, value: str | None = None) -> s
6666
payload: dict[str, str] = {"a": action_id}
6767
if isinstance(value, str):
6868
payload["v"] = value
69-
return f"{CALLBACK_DATA_PREFIX}{json.dumps(payload)}"
69+
return f"{CALLBACK_DATA_PREFIX}{json.dumps(payload, separators=(',', ':'))}"
7070

7171

7272
def decode_whatsapp_callback_data(data: str | None = None) -> dict[str, str | None]:

src/chat_sdk/emoji.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def get_emoji(name: str) -> EmojiValue:
6565
"check": EmojiFormats(slack=["white_check_mark", "heavy_check_mark"], gchat=["✅", "✔️"]),
6666
"x": EmojiFormats(slack=["x", "heavy_multiplication_x"], gchat=["❌", "✖️"]),
6767
"question": EmojiFormats(slack="question", gchat=["❓", "?"]),
68-
"exclamation": EmojiFormats(slack="exclamation", gchat=["❗", "!"]),
68+
"exclamation": EmojiFormats(slack="exclamation", gchat="❗"),
6969
"warning": EmojiFormats(slack="warning", gchat="⚠️"),
7070
"stop": EmojiFormats(slack="octagonal_sign", gchat="🛑"),
7171
"info": EmojiFormats(slack="information_source", gchat="ℹ️"),
@@ -104,7 +104,7 @@ def get_emoji(name: str) -> EmojiValue:
104104
"key": EmojiFormats(slack="key", gchat="🔑"),
105105
"pin": EmojiFormats(slack="pushpin", gchat="📌"),
106106
"bell": EmojiFormats(slack="bell", gchat="🔔"),
107-
"megaphone": EmojiFormats(slack="mega", gchat="📣"),
107+
"megaphone": EmojiFormats(slack="mega", gchat="📢"),
108108
"loudspeaker": EmojiFormats(slack="loudspeaker", gchat="📢"),
109109
"speech_bubble": EmojiFormats(slack="speech_balloon", gchat="💬"),
110110
"clipboard": EmojiFormats(slack="clipboard", gchat="📋"),
@@ -193,7 +193,12 @@ def from_slack(self, slack_emoji: str) -> EmojiValue:
193193
194194
Returns an EmojiValue for the raw emoji if no mapping exists.
195195
"""
196-
cleaned = slack_emoji.strip(":").lower()
196+
cleaned = slack_emoji.lower()
197+
# Strip at most one colon from each end (avoid stripping interior colons)
198+
if cleaned.startswith(":"):
199+
cleaned = cleaned[1:]
200+
if cleaned.endswith(":"):
201+
cleaned = cleaned[:-1]
197202
normalized = self._slack_to_normalized.get(cleaned, slack_emoji)
198203
return get_emoji(normalized)
199204

src/chat_sdk/state/postgres.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,10 +543,17 @@ async def dequeue(self, thread_id: str) -> QueueEntry | None:
543543
return None
544544

545545
data = json.loads(row["value"])
546+
msg_data = data["message"]
547+
if isinstance(msg_data, dict) and msg_data.get("_type") == "chat:Message":
548+
from chat_sdk.types import Message
549+
550+
msg = Message.from_json(msg_data)
551+
else:
552+
msg = msg_data
546553
return QueueEntry(
547554
enqueued_at=data["enqueued_at"],
548555
expires_at=data["expires_at"],
549-
message=data["message"],
556+
message=msg,
550557
)
551558

552559
async def queue_depth(self, thread_id: str) -> int:

src/chat_sdk/state/redis.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,17 @@ async def dequeue(self, thread_id: str) -> QueueEntry | None:
308308
return None
309309

310310
data = json.loads(value)
311+
msg_data = data["message"]
312+
if isinstance(msg_data, dict) and msg_data.get("_type") == "chat:Message":
313+
from chat_sdk.types import Message
314+
315+
msg = Message.from_json(msg_data)
316+
else:
317+
msg = msg_data
311318
return QueueEntry(
312319
enqueued_at=data["enqueued_at"],
313320
expires_at=data["expires_at"],
314-
message=data["message"],
321+
message=msg,
315322
)
316323

317324
async def queue_depth(self, thread_id: str) -> int:

0 commit comments

Comments
 (0)