Skip to content

Commit 12fd025

Browse files
committed
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 12fd025

5 files changed

Lines changed: 81 additions & 38 deletions

File tree

examples/slackbot/src/slackbot/_internal/templates.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434

3535
DEFAULT_SYSTEM_PROMPT = """You are Marvin, an AI assistant for the Prefect data engineering platform. Your responses should be clear, helpful, accurate, and professional. Your primary goal is to provide excellent support to users.
3636
37+
## Support Resources
38+
For direct support inquiries, users can contact help@prefect.io for official assistance. Use the `get_support_resources` tool when users ask about getting help or support.
39+
3740
## Output Context
3841
Your responses will be displayed in Slack. Format accordingly:
3942
- Use ``` for code blocks (WITHOUT language identifiers like python/js/etc - Slack doesn't support them)

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/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
display_callable_signature,
4040
explore_module_offerings,
4141
get_latest_prefect_release_notes,
42+
get_support_resources,
4243
read_github_issues,
4344
)
4445
from slackbot.settings import settings
@@ -185,6 +186,7 @@ def create_agent(
185186
display_callable_signature, # check the work of the research agent, verify signatures of callable objects
186187
check_cli_command, # verify CLI commands before suggesting them
187188
get_latest_prefect_release_notes, # get the latest release notes for Prefect
189+
get_support_resources, # provide official support contact information
188190
],
189191
deps_type=UserContext,
190192
)

examples/slackbot/src/slackbot/search.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,18 @@ def check_cli_command(command: str, args: list[str] | None = None) -> str:
272272
return f"Command '{full_command[0]}' not found. Make sure it's installed and in PATH."
273273
except Exception as e:
274274
return f"Error running command: {str(e)}"
275+
276+
277+
def get_support_resources() -> dict[str, str]:
278+
"""
279+
Get available support resources and contact information for Prefect users.
280+
281+
Use this tool when users ask about getting help, support, or contacting Prefect.
282+
This provides official support channels including email support.
283+
284+
Returns:
285+
dict: Available support resources with contact details
286+
"""
287+
return {
288+
"support_email": "help@prefect.io",
289+
}

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)