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,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
|