月曜日をもっと楽しく迎えるbot

# はじめに
こちらは[長野高専 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/nnct) 17日目の記事です！
@shun-shobon さんが開催するNNCTアドカレもこれで3回目の参加ですね〜。現在後続の主催者を探しているみたいなので、来年度以降も在籍予定の方はぜひ！

## 自己紹介
卒業してしまった身なので書いておきます！
18sで昨年電子情報工学科を卒業した**Bony_Chops**と申します！現在は大学に3年次編入して、各所でインターン等々をやっています。

https://bonychops.com

# 明日は月曜日
長野高専電子情報工学科18生のDiscord鯖には **月曜日を楽しく迎えるため** に作られた **#明日は月曜日** というチャンネルがあります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/70859d47-a009-d0d3-7eae-f6d6049658d0.png)

このチャンネルでは、 **明日は月曜日bot** が **毎週日曜21:00に陽気になれる画像を投稿** します。詳細は[NNCT18J 元ネタ集](https://w.atwiki.jp/nnct18j/pages/21.html)をご覧ください。

このbotは2022/1月に誕生し、2023/11月までの2年10ヶ月間、97回メンバーに明日が月曜日であることをお知らせしました。

ただ、このbotの問題点として、 **毎週同じ画像が投稿** されます。これではあまり陽気になれませんよね。今回は、 **動的に生成する動画** を使って、鯖メンバーを喜ばせようと思います！

## 技術制定
今回作るbotはこんな感じの構成にしようと思います。

![名称未設定ファイル.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/642747e7-5e08-1dcf-bb99-64ac8fcc5fe0.png)

- 動画生成: Cloud Run
    - 動画生成: [Remotion](https://www.remotion.dev/)
    - 諸々の処理: [Echo](https://echo.labstack.com/)(Go)
- トリガー: Google App Script

## 動画を作る
まずは応援する動画を作ります。今回は、「**カレンダーの明日(月曜日)にズームし、その枠の中で猫がAviciiのWaiting for Loveを歌う[^6]**」という内容にしようと思います。

### Remotion
まずはRemotionで動画を作っていきます。RemotionはReactで動画を作成するツールで、今回のような動的に動画を作成するケースに置いて非常に有用です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/33a9cdf0-f4b9-19db-7d32-1f9bd23d7388.png)

### カレンダーをcanvasで作る
動画を作るに当たり、カレンダーを用意しなければいけません。当初は[FullCalendar](https://fullcalendar.io/)あたりを使おうと思っていましたが、うまく描画できないため、canvasでフルスクラッチすることにしました。

とは言っても自力で作るのは大変なのでChatGPTにすべてやらせました。退屈なことはChatGPTにやらせよう[^2]。

<details>
<summary> <code>drawCalendar.ts</code> </summary>

```typescript：drawCalendar.ts
export function drawCalendar(
	canvas: HTMLCanvasElement,
	date: {year: number; month: number; day: number},
	size: {width: number; height: number},
	cat?: { // 猫の動画のcanvas要素
		src: CanvasImageSource;
		opacity: number;
	},
	holidayOpacity: number = 0 // ここの値を0->1にすることで、徐々に日付が"休"の文字に変化する(後述)
): {x: number; y: number} /* 指定したdate.dayの位置、動画のズームで使う */ | null {
	// Canvas要素を取得
	const ctx = canvas.getContext('2d');
	if (!ctx) return null;

	// カレンダーの設定
	const days = ['日', '月', '火', '水', '木', '金', '土'];

	// 今月の情報を取得（例として2023年10月）
	const firstDay = new Date(date.year, date.month - 1, 1).getDay();
	// 翌月の初日を取得
	const firstDayOfNextMonth: Date = new Date(date.year, date.month, 1);
	// 翌月の初日のミリ秒を取得し、1ミリ秒引く
	const lastDayOfMonthTime: number = firstDayOfNextMonth.getTime() - 1;
	// ミリ秒からDateオブジェクトを作成
	const lastDayOfMonth: Date = new Date(lastDayOfMonthTime);

	const totalDays = lastDayOfMonth.getDate();
	// Canvasのサイズを指定されたサイズに設定
	canvas.width = size.width;
	canvas.height = size.height;

	// セルのサイズを計算（全体の幅を7で割る）
	const cellWidth = size.width / 7;
	// 全体の高さから曜日のヘッダ分を引いたものを、（最大）6で割る
	const cellHeight = (size.height - cellWidth) / 6;

	// カレンダーの描画処理
	// 曜日のヘッダーを描画
	ctx.font = `${cellWidth / 3}px Arial`; // フォントサイズをセルの幅に合わせて調整
	days.forEach((day, i) => {
		ctx.fillStyle = i === 0 ? 'red' : i === 6 ? 'blue' : 'black'; // 日曜日は赤、土曜日は青、それ以外は黒
		ctx.fillText(
			day,
			cellWidth * i + cellWidth / 2 - ctx.measureText(day).width / 2,
			cellWidth / 2
		);
	});

	// 日付を描画する前に最終行が必要かどうかを確認
	let needsLastRow = true;
	// 日付を描画
	let day = 1;
	let tx: number;
	let ty: number;
	for (let i = 0; i < 6; i++) {
		// 最大で6週間
		for (let j = 0; j < 7; j++) {
			const x = cellWidth * j;
			const y = cellWidth + cellHeight * i;
			if (i === 0 && j < firstDay) {
				continue; // 月の最初の日までセルを空にする
			}
			if (day > totalDays) {
				break; // 月の最後の日を過ぎたら終了
			}
			// 曜日に応じて色を変更
			ctx.fillStyle = j === 0 ? 'red' : j === 6 ? 'blue' : 'black'; // 日曜日は赤、土曜日は青、それ以外は黒

			if (day === date.day) {
				tx = x + cellWidth / 2;
				ty = y + cellHeight / 2;

				// 日付をセンタリングして描画
				ctx.globalAlpha = 1 - holidayOpacity;
				const dayString = day.toString();
				ctx.fillText(
					dayString,
					x + cellWidth / 2 - ctx.measureText(dayString).width / 2,
					y + cellHeight / 2 + cellWidth / 6
				);
				// 日付をセンタリングして描画
				ctx.globalAlpha = holidayOpacity;
				ctx.fillStyle = 'red';
				const holidayString = '休';
				ctx.fillText(
					holidayString,
					x + cellWidth / 2 - ctx.measureText(holidayString).width / 2,
					y + cellHeight / 2 + cellWidth / 6
				);
				ctx.globalAlpha = 1;
				ctx.fillStyle = 'black';

				if (cat) {
					ctx.globalAlpha = cat.opacity ?? 1;
					ctx.drawImage(
						cat.src,
						x + cellWidth / 2 - cellWidth / 3,
						y + cellHeight / 2 - cellWidth / 3,
						(cellWidth * 2) / 3,
						(cellWidth * 2) / 3
					);
					ctx.globalAlpha = 1;
				}
			} else {
				// 日付をセンタリングして描画
				const dayString = day.toString();
				ctx.fillText(
					dayString,
					x + cellWidth / 2 - ctx.measureText(dayString).width / 2,
					y + cellHeight / 2 + cellWidth / 6
				);
			}
			day++;
		}
		if (day > totalDays) {
			needsLastRow = false; // 最終行が不要であることを示す
			break;
		}
	}

	// 罫線を描画する関数
	function drawGrid(ctx: CanvasRenderingContext2D, needsLastRow: boolean) {
		ctx.beginPath();
		// 縦の罫線の長さを調整するために最終行が必要かどうかに応じた値を計算
		const gridHeight = needsLastRow ? size.height : 6 * cellHeight;
		for (let i = 0; i <= 7; i++) {
			// 縦の罫線
			ctx.moveTo(i * cellWidth, 0);
			ctx.lineTo(i * cellWidth, gridHeight);
		}
		for (let i = 0; i <= 6; i++) {
			// 横の罫線（7日分 + ヘッダー1行）
			ctx.moveTo(0, i * cellHeight);
			ctx.lineTo(size.width, i * cellHeight);
		}
		if (needsLastRow) {
			ctx.moveTo(0, 7 * cellHeight);
			ctx.lineTo(size.width, 7 * cellHeight);
		}
		ctx.strokeStyle = 'black';
		ctx.stroke();
	}

	// 罫線の描画
	drawGrid(ctx, needsLastRow);
	return {x: tx!, y: ty!};
}

```

</details>

### 祝日を祝う
月曜日だけではなく、せっかくなので **祝日を祝う** 機能も作ります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/d771b42d-f97c-937b-74a1-f23cd0c0ea66.png)


下記を参考に、canvasで描画する部分を組み込みます。

https://github.com/rare25/5000choyen

https://github.com/yurafuca/5000choyen

<details>
<summary> <code>draw50ch.ts</code> </summary>

```typescript：draw50ch.ts
export function draw50ch(
	canvas: HTMLCanvasElement,
	text: string,
	text2: string
) {
	const ctx = canvas.getContext('2d');
	if (!ctx) return;
	ctx.font = '100px Noto Sans JP';
	ctx.lineJoin = 'round';
	ctx.setTransform(1, 0, 0, 1, 0, 0);
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	ctx.setTransform(1, 0, -0.4, 1, 0, 0);
	const posx = 70;
	const posy = 100;
	// 黒色
	ctx.strokeStyle = '#000';
	ctx.lineWidth = 22;
	ctx.strokeText(text, posx + 4, posy + 4);

	// 銀色
	{
		const grad = ctx.createLinearGradient(0, 24, 0, 122);
		grad.addColorStop(0.0, 'rgb(0,15,36)');
		grad.addColorStop(0.1, 'rgb(255,255,255)');
		grad.addColorStop(0.18, 'rgb(55,58,59)');
		grad.addColorStop(0.25, 'rgb(55,58,59)');
		grad.addColorStop(0.5, 'rgb(200,200,200)');
		grad.addColorStop(0.75, 'rgb(55,58,59)');
		grad.addColorStop(0.85, 'rgb(25,20,31)');
		grad.addColorStop(0.91, 'rgb(240,240,240)');
		grad.addColorStop(0.95, 'rgb(166,175,194)');
		grad.addColorStop(1, 'rgb(50,50,50)');
		ctx.strokeStyle = grad;
		ctx.lineWidth = 20;
		ctx.strokeText(text, posx + 4, posy + 4);
	}

	// 黒色
	ctx.strokeStyle = '#000000';
	ctx.lineWidth = 16;
	ctx.strokeText(text, posx, posy);

	// 金色
	{
		const grad = ctx.createLinearGradient(0, 20, 0, 100);
		grad.addColorStop(0, 'rgb(253,241,0)');
		grad.addColorStop(0.25, 'rgb(245,253,187)');
		grad.addColorStop(0.4, 'rgb(255,255,255)');
		grad.addColorStop(0.75, 'rgb(253,219,9)');
		grad.addColorStop(0.9, 'rgb(127,53,0)');
		grad.addColorStop(1, 'rgb(243,196,11)');
		ctx.strokeStyle = grad;
		ctx.lineWidth = 10;
		ctx.strokeText(text, posx, posy);
	}

	// 黒
	ctx.lineWidth = 6;
	ctx.strokeStyle = '#000';
	ctx.strokeText(text, posx + 2, posy - 3);

	// 白
	ctx.lineWidth = 6;
	ctx.strokeStyle = '#FFFFFF';
	ctx.strokeText(text, posx, posy - 3);

	// 赤
	{
		const grad = ctx.createLinearGradient(0, 20, 0, 100);
		grad.addColorStop(0, 'rgb(255, 100, 0)');
		grad.addColorStop(0.5, 'rgb(123, 0, 0)');
		grad.addColorStop(0.51, 'rgb(240, 0, 0)');
		grad.addColorStop(1, 'rgb(5, 0, 0)');
		ctx.lineWidth = 1;
		ctx.strokeStyle = grad;
		ctx.strokeText(text, posx, posy - 3);
	}

	// 赤
	{
		const grad = ctx.createLinearGradient(0, 20, 0, 100);
		grad.addColorStop(0, 'rgb(230, 0, 0)');
		grad.addColorStop(0.5, 'rgb(123, 0, 0)');
		grad.addColorStop(0.51, 'rgb(240, 0, 0)');
		grad.addColorStop(1, 'rgb(5, 0, 0)');
		ctx.fillStyle = grad;
		ctx.fillText(text, posx, posy - 3);
	}

	drawBottom(ctx, text2);
}

function drawBottom(ctx: CanvasRenderingContext2D, text: string): number {
	ctx.measureText(text);

	const x = 800 + 70 - ctx.measureText(text).width;
	const y = 220;
	ctx.setTransform(1, 0, -0.45, 1, 0, 0);

	// 黒色
	ctx.strokeStyle = '#000';
	ctx.lineWidth = 22;
	ctx.strokeText(text, x + 5, y + 2);

	// 銀
	{
		const grad = ctx.createLinearGradient(0, y - 80, 0, y + 18);
		grad.addColorStop(0, 'rgb(0,15,36)');
		grad.addColorStop(0.25, 'rgb(250,250,250)');
		grad.addColorStop(0.5, 'rgb(150,150,150)');
		grad.addColorStop(0.75, 'rgb(55,58,59)');
		grad.addColorStop(0.85, 'rgb(25,20,31)');
		grad.addColorStop(0.91, 'rgb(240,240,240)');
		grad.addColorStop(0.95, 'rgb(166,175,194)');
		grad.addColorStop(1, 'rgb(50,50,50)');
		ctx.strokeStyle = grad;
		ctx.lineWidth = 19;
		ctx.strokeText(text, x + 5, y + 2);
	}

	// 黒色
	ctx.strokeStyle = '#10193A';
	ctx.lineWidth = 17;
	ctx.strokeText(text, x, y);

	// 白
	ctx.strokeStyle = '#DDD';
	ctx.lineWidth = 8;
	ctx.strokeText(text, x, y);

	// 紺
	{
		const grad = ctx.createLinearGradient(0, y - 80, 0, y);
		grad.addColorStop(0, 'rgb(16,25,58)');
		grad.addColorStop(0.03, 'rgb(255,255,255)');
		grad.addColorStop(0.08, 'rgb(16,25,58)');
		grad.addColorStop(0.2, 'rgb(16,25,58)');
		grad.addColorStop(1, 'rgb(16,25,58)');
		ctx.strokeStyle = grad;
		ctx.lineWidth = 7;
		ctx.strokeText(text, x, y);
	}

	// 銀
	{
		const grad = ctx.createLinearGradient(0, y - 80, 0, y);
		grad.addColorStop(0, 'rgb(245,246,248)');
		grad.addColorStop(0.15, 'rgb(255,255,255)');
		grad.addColorStop(0.35, 'rgb(195,213,220)');
		grad.addColorStop(0.5, 'rgb(160,190,201)');
		grad.addColorStop(0.51, 'rgb(160,190,201)');
		grad.addColorStop(0.52, 'rgb(196,215,222)');
		grad.addColorStop(1.0, 'rgb(255,255,255)');
		ctx.fillStyle = grad;
		ctx.fillText(text, x, y - 3);
	}

	return ctx.measureText(text).width + 30;
}
```

</details>

### 生成結果[^6]

<iframe width="560" height="315" src="https://www.youtube.com/embed/jLQSMys8-lo?si=wLYk8VEob48tgsEU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

#### 祝日を祝う機能[^5]

<iframe width="560" height="315" src="https://www.youtube.com/embed/Kwp4HADt8Jc?si=UptVNXdWF1UdyTum" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>


## 処理部を作る
Cloud Runで動かすに当たって、HTTPリクエストを受け付ける必要があります。RemotionをNode.jsで動かしているため、Fastify等でやっても良かったのですが、`node_modules`をこれ以上でかくしたくなかったので、Goで書きます(？)[^3]



レンダリングは結構時間がかかるので、Validation等の作業が正常に完了した時点でgoroutineとしての実行に移行し、先に204レスポンスを返します。
```go:server.go
package main

import (
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"time"

	"github.com/BonyChops/monday-left-me-broken-bot/server/internal/utils"
	"github.com/go-playground/validator"
	"github.com/joho/godotenv"
	"github.com/labstack/echo/v4"
	"github.com/labstack/gommon/log"
)

type (
	Body struct {
		// ...
	}
	CustomValidator struct {
		validator *validator.Validate
	}
)

func (cv *CustomValidator) Validate(i interface{}) error {
	if err := cv.validator.Struct(i); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, err.Error())
	}
	return nil
}

func main() {
	godotenv.Load()

	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}

	e.POST("/judge", func(c echo.Context) error {
		body := Body{}
		if err := c.Bind(&body); err != nil {
			return echo.NewHTTPError(http.StatusBadRequest)

		}
		if err := c.Validate(body); err != nil {
			log.Error(err)
			return err
		}

		events, err := utils.GetHolidays(body.HolidayDataUrl)
		if err != nil {
			log.Error(err)
			return c.JSON(http.StatusInternalServerError, "Internal Server Error")
		}

		format, year, month, day := utils.GetDateFormat(body.Date.Year, body.Date.Month, body.Date.Day)

		afterResponse := func(val string) {
			log.Info("Rendering...")
			renderedFileName, err := utils.RenderProcess(year, month, day, val)
			if err != nil {
				log.Error(err)
				return
			}
			url, err := utils.UploadVideo(filepath.Join(os.Getenv("RENDER_MJS_DIR_PATH"), "out", renderedFileName), os.Getenv("IMGUR_CLIENT_ID"))
			if err != nil {
				log.Error(err)
				return
			}
			time.Sleep(5 * time.Second)
			utils.SendDiscord(url, body.DiscordUrl)
		}

		if val, ok := events[format]; ok {
			fmt.Printf("holiday event exists. The value is %#v\n", val)
			go afterResponse(val)
		} else if body.Force || (utils.GetWeekDayFromDate(year, month, day) == time.Monday) {
			go afterResponse("")
		} else {
			log.Info("Not Monday")
		}

		return c.NoContent(http.StatusNoContent)
	})

	e.Logger.Fatal(e.Start(":8080"))
}
```

祝日のデータは、[内閣府がCSVで公開している](https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html)のでそれを使います。

:::note warn
CSVはShift-JISなので注意
:::

```go:utils.go
package utils

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"
	"golang.org/x/text/encoding/japanese"
	"golang.org/x/text/transform"
)

type Events map[string]string

func GetHolidays(holidayDataUrl string) (Events, error) {
	resp, err := http.Get(holidayDataUrl)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	reader := transform.NewReader(resp.Body, japanese.ShiftJIS.NewDecoder())

	byteArray, _ := io.ReadAll(reader)
	eventStrings := strings.Split(string(byteArray), "\n")
	events := Events{}
	for _, eventString := range eventStrings {
		if eventString == "" {
			continue
		}
		event := strings.Split(eventString, ",")
		events[event[0]] = event[1]
	}

	return events, nil
}

```

## Dockerコンテナ化
Cloud Run で動かすため、`Dockerfile`を書きます。[Remotionが公開している推奨構成](https://www.remotion.dev/docs/docker)を参考にしながら、こんな感じにしてみました[^4]。

```Dockerfile:Dockerfile
# Goのサーバーを先にビルド
FROM golang:1.21-alpine3.18 as builder
WORKDIR /app
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server .
RUN go build -o server cmd/server.go


# 動かす本体
FROM node:18-bookworm
WORKDIR /app

RUN apt-get update
RUN apt-get install -y chromium fonts-noto-cjk

COPY package.json package*.json yarn.lock* pnpm-lock.yaml* bun.lockb* tsconfig.json* remotion.config.* ./
COPY src ./src
COPY --from=builder /app/server .

RUN npm ci

COPY render.mjs render.mjs
CMD ["/app/server"] # サーバー起動
```

## 詰まる
あとはCloud Runに上げるだけ...なのですが、curlで叩いてみても何故か一向にレンダリングが始まりません...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/00546b70-67c4-031a-9c59-c6d984ed6615.png)
始まってないんだよな...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/be4eb38d-c828-ff74-23ca-e4b3e1b2cb87.png)
ローカルのDockerだとうまく行く...なぜ？

### Stdoutが出ていなかった(実行はされていた)
```go:utils.go
	cmd := exec.Command("node", filepath.Join(os.Getenv("RENDER_MJS_DIR_PATH"), "render.mjs"))
	
    // ...
    
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
```
Nodeでの実行をこのようにして表示していたのですが、どうやらこの方法だとGoogle Cloudでログとして拾ってくれないみたいです。自分は実行されていないと勘違いして色々試していましたが、Discordの方を確認するとちゃんとできていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/a5025ea8-0a39-8a57-863d-dafdae121df2.png)

## 結果
以前のbotを止めることができなく、botが増えました[^8]。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/601799/333eb6fa-44f5-4bfb-ca5a-5ee7db9f184e.png)



[^2]: [退屈なことはPythonにやらせよう ―ノンプログラマーにもできる自動化処理プログラミング | Amazon](https://amzn.asia/d/cyJl9px)
[^3]: Fastifyで書いた後、BabelやViteで小さくするという手もあります...というかJSでRemotionのAPIを叩く以上、今考えれば **絶対にJSで完結させたほうがいいです、ええ。**
[^4]: これで完成するイメージは**2GB**と激重です。多分chromiumが大きく食っているとは思うのですが、かなり改善の余地があるのでどうにかしたいなーと思っています。
[^5]: 冒頭: https://www.youtube.com/watch?v=g6Ytui9HqCA
[^6]: 猫: https://youtube.com/shorts/mjRRoRRJhZI?si=kQLVGeDMMxdJctho
[^7]: https://zenn.dev/nobonobo/scraps/ba148faa8048f7
[^8]: どのプラットフォームでホストしたものかを忘れてしまい、停止ができませんでした