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,302 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+
5
+ import httpx
6
+
7
+ from aethergraph.contracts.services.channel import ChannelAdapter, OutEvent
8
+ from aethergraph.services.continuations.continuation import Correlator
9
+
10
+
11
+ def _tg_render_bar(percent: float, width: int = 20) -> str:
12
+ p = max(0.0, min(1.0, percent))
13
+ filled = int(round(p * width))
14
+ return "█" * filled + "░" * (width - filled)
15
+
16
+
17
+ def _tg_fmt_eta(sec: float | None) -> str:
18
+ if sec is None:
19
+ return ""
20
+ s = int(max(0, sec))
21
+ if s < 60:
22
+ return f"{s}s"
23
+ m, s = divmod(s, 60)
24
+ if m < 60:
25
+ return f"{m}m {s}s"
26
+ h, m = divmod(m, 60)
27
+ return f"{h}h {m}m"
28
+
29
+
30
+ def _prune(d: dict) -> dict:
31
+ return {k: v for k, v in d.items() if v is not None}
32
+
33
+
34
+ def _mk_params(chat_id: int, topic_id: int | None, **rest) -> dict:
35
+ p = {"chat_id": chat_id, **rest}
36
+ if topic_id is not None:
37
+ p["message_thread_id"] = topic_id
38
+ return p
39
+
40
+
41
+ def _safe_text_md(text: str | None) -> tuple[str, str | None]:
42
+ """
43
+ Best-effort: if text looks like Markdown-safe, return ("Markdown", text).
44
+ Else, drop parse mode to avoid 400s on unescaped symbols.
45
+ """
46
+ if not text:
47
+ return "", "Markdown"
48
+ # very light check – if it contains risky characters unbalanced, avoid MD
49
+ risky = any(c in text for c in ("*", "_", "[", "`"))
50
+ return (text, None if risky else "Markdown")
51
+
52
+
53
+ class TelegramChannelAdapter(ChannelAdapter):
54
+ """
55
+ Telegram channel adapter using the Bot API.
56
+ Channel key format:
57
+ - "tg:chat/<chat_id>"
58
+ - Optional topic (supergroups): "tg:chat/<chat_id>:topic/<message_thread_id>"
59
+ """
60
+
61
+ capabilities: set[str] = {"text", "buttons", "image", "file", "edit", "stream"}
62
+
63
+ def __init__(self, bot_token: str | None = None, *, timeout_s: int = 15):
64
+ self.token = bot_token or os.environ["TELEGRAM_BOT_TOKEN"]
65
+ self.base = f"https://api.telegram.org/bot{self.token}"
66
+
67
+ timeout = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=30.0)
68
+ limits = httpx.Limits(
69
+ max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0
70
+ )
71
+
72
+ try:
73
+ transport = httpx.AsyncHTTPTransport(retries=0, local_address="0.0.0.0", http2=False)
74
+ except Exception:
75
+ transport = httpx.AsyncHTTPTransport(retries=0, http2=False)
76
+
77
+ # proxies = os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") or None
78
+
79
+ self._client = httpx.AsyncClient(timeout=timeout, limits=limits, transport=transport)
80
+ # cache for edit/upsert: (channel_key, upsert_key) -> (chat_id, message_id)
81
+ self._msg_id_cache: dict[tuple[str, str], tuple[int, int]] = {}
82
+
83
+ async def aclose(self):
84
+ try:
85
+ await self._client.aclose()
86
+ except Exception as e:
87
+ logger = logging.getLogger("aethergraph.plugins.channel.adapters.telegram")
88
+ logger.warning(f"Failed to close Telegram client: {e}")
89
+
90
+ # ------------- helpers -------------
91
+ @staticmethod
92
+ def _parse(channel_key: str) -> dict:
93
+ """
94
+ Parse "tg:chat/<chat_id>[:topic/<message_thread_id>]" → {"chat": int, "topic": int|None}
95
+ """
96
+ if not channel_key.startswith("tg:"):
97
+ raise ValueError(f"Not a telegram channel key: {channel_key}")
98
+ parts = channel_key.split(":")[1:] # drop "tg"
99
+ d = {}
100
+ for p in parts:
101
+ k, v = p.split("/", 1)
102
+ d[k] = v
103
+ chat_id = int(d["chat"])
104
+ topic_id = int(d["topic"]) if "topic" in d else None
105
+ return {"chat": chat_id, "topic": topic_id}
106
+
107
+ async def _api(self, method: str, **params):
108
+ """POST to Telegram with retries on connect and 429, robust error handling."""
109
+ url = f"{self.base}/{method}"
110
+ files = params.pop("_files", None)
111
+
112
+ last_exc = None
113
+ for attempt in range(3):
114
+ try:
115
+ if files:
116
+ resp = await self._client.post(url, data=_prune(params), files=files)
117
+ else:
118
+ resp = await self._client.post(url, json=_prune(params))
119
+ resp.raise_for_status()
120
+ data = resp.json()
121
+ if not data.get("ok", False):
122
+ if data.get("error_code") == 429:
123
+ retry_after = (data.get("parameters") or {}).get("retry_after", 1)
124
+ await asyncio.sleep(int(retry_after))
125
+ continue
126
+ desc = data.get("description", "Unknown Telegram error")
127
+ raise RuntimeError(f"Telegram API error: {data.get('error_code')} {desc}")
128
+ return data
129
+ except httpx.ConnectError as e:
130
+ last_exc = e
131
+ await asyncio.sleep(0.6 * (attempt + 1))
132
+ except httpx.ReadTimeout as e:
133
+ last_exc = e
134
+ await asyncio.sleep(0.6 * (attempt + 1))
135
+ except ValueError as e:
136
+ text = getattr(resp, "text", lambda: "")()
137
+ raise RuntimeError(f"Telegram non-JSON response: {text[:200]}") from e
138
+
139
+ raise httpx.ConnectError(
140
+ f"Failed to call Telegram {method}; last_error={last_exc!r}"
141
+ ) from last_exc
142
+
143
+ # ------------- core send -------------
144
+ async def peek_thread(self, channel_key: str) -> str | None:
145
+ meta = self._parse(channel_key)
146
+ return str(meta["topic"]) if meta["topic"] is not None else ""
147
+
148
+ async def send(self, event: OutEvent) -> dict | None:
149
+ meta = self._parse(event.channel)
150
+ chat_id = meta["chat"]
151
+ topic_id = meta["topic"] # None if not provided
152
+
153
+ # Streaming & upsert (editMessageText)
154
+ if (
155
+ event.type
156
+ in (
157
+ "agent.stream.start",
158
+ "agent.stream.delta",
159
+ "agent.stream.end",
160
+ "agent.message.update",
161
+ )
162
+ and event.upsert_key
163
+ ):
164
+ key = (event.channel, event.upsert_key)
165
+ if key not in self._msg_id_cache:
166
+ text, md = _safe_text_md(event.text or "…")
167
+ params = _mk_params(chat_id, topic_id, text=text, parse_mode=md)
168
+ resp = await self._api("sendMessage", **params)
169
+ msg = resp["result"]
170
+ self._msg_id_cache[key] = (msg["chat"]["id"], msg["message_id"])
171
+ else:
172
+ ch, mid = self._msg_id_cache[key]
173
+ if event.text:
174
+ text, md = _safe_text_md(event.text)
175
+ await self._api(
176
+ "editMessageText", chat_id=ch, message_id=mid, text=text, parse_mode=md
177
+ )
178
+ return None
179
+
180
+ # Buttons / approvals
181
+ if event.type in ("session.need_approval", "link.buttons"):
182
+ buttons = getattr(event, "buttons", None) or []
183
+ if not buttons:
184
+ opts = (event.meta or {}).get("options", ["Approve", "Reject"])
185
+ buttons = [
186
+ type(
187
+ "B", (), {"label": opts[0], "value": "approve", "style": None, "url": None}
188
+ ),
189
+ type(
190
+ "B", (), {"label": opts[-1], "value": "reject", "style": None, "url": None}
191
+ ),
192
+ ]
193
+
194
+ # Compact callback data: "c=<choice>|k=<resume_key>" (<< 64 bytes)
195
+ resume_key = (event.meta or {}).get("resume_key") or ""
196
+ rows = []
197
+ for b in buttons[:8]:
198
+ label = b.label
199
+ val = getattr(b, "value", None) or label
200
+ if getattr(b, "url", None):
201
+ rows.append([{"text": label, "url": b.url}])
202
+ else:
203
+ data = f"c={str(val)[:20]}|k={resume_key}"
204
+ rows.append([{"text": label, "callback_data": data}])
205
+
206
+ reply_markup = {"inline_keyboard": rows}
207
+ text, md = _safe_text_md(event.text or "Please approve:")
208
+
209
+ params = _mk_params(
210
+ chat_id, topic_id, text=text, parse_mode=md, reply_markup=reply_markup
211
+ )
212
+ resp = await self._api("sendMessage", **params)
213
+ msg = resp["result"]
214
+
215
+ return {
216
+ "correlator": Correlator(
217
+ scheme="tg",
218
+ channel=event.channel,
219
+ thread=str(topic_id or ""),
220
+ message=str(msg["message_id"]),
221
+ )
222
+ }
223
+
224
+ # File upload
225
+ if event.type == "file.upload" and event.file:
226
+ filename = event.file.get("filename", "file.bin")
227
+ caption = event.text or filename
228
+ if "bytes" in event.file:
229
+ files = {"document": (filename, event.file["bytes"])}
230
+ params = _mk_params(chat_id, topic_id, caption=caption)
231
+ await self._api("sendDocument", _files=files, **params)
232
+ return None
233
+ if "url" in event.file:
234
+ text, md = _safe_text_md(f"{caption}: {event.file['url']}")
235
+ params = _mk_params(chat_id, topic_id, text=text, parse_mode=md)
236
+ await self._api("sendMessage", **params)
237
+ return None
238
+
239
+ # Progress with upsert/edit (single text body)
240
+ if (
241
+ event.type in ("agent.progress.start", "agent.progress.update", "agent.progress.end")
242
+ and event.upsert_key
243
+ ):
244
+ r = event.rich or {}
245
+ title = r.get("title") or "Working..."
246
+ subtitle = r.get("subtitle") or ""
247
+ total = r.get("total")
248
+ cur = r.get("current") or 0
249
+ pct = max(0.0, min(1.0, float(cur) / float(total))) if total else 0.0
250
+ bar = _tg_render_bar(pct, 20)
251
+ pct_txt = f"{int(round(pct * 100))}%"
252
+ eta_txt = _tg_fmt_eta(r.get("eta_seconds"))
253
+ header = f"⏳ {title}"
254
+ if event.type == "agent.progress.end":
255
+ header = f"{'✅' if r.get('success', True) else '⚠️'} {title}"
256
+ if total:
257
+ bar = _tg_render_bar(1.0, 20)
258
+ pct_txt = "100%"
259
+
260
+ body_lines = [f"*{header}*"]
261
+ if total:
262
+ body_lines.append(f"`{bar}` {pct_txt}")
263
+ tail = " • ".join([t for t in (subtitle, f"ETA {eta_txt}" if eta_txt else "") if t])
264
+ if tail:
265
+ body_lines.append(tail)
266
+ text = "\n".join(body_lines)
267
+
268
+ key = (event.channel, event.upsert_key)
269
+ if key not in self._msg_id_cache:
270
+ t, md = _safe_text_md(text)
271
+ params = _mk_params(chat_id, topic_id, text=t, parse_mode=md)
272
+ resp = await self._api("sendMessage", **params)
273
+ msg = resp["result"]
274
+ self._msg_id_cache[key] = (msg["chat"]["id"], msg["message_id"])
275
+ else:
276
+ ch, mid = self._msg_id_cache[key]
277
+ t, md = _safe_text_md(text)
278
+ await self._api(
279
+ "editMessageText", chat_id=ch, message_id=mid, text=t, parse_mode=md
280
+ )
281
+ return None
282
+
283
+ # Image (sendPhoto)
284
+ if getattr(event, "image", None):
285
+ url = event.image.get("url", "")
286
+ caption = event.text or event.image.get("title") or ""
287
+ params = _mk_params(chat_id, topic_id, photo=url, caption=caption)
288
+ await self._api("sendPhoto", **params)
289
+ return None
290
+
291
+ # Default: plain message
292
+ t, md = _safe_text_md(event.text or "")
293
+ params = _mk_params(chat_id, topic_id, text=t, parse_mode=md)
294
+ resp = await self._api("sendMessage", **params)
295
+ return {
296
+ "correlator": Correlator(
297
+ scheme="tg",
298
+ channel=event.channel,
299
+ thread=str(topic_id or ""),
300
+ message=str(resp["result"]["message_id"]),
301
+ )
302
+ }
@@ -0,0 +1,104 @@
1
+ """
2
+ Webhook channel adapter.
3
+
4
+ Channel key format (after alias resolution):
5
+ webhook:<URL>
6
+
7
+ Use cases include:
8
+ - Sending notifications to generic webhook endpoints.
9
+ - Integrating with services like Zapier, IFTTT, Discord, etc.
10
+ """
11
+
12
+ # aethergraph/channels/webhook.py
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ import logging
16
+ from typing import Any
17
+ import warnings
18
+
19
+ from aethergraph.contracts.services.channel import Button, ChannelAdapter, OutEvent
20
+ from aethergraph.plugins.net.http import get_async_client
21
+
22
+ logger = logging.getLogger("aethergraph.channels.webhook")
23
+
24
+
25
+ @dataclass
26
+ class WebhookChannelAdapter(ChannelAdapter):
27
+ """
28
+ Generic inform-only webhook adapter.
29
+
30
+ Channel key:
31
+ webhook:<URL>
32
+ Examples:
33
+ webhook:https://hooks.zapier.com/hooks/catch/123/abc/
34
+ webhook:https://discord.com/api/webhooks/.../...
35
+ """
36
+
37
+ default_headers: dict[str, str] | None = None
38
+ timeout_seconds: float = 10.0
39
+
40
+ capabilities: set[str] = frozenset({"text", "file", "rich", "buttons"})
41
+
42
+ def _url_for(self, channel_key: str) -> str:
43
+ try:
44
+ _, url = channel_key.split(":", 1)
45
+ except ValueError as exc:
46
+ raise ValueError(f"Invalid webhook channel key: {channel_key!r}") from exc
47
+ url = url.strip()
48
+ if not (url.startswith("http://") or url.startswith("https://")):
49
+ raise ValueError(f"Webhook channel key must contain a full URL, got: {url!r}")
50
+ return url
51
+
52
+ def _serialize_buttons(self, buttons: dict[str, Button] | None) -> list[dict[str, Any]]:
53
+ if not buttons:
54
+ return []
55
+ return [
56
+ {"key": k, "label": b.label, "value": b.value, "url": b.url, "style": b.style}
57
+ for k, b in buttons.items()
58
+ ]
59
+
60
+ def _serialize_file(self, file_info: dict[str, Any] | None) -> dict[str, Any] | None:
61
+ if not file_info:
62
+ return None
63
+ return {
64
+ "name": file_info.get("filename") or file_info.get("name"),
65
+ "mimetype": file_info.get("mimetype"),
66
+ "url": file_info.get("url"),
67
+ "size": file_info.get("size"),
68
+ }
69
+
70
+ def _build_payload(self, event: OutEvent) -> dict[str, Any]:
71
+ ts = datetime.now(timezone.utc).isoformat()
72
+ payload: dict[str, Any] = {
73
+ "type": event.type,
74
+ "channel": event.channel,
75
+ "text": event.text,
76
+ "meta": event.meta or {},
77
+ "rich": event.rich or {},
78
+ "buttons": self._serialize_buttons(event.buttons),
79
+ "file": self._serialize_file(event.file),
80
+ "upsert_key": event.upsert_key,
81
+ "timestamp": ts,
82
+ }
83
+ # For Discord-like webhooks that expect `content`
84
+ if event.text is not None:
85
+ payload["content"] = event.text
86
+ return payload
87
+
88
+ async def send(self, event: OutEvent) -> None:
89
+ url = self._url_for(event.channel)
90
+ payload = self._build_payload(event)
91
+ headers = {"Content-Type": "application/json", **(self.default_headers or {})}
92
+
93
+ try:
94
+ async with get_async_client(self.timeout_seconds, headers) as client:
95
+ resp = await client.post(url, json=payload)
96
+ if resp.status_code >= 400:
97
+ body = resp.text
98
+ logger.debug(
99
+ f"[WebhookChannelAdapter] POST {url} -> HTTP {resp.status_code}. "
100
+ f"Body: {body[:300]!r}"
101
+ )
102
+ except Exception as e:
103
+ # Best-effort; don't bubble failures into graph control flow
104
+ warnings.warn(f"[WebhookChannelAdapter] Failed to POST to {url}: {e}", stacklevel=2)
@@ -0,0 +1,134 @@
1
+ # src/aethergraph/plugins/channel/adapters/webui.py
2
+ from __future__ import annotations
3
+
4
+ from collections import deque
5
+ from dataclasses import asdict, is_dataclass
6
+ import logging
7
+ from typing import Any
8
+
9
+ from aethergraph.contracts.services.channel import ChannelAdapter, OutEvent
10
+ from aethergraph.services.continuations.continuation import Correlator
11
+
12
+
13
+ class WebSessionHub:
14
+ def __init__(self, backlog_size: int = 100):
15
+ self._conns: dict[str, set] = {}
16
+ self._backlog: dict[str, deque[dict]] = {}
17
+ self._backlog_size = backlog_size
18
+
19
+ async def attach(self, session_id: str, sender):
20
+ self._conns.setdefault(session_id, set()).add(sender)
21
+ # flush backlog to this new connection
22
+ for payload in list(self._backlog.get(session_id, [])):
23
+ try:
24
+ await sender(payload)
25
+ except Exception:
26
+ logger = logging.getLogger("aethergraph.plugins.channel.adapters.webui")
27
+ logger.warning(f"Failed to flush backlog payload to session {session_id}")
28
+
29
+ async def detach(self, session_id: str, sender):
30
+ s = self._conns.get(session_id)
31
+ if s and sender in s:
32
+ s.remove(sender)
33
+ if not s:
34
+ self._conns.pop(session_id, None)
35
+
36
+ async def emit(self, session_id: str, payload: dict):
37
+ conns = list(self._conns.get(session_id, []))
38
+ if conns:
39
+ for send in conns:
40
+ try:
41
+ await send(payload)
42
+ except Exception:
43
+ await self.detach(session_id, send)
44
+ return
45
+
46
+ # no live connections → store to backlog
47
+ q = self._backlog.setdefault(session_id, deque(maxlen=self._backlog_size))
48
+ q.append(payload)
49
+
50
+
51
+ def _serialize_event(event: OutEvent) -> dict:
52
+ """Dataclass → dict; normalize buttons; strip file bytes; drop None."""
53
+ if is_dataclass(event):
54
+ payload = asdict(event)
55
+ else:
56
+ # be lenient if a pydantic-like instance sneaks in
57
+ payload = (
58
+ (getattr(event, "model_dump", None) and event.model_dump())
59
+ or (getattr(event, "dict", None) and event.dict())
60
+ or dict(event)
61
+ )
62
+
63
+ # normalize buttons dict -> list
64
+ btns = payload.get("buttons")
65
+ if isinstance(btns, dict):
66
+ payload["buttons"] = list(btns.values())
67
+
68
+ # drop binary bytes from file
69
+ f = payload.get("file")
70
+ if isinstance(f, dict) and "bytes" in f:
71
+ f = f.copy()
72
+ f.pop("bytes", None)
73
+ payload["file"] = f
74
+
75
+ # clean None
76
+ return {k: v for k, v in payload.items() if v is not None}
77
+
78
+
79
+ class WebChannelAdapter(ChannelAdapter):
80
+ """
81
+ Channel key: 'web:session/{session_id}'
82
+ Mirrors Slack adapter semantics for:
83
+ - correlators
84
+ - stream/progress upserts via upsert_key
85
+ - buttons/image/file payload shapes
86
+ """
87
+
88
+ capabilities: set[str] = {"text", "buttons", "image", "file", "edit", "stream"}
89
+
90
+ def __init__(self, hub: WebSessionHub):
91
+ self.hub = hub
92
+ self._first_msg_by_key: dict[
93
+ tuple[str, str], str
94
+ ] = {} # (channel, upsert_key) -> synthetic message id
95
+ self._seq_by_chan: dict[str, int] = {}
96
+
97
+ @staticmethod
98
+ def _parse(channel_key: str) -> dict:
99
+ # "web:session/{id}" -> {"session": "..."}
100
+ parts = channel_key.split(":", 1)[1] # session/{id}
101
+ k, v = parts.split("/", 1)
102
+ return {k: v}
103
+
104
+ def _next_seq(self, ch: str) -> str:
105
+ n = self._seq_by_chan.get(ch, 0) + 1
106
+ self._seq_by_chan[ch] = n
107
+ return str(n)
108
+
109
+ async def peek_thread(self, channel_key: str) -> str | None:
110
+ # no threads in web adapter
111
+ return None
112
+
113
+ async def send(self, event: OutEvent) -> dict | None:
114
+ meta = self._parse(event.channel)
115
+ session_id = meta["session"]
116
+
117
+ # upsert bookkeeping like Slack: ensure a stable logical message per upsert_key
118
+ if event.upsert_key:
119
+ key = (event.channel, event.upsert_key)
120
+ if key not in self._first_msg_by_key:
121
+ self._first_msg_by_key[key] = self._next_seq(event.channel)
122
+
123
+ payload: dict[str, Any] = _serialize_event(event)
124
+ await self.hub.emit(session_id, payload)
125
+
126
+ # return correlator so ChannelSession can bind (consistent with Slack)
127
+ return {
128
+ "correlator": Correlator(
129
+ scheme="web",
130
+ channel=event.channel,
131
+ thread=None,
132
+ message=self._next_seq(event.channel),
133
+ )
134
+ }
File without changes
@@ -0,0 +1,86 @@
1
+ from fastapi import APIRouter, Query, Request
2
+ from pydantic import BaseModel
3
+
4
+ from aethergraph.services.continuations.continuation import Correlator
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ @router.get("/api/continuations/latest")
10
+ async def latest(request: Request, channel: str, kind: str | None = Query(None)):
11
+ """
12
+ Console: resolve newest open continuation bound to this channel.
13
+ We use a channel-wide correlator (no thread/message).
14
+ TODO: add a 'message' query param later if want more precise matching.
15
+ """
16
+ c = request.app.state.container
17
+
18
+ # First try channel-wide correlator (message="")
19
+ corr = Correlator(scheme="console", channel=channel, thread="", message="")
20
+ cont = await c.cont_store.find_by_correlator(corr=corr)
21
+
22
+ # (Optional) If we pass ?message=... from a UI client, try that first:
23
+ # message = request.query_params.get("message")
24
+ # if message:
25
+ # corr_precise = Correlator(scheme="console", channel=channel, thread="", message=message)
26
+ # cont = c.cont_store.find_by_correlator(corr=corr_precise) or cont
27
+
28
+ if kind and cont and cont.kind != kind:
29
+ # If caller asks for a specific kind, and the found one doesn't match, return None
30
+ return None
31
+
32
+ return cont.to_dict() if cont else None
33
+
34
+
35
+ class ConsoleResume(BaseModel):
36
+ run_id: str
37
+ node_id: str
38
+ token: str
39
+ payload: dict
40
+
41
+
42
+ @router.post("/api/console/resume")
43
+ async def console_resume(request: Request, req: ConsoleResume):
44
+ c = request.app.state.container
45
+ payload = dict(req.payload or {})
46
+ cont = (
47
+ await c.cont_store.get_by_token(req.token)
48
+ if hasattr(c.cont_store, "get_by_token")
49
+ else None
50
+ )
51
+
52
+ if cont and getattr(cont, "kind", "") == "approval":
53
+ # If client already parsed the choice, don't override it
54
+ if "choice" in payload or "approved" in payload:
55
+ pass # trust the client (console watcher)
56
+ else:
57
+ raw = str(payload.get("text", "")).strip()
58
+ # Try to use mappings echoed by the client (if any)
59
+ options_map = payload.get("options_map") or {}
60
+ label_map = payload.get("options_label_to_value") or {}
61
+
62
+ # Otherwise reconstruct from the Continuation.prompt
63
+ if not options_map and isinstance(cont.prompt, dict):
64
+ labels = cont.prompt.get("buttons") or cont.prompt.get("options") or []
65
+ options_map = {
66
+ str(i + 1): str(lbl).lower() for i, lbl in enumerate(labels, start=1)
67
+ }
68
+ label_map = {str(lbl).lower(): str(lbl).lower() for lbl in labels}
69
+
70
+ if raw.isdigit() and raw in options_map:
71
+ choice = options_map[raw]
72
+ else:
73
+ choice = label_map.get(raw.lower(), raw.lower())
74
+
75
+ payload = {
76
+ "approved": choice in {"approve", "approved", "yes", "y"},
77
+ "choice": choice,
78
+ }
79
+
80
+ await c.resume_router.resume(req.run_id, req.node_id, req.token, payload)
81
+
82
+ # TODO: (optional safety) if our resume router does NOT mark it closed internally:
83
+ # c.cont_store.mark_closed(req.token) or delete it;
84
+ # now it seems we haven't gone through resume_router for console resumes yet. So the continuation remains open.
85
+
86
+ return {"ok": True}
@@ -0,0 +1,49 @@
1
+ # slack_http_routes.py
2
+
3
+ import json
4
+
5
+ from fastapi import APIRouter, Request
6
+ from starlette.responses import JSONResponse
7
+
8
+ from ..utils.slack_utils import (
9
+ _verify_sig,
10
+ handle_slack_events_common,
11
+ handle_slack_interactive_common,
12
+ )
13
+
14
+ router = APIRouter()
15
+
16
+
17
+ @router.post("/slack/events")
18
+ async def slack_events(request: Request):
19
+ settings = request.app.state.settings
20
+ container = request.app.state.container
21
+
22
+ body = await request.body()
23
+ _verify_sig(request, body) # HTTP-only
24
+
25
+ payload = json.loads(body)
26
+
27
+ # URL verification (Events API handshake)
28
+ if payload.get("type") == "url_verification":
29
+ # Just echo the challenge back
30
+ return JSONResponse(payload)
31
+
32
+ # Delegate real work to shared handler
33
+ resp = await handle_slack_events_common(container, settings, payload)
34
+ return JSONResponse(resp or {})
35
+
36
+
37
+ @router.post("/slack/interact")
38
+ async def slack_interact(request: Request):
39
+ """Handle interactive components (buttons) from Slack via HTTP."""
40
+ container = request.app.state.container
41
+
42
+ body = await request.body()
43
+ _verify_sig(request, body) # HTTP-only
44
+
45
+ form = await request.form()
46
+ payload = json.loads(form["payload"])
47
+
48
+ await handle_slack_interactive_common(container, payload)
49
+ return JSONResponse({}) # ack