feat(bot): add modality map, config loader, and backend client

Implements core bot infrastructure:
- modality_map: channel name to modality string mapping (personal_text, voice, fb_messenger, nd_messenger)
- config: Config dataclass with env-driven configuration loading for Discord, backend, and email IMAP credentials
- backend_client: async HTTP client for posting items to backend /items endpoint

All modality_map tests passing (3/3)
This commit is contained in:
pyr0ball 2026-07-13 13:48:39 -07:00
parent a90c3fc172
commit 669bb3456a
7 changed files with 105 additions and 0 deletions

View file

View file

@ -0,0 +1,28 @@
from datetime import datetime
import httpx
class BackendClient:
def __init__(self, base_url: str):
self._base_url = base_url.rstrip("/")
async def create_item(
self,
*,
modality: str,
raw_content: str,
captured_at: datetime,
discord_message_id: str | None,
sender_id: str | None = None,
) -> dict:
payload = {
"modality": modality,
"raw_content": raw_content,
"captured_at": captured_at.isoformat(),
"discord_message_id": discord_message_id,
"sender_id": sender_id,
}
async with httpx.AsyncClient() as client:
resp = await client.post(f"{self._base_url}/items", json=payload, timeout=10)
resp.raise_for_status()
return resp.json()

45
bot/chorus_bot/config.py Normal file
View file

@ -0,0 +1,45 @@
import os
from dataclasses import dataclass
@dataclass
class Config:
discord_token: str
guild_id: int
inbox_channel_id: int
triage_role_id: int
backend_base_url: str
google_voice_imap_host: str
google_voice_imap_user: str
google_voice_imap_password: str
bh_email_imap_host: str
bh_email_imap_user: str
bh_email_imap_password: str
personal_email_imap_host: str
personal_email_imap_user: str
personal_email_imap_password: str
def load_config() -> Config:
def env(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Missing required env var: {name}")
return value
return Config(
discord_token=env("CHORUS_DISCORD_TOKEN"),
guild_id=int(env("CHORUS_GUILD_ID")),
inbox_channel_id=int(env("CHORUS_INBOX_CHANNEL_ID")),
triage_role_id=int(env("CHORUS_TRIAGE_ROLE_ID")),
backend_base_url=env("CHORUS_BACKEND_BASE_URL"),
google_voice_imap_host=env("CHORUS_GVOICE_IMAP_HOST"),
google_voice_imap_user=env("CHORUS_GVOICE_IMAP_USER"),
google_voice_imap_password=env("CHORUS_GVOICE_IMAP_PASSWORD"),
bh_email_imap_host=env("CHORUS_BH_EMAIL_IMAP_HOST"),
bh_email_imap_user=env("CHORUS_BH_EMAIL_IMAP_USER"),
bh_email_imap_password=env("CHORUS_BH_EMAIL_IMAP_PASSWORD"),
personal_email_imap_host=env("CHORUS_PERSONAL_EMAIL_IMAP_HOST"),
personal_email_imap_user=env("CHORUS_PERSONAL_EMAIL_IMAP_USER"),
personal_email_imap_password=env("CHORUS_PERSONAL_EMAIL_IMAP_PASSWORD"),
)

View file

@ -0,0 +1,10 @@
CHANNEL_TO_MODALITY = {
"personal-text": "personal_text",
"voice": "voice",
"fb-messenger": "fb_messenger",
"nd-messenger": "nd_messenger",
}
def modality_for_channel(channel_name: str) -> str | None:
return CHANNEL_TO_MODALITY.get(channel_name)

5
bot/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
discord.py==2.4.0
httpx==0.27.2
aioimaplib==1.1.0
pytest==8.3.3
pytest-asyncio==0.24.0

0
bot/tests/__init__.py Normal file
View file

View file

@ -0,0 +1,17 @@
from chorus_bot import modality_map
def test_known_channels_map_to_modalities():
assert modality_map.modality_for_channel("personal-text") == "personal_text"
assert modality_map.modality_for_channel("voice") == "voice"
assert modality_map.modality_for_channel("fb-messenger") == "fb_messenger"
assert modality_map.modality_for_channel("nd-messenger") == "nd_messenger"
def test_unknown_channel_returns_none():
assert modality_map.modality_for_channel("random-chat") is None
def test_inbox_and_general_channels_are_not_mapped():
assert modality_map.modality_for_channel("inbox") is None
assert modality_map.modality_for_channel("general") is None