Initial Commit

This commit is contained in:
bluepython508
2026-01-26 15:35:41 +00:00
commit 0f31af9dae
8 changed files with 3096 additions and 0 deletions

3
.envrc Normal file
View File

@@ -0,0 +1,3 @@
use flake
export DISCORD_TOKEN_FILE=./token

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/target
/.direnv/
/*.sqlite
/token
/token-*

2685
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "kunordaterano"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = "0.4.43"
color-eyre = "0.6.5"
eyre = "0.6.12"
futures = "0.3.31"
poise = "0.6.1"
serenity = "0.12.5"
tokio = { version = "1.49.0", features = ["rt", "net"] }
tracing = "0.1.44"
tracing-error = "0.2.1"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }

138
flake.lock generated Normal file
View File

@@ -0,0 +1,138 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1714864355,
"narHash": "sha256-uXNW6bapWFfkYIkK1EagydSrFMqycOYEDSq75GmUpjk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "442a7a6152f49b907e73206dc8e1f46a61e8e873",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"crane-flake-parts": {
"inputs": {
"crane": "crane",
"rust-overlay": [
"rust-overlay"
]
},
"locked": {
"lastModified": 1739030108,
"narHash": "sha256-kwFvwlI5dSl6TFQb54oJLP+qllPGKh2wAZun+gKnFOE=",
"owner": "bluepython508",
"repo": "crane-flake-parts",
"rev": "1e60976aa3cc1d474a62d8a8b63c7849ffdc9016",
"type": "github"
},
"original": {
"owner": "bluepython508",
"repo": "crane-flake-parts",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1768135262,
"narHash": "sha256-PVvu7OqHBGWN16zSi6tEmPwwHQ4rLPU9Plvs8/1TUBY=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "80daad04eddbbf5a4d883996a73f3f542fa437ac",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1714656196,
"narHash": "sha256-kjQkA98lMcsom6Gbhw8SYzmwrSo+2nruiTcTZp5jK7o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "94035b482d181af0a0f8f77823a790b256b7c3cc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1765674936,
"narHash": "sha256-k00uTP4JNfmejrCLJOwdObYC9jHRrr/5M/a/8L2EIdo=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "2075416fcb47225d9b68ac469a5c4801a9c4dd85",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1769170682,
"narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c5296fdd05cfa2c187990dd909864da9658df755",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane-flake-parts": "crane-flake-parts",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1769396217,
"narHash": "sha256-YNzh46h8fby49yOIB40lNoQ9ucVoXe1bHVwkZ4AwGe0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "e9bcd12156a577ac4e47d131c14dc0293cc9c8c2",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

60
flake.nix Normal file
View File

@@ -0,0 +1,60 @@
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
crane-flake-parts = {
url = "github:bluepython508/crane-flake-parts";
inputs.rust-overlay.follows = "rust-overlay";
};
};
outputs = inputs @ {
self,
flake-parts,
crane-flake-parts,
...
}:
flake-parts.lib.mkFlake {inherit inputs;} {
imports = [
crane-flake-parts.flakeModules.default
];
systems = ["x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin"];
crane.source = ./.;
flake.nixosModules.default = {
pkgs,
lib,
config,
...
}: let
pkg = self.packages.${pkgs.system}.default;
cfg = config.bluepython508.kunordaterano;
in {
options.bluepython508.kunordaterano = with lib; {
enable = mkEnableOption "kunordaterano";
tokenFile = mkOption {
type = types.path;
};
};
config.systemd.services.kunordaterano = lib.mkIf cfg.enable {
description = "kunordaterano";
environment = {
DISCORD_TOKEN_FILE = "%d/discord-token";
RUST_BACKTRACE = "1";
};
wantedBy = ["multi-user.target"];
script = "${pkg}/bin/kunordaterano";
serviceConfig = {
LoadCredential = ["discord-token:${cfg.tokenFile}"];
DynamicUser = true;
StateDirectory = "kunordaterano";
};
};
};
};
}

183
src/main.rs Normal file
View File

@@ -0,0 +1,183 @@
use std::{
collections::BTreeMap,
io,
sync::{Arc, OnceLock},
time::{Duration, Instant},
};
use ::serenity::all::{
ChannelId, CreateAllowedMentions, CreateForumPost, CreateMessage, EditThread, ForumTagId,
GuildId,
};
use chrono::{Days, NaiveTime};
use eyre::{Context as _, Error, OptionExt, Result};
use futures::{FutureExt, lock::Mutex};
use poise::serenity_prelude as serenity;
use tracing::{Instrument, Level};
use tracing_subscriber::{
EnvFilter,
fmt::{self, format::FmtSpan},
layer::SubscriberExt,
};
#[derive(Debug)]
struct GuildCfg {
topics: ChannelId,
tag: ForumTagId,
}
#[derive(Default)]
struct Data {
guilds: Mutex<BTreeMap<GuildId, GuildCfg>>,
ctx: OnceLock<(Arc<serenity::Cache>, Arc<serenity::Http>)>,
}
#[tracing::instrument(skip(data))]
async fn create_dailies(data: &Data) -> Result<()> {
let ctx = data.ctx.get().ok_or_eyre("Context not initialized")?;
let ctx = (&ctx.0, &*ctx.1);
let data = data.guilds.lock().await;
tracing::info!(?data);
for (guild, cfg) in data.iter() {
async move {
let channels = guild.get_active_threads(&ctx).await?;
tracing::info!(?guild, ?cfg, ?channels, "Creating dailies for guild");
for prev in channels.threads.into_iter().filter(|chan| {
chan.parent_id == Some(cfg.topics) && chan.applied_tags.contains(&cfg.tag)
}) {
async move {
tracing::info!(?prev.id, "Thread with tag");
prev.id
.edit_thread(&ctx, EditThread::new().archived(true).locked(true))
.await?;
Ok::<_, Error>(())
}
.instrument(tracing::span!(Level::TRACE, "close", ?prev.id))
.await?;
}
cfg.topics
.create_forum_post(
&ctx,
CreateForumPost::new(
format!("Daily {}", chrono::Utc::now().date_naive()),
CreateMessage::new()
.content("@everyone")
.allowed_mentions(CreateAllowedMentions::new().everyone(true)),
)
.set_applied_tags(vec![cfg.tag]),
)
.await?;
Ok::<_, Error>(())
}
.instrument(tracing::span!(Level::TRACE, "dailies", ?guild))
.await?
}
Ok(())
}
async fn run_cron(data: Arc<Data>) {
const T_9AM: NaiveTime = NaiveTime::from_hms_opt(9, 0, 0).expect("This is a real time");
let mut next_9am = chrono::Utc::now()
.with_time(T_9AM)
.earliest()
.expect("Should exist at least 1 9AM");
if next_9am < chrono::Utc::now() {
next_9am = next_9am + Days::new(1);
}
let mut interval = tokio::time::interval_at(
(Instant::now()
+ next_9am
.signed_duration_since(chrono::Utc::now())
.to_std()
.expect("Also should be real times?"))
.into(),
Duration::from_hours(24),
);
loop {
interval.tick().await;
if let Err(e) = create_dailies(&data).await {
eprintln!("{e:?}");
};
}
}
async fn setup_guild(ctx: &serenity::Context, data: &Arc<Data>, guild: GuildId) -> Result<()> {
let topics = guild
.channels(&ctx)
.await?
.values()
.find(|chan| &chan.name == "topics")
.cloned()
.ok_or_eyre("Expected a topics channel")?;
let tag = topics
.available_tags
.iter()
.find(|tag| tag.name == "Dailies")
.map(|tag| tag.id);
let cfg = GuildCfg {
topics: topics.id,
tag: tag.ok_or_eyre("Expected a Dailies tag")?,
};
data.guilds.lock().await.insert(guild, cfg);
Ok(())
}
async fn on_event(
ctx: &serenity::Context,
data: &Arc<Data>,
event: &serenity::FullEvent,
) -> Result<()> {
if let serenity::FullEvent::GuildCreate { guild, is_new: _ } = event {
setup_guild(ctx, data, guild.id).await?;
}
Ok(())
}
#[tokio::main(flavor = "current_thread")]
#[tracing::instrument]
async fn main() -> Result<()> {
color_eyre::install()?;
tracing::subscriber::set_global_default(
tracing_subscriber::registry()
.with(
fmt::layer()
.event_format(fmt::format().with_ansi(true).pretty())
.with_span_events(FmtSpan::ACTIVE)
.with_writer(io::stderr),
)
.with(EnvFilter::from_default_env())
.with(tracing_error::ErrorLayer::default()),
)?;
let token = std::fs::read_to_string(
std::env::var_os("DISCORD_TOKEN_FILE").ok_or_eyre("A Discord token is required")?,
)
.wrap_err("A Discord token is required")?;
let intents = serenity::GatewayIntents::non_privileged();
let data = Arc::new(Data::default());
tokio::spawn(run_cron(data.clone()));
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
event_handler: |ctx, ev, _, data| on_event(ctx, data, ev).boxed(),
..Default::default()
})
.setup(|ctx, ready, _f| {
Box::pin(async move {
data.ctx.set((ctx.cache.clone(), ctx.http.clone())).expect("Setup called again!");
for guild in &ready.guilds {
setup_guild(ctx, &data, guild.id).await?;
}
Ok(data)
})
})
.build();
serenity::ClientBuilder::new(token, intents)
.framework(framework)
.await?
.start()
.await?;
Ok(())
}

4
toolchain.toml Normal file
View File

@@ -0,0 +1,4 @@
[toolchain]
channel = "stable"
components = [ "rustfmt", "rust-analyzer", "rust-src", "clippy" ]
profile = "default"