Compare commits

..

5 Commits

Author SHA1 Message Date
fda16de4fa Add initial prototype of NixOS module
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-27 09:06:16 +00:00
2d17c8cdfb Add flake.nix to enable building with Nix
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-27 05:02:42 +00:00
26ae4e9589 Add a shards.lock and use HTTPs for new telepathy
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-25 18:44:17 -08:00
b4e67daa9b [Claude Haiku] Apply old refactors to code
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-25 17:52:03 -08:00
97668ba0d7 [Claude Haiki] Update to new, working Crystal version
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-25 17:35:59 -08:00
9 changed files with 266 additions and 28 deletions

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766736597,
"narHash": "sha256-BASnpCLodmgiVn0M1MU2Pqyoz0aHwar/0qLkp7CjvSQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "f560ccec6b1116b22e6ed15f4c510997d99d5852",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"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
}

29
flake.nix Normal file
View File

@@ -0,0 +1,29 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
joann-pupper-bot = pkgs.crystal.buildCrystalPackage {
pname = "joann-pupper-bot";
version = "0.1.0";
src = ./.;
lockFile = ./shard.lock;
shardsFile = ./shards.nix;
format = "shards";
buildInputs = [pkgs.sqlite];
};
in
{
packages = { inherit joann-pupper-bot; };
defaultPackage = joann-pupper-bot;
}
) // {
nixosModules.joann-pupper-bot = import ./module.nix;
};
}

71
module.nix Normal file
View File

@@ -0,0 +1,71 @@
{ config, lib, pkgs, ... }:
with lib;
let cfg = config.services.joann-pupper-bot; in {
options.services.joann-pupper-bot = {
enable = mkEnableOption "Joann's Pupper Bot";
user = mkOption {
type = types.str;
default = "joann-pupper-bot";
example = "myuser";
description = "Which user should be running Joann's Pupper Bot";
};
group = mkOption {
type = types.str;
default = "joann-pupper-bot";
example = "mygroup";
description = "Which group should be running Joann's Pupper Bot";
};
stateDir = mkOption {
type = types.str;
default = "/var/lib/joann-pupper-bot";
description = "Which directory the bot should use for storage";
};
config.token = mkOption {
type = types.str;
description = "Bot token used for Telegram";
};
config.subreddits = mkOption {
type = types.listOf types.str;
description = "Which subreddits to draw images from";
default = ["rarepuppers"];
};
config.sendCron = mkOption {
type = types.str;
description = "cron-style string determining when images should be sent";
default = "*/30 8-21 * * *";
};
config.refreshCron = mkOption {
type = types.str;
description = "cron-style string determining when the bot polls Reddit for new images";
default = "*/15 * * * *";
};
config.recipients = mkOption {
type = types.listOf types.int;
description = "Telegram chat IDs for each of the image recipients";
};
};
config = mkIf cfg.enable {
systemd.tmpfiles.rules = [
"d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -"
];
systemd.services.joann-pupper-bot = {
description = "Joann's Pupper Bot";
wantedBy = [ "multi-user.target" ];
preStart =
let
yml = generators.toYAML {} cfg.config;
configFile = pkgs.writeText "config.yaml" yml;
in
''
cp -f '${configFile}' '${cfg.stateDir}'/config.yaml
'';
serviceConfig = {
Type = "oneshot";
ExecStart = "/bin/sh -c :";
};
};
};
}

22
shard.lock Normal file
View File

@@ -0,0 +1,22 @@
version: 2.0
shards:
cron_parser:
git: https://github.com/kostya/cron_parser.git
version: 0.4.0
cron_scheduler:
git: https://github.com/kostya/cron_scheduler.git
version: 0.4.0
db:
git: https://github.com/crystal-lang/crystal-db.git
version: 0.14.0
sqlite3:
git: https://github.com/crystal-lang/crystal-sqlite3.git
version: 0.22.0
telepathy:
git: https://dev.danilafe.com/Crystal-Bots/telepathy
version: 0.2.0

View File

@@ -16,6 +16,6 @@ dependencies:
sqlite3: sqlite3:
github: crystal-lang/crystal-sqlite3 github: crystal-lang/crystal-sqlite3
crystal: 0.24.1 crystal: 1.0.0
license: MIT license: MIT

27
shards.nix Normal file
View File

@@ -0,0 +1,27 @@
{
"cron_parser" = {
url = "https://github.com/kostya/cron_parser.git";
rev = "v0.4.0";
sha256 = "17fgg2nvyx99v05l10h6cnxfr7swz8yaxhmnk4l47kg2spi8w90a";
};
"cron_scheduler" = {
url = "https://github.com/kostya/cron_scheduler.git";
rev = "v0.4.0";
sha256 = "0jd0maw1h87hjgjpqhbwxb4yz83g8shlrwfivyf0sd6x3l5lspns";
};
"db" = {
url = "https://github.com/crystal-lang/crystal-db.git";
rev = "v0.14.0";
sha256 = "1s67fs5abzgg2yyjsm2la967mk881i91h8jdnal072fb9qh9wr58";
};
"sqlite3" = {
url = "https://github.com/crystal-lang/crystal-sqlite3.git";
rev = "v0.22.0";
sha256 = "1lc8ixnzjx8nsp96qj9yxmfk7g334id8v87g3h5bwgkhkbx5ghqn";
};
"telepathy" = {
url = "https://dev.danilafe.com/Crystal-Bots/telepathy";
rev = "v0.2.0";
sha256 = "1zhjlpa31vldgd9f6l1hdfj7a97hlqkxkvxzisfs2zfnvc86aiyc";
};
}

View File

@@ -1,18 +1,18 @@
require "logger" require "log"
require "telepathy" require "telepathy"
require "time" require "time"
require "sqlite3" require "sqlite3"
require "cron_scheduler" require "cron_scheduler"
EXTENSIONS = ["png", "jpeg", "jpg"] EXTENSIONS = ["png", "jpeg", "jpg"]
LOGGER = Logger.new(STDOUT) LOGGER = Log.for("joann-pupper-bot")
class PupperBot class PupperBot
@db : DB::Database @db : DB::Database
def initialize(@configuration : BotConfiguration) def initialize(@configuration : BotConfiguration)
@telegram_bot = Telepathy::Bot.new configuration.token @telegram_bot = Telepathy::Bot.new @configuration.token
@db = DB.open "sqlite3://#{configuration.database}" @db = DB.open "sqlite3://#{@configuration.database}"
initialize_db initialize_db
update_database update_database
initialize_timers initialize_timers
@@ -49,12 +49,12 @@ class PupperBot
unsent_query = "select id, title, url from posts where not exists (select * from recepient_posts where recepient=? and post=id) limit 1" unsent_query = "select id, title, url from posts where not exists (select * from recepient_posts where recepient=? and post=id) limit 1"
unless to_send = @db.query_one? unsent_query, chatid, as: { Int64, String, String } unless to_send = @db.query_one? unsent_query, chatid, as: { Int64, String, String }
LOGGER.info "Unable to find a post to send to #{chatid}." LOGGER.info { "Unable to find a post to send to #{chatid}." }
return return
end end
id, title, url = to_send id, title, url = to_send
LOGGER.info "Using URL #{url} for request from #{chatid}" LOGGER.info { "Using URL #{url} for request from #{chatid}" }
@db.exec "insert into recepient_posts(recepient, post) values(?, ?)", chatid, id @db.exec "insert into recepient_posts(recepient, post) values(?, ?)", chatid, id
@telegram_bot.send_photo(chatid, url, title) @telegram_bot.send_photo(chatid, url, title)
end end
@@ -67,13 +67,13 @@ class PupperBot
def update_database def update_database
unless response = RedditResponse.from_subreddits(@configuration.subreddits) unless response = RedditResponse.from_subreddits(@configuration.subreddits)
LOGGER.info "Unable to find more posts for the database" LOGGER.info { "Unable to find more posts for the database" }
return return
end end
posts = response.data.posts_matching { |post| EXTENSIONS.any? { |it| post.url.ends_with? it } } posts = response.data.posts_matching { |post| EXTENSIONS.any? { |it| post.url.ends_with? it } }
posts.each do |post| posts.each do |post|
LOGGER.info "Trying to save post #{post.title} #{post.url}" LOGGER.info { "Trying to save post #{post.title} #{post.url}" }
begin begin
@db.exec "insert into posts(title, url) values(?, ?)", post.title, post.url @db.exec "insert into posts(title, url) values(?, ?)", post.title, post.url
rescue rescue

View File

@@ -1,11 +1,23 @@
require "yaml" require "yaml"
class BotConfiguration class BotConfiguration
YAML.mapping( include YAML::Serializable
token: String,
database: { type: String, default: "./data.sqlite" }, @[YAML::Field(key: "token")]
subreddits: { type: Array(String), default: [ "rarepuppers" ] }, property token : String
send_cron: { type: String, default: "*/30 8-21 * * *" },
refresh_cron: { type: String, default: "*/15 * * * *" }, @[YAML::Field(key: "database")]
recipients: Array(Int64)) property database : String = "./data.sqlite"
@[YAML::Field(key: "subreddits")]
property subreddits : Array(String) = ["rarepuppers"]
@[YAML::Field(key: "send_cron")]
property send_cron : String = "*/30 8-21 * * *"
@[YAML::Field(key: "refresh_cron")]
property refresh_cron : String = "*/15 * * * *"
@[YAML::Field(key: "recipients")]
property recipients : Array(Int64)
end end

View File

@@ -1,24 +1,40 @@
require "http/client" require "http/client"
require "json" require "json"
class RedditWrapper(T) class RedditWrapper(T)
JSON.mapping( include JSON::Serializable
kind: String,
data: T) @[JSON::Field(key: "kind")]
property kind : String
@[JSON::Field(key: "data")]
property data : T
end end
class RedditChild class RedditChild
JSON.mapping( include JSON::Serializable
url: String,
name: String, @[JSON::Field(key: "url")]
title: String) property url : String
@[JSON::Field(key: "name")]
property name : String
@[JSON::Field(key: "title")]
property title : String
end end
class RedditResponse class RedditResponse
JSON.mapping( include JSON::Serializable
modhash: String,
dist: Int32, @[JSON::Field(key: "modhash")]
children: Array(RedditWrapper(RedditChild))) property modhash : String
@[JSON::Field(key: "dist")]
property dist : Int32
@[JSON::Field(key: "children")]
property children : Array(RedditWrapper(RedditChild))
def self.from_subreddits(subreddits : Array(String)) def self.from_subreddits(subreddits : Array(String))
request_url = URI.new scheme: "https", host: "www.reddit.com", path: "/r/#{subreddits.join "+"}/hot.json", query: "limit=30" request_url = URI.new scheme: "https", host: "www.reddit.com", path: "/r/#{subreddits.join "+"}/hot.json", query: "limit=30"