@openhands/extensions 0.4.2 → 0.6.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/.github/workflows/pypi-publish.yml +104 -0
- package/.github/workflows/tests.yml +6 -0
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +1 -1
- package/README.md +31 -3
- package/automations/catalog/slack-channel-monitor.json +2 -2
- package/integrations/README.md +17 -7
- package/integrations/catalog/airtable.json +40 -10
- package/integrations/catalog/apify.json +0 -2
- package/integrations/catalog/asana.json +38 -0
- package/integrations/catalog/atlassian.json +5 -8
- package/integrations/catalog/bitbucket.json +39 -0
- package/integrations/catalog/box.json +39 -0
- package/integrations/catalog/brave-search.json +1 -2
- package/integrations/catalog/browser-mcp.json +0 -3
- package/integrations/catalog/canva.json +35 -0
- package/integrations/catalog/clickhouse.json +1 -2
- package/integrations/catalog/clickup.json +36 -0
- package/integrations/catalog/cloudflare-bindings.json +5 -8
- package/integrations/catalog/cloudflare-browser-rendering.json +5 -8
- package/integrations/catalog/cloudflare-builds.json +6 -9
- package/integrations/catalog/cloudflare-docs.json +4 -7
- package/integrations/catalog/cloudflare-observability.json +5 -8
- package/integrations/catalog/confluence.json +38 -0
- package/integrations/catalog/datadog.json +34 -0
- package/integrations/catalog/deepwiki.json +3 -7
- package/integrations/catalog/discord.json +39 -0
- package/integrations/catalog/dropbox.json +38 -0
- package/integrations/catalog/elevenlabs.json +27 -28
- package/integrations/catalog/everything.json +0 -2
- package/integrations/catalog/exa.json +0 -2
- package/integrations/catalog/fetch.json +0 -2
- package/integrations/catalog/figma.json +41 -9
- package/integrations/catalog/filesystem.json +0 -3
- package/integrations/catalog/firecrawl.json +0 -2
- package/integrations/catalog/freshdesk.json +38 -0
- package/integrations/catalog/git.json +0 -3
- package/integrations/catalog/github.json +51 -34
- package/integrations/catalog/gitlab.json +38 -0
- package/integrations/catalog/gmail.json +38 -0
- package/integrations/catalog/google-calendar.json +38 -0
- package/integrations/catalog/google-docs.json +38 -0
- package/integrations/catalog/google-drive.json +38 -0
- package/integrations/catalog/google-sheets.json +38 -0
- package/integrations/catalog/hubspot.json +34 -0
- package/integrations/catalog/huggingface.json +1 -2
- package/integrations/catalog/intercom.json +39 -0
- package/integrations/catalog/jira.json +38 -0
- package/integrations/catalog/kagi.json +2 -3
- package/integrations/catalog/linear.json +47 -16
- package/integrations/catalog/mailchimp.json +38 -0
- package/integrations/catalog/memory.json +0 -2
- package/integrations/catalog/microsoft-outlook.json +38 -0
- package/integrations/catalog/microsoft-teams.json +38 -0
- package/integrations/catalog/miro.json +38 -0
- package/integrations/catalog/monday.json +38 -0
- package/integrations/catalog/mongodb.json +1 -2
- package/integrations/catalog/neon.json +0 -2
- package/integrations/catalog/netlify.json +38 -0
- package/integrations/catalog/notion.json +37 -10
- package/integrations/catalog/obsidian.json +1 -3
- package/integrations/catalog/okta.json +38 -0
- package/integrations/catalog/onedrive.json +38 -0
- package/integrations/catalog/ordinal.json +33 -0
- package/integrations/catalog/paypal.json +5 -8
- package/integrations/catalog/pipedrive.json +39 -0
- package/integrations/catalog/plaid.json +31 -0
- package/integrations/catalog/playwright.json +0 -2
- package/integrations/catalog/posthog.json +38 -0
- package/integrations/catalog/quickbooks.json +38 -0
- package/integrations/catalog/redis.json +1 -3
- package/integrations/catalog/resend.json +1 -2
- package/integrations/catalog/salesforce.json +38 -0
- package/integrations/catalog/sentry.json +52 -14
- package/integrations/catalog/sequential-thinking.json +0 -2
- package/integrations/catalog/servicenow.json +39 -0
- package/integrations/catalog/sharepoint.json +38 -0
- package/integrations/catalog/shopify.json +38 -0
- package/integrations/catalog/slack.json +49 -10
- package/integrations/catalog/stripe.json +44 -14
- package/integrations/catalog/supabase.json +36 -9
- package/integrations/catalog/tavily.json +0 -2
- package/integrations/catalog/time.json +0 -2
- package/integrations/catalog/trello.json +31 -0
- package/integrations/catalog/vercel.json +38 -0
- package/integrations/catalog/webflow.json +34 -0
- package/integrations/catalog/xero.json +38 -0
- package/integrations/catalog/zendesk.json +38 -0
- package/integrations/catalog/zoom.json +38 -0
- package/integrations/catalog-index.js +171 -0
- package/integrations/index.d.ts +26 -60
- package/integrations/index.js +29 -207
- package/marketplaces/openhands-extensions.json +14 -1
- package/package.json +4 -7
- package/plugins/cobol-modernization/README.md +3 -3
- package/plugins/cobol-modernization/skills/{cobol-modernization-overview → cobol-modernization}/SKILL.md +1 -1
- package/plugins/magic-test/skills/magic-word/SKILL.md +1 -0
- package/plugins/migration-scoring/README.md +1 -1
- package/pyproject.toml +17 -3
- package/python/openhands_extensions/__init__.py +24 -0
- package/python/openhands_extensions/_version.py +24 -0
- package/python/openhands_extensions/integrations.py +92 -0
- package/release-please-config.json +2 -1
- package/scripts/build-integration-catalog.mjs +45 -0
- package/skills/agent-canvas-environment/.plugin/plugin.json +19 -0
- package/skills/agent-canvas-environment/README.md +5 -0
- package/skills/agent-canvas-environment/SKILL.md +141 -0
- package/skills/github-pr-reviewer/references/state-schema.md +18 -4
- package/skills/github-pr-reviewer/scripts/main.py +66 -12
- package/skills/github-repo-monitor/references/state-schema.md +11 -4
- package/skills/github-repo-monitor/scripts/main.py +67 -19
- package/skills/github-repo-monitor/tests/test_main.py +67 -8
- package/skills/index.js +19 -5
- package/skills/openhands-api/README.md +5 -1
- package/skills/openhands-api/SKILL.md +74 -5
- package/skills/openhands-automation/SKILL.md +3 -1
- package/skills/openhands-automation/references/custom-automation.md +166 -0
- package/skills/openhands-sdk/SKILL.md +8 -2
- package/skills/slack-channel-monitor/README.md +9 -5
- package/skills/slack-channel-monitor/SKILL.md +16 -6
- package/skills/slack-channel-monitor/references/slack-api.md +5 -3
- package/skills/slack-channel-monitor/references/state-schema.md +70 -36
- package/skills/slack-channel-monitor/scripts/main.py +335 -63
- package/tests/test_catalogs.py +21 -3
- package/tests/test_integration_catalog_in_sync.py +204 -0
- package/tests/test_slack_channel_monitor.py +142 -0
- package/tests/test_version_alignment.py +64 -0
- package/integrations/logos.d.ts +0 -5
- package/integrations/logos.js +0 -94
- package/integrations/oauth-provider-catalog.js +0 -553
- package/integrations/oauth-provider-registration-defaults.js +0 -610
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/SKILL.md +0 -0
- /package/plugins/cobol-modernization/skills/{mainfraime-removal → mainframe-removal}/references/cics-transformation-examples.md +0 -0
- /package/plugins/migration-scoring/skills/{migration-scoring-overview → migration-scoring}/SKILL.md +0 -0
|
@@ -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
|
|
|
@@ -39,11 +40,10 @@ import urllib.request
|
|
|
39
40
|
from datetime import datetime, timezone
|
|
40
41
|
from urllib.parse import urlencode
|
|
41
42
|
|
|
42
|
-
# ── Debug logging to a
|
|
43
|
+
# ── Debug logging to a per-run file ───────────────────────────────────────────
|
|
43
44
|
_DEBUG_LOG_PATH = os.path.join(
|
|
44
|
-
os.
|
|
45
|
-
|
|
46
|
-
"automation-state", "slack_poller_debug.log",
|
|
45
|
+
os.environ.get("WORKSPACE_BASE", "/tmp"),
|
|
46
|
+
"slack_poller_debug.log",
|
|
47
47
|
)
|
|
48
48
|
os.makedirs(os.path.dirname(_DEBUG_LOG_PATH), exist_ok=True)
|
|
49
49
|
_debug_log_fh = open(_DEBUG_LOG_PATH, "a")
|
|
@@ -85,6 +85,18 @@ CONTEXT_MESSAGE_LIMIT = 15
|
|
|
85
85
|
# How far back (seconds) to look for context when creating a new conversation.
|
|
86
86
|
CONTEXT_LOOKBACK_SECONDS = 3600 # 1 hour of recent messages for context
|
|
87
87
|
|
|
88
|
+
# Keep completed conversations available for triggered follow-up replies for a
|
|
89
|
+
# short inactivity window. Polling conversations.replies is intentionally throttled:
|
|
90
|
+
# bot-token Slack apps may have a very small per-method quota, so each thread
|
|
91
|
+
# carries its own exponential backoff and each automation run polls at most one
|
|
92
|
+
# due thread. Start hot immediately after each bot reply, then back off
|
|
93
|
+
# on quiet threads.
|
|
94
|
+
THREAD_FOLLOWUP_WATCH_SECONDS = 300
|
|
95
|
+
THREAD_REPLY_INITIAL_BACKOFF_SECONDS = 5
|
|
96
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS = 300
|
|
97
|
+
THREAD_REPLY_BACKOFF_MULTIPLIER = 2
|
|
98
|
+
MAX_THREAD_REPLY_POLLS_PER_RUN = 1
|
|
99
|
+
|
|
88
100
|
|
|
89
101
|
# ── Stdlib helpers ─────────────────────────────────────────────────────────────
|
|
90
102
|
|
|
@@ -108,6 +120,55 @@ def get_secret(name: str) -> str:
|
|
|
108
120
|
return r.read().decode().strip()
|
|
109
121
|
|
|
110
122
|
|
|
123
|
+
def _is_loopback_openhands_url(url: str) -> bool:
|
|
124
|
+
return url.startswith((
|
|
125
|
+
"http://localhost",
|
|
126
|
+
"https://localhost",
|
|
127
|
+
"http://127.0.0.1",
|
|
128
|
+
"https://127.0.0.1",
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _ngrok_public_openhands_url() -> str | None:
|
|
133
|
+
try:
|
|
134
|
+
with urllib.request.urlopen(
|
|
135
|
+
"http://127.0.0.1:4040/api/tunnels", timeout=2
|
|
136
|
+
) as response:
|
|
137
|
+
tunnels = json.loads(response.read()).get("tunnels", [])
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
print(f"Could not discover ngrok public URL: {exc}")
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
for tunnel in tunnels:
|
|
143
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
144
|
+
addr = str(tunnel.get("config", {}).get("addr", ""))
|
|
145
|
+
if (
|
|
146
|
+
public_url.startswith("https://")
|
|
147
|
+
and ("localhost:8000" in addr or "127.0.0.1:8000" in addr)
|
|
148
|
+
):
|
|
149
|
+
return public_url
|
|
150
|
+
|
|
151
|
+
for tunnel in tunnels:
|
|
152
|
+
public_url = str(tunnel.get("public_url", "")).rstrip("/")
|
|
153
|
+
if public_url.startswith("https://"):
|
|
154
|
+
return public_url
|
|
155
|
+
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolve_openhands_url() -> str:
|
|
160
|
+
try:
|
|
161
|
+
configured = get_secret("OPENHANDS_URL").rstrip()
|
|
162
|
+
except Exception:
|
|
163
|
+
configured = ""
|
|
164
|
+
|
|
165
|
+
configured = (configured or DEFAULT_OPENHANDS_URL).rstrip("/")
|
|
166
|
+
if not _is_loopback_openhands_url(configured):
|
|
167
|
+
return configured
|
|
168
|
+
|
|
169
|
+
return _ngrok_public_openhands_url() or configured
|
|
170
|
+
|
|
171
|
+
|
|
111
172
|
def fire_callback(
|
|
112
173
|
status: str = "COMPLETED",
|
|
113
174
|
error: str | None = None,
|
|
@@ -136,14 +197,47 @@ def fire_callback(
|
|
|
136
197
|
print(f"Callback error (non-fatal): {exc}")
|
|
137
198
|
|
|
138
199
|
|
|
139
|
-
# ── State
|
|
200
|
+
# ── State persistence (KV store with local-file fallback) ─────────────────────
|
|
140
201
|
|
|
141
|
-
|
|
142
|
-
|
|
202
|
+
_KV_TOKEN = os.environ.get("AUTOMATION_KV_TOKEN", "")
|
|
203
|
+
_KV_BASE = os.environ.get("AUTOMATION_API_URL", "").rstrip("/")
|
|
204
|
+
_STATE_KEY = "state"
|
|
143
205
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
206
|
+
|
|
207
|
+
def _kv_available() -> bool:
|
|
208
|
+
return bool(_KV_TOKEN and _KV_BASE)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _kv_get(key: str) -> dict | None:
|
|
212
|
+
req = urllib.request.Request(
|
|
213
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
214
|
+
headers={"Authorization": f"Bearer {_KV_TOKEN}"},
|
|
215
|
+
)
|
|
216
|
+
try:
|
|
217
|
+
with urllib.request.urlopen(req) as r:
|
|
218
|
+
return json.loads(r.read())["value"]
|
|
219
|
+
except urllib.error.HTTPError as exc:
|
|
220
|
+
if exc.code == 404:
|
|
221
|
+
return None
|
|
222
|
+
raise
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _kv_set(key: str, value: dict) -> None:
|
|
226
|
+
req = urllib.request.Request(
|
|
227
|
+
f"{_KV_BASE}/v1/kv/{key}",
|
|
228
|
+
data=json.dumps(value).encode(),
|
|
229
|
+
headers={
|
|
230
|
+
"Authorization": f"Bearer {_KV_TOKEN}",
|
|
231
|
+
"Content-Type": "application/json",
|
|
232
|
+
},
|
|
233
|
+
method="PUT",
|
|
234
|
+
)
|
|
235
|
+
with urllib.request.urlopen(req) as r:
|
|
236
|
+
r.read()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _state_file_path() -> str:
|
|
240
|
+
"""Derive a persistent storage path from WORKSPACE_BASE (file fallback only)."""
|
|
147
241
|
workspace_base = os.environ.get("WORKSPACE_BASE", "")
|
|
148
242
|
event_payload = json.loads(os.environ.get("AUTOMATION_EVENT_PAYLOAD", "{}"))
|
|
149
243
|
automation_id = event_payload.get("automation_id", "default")
|
|
@@ -158,22 +252,42 @@ def _state_file_path() -> str:
|
|
|
158
252
|
return os.path.join(state_dir, f"slack_poller_{automation_id}.json")
|
|
159
253
|
|
|
160
254
|
|
|
161
|
-
def
|
|
162
|
-
if os.path.exists(path):
|
|
163
|
-
return json.load(open(path))
|
|
255
|
+
def _default_state() -> dict:
|
|
164
256
|
return {
|
|
165
257
|
"version": 1,
|
|
166
258
|
"bot_user_id": None,
|
|
167
|
-
"last_poll": {},
|
|
168
|
-
"conversations": {},
|
|
169
|
-
"bot_message_ts": [],
|
|
170
|
-
"processed_ts": [],
|
|
259
|
+
"last_poll": {},
|
|
260
|
+
"conversations": {},
|
|
261
|
+
"bot_message_ts": [],
|
|
262
|
+
"processed_ts": [],
|
|
171
263
|
}
|
|
172
264
|
|
|
173
265
|
|
|
174
|
-
def
|
|
266
|
+
def load_state() -> dict:
|
|
267
|
+
if _kv_available():
|
|
268
|
+
data = _kv_get(_STATE_KEY)
|
|
269
|
+
if data is not None:
|
|
270
|
+
print("State loaded from KV store")
|
|
271
|
+
return data
|
|
272
|
+
return _default_state()
|
|
273
|
+
path = _state_file_path()
|
|
274
|
+
if os.path.exists(path):
|
|
275
|
+
try:
|
|
276
|
+
return json.load(open(path))
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
print(f"Warning: state file {path} unreadable ({exc}); starting fresh")
|
|
279
|
+
return _default_state()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def save_state(state: dict) -> None:
|
|
283
|
+
if _kv_available():
|
|
284
|
+
_kv_set(_STATE_KEY, state)
|
|
285
|
+
print("State saved to KV store")
|
|
286
|
+
return
|
|
287
|
+
path = _state_file_path()
|
|
175
288
|
with open(path, "w") as f:
|
|
176
289
|
json.dump(state, f, indent=2)
|
|
290
|
+
print(f"State saved to {path}")
|
|
177
291
|
|
|
178
292
|
|
|
179
293
|
# ── Slack API helpers ──────────────────────────────────────────────────────────
|
|
@@ -195,8 +309,14 @@ def _slack_call(
|
|
|
195
309
|
}
|
|
196
310
|
data = json.dumps(body).encode() if body is not None else None
|
|
197
311
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
198
|
-
|
|
199
|
-
|
|
312
|
+
try:
|
|
313
|
+
with urllib.request.urlopen(req) as r:
|
|
314
|
+
result = json.loads(r.read())
|
|
315
|
+
except urllib.error.HTTPError as exc:
|
|
316
|
+
if exc.code == 429:
|
|
317
|
+
retry_after = exc.headers.get("Retry-After", "60")
|
|
318
|
+
raise RuntimeError(f"Slack {endpoint}: rate_limited retry_after={retry_after}") from exc
|
|
319
|
+
raise
|
|
200
320
|
if not result.get("ok"):
|
|
201
321
|
raise RuntimeError(f"Slack {endpoint}: {result.get('error', 'unknown_error')}")
|
|
202
322
|
return result
|
|
@@ -243,7 +363,12 @@ def add_reaction(token: str, channel: str, ts: str, emoji: str = "eyes") -> None
|
|
|
243
363
|
|
|
244
364
|
def post_message(token: str, channel: str, text: str, thread_ts: str | None = None) -> str:
|
|
245
365
|
"""Post a Slack message and return its timestamp."""
|
|
246
|
-
body: dict = {
|
|
366
|
+
body: dict = {
|
|
367
|
+
"channel": channel,
|
|
368
|
+
"markdown_text": text,
|
|
369
|
+
"unfurl_links": False,
|
|
370
|
+
"unfurl_media": False,
|
|
371
|
+
}
|
|
247
372
|
if thread_ts:
|
|
248
373
|
body["thread_ts"] = thread_ts
|
|
249
374
|
return slack_post(token, "chat.postMessage", body).get("ts", "")
|
|
@@ -273,6 +398,36 @@ def thread_replies(token: str, channel: str, thread_ts: str, oldest: str) -> lis
|
|
|
273
398
|
return [m for m in messages if m.get("ts") != thread_ts]
|
|
274
399
|
|
|
275
400
|
|
|
401
|
+
def _trigger_index(text: str) -> int:
|
|
402
|
+
"""Return the start index of an exact trigger phrase match, or -1."""
|
|
403
|
+
lowered = text.lower()
|
|
404
|
+
trigger = TRIGGER_PHRASE.lower()
|
|
405
|
+
start = 0
|
|
406
|
+
while True:
|
|
407
|
+
idx = lowered.find(trigger, start)
|
|
408
|
+
if idx < 0:
|
|
409
|
+
return -1
|
|
410
|
+
before = lowered[idx - 1] if idx > 0 else ""
|
|
411
|
+
after_idx = idx + len(trigger)
|
|
412
|
+
after = lowered[after_idx] if after_idx < len(lowered) else ""
|
|
413
|
+
before_ok = not before or not (before.isalnum() or before in "_-")
|
|
414
|
+
after_ok = not after or not (after.isalnum() or after in "_-")
|
|
415
|
+
if before_ok and after_ok:
|
|
416
|
+
return idx
|
|
417
|
+
start = idx + 1
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _has_trigger(text: str) -> bool:
|
|
421
|
+
return _trigger_index(text) >= 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _request_after_trigger(text: str) -> str:
|
|
425
|
+
idx = _trigger_index(text)
|
|
426
|
+
if idx < 0:
|
|
427
|
+
return text
|
|
428
|
+
return text[idx + len(TRIGGER_PHRASE):].strip(" :–—")
|
|
429
|
+
|
|
430
|
+
|
|
276
431
|
def full_thread_history(
|
|
277
432
|
token: str, channel: str, thread_ts: str,
|
|
278
433
|
bot_user_id: str, bot_message_ts: list[str],
|
|
@@ -567,14 +722,114 @@ def _gather_channel_context(
|
|
|
567
722
|
return context_lines
|
|
568
723
|
|
|
569
724
|
|
|
725
|
+
def _close_thread_watch(rec: dict, conv_key: str, now: float) -> None:
|
|
726
|
+
rec["status"] = "closed"
|
|
727
|
+
rec["closed_reason"] = "followup_watch_expired"
|
|
728
|
+
rec["closed_at"] = now
|
|
729
|
+
print(f" Follow-up watch expired for {conv_key}")
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _next_reply_poll_at(rec: dict, now: float, delay: int) -> float:
|
|
733
|
+
next_poll_at = now + delay
|
|
734
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
735
|
+
if rec.get("status") == "watching" and watch_until:
|
|
736
|
+
next_poll_at = min(next_poll_at, watch_until)
|
|
737
|
+
return next_poll_at
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _poll_due_thread_replies(
|
|
741
|
+
slack_token: str,
|
|
742
|
+
active_convs: dict[str, dict],
|
|
743
|
+
bot_user_id: str,
|
|
744
|
+
bot_message_ts: list[str],
|
|
745
|
+
) -> list[tuple[str, dict]]:
|
|
746
|
+
now = time.time()
|
|
747
|
+
due: list[tuple[float, str, dict]] = []
|
|
748
|
+
for conv_key, rec in active_convs.items():
|
|
749
|
+
if rec.get("status") not in {"active", "watching"}:
|
|
750
|
+
continue
|
|
751
|
+
next_poll = float(rec.get("next_reply_poll_at") or 0)
|
|
752
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
753
|
+
if rec.get("status") == "watching" and watch_until and now >= watch_until:
|
|
754
|
+
next_poll = min(next_poll or watch_until, watch_until)
|
|
755
|
+
if next_poll <= now:
|
|
756
|
+
due.append((next_poll, conv_key, rec))
|
|
757
|
+
|
|
758
|
+
reply_messages: list[tuple[str, dict]] = []
|
|
759
|
+
for _next_poll, conv_key, rec in sorted(due)[:MAX_THREAD_REPLY_POLLS_PER_RUN]:
|
|
760
|
+
cid = rec["channel_id"]
|
|
761
|
+
thread_ts = rec["thread_ts"]
|
|
762
|
+
oldest = rec.get("last_seen_reply_ts") or thread_ts
|
|
763
|
+
watch_until = float(rec.get("watch_until") or 0)
|
|
764
|
+
watch_expired = rec.get("status") == "watching" and watch_until and now >= watch_until
|
|
765
|
+
try:
|
|
766
|
+
replies = thread_replies(slack_token, cid, thread_ts, oldest)
|
|
767
|
+
except Exception as exc:
|
|
768
|
+
message = str(exc)
|
|
769
|
+
retry_after = THREAD_REPLY_MAX_BACKOFF_SECONDS
|
|
770
|
+
marker = "retry_after="
|
|
771
|
+
if marker in message:
|
|
772
|
+
raw = message.split(marker, 1)[1].split()[0]
|
|
773
|
+
try:
|
|
774
|
+
retry_after = max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, int(raw))
|
|
775
|
+
except ValueError:
|
|
776
|
+
pass
|
|
777
|
+
if watch_expired:
|
|
778
|
+
_close_thread_watch(rec, conv_key, now)
|
|
779
|
+
else:
|
|
780
|
+
rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, retry_after)
|
|
781
|
+
rec["reply_poll_backoff_seconds"] = min(
|
|
782
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
783
|
+
max(retry_after, int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)),
|
|
784
|
+
)
|
|
785
|
+
print(f" Warning: could not fetch replies for thread {thread_ts}: {exc}")
|
|
786
|
+
continue
|
|
787
|
+
|
|
788
|
+
if replies:
|
|
789
|
+
rec["last_seen_reply_ts"] = max(r.get("ts", oldest) for r in replies)
|
|
790
|
+
|
|
791
|
+
human_replies = [r for r in replies if _is_human_message(r, bot_user_id, bot_message_ts)]
|
|
792
|
+
triggered_replies = [
|
|
793
|
+
r for r in human_replies
|
|
794
|
+
if _has_trigger(r.get("text", "") or "")
|
|
795
|
+
]
|
|
796
|
+
if triggered_replies:
|
|
797
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
798
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
799
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
800
|
+
for r in triggered_replies:
|
|
801
|
+
reply_messages.append((cid, r))
|
|
802
|
+
print(f" {conv_key}: {len(triggered_replies)} triggered follow-up reply/replies")
|
|
803
|
+
elif watch_expired:
|
|
804
|
+
if human_replies:
|
|
805
|
+
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
806
|
+
_close_thread_watch(rec, conv_key, now)
|
|
807
|
+
else:
|
|
808
|
+
if human_replies:
|
|
809
|
+
print(f" {conv_key}: ignored {len(human_replies)} follow-up reply/replies without trigger")
|
|
810
|
+
current = int(rec.get("reply_poll_backoff_seconds") or THREAD_REPLY_INITIAL_BACKOFF_SECONDS)
|
|
811
|
+
next_backoff = min(
|
|
812
|
+
THREAD_REPLY_MAX_BACKOFF_SECONDS,
|
|
813
|
+
max(THREAD_REPLY_INITIAL_BACKOFF_SECONDS, current * THREAD_REPLY_BACKOFF_MULTIPLIER),
|
|
814
|
+
)
|
|
815
|
+
rec["reply_poll_backoff_seconds"] = next_backoff
|
|
816
|
+
rec["next_reply_poll_at"] = _next_reply_poll_at(rec, now, next_backoff)
|
|
817
|
+
next_delay = max(0, int(rec["next_reply_poll_at"] - now))
|
|
818
|
+
print(f" {conv_key}: no follow-ups; next reply poll in {next_delay}s")
|
|
819
|
+
|
|
820
|
+
return reply_messages
|
|
821
|
+
|
|
822
|
+
|
|
570
823
|
def _poll_new_messages(
|
|
571
824
|
slack_token: str,
|
|
572
825
|
use_search: bool,
|
|
573
826
|
oldest_by_channel: dict[str, str],
|
|
574
827
|
global_oldest: str,
|
|
575
828
|
active_convs: dict[str, dict],
|
|
829
|
+
bot_user_id: str,
|
|
830
|
+
bot_message_ts: list[str],
|
|
576
831
|
) -> list[tuple[str, dict]]:
|
|
577
|
-
"""Collect and sort new top-level messages and thread replies from Slack."""
|
|
832
|
+
"""Collect and sort new top-level messages and due thread replies from Slack."""
|
|
578
833
|
new_messages: list[tuple[str, dict]] = []
|
|
579
834
|
|
|
580
835
|
if use_search:
|
|
@@ -602,19 +857,9 @@ def _poll_new_messages(
|
|
|
602
857
|
except Exception as exc:
|
|
603
858
|
print(f" Warning: could not fetch history for {cid}: {exc}")
|
|
604
859
|
|
|
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}")
|
|
860
|
+
reply_messages = _poll_due_thread_replies(
|
|
861
|
+
slack_token, active_convs, bot_user_id, bot_message_ts
|
|
862
|
+
)
|
|
618
863
|
|
|
619
864
|
return sorted(
|
|
620
865
|
new_messages + reply_messages,
|
|
@@ -669,10 +914,7 @@ def _process_trigger_message(
|
|
|
669
914
|
print(f" Warning: could not fetch thread history: {exc}")
|
|
670
915
|
|
|
671
916
|
# 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(" :–—")
|
|
917
|
+
request_part = _request_after_trigger(text)
|
|
676
918
|
|
|
677
919
|
initial_prompt = (
|
|
678
920
|
f"You are an AI assistant responding to a Slack message.\n\n"
|
|
@@ -697,12 +939,17 @@ def _process_trigger_message(
|
|
|
697
939
|
conv_id = create_conversation(agent_url, api_key, initial_prompt)
|
|
698
940
|
conv_url = f"{openhands_url}/conversations/{conv_id}"
|
|
699
941
|
|
|
942
|
+
now = time.time()
|
|
700
943
|
active_convs[conv_key] = {
|
|
701
944
|
"conversation_id": conv_id,
|
|
702
945
|
"channel_id": channel_id,
|
|
703
946
|
"thread_ts": thread_root,
|
|
704
947
|
"status": "active",
|
|
705
|
-
"last_activity":
|
|
948
|
+
"last_activity": now,
|
|
949
|
+
"last_seen_reply_ts": msg_ts,
|
|
950
|
+
"reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
951
|
+
"next_reply_poll_at": now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
|
|
952
|
+
"watch_until": now + THREAD_FOLLOWUP_WATCH_SECONDS,
|
|
706
953
|
}
|
|
707
954
|
|
|
708
955
|
link_text = f"🤖 On it! View progress here: {conv_url}"
|
|
@@ -760,26 +1007,30 @@ def _check_conversation_completion(
|
|
|
760
1007
|
if ts_back:
|
|
761
1008
|
bot_message_ts.append(ts_back)
|
|
762
1009
|
|
|
763
|
-
|
|
764
|
-
|
|
1010
|
+
now = time.time()
|
|
1011
|
+
rec["status"] = "watching"
|
|
1012
|
+
rec["last_activity"] = now
|
|
1013
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
1014
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
1015
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
1016
|
+
print(
|
|
1017
|
+
f" Posted summary for {conv_key}; watching for follow-ups "
|
|
1018
|
+
f"until {rec['watch_until']:.0f}"
|
|
1019
|
+
)
|
|
765
1020
|
|
|
766
1021
|
|
|
767
1022
|
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
768
1023
|
|
|
769
1024
|
def main() -> str | None:
|
|
770
1025
|
"""Run one polling cycle. Returns the last conversation ID created, if any."""
|
|
771
|
-
|
|
772
|
-
state = load_state(state_path)
|
|
1026
|
+
state = load_state()
|
|
773
1027
|
|
|
774
1028
|
agent_url = os.environ.get("AGENT_SERVER_URL", "").rstrip("/")
|
|
775
1029
|
api_key = _get_env_key()
|
|
776
1030
|
|
|
777
1031
|
slack_token, token_is_user = _resolve_slack_token()
|
|
778
1032
|
|
|
779
|
-
|
|
780
|
-
openhands_url = get_secret("OPENHANDS_URL").rstrip("/") or DEFAULT_OPENHANDS_URL
|
|
781
|
-
except Exception:
|
|
782
|
-
openhands_url = DEFAULT_OPENHANDS_URL
|
|
1033
|
+
openhands_url = resolve_openhands_url()
|
|
783
1034
|
|
|
784
1035
|
# Raises RuntimeError immediately if the token is invalid - no point polling.
|
|
785
1036
|
bot_user_id_new, scopes = _slack_auth_test(slack_token)
|
|
@@ -808,7 +1059,8 @@ def main() -> str | None:
|
|
|
808
1059
|
active_convs: dict[str, dict] = state.get("conversations", {})
|
|
809
1060
|
|
|
810
1061
|
all_incoming = _poll_new_messages(
|
|
811
|
-
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
1062
|
+
slack_token, use_search, oldest_by_channel, global_oldest,
|
|
1063
|
+
active_convs, bot_user_id, bot_message_ts,
|
|
812
1064
|
)
|
|
813
1065
|
|
|
814
1066
|
print(f" all_incoming: {len(all_incoming)} message(s), "
|
|
@@ -848,14 +1100,16 @@ def main() -> str | None:
|
|
|
848
1100
|
thread_root: str = thread_ts if thread_ts and thread_ts != msg_ts else msg_ts
|
|
849
1101
|
conv_key = f"{channel_id}:{thread_root}"
|
|
850
1102
|
|
|
851
|
-
has_trigger =
|
|
1103
|
+
has_trigger = _has_trigger(text)
|
|
852
1104
|
is_thread_reply = (
|
|
853
1105
|
thread_ts is not None
|
|
854
1106
|
and thread_ts != msg_ts
|
|
855
1107
|
)
|
|
1108
|
+
tracked_rec = active_convs.get(conv_key)
|
|
856
1109
|
is_reply_in_tracked = (
|
|
857
1110
|
is_thread_reply
|
|
858
|
-
and
|
|
1111
|
+
and tracked_rec is not None
|
|
1112
|
+
and tracked_rec.get("status") in {"active", "watching"}
|
|
859
1113
|
)
|
|
860
1114
|
|
|
861
1115
|
print(f" EVAL: ts={msg_ts} trigger={has_trigger} reply={is_thread_reply} "
|
|
@@ -863,20 +1117,39 @@ def main() -> str | None:
|
|
|
863
1117
|
f"text={text[:60]!r}")
|
|
864
1118
|
|
|
865
1119
|
# ── Case A: reply in a thread that has a tracked conversation ──────────
|
|
866
|
-
# Route to the existing conversation
|
|
867
|
-
#
|
|
868
|
-
#
|
|
1120
|
+
# Route to the existing conversation while it is active or in its
|
|
1121
|
+
# follow-up watch window, but only when the reply includes the trigger
|
|
1122
|
+
# phrase. Once the watch expires, a new trigger in the thread creates a
|
|
1123
|
+
# fresh conversation instead.
|
|
869
1124
|
if is_reply_in_tracked:
|
|
870
1125
|
rec = active_convs[conv_key]
|
|
871
|
-
|
|
1126
|
+
rec["last_seen_reply_ts"] = max(rec.get("last_seen_reply_ts", msg_ts), msg_ts)
|
|
1127
|
+
if not has_trigger:
|
|
1128
|
+
processed_ts.add(msg_ts)
|
|
1129
|
+
print(f" → Case A ignored (tracked reply without trigger): {msg_ts}")
|
|
1130
|
+
continue
|
|
1131
|
+
|
|
1132
|
+
request_part = _request_after_trigger(text)
|
|
1133
|
+
print(f" → Case A: Forwarding triggered reply {msg_ts} → conversation {rec['conversation_id']}")
|
|
872
1134
|
try:
|
|
873
|
-
send_to_conversation(
|
|
874
|
-
|
|
1135
|
+
send_to_conversation(
|
|
1136
|
+
agent_url, api_key, rec["conversation_id"],
|
|
1137
|
+
(
|
|
1138
|
+
f"User <@{msg.get('user', '?')}> replied in Slack thread.\n"
|
|
1139
|
+
f"The reply was activated by the trigger phrase: `{TRIGGER_PHRASE}`\n"
|
|
1140
|
+
f"Full reply: {text}\n"
|
|
1141
|
+
f"User request: {request_part or '(no explicit request — use your best judgement)'}"
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
now = time.time()
|
|
875
1145
|
rec["status"] = "active"
|
|
876
|
-
rec["last_activity"] =
|
|
1146
|
+
rec["last_activity"] = now
|
|
1147
|
+
rec["watch_until"] = now + THREAD_FOLLOWUP_WATCH_SECONDS
|
|
1148
|
+
rec["reply_poll_backoff_seconds"] = THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
1149
|
+
rec["next_reply_poll_at"] = now + THREAD_REPLY_INITIAL_BACKOFF_SECONDS
|
|
877
1150
|
except Exception as exc:
|
|
878
1151
|
print(f" Warning: failed to forward reply: {exc}")
|
|
879
|
-
if
|
|
1152
|
+
if can_react:
|
|
880
1153
|
add_reaction(slack_token, channel_id, msg_ts)
|
|
881
1154
|
processed_ts.add(msg_ts)
|
|
882
1155
|
continue
|
|
@@ -922,7 +1195,7 @@ def main() -> str | None:
|
|
|
922
1195
|
print(f" last_poll set to {effective_last_poll}")
|
|
923
1196
|
|
|
924
1197
|
for conv_key, rec in list(active_convs.items()):
|
|
925
|
-
if rec.get("status")
|
|
1198
|
+
if rec.get("status") == "active":
|
|
926
1199
|
_check_conversation_completion(
|
|
927
1200
|
conv_key, rec, agent_url, api_key, slack_token, bot_message_ts,
|
|
928
1201
|
)
|
|
@@ -937,8 +1210,7 @@ def main() -> str | None:
|
|
|
937
1210
|
state["processed_ts"] = processed_list[-MAX_PROCESSED_TS:]
|
|
938
1211
|
|
|
939
1212
|
state["conversations"] = active_convs
|
|
940
|
-
save_state(
|
|
941
|
-
print(f"State saved to {state_path}")
|
|
1213
|
+
save_state(state)
|
|
942
1214
|
return last_conversation_id
|
|
943
1215
|
|
|
944
1216
|
|
package/tests/test_catalogs.py
CHANGED
|
@@ -40,10 +40,10 @@ def test_catalog_entries_have_required_fields():
|
|
|
40
40
|
assert entry["id"]
|
|
41
41
|
assert entry["name"]
|
|
42
42
|
assert entry["description"]
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
# iconBg/iconColor are optional UI styling hints (OAuth-only entries may
|
|
44
|
+
# ship without a bespoke icon background).
|
|
45
|
+
assert "iconBg" not in entry or entry["iconBg"]
|
|
45
46
|
assert entry["connectionOptions"]
|
|
46
|
-
assert entry["defaultConnectionOptionId"]
|
|
47
47
|
for option in entry["connectionOptions"]:
|
|
48
48
|
assert option["id"]
|
|
49
49
|
assert option["provider"] in {"mcp", "http"}
|
|
@@ -72,6 +72,24 @@ def test_catalog_entries_have_required_fields():
|
|
|
72
72
|
assert isinstance(entry["estimatedSetupMinutes"], int)
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def test_remote_no_auth_mcp_entries_are_intentionally_public():
|
|
76
|
+
public_remote_mcp_ids = {"cloudflare-docs", "deepwiki", "huggingface"}
|
|
77
|
+
|
|
78
|
+
actual = set()
|
|
79
|
+
for entry in load_catalog_entries("integrations/catalog"):
|
|
80
|
+
for option in entry["connectionOptions"]:
|
|
81
|
+
transport = option.get("transport", {})
|
|
82
|
+
if (
|
|
83
|
+
option["provider"] == "mcp"
|
|
84
|
+
and option["auth"]["strategy"] == "none"
|
|
85
|
+
and transport.get("url", "").startswith("https://")
|
|
86
|
+
):
|
|
87
|
+
actual.add(entry["id"])
|
|
88
|
+
assert transport["kind"] == "shttp"
|
|
89
|
+
|
|
90
|
+
assert actual == public_remote_mcp_ids
|
|
91
|
+
|
|
92
|
+
|
|
75
93
|
def test_credential_fields_have_helper_text_and_link():
|
|
76
94
|
"""All password fields must have helperText plus a link (either a helperLink field or a
|
|
77
95
|
markdown link embedded in helperText) so users know how to get credentials."""
|