├── .gitignore ├── README.md ├── flake.lock ├── flake.nix ├── module.nix └── tsoping /.gitignore: -------------------------------------------------------------------------------- 1 | /data 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tsoping 2 | 3 | This is a tool that posts a link to a Telegram chat when Tsoding uploads. 4 | 5 | ## Building 6 | 7 | To build with Nix, run `nix build`. `result/bin/tsoping` will contain the script. 8 | 9 | Dependencies can be seen in the flake.nix file. 10 | 11 | ## Running 12 | 13 | `tsoping run` 14 | 15 | This program expects two files to be present in the current directory: 16 | 17 | - `data/chat.id` -- the id of the chat where to send the link. The bot has to be added to this chat. 18 | 19 | - `data/telegram.secret` -- the bot's secret token provided by BotFather. 20 | 21 | The following command line may be helpful to discover the id of the chat to which the bot has been added: 22 | 23 | `curl "https://api.telegram.org/bot$(cat secret)/getUpdates" | jq` 24 | 25 | ## Modes 26 | 27 | The program has a couple of different modes, which may be helpful for debugging. They are indicated by the first argument to the program. 28 | 29 | - `fetch`: fetches the feed, converts it to JSON and saves it to `data/videos.json`. 30 | 31 | - `set-start-time`: sets `data/last.time` with the current system time. You may want to set the time manually. 32 | 33 | - `set-chat-id`: saves the chat id given as an argument to `data/chat.id` (this is just an echo at the time of writing). 34 | 35 | - `links`: does all of the above if needed, but also filters out only new videos and saves them to `links.txt`. 36 | 37 | - `send-to-telegram`: sends one link to the Telegram chat and updates the time in `data/last.time`. 38 | 39 | - Run: sends all not yet sent links since the recorded time to the Telegram chat. 40 | 41 | ## License 42 | 43 | MIT. 44 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1722421184, 24 | "narHash": "sha256-/DJBI6trCeVnasdjUo9pbnodCLZcFqnVZiLUfqLH4jA=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "9f918d616c5321ad374ae6cb5ea89c9e04bf3e58", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A very basic flake"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, flake-utils }: 10 | flake-utils.lib.eachDefaultSystem (system: let 11 | pkgs = nixpkgs.legacyPackages.${system}; 12 | name = "tsoping"; 13 | tsoping = ( 14 | pkgs.writeScriptBin name (builtins.readFile ./tsoping) 15 | ).overrideAttrs(old: { 16 | buildCommand = "${old.buildCommand}\n patchShebangs $out"; 17 | }); 18 | in rec { 19 | packages.tsoping = pkgs.symlinkJoin { 20 | inherit name; 21 | paths = [ 22 | tsoping pkgs.python312Packages.xmljson pkgs.jq pkgs.curl pkgs.coreutils 23 | ]; 24 | buildInputs = [ pkgs.makeWrapper ]; 25 | postBuild = "wrapProgram $out/bin/${name} --prefix PATH : $out/bin"; 26 | }; 27 | }) // { 28 | nixosModules.tsoping = import ./module.nix self; 29 | } 30 | ; 31 | } 32 | -------------------------------------------------------------------------------- /module.nix: -------------------------------------------------------------------------------- 1 | self: { config, lib, pkgs, ... }: let 2 | cfg = config.services.tsoping; 3 | in { 4 | imports = []; 5 | 6 | options = { 7 | services.tsoping = let inherit (lib) mkOption types; in { 8 | enable = mkOption { 9 | type = types.bool; 10 | default = false; 11 | description = '' 12 | Enable Tsoping, a service to send a link to a Telegram chat when Tsoding uploads a new video. 13 | ''; 14 | }; 15 | chat-id-file = mkOption { 16 | type = types.path; 17 | }; 18 | telegram-token-file = mkOption { 19 | type = types.path; 20 | }; 21 | }; 22 | }; 23 | 24 | config = lib.mkIf cfg.enable { 25 | users.users.tsoping = { 26 | description = "Tsoping service user"; 27 | isSystemUser = true; 28 | group = "tsoping"; 29 | createHome = true; 30 | home = "/home/tsoping"; 31 | }; 32 | 33 | users.groups.tsoping = {}; 34 | 35 | systemd.services.tsoping = { 36 | after = [ "network-online.target" ]; 37 | wants = [ "network-online.target" ]; 38 | wantedBy = [ "multi-user.target" ]; 39 | script = '' 40 | cd /home/tsoping 41 | \ 42 | TSOPING_CHAT_ID_FILE="${cfg.chat-id-file}" \ 43 | TSOPING_TELEGRAM_TOKEN_FILE="${cfg.telegram-token-file}" \ 44 | exec \ 45 | ${self.packages.${pkgs.system}.tsoping}/bin/tsoping run \ 46 | ; 47 | ''; 48 | serviceConfig = { 49 | Type = "oneshot"; 50 | User = "tsoping"; 51 | Group = "tsoping"; 52 | }; 53 | startAt = "hourly"; 54 | restartIfChanged = false; 55 | }; 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /tsoping: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o pipefail 4 | 5 | which tsoping > /dev/null 2> /dev/null || { 6 | PATH=$PATH:$(dirname "$0") 7 | } 8 | 9 | usage () { 10 | echo commands: fetch, links, set-start-time, send-to-telegram, set-chat-id, run 11 | exit 1 12 | } 13 | 14 | echo tsoping is being run, args: '[' "$@" ']', date: 15 | date 16 | 17 | command=$1; shift 2> /dev/null || usage 18 | 19 | case "$command" in 20 | fetch) 21 | id=UCrqM0Ym_NbK1fqeQG2VIohg 22 | url="https://www.youtube.com/feeds/videos.xml?channel_id=$id" 23 | exec curl "$url" | xml2json > data/videos.json || { 24 | echo "error: couldn't fetch videos XML or convert them to JSON" 25 | exit 1 26 | } 27 | ;; 28 | links) 29 | test -e data/videos.json || tsoping fetch || exit 1 30 | test -e data/last.time || tsoping set-start-time || exit 1 31 | 32 | lasttime=$(cat data/last.time) || exit 1 33 | 34 | atom='{http://www.w3.org/2005/Atom}' 35 | yt='{http://www.youtube.com/xml/schemas/2015}' 36 | 37 | cat data/videos.json | jq -r ' 38 | def striptimezone: 39 | match("(.*)\\+(.*)").captures | map(.string) 40 | | if .[1] != "00:00" then 41 | error("timezone is not zero") 42 | else . 43 | end 44 | | .[0] + "Z" 45 | ; 46 | 47 | def tounix: striptimezone | fromdateiso8601; 48 | 49 | ."'"$atom"'entry" 50 | | map( 51 | { 52 | time: (."'"$atom"'published" | tounix), 53 | link: ("https://youtu.be/" + ."'"$yt"'videoId") 54 | } 55 | | select( 56 | .time > '"$lasttime"' 57 | ) 58 | # [ .time, .link ] 59 | # .[] 60 | ) 61 | | sort_by(.time) 62 | | map(.[]) | .[] 63 | ' > data/links.txt 64 | ;; 65 | set-start-time) 66 | echo 'setting data/last.time to current time' 67 | date +%s > data/last.time || { 68 | echo "error: countn't set start time" 69 | exit 1 70 | } 71 | ;; 72 | run) 73 | tsoping fetch || { echo tsoping fetch failed; exit 1; } 74 | tsoping links || { echo tsoping links failed; exit 1; } 75 | 76 | cat data/links.txt | while true; do 77 | IFS= read -r time || break 78 | IFS= read -r link || { 79 | echo error: read time but not link; exit 1 80 | } 81 | 82 | tsoping send-to-telegram "$time" "$link" || exit 1 83 | 84 | # Prevent link from being sent again. 85 | echo "$time" > data/last.time || exit 1 86 | done 87 | ;; 88 | send-to-telegram) 89 | time=$1; shift || { echo "error: no video timestamp given"; exit 1; } 90 | link=$1; shift || { echo "error: no video link given"; exit 1; } 91 | 92 | if ! chat=$(cat "$TSOPING_CHAT_ID_FILE"); then 93 | echo "error: cound't read chat id file" 94 | exit 1 95 | fi 96 | 97 | if ! token=$(cat "$TSOPING_TELEGRAM_TOKEN_FILE"); then 98 | echo "error: couldn't read telegram token file" 99 | exit 1 100 | fi 101 | 102 | url="https://api.telegram.org/bot$token" 103 | 104 | curl \ 105 | "$url/sendMessage" \ 106 | -H 'content-type: application/json' \ 107 | -d '{ 108 | "chat_id": '"$chat"', 109 | "text": "'"$link"'", 110 | "disable_notification": true, 111 | "link_preview_options": { 112 | "url": "'"$link"'", 113 | "is_disabled": false, 114 | "prefer_small_media": false 115 | } 116 | }' \ 117 | || exit 1 118 | ;; 119 | set-chat-id) 120 | id=$1; shift || { echo "error: no chat id given"; exit 1; } 121 | echo "$id" > data/chat.id || exit 1 122 | ;; 123 | *) 124 | echo "error: no such command: $command" 125 | usage 126 | ;; 127 | esac 128 | --------------------------------------------------------------------------------