Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .ai-sessions/session-20260730-1145-meetup-dispatch-workflow.md
Original file line number Diff line number Diff line change
@@ -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`.
65 changes: 55 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
```
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/meetup_dispatch/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# ABOUTME: Package for the monthly meetup dispatch workflow.
# Posts meetup announcements to Discord channels and creates the scheduled event.
149 changes: 149 additions & 0 deletions src/meetup_dispatch/activities.py
Original file line number Diff line number Diff line change
@@ -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/<guild_id>/<event_id>.

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}"
49 changes: 49 additions & 0 deletions src/meetup_dispatch/config.py
Original file line number Diff line number Diff line change
@@ -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),
)
63 changes: 63 additions & 0 deletions src/meetup_dispatch/models.py
Original file line number Diff line number Diff line change
@@ -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)
Loading