@@ -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 )
122149async 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" ,
0 commit comments