Skip to content

Commit d318fb7

Browse files
authored
Slackbot: per-message dedupe; ignore only edits while in-progress (#1226)
* slackbot: remove locals() hack; clean root completion mark; fix duplicate bot_auth line * slackbot: dedupe per-message (ts), detect edits via message_changed; ignore only edits while in-progress\n\n- Parse edit events (event.subtype == message_changed) including nested message.ts/text\n- Use message_ts as idempotency key so replies/new mentions are unaffected\n- Post polite notice only on edit duplicates; skip quiet otherwise\n- Mark completion by message key * address copilot review: robust fallbacks for message_ts/thread_ts on edit and non-edit events * refactor: extract edit/non-edit parsing into helper; remove repeated (event.message or {}) usage per Copilot feedback
1 parent 05a699a commit d318fb7

2 files changed

Lines changed: 61 additions & 38 deletions

File tree

examples/slackbot/src/slackbot/api.py

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,33 @@ async def run_agent(
118118
raise
119119

120120

121+
def _extract_message_context(event: Any) -> tuple[bool, str | None, str | None, str]:
122+
"""Return (is_edit, message_ts, thread_ts, text) for Slack events.
123+
124+
- For `message_changed` events, Slack nests the edited message under `event.message`.
125+
- For normal app_mention events, fields are at the top level.
126+
"""
127+
is_edit = getattr(event, "subtype", None) == "message_changed"
128+
msg = (getattr(event, "message", None) or {}) if is_edit else {}
129+
130+
# Prefer the message ts for idempotency; fall back to event_ts if needed
131+
message_ts = (
132+
msg.get("ts")
133+
if is_edit
134+
else (getattr(event, "ts", None) or getattr(event, "event_ts", None))
135+
)
136+
# Thread anchor where we should post replies
137+
thread_ts = (
138+
(msg.get("thread_ts") or msg.get("ts"))
139+
if is_edit
140+
else (getattr(event, "thread_ts", None) or getattr(event, "ts", None))
141+
)
142+
# Text used for bot mention detection
143+
text = (msg.get("text") if is_edit else (getattr(event, "text", None) or "")) or ""
144+
145+
return is_edit, message_ts, thread_ts, text
146+
147+
121148
@flow(name="Handle Slack Message", retries=1)
122149
async def handle_message(payload: SlackPayload, db: Database):
123150
logger = get_run_logger()
@@ -127,9 +154,10 @@ async def handle_message(payload: SlackPayload, db: Database):
127154
return Completed(message="Invalid event", name="SKIPPED")
128155

129156
USER_MESSAGE_MAX_TOKENS = settings.user_message_max_tokens
130-
user_message = event.text or ""
131-
thread_ts = event.thread_ts or event.ts
157+
# Determine message context accommodating edit events
158+
is_edit, message_ts, thread_ts, user_message = _extract_message_context(event)
132159
assert thread_ts is not None, "No thread_ts found"
160+
assert message_ts is not None, "No message_ts found"
133161
cleaned_message = re.sub(BOT_MENTION, "", user_message).strip()
134162
msg_len = count_tokens(cleaned_message)
135163

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

151179
if re.search(BOT_MENTION, user_message) and payload.authorizations:
152-
# Only gate the root message; replies should not be blocked
153-
is_root_message = event.thread_ts is None
154-
root_ts = thread_ts
155-
156-
if is_root_message:
157-
# Cross-process acquire; only one handler should proceed for the root
158-
acquired = await try_acquire_thread(db, root_ts)
159-
if not acquired:
160-
status = await get_thread_status(db, root_ts)
161-
if status == "in_progress":
162-
assert event.channel is not None, (
163-
"Event channel is None when posting edit-ignored notice"
164-
)
165-
await post_slack_message(
166-
message=(
167-
"✋ I noticed you edited your original message. "
168-
"I'm already working on your first version — please add any "
169-
"clarifications as new messages in this thread so I don't lose track."
170-
),
171-
channel_id=event.channel,
172-
thread_ts=root_ts,
173-
)
174-
return Completed(
175-
message="Ignored edit while in progress",
176-
name="IGNORED_EDIT",
177-
data=dict(thread_ts=root_ts),
178-
)
180+
# Per-message acquire: prevent duplicate handling for this specific message
181+
acquired = await try_acquire_thread(db, message_ts)
182+
if not acquired:
183+
status = await get_thread_status(db, message_ts)
184+
if status == "in_progress" and is_edit:
185+
assert event.channel is not None, (
186+
"Event channel is None when posting edit-ignored notice"
187+
)
188+
await post_slack_message(
189+
message=(
190+
"✋ I noticed you edited your original message. "
191+
"I'm already working on your first version — please add any "
192+
"clarifications as new messages in this thread so I don't lose track."
193+
),
194+
channel_id=event.channel,
195+
thread_ts=thread_ts,
196+
)
179197
return Completed(
180-
message="Duplicate root event after completion",
181-
name="SKIPPED_DUPLICATE",
182-
data=dict(thread_ts=root_ts),
198+
message="Ignored edit while in progress",
199+
name="IGNORED_EDIT",
200+
data=dict(message_ts=message_ts, thread_ts=thread_ts),
183201
)
202+
return Completed(
203+
message="Duplicate event for message",
204+
name="SKIPPED_DUPLICATE",
205+
data=dict(message_ts=message_ts, thread_ts=thread_ts),
206+
)
184207

185208
# Check if this is the designated channel
186209
team_id = payload.team_id or ""
@@ -263,12 +286,10 @@ async def handle_message(payload: SlackPayload, db: Database):
263286
data=dict(error=str(e), user_context=user_context),
264287
)
265288
finally:
266-
# Only mark completion for the root message; do not block replies
267-
if "is_root_message" in locals() and is_root_message:
268-
try:
269-
await mark_thread_completed(db, root_ts)
270-
except Exception:
271-
logger.warning("Failed to mark thread as completed")
289+
try:
290+
await mark_thread_completed(db, message_ts)
291+
except Exception:
292+
logger.warning("Failed to mark message as completed")
272293

273294
return Completed(
274295
message="Responded to mention",

examples/slackbot/src/slackbot/slack.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ class SlackEvent(BaseModel):
3131
type: str
3232
subtype: str | None = None
3333
text: str | None = None
34+
# For message_changed edit events, Slack nests the edited message here
35+
message: dict[str, Any] | None = None
3436
user: str | dict[str, Any] | None = None
3537
ts: str | None = None
3638
team: str | None = None

0 commit comments

Comments
 (0)