Initial Commit
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
/.direnv/
|
||||||
|
/*.sqlite
|
||||||
|
/token
|
||||||
|
/token-*
|
||||||
2685
Cargo.lock
generated
Normal file
2685
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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
138
flake.lock
generated
Normal 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
60
flake.nix
Normal 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
183
src/main.rs
Normal 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
4
toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = [ "rustfmt", "rust-analyzer", "rust-src", "clippy" ]
|
||||||
|
profile = "default"
|
||||||
Reference in New Issue
Block a user