Compare commits
13 Commits
1c6625400a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fda16de4fa | |||
| 2d17c8cdfb | |||
| 26ae4e9589 | |||
| b4e67daa9b | |||
| 97668ba0d7 | |||
| 59365e658b | |||
| 308143e0c9 | |||
| 7834d345bf | |||
| 32facc0d87 | |||
| 17d218c123 | |||
| 5150d965a3 | |||
| 3f1039e5dc | |||
| 92cdd200ba |
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal 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
29
flake.nix
Normal 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
71
module.nix
Normal 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
22
shard.lock
Normal 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
|
||||
|
||||
@@ -10,8 +10,12 @@ targets:
|
||||
|
||||
dependencies:
|
||||
telepathy:
|
||||
git: http://dev.danilafe.com/Crystal-Bots/telepathy
|
||||
git: https://dev.danilafe.com/Crystal-Bots/telepathy
|
||||
cron_scheduler:
|
||||
github: kostya/cron_scheduler
|
||||
sqlite3:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
|
||||
crystal: 0.24.1
|
||||
crystal: 1.0.0
|
||||
|
||||
license: MIT
|
||||
|
||||
27
shards.nix
Normal file
27
shards.nix
Normal 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";
|
||||
};
|
||||
}
|
||||
@@ -1,67 +1,18 @@
|
||||
require "./joann-pupper-bot/*"
|
||||
require "logger"
|
||||
require "telepathy"
|
||||
require "time"
|
||||
require "option_parser"
|
||||
|
||||
# Chat IDs
|
||||
chatid_joann = 215301902
|
||||
chatid_daniel = 220888832
|
||||
config_file = "./config.yaml"
|
||||
|
||||
# Configuration
|
||||
subreddit = "rarepuppers"
|
||||
chatid = chatid_daniel
|
||||
delay = 1.hours
|
||||
active_hours = 7..24
|
||||
bot_token = "599474797:AAEmjQNO32uqurI16blS9FT4OoO7GdUZ6h0"
|
||||
|
||||
# Setup
|
||||
completed = [] of String
|
||||
logger = Logger.new(STDOUT)
|
||||
bot = Telepathy::Bot.new bot_token
|
||||
|
||||
# Commands.
|
||||
bot.command "ping" do |update, args|
|
||||
bot.send_message(update.message.as(Telepathy::Message).chat.id, "pong")
|
||||
OptionParser.parse do |parser|
|
||||
parser.banner = "Usage: joann-pupper-bot [arguments]"
|
||||
parser.on("-c", "--config=CONFIG", "Select config file") do |c|
|
||||
config_file = c
|
||||
end
|
||||
|
||||
bot.command "pupper" do |update, args|
|
||||
url_tuple = get_reddit_post(subreddit, completed)
|
||||
if url_tuple
|
||||
url, title = url_tuple
|
||||
command_chatid = update.message.as(Telepathy::Message).chat.id
|
||||
logger.info "Using URL #{url} for request from #{command_chatid}"
|
||||
bot.send_photo(command_chatid, url, title)
|
||||
else
|
||||
logger.error "Unable to find a post to send."
|
||||
parser.on("-h", "--help", "Show this message") do
|
||||
puts parser
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
spawn do
|
||||
loop do
|
||||
time = Time.now
|
||||
url_tuple = get_reddit_post(subreddit, completed) if (active_hours.includes? time.hour)
|
||||
if url_tuple
|
||||
url, title = url_tuple
|
||||
logger.info "Sending regular picture to #{chatid}."
|
||||
bot.send_photo(chatid.to_i64, url, title)
|
||||
else
|
||||
logger.error "Unable to find a post to send. (Or it's quiet hours)"
|
||||
end
|
||||
sleep delay
|
||||
end
|
||||
end
|
||||
|
||||
# Code to stop the bot on time.
|
||||
end_channel = Channel(Nil).new(1)
|
||||
|
||||
bot.poll_end do
|
||||
end_channel.send nil
|
||||
end
|
||||
|
||||
Signal::INT.trap do
|
||||
logger.info "Shutting down bot..."
|
||||
bot.end_poll
|
||||
end
|
||||
|
||||
bot.poll
|
||||
end_channel.receive
|
||||
botc = PupperBot.new(BotConfiguration.from_yaml(File.open(config_file)))
|
||||
sleep
|
||||
|
||||
83
src/joann-pupper-bot/bot.cr
Normal file
83
src/joann-pupper-bot/bot.cr
Normal file
@@ -0,0 +1,83 @@
|
||||
require "log"
|
||||
require "telepathy"
|
||||
require "time"
|
||||
require "sqlite3"
|
||||
require "cron_scheduler"
|
||||
|
||||
EXTENSIONS = ["png", "jpeg", "jpg"]
|
||||
LOGGER = Log.for("joann-pupper-bot")
|
||||
|
||||
class PupperBot
|
||||
@db : DB::Database
|
||||
|
||||
def initialize(@configuration : BotConfiguration)
|
||||
@telegram_bot = Telepathy::Bot.new @configuration.token
|
||||
@db = DB.open "sqlite3://#{@configuration.database}"
|
||||
initialize_db
|
||||
update_database
|
||||
initialize_timers
|
||||
initialize_telegram
|
||||
send_broadcast
|
||||
end
|
||||
|
||||
private def initialize_db
|
||||
@db.exec "create table if not exists posts(id integer primary key, title text, url text unique)"
|
||||
@db.exec "create table if not exists recepient_posts(recepient integer, post integer, foreign key(post) references posts(id))"
|
||||
end
|
||||
|
||||
private def initialize_timers
|
||||
CronScheduler.define do
|
||||
at(@configuration.send_cron) { send_broadcast }
|
||||
at(@configuration.refresh_cron) { update_database }
|
||||
end
|
||||
end
|
||||
|
||||
private def initialize_telegram
|
||||
@telegram_bot.command "ping" do |update, args|
|
||||
@telegram_bot.send_message(update.message.as(Telepathy::Message).chat.id, "pong")
|
||||
end
|
||||
|
||||
@telegram_bot.command "pupper" do |update, args|
|
||||
command_chatid = update.message.as(Telepathy::Message).chat.id
|
||||
send_single command_chatid
|
||||
end
|
||||
|
||||
@telegram_bot.poll
|
||||
end
|
||||
|
||||
def send_single(chatid)
|
||||
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 }
|
||||
LOGGER.info { "Unable to find a post to send to #{chatid}." }
|
||||
return
|
||||
end
|
||||
|
||||
id, title, url = to_send
|
||||
LOGGER.info { "Using URL #{url} for request from #{chatid}" }
|
||||
@db.exec "insert into recepient_posts(recepient, post) values(?, ?)", chatid, id
|
||||
@telegram_bot.send_photo(chatid, url, title)
|
||||
end
|
||||
|
||||
def send_broadcast
|
||||
@configuration.recipients.each do |recepient|
|
||||
send_single recepient
|
||||
end
|
||||
end
|
||||
|
||||
def update_database
|
||||
unless response = RedditResponse.from_subreddits(@configuration.subreddits)
|
||||
LOGGER.info { "Unable to find more posts for the database" }
|
||||
return
|
||||
end
|
||||
|
||||
posts = response.data.posts_matching { |post| EXTENSIONS.any? { |it| post.url.ends_with? it } }
|
||||
posts.each do |post|
|
||||
LOGGER.info { "Trying to save post #{post.title} #{post.url}" }
|
||||
begin
|
||||
@db.exec "insert into posts(title, url) values(?, ?)", post.title, post.url
|
||||
rescue
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
23
src/joann-pupper-bot/config.cr
Normal file
23
src/joann-pupper-bot/config.cr
Normal file
@@ -0,0 +1,23 @@
|
||||
require "yaml"
|
||||
|
||||
class BotConfiguration
|
||||
include YAML::Serializable
|
||||
|
||||
@[YAML::Field(key: "token")]
|
||||
property token : String
|
||||
|
||||
@[YAML::Field(key: "database")]
|
||||
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
|
||||
@@ -1,27 +1,63 @@
|
||||
require "http/client"
|
||||
require "json"
|
||||
|
||||
def get_reddit_json(subreddit)
|
||||
request_url = "https://www.reddit.com/r/#{subreddit}/hot.json?limit=30"
|
||||
class RedditWrapper(T)
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "kind")]
|
||||
property kind : String
|
||||
|
||||
@[JSON::Field(key: "data")]
|
||||
property data : T
|
||||
end
|
||||
|
||||
class RedditChild
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "url")]
|
||||
property url : String
|
||||
|
||||
@[JSON::Field(key: "name")]
|
||||
property name : String
|
||||
|
||||
@[JSON::Field(key: "title")]
|
||||
property title : String
|
||||
end
|
||||
|
||||
class RedditResponse
|
||||
include JSON::Serializable
|
||||
|
||||
@[JSON::Field(key: "modhash")]
|
||||
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))
|
||||
request_url = URI.new scheme: "https", host: "www.reddit.com", path: "/r/#{subreddits.join "+"}/hot.json", query: "limit=30"
|
||||
response = HTTP::Client.get(request_url, headers: HTTP::Headers {
|
||||
"User-agent" => "Joann-Pupper-Bot"
|
||||
})
|
||||
response.body?.try { |body| JSON.parse(body) }
|
||||
end
|
||||
|
||||
def filter_reddit_json(json, completed)
|
||||
json["data"]["children"]
|
||||
.as_a
|
||||
.map(&.["data"])
|
||||
.select do |it|
|
||||
url = it["url"].as_s
|
||||
name = it["name"].as_s
|
||||
!completed.includes?(name) && (url.ends_with?(".png") || url.ends_with?(".jpg"))
|
||||
return nil unless body = response.body?
|
||||
|
||||
begin
|
||||
RedditWrapper(RedditResponse).from_json body
|
||||
rescue e
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def get_reddit_post(subreddit, completed)
|
||||
json = get_reddit_json(subreddit)
|
||||
post = json.try { |json| filter_reddit_json(json, completed).first? }
|
||||
post.try { |post| completed.push(post["name"].as_s); { post["url"].as_s, post["title"].as_s } }
|
||||
def self.from_subreddit(subreddit)
|
||||
from_subreddits [subreddit]
|
||||
end
|
||||
|
||||
def posts_matching(&block)
|
||||
children
|
||||
.map(&.data)
|
||||
.select { |it| yield it }
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user