aethergraph 0.1.0a1__py3-none-any.whl
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.
- aethergraph/__init__.py +49 -0
- aethergraph/config/__init__.py +0 -0
- aethergraph/config/config.py +121 -0
- aethergraph/config/context.py +16 -0
- aethergraph/config/llm.py +26 -0
- aethergraph/config/loader.py +60 -0
- aethergraph/config/runtime.py +9 -0
- aethergraph/contracts/errors/errors.py +44 -0
- aethergraph/contracts/services/artifacts.py +142 -0
- aethergraph/contracts/services/channel.py +72 -0
- aethergraph/contracts/services/continuations.py +23 -0
- aethergraph/contracts/services/eventbus.py +12 -0
- aethergraph/contracts/services/kv.py +24 -0
- aethergraph/contracts/services/llm.py +17 -0
- aethergraph/contracts/services/mcp.py +22 -0
- aethergraph/contracts/services/memory.py +108 -0
- aethergraph/contracts/services/resume.py +28 -0
- aethergraph/contracts/services/state_stores.py +33 -0
- aethergraph/contracts/services/wakeup.py +28 -0
- aethergraph/core/execution/base_scheduler.py +77 -0
- aethergraph/core/execution/forward_scheduler.py +777 -0
- aethergraph/core/execution/global_scheduler.py +634 -0
- aethergraph/core/execution/retry_policy.py +22 -0
- aethergraph/core/execution/step_forward.py +411 -0
- aethergraph/core/execution/step_result.py +18 -0
- aethergraph/core/execution/wait_types.py +72 -0
- aethergraph/core/graph/graph_builder.py +192 -0
- aethergraph/core/graph/graph_fn.py +219 -0
- aethergraph/core/graph/graph_io.py +67 -0
- aethergraph/core/graph/graph_refs.py +154 -0
- aethergraph/core/graph/graph_spec.py +115 -0
- aethergraph/core/graph/graph_state.py +59 -0
- aethergraph/core/graph/graphify.py +128 -0
- aethergraph/core/graph/interpreter.py +145 -0
- aethergraph/core/graph/node_handle.py +33 -0
- aethergraph/core/graph/node_spec.py +46 -0
- aethergraph/core/graph/node_state.py +63 -0
- aethergraph/core/graph/task_graph.py +747 -0
- aethergraph/core/graph/task_node.py +82 -0
- aethergraph/core/graph/utils.py +37 -0
- aethergraph/core/graph/visualize.py +239 -0
- aethergraph/core/runtime/ad_hoc_context.py +61 -0
- aethergraph/core/runtime/base_service.py +153 -0
- aethergraph/core/runtime/bind_adapter.py +42 -0
- aethergraph/core/runtime/bound_memory.py +69 -0
- aethergraph/core/runtime/execution_context.py +220 -0
- aethergraph/core/runtime/graph_runner.py +349 -0
- aethergraph/core/runtime/lifecycle.py +26 -0
- aethergraph/core/runtime/node_context.py +203 -0
- aethergraph/core/runtime/node_services.py +30 -0
- aethergraph/core/runtime/recovery.py +159 -0
- aethergraph/core/runtime/run_registration.py +33 -0
- aethergraph/core/runtime/runtime_env.py +157 -0
- aethergraph/core/runtime/runtime_registry.py +32 -0
- aethergraph/core/runtime/runtime_services.py +224 -0
- aethergraph/core/runtime/wakeup_watcher.py +40 -0
- aethergraph/core/tools/__init__.py +10 -0
- aethergraph/core/tools/builtins/channel_tools.py +194 -0
- aethergraph/core/tools/builtins/toolset.py +134 -0
- aethergraph/core/tools/toolkit.py +510 -0
- aethergraph/core/tools/waitable.py +109 -0
- aethergraph/plugins/channel/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/__init__.py +0 -0
- aethergraph/plugins/channel/adapters/console.py +106 -0
- aethergraph/plugins/channel/adapters/file.py +102 -0
- aethergraph/plugins/channel/adapters/slack.py +285 -0
- aethergraph/plugins/channel/adapters/telegram.py +302 -0
- aethergraph/plugins/channel/adapters/webhook.py +104 -0
- aethergraph/plugins/channel/adapters/webui.py +134 -0
- aethergraph/plugins/channel/routes/__init__.py +0 -0
- aethergraph/plugins/channel/routes/console_routes.py +86 -0
- aethergraph/plugins/channel/routes/slack_routes.py +49 -0
- aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
- aethergraph/plugins/channel/routes/webui_routes.py +136 -0
- aethergraph/plugins/channel/utils/__init__.py +0 -0
- aethergraph/plugins/channel/utils/slack_utils.py +278 -0
- aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
- aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
- aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
- aethergraph/plugins/mcp/fs_server.py +128 -0
- aethergraph/plugins/mcp/http_server.py +101 -0
- aethergraph/plugins/mcp/ws_server.py +180 -0
- aethergraph/plugins/net/http.py +10 -0
- aethergraph/plugins/utils/data_io.py +359 -0
- aethergraph/runner/__init__.py +5 -0
- aethergraph/runtime/__init__.py +62 -0
- aethergraph/server/__init__.py +3 -0
- aethergraph/server/app_factory.py +84 -0
- aethergraph/server/start.py +122 -0
- aethergraph/services/__init__.py +10 -0
- aethergraph/services/artifacts/facade.py +284 -0
- aethergraph/services/artifacts/factory.py +35 -0
- aethergraph/services/artifacts/fs_store.py +656 -0
- aethergraph/services/artifacts/jsonl_index.py +123 -0
- aethergraph/services/artifacts/paths.py +23 -0
- aethergraph/services/artifacts/sqlite_index.py +209 -0
- aethergraph/services/artifacts/utils.py +124 -0
- aethergraph/services/auth/dev.py +16 -0
- aethergraph/services/channel/channel_bus.py +293 -0
- aethergraph/services/channel/factory.py +44 -0
- aethergraph/services/channel/session.py +511 -0
- aethergraph/services/channel/wait_helpers.py +57 -0
- aethergraph/services/clock/clock.py +9 -0
- aethergraph/services/container/default_container.py +320 -0
- aethergraph/services/continuations/continuation.py +56 -0
- aethergraph/services/continuations/factory.py +34 -0
- aethergraph/services/continuations/stores/fs_store.py +264 -0
- aethergraph/services/continuations/stores/inmem_store.py +95 -0
- aethergraph/services/eventbus/inmem.py +21 -0
- aethergraph/services/features/static.py +10 -0
- aethergraph/services/kv/ephemeral.py +90 -0
- aethergraph/services/kv/factory.py +27 -0
- aethergraph/services/kv/layered.py +41 -0
- aethergraph/services/kv/sqlite_kv.py +128 -0
- aethergraph/services/llm/factory.py +157 -0
- aethergraph/services/llm/generic_client.py +542 -0
- aethergraph/services/llm/providers.py +3 -0
- aethergraph/services/llm/service.py +105 -0
- aethergraph/services/logger/base.py +36 -0
- aethergraph/services/logger/compat.py +50 -0
- aethergraph/services/logger/formatters.py +106 -0
- aethergraph/services/logger/std.py +203 -0
- aethergraph/services/mcp/helpers.py +23 -0
- aethergraph/services/mcp/http_client.py +70 -0
- aethergraph/services/mcp/mcp_tools.py +21 -0
- aethergraph/services/mcp/registry.py +14 -0
- aethergraph/services/mcp/service.py +100 -0
- aethergraph/services/mcp/stdio_client.py +70 -0
- aethergraph/services/mcp/ws_client.py +115 -0
- aethergraph/services/memory/bound.py +106 -0
- aethergraph/services/memory/distillers/episode.py +116 -0
- aethergraph/services/memory/distillers/rolling.py +74 -0
- aethergraph/services/memory/facade.py +633 -0
- aethergraph/services/memory/factory.py +78 -0
- aethergraph/services/memory/hotlog_kv.py +27 -0
- aethergraph/services/memory/indices.py +74 -0
- aethergraph/services/memory/io_helpers.py +72 -0
- aethergraph/services/memory/persist_fs.py +40 -0
- aethergraph/services/memory/resolver.py +152 -0
- aethergraph/services/metering/noop.py +4 -0
- aethergraph/services/prompts/file_store.py +41 -0
- aethergraph/services/rag/chunker.py +29 -0
- aethergraph/services/rag/facade.py +593 -0
- aethergraph/services/rag/index/base.py +27 -0
- aethergraph/services/rag/index/faiss_index.py +121 -0
- aethergraph/services/rag/index/sqlite_index.py +134 -0
- aethergraph/services/rag/index_factory.py +52 -0
- aethergraph/services/rag/parsers/md.py +7 -0
- aethergraph/services/rag/parsers/pdf.py +14 -0
- aethergraph/services/rag/parsers/txt.py +7 -0
- aethergraph/services/rag/utils/hybrid.py +39 -0
- aethergraph/services/rag/utils/make_fs_key.py +62 -0
- aethergraph/services/redactor/simple.py +16 -0
- aethergraph/services/registry/key_parsing.py +44 -0
- aethergraph/services/registry/registry_key.py +19 -0
- aethergraph/services/registry/unified_registry.py +185 -0
- aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
- aethergraph/services/resume/router.py +73 -0
- aethergraph/services/schedulers/registry.py +41 -0
- aethergraph/services/secrets/base.py +7 -0
- aethergraph/services/secrets/env.py +8 -0
- aethergraph/services/state_stores/externalize.py +135 -0
- aethergraph/services/state_stores/graph_observer.py +131 -0
- aethergraph/services/state_stores/json_store.py +67 -0
- aethergraph/services/state_stores/resume_policy.py +119 -0
- aethergraph/services/state_stores/serialize.py +249 -0
- aethergraph/services/state_stores/utils.py +91 -0
- aethergraph/services/state_stores/validate.py +78 -0
- aethergraph/services/tracing/noop.py +18 -0
- aethergraph/services/waits/wait_registry.py +91 -0
- aethergraph/services/wakeup/memory_queue.py +57 -0
- aethergraph/services/wakeup/scanner_producer.py +56 -0
- aethergraph/services/wakeup/worker.py +31 -0
- aethergraph/tools/__init__.py +25 -0
- aethergraph/utils/optdeps.py +8 -0
- aethergraph-0.1.0a1.dist-info/METADATA +410 -0
- aethergraph-0.1.0a1.dist-info/RECORD +182 -0
- aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
- aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
- aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
- aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
- aethergraph-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
4
|
+
from starlette.responses import Response
|
|
5
|
+
|
|
6
|
+
from ..utils.telegram_utils import _process_update, _verify_secret
|
|
7
|
+
|
|
8
|
+
router = APIRouter()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.post("/telegram/webhook")
|
|
12
|
+
async def telegram_webhook(request: Request):
|
|
13
|
+
c = request.app.state.container
|
|
14
|
+
BOT_TOKEN = request.app.state.settings.telegram.bot_token.get_secret_value() or ""
|
|
15
|
+
if not BOT_TOKEN:
|
|
16
|
+
raise HTTPException(503, "telegram bot token not configured")
|
|
17
|
+
try:
|
|
18
|
+
_verify_secret(request)
|
|
19
|
+
payload = await request.json()
|
|
20
|
+
except HTTPException:
|
|
21
|
+
raise
|
|
22
|
+
except Exception:
|
|
23
|
+
return Response(status_code=400)
|
|
24
|
+
|
|
25
|
+
asyncio.create_task(_process_update(c, payload, BOT_TOKEN))
|
|
26
|
+
return Response(status_code=200)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# src/aethergraph/server/webui.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, File, Request, UploadFile, WebSocket, WebSocketDisconnect
|
|
8
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from aethergraph.plugins.channel.adapters.webui import WebChannelAdapter, WebSessionHub
|
|
12
|
+
|
|
13
|
+
webui_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
# ------- runtime singletons (attached in create_app) -------
|
|
16
|
+
HUB_ATTR = "web_session_hub"
|
|
17
|
+
UPLOAD_DIR_ATTR = "web_upload_dir"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _hub(app) -> WebSessionHub:
|
|
21
|
+
return getattr(app.state, HUB_ATTR)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _uploads_dir(app) -> str:
|
|
25
|
+
return getattr(app.state, UPLOAD_DIR_ATTR)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ------- WebSocket endpoint -------
|
|
29
|
+
@webui_router.websocket("/ws/channel/{session_id}")
|
|
30
|
+
async def ws_channel(ws: WebSocket, session_id: str):
|
|
31
|
+
await ws.accept()
|
|
32
|
+
|
|
33
|
+
async def send_json(payload: dict):
|
|
34
|
+
await ws.send_json(payload)
|
|
35
|
+
|
|
36
|
+
hub = _hub(ws.app)
|
|
37
|
+
await hub.attach(session_id, send_json)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
while True:
|
|
41
|
+
msg = await ws.receive_json()
|
|
42
|
+
# Expect inbound: {"type": "resume", "run_id": ..., "node_id": ..., "token": ..., "payload": {...}}
|
|
43
|
+
t = (msg or {}).get("type")
|
|
44
|
+
if t == "resume":
|
|
45
|
+
c = ws.app.state.container
|
|
46
|
+
# basic token verification happens in ResumeRouter
|
|
47
|
+
await c.resume_router.resume(
|
|
48
|
+
run_id=msg["run_id"],
|
|
49
|
+
node_id=msg["node_id"],
|
|
50
|
+
token=msg["token"],
|
|
51
|
+
payload=msg.get("payload") or {},
|
|
52
|
+
)
|
|
53
|
+
# optionally handle ping or upload notifications (not required)
|
|
54
|
+
except WebSocketDisconnect:
|
|
55
|
+
pass
|
|
56
|
+
finally:
|
|
57
|
+
await hub.detach(session_id, send_json)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ------- HTTP resume fallback (for InputDock before WS ready) -------
|
|
61
|
+
class ResumeBody(BaseModel):
|
|
62
|
+
run_id: str
|
|
63
|
+
node_id: str
|
|
64
|
+
token: str
|
|
65
|
+
payload: dict
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@webui_router.post("/api/web/resume")
|
|
69
|
+
async def http_resume(request: Request, body: ResumeBody):
|
|
70
|
+
c = request.app.state.container
|
|
71
|
+
await c.resume_router.resume(body.run_id, body.node_id, body.token, body.payload)
|
|
72
|
+
return {"ok": True}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ------- Uploads -------
|
|
76
|
+
@webui_router.post("/api/web/upload")
|
|
77
|
+
async def upload_files(request: Request, files: list[UploadFile] = None):
|
|
78
|
+
"""
|
|
79
|
+
Save to <workspace>/web_uploads/<session_or_any>/... and return FileRef[]:
|
|
80
|
+
[{url, filename, size, mime}]
|
|
81
|
+
UI doesn't pass session; we just save under a common folder.
|
|
82
|
+
"""
|
|
83
|
+
if files is None:
|
|
84
|
+
files = File(...)
|
|
85
|
+
root = _uploads_dir(request.app)
|
|
86
|
+
os.makedirs(root, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
out = []
|
|
89
|
+
for f in files:
|
|
90
|
+
target = os.path.join(root, f.filename)
|
|
91
|
+
with open(target, "wb") as w:
|
|
92
|
+
w.write(await f.read())
|
|
93
|
+
url = f"/api/web/files/{f.filename}"
|
|
94
|
+
out.append(
|
|
95
|
+
{
|
|
96
|
+
"url": url,
|
|
97
|
+
"filename": f.filename,
|
|
98
|
+
"mime": f.content_type or "application/octet-stream",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@webui_router.get("/api/web/files/{filename}")
|
|
105
|
+
async def serve_uploaded(request: Request, filename: str):
|
|
106
|
+
root = _uploads_dir(request.app)
|
|
107
|
+
path = os.path.join(root, filename)
|
|
108
|
+
if not os.path.exists(path):
|
|
109
|
+
return JSONResponse({"error": "not found"}, status_code=404)
|
|
110
|
+
return FileResponse(path, filename=filename)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ------- Integration helper -------
|
|
114
|
+
def install_web_channel(app: Any):
|
|
115
|
+
"""
|
|
116
|
+
1) Creates a WebSessionHub
|
|
117
|
+
2) Registers WebChannelAdapter under prefix 'web' in ChannelBus
|
|
118
|
+
3) Sets default channel to 'web:session/<uuid>'
|
|
119
|
+
4) Ensures upload dir exists
|
|
120
|
+
"""
|
|
121
|
+
# 1) Hub
|
|
122
|
+
hub = WebSessionHub()
|
|
123
|
+
setattr(app.state, HUB_ATTR, hub)
|
|
124
|
+
|
|
125
|
+
# 2) Adapter registration
|
|
126
|
+
container = app.state.container
|
|
127
|
+
web_adapter = WebChannelAdapter(hub)
|
|
128
|
+
container.channels.adapters["web"] = web_adapter
|
|
129
|
+
|
|
130
|
+
# 3) Keep default as console unless you want to swap globally:
|
|
131
|
+
# container.channels.set_default_channel_key("web:session/dev-local")
|
|
132
|
+
|
|
133
|
+
# 4) Upload dir
|
|
134
|
+
updir = os.path.join(container.root, "web_uploads")
|
|
135
|
+
os.makedirs(updir, exist_ok=True)
|
|
136
|
+
setattr(app.state, UPLOAD_DIR_ATTR, updir)
|
|
File without changes
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from fastapi import HTTPException, Request
|
|
8
|
+
|
|
9
|
+
from aethergraph.services.continuations.continuation import Correlator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# --- shared utils ---
|
|
13
|
+
async def _download_slack_file(url: str, token: str) -> bytes:
|
|
14
|
+
async with (
|
|
15
|
+
aiohttp.ClientSession() as sess,
|
|
16
|
+
sess.get(url, headers={"Authorization": f"Bearer {token}"}) as r,
|
|
17
|
+
):
|
|
18
|
+
r.raise_for_status()
|
|
19
|
+
return await r.read()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# async def _download_slack_file(url: str, token: str) -> bytes:
|
|
23
|
+
# async with aiohttp.ClientSession() as sess:
|
|
24
|
+
# async with sess.get(url, headers={"Authorization": f"Bearer {token}"}) as r:
|
|
25
|
+
# r.raise_for_status()
|
|
26
|
+
# return await r.read()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verify_sig(request: Request, body: bytes):
|
|
30
|
+
"""Verify Slack request signature (HTTP webhooks only)."""
|
|
31
|
+
SLACK_SIGNING_SECRET = (
|
|
32
|
+
request.app.state.settings.slack.signing_secret.get_secret_value()
|
|
33
|
+
if request.app.state.settings.slack.signing_secret
|
|
34
|
+
else ""
|
|
35
|
+
)
|
|
36
|
+
if not SLACK_SIGNING_SECRET:
|
|
37
|
+
raise HTTPException(401, "no slack signing secret configured")
|
|
38
|
+
|
|
39
|
+
ts = request.headers.get("X-Slack-Request-Timestamp")
|
|
40
|
+
sig = request.headers.get("X-Slack-Signature")
|
|
41
|
+
if not ts or not sig or abs(time.time() - int(ts)) > 300:
|
|
42
|
+
raise HTTPException(400, "stale or missing signature")
|
|
43
|
+
basestring = f"v0:{ts}:{body.decode()}"
|
|
44
|
+
my_sig = (
|
|
45
|
+
"v0="
|
|
46
|
+
+ hmac.new(SLACK_SIGNING_SECRET.encode(), basestring.encode(), hashlib.sha256).hexdigest()
|
|
47
|
+
)
|
|
48
|
+
if not hmac.compare_digest(my_sig, sig):
|
|
49
|
+
raise HTTPException(401, "bad signature")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _channel_key(team_id: str, channel_id: str, thread_ts: str | None) -> str:
|
|
53
|
+
"""Construct a Slack channel key from its components.
|
|
54
|
+
E.g., team_id="T", channel_id="C", thread_ts="TS" -> "slack:team/T:chan/C:thread/TS"
|
|
55
|
+
"""
|
|
56
|
+
key = f"slack:team/{team_id}:chan/{channel_id}"
|
|
57
|
+
if thread_ts:
|
|
58
|
+
key += f":thread/{thread_ts}"
|
|
59
|
+
return key
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _stage_and_save(c, *, data: bytes, file_id: str, name: str, ch_key: str, cont) -> str:
|
|
63
|
+
"""Write bytes to tmp path, then save via FileArtifactStore.save_file(...).
|
|
64
|
+
Returns the Artifact.uri (string)."""
|
|
65
|
+
tmp = c.artifacts.tmp_path(suffix=f"_{file_id}")
|
|
66
|
+
with open(tmp, "wb") as f:
|
|
67
|
+
f.write(data)
|
|
68
|
+
run_id = cont.run_id if cont else "ad-hoc"
|
|
69
|
+
node_id = cont.node_id if cont else "channel"
|
|
70
|
+
# graph_id is unknown here; set a neutral tag
|
|
71
|
+
art = await c.artifacts.save_file(
|
|
72
|
+
path=tmp,
|
|
73
|
+
kind="upload",
|
|
74
|
+
run_id=run_id,
|
|
75
|
+
graph_id="channel",
|
|
76
|
+
node_id=node_id,
|
|
77
|
+
tool_name="slack.upload",
|
|
78
|
+
tool_version="0.0.1",
|
|
79
|
+
suggested_uri=None,
|
|
80
|
+
pin=False,
|
|
81
|
+
labels={"source": "slack", "slack_file_id": file_id, "channel": ch_key, "name": name},
|
|
82
|
+
metrics=None,
|
|
83
|
+
preview_uri=None,
|
|
84
|
+
)
|
|
85
|
+
return getattr(art, "uri", None) or getattr(art, "path", None) or f"file://{tmp}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def handle_slack_events_common(container, settings, payload: dict) -> dict:
|
|
89
|
+
"""
|
|
90
|
+
Common handler for Slack Events API payloads.
|
|
91
|
+
This is transport-agnostic: can be called from HTTP route or Socket Mode.
|
|
92
|
+
"""
|
|
93
|
+
SLACK_BOT_TOKEN = (
|
|
94
|
+
settings.slack.bot_token.get_secret_value() if settings.slack.bot_token else ""
|
|
95
|
+
)
|
|
96
|
+
c = container
|
|
97
|
+
|
|
98
|
+
ev = payload.get("event") or {}
|
|
99
|
+
ev_type = ev.get("type")
|
|
100
|
+
thread_ts = ev.get("thread_ts") or ev.get("ts")
|
|
101
|
+
|
|
102
|
+
# --- message (user -> bot) ---
|
|
103
|
+
if ev_type == "message" and not ev.get("bot_id"):
|
|
104
|
+
team = payload.get("team_id")
|
|
105
|
+
chan = ev.get("channel")
|
|
106
|
+
text = ev.get("text", "") or ""
|
|
107
|
+
files = ev.get("files") or []
|
|
108
|
+
ch_key = _channel_key(team, chan, None)
|
|
109
|
+
|
|
110
|
+
cont = None
|
|
111
|
+
if thread_ts:
|
|
112
|
+
# Try precise thread-level match
|
|
113
|
+
corr = Correlator(scheme="slack", channel=ch_key, thread=thread_ts, message="")
|
|
114
|
+
cont = await c.cont_store.find_by_correlator(corr=corr)
|
|
115
|
+
if not cont:
|
|
116
|
+
# Fallback to channel-root
|
|
117
|
+
corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
|
|
118
|
+
cont = await c.cont_store.find_by_correlator(corr=corr2)
|
|
119
|
+
|
|
120
|
+
file_refs = []
|
|
121
|
+
if files:
|
|
122
|
+
token = SLACK_BOT_TOKEN
|
|
123
|
+
for f in files:
|
|
124
|
+
if f.get("mode") == "tombstone":
|
|
125
|
+
continue
|
|
126
|
+
file_id = f.get("id")
|
|
127
|
+
name = f.get("name") or f.get("title") or "file"
|
|
128
|
+
mimetype = f.get("mimetype")
|
|
129
|
+
size = f.get("size")
|
|
130
|
+
url_priv = f.get("url_private") or f.get("url_private_download")
|
|
131
|
+
|
|
132
|
+
uri = None
|
|
133
|
+
if url_priv:
|
|
134
|
+
try:
|
|
135
|
+
data_bytes = await _download_slack_file(url_priv, token)
|
|
136
|
+
uri = await _stage_and_save(
|
|
137
|
+
c, data=data_bytes, file_id=file_id, name=name, ch_key=ch_key, cont=cont
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
container.logger and container.logger.warning(
|
|
141
|
+
f"Slack download failed: {e}", exc_info=True
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
file_refs.append(
|
|
145
|
+
{
|
|
146
|
+
"id": file_id,
|
|
147
|
+
"name": name,
|
|
148
|
+
"mimetype": mimetype,
|
|
149
|
+
"size": size,
|
|
150
|
+
"uri": uri,
|
|
151
|
+
"url_private": url_priv,
|
|
152
|
+
"platform": "slack",
|
|
153
|
+
"channel_key": ch_key,
|
|
154
|
+
"ts": ev.get("ts"),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# append to per-channel inbox (dedupe by id)
|
|
159
|
+
inbox_key = f"inbox://{ch_key}"
|
|
160
|
+
await c.kv_hot.list_append_unique(inbox_key, file_refs, id_key="id")
|
|
161
|
+
|
|
162
|
+
if not cont:
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
if cont.kind in ("user_files", "user_input_or_files"):
|
|
166
|
+
await c.resume_router.resume(
|
|
167
|
+
cont.run_id, cont.node_id, cont.token, {"text": text, "files": file_refs}
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
await c.resume_router.resume(cont.run_id, cont.node_id, cont.token, {"text": text})
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
# --- file_shared (out-of-band file) ---
|
|
174
|
+
if ev_type == "file_shared":
|
|
175
|
+
team = payload.get("team_id")
|
|
176
|
+
file_id = (ev.get("file") or {}).get("id")
|
|
177
|
+
thread_ts = (
|
|
178
|
+
(ev.get("file") or {}).get("thread_ts")
|
|
179
|
+
or (ev.get("channel") or {}).get("thread_ts")
|
|
180
|
+
or (ev.get("event_ts"))
|
|
181
|
+
)
|
|
182
|
+
chan = ev.get("channel_id") or (ev.get("channel") or {}).get("id")
|
|
183
|
+
if not (file_id and chan):
|
|
184
|
+
return {}
|
|
185
|
+
ch_key = _channel_key(team, chan, None)
|
|
186
|
+
|
|
187
|
+
cont = None
|
|
188
|
+
if thread_ts:
|
|
189
|
+
corr = Correlator(scheme="slack", channel=ch_key, thread=thread_ts, message="")
|
|
190
|
+
cont = await c.cont_store.find_by_correlator(corr=corr)
|
|
191
|
+
if not cont:
|
|
192
|
+
corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
|
|
193
|
+
cont = await c.cont_store.find_by_correlator(corr=corr2)
|
|
194
|
+
|
|
195
|
+
info = await c.slack.client.files_info(file=file_id)
|
|
196
|
+
f = info.get("file") or {}
|
|
197
|
+
name = f.get("name") or f.get("title") or "file"
|
|
198
|
+
mimetype = f.get("mimetype")
|
|
199
|
+
size = f.get("size")
|
|
200
|
+
url_priv = f.get("url_private") or f.get("url_private_download")
|
|
201
|
+
|
|
202
|
+
uri = None
|
|
203
|
+
if url_priv:
|
|
204
|
+
try:
|
|
205
|
+
data_bytes = await _download_slack_file(url_priv, SLACK_BOT_TOKEN)
|
|
206
|
+
uri = await _stage_and_save(
|
|
207
|
+
c, data=data_bytes, file_id=file_id, name=name, ch_key=ch_key, cont=cont
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
container.logger and container.logger.for_run().warning(
|
|
211
|
+
f"Slack download failed: {e}", exc_info=True
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
fr = {
|
|
215
|
+
"id": file_id,
|
|
216
|
+
"name": name,
|
|
217
|
+
"mimetype": mimetype,
|
|
218
|
+
"size": size,
|
|
219
|
+
"uri": uri,
|
|
220
|
+
"url_private": url_priv,
|
|
221
|
+
"platform": "slack",
|
|
222
|
+
"channel_key": ch_key,
|
|
223
|
+
"ts": ev.get("event_ts"),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
inbox_key = f"inbox://{ch_key}"
|
|
227
|
+
await c.kv_hot.list_append_unique(inbox_key, [fr], id_key="id")
|
|
228
|
+
|
|
229
|
+
if cont and cont.kind in ("user_files", "user_input_or_files"):
|
|
230
|
+
await c.resume_router.resume(
|
|
231
|
+
cont.run_id, cont.node_id, cont.token, {"text": "", "files": [fr]}
|
|
232
|
+
)
|
|
233
|
+
return {}
|
|
234
|
+
|
|
235
|
+
# other events might add later
|
|
236
|
+
return {}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def handle_slack_interactive_common(container, payload: dict) -> dict:
|
|
240
|
+
"""
|
|
241
|
+
Common handler for Slack interactive payloads (buttons, etc.).
|
|
242
|
+
Can be called from HTTP /slack/interact or from Socket Mode.
|
|
243
|
+
"""
|
|
244
|
+
c = container
|
|
245
|
+
|
|
246
|
+
action = (payload.get("actions") or [{}])[0]
|
|
247
|
+
team = (payload.get("team") or {}).get("id")
|
|
248
|
+
chan = (payload.get("channel") or {}).get("id") or (payload.get("container") or {}).get(
|
|
249
|
+
"channel_id"
|
|
250
|
+
)
|
|
251
|
+
# thread_ts = (payload.get("message") or {}).get("thread_ts")
|
|
252
|
+
ch_key = _channel_key(team, chan, None)
|
|
253
|
+
|
|
254
|
+
meta_raw = action.get("value") or "{}"
|
|
255
|
+
try:
|
|
256
|
+
meta = json.loads(meta_raw)
|
|
257
|
+
except Exception:
|
|
258
|
+
meta = {"choice": meta_raw} # super defensive fallback
|
|
259
|
+
|
|
260
|
+
choice = meta.get("choice", "reject")
|
|
261
|
+
|
|
262
|
+
# value contains {"choice", "run_id", "node_id", "token", "sig" (optional)}
|
|
263
|
+
token = meta.get("token")
|
|
264
|
+
run_id = meta.get("run_id")
|
|
265
|
+
node_id = meta.get("node_id")
|
|
266
|
+
if token and run_id and node_id:
|
|
267
|
+
await c.resume_router.resume(
|
|
268
|
+
run_id=run_id,
|
|
269
|
+
node_id=node_id,
|
|
270
|
+
token=token,
|
|
271
|
+
payload={
|
|
272
|
+
"choice": choice,
|
|
273
|
+
"slack_ts": (payload.get("message") or {}).get("ts"),
|
|
274
|
+
"channel_key": ch_key,
|
|
275
|
+
},
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return {}
|