From cfcac61a3501dd331064de94f0cecb70659cce4d Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:07:49 +0900 Subject: [PATCH 01/20] =?UTF-8?q?rss=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-tag.yaml | 37 +++-- Dockerfile => Dockerfile.main | 6 +- Dockerfile.rss_cron | 48 ++++++ scripts/_build.sh | 10 +- src/cmd/rss_cron/main.go | 149 ++++++++++++++++++ src/go.mod | 8 + src/go.sum | 20 +++ .../interaction/command/general/rss.go | 17 ++ .../command/general/rss/subscribe.go | 97 ++++++++++++ .../bot/handlers/interaction/registry.go | 3 + src/internal/model/rss_setting.go | 18 ++- src/internal/repository/guild.go | 16 ++ 12 files changed, 405 insertions(+), 24 deletions(-) rename Dockerfile => Dockerfile.main (92%) create mode 100644 Dockerfile.rss_cron create mode 100644 src/cmd/rss_cron/main.go create mode 100644 src/internal/bot/handlers/interaction/command/general/rss.go create mode 100644 src/internal/bot/handlers/interaction/command/general/rss/subscribe.go 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 92% rename from Dockerfile rename to Dockerfile.main index 2cf104de..a3ebf51f 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 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 \ diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron new file mode 100644 index 00000000..d854c570 --- /dev/null +++ b/Dockerfile.rss_cron @@ -0,0 +1,48 @@ +# 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 + +FROM alpine:3.24.0 +WORKDIR /root/ + +RUN apk update && apk add --no-cache \ + opus \ + opus-dev \ + opusfile \ + opusfile-dev \ + ffmpeg + +ENV PKG_CONFIG_PATH=/root/.local/lib/pkgconfig +ENV LD_LIBRARY_PATH=/root/.local/lib/ +COPY --from=builder /root/.local/ /root/.local/ +RUN chmod -R 755 /root/.local + +COPY --from=builder /app/src/main . +RUN chmod 777 ./main + +CMD ["/root/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..1c74169f --- /dev/null +++ b/src/cmd/rss_cron/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "encoding/base64" + "fmt" + "log" + "os" + "slices" + "sort" + "unibot/internal/db" + "unibot/internal/repository" + + "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) + } else { + for _, rssSetting := range rssSubscribeList { + // fetch + url := rssSetting.URL + fp := gofeed.NewParser() + feed, err := fp.ParseURL(url) + if err != nil { + rssSetting.IsFailed = true + err := repo.Update(rssSetting) + if err != nil { + log.Print("Update Record Failed", err) + return + } + } + feedTitle := rssSetting.Title + if feedTitle == nil { + feedTitle = &feed.Title + } + sort.Slice(feed.Items, func(i, j int) bool { + prev := feed.Items[i] + next := feed.Items[j] + if prev.PublishedParsed != nil && next.PublishedParsed != nil { + prevNano := prev.PublishedParsed.UnixNano() + nextNano := next.PublishedParsed.UnixNano() + return prevNano >= nextNano + } + return false + }) + var targetItems []*gofeed.Item + for _, item := range feed.Items { + hash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", item.Title, item.Description))) + if hash == *rssSetting.LastItemTitleDescriptionHash { + targetItems = append(targetItems, item) + } + } + 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 = "説明なし" + } else { + itemDescription = item.Content + } + } + itemLink := item.Link + if itemLink == "" { + itemLink = "リンクなし" + } + message := fmt.Sprintf(`# %s に新しい記事が追加されました! + ## %s + %s + URL: %s`, *feedTitle, itemTitle, itemDescription, itemLink) + _, err := client.Rest.CreateMessage(channelID, discord.NewMessageCreate().WithContent(message)) + if err != nil { + log.Print("Message create error:", err) + } + } + } + } +} diff --git a/src/go.mod b/src/go.mod index 524ff588..e95f718a 100644 --- a/src/go.mod +++ b/src/go.mod @@ -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..bbe78cf1 --- /dev/null +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -0,0 +1,97 @@ +package rss + +import ( + "fmt" + "time" + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + + "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() + } + } + + db := ctx.DB + guildRepo := repository.NewGuildRepository(db) + guildRepo.GetOrCreate(guildID.String()) + rssRepo := repository.NewRSSSettingRepository(db) + rssRepo.Create(&model.RSSSetting{ + GuildID: guildID.String(), + URL: url, + Title: title, + }) + + // 成功レスポンス + responseEmbed := discord.Embed{ + Title: "TTSボイスチャンネル接続", + Description: "ボイスチャンネルに参加しました。", + 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(false)) + return err + } +} diff --git a/src/internal/bot/handlers/interaction/registry.go b/src/internal/bot/handlers/interaction/registry.go index cf715dc2..261dd700 100644 --- a/src/internal/bot/handlers/interaction/registry.go +++ b/src/internal/bot/handlers/interaction/registry.go @@ -65,6 +65,9 @@ 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)) + }) // 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..8ca86000 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 From d6ff6a78f0ab5d375adf4228ebefc562400aca61 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:39:04 +0900 Subject: [PATCH 02/20] Update Dockerfile.main Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- Dockerfile.main | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.main b/Dockerfile.main index a3ebf51f..e359c5d0 100644 --- a/Dockerfile.main +++ b/Dockerfile.main @@ -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 pkgconfig perl nasm +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 From a5e274200f3c39deece6634d7e3056f38ffb340b Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:39:42 +0900 Subject: [PATCH 03/20] Update Dockerfile.rss_cron Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- Dockerfile.rss_cron | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron index d854c570..ae7f7aa8 100644 --- a/Dockerfile.rss_cron +++ b/Dockerfile.rss_cron @@ -15,7 +15,7 @@ WORKDIR /app/src RUN --mount=type=cache,target=/go/pkg/mod/,sharing=locked \ go mod download -x -RUN ../scripts/_build.sh +RUN ../scripts/_build.sh --rss-cron FROM alpine:3.24.0 WORKDIR /root/ From 1800727cc4860fc25f245585d4764b53dd2e720b Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:40:18 +0900 Subject: [PATCH 04/20] Update src/cmd/rss_cron/main.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- src/cmd/rss_cron/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmd/rss_cron/main.go b/src/cmd/rss_cron/main.go index 1c74169f..f229fc02 100644 --- a/src/cmd/rss_cron/main.go +++ b/src/cmd/rss_cron/main.go @@ -93,6 +93,7 @@ func Ready(db *gorm.DB, e *events.Ready) { log.Print("Update Record Failed", err) return } + continue } feedTitle := rssSetting.Title if feedTitle == nil { From 5729780298940989e9e3ef802e3535cb9637e3e7 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:40:54 +0900 Subject: [PATCH 05/20] Update src/internal/bot/handlers/interaction/command/general/rss/subscribe.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- .../interaction/command/general/rss/subscribe.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index bbe78cf1..b8063eaa 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -69,13 +69,18 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti db := ctx.DB guildRepo := repository.NewGuildRepository(db) - guildRepo.GetOrCreate(guildID.String()) + if _, err := guildRepo.GetOrCreate(guildID.String()); err != nil { + return err + } rssRepo := repository.NewRSSSettingRepository(db) - rssRepo.Create(&model.RSSSetting{ + if err := rssRepo.Create(&model.RSSSetting{ GuildID: guildID.String(), + ChannelID: e.Channel().ID().String(), URL: url, Title: title, - }) + }); err != nil { + return err + } // 成功レスポンス responseEmbed := discord.Embed{ From 93b8242fea457d48110295402d8522606644fc75 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:42:39 +0900 Subject: [PATCH 06/20] =?UTF-8?q?=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=81=AE=E3=83=9F=E3=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/general/rss/subscribe.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index b8063eaa..358461f9 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -74,19 +74,25 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } rssRepo := repository.NewRSSSettingRepository(db) if err := rssRepo.Create(&model.RSSSetting{ - GuildID: guildID.String(), + GuildID: guildID.String(), ChannelID: e.Channel().ID().String(), - URL: url, - Title: title, + URL: url, + Title: title, }); err != nil { return err } // 成功レスポンス responseEmbed := discord.Embed{ - Title: "TTSボイスチャンネル接続", - Description: "ボイスチャンネルに参加しました。", - Color: config.Colors.Success, + 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(), From 3e9d1401ce730191fda5f07bf2859cea49be287e Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:43:03 +0900 Subject: [PATCH 07/20] Update src/internal/bot/handlers/interaction/registry.go Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- src/internal/bot/handlers/interaction/registry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/bot/handlers/interaction/registry.go b/src/internal/bot/handlers/interaction/registry.go index 261dd700..a474b865 100644 --- a/src/internal/bot/handlers/interaction/registry.go +++ b/src/internal/bot/handlers/interaction/registry.go @@ -67,6 +67,7 @@ func RegistHandler(r *handler.Mux, ctxData *internal.BotContext) { }) r.Route("/rss", func(r handler.Router) { r.Use(DeferReplyMiddleware(ctxData, true, false)) + r.SlashCommand("/subscribe", rss.Subscribe(ctxData)) }) // action row // select menu From 1034d6327c9f89196ffb1dc2a79e58c87f404a57 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:43:22 +0900 Subject: [PATCH 08/20] Update Dockerfile.rss_cron Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- Dockerfile.rss_cron | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron index ae7f7aa8..773bdc60 100644 --- a/Dockerfile.rss_cron +++ b/Dockerfile.rss_cron @@ -21,11 +21,8 @@ FROM alpine:3.24.0 WORKDIR /root/ RUN apk update && apk add --no-cache \ - opus \ - opus-dev \ - opusfile \ - opusfile-dev \ - ffmpeg + # rss_cronには音声機能が不要なため、最小限のランタイムのみ + ca-certificates ENV PKG_CONFIG_PATH=/root/.local/lib/pkgconfig ENV LD_LIBRARY_PATH=/root/.local/lib/ From fc1679cad6283846b43a0b61d5ac9125954027a8 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:44:07 +0900 Subject: [PATCH 09/20] =?UTF-8?q?=E4=B8=8D=E8=A6=81=E3=81=AA=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.rss_cron | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron index 773bdc60..230827e8 100644 --- a/Dockerfile.rss_cron +++ b/Dockerfile.rss_cron @@ -21,14 +21,8 @@ FROM alpine:3.24.0 WORKDIR /root/ RUN apk update && apk add --no-cache \ - # rss_cronには音声機能が不要なため、最小限のランタイムのみ ca-certificates -ENV PKG_CONFIG_PATH=/root/.local/lib/pkgconfig -ENV LD_LIBRARY_PATH=/root/.local/lib/ -COPY --from=builder /root/.local/ /root/.local/ -RUN chmod -R 755 /root/.local - COPY --from=builder /app/src/main . RUN chmod 777 ./main From e349e0d05130869663d55ef5b1ac0a07ad0692b6 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:44:20 +0900 Subject: [PATCH 10/20] =?UTF-8?q?import=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/internal/bot/handlers/interaction/registry.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/bot/handlers/interaction/registry.go b/src/internal/bot/handlers/interaction/registry.go index a474b865..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" From a6902a9ebec0e32cfb71738cfd3992e821682d44 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 21:58:46 +0900 Subject: [PATCH 11/20] =?UTF-8?q?=E5=88=9D=E5=9B=9Efetch=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E3=82=8A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cmd/rss_cron/main.go | 10 +++- .../command/general/rss/subscribe.go | 51 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/cmd/rss_cron/main.go b/src/cmd/rss_cron/main.go index f229fc02..d2cd8546 100644 --- a/src/cmd/rss_cron/main.go +++ b/src/cmd/rss_cron/main.go @@ -99,6 +99,7 @@ func Ready(db *gorm.DB, e *events.Ready) { 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] @@ -111,11 +112,18 @@ func Ready(db *gorm.DB, e *events.Ready) { }) var targetItems []*gofeed.Item for _, item := range feed.Items { - hash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", item.Title, item.Description))) + hash := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", item.Title, item.Description)) if hash == *rssSetting.LastItemTitleDescriptionHash { targetItems = append(targetItems, item) } } + hash := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", feed.Items[0].Title, feed.Items[0].Description)) + rssSetting.LastItemTitleDescriptionHash = &hash + err = repo.Update(rssSetting) + if err != nil { + log.Print("Update Record Failed", err) + } + // 古い日時がindex:0 slices.Reverse(targetItems) channelID := snowflake.MustParse(rssSetting.ChannelID) client := e.Client() diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index 358461f9..ede4c599 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -1,7 +1,9 @@ package rss import ( + "encoding/base64" "fmt" + "sort" "time" "unibot/internal" "unibot/internal/model" @@ -9,6 +11,7 @@ import ( "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" + "github.com/mmcdole/gofeed" ) func LoadSubscribeCommandContext() discord.ApplicationCommandOption { @@ -67,6 +70,43 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } } + // 初回Fetch + fp := gofeed.NewParser() + feed, err := fp.ParseURL(url) + if err != nil { + 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 + } + + if feed.Title != "" && title == nil { + title = &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 { + prevNano := prev.PublishedParsed.UnixNano() + nextNano := next.PublishedParsed.UnixNano() + return prevNano >= nextNano + } + return false + }) + hash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", feed.Items[0].Title, feed.Items[0].Description))) + db := ctx.DB guildRepo := repository.NewGuildRepository(db) if _, err := guildRepo.GetOrCreate(guildID.String()); err != nil { @@ -74,10 +114,11 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } rssRepo := repository.NewRSSSettingRepository(db) if err := rssRepo.Create(&model.RSSSetting{ - GuildID: guildID.String(), - ChannelID: e.Channel().ID().String(), - URL: url, - Title: title, + GuildID: guildID.String(), + ChannelID: e.Channel().ID().String(), + URL: url, + Title: title, + LastItemTitleDescriptionHash: &hash, }); err != nil { return err } @@ -102,7 +143,7 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti return &t }(), } - _, err := e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(false)) + _, err = e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(false)) return err } } From bc6d4b1aefe166720f1bb309e2a8f88324d2220a Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 22:18:59 +0900 Subject: [PATCH 12/20] miss --- src/cmd/rss_cron/main.go | 143 +++++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 67 deletions(-) diff --git a/src/cmd/rss_cron/main.go b/src/cmd/rss_cron/main.go index d2cd8546..6a8c44b8 100644 --- a/src/cmd/rss_cron/main.go +++ b/src/cmd/rss_cron/main.go @@ -80,79 +80,88 @@ func Ready(db *gorm.DB, e *events.Ready) { rssSubscribeList, err := repo.List() if err != nil { log.Fatal("An error occured:", err) - } else { - for _, rssSetting := range rssSubscribeList { - // fetch - url := rssSetting.URL - fp := gofeed.NewParser() - feed, err := fp.ParseURL(url) - if err != nil { - rssSetting.IsFailed = true - err := repo.Update(rssSetting) - if err != nil { - log.Print("Update Record Failed", err) - return - } - continue + return + } + for _, rssSetting := range rssSubscribeList { + url := rssSetting.URL + fp := gofeed.NewParser() + feed, err := fp.ParseURL(url) + if err != nil { + rssSetting.IsFailed = true + if err := repo.Update(rssSetting); err != nil { + log.Print("Update Record Failed", err) } - feedTitle := rssSetting.Title - if feedTitle == nil { - feedTitle = &feed.Title + 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() } - // 新しい日時が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 { - prevNano := prev.PublishedParsed.UnixNano() - nextNano := next.PublishedParsed.UnixNano() - return prevNano >= nextNano - } - return false - }) - var targetItems []*gofeed.Item - for _, item := range feed.Items { - hash := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", item.Title, item.Description)) - if hash == *rssSetting.LastItemTitleDescriptionHash { - targetItems = append(targetItems, item) - } + 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 // ここから先は既読 } - hash := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", feed.Items[0].Title, feed.Items[0].Description)) - rssSetting.LastItemTitleDescriptionHash = &hash - err = repo.Update(rssSetting) - if err != nil { - log.Print("Update Record Failed", err) + 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 = "(タイトルなし)" } - // 古い日時がindex:0 - 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 = "説明なし" - } else { - itemDescription = item.Content - } - } - itemLink := item.Link - if itemLink == "" { - itemLink = "リンクなし" - } - message := fmt.Sprintf(`# %s に新しい記事が追加されました! - ## %s - %s - URL: %s`, *feedTitle, itemTitle, itemDescription, itemLink) - _, err := client.Rest.CreateMessage(channelID, discord.NewMessageCreate().WithContent(message)) - if err != nil { - log.Print("Message create error:", err) + 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) } } } From 9507af85144abaf66eefbd907f2ef6c7be4b67d9 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 22:28:52 +0900 Subject: [PATCH 13/20] =?UTF-8?q?=E5=AE=89=E5=85=A8=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile.main | 2 +- Dockerfile.rss_cron | 9 ++++++++- src/go.mod | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Dockerfile.main b/Dockerfile.main index e359c5d0..3d02f00d 100644 --- a/Dockerfile.main +++ b/Dockerfile.main @@ -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 index 230827e8..ea01bca6 100644 --- a/Dockerfile.rss_cron +++ b/Dockerfile.rss_cron @@ -24,7 +24,14 @@ RUN apk update && apk add --no-cache \ ca-certificates COPY --from=builder /app/src/main . -RUN chmod 777 ./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 ["/root/main"] diff --git a/src/go.mod b/src/go.mod index e95f718a..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 From 1008af44add31dc6d165d5c481bed49ac67277c2 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Fri, 12 Jun 2026 22:52:58 +0900 Subject: [PATCH 14/20] miss --- .../command/general/rss/subscribe.go | 222 +++++++++++++++--- 1 file changed, 192 insertions(+), 30 deletions(-) diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index ede4c599..c26e505a 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -1,8 +1,15 @@ package rss import ( + "context" "encoding/base64" + "errors" "fmt" + "io" + "net" + "net/http" + "net/url" + neturl "net/url" "sort" "time" "unibot/internal" @@ -70,42 +77,106 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } } - // 初回Fetch + const ( + maxFeedSize = 10 * 1024 * 1024 // 10MB + maxRedirect = 5 + ) + + parsedURL, err := neturl.Parse(url) + if err != nil { + return errorSubscribeResponse(config, e) + } + + if err := validateURL(parsedURL); err != nil { + return errorSubscribeResponse(config, e) + } + + 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") + } + + // 全IP検査 + for _, ip := range ips { + if isPrivateIP(ip) { + return nil, fmt.Errorf("private ip detected: %s", ip) + } + } + + // 検査済みIPへ接続 + return dialer.DialContext( + ctx, + network, + net.JoinHostPort(ips[0].String(), port), + ) + }, + } + + httpClient := &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 := httpClient.Get(url) + if err != nil { + return errorSubscribeResponse(config, e) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errorSubscribeResponse(config, e) + } + limitedBody := io.LimitReader(resp.Body, 10<<20) fp := gofeed.NewParser() - feed, err := fp.ParseURL(url) + feed, err := fp.Parse(limitedBody) if err != nil { - 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 + return errorSubscribeResponse(config, e) } if feed.Title != "" && title == nil { title = &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 { - prevNano := prev.PublishedParsed.UnixNano() - nextNano := next.PublishedParsed.UnixNano() - return prevNano >= nextNano - } - return false - }) - hash := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", feed.Items[0].Title, feed.Items[0].Description))) + 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) @@ -118,7 +189,7 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti ChannelID: e.Channel().ID().String(), URL: url, Title: title, - LastItemTitleDescriptionHash: &hash, + LastItemTitleDescriptionHash: hash, }); err != nil { return err } @@ -147,3 +218,94 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti return err } } + +func isPrivateIP(ip net.IP) bool { + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + + privateCIDRs := []string{ + "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", + + "::1/128", + "fc00::/7", + "fe80::/10", + } + + for _, cidr := range privateCIDRs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + + if network.Contains(ip) { + return true + } + } + + return false +} + +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 +} + +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 +} From b75f0247dae721f5d5237b3370878821bd5ec380 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 08:24:50 +0900 Subject: [PATCH 15/20] =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=81=AB=E6=8B=AC=E3=82=8A=E5=87=BA=E3=81=97=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=84=E6=84=9F=E3=81=98=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cmd/rss_cron/main.go | 4 +- .../command/general/rss/subscribe.go | 153 +--------------- src/internal/util/rss_fetch.go | 168 ++++++++++++++++++ 3 files changed, 172 insertions(+), 153 deletions(-) create mode 100644 src/internal/util/rss_fetch.go diff --git a/src/cmd/rss_cron/main.go b/src/cmd/rss_cron/main.go index 6a8c44b8..f8033582 100644 --- a/src/cmd/rss_cron/main.go +++ b/src/cmd/rss_cron/main.go @@ -10,6 +10,7 @@ import ( "sort" "unibot/internal/db" "unibot/internal/repository" + "unibot/internal/util" "github.com/mmcdole/gofeed" @@ -84,8 +85,7 @@ func Ready(db *gorm.DB, e *events.Ready) { } for _, rssSetting := range rssSubscribeList { url := rssSetting.URL - fp := gofeed.NewParser() - feed, err := fp.ParseURL(url) + feed, err := util.FetchFeed(url) if err != nil { rssSetting.IsFailed = true if err := repo.Update(rssSetting); err != nil { diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index c26e505a..f268105a 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -1,24 +1,17 @@ package rss import ( - "context" "encoding/base64" - "errors" "fmt" - "io" - "net" - "net/http" - "net/url" - neturl "net/url" "sort" "time" "unibot/internal" "unibot/internal/model" "unibot/internal/repository" + "unibot/internal/util" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/handler" - "github.com/mmcdole/gofeed" ) func LoadSubscribeCommandContext() discord.ApplicationCommandOption { @@ -77,76 +70,7 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } } - const ( - maxFeedSize = 10 * 1024 * 1024 // 10MB - maxRedirect = 5 - ) - - parsedURL, err := neturl.Parse(url) - if err != nil { - return errorSubscribeResponse(config, e) - } - - if err := validateURL(parsedURL); err != nil { - return errorSubscribeResponse(config, e) - } - - 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") - } - - // 全IP検査 - for _, ip := range ips { - if isPrivateIP(ip) { - return nil, fmt.Errorf("private ip detected: %s", ip) - } - } - - // 検査済みIPへ接続 - return dialer.DialContext( - ctx, - network, - net.JoinHostPort(ips[0].String(), port), - ) - }, - } - - httpClient := &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 := httpClient.Get(url) - if err != nil { - return errorSubscribeResponse(config, e) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return errorSubscribeResponse(config, e) - } - limitedBody := io.LimitReader(resp.Body, 10<<20) - fp := gofeed.NewParser() - feed, err := fp.Parse(limitedBody) + feed, err := util.FetchFeed(url) if err != nil { return errorSubscribeResponse(config, e) } @@ -219,42 +143,6 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti } } -func isPrivateIP(ip net.IP) bool { - if ipv4 := ip.To4(); ipv4 != nil { - ip = ipv4 - } - - privateCIDRs := []string{ - "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", - - "::1/128", - "fc00::/7", - "fe80::/10", - } - - for _, cidr := range privateCIDRs { - _, network, err := net.ParseCIDR(cidr) - if err != nil { - continue - } - - if network.Contains(ip) { - return true - } - } - - return false -} - func errorSubscribeResponse(config *internal.Config, e *handler.CommandEvent) error { responseEmbed := discord.Embed{ Title: "RSS購読", @@ -272,40 +160,3 @@ func errorSubscribeResponse(config *internal.Config, e *handler.CommandEvent) er _, err := e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(true)) return err } - -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 -} diff --git a/src/internal/util/rss_fetch.go b/src/internal/util/rss_fetch.go new file mode 100644 index 00000000..0453495e --- /dev/null +++ b/src/internal/util/rss_fetch.go @@ -0,0 +1,168 @@ +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), + ) + }, + } + + 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{ + "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", + + "::1/128", + "fc00::/7", + "fe80::/10", + } + + for _, cidr := range privateCIDRs { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + + if network.Contains(ip) { + return true + } + } + + return false +} From 288e27e3619fafadba5fe4a9cd2c35f9f4777a63 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 08:29:00 +0900 Subject: [PATCH 16/20] =?UTF-8?q?=E3=82=BB=E3=82=AD=E3=83=A5=E3=83=AA?= =?UTF-8?q?=E3=83=86=E3=82=A3=E3=81=AB=E9=96=A2=E3=81=99=E3=82=8B=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bot/handlers/interaction/command/general/rss/subscribe.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go index f268105a..9f2da91f 100644 --- a/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go +++ b/src/internal/bot/handlers/interaction/command/general/rss/subscribe.go @@ -138,7 +138,7 @@ func Subscribe(ctx *internal.BotContext) func(data discord.SlashCommandInteracti return &t }(), } - _, err = e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(false)) + _, err = e.Client().Rest.CreateFollowupMessage(e.ApplicationID(), e.Token(), discord.NewMessageCreate().WithEmbeds(responseEmbed).WithEphemeral(true)) return err } } From efd3c0fbec01da8e24c7e7c9f248866bb3b8396d Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 08:35:12 +0900 Subject: [PATCH 17/20] Update Dockerfile.rss_cron Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Yuito Akatsuki (Tani Yutaka) --- Dockerfile.rss_cron | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.rss_cron b/Dockerfile.rss_cron index ea01bca6..5b5f4814 100644 --- a/Dockerfile.rss_cron +++ b/Dockerfile.rss_cron @@ -33,7 +33,7 @@ RUN mv /root/main /home/appuser/main && chown appuser:appuser ./main && chmod 75 USER appuser -CMD ["/root/main"] +CMD ["/home/appuser/main"] # OCI Metadata From c77204dc858377564c3283c9d7088f6598c5fff0 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 08:36:14 +0900 Subject: [PATCH 18/20] =?UTF-8?q?=E6=9C=AA=E6=8C=87=E5=AE=9A=E3=82=A2?= =?UTF-8?q?=E3=83=89=E3=83=AC=E3=82=B9=E3=81=A8=E3=83=9E=E3=83=AB=E3=83=81?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/internal/util/rss_fetch.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/internal/util/rss_fetch.go b/src/internal/util/rss_fetch.go index 0453495e..d833c46a 100644 --- a/src/internal/util/rss_fetch.go +++ b/src/internal/util/rss_fetch.go @@ -137,6 +137,7 @@ func isPrivateIP(ip net.IP) bool { } privateCIDRs := []string{ + "0.0.0.0/8", "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", @@ -148,8 +149,10 @@ func isPrivateIP(ip net.IP) bool { "224.0.0.0/4", "240.0.0.0/4", + "::/128", "::1/128", "fc00::/7", + "ff00::/8", "fe80::/10", } From 7de0735def6d5e91a937d0ce2e42567df7ec2c9b Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 08:36:58 +0900 Subject: [PATCH 19/20] =?UTF-8?q?transport=E3=82=92=E7=A2=BA=E5=AE=9F?= =?UTF-8?q?=E3=81=ABclose=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/internal/util/rss_fetch.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/internal/util/rss_fetch.go b/src/internal/util/rss_fetch.go index d833c46a..2a28bacd 100644 --- a/src/internal/util/rss_fetch.go +++ b/src/internal/util/rss_fetch.go @@ -59,6 +59,7 @@ func FetchFeed(feedURL string) (*gofeed.Feed, error) { ) }, } + defer transport.CloseIdleConnections() client := &http.Client{ Timeout: 10 * time.Second, From 80f270375d4c10f23755261dca04fd1f8fa9d9f1 Mon Sep 17 00:00:00 2001 From: "Yuito Akatsuki (Tani Yutaka)" Date: Wed, 17 Jun 2026 15:11:50 +0900 Subject: [PATCH 20/20] =?UTF-8?q?=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=81=AE=E8=AA=A4=E5=AD=97=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/internal/repository/guild.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/repository/guild.go b/src/internal/repository/guild.go index 8ca86000..9ca83032 100644 --- a/src/internal/repository/guild.go +++ b/src/internal/repository/guild.go @@ -37,7 +37,7 @@ 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 {