Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ cp config.json.example config.json
| Параметр | Описание | По умолчанию |
|----------|----------|-------------|
| `vk_token` | VK API токен (получить [тут](https://vk.com/dev)) | — |
| `opencode_url` | URL opencode serve | `http://127.0.0.1:4096` |
| `opencode_url` | URL opencode serve | `http://127.0.0.1:4098` |
| `session_file` | Файл хранения сессий | `sessions.json` |
| `vk_api_version` | Версия VK API | `5.200` |
| `longpoll_wait` | Время ожидания longpoll (сек) | `25` |
Expand All @@ -65,6 +65,7 @@ cp config.json.example config.json
| `opencode_bin_path` | Путь к бинарнику opencode | — |
| `llama_server_path` | Путь к llama-server | `llama-server` |
| `llama_server_host` | URL llama-server | `http://localhost:8081` |
| `llama_server_env` | Переменные окружения для llama-server (например, `{"TURBOPREFILL": "1"}`) | `{}` |
| `models` | Словарь моделей и параметров запуска | — |
| `default_model` | Алиас модели по умолчанию | — |
| `mcp_servers` | (опционально) MCP серверы для opencode | — |
Expand Down
61 changes: 61 additions & 0 deletions bot/config-turbo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"vk_token": "vk1.a.Iff3QxRdMeE9iG6HPbTYSMyj6-4Uv-euwpd6sM1EUWtL9Tf3ZH8zRW5G7nHqmYPu--JFdvBtepc4Yj3AtQyDuQW4OuNxQGU-CwDxYezHK2-kzKi4_h3RORGeriMbKMDyBCJwwDjgsD8ssYd5rHrt0MYh1JB9rnDjiPtu1Xlr6Gr5yqnt8fOBFINHJCsAjUjvax8HT0_IW0VRbC5DYwnYFw",
"opencode_url": "http://127.0.0.1:4098",
"session_file": "sessions.json",
"vk_api_version": "5.200",
"longpoll_wait": 25,
"thinking_peer_id": 2000000003,
"llama_server_path": "/home/grishberg/llama.cpp.turbo/build/bin/llama-server",
"llama_server_host": "http://127.0.0.1:8081",
"llama_server_env": {
"TURBOPREFILL": "1"
},
"opencode_config_path": "/home/grishberg/.config/opencode2/config.json",
"models": {
"qwen3.8-27b": {
"model": "qwen3.8-27b",
"args": "-m /home/grishberg/models/Qwen3.8-27B-UD-Q8_K_XL.gguf --port 8081 --host 0.0.0.0 --ctx-size 200000 -fit off -sm tensor --tensor-split 1,1 -fa on --spec-type draft-mtp --spec-draft-n-max 3 --no-mmap --cache-type-k q8_0 --cache-type-v q8_0 --mmproj /home/grishberg/models/Qwen3.8-27B-mmproj-F16.gguf --no-mmproj-offload --repeat-penalty 1.1 --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0"
},
"qwen3.6-27b-vision": {
"model": "qwen3.6-27b-vision",
"args": "-m /home/grishberg/models/Qwen3.6-27B-UD-Q8_K_XL.gguf --port 8081 --host 0.0.0.0 --ctx-size 200000 -fit off -sm tensor --tensor-split 1,1 -fa on --spec-type draft-mtp --spec-draft-n-max 3 --no-mmap --cache-type-k q8_0 --cache-type-v q8_0 --mmproj /home/grishberg/models/Qwen3.6-27B-mmproj-F16.gguf --no-mmproj-offload --repeat-penalty 1.1"
}
},
"default_model": "qwen3.8-27b",
"allowed_folders": [
"/tmp",
"/home/grishberg/projects"
],
"reasoning_timeout_seconds": 900,
"server_type": "llama",
"permission": {
"external_directory": {
"/tmp/**": "allow",
"*": "ask"
}
},
"peer_id": 2000000002,
"opencode_bin_path": "/home/grishberg/projects/ts/opencode/packages/opencode/dist/opencode-linux-x64/bin/opencode",
"opencode-app-name": "opencode2",
"shutdown": "/home/grishberg/shutdown-pc.sh",
"mcp_servers": {
"ya-disk-uploader": {
"type": "local",
"command": [
"/home/grishberg/projects/go/ya-disk-uploader/ya-disk-uploader",
"mcp"
],
"enabled": true
},
"send-files": {
"type": "local",
"command": [
"/home/grishberg/projects/go/mcp-vk-files/mcp-vk-files",
"mcp"
],
"enabled": true
}
},
"voice_auto_transcribe_enabled": true,
"whisper_api_url": "http://127.0.0.1:8013"
}
3 changes: 3 additions & 0 deletions bot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ def _resolve_config_path(name: str) -> Path:
"models": [],
"default_model": "qwen3.5-122b",
"allowed_folders": ["/tmp"],
"reasoning_timeout_seconds": 900,
}


Expand Down Expand Up @@ -126,6 +127,7 @@ def load_config(config_path: str = "config.json") -> dict:
ALLOWED_FOLDERS = CONFIG.get("allowed_folders", ["/tmp"])
SHUTDOWN_SCRIPT = CONFIG.get("shutdown")
RESTART_SCRIPT = CONFIG.get("restart")
REASONING_TIMEOUT_SECONDS = CONFIG.get("reasoning_timeout_seconds", 900)

if not VK_TOKEN:
raise ValueError("VK_TOKEN is required in config file")
Expand Down Expand Up @@ -210,6 +212,7 @@ def switch_config(config_name: str) -> bool:
current_module.ALLOWED_FOLDERS = new_config.get("allowed_folders", ["/tmp"])
current_module.SHUTDOWN_SCRIPT = new_config.get("shutdown")
current_module.RESTART_SCRIPT = new_config.get("restart")
current_module.REASONING_TIMEOUT_SECONDS = new_config.get("reasoning_timeout_seconds", 900)
current_module.OPENCODE_BIN = Path(new_config["opencode_bin_path"])
if not current_module.OPENCODE_BIN.is_absolute():
current_module.OPENCODE_BIN = (SCRIPT_DIR / current_module.OPENCODE_BIN).resolve()
Expand Down
8 changes: 7 additions & 1 deletion bot/llama_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ async def restart_llama_server(
env = os.environ.copy()
env.pop("TMUX", None)

# Дополнительные переменные окружения для llama-server из конфига
# (например, {"TURBOPREFILL": "1"} в config-turbo.json)
import config
for key, value in (config.CONFIG.get("llama_server_env") or {}).items():
env[str(key)] = str(value)

log_path = f"/tmp/llama-server-{alias or 'unknown'}.log"
logger.info(f"Starting llama server, logging to {log_path}")

Expand Down Expand Up @@ -196,7 +202,7 @@ async def test_llama_server_speed(complete_url: str, model_name: str = None) ->
async with aiohttp.ClientSession() as session:
payload = {
"prompt": "Test",
"n_predict": 10,
"n_predict": 100,
"stream": False,
"temperature": 0.7,
}
Expand Down
12 changes: 10 additions & 2 deletions bot/sse_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""
import asyncio
import json
from pathlib import Path
from typing import Awaitable, Callable, Optional

import aiohttp
Expand All @@ -18,8 +19,9 @@
class SSEEventListener:
"""Асинхронный SSE клиент для OpenCode events."""

def __init__(self, base_url: str):
def __init__(self, base_url: str, directory: Optional[str] = None):
self.base_url = base_url.rstrip("/")
self.directory = directory
self._task: Optional[asyncio.Task] = None
self.running = False
self.processed_events: set[str] = set()
Expand All @@ -45,8 +47,14 @@ async def stop(self):
except (asyncio.CancelledError, Exception):
pass

async def _run(self):
def _build_url(self) -> str:
url = f"{self.base_url}/event"
if self.directory:
url += f"?directory={self.directory}"
return url

async def _run(self):
url = self._build_url()
while self.running:
try:
async with aiohttp.ClientSession(timeout=SSE_TIMEOUT) as session:
Expand Down
53 changes: 23 additions & 30 deletions bot/tests/test_auto_grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,36 @@ class TestAutoGrantPermissionResponse:
@pytest.mark.asyncio
async def test_auto_grant_uses_always_not_once(self, mock_vk_client, mock_opencode_client,
mock_opencode_process, temp_config_file):
"""
BUG: При авто-одобрении используется "once" вместо "always".
"once" означает разовое одобрение, но пользователь всё равно видит запрос.
"always" означает постоянное одобрение.
"""
"""При включённом grant_mode авто-одобрение использует "always"."""
from vk_longpoll import VKLongPoll
from session_manager import SessionManager

# Создаём SessionManager и VKLongPoll
session_mgr = SessionManager(temp_config_file)
longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process)
longpoll.opencode_client = mock_opencode_client
session_id = "test-session-auto-grant"
longpoll.user_session[999] = session_id
longpoll.seen_permissions[session_id] = set()
longpoll.session_to_user[session_id] = 999
# Включаем авто-одобрение через session_mgr
longpoll.session_mgr.set_grant_mode(session_id, True)

# Создаём разрешение
permission = {

# SSE-событие permission.asked
permission_event = {
"sessionID": session_id,
"id": "perm-auto-123",
"session_id": session_id,
"permission": "write_file",
"metadata": {"filepath": "/tmp/test.txt"}
}

# Вызываем обработку разрешения
await longpoll._process_permission(permission, session_id, 999)
await longpoll._on_permission("permission.asked", permission_event)

# Проверяем, что ответ был отправлен
mock_opencode_client.send_permission_response.assert_called_once()

# BUG: Проверка, что используется "always" не "once"
# Ответ уходит третьим позиционным аргументом: (session_id, perm_id, response)
call_args = mock_opencode_client.send_permission_response.call_args
response_type = call_args[0][2] # Третий аргумент: response
response_type = call_args[0][2]

assert response_type == "always", \
f"Авто-одобрение должно использовать 'always', а не '{response_type}'"
Expand All @@ -62,19 +57,18 @@ async def test_auto_grant_does_not_send_prompt(self, mock_vk_client, mock_openco
longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process)
longpoll.opencode_client = mock_opencode_client
session_id = "test-session-no-prompt"
longpoll.user_session[999] = session_id
longpoll.seen_permissions[session_id] = set()
longpoll.session_to_user[session_id] = 999
# Включаем авто-одобрение через session_mgr
longpoll.session_mgr.set_grant_mode(session_id, True)

permission = {

permission_event = {
"sessionID": session_id,
"id": "perm-no-prompt",
"session_id": session_id,
"permission": "write_file",
"metadata": {"filepath": "/tmp/test.txt"}
}
await longpoll._process_permission(permission, session_id, 999)

await longpoll._on_permission("permission.asked", permission_event)

# mock_vk_client.send_message не должен вызываться для permission
send_message_calls = [
Expand All @@ -95,19 +89,18 @@ async def test_normal_mode_sends_permission_prompt(self, mock_vk_client, mock_op
longpoll = VKLongPoll(mock_vk_client, session_mgr, mock_opencode_process)
longpoll.opencode_client = mock_opencode_client
session_id = "test-session-normal-mode"
longpoll.user_session[999] = session_id
longpoll.seen_permissions[session_id] = set()
longpoll.session_to_user[session_id] = 999
# Выключаем авто-одобрение через session_mgr
longpoll.session_mgr.set_grant_mode(session_id, False)

permission = {

permission_event = {
"sessionID": session_id,
"id": "perm-normal-456",
"session_id": session_id,
"permission": "write_file",
"metadata": {"filepath": "/tmp/test.txt"}
}
await longpoll._process_permission(permission, session_id, 999)

await longpoll._on_permission("permission.asked", permission_event)

# Должно быть отправлено сообщение пользователю
mock_vk_client.send_message.assert_called_once()
Expand Down
4 changes: 2 additions & 2 deletions bot/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
ROOT_DIR = Path(__file__).parent.parent
sys.path.insert(0, str(ROOT_DIR))

from opencode_vk_gateway import load_config
from config import load_config


class TestLoadConfig:
Expand Down Expand Up @@ -60,7 +60,7 @@ def test_load_config_file_not_found(self, tmp_path):
config = load_config(str(config_file))

assert config["vk_token"] == "token"
assert config["opencode_url"] == "http://127.0.0.1:4096"
assert config["opencode_url"] == "http://127.0.0.1:4098"
assert config["session_file"] == "sessions.json"

def test_load_config_invalid_json(self, tmp_path):
Expand Down
Loading
Loading