Files
pocketpaw/tests/test_extras_install.py
Rohit Kushwaha 620f4cacab feat(discord): replace discord.py with discli adapter (#604)
* feat(discord): replace discord.py with discli subprocess adapter

Replace the direct discord.py library dependency with discord-cli-agent (discli),
a subprocess-based adapter that communicates via JSONL over stdin/stdout. This
gives the agent full Discord superpowers: reactions, threads, message search,
DMs, and channel management via the new DiscordCLITool.

- Rewrite DiscordAdapter as DiscliAdapter (subprocess bridge to discli serve)
- Add DiscordCLITool wrapping discli CLI for agent-initiated Discord actions
- Replace discord.py with discord-cli-agent in all pyproject.toml extras
- Update dependency checks in headless.py and dashboard_state.py
- Register discord_cli tool in builtin tools and tool policy
- Rewrite test suite for new adapter API (46 tests passing)
- Update deploy identity/instructions with new capabilities (reactions, threads, search)

* fix(tests): update extras install test for discord-cli-agent

Update test_check_installed_dep assertion from discord.py to
discord-cli-agent to match the dependency change.

* fix(tests): update extras install test for discli module name

* fix(tests): update media attachment tests for DiscliAdapter

* fix(discord): drain stderr and clean up process on startup failure

Two fixes for the dashboard showing stuck "starting" status:

1. Add _drain_stderr() task to continuously read discli's stderr pipe.
   Without this, if discord.py logs warnings to stderr the pipe buffer
   fills up and the process blocks, preventing the ready event from
   being emitted on stdout.

2. Call _on_stop() before raising on startup timeout so the subprocess
   and background tasks are properly cleaned up instead of leaking.

* feat(discord): add thread/poll to system prompt and tool description

- Add Discord section to default instructions with thread/poll examples
- Update DiscordCLITool description with thread create and poll create
- Mark /resume target param as optional

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(discord): inject discord.md instructions when on Discord channel

- Create bootstrap/discord.md with full Discord capability docs
  (messages, DMs, threads, polls, channels, roles, members, server)
- Context builder loads channel-specific instruction files and injects
  them into the system prompt only when on that channel
- Inject discord_username and discord_guild_id from message metadata
  so the LLM knows who it's talking to and can DM them
- Pass metadata through from agent loop to context builder
- Remove Discord section from default_provider.py (now in discord.md)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump discord-cli-agent dependency to >=0.6.0

Requires v0.6.0 for the complete serve action registry
(threads, polls, DMs, channels, roles, members, server).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(discord): propagate bot token to parent env for CLI tool

DiscordCLITool spawns separate discli processes that need
DISCORD_BOT_TOKEN, but the token was only set in the serve
subprocess env. Now also sets it in os.environ so all child
processes (including discord_cli tool calls) inherit it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: register DiscordCLITool in tools CLI dispatcher

discord_cli was missing from the CLI registry, so all agent calls
via `python -m pocketpaw.tools.cli discord_cli '...'` failed with
"Unknown tool". This is why the bot couldn't DM, create threads,
or do any Discord operations through the Claude SDK backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: background channel startup, CLI auth bypass, converse tests

- Dashboard: start slow adapters (Discord) in background so HTTP
  response returns fast; frontend polls until running
- Claude SDK: skip API key check when claude CLI is available
  (handles OAuth auth internally)
- Tests: add converse slash command enable/disable tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(discord): add slash commands, user mentions, and admin-gate converse

Add rename/backend/model/tools/delete/backends slash commands to the
Discord adapter, include sender_id in context, document user mention
syntax (<@ID>), restrict /converse to admins, and update /help output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(security): patch command injection, temp leak, and deprecated API

- DiscordCLITool: replace create_subprocess_shell with
  create_subprocess_exec + shlex.split to prevent shell injection
- Clean up slash config temp file on adapter stop
- Use asyncio.get_running_loop() instead of deprecated get_event_loop()
- Convert conversation_channel_ids from list to set
- Scope Claude CLI auth bypass to claude_code provider only
- Add is_admin to converse test events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(discord): MCP server, permission fix, and system prompt overhaul

- Add pocketpaw-discord MCP server that wraps discli, exposing Discord
  operations (send messages, polls, threads, reactions, roles, etc.)
  as native MCP tools for all backends (codex_cli, google_adk, etc.)
- Auto-register the MCP server when the Discord adapter starts
- Fix /converse permission check: accept Manage Server in addition to
  Administrator, use interaction.permissions (Discord payload) instead
  of guild_permissions (member cache) via discli 0.6.4
- Rewrite discord.md system prompt: hide internal tool names from users,
  add context-based reaction guidance, keep responses conversational
- Fix [NO_RESPONSE] marker leaking through streaming responses
- Handle rate_limit_event from Anthropic API in stateless query path
- Update docs and CLAUDE.md for discli adapter and MCP integration
- Remove stale /allowchannel /allowuser references from dashboard UI

* fix(dependencies): update discord-cli-agent to version 0.6.4

* style: format discord_server and discord_adapter

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:54:13 +05:30

257 lines
9.8 KiB
Python

"""Tests for GET /api/extras/check and POST /api/extras/install endpoints."""
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture
def test_client():
"""Create a FastAPI TestClient for the dashboard app with auth bypassed."""
from starlette.testclient import TestClient
from pocketpaw.dashboard import app
return TestClient(app, raise_server_exceptions=False)
def _auth_bypass():
"""Context manager to bypass dashboard auth middleware."""
return patch("pocketpaw.dashboard_auth._is_genuine_localhost", return_value=True)
def _dep_installed():
"""Mock _is_module_importable to return True (dep present)."""
return patch("pocketpaw.dashboard_channels._is_module_importable", return_value=True)
def _dep_missing():
"""Mock _is_module_importable to return False (dep absent)."""
return patch("pocketpaw.dashboard_channels._is_module_importable", return_value=False)
# ---------------------------------------------------------------------------
# GET /api/extras/check
# ---------------------------------------------------------------------------
class TestExtrasCheck:
def test_check_installed_dep(self, test_client):
"""When the module is importable, installed should be True."""
with _auth_bypass(), _dep_installed():
resp = test_client.get("/api/extras/check?channel=discord")
assert resp.status_code == 200
data = resp.json()
assert data["installed"] is True
assert data["extra"] == "discord"
assert data["package"] == "discord-cli-agent"
assert data["pip_spec"] == "pocketpaw[discord]"
def test_check_missing_dep(self, test_client):
"""When the module is NOT importable, installed should be False."""
with _auth_bypass(), _dep_missing():
resp = test_client.get("/api/extras/check?channel=discord")
assert resp.status_code == 200
data = resp.json()
assert data["installed"] is False
assert data["package"] == "discord-cli-agent"
def test_check_unknown_channel_returns_installed(self, test_client):
"""Unknown channels (e.g. signal) have no optional dep — always installed."""
with _auth_bypass():
resp = test_client.get("/api/extras/check?channel=signal")
assert resp.status_code == 200
data = resp.json()
assert data["installed"] is True
assert data["package"] == ""
def test_check_all_known_channels(self, test_client):
"""Every channel in _CHANNEL_DEPS should return correct metadata."""
from pocketpaw.dashboard import _CHANNEL_DEPS
for ch, (_import_mod, package, pip_spec) in _CHANNEL_DEPS.items():
with _auth_bypass(), _dep_missing():
resp = test_client.get(f"/api/extras/check?channel={ch}")
data = resp.json()
assert data["extra"] == ch
assert data["package"] == package
assert data["pip_spec"] == pip_spec
def test_check_whatsapp_returns_neonize(self, test_client):
"""WhatsApp check should refer to neonize (personal mode dep)."""
with _auth_bypass(), _dep_missing():
resp = test_client.get("/api/extras/check?channel=whatsapp")
data = resp.json()
assert data["package"] == "neonize"
assert data["pip_spec"] == "pocketpaw[whatsapp-personal]"
# ---------------------------------------------------------------------------
# POST /api/extras/install
# ---------------------------------------------------------------------------
class TestExtrasInstall:
def test_install_unknown_extra_returns_400(self, test_client):
"""Unknown extra names should be rejected."""
with _auth_bypass():
resp = test_client.post(
"/api/extras/install",
json={"extra": "nonexistent"},
)
assert resp.status_code == 400
def test_install_already_installed(self, test_client):
"""If the dep is already importable, return ok immediately."""
with _auth_bypass(), _dep_installed():
resp = test_client.post(
"/api/extras/install",
json={"extra": "discord"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
def test_install_success(self, test_client):
"""Successful install calls auto_install and returns ok."""
with (
_auth_bypass(),
_dep_missing(),
patch(
"pocketpaw.bus.adapters.auto_install",
return_value={"status": "ok"},
) as mock_install,
):
resp = test_client.post(
"/api/extras/install",
json={"extra": "discord"},
)
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
mock_install.assert_called_once_with("discord", "discli")
def test_install_whatsapp_uses_personal_extra(self, test_client):
"""WhatsApp should use 'whatsapp-personal' as the extra name."""
with (
_auth_bypass(),
_dep_missing(),
patch(
"pocketpaw.bus.adapters.auto_install",
return_value={"status": "ok"},
) as mock_install,
):
resp = test_client.post(
"/api/extras/install",
json={"extra": "whatsapp"},
)
assert resp.status_code == 200
mock_install.assert_called_once_with("whatsapp-personal", "neonize")
def test_install_failure_returns_error(self, test_client):
"""If auto_install raises RuntimeError, return the error message."""
with (
_auth_bypass(),
_dep_missing(),
patch(
"pocketpaw.bus.adapters.auto_install",
side_effect=RuntimeError("pip not found"),
),
):
resp = test_client.post(
"/api/extras/install",
json={"extra": "discord"},
)
assert resp.status_code == 200
data = resp.json()
assert "error" in data
assert "pip not found" in data["error"]
def test_install_restart_required(self, test_client):
"""When auto_install returns restart_required (e.g., neonize), return the flag."""
with (
_auth_bypass(),
_dep_missing(),
patch(
"pocketpaw.bus.adapters.auto_install",
return_value={
"status": "restart_required",
"message": (
"Installed pocketpaw[whatsapp-personal] successfully."
" Server restart required to load native extensions."
),
},
),
):
resp = test_client.post(
"/api/extras/install",
json={"extra": "whatsapp"},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["restart_required"] is True
assert "restart required" in data["message"].lower()
def test_install_prevents_arbitrary_packages(self, test_client):
"""Ensure only known extras can be installed (prevents arbitrary pkg install)."""
with _auth_bypass():
resp = test_client.post(
"/api/extras/install",
json={"extra": "malicious-package"},
)
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/channels/toggle — missing_dep fallback
# ---------------------------------------------------------------------------
class TestToggleMissingDep:
def test_toggle_start_import_error_returns_missing_dep(self, test_client):
"""When _start_channel_adapter raises ImportError, return missing_dep."""
with (
_auth_bypass(),
patch("pocketpaw.dashboard_channels.Settings") as mock_settings_cls,
patch("pocketpaw.dashboard_channels._channel_is_running", return_value=False),
patch("pocketpaw.dashboard_channels._channel_is_configured", return_value=True),
patch(
"pocketpaw.dashboard_channels._start_channel_adapter",
side_effect=ImportError("module not found"),
),
):
settings = MagicMock()
mock_settings_cls.load.return_value = settings
resp = test_client.post(
"/api/channels/toggle",
json={"channel": "telegram", "action": "start"},
)
assert resp.status_code == 200
data = resp.json()
assert data["missing_dep"] is True
assert data["channel"] == "telegram"
assert data["package"] == "python-telegram-bot"
assert data["pip_spec"] == "pocketpaw[telegram]"
def test_toggle_start_regular_error_returns_error(self, test_client):
"""Non-ImportError exceptions return a plain error string."""
with (
_auth_bypass(),
patch("pocketpaw.dashboard_channels.Settings") as mock_settings_cls,
patch("pocketpaw.dashboard_channels._channel_is_running", return_value=False),
patch("pocketpaw.dashboard_channels._channel_is_configured", return_value=True),
patch(
"pocketpaw.dashboard_channels._start_channel_adapter",
side_effect=RuntimeError("connection refused"),
),
):
settings = MagicMock()
mock_settings_cls.load.return_value = settings
resp = test_client.post(
"/api/channels/toggle",
json={"channel": "telegram", "action": "start"},
)
assert resp.status_code == 200
data = resp.json()
assert "error" in data
assert "missing_dep" not in data