Skip to content
Merged
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
71 changes: 71 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
cargo build # build
cargo test # run all tests
cargo test <test_name> # run single test
make check # cargo fmt + clippy -D warnings
make fmt # cargo fmt + clippy --fix
```

Tests use `test-log` and `tokio::test`. Integration tests in `tests/` require live Discord/API access; unit tests in `src/` use local fixtures from `res/tests/`.

## Architecture

One-shot async Rust binary (tokio). On each run it:

1. Scrapes new theater/arts events from the `agendalx.pt` REST API + HTML
2. Posts new events as Discord embeds into per-month threads in two channels (Teatro, Artes)
3. Processes reactions on existing posts: updates "save for later" pins and sends vote DMs
4. Backs up vote data to `vote_backups/YYYY_MM_DD.json`

### Module map

| Module | Purpose |
|---|---|
| `src/agenda_cultural/` | Fetch events from agendalx.pt; parse HTML descriptions; map to `Event` model |
| `src/discord/api.rs` | `DiscordAPI` wrapper around serenity: send embeds, manage threads, reactions, DMs |
| `src/discord/backup.rs` | Reads bot DMs to reconstruct `VoteRecord` structs and write backup JSON |
| `src/config/` | Load all config from env vars at startup |
| `src/api.rs` | Orchestration: filter unsent events, map events to month-threads |
| `src/main.rs` | Entry point: runs Teatro + Artes pipelines, then triggers vote backup |
| `src/tracing.rs` | Sets up stdout + optional Grafana Loki logging |

### Key data flow

`AgendaCulturalAPI::get_events_by_month` → `filter_new_events_by_thread` (dedupes against already-sent Discord embeds) → `send_new_events` (creates/reuses month threads, posts embeds, adds reaction emojis)

### Discord thread structure

Each channel (Teatro / Artes) has public threads named `"<Month PT> <Year>"` (e.g. `"Maio 2026"`). Each event is a single embed message inside the relevant month thread.

## Required Environment Variables

| Variable | Format | Notes |
|---|---|---|
| `DISCORD_TOKEN` | string | Bot token |
| `DISCORD_TEATRO_CHANNEL_ID` | integer | Channel ID |
| `DISCORD_ARTES_CHANNEL_ID` | integer | Channel ID |
| `VOTING_EMOJIS` | `name:ID;name:ID;...` | Exactly 5, worst→best |
| `VENUE_TICKET_SHOP_URLS` | `venue:url;venue:url;...` | Semicolon-separated |
| `TICKET_SHOP_ICON_URL` | URL | Icon shown on ticket link |
| `LOKI_URL` | `https://user:token@host` | Optional; skipped if missing or unreachable |
| `OTLP_ENDPOINT` | `http://host:4317` | Optional; gRPC OTLP endpoint (Tempo) |
| `GATHER_NEW_EVENTS` | bool | Default `true`; set `false` to only process reactions |

### Debug variables (all optional)

| Variable | Default | Effect |
|---|---|---|
| `DEBUG_CLEAR_CHANNEL` | `false` | Delete all messages in both channels |
| `DEBUG_EXIT_AFTER_CLEARING` | `false` | Exit immediately after clearing |
| `DEBUG_SKIP_SENDING` | `false` | Fetch events but don't post them |
| `DEBUG_SKIP_FEATURE_REACTIONS` | `false` | Skip reaction processing |
| `DEBUG_SKIP_ARTES` | `false` | Only run Teatro pipeline |
| `DEBUG_EVENT_LIMIT` | unset | Limit events fetched per category |

See `run-locally.sh` for a working local configuration example.
72 changes: 1 addition & 71 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,71 +1 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
cargo build # build
cargo test # run all tests
cargo test <test_name> # run single test
make check # cargo fmt + clippy -D warnings
make fmt # cargo fmt + clippy --fix
```

Tests use `test-log` and `tokio::test`. Integration tests in `tests/` require live Discord/API access; unit tests in `src/` use local fixtures from `res/tests/`.

## Architecture

One-shot async Rust binary (tokio). On each run it:

1. Scrapes new theater/arts events from the `agendalx.pt` REST API + HTML
2. Posts new events as Discord embeds into per-month threads in two channels (Teatro, Artes)
3. Processes reactions on existing posts: updates "save for later" pins and sends vote DMs
4. Backs up vote data to `vote_backups/YYYY_MM_DD.json`

### Module map

| Module | Purpose |
|---|---|
| `src/agenda_cultural/` | Fetch events from agendalx.pt; parse HTML descriptions; map to `Event` model |
| `src/discord/api.rs` | `DiscordAPI` wrapper around serenity: send embeds, manage threads, reactions, DMs |
| `src/discord/backup.rs` | Reads bot DMs to reconstruct `VoteRecord` structs and write backup JSON |
| `src/config/` | Load all config from env vars at startup |
| `src/api.rs` | Orchestration: filter unsent events, map events to month-threads |
| `src/main.rs` | Entry point: runs Teatro + Artes pipelines, then triggers vote backup |
| `src/tracing.rs` | Sets up stdout + optional Grafana Loki logging |

### Key data flow

`AgendaCulturalAPI::get_events_by_month` → `filter_new_events_by_thread` (dedupes against already-sent Discord embeds) → `send_new_events` (creates/reuses month threads, posts embeds, adds reaction emojis)

### Discord thread structure

Each channel (Teatro / Artes) has public threads named `"<Month PT> <Year>"` (e.g. `"Maio 2026"`). Each event is a single embed message inside the relevant month thread.

## Required Environment Variables

| Variable | Format | Notes |
|---|---|---|
| `DISCORD_TOKEN` | string | Bot token |
| `DISCORD_TEATRO_CHANNEL_ID` | integer | Channel ID |
| `DISCORD_ARTES_CHANNEL_ID` | integer | Channel ID |
| `VOTING_EMOJIS` | `name:ID;name:ID;...` | Exactly 5, worst→best |
| `VENUE_TICKET_SHOP_URLS` | `venue:url;venue:url;...` | Semicolon-separated |
| `TICKET_SHOP_ICON_URL` | URL | Icon shown on ticket link |
| `LOKI_URL` | `https://user:token@host` | Optional; skipped if missing or unreachable |
| `OTLP_ENDPOINT` | `http://host:4317` | Optional; gRPC OTLP endpoint (Tempo) |
| `GATHER_NEW_EVENTS` | bool | Default `true`; set `false` to only process reactions |

### Debug variables (all optional)

| Variable | Default | Effect |
|---|---|---|
| `DEBUG_CLEAR_CHANNEL` | `false` | Delete all messages in both channels |
| `DEBUG_EXIT_AFTER_CLEARING` | `false` | Exit immediately after clearing |
| `DEBUG_SKIP_SENDING` | `false` | Fetch events but don't post them |
| `DEBUG_SKIP_FEATURE_REACTIONS` | `false` | Skip reaction processing |
| `DEBUG_SKIP_ARTES` | `false` | Only run Teatro pipeline |
| `DEBUG_EVENT_LIMIT` | unset | Limit events fetched per category |

See `run-locally.sh` for a working local configuration example.
AGENTS.md
78 changes: 10 additions & 68 deletions src/agenda_cultural/api.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use super::{dto::EventResponse, model::Event};
use crate::agenda_cultural::dto::SingleEventResponse;
use crate::agenda_cultural::model::Category;
use chrono::{Datelike, NaiveDate, TimeDelta, Utc};
use futures::TryFutureExt;
Expand All @@ -14,11 +13,11 @@ use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::ops::Add;
use std::time::Duration;
use tracing::{debug, info, instrument, trace, warn};
use strum::Display;
use tracing::{debug, error, info, instrument, trace, warn};
use voca_rs::strip::strip_tags;

const AGENDA_EVENTS_URL: &str = "https://www.agendalx.pt/wp-json/agendalx/v1/events";
const AGENDA_PAGE_BY_ID_PATH: &str = "https://www.agendalx.pt/?p=";
const EVENT_TYPE: &str = "event";
const DATE_PRINT_FORMAT: &str = "%Y-%m-%d";

Expand All @@ -31,11 +30,6 @@ lazy_static! {
.build_with_total_retry_duration_and_max_retries(Duration::from_secs(30))
))
.build();
static ref EVENT_ID_SELECTOR: Selector = Selector::parse(&format!(
r#"link[rel="shortlink"][href^="{}"]"#,
AGENDA_PAGE_BY_ID_PATH
))
.unwrap();
static ref EVENT_DESCRIPTION_SELECTOR: Selector =
Selector::parse(".entry-container > :not(.event__extra-info):not(.section-title):not(.section-title--venue):not(.venue):not(.post__share)").unwrap();
}
Expand All @@ -47,7 +41,7 @@ impl AgendaCulturalAPI {
Returns events in ascending order
* amount_per_page: if not specified, will retrieve everything
*/
#[tracing::instrument]
#[instrument]
pub async fn get_events_by_month(
category: &Category,
amount_per_page: Option<i32>,
Expand All @@ -62,7 +56,11 @@ impl AgendaCulturalAPI {
}

let category: &'static str = category.into();
let parsed_response = Self::get_events_by_category(amount_per_page, category).await?;
let parsed_response = Self::get_events_by_category(amount_per_page, category)
.await
.inspect_err(|err| {
error!("Failed to get events by category '{}': {}", category, err)
})?;

info!("Fetched {} events", parsed_response.len());

Expand Down Expand Up @@ -149,63 +147,7 @@ impl AgendaCulturalAPI {
response.to_model(description).await
}

/**
Returns the specified event
*/
#[tracing::instrument]
pub async fn get_event_by_id(event_id: u32) -> Result<Event, APIError> {
let json_response = REST_CLIENT
.get(format!("{}/{}", AGENDA_EVENTS_URL, event_id))
.send()
.await
.map_err(APIError::ErrorSending)?
.error_for_status()
.map_err(APIError::ResponseError)?
.text()
.map_err(APIError::InvalidResponse)
.await?;
trace!("Json response: {json_response}");

let parsed_response = serde_json::from_str::<SingleEventResponse>(&json_response)
.map_err(APIError::ParseError)?;

Ok(Self::convert_response_to_model(&parsed_response.event).await)
}

/**
Returns the specified event
*/
#[tracing::instrument]
pub async fn get_event_by_public_url(url: &str) -> Result<Event, APIError> {
let full_page: String = REST_CLIENT
.get(url)
.send()
.map_err(APIError::ErrorSending)
.await?
.error_for_status()
.map_err(APIError::ResponseError)?
.text()
.map_err(APIError::InvalidResponse)
.await?;
let page_html = Html::parse_fragment(&full_page);
let id_element = page_html
.select(&EVENT_ID_SELECTOR)
.next()
.ok_or_else(|| {
APIError::FailedParsingHtml("Could not find ID element in page!".to_string())
})?
.attr("href")
.and_then(|href| href.strip_prefix(AGENDA_PAGE_BY_ID_PATH))
.ok_or_else(|| {
APIError::FailedParsingHtml("Could not find ID in the element!".to_string())
})?
.parse()
.map_err(|_| APIError::FailedParsingHtml("Fetched ID is not valid!".to_string()))?;

Self::get_event_by_id(id_element).await
}

#[tracing::instrument]
#[instrument]
async fn get_events_by_category(
amount_per_page: Option<i32>,
category: &str,
Expand Down Expand Up @@ -377,7 +319,7 @@ mod tests {
}
}

#[derive(Debug)]
#[derive(Debug, Display)]
pub enum APIError {
ErrorSending(reqwest_middleware::Error),
ResponseError(reqwest::Error),
Expand Down
7 changes: 0 additions & 7 deletions src/agenda_cultural/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use tracing::warn;

#[derive(Debug, Deserialize)]
pub struct SingleEventResponse {
#[serde(rename = "data")]
pub event: EventResponse,
}

// Note: some String fields need the custom deserializer due to being optional
#[derive(Debug, Deserialize)]
pub struct EventResponse {
Expand All @@ -39,7 +33,6 @@ lazy_static! {
}

impl EventResponse {
#[tracing::instrument(skip(self), fields(self.link = %self.link))]
pub async fn to_model(&self, description: String) -> Event {
let subtitle = match self.subtitle.clone() {
SingleOrVec::Single(subtitle) => subtitle,
Expand Down
2 changes: 1 addition & 1 deletion src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::discord::api::{DiscordAPI, EventsThread};
use chrono::NaiveDate;
use serenity::all::{ChannelId, GuildChannel, Message, PartialGuild};
use std::collections::BTreeMap;
use tracing::{debug, info, instrument, trace};
use tracing::{debug, trace};

pub async fn filter_new_events_by_thread(
discord: &DiscordAPI,
Expand Down
2 changes: 1 addition & 1 deletion src/discord/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use serenity::Client;
use std::env;
use std::fmt::Debug;
use tracing::field::debug;
use tracing::{debug, error, info, instrument, trace, warn};
use tracing::{debug, error, info, trace, warn};

const PORTUGUESE_MONTHS: [&str; 12] = [
"Janeiro",
Expand Down
Loading
Loading