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