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.
@@ -0,0 +1,3 @@
1
+ """dfcode-remote: Feishu bridge for dfcode."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m dfcode_remote`."""
2
+
3
+ from dfcode_remote.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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,4 @@
1
+ from dfcode_remote.dfcode_client.client import DfcodeClient
2
+ from dfcode_remote.dfcode_client.sse import sse_stream, SSEEvent
3
+
4
+ __all__ = ["DfcodeClient", "sse_stream", "SSEEvent"]
@@ -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
+ ]