Skip to content
Merged

V3 #35

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
10 changes: 6 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
*.iml
/.idea/
./DevOpsBot
./log
./config.yaml
./dist

/DevOpsBot
/log
/config.yaml
/dist
/test.sh
25 changes: 25 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,35 @@ ENV GOARCH=$TARGETARCH
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go build -o /dev-ops-bot -ldflags="-s -w -X main.version=$VERSION" .

FROM --platform=$BUILDPLATFORM golang:1.20-alpine AS installer

ENV CGO_ENABLED 0
ARG TARGETOS
ARG TARGETARCH
ENV GOOS=$TARGETOS
ENV GOARCH=$TARGETARCH

RUN apk add --no-cache wget

RUN wget https://github.com/mikefarah/yq/releases/latest/download/yq_"$TARGETOS"_"$TARGETARCH" -O /yq && \
chmod +x /yq

RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go install sigs.k8s.io/kustomize/kustomize/v5@latest
# keep output directory the same between platforms; workaround for https://github.com/golang/go/issues/57485
RUN cp /go/bin/kustomize /kustomize || cp /go/bin/"$GOOS"_"$GOARCH"/kustomize /kustomize

FROM alpine:3

WORKDIR /work

# Install commands for deploy scripts
RUN apk add --no-cache git openssh
RUN mkdir -p /root/.ssh && ssh-keyscan github.com >> /root/.ssh/known_hosts

COPY --from=installer /yq /usr/local/bin/
COPY --from=installer /kustomize /usr/local/bin/

COPY --from=builder /dev-ops-bot ./

ENTRYPOINT ["/work/dev-ops-bot"]
160 changes: 47 additions & 113 deletions bot.go
Original file line number Diff line number Diff line change
@@ -1,154 +1,88 @@
package main

import (
"errors"
"context"
"fmt"
"github.com/dghubble/sling"
"github.com/go-chi/render"
"strings"

"github.com/kballard/go-shellquote"
"github.com/samber/lo"
"github.com/traPtitech/go-traq"
"github.com/traPtitech/traq-ws-bot/payload"
"go.uber.org/zap"
"net/http"
"time"
)

var traQClient *sling.Sling

type Map map[string]interface{}

// MessageCreatedPayload MESSAGE_CREATEDイベントペイロード
type MessageCreatedPayload struct {
EventTime time.Time `json:"eventTime"`
Message struct {
ID string `json:"id"`
User struct {
ID string `json:"id"`
Name string `json:"name"`
Bot bool `json:"bot"`
} `json:"user"`
ChannelID string `json:"channelId"`
Text string `json:"text"`
PlainText string `json:"plainText"`
CreatedAt string `json:"createdAt"`
} `json:"message"`
}

// BotEndPoint Botサーバーエンドポイント
func BotEndPoint(w http.ResponseWriter, r *http.Request) {
// トークン検証
if r.Header.Get("X-TRAQ-BOT-TOKEN") != config.VerificationToken {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
// BotMessageReceived BOTのMESSAGE_CREATEDイベントハンドラ
func BotMessageReceived(p *payload.MessageCreated) {
ctx := context.Background()

switch r.Header.Get("X-TRAQ-BOT-EVENT") {
case "PING", "JOINED", "LEFT":
w.WriteHeader(http.StatusNoContent)
case "MESSAGE_CREATED":
var payload MessageCreatedPayload
if err := render.Decode(r, &payload); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
go BotMessageReceived(payload)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
if p.Message.User.Bot {
return // Ignore bots
}
}

// BotMessageReceived BOTのMESSAGE_CREATEDイベントハンドラ
func BotMessageReceived(p MessageCreatedPayload) {
if p.Message.ChannelID != config.DevOpsChannelID {
if p.Message.ChannelID != config.ChannelID {
return // DevOpsチャンネル以外からのメッセージは無視
}

args, err := shellquote.Split(p.Message.PlainText)
if err != nil {
_ = SendTRAQMessage(p.Message.ChannelID, fmt.Sprintf("invalid syntax error\n%s", cite(p.Message.ID)))
_ = PushTRAQStamp(p.Message.ID, config.Stamps.BadCommand)
_ = SendTRAQMessage(ctx, p.Message.ChannelID, fmt.Sprintf("invalid syntax error\n%s", cite(p.Message.ID)))
_ = PushTRAQStamp(ctx, p.Message.ID, config.Stamps.BadCommand)
return
}
if len(args[0]) == 0 {
return // 空メッセージは無視
_, argStart, ok := lo.FindIndexOf(args, func(arg string) bool { return strings.HasPrefix(arg, config.Prefix) })
if !ok {
return
}
args = args[argStart:]
args[0] = strings.TrimPrefix(args[0], config.Prefix)

ctx := &Context{
P: &p,
Args: args,
cmdCtx := &Context{
Context: ctx,
P: p,
Args: args,
}
c, ok := commands[args[0]]
if !ok {
// コマンドが見つからない
_ = ctx.ReplyBad(fmt.Sprintf("Unknown command: `%s`", args[0]))
_ = cmdCtx.ReplyBad(fmt.Sprintf("Unknown command: `%s`", args[0]))
return
}
err = c.Execute(ctx)
err = c.Execute(cmdCtx)
if err != nil {
ctx.L().Error("failed to execute command", zap.Error(err))
cmdCtx.L().Error("failed to execute command", zap.Error(err))
}
}

// SendTRAQMessage traQにメッセージ送信
func SendTRAQMessage(channelID string, text string) error {
req, err := traQClient.New().
Post(fmt.Sprintf("api/v3/channels/%s/messages", channelID)).
BodyJSON(Map{"content": text}).
Request()
if err != nil {
return err
}

res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return errors.New(res.Status)
}
return nil
func SendTRAQMessage(ctx context.Context, channelID string, text string) error {
_, _, err := bot.API().
ChannelApi.
PostMessage(ctx, channelID).
PostMessageRequest(traq.PostMessageRequest{Content: text}).
Execute()
return err
}

// SendTRAQDirectMessage traQにダイレクトメッセージ送信
func SendTRAQDirectMessage(userID string, text string) error {
req, err := traQClient.New().
Post(fmt.Sprintf("api/v3/users/%s/messages", userID)).
BodyJSON(Map{"content": text}).
Request()
if err != nil {
return err
}

res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusCreated {
return errors.New(res.Status)
}
return nil
func SendTRAQDirectMessage(ctx context.Context, userID string, text string) error {
_, _, err := bot.API().
UserApi.
PostDirectMessage(ctx, userID).
PostMessageRequest(traq.PostMessageRequest{Content: text}).
Execute()
return err
}

// PushTRAQStamp traQのメッセージにスタンプを押す
func PushTRAQStamp(messageID, stampID string) error {
req, err := traQClient.New().
Post(fmt.Sprintf("api/v3/messages/%s/stamps/%s", messageID, stampID)).
BodyJSON(Map{"count": 1}).
Request()
if err != nil {
return err
}

res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return errors.New(res.Status)
}
return nil
func PushTRAQStamp(ctx context.Context, messageID, stampID string) error {
_, err := bot.API().
MessageApi.
AddMessageStamp(ctx, messageID, stampID).
PostMessageStampRequest(traq.PostMessageStampRequest{Count: 1}).
Execute()
return err
}

// cite traQのメッセージ引用形式を作る
Expand Down
47 changes: 27 additions & 20 deletions command.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import (
"context"
"strings"

"github.com/traPtitech/traq-ws-bot/payload"
"go.uber.org/zap"
)

Expand All @@ -14,8 +18,9 @@ type Command interface {

// Context コマンド実行コンテキスト
type Context struct {
context.Context
// P BOTが受信したMESSAGE_CREATEDイベントの生のペイロード
P *MessageCreatedPayload
P *payload.MessageCreated
// Args 投稿メッセージを空白区切りで分けたもの
Args []string
}
Comment on lines 20 to 26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

各メソッドの第1引数にcontext入れる方がよく見る気がするんですが構造体の中に入れてるのってなんか意図あったりしますか?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

正直ここでは大した意味はないです embedとして入れてcontext.Contextの拡張として独自のContextを定義しようと思っただけですね
ですが現状ではcontext.Contextの拡張としてはあんまり役割を果たしてないです 今後timeoutとか使いたくなったときに入れることができるくらいです

Context以外の構造体にcontext.Contextを入れて連れ回すのはアンチパターンなのはそうですね

Expand All @@ -25,56 +30,58 @@ func (ctx *Context) GetExecutor() string {
return ctx.P.Message.User.Name
}

// ReplyViaDM コマンドメッセージに返信します
func (ctx *Context) Reply(message, stamp string) (err error) {
if len(message) > 0 {
err = SendTRAQMessage(ctx.P.Message.ChannelID, message)
if err != nil {
return
}
// Reply コマンドメッセージに返信します
func (ctx *Context) Reply(message ...string) error {
return SendTRAQMessage(ctx, ctx.P.Message.ChannelID, strings.Join(message, "\n"))
}

func (ctx *Context) ReplyWithStamp(stamp string, message ...string) error {
err := PushTRAQStamp(ctx, ctx.P.Message.ID, stamp)
if err != nil {
return err
}
if len(stamp) > 0 {
err = PushTRAQStamp(ctx.P.Message.ID, stamp)
if len(message) > 0 {
err = ctx.Reply(message...)
if err != nil {
return
return err
}
}
return
return nil
}

// ReplyViaDM コマンド実行者にDMで返信します
func (ctx *Context) ReplyViaDM(message string) error {
return SendTRAQDirectMessage(ctx.P.Message.User.ID, message)
func (ctx *Context) ReplyViaDM(message ...string) error {
return SendTRAQDirectMessage(ctx, ctx.P.Message.User.ID, strings.Join(message, "\n"))
}

// ReplyBad コマンドメッセージにBadスタンプをつけて返信します
func (ctx *Context) ReplyBad(message ...string) (err error) {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.BadCommand)
return ctx.ReplyWithStamp(config.Stamps.BadCommand, message...)
}

// ReplyForbid コマンドメッセージにForbidスタンプをつけて返信します
func (ctx *Context) ReplyForbid(message ...string) error {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.Forbid)
return ctx.ReplyWithStamp(config.Stamps.Forbid, message...)
}

// ReplyAccept コマンドメッセージにAcceptスタンプをつけて返信します
func (ctx *Context) ReplyAccept(message ...string) error {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.Accept)
return ctx.ReplyWithStamp(config.Stamps.Accept, message...)
}

// ReplySuccess コマンドメッセージにSuccessスタンプをつけて返信します
func (ctx *Context) ReplySuccess(message ...string) error {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.Success)
return ctx.ReplyWithStamp(config.Stamps.Success, message...)
}

// ReplyFailure コマンドメッセージにFailureスタンプをつけて返信します
func (ctx *Context) ReplyFailure(message ...string) error {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.Failure)
return ctx.ReplyWithStamp(config.Stamps.Failure, message...)
}

// ReplyRunning コマンドメッセージにRunningスタンプをつけて返信します
func (ctx *Context) ReplyRunning(message ...string) error {
return ctx.Reply(stringOrEmpty(message...), config.Stamps.Running)
return ctx.ReplyWithStamp(config.Stamps.Running, message...)
}

func (ctx *Context) L() *zap.Logger {
Expand Down
Loading