diff --git a/cmd/bot/app.go b/cmd/bot/app.go index 36cf963..0837582 100644 --- a/cmd/bot/app.go +++ b/cmd/bot/app.go @@ -39,7 +39,6 @@ import ( "github.com/gotd/bot/internal/dispatch" "github.com/gotd/bot/internal/docs" "github.com/gotd/bot/internal/entdb" - "github.com/gotd/bot/internal/gh" "github.com/gotd/bot/internal/oas" "github.com/gotd/bot/internal/storage" "github.com/gotd/bot/internal/tgmanager" @@ -223,66 +222,56 @@ func (b *App) Run(ctx context.Context) error { return b.manager.Run(ctx) }) - if secret, ok := os.LookupEnv("GITHUB_SECRET"); ok { - logger := b.logger.Named("webhook") + httpAddr := os.Getenv("HTTP_ADDR") + if httpAddr == "" { + httpAddr = "localhost:8080" + } - httpAddr := os.Getenv("HTTP_ADDR") - if httpAddr == "" { - httpAddr = "localhost:8080" - } + logger := b.logger.Named("http") + e := echo.New() + e.Use( + middleware.Recover(), + middleware.RequestID(), + echozap.ZapLogger(logger.Named("requests")), + ) - webhook := gh.NewWebhook(b.storage, b.sender, secret). - WithLogger(logger) - if notifyGroup, ok := os.LookupEnv("TG_NOTIFY_GROUP"); ok { - webhook = webhook.WithNotifyGroup(notifyGroup) - } + e.GET("/probe/startup", func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) + e.GET("/probe/ready", func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) - e := echo.New() - e.Use( - middleware.Recover(), - middleware.RequestID(), - echozap.ZapLogger(logger.Named("requests")), - ) + e.GET("/status", func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) - e.GET("/probe/startup", func(c echo.Context) error { - return c.String(http.StatusOK, "ok") - }) - e.GET("/probe/ready", func(c echo.Context) error { - return c.String(http.StatusOK, "ok") - }) + mux := http.NewServeMux() + mux.Handle("/", e) + mux.Handle("/api/", b.srv) - e.GET("/status", func(c echo.Context) error { - return c.String(http.StatusOK, "ok") - }) - webhook.RegisterRoutes(e) - - mux := http.NewServeMux() - mux.Handle("/", e) - mux.Handle("/api/", b.srv) - - server := http.Server{ - Addr: httpAddr, - Handler: mux, - BaseContext: func(listener net.Listener) context.Context { - return zctx.Base(ctx, b.logger) - }, - } - group.Go(func() error { - logger.Info("ListenAndServe", zap.String("addr", server.Addr)) - return server.ListenAndServe() - }) - group.Go(func() error { - <-ctx.Done() - shutCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) - defer cancel() - - logger.Info("Shutdown", zap.String("addr", server.Addr)) - if err := server.Shutdown(shutCtx); err != nil { - return multierr.Append(err, server.Close()) - } - return nil - }) + server := http.Server{ + Addr: httpAddr, + Handler: mux, + BaseContext: func(listener net.Listener) context.Context { + return zctx.Base(ctx, b.logger) + }, } + group.Go(func() error { + logger.Info("ListenAndServe", zap.String("addr", server.Addr)) + return server.ListenAndServe() + }) + group.Go(func() error { + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + logger.Info("Shutdown", zap.String("addr", server.Addr)) + if err := server.Shutdown(shutCtx); err != nil { + return multierr.Append(err, server.Close()) + } + return nil + }) group.Go(func() error { return b.client.Run(ctx, func(ctx context.Context) error { diff --git a/internal/gh/doc.go b/internal/gh/doc.go deleted file mode 100644 index cbc0302..0000000 --- a/internal/gh/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package gh implements bot command to create Github workflow dispatch events. -package gh diff --git a/internal/gh/handle_discussion.go b/internal/gh/handle_discussion.go deleted file mode 100644 index c214d4f..0000000 --- a/internal/gh/handle_discussion.go +++ /dev/null @@ -1,63 +0,0 @@ -package gh - -import ( - "context" - "fmt" - - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/telegram/message/entity" - "github.com/gotd/td/telegram/message/styling" -) - -func getDiscussionType(d *github.Discussion) string { - cat := d.GetDiscussionCategory() - emoji := cat.GetEmoji() - if emoji == "" { - return cat.GetName() - } - return cat.GetName() + " " + cat.GetEmoji() -} - -func formatDiscussion(e *github.DiscussionEvent) message.StyledTextOption { - discussion := e.GetDiscussion() - sender := e.GetSender() - formatter := func(eb *entity.Builder) error { - eb.Plain("New ") - eb.Plain(getDiscussionType(discussion)) - eb.Plain(" discussion") - - urlName := fmt.Sprintf(" %s#%d", - e.GetRepo().GetFullName(), - discussion.GetNumber(), - ) - eb.TextURL(urlName, discussion.GetHTMLURL()) - eb.Plain(" by ") - eb.TextURL(sender.GetLogin(), sender.GetHTMLURL()) - eb.Plain("\n\n") - - eb.Italic(discussion.GetTitle()) - eb.Plain("\n\n") - - return nil - } - - return styling.Custom(formatter) -} - -func (h Webhook) handleDiscussion(ctx context.Context, e *github.DiscussionEvent) error { - if e.GetAction() != "created" { - return nil - } - - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - - if _, err := h.sender.To(p).NoWebpage().StyledText(ctx, formatDiscussion(e)); err != nil { - return errors.Wrap(err, "send") - } - return nil -} diff --git a/internal/gh/handle_issue.go b/internal/gh/handle_issue.go deleted file mode 100644 index b731d24..0000000 --- a/internal/gh/handle_issue.go +++ /dev/null @@ -1,94 +0,0 @@ -package gh - -import ( - "context" - "fmt" - - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/telegram/message/entity" - "github.com/gotd/td/telegram/message/styling" -) - -type issueType string - -const ( - featureRequest issueType = "feature request" - bugReport issueType = "bug report" - plain issueType = "issue" -) - -func getIssueType(issue *github.Issue) issueType { - for _, label := range issue.Labels { - switch label.GetName() { - case "enhancement": - return featureRequest - case "bug": - return bugReport - } - } - - return plain -} - -func formatIssue(e *github.IssuesEvent) message.StyledTextOption { - issue := e.GetIssue() - sender := e.GetSender() - formatter := func(eb *entity.Builder) error { - eb.Plain("New ") - eb.Plain(string(getIssueType(issue))) - - urlName := fmt.Sprintf(" %s#%d", - e.GetRepo().GetFullName(), - issue.GetNumber(), - ) - eb.TextURL(urlName, issue.GetHTMLURL()) - eb.Plain(" by ") - eb.TextURL(sender.GetLogin(), sender.GetHTMLURL()) - eb.Plain("\n\n") - - eb.Italic(issue.GetTitle()) - eb.Plain("\n\n") - - length := len(issue.Labels) - if length > 0 { - eb.Italic("Labels: ") - - for idx, label := range issue.Labels { - switch label.GetName() { - case "": - continue - case "bug": - eb.Bold(label.GetName()) - default: - eb.Italic(label.GetName()) - } - - if idx != length-1 { - eb.Plain(", ") - } - } - } - return nil - } - - return styling.Custom(formatter) -} - -func (h Webhook) handleIssue(ctx context.Context, e *github.IssuesEvent) error { - if e.GetAction() != "opened" { - return nil - } - - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - - if _, err := h.sender.To(p).NoWebpage().StyledText(ctx, formatIssue(e)); err != nil { - return errors.Wrap(err, "send") - } - return nil -} diff --git a/internal/gh/handle_pr.go b/internal/gh/handle_pr.go deleted file mode 100644 index 61e45fb..0000000 --- a/internal/gh/handle_pr.go +++ /dev/null @@ -1,186 +0,0 @@ -package gh - -import ( - "context" - "fmt" - "net/url" - "path" - - "github.com/cockroachdb/pebble" - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - "go.uber.org/multierr" - "go.uber.org/zap" - - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/telegram/message/entity" - "github.com/gotd/td/telegram/message/markup" - "github.com/gotd/td/telegram/message/styling" - "github.com/gotd/td/telegram/message/unpack" - "github.com/gotd/td/tg" -) - -func getPullRequestURL(e *github.PullRequestEvent) styling.StyledTextOption { - urlName := fmt.Sprintf("%s#%d", - e.GetRepo().GetFullName(), - e.PullRequest.GetNumber(), - ) - - return styling.TextURL(urlName, e.GetPullRequest().GetHTMLURL()) -} - -func getPullRequestAuthor(e *github.PullRequestEvent) styling.StyledTextOption { - u := e.GetPullRequest().GetUser() - return styling.TextURL(u.GetLogin(), u.GetHTMLURL()) -} - -func getPullRequestMergedBy(e *github.PullRequestEvent) styling.StyledTextOption { - u := e.GetPullRequest().GetMergedBy() - return styling.TextURL(u.GetLogin(), u.GetHTMLURL()) -} - -func (h Webhook) notifyPR(p tg.InputPeerClass, e *github.PullRequestEvent) *message.Builder { - r := h.sender.To(p).NoWebpage() - if u, _ := url.Parse(e.GetPullRequest().GetHTMLURL()); u != nil { - files, checks := *u, *u - files.Path = path.Join(files.Path, "files") - checks.Path = path.Join(checks.Path, "checks") - r = r.Row( - markup.URL("Diff🔀", files.String()), - markup.URL("Checks▶", checks.String()), - ) - } - return r -} - -func (h Webhook) handlePRClosed(ctx context.Context, e *github.PullRequestEvent) error { - prID := e.GetPullRequest().GetNumber() - log := h.logger.With(zap.Int("pr", prID), zap.String("repo", e.GetRepo().GetFullName())) - if !e.GetPullRequest().GetMerged() { - h.logger.Info("Ignoring non-merged PR") - return nil - } - - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - - var replyID int - fallback := func(ctx context.Context) error { - r := h.notifyPR(p, e) - if replyID != 0 { - r = r.Reply(replyID) - } - if _, err := r.StyledText(ctx, - styling.Plain("Pull request "), - getPullRequestURL(e), - styling.Plain(" merged by "), - getPullRequestMergedBy(e), - styling.Plain("\n\n"), - styling.Italic(e.GetPullRequest().GetTitle()), - ); err != nil { - return errors.Wrap(err, "send") - } - - return nil - } - - ch, ok := p.(*tg.InputPeerChannel) - if !ok { - return fallback(ctx) - } - - msgID, lastMsgID, err := h.storage.FindPRNotification(ch.ChannelID, e) - if msgID != 0 { - log.Debug("Found PR notification ID", zap.Int("msg_id", msgID)) - replyID = msgID - } - if err != nil { - if errors.Is(err, pebble.ErrNotFound) { - return fallback(ctx) - } - return errors.Wrap(err, "find notification") - } - - log.Debug("Found last message ID", zap.Int("msg_id", lastMsgID), zap.Int64("channel", ch.ChannelID)) - if lastMsgID-msgID > 10 { - log.Debug("Can't merge, send new message") - return fallback(ctx) - } - - if _, err := h.notifyPR(p, e).Edit(msgID).StyledText(ctx, - styling.Plain("Pull request "), - getPullRequestURL(e), - styling.Plain(" "), - styling.Strike("opened by "), - styling.Custom(func(eb *entity.Builder) error { - u := e.GetPullRequest().GetUser() - eb.Format( - u.GetLogin(), - entity.Strike(), - entity.TextURL(u.GetHTMLURL()), - ) - return nil - }), - styling.Plain(" merged by "), - getPullRequestMergedBy(e), - styling.Plain("\n\n"), - styling.Italic(e.GetPullRequest().GetTitle()), - ); err != nil { - return errors.Wrap(err, "send") - } - - return nil -} - -func (h Webhook) handlePROpened(ctx context.Context, event *github.PullRequestEvent) error { - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - action := " opened" - if event.GetPullRequest().GetDraft() { - action = " drafted" - } - - msgID, err := unpack.MessageID(h.notifyPR(p, event).StyledText(ctx, - styling.Plain("New pull request "), - getPullRequestURL(event), - styling.Plain(action), - styling.Plain(" by "), - getPullRequestAuthor(event), - styling.Plain("\n\n"), - styling.Italic(event.GetPullRequest().GetTitle()), - )) - if err != nil { - return errors.Wrap(err, "send") - } - - ch, ok := p.(*tg.InputPeerChannel) - if !ok { - return h.storage.SetPRNotification(event, msgID) - } - - return multierr.Append( - h.storage.UpdateLastMsgID(ch.ChannelID, msgID), - h.storage.SetPRNotification(event, msgID), - ) -} - -func (h Webhook) handlePR(ctx context.Context, e *github.PullRequestEvent) error { - // Ignore PR-s from dependabot (too much noise). - // TODO(ernado): delay and merge into single message - if e.GetPullRequest().GetUser().GetLogin() == "dependabot[bot]" { - h.logger.Info("Ignored PR from dependabot") - return nil - } - - switch e.GetAction() { - case "opened": - return h.handlePROpened(ctx, e) - case "closed": - return h.handlePRClosed(ctx, e) - } - return nil -} diff --git a/internal/gh/handle_pr_test.go b/internal/gh/handle_pr_test.go deleted file mode 100644 index b009ffc..0000000 --- a/internal/gh/handle_pr_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package gh - -import ( - "context" - "testing" - - "github.com/cockroachdb/pebble" - "github.com/cockroachdb/pebble/vfs" - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - "github.com/stretchr/testify/require" - "go.uber.org/zap/zaptest" - - "github.com/gotd/td/bin" - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/tg" - "github.com/gotd/td/tgerr" - - "github.com/gotd/bot/internal/storage" -) - -type mockResolver map[string]tg.InputPeerClass - -func (m mockResolver) ResolveDomain(ctx context.Context, domain string) (tg.InputPeerClass, error) { - f, ok := m[domain] - if !ok { - return nil, tgerr.New(400, tg.ErrUsernameInvalid) - } - return f, nil -} - -func (m mockResolver) ResolvePhone(ctx context.Context, phone string) (tg.InputPeerClass, error) { - f, ok := m[phone] - if !ok { - return nil, tgerr.New(400, tg.ErrUsernameInvalid) - } - return f, nil -} - -func prEvent(prID int, orgID int64) *github.PullRequestEvent { - return &github.PullRequestEvent{ - PullRequest: &github.PullRequest{ - Merged: github.Bool(true), - Number: &prID, - }, - Repo: &github.Repository{ - ID: &orgID, - Name: github.String("test"), - }, - } -} - -type mockInvoker struct { - lastReq *tg.MessagesEditMessageRequest -} - -func (m *mockInvoker) Invoke(ctx context.Context, input bin.Encoder, output bin.Decoder) error { - req, ok := input.(*tg.MessagesEditMessageRequest) - if !ok { - return errors.Errorf("unexpected type %T", input) - } - m.lastReq = req - return nil -} - -func TestWebhook(t *testing.T) { - ctx := context.Background() - a := require.New(t) - - msgID, lastMsgID := 10, 11 - prID, orgID := 13, int64(37) - channel := &tg.InputPeerChannel{ - ChannelID: 69, - AccessHash: 42, - } - event := prEvent(prID, orgID) - - log := zaptest.NewLogger(t) - db, err := pebble.Open("golovach_lena.db", &pebble.Options{FS: vfs.NewMem()}) - a.NoError(err) - store := storage.NewMsgID(db) - - a.NoError(store.UpdateLastMsgID(channel.ChannelID, lastMsgID)) - a.NoError(store.SetPRNotification(event, msgID)) - - invoker := &mockInvoker{} - raw := tg.NewClient(invoker) - sender := message.NewSender(raw).WithResolver(mockResolver{ - "gotd_ru": channel, - }) - hook := NewWebhook(storage.NewMsgID(db), sender, "secret").WithLogger(log) - - err = hook.handlePRClosed(ctx, event) - a.NoError(err) - - a.NotNil(invoker.lastReq) - a.Contains(invoker.lastReq.Message, "opened") -} diff --git a/internal/gh/handle_release.go b/internal/gh/handle_release.go deleted file mode 100644 index a45f20d..0000000 --- a/internal/gh/handle_release.go +++ /dev/null @@ -1,32 +0,0 @@ -package gh - -import ( - "context" - "fmt" - - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - - "github.com/gotd/td/telegram/message/styling" -) - -func (h Webhook) handleRelease(ctx context.Context, e *github.ReleaseEvent) error { - if e.GetAction() != "published" { - return nil - } - - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - - if _, err := h.sender.To(p).StyledText(ctx, - styling.Plain("New release: "), - styling.TextURL(e.GetRelease().GetTagName(), e.GetRelease().GetHTMLURL()), - styling.Plain(fmt.Sprintf(" for %s", e.GetRepo().GetFullName())), - ); err != nil { - return errors.Wrap(err, "send") - } - - return nil -} diff --git a/internal/gh/handle_repo.go b/internal/gh/handle_repo.go deleted file mode 100644 index c5856c6..0000000 --- a/internal/gh/handle_repo.go +++ /dev/null @@ -1,34 +0,0 @@ -package gh - -import ( - "context" - - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - "go.uber.org/zap" - - "github.com/gotd/td/telegram/message/styling" -) - -func (h Webhook) handleRepo(ctx context.Context, e *github.RepositoryEvent) error { - switch e.GetAction() { - case "created", "publicized": - p, err := h.notifyPeer(ctx) - if err != nil { - return errors.Wrap(err, "peer") - } - - if _, err := h.sender.To(p).StyledText(ctx, - styling.Plain("New repository "), - styling.TextURL(e.GetRepo().GetFullName(), e.GetRepo().GetHTMLURL()), - ); err != nil { - return errors.Wrap(err, "send") - } - - return nil - default: - h.logger.Info("Action ignored", zap.String("action", e.GetAction())) - - return nil - } -} diff --git a/internal/gh/webhook.go b/internal/gh/webhook.go deleted file mode 100644 index 19afbf3..0000000 --- a/internal/gh/webhook.go +++ /dev/null @@ -1,118 +0,0 @@ -package gh - -import ( - "context" - "fmt" - "net/http" - - "github.com/go-faster/errors" - "github.com/google/go-github/v42/github" - "github.com/labstack/echo/v4" - "go.uber.org/zap" - - "github.com/gotd/td/telegram/message" - "github.com/gotd/td/tg" - - "github.com/gotd/bot/internal/storage" -) - -// Webhook is a Github events web hook handler. -type Webhook struct { - storage storage.MsgID - - sender *message.Sender - notifyGroup string - githubSecret string - - logger *zap.Logger -} - -// NewWebhook creates new web hook handler. -func NewWebhook(msgID storage.MsgID, sender *message.Sender, githubSecret string) *Webhook { - return &Webhook{ - storage: msgID, - sender: sender, - notifyGroup: "gotd_ru", - githubSecret: githubSecret, - logger: zap.NewNop(), - } -} - -// WithSender sets message sender to use. -func (h *Webhook) WithSender(sender *message.Sender) *Webhook { - h.sender = sender - return h -} - -// WithNotifyGroup sets channel name to send notifications. -func (h *Webhook) WithNotifyGroup(domain string) *Webhook { - h.notifyGroup = domain - return h -} - -// WithLogger sets logger to use. -func (h *Webhook) WithLogger(logger *zap.Logger) *Webhook { - h.logger = logger - return h -} - -// RegisterRoutes registers hook using given Echo router. -func (h Webhook) RegisterRoutes(e *echo.Echo) { - e.POST("/hook", h.handleHook) -} - -func (h Webhook) handleHook(e echo.Context) error { - payload, err := github.ValidatePayload(e.Request(), []byte(h.githubSecret)) - if err != nil { - h.logger.Info("Failed to validate payload") - return echo.ErrNotFound - } - whType := github.WebHookType(e.Request()) - if whType == "security_advisory" { - // Current github library is unable to handle this. - return e.String(http.StatusOK, "ignored") - } - - event, err := github.ParseWebHook(whType, payload) - if err != nil { - h.logger.Error("Failed to parse webhook", zap.Error(err)) - return echo.ErrInternalServerError - } - - log := h.logger.With( - zap.String("type", fmt.Sprintf("%T", event)), - ) - log.Info("Processing event") - if err := h.processEvent(e, event, log); err != nil { - log.Error("Failed to process event", zap.Error(err)) - return echo.ErrInternalServerError - } - return e.String(http.StatusOK, "done") -} - -func (h Webhook) processEvent(e echo.Context, event interface{}, log *zap.Logger) error { - ctx := e.Request().Context() - switch event := event.(type) { - case *github.PullRequestEvent: - return h.handlePR(ctx, event) - case *github.ReleaseEvent: - return h.handleRelease(ctx, event) - case *github.RepositoryEvent: - return h.handleRepo(ctx, event) - case *github.IssuesEvent: - return h.handleIssue(ctx, event) - case *github.DiscussionEvent: - return h.handleDiscussion(ctx, event) - default: - log.Info("No handler") - return nil - } -} - -func (h Webhook) notifyPeer(ctx context.Context) (tg.InputPeerClass, error) { - p, err := h.sender.ResolveDomain(h.notifyGroup).AsInputPeer(ctx) - if err != nil { - return nil, errors.Wrap(err, "resolve") - } - return p, nil -}