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