fix: add update_gitea_avatar capability (#368)
All checks were successful
Tests / lint (push) Successful in 6s
Tests / test (push) Successful in 1m46s

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit was merged in pull request #368.
This commit is contained in:
2026-03-18 22:04:57 -04:00
committed by hermes
parent c1af9e3905
commit c1f939ef22
3 changed files with 269 additions and 0 deletions

View File

@@ -29,6 +29,8 @@ from contextlib import closing
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import httpx
from config import settings from config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -268,6 +270,140 @@ async def create_gitea_issue_via_mcp(title: str, body: str = "", labels: str = "
return f"Failed to create issue via MCP: {exc}" return f"Failed to create issue via MCP: {exc}"
def _generate_avatar_image() -> bytes:
"""Generate a Timmy-themed avatar image using Pillow.
Creates a 512x512 wizard-themed avatar with emerald/purple/gold palette.
Returns raw PNG bytes. Falls back to a minimal solid-color image if
Pillow drawing primitives fail.
"""
from PIL import Image, ImageDraw
size = 512
img = Image.new("RGB", (size, size), (15, 25, 20))
draw = ImageDraw.Draw(img)
# Background gradient effect — concentric circles
for i in range(size // 2, 0, -4):
g = int(25 + (i / (size // 2)) * 30)
draw.ellipse(
[size // 2 - i, size // 2 - i, size // 2 + i, size // 2 + i],
fill=(10, g, 20),
)
# Wizard hat (triangle)
hat_color = (100, 50, 160) # purple
draw.polygon(
[(256, 40), (160, 220), (352, 220)],
fill=hat_color,
outline=(180, 130, 255),
)
# Hat brim
draw.ellipse([140, 200, 372, 250], fill=hat_color, outline=(180, 130, 255))
# Face circle
draw.ellipse([190, 220, 322, 370], fill=(60, 180, 100), outline=(80, 220, 120))
# Eyes
draw.ellipse([220, 275, 248, 310], fill=(255, 255, 255))
draw.ellipse([264, 275, 292, 310], fill=(255, 255, 255))
draw.ellipse([228, 285, 242, 300], fill=(30, 30, 60))
draw.ellipse([272, 285, 286, 300], fill=(30, 30, 60))
# Smile
draw.arc([225, 300, 287, 355], start=10, end=170, fill=(30, 30, 60), width=3)
# Stars around the hat
gold = (220, 190, 50)
star_positions = [(120, 100), (380, 120), (100, 300), (400, 280), (256, 10)]
for sx, sy in star_positions:
r = 8
draw.polygon(
[
(sx, sy - r),
(sx + r // 3, sy - r // 3),
(sx + r, sy),
(sx + r // 3, sy + r // 3),
(sx, sy + r),
(sx - r // 3, sy + r // 3),
(sx - r, sy),
(sx - r // 3, sy - r // 3),
],
fill=gold,
)
# "T" monogram on the hat
draw.text((243, 100), "T", fill=gold)
# Robe / body
draw.polygon(
[(180, 370), (140, 500), (372, 500), (332, 370)],
fill=(40, 100, 70),
outline=(60, 160, 100),
)
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
async def update_gitea_avatar() -> str:
"""Generate and upload a unique avatar to Timmy's Gitea profile.
Creates a wizard-themed avatar image using Pillow drawing primitives,
base64-encodes it, and POSTs to the Gitea user avatar API endpoint.
Returns:
Success or failure message string.
"""
if not settings.gitea_enabled or not settings.gitea_token:
return "Gitea integration is not configured (no token or disabled)."
try:
from PIL import Image # noqa: F401 — availability check
except ImportError:
return "Pillow is not installed — cannot generate avatar image."
try:
import base64
# Step 1: Generate the avatar image
png_bytes = _generate_avatar_image()
logger.info("Generated avatar image (%d bytes)", len(png_bytes))
# Step 2: Base64-encode (raw, no data URI prefix)
b64_image = base64.b64encode(png_bytes).decode("ascii")
# Step 3: POST to Gitea
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
f"{settings.gitea_url}/api/v1/user/avatar",
headers={
"Authorization": f"token {settings.gitea_token}",
"Content-Type": "application/json",
},
json={"image": b64_image},
)
# Gitea returns empty body on success (204 or 200)
if resp.status_code in (200, 204):
logger.info("Gitea avatar updated successfully")
return "Avatar updated successfully on Gitea."
logger.warning("Gitea avatar update failed: %s %s", resp.status_code, resp.text[:200])
return f"Gitea avatar update failed (HTTP {resp.status_code}): {resp.text[:200]}"
except (httpx.ConnectError, httpx.ReadError, ConnectionError) as exc:
logger.warning("Gitea connection failed during avatar update: %s", exc)
return f"Could not connect to Gitea: {exc}"
except Exception as exc:
logger.error("Avatar update failed: %s", exc)
return f"Avatar update failed: {exc}"
async def close_mcp_sessions() -> None: async def close_mcp_sessions() -> None:
"""Close any open MCP sessions. Called during app shutdown.""" """Close any open MCP sessions. Called during app shutdown."""
global _issue_session global _issue_session

View File

@@ -586,6 +586,13 @@ def _register_introspection_tools(toolkit: Toolkit) -> None:
logger.warning("Tool execution failed (Introspection tools registration): %s", exc) logger.warning("Tool execution failed (Introspection tools registration): %s", exc)
logger.debug("Introspection tools not available") logger.debug("Introspection tools not available")
try:
from timmy.mcp_tools import update_gitea_avatar
toolkit.register(update_gitea_avatar, name="update_gitea_avatar")
except (ImportError, AttributeError) as exc:
logger.debug("update_gitea_avatar tool not available: %s", exc)
try: try:
from timmy.session_logger import session_history from timmy.session_logger import session_history
@@ -867,6 +874,11 @@ def _introspection_tool_catalog() -> dict:
"description": "Query Timmy's own thought history for past reflections and insights", "description": "Query Timmy's own thought history for past reflections and insights",
"available_in": ["orchestrator"], "available_in": ["orchestrator"],
}, },
"update_gitea_avatar": {
"name": "Update Gitea Avatar",
"description": "Generate and upload a wizard-themed avatar to Timmy's Gitea profile",
"available_in": ["orchestrator"],
},
} }

View File

@@ -6,11 +6,13 @@ import pytest
from timmy.mcp_tools import ( from timmy.mcp_tools import (
_bridge_to_work_order, _bridge_to_work_order,
_generate_avatar_image,
_parse_command, _parse_command,
close_mcp_sessions, close_mcp_sessions,
create_filesystem_mcp_tools, create_filesystem_mcp_tools,
create_gitea_issue_via_mcp, create_gitea_issue_via_mcp,
create_gitea_mcp_tools, create_gitea_mcp_tools,
update_gitea_avatar,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -302,3 +304,122 @@ def test_mcp_tools_classified_in_safety():
assert not requires_confirmation("issue_write") assert not requires_confirmation("issue_write")
assert not requires_confirmation("list_directory") assert not requires_confirmation("list_directory")
assert requires_confirmation("write_file") assert requires_confirmation("write_file")
# ---------------------------------------------------------------------------
# update_gitea_avatar
# ---------------------------------------------------------------------------
def test_generate_avatar_image_returns_png():
"""_generate_avatar_image returns valid PNG bytes."""
pytest.importorskip("PIL")
data = _generate_avatar_image()
assert isinstance(data, bytes)
assert len(data) > 0
# PNG magic bytes
assert data[:4] == b"\x89PNG"
@pytest.mark.asyncio
async def test_update_avatar_not_configured():
"""update_gitea_avatar returns message when Gitea is disabled."""
with patch("timmy.mcp_tools.settings") as mock_settings:
mock_settings.gitea_enabled = False
mock_settings.gitea_token = ""
result = await update_gitea_avatar()
assert "not configured" in result
@pytest.mark.asyncio
async def test_update_avatar_success():
"""update_gitea_avatar uploads avatar and returns success."""
import sys
import timmy.mcp_tools as mcp_mod
mock_response = MagicMock()
mock_response.status_code = 204
mock_response.text = ""
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
# Ensure PIL import check passes even if Pillow isn't installed
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "successfully" in result
mock_client.post.assert_awaited_once()
call_args = mock_client.post.call_args
assert "/api/v1/user/avatar" in call_args[0][0]
@pytest.mark.asyncio
async def test_update_avatar_api_failure():
"""update_gitea_avatar handles HTTP error gracefully."""
import sys
import timmy.mcp_tools as mcp_mod
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "bad request"
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "failed" in result.lower()
assert "400" in result
@pytest.mark.asyncio
async def test_update_avatar_connection_error():
"""update_gitea_avatar handles connection errors gracefully."""
import sys
import timmy.mcp_tools as mcp_mod
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=mcp_mod.httpx.ConnectError("refused"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
pil_stub = MagicMock()
with (
patch("timmy.mcp_tools.settings") as mock_settings,
patch.object(mcp_mod.httpx, "AsyncClient", return_value=mock_client),
patch("timmy.mcp_tools._generate_avatar_image", return_value=b"\x89PNG fake"),
patch.dict(sys.modules, {"PIL": pil_stub, "PIL.Image": pil_stub}),
):
mock_settings.gitea_enabled = True
mock_settings.gitea_token = "tok123"
mock_settings.gitea_url = "http://localhost:3000"
result = await update_gitea_avatar()
assert "connect" in result.lower()