# github-multi.tcl # Supports ANY public owner/repo via !gh commands # Polls and auto-announces only the defined repos namespace eval ::github { variable cfg variable state variable repos variable cmd_cooldown variable default_repo "boxlabss/PASTE" array set cfg { poll_interval 120 token "GITHUBTOKENHERE" per_page 5 timeout_ms 10000 state_file "github_state.txt" announce_on_start 0 cmd_limit 3 } array set repos { "boxlabss/PASTE" {master "#PASTE"} "boxlabss/HexDroid" {main "#HexDroid"} } # map for monitored repos array set repo_map {} foreach r [array names repos] { set repo_map([string tolower $r]) $r } array set state {} array set cmd_cooldown {} } # DEPENDENCIES package require http package require tls package require json ::tls::init -ssl2 0 -ssl3 0 -tls1 1 -tls1.1 1 -tls1.2 1 -tls1.3 1 ::http::register https 443 ::tls::socket ::http::config -useragent "eggdrop-github-multi/1.6" # SAVE / LOAD STATE proc ::github::save_state {} { variable cfg variable state set f [open $cfg(state_file) w] puts $f [array get state] close $f } proc ::github::load_state {} { variable cfg variable state if {![file exists $cfg(state_file)]} return set f [open $cfg(state_file) r] catch {array set state [read $f]} close $f } # HTTP GET WRAPPER proc ::github::http_get {url {extraHeaders {}}} { variable cfg set headers [list Accept "application/vnd.github+json" User-Agent "eggdrop-github-multi/1.6"] if {[string length $cfg(token)]} { lappend headers Authorization "Bearer $cfg(token)" } if {[llength $extraHeaders]} { set headers [concat $headers $extraHeaders] } set tok [::http::geturl $url -headers $headers -timeout $cfg(timeout_ms)] set code [lindex [split [::http::code $tok]] 1] set body [::http::data $tok] set meta [::http::meta $tok] array set H {} foreach {k v} $meta { set H([string tolower $k]) $v } ::http::cleanup $tok return [list $code $body [array get H]] } proc ::github::say {channel msg} { if {$channel eq ""} return putserv "PRIVMSG $channel :$msg" } proc ::github::shortmsg {text maxlen} { set line [string trim [lindex [split $text \n] 0]] if {[string length $line] > $maxlen} { set line "[string range $line 0 [expr {$maxlen-4}]]..." } return $line } # DATA FETCHERS proc ::github::fetch_latest_commit {repo branch} { set url "https://api.github.com/repos/$repo/commits?per_page=1&sha=$branch" lassign [::github::http_get $url] code body H if {$code < 200 || $code >= 300} { return {} } set data [::json::json2dict $body] if {![llength $data]} { return {} } set item [lindex $data 0] set sha [dict get $item sha] set commit [dict get $item commit] set msg [dict get $commit message] set url [dict get $item html_url] if {[dict exists $item author] && [dict get $item author] ne ""} { set author [dict get [dict get $item author] login] } else { set author [dict get [dict get $commit author] name] } return [list sha $sha author $author message $msg url $url] } proc ::github::fetch_commits {repo branch} { variable cfg variable state set url "https://api.github.com/repos/$repo/commits?per_page=$cfg(per_page)&sha=$branch" set headers {} if {[info exists state($repo,etag_commits)]} { lappend headers If-None-Match $state($repo,etag_commits) } lassign [::github::http_get $url $headers] code body H if {$code == 304} { return {} } if {$code < 200 || $code >= 300} { return {} } if {[dict exists $H etag]} { set state($repo,etag_commits) [dict get $H etag] } set data [::json::json2dict $body] set out {} foreach item $data { set sha [dict get $item sha] if {[info exists state($repo,last_commit_sha)] && $sha eq $state($repo,last_commit_sha)} break set commit [dict get $item commit] set msg [dict get $commit message] set url [dict get $item html_url] if {[dict exists $item author] && [dict get $item author] ne ""} { set author [dict get [dict get $item author] login] } else { set author [dict get [dict get $commit author] name] } lappend out [list sha $sha author $author message $msg url $url] } return $out } proc ::github::fetch_issues {repo {limit 0}} { variable cfg if {$limit == 0} { set limit $cfg(per_page) } set url "https://api.github.com/repos/$repo/issues?state=open&sort=created&direction=desc&per_page=$limit" lassign [::github::http_get $url] code body H if {$code < 200 || $code >= 300} { return {} } set data [::json::json2dict $body] set out {} foreach item $data { if {[dict exists $item pull_request]} continue lappend out [list id [dict get $item id] number [dict get $item number] \ title [dict get $item title] user [dict get [dict get $item user] login] \ url [dict get $item html_url]] } return $out } # ANNOUNCEMENTS proc ::github::announce_commits {repo branch channel commits} { variable state foreach c [lreverse $commits] { array set C $c set msg [::github::shortmsg $C(message) 140] ::github::say $channel "🧩 $repo@$branch: $C(author) committed “$msg” — $C(url)" set state($repo,last_commit_sha) $C(sha) } } proc ::github::announce_issues {repo channel issues} { variable state if {![info exists state($repo,seen_issue_ids)]} { set state($repo,seen_issue_ids) {} } foreach i [lreverse $issues] { array set I $i if {[lsearch -exact $state($repo,seen_issue_ids) $I(id)] >= 0} continue ::github::say $channel "🐞 New issue #$I(number) by $I(user): $I(title) — $I(url)" lappend state($repo,seen_issue_ids) $I(id) if {[llength $state($repo,seen_issue_ids)] > 200} { set state($repo,seen_issue_ids) [lrange $state($repo,seen_issue_ids) end-199 end] } } } # POLLING proc ::github::poll {} { variable repos variable cfg foreach repo [array names repos] { lassign $repos($repo) branch channel set commits [::github::fetch_commits $repo $branch] if {[llength $commits]} { ::github::announce_commits $repo $branch $channel $commits } set issues [::github::fetch_issues $repo] if {[llength $issues]} { ::github::announce_issues $repo $channel $issues } } ::github::save_state utimer $cfg(poll_interval) ::github::poll } # COMMAND HELPERS proc ::github::cooldown_ok {chan} { variable cmd_cooldown set now [unixtime] if {[info exists cmd_cooldown($chan)] && ($now - $cmd_cooldown($chan)) < 8} { return 0 } set cmd_cooldown($chan) $now return 1 } proc ::github::get_canonical_repo {input} { variable repo_map set lower [string tolower $input] return [expr {[info exists repo_map($lower)] ? $repo_map($lower) : $input}] } proc ::github::get_default_branch {repo} { set url "https://api.github.com/repos/$repo" lassign [::github::http_get $url] code body H if {$code != 200} { putlog "github: failed to get default branch for $repo (HTTP $code)" return "main" } set data [::json::json2dict $body] if {[dict exists $data default_branch]} { return [dict get $data default_branch] } return "main" } proc ::github::get_branch {repo} { variable repos if {[info exists repos($repo)]} { return [lindex $repos($repo) 0] } return [::github::get_default_branch $repo] } # USER COMMANDS proc ::github::cmd_latest {chan repo} { set branch [::github::get_branch $repo] set latest [::github::fetch_latest_commit $repo $branch] if {[llength $latest]} { array set C $latest set msg [::github::shortmsg $C(message) 100] putserv "PRIVMSG $chan :🧩 Latest commit: $repo@$branch → $C(author): $msg — $C(url)" } else { putserv "PRIVMSG $chan :❌ Could not fetch latest commit for $repo (repo may not exist, be private, or rate-limited)" } set issues [::github::fetch_issues $repo 1] if {[llength $issues]} { array set I [lindex $issues 0] putserv "PRIVMSG $chan :🐞 Latest open issue: #$I(number) by $I(user): $I(title) — $I(url)" } } proc ::github::cmd_commits {chan repo} { variable cfg set branch [::github::get_branch $repo] putserv "PRIVMSG $chan :🧩 Last $cfg(cmd_limit) commits for $repo@$branch:" set url "https://api.github.com/repos/$repo/commits?per_page=$cfg(cmd_limit)&sha=$branch" lassign [::github::http_get $url] code body H if {$code < 200 || $code >= 300} { putserv "PRIVMSG $chan :❌ Error fetching commits (HTTP $code) — repo may not exist or be private" return } set data [::json::json2dict $body] if {![llength $data]} { putserv "PRIVMSG $chan :No commits found on branch $branch" return } foreach item $data { set commit [dict get $item commit] set msg [::github::shortmsg [dict get $commit message] 80] set url [dict get $item html_url] putserv "PRIVMSG $chan :• $msg — $url" } } proc ::github::cmd_issues {chan repo} { variable cfg putserv "PRIVMSG $chan :🐞 Last $cfg(cmd_limit) open issues for $repo:" set issues [::github::fetch_issues $repo $cfg(cmd_limit)] if {![llength $issues]} { putserv "PRIVMSG $chan :No open issues found (repo may not exist, be private, or have none open)." return } foreach i $issues { array set I $i putserv "PRIVMSG $chan :• #$I(number): $I(title) — $I(url)" } } proc ::github::cmd_dispatch {nick host hand chan text} { variable default_repo if {![::github::cooldown_ok $chan]} return set args [split [string trim $text]] set sub [string tolower [lindex $args 0]] set input [lindex $args 1] if {$input eq ""} { set repo $default_repo } else { set repo [::github::get_canonical_repo $input] } switch -- $sub { latest { ::github::cmd_latest $chan $repo } commits { ::github::cmd_commits $chan $repo } issues { ::github::cmd_issues $chan $repo } default { putserv "PRIVMSG $chan :Usage: !gh \[owner/repo\]" putserv "PRIVMSG $chan :Monitored (auto-announce): [join [array names ::github::repos] ", "]" putserv "PRIVMSG $chan :Any public repo works for on-demand queries (e.g. !gh commits evilnet/x3)" } } } bind pub - "!gh" ::github::cmd_dispatch proc ::github::init {event} { ::github::load_state if {!$::github::cfg(announce_on_start)} { foreach repo [array names ::github::repos] { lassign $::github::repos($repo) branch channel set first [::github::fetch_latest_commit $repo $branch] if {[llength $first]} { array set C $first set ::github::state($repo,last_commit_sha) $C(sha) } set issues [::github::fetch_issues $repo 5] foreach i $issues { array set I $i lappend ::github::state($repo,seen_issue_ids) $I(id) } } ::github::save_state } putlog "github-multi: monitoring started" utimer 5 ::github::poll } bind evnt - init-server ::github::init