diff --git a/.github/workflows/docker-tag.yaml b/.github/workflows/docker-tag.yaml index 4e33f9c3..1ef6bbe6 100644 --- a/.github/workflows/docker-tag.yaml +++ b/.github/workflows/docker-tag.yaml @@ -3,7 +3,8 @@ name: Docker Build and Push on: push: tags: - - "v*" + - "bot/v*" + - "rss-cron/v*" concurrency: group: docker-build-push @@ -24,10 +25,24 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: Extract tag name + - name: Extract tag info run: | - tagname=${GITHUB_REF#refs/*/} - echo "TAG_NAME=${tagname#v}" >> $GITHUB_ENV + TAG="${GITHUB_REF#refs/tags/}" + + if [[ "$TAG" == bot/v* ]]; then + echo "IMAGE_NAME=unibot" >> "$GITHUB_ENV" + echo "DOCKERFILE=Dockerfile.main" >> "$GITHUB_ENV" + echo "TAG_NAME=${TAG#bot/v}" >> "$GITHUB_ENV" + + elif [[ "$TAG" == rss-cron/v* ]]; then + echo "IMAGE_NAME=unibot-rss-cron" >> "$GITHUB_ENV" + echo "DOCKERFILE=Dockerfile.rss_cron" >> "$GITHUB_ENV" + echo "TAG_NAME=${TAG#rss-cron/v}" >> "$GITHUB_ENV" + + else + echo "Unsupported tag: $TAG" + exit 1 + fi - name: Login to Harbor Registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 @@ -40,12 +55,12 @@ jobs: id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: - images: registry.uniproject.jp/infra/unibot + images: registry.uniproject.jp/infra/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{version}},value=${{ env.TAG_NAME }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG_NAME }} + type=semver,pattern={{major}},value=${{ env.TAG_NAME }} type=sha,prefix=sha-,suffix=,format=short - name: Install Cosign @@ -58,13 +73,13 @@ jobs: id: build-and-push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 with: - file: ./Dockerfile + file: ./${{ env.DOCKERFILE }} context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=registry.uniproject.jp/infra/unibot:buildcache - cache-to: type=registry,ref=registry.uniproject.jp/infra/unibot:buildcache,mode=max + cache-from: type=registry,ref=registry.uniproject.jp/infra/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=registry.uniproject.jp/infra/${{ env.IMAGE_NAME }}:buildcache,mode=max - name: Sign Docker images with GitHub OIDC (cosign keyless) env: diff --git a/Dockerfile b/Dockerfile.main similarity index 90% rename from Dockerfile rename to Dockerfile.main index 2cf104de..3d02f00d 100644 --- a/Dockerfile +++ b/Dockerfile.main @@ -1,6 +1,6 @@ # check=error=true # syntax=docker/dockerfile:1 -FROM golang:1.26.2-alpine3.22 AS builder +FROM golang:1.26.4-alpine3.24 AS builder WORKDIR /app RUN apk update && apk add --no-cache \ @@ -16,7 +16,7 @@ ENV SHELL=/bin/sh ENV VCPKG_FORCE_SYSTEM_BINARIES=1 ENV CC=/usr/bin/gcc CXX=/usr/bin/g++ ENV CXXFLAGS="-Wno-error=maybe-uninitialized" -RUN apk add build-base cmake ninja zip unzip curl git pkgconfig perl nasm go +RUN apk add --no-cache build-base cmake ninja zip pkgconfig perl nasm RUN FORCE_BUILD=1 ./godave/scripts/libdave_install.sh v1.1.0 ENV PKG_CONFIG_PATH=/root/.local/lib/pkgconfig RUN rm -r ./godave @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/go/pkg/mod/,sharing=locked \ RUN ../scripts/_build.sh -FROM alpine:3.23.4 +FROM alpine:3.24.0 WORKDIR /root/ RUN apk update && apk add --no-cache \ @@ -46,7 +46,7 @@ COPY --from=builder /root/.local/ /root/.local/ RUN chmod -R 755 /root/.local COPY --from=builder /app/src/main . -RUN chmod 777 ./main +RUN chmod 755 ./main CMD ["/root/main"] diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron new file mode 100644 index 00000000..5b5f4814 --- /dev/null +++ b/Dockerfile.rss_cron @@ -0,0 +1,46 @@ +# check=error=true +# syntax=docker/dockerfile:1 +FROM golang:1.26.4-alpine3.24 AS builder +WORKDIR /app + +RUN apk update && apk add --no-cache \ + curl git file unzip \ + python3 make g++ \ + pkgconf + +COPY . . + +WORKDIR /app/src + +RUN --mount=type=cache,target=/go/pkg/mod/,sharing=locked \ + go mod download -x + +RUN ../scripts/_build.sh --rss-cron + +FROM alpine:3.24.0 +WORKDIR /root/ + +RUN apk update && apk add --no-cache \ + ca-certificates + +COPY --from=builder /app/src/main . + +RUN addgroup -g 1000 appuser && \ + adduser -D -u 1000 -G appuser appuser +WORKDIR /home/appuser + +RUN mv /root/main /home/appuser/main && chown appuser:appuser ./main && chmod 755 ./main + +USER appuser + +CMD ["/home/appuser/main"] + +# OCI Metadata + +LABEL org.opencontainers.image.authors="Yuito Akatsuki " +LABEL org.opencontainers.image.url="https://github.com/UniPro-tech/UniBot" +LABEL org.opencontainers.image.source="https://github.com/UniPro-tech/UniBot" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.title="UniBot" +LABEL org.opencontainers.image.description="A multifunctional Discord bot for community management, entertainment, and productivity." +LABEL org.opencontainers.image.vendor="All-Japan Digital Creative Club UniProject " diff --git a/scripts/_build.sh b/scripts/_build.sh index 74b37880..f5cee7d4 100755 --- a/scripts/_build.sh +++ b/scripts/_build.sh @@ -1,6 +1,12 @@ COMMIT=$(git rev-parse --short HEAD) BRANCH=$(git branch --show-current) +if [[ "$*" == *"--rss-cron"* ]]; then + TARGET="cmd/rss_cron/main.go" +else + TARGET="cmd/bot/main.go" +fi + if [[ "$*" == *"--dev"* ]]; then export DISCORD_TOKEN="your_token_here" export CONFIG_ADMIN_GUILD_ID="your_guild_id_here" @@ -12,7 +18,7 @@ if [[ "$*" == *"--dev"* ]]; then go run -ldflags "\ -X unibot/internal.GitCommit=$COMMIT \ -X unibot/internal.GitBranch=$BRANCH" \ -cmd/bot/main.go +"$TARGET" else VERSION=$(git describe --tags --abbrev=0) @@ -20,5 +26,5 @@ else -X unibot/internal.Version=$VERSION \ -X unibot/internal.GitCommit=$COMMIT \ -X unibot/internal.GitBranch=$BRANCH" \ -cmd/bot/main.go +"$TARGET" fi \ No newline at end of file diff --git a/src/cmd/rss_cron/main.go b/src/cmd/rss_cron/main.go new file mode 100644 index 00000000..f8033582 --- /dev/null +++ b/src/cmd/rss_cron/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "os" + "slices" + "sort" + "unibot/internal/db" + "unibot/internal/repository" + "unibot/internal/util" + + "github.com/mmcdole/gofeed" + + "github.com/disgoorg/disgo" + "github.com/disgoorg/disgo/bot" + "github.com/disgoorg/disgo/cache" + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/events" + "github.com/disgoorg/disgo/gateway" + "github.com/disgoorg/snowflake/v2" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func main() { + token := os.Getenv("DISCORD_TOKEN") + if token == "" { + log.Fatal("DISCORD_TOKEN is not set") + } + + dbConnection, err := db.NewDB() + if err != nil { + log.Fatal(err) + } + dbConnection.Logger = dbConnection.Logger.LogMode(logger.Info) + + client, err := disgo.New(token, + //bot.WithDefaultGateway(), + bot.WithGatewayConfigOpts( + // Intents + gateway.WithIntents( + gateway.IntentsNonPrivileged, + ), + ), + // Cache + bot.WithCacheConfigOpts( + cache.WithCaches(cache.FlagVoiceStates), + cache.WithCaches(cache.FlagChannels), + cache.WithCaches(cache.FlagMessages), + cache.WithCaches(cache.FlagRoles), + cache.WithCaches(cache.FlagMembers), + cache.WithCaches(cache.FlagGuilds), + ), + // Event Handler + bot.WithEventListenerFunc(func(e *events.Ready) { + Ready(dbConnection, e) + }), + ) + if err != nil { + log.Fatal("error while building disgo instance: ", err) + } + + defer client.Close(context.TODO()) + + // 接続開始 + if err = client.OpenGateway(context.TODO()); err != nil { + log.Fatal("error while connecting to gateway: ", err) + } + + log.Println("Bot is running...") +} + +func Ready(db *gorm.DB, e *events.Ready) { + log.Println("Bot is ready 🚀") + log.Printf("Logged in as: %v#%v", e.User.Username, e.User.Discriminator) + + repo := repository.NewRSSSettingRepository(db) + rssSubscribeList, err := repo.List() + if err != nil { + log.Fatal("An error occured:", err) + return + } + for _, rssSetting := range rssSubscribeList { + url := rssSetting.URL + feed, err := util.FetchFeed(url) + if err != nil { + rssSetting.IsFailed = true + if err := repo.Update(rssSetting); err != nil { + log.Print("Update Record Failed", err) + } + continue + } + + feedTitle := rssSetting.Title + if feedTitle == nil { + feedTitle = &feed.Title + } + + // 新しい日時がindex:0 + sort.Slice(feed.Items, func(i, j int) bool { + prev := feed.Items[i] + next := feed.Items[j] + if prev.PublishedParsed != nil && next.PublishedParsed != nil { + return prev.PublishedParsed.UnixNano() >= next.PublishedParsed.UnixNano() + } + return false + }) + + // 保存済みハッシュより新しい記事を収集する + // - LastItemTitleDescriptionHash が nil(初回)なら全件対象 + // - 一致するハッシュが見つかった時点で break(それ以降は既読) + var targetItems []*gofeed.Item + for _, item := range feed.Items { + hash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", item.Title, item.Description))) + if rssSetting.LastItemTitleDescriptionHash != nil && hash == *rssSetting.LastItemTitleDescriptionHash { + break // ここから先は既読 + } + targetItems = append(targetItems, item) + } + + if len(targetItems) == 0 { + continue + } + + // 古い→新しい順に送信 + slices.Reverse(targetItems) + channelID := snowflake.MustParse(rssSetting.ChannelID) + client := e.Client() + for _, item := range targetItems { + itemTitle := item.Title + if itemTitle == "" { + itemTitle = "(タイトルなし)" + } + itemDescription := item.Description + if itemDescription == "" { + if item.Content != "" { + itemDescription = item.Content + } else { + itemDescription = "(説明なし)" + } + } + itemLink := item.Link + if itemLink == "" { + itemLink = "リンクなし" + } + message := fmt.Sprintf( + "# %s に新しい記事が追加されました!\n## %s\n%s\nURL: %s", + *feedTitle, itemTitle, itemDescription, itemLink, + ) + _, err := client.Rest.CreateMessage(channelID, discord.NewMessageCreate().WithContent(message)) + if err != nil { + log.Print("Message create error:", err) + } + } + + // 送信完了後に最新ハッシュを保存(feed.Items[0] = 最新記事) + newestHash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", feed.Items[0].Title, feed.Items[0].Description))) + rssSetting.LastItemTitleDescriptionHash = &newestHash + rssSetting.IsFailed = false + if err := repo.Update(rssSetting); err != nil { + log.Print("Update Record Failed", err) + } + } +} diff --git a/src/go.mod b/src/go.mod index 524ff588..f771765f 100644 --- a/src/go.mod +++ b/src/go.mod @@ -1,6 +1,6 @@ module unibot -go 1.24.4 +go 1.26.4 require ( github.com/bwmarrin/discordgo v0.29.1-0.20260214123928-f43dd94faaac @@ -9,6 +9,7 @@ require ( github.com/disgoorg/snowflake/v2 v2.0.3 github.com/hraban/opus v0.0.0-20251117090126-c76ea7e21bf3 github.com/jackc/pgtype v1.14.4 + github.com/mmcdole/gofeed v1.3.0 github.com/stretchr/testify v1.11.1 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 @@ -16,6 +17,8 @@ require ( ) require ( + github.com/PuerkitoBio/goquery v1.8.0 // indirect + github.com/andybalholm/cascadia v1.3.1 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/disgoorg/godave v0.1.0 // indirect @@ -30,12 +33,17 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/src/go.sum b/src/go.sum index e1657e73..087f5b2c 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,5 +1,9 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= +github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= @@ -27,6 +31,7 @@ github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vb github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -91,6 +96,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= @@ -115,6 +122,15 @@ github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= +github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= +github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -185,10 +201,13 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -205,6 +224,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/src/internal/bot/handlers/interaction/command/general/rss.go b/src/internal/bot/handlers/interaction/command/general/rss.go new file mode 100644 index 00000000..b5a2e810 --- /dev/null +++ b/src/internal/bot/handlers/interaction/command/general/rss.go @@ -0,0 +1,17 @@ +package general + +import ( + "unibot/internal/bot/handlers/interaction/command/general/rss" + + "github.com/disgoorg/disgo/discord" +) + +func LoadRssCommandContext() discord.SlashCommandCreate { + return discord.SlashCommandCreate{ + Name: "rss", + Description: "RSSフィードを受信します", + Options: []discord.ApplicationCommandOption{ + rss.LoadSubscribeCommandContext(), + }, + } +} diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go new file mode 100644 index 00000000..9f2da91f --- /dev/null +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -0,0 +1,162 @@ +package rss + +import ( + "encoding/base64" + "fmt" + "sort" + "time" + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + "unibot/internal/util" + + "github.com/disgoorg/disgo/discord" + "github.com/disgoorg/disgo/handler" +) + +func LoadSubscribeCommandContext() discord.ApplicationCommandOption { + return discord.ApplicationCommandOptionSubCommand{ + Name: "subscribe", + Description: "RSSフィードを購読します", + Options: []discord.ApplicationCommandOption{ + discord.ApplicationCommandOptionString{ + Name: "url", + Description: "RSSフィードのURLを設定します", + Required: true, + }, + discord.ApplicationCommandOptionString{ + Name: "title", + Description: "タイトルを設定します", + Required: false, + }, + }, + } +} + +func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + return func(data discord.SlashCommandInteractionData, e *handler.CommandEvent) error { + config := ctx.Config + + if e.Channel().Type() == discord.ChannelTypeDM || e.Channel().Type() == discord.ChannelTypeGroupDM { + responseEmbed := discord.Embed{ + Title: "DMでは実行できません", + Description: "このコマンドはDMでは実行できません。", + Color: config.Colors.Error, + Footer: &discord.EmbedFooter{ + Text: fmt.Sprintf("Requested by %s", e.User().Username), + IconURL: e.User().EffectiveAvatarURL(), + }, + Timestamp: func() *time.Time { + t := time.Now() + return &t + }(), + } + _, err := e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(true)) + return err + } + + guildID := *e.GuildID() + var title *string + var url string + for _, opt := range data.Options { + switch opt.Name { + case "title": + title = func() *string { + titleValue := opt.String() + return &titleValue + }() + case "url": + url = opt.String() + } + } + + feed, err := util.FetchFeed(url) + if err != nil { + return errorSubscribeResponse(config, e) + } + + if feed.Title != "" && title == nil { + title = &feed.Title + } + var hash *string + if len(feed.Items) != 0 { + // 新しい日時がindex:0 + sort.SliceStable(feed.Items, func(i, j int) bool { + a := feed.Items[i].PublishedParsed + b := feed.Items[j].PublishedParsed + + switch { + case a == nil && b == nil: + return false + case a == nil: + return false + case b == nil: + return true + default: + return a.After(*b) + } + }) + hash = func() *string { + data := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", feed.Items[0].Title, feed.Items[0].Description))) + return &data + }() + } + + db := ctx.DB + guildRepo := repository.NewGuildRepository(db) + if _, err := guildRepo.GetOrCreate(guildID.String()); err != nil { + return err + } + rssRepo := repository.NewRSSSettingRepository(db) + if err := rssRepo.Create(&model.RSSSetting{ + GuildID: guildID.String(), + ChannelID: e.Channel().ID().String(), + URL: url, + Title: title, + LastItemTitleDescriptionHash: hash, + }); err != nil { + return err + } + + // 成功レスポンス + responseEmbed := discord.Embed{ + Title: "RSS購読", + Description: "RSS購読設定が完了しました。", + Fields: []discord.EmbedField{ + { + Name: "URL", + Value: url, + }, + }, + Color: config.Colors.Success, + Footer: &discord.EmbedFooter{ + Text: fmt.Sprintf("Requested by %s", e.User().Username), + IconURL: e.User().EffectiveAvatarURL(), + }, + Timestamp: func() *time.Time { + t := time.Now() + return &t + }(), + } + _, err = e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(true)) + return err + } +} + +func errorSubscribeResponse(config *internal.Config, e *handler.CommandEvent) error { + responseEmbed := discord.Embed{ + Title: "RSS購読", + Description: "RSSフィードの取得に失敗しました。", + Color: config.Colors.Error, + Footer: &discord.EmbedFooter{ + Text: fmt.Sprintf("Requested by %s", e.User().Username), + IconURL: e.User().EffectiveAvatarURL(), + }, + Timestamp: func() *time.Time { + t := time.Now() + return &t + }(), + } + _, err := e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(true)) + return err +} diff --git a/src/internal/bot/handlers/interaction/registry.go b/src/internal/bot/handlers/interaction/registry.go index cf715dc2..5f6f64c7 100644 --- a/src/internal/bot/handlers/interaction/registry.go +++ b/src/internal/bot/handlers/interaction/registry.go @@ -6,6 +6,7 @@ import ( "unibot/internal" "unibot/internal/bot/handlers/interaction/command/admin/maintenance" "unibot/internal/bot/handlers/interaction/command/general" + "unibot/internal/bot/handlers/interaction/command/general/rss" "unibot/internal/bot/handlers/interaction/command/general/tts" "unibot/internal/bot/handlers/interaction/command/general/tts/dict" "unibot/internal/bot/handlers/interaction/command/general/tts/ttsSet" @@ -65,6 +66,10 @@ func RegistHandler(r *handler.Mux, ctxData *internal.BotContext) { r.SlashCommand("/status/reset", maintenance.StatusResetHandler(ctxData)) r.SlashCommand("/shutdown", maintenance.Shutdown(ctxData)) }) + r.Route("/rss", func(r handler.Router) { + r.Use(DeferReplyMiddleware(ctxData, true, false)) + r.SlashCommand("/subscribe", rss.Subscribe(ctxData)) + }) // action row // select menu r.Route("/tts_dict_remove", func(r handler.Router) { diff --git a/src/internal/model/rss_setting.go b/src/internal/model/rss_setting.go index 686a5698..62d0ac51 100644 --- a/src/internal/model/rss_setting.go +++ b/src/internal/model/rss_setting.go @@ -1,12 +1,14 @@ package model type RSSSetting struct { - ID string `gorm:"primaryKey;size:255"` - URL string `gorm:"not null"` - ChannelID string `gorm:"not null"` - Title string `gorm:"not null"` - CreatedAt int64 `gorm:"autoCreateTime:nano"` - UpdatedAt int64 `gorm:"autoUpdateTime:nano"` - GuildID string `gorm:"not null"` - Guild Guild `gorm:"foreignKey:GuildID;references:DiscordID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + ID string `gorm:"primaryKey;size:255"` + URL string `gorm:"not null"` + ChannelID string `gorm:"not null"` + Title *string `gorm:"null"` + IsFailed bool `gorm:"default:false"` + LastItemTitleDescriptionHash *string `gorm:"null"` + CreatedAt int64 `gorm:"autoCreateTime:nano"` + UpdatedAt int64 `gorm:"autoUpdateTime:nano"` + GuildID string `gorm:"not null"` + Guild Guild `gorm:"foreignKey:GuildID;references:DiscordID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` } diff --git a/src/internal/repository/guild.go b/src/internal/repository/guild.go index e882e730..9ca83032 100644 --- a/src/internal/repository/guild.go +++ b/src/internal/repository/guild.go @@ -37,6 +37,22 @@ func (r *GuildRepository) Get(DiscordID string) (*model.Guild, error) { return &guild, err } +// なければ挿入する +func (r *GuildRepository) GetOrCreate(DiscordID string) (*model.Guild, error) { + exist, err := r.Get(DiscordID) + if err != nil { + return exist, err + } + if exist == nil { + err = r.Create(DiscordID) + if err != nil { + return nil, err + } + exist, err = r.Get(DiscordID) + } + return exist, err +} + // 全てのギルドを取得する関数 func (r *GuildRepository) List() ([]*model.Guild, error) { var guilds []*model.Guild diff --git a/src/internal/util/rss_fetch.go b/src/internal/util/rss_fetch.go new file mode 100644 index 00000000..2a28bacd --- /dev/null +++ b/src/internal/util/rss_fetch.go @@ -0,0 +1,172 @@ +package util + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/mmcdole/gofeed" +) + +const ( + MaxFeedSize = 10 * 1024 * 1024 + MaxRedirect = 5 +) + +func FetchFeed(feedURL string) (*gofeed.Feed, error) { + parsedURL, err := url.Parse(feedURL) + if err != nil { + return nil, err + } + + if err := validateURL(parsedURL); err != nil { + return nil, err + } + + dialer := &net.Dialer{} + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + ips, err := net.LookupIP(host) + if err != nil { + return nil, err + } + + if len(ips) == 0 { + return nil, errors.New("no ip found") + } + + for _, ip := range ips { + if isPrivateIP(ip) { + return nil, fmt.Errorf("private ip detected: %s", ip) + } + } + + return dialer.DialContext( + ctx, + network, + net.JoinHostPort(ips[0].String(), port), + ) + }, + } + defer transport.CloseIdleConnections() + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= MaxRedirect { + return errors.New("too many redirects") + } + + return validateURL(req.URL) + }, + } + + resp, err := client.Get(feedURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + parser := gofeed.NewParser() + + feed, err := parser.Parse( + io.LimitReader(resp.Body, MaxFeedSize), + ) + if err != nil { + return nil, err + } + + return feed, nil +} + +func validateURL(u *url.URL) error { + if u == nil { + return errors.New("nil url") + } + + if u.Scheme != "http" && u.Scheme != "https" { + return errors.New("invalid scheme") + } + + host := u.Hostname() + if host == "" { + return errors.New("missing host") + } + + ips, err := net.LookupIP(host) + if err != nil { + return err + } + + if len(ips) == 0 { + return errors.New("no ip found") + } + + for _, ip := range ips { + if isPrivateIP(ip) { + return fmt.Errorf("private address: %s", ip) + } + } + + port := u.Port() + if port != "" && port != "80" && port != "443" { + return fmt.Errorf("invalid port: %s", port) + } + + return nil +} + +func isPrivateIP(ip net.IP) bool { + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + + privateCIDRs := []string{ + "0.0.0.0/8", + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + + "100.64.0.0/10", + "198.18.0.0/15", + "224.0.0.0/4", + "240.0.0.0/4", + + "::/128", + "::1/128", + "fc00::/7", + "ff00::/8", + "fe80::/10", + } + + for _, cidr := range privateCIDRs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + + if network.Contains(ip) { + return true + } + } + + return false +}