Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 66 additions & 31 deletions cmd/bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"html"
"github-webhook/internal/bot/middleware"
"log"
"log/slog"
Expand Down Expand Up @@ -42,6 +43,9 @@ func main() {

func run() (runErr error) {
cfg := config.Load()
if cfg.GitHubWebhookSecret == "" {
log.Printf("WARNING: GITHUB_WEBHOOK_SECRET is empty — incoming webhook signature verification is DISABLED. Only use this for local development.")
}
database, err := db.Connect(cfg)
if err != nil {
return fmt.Errorf("connect to DB: %w", err)
Expand Down Expand Up @@ -122,6 +126,11 @@ func run() (runErr error) {
dispatcher.AddHandler(handlers.NewCallback(callbackquery.Prefix("act:"), cbHandler.HandlePRAction))

mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "text/plain")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte("ok"))
})
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
html := fmt.Sprintf(`
<html>
Expand Down Expand Up @@ -155,42 +164,49 @@ func run() (runErr error) {
}

oauthStateCache.Delete(state)
requestCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()

token, err := oauth.ExchangeCode(requestCtx, code)
if err != nil {
http.Error(w, "Failed to exchange code", http.StatusInternalServerError)
return
}
// Do the (slow) GitHub exchange + DB write in the background so we return a
// fast 200 to the browser. The request context is cancelled when we return,
// so the goroutine uses its own context with a generous timeout.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

encToken, err := utils.Encrypt(token.AccessToken, cfg.EncryptionKey)
if err != nil {
http.Error(w, "Encryption failed", http.StatusInternalServerError)
return
}
token, err := oauth.ExchangeCode(ctx, code)
if err != nil {
log.Printf("OAuth exchange failed for %d: %v", telegramID, err)
return
}

ghClient := clientFactory.GetUserClient(requestCtx, token.AccessToken)
u, _, err := ghClient.Users.Get(requestCtx, "")
if err != nil {
http.Error(w, "Failed to fetch user", http.StatusInternalServerError)
return
}
encToken, err := utils.Encrypt(token.AccessToken, cfg.EncryptionKey)
if err != nil {
log.Printf("OAuth encrypt failed for %d: %v", telegramID, err)
return
}

user := &models.User{
ID: telegramID,
GitHubUserID: u.GetID(),
GitHubUsername: u.GetLogin(),
EncryptedOAuthToken: encToken,
}
if err := database.UpsertUser(requestCtx, user); err != nil {
http.Error(w, "DB Error", http.StatusInternalServerError)
return
}
ghClient := clientFactory.GetUserClient(ctx, token.AccessToken)
u, _, err := ghClient.Users.Get(ctx, "")
if err != nil {
log.Printf("OAuth fetch user failed for %d: %v", telegramID, err)
return
}

_, _ = b.SendMessage(telegramID, fmt.Sprintf("✅ GitHub account <b>%s</b> connected successfully!", u.GetLogin()), &gotgbot.SendMessageOpts{ParseMode: "HTML"})
user := &models.User{
ID: telegramID,
GitHubUserID: u.GetID(),
GitHubUsername: u.GetLogin(),
EncryptedOAuthToken: encToken,
}
if err := database.UpsertUser(ctx, user); err != nil {
log.Printf("OAuth DB upsert failed for %d: %v", telegramID, err)
_, _ = b.SendMessage(telegramID, "⚠️ Connected to GitHub but failed to save your token. Please run /connect again.", &gotgbot.SendMessageOpts{ParseMode: "HTML"})
return
}

html := fmt.Sprintf(`
_, _ = b.SendMessage(telegramID, fmt.Sprintf("✅ GitHub account <b>%s</b> connected successfully!", html.EscapeString(u.GetLogin())), &gotgbot.SendMessageOpts{ParseMode: "HTML"})
}()

htmlBody := fmt.Sprintf(`
<html>
<head><title>Connected</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
Expand All @@ -205,7 +221,7 @@ func run() (runErr error) {
</body>
</html>`, b.User.Username, b.User.Username)
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(html))
_, _ = w.Write([]byte(htmlBody))
})

listener, err := net.Listen("tcp", ":"+cfg.Port)
Expand Down Expand Up @@ -291,6 +307,25 @@ func run() (runErr error) {
signalCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignals()

// Periodically sweep TTL caches so they do not grow unbounded (entries otherwise
// only evict on re-access, and most are never read again).
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-signalCtx.Done():
return
case <-ticker.C:
database.ChatReposCache.Cleanup()
oauthStateCache.Cleanup()
contextCache.Cleanup()
actionCache.Cleanup()
webhookServer.DeliverySeen.Cleanup()
}
}
}()

select {
case <-signalCtx.Done():
log.Printf("Shutdown signal received")
Expand Down
61 changes: 6 additions & 55 deletions internal/bot/callbacks/callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (h *CallbackHandler) getClient(b *gotgbot.Bot, ctx *ext.Context) (*gh.Clien
client, err := github.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
msg := "Auth error."
if err.Error() == "unauthorized" {
if errors.Is(err, github.ErrUnauthorized) {
msg = "Please /connect to GitHub first."
}
_, _ = ctx.CallbackQuery.Answer(b, &gotgbot.AnswerCallbackQueryOpts{Text: msg, ShowAlert: true})
Expand Down Expand Up @@ -95,55 +95,6 @@ func cbAddRepoID(repoID int64) string {
return cb(cbPrefixSettings, cbAddRepo, "id", strconv.FormatInt(repoID, 10))
}

func repoButtonText(name string) string {
const max = 42
if len(name) <= max {
return name
}
return name[:max-1] + "…"
}

func repoPageNav(page int, resp *gh.Response) []gotgbot.InlineKeyboardButton {
var navRow []gotgbot.InlineKeyboardButton

if resp.FirstPage != 0 && resp.PrevPage != 0 {
navRow = append(navRow, ui.Callback("<", cbAddRepoPage(resp.PrevPage),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconPrevious),
))
}

startPage := page - 1
if startPage < 1 {
startPage = 1
}

endPage := page + 1
if resp.LastPage != 0 && endPage > resp.LastPage {
endPage = resp.LastPage
}
if resp.LastPage == 0 && resp.NextPage != 0 {
endPage = resp.NextPage
}

for i := startPage; i <= endPage; i++ {
text := strconv.Itoa(i)
if i == page {
text = "· " + text + " ·"
}
navRow = append(navRow, ui.Callback(text, cbAddRepoPage(i), ui.WithStyle(ui.StylePrimary)))
}

if resp.NextPage != 0 {
navRow = append(navRow, ui.Callback(">", cbAddRepoPage(resp.NextPage),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconNext),
))
}

return navRow
}

func (h *CallbackHandler) HandleSettings(b *gotgbot.Bot, ctx *ext.Context) error {
if ctx.EffectiveChat.Type != gotgbot.ChatTypePrivate && !utils.IsAdmin(b, ctx.EffectiveChat.Id, ctx.EffectiveUser.Id) {
_, _ = ctx.CallbackQuery.Answer(b, &gotgbot.AnswerCallbackQueryOpts{Text: "Only admins can change settings", ShowAlert: true})
Expand Down Expand Up @@ -372,7 +323,7 @@ func (h *CallbackHandler) handleStopNotifications(b *gotgbot.Bot, ctx *ext.Conte
if l.WebhookID != 0 {
client, err := github.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, github.ErrUnauthorized) {
warning = "\n\nWarning: your GitHub account is not connected, so the GitHub webhook could not be removed automatically."
} else {
warning = "\n\nWarning: your GitHub token could not be decrypted, so the GitHub webhook was not removed automatically."
Expand Down Expand Up @@ -486,7 +437,7 @@ func (h *CallbackHandler) handlePresets(b *gotgbot.Bot, ctx *ext.Context, l *mod
func (h *CallbackHandler) showIndividualEvents(b *gotgbot.Bot, ctx *ext.Context, l *models.RepoLink, page int) error {
client, err := github.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, github.ErrUnauthorized) {
_, _, _ = ctx.EffectiveMessage.EditText(b, "Error: You must be connected to GitHub to view/edit settings.", nil)
} else {
_, _, _ = ctx.EffectiveMessage.EditText(b, "Auth error. Please reconnect.", nil)
Expand Down Expand Up @@ -583,7 +534,7 @@ func (h *CallbackHandler) showRepoList(b *gotgbot.Bot, ctx *ext.Context) error {
var kb [][]gotgbot.InlineKeyboardButton
for _, l := range links {
kb = append(kb, []gotgbot.InlineKeyboardButton{
ui.Callback(repoButtonText(l.RepoFullName), cbRepo(cbRepoMenu, &l),
ui.Callback(ui.CompactButtonText(l.RepoFullName), cbRepo(cbRepoMenu, &l),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconSettings),
),
Expand Down Expand Up @@ -619,14 +570,14 @@ func (h *CallbackHandler) handleRepoPage(b *gotgbot.Bot, ctx *ext.Context, page
var kb [][]gotgbot.InlineKeyboardButton
for _, repo := range repos {
kb = append(kb, []gotgbot.InlineKeyboardButton{
ui.Callback(repoButtonText(repo.GetFullName()), cbAddRepoID(repo.GetID()),
ui.Callback(ui.CompactButtonText(repo.GetFullName()), cbAddRepoID(repo.GetID()),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconAdd),
),
})
}

if navRow := repoPageNav(page, resp); len(navRow) > 0 {
if navRow := ui.RepoPageNav(page, resp, cbAddRepoPage); len(navRow) > 0 {
kb = append(kb, navRow)
}

Expand Down
63 changes: 7 additions & 56 deletions internal/bot/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,55 +140,6 @@ func repoSettingsCallback(link models.RepoLink) string {
return settingsCallback("c", "r", linkID)
}

func compactButtonText(name string) string {
const max = 42
if len(name) <= max {
return name
}
return name[:max-1] + "…"
}

func repoPageKeyboardNav(page int, resp *github.Response) []gotgbot.InlineKeyboardButton {
var navRow []gotgbot.InlineKeyboardButton

if resp.FirstPage != 0 && resp.PrevPage != 0 {
navRow = append(navRow, ui.Callback("<", addRepoPageCallback(resp.PrevPage),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconPrevious),
))
}

startPage := page - 1
if startPage < 1 {
startPage = 1
}

endPage := page + 1
if resp.LastPage != 0 && endPage > resp.LastPage {
endPage = resp.LastPage
}
if resp.LastPage == 0 && resp.NextPage != 0 {
endPage = resp.NextPage
}

for i := startPage; i <= endPage; i++ {
text := strconv.Itoa(i)
if i == page {
text = "· " + text + " ·"
}
navRow = append(navRow, ui.Callback(text, addRepoPageCallback(i), ui.WithStyle(ui.StylePrimary)))
}

if resp.NextPage != 0 {
navRow = append(navRow, ui.Callback(">", addRepoPageCallback(resp.NextPage),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconNext),
))
}

return navRow
}

func (h *CommandHandler) AddRepo(b *gotgbot.Bot, ctx *ext.Context) error {
if err := requireAdminOrPrivate(b, ctx, "Only admins can add repositories."); err != nil {
return err
Expand All @@ -202,7 +153,7 @@ func (h *CommandHandler) AddRepo(b *gotgbot.Bot, ctx *ext.Context) error {
repoFullName := args[1]
client, err := gh.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, gh.ErrUnauthorized) {
url, urlErr := h.loginURLForUser(ctx.EffectiveUser.Id)
if urlErr != nil {
return urlErr
Expand Down Expand Up @@ -316,7 +267,7 @@ func (h *CommandHandler) listUserRepos(b *gotgbot.Bot, ctx *ext.Context) error {
func (h *CommandHandler) sendRepoList(b *gotgbot.Bot, ctx *ext.Context, page int) error {
client, err := gh.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, gh.ErrUnauthorized) {
_, _ = ctx.EffectiveMessage.Reply(b, "Please /connect your GitHub account first to list repositories.", nil)
} else {
_, _ = ctx.EffectiveMessage.Reply(b, "Auth error. Reconnect via /connect", nil)
Expand Down Expand Up @@ -346,14 +297,14 @@ func (h *CommandHandler) sendRepoList(b *gotgbot.Bot, ctx *ext.Context, page int
var kb [][]gotgbot.InlineKeyboardButton
for _, repo := range repos {
kb = append(kb, []gotgbot.InlineKeyboardButton{
ui.Callback(compactButtonText(repo.GetFullName()), addRepoIDCallback(repo.GetID()),
ui.Callback(ui.CompactButtonText(repo.GetFullName()), addRepoIDCallback(repo.GetID()),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconAdd),
),
})
}

if navRow := repoPageKeyboardNav(page, resp); len(navRow) > 0 {
if navRow := ui.RepoPageNav(page, resp, addRepoPageCallback); len(navRow) > 0 {
kb = append(kb, navRow)
}

Expand Down Expand Up @@ -381,7 +332,7 @@ func (h *CommandHandler) Settings(b *gotgbot.Bot, ctx *ext.Context) error {
var kb [][]gotgbot.InlineKeyboardButton
for _, l := range links {
kb = append(kb, []gotgbot.InlineKeyboardButton{
ui.Callback(compactButtonText(l.RepoFullName), repoSettingsCallback(l),
ui.Callback(ui.CompactButtonText(l.RepoFullName), repoSettingsCallback(l),
ui.WithStyle(ui.StylePrimary),
ui.WithCustomEmojiEnv(ui.IconSettings),
),
Expand Down Expand Up @@ -417,7 +368,7 @@ func (h *CommandHandler) RemoveRepo(b *gotgbot.Bot, ctx *ext.Context) error {
if link.WebhookID != 0 {
client, err := gh.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, gh.ErrUnauthorized) {
webhookStatusMsg = "\n\n⚠️ <b>Warning:</b> You are not connected to GitHub. The webhook could not be removed from the repository settings. Please remove it manually."
} else {
webhookStatusMsg = "\n\n⚠️ <b>Warning:</b> Could not decrypt your access token. Webhook not removed from GitHub."
Expand Down Expand Up @@ -654,7 +605,7 @@ func (h *CommandHandler) handleIssueAction(b *gotgbot.Bot, ctx *ext.Context, sta
func (h *CommandHandler) getAuthenticatedClient(b *gotgbot.Bot, ctx *ext.Context) (*github.Client, error) {
client, err := gh.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, gh.ErrUnauthorized) {
url, urlErr := h.loginURLForUser(ctx.EffectiveUser.Id)
if urlErr != nil {
return nil, urlErr
Expand Down
3 changes: 2 additions & 1 deletion internal/bot/commands/reply_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package commands

import (
"context"
"errors"
"fmt"
"strings"

Expand Down Expand Up @@ -50,7 +51,7 @@ func (h *ReplyHandler) HandleReply(b *gotgbot.Bot, ctx *ext.Context) error {

client, err := github.GetClientForUser(context.Background(), h.DB, h.ClientFactory, ctx.EffectiveUser.Id, h.EncryptionKey)
if err != nil {
if err.Error() == "unauthorized" {
if errors.Is(err, github.ErrUnauthorized) {
_, _ = msg.Reply(b, "Please /connect your GitHub account in a private chat before replying to GitHub items.", nil)
} else {
_, _ = msg.Reply(b, "Auth error. Reconnect via /connect", nil)
Expand Down
Loading
Loading