Skip to content

Commit 195d9d4

Browse files
vsumnerclaude
andcommitted
feat(card): add thumbnail, image, author, timestamp, footer icon_url
- Add `thumbnail` and `image` fields to `Card` for small/large embed images - Add `author` field for branded attribution bars at the top of embeds - Add `timestamp` field for ISO 8601 timestamps in the embed footer area - Expand `footer` from a plain string to `CardFooter { text, icon_url }` - Update `build_embed()` to map all new fields via serenity's builder API - Fix `EnvGuard` missing `ANTHROPIC_AUTH_TOKEN` and `GITHUB_COPILOT_API_KEY` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f81f39 commit 195d9d4

4 files changed

Lines changed: 123 additions & 18 deletions

File tree

src/config.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ mod tests {
6060

6161
impl EnvGuard {
6262
fn new() -> Self {
63-
// NOTE: Keep in sync with provider env vars that affect test behavior
64-
const KEYS: [&str; 27] = [
63+
const KEYS: &[&str] = &[
6564
"SPACEBOT_DIR",
6665
"SPACEBOT_DEPLOYMENT",
6766
"SPACEBOT_CRON_TIMEZONE",
6867
"SPACEBOT_USER_TIMEZONE",
6968
"ANTHROPIC_API_KEY",
7069
"ANTHROPIC_BASE_URL",
70+
"ANTHROPIC_AUTH_TOKEN",
7171
"ANTHROPIC_OAUTH_TOKEN",
7272
"OPENAI_API_KEY",
7373
"OPENROUTER_API_KEY",
@@ -89,14 +89,15 @@ mod tests {
8989
"MINIMAX_CN_API_KEY",
9090
"MOONSHOT_API_KEY",
9191
"ZAI_CODING_PLAN_API_KEY",
92+
"GITHUB_COPILOT_API_KEY",
9293
];
9394

9495
let vars = KEYS
95-
.into_iter()
96-
.map(|key| (key, std::env::var(key).ok()))
96+
.iter()
97+
.map(|&key| (key, std::env::var(key).ok()))
9798
.collect::<Vec<_>>();
9899

99-
for key in KEYS {
100+
for &key in KEYS {
100101
unsafe {
101102
std::env::remove_var(key);
102103
}

src/lib.rs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -742,9 +742,20 @@ impl OutboundResponse {
742742
}
743743
}
744744
if let Some(footer) = &card.footer
745-
&& !footer.trim().is_empty()
745+
&& !footer.text.trim().is_empty()
746746
{
747-
lines.push(footer.trim().to_string());
747+
lines.push(footer.text.trim().to_string());
748+
}
749+
if let Some(author) = &card.author
750+
&& !author.name.trim().is_empty()
751+
{
752+
lines.push(author.name.trim().to_string());
753+
}
754+
if let Some(timestamp) = &card.timestamp
755+
&& !timestamp.trim().is_empty()
756+
&& chrono::DateTime::parse_from_rfc3339(timestamp.trim()).is_ok()
757+
{
758+
lines.push(timestamp.trim().to_string());
748759
}
749760
if !lines.is_empty() {
750761
sections.push(lines.join("\n\n"));
@@ -763,7 +774,15 @@ pub struct Card {
763774
pub url: Option<String>,
764775
#[serde(default)]
765776
pub fields: Vec<CardField>,
766-
pub footer: Option<String>,
777+
pub footer: Option<CardFooter>,
778+
/// Small image in the top-right corner of the embed.
779+
pub thumbnail: Option<CardImage>,
780+
/// Large image at the bottom of the embed.
781+
pub image: Option<CardImage>,
782+
/// Author bar at the top of the embed.
783+
pub author: Option<CardAuthor>,
784+
/// ISO 8601 timestamp displayed in the footer area.
785+
pub timestamp: Option<String>,
767786
}
768787

769788
/// A field within a generic Card.
@@ -775,6 +794,27 @@ pub struct CardField {
775794
pub inline: bool,
776795
}
777796

797+
/// Footer for a Card.
798+
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
799+
pub struct CardFooter {
800+
pub text: String,
801+
pub icon_url: Option<String>,
802+
}
803+
804+
/// Image (thumbnail or main image) for a Card.
805+
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
806+
pub struct CardImage {
807+
pub url: String,
808+
}
809+
810+
/// Author for a Card.
811+
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
812+
pub struct CardAuthor {
813+
pub name: String,
814+
pub url: Option<String>,
815+
pub icon_url: Option<String>,
816+
}
817+
778818
/// Container for interactive elements (maps to ActionRows in Discord).
779819
/// In Discord, an action row can contain either buttons or a single select menu.
780820
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]

src/messaging/discord.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ use arc_swap::ArcSwap;
1010
use async_trait::async_trait;
1111
use serenity::all::{
1212
ButtonStyle, ChannelId, ChannelType, Context, CreateActionRow, CreateAttachment, CreateButton,
13-
CreateEmbed, CreateEmbedFooter, CreateInteractionResponse, CreateInteractionResponseMessage,
14-
CreateMessage, CreatePoll, CreatePollAnswer, CreateSelectMenu, CreateSelectMenuKind,
15-
CreateSelectMenuOption, CreateThread, EditMessage, EventHandler, GatewayIntents, GetMessages,
16-
Http, Interaction, Message, MessageId, ReactionType, Ready, ShardManager, User, UserId,
13+
CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter, CreateInteractionResponse,
14+
CreateInteractionResponseMessage, CreateMessage, CreatePoll, CreatePollAnswer,
15+
CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage,
16+
EventHandler, GatewayIntents, GetMessages, Http, Interaction, Message, MessageId, ReactionType,
17+
Ready, ShardManager, Timestamp, User, UserId,
1718
};
1819
use std::collections::HashMap;
1920
use std::sync::Arc;
@@ -1038,7 +1039,39 @@ fn build_embed(card: &crate::Card) -> CreateEmbed {
10381039
embed = embed.url(url);
10391040
}
10401041
if let Some(footer) = &card.footer {
1041-
embed = embed.footer(CreateEmbedFooter::new(footer));
1042+
let footer_text = footer.text.trim();
1043+
if !footer_text.is_empty() {
1044+
let mut footer_builder = CreateEmbedFooter::new(footer_text);
1045+
if let Some(icon_url) = &footer.icon_url {
1046+
footer_builder = footer_builder.icon_url(icon_url);
1047+
}
1048+
embed = embed.footer(footer_builder);
1049+
}
1050+
}
1051+
if let Some(thumbnail) = &card.thumbnail {
1052+
embed = embed.thumbnail(&thumbnail.url);
1053+
}
1054+
if let Some(image) = &card.image {
1055+
embed = embed.image(&image.url);
1056+
}
1057+
if let Some(author) = &card.author {
1058+
let author_name = author.name.trim();
1059+
if !author_name.is_empty() {
1060+
let mut author_builder = CreateEmbedAuthor::new(author_name);
1061+
if let Some(url) = &author.url {
1062+
author_builder = author_builder.url(url);
1063+
}
1064+
if let Some(icon_url) = &author.icon_url {
1065+
author_builder = author_builder.icon_url(icon_url);
1066+
}
1067+
embed = embed.author(author_builder);
1068+
}
1069+
}
1070+
if let Some(timestamp) = &card.timestamp {
1071+
match timestamp.parse::<Timestamp>() {
1072+
Ok(ts) => embed = embed.timestamp(ts),
1073+
Err(e) => tracing::warn!(timestamp, %e, "invalid ISO 8601 timestamp in card, skipping"),
1074+
}
10421075
}
10431076

10441077
for (i, field) in card.fields.iter().enumerate() {
@@ -1315,10 +1348,7 @@ mod tests {
13151348
let cards = vec![Card {
13161349
title: Some("Status".into()),
13171350
description: Some("All green".into()),
1318-
color: None,
1319-
url: None,
1320-
fields: Vec::new(),
1321-
footer: None,
1351+
..Default::default()
13221352
}];
13231353

13241354
let parts = prepare_rich_message_parts(String::new(), &cards, &[], None);

src/tools/reply.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,41 @@ impl Tool for ReplyTool {
285285
"required": ["name", "value"]
286286
}
287287
},
288-
"footer": { "type": "string" }
288+
"footer": {
289+
"type": "object",
290+
"properties": {
291+
"text": { "type": "string" },
292+
"icon_url": { "type": "string", "format": "uri" }
293+
},
294+
"required": ["text"]
295+
},
296+
"thumbnail": {
297+
"type": "object",
298+
"description": "Small image in the top-right corner of the embed.",
299+
"properties": { "url": { "type": "string", "format": "uri" } },
300+
"required": ["url"]
301+
},
302+
"image": {
303+
"type": "object",
304+
"description": "Large image at the bottom of the embed.",
305+
"properties": { "url": { "type": "string", "format": "uri" } },
306+
"required": ["url"]
307+
},
308+
"author": {
309+
"type": "object",
310+
"description": "Author bar at the top of the embed.",
311+
"properties": {
312+
"name": { "type": "string" },
313+
"url": { "type": "string", "format": "uri" },
314+
"icon_url": { "type": "string", "format": "uri" }
315+
},
316+
"required": ["name"]
317+
},
318+
"timestamp": {
319+
"type": "string",
320+
"format": "date-time",
321+
"description": "ISO 8601 timestamp (e.g. 2024-01-01T00:00:00Z) displayed in the footer area."
322+
}
289323
}
290324
}
291325
},

0 commit comments

Comments
 (0)