diff --git a/.ai-sessions/session-20260730-1145-meetup-dispatch-workflow.md b/.ai-sessions/session-20260730-1145-meetup-dispatch-workflow.md new file mode 100644 index 0000000..7528c44 --- /dev/null +++ b/.ai-sessions/session-20260730-1145-meetup-dispatch-workflow.md @@ -0,0 +1,27 @@ +# Session Summary: Rename to dispatch and Add the MonthlyMeetupDispatch Workflow + +**Date**: 2026-07-30 +**Duration**: ~45 minutes (dispatch-repo portion of a longer meetup automation session) +**Conversation Turns**: ~10 in this repo +**Model**: claude-fable-5 + +## Key Actions + +- Renamed the GitHub repo from `pretix-discord-middleware` to `pytexas/dispatch`; moved the local checkout and updated the origin remote. +- Added a new `src/meetup_dispatch/` package: models (links-only input per Temporal's 2MB payload cap), config (webhook URLs required, bot token optional), activities (marketing webhook, organizers webhook, Discord scheduled event), and the `MonthlyMeetupDispatch` workflow. +- Discord event is created as an external event with location text "PyTexas Stage" (never stage-linked; stage audio bug). Missing bot token raises a non-retryable ApplicationError that the workflow catches, posting both messages with a TBD event link and a result note. +- Registered the new workflow and activities in the existing worker; added explicit hatch wheel packages for the second package. +- Wrote `tests/test_meetup_dispatch.py` first (TDD): four workflow tests with call-recording mock activities plus three formatting tests. All 7 pass; mypy strict clean on new files. +- Updated README: repo renamed, two-workflow framing, meetup dispatch section with CLI start example, env var tables. +- Left pre-existing mypy failures in `tests/test_pretix.py` untouched (exist on main); flagged to Mason instead of fixing in this PR. + +## Prompt Inventory + +| Prompt/Command | Action Taken | Outcome | +|---|---|---| +| Rename to pytexas/dispatch and scaffold the workflow | gh API rename, local remote update, package scaffold with tests-first | Done; PR to follow | +| Where is the deployment model? | Traced: service compose file lives here; infrastructure repo's ansible clones the repo and composes it up via include | Documented for Mason | + +## Lessons + +- `ActivityError.cause` is typed `BaseException | None`; under strict mypy, narrow with `isinstance(cause, ApplicationError)` before reading `.message`. diff --git a/README.md b/README.md index 45ea076..f36e96f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# pretix-discord +# dispatch -Middleware that listens for [pretix](https://pretix.eu) webhook notifications and posts formatted order summaries to a Discord channel via webhook. +PyTexas's communications dispatch service, powered by [Temporal](https://temporal.io). +It currently runs two workflows: + +1. **Pretix orders**: listens for [pretix](https://pretix.eu) webhook notifications and posts formatted order summaries to a Discord channel. +2. **Monthly meetup dispatch**: announces a booked meetup across channels; creates the Discord scheduled event and posts the asset handoff to the marketing channel and a setup summary to the organizers channel. + Started from the CLI by the meetup repo's `/meetup-update` automation. + +## Pretix Orders When a new order is placed in pretix, this service: @@ -41,10 +48,39 @@ src/pretix_discord/ ├── main.py # FastAPI/uvicorn entrypoint ├── models.py # All dataclasses (orders, embeds, inputs) ├── pretix_activities.py # Fetch and parse pretix orders -├── worker.py # Temporal worker entrypoint +├── worker.py # Temporal worker entrypoint (registers both workflows) └── workflow.py # Workflow: fetch -> format -> send + +src/meetup_dispatch/ +├── activities.py # Webhook posts, Discord event creation, message formatting +├── config.py # Meetup settings loaded from environment variables +├── models.py # Dispatch input, activity inputs, result +└── workflow.py # Workflow: create event -> post marketing -> post organizers ``` +## Monthly Meetup Dispatch + +The meetup workflow is started manually (by the meetup repo's automation) once the month's assets exist: + +```bash +temporal workflow start \ + --task-queue pretix-discord \ + --type MonthlyMeetupDispatch \ + --workflow-id "meetup-dispatch-2026-08" \ + --input '{"month": "August 2026", + "date_display": "Tuesday, August 4 at 8:00 PM Central", + "start_time_utc": "2026-08-05T01:00:00Z", + "end_time_utc": "2026-08-05T02:00:00Z", + "talk_title": "...", "speaker_name": "...", "promo_blurb": "...", + "canva_link": "...", "card_png_link": "...", "run_of_show_link": "...", + "attendance_form_link": "...", "questions_form_link": "...", + "still_manual": "Canva page title rename, meetup.com event"}' +``` + +Inputs are links and strings only, never file bytes: Temporal caps a payload at 2MB, so the card image is passed as a Drive link. +The Discord event is created as an external event with the location text "PyTexas Stage" (stage-linked events have an audio quality bug). +If `PYTEXAS_DISCORD_BOT_TOKEN` is not configured, the event is skipped with a note and both webhook posts still go out with a TBD event link. + ## Prerequisites - A server with [Docker](https://docs.docker.com/engine/install/) installed @@ -62,11 +98,20 @@ cp .env.example .env ### Required variables -| Variable | Description | -|-----------------------|--------------------------------------------------| -| `PRETIX_API_TOKEN` | API token from your pretix organizer account | -| `DISCORD_WEBHOOK_URL` | Full Discord webhook URL for the target channel | -| `DOMAIN` | Public domain for this service (used by Caddy for TLS) | +| Variable | Description | +|------------------------------|--------------------------------------------------| +| `PRETIX_API_TOKEN` | API token from your pretix organizer account | +| `DISCORD_WEBHOOK_URL` | Full Discord webhook URL for the pretix order channel | +| `DOMAIN` | Public domain for this service (used by Caddy for TLS) | +| `PYTEXAS_MARKETING_WEBHOOK` | Webhook URL for the marketing channel (meetup dispatch) | +| `PYTEXAS_MEETUP_WEBHOOK` | Webhook URL for the meetup organizers channel (meetup dispatch) | + +### Optional meetup dispatch variables + +| Variable | Default | Description | +|------------------------------|-----------------------|--------------------------------------| +| `PYTEXAS_DISCORD_BOT_TOKEN` | unset | Bot token with Manage Events; event creation is skipped without it | +| `PYTEXAS_GUILD_ID` | `1012382914035597372` | PyTexas Discord guild ID | ### Optional variables @@ -90,8 +135,8 @@ curl -fsSL https://get.docker.com | sh ### 2. Clone and configure ```bash -git clone https://github.com/PyTexas/pretix-discord.git -cd pretix-discord +git clone https://github.com/pytexas/dispatch.git +cd dispatch cp .env.example .env # Edit .env with your values ``` diff --git a/pyproject.toml b/pyproject.toml index a38f488..26120ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.build.targets.wheel] +packages = ["src/pretix_discord", "src/meetup_dispatch"] + [project] name = "pretix-discord" version = "0.1.0" diff --git a/src/meetup_dispatch/__init__.py b/src/meetup_dispatch/__init__.py new file mode 100644 index 0000000..cb95dd4 --- /dev/null +++ b/src/meetup_dispatch/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Package for the monthly meetup dispatch workflow. +# Posts meetup announcements to Discord channels and creates the scheduled event. diff --git a/src/meetup_dispatch/activities.py b/src/meetup_dispatch/activities.py new file mode 100644 index 0000000..bd8f039 --- /dev/null +++ b/src/meetup_dispatch/activities.py @@ -0,0 +1,149 @@ +# ABOUTME: Activities and message formatting for the meetup dispatch workflow. +# Posts to the marketing and organizers webhooks and creates the Discord scheduled event. + +from __future__ import annotations + +import httpx +from temporalio import activity +from temporalio.exceptions import ApplicationError + +from meetup_dispatch.config import DISCORD_API_BASE, load_meetup_config +from meetup_dispatch.models import CreateEventInput, MeetupDispatchInput, PostWebhookInput + +# External event (entity_type 3) with location text, never a stage-linked event: +# stage-linked events have a bug where the stage audio quality is bad. +EVENT_ENTITY_TYPE_EXTERNAL = 3 +EVENT_PRIVACY_GUILD_ONLY = 2 +EVENT_LOCATION = "PyTexas Stage" + + +def format_marketing_message(inp: MeetupDispatchInput, discord_event_link: str) -> str: + """Format the marketing channel asset handoff message. + + Args: + inp: The dispatch input with the month's details and asset links. + discord_event_link: Link to the Discord event, or "TBD" if not created. + + Returns: + The message content for the marketing webhook. + """ + lines = [ + f"{inp.month} meetup assets are ready!", + f"* Date: {inp.date_display}", + f"* Talk: {inp.talk_title} - {inp.speaker_name}", + f"* Promo blurb: {inp.promo_blurb}", + f"* Card (Canva): {inp.canva_link}", + f"* Card image (PNG): {inp.card_png_link}", + ] + if inp.speaker_socials: + lines.append(f"* Speaker socials for tagging: {inp.speaker_socials}") + lines.extend( + [ + f"* Run of Show: {inp.run_of_show_link}", + f"* Attendance form: {inp.attendance_form_link}", + f"* Questions form: {inp.questions_form_link}", + f"* Meetup.com event: {inp.meetup_com_link}", + f"* Discord event: {discord_event_link}", + f"* RSVP: {inp.rsvp_link}", + ] + ) + return "\n".join(lines) + + +def format_organizers_message(inp: MeetupDispatchInput, discord_event_link: str) -> str: + """Format the organizers channel setup summary message. + + Args: + inp: The dispatch input with the month's details and asset links. + discord_event_link: Link to the Discord event, or "TBD" if not created. + + Returns: + The message content for the organizers webhook. + """ + lines = [ + f"{inp.month} meetup setup is done.", + f"* {inp.date_display}: {inp.talk_title} - {inp.speaker_name}", + f"* Run of Show: {inp.run_of_show_link}", + f"* Card (Canva): {inp.canva_link}", + f"* Discord event: {discord_event_link}", + f"* Website PR: {inp.website_pr_link}", + ] + if inp.still_manual: + lines.append(f"* Still manual: {inp.still_manual}") + return "\n".join(lines) + + +async def _post_webhook(url: str, inp: PostWebhookInput) -> None: + activity.logger.info("Posting to the %s webhook", inp.channel) + async with httpx.AsyncClient() as client: + response = await client.post(url, json={"content": inp.content}) + response.raise_for_status() + activity.logger.info("Posted to the %s webhook (HTTP %s)", inp.channel, response.status_code) + + +@activity.defn +async def post_marketing_webhook(inp: PostWebhookInput) -> None: + """POST the asset handoff message to the marketing channel webhook. + + Args: + inp: The message content and channel label. + + Raises: + httpx.HTTPStatusError: If Discord returns a non-2xx response. + """ + config = load_meetup_config() + await _post_webhook(config.marketing_webhook_url, inp) + + +@activity.defn +async def post_organizers_webhook(inp: PostWebhookInput) -> None: + """POST the setup summary message to the meetup organizers channel webhook. + + Args: + inp: The message content and channel label. + + Raises: + httpx.HTTPStatusError: If Discord returns a non-2xx response. + """ + config = load_meetup_config() + await _post_webhook(config.meetup_webhook_url, inp) + + +@activity.defn +async def create_discord_event(inp: CreateEventInput) -> str: + """Create the external Discord scheduled event for the meetup. + + Args: + inp: Event name, description, and start/end times in ISO 8601 UTC. + + Returns: + The event link, e.g. https://discord.com/events//. + + Raises: + ApplicationError: Non-retryable, if the bot token is not configured. + httpx.HTTPStatusError: If Discord returns a non-2xx response. + """ + config = load_meetup_config() + if config.discord_bot_token is None: + raise ApplicationError("PYTEXAS_DISCORD_BOT_TOKEN is not configured", non_retryable=True) + + activity.logger.info("Creating Discord event %s", inp.name) + async with httpx.AsyncClient() as client: + response = await client.post( + f"{DISCORD_API_BASE}/guilds/{config.guild_id}/scheduled-events", + headers={"Authorization": f"Bot {config.discord_bot_token}"}, + json={ + "name": inp.name, + "description": inp.description, + "scheduled_start_time": inp.start_time_utc, + "scheduled_end_time": inp.end_time_utc, + "privacy_level": EVENT_PRIVACY_GUILD_ONLY, + "entity_type": EVENT_ENTITY_TYPE_EXTERNAL, + "entity_metadata": {"location": EVENT_LOCATION}, + }, + ) + response.raise_for_status() + + event_id = response.json()["id"] + activity.logger.info("Created Discord event %s", event_id) + return f"https://discord.com/events/{config.guild_id}/{event_id}" diff --git a/src/meetup_dispatch/config.py b/src/meetup_dispatch/config.py new file mode 100644 index 0000000..e55745a --- /dev/null +++ b/src/meetup_dispatch/config.py @@ -0,0 +1,49 @@ +# ABOUTME: Configuration module for the meetup dispatch workflow. +# Loads and validates settings from environment variables. + +from __future__ import annotations + +import os +from dataclasses import dataclass + +PYTEXAS_GUILD_ID = "1012382914035597372" +DISCORD_API_BASE = "https://discord.com/api/v10" + + +@dataclass(frozen=True) +class MeetupSettings: + """Meetup dispatch settings loaded from environment variables.""" + + marketing_webhook_url: str + meetup_webhook_url: str + discord_bot_token: str | None + guild_id: str = PYTEXAS_GUILD_ID + + +def load_meetup_config() -> MeetupSettings: + """Load meetup dispatch configuration from environment variables. + + The bot token is optional; without it the Discord event activity reports + itself as unconfigured instead of failing the whole dispatch. + + Returns: + Validated settings for the meetup dispatch activities. + + Raises: + ValueError: If ``PYTEXAS_MARKETING_WEBHOOK`` or ``PYTEXAS_MEETUP_WEBHOOK`` + is missing. + """ + marketing_webhook_url = os.environ.get("PYTEXAS_MARKETING_WEBHOOK") + if not marketing_webhook_url: + raise ValueError("PYTEXAS_MARKETING_WEBHOOK environment variable is required") + + meetup_webhook_url = os.environ.get("PYTEXAS_MEETUP_WEBHOOK") + if not meetup_webhook_url: + raise ValueError("PYTEXAS_MEETUP_WEBHOOK environment variable is required") + + return MeetupSettings( + marketing_webhook_url=marketing_webhook_url, + meetup_webhook_url=meetup_webhook_url, + discord_bot_token=os.environ.get("PYTEXAS_DISCORD_BOT_TOKEN") or None, + guild_id=os.environ.get("PYTEXAS_GUILD_ID", PYTEXAS_GUILD_ID), + ) diff --git a/src/meetup_dispatch/models.py b/src/meetup_dispatch/models.py new file mode 100644 index 0000000..63187cc --- /dev/null +++ b/src/meetup_dispatch/models.py @@ -0,0 +1,63 @@ +# ABOUTME: Data models for the meetup dispatch workflow. +# Contains all dataclasses used across activities and the workflow. + +from __future__ import annotations + +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class MeetupDispatchInput: + """Input for the MonthlyMeetupDispatch workflow. + + Links only, never file bytes: Temporal caps a payload at 2MB, so the card + image is passed as a Drive link (claim check) rather than inline. + """ + + month: str + date_display: str + start_time_utc: str + end_time_utc: str + talk_title: str + speaker_name: str + promo_blurb: str + canva_link: str + card_png_link: str + run_of_show_link: str + attendance_form_link: str + questions_form_link: str + speaker_socials: str = "" + meetup_com_link: str = "TBD" + website_pr_link: str = "TBD" + still_manual: str = "" + rsvp_link: str = "https://pytexas.org/meetup/join" + + +@dataclass(frozen=True) +class PostWebhookInput: + """Input for the webhook posting activities.""" + + content: str + channel: str + + +@dataclass(frozen=True) +class CreateEventInput: + """Input for the create_discord_event activity.""" + + name: str + description: str + start_time_utc: str + end_time_utc: str + + +@dataclass(frozen=True) +class DispatchResult: + """Result of a dispatch run. + + The Discord event link is "TBD" when event creation was skipped; notes + explain anything that did not complete. + """ + + discord_event_link: str + notes: list[str] = field(default_factory=list) diff --git a/src/meetup_dispatch/workflow.py b/src/meetup_dispatch/workflow.py new file mode 100644 index 0000000..675800d --- /dev/null +++ b/src/meetup_dispatch/workflow.py @@ -0,0 +1,84 @@ +# ABOUTME: Temporal workflow that dispatches monthly meetup announcements. +# Creates the Discord event, then posts the marketing and organizers webhooks. + +from __future__ import annotations + +from datetime import timedelta + +from temporalio import workflow +from temporalio.common import RetryPolicy +from temporalio.exceptions import ActivityError, ApplicationError + +with workflow.unsafe.imports_passed_through(): + from meetup_dispatch.activities import ( + create_discord_event, + format_marketing_message, + format_organizers_message, + post_marketing_webhook, + post_organizers_webhook, + ) + from meetup_dispatch.models import ( + CreateEventInput, + DispatchResult, + MeetupDispatchInput, + PostWebhookInput, + ) + +RETRY_POLICY = RetryPolicy(maximum_attempts=5) + + +@workflow.defn +class MonthlyMeetupDispatch: + """Workflow that announces a booked meetup across all channels. + + The Discord event is created first so both messages can link it; if event + creation is unconfigured or exhausts its retries, the messages still go + out with a TBD link and the result carries a note. + """ + + @workflow.run + async def run(self, inp: MeetupDispatchInput) -> DispatchResult: + workflow.logger.info("Dispatching %s meetup announcements", inp.month) + notes: list[str] = [] + + try: + discord_event_link = await workflow.execute_activity( + create_discord_event, + CreateEventInput( + name=f"PyTexas Virtual Meetup - {inp.month}", + description=f"{inp.talk_title} - {inp.speaker_name}\n\n{inp.promo_blurb}", + start_time_utc=inp.start_time_utc, + end_time_utc=inp.end_time_utc, + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RETRY_POLICY, + ) + except ActivityError as err: + cause = err.cause + detail = cause.message if isinstance(cause, ApplicationError) else str(err) + workflow.logger.warning("Discord event creation skipped: %s", detail) + discord_event_link = "TBD" + notes.append(f"Discord event not created: {detail}") + + await workflow.execute_activity( + post_marketing_webhook, + PostWebhookInput( + content=format_marketing_message(inp, discord_event_link), + channel="marketing", + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RETRY_POLICY, + ) + + await workflow.execute_activity( + post_organizers_webhook, + PostWebhookInput( + content=format_organizers_message(inp, discord_event_link), + channel="organizers", + ), + start_to_close_timeout=timedelta(seconds=30), + retry_policy=RETRY_POLICY, + ) + + workflow.logger.info("%s meetup announcements dispatched", inp.month) + return DispatchResult(discord_event_link=discord_event_link, notes=notes) diff --git a/src/pretix_discord/worker.py b/src/pretix_discord/worker.py index acc38ea..a0ee77e 100644 --- a/src/pretix_discord/worker.py +++ b/src/pretix_discord/worker.py @@ -9,12 +9,17 @@ from temporalio.client import Client from temporalio.worker import Worker +from meetup_dispatch.activities import ( + create_discord_event, + post_marketing_webhook, + post_organizers_webhook, +) +from meetup_dispatch.workflow import MonthlyMeetupDispatch from pretix_discord.config import load_config from pretix_discord.discord_activities import send_discord_webhook from pretix_discord.pretix_activities import fetch_pretix_order from pretix_discord.workflow import PretixWebhookWorkflow - logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", @@ -37,10 +42,13 @@ async def main() -> None: worker = Worker( client, task_queue=config.temporal_task_queue, - workflows=[PretixWebhookWorkflow], + workflows=[PretixWebhookWorkflow, MonthlyMeetupDispatch], activities=[ fetch_pretix_order, send_discord_webhook, + post_marketing_webhook, + post_organizers_webhook, + create_discord_event, ], ) await worker.run() diff --git a/tests/test_meetup_dispatch.py b/tests/test_meetup_dispatch.py new file mode 100644 index 0000000..cabc2db --- /dev/null +++ b/tests/test_meetup_dispatch.py @@ -0,0 +1,162 @@ +# ABOUTME: Tests for the MonthlyMeetupDispatch Temporal workflow. +# Validates event creation, both webhook posts, and the token-missing skip path. + +from __future__ import annotations + +import uuid +from collections.abc import Callable +from typing import Any + +import pytest +from temporalio import activity +from temporalio.exceptions import ApplicationError +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +from meetup_dispatch.activities import format_marketing_message, format_organizers_message +from meetup_dispatch.models import ( + CreateEventInput, + DispatchResult, + MeetupDispatchInput, + PostWebhookInput, +) +from meetup_dispatch.workflow import MonthlyMeetupDispatch + +SAMPLE_INPUT = MeetupDispatchInput( + month="August 2026", + date_display="Tuesday, August 4 at 8:00 PM Central", + start_time_utc="2026-08-05T01:00:00Z", + end_time_utc="2026-08-05T02:00:00Z", + talk_title="Automating the Monthly Meetup", + speaker_name="Mason Egger", + promo_blurb="A talk about automating everything.", + canva_link="https://www.canva.com/d/deck", + card_png_link="https://drive.google.com/file/d/card", + run_of_show_link="https://docs.google.com/document/d/ros", + attendance_form_link="https://forms.gle/attendance", + questions_form_link="https://forms.gle/questions", + still_manual="Canva page title rename, meetup.com event", +) + +EVENT_LINK = "https://discord.com/events/1012382914035597372/999" + + +# Mock activities that record calls for verification +marketing_calls: list[PostWebhookInput] = [] +organizers_calls: list[PostWebhookInput] = [] +event_calls: list[CreateEventInput] = [] + + +@activity.defn(name="post_marketing_webhook") +async def mock_post_marketing_webhook(inp: PostWebhookInput) -> None: + marketing_calls.append(inp) + + +@activity.defn(name="post_organizers_webhook") +async def mock_post_organizers_webhook(inp: PostWebhookInput) -> None: + organizers_calls.append(inp) + + +@activity.defn(name="create_discord_event") +async def mock_create_discord_event(inp: CreateEventInput) -> str: + event_calls.append(inp) + return EVENT_LINK + + +@activity.defn(name="create_discord_event") +async def mock_create_discord_event_unconfigured(inp: CreateEventInput) -> str: + event_calls.append(inp) + raise ApplicationError("PYTEXAS_DISCORD_BOT_TOKEN is not configured", non_retryable=True) + + +@pytest.fixture(autouse=True) +def _clear_call_logs() -> None: + marketing_calls.clear() + organizers_calls.clear() + event_calls.clear() + + +MOCK_ACTIVITIES: list[Callable[..., Any]] = [ + mock_post_marketing_webhook, + mock_post_organizers_webhook, + mock_create_discord_event, +] + + +async def run_dispatch(activities: list[Callable[..., Any]]) -> DispatchResult: + task_queue = str(uuid.uuid4()) + async with await WorkflowEnvironment.start_local() as env: + async with Worker( + env.client, + task_queue=task_queue, + workflows=[MonthlyMeetupDispatch], + activities=activities, + ): + return await env.client.execute_workflow( + MonthlyMeetupDispatch.run, + SAMPLE_INPUT, + id=str(uuid.uuid4()), + task_queue=task_queue, + ) + + +class TestMonthlyMeetupDispatch: + async def test_creates_event_then_posts_both_webhooks(self) -> None: + result = await run_dispatch(MOCK_ACTIVITIES) + + assert len(event_calls) == 1 + assert event_calls[0].name == "PyTexas Virtual Meetup - August 2026" + assert len(marketing_calls) == 1 + assert len(organizers_calls) == 1 + assert result.discord_event_link == EVENT_LINK + assert result.notes == [] + + async def test_marketing_message_contains_assets_and_event_link(self) -> None: + await run_dispatch(MOCK_ACTIVITIES) + + content = marketing_calls[0].content + assert "Automating the Monthly Meetup - Mason Egger" in content + assert SAMPLE_INPUT.canva_link in content + assert SAMPLE_INPUT.card_png_link in content + assert EVENT_LINK in content + assert SAMPLE_INPUT.rsvp_link in content + + async def test_organizers_message_contains_summary(self) -> None: + await run_dispatch(MOCK_ACTIVITIES) + + content = organizers_calls[0].content + assert "August 2026 meetup setup is done." in content + assert SAMPLE_INPUT.run_of_show_link in content + assert SAMPLE_INPUT.still_manual in content + + async def test_missing_bot_token_skips_event_but_still_posts(self) -> None: + result = await run_dispatch( + [ + mock_post_marketing_webhook, + mock_post_organizers_webhook, + mock_create_discord_event_unconfigured, + ] + ) + + assert result.discord_event_link == "TBD" + assert len(result.notes) == 1 + assert len(marketing_calls) == 1 + assert len(organizers_calls) == 1 + assert "TBD" in marketing_calls[0].content + + +class TestMessageFormatting: + def test_socials_line_omitted_when_empty(self) -> None: + message = format_marketing_message(SAMPLE_INPUT, discord_event_link=EVENT_LINK) + assert "Speaker socials" not in message + + def test_socials_line_present_when_set(self) -> None: + inp = MeetupDispatchInput( + **{**SAMPLE_INPUT.__dict__, "speaker_socials": "@mason@fosstodon.org"} + ) + message = format_marketing_message(inp, discord_event_link=EVENT_LINK) + assert "Speaker socials for tagging: @mason@fosstodon.org" in message + + def test_organizers_message_includes_website_pr(self) -> None: + message = format_organizers_message(SAMPLE_INPUT, discord_event_link=EVENT_LINK) + assert "Website PR: TBD" in message