# 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 <latest|commits|issues> \[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