From f4b57238d2dda2c0c563badea47c64b9b0e9626f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 13 Jul 2026 14:07:53 -0700 Subject: [PATCH] feat(bot): add email ingestion polling and thread-open triage creation --- bot/chorus_bot/cogs/email_ingest.py | 94 ++++++++++++++++++++++++++++ bot/chorus_bot/cogs/thread_ingest.py | 37 +++++++++++ 2 files changed, 131 insertions(+) create mode 100644 bot/chorus_bot/cogs/email_ingest.py create mode 100644 bot/chorus_bot/cogs/thread_ingest.py diff --git a/bot/chorus_bot/cogs/email_ingest.py b/bot/chorus_bot/cogs/email_ingest.py new file mode 100644 index 0000000..8abf1bc --- /dev/null +++ b/bot/chorus_bot/cogs/email_ingest.py @@ -0,0 +1,94 @@ +import json +import os +from pathlib import Path +import discord +from discord.ext import commands, tasks +from imaplib import IMAP4_SSL +from email import message_from_bytes +from email.utils import parsedate_to_datetime +from chorus_bot.ping import build_ping_embed + +SEEN_UIDS_PATH = Path(os.environ.get("CHORUS_SEEN_UIDS_PATH", "seen_uids.json")) + +# modality -> (imap host attr, imap user attr, imap password attr) on Config +MAILBOXES = { + "bh_email": ("bh_email_imap_host", "bh_email_imap_user", "bh_email_imap_password"), + "personal_email": ( + "personal_email_imap_host", "personal_email_imap_user", "personal_email_imap_password", + ), + "bh_text": ("google_voice_imap_host", "google_voice_imap_user", "google_voice_imap_password"), +} + + +def _load_seen() -> dict: + if SEEN_UIDS_PATH.exists(): + return json.loads(SEEN_UIDS_PATH.read_text()) + return {} + + +def _save_seen(seen: dict) -> None: + SEEN_UIDS_PATH.write_text(json.dumps(seen)) + + +class EmailIngestCog(commands.Cog): + def __init__(self, bot: commands.Bot, config): + self.bot = bot + self.config = config + self.seen_uids = _load_seen() + self.poll_mailboxes.start() + + def cog_unload(self): + self.poll_mailboxes.cancel() + + @tasks.loop(minutes=2) + async def poll_mailboxes(self): + for modality, (host_attr, user_attr, pass_attr) in MAILBOXES.items(): + host = getattr(self.config, host_attr) + user = getattr(self.config, user_attr) + password = getattr(self.config, pass_attr) + await self._poll_one(modality, host, user, password) + + async def _poll_one(self, modality: str, host: str, user: str, password: str): + seen = self.seen_uids.setdefault(modality, []) + conn = IMAP4_SSL(host) + try: + conn.login(user, password) + conn.select("INBOX") + _, data = conn.search(None, "UNSEEN") + for uid in data[0].split(): + uid_str = uid.decode() + if uid_str in seen: + continue + _, msg_data = conn.fetch(uid, "(RFC822)") + raw_email = msg_data[0][1] + msg = message_from_bytes(raw_email) + sender = msg.get("From") + captured_at = parsedate_to_datetime(msg.get("Date")) + body = self._extract_body(msg) + snippet = body if len(body) <= 120 else body[:117] + "..." + + embed = build_ping_embed( + modality=modality, sender_id=sender, snippet=snippet, + raw_content=body, captured_at=captured_at, + ) + inbox = self.bot.get_channel(self.config.inbox_channel_id) + role_mention = f"<@&{self.config.triage_role_id}>" + await inbox.send(content=role_mention, embed=embed) + + seen.append(uid_str) + _save_seen(self.seen_uids) + finally: + conn.logout() + + @staticmethod + def _extract_body(msg) -> str: + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + return part.get_payload(decode=True).decode(errors="replace") + return "(no plain-text body found)" + return msg.get_payload(decode=True).decode(errors="replace") + + +async def setup(bot: commands.Bot, config): + await bot.add_cog(EmailIngestCog(bot, config)) diff --git a/bot/chorus_bot/cogs/thread_ingest.py b/bot/chorus_bot/cogs/thread_ingest.py new file mode 100644 index 0000000..ad298ef --- /dev/null +++ b/bot/chorus_bot/cogs/thread_ingest.py @@ -0,0 +1,37 @@ +import discord +from discord.ext import commands +from chorus_bot.ping import parse_ping_embed + + +class ThreadIngestCog(commands.Cog): + """Opening a thread on an #inbox ping is what creates the real triage row.""" + + def __init__(self, bot: commands.Bot, config, backend_client): + self.bot = bot + self.config = config + self.backend_client = backend_client + + @commands.Cog.listener() + async def on_thread_create(self, thread: discord.Thread): + if thread.parent_id != self.config.inbox_channel_id: + return + + parent_message = await thread.parent.fetch_message(thread.id) + if not parent_message.embeds: + return + + parsed = parse_ping_embed(parent_message.embeds[0]) + await self.backend_client.create_item( + modality=parsed["modality"], + raw_content=parsed["raw_content"], + captured_at=parsed["captured_at"], + discord_message_id=str(thread.id), + sender_id=parsed["sender_id"], + ) + await thread.send( + "Logged in Chorus — open the app to fill in details and track this one." + ) + + +async def setup(bot: commands.Bot, config, backend_client): + await bot.add_cog(ThreadIngestCog(bot, config, backend_client))