Add listener email system with IMAP polling, TTS playback, and show awareness

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 05:22:56 -07:00
parent f0271e61df
commit d85a8d4511
11 changed files with 335 additions and 15 deletions

View File

@@ -22,6 +22,11 @@ class Settings(BaseSettings):
signalwire_phone: str = os.getenv("SIGNALWIRE_PHONE", "")
signalwire_stream_url: str = os.getenv("SIGNALWIRE_STREAM_URL", "")
# Email (IMAP)
submissions_imap_host: str = os.getenv("SUBMISSIONS_IMAP_HOST", "")
submissions_imap_user: str = os.getenv("SUBMISSIONS_IMAP_USER", "")
submissions_imap_pass: str = os.getenv("SUBMISSIONS_IMAP_PASS", "")
# LLM Settings
llm_provider: str = "openrouter" # "openrouter" or "ollama"
openrouter_model: str = "anthropic/claude-sonnet-4-5"

View File

@@ -2717,15 +2717,22 @@ class Session:
def get_show_history(self) -> str:
"""Get formatted show history for AI caller prompts.
Randomly picks one previous caller to have a strong reaction to."""
if not self.call_history:
if not self.call_history and not any(e.read_on_air for e in _listener_emails):
return ""
lines = ["EARLIER IN THE SHOW:"]
for record in self.call_history:
caller_type_label = "(real caller)" if record.caller_type == "real" else "(AI)"
lines.append(f"- {record.caller_name} {caller_type_label}: {record.summary}")
# Include emails that were read on the show
read_emails = [e for e in _listener_emails if e.read_on_air]
for em in read_emails:
sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener"
preview = em.body[:150] if len(em.body) > 150 else em.body
lines.append(f"- A listener email from {sender_name} was read on air: \"{em.subject}\"{preview}")
# 20% chance to have a strong reaction to a previous caller
if random.random() < 0.20:
if self.call_history and random.random() < 0.20:
target = random.choice(self.call_history)
reaction = random.choice(SHOW_HISTORY_REACTIONS)
lines.append(f"\nYOU HEARD {target.caller_name.upper()} EARLIER and you {reaction}. Mention it if it comes up.")
@@ -3092,7 +3099,9 @@ async def _sync_signalwire_voicemails():
async def startup():
"""Pre-generate caller backgrounds on server start"""
_load_voicemails()
_load_emails()
asyncio.create_task(_sync_signalwire_voicemails())
asyncio.create_task(_poll_imap_emails())
restored = _load_checkpoint()
if not restored:
asyncio.create_task(_pregenerate_backgrounds())
@@ -3418,6 +3427,196 @@ async def delete_voicemail(vm_id: str):
return {"status": "deleted"}
# --- Listener Emails ---
EMAILS_META = Path(__file__).parent.parent / "data" / "emails.json"
@dataclass
class ListenerEmail:
id: str
sender: str
subject: str
body: str
timestamp: float
read_on_air: bool = False
_listener_emails: list[ListenerEmail] = []
def _load_emails():
global _listener_emails
if EMAILS_META.exists():
try:
with open(EMAILS_META) as f:
data = json.load(f)
_listener_emails = [
ListenerEmail(
id=e["id"], sender=e["sender"], subject=e["subject"],
body=e["body"], timestamp=e["timestamp"],
read_on_air=e.get("read_on_air", False),
)
for e in data.get("emails", [])
]
print(f"[Email] Loaded {len(_listener_emails)} emails")
except Exception as e:
print(f"[Email] Failed to load: {e}")
_listener_emails = []
def _save_emails():
try:
EMAILS_META.parent.mkdir(parents=True, exist_ok=True)
data = {
"emails": [
{
"id": e.id, "sender": e.sender, "subject": e.subject,
"body": e.body, "timestamp": e.timestamp,
"read_on_air": e.read_on_air,
}
for e in _listener_emails
],
}
with open(EMAILS_META, "w") as f:
json.dump(data, f, indent=2)
except Exception as exc:
print(f"[Email] Failed to save: {exc}")
async def _poll_imap_emails():
"""Background task: poll IMAP every 30s for new listener emails"""
import imaplib
import email as email_lib
from email.header import decode_header
host = settings.submissions_imap_host
user = settings.submissions_imap_user
passwd = settings.submissions_imap_pass
if not host or not user or not passwd:
print("[Email] IMAP not configured, skipping email polling")
return
while True:
try:
mail = imaplib.IMAP4_SSL(host, 993)
mail.login(user, passwd)
mail.select("INBOX")
_, msg_nums = mail.search(None, "UNSEEN")
if msg_nums[0]:
for num in msg_nums[0].split():
_, msg_data = mail.fetch(num, "(RFC822)")
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
# Decode sender
from_raw = msg.get("From", "Unknown")
# Decode subject
subj_raw = msg.get("Subject", "(no subject)")
decoded_parts = decode_header(subj_raw)
subject = ""
for part, charset in decoded_parts:
if isinstance(part, bytes):
subject += part.decode(charset or "utf-8", errors="replace")
else:
subject += part
# Extract plain text body
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
break
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
body = payload.decode(charset, errors="replace")
body = body.strip()
if not body:
continue
# Parse timestamp from email Date header
from email.utils import parsedate_to_datetime
try:
ts = parsedate_to_datetime(msg.get("Date", "")).timestamp()
except Exception:
ts = time.time()
em = ListenerEmail(
id=str(uuid.uuid4())[:8],
sender=from_raw,
subject=subject,
body=body,
timestamp=ts,
)
_listener_emails.append(em)
print(f"[Email] New email from {from_raw}: {subject[:50]}")
# Mark as SEEN (already done by fetch with UNSEEN filter)
mail.store(num, "+FLAGS", "\\Seen")
_save_emails()
mail.logout()
except Exception as exc:
print(f"[Email] IMAP poll error: {exc}")
await asyncio.sleep(30)
@app.get("/api/emails")
async def list_emails():
return [
{
"id": e.id, "sender": e.sender, "subject": e.subject,
"body": e.body, "timestamp": e.timestamp,
"read_on_air": e.read_on_air,
}
for e in sorted(_listener_emails, key=lambda e: e.timestamp, reverse=True)
]
@app.post("/api/email/{email_id}/play-on-air")
async def play_email_on_air(email_id: str):
em = next((e for e in _listener_emails if e.id == email_id), None)
if not em:
raise HTTPException(status_code=404, detail="Email not found")
# Extract display name, fall back to just "a listener"
sender_name = em.sender.split("<")[0].strip().strip('"') if "<" in em.sender else "a listener"
intro = f"This email is from {sender_name}. Subject: {em.subject}."
full_text = f"{intro}\n\n{em.body}"
async def _generate_and_play():
try:
audio_bytes = await generate_speech(full_text, "Alex", phone_quality="none", apply_filter=False)
audio_service.play_caller_audio(audio_bytes, 24000)
except Exception as exc:
print(f"[Email] TTS playback error: {exc}")
asyncio.create_task(_generate_and_play())
em.read_on_air = True
_save_emails()
return {"status": "playing"}
@app.delete("/api/email/{email_id}")
async def delete_email(email_id: str):
em = next((e for e in _listener_emails if e.id == email_id), None)
if not em:
raise HTTPException(status_code=404, detail="Email not found")
_listener_emails.remove(em)
_save_emails()
return {"status": "deleted"}
async def _signalwire_end_call(call_sid: str):
"""End a phone call via SignalWire REST API"""
if not call_sid or not settings.signalwire_space:

View File

@@ -761,3 +761,13 @@ section h2 {
.vm-btn.save:hover { background: #2a5db0; }
.vm-btn.delete { background: var(--accent-red); color: white; }
.vm-btn.delete:hover { background: #e03030; }
/* Listener Emails */
.email-item { display: flex; flex-direction: column; gap: 0.25rem; padding: 0.5rem; border-bottom: 1px solid rgba(232, 121, 29, 0.08); }
.email-item:last-child { border-bottom: none; }
.email-item.vm-unlistened { background: rgba(232, 121, 29, 0.06); }
.email-header { display: flex; justify-content: space-between; align-items: center; }
.email-sender { color: var(--accent); font-size: 0.85rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-subject { font-size: 0.85rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-preview { font-size: 0.8rem; color: var(--text-muted); line-height: 1.3; }
.email-item .vm-actions { margin-top: 0.25rem; }

View File

@@ -73,6 +73,14 @@
</div>
</section>
<!-- Listener Emails -->
<section class="voicemail-section">
<h2>Emails <span id="email-badge" class="voicemail-badge hidden">0</span></h2>
<div id="email-list" class="voicemail-list" style="max-height:300px">
<div class="queue-empty">No emails</div>
</div>
</section>
<!-- Chat -->
<section class="chat-section">
<div id="chat" class="chat-log"></div>

View File

@@ -62,6 +62,8 @@ document.addEventListener('DOMContentLoaded', async () => {
initEventListeners();
loadVoicemails();
setInterval(loadVoicemails, 30000);
loadEmails();
setInterval(loadEmails, 30000);
log('Ready. Configure audio devices in Settings, then click a caller to start.');
console.log('AI Radio Show ready');
} catch (err) {
@@ -1360,3 +1362,85 @@ async function deleteVoicemail(id) {
log('Failed to delete voicemail: ' + err.message);
}
}
// --- Listener Emails ---
async function loadEmails() {
try {
const res = await fetch('/api/emails');
const data = await res.json();
renderEmails(data);
} catch (err) {}
}
function renderEmails(emails) {
const list = document.getElementById('email-list');
const badge = document.getElementById('email-badge');
if (!list) return;
const unread = emails.filter(e => !e.read_on_air).length;
if (badge) {
badge.textContent = unread;
badge.classList.toggle('hidden', unread === 0);
}
if (emails.length === 0) {
list.innerHTML = '<div class="queue-empty">No emails</div>';
return;
}
list.innerHTML = emails.map(e => {
const date = new Date(e.timestamp * 1000);
const timeStr = date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const preview = e.body.length > 120 ? e.body.substring(0, 120) + '…' : e.body;
const unreadCls = e.read_on_air ? '' : ' vm-unlistened';
const senderName = e.sender.replace(/<.*>/, '').trim() || e.sender;
return `<div class="email-item${unreadCls}" data-id="${e.id}">
<div class="email-header">
<span class="email-sender">${escapeHtml(senderName)}</span>
<span class="vm-time">${timeStr}</span>
</div>
<div class="email-subject">${escapeHtml(e.subject)}</div>
<div class="email-preview">${escapeHtml(preview)}</div>
<div class="vm-actions">
<button class="vm-btn listen" onclick="viewEmail('${e.id}')">View</button>
<button class="vm-btn on-air" onclick="playEmailOnAir('${e.id}')">On Air (TTS)</button>
<button class="vm-btn delete" onclick="deleteEmail('${e.id}')">Del</button>
</div>
</div>`;
}).join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function viewEmail(id) {
fetch('/api/emails').then(r => r.json()).then(emails => {
const em = emails.find(e => e.id === id);
if (!em) return;
alert(`From: ${em.sender}\nSubject: ${em.subject}\n\n${em.body}`);
});
}
async function playEmailOnAir(id) {
try {
await safeFetch(`/api/email/${id}/play-on-air`, { method: 'POST' });
log('Reading email on air (TTS)');
loadEmails();
} catch (err) {
log('Failed to play email: ' + err.message);
}
}
async function deleteEmail(id) {
if (!confirm('Delete this email?')) return;
try {
await safeFetch(`/api/email/${id}`, { method: 'DELETE' });
loadEmails();
} catch (err) {
log('Failed to delete email: ' + err.message);
}
}

View File

@@ -110,7 +110,7 @@
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
</footer>

View File

@@ -146,6 +146,14 @@
<div class="hiw-step">
<div class="hiw-step-number">6</div>
<div class="hiw-step-content">
<h3>Listener Emails</h3>
<p>Listeners can send emails to <a href="mailto:submissions@lukeattheroost.com" style="color:var(--accent)">submissions@lukeattheroost.com</a> and have them read on the show. A background poller checks for new messages every 30 seconds — they show up in the control room as soon as they arrive. Luke can read them himself on the mic, or hit a button to have an AI voice read them aloud on the caller channel. It's like a call-in show meets a letters segment — listeners who can't call in can still be part of the conversation.</p>
</div>
</div>
<div class="hiw-step">
<div class="hiw-step-number">7</div>
<div class="hiw-step-content">
<h3>The Control Room</h3>
<p>The entire show runs through a custom-built control panel. Luke manages callers, plays music and sound effects, runs ads, monitors the call queue, and controls everything from one screen. Audio is routed across multiple channels simultaneously — caller voices, music, sound effects, and live phone audio all on separate tracks. The website shows a live on-air indicator so listeners know when to call in.</p>
@@ -203,6 +211,12 @@
</div>
<span>Voicemails</span>
</div>
<div class="diagram-box diagram-accent">
<div class="diagram-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</div>
<span>Listener Emails</span>
</div>
</div>
<div class="diagram-arrow">&#8595;</div>
<!-- Row 2: Control Room -->
@@ -396,7 +410,7 @@
<div class="hiw-steps">
<div class="hiw-step">
<div class="hiw-step-number">7</div>
<div class="hiw-step-number">8</div>
<div class="hiw-step-content">
<h3>Multi-Stem Recording</h3>
<p>During every show, the system records five separate audio stems simultaneously: host microphone, AI caller voices, music, sound effects, and ads. Each stem is captured as an independent WAV file with sample-accurate alignment. This gives full control over the final mix — like having a recording studio's multitrack session, not just a flat recording.</p>
@@ -422,7 +436,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">8</div>
<div class="hiw-step-number">9</div>
<div class="hiw-step-content">
<h3>Post-Production Pipeline</h3>
<p>Once the show ends, a 15-step automated pipeline processes the raw stems into a broadcast-ready episode. Ads and sound effects are hard-limited to prevent clipping. The host mic gets a high-pass filter, de-essing, and breath reduction. Voice tracks are compressed — the host gets aggressive spoken-word compression for consistent levels, callers get telephone EQ to sound like real phone calls. All stems are level-matched, music is ducked under dialog and muted during ads, then everything is mixed to stereo with panning and width. A bus compressor glues the final mix together before silence trimming, fades, and EBU R128 loudness normalization.</p>
@@ -448,7 +462,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">9</div>
<div class="hiw-step-number">10</div>
<div class="hiw-step-content">
<h3>Automated Publishing</h3>
<p>A single command takes a finished episode and handles everything: the audio is transcribed using MLX Whisper running on Apple Silicon GPU to generate full-text transcripts, then an LLM analyzes the transcript to write the episode title, description, and chapter markers with timestamps. The episode is uploaded to the podcast server, chapters and transcripts are attached to the metadata, and all media is synced to a global CDN so listeners everywhere get fast downloads.</p>
@@ -474,7 +488,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">10</div>
<div class="hiw-step-number">11</div>
<div class="hiw-step-content">
<h3>Automated Social Clips</h3>
<p>No manual editing, no scheduling tools. After each episode, an LLM reads the full transcript and picks the best moments — funny exchanges, wild confessions, heated debates. Each clip is automatically extracted, transcribed with word-level timestamps, then polished by a second LLM pass that fixes punctuation, capitalization, and misheard words while preserving timing. The clips are rendered as vertical video with speaker-labeled captions and the show's branding. A third LLM writes platform-specific descriptions and hashtags. Then clips are uploaded directly to YouTube Shorts and Bluesky via their APIs, and pushed to Instagram Reels, Facebook, and Mastodon — six platforms, zero manual work.</p>
@@ -500,7 +514,7 @@
</div>
<div class="hiw-step">
<div class="hiw-step-number">11</div>
<div class="hiw-step-number">12</div>
<div class="hiw-step-content">
<h3>Global Distribution</h3>
<p>Episodes are served through a CDN edge network for fast, reliable playback worldwide. The RSS feed is automatically updated and picked up by Spotify, Apple Podcasts, YouTube, and every other podcast app. The website pulls the live feed to show episodes with embedded playback, full transcripts, and chapter navigation — all served through Cloudflare with edge caching. From recording to available on every platform, the whole pipeline is automated end-to-end.</p>
@@ -610,7 +624,7 @@
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
</footer>

View File

@@ -229,7 +229,7 @@
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
</footer>

View File

@@ -19,4 +19,4 @@ Luke at the Roost is a late-night call-in radio show hosted by Luke MacNeil, bro
- Host: Luke MacNeil
- Genre: Comedy
- Format: Live call-in radio show
- Contact: luke@macneilmediagroup.com
- Contact: luke@lukeattheroost.com

View File

@@ -71,14 +71,14 @@
<p>Our content is rated explicit and is not directed at children under 13. We do not knowingly collect personal information from children.</p>
<h2>Your Rights</h2>
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a>.</p>
<p>If you have questions about your data or want to request removal of your voice from a published episode, contact us at <a href="mailto:luke@lukeattheroost.com" style="color: var(--accent, #d4a44a);">luke@lukeattheroost.com</a>.</p>
<h2>Changes</h2>
<p>We may update this policy from time to time. Changes will be posted on this page with an updated date.</p>
<h2>Contact</h2>
<p>MacNeil Media Group<br>
Email: <a href="mailto:luke@macneilmediagroup.com" style="color: var(--accent, #d4a44a);">luke@macneilmediagroup.com</a></p>
Email: <a href="mailto:luke@lukeattheroost.com" style="color: var(--accent, #d4a44a);">luke@lukeattheroost.com</a></p>
</div>
</section>
@@ -107,7 +107,7 @@
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
</footer>

View File

@@ -79,7 +79,7 @@
</div>
</div>
<p class="footer-contact"><a href="https://ko-fi.com/lukemacneil" target="_blank" rel="noopener">Support the Show</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
<p class="footer-contact">Sales &amp; Collaboration: <a href="mailto:luke@lukeattheroost.com">luke@lukeattheroost.com</a></p>
<p>&copy; 2026 Luke at the Roost &middot; <a href="/privacy">Privacy Policy</a> &middot; <a href="https://monitoring.macneilmediagroup.com/status/lukeattheroost" target="_blank" rel="noopener">System Status</a></p>
</footer>