#!/usr/bin/env bash if [[ $# -lt 4 ]]; then echo "Usage: $0 [channel...]" >&2 exit 1 fi inputFile="$1" logFile="$2" dateFormat="$3" shift 3 scriptPath="${BASH_SOURCE[0]}" serviceDir="${scriptPath%/*}" if [[ "$serviceDir" == "$scriptPath" ]]; then serviceDir="." fi configFile="${serviceDir}/rss.conf" stateFile="${serviceDir}/rss.seen" enabled=true pollInterval=3600 delayInterval=60 maxItemsPerPoll=5 startupDelay=15 userAgent="stormbot-rss/1.0" urls=("https://stormux.org/index.xml") channels=() loadedUrls=() pendingTitles=() pendingLinks=() itemsObserved=0 declare -A seenItems=() trim_value() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "$value" } strip_wrapping_quotes() { local value="$1" local firstChar local lastChar if [[ ${#value} -lt 2 ]]; then printf '%s' "$value" return fi firstChar="${value:0:1}" lastChar="${value: -1}" if [[ "$firstChar" == "$lastChar" && ( "$firstChar" == "\"" || "$firstChar" == "'" ) ]]; then value="${value:1:${#value}-2}" fi printf '%s' "$value" } log_rss() { local message="$1" local timestamp timestamp="$(date "+$dateFormat" 2>/dev/null || date)" printf 'RSS service: %s [%s]\n' "$message" "$timestamp" >> "$logFile" } append_url_token() { local url="$1" url="$(trim_value "$url")" url="${url%,}" url="$(strip_wrapping_quotes "$url")" if [[ -z "$url" ]]; then return fi if [[ "$url" =~ ^https?://[^[:space:]]+$ ]]; then loadedUrls+=("$url") else log_rss "Ignoring invalid feed URL: $url" fi } parse_url_tokens() { local content="$1" local token local doubleQuoteRegex='^[[:space:]]*"([^"]*)"(.*)$' local singleQuoteRegex="^[[:space:]]*'([^']*)'(.*)$" local closeRegex='^[[:space:]]*\)(.*)$' local bareRegex='^[[:space:]]*([^[:space:])]+)(.*)$' while [[ -n "$content" ]]; do if [[ "$content" =~ $closeRegex ]]; then break elif [[ "$content" =~ $doubleQuoteRegex ]]; then token="${BASH_REMATCH[1]}" content="${BASH_REMATCH[2]}" elif [[ "$content" =~ $singleQuoteRegex ]]; then token="${BASH_REMATCH[1]}" content="${BASH_REMATCH[2]}" elif [[ "$content" =~ $bareRegex ]]; then token="${BASH_REMATCH[1]}" content="${BASH_REMATCH[2]}" else break fi append_url_token "$token" done } assign_numeric_config() { local key="$1" local value="$2" local numericValue value="$(trim_value "${value%%#*}")" if [[ ! "$value" =~ ^[0-9]+$ ]]; then log_rss "Ignoring invalid numeric config ${key}=${value}" return fi numericValue=$((10#$value)) case "$key" in pollInterval) if (( numericValue > 0 )); then pollInterval="$numericValue" else log_rss "Ignoring pollInterval=0" fi ;; delayInterval) delayInterval="$numericValue" ;; maxItemsPerPoll) if (( numericValue > 0 )); then maxItemsPerPoll="$numericValue" else log_rss "Ignoring maxItemsPerPoll=0" fi ;; startupDelay) startupDelay="$numericValue" ;; esac } load_config() { local rawLine local line local key local value local inUrls=false local urlsConfigured=false if [[ ! -f "$configFile" ]]; then return fi loadedUrls=() while IFS= read -r rawLine || [[ -n "$rawLine" ]]; do line="$(trim_value "$rawLine")" if [[ -z "$line" || "${line:0:1}" == "#" ]]; then continue fi if [[ "$inUrls" == "true" ]]; then parse_url_tokens "$line" if [[ "$line" == *")"* ]]; then inUrls=false fi continue fi if [[ "$line" != *"="* ]]; then log_rss "Ignoring invalid config line: $line" continue fi key="$(trim_value "${line%%=*}")" value="$(trim_value "${line#*=}")" if [[ "$key" == "urls" ]]; then urlsConfigured=true if [[ "$value" == \(* ]]; then value="${value#(}" parse_url_tokens "$value" if [[ "$value" != *")"* ]]; then inUrls=true fi else log_rss "Ignoring invalid urls assignment" fi continue fi case "$key" in enabled) value="$(strip_wrapping_quotes "$(trim_value "${value%%#*}")")" case "${value,,}" in true|false) enabled="${value,,}" ;; *) log_rss "Ignoring invalid enabled=${value}" ;; esac ;; pollInterval|delayInterval|maxItemsPerPoll|startupDelay) assign_numeric_config "$key" "$value" ;; userAgent) value="$(strip_wrapping_quotes "$value")" if [[ -n "$value" ]]; then userAgent="$value" fi ;; *) log_rss "Ignoring unknown config key: $key" ;; esac done < "$configFile" if [[ "$urlsConfigured" == "true" ]]; then urls=("${loadedUrls[@]}") fi } normalize_channels() { local rawChannel local channelName channels=() for rawChannel in "$@"; do channelName="$rawChannel" if [[ "$channelName" != \#* ]]; then channelName="#$channelName" fi if [[ "$channelName" =~ ^#[a-zA-Z0-9_-]+$ ]]; then channels+=("$channelName") else log_rss "Skipping invalid channel name: $rawChannel" fi done } check_dependencies() { local missing=() local dependency for dependency in curl xmlstarlet date grep sort tr; do if ! command -v "$dependency" &>/dev/null; then missing+=("$dependency") fi done if [[ ${#missing[@]} -gt 0 ]]; then log_rss "Missing dependencies: ${missing[*]}" return 1 fi return 0 } load_seen_state() { local itemId seenItems=() if [[ ! -f "$stateFile" ]]; then return fi while IFS= read -r itemId || [[ -n "$itemId" ]]; do if [[ -n "$itemId" ]]; then seenItems["$itemId"]=1 fi done < "$stateFile" } save_seen_state() { local tempFile tempFile="$(mktemp)" || return 1 if [[ ${#seenItems[@]} -gt 0 ]]; then printf '%s\n' "${!seenItems[@]}" | sort > "$tempFile" else : > "$tempFile" fi mv "$tempFile" "$stateFile" } clean_text() { local value="$1" value="${value//$'\r'/ }" value="${value//$'\n'/ }" value="${value//$'\t'/ }" value="${value//[$'\001'-$'\037'$'\177']/}" value="$(trim_value "$value")" printf '%s' "$value" } fetch_feed() { local feedUrl="$1" local feedFile="$2" local errorFile local errorMessage errorFile="$(mktemp)" || return 1 if ! curl --fail --location --silent --show-error --proto '=http,https' --proto-redir '=http,https' --connect-timeout 5 --max-time 20 --user-agent "$userAgent" --output "$feedFile" "$feedUrl" 2>"$errorFile"; then errorMessage="$(tr '\n' ' ' < "$errorFile")" log_rss "Fetch failed for $feedUrl: $errorMessage" rm -f "$errorFile" return 1 fi rm -f "$errorFile" return 0 } feed_has_doctype() { local feedFile="$1" LC_ALL=C grep -a -q ' "$parsedFile" 2>"$errorFile"; then parseError="$(tr '\n' ' ' < "$errorFile")" log_rss "Parse failed for $feedUrl: $parseError" rm -f "$feedFile" "$parsedFile" "$errorFile" return 1 fi while IFS=$'\t' read -r title itemGuid alternateLink firstHref linkText || [[ -n "$title$itemGuid$alternateLink$firstHref$linkText" ]]; do title="$(clean_text "$title")" itemGuid="$(clean_text "$itemGuid")" alternateLink="$(clean_text "$alternateLink")" firstHref="$(clean_text "$firstHref")" linkText="$(clean_text "$linkText")" link="${alternateLink:-${firstHref:-$linkText}}" if [[ -z "$title" ]]; then title="$link" fi if [[ -z "$title" ]]; then continue fi itemKey="${feedUrl}|${itemGuid:-${link:-$title}}" ((itemsObserved++)) if [[ -z "${seenItems[$itemKey]+seen}" ]]; then seenItems["$itemKey"]=1 if [[ "$firstRun" == "false" ]]; then queue_announcement "$title" "$link" fi fi done < "$parsedFile" rm -f "$feedFile" "$parsedFile" "$errorFile" } format_announcement() { local title="$1" local link="$2" local message if [[ -n "$link" ]]; then message="${title} - ${link}" else message="RSS: ${title}" fi if [[ ${#message} -gt 400 ]]; then message="${message:0:397}..." fi printf '%s' "$message" } send_message() { local channelName="$1" local message="$2" message="$(clean_text "$message")" printf 'PRIVMSG %s :%s\r\n' "$channelName" "$message" >> "$inputFile" } deliver_announcements() { local totalCount="${#pendingTitles[@]}" local deliverCount="$totalCount" local itemIndex local channelName local message if (( totalCount == 0 )); then return fi if (( deliverCount > maxItemsPerPoll )); then deliverCount="$maxItemsPerPoll" log_rss "Suppressing $((totalCount - deliverCount)) RSS items over maxItemsPerPoll" fi for ((itemIndex = 0; itemIndex < deliverCount; itemIndex++)); do message="$(format_announcement "${pendingTitles[itemIndex]}" "${pendingLinks[itemIndex]}")" for channelName in "${channels[@]}"; do send_message "$channelName" "$message" done if (( itemIndex < deliverCount - 1 && delayInterval > 0 )); then sleep "$delayInterval" fi done } poll_feeds() { local firstRun="$1" local feedUrl pendingTitles=() pendingLinks=() itemsObserved=0 load_seen_state for feedUrl in "${urls[@]}"; do if [[ -n "$feedUrl" ]]; then process_feed "$feedUrl" "$firstRun" fi done if [[ "$firstRun" == "true" && "$itemsObserved" -eq 0 ]]; then log_rss "Initial poll found no RSS items; leaving state uninitialized" return fi save_seen_state if [[ "$firstRun" == "true" ]]; then log_rss "Initial poll marked $itemsObserved RSS items read" return fi deliver_announcements } load_config normalize_channels "$@" if [[ "$enabled" != "true" ]]; then log_rss "RSS service disabled" exit 0 fi if [[ ${#channels[@]} -eq 0 ]]; then log_rss "No valid channels configured for RSS service" exit 0 fi if [[ ! -w "$inputFile" ]]; then log_rss "Input file is not writable: $inputFile" exit 1 fi if ! check_dependencies; then exit 1 fi if (( startupDelay > 0 )); then sleep "$startupDelay" fi while true; do if [[ -f "$stateFile" ]]; then poll_feeds false else poll_feeds true fi sleep "$pollInterval" done