diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b760a86..d7eaa6c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile "dockerComposeFile": "docker-compose.yml", "service": "devcontainer", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}" // Features to add to the dev container. More info: https://containers.dev/features. // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. @@ -16,4 +16,12 @@ // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" + , + "customizations": { + "vscode": { + "extensions": [ + "github.vscode-pull-request-github" + ] + } + } } diff --git a/.devcontainer/devcontainer/Dockerfile b/.devcontainer/devcontainer/Dockerfile deleted file mode 100644 index cddf8ff..0000000 --- a/.devcontainer/devcontainer/Dockerfile +++ /dev/null @@ -1,4 +0,0 @@ -FROM mcr.microsoft.com/devcontainers/go:0-1.20-bullseye -RUN go install github.com/rubenv/sql-migrate/...@latest -RUN go install github.com/kyleconroy/sqlc/cmd/sqlc@latest -RUN chmod -R og+w /go diff --git a/README.md b/README.md index bdf8154..2033139 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ mirakc/dtv-discord-goなどの更新は./docker-composeディレクトリで`./u ### 自動検索 -`録画-通知・予約`というカテゴリーの下に`自動検索`というチャンネルが作成されています。このチャンネルに特定の形式でスレッドを投稿すると、その内容が自動検索機能の対象になります。投稿のタイトルは何でも構いません。本文は以下のように指定します。 +`録画-通知・予約`というカテゴリーの下に`自動検索`というチャンネルが作成されています。このチャンネルに特定の形式でスレッドを投稿し、🆗(`:ok:`)でリアクションすると、その内容が自動検索機能の対象になります。投稿のタイトルは何でも構いません。本文は以下のように指定します。 例1: 「NHK」を含む名前のチャンネルでタイトルに「ニュース」をタイトルに含む番組を自動検索する @@ -116,7 +116,7 @@ mirakc/dtv-discord-goなどの更新は./docker-composeディレクトリで`./u EPGが更新される度にこれらのルールがチェックされ、ルールに合う番組が見つかると、そのスレッドにぶら下がる発言で見つかった番組の情報が投稿されます。 -スレッドの発言に対して📼リアクションをしても録画はされないので注意してください。発言の中にある URL が`録画-番組情報`の発言へのリンクなので、そちらのメッセージに対してリアクションをしてください。 +スレッドの発言に対して📼リアクションをしても録画はされないので注意してください。発言の中にある URL が`録画-番組情報`の発言へのリンクなので、そちらのメッセージに対してリアクションするか、スレッドの最初の発言に📼でリアクションしてください。 ### 自動検索・通知 @@ -129,7 +129,7 @@ EPGが更新される度にこれらのルールがチェックされ、ルー ## 注意事項 -現時点では番組情報投稿、自動検索はEPGの更新で追加された番組情報に対してだけ有効です。途中で書き換わった番組情報については対処していません。また、自動検索のスレッドの投稿時に既に番組情報に投稿されている番組についてはまだ検索するようになっていません。 +現時点では番組情報投稿、自動検索はEPGの更新で追加された番組情報に対してだけ有効です。途中で書き換わった番組情報については対処していません。 録画予約について、チューナーの数を考慮していないため、チューナー数より多い数の番組を同時に録画予約した場合録画できない番組が出てきます。特に警告などもないので注意してください。 @@ -139,7 +139,7 @@ EPGが更新される度にこれらのルールがチェックされ、ルー https://github.com/users/kounoike/projects/2/views/1 にもありますが、大きなものは以下 -- 自動検索スレッドに追加したときにその時点のEPGに対して検索をかけたい +- EPG更新時にも自動検索を走らせる - 録画完了・失敗時の通知・発言を追加する - チューナー不足時の処理を考える - 過去の番組情報をどうするか考える diff --git a/discord/discord_client/discordclient.go b/discord/discord_client/discordclient.go index ebadaf7..853dcc0 100644 --- a/discord/discord_client/discordclient.go +++ b/discord/discord_client/discordclient.go @@ -1,6 +1,7 @@ package discord_client import ( + "errors" "fmt" "strings" @@ -26,7 +27,7 @@ func NewDiscordClient(cfg config.Config, queries *db.Queries, logger *zap.Logger if err != nil { return nil, err } - session.Identify.Intents = discordgo.IntentsMessageContent + session.Identify.Intents |= discordgo.IntentsMessageContent return &DiscordClient{ cfg: cfg, queries: queries, @@ -41,12 +42,17 @@ func (d *DiscordClient) Session() *discordgo.Session { return d.session } +func (d *DiscordClient) GetChannel(channelID string) (*discordgo.Channel, error) { + return d.session.Channel(channelID) +} + func (d *DiscordClient) GetChannelMessage(channelID string, messageID string) (*discordgo.Message, error) { return d.session.ChannelMessage(channelID, messageID) } func (d *DiscordClient) MessageReactionAdd(channelID string, messageID string, emoji string) error { - return d.session.MessageReactionAdd(channelID, messageID, emoji) + err := d.session.MessageReactionAdd(channelID, messageID, emoji) + return err } func (d *DiscordClient) MessageReactionRemove(channelID string, messageID string, emoji string) error { @@ -66,6 +72,9 @@ func (d *DiscordClient) Open() error { if err != nil { return err } + if len(d.session.State.Guilds) != 1 { + return errors.New("bot must join exactly one server") + } return nil } @@ -143,7 +152,7 @@ func (d *DiscordClient) GetCachedChannel(origCategory string, origChannelName st func (d *DiscordClient) SendMessage(category string, channel string, message string) (*discordgo.Message, error) { if len(d.session.State.Guilds) != 1 { - return nil, fmt.Errorf("discord app must join one server") + return nil, fmt.Errorf("discord app must join one server [%d]", len(d.session.State.Guilds)) } ch, err := d.GetCachedChannel(category, channel) if err != nil { @@ -210,7 +219,7 @@ func (d *DiscordClient) CreateNotifyAndScheduleChannel() (*discordgo.Channel, er return d.createChannelWithTopic(discord.NotifyAndScheduleCategory, discord.AutoActionChannelName, discord.AutoActionChannelTopic) } -func (d *DiscordClient) ListAutoSearchChannelThredFirstMessageContents(channelID string) ([]*discordgo.Message, error) { +func (d *DiscordClient) ListAutoSearchChannelThredOkReactionedFirstMessageContents(channelID string) ([]*discordgo.Message, error) { threadsList, err := d.session.GuildThreadsActive(d.session.State.Guilds[0].ID) if err != nil { return nil, err @@ -220,10 +229,18 @@ func (d *DiscordClient) ListAutoSearchChannelThredFirstMessageContents(channelID if th.ParentID == channelID { thMsgs, err := d.session.ChannelMessages(th.ID, 1, "", "0", "") if err != nil { - d.logger.Warn("can't get messages in thred", zap.String("th.ID", th.ID), zap.String("th.Name", th.Name)) + d.logger.Warn("can't get messages in thred", zap.Error(err), zap.String("th.ID", th.ID), zap.String("th.Name", th.Name)) + continue } - if len(thMsgs) == 1 { - messages = append(messages, thMsgs[0]) + if len(thMsgs) > 0 { + users, err := d.session.MessageReactions(th.ID, thMsgs[0].ID, discord.OkReactionEmoji, 1, "", "") + if err != nil { + d.logger.Warn("can't get message's reactions", zap.Error(err), zap.String("th.ID", th.ID), zap.String("msgID", thMsgs[0].ID)) + continue + } + if len(users) > 0 { + messages = append(messages, thMsgs[0]) + } } } } diff --git a/discord/discord_handler/discordhandler.go b/discord/discord_handler/discordhandler.go index 615709e..6d61f2d 100644 --- a/discord/discord_handler/discordhandler.go +++ b/discord/discord_handler/discordhandler.go @@ -26,12 +26,21 @@ func NewDiscordHandler(dtv *dtv.DTVUsecase, session *discordgo.Session, logger * func (h *DiscordHandler) reactionAdd(session *discordgo.Session, reaction *discordgo.MessageReactionAdd) { h.logger.Debug("add reaction emoji", zap.String("emoji", reaction.Emoji.Name), zap.String("UserID", reaction.UserID), zap.String("ChannelID", reaction.ChannelID), zap.String("MessageID", reaction.MessageID)) - if reaction.Emoji.Name == discord.RecordingReactionEmoji { + switch reaction.Emoji.Name { + case discord.RecordingReactionEmoji: ctx := context.Background() err := h.dtv.OnRecordingEmojiAdd(ctx, reaction) if err != nil { h.logger.Error("onrecording emoji add error", zap.Error(err), zap.String("UserID", reaction.UserID), zap.String("ChannelID", reaction.ChannelID), zap.String("MessageID", reaction.MessageID)) } + case discord.OkReactionEmoji: + ctx := context.Background() + err := h.dtv.OnOkEmojiAdd(ctx, reaction) + if err != nil { + h.logger.Error("OnOkEmojiAdd error", zap.Error(err), zap.String("UserID", reaction.UserID), zap.String("ChannelID", reaction.ChannelID), zap.String("MessageID", reaction.MessageID)) + } + default: + h.logger.Debug("no intent for this Emoji", zap.String("emojiName", reaction.Emoji.Name)) } } diff --git a/dtv/auto_search.go b/dtv/auto_search.go index e7b6603..ac4458d 100644 --- a/dtv/auto_search.go +++ b/dtv/auto_search.go @@ -41,8 +41,41 @@ func (a *AutoSearch) IsMatchProgram(program *AutoSearchProgram) bool { } } +func (a *AutoSearch) IsMatchService(serviceName string) bool { + if a.Channel == "" || strings.Contains(normalizeString(serviceName), normalizeString(a.Channel)) { + return true + } else { + return false + } +} + +func (dtv *DTVUsecase) getAutoSeachFromMessage(msg *discordgo.Message) (*AutoSearch, error) { + content := []byte(msg.Content) + var autoSearch AutoSearch + err := yaml.Unmarshal(content, &autoSearch) + if err != nil { + return nil, err + } + notifyUsers, err := dtv.discord.GetMessageReactions(msg.ChannelID, msg.ID, discord.NotifyReactionEmoji) + if err != nil { + dtv.logger.Warn("can't get message reactions", zap.Error(err), zap.String("msg.ChannelID", msg.ChannelID), zap.String("msg.ID", msg.ID), zap.String("emoji", discord.NotifyReactionEmoji)) + notifyUsers = []*discordgo.User{} + } + autoSearch.NotifyUsers = notifyUsers + + recordingUsers, err := dtv.discord.GetMessageReactions(msg.ChannelID, msg.ID, discord.RecordingReactionEmoji) + if err != nil { + dtv.logger.Warn("can't get message reactions", zap.Error(err), zap.String("msg.ChannelID", msg.ChannelID), zap.String("msg.ID", msg.ID), zap.String("emoji", discord.RecordingReactionEmoji)) + recordingUsers = []*discordgo.User{} + } + autoSearch.RecordingUsers = recordingUsers + autoSearch.ThreadID = msg.ChannelID + + return &autoSearch, nil +} + func (dtv *DTVUsecase) ListAutoSearchForServiceName(serviceName string) ([]*AutoSearch, error) { - msgs, err := dtv.discord.ListAutoSearchChannelThredFirstMessageContents(dtv.autoSearchChannel.ID) + msgs, err := dtv.discord.ListAutoSearchChannelThredOkReactionedFirstMessageContents(dtv.autoSearchChannel.ID) if err != nil { return nil, err } @@ -51,32 +84,14 @@ func (dtv *DTVUsecase) ListAutoSearchForServiceName(serviceName string) ([]*Auto autoSearchList := make([]*AutoSearch, 0) for _, msg := range msgs { - content := []byte(msg.Content) - var autoSearch AutoSearch - err := yaml.Unmarshal(content, &autoSearch) + autoSearch, err := dtv.getAutoSeachFromMessage(msg) if err != nil { dtv.logger.Warn("thread message yaml unmarshal error", zap.Error(err)) continue } if autoSearch.Channel == "" || strings.Contains(serviceNameNormalized, normalizeString(autoSearch.Channel)) { autoSearch.Title = normalizeString(autoSearch.Title) - - notifyUsers, err := dtv.discord.GetMessageReactions(msg.ChannelID, msg.ID, discord.NotifyReactionEmoji) - if err != nil { - dtv.logger.Warn("can't get message reactions", zap.Error(err), zap.String("msg.ChannelID", msg.ChannelID), zap.String("msg.ID", msg.ID), zap.String("emoji", discord.NotifyReactionEmoji)) - notifyUsers = []*discordgo.User{} - } - autoSearch.NotifyUsers = notifyUsers - - recordingUsers, err := dtv.discord.GetMessageReactions(msg.ChannelID, msg.ID, discord.RecordingReactionEmoji) - if err != nil { - dtv.logger.Warn("can't get message reactions", zap.Error(err), zap.String("msg.ChannelID", msg.ChannelID), zap.String("msg.ID", msg.ID), zap.String("emoji", discord.RecordingReactionEmoji)) - recordingUsers = []*discordgo.User{} - } - autoSearch.RecordingUsers = recordingUsers - autoSearch.ThreadID = msg.ChannelID - - autoSearchList = append(autoSearchList, &autoSearch) + autoSearchList = append(autoSearchList, autoSearch) } } return autoSearchList, nil diff --git a/dtv/on_ok_emoji_add.go b/dtv/on_ok_emoji_add.go new file mode 100644 index 0000000..64bd1d3 --- /dev/null +++ b/dtv/on_ok_emoji_add.go @@ -0,0 +1,96 @@ +package dtv + +import ( + "context" + "database/sql" + + "github.com/bwmarrin/discordgo" + "github.com/kounoike/dtv-discord-go/discord" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +func (dtv *DTVUsecase) OnOkEmojiAdd(ctx context.Context, reaction *discordgo.MessageReactionAdd) error { + users, err := dtv.discord.GetMessageReactions(reaction.ChannelID, reaction.MessageID, reaction.Emoji.Name) + if err != nil { + return err + } + if len(users) != 1 { + // NOTE: 最初のOKリアクション以外は無視 + return nil + } + th, err := dtv.discord.GetChannel(reaction.ChannelID) + if err != nil { + return err + } + if th.Type != discordgo.ChannelTypeGuildPublicThread { + // NOTE: 自動検索チャンネルじゃないのでリターン + return nil + } + asCh, err := dtv.discord.GetCachedChannel(discord.NotifyAndScheduleCategory, discord.AutoActionChannelName) + if err != nil { + return err + } + if th.ParentID != asCh.ID { + // NOTE: チャンネルが違うのでリターン + return nil + } + + // 自動検索を登録済みのEPGに対して実行する + threadMsg, err := dtv.discord.GetChannelMessage(reaction.ChannelID, reaction.MessageID) + if err != nil { + return err + } + autoSearch, err := dtv.getAutoSeachFromMessage(threadMsg) + if err != nil { + return err + } + + services, err := dtv.mirakc.ListServices() + if err != nil { + return err + } + + for _, service := range services { + if autoSearch.IsMatchService(service.Name) { + programs, err := dtv.mirakc.ListPrograms(uint(service.ID)) + if err != nil { + dtv.logger.Warn("ListPrograms error", zap.Error(err)) + continue + } + for _, program := range programs { + asp := NewAutoSearchProgram(program) + if autoSearch.IsMatchProgram(asp) { + // NOTE: DBに入ってるか確認する + _, err := dtv.queries.GetProgram(ctx, program.ID) + if errors.Cause(err) == sql.ErrNoRows { + // NOTE: DBに入ってないプログラムは後で検索が走るはずなのでそっちで通知 + continue + } + programMessage, err := dtv.queries.GetProgramMessageByProgramID(ctx, program.ID) + if err != nil { + dtv.logger.Warn("GetProgramMessageByProgramID error", zap.Error(err)) + continue + } + ch, err := dtv.discord.GetCachedChannel(discord.ProgramInformationCategory, service.Name) + if err != nil { + dtv.logger.Warn("GetCachedChannel error", zap.Error(err)) + continue + } + msg, err := dtv.discord.GetChannelMessage(ch.ID, programMessage.MessageID) + if err != nil { + dtv.logger.Warn("GetChannelMessage error", zap.Error(err)) + continue + } + err = dtv.sendAutoSearchMatchMessage(ctx, msg, program, &service, autoSearch) + if err != nil { + dtv.logger.Warn("sendAutoSearchMatchMessage error", zap.Error(err)) + continue + } + } + } + } + } + + return nil +} diff --git a/dtv/on_program_updated.go b/dtv/on_program_updated.go index e16ddcf..0e5953a 100644 --- a/dtv/on_program_updated.go +++ b/dtv/on_program_updated.go @@ -6,6 +6,7 @@ import ( "database/sql" "fmt" + "github.com/bwmarrin/discordgo" "github.com/kounoike/dtv-discord-go/db" "github.com/kounoike/dtv-discord-go/discord" "github.com/kounoike/dtv-discord-go/template" @@ -70,34 +71,10 @@ func (dtv *DTVUsecase) OnProgramsUpdated(ctx context.Context, serviceId uint) er dtv.logger.Debug("matching", zap.String("p.Name", p.Name), zap.String("asp.Title", asp.Title), zap.String("as.Title", as.Title), zap.Bool("isMatch", as.IsMatchProgram(asp))) if as.IsMatchProgram(asp) { dtv.logger.Debug("program matched", zap.String("program.Name", p.Name), zap.String("as.Title", as.Title)) - url := discord.BuildMessageLinkURL(dtv.discord.Session().State.Guilds[0].ID, msg.ChannelID, msg.ID) - content, err := template.GetAutoSearchMessage(p, *service, url) + err := dtv.sendAutoSearchMatchMessage(ctx, msg, p, service, as) if err != nil { - return err - } - content = width.Fold.String(content) - notifyString := "" - recorderString := "" - if len(as.NotifyUsers) > 0 { - for _, u := range as.NotifyUsers { - notifyString += "<@" + u.ID + "> " - } - notifyString += "\n" - } - if len(as.RecordingUsers) > 0 { - for _, u := range as.RecordingUsers { - recorderString += "<@" + u.ID + "> " - } - recorderString += "録画予約しました\n" - } - content += notifyString + recorderString - err = dtv.discord.SendMessageToThread(as.ThreadID, content) - if err != nil { - return err - } - if len(as.RecordingUsers) > 0 { - dtv.discord.MessageReactionAdd(msg.ChannelID, msg.ID, discord.RecordingReactionEmoji) - dtv.checkRecordingForMessage(ctx, msg.ChannelID, msg.ID) + dtv.logger.Warn("sendAutoSearchMatchMessage error", zap.Error(err)) + continue } } } @@ -119,3 +96,46 @@ func (dtv *DTVUsecase) OnProgramsUpdated(ctx context.Context, serviceId uint) er } return nil } + +func (dtv *DTVUsecase) sendAutoSearchMatchMessage(ctx context.Context, msg *discordgo.Message, p db.Program, service *db.Service, as *AutoSearch) error { + url := discord.BuildMessageLinkURL(dtv.discord.Session().State.Guilds[0].ID, msg.ChannelID, msg.ID) + content, err := template.GetAutoSearchMessage(p, *service, url) + if err != nil { + return err + } + content = width.Fold.String(content) + notifyString := "" + recorderString := "" + if len(as.NotifyUsers) > 0 { + for _, u := range as.NotifyUsers { + notifyString += "<@" + u.ID + "> " + } + notifyString += "\n" + } + content += notifyString + recorderString + err = dtv.discord.SendMessageToThread(as.ThreadID, content) + if err != nil { + return err + } + if len(as.RecordingUsers) > 0 { + users, err := dtv.discord.GetMessageReactions(msg.ChannelID, msg.ID, discord.RecordingReactionEmoji) + if err != nil { + return err + } + for _, u := range users { + if u.ID == dtv.discord.Session().State.User.ID { + // NOTE: 既にリアクション済みなので何もしない + return nil + } + } + err = dtv.discord.MessageReactionAdd(msg.ChannelID, msg.ID, discord.RecordingReactionEmoji) + if err != nil { + return err + } + err = dtv.checkRecordingForMessage(ctx, msg.ChannelID, msg.ID) + if err != nil { + return err + } + } + return nil +} diff --git a/main.go b/main.go index 90a581f..fb3d609 100644 --- a/main.go +++ b/main.go @@ -25,7 +25,7 @@ import ( ) var ( - version string + version = "develop" ) func main() { @@ -180,7 +180,8 @@ func main() { discordHandler.AddReactionAddHandler() discordHandler.AddReactionRemoveHandler() - // TODO: 自動検索フォーラムに新規スレッドがあったときのハンドラ + + logger.Info("AddDiscordHandle done. start subscribe to SSE events.") sseHandler := mirakc_handler.NewSSEHandler(*usecase, config.Mirakc.Host, config.Mirakc.Port, logger) sseHandler.Subscribe() diff --git a/mirakc/mirakc_client/mirakc-client.go b/mirakc/mirakc_client/mirakc-client.go index 6b28b3f..dbadfa9 100644 --- a/mirakc/mirakc_client/mirakc-client.go +++ b/mirakc/mirakc_client/mirakc-client.go @@ -124,11 +124,15 @@ func (m *MirakcClient) AddRecordingSchedule(programID int64, contentPath string) }, Tags: []string{"manual"}, } + dataJson, err := json.Marshal(data) + if err != nil { + return err + } // postOption := fmt.Sprintf(`{"programId": %d, "options": {"contentPath": "%d.m2ts"}, "tags": ["manual"]}`, programID, programID) client := resty.New() resp, err := client.R(). SetHeader("Content-Type", "application/json"). - SetBody(data). + SetBody(dataJson). Post(url) if err != nil { return err @@ -137,7 +141,7 @@ func (m *MirakcClient) AddRecordingSchedule(programID int64, contentPath string) if resp.StatusCode() == 201 { return nil } - return fmt.Errorf("post request:%s status code:%d", url, resp.StatusCode()) + return fmt.Errorf("post request:%s with body:%s status code:%d", url, string(dataJson), resp.StatusCode()) } func (m *MirakcClient) DeleteRecordingSchedule(programID int64) error { diff --git a/mirakc/mirakc_handler/sse_handler.go b/mirakc/mirakc_handler/sse_handler.go index 402038b..4515bf0 100644 --- a/mirakc/mirakc_handler/sse_handler.go +++ b/mirakc/mirakc_handler/sse_handler.go @@ -49,5 +49,6 @@ func (h *SSEHandler) Subscribe() { } h.onProgramsUpdated(data.ServiceId) } + h.logger.Debug("sse event processed successfully") }) }