Skip to content
Closed
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
3 changes: 3 additions & 0 deletions examples/slackbot/.dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ share/python-wheels/
pip-log.txt
pip-delete-this-directory.txt

# Exclude unit tests
tests/

# Unit test / coverage reports
htmlcov/
.tox/
Expand Down
95 changes: 79 additions & 16 deletions examples/slackbot/src/slackbot/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,26 +90,88 @@ def to_slack_link(match: re.Match[str]) -> str:
)


def transform_code_block(text: str) -> str:
"""
Remove the language specifier from a Markdown code block.
Slack does not support language-specific code blocks.

Example:
```python
print("Hello, World!")
```

becomes:
```
print("Hello, World!")
```
"""
# Remove the language specifier from the code block
code_block_pattern = r"```(?:\w+)?(.*?)```"

Copilot AI Jul 31, 2025

Copy link

Choose a reason for hiding this comment

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

The regex pattern doesn't properly handle the optional language specifier. The (?:\w+)? makes the language specifier optional but doesn't account for newlines after it. This will fail to match code blocks with language specifiers followed by newlines. Consider using r"```(?:\w+\n)?(.*?)```" to properly capture language specifiers followed by newlines.

Suggested change
code_block_pattern = r"```(?:\w+)?(.*?)```"
code_block_pattern = r"```(?:\w+\n)?(.*?)```"

Copilot uses AI. Check for mistakes.

return re.sub(code_block_pattern, r"```\1```", text, flags=re.DOTALL)


class SlackMessage:
"""
Handles transformation of Markdown to Slack-formatted messages.
"""

_transforms = [
convert_md_links_to_slack,
transform_code_block,
]

def __init__(self, text: str, channel_id=None, ts=None, thread_ts=None, attachments=None):
self.text = text
self.channel = channel_id
self.ts = ts
self.thread_ts = thread_ts
self.attachments = attachments or []

@staticmethod
async def from_message(channel_id: str, ts: str):
text = await fetch_current_message_text(channel_id, ts)
return SlackMessage(text, channel_id=channel_id, ts=ts)

def _get_transformed_text(self) -> str:
t = self.text
for fn in self._transforms:
t = fn(t)
return t

def append(self, text, delimiter="\n\n"):
self.text = f"{self.text}{delimiter}{text}"

def json(self):
return {
k: v for k, v in {
"text": self._get_transformed_text(),
"channel": self.channel,
"ts": self.ts,
"thread_ts": self.thread_ts,
"attachments": self.attachments or None,
}.items() if v is not None
}


async def post_slack_message(
message: str,
channel_id: str,
attachments: list[dict[str, Any]] | None = None,
thread_ts: str | None = None,
) -> httpx.Response:
post_data = {
"channel": channel_id,
"text": convert_md_links_to_slack(message),
"attachments": attachments if attachments else [],
}

if thread_ts:
post_data["thread_ts"] = thread_ts
message = SlackMessage(
message,
channel_id=channel_id,
attachments=attachments,
thread_ts=thread_ts,
)

async with httpx.AsyncClient() as client:
response = await client.post(
"https://slack.com/api/chat.postMessage",
headers={"Authorization": f"Bearer {settings.slack_api_token}"},
json=post_data,
json=message.json(),
)
response_data = response.json()

Expand Down Expand Up @@ -187,28 +249,29 @@ async def edit_slack_message(
"""Edit an existing Slack message by appending new text or replacing it.

Args:
channel (str): The Slack channel ID.
ts (str): The timestamp of the message to edit.
channel_id (str): The Slack channel ID.
thread_ts (str): The timestamp of the message to edit.
new_text (str): The new text to append or replace in the message.
mode (str): The mode of text editing, 'append' (default) or 'replace'.
delimiter (Union[str, None]): The delimiter to use if appending to an
existing message.

Returns:
httpx.Response: The response from the Slack API.
"""
if mode == "append":
current_text = await fetch_current_message_text(channel_id, thread_ts)
delimiter = "\n\n" if delimiter is None else delimiter
updated_text = f"{current_text}{delimiter}{convert_md_links_to_slack(new_text)}"
message = await SlackMessage.from_message(channel_id, thread_ts)
message.append(new_text, delimiter=delimiter)

Copilot AI Jul 31, 2025

Copy link

Choose a reason for hiding this comment

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

The delimiter parameter is used without being defined in the function signature. The function signature shows delimiter as a parameter, but it's not included in the actual parameter list, which will cause a NameError when delimiter is None and needs to default to "\n\n".

Copilot uses AI. Check for mistakes.
elif mode == "replace":
updated_text = convert_md_links_to_slack(new_text)
message = SlackMessage(new_text)
else:
raise ValueError("Invalid mode. Use 'append' or 'replace'.")

async with httpx.AsyncClient() as client:
response = await client.post(
"https://slack.com/api/chat.update",
headers={"Authorization": f"Bearer {settings.slack_api_token}"},
json={"channel": channel_id, "ts": thread_ts, "text": updated_text},
json=message.json(),

Copilot AI Jul 31, 2025

Copy link

Choose a reason for hiding this comment

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

The message.json() call will exclude the ts field needed for updating messages. The edit_slack_message function needs to pass the ts parameter to identify which message to update, but the current SlackMessage instance doesn't have the ts set when created with SlackMessage(new_text) in replace mode.

Copilot uses AI. Check for mistakes.
)

response.raise_for_status()
Expand Down
73 changes: 73 additions & 0 deletions examples/slackbot/tests/test_slack_message_class.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import os
import unittest
from unittest import mock

@mock.patch.dict(os.environ, {"MARVIN_SLACKBOT_SLACK_API_TOKEN": "xoxb-fake"})
class SlackMessageClassTests(unittest.TestCase):
def test_transforms_code_text(self):
from slackbot.slack import SlackMessage

code = """```python
print(test)
```"""

message = SlackMessage(code)

expected = """```
print(test)
```"""

self.assertEqual(expected, message._get_transformed_text())

def test_append(self):
from slackbot.slack import SlackMessage

code = "Original message"

message = SlackMessage(code)
message.append("Updated text")

expected = "Original message\n\nUpdated text"

self.assertEqual(expected, message.json()["text"])

def test_correct_attributes(self):
from slackbot.slack import SlackMessage
text = "Test message"
code = """```python
print(test)
```"""
code_output = """```
print(test)
```"""

message = SlackMessage(text)
attributes = {
"text": text
}
self.assertEqual(attributes, message.json())

message = SlackMessage(text, channel_id=1, ts=1234567890)
attributes = {
"text": text,
"channel": 1,
"ts": 1234567890
}
self.assertEqual(attributes, message.json())

message = SlackMessage(text, channel_id=1, thread_ts=1234567890)
attributes = {
"text": text,
"channel": 1,
"thread_ts": 1234567890
}
self.assertEqual(attributes, message.json())

message = SlackMessage(code, channel_id=1, thread_ts=1234567890)
attributes = {
"text": code_output,
"channel": 1,
"thread_ts": 1234567890
}
self.assertEqual(attributes, message.json())

Loading