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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ unicode-width = "0.2"
pulldown-cmark = { version = "0.13", default-features = false }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
cron = "0.16.0"
chrono = "0.4.44"
chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10.4"

[target.'cfg(unix)'.dependencies]
Expand Down
43 changes: 43 additions & 0 deletions docs/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ OpenAB registers Discord slash commands for session control. These work in both
| `/agents` | Select the agent mode via dropdown menu | Yes |
| `/cancel` | Cancel the current in-flight operation | Yes |
| `/reset` | Reset the conversation session (clear history, start fresh) | Yes |
| `/remind` | Set a one-shot delayed reminder to mention users/roles | No |

All responses are **ephemeral** — only the user who invoked the command sees the reply.

Expand Down Expand Up @@ -74,3 +75,45 @@ In addition to slash commands, you can pass built-in CLI commands directly after
```

These are forwarded as-is to the ACP session as a prompt. Any command the underlying CLI supports in its interactive mode works here. This is the recommended workaround for agents that don't expose `configOptions`.

## `/remind`

Set a one-shot delayed reminder that mentions users or roles in the channel after a specified delay.

**Syntax:**
```
/remind targets:<@user @role ...> message:<text> delay:<duration>
```

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `targets` | Yes | Space-separated @mentions (users and/or roles) |
| `message` | Yes | Reminder text |
| `delay` | Yes | Duration before firing: `1m` to `30d` (supports `m`, `h`, `d` and combinations like `1h30m`) |

**Constraints:**
- Only humans can use `/remind` (bots are rejected)
- Minimum delay: 1 minute
- Maximum delay: 30 days
- Maximum message length: 1800 characters
- Maximum 5 active reminders per user
- Maximum 10 mention targets per reminder (use a @role for larger groups)
- `@everyone` and `@here` in messages are automatically neutralized (will not trigger mass mentions)
- One-shot only (fires once, then removed)
- Reminders persist across bot restarts (stored in `$HOME/.openab/reminders.json`)

**Examples:**
```
/remind targets:@Alice @Bob message:Review PR #42 delay:2h
/remind targets:@Reviewers message:Stand-up time delay:30m
/remind targets:@Charlie message:Check deployment delay:1d
```

**When fired, the bot posts:**
```
⏰ Reminder from @sender:
"Review PR #42"
cc @Alice @Bob
```
184 changes: 182 additions & 2 deletions src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity};
use crate::config::{AllowBots, AllowUsers, SttConfig};
use crate::format;
use crate::media;
use crate::remind::{self, ReminderStore};
use async_trait::async_trait;
use serenity::builder::{
CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse,
CreateActionRow, CreateButton, CreateCommand, CreateCommandOption, CreateInteractionResponse,
CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind,
CreateSelectMenuOption, CreateThread, EditMessage,
};
use serenity::http::Http;
use serenity::model::application::ButtonStyle;
use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction};
use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction};
use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType};
use serenity::model::gateway::Ready;
use serenity::model::id::{ChannelId, MessageId, UserId};
Expand Down Expand Up @@ -207,6 +208,10 @@ pub struct Handler {
pub allow_dm: bool,
/// Per-thread dispatcher (Message mode uses cap=1 for FIFO; Thread/Lane use configured cap).
pub dispatcher: Arc<crate::dispatch::Dispatcher>,
/// Reminder store for /remind slash command.
pub reminder_store: ReminderStore,
/// Track scheduled reminder IDs to prevent duplicate scheduling on reconnect.
pub scheduled_ids: tokio::sync::Mutex<std::collections::HashSet<String>>,
}

impl Handler {
Expand Down Expand Up @@ -815,6 +820,23 @@ impl EventHandler for Handler {
CreateCommand::new("cancel-all")
.description("Cancel current operation and drop all buffered messages"),
CreateCommand::new("reset").description("Reset the conversation session"),
CreateCommand::new("remind")
.description("Set a one-shot reminder to mention users/roles after a delay")
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"targets",
"Users/roles to mention (e.g. @user1 @role1)",
).required(true))
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"message",
"Reminder message",
).required(true))
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"delay",
"Delay before firing (e.g. 30m, 2h, 1d)",
).required(true)),
];

// Register global commands (works in DMs + all guilds after propagation).
Expand All @@ -833,6 +855,22 @@ impl EventHandler for Handler {
info!(%guild_id, "registered guild slash commands");
}
}

// Re-schedule any pending reminders that survived a restart.
let pending = self.reminder_store.pending().await;
if !pending.is_empty() {
let mut scheduled = self.scheduled_ids.lock().await;
let mut count = 0;
for r in pending {
if scheduled.insert(r.id.clone()) {
remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), r);
count += 1;
}
}
if count > 0 {
info!(count, "re-scheduled pending reminders");
}
}
}

async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
Expand All @@ -854,6 +892,9 @@ impl EventHandler for Handler {
Interaction::Command(cmd) if cmd.data.name == "reset" => {
self.handle_reset_command(&ctx, &cmd).await;
}
Interaction::Command(cmd) if cmd.data.name == "remind" => {
self.handle_remind_command(&ctx, &cmd).await;
}
Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => {
self.handle_config_select(&ctx, &comp).await;
}
Expand Down Expand Up @@ -1116,6 +1157,145 @@ impl Handler {
}
}

async fn handle_remind_command(
&self,
ctx: &Context,
cmd: &serenity::model::application::CommandInteraction,
) {
// Only humans can use /remind
if cmd.user.bot {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ Only humans can set reminders.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// Extract options
let opts = &cmd.data.options;
let targets_raw = opts.iter()
.find(|o| o.name == "targets")
.and_then(|o| o.value.as_str())
.unwrap_or("");
let message = opts.iter()
.find(|o| o.name == "message")
.and_then(|o| o.value.as_str())
.unwrap_or("");
let delay_raw = opts.iter()
.find(|o| o.name == "delay")
.and_then(|o| o.value.as_str())
.unwrap_or("");

if targets_raw.is_empty() || message.is_empty() || delay_raw.is_empty() {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ All fields (targets, message, delay) are required.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// Parse delay
let delay_secs = match remind::parse_delay(delay_raw) {
Ok(s) => s,
Err(e) => {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("⚠️ Invalid delay: {e}"))
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}
};

if let Err(e) = remind::validate_message(message) {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("⚠️ {e}"))
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// Strip @everyone / @here to prevent unintended mass pings.
let message = remind::sanitize_message(message);

// Extract mention strings from targets (keep raw — Discord renders them)
let targets: Vec<String> = targets_raw
.split_whitespace()
.filter(|t| t.starts_with("<@") && t.ends_with('>'))
.map(|t| t.to_string())
.collect();

if targets.is_empty() {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ No valid mentions found in targets. Use @user or @role.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

if targets.len() > remind::MAX_TARGETS {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("⚠️ Too many targets (max {}). Use a @role instead.", remind::MAX_TARGETS))
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

// F4: Per-user rate limit (max 5 active reminders)
let user_id = cmd.user.id.get();
let pending = self.reminder_store.pending().await;
let user_count = pending.iter().filter(|r| r.sender_id == user_id).count();
if user_count >= 5 {
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("⚠️ You already have 5 active reminders. Wait for some to fire before adding more.")
.ephemeral(true),
);
let _ = cmd.create_response(&ctx.http, response).await;
return;
}

let fire_at = chrono::Utc::now() + chrono::Duration::seconds(delay_secs as i64);
let reminder = remind::Reminder {
id: uuid::Uuid::new_v4().to_string(),
channel_id: cmd.channel_id.get(),
sender_id: cmd.user.id.get(),
targets: targets.clone(),
message: message.clone(),
fire_at,
created_at: chrono::Utc::now(),
};

// Persist and schedule
self.reminder_store.add(reminder.clone()).await;
self.scheduled_ids.lock().await.insert(reminder.id.clone());
remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), reminder);

let delay_str = remind::format_delay(delay_secs);
let response = CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!(
"⏰ Reminder set! Will fire in **{delay_str}** and mention {}",
targets.join(" ")
))
.ephemeral(true),
);
if let Err(e) = cmd.create_response(&ctx.http, response).await {
tracing::error!(error = %e, "failed to respond to /remind command");
}
}

async fn handle_config_select(
&self,
ctx: &Context,
Expand Down
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod gateway;
mod markdown;
mod media;
mod reactions;
mod remind;
mod setup;
mod slack;
mod stt;
Expand Down Expand Up @@ -403,6 +404,14 @@ async fn main() -> anyhow::Result<()> {
));
dispatchers.lock().unwrap().push(discord_dispatcher.clone());

// Initialize reminder store (persists to $HOME/.openab/reminders.json)
let reminder_path = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default()
.join(".openab")
.join("reminders.json");
let reminder_store = remind::ReminderStore::load(reminder_path);

let handler = discord::Handler {
router,
allow_all_channels,
Expand All @@ -424,6 +433,8 @@ async fn main() -> anyhow::Result<()> {
)),
allow_dm: discord_cfg.allow_dm,
dispatcher: discord_dispatcher,
reminder_store: reminder_store.clone(),
scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()),
};

let intents = GatewayIntents::GUILD_MESSAGES
Expand Down
Loading
Loading