Skip to content

Commit 910b619

Browse files
feat: implement medium-impact gaps — invites, emojis, scheduled events
Inbound (Discord → NATS): - InviteCreateEvent, InviteDeleteEvent with guild access guard - Serenity invite_create / invite_delete EventHandler impls Outbound (NATS → Discord): - CreateInviteCommand, RevokeInviteCommand - CreateEmojiCommand, DeleteEmojiCommand - CreateScheduledEventCommand (voice/external), DeleteScheduledEventCommand Subjects (8 new): - bot::invite_{create,delete} - agent::invite_{create,revoke}, emoji_{create,delete} - agent::scheduled_event_{create,delete} 281 tests passing
1 parent 9c8e2e4 commit 910b619

6 files changed

Lines changed: 438 additions & 7 deletions

File tree

rsworkspace/crates/discord-bot/src/bridge.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,52 @@ impl DiscordBridge {
978978
Ok(())
979979
}
980980

981+
pub async fn publish_invite_create(
982+
&self,
983+
data: &serenity::model::event::InviteCreateEvent,
984+
) -> Result<()> {
985+
let guild_id = data.guild_id.map(|g| g.get());
986+
let meta = EventMetadata::new(
987+
format!("dc-guild-{}", guild_id.unwrap_or(0)),
988+
self.next_sequence(),
989+
);
990+
let ev = InviteCreateEvent {
991+
metadata: meta,
992+
code: data.code.clone(),
993+
channel_id: data.channel_id.get(),
994+
guild_id,
995+
inviter_id: data.inviter.as_ref().map(|u| u.id.get()),
996+
max_age_secs: data.max_age as u64,
997+
max_uses: data.max_uses as u64,
998+
temporary: data.temporary,
999+
};
1000+
let subject = subjects::bot::invite_create(self.prefix());
1001+
self.publisher.publish(&subject, &ev).await?;
1002+
debug!("Published invite_create to {}", subject);
1003+
Ok(())
1004+
}
1005+
1006+
pub async fn publish_invite_delete(
1007+
&self,
1008+
data: &serenity::model::event::InviteDeleteEvent,
1009+
) -> Result<()> {
1010+
let guild_id = data.guild_id.map(|g| g.get());
1011+
let meta = EventMetadata::new(
1012+
format!("dc-guild-{}", guild_id.unwrap_or(0)),
1013+
self.next_sequence(),
1014+
);
1015+
let ev = InviteDeleteEvent {
1016+
metadata: meta,
1017+
code: data.code.clone(),
1018+
channel_id: data.channel_id.get(),
1019+
guild_id,
1020+
};
1021+
let subject = subjects::bot::invite_delete(self.prefix());
1022+
self.publisher.publish(&subject, &ev).await?;
1023+
debug!("Published invite_delete to {}", subject);
1024+
Ok(())
1025+
}
1026+
9811027
pub async fn publish_thread_created(&self, thread: &GuildChannel) -> Result<()> {
9821028
let meta = EventMetadata::new(
9831029
format!("dc-guild-{}", thread.guild_id.get()),

rsworkspace/crates/discord-bot/src/handlers/mod.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,50 @@ impl EventHandler for Handler {
619619
}
620620
}
621621

622+
async fn invite_create(
623+
&self,
624+
ctx: Context,
625+
data: serenity::model::event::InviteCreateEvent,
626+
) {
627+
let bridge = {
628+
let d = ctx.data.read().await;
629+
match d.get::<DiscordBridge>() {
630+
Some(b) => b.clone(),
631+
None => return,
632+
}
633+
};
634+
if let Some(guild_id) = data.guild_id {
635+
if !bridge.check_guild_access(guild_id.get()) {
636+
return;
637+
}
638+
}
639+
if let Err(e) = bridge.publish_invite_create(&data).await {
640+
error!("Failed to publish invite_create: {}", e);
641+
}
642+
}
643+
644+
async fn invite_delete(
645+
&self,
646+
ctx: Context,
647+
data: serenity::model::event::InviteDeleteEvent,
648+
) {
649+
let bridge = {
650+
let d = ctx.data.read().await;
651+
match d.get::<DiscordBridge>() {
652+
Some(b) => b.clone(),
653+
None => return,
654+
}
655+
};
656+
if let Some(guild_id) = data.guild_id {
657+
if !bridge.check_guild_access(guild_id.get()) {
658+
return;
659+
}
660+
}
661+
if let Err(e) = bridge.publish_invite_delete(&data).await {
662+
error!("Failed to publish invite_delete: {}", e);
663+
}
664+
}
665+
622666
async fn thread_create(&self, ctx: Context, thread: GuildChannel) {
623667
let bridge = {
624668
let data = ctx.data.read().await;

rsworkspace/crates/discord-bot/src/outbound.rs

Lines changed: 198 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ use anyhow::Result;
1313
use discord_nats::{subjects, MessagePublisher, MessageSubscriber};
1414
use discord_types::{
1515
AddReactionCommand, ArchiveThreadCommand, AssignRoleCommand, AutocompleteRespondCommand,
16-
BanUserCommand, BulkDeleteMessagesCommand, CreateChannelCommand, CreateRoleCommand,
17-
CreateThreadCommand, CreateWebhookCommand, DeleteChannelCommand, DeleteMessageCommand,
18-
DeleteRoleCommand, DeleteWebhookCommand, EditChannelCommand, EditMessageCommand,
19-
ExecuteWebhookCommand, FetchMemberCommand, FetchMessagesCommand, GuildMemberNickCommand,
20-
InteractionDeferCommand, InteractionFollowupCommand, InteractionRespondCommand, KickUserCommand,
21-
ModalRespondCommand, PinMessageCommand, RemoveReactionCommand, RemoveRoleCommand,
22-
SendMessageCommand, SetBotPresenceCommand, TimeoutUserCommand, TypingCommand, UnbanUserCommand,
16+
BanUserCommand, BulkDeleteMessagesCommand, CreateChannelCommand, CreateEmojiCommand,
17+
CreateInviteCommand, CreateRoleCommand, CreateScheduledEventCommand, CreateThreadCommand,
18+
CreateWebhookCommand, DeleteChannelCommand, DeleteEmojiCommand, DeleteMessageCommand,
19+
DeleteRoleCommand, DeleteScheduledEventCommand, DeleteWebhookCommand, EditChannelCommand,
20+
EditMessageCommand, ExecuteWebhookCommand, FetchMemberCommand, FetchMessagesCommand,
21+
GuildMemberNickCommand, InteractionDeferCommand, InteractionFollowupCommand,
22+
InteractionRespondCommand, KickUserCommand, ModalRespondCommand, PinMessageCommand,
23+
RemoveReactionCommand, RemoveRoleCommand, RevokeInviteCommand, SendMessageCommand,
24+
SetBotPresenceCommand, TimeoutUserCommand, TypingCommand, UnbanUserCommand,
2325
UnpinMessageCommand, VoiceDisconnectCommand, VoiceMoveCommand,
2426
};
2527
use discord_types::types::{
@@ -338,6 +340,7 @@ impl OutboundProcessor {
338340
r11, r12, r13, r14, r15, r16, r17, r18, r19, r20,
339341
r21, r22, r23, r24, r25, r26, r27, r28, r29, r30,
340342
r31, r32, r33, r34, r35, r36, r37,
343+
r38, r39, r40, r41, r42, r43,
341344
) = tokio::join!(
342345
Self::handle_send_messages(
343346
http.clone(),
@@ -411,6 +414,12 @@ impl OutboundProcessor {
411414
Self::handle_webhook_delete(http.clone(), client.clone(), prefix.clone()),
412415
Self::handle_voice_move(http.clone(), client.clone(), prefix.clone(), publisher.clone()),
413416
Self::handle_voice_disconnect(http.clone(), client.clone(), prefix.clone(), publisher.clone()),
417+
Self::handle_invite_create(http.clone(), client.clone(), prefix.clone()),
418+
Self::handle_invite_revoke(http.clone(), client.clone(), prefix.clone()),
419+
Self::handle_emoji_create(http.clone(), client.clone(), prefix.clone()),
420+
Self::handle_emoji_delete(http.clone(), client.clone(), prefix.clone()),
421+
Self::handle_scheduled_event_create(http.clone(), client.clone(), prefix.clone()),
422+
Self::handle_scheduled_event_delete(http.clone(), client.clone(), prefix.clone()),
414423
);
415424

416425
// Log any errors (all handlers run indefinitely until NATS disconnects)
@@ -452,6 +461,12 @@ impl OutboundProcessor {
452461
("webhook_delete", r35),
453462
("voice_move", r36),
454463
("voice_disconnect", r37),
464+
("invite_create", r38),
465+
("invite_revoke", r39),
466+
("emoji_create", r40),
467+
("emoji_delete", r41),
468+
("scheduled_event_create", r42),
469+
("scheduled_event_delete", r43),
455470
] {
456471
if let Err(e) = result {
457472
error!("Outbound handler '{}' exited with error: {}", name, e);
@@ -2263,6 +2278,182 @@ impl OutboundProcessor {
22632278
}
22642279
Ok(())
22652280
}
2281+
2282+
async fn handle_invite_create(
2283+
http: Arc<Http>,
2284+
client: async_nats::Client,
2285+
prefix: String,
2286+
) -> Result<()> {
2287+
let subscriber = MessageSubscriber::new(client, &prefix);
2288+
let subject = subjects::agent::invite_create(&prefix);
2289+
let mut stream = subscriber.subscribe::<CreateInviteCommand>(&subject).await?;
2290+
info!("Listening for invite_create commands on {}", subject);
2291+
while let Some(result) = stream.next().await {
2292+
match result {
2293+
Ok(cmd) => {
2294+
let channel = ChannelId::new(cmd.channel_id);
2295+
let builder = serenity::builder::CreateInvite::new()
2296+
.max_age(cmd.max_age_secs as u32)
2297+
.max_uses(cmd.max_uses as u8)
2298+
.temporary(cmd.temporary)
2299+
.unique(cmd.unique);
2300+
if let Err(e) = channel.create_invite(&*http, builder).await {
2301+
warn!("Failed to create invite for channel {}: {}", cmd.channel_id, e);
2302+
}
2303+
}
2304+
Err(e) => warn!("Failed to deserialize invite_create command: {}", e),
2305+
}
2306+
}
2307+
Ok(())
2308+
}
2309+
2310+
async fn handle_invite_revoke(
2311+
http: Arc<Http>,
2312+
client: async_nats::Client,
2313+
prefix: String,
2314+
) -> Result<()> {
2315+
let subscriber = MessageSubscriber::new(client, &prefix);
2316+
let subject = subjects::agent::invite_revoke(&prefix);
2317+
let mut stream = subscriber.subscribe::<RevokeInviteCommand>(&subject).await?;
2318+
info!("Listening for invite_revoke commands on {}", subject);
2319+
while let Some(result) = stream.next().await {
2320+
match result {
2321+
Ok(cmd) => {
2322+
let reason = cmd.reason.as_deref();
2323+
if let Err(e) = http.delete_invite(&cmd.code, reason).await {
2324+
warn!("Failed to revoke invite {}: {}", cmd.code, e);
2325+
}
2326+
}
2327+
Err(e) => warn!("Failed to deserialize invite_revoke command: {}", e),
2328+
}
2329+
}
2330+
Ok(())
2331+
}
2332+
2333+
async fn handle_emoji_create(
2334+
http: Arc<Http>,
2335+
client: async_nats::Client,
2336+
prefix: String,
2337+
) -> Result<()> {
2338+
let subscriber = MessageSubscriber::new(client, &prefix);
2339+
let subject = subjects::agent::emoji_create(&prefix);
2340+
let mut stream = subscriber.subscribe::<CreateEmojiCommand>(&subject).await?;
2341+
info!("Listening for emoji_create commands on {}", subject);
2342+
while let Some(result) = stream.next().await {
2343+
match result {
2344+
Ok(cmd) => {
2345+
let guild = GuildId::new(cmd.guild_id);
2346+
if let Err(e) = guild.create_emoji(&*http, &cmd.name, &cmd.image_data).await {
2347+
warn!("Failed to create emoji '{}' in guild {}: {}", cmd.name, cmd.guild_id, e);
2348+
}
2349+
}
2350+
Err(e) => warn!("Failed to deserialize emoji_create command: {}", e),
2351+
}
2352+
}
2353+
Ok(())
2354+
}
2355+
2356+
async fn handle_emoji_delete(
2357+
http: Arc<Http>,
2358+
client: async_nats::Client,
2359+
prefix: String,
2360+
) -> Result<()> {
2361+
let subscriber = MessageSubscriber::new(client, &prefix);
2362+
let subject = subjects::agent::emoji_delete(&prefix);
2363+
let mut stream = subscriber.subscribe::<DeleteEmojiCommand>(&subject).await?;
2364+
info!("Listening for emoji_delete commands on {}", subject);
2365+
while let Some(result) = stream.next().await {
2366+
match result {
2367+
Ok(cmd) => {
2368+
let guild = GuildId::new(cmd.guild_id);
2369+
let emoji_id = serenity::model::id::EmojiId::new(cmd.emoji_id);
2370+
if let Err(e) = guild.delete_emoji(&*http, emoji_id).await {
2371+
warn!("Failed to delete emoji {} from guild {}: {}", cmd.emoji_id, cmd.guild_id, e);
2372+
}
2373+
}
2374+
Err(e) => warn!("Failed to deserialize emoji_delete command: {}", e),
2375+
}
2376+
}
2377+
Ok(())
2378+
}
2379+
2380+
async fn handle_scheduled_event_create(
2381+
http: Arc<Http>,
2382+
client: async_nats::Client,
2383+
prefix: String,
2384+
) -> Result<()> {
2385+
use serenity::builder::CreateScheduledEvent;
2386+
use serenity::model::guild::ScheduledEventType;
2387+
2388+
let subscriber = MessageSubscriber::new(client, &prefix);
2389+
let subject = subjects::agent::scheduled_event_create(&prefix);
2390+
let mut stream = subscriber.subscribe::<CreateScheduledEventCommand>(&subject).await?;
2391+
info!("Listening for scheduled_event_create commands on {}", subject);
2392+
while let Some(result) = stream.next().await {
2393+
match result {
2394+
Ok(cmd) => {
2395+
let guild = GuildId::new(cmd.guild_id);
2396+
let start = match cmd.start_time.parse::<serenity::model::Timestamp>() {
2397+
Ok(t) => t,
2398+
Err(e) => {
2399+
warn!("Invalid start_time '{}': {}", cmd.start_time, e);
2400+
continue;
2401+
}
2402+
};
2403+
2404+
let (kind, mut builder) = if let Some(channel_id) = cmd.channel_id {
2405+
let b = CreateScheduledEvent::new(ScheduledEventType::Voice, &cmd.name, start)
2406+
.channel_id(ChannelId::new(channel_id));
2407+
(ScheduledEventType::Voice, b)
2408+
} else {
2409+
let location = cmd.external_location.unwrap_or_default();
2410+
let end = cmd.end_time.as_deref()
2411+
.and_then(|s| s.parse::<serenity::model::Timestamp>().ok());
2412+
let mut b = CreateScheduledEvent::new(ScheduledEventType::External, &cmd.name, start)
2413+
.location(&location);
2414+
if let Some(end_ts) = end {
2415+
b = b.end_time(end_ts);
2416+
}
2417+
(ScheduledEventType::External, b)
2418+
};
2419+
let _ = kind;
2420+
if let Some(ref desc) = cmd.description {
2421+
builder = builder.description(desc);
2422+
}
2423+
2424+
if let Err(e) = guild.create_scheduled_event(&*http, builder).await {
2425+
warn!("Failed to create scheduled event '{}' in guild {}: {}", cmd.name, cmd.guild_id, e);
2426+
}
2427+
}
2428+
Err(e) => warn!("Failed to deserialize scheduled_event_create command: {}", e),
2429+
}
2430+
}
2431+
Ok(())
2432+
}
2433+
2434+
async fn handle_scheduled_event_delete(
2435+
http: Arc<Http>,
2436+
client: async_nats::Client,
2437+
prefix: String,
2438+
) -> Result<()> {
2439+
let subscriber = MessageSubscriber::new(client, &prefix);
2440+
let subject = subjects::agent::scheduled_event_delete(&prefix);
2441+
let mut stream = subscriber.subscribe::<DeleteScheduledEventCommand>(&subject).await?;
2442+
info!("Listening for scheduled_event_delete commands on {}", subject);
2443+
while let Some(result) = stream.next().await {
2444+
match result {
2445+
Ok(cmd) => {
2446+
let guild = GuildId::new(cmd.guild_id);
2447+
let event_id = serenity::model::id::ScheduledEventId::new(cmd.event_id);
2448+
if let Err(e) = guild.delete_scheduled_event(&*http, event_id).await {
2449+
warn!("Failed to delete scheduled event {} from guild {}: {}", cmd.event_id, cmd.guild_id, e);
2450+
}
2451+
}
2452+
Err(e) => warn!("Failed to deserialize scheduled_event_delete command: {}", e),
2453+
}
2454+
}
2455+
Ok(())
2456+
}
22662457
}
22672458

22682459
/// Publish a `CommandErrorEvent` to NATS if the outcome is `Permanent`.

0 commit comments

Comments
 (0)