This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/tools/epiphany_face_discord.py
2026-05-05 13:50:43 +01:00

359 lines
12 KiB
Python

from __future__ import annotations
import argparse
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import sys
from typing import Any
from urllib import error
from urllib import request
from uuid import uuid4
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_CONFIG = ROOT / "state" / "face-discord.json"
DEFAULT_ARTIFACT_DIR = ROOT / ".epiphany-face"
DISCORD_API = "https://discord.com/api/v10"
CHAT_SCHEMA_VERSION = "epiphany.face_chat.v0"
BUBBLE_SCHEMA_VERSION = "epiphany.face_bubble.v0"
def now_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def read_text_arg(value: str | None, *, stdin_fallback: bool = True) -> str:
if value:
path = Path(value)
if path.exists():
return path.read_text(encoding="utf-8")
return value
if stdin_fallback:
return sys.stdin.read()
return ""
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=False) + "\n", encoding="utf-8")
def latest_face_artifacts(artifact_dir: Path, *, limit: int = 8) -> list[dict[str, Any]]:
if not artifact_dir.exists():
return []
items: list[dict[str, Any]] = []
for path in sorted(artifact_dir.glob("face-*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
try:
payload = load_json(path)
except (OSError, json.JSONDecodeError):
continue
if not isinstance(payload, dict):
continue
items.append(
{
"path": str(path),
"name": path.name,
"modifiedAt": datetime.fromtimestamp(
path.stat().st_mtime, timezone.utc
).replace(microsecond=0).isoformat(),
"schemaVersion": payload.get("schema_version"),
"status": payload.get("status"),
"reason": payload.get("reason"),
"content": payload.get("content"),
"bubble": payload.get("bubble"),
"source": payload.get("source"),
}
)
if len(items) >= limit:
break
return items
def allowed_channel_id(config: dict[str, Any]) -> str | None:
explicit = config.get("allowed_channel_id")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
env_name = config.get("allowed_channel_id_env")
if isinstance(env_name, str) and env_name:
env_value = os.environ.get(env_name)
if env_value:
return env_value.strip()
return None
def bot_token(config: dict[str, Any]) -> str | None:
env_name = config.get("bot_token_env")
if not isinstance(env_name, str) or not env_name:
return None
value = os.environ.get(env_name)
return value.strip() if value else None
def draft_payload(content: str, *, config: dict[str, Any], status: str, reason: str) -> dict[str, Any]:
return {
"schema_version": CHAT_SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"status": status,
"reason": reason,
"allowed_channel_name": config.get("allowed_channel_name", "#aquarium"),
"allowed_channel_id": allowed_channel_id(config),
"content": content.strip(),
}
def write_draft(content: str, *, config: dict[str, Any], artifact_dir: Path, status: str, reason: str) -> Path:
payload = draft_payload(content, config=config, status=status, reason=reason)
path = artifact_dir / f"face-chat-{now_stamp()}-{uuid4().hex[:8]}.json"
write_json(path, payload)
return path
def bubble_payload(
content: str,
*,
source: str,
status: str = "ready",
mood: str = "attentive",
target: str = "aquarium",
) -> dict[str, Any]:
return {
"schema_version": BUBBLE_SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"status": status,
"source": source,
"target": target,
"role_id": "face",
"agent_id": "face",
"display_name": "Face",
"mood": mood,
"content": content.strip(),
"bubble": {
"kind": "agent-chat",
"anchorRoleId": "face",
"opensIn": "aquarium",
"requiresDiscord": False,
"ttlSeconds": 90,
},
}
def write_bubble(
content: str,
*,
artifact_dir: Path,
source: str,
status: str = "ready",
mood: str = "attentive",
) -> Path:
payload = bubble_payload(content, source=source, status=status, mood=mood)
path = artifact_dir / f"face-bubble-{now_stamp()}-{uuid4().hex[:8]}.json"
write_json(path, payload)
return path
def post_message(content: str, *, channel_id: str, token: str) -> dict[str, Any]:
body = json.dumps({"content": content}).encode("utf-8")
req = request.Request(
f"{DISCORD_API}/channels/{channel_id}/messages",
data=body,
headers={
"Authorization": f"Bot {token}",
"Content-Type": "application/json",
"User-Agent": "EpiphanyFace/0.1",
},
method="POST",
)
try:
with request.urlopen(req, timeout=20) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Discord post failed with HTTP {exc.code}: {detail}") from exc
def run_draft(args: argparse.Namespace) -> dict[str, Any]:
config = load_json(args.config)
content = read_text_arg(args.content)
if not content.strip():
raise ValueError("Face chat content is empty")
path = write_draft(
content,
config=config,
artifact_dir=args.artifact_dir,
status="draft",
reason="drafted without posting",
)
return {"ok": True, "posted": False, "draftPath": str(path)}
def run_bubble(args: argparse.Namespace) -> dict[str, Any]:
content = read_text_arg(args.content)
if not content.strip():
raise ValueError("Face bubble content is empty")
path = write_bubble(
content,
artifact_dir=args.artifact_dir,
source=args.source,
status=args.status,
mood=args.mood,
)
return {"ok": True, "posted": False, "bubblePath": str(path), "bubble": load_json(path)}
def run_post(args: argparse.Namespace) -> dict[str, Any]:
config = load_json(args.config)
content = read_text_arg(args.content)
if not content.strip():
raise ValueError("Face chat content is empty")
configured_channel_id = allowed_channel_id(config)
requested_channel_id = args.channel_id or configured_channel_id
if not configured_channel_id:
path = write_draft(
content,
config=config,
artifact_dir=args.artifact_dir,
status="blocked",
reason="missing #aquarium channel id",
)
return {"ok": False, "posted": False, "blocked": "missing-channel-id", "draftPath": str(path)}
if requested_channel_id != configured_channel_id:
path = write_draft(
content,
config=config,
artifact_dir=args.artifact_dir,
status="blocked",
reason="requested channel does not match configured #aquarium channel id",
)
return {"ok": False, "posted": False, "blocked": "wrong-channel", "draftPath": str(path)}
token = bot_token(config)
if not token:
path = write_draft(
content,
config=config,
artifact_dir=args.artifact_dir,
status="blocked",
reason="missing Discord bot token",
)
return {"ok": False, "posted": False, "blocked": "missing-token", "draftPath": str(path)}
response = post_message(content.strip(), channel_id=configured_channel_id, token=token)
path = write_draft(
content,
config=config,
artifact_dir=args.artifact_dir,
status="posted",
reason=f"posted message {response.get('id')}",
)
return {"ok": True, "posted": True, "messageId": response.get("id"), "draftPath": str(path)}
def run_smoke(args: argparse.Namespace) -> dict[str, Any]:
import tempfile
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
config = {
"schema_version": "epiphany.face_discord.v0",
"allowed_channel_name": "#aquarium",
"allowed_channel_id": None,
"allowed_channel_id_env": "EPIPHANY_FACE_AQUARIUM_CHANNEL_ID_TEST",
"bot_token_env": "DISCORD_BOT_TOKEN_TEST",
"policy": ["Face may post only in #aquarium."],
}
config_path = tmp_dir / "face-discord.json"
write_json(config_path, config)
draft = run_draft(
argparse.Namespace(
config=config_path,
artifact_dir=tmp_dir,
content="Face notices Body and Soul disagree about evidence shape.",
)
)
bubble = run_bubble(
argparse.Namespace(
artifact_dir=tmp_dir,
content="Face opens an Aquarium bubble even while Discord is unavailable.",
source="smoke/face",
status="ready",
mood="attentive",
)
)
blocked = run_post(
argparse.Namespace(
config=config_path,
artifact_dir=tmp_dir,
content="Face should not post without a configured #aquarium channel id.",
channel_id=None,
)
)
os.environ["EPIPHANY_FACE_AQUARIUM_CHANNEL_ID_TEST"] = "123"
wrong = run_post(
argparse.Namespace(
config=config_path,
artifact_dir=tmp_dir,
content="Face should not post outside #aquarium.",
channel_id="456",
)
)
ok = (
draft["ok"]
and bubble["ok"]
and bubble["bubble"]["schema_version"] == BUBBLE_SCHEMA_VERSION
and bubble["bubble"]["bubble"]["requiresDiscord"] is False
and not blocked["ok"]
and blocked["blocked"] == "missing-channel-id"
and not wrong["ok"]
and wrong["blocked"] == "wrong-channel"
)
return {"ok": ok, "draft": draft, "bubble": bubble, "blocked": blocked, "wrongChannel": wrong}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Draft or post Epiphany Face chat with #aquarium-only guardrails.")
subparsers = parser.add_subparsers(dest="command", required=True)
for command in ("draft", "post"):
sub = subparsers.add_parser(command)
sub.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
sub.add_argument("--artifact-dir", type=Path, default=DEFAULT_ARTIFACT_DIR)
sub.add_argument("--content", help="Content string or path. Reads stdin when omitted.")
if command == "post":
sub.add_argument("--channel-id", help="Must match configured #aquarium channel id if supplied.")
bubble = subparsers.add_parser("bubble")
bubble.add_argument("--artifact-dir", type=Path, default=DEFAULT_ARTIFACT_DIR)
bubble.add_argument("--content", help="Content string or path. Reads stdin when omitted.")
bubble.add_argument("--source", default="epiphany/face")
bubble.add_argument("--status", default="ready")
bubble.add_argument("--mood", default="attentive")
smoke = subparsers.add_parser("smoke")
smoke.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "draft":
result = run_draft(args)
elif args.command == "bubble":
result = run_bubble(args)
elif args.command == "post":
result = run_post(args)
elif args.command == "smoke":
result = run_smoke(args)
else:
raise AssertionError(args.command)
print(json.dumps(result, indent=2))
return 0 if result.get("ok") else 1
except Exception as exc:
print(json.dumps({"ok": False, "error": str(exc)}, indent=2), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())