Initial commit of proof-of-concept

This commit is contained in:
Gabriel Simmer 2024-06-20 19:29:39 +01:00
parent 455bfce937
commit 08bd8a6fd1
Signed by: arch
SSH key fingerprint: SHA256:m3OEcdtrnBpMX+2BDGh/byv3hrCekCLzDYMdvGEKPPQ
7 changed files with 2518 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

2113
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "duplikate"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dotenv = "0.15.0"
once_cell = "1.19.0"
redis = { version = "0.25.3", features = ["tokio-rustls-comp"] }
regex = "1.10.5"
serenity = { version = "0.12", features = ["collector"] }
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread"] }

117
flake.lock Normal file
View file

@ -0,0 +1,117 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1718730147,
"narHash": "sha256-QmD6B6FYpuoCqu6ZuPJH896ItNquDkn0ulQlOn4ykN8=",
"owner": "ipetkov",
"repo": "crane",
"rev": "32c21c29b034d0a93fdb2379d6fabc40fc3d0e6c",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"docker-utils": {
"inputs": {
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1705782409,
"narHash": "sha256-RWprcwv0q+u/Hp7CMGj1Qqfq8Qpy0dlxhBoRFvJCtzE=",
"owner": "collinarnett",
"repo": "docker-utils",
"rev": "ec44aa75f57cfbb98f9e5b8d9a053795f123b1a0",
"type": "github"
},
"original": {
"owner": "collinarnett",
"repo": "docker-utils",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1688646010,
"narHash": "sha256-kCeza5eKI2NEi8k0EoeZfv3lN1r1Vwx+L/VA6I8tmG4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5daaa32204e9c46b05cd709218b7ba733d07e80c",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718710757,
"narHash": "sha256-zzHTI7iQByeuGDto16eRwPflCf3xfVOJJSB0/cnEd2s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "56fc115880db6498245adecda277ccdb33025bc2",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"docker-utils": "docker-utils",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

94
flake.nix Normal file
View file

@ -0,0 +1,94 @@
{
description = "Monitor for duplicate links shared in Discord channels";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
docker-utils.url = "github:collinarnett/docker-utils";
};
outputs = { self, nixpkgs, crane, flake-utils, docker-utils, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
craneLib = crane.mkLib pkgs;
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = {
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
buildInputs = with pkgs;[
openssl
pkg-config
] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
my-crate = craneLib.buildPackage (commonArgs // {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
# Additional environment variables or build phases/hooks can be set
# here *without* rebuilding all dependency crates
# MY_CUSTOM_VAR = "some value";
});
dockerImage = pkgs.dockerTools.buildImage {
name = "git.gmem.ca/arch/duplikate";
tag = "latest-${system}";
config = {
Cmd = [ "${my-crate}/bin/duplikate" ];
};
architecture = system;
};
in
{
checks = {
inherit my-crate;
};
packages.default = my-crate;
packages.docker = dockerImage;
apps.default = flake-utils.lib.mkApp {
drv = my-crate;
};
apps.concatDocker = {
type = "app";
program = toString (pkgs.writers.writeBash "concatDocker" ''
amd64=git.gmem.ca/arch/duplikate:latest-x86_64-linux
arm64=git.gmem.ca/arch/duplikate:latest-aarch64-linux
docker load -i ${self.packages.x86_64-linux.docker}
docker load -i ${self.packages.aarch64-linux.docker}
docker push $amd64
docker push $arm64
docker manifest create --amend git.gmem.ca/arch/duplikate:latest $arm64 $amd64
docker manifest push git.gmem.ca/arch/duplikate:latest
'');
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks.
checks = self.checks.${system};
# Additional dev-shell environment variables can be set directly
# MY_CUSTOM_DEVELOPMENT_VAR = "something else";
# Extra inputs can be added here; cargo and rustc are provided by default.
packages = with pkgs; [
rust-analyzer
];
};
});
}

178
src/main.rs Normal file
View file

@ -0,0 +1,178 @@
use std::env;
use std::time::Duration;
use dotenv::dotenv;
use once_cell::sync::Lazy;
use redis::Commands;
use regex::Regex;
use serenity::all::{
ChannelId, CreateButton, CreateEmbed, CreateEmbedFooter, CreateMessage, GuildId, ReactionType,
};
use serenity::futures::StreamExt;
use serenity::model::channel::Message;
use serenity::prelude::*;
use serenity::{all::MessageId, async_trait};
use std::hash::{DefaultHasher, Hash, Hasher};
struct Handler {
redis: redis::Client,
}
#[async_trait]
impl EventHandler for Handler {
async fn message_delete(
&self,
_ctx: Context,
_channel_id: ChannelId,
deleted_message_id: MessageId,
guild_id: Option<GuildId>,
) {
let meta = format!("{}-{}", guild_id.unwrap(), deleted_message_id);
let existing: Result<String, redis::RedisError> =
self.redis.get_connection().unwrap().get(&meta);
if let Ok(existing) = existing {
let _: Result<String, redis::RedisError> =
self.redis.get_connection().unwrap().del(existing);
let _: Result<String, redis::RedisError> =
self.redis.get_connection().unwrap().del(meta);
}
}
async fn message(&self, ctx: Context, msg: Message) {
// Ignore messages from bots.
if msg.author.bot {
return;
}
static RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(http|ftp|https:)\/\/([\w_-]+(?:(?:\.[\w_-]+)+)[\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])").unwrap()
});
let mut links = vec![];
for (link, [_, _]) in RE.captures_iter(&msg.content).map(|c| c.extract()) {
links.push(link);
}
// Remove any links that reference media.
for embed in &msg.embeds {
let e = embed.clone();
if vec![
"image".to_owned(),
"video".to_owned(),
"gifv".to_owned(),
"rich".to_owned(),
]
.contains(&e.kind.unwrap_or_else(|| "".to_owned()))
{
let url = e.url.unwrap();
links.retain(|x| *x != url);
}
}
if links.len() > 0 {
let mut exists = vec![];
let guild_id = msg.guild_id.unwrap();
for link in links {
let mut s = DefaultHasher::new();
format!("{}-{}", link, guild_id).hash(&mut s);
let h = s.finish();
let hash = format!("{:x}", h);
let meta = format!("{}-{}", guild_id, msg.id.get());
let existing: Result<String, redis::RedisError> =
self.redis.get_connection().unwrap().get(&hash);
if existing.is_ok() {
exists.push((link, existing.unwrap()));
} else {
let _: () = self
.redis
.get_connection()
.unwrap()
.set(&hash, &msg.link())
.unwrap();
let _: () = self
.redis
.get_connection()
.unwrap()
.set(meta, hash)
.unwrap();
}
}
if exists.len() > 0 {
// Links have already been posted, let's tell them
let desc = format!(
"{} have already been posted!",
if exists.len() > 1 {
"Some of these"
} else {
"One of these"
}
);
let mut fields = vec![];
for existing in exists {
fields.push((existing.0, existing.1, true));
}
let footer = CreateEmbedFooter::new("Bugs? Ask @gmem.ca");
let embed = CreateEmbed::new()
.title("Duplicate Links")
.description(desc)
.fields(fields)
.footer(footer);
let ignore_emoji: ReactionType = "🗑".parse().unwrap();
let remove_emoji: ReactionType = "👍".parse().unwrap();
let builder = CreateMessage::new()
.embed(embed)
.button(
CreateButton::new("ignore")
.label("Ignore")
.emoji(ignore_emoji),
)
.button(
CreateButton::new("remove")
.label("Remove my post")
.emoji(remove_emoji),
);
let reply = msg
.channel_id
.send_message(&ctx.http, builder)
.await
.unwrap();
// Wait for multiple interactions
let mut interaction_stream = reply
.await_component_interaction(&ctx.shard)
.timeout(Duration::from_secs(60 * 3))
.stream();
while let Some(interaction) = interaction_stream.next().await {
let action = &interaction.data.custom_id;
if action == "remove" {
msg.delete(&ctx).await.unwrap();
}
reply.delete(&ctx).await.unwrap();
}
}
}
}
}
#[tokio::main]
async fn main() {
dotenv().ok();
// Login with a bot token from the environment
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
// Set gateway intents, which decides what events the bot will be notified about
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::DIRECT_MESSAGES
| GatewayIntents::MESSAGE_CONTENT;
let client = redis::Client::open("redis://127.0.0.1/").unwrap();
let handler = Handler { redis: client };
// Create a new instance of the Client, logging in as a bot.
let mut client = Client::builder(&token, intents)
.event_handler(handler)
.await
.expect("Err creating client");
// Start listening for events by starting a single shard
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
}
}