@openhands/extensions 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/automations/catalog/github-pr-reviewer.json +2 -2
- package/automations/catalog/slack-channel-monitor.json +2 -2
- package/integrations/catalog/github.json +8 -24
- package/integrations/index.d.ts +2 -0
- package/package.json +1 -1
- package/plugins/qa-changes/action.yml +1 -1
- package/pyproject.toml +1 -1
- package/skills/github-pr-reviewer/.plugin/plugin.json +1 -1
- package/skills/github-pr-reviewer/README.md +21 -15
- package/skills/github-pr-reviewer/SKILL.md +85 -66
- package/skills/github-pr-reviewer/commands/pr-reviewer-setup.md +1 -1
- package/skills/github-pr-reviewer/references/state-schema.md +74 -46
- package/skills/github-pr-reviewer/scripts/main.py +210 -227
- package/skills/index.js +3 -3
- package/skills/slack-channel-monitor/README.md +8 -4
- package/skills/slack-channel-monitor/SKILL.md +15 -6
- package/skills/slack-channel-monitor/references/slack-api.md +5 -3
- package/skills/slack-channel-monitor/references/state-schema.md +59 -32
- package/skills/slack-channel-monitor/scripts/main.py +242 -40
|
@@ -9,7 +9,8 @@ trigger phrase is detected it:
|
|
|
9
9
|
3. Posts a reply in the Slack thread with a link to the conversation.
|
|
10
10
|
|
|
11
11
|
On subsequent runs:
|
|
12
|
-
- New replies in a tracked thread are forwarded
|
|
12
|
+
- New replies in a tracked thread are forwarded only when they contain the
|
|
13
|
+
trigger phrase.
|
|
13
14
|
- When the conversation reaches a terminal/idle state the agent's final
|
|
14
15
|
response (or an error notice) is posted back to the Slack thread.
|
|
15
16
|
|
|
@@ -85,6 +86,18 @@ CONTEXT_MESSAGE_LIMIT = 15
|
|
|
85
86
|
# How far back (seconds) to look for context when creating a new conversation.
|
|
86
87
|
CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
|
|
87
88
|
|
|
89
|
+
# Keep completed conversations available for triggered follow-up replies for a
|
|
90
|
+
# short inactivity window. Polling conversations.replies is intentionally throttled:
|
|
91
|
+
# bot-token Slack apps may have a very small per-method quota, so each thread
|
|
92
|
+
# carries its own exponential backoff and each automation run polls at most one
|
|
93
|
+
# due thread. Start hot immediately after each bot reply, then back off
|
|
94
|
+
# on quiet threads.
|
|
95
|
+
THREAD_FOLLOWUP_WATCH_SECONDS = 300
|
|
96
|
+
THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
|
|
97
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
|
|
98
|
+
THREAD_REPLY_BACKOFF_MULTIPLIER = 2
|
|
99
|
+
MAX_THREAD_REPLY_POLLS_PER_RUN = 1
|
|
100
|
+
|
|
88
101
|
|
|
89
102
|
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
90
103
|
|
|
@@ -108,6 +121,55 @@ def get_secret(name: str) -> str:
|
|
|
108
121
|
return r.read().decode().strip()
|
|
109
122
|
|
|
110
123
|
|
|
124
|
+
def _is_loopback_openhands_url(url: str) -> bool:
|
|
125
|
+
return url.startswith((
|
|
126
|
+
"http://localhost",
|
|
127
|
+
"https://localhost",
|
|
128
|
+
"http://127.0.0.1",
|
|
129
|
+
"https://127.0.0.1",
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _ngrok_public_openhands_url() -> str | None:
|
|
134
|
+
try:
|
|
135
|
+
with urllib.request.urlopen(
|
|
136
|
+
"http://127.0.0.1:4040/api/tunnels", timeout=2
|
|
137
|
+
) as response:
|
|
138
|
+
tunnels = json.loads(response.read()).get("tunnels", [])
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
print(f"Could not discover ngrok public URL: {exc}")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
for tunnel in tunnels:
|
|
144
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
145
|
+
addr = str(tunnel.get("config", {}).get("addr", ""))
|
|
146
|
+
if (
|
|
147
|
+
public_url.startswith("https://")
|
|
148
|
+
and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
|
|
149
|
+
):
|
|
150
|
+
return public_url
|
|
151
|
+
|
|
152
|
+
for tunnel in tunnels:
|
|
153
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
154
|
+
if public_url.startswith("https://"):
|
|
155
|
+
return public_url
|
|
156
|
+
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_openhands_url() -> str:
|
|
161
|
+
try:
|
|
162
|
+
configured = get_secret("OPENHANDS_URL").rstrip()
|
|
163
|
+
except Exception:
|
|
164
|
+
configured = ""
|
|
165
|
+
|
|
166
|
+
configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
|
|
167
|
+
if not _is_loopback_openhands_url(configured):
|
|
168
|
+
return configured
|
|
169
|
+
|
|
170
|
+
return _ngrok_public_openhands_url() or configured
|
|
171
|
+
|
|
172
|
+
|
|
111
173
|
def fire_callback(
|
|
112
174
|
status: str = "COMPLETED",
|
|
113
175
|
error: str | None = None,
|
|
@@ -195,8 +257,14 @@ def _slack_call(
|
|
|
195
257
|
}
|
|
196
258
|
data = json.dumps(body).encode() if body is not None else None
|
|
197
259
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
198
|
-
|
|
199
|
-
|
|
260
|
+
try:
|
|
261
|
+
with urllib.request.urlopen(req) as r:
|
|
262
|
+
result = json.loads(r.read())
|
|
263
|
+
except urllib.error.HTTPError as exc:
|
|
264
|
+
if exc.code == 429:
|
|
265
|
+
retry_after = exc.headers.get("Retry-After", "60")
|
|
266
|
+
raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
|
|
267
|
+
raise
|
|
200
268
|
if not result.get("ok"):
|
|
201
269
|
raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
|
|
202
270
|
return result
|
|
@@ -273,6 +341,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
|
|
|
273
341
|
return [m for m in messages if m.get("ts") != thread_ts]
|
|
274
342
|
|
|
275
343
|
|
|
344
|
+
def _trigger_index(text: str) -> int:
|
|
345
|
+
"""Return the start index of an exact trigger phrase match, or -1."""
|
|
346
|
+
lowered = text.lower()
|
|
347
|
+
trigger = TRIGGER_PHRASE.lower()
|
|
348
|
+
start = 0
|
|
349
|
+
while True:
|
|
350
|
+
idx = lowered.find(trigger, start)
|
|
351
|
+
if idx < 0:
|
|
352
|
+
return -1
|
|
353
|
+
before = lowered[idx - 1] if idx > 0 else ""
|
|
354
|
+
after_idx = idx + len(trigger)
|
|
355
|
+
after = lowered[after_idx] if after_idx < len(lowered) else ""
|
|
356
|
+
before_ok = not before or not (before.isalnum() or before in "_-")
|
|
357
|
+
after_ok = not after or not (after.isalnum() or after in "_-")
|
|
358
|
+
if before_ok and after_ok:
|
|
359
|
+
return idx
|
|
360
|
+
start = idx + 1
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _has_trigger(text: str) -> bool:
|
|
364
|
+
return _trigger_index(text) >= 0
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _request_after_trigger(text: str) -> str:
|
|
368
|
+
idx = _trigger_index(text)
|
|
369
|
+
if idx < 0:
|
|
370
|
+
return text
|
|
371
|
+
return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
|
|
372
|
+
|
|
373
|
+
|
|
276
374
|
def full_thread_history(
|
|
277
375
|
token: str, channel: str, thread_ts: str,
|
|
278
376
|
bot_user_id: str, bot_message_ts: list[str],
|
|
@@ -567,14 +665,99 @@ def _gather_channel_context(
|
|
|
567
665
|
return context_lines
|
|
568
666
|
|
|
569
667
|
|
|
668
|
+
def _expire_inactive_thread_watches(active_convs: dict[str, dict], now: float) -> None:
|
|
669
|
+
for conv_key, rec in active_convs.items():
|
|
670
|
+
if rec.get("status") != "watching":
|
|
671
|
+
continue
|
|
672
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
673
|
+
if watch_until and now >= watch_until:
|
|
674
|
+
rec["status"] = "closed"
|
|
675
|
+
rec["closed_reason"] = "followup_watch_expired"
|
|
676
|
+
rec["closed_at"] = now
|
|
677
|
+
print(f" Follow-up watch expired for {conv_key}")
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _poll_due_thread_replies(
|
|
681
|
+
slack_token: str,
|
|
682
|
+
active_convs: dict[str, dict],
|
|
683
|
+
bot_user_id: str,
|
|
684
|
+
bot_message_ts: list[str],
|
|
685
|
+
) -> list[tuple[str, dict]]:
|
|
686
|
+
now = time.time()
|
|
687
|
+
_expire_inactive_thread_watches(active_convs, now)
|
|
688
|
+
due: list[tuple[float, str, dict]] = []
|
|
689
|
+
for conv_key, rec in active_convs.items():
|
|
690
|
+
if rec.get("status") not in {"active", "watching"}:
|
|
691
|
+
continue
|
|
692
|
+
next_poll = float(rec.get("next_reply_poll_at") or 0)
|
|
693
|
+
if next_poll <= now:
|
|
694
|
+
due.append((next_poll, conv_key, rec))
|
|
695
|
+
|
|
696
|
+
reply_messages: list[tuple[str, dict]] = []
|
|
697
|
+
for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
|
|
698
|
+
cid = rec["channel_id"]
|
|
699
|
+
thread_ts = rec["thread_ts"]
|
|
700
|
+
oldest = rec.get("last_seen_reply_ts") or thread_ts
|
|
701
|
+
try:
|
|
702
|
+
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
703
|
+
except Exception as exc:
|
|
704
|
+
message = str(exc)
|
|
705
|
+
retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
|
|
706
|
+
marker = "retry_after="
|
|
707
|
+
if marker in message:
|
|
708
|
+
raw = message.split(marker, 1)[1].split()[0]
|
|
709
|
+
try:
|
|
710
|
+
retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
|
|
711
|
+
except ValueError:
|
|
712
|
+
pass
|
|
713
|
+
rec["next_reply_poll_at"] = now + retry_after
|
|
714
|
+
rec["reply_poll_backoff_seconds"] = min(
|
|
715
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
716
|
+
max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
|
|
717
|
+
)
|
|
718
|
+
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
if replies:
|
|
722
|
+
rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
|
|
723
|
+
|
|
724
|
+
human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
|
|
725
|
+
triggered_replies = [
|
|
726
|
+
r for r in human_replies
|
|
727
|
+
if _has_trigger(r.get("text", "") or "")
|
|
728
|
+
]
|
|
729
|
+
if triggered_replies:
|
|
730
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
731
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
732
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
733
|
+
for r in triggered_replies:
|
|
734
|
+
reply_messages.append((cid, r))
|
|
735
|
+
print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
|
|
736
|
+
else:
|
|
737
|
+
if human_replies:
|
|
738
|
+
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
739
|
+
current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
|
|
740
|
+
next_backoff = min(
|
|
741
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
742
|
+
max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
|
|
743
|
+
)
|
|
744
|
+
rec["reply_poll_backoff_seconds"] = next_backoff
|
|
745
|
+
rec["next_reply_poll_at"] = now + next_backoff
|
|
746
|
+
print(f" {conv_key}: no follow-ups; next reply poll in {next_backoff}s")
|
|
747
|
+
|
|
748
|
+
return reply_messages
|
|
749
|
+
|
|
750
|
+
|
|
570
751
|
def _poll_new_messages(
|
|
571
752
|
slack_token: str,
|
|
572
753
|
use_search: bool,
|
|
573
754
|
oldest_by_channel: dict[str, str],
|
|
574
755
|
global_oldest: str,
|
|
575
756
|
active_convs: dict[str, dict],
|
|
757
|
+
bot_user_id: str,
|
|
758
|
+
bot_message_ts: list[str],
|
|
576
759
|
) -> list[tuple[str, dict]]:
|
|
577
|
-
"""Collect and sort new top-level messages and thread replies from Slack."""
|
|
760
|
+
"""Collect and sort new top-level messages and due thread replies from Slack."""
|
|
578
761
|
new_messages: list[tuple[str, dict]] = []
|
|
579
762
|
|
|
580
763
|
if use_search:
|
|
@@ -602,19 +785,9 @@ def _poll_new_messages(
|
|
|
602
785
|
except Exception as exc:
|
|
603
786
|
print(f" Warning: could not fetch history for {cid}: {exc}")
|
|
604
787
|
|
|
605
|
-
reply_messages
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
continue
|
|
609
|
-
cid = rec["channel_id"]
|
|
610
|
-
thread_ts = rec["thread_ts"]
|
|
611
|
-
oldest = oldest_by_channel.get(cid, global_oldest)
|
|
612
|
-
try:
|
|
613
|
-
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
614
|
-
for r in replies:
|
|
615
|
-
reply_messages.append((cid, r))
|
|
616
|
-
except Exception as exc:
|
|
617
|
-
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
788
|
+
reply_messages = _poll_due_thread_replies(
|
|
789
|
+
slack_token, active_convs, bot_user_id, bot_message_ts
|
|
790
|
+
)
|
|
618
791
|
|
|
619
792
|
return sorted(
|
|
620
793
|
new_messages + reply_messages,
|
|
@@ -669,10 +842,7 @@ def _process_trigger_message(
|
|
|
669
842
|
print(f" Warning: could not fetch thread history: {exc}")
|
|
670
843
|
|
|
671
844
|
# Extract the user's request: the text that follows the trigger phrase.
|
|
672
|
-
request_part = text
|
|
673
|
-
idx = text.lower().find(TRIGGER_PHRASE.lower())
|
|
674
|
-
if idx >= 0:
|
|
675
|
-
request_part = text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
|
|
845
|
+
request_part = _request_after_trigger(text)
|
|
676
846
|
|
|
677
847
|
initial_prompt = (
|
|
678
848
|
f"You are an AI assistant responding to a Slack message.\n\n"
|
|
@@ -697,12 +867,17 @@ def _process_trigger_message(
|
|
|
697
867
|
conv_id = create_conversation(agent_url, api_key, initial_prompt)
|
|
698
868
|
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
699
869
|
|
|
870
|
+
now = time.time()
|
|
700
871
|
active_convs[conv_key] = {
|
|
701
872
|
"conversation_id": conv_id,
|
|
702
873
|
"channel_id": channel_id,
|
|
703
874
|
"thread_ts": thread_root,
|
|
704
875
|
"status": "active",
|
|
705
|
-
"last_activity":
|
|
876
|
+
"last_activity": now,
|
|
877
|
+
"last_seen_reply_ts": msg_ts,
|
|
878
|
+
"reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
879
|
+
"next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
880
|
+
"watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
|
|
706
881
|
}
|
|
707
882
|
|
|
708
883
|
link_text = f"🤖 On it! View progress here: {conv_url}"
|
|
@@ -760,8 +935,16 @@ def _check_conversation_completion(
|
|
|
760
935
|
if ts_back:
|
|
761
936
|
bot_message_ts.append(ts_back)
|
|
762
937
|
|
|
763
|
-
|
|
764
|
-
|
|
938
|
+
now = time.time()
|
|
939
|
+
rec["status"] = "watching"
|
|
940
|
+
rec["last_activity"] = now
|
|
941
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
942
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
943
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
944
|
+
print(
|
|
945
|
+
f" Posted summary for {conv_key}; watching for follow-ups "
|
|
946
|
+
f"until {rec['watch_until']:.0f}"
|
|
947
|
+
)
|
|
765
948
|
|
|
766
949
|
|
|
767
950
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
@@ -776,10 +959,7 @@ def main() -> str | None:
|
|
|
776
959
|
|
|
777
960
|
slack_token, token_is_user = _resolve_slack_token()
|
|
778
961
|
|
|
779
|
-
|
|
780
|
-
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
781
|
-
except Exception:
|
|
782
|
-
openhands_url = DEFAULT_OPENHANDS_URL
|
|
962
|
+
openhands_url = resolve_openhands_url()
|
|
783
963
|
|
|
784
964
|
# Raises RuntimeError immediately if the token is invalid - no point polling.
|
|
785
965
|
bot_user_id_new, scopes = _slack_auth_test(slack_token)
|
|
@@ -808,7 +988,8 @@ def main() -> str | None:
|
|
|
808
988
|
active_convs: dict[str, dict] = state.get("conversations", {})
|
|
809
989
|
|
|
810
990
|
all_incoming = _poll_new_messages(
|
|
811
|
-
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
991
|
+
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
992
|
+
active_convs, bot_user_id, bot_message_ts,
|
|
812
993
|
)
|
|
813
994
|
|
|
814
995
|
print(f" all_incoming: {len(all_incoming)} message(s), "
|
|
@@ -848,14 +1029,16 @@ def main() -> str | None:
|
|
|
848
1029
|
thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
|
|
849
1030
|
conv_key = f"{channel_id}:{thread_root}"
|
|
850
1031
|
|
|
851
|
-
has_trigger =
|
|
1032
|
+
has_trigger = _has_trigger(text)
|
|
852
1033
|
is_thread_reply = (
|
|
853
1034
|
thread_ts is not None
|
|
854
1035
|
and thread_ts != msg_ts
|
|
855
1036
|
)
|
|
1037
|
+
tracked_rec = active_convs.get(conv_key)
|
|
856
1038
|
is_reply_in_tracked = (
|
|
857
1039
|
is_thread_reply
|
|
858
|
-
and
|
|
1040
|
+
and tracked_rec is not None
|
|
1041
|
+
and tracked_rec.get("status") in {"active", "watching"}
|
|
859
1042
|
)
|
|
860
1043
|
|
|
861
1044
|
print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
|
|
@@ -863,20 +1046,39 @@ def main() -> str | None:
|
|
|
863
1046
|
f"text={text[:60]!r}")
|
|
864
1047
|
|
|
865
1048
|
# ── Case A: reply in a thread that has a tracked conversation ──────────
|
|
866
|
-
# Route to the existing conversation
|
|
867
|
-
#
|
|
868
|
-
#
|
|
1049
|
+
# Route to the existing conversation while it is active or in its
|
|
1050
|
+
# follow-up watch window, but only when the reply includes the trigger
|
|
1051
|
+
# phrase. Once the watch expires, a new trigger in the thread creates a
|
|
1052
|
+
# fresh conversation instead.
|
|
869
1053
|
if is_reply_in_tracked:
|
|
870
1054
|
rec = active_convs[conv_key]
|
|
871
|
-
|
|
1055
|
+
rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
|
|
1056
|
+
if not has_trigger:
|
|
1057
|
+
processed_ts.add(msg_ts)
|
|
1058
|
+
print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
|
|
1059
|
+
continue
|
|
1060
|
+
|
|
1061
|
+
request_part = _request_after_trigger(text)
|
|
1062
|
+
print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
|
|
872
1063
|
try:
|
|
873
|
-
send_to_conversation(
|
|
874
|
-
|
|
1064
|
+
send_to_conversation(
|
|
1065
|
+
agent_url, api_key, rec["conversation_id"],
|
|
1066
|
+
(
|
|
1067
|
+
f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
|
|
1068
|
+
f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
|
|
1069
|
+
f"Full reply: {text}\n"
|
|
1070
|
+
f"User request: {request_part or '(no explicit request — use your best judgement)'}"
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
now = time.time()
|
|
875
1074
|
rec["status"] = "active"
|
|
876
|
-
rec["last_activity"] =
|
|
1075
|
+
rec["last_activity"] = now
|
|
1076
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
1077
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
1078
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
877
1079
|
except Exception as exc:
|
|
878
1080
|
print(f" Warning: failed to forward reply: {exc}")
|
|
879
|
-
if
|
|
1081
|
+
if can_react:
|
|
880
1082
|
add_reaction(slack_token, channel_id, msg_ts)
|
|
881
1083
|
processed_ts.add(msg_ts)
|
|
882
1084
|
continue
|
|
@@ -922,7 +1124,7 @@ def main() -> str | None:
|
|
|
922
1124
|
print(f" last_poll set to {effective_last_poll}")
|
|
923
1125
|
|
|
924
1126
|
for conv_key, rec in list(active_convs.items()):
|
|
925
|
-
if rec.get("status")
|
|
1127
|
+
if rec.get("status") == "active":
|
|
926
1128
|
_check_conversation_completion(
|
|
927
1129
|
conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
|
|
928
1130
|
)
|