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.
Files changed (182) hide show
  1. aethergraph/__init__.py +49 -0
  2. aethergraph/config/__init__.py +0 -0
  3. aethergraph/config/config.py +121 -0
  4. aethergraph/config/context.py +16 -0
  5. aethergraph/config/llm.py +26 -0
  6. aethergraph/config/loader.py +60 -0
  7. aethergraph/config/runtime.py +9 -0
  8. aethergraph/contracts/errors/errors.py +44 -0
  9. aethergraph/contracts/services/artifacts.py +142 -0
  10. aethergraph/contracts/services/channel.py +72 -0
  11. aethergraph/contracts/services/continuations.py +23 -0
  12. aethergraph/contracts/services/eventbus.py +12 -0
  13. aethergraph/contracts/services/kv.py +24 -0
  14. aethergraph/contracts/services/llm.py +17 -0
  15. aethergraph/contracts/services/mcp.py +22 -0
  16. aethergraph/contracts/services/memory.py +108 -0
  17. aethergraph/contracts/services/resume.py +28 -0
  18. aethergraph/contracts/services/state_stores.py +33 -0
  19. aethergraph/contracts/services/wakeup.py +28 -0
  20. aethergraph/core/execution/base_scheduler.py +77 -0
  21. aethergraph/core/execution/forward_scheduler.py +777 -0
  22. aethergraph/core/execution/global_scheduler.py +634 -0
  23. aethergraph/core/execution/retry_policy.py +22 -0
  24. aethergraph/core/execution/step_forward.py +411 -0
  25. aethergraph/core/execution/step_result.py +18 -0
  26. aethergraph/core/execution/wait_types.py +72 -0
  27. aethergraph/core/graph/graph_builder.py +192 -0
  28. aethergraph/core/graph/graph_fn.py +219 -0
  29. aethergraph/core/graph/graph_io.py +67 -0
  30. aethergraph/core/graph/graph_refs.py +154 -0
  31. aethergraph/core/graph/graph_spec.py +115 -0
  32. aethergraph/core/graph/graph_state.py +59 -0
  33. aethergraph/core/graph/graphify.py +128 -0
  34. aethergraph/core/graph/interpreter.py +145 -0
  35. aethergraph/core/graph/node_handle.py +33 -0
  36. aethergraph/core/graph/node_spec.py +46 -0
  37. aethergraph/core/graph/node_state.py +63 -0
  38. aethergraph/core/graph/task_graph.py +747 -0
  39. aethergraph/core/graph/task_node.py +82 -0
  40. aethergraph/core/graph/utils.py +37 -0
  41. aethergraph/core/graph/visualize.py +239 -0
  42. aethergraph/core/runtime/ad_hoc_context.py +61 -0
  43. aethergraph/core/runtime/base_service.py +153 -0
  44. aethergraph/core/runtime/bind_adapter.py +42 -0
  45. aethergraph/core/runtime/bound_memory.py +69 -0
  46. aethergraph/core/runtime/execution_context.py +220 -0
  47. aethergraph/core/runtime/graph_runner.py +349 -0
  48. aethergraph/core/runtime/lifecycle.py +26 -0
  49. aethergraph/core/runtime/node_context.py +203 -0
  50. aethergraph/core/runtime/node_services.py +30 -0
  51. aethergraph/core/runtime/recovery.py +159 -0
  52. aethergraph/core/runtime/run_registration.py +33 -0
  53. aethergraph/core/runtime/runtime_env.py +157 -0
  54. aethergraph/core/runtime/runtime_registry.py +32 -0
  55. aethergraph/core/runtime/runtime_services.py +224 -0
  56. aethergraph/core/runtime/wakeup_watcher.py +40 -0
  57. aethergraph/core/tools/__init__.py +10 -0
  58. aethergraph/core/tools/builtins/channel_tools.py +194 -0
  59. aethergraph/core/tools/builtins/toolset.py +134 -0
  60. aethergraph/core/tools/toolkit.py +510 -0
  61. aethergraph/core/tools/waitable.py +109 -0
  62. aethergraph/plugins/channel/__init__.py +0 -0
  63. aethergraph/plugins/channel/adapters/__init__.py +0 -0
  64. aethergraph/plugins/channel/adapters/console.py +106 -0
  65. aethergraph/plugins/channel/adapters/file.py +102 -0
  66. aethergraph/plugins/channel/adapters/slack.py +285 -0
  67. aethergraph/plugins/channel/adapters/telegram.py +302 -0
  68. aethergraph/plugins/channel/adapters/webhook.py +104 -0
  69. aethergraph/plugins/channel/adapters/webui.py +134 -0
  70. aethergraph/plugins/channel/routes/__init__.py +0 -0
  71. aethergraph/plugins/channel/routes/console_routes.py +86 -0
  72. aethergraph/plugins/channel/routes/slack_routes.py +49 -0
  73. aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
  74. aethergraph/plugins/channel/routes/webui_routes.py +136 -0
  75. aethergraph/plugins/channel/utils/__init__.py +0 -0
  76. aethergraph/plugins/channel/utils/slack_utils.py +278 -0
  77. aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
  78. aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
  79. aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
  80. aethergraph/plugins/mcp/fs_server.py +128 -0
  81. aethergraph/plugins/mcp/http_server.py +101 -0
  82. aethergraph/plugins/mcp/ws_server.py +180 -0
  83. aethergraph/plugins/net/http.py +10 -0
  84. aethergraph/plugins/utils/data_io.py +359 -0
  85. aethergraph/runner/__init__.py +5 -0
  86. aethergraph/runtime/__init__.py +62 -0
  87. aethergraph/server/__init__.py +3 -0
  88. aethergraph/server/app_factory.py +84 -0
  89. aethergraph/server/start.py +122 -0
  90. aethergraph/services/__init__.py +10 -0
  91. aethergraph/services/artifacts/facade.py +284 -0
  92. aethergraph/services/artifacts/factory.py +35 -0
  93. aethergraph/services/artifacts/fs_store.py +656 -0
  94. aethergraph/services/artifacts/jsonl_index.py +123 -0
  95. aethergraph/services/artifacts/paths.py +23 -0
  96. aethergraph/services/artifacts/sqlite_index.py +209 -0
  97. aethergraph/services/artifacts/utils.py +124 -0
  98. aethergraph/services/auth/dev.py +16 -0
  99. aethergraph/services/channel/channel_bus.py +293 -0
  100. aethergraph/services/channel/factory.py +44 -0
  101. aethergraph/services/channel/session.py +511 -0
  102. aethergraph/services/channel/wait_helpers.py +57 -0
  103. aethergraph/services/clock/clock.py +9 -0
  104. aethergraph/services/container/default_container.py +320 -0
  105. aethergraph/services/continuations/continuation.py +56 -0
  106. aethergraph/services/continuations/factory.py +34 -0
  107. aethergraph/services/continuations/stores/fs_store.py +264 -0
  108. aethergraph/services/continuations/stores/inmem_store.py +95 -0
  109. aethergraph/services/eventbus/inmem.py +21 -0
  110. aethergraph/services/features/static.py +10 -0
  111. aethergraph/services/kv/ephemeral.py +90 -0
  112. aethergraph/services/kv/factory.py +27 -0
  113. aethergraph/services/kv/layered.py +41 -0
  114. aethergraph/services/kv/sqlite_kv.py +128 -0
  115. aethergraph/services/llm/factory.py +157 -0
  116. aethergraph/services/llm/generic_client.py +542 -0
  117. aethergraph/services/llm/providers.py +3 -0
  118. aethergraph/services/llm/service.py +105 -0
  119. aethergraph/services/logger/base.py +36 -0
  120. aethergraph/services/logger/compat.py +50 -0
  121. aethergraph/services/logger/formatters.py +106 -0
  122. aethergraph/services/logger/std.py +203 -0
  123. aethergraph/services/mcp/helpers.py +23 -0
  124. aethergraph/services/mcp/http_client.py +70 -0
  125. aethergraph/services/mcp/mcp_tools.py +21 -0
  126. aethergraph/services/mcp/registry.py +14 -0
  127. aethergraph/services/mcp/service.py +100 -0
  128. aethergraph/services/mcp/stdio_client.py +70 -0
  129. aethergraph/services/mcp/ws_client.py +115 -0
  130. aethergraph/services/memory/bound.py +106 -0
  131. aethergraph/services/memory/distillers/episode.py +116 -0
  132. aethergraph/services/memory/distillers/rolling.py +74 -0
  133. aethergraph/services/memory/facade.py +633 -0
  134. aethergraph/services/memory/factory.py +78 -0
  135. aethergraph/services/memory/hotlog_kv.py +27 -0
  136. aethergraph/services/memory/indices.py +74 -0
  137. aethergraph/services/memory/io_helpers.py +72 -0
  138. aethergraph/services/memory/persist_fs.py +40 -0
  139. aethergraph/services/memory/resolver.py +152 -0
  140. aethergraph/services/metering/noop.py +4 -0
  141. aethergraph/services/prompts/file_store.py +41 -0
  142. aethergraph/services/rag/chunker.py +29 -0
  143. aethergraph/services/rag/facade.py +593 -0
  144. aethergraph/services/rag/index/base.py +27 -0
  145. aethergraph/services/rag/index/faiss_index.py +121 -0
  146. aethergraph/services/rag/index/sqlite_index.py +134 -0
  147. aethergraph/services/rag/index_factory.py +52 -0
  148. aethergraph/services/rag/parsers/md.py +7 -0
  149. aethergraph/services/rag/parsers/pdf.py +14 -0
  150. aethergraph/services/rag/parsers/txt.py +7 -0
  151. aethergraph/services/rag/utils/hybrid.py +39 -0
  152. aethergraph/services/rag/utils/make_fs_key.py +62 -0
  153. aethergraph/services/redactor/simple.py +16 -0
  154. aethergraph/services/registry/key_parsing.py +44 -0
  155. aethergraph/services/registry/registry_key.py +19 -0
  156. aethergraph/services/registry/unified_registry.py +185 -0
  157. aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
  158. aethergraph/services/resume/router.py +73 -0
  159. aethergraph/services/schedulers/registry.py +41 -0
  160. aethergraph/services/secrets/base.py +7 -0
  161. aethergraph/services/secrets/env.py +8 -0
  162. aethergraph/services/state_stores/externalize.py +135 -0
  163. aethergraph/services/state_stores/graph_observer.py +131 -0
  164. aethergraph/services/state_stores/json_store.py +67 -0
  165. aethergraph/services/state_stores/resume_policy.py +119 -0
  166. aethergraph/services/state_stores/serialize.py +249 -0
  167. aethergraph/services/state_stores/utils.py +91 -0
  168. aethergraph/services/state_stores/validate.py +78 -0
  169. aethergraph/services/tracing/noop.py +18 -0
  170. aethergraph/services/waits/wait_registry.py +91 -0
  171. aethergraph/services/wakeup/memory_queue.py +57 -0
  172. aethergraph/services/wakeup/scanner_producer.py +56 -0
  173. aethergraph/services/wakeup/worker.py +31 -0
  174. aethergraph/tools/__init__.py +25 -0
  175. aethergraph/utils/optdeps.py +8 -0
  176. aethergraph-0.1.0a1.dist-info/METADATA +410 -0
  177. aethergraph-0.1.0a1.dist-info/RECORD +182 -0
  178. aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
  179. aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
  180. aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
  181. aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
  182. 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 {}