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,324 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
7
|
+
|
|
8
|
+
from aethergraph.services.continuations.continuation import Correlator
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
# Reuse one aiohttp session with timeouts
|
|
13
|
+
_aiohttp_session: aiohttp.ClientSession | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _http_session() -> aiohttp.ClientSession:
|
|
17
|
+
global _aiohttp_session
|
|
18
|
+
if _aiohttp_session is None or _aiohttp_session.closed:
|
|
19
|
+
timeout = aiohttp.ClientTimeout(
|
|
20
|
+
total=40, # > 30
|
|
21
|
+
connect=5,
|
|
22
|
+
sock_read=35, # > 30
|
|
23
|
+
)
|
|
24
|
+
connector = aiohttp.TCPConnector(limit=50, ttl_dns_cache=300)
|
|
25
|
+
_aiohttp_session = aiohttp.ClientSession(timeout=timeout, connector=connector)
|
|
26
|
+
return _aiohttp_session
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verify_secret(request: Request):
|
|
30
|
+
TELEGRAM_WEBHOOK_SECRET = (
|
|
31
|
+
request.app.state.settings.telegram.webhook_secret.get_secret_value() or ""
|
|
32
|
+
)
|
|
33
|
+
if not TELEGRAM_WEBHOOK_SECRET:
|
|
34
|
+
raise HTTPException(401, "no telegram webhook secret configured")
|
|
35
|
+
if TELEGRAM_WEBHOOK_SECRET:
|
|
36
|
+
hdr = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
|
|
37
|
+
if not hmac.compare_digest(hdr or "", TELEGRAM_WEBHOOK_SECRET):
|
|
38
|
+
raise HTTPException(401, "bad telegram webhook secret")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _channel_key(chat_id: int, topic_id: int | None) -> str:
|
|
42
|
+
base = f"tg:chat/{int(chat_id)}"
|
|
43
|
+
return f"{base}:topic/{int(topic_id)}" if topic_id else base
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---- helpers ----
|
|
47
|
+
async def _tg_get_file_path(file_id: str, token: str) -> str | None:
|
|
48
|
+
if not token:
|
|
49
|
+
return None
|
|
50
|
+
api = f"https://api.telegram.org/bot{token}/getFile"
|
|
51
|
+
async with _http_session().post(api, json={"file_id": file_id}) as r:
|
|
52
|
+
if r.status != 200:
|
|
53
|
+
return None
|
|
54
|
+
data = await r.json()
|
|
55
|
+
if not data.get("ok"):
|
|
56
|
+
return None
|
|
57
|
+
return (data.get("result") or {}).get("file_path")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _tg_download_file(file_path: str, token: str) -> bytes:
|
|
61
|
+
url = f"https://api.telegram.org/file/bot{token}/{file_path}"
|
|
62
|
+
async with _http_session().get(url) as r:
|
|
63
|
+
r.raise_for_status()
|
|
64
|
+
return await r.read()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -------- NEW: background worker that does the heavy lifting --------
|
|
68
|
+
async def _process_update(container, payload: dict, token: str):
|
|
69
|
+
try:
|
|
70
|
+
# Callback queries (inline button presses)
|
|
71
|
+
cq = payload.get("callback_query")
|
|
72
|
+
if cq:
|
|
73
|
+
msg = cq.get("message") or {}
|
|
74
|
+
chat = msg.get("chat") or {}
|
|
75
|
+
chat_id = chat.get("id")
|
|
76
|
+
topic_id = msg.get("message_thread_id")
|
|
77
|
+
ch_key = _channel_key(chat_id, topic_id)
|
|
78
|
+
|
|
79
|
+
data_raw = cq.get("data") or ""
|
|
80
|
+
choice = "reject"
|
|
81
|
+
resume_key = None
|
|
82
|
+
|
|
83
|
+
# Accept JSON or compact "c=...|k=..." forms
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(data_raw)
|
|
86
|
+
choice = str(data.get("choice", "reject"))
|
|
87
|
+
resume_key = data.get("resume_key") or data.get("k")
|
|
88
|
+
except Exception:
|
|
89
|
+
try:
|
|
90
|
+
parts = dict(p.split("=", 1) for p in data_raw.split("|") if "=" in p)
|
|
91
|
+
choice = parts.get("c", "reject")
|
|
92
|
+
resume_key = parts.get("k")
|
|
93
|
+
except Exception:
|
|
94
|
+
choice = str(data_raw)
|
|
95
|
+
|
|
96
|
+
choice_l = choice.lower()
|
|
97
|
+
# approved = choice_l.startswith("approve") or choice_l in {"yes","y","ok"} # resolve from choice string
|
|
98
|
+
|
|
99
|
+
token = None
|
|
100
|
+
run_id = None
|
|
101
|
+
node_id = None
|
|
102
|
+
|
|
103
|
+
# Resolve alias → token (preferred)
|
|
104
|
+
if resume_key and hasattr(container.cont_store, "token_from_alias"):
|
|
105
|
+
token = container.cont_store.token_from_alias(resume_key)
|
|
106
|
+
|
|
107
|
+
if token and hasattr(container.cont_store, "get_by_token"):
|
|
108
|
+
cont = container.cont_store.get_by_token(token)
|
|
109
|
+
if cont:
|
|
110
|
+
run_id, node_id = cont.run_id, cont.node_id
|
|
111
|
+
|
|
112
|
+
# Fallback: thread-scoped correlator
|
|
113
|
+
if not token:
|
|
114
|
+
corr = Correlator(
|
|
115
|
+
scheme="tg", channel=ch_key, thread=str(topic_id or ""), message=""
|
|
116
|
+
)
|
|
117
|
+
cont = await container.cont_store.find_by_correlator(corr=corr)
|
|
118
|
+
if cont:
|
|
119
|
+
run_id, node_id, token = cont.run_id, cont.node_id, cont.token
|
|
120
|
+
|
|
121
|
+
if token and run_id and node_id:
|
|
122
|
+
await container.resume_router.resume(
|
|
123
|
+
run_id=run_id,
|
|
124
|
+
node_id=node_id,
|
|
125
|
+
token=token,
|
|
126
|
+
payload={
|
|
127
|
+
"choice": choice_l,
|
|
128
|
+
"telegram": {
|
|
129
|
+
"callback_id": cq.get("id"),
|
|
130
|
+
"message_id": msg.get("message_id"),
|
|
131
|
+
"chat_id": chat_id,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Ack the button press to stop the spinner
|
|
137
|
+
try:
|
|
138
|
+
tg_adapter = container.channels.adapters.get("tg")
|
|
139
|
+
if tg_adapter:
|
|
140
|
+
await tg_adapter._api("answerCallbackQuery", callback_query_id=cq.get("id"))
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Regular messages / uploads
|
|
146
|
+
msg = payload.get("message")
|
|
147
|
+
if not msg:
|
|
148
|
+
return
|
|
149
|
+
if (msg.get("from") or {}).get("is_bot"):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
chat = msg.get("chat") or {}
|
|
153
|
+
chat_id = chat.get("id")
|
|
154
|
+
topic_id = msg.get("message_thread_id")
|
|
155
|
+
ch_key = _channel_key(chat_id, topic_id)
|
|
156
|
+
text = (msg.get("text") or msg.get("caption") or "") or ""
|
|
157
|
+
|
|
158
|
+
files: list[dict[str, Any]] = []
|
|
159
|
+
|
|
160
|
+
# Photos
|
|
161
|
+
photos = msg.get("photo") or []
|
|
162
|
+
if photos:
|
|
163
|
+
ph = photos[-1]
|
|
164
|
+
file_id = ph.get("file_id")
|
|
165
|
+
size = ph.get("file_size")
|
|
166
|
+
name = f"photo_{file_id}.jpg"
|
|
167
|
+
file_path = await _tg_get_file_path(file_id, token)
|
|
168
|
+
if file_path:
|
|
169
|
+
try:
|
|
170
|
+
data = await _tg_download_file(file_path, token)
|
|
171
|
+
uri = await _stage_and_save(
|
|
172
|
+
container, data=data, name=name, ch_key=ch_key, cont=None
|
|
173
|
+
)
|
|
174
|
+
files.append(
|
|
175
|
+
_file_ref(
|
|
176
|
+
file_id=file_id,
|
|
177
|
+
name=name,
|
|
178
|
+
mimetype="image/jpeg",
|
|
179
|
+
size=size,
|
|
180
|
+
uri=uri,
|
|
181
|
+
ch_key=ch_key,
|
|
182
|
+
ts=msg.get("date"),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
container.logger and container.logger.for_run().warning(
|
|
187
|
+
f"Telegram photo download failed: {e}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Documents
|
|
191
|
+
doc = msg.get("document")
|
|
192
|
+
if doc:
|
|
193
|
+
file_id = doc.get("file_id")
|
|
194
|
+
size = doc.get("file_size")
|
|
195
|
+
name = doc.get("file_name") or f"document_{file_id}"
|
|
196
|
+
mime = _normalize_mime_by_name(name, doc.get("mime_type"))
|
|
197
|
+
file_path = await _tg_get_file_path(file_id, token)
|
|
198
|
+
if file_path:
|
|
199
|
+
try:
|
|
200
|
+
data = await _tg_download_file(file_path, token)
|
|
201
|
+
uri = _stage_and_save(container, data=data, name=name, ch_key=ch_key, cont=None)
|
|
202
|
+
files.append(
|
|
203
|
+
_file_ref(
|
|
204
|
+
file_id=file_id,
|
|
205
|
+
name=name,
|
|
206
|
+
mimetype=mime,
|
|
207
|
+
size=size,
|
|
208
|
+
uri=uri,
|
|
209
|
+
ch_key=ch_key,
|
|
210
|
+
ts=msg.get("date"),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
container.logger and container.logger.for_run().warning(
|
|
215
|
+
f"Telegram document download failed: {e}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if files:
|
|
219
|
+
await _append_inbox(container, ch_key, files)
|
|
220
|
+
|
|
221
|
+
# Look up continuation by thread-scoped correlator (message-less)
|
|
222
|
+
cont = None
|
|
223
|
+
corr = Correlator(scheme="tg", channel=ch_key, thread=str(topic_id or ""), message="")
|
|
224
|
+
cont = await container.cont_store.find_by_correlator(corr=corr)
|
|
225
|
+
container.logger and container.logger.for_run().debug(
|
|
226
|
+
f"[TG] inbound: text='{text}' files={len(files)} cont={bool(cont)}"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if not cont:
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
payload_out = {
|
|
233
|
+
"text": text,
|
|
234
|
+
"telegram": {"message_id": msg.get("message_id"), "chat_id": chat_id},
|
|
235
|
+
}
|
|
236
|
+
if cont.kind in ("user_files", "user_input_or_files"):
|
|
237
|
+
payload_out["files"] = files
|
|
238
|
+
|
|
239
|
+
await container.resume_router.resume(cont.run_id, cont.node_id, cont.token, payload_out)
|
|
240
|
+
|
|
241
|
+
except Exception as e:
|
|
242
|
+
container.logger and container.logger.for_run().error(
|
|
243
|
+
f"Telegram inbound processing error: {e}", exc_info=True
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- file helpers ----
|
|
248
|
+
def _normalize_mime_by_name(name: str | None, hint: str | None) -> str:
|
|
249
|
+
extmap = {
|
|
250
|
+
"png": "image/png",
|
|
251
|
+
"jpg": "image/jpeg",
|
|
252
|
+
"jpeg": "image/jpeg",
|
|
253
|
+
"gif": "image/gif",
|
|
254
|
+
"webp": "image/webp",
|
|
255
|
+
"tif": "image/tiff",
|
|
256
|
+
"tiff": "image/tiff",
|
|
257
|
+
"bmp": "image/bmp",
|
|
258
|
+
"svg": "image/svg+xml",
|
|
259
|
+
"pdf": "application/pdf",
|
|
260
|
+
"csv": "text/csv",
|
|
261
|
+
"json": "application/json",
|
|
262
|
+
"yaml": "text/yaml",
|
|
263
|
+
"yml": "text/yaml",
|
|
264
|
+
"txt": "text/plain",
|
|
265
|
+
"md": "text/markdown",
|
|
266
|
+
"zip": "application/zip",
|
|
267
|
+
"gz": "application/gzip",
|
|
268
|
+
"tar": "application/x-tar",
|
|
269
|
+
"7z": "application/x-7z-compressed",
|
|
270
|
+
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
271
|
+
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
272
|
+
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
273
|
+
"mph": "application/octet-stream",
|
|
274
|
+
}
|
|
275
|
+
if hint:
|
|
276
|
+
return hint.lower()
|
|
277
|
+
if name and "." in name:
|
|
278
|
+
ext = name.lower().rsplit(".", 1)[-1]
|
|
279
|
+
return extmap.get(ext, "application/octet-stream")
|
|
280
|
+
return "application/octet-stream"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def _stage_and_save(container, *, data: bytes, name: str, ch_key: str, cont) -> str:
|
|
284
|
+
tmp = container.artifacts.tmp_path(suffix=f"_{name}")
|
|
285
|
+
with open(tmp, "wb") as f:
|
|
286
|
+
f.write(data)
|
|
287
|
+
run_id = cont.run_id if cont else "ad-hoc"
|
|
288
|
+
node_id = cont.node_id if cont else "telegram"
|
|
289
|
+
art = await container.artifacts.save_file(
|
|
290
|
+
path=tmp,
|
|
291
|
+
kind="upload",
|
|
292
|
+
run_id=run_id,
|
|
293
|
+
graph_id="channel",
|
|
294
|
+
node_id=node_id,
|
|
295
|
+
tool_name="telegram.upload",
|
|
296
|
+
tool_version="0.0.1",
|
|
297
|
+
labels={"source": "telegram", "channel": ch_key, "name": name},
|
|
298
|
+
)
|
|
299
|
+
return getattr(art, "uri", None) or f"file://{tmp}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _file_ref(
|
|
303
|
+
*, file_id: str, name: str, mimetype: str, size: int | None, uri: str, ch_key: str, ts: Any
|
|
304
|
+
):
|
|
305
|
+
return {
|
|
306
|
+
"id": file_id,
|
|
307
|
+
"name": name,
|
|
308
|
+
"mimetype": mimetype or "",
|
|
309
|
+
"size": size,
|
|
310
|
+
"uri": uri,
|
|
311
|
+
"url_private": None,
|
|
312
|
+
"platform": "telegram",
|
|
313
|
+
"channel_key": ch_key,
|
|
314
|
+
"ts": ts,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def _append_inbox(container, ch_key: str, file_refs: list[dict[str, Any]]):
|
|
319
|
+
kv = getattr(container, "kv_hot", None)
|
|
320
|
+
if kv:
|
|
321
|
+
await kv.list_append_unique(f"inbox://{ch_key}", file_refs, id_key="id")
|
|
322
|
+
else:
|
|
323
|
+
logger = getattr(container, "logger", None)
|
|
324
|
+
logger and logger.for_run().warning("No KV present; uploads inbox not stored.")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from aethergraph.utils.optdeps import require
|
|
2
|
+
|
|
3
|
+
from ..utils.slack_utils import handle_slack_events_common, handle_slack_interactive_common
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
require(pkg="slack_sdk", extra="slack")
|
|
7
|
+
from slack_sdk.socket_mode.aiohttp import SocketModeClient
|
|
8
|
+
from slack_sdk.socket_mode.request import SocketModeRequest
|
|
9
|
+
from slack_sdk.web.async_client import AsyncWebClient
|
|
10
|
+
except ImportError:
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"slack_sdk is required for SlackSocketModeRunner; please install aethergraph with the [slack] extra."
|
|
13
|
+
) from None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SlackSocketModeRunner:
|
|
17
|
+
def __init__(self, container, settings):
|
|
18
|
+
self.container = container
|
|
19
|
+
self.settings = settings
|
|
20
|
+
|
|
21
|
+
self.bot_token = (
|
|
22
|
+
settings.slack.bot_token.get_secret_value() if settings.slack.bot_token else ""
|
|
23
|
+
)
|
|
24
|
+
self.app_token = (
|
|
25
|
+
settings.slack.app_token.get_secret_value() if settings.slack.app_token else ""
|
|
26
|
+
) # xapp-...
|
|
27
|
+
|
|
28
|
+
self.web_client = AsyncWebClient(token=self.bot_token)
|
|
29
|
+
self.client: SocketModeClient | None = None
|
|
30
|
+
|
|
31
|
+
async def _handle_socket_request(self, client: SocketModeClient, req: SocketModeRequest):
|
|
32
|
+
# events from Slack
|
|
33
|
+
if req.type == "events_api":
|
|
34
|
+
# req.payload has same shape as HTTP Events API body
|
|
35
|
+
await handle_slack_events_common(self.container, self.settings, req.payload)
|
|
36
|
+
await client.send_socket_mode_response({"envelope_id": req.envelope_id})
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
# interactive actions (buttons, etc.)
|
|
40
|
+
if req.type == "interactive":
|
|
41
|
+
# payload is already parsed JSON dict
|
|
42
|
+
payload = req.payload
|
|
43
|
+
await handle_slack_interactive_common(self.container, payload)
|
|
44
|
+
await client.send_socket_mode_response({"envelope_id": req.envelope_id})
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# other request types (slash commands, shortcuts, etc.) can be added later
|
|
48
|
+
await client.send_socket_mode_response({"envelope_id": req.envelope_id})
|
|
49
|
+
|
|
50
|
+
async def start(self):
|
|
51
|
+
lg = self.container.logger.for_run()
|
|
52
|
+
if not (self.bot_token and self.app_token):
|
|
53
|
+
lg.warning(
|
|
54
|
+
"[Slack SocketMode] bot_token or app_token not configured; skipping Socket Mode startup."
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
self.client = SocketModeClient(
|
|
59
|
+
app_token=self.app_token,
|
|
60
|
+
web_client=self.web_client,
|
|
61
|
+
)
|
|
62
|
+
# register listener
|
|
63
|
+
self.client.socket_mode_request_listeners.append(self._handle_socket_request)
|
|
64
|
+
|
|
65
|
+
lg.info("[Slack SocketMode] connecting to Slack...")
|
|
66
|
+
await self.client.connect()
|
|
67
|
+
# NOTE: this call returns immediately; the internal loop lives with the event loop
|
|
68
|
+
lg.info("[Slack SocketMode] connected.")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# telegram_polling.py
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from ..utils.telegram_utils import _http_session, _process_update
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TelegramPollingRunner:
|
|
12
|
+
def __init__(self, container, settings):
|
|
13
|
+
self.container = container
|
|
14
|
+
self.settings = settings
|
|
15
|
+
self.bot_token: str = settings.telegram.bot_token.get_secret_value() or ""
|
|
16
|
+
self._stop = False
|
|
17
|
+
|
|
18
|
+
async def stop(self):
|
|
19
|
+
self._stop = True
|
|
20
|
+
|
|
21
|
+
async def _fetch_updates(self, offset: int | None) -> list[dict[str, Any]]:
|
|
22
|
+
if not self.bot_token:
|
|
23
|
+
self.container.logger.for_run().warning(
|
|
24
|
+
"[TelegramPolling] no bot token, skipping fetch"
|
|
25
|
+
)
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
api = f"https://api.telegram.org/bot{self.bot_token}/getUpdates"
|
|
29
|
+
params: dict[str, Any] = {"timeout": 30}
|
|
30
|
+
if offset is not None:
|
|
31
|
+
params["offset"] = offset
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
sess = _http_session()
|
|
35
|
+
async with sess.get(api, params=params) as r:
|
|
36
|
+
status = r.status
|
|
37
|
+
# Read raw text so we can log even if JSON parse fails
|
|
38
|
+
raw = await r.text()
|
|
39
|
+
|
|
40
|
+
if status != 200:
|
|
41
|
+
self.container.logger.for_run().warning(
|
|
42
|
+
f"[TelegramPolling] non-200 status: {status}, returning empty list"
|
|
43
|
+
)
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(raw)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
self.container.logger.for_run().error(
|
|
50
|
+
f"[TelegramPolling] JSON decode error: {e}"
|
|
51
|
+
)
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
if not data.get("ok"):
|
|
55
|
+
self.container.logger.for_run().warning(
|
|
56
|
+
f"[TelegramPolling] ok=false in response: {data}"
|
|
57
|
+
)
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
result = data.get("result") or []
|
|
61
|
+
if result:
|
|
62
|
+
first_id = result[0].get("update_id")
|
|
63
|
+
last_id = result[-1].get("update_id")
|
|
64
|
+
self.container.logger.for_run().info(
|
|
65
|
+
f"[TelegramPolling] got {len(result)} updates, ids {first_id}..{last_id}"
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
self.container.logger.for_run().info(
|
|
69
|
+
"[TelegramPolling] got 0 updates in this poll"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
except asyncio.TimeoutError:
|
|
75
|
+
self.container.logger.for_run().warning(
|
|
76
|
+
"[TelegramPolling] asyncio.TimeoutError while fetching updates"
|
|
77
|
+
)
|
|
78
|
+
return []
|
|
79
|
+
except aiohttp.ClientError as e:
|
|
80
|
+
self.container.logger.for_run().error(f"[TelegramPolling] aiohttp.ClientError: {e}")
|
|
81
|
+
return []
|
|
82
|
+
except Exception as e:
|
|
83
|
+
self.container.logger.for_run().error(
|
|
84
|
+
f"[TelegramPolling] unexpected error in _fetch_updates: {e}"
|
|
85
|
+
)
|
|
86
|
+
return []
|
|
87
|
+
except aiohttp.ClientConnectionError as e:
|
|
88
|
+
self.container.logger.for_run().warning(f"[TelegramPolling] ClientConnectionError: {e}")
|
|
89
|
+
|
|
90
|
+
async def _fetch_updates_(self, offset: int | None) -> list[dict[str, Any]]:
|
|
91
|
+
if not self.bot_token:
|
|
92
|
+
return []
|
|
93
|
+
api = f"https://api.telegram.org/bot{self.bot_token}/getUpdates"
|
|
94
|
+
params: dict[str, Any] = {"timeout": 30}
|
|
95
|
+
if offset is not None:
|
|
96
|
+
params["offset"] = offset
|
|
97
|
+
|
|
98
|
+
async with _http_session().get(api, params=params) as r:
|
|
99
|
+
if r.status != 200:
|
|
100
|
+
return []
|
|
101
|
+
data = await r.json()
|
|
102
|
+
if not data.get("ok"):
|
|
103
|
+
return []
|
|
104
|
+
return data.get("result") or []
|
|
105
|
+
|
|
106
|
+
async def start(self):
|
|
107
|
+
if not self.bot_token:
|
|
108
|
+
self.container.logger.for_run().warning(
|
|
109
|
+
"[TelegramPolling] not started: missing bot token"
|
|
110
|
+
)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self.container.logger.for_run().info("[TelegramPolling] starting polling loop...")
|
|
114
|
+
|
|
115
|
+
offset: int | None = None
|
|
116
|
+
|
|
117
|
+
# OPTIONAL: initial drain of old updates so we only react to new ones
|
|
118
|
+
try:
|
|
119
|
+
initial_updates = await self._fetch_updates(offset=None)
|
|
120
|
+
if initial_updates:
|
|
121
|
+
last_id = initial_updates[-1].get("update_id")
|
|
122
|
+
if last_id is not None:
|
|
123
|
+
offset = last_id + 1
|
|
124
|
+
except Exception as e:
|
|
125
|
+
self.container.logger.for_run().error(f"[TelegramPolling] initial drain failed: {e}")
|
|
126
|
+
|
|
127
|
+
while not self._stop:
|
|
128
|
+
try:
|
|
129
|
+
self.container.logger.for_run().info(
|
|
130
|
+
f"[TelegramPolling] fetching updates with offset={offset}..."
|
|
131
|
+
)
|
|
132
|
+
updates = await self._fetch_updates(offset)
|
|
133
|
+
self.container.logger.for_run().info(
|
|
134
|
+
f"[TelegramPolling] fetched {len(updates)} updates."
|
|
135
|
+
)
|
|
136
|
+
if updates:
|
|
137
|
+
# process each, then bump offset past the last one
|
|
138
|
+
for upd in updates:
|
|
139
|
+
await _process_update(self.container, upd, self.bot_token)
|
|
140
|
+
|
|
141
|
+
last_id = updates[-1].get("update_id")
|
|
142
|
+
if last_id is not None:
|
|
143
|
+
offset = last_id + 1
|
|
144
|
+
# if no updates, loop back (Telegram held the connection up to timeout)
|
|
145
|
+
except asyncio.CancelledError:
|
|
146
|
+
break
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self.container.logger.for_run().error(f"[TelegramPolling] error: {e}")
|
|
149
|
+
await asyncio.sleep(5)
|
|
150
|
+
|
|
151
|
+
self.container.logger.for_run().info("[TelegramPolling] stopped.")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Minimal MCP filesystem server over stdio JSON-RPC (cross-platform)
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
"name": "readFile",
|
|
10
|
+
"description": "Read a text file",
|
|
11
|
+
"input_schema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {"path": {"type": "string"}},
|
|
14
|
+
"required": ["path"],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "writeFile",
|
|
19
|
+
"description": "Write text to a file",
|
|
20
|
+
"input_schema": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {"path": {"type": "string"}, "text": {"type": "string"}},
|
|
23
|
+
"required": ["path", "text"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "listDir",
|
|
28
|
+
"description": "List directory entries",
|
|
29
|
+
"input_schema": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {"path": {"type": "string"}},
|
|
32
|
+
"required": ["path"],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "stat",
|
|
37
|
+
"description": "Stat a file or directory",
|
|
38
|
+
"input_schema": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {"path": {"type": "string"}},
|
|
41
|
+
"required": ["path"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ok(id, result):
|
|
48
|
+
return {"jsonrpc": "2.0", "id": id, "result": result}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _err(id, code=-32000, msg="Server error", data=None):
|
|
52
|
+
e = {"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": msg}}
|
|
53
|
+
if data is not None:
|
|
54
|
+
e["error"]["data"] = data
|
|
55
|
+
return e
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_tools():
|
|
59
|
+
return TOOLS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def call(name, args):
|
|
63
|
+
if name == "readFile":
|
|
64
|
+
p = args["path"]
|
|
65
|
+
with open(p, encoding="utf-8") as f:
|
|
66
|
+
txt = f.read()
|
|
67
|
+
return {"text": txt}
|
|
68
|
+
if name == "writeFile":
|
|
69
|
+
p, t = args["path"], args["text"]
|
|
70
|
+
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
|
71
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
72
|
+
f.write(t)
|
|
73
|
+
return {"ok": True, "bytes": len(t)}
|
|
74
|
+
if name == "listDir":
|
|
75
|
+
p = args["path"]
|
|
76
|
+
entries = []
|
|
77
|
+
for name in os.listdir(p):
|
|
78
|
+
fp = os.path.join(p, name)
|
|
79
|
+
entries.append(
|
|
80
|
+
{
|
|
81
|
+
"name": name,
|
|
82
|
+
"is_dir": os.path.isdir(fp),
|
|
83
|
+
"size": os.path.getsize(fp) if os.path.isfile(fp) else None,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return {"entries": entries}
|
|
87
|
+
if name == "stat":
|
|
88
|
+
p = args["path"]
|
|
89
|
+
st = os.stat(p)
|
|
90
|
+
return {"path": p, "is_dir": os.path.isdir(p), "size": st.st_size, "mtime": st.st_mtime}
|
|
91
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main():
|
|
95
|
+
stdin = sys.stdin
|
|
96
|
+
stdout = sys.stdout
|
|
97
|
+
while True:
|
|
98
|
+
line = stdin.readline()
|
|
99
|
+
if not line:
|
|
100
|
+
break
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
req = json.loads(line)
|
|
106
|
+
mid = req.get("id")
|
|
107
|
+
method = req.get("method")
|
|
108
|
+
params = req.get("params") or {}
|
|
109
|
+
if method == "tools/list":
|
|
110
|
+
resp = _ok(mid, list_tools())
|
|
111
|
+
elif method == "tools/call":
|
|
112
|
+
name = params.get("name")
|
|
113
|
+
args = params.get("arguments") or {}
|
|
114
|
+
resp = _ok(mid, call(name, args))
|
|
115
|
+
elif method == "resources/list":
|
|
116
|
+
resp = _ok(mid, []) # not used in this minimal server
|
|
117
|
+
elif method == "resources/read":
|
|
118
|
+
resp = _ok(mid, {"uri": params.get("uri"), "data": None})
|
|
119
|
+
else:
|
|
120
|
+
resp = _err(mid, msg=f"Unknown method {method}")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
resp = _err(req.get("id"), msg=str(e), data=traceback.format_exc())
|
|
123
|
+
stdout.write(json.dumps(resp) + "\n")
|
|
124
|
+
stdout.flush()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
main()
|