aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a2__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 +4 -10
- aethergraph/__main__.py +293 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +190 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a2.dist-info/RECORD +356 -0
- aethergraph-0.1.0a2.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/top_level.txt +0 -0
|
@@ -6,7 +6,11 @@ import time
|
|
|
6
6
|
import aiohttp
|
|
7
7
|
from fastapi import HTTPException, Request
|
|
8
8
|
|
|
9
|
-
from aethergraph.services.
|
|
9
|
+
from aethergraph.services.channel.ingress import (
|
|
10
|
+
ChannelIngress,
|
|
11
|
+
IncomingFile,
|
|
12
|
+
IncomingMessage,
|
|
13
|
+
)
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
# --- shared utils ---
|
|
@@ -19,11 +23,20 @@ async def _download_slack_file(url: str, token: str) -> bytes:
|
|
|
19
23
|
return await r.read()
|
|
20
24
|
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
def _slack_scheme_and_channel_id(team_id: str | None, channel_id: str | None) -> tuple[str, str]:
|
|
27
|
+
"""
|
|
28
|
+
Map Slack team/channel to the (scheme, channel_id) pair used by ChannelIngress.
|
|
29
|
+
|
|
30
|
+
We keep the existing Slack channel key shape:
|
|
31
|
+
ch_key = "slack:team/T:chan/C"
|
|
32
|
+
and split it as:
|
|
33
|
+
scheme = "slack"
|
|
34
|
+
channel_id = "team/T:chan/C"
|
|
35
|
+
"""
|
|
36
|
+
team = team_id or "unknown"
|
|
37
|
+
chan = channel_id or "unknown"
|
|
38
|
+
# This matches your existing _channel_key base form.
|
|
39
|
+
return "slack", f"team/{team}:chan/{chan}"
|
|
27
40
|
|
|
28
41
|
|
|
29
42
|
def _verify_sig(request: Request, body: bytes):
|
|
@@ -62,7 +75,7 @@ def _channel_key(team_id: str, channel_id: str, thread_ts: str | None) -> str:
|
|
|
62
75
|
async def _stage_and_save(c, *, data: bytes, file_id: str, name: str, ch_key: str, cont) -> str:
|
|
63
76
|
"""Write bytes to tmp path, then save via FileArtifactStore.save_file(...).
|
|
64
77
|
Returns the Artifact.uri (string)."""
|
|
65
|
-
tmp = c.artifacts.
|
|
78
|
+
tmp = await c.artifacts.plan_staging_path(planned_ext=f"_{file_id}")
|
|
66
79
|
with open(tmp, "wb") as f:
|
|
67
80
|
f.write(data)
|
|
68
81
|
run_id = cont.run_id if cont else "ad-hoc"
|
|
@@ -88,12 +101,13 @@ async def _stage_and_save(c, *, data: bytes, file_id: str, name: str, ch_key: st
|
|
|
88
101
|
async def handle_slack_events_common(container, settings, payload: dict) -> dict:
|
|
89
102
|
"""
|
|
90
103
|
Common handler for Slack Events API payloads.
|
|
91
|
-
|
|
104
|
+
Now delegates continuation lookup & resume to ChannelIngress.
|
|
92
105
|
"""
|
|
93
106
|
SLACK_BOT_TOKEN = (
|
|
94
107
|
settings.slack.bot_token.get_secret_value() if settings.slack.bot_token else ""
|
|
95
108
|
)
|
|
96
109
|
c = container
|
|
110
|
+
ingress: ChannelIngress = c.channel_ingress # must exist in your container
|
|
97
111
|
|
|
98
112
|
ev = payload.get("event") or {}
|
|
99
113
|
ev_type = ev.get("type")
|
|
@@ -105,19 +119,13 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
|
|
|
105
119
|
chan = ev.get("channel")
|
|
106
120
|
text = ev.get("text", "") or ""
|
|
107
121
|
files = ev.get("files") or []
|
|
108
|
-
ch_key = _channel_key(team, chan, None)
|
|
109
122
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# Fallback to channel-root
|
|
117
|
-
corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
|
|
118
|
-
cont = await c.cont_store.find_by_correlator(corr=corr2)
|
|
119
|
-
|
|
120
|
-
file_refs = []
|
|
123
|
+
# Full Slack key for labels/metadata
|
|
124
|
+
ch_key = _channel_key(team, chan, None) # "slack:team/T:chan/C"
|
|
125
|
+
scheme, channel_id = _slack_scheme_and_channel_id(team, chan) # ("slack", "team/T:chan/C")
|
|
126
|
+
|
|
127
|
+
# --- Slack-specific file download + artifact save ---
|
|
128
|
+
file_refs: list[dict] = []
|
|
121
129
|
if files:
|
|
122
130
|
token = SLACK_BOT_TOKEN
|
|
123
131
|
for f in files:
|
|
@@ -130,11 +138,17 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
|
|
|
130
138
|
url_priv = f.get("url_private") or f.get("url_private_download")
|
|
131
139
|
|
|
132
140
|
uri = None
|
|
133
|
-
if url_priv:
|
|
141
|
+
if url_priv and token:
|
|
134
142
|
try:
|
|
135
143
|
data_bytes = await _download_slack_file(url_priv, token)
|
|
144
|
+
# use Slack-specific labels via _stage_and_save
|
|
136
145
|
uri = await _stage_and_save(
|
|
137
|
-
c,
|
|
146
|
+
c,
|
|
147
|
+
data=data_bytes,
|
|
148
|
+
file_id=file_id,
|
|
149
|
+
name=name,
|
|
150
|
+
ch_key=ch_key,
|
|
151
|
+
cont=None, # we don't know cont yet; ChannelIngress will find it
|
|
138
152
|
)
|
|
139
153
|
except Exception as e:
|
|
140
154
|
container.logger and container.logger.warning(
|
|
@@ -155,19 +169,48 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
|
|
|
155
169
|
}
|
|
156
170
|
)
|
|
157
171
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
172
|
+
# Turn Slack file_refs into IncomingFile so Ingress can do inbox + payload
|
|
173
|
+
incoming_files: list[IncomingFile] = []
|
|
174
|
+
for fr in file_refs:
|
|
175
|
+
incoming_files.append(
|
|
176
|
+
IncomingFile(
|
|
177
|
+
id=fr["id"],
|
|
178
|
+
name=fr["name"],
|
|
179
|
+
mimetype=fr.get("mimetype"),
|
|
180
|
+
size=fr.get("size"),
|
|
181
|
+
uri=fr.get("uri"), # already artifact-backed
|
|
182
|
+
url=None, # no re-download
|
|
183
|
+
extra={
|
|
184
|
+
"platform": "slack",
|
|
185
|
+
"channel_key": fr.get("channel_key"),
|
|
186
|
+
"ts": fr.get("ts"),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
161
190
|
|
|
162
|
-
|
|
163
|
-
|
|
191
|
+
meta = {
|
|
192
|
+
"raw": payload,
|
|
193
|
+
"channel_key": ch_key,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
# Let ChannelIngress find the continuation, update inbox, and resume
|
|
197
|
+
resumed = await ingress.handle(
|
|
198
|
+
IncomingMessage(
|
|
199
|
+
scheme=scheme,
|
|
200
|
+
channel_id=channel_id,
|
|
201
|
+
thread_id=str(thread_ts or ""),
|
|
202
|
+
text=text,
|
|
203
|
+
files=incoming_files or None,
|
|
204
|
+
meta=meta,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
164
207
|
|
|
165
|
-
if
|
|
166
|
-
|
|
167
|
-
|
|
208
|
+
if container.logger:
|
|
209
|
+
container.logger.for_run().debug(
|
|
210
|
+
f"[Slack] inbound message: text={text!r}, files={len(incoming_files)}, resumed={resumed}"
|
|
168
211
|
)
|
|
169
|
-
|
|
170
|
-
|
|
212
|
+
|
|
213
|
+
# Nothing special to return to Slack (Events API only cares that we 200)
|
|
171
214
|
return {}
|
|
172
215
|
|
|
173
216
|
# --- file_shared (out-of-band file) ---
|
|
@@ -182,15 +225,9 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
|
|
|
182
225
|
chan = ev.get("channel_id") or (ev.get("channel") or {}).get("id")
|
|
183
226
|
if not (file_id and chan):
|
|
184
227
|
return {}
|
|
185
|
-
ch_key = _channel_key(team, chan, None)
|
|
186
228
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
corr = Correlator(scheme="slack", channel=ch_key, thread=thread_ts, message="")
|
|
190
|
-
cont = await c.cont_store.find_by_correlator(corr=corr)
|
|
191
|
-
if not cont:
|
|
192
|
-
corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
|
|
193
|
-
cont = await c.cont_store.find_by_correlator(corr=corr2)
|
|
229
|
+
ch_key = _channel_key(team, chan, None)
|
|
230
|
+
scheme, channel_id = _slack_scheme_and_channel_id(team, chan)
|
|
194
231
|
|
|
195
232
|
info = await c.slack.client.files_info(file=file_id)
|
|
196
233
|
f = info.get("file") or {}
|
|
@@ -200,39 +237,58 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
|
|
|
200
237
|
url_priv = f.get("url_private") or f.get("url_private_download")
|
|
201
238
|
|
|
202
239
|
uri = None
|
|
203
|
-
if url_priv:
|
|
240
|
+
if url_priv and SLACK_BOT_TOKEN:
|
|
204
241
|
try:
|
|
205
242
|
data_bytes = await _download_slack_file(url_priv, SLACK_BOT_TOKEN)
|
|
206
243
|
uri = await _stage_and_save(
|
|
207
|
-
c,
|
|
244
|
+
c,
|
|
245
|
+
data=data_bytes,
|
|
246
|
+
file_id=file_id,
|
|
247
|
+
name=name,
|
|
248
|
+
ch_key=ch_key,
|
|
249
|
+
cont=None,
|
|
208
250
|
)
|
|
209
251
|
except Exception as e:
|
|
210
252
|
container.logger and container.logger.for_run().warning(
|
|
211
253
|
f"Slack download failed: {e}", exc_info=True
|
|
212
254
|
)
|
|
213
255
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
256
|
+
# Build IncomingFile with pre-saved uri
|
|
257
|
+
incoming_file = IncomingFile(
|
|
258
|
+
id=file_id,
|
|
259
|
+
name=name,
|
|
260
|
+
mimetype=mimetype,
|
|
261
|
+
size=size,
|
|
262
|
+
uri=uri, # already artifact-backed, no re-download when uri used in ingress
|
|
263
|
+
url=None,
|
|
264
|
+
extra={
|
|
265
|
+
"platform": "slack",
|
|
266
|
+
"channel_key": ch_key,
|
|
267
|
+
"ts": ev.get("event_ts"),
|
|
268
|
+
},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
meta = {"raw": payload, "channel_key": ch_key}
|
|
225
272
|
|
|
226
|
-
|
|
227
|
-
|
|
273
|
+
resumed = await ingress.handle(
|
|
274
|
+
IncomingMessage(
|
|
275
|
+
scheme=scheme,
|
|
276
|
+
channel_id=channel_id,
|
|
277
|
+
thread_id=str(thread_ts or ""),
|
|
278
|
+
text="", # no text; just a file drop
|
|
279
|
+
files=[incoming_file],
|
|
280
|
+
meta=meta,
|
|
281
|
+
)
|
|
282
|
+
)
|
|
228
283
|
|
|
229
|
-
if
|
|
230
|
-
|
|
231
|
-
|
|
284
|
+
if container.logger:
|
|
285
|
+
container.logger.for_run().debug(
|
|
286
|
+
f"[Slack] file_shared: file_id={file_id}, resumed={resumed}"
|
|
232
287
|
)
|
|
288
|
+
|
|
233
289
|
return {}
|
|
234
290
|
|
|
235
|
-
# other events might
|
|
291
|
+
# other events might be added later
|
|
236
292
|
return {}
|
|
237
293
|
|
|
238
294
|
|
|
@@ -5,6 +5,11 @@ from typing import Any
|
|
|
5
5
|
import aiohttp
|
|
6
6
|
from fastapi import APIRouter, HTTPException, Request
|
|
7
7
|
|
|
8
|
+
from aethergraph.services.channel.ingress import (
|
|
9
|
+
ChannelIngress,
|
|
10
|
+
IncomingFile,
|
|
11
|
+
IncomingMessage,
|
|
12
|
+
)
|
|
8
13
|
from aethergraph.services.continuations.continuation import Correlator
|
|
9
14
|
|
|
10
15
|
router = APIRouter()
|
|
@@ -43,6 +48,23 @@ def _channel_key(chat_id: int, topic_id: int | None) -> str:
|
|
|
43
48
|
return f"{base}:topic/{int(topic_id)}" if topic_id else base
|
|
44
49
|
|
|
45
50
|
|
|
51
|
+
def _tg_scheme_and_channel_id(chat_id: int, topic_id: int | None) -> tuple[str, str]:
|
|
52
|
+
"""
|
|
53
|
+
Map Telegram chat/topic to (scheme, channel_id) pair for ChannelIngress.
|
|
54
|
+
|
|
55
|
+
_channel_key(chat_id, topic_id) builds:
|
|
56
|
+
"tg:chat/<id>" or "tg:chat/<id>:topic/<topic_id>"
|
|
57
|
+
|
|
58
|
+
So we use:
|
|
59
|
+
scheme = "tg"
|
|
60
|
+
channel_id = "chat/<id>" or "chat/<id>:topic/<topic_id>"
|
|
61
|
+
"""
|
|
62
|
+
base = f"chat/{int(chat_id)}"
|
|
63
|
+
if topic_id:
|
|
64
|
+
base = f"{base}:topic/{int(topic_id)}"
|
|
65
|
+
return "tg", base
|
|
66
|
+
|
|
67
|
+
|
|
46
68
|
# ---- helpers ----
|
|
47
69
|
async def _tg_get_file_path(file_id: str, token: str) -> str | None:
|
|
48
70
|
if not token:
|
|
@@ -64,10 +86,11 @@ async def _tg_download_file(file_path: str, token: str) -> bytes:
|
|
|
64
86
|
return await r.read()
|
|
65
87
|
|
|
66
88
|
|
|
67
|
-
# -------- NEW: background worker that does the heavy lifting --------
|
|
68
89
|
async def _process_update(container, payload: dict, token: str):
|
|
90
|
+
ingress: ChannelIngress = container.channel_ingress
|
|
91
|
+
|
|
69
92
|
try:
|
|
70
|
-
# Callback queries (inline
|
|
93
|
+
# 1) Callback queries (inline buttons) -------------------------
|
|
71
94
|
cq = payload.get("callback_query")
|
|
72
95
|
if cq:
|
|
73
96
|
msg = cq.get("message") or {}
|
|
@@ -94,35 +117,37 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
94
117
|
choice = str(data_raw)
|
|
95
118
|
|
|
96
119
|
choice_l = choice.lower()
|
|
97
|
-
# approved = choice_l.startswith("approve") or choice_l in {"yes","y","ok"} # resolve from choice string
|
|
98
120
|
|
|
99
|
-
|
|
121
|
+
tok = None
|
|
100
122
|
run_id = None
|
|
101
123
|
node_id = None
|
|
102
124
|
|
|
103
|
-
#
|
|
125
|
+
# Preferred: resolve alias → token
|
|
104
126
|
if resume_key and hasattr(container.cont_store, "token_from_alias"):
|
|
105
|
-
|
|
127
|
+
tok = container.cont_store.token_from_alias(resume_key)
|
|
106
128
|
|
|
107
|
-
if
|
|
108
|
-
cont = container.cont_store.get_by_token(
|
|
129
|
+
if tok and hasattr(container.cont_store, "get_by_token"):
|
|
130
|
+
cont = container.cont_store.get_by_token(tok)
|
|
109
131
|
if cont:
|
|
110
132
|
run_id, node_id = cont.run_id, cont.node_id
|
|
111
133
|
|
|
112
134
|
# Fallback: thread-scoped correlator
|
|
113
|
-
if not
|
|
135
|
+
if not tok:
|
|
114
136
|
corr = Correlator(
|
|
115
|
-
scheme="tg",
|
|
137
|
+
scheme="tg",
|
|
138
|
+
channel=ch_key,
|
|
139
|
+
thread=str(topic_id or ""),
|
|
140
|
+
message="",
|
|
116
141
|
)
|
|
117
142
|
cont = await container.cont_store.find_by_correlator(corr=corr)
|
|
118
143
|
if cont:
|
|
119
|
-
run_id, node_id,
|
|
144
|
+
run_id, node_id, tok = cont.run_id, cont.node_id, cont.token
|
|
120
145
|
|
|
121
|
-
if
|
|
146
|
+
if tok and run_id and node_id:
|
|
122
147
|
await container.resume_router.resume(
|
|
123
148
|
run_id=run_id,
|
|
124
149
|
node_id=node_id,
|
|
125
|
-
token=
|
|
150
|
+
token=tok,
|
|
126
151
|
payload={
|
|
127
152
|
"choice": choice_l,
|
|
128
153
|
"telegram": {
|
|
@@ -142,7 +167,7 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
142
167
|
pass
|
|
143
168
|
return
|
|
144
169
|
|
|
145
|
-
# Regular messages / uploads
|
|
170
|
+
# 2) Regular messages / uploads -------------------------------
|
|
146
171
|
msg = payload.get("message")
|
|
147
172
|
if not msg:
|
|
148
173
|
return
|
|
@@ -153,9 +178,11 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
153
178
|
chat_id = chat.get("id")
|
|
154
179
|
topic_id = msg.get("message_thread_id")
|
|
155
180
|
ch_key = _channel_key(chat_id, topic_id)
|
|
181
|
+
scheme, channel_id = _tg_scheme_and_channel_id(chat_id, topic_id)
|
|
182
|
+
|
|
156
183
|
text = (msg.get("text") or msg.get("caption") or "") or ""
|
|
157
184
|
|
|
158
|
-
|
|
185
|
+
tg_files: list[dict[str, Any]] = []
|
|
159
186
|
|
|
160
187
|
# Photos
|
|
161
188
|
photos = msg.get("photo") or []
|
|
@@ -171,7 +198,7 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
171
198
|
uri = await _stage_and_save(
|
|
172
199
|
container, data=data, name=name, ch_key=ch_key, cont=None
|
|
173
200
|
)
|
|
174
|
-
|
|
201
|
+
tg_files.append(
|
|
175
202
|
_file_ref(
|
|
176
203
|
file_id=file_id,
|
|
177
204
|
name=name,
|
|
@@ -198,8 +225,10 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
198
225
|
if file_path:
|
|
199
226
|
try:
|
|
200
227
|
data = await _tg_download_file(file_path, token)
|
|
201
|
-
uri = _stage_and_save(
|
|
202
|
-
|
|
228
|
+
uri = await _stage_and_save(
|
|
229
|
+
container, data=data, name=name, ch_key=ch_key, cont=None
|
|
230
|
+
)
|
|
231
|
+
tg_files.append(
|
|
203
232
|
_file_ref(
|
|
204
233
|
file_id=file_id,
|
|
205
234
|
name=name,
|
|
@@ -215,29 +244,49 @@ async def _process_update(container, payload: dict, token: str):
|
|
|
215
244
|
f"Telegram document download failed: {e}"
|
|
216
245
|
)
|
|
217
246
|
|
|
218
|
-
|
|
219
|
-
|
|
247
|
+
# Turn Telegram file_refs into IncomingFile with pre-saved URIs
|
|
248
|
+
incoming_files: list[IncomingFile] = []
|
|
249
|
+
for fr in tg_files:
|
|
250
|
+
incoming_files.append(
|
|
251
|
+
IncomingFile(
|
|
252
|
+
id=fr["id"],
|
|
253
|
+
name=fr["name"],
|
|
254
|
+
mimetype=fr.get("mimetype"),
|
|
255
|
+
size=fr.get("size"),
|
|
256
|
+
uri=fr.get("uri"), # already staged as artifact
|
|
257
|
+
url=None, # no re-download
|
|
258
|
+
extra={
|
|
259
|
+
"platform": "telegram",
|
|
260
|
+
"channel_key": fr.get("channel_key"),
|
|
261
|
+
"ts": fr.get("ts"),
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
meta = {
|
|
267
|
+
"raw": payload,
|
|
268
|
+
"channel_key": ch_key,
|
|
269
|
+
"telegram": {
|
|
270
|
+
"message_id": msg.get("message_id"),
|
|
271
|
+
"chat_id": chat_id,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
resumed = await ingress.handle(
|
|
276
|
+
IncomingMessage(
|
|
277
|
+
scheme=scheme,
|
|
278
|
+
channel_id=channel_id,
|
|
279
|
+
thread_id=str(topic_id or ""),
|
|
280
|
+
text=text,
|
|
281
|
+
files=incoming_files or None,
|
|
282
|
+
meta=meta,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
220
285
|
|
|
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
286
|
container.logger and container.logger.for_run().debug(
|
|
226
|
-
f"[TG] inbound: text=
|
|
287
|
+
f"[TG] inbound: text={text!r} files={len(incoming_files)} resumed={resumed}"
|
|
227
288
|
)
|
|
228
289
|
|
|
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
290
|
except Exception as e:
|
|
242
291
|
container.logger and container.logger.for_run().error(
|
|
243
292
|
f"Telegram inbound processing error: {e}", exc_info=True
|
|
@@ -281,7 +330,8 @@ def _normalize_mime_by_name(name: str | None, hint: str | None) -> str:
|
|
|
281
330
|
|
|
282
331
|
|
|
283
332
|
async def _stage_and_save(container, *, data: bytes, name: str, ch_key: str, cont) -> str:
|
|
284
|
-
tmp = container.artifacts.
|
|
333
|
+
tmp = await container.artifacts.plan_staging_path(planned_ext=f"_{name}")
|
|
334
|
+
|
|
285
335
|
with open(tmp, "wb") as f:
|
|
286
336
|
f.write(data)
|
|
287
337
|
run_id = cont.run_id if cont else "ad-hoc"
|
|
@@ -313,12 +363,3 @@ def _file_ref(
|
|
|
313
363
|
"channel_key": ch_key,
|
|
314
364
|
"ts": ts,
|
|
315
365
|
}
|
|
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,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
8
|
+
|
|
9
|
+
from aethergraph.api.v1.deps import RequestIdentity # adjust import
|
|
10
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
11
|
+
from aethergraph.services.eventhub.event_hub import EventHub
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
WebSocket endpoint for pushing EventLog rows to the browser in real time.
|
|
15
|
+
|
|
16
|
+
Protocol (JSON messages from client):
|
|
17
|
+
|
|
18
|
+
{ "type": "subscribe",
|
|
19
|
+
"scope_id": "session:<id>",
|
|
20
|
+
"kinds": ["session_chat"] }
|
|
21
|
+
|
|
22
|
+
{ "type": "unsubscribe",
|
|
23
|
+
"scope_id": "session:<id>",
|
|
24
|
+
"kinds": ["session_chat"] }
|
|
25
|
+
|
|
26
|
+
{ "type": "ping" }
|
|
27
|
+
|
|
28
|
+
Messages from server:
|
|
29
|
+
|
|
30
|
+
{ "type": "event",
|
|
31
|
+
"scope_id": "session:<id>",
|
|
32
|
+
"kind": "session_chat",
|
|
33
|
+
"id": "<event-id>",
|
|
34
|
+
"ts": <float>,
|
|
35
|
+
"payload": { ...same as HTTP /chat/events... }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
{ "type": "pong" }
|
|
39
|
+
|
|
40
|
+
NOTE: This is a scaffold. It is *not* yet wired into auth or your router.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
router = APIRouter()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.websocket("/ws/events")
|
|
48
|
+
async def ws_events(
|
|
49
|
+
websocket: WebSocket,
|
|
50
|
+
identity: RequestIdentity = None, # TODO: hook in proper auth if desired
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
WebSocket endpoint for UI event streaming.
|
|
54
|
+
|
|
55
|
+
Typical usage (client-side, future):
|
|
56
|
+
|
|
57
|
+
ws = new WebSocket("wss://.../ws/events");
|
|
58
|
+
ws.send(JSON.stringify({
|
|
59
|
+
type: "subscribe",
|
|
60
|
+
scope_id: "session:<session_id>",
|
|
61
|
+
kinds: ["session_chat"],
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
For now this is scaffold-only and not used by the frontend.
|
|
65
|
+
"""
|
|
66
|
+
await websocket.accept()
|
|
67
|
+
|
|
68
|
+
container = current_services()
|
|
69
|
+
event_hub: EventHub | None = getattr(container, "event_hub", None)
|
|
70
|
+
|
|
71
|
+
if event_hub is None:
|
|
72
|
+
# If EventHub hasn't been wired yet, just close gracefully.
|
|
73
|
+
await websocket.close(code=1011)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
# (scope_id, kind) -> callback
|
|
77
|
+
callbacks: dict[tuple[str, str], Any] = {}
|
|
78
|
+
|
|
79
|
+
# Queue of rows to send to this client
|
|
80
|
+
queue: asyncio.Queue[dict] = asyncio.Queue()
|
|
81
|
+
|
|
82
|
+
async def make_callback(scope_id: str, kind: str):
|
|
83
|
+
async def _cb(row: dict) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Called by EventHub.broadcast(row).
|
|
86
|
+
|
|
87
|
+
We avoid calling websocket.send_json directly here to keep ordering
|
|
88
|
+
and error-handling in a single place (the sender task).
|
|
89
|
+
"""
|
|
90
|
+
await queue.put(row)
|
|
91
|
+
|
|
92
|
+
return _cb
|
|
93
|
+
|
|
94
|
+
async def sender() -> None:
|
|
95
|
+
"""
|
|
96
|
+
Background task that forwards rows from the queue to the WebSocket.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
while True:
|
|
100
|
+
row = await queue.get()
|
|
101
|
+
# Minimal envelope; payload matches HTTP /chat/events structure.
|
|
102
|
+
await websocket.send_json(
|
|
103
|
+
{
|
|
104
|
+
"type": "event",
|
|
105
|
+
"scope_id": row.get("scope_id"),
|
|
106
|
+
"kind": row.get("kind"),
|
|
107
|
+
"id": row.get("id"),
|
|
108
|
+
"ts": row.get("ts"),
|
|
109
|
+
"payload": row.get("payload") or {},
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
except WebSocketDisconnect:
|
|
113
|
+
# Client went away; main function will handle cleanup.
|
|
114
|
+
return
|
|
115
|
+
except Exception:
|
|
116
|
+
# TODO: log error
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
sender_task = asyncio.create_task(sender())
|
|
120
|
+
|
|
121
|
+
async def subscribe(scope_id: str, kinds: list[str]) -> None:
|
|
122
|
+
for kind in kinds:
|
|
123
|
+
key = (scope_id, kind)
|
|
124
|
+
if key in callbacks:
|
|
125
|
+
continue
|
|
126
|
+
cb = await make_callback(scope_id, kind)
|
|
127
|
+
callbacks[key] = cb
|
|
128
|
+
event_hub.subscribe(scope_id, kind, cb)
|
|
129
|
+
|
|
130
|
+
async def unsubscribe(scope_id: str, kinds: list[str]) -> None:
|
|
131
|
+
for kind in kinds:
|
|
132
|
+
key = (scope_id, kind)
|
|
133
|
+
cb = callbacks.pop(key, None)
|
|
134
|
+
if cb is not None:
|
|
135
|
+
event_hub.unsubscribe(scope_id, kind, cb)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
while True:
|
|
139
|
+
msg = await websocket.receive_json()
|
|
140
|
+
|
|
141
|
+
msg_type = msg.get("type")
|
|
142
|
+
if msg_type == "subscribe":
|
|
143
|
+
scope_id = msg["scope_id"]
|
|
144
|
+
kinds = msg.get("kinds") or ["session_chat"]
|
|
145
|
+
# TODO: enforce authorization here based on `identity` & scope_id
|
|
146
|
+
await subscribe(scope_id, kinds)
|
|
147
|
+
|
|
148
|
+
elif msg_type == "unsubscribe":
|
|
149
|
+
scope_id = msg["scope_id"]
|
|
150
|
+
kinds = msg.get("kinds") or ["session_chat"]
|
|
151
|
+
await unsubscribe(scope_id, kinds)
|
|
152
|
+
|
|
153
|
+
elif msg_type == "ping":
|
|
154
|
+
await websocket.send_json({"type": "pong"})
|
|
155
|
+
|
|
156
|
+
# else: ignore unknown types for now
|
|
157
|
+
|
|
158
|
+
except WebSocketDisconnect:
|
|
159
|
+
# Normal disconnect
|
|
160
|
+
pass
|
|
161
|
+
except Exception:
|
|
162
|
+
# TODO: log error
|
|
163
|
+
pass
|
|
164
|
+
finally:
|
|
165
|
+
# Cleanup subscriptions and sender task
|
|
166
|
+
for (scope_id, kind), cb in callbacks.items():
|
|
167
|
+
event_hub.unsubscribe(scope_id, kind, cb)
|
|
168
|
+
callbacks.clear()
|
|
169
|
+
|
|
170
|
+
sender_task.cancel()
|
|
171
|
+
with contextlib.suppress(Exception):
|
|
172
|
+
await sender_task
|