Initial commit
CI and release / Detect release commit (push) Successful in 2m42s
CI and release / Run tests (push) Successful in 7m18s
CI and release / Build and publish container (push) Failing after 5m12s
CI and release / Create release (push) Skipped

This commit is contained in:
Elias Wendland committed 2026-08-07 11:54:42 +02:00
commit 5a5f817df7
55 files changed
+3919

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
use crate::messaging::{edit_response_message, trace_message};
use crate::{Context, Error};
use tracing::debug;
/// Get a link to the git repo
#[poise::command(slash_command, prefix_command)]
pub async fn git(ctx: Context<'_>) -> Result<(), Error> {
debug!(
"git command ran by {} in {}",
ctx.author().name,
ctx.channel_id().get()
);
let response = ctx.say("Processing...").await?;
let msg = "Git repo: https://git.ewenlau.net/ewenlau/tg-archive-bot\nIf you'd like to contribute, contact <@1389325880853270569> to get an account.";
edit_response_message(&response, ctx, msg, true).await?;
trace_message(
msg,
ctx.channel_id().get().to_string(),
ctx.guild_id().unwrap().get().to_string(),
)
.await;
Ok(())
}
+7
View File
@@ -0,0 +1,7 @@
pub mod git;
pub mod ping;
pub mod version;
pub use git::git;
pub use ping::ping;
pub use version::version;
+37
View File
@@ -0,0 +1,37 @@
use crate::messaging::trace_message;
use crate::{Context, Error};
use poise::serenity_prelude as sere;
use tracing::{debug, trace};
/// Get the bot's current ping (back and forth)
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Context<'_>) -> Result<(), Error> {
trace!(
"ping command called by user {} in guild {}",
ctx.author().id.get(),
ctx.guild_id().unwrap().get()
);
trace!("Calculating ping");
let command_time_in_microseconds = ctx.created_at().timestamp_micros();
trace!("Saved command time in microseconds");
let current_time_in_microseconds = sere::Timestamp::now().timestamp_micros();
trace!("Saved current time in microseconds");
let ping = (current_time_in_microseconds - command_time_in_microseconds) / 500;
trace!("Calculated ping: {}", ping);
// We divide by 1000 since we get the time in microseconds, and then multiply by 2 to get the roundtrip time
// This is arguably not the best way to calculate ping, since it assumes perfect clock accuracy, but I'm lazy
let msg = format!("Current ping: {} ms", ping);
trace_message(
&msg,
ctx.channel_id().to_string(),
ctx.guild_id().unwrap().to_string(),
)
.await;
ctx.say(msg).await?;
debug!(
"Ping command performed for user {} with ping {}",
ctx.author().name,
ping
);
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
use crate::messaging::trace_message;
use crate::{Context, Error};
use tracing::{debug, trace};
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
/// Get the bot's current version
#[poise::command(slash_command, prefix_command)]
pub async fn version(ctx: Context<'_>) -> Result<(), Error> {
trace!(
"version command called by user {} in guild {}",
ctx.author().id.get(),
ctx.guild_id().unwrap().get()
);
trace!("Loading embedded version information");
let msg = format!("Current version:\n{VERSION}");
trace_message(
&msg,
ctx.channel_id().to_string(),
ctx.guild_id().unwrap().to_string(),
)
.await;
ctx.say(msg).await?;
debug!(
"Version command performed for user {} with version information",
ctx.author().name
);
Ok(())
}
+103
View File
@@ -0,0 +1,103 @@
use crate::{Data, Error};
use poise::serenity_prelude as sere;
use tracing::{debug, trace};
pub async fn event_handler(
ctx: &sere::Context,
event: &sere::FullEvent,
_framework: poise::FrameworkContext<'_, Data, Error>,
user_data: &Data,
) -> Result<(), Error> {
match event {
sere::FullEvent::Message { new_message } => {
crate::message_archive::record_message(ctx, new_message, &user_data.pool).await?;
}
sere::FullEvent::MessageUpdate {
old_if_available,
new,
event,
} => {
let old_guild_id = old_if_available
.as_ref()
.and_then(|message| message.guild_id);
let mut message = if let Some(new_message) = new {
new_message.clone()
} else if let Some(old_message) = old_if_available {
let mut updated_message = old_message.clone();
event.apply_to_message(&mut updated_message);
updated_message
} else {
debug!(
"Updated message {} was not cached, fetching it from Discord",
event.id.get()
);
event.channel_id.message(ctx, event.id).await?
};
message.guild_id =
updated_message_guild_id(message.guild_id, event.guild_id, old_guild_id);
if message.guild_id.is_none() {
debug!(
"Updated message {} had no server ID in the gateway event, fetching it from Discord",
event.id.get()
);
message = event.channel_id.message(ctx, event.id).await?;
}
crate::message_archive::record_message_edit(ctx, &message, &user_data.pool).await?;
}
sere::FullEvent::MessageDelete {
deleted_message_id, ..
} => {
trace!(
"Message {} was deleted from Discord, retaining archived copy",
deleted_message_id.get()
);
}
sere::FullEvent::MessageDeleteBulk {
multiple_deleted_messages_ids,
..
} => {
trace!(
"{} messages were deleted from Discord, retaining archived copies",
multiple_deleted_messages_ids.len()
);
}
_ => {}
}
Ok(())
}
fn updated_message_guild_id(
message_guild_id: Option<sere::GuildId>,
event_guild_id: Option<sere::GuildId>,
old_guild_id: Option<sere::GuildId>,
) -> Option<sere::GuildId> {
message_guild_id.or(event_guild_id).or(old_guild_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retains_the_cached_server_when_an_update_omits_it() {
let guild_id = sere::GuildId::new(123);
assert_eq!(
updated_message_guild_id(None, None, Some(guild_id)),
Some(guild_id)
);
}
#[test]
fn prefers_the_updated_message_server() {
let message_guild_id = sere::GuildId::new(123);
let old_guild_id = sere::GuildId::new(456);
assert_eq!(
updated_message_guild_id(Some(message_guild_id), None, Some(old_guild_id)),
Some(message_guild_id)
);
}
}
+175
View File
@@ -0,0 +1,175 @@
use poise::serenity_prelude as sere;
use std::env;
use tracing::{debug, error, info, trace};
use tracing_subscriber::EnvFilter;
pub mod commands;
pub mod event_handler;
pub mod message_archive;
pub mod messaging;
pub mod web;
pub struct Data {
pub pool: sqlx::PgPool,
}
// Define standard types for Poise context, commands, and errors.
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main]
async fn main() {
dotenv::dotenv().ok();
let log_level = env::var("TG_BOT_LOG").unwrap_or_else(|_| "info".to_string());
let filter = EnvFilter::new(format!("tg_archive_bot={}", log_level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_file(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_line_number(true)
.with_target(true)
.init();
trace!("Logging system started");
info!("Log Level: {}", log_level);
trace!("Env loaded");
// Grab envs
trace!("Loading database url");
let database_url =
env::var("TG_BOT_DATABASE_URL").expect("Expected a database url in the environment");
trace!("Database url loaded");
trace!("Loading token");
let token = env::var("TG_BOT_DISCORD_TOKEN").expect("Expected a token in the environment");
trace!("Token loaded");
trace!("Loading web address");
let web_address = env::var("TG_BOT_WEB_ADDRESS").unwrap_or_else(|_| "0.0.0.0:3000".to_string());
trace!("Web address loaded");
info!("Starting bot...");
// Initialize database
debug!("Initalizing database");
let pool = connect_database(&database_url)
.await
.expect("Failed to connect to database");
debug!("Database connected");
trace!("Applying migrations");
sqlx::migrate!()
.run(&pool)
.await
.expect("Failed to apply database migrations");
debug!("Migrations applied");
debug!("Starting web server on {}", web_address);
let web_listener = tokio::net::TcpListener::bind(&web_address)
.await
.expect("Failed to bind web server");
tokio::spawn(web::run(web_listener, pool.clone()));
debug!("Web server started");
trace!("Loading intents");
let intents = sere::GatewayIntents::non_privileged()
| sere::GatewayIntents::MESSAGE_CONTENT
| sere::GatewayIntents::GUILD_MEMBERS;
trace!("Intents loaded");
trace!("Initalizing framework");
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
event_handler: |ctx, event, framework, user_data| {
Box::pin(event_handler::event_handler(
ctx, event, framework, user_data,
))
},
on_error: |error| {
Box::pin(async move {
match error {
poise::FrameworkError::Command { error, ctx, .. } => {
error!("Error in command `{}`: {:?}", ctx.command().name, error);
}
error => {
if let Err(e) = poise::builtins::on_error(error).await {
error!("Error while handling error: {}", e);
}
}
}
})
},
commands: vec![commands::ping(), commands::version(), commands::git()],
..Default::default()
})
.setup(|ctx, _ready, framework| {
Box::pin(async move {
if let Ok(guild_id) = env::var("TG_BOT_GUILD_ID") {
let guild_id = sere::GuildId::new(guild_id.parse()?);
poise::builtins::register_in_guild(
ctx,
&framework.options().commands,
guild_id,
)
.await?;
} else {
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
}
Ok(Data { pool: pool.clone() })
})
})
.build();
trace!("Framework initialized");
debug!("Connecting to Discord");
let mut client = sere::ClientBuilder::new(&token, intents)
.framework(framework)
.await
.expect("Err creating client");
debug!("Connected to Discord");
info!("Bot running");
if let Err(why) = client.start().await {
error!("Client error: {why:?}");
}
}
async fn connect_database(database_url: &str) -> Result<sqlx::PgPool, Error> {
let options: sqlx::postgres::PgConnectOptions = database_url.parse()?;
match sqlx::postgres::PgPoolOptions::new()
.connect_with(options.clone())
.await
{
Ok(pool) => Ok(pool),
Err(sqlx::Error::Database(error)) if error.code().as_deref() == Some("3D000") => {
let database_name = options.get_database().ok_or_else(|| {
std::io::Error::other("Database URL does not contain a database name")
})?;
let database_name = quote_identifier(database_name);
info!("Creating database {}", database_name);
let maintenance_pool = sqlx::postgres::PgPoolOptions::new()
.connect_with(options.database("postgres"))
.await?;
let create_database = format!("CREATE DATABASE {database_name};");
sqlx::query(sqlx::AssertSqlSafe(create_database))
.execute(&maintenance_pool)
.await?;
maintenance_pool.close().await;
sqlx::postgres::PgPoolOptions::new()
.connect(database_url)
.await
.map_err(Into::into)
}
Err(error) => Err(error.into()),
}
}
fn quote_identifier(identifier: &str) -> String {
format!("\"{}\"", identifier.replace('"', "\"\""))
}
+381
View File
@@ -0,0 +1,381 @@
use crate::Error;
use poise::serenity_prelude as sere;
use tracing::{debug, trace};
pub async fn record_message(
ctx: &sere::Context,
message: &sere::Message,
pool: &sqlx::PgPool,
) -> Result<(), Error> {
record_message_version(ctx, message, pool, false).await
}
pub async fn record_message_edit(
ctx: &sere::Context,
message: &sere::Message,
pool: &sqlx::PgPool,
) -> Result<(), Error> {
record_message_version(ctx, message, pool, true).await
}
async fn record_message_version(
ctx: &sere::Context,
message: &sere::Message,
pool: &sqlx::PgPool,
is_edit: bool,
) -> Result<(), Error> {
let Some(guild_id) = message.guild_id else {
trace!("Ignoring message {} without a server", message.id.get());
return Ok(());
};
trace!(
"Archiving message {} from channel {} in guild {}",
message.id.get(),
message.channel_id.get(),
guild_id.get()
);
let guild = guild_id.to_partial_guild(ctx).await?;
let channel = message
.channel_id
.to_channel(ctx)
.await?
.guild()
.ok_or_else(|| std::io::Error::other("Guild message belonged to a private channel"))?;
let mut downloaded_attachments = Vec::with_capacity(message.attachments.len());
for attachment in &message.attachments {
trace!(
"Downloading attachment {} from message {}",
attachment.id.get(),
message.id.get()
);
downloaded_attachments.push((attachment, attachment.download().await?));
}
let guild_id = guild_id.get() as i64;
let channel_id = message.channel_id.get() as i64;
let message_id = message.id.get() as i64;
let author_id = message.author.id.get() as i64;
let timestamp = message.timestamp.to_string();
let mut transaction = pool.begin().await?;
sqlx::query(
"INSERT INTO guilds (guild_id, guild_name, guild_icon_url)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id) DO UPDATE SET
guild_name = EXCLUDED.guild_name,
guild_icon_url = EXCLUDED.guild_icon_url;",
)
.bind(guild_id)
.bind(&guild.name)
.bind(guild.icon_url())
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO guild_history (guild_id, guild_name, guild_icon_url)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id, guild_name, guild_icon_url) DO UPDATE SET
last_seen_at = NOW();",
)
.bind(guild_id)
.bind(&guild.name)
.bind(guild.icon_url())
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO channels (guild_id, channel_id, channel_name)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id) DO UPDATE SET
guild_id = EXCLUDED.guild_id,
channel_name = EXCLUDED.channel_name;",
)
.bind(guild_id)
.bind(channel_id)
.bind(&channel.name)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO channel_history (channel_id, channel_name)
VALUES ($1, $2)
ON CONFLICT (channel_id, channel_name) DO UPDATE SET
last_seen_at = NOW();",
)
.bind(channel_id)
.bind(&channel.name)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO discord_users (discord_id, discord_username)
VALUES ($1, $2)
ON CONFLICT (discord_id) DO UPDATE SET
discord_username = EXCLUDED.discord_username;",
)
.bind(author_id)
.bind(&message.author.name)
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO discord_user_history (discord_id, discord_username, discord_avatar_url)
VALUES ($1, $2, $3)
ON CONFLICT (discord_id, discord_username, discord_avatar_url) DO UPDATE SET
last_seen_at = NOW();",
)
.bind(author_id)
.bind(&message.author.name)
.bind(message.author.avatar_url())
.execute(&mut *transaction)
.await?;
sqlx::query(
"INSERT INTO guild_users (guild_id, discord_id)
VALUES ($1, $2)
ON CONFLICT (guild_id, discord_id) DO UPDATE SET
last_seen_at = NOW();",
)
.bind(guild_id)
.bind(author_id)
.execute(&mut *transaction)
.await?;
for role in guild.roles.values() {
sqlx::query(
"INSERT INTO discord_roles (guild_id, discord_id, discord_role_name)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id, discord_id) DO UPDATE SET
discord_role_name = EXCLUDED.discord_role_name;",
)
.bind(guild_id)
.bind(role.id.get() as i64)
.bind(&role.name)
.execute(&mut *transaction)
.await?;
}
sqlx::query(
"INSERT INTO messages (
guild_id, channel_id, message_id, author_id, author_username, content, timestamp
)
VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)
ON CONFLICT (message_id) DO UPDATE SET
guild_id = EXCLUDED.guild_id,
channel_id = EXCLUDED.channel_id,
author_id = EXCLUDED.author_id,
author_username = EXCLUDED.author_username,
content = EXCLUDED.content,
timestamp = EXCLUDED.timestamp;",
)
.bind(guild_id)
.bind(channel_id)
.bind(message_id)
.bind(author_id)
.bind(&message.author.name)
.bind(&message.content)
.bind(timestamp)
.execute(&mut *transaction)
.await?;
let message_version = if is_edit {
sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(MAX(version), 0) + 1
FROM message_versions
WHERE message_id = $1;",
)
.bind(message_id)
.fetch_one(&mut *transaction)
.await?
} else {
1
};
sqlx::query(
"INSERT INTO message_versions (message_id, version, content)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, version) DO UPDATE SET
content = EXCLUDED.content;",
)
.bind(message_id)
.bind(message_version)
.bind(&message.content)
.execute(&mut *transaction)
.await?;
for (attachment, data) in downloaded_attachments {
sqlx::query(
"INSERT INTO attachments (
attachment_id, message_id, message_version, filename, description, content_type,
size, width, height, data
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (message_id, message_version, attachment_id) DO UPDATE SET
filename = EXCLUDED.filename,
description = EXCLUDED.description,
content_type = EXCLUDED.content_type,
size = EXCLUDED.size,
width = EXCLUDED.width,
height = EXCLUDED.height,
data = EXCLUDED.data;",
)
.bind(attachment.id.get() as i64)
.bind(message_id)
.bind(message_version)
.bind(&attachment.filename)
.bind(&attachment.description)
.bind(&attachment.content_type)
.bind(i64::from(attachment.size))
.bind(optional_u32_to_i32(attachment.width)?)
.bind(optional_u32_to_i32(attachment.height)?)
.bind(data)
.execute(&mut *transaction)
.await?;
}
for (embed_index, embed) in message.embeds.iter().enumerate() {
let embed_index = i32::try_from(embed_index)?;
let embed_timestamp = embed.timestamp.map(|timestamp| timestamp.to_string());
sqlx::query(
"INSERT INTO embeds (
message_id, message_version, embed_index, embed_type, title, description, url,
timestamp, color,
footer_text, footer_icon_url, image_url, image_proxy_url, image_width,
image_height, thumbnail_url, thumbnail_proxy_url, thumbnail_width,
thumbnail_height, video_url, video_proxy_url, video_width, video_height,
provider_name, provider_url, author_name, author_url, author_icon_url,
author_proxy_icon_url
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8::timestamptz, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29
);",
)
.bind(message_id)
.bind(message_version)
.bind(embed_index)
.bind(&embed.kind)
.bind(&embed.title)
.bind(&embed.description)
.bind(&embed.url)
.bind(embed_timestamp)
.bind(embed.colour.map(|colour| colour.0 as i32))
.bind(embed.footer.as_ref().map(|footer| &footer.text))
.bind(
embed
.footer
.as_ref()
.and_then(|footer| footer.icon_url.as_ref()),
)
.bind(embed.image.as_ref().map(|image| &image.url))
.bind(
embed
.image
.as_ref()
.and_then(|image| image.proxy_url.as_ref()),
)
.bind(optional_u32_to_i32(
embed.image.as_ref().and_then(|image| image.width),
)?)
.bind(optional_u32_to_i32(
embed.image.as_ref().and_then(|image| image.height),
)?)
.bind(embed.thumbnail.as_ref().map(|thumbnail| &thumbnail.url))
.bind(
embed
.thumbnail
.as_ref()
.and_then(|thumbnail| thumbnail.proxy_url.as_ref()),
)
.bind(optional_u32_to_i32(
embed
.thumbnail
.as_ref()
.and_then(|thumbnail| thumbnail.width),
)?)
.bind(optional_u32_to_i32(
embed
.thumbnail
.as_ref()
.and_then(|thumbnail| thumbnail.height),
)?)
.bind(embed.video.as_ref().map(|video| &video.url))
.bind(
embed
.video
.as_ref()
.and_then(|video| video.proxy_url.as_ref()),
)
.bind(optional_u32_to_i32(
embed.video.as_ref().and_then(|video| video.width),
)?)
.bind(optional_u32_to_i32(
embed.video.as_ref().and_then(|video| video.height),
)?)
.bind(
embed
.provider
.as_ref()
.and_then(|provider| provider.name.as_ref()),
)
.bind(
embed
.provider
.as_ref()
.and_then(|provider| provider.url.as_ref()),
)
.bind(embed.author.as_ref().map(|author| &author.name))
.bind(embed.author.as_ref().and_then(|author| author.url.as_ref()))
.bind(
embed
.author
.as_ref()
.and_then(|author| author.icon_url.as_ref()),
)
.bind(
embed
.author
.as_ref()
.and_then(|author| author.proxy_icon_url.as_ref()),
)
.execute(&mut *transaction)
.await?;
for (field_index, field) in embed.fields.iter().enumerate() {
sqlx::query(
"INSERT INTO embed_fields (embed_uuid, field_index, name, value, inline)
SELECT uuid, $4, $5, $6, $7
FROM embeds
WHERE message_id = $1 AND message_version = $2 AND embed_index = $3;",
)
.bind(message_id)
.bind(message_version)
.bind(embed_index)
.bind(i32::try_from(field_index)?)
.bind(&field.name)
.bind(&field.value)
.bind(field.inline)
.execute(&mut *transaction)
.await?;
}
}
transaction.commit().await?;
debug!(
"Archived version {} of message {} with {} attachments and {} embeds",
message_version,
message.id.get(),
message.attachments.len(),
message.embeds.len()
);
Ok(())
}
fn optional_u32_to_i32(value: Option<u32>) -> Result<Option<i32>, std::num::TryFromIntError> {
value.map(i32::try_from).transpose()
}
+34
View File
@@ -0,0 +1,34 @@
use crate::{Context, Error};
use tracing::trace;
pub async fn trace_message(msg: &str, channel: String, guild: String) {
trace!(
"Saying \"{}\" in channel {} in guild {}",
msg, channel, guild
);
}
pub async fn edit_response_message<'a>(
response_message: &poise::ReplyHandle<'a>,
ctx: Context<'_>,
content: &str,
silent: bool,
) -> Result<(), Error> {
if silent {
response_message
.edit(
ctx,
poise::CreateReply::default()
.content(content)
.allowed_mentions(
poise::serenity_prelude::CreateAllowedMentions::new().empty_users(),
),
)
.await?;
} else {
response_message
.edit(ctx, poise::CreateReply::default().content(content))
.await?;
}
Ok(())
}
+1616
View File
File diff suppressed because it is too large. Load diff