Skip to content

Commit 7c827cf

Browse files
authored
Merge pull request #467 from spacedriveapp/feat/card-embed-fields
feat(card): add thumbnail, image, author, timestamp, footer icon_url
2 parents 2407780 + 59569dd commit 7c827cf

4 files changed

Lines changed: 112 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,17 @@ impl OutboundResponse {
740740
{
741741
lines.push(footer.text.trim().to_string());
742742
}
743+
if let Some(author) = &card.author
744+
&& !author.name.trim().is_empty()
745+
{
746+
lines.push(author.name.trim().to_string());
747+
}
748+
if let Some(timestamp) = &card.timestamp
749+
&& !timestamp.trim().is_empty()
750+
&& chrono::DateTime::parse_from_rfc3339(timestamp.trim()).is_ok()
751+
{
752+
lines.push(timestamp.trim().to_string());
753+
}
743754
if !lines.is_empty() {
744755
sections.push(lines.join("\n\n"));
745756
}
@@ -759,6 +770,14 @@ pub struct Card {
759770
pub fields: Vec<CardField>,
760771
#[serde(default, deserialize_with = "deserialize_card_footer")]
761772
pub footer: Option<CardFooter>,
773+
/// Small image in the top-right corner of the embed.
774+
pub thumbnail: Option<CardImage>,
775+
/// Large image at the bottom of the embed.
776+
pub image: Option<CardImage>,
777+
/// Author bar at the top of the embed.
778+
pub author: Option<CardAuthor>,
779+
/// ISO 8601 timestamp displayed in the footer area.
780+
pub timestamp: Option<String>,
762781
}
763782

764783
/// A card footer that can be either a plain string or a structured object.
@@ -866,6 +885,20 @@ pub struct CardField {
866885
pub inline: bool,
867886
}
868887

888+
/// Image (thumbnail or main image) for a Card.
889+
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
890+
pub struct CardImage {
891+
pub url: String,
892+
}
893+
894+
/// Author for a Card.
895+
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
896+
pub struct CardAuthor {
897+
pub name: String,
898+
pub url: Option<String>,
899+
pub icon_url: Option<String>,
900+
}
901+
869902
/// Container for interactive elements (maps to ActionRows in Discord).
870903
/// In Discord, an action row can contain either buttons or a single select menu.
871904
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]

src/messaging/discord.rs

Lines changed: 38 additions & 12 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,11 +1039,39 @@ fn build_embed(card: &crate::Card) -> CreateEmbed {
10381039
embed = embed.url(url);
10391040
}
10401041
if let Some(footer) = &card.footer {
1041-
let mut discord_footer = CreateEmbedFooter::new(footer.text.clone());
1042-
if let Some(icon_url) = &footer.icon_url {
1043-
discord_footer = discord_footer.icon_url(icon_url);
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"),
10441074
}
1045-
embed = embed.footer(discord_footer);
10461075
}
10471076

10481077
for (i, field) in card.fields.iter().enumerate() {
@@ -1319,10 +1348,7 @@ mod tests {
13191348
let cards = vec![Card {
13201349
title: Some("Status".into()),
13211350
description: Some("All green".into()),
1322-
color: None,
1323-
url: None,
1324-
fields: Vec::new(),
1325-
footer: None,
1351+
..Default::default()
13261352
}];
13271353

13281354
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)