otiai10/amesh-bot

View on GitHub
commands/forecast.go

Summary

Maintainability
B
5 hrs
Test Coverage
package commands

import (
    "bytes"
    "context"
    "fmt"
    "strings"
    "time"

    "github.com/otiai10/amesh-bot/service"
    "github.com/otiai10/ja"
    "github.com/otiai10/jma"
    "github.com/otiai10/jma/api"
    "github.com/otiai10/largo"
    "github.com/slack-go/slack"
    "github.com/slack-go/slack/slackevents"
)

type ForecastCommand struct {
    SourceURL string
    Timezone  *time.Location
}

func (cmd ForecastCommand) Match(event slackevents.AppMentionEvent) bool {
    tokens := largo.Tokenize(event.Text)
    if len(tokens) < 2 {
        return false
    }
    if tokens[1] == "forecast" || tokens[1] == "予報" {
        return true
    }
    return false
}

func (cmd ForecastCommand) Execute(ctx context.Context, client service.ISlackClient, event slackevents.AppMentionEvent) error {
    city := "tokyo"
    list := false
    help := bytes.NewBuffer(nil)
    fset := largo.NewFlagSet("forecast", largo.ContinueOnError)
    fset.BoolVar(&list, "list", false, "対応都市・観測所のリスト")
    fset.Output = help

    msg := inreply(event)

    if err := fset.Parse(largo.Tokenize(event.Text)[2:]); err != nil {
        msg.Text = err.Error()
        _, err = client.PostMessage(ctx, msg)
        return err
    }

    if fset.HelpRequested() {
        msg.Text = fmt.Sprintf("天気予報コマンド\n```@amesh forecast {都市の名前=tokyo} [-list]\n%v```", help.String())
        _, err := client.PostMessage(ctx, msg)
        return err
    }

    if list {
        for _, o := range jma.Offices {
            msg.Text += fmt.Sprintf("%v %v\n", o.NameEnLower, o.OfficeName)
        }
        _, err := client.PostMessage(ctx, msg)
        return err
    }

    if rest := fset.Rest(); len(rest) > 0 {
        city = rest[0]
    }

    areas := jma.SearchOffice(city)
    if len(areas) == 0 {
        msg.Text = fmt.Sprintf("クエリ「%s」に対する観測所を発見できませんでした.\n以下のコマンドを試してください.\n```@amesh forecast -list```\n", city)
        _, err := client.PostMessage(ctx, msg)
        return err
    }

    jmaclient := &api.Client{BaseURL: cmd.SourceURL}
    entries, err := jmaclient.Forecast(areas[0].Code)

    if err != nil {
        msg.Text = err.Error()
        _, err := client.PostMessage(ctx, msg)
        return err
    }
    overview, _ := jmaclient.Overview(areas[0].Code)
    // Overviewは無くていいので、エラーは無視する.
    // if err != nil {
    //     msg.Text = err.Error()
    //     _, err := client.PostMessage(ctx, msg)
    //     return err
    // }

    blocks := cmd.FormatForecastToSlackBlocks(entries, overview)
    msg.Blocks = blocks

    // json.NewEncoder(os.Stderr).Encode(msg)

    _, err = client.PostMessage(ctx, msg)
    if err != nil {
        msg.Text = err.Error()
        _, err := client.PostMessage(ctx, msg)
        return err
    }

    return nil
}

func (cmd ForecastCommand) Help() string {
    return "天気予報コマンド\n```@amesh forecast|予報 {都市の名前=tokyo}```"
}

// https://app.slack.com/block-kit-builder/T02N4356M#%7B%22blocks%22:%5B%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22plain_text%22,%22text%22:%22%E4%BA%88%E5%A0%B1%E6%9C%9F%E9%96%93%E3%80%80%EF%BC%98%E6%9C%88%EF%BC%93%E6%97%A5%E3%81%8B%E3%82%89%EF%BC%98%E6%9C%88%EF%BC%99%E6%97%A5%E3%81%BE%E3%81%A7%5Cn%E5%90%91%E3%81%93%E3%81%86%E4%B8%80%E9%80%B1%E9%96%93%E3%81%AF%E3%80%81%E6%9C%9F%E9%96%93%E3%81%AE%E5%89%8D%E5%8D%8A%E3%81%AF%E9%AB%98%E6%B0%97%E5%9C%A7%E3%81%AB%E8%A6%86%E3%82%8F%E3%82%8C%E3%81%A6%E6%99%B4%E3%82%8C%E3%82%8B%E6%97%A5%E3%82%82%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%81%8C%E3%80%81%E6%B0%97%E5%9C%A7%E3%81%AE%E8%B0%B7%E3%82%84%E6%B9%BF%E3%81%A3%E3%81%9F%E7%A9%BA%E6%B0%97%E3%81%AE%E5%BD%B1%E9%9F%BF%E3%81%A7%E9%9B%B2%E3%81%8C%E5%BA%83%E3%81%8C%E3%82%8A%E3%82%84%E3%81%99%E3%81%84%E3%81%A7%E3%81%97%E3%82%87%E3%81%86%E3%80%82%E6%9C%80%E9%AB%98%E6%B0%97%E6%B8%A9%E3%81%A8%E6%9C%80%E4%BD%8E%E6%B0%97%E6%B8%A9%E3%81%AF%E3%81%A8%E3%82%82%E3%81%AB%E3%80%81%E5%B9%B3%E5%B9%B4%E4%B8%A6%E3%81%8B%E5%B9%B3%E5%B9%B4%E3%82%88%E3%82%8A%E9%AB%98%E3%81%84%E8%A6%8B%E8%BE%BC%E3%81%BF%E3%81%A7%E3%81%99%E3%80%82%E7%86%B1%E4%B8%AD%E7%97%87%E3%81%AA%E3%81%A9%E5%81%A5%E5%BA%B7%E7%AE%A1%E7%90%86%E3%81%AB%E6%B3%A8%E6%84%8F%E3%81%97%E3%81%A6%E3%81%8F%E3%81%A0%E3%81%95%E3%81%84%E3%80%82%E9%99%8D%E6%B0%B4%E9%87%8F%E3%81%AF%E3%80%81%E5%B9%B3%E5%B9%B4%E4%B8%A6%E3%81%8B%E5%B9%B3%E5%B9%B4%E3%82%88%E3%82%8A%E5%B0%91%E3%81%AA%E3%81%84%E3%81%A7%E3%81%97%E3%82%87%E3%81%86%E3%80%82%22,%22emoji%22:true%7D%5D%7D,%7B%22type%22:%22section%22,%22fields%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%22*%E6%9D%B1%E4%BA%AC%E5%9C%B0%E6%96%B9*%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22-/-/50/20%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2210/20/10/10%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2220%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2230%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2210%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%9C%88%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2220%22%7D%5D%7D,%7B%22type%22:%22context%22,%22elements%22:%5B%7B%22type%22:%22mrkdwn%22,%22text%22:%228%E6%9C%8802%E6%97%A5%EF%BC%88%E6%B0%B4%E6%9B%9C%EF%BC%89%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22:sunny:%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2233/25%E2%84%83%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%22%7C%22%7D,%7B%22type%22:%22mrkdwn%22,%22text%22:%2230%22%7D%5D%7D%5D%7D
func (cmd ForecastCommand) FormatForecastToSlackBlocks(entries []api.ComprehensiveForecastEntry, overview *api.OverviewForecast) (blocks []slack.Block) {

    k := 0 // まずは一番うえのAreaだけ見る, which means 伊豆諸島・小笠原諸島を無視している

    // {{{ エリア「k」における情報をまず抽出
    weekly := entries[1]
    codes := weekly.TimeSeries[0].Areas[k].WeatherCodes
    pops := weekly.TimeSeries[0].Areas[k].Pops
    temps := weekly.TimeSeries[1].Areas[k]
    area := weekly.TimeSeries[0].Areas[k]
    // }}}

    // 地域タイトル
    title := slack.NewTextBlockObject(slack.MarkdownType, fmt.Sprintf("*%s*", area.Area.Name), false, false)
    blocks = append(blocks, slack.NewSectionBlock(title, nil, nil))

    rows := []Row{}

    // {{{ 最初の数日分を作成
    // 実はweeklyは、明日からの予報しかないうえに、明日の予報の情報を欠損している.
    // したがって、entries[0]を解釈し、今日と明日の予報を生成する必要がある.
    coming := entries[0]
    for i, t := range coming.TimeSeries[0].TimeDefines {
        row := Row{
            Date:    t,
            Weather: jma.Weathers[coming.TimeSeries[0].Areas[k].WeatherCodes[i]],
        }
        rows = append(rows, row)
    }
    // }}}

    // {{{ 最初の数日の最高気温最低気温を補完
    for i, t := range coming.TimeSeries[2].TimeDefines {
        tmps := coming.TimeSeries[2].Areas[k].Temps
        for j, r := range rows {
            if DateDecimal(r.Date) == DateDecimal(t) {
                // 同じ日付のエントリだが、最高気温なのか最低気温なのか、判断基準が無い
                if r.TempMax == "" { // とりあえず埋める
                    rows[j].TempMax, rows[j].TempMin = tmps[i], tmps[i]
                } else if r.TempMax < tmps[i] { // 来たものがMaxより上ならMax
                    rows[j].TempMax = tmps[i]
                } else if r.TempMin > tmps[i] { // 来たものがMinより下ならMin
                    rows[j].TempMin = tmps[i]
                }
            }
        }
    }
    // }}}

    // {{{ 最初の数日の降水確率を補完
    for i, t := range coming.TimeSeries[1].TimeDefines {
        pops := coming.TimeSeries[1].Areas[k].Pops
        for j, r := range rows {
            if DateDecimal(r.Date) == DateDecimal(t) {
                // 降水確率は時系列なので、単純にその日の降水確率にappendでよい
                rows[j].PoPs = append(rows[j].PoPs, pops[i])
            }
        }
    }
    // }}}

    // 7日間分作成. k == 0 で固定していることに注意
    // 1日を1行(= 1block)で表現している
    for i, t := range weekly.TimeSeries[0].TimeDefines {
        // すでに上記のブロックで最初の数日登録されている場合があるので、チェック
        done := false
        for j, r := range rows {
            if DateDecimal(r.Date) == DateDecimal(t) {
                // 補完だけする
                if r.TempMax == "" || r.TempMin == "" {
                    rows[j].TempMax = temps.TempsMax[i]
                    rows[j].TempMin = temps.TempsMin[i]
                }
                if len(r.PoPs) == 0 {
                    rows[j].PoPs = append(r.PoPs, pops[i])
                }
                done = true
                break
            }
        }
        if !done {
            rows = append(rows, Row{
                Date:    t,
                Weather: jma.Weathers[codes[i]],
                TempMax: temps.TempsMax[i],
                TempMin: temps.TempsMin[i],
                PoPs:    []string{pops[i]},
            })
        }
    }

    for _, row := range rows {
        blocks = append(blocks, row.ToSlackBlock())
    }

    // 広域のOverviewをヘッドラインとして表示
    if overview != nil {
        chunks := ja.Cut(overview.Text, true)
        text := "> " + chunks[0] + "\n" + "> " + strings.Join(chunks[1:], "")
        headline := slack.NewTextBlockObject(slack.MarkdownType, text, false, false)
        blocks = append(blocks, slack.NewContextBlock("", headline))
    }

    return blocks
}

type (
    Row struct {
        Date    time.Time
        Weather jma.Weather
        TempMax string
        TempMin string
        PoPs    []string
    }
)

func (row Row) ToSlackBlock() *slack.ContextBlock {
    date := row.Date.Format("01/02") + fmt.Sprintf("(%s)", ja.Weekday[row.Date.Weekday()])
    columns := []slack.MixedElement{
        slack.NewTextBlockObject(slack.MarkdownType, date, false, false),                    // 日付
        slack.NewTextBlockObject(slack.MarkdownType, row.Weather.Emoji.Slack, false, false), // 天気emoji
    }
    if row.TempMax != "" || row.TempMin != "" { // 気温を追加
        t := fmt.Sprintf("%s/%s℃", row.TempMax, row.TempMin)
        columns = append(columns, slack.NewTextBlockObject(slack.MarkdownType, t, false, false))
    }
    if len(row.PoPs) != 0 { // 降水確率を追加
        for i, pop := range row.PoPs {
            row.PoPs[i] = pop + "%"
        }
        columns = append(columns, slack.NewTextBlockObject(slack.MarkdownType, strings.Join(row.PoPs, "/"), false, false))
    }
    return slack.NewContextBlock("", columns...)
}

// 2021/08/15 -> 20210815
func DateDecimal(t time.Time) int {
    y, m, d := t.Date()
    return y*10000 + int(m)*100 + d
}