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
97 changes: 59 additions & 38 deletions examples/slackbot/src/slackbot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ async def run_agent(
raise


def _extract_message_context(event: Any) -> tuple[bool, str | None, str | None, str]:
"""Return (is_edit, message_ts, thread_ts, text) for Slack events.

- For `message_changed` events, Slack nests the edited message under `event.message`.
- For normal app_mention events, fields are at the top level.
Comment on lines +122 to +125
Copy link

Copilot AI Sep 10, 2025

Choose a reason for hiding this comment

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

This function lacks a docstring that explains the return tuple structure. While there's a brief comment, a proper docstring would improve maintainability by clearly documenting the return values: (is_edit, message_ts, thread_ts, text).

Suggested change
"""Return (is_edit, message_ts, thread_ts, text) for Slack events.
- For `message_changed` events, Slack nests the edited message under `event.message`.
- For normal app_mention events, fields are at the top level.
"""
Extracts context from a Slack event and returns a tuple containing:
- is_edit (bool): True if the event is a message edit (`message_changed`), False otherwise.
- message_ts (str | None): The timestamp of the message, used for idempotency.
- thread_ts (str | None): The thread anchor timestamp where replies should be posted.
- text (str): The text content of the message, used for bot mention detection.
For `message_changed` events, Slack nests the edited message under `event.message`.
For normal app_mention events, fields are at the top level.
Returns:
tuple[bool, str | None, str | None, str]: (is_edit, message_ts, thread_ts, text)

Copilot uses AI. Check for mistakes.
"""
is_edit = getattr(event, "subtype", None) == "message_changed"
msg = (getattr(event, "message", None) or {}) if is_edit else {}
Copy link

Copilot AI Sep 10, 2025

Choose a reason for hiding this comment

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

[nitpick] The logic for extracting the message object could be clearer. Consider using a more explicit conditional structure: msg = getattr(event, 'message', {}) if is_edit else {} to avoid the unnecessary or {} operation.

Suggested change
msg = (getattr(event, "message", None) or {}) if is_edit else {}
msg = getattr(event, "message", {}) if is_edit else {}

Copilot uses AI. Check for mistakes.

# Prefer the message ts for idempotency; fall back to event_ts if needed
message_ts = (
msg.get("ts")
if is_edit
else (getattr(event, "ts", None) or getattr(event, "event_ts", None))
)
# Thread anchor where we should post replies
thread_ts = (
(msg.get("thread_ts") or msg.get("ts"))
if is_edit
else (getattr(event, "thread_ts", None) or getattr(event, "ts", None))
)
# Text used for bot mention detection
text = (msg.get("text") if is_edit else (getattr(event, "text", None) or "")) or ""
Copy link

Copilot AI Sep 10, 2025

Choose a reason for hiding this comment

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

[nitpick] This nested conditional with multiple or operators is difficult to read. Consider breaking this into separate lines or using a more explicit if-else structure for better readability.

Suggested change
text = (msg.get("text") if is_edit else (getattr(event, "text", None) or "")) or ""
if is_edit:
text = msg.get("text") or ""
else:
text = getattr(event, "text", None) or ""

Copilot uses AI. Check for mistakes.

return is_edit, message_ts, thread_ts, text


@flow(name="Handle Slack Message", retries=1)
async def handle_message(payload: SlackPayload, db: Database):
logger = get_run_logger()
Expand All @@ -127,9 +154,10 @@ async def handle_message(payload: SlackPayload, db: Database):
return Completed(message="Invalid event", name="SKIPPED")

USER_MESSAGE_MAX_TOKENS = settings.user_message_max_tokens
user_message = event.text or ""
thread_ts = event.thread_ts or event.ts
# Determine message context accommodating edit events
is_edit, message_ts, thread_ts, user_message = _extract_message_context(event)
assert thread_ts is not None, "No thread_ts found"
assert message_ts is not None, "No message_ts found"
cleaned_message = re.sub(BOT_MENTION, "", user_message).strip()
msg_len = count_tokens(cleaned_message)

Expand All @@ -149,38 +177,33 @@ async def handle_message(payload: SlackPayload, db: Database):
return Completed(message="Message too long", name="SKIPPED")

if re.search(BOT_MENTION, user_message) and payload.authorizations:
# Only gate the root message; replies should not be blocked
is_root_message = event.thread_ts is None
root_ts = thread_ts

if is_root_message:
# Cross-process acquire; only one handler should proceed for the root
acquired = await try_acquire_thread(db, root_ts)
if not acquired:
status = await get_thread_status(db, root_ts)
if status == "in_progress":
assert event.channel is not None, (
"Event channel is None when posting edit-ignored notice"
)
await post_slack_message(
message=(
"✋ I noticed you edited your original message. "
"I'm already working on your first version — please add any "
"clarifications as new messages in this thread so I don't lose track."
),
channel_id=event.channel,
thread_ts=root_ts,
)
return Completed(
message="Ignored edit while in progress",
name="IGNORED_EDIT",
data=dict(thread_ts=root_ts),
)
# Per-message acquire: prevent duplicate handling for this specific message
acquired = await try_acquire_thread(db, message_ts)
if not acquired:
status = await get_thread_status(db, message_ts)
if status == "in_progress" and is_edit:
assert event.channel is not None, (
"Event channel is None when posting edit-ignored notice"
)
await post_slack_message(
message=(
"✋ I noticed you edited your original message. "
"I'm already working on your first version — please add any "
"clarifications as new messages in this thread so I don't lose track."
),
channel_id=event.channel,
thread_ts=thread_ts,
)
return Completed(
message="Duplicate root event after completion",
name="SKIPPED_DUPLICATE",
data=dict(thread_ts=root_ts),
message="Ignored edit while in progress",
name="IGNORED_EDIT",
data=dict(message_ts=message_ts, thread_ts=thread_ts),
)
return Completed(
message="Duplicate event for message",
name="SKIPPED_DUPLICATE",
data=dict(message_ts=message_ts, thread_ts=thread_ts),
)

# Check if this is the designated channel
team_id = payload.team_id or ""
Expand Down Expand Up @@ -263,12 +286,10 @@ async def handle_message(payload: SlackPayload, db: Database):
data=dict(error=str(e), user_context=user_context),
)
finally:
# Only mark completion for the root message; do not block replies
if "is_root_message" in locals() and is_root_message:
try:
await mark_thread_completed(db, root_ts)
except Exception:
logger.warning("Failed to mark thread as completed")
try:
await mark_thread_completed(db, message_ts)
except Exception:
logger.warning("Failed to mark message as completed")

return Completed(
message="Responded to mention",
Expand Down
2 changes: 2 additions & 0 deletions examples/slackbot/src/slackbot/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class SlackEvent(BaseModel):
type: str
subtype: str | None = None
text: str | None = None
# For message_changed edit events, Slack nests the edited message here
message: dict[str, Any] | None = None
user: str | dict[str, Any] | None = None
ts: str | None = None
team: str | None = None
Expand Down
Loading