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: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Pixelbot 3.0 is an app-first Pixeltable 0.7.7 project. Python 3.11+ and Node 22.
- `backend/pixelbot/app.py`: application entry, endpoint-facing `@pxt.query` functions, module-scope `FastAPIRouter` routes, and the custom FastAPI app.
- `backend/pixelbot/schema.py`: the single `TableModel` schema for `pixelbot_v3`; its query functions are reusable parts of computed pipelines.
- `backend/pixelbot/routers/`: custom REST writes and HTTP-specific behavior. The database router is read-only.
- `backend/pixelbot/notifications.py`: the single notification transport used by both HTTP routes and Pixeltable tools.
- `backend/pixelbot/static/`: production SPA package data generated by Vite.
- `frontend/`: React SPA with route-level lazy loading.
- Pixeltable agent guidance lives in the canonical
Expand All @@ -21,6 +22,8 @@ Declare stored columns with annotations and computed columns with assignments in

Use `@pxt.query` plus `FastAPIRouter.add_query_route()` for table-backed read endpoints. Put endpoint-only queries beside their routes in `app.py`; keep queries in `schema.py` when computed columns also call them. Use custom FastAPI handlers for request validation, application defaults, and multi-step writes. Do not mirror a query in a plain Python helper.

Let the application exception handler log and sanitize unexpected route failures. Convert only expected validation and missing-resource failures to `HTTPException`. Apply `pxt_retry()` only to read-only operations; retrying writes, provider calls, or notifications can duplicate side effects.

A changed computed expression is unsupported by schema migration. Rename the column, or drop and re-add it in separate schema updates. `--allow-destructive` does not change that restriction.

## Workflow
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ The Database page is an inspector for catalog rows, schemas, lineage, history, s
- Media reads are confined to the upload root and configured `PIXELTABLE_HOME`, including resolved symlinks.
- Database inspection and exports are confined to `pixelbot_v3` and registered scratch tables, with result caps.
- The agent webhook tool can send only to `WEBHOOK_URL` configured at process startup.
- HTTP routes and agent tools share one notification transport, and catalog logs store only the destination origin.
- There is no runtime expression evaluation or general catalog mutation API.

## Validation
Expand Down
42 changes: 11 additions & 31 deletions backend/pixelbot/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import yfinance as yf
from duckduckgo_search import DDGS

from pixelbot.notifications import deliver_notification


@pxt.udf
def get_latest_news(topic: str) -> str:
Expand Down Expand Up @@ -186,47 +188,25 @@ def fetch_financial_data(ticker: str) -> str:
@pxt.udf
def send_slack_message(message: str) -> str:
"""Send a message to a configured Slack channel via incoming webhook."""
webhook_url = os.environ.get("SLACK_WEBHOOK_URL", "")
if not webhook_url:
return "Error: SLACK_WEBHOOK_URL not configured."
try:
resp = requests.post(webhook_url, json={"text": message}, timeout=10)
if resp.status_code == 200:
return "Slack message sent successfully."
return f"Slack error ({resp.status_code}): {resp.text}"
except requests.RequestException as e:
return f"Slack request failed: {e}"
result = deliver_notification("slack", message)
assert result is not None
return result.message


@pxt.udf
def send_discord_message(message: str) -> str:
"""Send a message to a configured Discord channel via webhook."""
webhook_url = os.environ.get("DISCORD_WEBHOOK_URL", "")
if not webhook_url:
return "Error: DISCORD_WEBHOOK_URL not configured."
try:
resp = requests.post(webhook_url, json={"content": message}, timeout=10)
if resp.status_code in (200, 204):
return "Discord message sent successfully."
return f"Discord error ({resp.status_code}): {resp.text}"
except requests.RequestException as e:
return f"Discord request failed: {e}"
result = deliver_notification("discord", message)
assert result is not None
return result.message


@pxt.udf
def send_webhook(message: str) -> str:
"""POST a JSON payload to the configured webhook URL."""
target_url = os.environ.get("WEBHOOK_URL", "")
if not target_url:
return "Error: WEBHOOK_URL not configured."
try:
payload = {"text": message, "source": "pixelbot", "timestamp": datetime.utcnow().isoformat()}
resp = requests.post(target_url, json=payload, timeout=10)
if resp.status_code < 300:
return f"Webhook delivered ({resp.status_code})."
return f"Webhook error ({resp.status_code}): {resp.text}"
except requests.RequestException as e:
return f"Webhook request failed: {e}"
result = deliver_notification("webhook", message)
assert result is not None
return result.message


@pxt.udf
Expand Down
12 changes: 0 additions & 12 deletions backend/pixelbot/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,18 +263,6 @@ class AddUrlResponse(BaseModel):
uuid: str


class DeleteFileResponse(BaseModel):
message: str
db_deleted: bool
file_deleted: bool
uuid: str


class DeleteAllResponse(BaseModel):
message: str
should_refresh: bool = True


# ── History ──────────────────────────────────────────────────────────────────


Expand Down
86 changes: 86 additions & 0 deletions backend/pixelbot/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Notification delivery shared by the HTTP API and Pixeltable agent tools."""

from __future__ import annotations

import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from urllib.parse import urlsplit

import requests

from pixelbot import config

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class DeliveryResult:
message: str
success: bool
response_code: int


def deliver_notification(service: str, message: str) -> DeliveryResult | None:
"""Deliver a message to one configured notification service."""
service = service.lower()
url = _service_url(service)
if service not in config.INTEGRATIONS:
return None
if not url:
env_var = config.INTEGRATIONS[service]["env_var"]
return DeliveryResult(f"Error: {env_var} not configured.", False, 0)

payload = _payload(service, message)
try:
response = requests.post(url, json=payload, timeout=10)
except requests.RequestException:
logger.exception("%s notification request failed", service)
return DeliveryResult(f"{service.title()} request failed.", False, 0)

success = response.status_code in _success_codes(service)
if success:
if service == "webhook":
result_message = f"Webhook delivered ({response.status_code})."
else:
result_message = f"{service.title()} message sent successfully."
else:
result_message = f"{service.title()} delivery failed ({response.status_code})."
return DeliveryResult(result_message, success, response.status_code)


def redacted_destination(service: str) -> str:
"""Return a display-safe form of the configured destination."""
url = _service_url(service.lower())
if not url:
return "(not configured)"
parsed = urlsplit(url)
return f"{parsed.scheme}://{parsed.netloc}/..."


def _service_url(service: str) -> str:
return {
"slack": config.SLACK_WEBHOOK_URL,
"discord": config.DISCORD_WEBHOOK_URL,
"webhook": config.WEBHOOK_URL,
}.get(service, "")


def _payload(service: str, message: str) -> dict[str, str]:
if service == "discord":
return {"content": message}
if service == "webhook":
return {
"text": message,
"source": "pixelbot",
"timestamp": datetime.now(UTC).isoformat(),
}
return {"text": message}


def _success_codes(service: str) -> range | tuple[int, ...]:
if service == "slack":
return (200,)
if service == "discord":
return (200, 204)
return range(200, 300)
Loading
Loading