foukoapi 0.1.2-alpha.2

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
//! Discord adapter built on top of [`serenity`].

use crate::{
    bot::Router,
    ctx::{ChatInfo, ChatInfoFn, Ctx, UrlFn},
    error::{Error, Result},
    keyboard::{ButtonKind, Embed as FkEmbed, Reply},
    platform::PlatformKind,
};
use async_trait::async_trait;
use serenity::{
    all::{
        ButtonStyle, ChannelId, Command, CommandInteraction, CommandOptionType,
        ComponentInteraction, CreateActionRow, CreateAttachment, CreateButton, CreateCommand,
        CreateCommandOption, CreateEmbed, CreateEmbedFooter, CreateInteractionResponse,
        CreateInteractionResponseFollowup, CreateInteractionResponseMessage, CreateMessage,
        EditInteractionResponse, GatewayIntents, GuildId, Interaction, Message, Ready, User,
        UserId,
    },
    client::{Context as SerenityContext, EventHandler},
    http::Http,
    model::guild::Guild,
    Client,
};
use std::sync::Arc;

/// Start the Discord adapter.
///
/// `commands` is the list of `(name, description)` pairs the bot has
/// registered. The adapter promotes each of them into a native Discord
/// slash command on startup so `/help`, `/menu`, etc. show up in the
/// client's autocomplete and can be invoked as real slash commands.
pub async fn run(
    token: String,
    router: Arc<Router>,
    commands: Vec<(String, Option<String>, bool)>,
    notifier: Option<crate::notifier::Notifier>,
    presence: Option<crate::platform::Presence>,
) -> Result<()> {
    tracing::info!("starting discord adapter");

    let intents = GatewayIntents::GUILDS
        | GatewayIntents::GUILD_MESSAGES
        | GatewayIntents::DIRECT_MESSAGES
        | GatewayIntents::MESSAGE_CONTENT;

    let client = Client::builder(&token, intents)
        .event_handler(Handler {
            router,
            commands: Arc::new(commands),
            reply_index: Arc::new(std::sync::Mutex::new(ReplyIndex::default())),
            self_id: Arc::new(std::sync::atomic::AtomicU64::new(0)),
            presence,
        })
        .await
        .map_err(|e| Error::platform("discord", e))?;

    // Install an outbound sender before the gateway loop takes over. The
    // HTTP handle works on its own, so background tasks can post to any
    // channel by id without an incoming interaction.
    if let Some(notifier) = &notifier {
        let http = client.http.clone();
        let send: crate::notifier::SendFn = Arc::new(move |chan_id: String, reply: Reply| {
            let http = http.clone();
            Box::pin(async move {
                let id: u64 = chan_id
                    .parse()
                    .map_err(|_| Error::platform("discord", format!("bad channel id {chan_id}")))?;
                let channel = ChannelId::new(id);
                for out in build_messages(&reply)? {
                    channel
                        .send_message(&http, out)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                }
                Ok(())
            })
        });
        notifier.register(PlatformKind::Discord, send).await;

        // DM sender: resolve the user's private channel first (create_dm
        // hits POST /users/@me/channels), cache the mapping so repeated
        // DMs to the same user skip the REST round-trip, then deliver
        // through the usual message-building path.
        let http = client.http.clone();
        let dm_cache = Arc::new(std::sync::Mutex::new(DmChannelCache::default()));
        let send_dm: crate::notifier::DmSendFn = Arc::new(move |user_id: String, reply: Reply| {
            let http = http.clone();
            let dm_cache = Arc::clone(&dm_cache);
            Box::pin(async move {
                let id: u64 = user_id
                    .parse()
                    .map_err(|_| Error::platform("discord", format!("bad user id {user_id}")))?;
                let cached = dm_cache.lock().ok().and_then(|c| c.get(id));
                let channel = match cached {
                    Some(chan) => ChannelId::new(chan),
                    None => {
                        let chan = UserId::new(id)
                            .create_dm_channel(&http)
                            .await
                            .map_err(|e| Error::platform("discord", e))?
                            .id;
                        if let Ok(mut cache) = dm_cache.lock() {
                            cache.record(id, chan.get());
                        }
                        chan
                    }
                };
                for out in build_messages(&reply)? {
                    channel
                        .send_message(&http, out)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                }
                Ok(())
            })
        });
        notifier.register_dm(PlatformKind::Discord, send_dm).await;

        // User lookup via GET /users/{id}. A 404 means no such user and
        // maps to Ok(None); other failures bubble up as errors.
        let http = client.http.clone();
        let lookup: crate::notifier::UserLookupFn = Arc::new(move |user_id: String| {
            let http = http.clone();
            Box::pin(async move {
                let id: u64 = user_id
                    .parse()
                    .map_err(|_| Error::platform("discord", format!("bad user id {user_id}")))?;
                match http.get_user(UserId::new(id)).await {
                    Ok(user) => Ok(Some(discord_display_name(&user))),
                    Err(e) if is_not_found(&e) => Ok(None),
                    Err(e) => Err(Error::platform("discord", e)),
                }
            })
        });
        notifier
            .register_user_lookup(PlatformKind::Discord, lookup)
            .await;
    }

    let mut client = client;
    client
        .start()
        .await
        .map_err(|e| Error::platform("discord", e))?;

    Ok(())
}

struct Handler {
    router: Arc<Router>,
    /// Bot commands to expose as native Discord slash commands. Each
    /// item is `(name_with_slash, description)`.
    commands: Arc<Vec<(String, Option<String>, bool)>>,
    /// Maps a user's message id to the bot's reply ids in that channel, so
    /// when the user deletes their message we can clean up ours too.
    /// Bounded: oldest entries are evicted once the map grows past a cap.
    reply_index: Arc<std::sync::Mutex<ReplyIndex>>,
    /// Our own user id, learned at `ready`. Zero until then.
    self_id: Arc<std::sync::atomic::AtomicU64>,
    /// Presence to publish once the gateway is up. `ready` fires again on
    /// every reconnect, so the presence survives connection drops too.
    presence: Option<crate::platform::Presence>,
}

/// Bookkeeping for "delete my reply when the trigger message is deleted".
#[derive(Default)]
struct ReplyIndex {
    /// trigger message id -> (channel, bot message id)s sent in response.
    map: std::collections::HashMap<u64, Vec<(u64, u64)>>,
    /// Insertion order for eviction. May hold ids already taken out of
    /// `map`; eviction skips those lazily.
    order: std::collections::VecDeque<u64>,
}

impl ReplyIndex {
    const CAP: usize = 5_000;

    fn record(&mut self, trigger: u64, channel: u64, bot_msg: u64) {
        let entry = self.map.entry(trigger).or_default();
        if entry.is_empty() {
            self.order.push_back(trigger);
        }
        entry.push((channel, bot_msg));
        // Evict oldest live entries; ids already removed by take() don't
        // count against the cap.
        while self.map.len() > Self::CAP {
            match self.order.pop_front() {
                Some(old) => {
                    self.map.remove(&old);
                }
                None => break,
            }
        }
    }

    fn take(&mut self, trigger: u64) -> Vec<(u64, u64)> {
        self.map.remove(&trigger).unwrap_or_default()
    }
}

/// Bounded user id -> DM channel id cache for `Notifier::send_dm`, so a
/// repeat DM to the same user skips the create-channel REST call. Same
/// eviction scheme as [`ReplyIndex`]: oldest entries go first.
#[derive(Default)]
struct DmChannelCache {
    map: std::collections::HashMap<u64, u64>,
    order: std::collections::VecDeque<u64>,
}

impl DmChannelCache {
    const CAP: usize = 1_000;

    fn get(&self, user: u64) -> Option<u64> {
        self.map.get(&user).copied()
    }

    fn record(&mut self, user: u64, channel: u64) {
        if self.map.insert(user, channel).is_none() {
            self.order.push_back(user);
        }
        while self.map.len() > Self::CAP {
            match self.order.pop_front() {
                Some(old) => {
                    self.map.remove(&old);
                }
                None => break,
            }
        }
    }
}

impl Handler {
    /// Build the native Discord slash-command set from the bot's command
    /// list. Names are lowercased and stripped of the leading `/`; ones
    /// Discord would reject (empty, starting with a digit like `8ball`,
    /// containing spaces) are skipped so the register call never fails
    /// on a single bad entry. Each command carries an optional `args`
    /// string field so the client shows a text box for `/weather Paris`,
    /// `/link CODE` and friends.
    fn build_commands(&self) -> Vec<CreateCommand> {
        let mut batch: Vec<CreateCommand> = Vec::new();
        for (name, desc, takes_user) in self.commands.iter() {
            let trimmed = name.trim_start_matches('/').to_ascii_lowercase();
            if trimmed.is_empty() || !is_valid_slash_name(&trimmed) {
                continue;
            }
            let description = desc
                .clone()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| trimmed.clone());
            // Discord caps descriptions at 100 chars; truncate on a char
            // boundary so multi-byte codepoints never get sliced.
            let description = truncate_chars(&description, 100);
            let mut cmd = CreateCommand::new(trimmed).description(description);
            // Commands that target a person get a native USER option so
            // the Discord client offers its member picker instead of the
            // user typing an id by hand.
            if *takes_user {
                cmd = cmd.add_option(
                    CreateCommandOption::new(CommandOptionType::User, "user", "who to look at")
                        .required(false),
                );
            }
            cmd = cmd.add_option(
                CreateCommandOption::new(
                    CommandOptionType::String,
                    "args",
                    "arguments passed to the command (optional)",
                )
                .required(false),
            );
            batch.push(cmd);
        }
        batch
    }
}

#[async_trait]
impl EventHandler for Handler {
    async fn ready(&self, ctx: SerenityContext, ready: Ready) {
        tracing::info!(bot = %ready.user.name, "discord adapter ready");
        self.self_id
            .store(ready.user.id.get(), std::sync::atomic::Ordering::Relaxed);

        // Publish the configured presence. Doing it here (not once at
        // startup) means a reconnect restores it automatically.
        if let Some(p) = &self.presence {
            ctx.set_presence(to_activity(p), to_status(p.status));
            tracing::info!("discord: presence published");
        }

        // Register every bot command as a native Discord slash command.
        // Global commands work everywhere (guilds and DMs). We used to
        // also push the same set per-guild for instant availability, but
        // Discord does NOT de-duplicate the two scopes in the client -
        // every command showed up twice. Global-only is the correct way;
        // the initial rollout delay only affects a freshly invited bot.
        let batch = self.build_commands();
        match Command::set_global_commands(&ctx.http, batch).await {
            Ok(cmds) => {
                tracing::info!(
                    count = cmds.len(),
                    "discord: registered global slash commands"
                );
            }
            Err(e) => {
                tracing::warn!(error = %e, "discord: could not register slash commands");
            }
        }
    }

    async fn guild_create(&self, ctx: SerenityContext, guild: Guild, _is_new: Option<bool>) {
        // Wipe any per-guild copies left over from older versions that
        // registered commands in both scopes: the client showed each
        // command twice (global + guild). Guild commands are not used
        // anymore, an empty set removes them.
        match guild.id.set_commands(&ctx.http, Vec::new()).await {
            Ok(_) => {
                tracing::debug!(guild = %guild.id, "discord: cleared per-guild slash commands");
            }
            Err(e) => {
                tracing::debug!(
                    guild = %guild.id,
                    error = %e,
                    "discord: could not clear per-guild slash commands"
                );
            }
        }
    }

    async fn message(&self, ctx: SerenityContext, msg: Message) {
        if msg.author.bot {
            return;
        }

        let router = Arc::clone(&self.router);
        let channel_id = msg.channel_id;
        let http = ctx.http.clone();

        // A guild message carries its guild id; a DM doesn't. Reading it
        // off the message is instant and never leaks private data into a
        // guild, unlike probing the channel over HTTP where a failed
        // lookup used to fall back to a guess.
        let is_dm = msg.guild_id.is_none();

        // First attachment that looks like an image, by content type or
        // filename extension. Downloaded lazily on ctx.incoming_image().
        let image_url = msg
            .attachments
            .iter()
            .find(|a| is_image_attachment(a))
            .map(|a| a.url.clone());

        let trigger_id = msg.id.get();
        let reply_index = Arc::clone(&self.reply_index);
        // Last message the bot sent in reply to this update, shared between
        // reply_fn (writes it) and edit_fn (edits it). Lets ctx.edit_reply()
        // rewrite the bot's own answer in place - e.g. streaming AI output
        // into a single message instead of spamming new ones.
        let last_sent: Arc<std::sync::Mutex<Option<serenity::all::MessageId>>> =
            Arc::new(std::sync::Mutex::new(None));
        let sent_for_reply = Arc::clone(&last_sent);
        let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
            let http = http.clone();
            let reply_index = Arc::clone(&reply_index);
            let last_sent = Arc::clone(&sent_for_reply);
            Box::pin(async move {
                for out in build_messages(&reply)? {
                    let sent = channel_id
                        .send_message(&http, out)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                    // Remember which message triggered this reply so a
                    // delete of the trigger can take the reply down too.
                    if let Ok(mut index) = reply_index.lock() {
                        index.record(trigger_id, channel_id.get(), sent.id.get());
                    }
                    // Track the last chunk so edit_reply can rewrite it.
                    if let Ok(mut slot) = last_sent.lock() {
                        *slot = Some(sent.id);
                    }
                }
                Ok(())
            })
        });

        // Edit the last reply when there is one, otherwise fall back to a
        // fresh message so early edit_reply() calls still answer the user.
        // Attachments are left alone: edits stay text/embed-only, same as
        // the component edit path.
        let http_for_edit = ctx.http.clone();
        let sent_for_edit = Arc::clone(&last_sent);
        let reply_fn_index = Arc::clone(&self.reply_index);
        let edit_fn: crate::ctx::EditFn = Arc::new(move |reply: Reply| {
            let http = http_for_edit.clone();
            let last_sent = Arc::clone(&sent_for_edit);
            let reply_index = Arc::clone(&reply_fn_index);
            Box::pin(async move {
                let target = last_sent.lock().ok().and_then(|slot| *slot);
                let Some(msg_id) = target else {
                    // Nothing sent yet: behave like a plain reply.
                    for out in build_messages(&reply)? {
                        let sent = channel_id
                            .send_message(&http, out)
                            .await
                            .map_err(|e| Error::platform("discord", e))?;
                        if let Ok(mut index) = reply_index.lock() {
                            index.record(trigger_id, channel_id.get(), sent.id.get());
                        }
                        if let Ok(mut slot) = last_sent.lock() {
                            *slot = Some(sent.id);
                        }
                    }
                    return Ok(());
                };
                // An edit can't spill into extra messages, so overlong
                // text gets cut at the first chunk boundary.
                let content = crate::util::truncate_chunk(reply.get_text(), DISCORD_LIMIT);
                let mut edit = serenity::all::EditMessage::new().content(content);
                // Always clear embeds/components so stale ones don't
                // linger when the new reply doesn't include them.
                let mut embeds = Vec::new();
                if let Some(em) = reply.get_embed() {
                    embeds.push(to_discord_embed(em));
                }
                edit = edit.embeds(embeds);
                let components = match reply.get_keyboard() {
                    Some(kb) => build_rows(kb),
                    None => Vec::new(),
                };
                edit = edit.components(components);
                channel_id
                    .edit_message(&http, msg_id, edit)
                    .await
                    .map_err(|e| Error::platform("discord", e))?;
                Ok(())
            })
        });

        let fouko_ctx = Ctx::new_with_edit(
            PlatformKind::Discord,
            channel_id.to_string(),
            msg.author.id.to_string(),
            msg.content.clone(),
            reply_fn,
            Some(is_dm),
            None,
            Some(edit_fn),
        )
        .with_lookups(
            Some(avatar_lookup(msg.author.clone())),
            Some(banner_lookup(ctx.http.clone(), msg.author.id)),
            chatinfo_lookup(ctx.http.clone(), msg.guild_id, is_dm, channel_id),
        )
        .with_typing(typing_lookup(ctx.http.clone(), channel_id))
        .with_user_avatar(user_avatar_lookup(ctx.http.clone()))
        .with_user_name(Some(
            msg.author
                .global_name
                .clone()
                .unwrap_or_else(|| msg.author.name.clone()),
        ))
        .with_reply_to_bot(
            // The referenced (quoted) message was authored by this bot.
            msg.referenced_message
                .as_deref()
                .map(|r| {
                    r.author.id.get() == self.self_id.load(std::sync::atomic::Ordering::Relaxed)
                })
                .unwrap_or(false),
        )
        .with_incoming_image(image_url.is_some(), image_url.map(image_lookup))
        .with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id));

        if let Err(e) = router.dispatch(fouko_ctx).await {
            tracing::warn!(error = %e, "discord handler error");
        }
    }

    async fn interaction_create(&self, ctx: SerenityContext, interaction: Interaction) {
        match interaction {
            Interaction::Component(component) => {
                handle_component(&ctx, &self.router, component).await;
            }
            Interaction::Command(command) => {
                handle_command(&ctx, &self.router, command).await;
            }
            _ => {}
        }
    }

    async fn message_delete(
        &self,
        ctx: SerenityContext,
        _channel_id: ChannelId,
        deleted: serenity::all::MessageId,
        _guild_id: Option<GuildId>,
    ) {
        // If the deleted message was one we replied to, take our replies
        // down as well - nobody wants an orphaned bot answer quoting a
        // message that no longer exists.
        let replies = match self.reply_index.lock() {
            Ok(mut index) => index.take(deleted.get()),
            Err(_) => return,
        };
        for (chan, msg_id) in replies {
            let _ = ChannelId::new(chan)
                .delete_message(&ctx.http, serenity::all::MessageId::new(msg_id))
                .await;
        }
    }
}

/// Treat a Discord slash-command invocation as if the user had typed
/// `/<name> <args>` into the chat, then forward it to the router.
async fn handle_command(ctx: &SerenityContext, router: &Router, command: CommandInteraction) {
    let channel_id = command.channel_id;
    let user_id = command.user.id.to_string();
    // A slash command carries its guild id inline: `None` means it came
    // from a DM. That's far more reliable (and free) than probing the
    // channel over HTTP, and it's what makes `/link`'s DM-only guard
    // behave correctly on servers.
    let is_dm = command.guild_id.is_none();

    // Stitch `/<name> <arg1> <arg2> ...` back together so the dispatch
    // layer sees what it would see from a real text message. Options are
    // picked by name, not position: the native USER option (the member
    // picker) becomes an id token placed before the free-form args.
    let mut text = format!("/{}", command.data.name);
    if let Some(opt) = command.data.options.iter().find(|o| o.name == "user") {
        if let Some(user_id) = opt.value.as_user_id() {
            text.push(' ');
            text.push_str(&user_id.to_string());
        }
    }
    if let Some(opt) = command.data.options.iter().find(|o| o.name == "args") {
        if let Some(v) = opt.value.as_str() {
            text.push(' ');
            text.push_str(v);
        }
    }

    // Defer the interaction so Discord knows we're working on it.
    // The first reply the handler sends replaces the "..." placeholder,
    // every next one comes through as a followup. That way buttons
    // that live on an embed stay attached to the same message as the
    // slash-command answer itself, and secondary messages (like a
    // "linked!" confirmation after /link CODE) still show up.
    let defer = CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new());
    // When the defer fails the interaction token is unusable: every
    // edit_response/followup would fail too and the user would get
    // nothing. Fall back to plain channel sends in that case.
    let use_interaction = match command.create_response(&ctx.http, defer).await {
        Ok(()) => true,
        Err(e) => {
            tracing::debug!(error = %e, "discord slash-cmd defer failed; falling back to channel sends");
            false
        }
    };

    let http = ctx.http.clone();
    let cmd_clone = command.clone();
    let first_call = Arc::new(std::sync::atomic::AtomicBool::new(true));
    // Shared with the reply closure: stays `true` until the first reply
    // flips it, so after dispatch we can tell whether anything answered.
    let awaiting_reply = first_call.clone();
    let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
        let http = http.clone();
        let command = cmd_clone.clone();
        let first = first_call.clone();
        Box::pin(async move {
            if !use_interaction {
                // Defer failed: the token is dead, post straight to the
                // channel instead so the user still gets an answer.
                first.store(false, std::sync::atomic::Ordering::SeqCst);
                for out in build_messages(&reply)? {
                    command
                        .channel_id
                        .send_message(&http, out)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                }
                return Ok(());
            }
            // First reply: edit the defered message so the slash-command
            // "bot is thinking..." placeholder turns into the real answer.
            // Every subsequent reply becomes a followup. Long text is
            // split at Discord's 2000-char cap: the first chunk edits,
            // the rest go out as followups.
            let is_first = first
                .compare_exchange(
                    true,
                    false,
                    std::sync::atomic::Ordering::SeqCst,
                    std::sync::atomic::Ordering::SeqCst,
                )
                .is_ok();
            let chunks = split_text(reply.get_text());
            let last = chunks.len().saturating_sub(1);
            if is_first {
                let attachment = build_attachment(&reply)?;
                let mut edit = EditInteractionResponse::new();
                if let Some(first_chunk) = chunks.first() {
                    edit = edit.content(first_chunk.clone());
                }
                if last == 0 {
                    if let Some(em) = reply.get_embed() {
                        edit = edit.embed(embed_for_reply(&reply, em));
                    }
                    if let Some(kb) = reply.get_keyboard() {
                        edit = edit.components(build_rows(kb));
                    }
                    if let Some(file) = attachment.clone() {
                        edit = edit.new_attachment(file);
                    }
                }
                command
                    .edit_response(&http, edit)
                    .await
                    .map_err(|e| Error::platform("discord", e))?;
                // Remaining chunks (and the embed/keyboard/media, attached
                // to the final one) follow up.
                for (i, chunk) in chunks.iter().enumerate().skip(1) {
                    let mut follow =
                        CreateInteractionResponseFollowup::new().content(chunk.clone());
                    if i == last {
                        if let Some(em) = reply.get_embed() {
                            follow = follow.add_embed(embed_for_reply(&reply, em));
                        }
                        if let Some(kb) = reply.get_keyboard() {
                            follow = follow.components(build_rows(kb));
                        }
                        if let Some(file) = attachment.clone() {
                            follow = follow.add_file(file);
                        }
                    }
                    command
                        .create_followup(&http, follow)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                }
            } else {
                for follow in build_followups(&reply)? {
                    command
                        .create_followup(&http, follow)
                        .await
                        .map_err(|e| Error::platform("discord", e))?;
                }
            }
            Ok(())
        })
    });

    let fouko_ctx = Ctx::new_full(
        PlatformKind::Discord,
        channel_id.to_string(),
        user_id,
        text,
        reply_fn,
        Some(is_dm),
        None,
    )
    .with_lookups(
        Some(avatar_lookup(command.user.clone())),
        Some(banner_lookup(ctx.http.clone(), command.user.id)),
        chatinfo_lookup(ctx.http.clone(), command.guild_id, is_dm, channel_id),
    )
    .with_typing(typing_lookup(ctx.http.clone(), channel_id))
    .with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id))
    .with_user_avatar(user_avatar_lookup(ctx.http.clone()))
    // Slash commands never carry an image.
    .with_incoming_image(false, None)
    .with_user_name(Some(
        command
            .user
            .global_name
            .clone()
            .unwrap_or_else(|| command.user.name.clone()),
    ));

    let dispatch_result = router.dispatch(fouko_ctx).await;
    if let Err(e) = &dispatch_result {
        tracing::warn!(error = %e, "discord slash-cmd handler error");
    }

    // If nothing ever answered (a command gated to another platform, or
    // one that silently bailed), the "bot is thinking..." placeholder would
    // hang forever. Clear it with a short acknowledgement - a check when
    // the handler ran fine, a warning marker when it errored - so the
    // client doesn't sit on a dead spinner or fake a success.
    if use_interaction && awaiting_reply.load(std::sync::atomic::Ordering::SeqCst) {
        let marker = if dispatch_result.is_ok() {
            "\u{2705}"
        } else {
            "\u{26A0}\u{FE0F}"
        };
        let edit = EditInteractionResponse::new().content(marker);
        if let Err(e) = command.edit_response(&ctx.http, edit).await {
            tracing::debug!(error = %e, "discord slash-cmd cleanup edit failed");
        }
    }
}

/// Handle a button / select press the same way we handle messages,
/// routing the custom-id back through the router as `callback_data`.
///
/// The incoming `component` interaction is deferred (so Discord stops
/// the spinner) and then the handler is free to either:
///
/// - send followups against the interaction token (`ctx.reply`), which
///   post fresh messages in the same channel, or
/// - edit the **original** message that carried the pressed button
///   (`ctx.edit_reply`), wiping or replacing its text/keyboard.
async fn handle_component(ctx: &SerenityContext, router: &Router, component: ComponentInteraction) {
    let channel_id = component.channel_id;
    let http = ctx.http.clone();
    let data = component.data.custom_id.clone();
    let user_id = component.user.id.to_string();
    // Same trick as slash commands: the interaction knows its own guild,
    // so a missing guild id means we're in a DM.
    let is_dm = component.guild_id.is_none();

    // Defer the component update so Discord stops the "thinking" spinner
    // on the button. DeferredUpdateMessage keeps the original message
    // intact - we can still edit it via edit_response.
    let defer = CreateInteractionResponse::Acknowledge;
    if let Err(e) = component.create_response(&http, defer).await {
        tracing::debug!(error = %e, "discord component defer failed");
    }

    let http_for_reply = http.clone();
    let comp_for_reply = component.clone();
    let reply_fn: crate::ctx::ReplyFn = Box::new(move |reply: Reply| {
        let http = http_for_reply.clone();
        let comp = comp_for_reply.clone();
        Box::pin(async move {
            // Send as a followup so the reply belongs to the same
            // interaction. That's how buttons "produce" new messages
            // in Discord; plain channel_id.send_message would work but
            // would show the bot posting out of the blue instead of
            // being tied to the user's click.
            for follow in build_followups(&reply)? {
                comp.create_followup(&http, follow)
                    .await
                    .map_err(|e| Error::platform("discord", e))?;
            }
            Ok(())
        })
    });

    // Edit callback: rewrite the message that carried the button. Overlong
    // text is edited in with the first chunk; the rest follow up. Media
    // bytes are ignored here: swapping attachments on an existing message
    // clashes with its original upload set, so edits stay text/embed-only
    // (matching the Telegram edit path).
    let http_for_edit = http.clone();
    let comp_for_edit = component.clone();
    let edit_fn: crate::ctx::EditFn = Arc::new(move |reply: Reply| {
        let http = http_for_edit.clone();
        let comp = comp_for_edit.clone();
        Box::pin(async move {
            let chunks = split_text(reply.get_text());
            let mut edit = serenity::all::EditMessage::new();
            // Always clear embeds/components first so stale ones don't
            // linger when the new reply doesn't include them.
            edit = edit.content(chunks.first().cloned().unwrap_or_default());
            let mut embeds = Vec::new();
            if let Some(em) = reply.get_embed() {
                embeds.push(to_discord_embed(em));
            }
            edit = edit.embeds(embeds);
            let components = match reply.get_keyboard() {
                Some(kb) => build_rows(kb),
                None => Vec::new(),
            };
            edit = edit.components(components);
            let mut msg = comp.message.clone();
            msg.edit(&http, edit)
                .await
                .map_err(|e| Error::platform("discord", e))?;
            for chunk in chunks.iter().skip(1) {
                let follow = CreateInteractionResponseFollowup::new().content(chunk.clone());
                comp.create_followup(&http, follow)
                    .await
                    .map_err(|e| Error::platform("discord", e))?;
            }
            Ok(())
        })
    });

    let fouko_ctx = Ctx::new_with_edit(
        PlatformKind::Discord,
        channel_id.to_string(),
        user_id,
        data.clone(),
        reply_fn,
        Some(is_dm),
        Some(data),
        Some(edit_fn),
    )
    .with_lookups(
        Some(avatar_lookup(component.user.clone())),
        Some(banner_lookup(ctx.http.clone(), component.user.id)),
        chatinfo_lookup(ctx.http.clone(), component.guild_id, is_dm, channel_id),
    )
    .with_typing(typing_lookup(ctx.http.clone(), channel_id))
    .with_user_avatar(user_avatar_lookup(ctx.http.clone()))
    .with_user_name(Some(
        component
            .user
            .global_name
            .clone()
            .unwrap_or_else(|| component.user.name.clone()),
    ))
    // Button presses never carry an image.
    .with_incoming_image(false, None)
    .with_temp_reply(temp_reply_lookup(ctx.http.clone(), channel_id));

    if let Err(e) = router.dispatch(fouko_ctx).await {
        tracing::warn!(error = %e, "discord interaction handler error");
    }
}

/// Discord caps message content at 2000 chars.
const DISCORD_LIMIT: usize = 2000;

/// Split reply text at Discord's content cap. Empty text yields no chunks.
fn split_text(text: &str) -> Vec<String> {
    crate::util::split_chunks(text, DISCORD_LIMIT)
}

/// Discord's upload cap on a regular (non-boosted) server. The builder's
/// shared cap is 25 MiB for video/audio because Telegram takes that much,
/// so this adapter re-checks and rejects anything Discord won't accept.
const DISCORD_MAX_ATTACHMENT_BYTES: usize = 8 * 1024 * 1024;

/// Build the attachment carrying a reply's raw media bytes, if any.
/// Errors when the media exceeds the shared size cap, or Discord's own
/// 8 MiB upload cap for video/audio (photos already share that cap).
fn build_attachment(reply: &Reply) -> Result<Option<CreateAttachment>> {
    match reply.get_attachment() {
        Some((bytes, name, kind)) => {
            crate::keyboard::check_attachment_size(bytes, kind)?;
            if bytes.len() > DISCORD_MAX_ATTACHMENT_BYTES {
                return Err(Error::Other(format!(
                    "attachment too large for discord: {} bytes (max {DISCORD_MAX_ATTACHMENT_BYTES})",
                    bytes.len()
                )));
            }
            Ok(Some(CreateAttachment::bytes(bytes.to_vec(), name)))
        }
        None => Ok(None),
    }
}

/// Convert a reply's embed, pointing its image at the uploaded attachment
/// (`attachment://filename`) when the reply carries raw image bytes, so
/// the picture renders inside the embed instead of as a loose file.
/// Video/audio attachments stay off the embed - Discord can't inline
/// them there, so the file simply rides next to the message.
fn embed_for_reply(reply: &Reply, em: &FkEmbed) -> CreateEmbed {
    let converted = to_discord_embed(em);
    match reply.get_image_bytes() {
        Some((_, name)) => converted.attachment(name.to_owned()),
        None => converted,
    }
}

/// Render a [`Reply`] into one or more channel messages, splitting long
/// text at Discord's cap. The embed, keyboard and media attachment ride
/// on the last message so buttons sit under the final chunk.
fn build_messages(reply: &Reply) -> Result<Vec<CreateMessage>> {
    let chunks = split_text(reply.get_text());
    let attachment = build_attachment(reply)?;
    let mut out = Vec::new();
    let last = chunks.len().saturating_sub(1);
    if chunks.is_empty() {
        let mut msg = CreateMessage::new();
        if let Some(em) = reply.get_embed() {
            msg = msg.add_embed(embed_for_reply(reply, em));
        }
        if let Some(kb) = reply.get_keyboard() {
            msg = msg.components(build_rows(kb));
        }
        if let Some(file) = attachment {
            msg = msg.add_file(file);
        }
        out.push(msg);
        return Ok(out);
    }
    for (i, chunk) in chunks.iter().enumerate() {
        let mut msg = CreateMessage::new().content(chunk.clone());
        if i == last {
            if let Some(em) = reply.get_embed() {
                msg = msg.add_embed(embed_for_reply(reply, em));
            }
            if let Some(kb) = reply.get_keyboard() {
                msg = msg.components(build_rows(kb));
            }
            if let Some(file) = attachment.clone() {
                msg = msg.add_file(file);
            }
        }
        out.push(msg);
    }
    Ok(out)
}

/// Same as [`build_messages`] but for interaction followups.
fn build_followups(reply: &Reply) -> Result<Vec<CreateInteractionResponseFollowup>> {
    let chunks = split_text(reply.get_text());
    let attachment = build_attachment(reply)?;
    let mut out = Vec::new();
    let last = chunks.len().saturating_sub(1);
    if chunks.is_empty() {
        let mut follow = CreateInteractionResponseFollowup::new();
        if let Some(em) = reply.get_embed() {
            follow = follow.add_embed(embed_for_reply(reply, em));
        }
        if let Some(kb) = reply.get_keyboard() {
            follow = follow.components(build_rows(kb));
        }
        if let Some(file) = attachment {
            follow = follow.add_file(file);
        }
        out.push(follow);
        return Ok(out);
    }
    for (i, chunk) in chunks.iter().enumerate() {
        let mut follow = CreateInteractionResponseFollowup::new().content(chunk.clone());
        if i == last {
            if let Some(em) = reply.get_embed() {
                follow = follow.add_embed(embed_for_reply(reply, em));
            }
            if let Some(kb) = reply.get_keyboard() {
                follow = follow.components(build_rows(kb));
            }
            if let Some(file) = attachment.clone() {
                follow = follow.add_file(file);
            }
        }
        out.push(follow);
    }
    Ok(out)
}

/// Discord hard limits: at most 5 action rows per message, at most 5
/// buttons per row. Telegram has no such caps, so keyboards built for it
/// can overflow; repack buttons greedily into full rows and drop the
/// overflow (with a warning) instead of having Discord reject the whole
/// message.
/// Map our platform-neutral presence onto serenity's activity type. A
/// streaming presence with an unparseable URL degrades to "playing" so a
/// typo doesn't strip the bot of its status entirely.
fn to_activity(p: &crate::platform::Presence) -> Option<serenity::gateway::ActivityData> {
    use crate::platform::PresenceKind;
    use serenity::gateway::ActivityData;
    let mut activity = match p.kind {
        PresenceKind::Playing => ActivityData::playing(&p.name),
        PresenceKind::Streaming => {
            let url = p.url.as_deref().unwrap_or_default();
            match ActivityData::streaming(&p.name, url) {
                Ok(a) => a,
                Err(e) => {
                    tracing::warn!(error = %e, url, "discord: bad stream url, presence degrades to playing");
                    ActivityData::playing(&p.name)
                }
            }
        }
        PresenceKind::Listening => ActivityData::listening(&p.name),
        PresenceKind::Watching => ActivityData::watching(&p.name),
        PresenceKind::Competing => ActivityData::competing(&p.name),
        PresenceKind::Custom => ActivityData::custom(&p.name),
    };
    // The custom kind carries its text in `state` already; for the rest
    // the state is the optional small line under the activity name.
    if p.kind != crate::platform::PresenceKind::Custom {
        activity.state = p.state.clone();
    }
    Some(activity)
}

fn to_status(s: crate::platform::PresenceStatus) -> serenity::model::user::OnlineStatus {
    use crate::platform::PresenceStatus;
    use serenity::model::user::OnlineStatus;
    match s {
        PresenceStatus::Online => OnlineStatus::Online,
        PresenceStatus::Idle => OnlineStatus::Idle,
        PresenceStatus::DoNotDisturb => OnlineStatus::DoNotDisturb,
        PresenceStatus::Invisible => OnlineStatus::Invisible,
    }
}

fn build_rows(kb: &crate::keyboard::Keyboard) -> Vec<CreateActionRow> {
    const MAX_ROWS: usize = 5;
    const MAX_PER_ROW: usize = 5;

    let make = |b: &crate::keyboard::Button| match &b.kind {
        ButtonKind::Callback(id) => CreateButton::new(id.clone())
            .label(b.label())
            .style(ButtonStyle::Primary),
        ButtonKind::Url(u) => CreateButton::new_link(u.clone()).label(b.label()),
        // Discord has no Mini App concept, so a web_app button degrades
        // to a plain link.
        ButtonKind::WebApp(u) => CreateButton::new_link(u.clone()).label(b.label()),
    };

    let fits = kb.rows().len() <= MAX_ROWS && kb.rows().iter().all(|r| r.len() <= MAX_PER_ROW);
    if fits {
        return kb
            .rows()
            .iter()
            .map(|row| CreateActionRow::Buttons(row.iter().map(make).collect()))
            .collect();
    }

    // Repack: flatten and refill rows to the caps, keeping button order.
    let total: usize = kb.rows().iter().map(|r| r.len()).sum();
    let cap = MAX_ROWS * MAX_PER_ROW;
    if total > cap {
        tracing::warn!(
            total,
            cap,
            "discord: keyboard overflow, dropping extra buttons"
        );
    }
    let mut rows: Vec<CreateActionRow> = Vec::with_capacity(MAX_ROWS);
    let mut current: Vec<CreateButton> = Vec::with_capacity(MAX_PER_ROW);
    for b in kb.rows().iter().flatten().take(cap) {
        current.push(make(b));
        if current.len() == MAX_PER_ROW {
            rows.push(CreateActionRow::Buttons(std::mem::take(&mut current)));
        }
    }
    if !current.is_empty() {
        rows.push(CreateActionRow::Buttons(current));
    }
    rows
}

/// Discord requires slash-command names to start with a letter and only
/// contain `[a-z0-9_-]`. Everything else (starting with a digit like
/// `8ball`, containing spaces, etc.) is rejected server-side, so we
/// filter out non-conforming names before the register call.
fn is_valid_slash_name(s: &str) -> bool {
    let mut chars = s.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_lowercase() {
        return false;
    }
    chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
        && s.len() <= 32
}

/// Truncate `s` to at most `max_chars` Unicode characters without
/// slicing in the middle of a multi-byte codepoint.
fn truncate_chars(s: &str, max_chars: usize) -> String {
    let mut out = String::new();
    for (i, ch) in s.chars().enumerate() {
        if i >= max_chars {
            break;
        }
        out.push(ch);
    }
    out
}

/// Translate a cross-platform [`FkEmbed`] into a [`CreateEmbed`] that
/// serenity knows how to send.
fn to_discord_embed(src: &FkEmbed) -> CreateEmbed {
    let mut em = CreateEmbed::new();
    if let Some(t) = src.get_title() {
        em = em.title(t);
    }
    if let Some(u) = src.get_url() {
        em = em.url(u);
    }
    if let Some(d) = src.get_description() {
        em = em.description(d);
    }
    if let Some(c) = src.get_color() {
        em = em.colour(c);
    }
    for f in src.get_fields() {
        em = em.field(f.name(), f.value(), f.is_inline());
    }
    if let Some(url) = src.get_image() {
        em = em.image(url);
    }
    if let Some(url) = src.get_thumbnail() {
        em = em.thumbnail(url);
    }
    if let Some(foot) = src.get_footer() {
        em = em.footer(CreateEmbedFooter::new(foot));
    }
    em
}

/// Avatar lookup: Discord builds the CDN URL locally from the user's id
/// and avatar hash, so no network call is needed. `face()` always yields a
/// URL, falling back to the default avatar.
fn avatar_lookup(user: User) -> UrlFn {
    Arc::new(move || {
        let user = user.clone();
        Box::pin(async move { Ok(Some(user.face())) })
    })
}

/// Banner lookup: the banner hash isn't in gateway events, so we fetch the
/// full user over REST and read its banner URL. Returns `None` if the user
/// has no banner.
fn banner_lookup(http: Arc<Http>, user_id: UserId) -> UrlFn {
    Arc::new(move || {
        let http = http.clone();
        Box::pin(async move {
            match user_id.to_user(&http).await {
                Ok(user) => Ok(user.banner_url()),
                Err(e) => Err(Error::platform("discord", e)),
            }
        })
    })
}

/// Chat-info lookup: for a guild command, fetch member counts and icon via
/// REST; for a DM there's no server, so report a minimal private ChatInfo.
fn chatinfo_lookup(
    http: Arc<Http>,
    guild_id: Option<GuildId>,
    is_dm: bool,
    channel_id: ChannelId,
) -> Option<ChatInfoFn> {
    Some(Arc::new(move || {
        let http = http.clone();
        Box::pin(async move {
            match guild_id {
                Some(gid) => match gid.to_partial_guild_with_counts(&http).await {
                    Ok(g) => Ok(ChatInfo {
                        id: gid.to_string(),
                        title: Some(g.name.clone()),
                        member_count: g.approximate_member_count,
                        icon_url: g.icon_url(),
                        description: g.description.clone(),
                        is_private: false,
                    }),
                    Err(e) => Err(Error::platform("discord", e)),
                },
                None => Ok(ChatInfo {
                    id: channel_id.to_string(),
                    is_private: is_dm,
                    ..Default::default()
                }),
            }
        })
    }))
}

/// Typing indicator for a Discord channel. Lasts ~10s or until the bot
/// posts, which comfortably covers most command work.
fn typing_lookup(http: Arc<Http>, channel_id: ChannelId) -> Option<crate::ctx::TypingFn> {
    Some(Arc::new(move || {
        let http = http.clone();
        Box::pin(async move {
            channel_id
                .broadcast_typing(&http)
                .await
                .map_err(|e| Error::platform("discord", e))?;
            Ok(())
        })
    }))
}

/// Send a message to a channel and delete it after a delay - for transient
/// notices that shouldn't clutter the chat.
fn temp_reply_lookup(http: Arc<Http>, channel_id: ChannelId) -> Option<crate::ctx::TempReplyFn> {
    Some(Arc::new(move |reply: Reply, secs: u64| {
        let http = http.clone();
        Box::pin(async move {
            let mut sent_ids = Vec::new();
            for msg in build_messages(&reply)? {
                let sent = channel_id
                    .send_message(&http, msg)
                    .await
                    .map_err(|e| Error::platform("discord", e))?;
                sent_ids.push(sent.id);
            }
            tokio::spawn(async move {
                tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
                for id in sent_ids {
                    let _ = channel_id.delete_message(&http, id).await;
                }
            });
            Ok(())
        })
    }))
}

/// Avatar lookup for an arbitrary user id, via REST. Backs commands like
/// "/avatar @someone" where the target isn't the interaction's author.
fn user_avatar_lookup(http: Arc<Http>) -> Option<crate::ctx::UserUrlFn> {
    Some(Arc::new(move |raw_id: String| {
        let http = http.clone();
        Box::pin(async move {
            let Ok(id) = raw_id.parse::<u64>() else {
                return Ok(None);
            };
            match UserId::new(id).to_user(&http).await {
                Ok(user) => Ok(Some(user.face())),
                Err(_) => Ok(None),
            }
        })
    }))
}

/// Hard cap on a downloaded incoming image, so one oversized upload
/// can't balloon memory.
const MAX_INCOMING_IMAGE_BYTES: usize = 10 * 1024 * 1024;

/// Extensions counted as images when an attachment has no content type.
const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];

/// Whether an attachment is an image, by content type first and filename
/// extension as a fallback (some clients omit the content type).
fn is_image_attachment(a: &serenity::all::Attachment) -> bool {
    if let Some(ct) = &a.content_type {
        return ct.starts_with("image/");
    }
    a.filename
        .rsplit('.')
        .next()
        .map(|ext| IMAGE_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
        .unwrap_or(false)
}

/// Build the lazy download of an image attachment by its CDN URL.
/// Capped at [`MAX_INCOMING_IMAGE_BYTES`].
fn image_lookup(url: String) -> crate::ctx::ImageFn {
    Arc::new(move || {
        let url = url.clone();
        Box::pin(async move {
            let resp = reqwest::get(&url)
                .await
                .map_err(|e| Error::platform("discord", format!("image download failed: {e}")))?;
            if !resp.status().is_success() {
                return Err(Error::platform(
                    "discord",
                    format!("image download HTTP {}", resp.status().as_u16()),
                ));
            }
            let mut resp = resp;
            let mut bytes = Vec::new();
            while let Some(chunk) = resp
                .chunk()
                .await
                .map_err(|e| Error::platform("discord", format!("image download failed: {e}")))?
            {
                if bytes.len() + chunk.len() > MAX_INCOMING_IMAGE_BYTES {
                    return Err(Error::platform("discord", "incoming image too large"));
                }
                bytes.extend_from_slice(&chunk);
            }
            Ok(Some(bytes))
        })
    })
}

/// Pretty name for a user: display name first, plus the unique handle in
/// brackets when it differs, e.g. "Vall (@vall_)".
fn discord_display_name(user: &User) -> String {
    let display = user
        .global_name
        .clone()
        .unwrap_or_else(|| user.name.clone());
    if display == user.name {
        display
    } else {
        format!("{display} (@{})", user.name)
    }
}

/// `true` when a serenity error is an HTTP 404 from the REST API.
fn is_not_found(e: &serenity::Error) -> bool {
    matches!(
        e,
        serenity::Error::Http(http) if http.status_code() == Some(serenity::http::StatusCode::NOT_FOUND)
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::keyboard::{Button, Keyboard};

    #[test]
    fn keyboard_within_discord_limits_passes_through() {
        let mut kb = Keyboard::new();
        for r in 0..5 {
            kb = kb.row((0..5).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
        }
        assert_eq!(build_rows(&kb).len(), 5);
    }

    #[test]
    fn oversized_keyboard_is_repacked_and_capped() {
        // 8 rows of 2 = 16 buttons: fits in 25 but needs repacking into
        // at most 5 rows of 5.
        let mut kb = Keyboard::new();
        for r in 0..8 {
            kb = kb.row((0..2).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
        }
        let rows = build_rows(&kb);
        assert!(rows.len() <= 5);

        // 30 buttons overflow the 25 cap: extra ones are dropped.
        let mut kb = Keyboard::new();
        for r in 0..6 {
            kb = kb.row((0..5).map(|i| Button::callback(format!("b{r}{i}"), format!("c{r}{i}"))));
        }
        assert_eq!(build_rows(&kb).len(), 5);
    }
}