Skip to content

Commit 097195f

Browse files
committed
fix: correct OpenCode API endpoints and rewrite bot handler
All API paths were wrong — OpenCode uses /session, /project, /global/health etc. not /api/sessions. Rewrote the entire client and handler to match the actual OpenCode server API spec. - POST /session/:id/message now waits synchronously for full response - SSE event streaming filters by sessionID from global /event stream - Added proper error logging with tracebacks - Extract text from response parts correctly - Cache providers/agents/commands metadata
1 parent 059ef03 commit 097195f

4 files changed

Lines changed: 374 additions & 280 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
lint:
11+
name: Lint & Type Check
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
21+
- name: Install dependencies
22+
run: pip install -e ".[dev]"
23+
24+
- name: Lint with ruff
25+
run: ruff check src/ tests/
26+
27+
- name: Format check
28+
run: ruff format --check src/ tests/
29+
30+
- name: Type check with mypy
31+
run: mypy src/
32+
33+
test:
34+
name: Tests (Python ${{ matrix.python-version }})
35+
runs-on: ubuntu-latest
36+
strategy:
37+
matrix:
38+
python-version: ["3.10", "3.11", "3.12"]
39+
steps:
40+
- uses: actions/checkout@v4
41+
42+
- name: Set up Python ${{ matrix.python-version }}
43+
uses: actions/setup-python@v5
44+
with:
45+
python-version: ${{ matrix.python-version }}
46+
47+
- name: Install dependencies
48+
run: pip install -e ".[dev]"
49+
50+
- name: Run tests
51+
run: pytest tests/ -v --cov=opencode_telegram_bot

.github/workflows/publish.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
workflow_dispatch:
7+
8+
jobs:
9+
build:
10+
name: Build package
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
20+
- name: Install build tools
21+
run: python -m pip install --upgrade build
22+
23+
- name: Build package
24+
run: python -m build
25+
26+
- name: Upload dist artifacts
27+
uses: actions/upload-artifact@v4
28+
with:
29+
name: dist
30+
path: dist/
31+
32+
publish:
33+
name: Publish to PyPI
34+
needs: build
35+
runs-on: ubuntu-latest
36+
environment:
37+
name: pypi
38+
url: https://pypi.org/p/tp-opencode
39+
permissions:
40+
id-token: write
41+
steps:
42+
- name: Download dist artifacts
43+
uses: actions/download-artifact@v4
44+
with:
45+
name: dist
46+
path: dist/
47+
48+
- name: Publish to PyPI
49+
uses: pypa/gh-action-pypi-publish@release/v1
Lines changed: 133 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import asyncio
43
import json
54
import logging
65
from typing import Any, AsyncGenerator
@@ -35,81 +34,151 @@ async def _get(self, path: str, **kwargs: Any) -> httpx.Response:
3534
resp.raise_for_status()
3635
return resp
3736

38-
async def _post(self, path: str, **kwargs: Any) -> httpx.Response:
39-
resp = await self._client.post(path, **kwargs)
37+
async def _post(self, path: str, json: dict | None = None, **kwargs: Any) -> httpx.Response:
38+
resp = await self._client.post(path, json=json, **kwargs)
39+
resp.raise_for_status()
40+
return resp
41+
42+
async def _delete(self, path: str, **kwargs: Any) -> httpx.Response:
43+
resp = await self._client.delete(path, **kwargs)
44+
resp.raise_for_status()
45+
return resp
46+
47+
async def _patch(self, path: str, json: dict | None = None, **kwargs: Any) -> httpx.Response:
48+
resp = await self._client.patch(path, json=json, **kwargs)
4049
resp.raise_for_status()
4150
return resp
4251

4352
async def health(self) -> dict[str, Any]:
44-
resp = await self._get("/health")
53+
resp = await self._get("/global/health")
4554
return resp.json()
4655

4756
async def get_projects(self) -> list[dict[str, Any]]:
48-
resp = await self._get("/api/projects")
57+
resp = await self._get("/project")
4958
data = resp.json()
5059
return data if isinstance(data, list) else []
5160

5261
async def get_current_project(self) -> dict[str, Any]:
53-
resp = await self._get("/api/project")
54-
return resp.json()
55-
56-
async def switch_project(self, project_id: str) -> dict[str, Any]:
57-
resp = await self._post("/api/project/switch", json={"id": project_id})
62+
resp = await self._get("/project/current")
5863
return resp.json()
5964

6065
async def get_sessions(self) -> list[dict[str, Any]]:
61-
resp = await self._get("/api/sessions")
66+
resp = await self._get("/session")
6267
data = resp.json()
6368
return data if isinstance(data, list) else []
6469

65-
async def create_session(self, agent: str = "build") -> dict[str, Any]:
66-
resp = await self._post("/api/sessions", json={"agent": agent})
70+
async def create_session(
71+
self,
72+
title: str | None = None,
73+
parent_id: str | None = None,
74+
) -> dict[str, Any]:
75+
body: dict[str, Any] = {}
76+
if title:
77+
body["title"] = title
78+
if parent_id:
79+
body["parentID"] = parent_id
80+
resp = await self._post("/session", json=body)
6781
return resp.json()
6882

6983
async def get_session(self, session_id: str) -> dict[str, Any]:
70-
resp = await self._get(f"/api/sessions/{session_id}")
71-
return resp.json()
72-
73-
async def delete_session(self, session_id: str) -> dict[str, Any]:
74-
resp = await self._post(f"/api/sessions/{session_id}/delete")
84+
resp = await self._get(f"/session/{session_id}")
7585
return resp.json()
7686

77-
async def compact_session(self, session_id: str) -> dict[str, Any]:
78-
resp = await self._post(f"/api/sessions/{session_id}/compact")
87+
async def delete_session(self, session_id: str) -> bool:
88+
resp = await self._delete(f"/session/{session_id}")
7989
return resp.json()
8090

8191
async def rename_session(self, session_id: str, title: str) -> dict[str, Any]:
82-
resp = await self._post(f"/api/sessions/{session_id}/rename", json={"title": title})
92+
resp = await self._patch(f"/session/{session_id}", json={"title": title})
8393
return resp.json()
8494

85-
async def abort_session(self, session_id: str) -> dict[str, Any]:
86-
resp = await self._post(f"/api/sessions/{session_id}/abort")
95+
async def abort_session(self, session_id: str) -> bool:
96+
resp = await self._post(f"/session/{session_id}/abort")
8797
return resp.json()
8898

89-
async def get_models(self) -> dict[str, Any]:
90-
resp = await self._get("/api/models")
99+
async def summarize_session(self, session_id: str) -> bool:
100+
resp = await self._post(f"/session/{session_id}/summarize", json={})
91101
return resp.json()
92102

93-
async def switch_model(self, provider: str, model_id: str) -> dict[str, Any]:
94-
resp = await self._post("/api/models/switch", json={"provider": provider, "model": model_id})
103+
async def get_session_status(self) -> dict[str, Any]:
104+
resp = await self._get("/session/status")
95105
return resp.json()
96106

107+
async def get_session_todo(self, session_id: str) -> list[dict[str, Any]]:
108+
resp = await self._get(f"/session/{session_id}/todo")
109+
data = resp.json()
110+
return data if isinstance(data, list) else []
111+
112+
async def get_messages(self, session_id: str, limit: int = 50) -> list[dict[str, Any]]:
113+
resp = await self._get(f"/session/{session_id}/message", params={"limit": limit})
114+
data = resp.json()
115+
return data if isinstance(data, list) else []
116+
97117
async def send_message(
98118
self,
99119
session_id: str,
100120
message: str,
101-
files: list[dict[str, str]] | None = None,
121+
model: str | None = None,
122+
agent: str | None = None,
102123
) -> dict[str, Any]:
103-
payload: dict[str, Any] = {"message": message}
104-
if files:
105-
payload["files"] = files
106-
resp = await self._post(f"/api/sessions/{session_id}/message", json=payload)
124+
body: dict[str, Any] = {
125+
"parts": [{"type": "text", "text": message}]
126+
}
127+
if model:
128+
body["model"] = model
129+
if agent:
130+
body["agent"] = agent
131+
resp = await self._post(f"/session/{session_id}/message", json=body)
107132
return resp.json()
108133

109-
async def stream_session_events(self, session_id: str) -> AsyncGenerator[dict[str, Any], None]:
110-
"""Subscribe to SSE events for a session."""
111-
url = f"/api/sessions/{session_id}/events"
112-
async with self._client.stream("GET", url) as resp:
134+
async def send_message_async(
135+
self,
136+
session_id: str,
137+
message: str,
138+
model: str | None = None,
139+
agent: str | None = None,
140+
) -> None:
141+
body: dict[str, Any] = {
142+
"parts": [{"type": "text", "text": message}]
143+
}
144+
if model:
145+
body["model"] = model
146+
if agent:
147+
body["agent"] = agent
148+
await self._post(f"/session/{session_id}/prompt_async", json=body)
149+
150+
async def get_providers(self) -> dict[str, Any]:
151+
resp = await self._get("/provider")
152+
return resp.json()
153+
154+
async def get_config_providers(self) -> dict[str, Any]:
155+
resp = await self._get("/config/providers")
156+
return resp.json()
157+
158+
async def get_agents(self) -> list[dict[str, Any]]:
159+
resp = await self._get("/agent")
160+
data = resp.json()
161+
return data if isinstance(data, list) else []
162+
163+
async def get_commands(self) -> list[dict[str, Any]]:
164+
resp = await self._get("/command")
165+
data = resp.json()
166+
return data if isinstance(data, list) else []
167+
168+
async def run_command(
169+
self,
170+
session_id: str,
171+
command: str,
172+
arguments: str = "",
173+
) -> dict[str, Any]:
174+
body: dict[str, Any] = {"command": command}
175+
if arguments:
176+
body["arguments"] = arguments
177+
resp = await self._post(f"/session/{session_id}/command", json=body)
178+
return resp.json()
179+
180+
async def stream_events(self) -> AsyncGenerator[dict[str, Any], None]:
181+
async with self._client.stream("GET", "/event") as resp:
113182
resp.raise_for_status()
114183
buffer = ""
115184
async for chunk in resp.aiter_text():
@@ -121,6 +190,13 @@ async def stream_session_events(self, session_id: str) -> AsyncGenerator[dict[st
121190
if event:
122191
yield event
123192

193+
async def stream_session_events(self, session_id: str) -> AsyncGenerator[dict[str, Any], None]:
194+
async for event in self.stream_events():
195+
data = event.get("data", {})
196+
props = data.get("properties", {})
197+
if props.get("sessionID") == session_id:
198+
yield event
199+
124200
@staticmethod
125201
def _parse_sse_event(text: str) -> dict[str, Any] | None:
126202
event_type = None
@@ -138,22 +214,24 @@ def _parse_sse_event(text: str) -> dict[str, Any] | None:
138214
return None
139215
return None
140216

141-
async def get_session_status(self, session_id: str) -> dict[str, Any]:
142-
try:
143-
session = await self.get_session(session_id)
144-
return {
145-
"session_id": session_id,
146-
"status": session.get("status", "idle"),
147-
"model": session.get("model", ""),
148-
"tokens": session.get("tokens", {}),
149-
"agent": session.get("agent", "build"),
150-
}
151-
except Exception:
152-
return {"session_id": session_id, "status": "error"}
153-
154-
async def run_custom_command(self, command: str, session_id: str | None = None) -> dict[str, Any]:
155-
payload = {"command": command}
156-
if session_id:
157-
payload["session_id"] = session_id
158-
resp = await self._post("/api/commands/run", json=payload)
159-
return resp.json()
217+
@staticmethod
218+
def extract_text_from_response(response: dict[str, Any]) -> str:
219+
parts = response.get("parts", [])
220+
text_parts = []
221+
for part in parts:
222+
if part.get("type") == "text":
223+
text_parts.append(part.get("text", ""))
224+
return "\n".join(text_parts)
225+
226+
@staticmethod
227+
def extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
228+
parts = response.get("parts", [])
229+
tool_calls = []
230+
for part in parts:
231+
if part.get("type") == "tool":
232+
tool_calls.append({
233+
"name": part.get("name", part.get("toolName", "unknown")),
234+
"input": part.get("input", {}),
235+
"status": part.get("status", "unknown"),
236+
})
237+
return tool_calls

0 commit comments

Comments
 (0)