dfcode-remote 0.1.0__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.
- dfcode_remote/__init__.py +3 -0
- dfcode_remote/__main__.py +6 -0
- dfcode_remote/cli.py +106 -0
- dfcode_remote/dfcode_client/__init__.py +4 -0
- dfcode_remote/dfcode_client/client.py +196 -0
- dfcode_remote/dfcode_client/sse.py +105 -0
- dfcode_remote/dfcode_client/types.py +213 -0
- dfcode_remote/lark_client/__init__.py +19 -0
- dfcode_remote/lark_client/bot.py +73 -0
- dfcode_remote/lark_client/card_builder.py +467 -0
- dfcode_remote/lark_client/card_service.py +256 -0
- dfcode_remote/lark_client/event_listener.py +381 -0
- dfcode_remote/lark_client/handler.py +450 -0
- dfcode_remote/utils/__init__.py +4 -0
- dfcode_remote/utils/config.py +43 -0
- dfcode_remote/utils/markdown.py +53 -0
- dfcode_remote/utils/persistence.py +58 -0
- dfcode_remote-0.1.0.dist-info/METADATA +360 -0
- dfcode_remote-0.1.0.dist-info/RECORD +22 -0
- dfcode_remote-0.1.0.dist-info/WHEEL +5 -0
- dfcode_remote-0.1.0.dist-info/entry_points.txt +2 -0
- dfcode_remote-0.1.0.dist-info/top_level.txt +1 -0
dfcode_remote/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Entry point for the dfcode Feishu bridge.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
dfcode-remote
|
|
5
|
+
dfcode-remote --dfcode-url http://host:4096
|
|
6
|
+
python -m dfcode_remote
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from dfcode_remote.dfcode_client.client import DfcodeClient
|
|
16
|
+
from dfcode_remote.lark_client.bot import start_bot_async
|
|
17
|
+
from dfcode_remote.lark_client.card_service import CardService
|
|
18
|
+
from dfcode_remote.lark_client.event_listener import EventListener
|
|
19
|
+
from dfcode_remote.lark_client.handler import Handler
|
|
20
|
+
from dfcode_remote.utils.config import Config
|
|
21
|
+
from dfcode_remote.utils.persistence import Persistence
|
|
22
|
+
|
|
23
|
+
logging.basicConfig(
|
|
24
|
+
level=logging.INFO,
|
|
25
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
26
|
+
)
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_args() -> argparse.Namespace:
|
|
31
|
+
parser = argparse.ArgumentParser(description="dfcode Feishu bridge")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--dfcode-url", default=None, help="Override DFCODE_URL from env"
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
36
|
+
return parser.parse_args()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _run(config: Config) -> None:
|
|
40
|
+
client = DfcodeClient(
|
|
41
|
+
base_url=config.dfcode_url,
|
|
42
|
+
directory=config.dfcode_directory,
|
|
43
|
+
username=config.dfcode_username or None,
|
|
44
|
+
password=config.dfcode_password or None,
|
|
45
|
+
)
|
|
46
|
+
card_service = CardService(config.feishu_app_id, config.feishu_app_secret)
|
|
47
|
+
persistence = Persistence()
|
|
48
|
+
handler = Handler(config, client, card_service, persistence)
|
|
49
|
+
listener = EventListener(
|
|
50
|
+
dfcode_url=config.dfcode_url,
|
|
51
|
+
client=client,
|
|
52
|
+
card_service=card_service,
|
|
53
|
+
persistence=persistence,
|
|
54
|
+
handler=handler,
|
|
55
|
+
enable_urgent=config.enable_urgent,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
logger.info("dfcode URL: %s", config.dfcode_url)
|
|
59
|
+
logger.info("Feishu app_id: %s", config.feishu_app_id)
|
|
60
|
+
|
|
61
|
+
async def sse_task() -> None:
|
|
62
|
+
await listener.run()
|
|
63
|
+
|
|
64
|
+
async def bot_task() -> None:
|
|
65
|
+
await start_bot_async(
|
|
66
|
+
config.feishu_app_id,
|
|
67
|
+
config.feishu_app_secret,
|
|
68
|
+
handler.handle_message,
|
|
69
|
+
handler.handle_card_action,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
loop = asyncio.get_running_loop()
|
|
73
|
+
|
|
74
|
+
def _shutdown(sig: signal.Signals) -> None:
|
|
75
|
+
logger.info("Received %s — shutting down", sig.name)
|
|
76
|
+
for task in asyncio.all_tasks(loop):
|
|
77
|
+
task.cancel()
|
|
78
|
+
|
|
79
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
80
|
+
loop.add_signal_handler(sig, lambda s=sig: _shutdown(s))
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
await asyncio.gather(sse_task(), bot_task())
|
|
84
|
+
except asyncio.CancelledError:
|
|
85
|
+
logger.info("Shutdown complete")
|
|
86
|
+
finally:
|
|
87
|
+
await client.close()
|
|
88
|
+
await card_service.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
args = _parse_args()
|
|
93
|
+
|
|
94
|
+
if args.debug:
|
|
95
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
96
|
+
|
|
97
|
+
config = Config.load()
|
|
98
|
+
|
|
99
|
+
if args.dfcode_url:
|
|
100
|
+
config.dfcode_url = args.dfcode_url
|
|
101
|
+
|
|
102
|
+
if not config.feishu_app_id or not config.feishu_app_secret:
|
|
103
|
+
logger.error("FEISHU_APP_ID and FEISHU_APP_SECRET must be set")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
asyncio.run(_run(config))
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from dfcode_remote.dfcode_client.types import (
|
|
5
|
+
ActiveSessionInfo,
|
|
6
|
+
SessionInfo,
|
|
7
|
+
SessionStatus,
|
|
8
|
+
MessageInfo,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DfcodeAPIError(Exception):
|
|
13
|
+
"""Raised when the dfcode API returns an error response."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, message: str, url: str = ""):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.url = url
|
|
18
|
+
super().__init__(f"dfcode API error {status}: {message} (url={url})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DfcodeClient:
|
|
22
|
+
"""Async HTTP client wrapping the dfcode REST API."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str = "http://localhost:4096",
|
|
27
|
+
directory: Optional[str] = None,
|
|
28
|
+
username: Optional[str] = None,
|
|
29
|
+
password: Optional[str] = None,
|
|
30
|
+
):
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.directory = directory
|
|
33
|
+
headers: dict[str, str] = {}
|
|
34
|
+
if directory:
|
|
35
|
+
headers["x-dfcode-directory"] = directory
|
|
36
|
+
auth = None
|
|
37
|
+
if username and password:
|
|
38
|
+
auth = httpx.BasicAuth(username, password)
|
|
39
|
+
self._client = httpx.AsyncClient(
|
|
40
|
+
base_url=self.base_url,
|
|
41
|
+
headers=headers,
|
|
42
|
+
auth=auth,
|
|
43
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> "DfcodeClient":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(self, *exc) -> None:
|
|
50
|
+
await self.close()
|
|
51
|
+
|
|
52
|
+
async def close(self) -> None:
|
|
53
|
+
"""Close the underlying HTTP client."""
|
|
54
|
+
await self._client.aclose()
|
|
55
|
+
|
|
56
|
+
async def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
57
|
+
"""Send a request and raise on non-2xx status."""
|
|
58
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
59
|
+
if resp.status_code >= 400:
|
|
60
|
+
text = resp.text
|
|
61
|
+
raise DfcodeAPIError(resp.status_code, text, url=str(resp.url))
|
|
62
|
+
return resp
|
|
63
|
+
|
|
64
|
+
# --- Global ---
|
|
65
|
+
|
|
66
|
+
async def health(self) -> dict:
|
|
67
|
+
"""GET /global/health"""
|
|
68
|
+
resp = await self._request("GET", "/global/health")
|
|
69
|
+
return resp.json()
|
|
70
|
+
|
|
71
|
+
# --- Sessions ---
|
|
72
|
+
|
|
73
|
+
async def list_sessions(self) -> list[SessionInfo]:
|
|
74
|
+
"""GET /session"""
|
|
75
|
+
resp = await self._request("GET", "/session")
|
|
76
|
+
return [SessionInfo.from_dict(s) for s in resp.json()]
|
|
77
|
+
|
|
78
|
+
async def list_active_sessions(self) -> list[ActiveSessionInfo]:
|
|
79
|
+
"""GET /session/active"""
|
|
80
|
+
resp = await self._request("GET", "/session/active")
|
|
81
|
+
return [ActiveSessionInfo.from_dict(s) for s in resp.json()]
|
|
82
|
+
|
|
83
|
+
async def get_active_session(self, session_id: str) -> ActiveSessionInfo:
|
|
84
|
+
"""GET /session/active/{id}"""
|
|
85
|
+
resp = await self._request("GET", f"/session/active/{session_id}")
|
|
86
|
+
return ActiveSessionInfo.from_dict(resp.json())
|
|
87
|
+
|
|
88
|
+
async def create_session(self, title: Optional[str] = None) -> SessionInfo:
|
|
89
|
+
"""POST /session"""
|
|
90
|
+
body: dict = {}
|
|
91
|
+
if title:
|
|
92
|
+
body["title"] = title
|
|
93
|
+
resp = await self._request("POST", "/session", json=body)
|
|
94
|
+
return SessionInfo.from_dict(resp.json())
|
|
95
|
+
|
|
96
|
+
async def get_session(self, session_id: str) -> SessionInfo:
|
|
97
|
+
"""GET /session/{id}"""
|
|
98
|
+
resp = await self._request("GET", f"/session/{session_id}")
|
|
99
|
+
return SessionInfo.from_dict(resp.json())
|
|
100
|
+
|
|
101
|
+
async def delete_session(self, session_id: str) -> bool:
|
|
102
|
+
"""DELETE /session/{id}"""
|
|
103
|
+
resp = await self._request("DELETE", f"/session/{session_id}")
|
|
104
|
+
return resp.status_code < 400
|
|
105
|
+
|
|
106
|
+
async def get_status(self) -> dict[str, SessionStatus]:
|
|
107
|
+
"""GET /session/status"""
|
|
108
|
+
resp = await self._request("GET", "/session/status")
|
|
109
|
+
data = resp.json()
|
|
110
|
+
return {k: SessionStatus.from_dict(v) for k, v in data.items()}
|
|
111
|
+
|
|
112
|
+
async def prompt_async(self, session_id: str, text: str) -> None:
|
|
113
|
+
"""POST /session/{id}/prompt_async — returns 204 immediately."""
|
|
114
|
+
await self._request(
|
|
115
|
+
"POST",
|
|
116
|
+
f"/session/{session_id}/prompt_async",
|
|
117
|
+
json={"parts": [{"type": "text", "text": text}]},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
async def prompt_active_async(self, session_id: str, text: str) -> None:
|
|
121
|
+
"""POST /session/active/{id}/prompt_async — cross-project active session prompt."""
|
|
122
|
+
await self._request(
|
|
123
|
+
"POST",
|
|
124
|
+
f"/session/active/{session_id}/prompt_async",
|
|
125
|
+
json={"parts": [{"type": "text", "text": text}]},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async def prompt_async_in_project(
|
|
129
|
+
self,
|
|
130
|
+
session_id: str,
|
|
131
|
+
text: str,
|
|
132
|
+
project_root: str,
|
|
133
|
+
) -> None:
|
|
134
|
+
"""POST /session/{id}/prompt_async with explicit project directory context."""
|
|
135
|
+
await self._request(
|
|
136
|
+
"POST",
|
|
137
|
+
f"/session/{session_id}/prompt_async",
|
|
138
|
+
json={"parts": [{"type": "text", "text": text}]},
|
|
139
|
+
headers={"x-dfcode-directory": project_root},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
async def abort(self, session_id: str) -> bool:
|
|
143
|
+
"""POST /session/{id}/abort"""
|
|
144
|
+
resp = await self._request("POST", f"/session/{session_id}/abort")
|
|
145
|
+
return resp.status_code < 400
|
|
146
|
+
|
|
147
|
+
async def get_messages(self, session_id: str) -> list[MessageInfo]:
|
|
148
|
+
"""GET /session/{id}/message"""
|
|
149
|
+
resp = await self._request("GET", f"/session/{session_id}/message")
|
|
150
|
+
return [MessageInfo.from_dict(m) for m in resp.json()]
|
|
151
|
+
|
|
152
|
+
# --- Permissions ---
|
|
153
|
+
|
|
154
|
+
async def reply_permission(
|
|
155
|
+
self,
|
|
156
|
+
request_id: str,
|
|
157
|
+
reply: str,
|
|
158
|
+
message: Optional[str] = None,
|
|
159
|
+
) -> bool:
|
|
160
|
+
"""POST /permission/{id}/reply"""
|
|
161
|
+
body: dict = {"reply": reply}
|
|
162
|
+
if message:
|
|
163
|
+
body["message"] = message
|
|
164
|
+
resp = await self._request("POST", f"/permission/{request_id}/reply", json=body)
|
|
165
|
+
return resp.status_code < 400
|
|
166
|
+
|
|
167
|
+
async def list_permissions(self) -> list[dict]:
|
|
168
|
+
"""GET /permission"""
|
|
169
|
+
resp = await self._request("GET", "/permission")
|
|
170
|
+
return resp.json()
|
|
171
|
+
|
|
172
|
+
# --- Questions ---
|
|
173
|
+
|
|
174
|
+
async def reply_question(self, request_id: str, answers: list[list[str]]) -> bool:
|
|
175
|
+
"""POST /question/{id}/reply
|
|
176
|
+
|
|
177
|
+
Each element in answers corresponds to one question.
|
|
178
|
+
Each answer is a list of selected labels (even for single-select).
|
|
179
|
+
Example: [["Yes"]] for a single question with one selection.
|
|
180
|
+
"""
|
|
181
|
+
resp = await self._request(
|
|
182
|
+
"POST",
|
|
183
|
+
f"/question/{request_id}/reply",
|
|
184
|
+
json={"answers": answers},
|
|
185
|
+
)
|
|
186
|
+
return resp.status_code < 400
|
|
187
|
+
|
|
188
|
+
async def reject_question(self, request_id: str) -> bool:
|
|
189
|
+
"""POST /question/{id}/reject"""
|
|
190
|
+
resp = await self._request("POST", f"/question/{request_id}/reject")
|
|
191
|
+
return resp.status_code < 400
|
|
192
|
+
|
|
193
|
+
async def list_questions(self) -> list[dict]:
|
|
194
|
+
"""GET /question"""
|
|
195
|
+
resp = await self._request("GET", "/question")
|
|
196
|
+
return resp.json()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import AsyncIterator, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from dfcode_remote.dfcode_client.types import SSEEvent
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_INITIAL_BACKOFF = 1.0
|
|
13
|
+
_MAX_BACKOFF = 30.0
|
|
14
|
+
_HEARTBEAT_TIMEOUT = 60.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def sse_stream(
|
|
18
|
+
url: str,
|
|
19
|
+
headers: Optional[dict[str, str]] = None,
|
|
20
|
+
) -> AsyncIterator[SSEEvent]:
|
|
21
|
+
"""Connect to a dfcode SSE endpoint and yield parsed events.
|
|
22
|
+
|
|
23
|
+
Auto-reconnects with exponential backoff on connection loss.
|
|
24
|
+
Reconnects if no data received within the heartbeat timeout (60s).
|
|
25
|
+
"""
|
|
26
|
+
backoff = _INITIAL_BACKOFF
|
|
27
|
+
|
|
28
|
+
while True:
|
|
29
|
+
try:
|
|
30
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
31
|
+
async with client.stream(
|
|
32
|
+
"GET",
|
|
33
|
+
url,
|
|
34
|
+
headers={**(headers or {}), "Accept": "text/event-stream"},
|
|
35
|
+
) as resp:
|
|
36
|
+
if resp.status_code >= 400:
|
|
37
|
+
logger.error("SSE connect failed: %d", resp.status_code)
|
|
38
|
+
await asyncio.sleep(backoff)
|
|
39
|
+
backoff = min(backoff * 2, _MAX_BACKOFF)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
# Connected — reset backoff
|
|
43
|
+
backoff = _INITIAL_BACKOFF
|
|
44
|
+
|
|
45
|
+
async for event in _parse_stream(resp):
|
|
46
|
+
yield event
|
|
47
|
+
|
|
48
|
+
except asyncio.CancelledError:
|
|
49
|
+
logger.debug("SSE stream cancelled")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
53
|
+
logger.warning(
|
|
54
|
+
"SSE connection error: %s — reconnecting in %.0fs", exc, backoff
|
|
55
|
+
)
|
|
56
|
+
await asyncio.sleep(backoff)
|
|
57
|
+
backoff = min(backoff * 2, _MAX_BACKOFF)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _parse_stream(resp: httpx.Response) -> AsyncIterator[SSEEvent]:
|
|
61
|
+
"""Parse SSE frames from an httpx streaming response with heartbeat timeout."""
|
|
62
|
+
buffer = ""
|
|
63
|
+
|
|
64
|
+
async for chunk in _timeout_iter(resp.aiter_text(), _HEARTBEAT_TIMEOUT):
|
|
65
|
+
buffer += chunk
|
|
66
|
+
while "\n\n" in buffer:
|
|
67
|
+
frame, buffer = buffer.split("\n\n", 1)
|
|
68
|
+
event = _parse_frame(frame)
|
|
69
|
+
if event:
|
|
70
|
+
yield event
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def _timeout_iter(aiter, timeout: float):
|
|
74
|
+
"""Wrap an async iterator with a per-item timeout. Raises TimeoutError on expiry."""
|
|
75
|
+
ait = aiter.__aiter__()
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
item = await asyncio.wait_for(ait.__anext__(), timeout=timeout)
|
|
79
|
+
yield item
|
|
80
|
+
except StopAsyncIteration:
|
|
81
|
+
return
|
|
82
|
+
except asyncio.TimeoutError:
|
|
83
|
+
logger.warning("SSE heartbeat timeout (%.0fs) — will reconnect", timeout)
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_frame(frame: str) -> Optional[SSEEvent]:
|
|
88
|
+
"""Parse a single SSE frame into an SSEEvent, or None if unparseable."""
|
|
89
|
+
for line in frame.strip().splitlines():
|
|
90
|
+
if line.startswith("data:"):
|
|
91
|
+
payload = line[len("data:") :].strip()
|
|
92
|
+
if not payload:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(payload)
|
|
96
|
+
if (
|
|
97
|
+
isinstance(data, dict)
|
|
98
|
+
and "payload" in data
|
|
99
|
+
and isinstance(data["payload"], dict)
|
|
100
|
+
):
|
|
101
|
+
data = data["payload"]
|
|
102
|
+
return SSEEvent.from_dict(data)
|
|
103
|
+
except (json.JSONDecodeError, KeyError) as exc:
|
|
104
|
+
logger.debug("Failed to parse SSE data: %s", exc)
|
|
105
|
+
return None
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class SessionInfo:
|
|
7
|
+
"""Represents a dfcode session."""
|
|
8
|
+
|
|
9
|
+
id: str
|
|
10
|
+
title: str
|
|
11
|
+
directory: str
|
|
12
|
+
parentID: Optional[str] = None
|
|
13
|
+
time: dict = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_dict(cls, data: dict) -> "SessionInfo":
|
|
17
|
+
return cls(
|
|
18
|
+
id=data["id"],
|
|
19
|
+
title=data.get("title", ""),
|
|
20
|
+
directory=data.get("directory", ""),
|
|
21
|
+
parentID=data.get("parentID"),
|
|
22
|
+
time=data.get("time", {}),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class SessionStatus:
|
|
28
|
+
"""Session processing status."""
|
|
29
|
+
|
|
30
|
+
type: str # "idle" | "busy" | "retry"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: dict) -> "SessionStatus":
|
|
34
|
+
return cls(type=data.get("type", "idle"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ActiveSessionInfo:
|
|
39
|
+
"""A cross-project active session item from /session/active."""
|
|
40
|
+
|
|
41
|
+
id: str
|
|
42
|
+
title: str = ""
|
|
43
|
+
directory: str = ""
|
|
44
|
+
projectRoot: str = ""
|
|
45
|
+
projectID: str = ""
|
|
46
|
+
parentID: Optional[str] = None
|
|
47
|
+
time: dict = field(default_factory=dict)
|
|
48
|
+
status: SessionStatus = field(default_factory=lambda: SessionStatus(type="idle"))
|
|
49
|
+
activity: dict = field(default_factory=dict)
|
|
50
|
+
attachable: bool = False
|
|
51
|
+
continueable: bool = False
|
|
52
|
+
rank: int = 0
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_dict(cls, data: dict) -> "ActiveSessionInfo":
|
|
56
|
+
return cls(
|
|
57
|
+
id=data["id"],
|
|
58
|
+
title=data.get("title", ""),
|
|
59
|
+
directory=data.get("directory", ""),
|
|
60
|
+
projectRoot=data.get("projectRoot", data.get("directory", "")),
|
|
61
|
+
projectID=data.get("projectID", ""),
|
|
62
|
+
parentID=data.get("parentID"),
|
|
63
|
+
time=data.get("time", {}),
|
|
64
|
+
status=SessionStatus.from_dict(data.get("status", {})),
|
|
65
|
+
activity=data.get("activity", {}),
|
|
66
|
+
attachable=data.get("attachable", False),
|
|
67
|
+
continueable=data.get("continueable", False),
|
|
68
|
+
rank=data.get("rank", 0),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class PartInfo:
|
|
74
|
+
"""Message part — discriminated union on 'type' field."""
|
|
75
|
+
|
|
76
|
+
type: str
|
|
77
|
+
id: Optional[str] = None
|
|
78
|
+
text: Optional[str] = None
|
|
79
|
+
time: Optional[dict] = None
|
|
80
|
+
callID: Optional[str] = None
|
|
81
|
+
tool: Optional[str] = None
|
|
82
|
+
state: Optional[dict] = None
|
|
83
|
+
cost: Optional[dict] = None
|
|
84
|
+
tokens: Optional[dict] = None
|
|
85
|
+
sessionID: Optional[str] = None
|
|
86
|
+
messageID: Optional[str] = None
|
|
87
|
+
raw: Optional[dict] = None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_dict(cls, data: dict) -> "PartInfo":
|
|
91
|
+
t = data.get("type", "")
|
|
92
|
+
if t == "text":
|
|
93
|
+
return cls(
|
|
94
|
+
type=t,
|
|
95
|
+
id=data.get("id"),
|
|
96
|
+
text=data.get("text"),
|
|
97
|
+
time=data.get("time"),
|
|
98
|
+
sessionID=data.get("sessionID"),
|
|
99
|
+
messageID=data.get("messageID"),
|
|
100
|
+
)
|
|
101
|
+
if t == "tool":
|
|
102
|
+
return cls(
|
|
103
|
+
type=t,
|
|
104
|
+
id=data.get("id"),
|
|
105
|
+
callID=data.get("callID"),
|
|
106
|
+
tool=data.get("tool"),
|
|
107
|
+
state=data.get("state"),
|
|
108
|
+
time=data.get("time"),
|
|
109
|
+
sessionID=data.get("sessionID"),
|
|
110
|
+
messageID=data.get("messageID"),
|
|
111
|
+
)
|
|
112
|
+
if t == "step-finish":
|
|
113
|
+
return cls(
|
|
114
|
+
type=t,
|
|
115
|
+
id=data.get("id"),
|
|
116
|
+
cost=data.get("cost"),
|
|
117
|
+
tokens=data.get("tokens"),
|
|
118
|
+
time=data.get("time"),
|
|
119
|
+
sessionID=data.get("sessionID"),
|
|
120
|
+
messageID=data.get("messageID"),
|
|
121
|
+
)
|
|
122
|
+
if t == "reasoning":
|
|
123
|
+
return cls(
|
|
124
|
+
type=t,
|
|
125
|
+
id=data.get("id"),
|
|
126
|
+
text=data.get("text"),
|
|
127
|
+
time=data.get("time"),
|
|
128
|
+
sessionID=data.get("sessionID"),
|
|
129
|
+
messageID=data.get("messageID"),
|
|
130
|
+
)
|
|
131
|
+
return cls(
|
|
132
|
+
type=t,
|
|
133
|
+
id=data.get("id"),
|
|
134
|
+
sessionID=data.get("sessionID"),
|
|
135
|
+
messageID=data.get("messageID"),
|
|
136
|
+
raw=data,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class MessageInfo:
|
|
142
|
+
"""A message in a session."""
|
|
143
|
+
|
|
144
|
+
id: str
|
|
145
|
+
sessionID: str
|
|
146
|
+
role: str
|
|
147
|
+
parts: list[PartInfo] = field(default_factory=list)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_dict(cls, data: dict) -> "MessageInfo":
|
|
151
|
+
info = data.get("info", data)
|
|
152
|
+
parts = [PartInfo.from_dict(p) for p in data.get("parts", [])]
|
|
153
|
+
return cls(
|
|
154
|
+
id=info.get("id", ""),
|
|
155
|
+
sessionID=info.get("sessionID", ""),
|
|
156
|
+
role=info.get("role", ""),
|
|
157
|
+
parts=parts,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class PermissionRequest:
|
|
163
|
+
"""A pending permission request from dfcode."""
|
|
164
|
+
|
|
165
|
+
id: str
|
|
166
|
+
sessionID: str
|
|
167
|
+
permission: str
|
|
168
|
+
patterns: list[str] = field(default_factory=list)
|
|
169
|
+
metadata: dict = field(default_factory=dict)
|
|
170
|
+
tool: Optional[str] = None
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_dict(cls, data: dict) -> "PermissionRequest":
|
|
174
|
+
return cls(
|
|
175
|
+
id=data["id"],
|
|
176
|
+
sessionID=data.get("sessionID", ""),
|
|
177
|
+
permission=data.get("permission", ""),
|
|
178
|
+
patterns=data.get("patterns", []),
|
|
179
|
+
metadata=data.get("metadata", {}),
|
|
180
|
+
tool=data.get("tool"),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class QuestionRequest:
|
|
186
|
+
"""A pending question from dfcode."""
|
|
187
|
+
|
|
188
|
+
id: str
|
|
189
|
+
sessionID: str
|
|
190
|
+
questions: list[dict] = field(default_factory=list)
|
|
191
|
+
|
|
192
|
+
@classmethod
|
|
193
|
+
def from_dict(cls, data: dict) -> "QuestionRequest":
|
|
194
|
+
return cls(
|
|
195
|
+
id=data["id"],
|
|
196
|
+
sessionID=data.get("sessionID", ""),
|
|
197
|
+
questions=data.get("questions", []),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class SSEEvent:
|
|
203
|
+
"""A parsed Server-Sent Event."""
|
|
204
|
+
|
|
205
|
+
type: str
|
|
206
|
+
properties: dict = field(default_factory=dict)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def from_dict(cls, data: dict) -> "SSEEvent":
|
|
210
|
+
return cls(
|
|
211
|
+
type=data.get("type", ""),
|
|
212
|
+
properties=data.get("properties", {}),
|
|
213
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dfcode_remote.lark_client.bot import start_bot_async
|
|
2
|
+
from dfcode_remote.lark_client.card_builder import (
|
|
3
|
+
build_streaming_card,
|
|
4
|
+
build_frozen_card,
|
|
5
|
+
build_menu_card,
|
|
6
|
+
build_command_response_card,
|
|
7
|
+
)
|
|
8
|
+
from dfcode_remote.lark_client.card_service import CardService
|
|
9
|
+
from dfcode_remote.lark_client.event_listener import EventListener
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"start_bot_async",
|
|
13
|
+
"build_streaming_card",
|
|
14
|
+
"build_frozen_card",
|
|
15
|
+
"build_menu_card",
|
|
16
|
+
"build_command_response_card",
|
|
17
|
+
"CardService",
|
|
18
|
+
"EventListener",
|
|
19
|
+
]
|