diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb40900 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + format: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make format + - run: git diff --exit-code + + goreleaser-lint: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + - uses: actions/goreleaser-action@v6.4.0 + with: + args: check + + lint: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make ci-lint + + test: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make test + + build: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1f41606 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: Release + +on: + push: + tags: ["v*"] + +jobs: + release: + runs-on: docker + container: git.nakama.town/fmartingr/ci-images/ci-base:1.0.0 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Install Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Login to container registry + run: echo "${{ secrets.FORGEJO_TOKEN }}" | docker login git.nakama.town -u ${{ github.actor }} --password-stdin + + - name: Run GoReleaser + uses: actions/goreleaser-action@v6.4.0 + with: + args: release --clean + env: + GORELEASER_FORCE_TOKEN: gitea + GITEA_TOKEN: ${{ secrets.FORGEJO_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index a3836e9..7ce5676 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -50,46 +50,43 @@ archives: formats: ['zip'] dockers: -- image_templates: - - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64" - use: buildx - dockerfile: &dockerfile Containerfile - goos: linux - goarch: amd64 - build_flag_templates: - - "--pull" - - "--platform=linux/amd64" -- image_templates: - - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64" - use: buildx - dockerfile: *dockerfile - goos: linux - goarch: arm64 - build_flag_templates: - - "--pull" - - "--platform=linux/arm64" -- image_templates: - - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7" - use: buildx - dockerfile: *dockerfile - goos: linux - goarch: arm - goarm: "7" - build_flag_templates: - - "--pull" - - "--platform=linux/arm/v7" + - image_templates: + - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-amd64" + use: buildx + build_flag_templates: + - "--platform=linux/amd64" + dockerfile: Containerfile + goos: linux + goarch: amd64 + - image_templates: + - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-arm64" + use: buildx + build_flag_templates: + - "--platform=linux/arm64" + dockerfile: Containerfile + goos: linux + goarch: arm64 + - image_templates: + - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-armv7" + use: buildx + build_flag_templates: + - "--platform=linux/arm/v7" + dockerfile: Containerfile + goos: linux + goarch: arm + goarm: "7" docker_manifests: - - name_template: "git.nakama.town/fmartingr/butterrobot:{{ .Version }}" + - name_template: "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}" + image_templates: + - *amd64_image + - *arm64_image + - *armv7_image + - name_template: "git.nakama.town/fmartingr/butterrobot:latest" image_templates: - *amd64_image - *arm64_image - *armv7_image - # - name_template: "git.nakama.town/fmartingr/butterrobot:latest" - # image_templates: - # - *amd64_image - # - *arm64_image - # - *armv7_image nfpms: - maintainer: Felipe Martin @@ -147,4 +144,7 @@ changelog: - "^chore\\(deps\\):" release: + gitea: + owner: fmartingr + name: butterrobot prerelease: auto diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml deleted file mode 100644 index 5b32d48..0000000 --- a/.woodpecker/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -when: - event: - - push - - pull_request - branch: - - master - -steps: - format: - image: golang:1.24 - commands: - - make format - - git diff --exit-code # Fail if files were changed - - lint: - image: golang:1.24 - commands: - - make ci-lint - - test: - image: golang:1.24 - commands: - - make test diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml deleted file mode 100644 index 39dbf65..0000000 --- a/.woodpecker/release.yml +++ /dev/null @@ -1,16 +0,0 @@ -when: - - event: tag - branch: master - -steps: - - name: Release - image: goreleaser/goreleaser:latest - environment: - GITEA_TOKEN: - from_secret: GITEA_TOKEN - DOCKER_HOST: unix:///var/run/docker.sock - volumes: - - "/var/run/docker.sock:/var/run/docker.sock" - commands: - - docker login -u fmartingr -p $GITEA_TOKEN git.nakama.town - - goreleaser release --clean --parallelism=2 diff --git a/Containerfile b/Containerfile index 6a9ff7d..dd4b64a 100644 --- a/Containerfile +++ b/Containerfile @@ -1,6 +1,17 @@ -# This file is used directly by the goreleaser build -# It is used to build the final container image +FROM --platform=$BUILDPLATFORM alpine:3.23 AS base +RUN apk add --no-cache ca-certificates tzdata +RUN addgroup -g 1000 butterrobot && adduser -u 1000 -G butterrobot -s /bin/sh -D butterrobot + FROM scratch -WORKDIR / -COPY /butterrobot /usr/bin/butterrobot -ENTRYPOINT ["/usr/bin/butterrobot"] +LABEL maintainer="Felipe Martin " +LABEL org.opencontainers.image.source="https://git.nakama.town/fmartingr/butterrobot" + +COPY --from=base /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=base /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=base /etc/passwd /etc/passwd +COPY --from=base /etc/group /etc/group +COPY butterrobot /usr/local/bin/butterrobot + +USER butterrobot + +ENTRYPOINT ["/usr/local/bin/butterrobot"] diff --git a/Makefile b/Makefile index ff08163..ac6d025 100644 --- a/Makefile +++ b/Makefile @@ -1,100 +1,57 @@ PROJECT_NAME := butterrobot - -SOURCE_FILES ?=./... - -TEST_OPTIONS ?= -v -failfast -race -bench=. -benchtime=100000x -cover -coverprofile=coverage.out -TEST_TIMEOUT ?=1m - -GOLANGCI_LINT_VERSION ?= v1.64.5 - -CLEAN_OPTIONS ?=-modcache -testcache - CGO_ENABLED := 0 -BUILDS_PATH := ./dist -FROM_MAKEFILE := y +DIST_PATH := ./dist +TEST_OPTIONS := -v -failfast -race -cover -CONTAINERFILE_NAME := Containerfile -CONTAINER_ALPINE_VERSION := 3.21 -CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}" -CONTAINER_MAINTAINER := "Felipe Martin " -CONTAINER_BIN_NAME := ${PROJECT_NAME} +GOLANGCI_LINT_VERSION := v1.64.5 -BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 +.DEFAULT_GOAL := help -export PROJECT_NAME -export FROM_MAKEFILE - -export CGO_ENABLED - -export SOURCE_FILES -export TEST_OPTIONS -export TEST_TIMEOUT -export BUILDS_PATH - -export CONTAINERFILE_NAME -export CONTAINER_ALPINE_VERSION -export CONTAINER_SOURCE_URL -export CONTAINER_MAINTAINER -export CONTAINER_BIN_NAME - -export BUILDX_PLATFORMS - -.PHONY: all -all: help - -# this is godly -# https://news.ycombinator.com/item?id=11939200 +## help: Display this help message .PHONY: help -help: ### this screen. Keep it first target to be default -ifeq ($(UNAME), Linux) - @grep -P '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ - awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' -else - @# this is not tested, but prepared in advance for you, Mac drivers - @awk -F ':.*###' '$$0 ~ FS {printf "%15s%s\n", $$1 ":", $$2}' \ - $(MAKEFILE_LIST) | grep -v '@awk' | sort -endif - -.PHONY: clean -clean: ### clean test cache, build files - $(info: Make: Clean) - @rm -rf ${BUILDS_PATH} - @go clean ${CLEAN_OPTIONS} - @-docker buildx rm ${PROJECT_NAME}_builder +help: + @echo "Available targets:" + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' | sort +## build: Build project using goreleaser (snapshot) .PHONY: build -build: clean ### builds the project for the setup os/arch combinations - $(info: Make: Build) - @goreleaser --clean --snapshot +build: + goreleaser build --snapshot --clean -.PHONY: quick-run -quick-run: ### Executes the project using golang - CGO_ENABLED=${CGO_ENABLED} go run ./cmd/${PROJECT_NAME}/*.go +## build-docker: Build Docker image locally via goreleaser +.PHONY: build-docker +build-docker: + goreleaser release --snapshot --clean +## run: Run the server directly via go run .PHONY: run -run: ### Executes the project build locally - @make build - ${BUILDS_PATH}/${PROJECT_NAME} +run: + CGO_ENABLED=$(CGO_ENABLED) go run ./cmd/$(PROJECT_NAME) +## format: Format code and tidy modules .PHONY: format -format: ### Executes the formatting pipeline on the project - $(info: Make: Format) - @go fmt ./... - @go mod tidy +format: + go fmt ./... + go mod tidy +## ci-lint: Run golangci-lint (installs if missing) .PHONY: ci-lint -ci-lint: ### Check the project for errors - $(info: Make: Lint) - @go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} - @golangci-lint run ./... +ci-lint: + @which golangci-lint > /dev/null 2>&1 || go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + golangci-lint run ./... +## lint: Run linters .PHONY: lint -lint: ### Check the project for errors - $(info: Make: Lint) - @golangci-lint run ./... +lint: ci-lint +## test: Run tests with coverage .PHONY: test -test: ### Runs the test suite - $(info: Make: Test) - CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES} +test: + CGO_ENABLED=1 go test $(TEST_OPTIONS) -timeout 1m ./... + +## clean: Remove build artifacts +.PHONY: clean +clean: + rm -rf $(DIST_PATH) + go clean -cache diff --git a/docs/plugins.md b/docs/plugins.md index 3472a08..e09d36f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -9,7 +9,6 @@ - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Dice: Put `!dice` and wathever roll you want to perform. - Coin: Flip a coin and get heads or tails. -- How Long To Beat: Get game completion times from HowLongToBeat.com using `!hltb ` ### Utility diff --git a/internal/app/app.go b/internal/app/app.go index 037089f..4f68979 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -89,7 +89,6 @@ func (a *App) Run() error { plugin.Register(fun.NewCoin()) plugin.Register(fun.NewDice()) plugin.Register(fun.NewLoquito()) - plugin.Register(fun.NewHLTB()) plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewInstagramExpander()) plugin.Register(reminder.New(a.db)) diff --git a/internal/plugin/fun/hltb.go b/internal/plugin/fun/hltb.go deleted file mode 100644 index 4c92052..0000000 --- a/internal/plugin/fun/hltb.go +++ /dev/null @@ -1,540 +0,0 @@ -package fun - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "regexp" - "strings" - "time" - - "git.nakama.town/fmartingr/butterrobot/internal/model" - "git.nakama.town/fmartingr/butterrobot/internal/plugin" -) - -// HLTBPlugin searches HowLongToBeat for game completion times -type HLTBPlugin struct { - plugin.BasePlugin - httpClient *http.Client -} - -// HLTBGame represents a game from HowLongToBeat -type HLTBGame struct { - ID int `json:"game_id"` - Name string `json:"game_name"` - GameAlias string `json:"game_alias"` - GameImage string `json:"game_image"` - CompMain int `json:"comp_main"` - CompPlus int `json:"comp_plus"` - CompComplete int `json:"comp_complete"` - CompAll int `json:"comp_all"` - InvestedCo int `json:"invested_co"` - InvestedMp int `json:"invested_mp"` - CountComp int `json:"count_comp"` - CountSpeedruns int `json:"count_speedruns"` - CountBacklog int `json:"count_backlog"` - CountReview int `json:"count_review"` - ReviewScore int `json:"review_score"` - CountPlaying int `json:"count_playing"` - CountRetired int `json:"count_retired"` -} - -// NewHLTB creates a new HLTBPlugin instance -func NewHLTB() *HLTBPlugin { - return &HLTBPlugin{ - BasePlugin: plugin.BasePlugin{ - ID: "fun.hltb", - Name: "How Long To Beat", - Help: "Get game completion times from HowLongToBeat.com using `!hltb `", - }, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// OnMessage handles incoming messages -func (p *HLTBPlugin) OnMessage(msg *model.Message, config map[string]interface{}, cache model.CacheInterface) []*model.MessageAction { - // Check if message starts with !hltb - text := strings.TrimSpace(msg.Text) - if !strings.HasPrefix(text, "!hltb ") { - return nil - } - - // Extract game name - gameName := strings.TrimSpace(text[6:]) // Remove "!hltb " - if gameName == "" { - return p.createErrorResponse(msg, "Please provide a game name. Usage: !hltb ") - } - - // Check cache first - var games []HLTBGame - var err error - cacheKey := strings.ToLower(gameName) - - err = cache.Get(cacheKey, &games) - if err != nil || len(games) == 0 { - // Cache miss - search for the game - games, err = p.searchGame(gameName) - if err != nil { - return p.createErrorResponse(msg, fmt.Sprintf("Error searching for game: %s", err.Error())) - } - - if len(games) == 0 { - return p.createErrorResponse(msg, fmt.Sprintf("No results found for '%s'", gameName)) - } - - // Cache the results for 1 hour - err = cache.SetWithTTL(cacheKey, games, time.Hour) - if err != nil { - // Log cache error but don't fail the request - fmt.Printf("Warning: Failed to cache HLTB results: %v\n", err) - } - } - - // Use the first result - game := games[0] - - // Format the response - response := p.formatGameInfo(game) - - // Create response message with game cover if available - responseMsg := &model.Message{ - Text: response, - Chat: msg.Chat, - ReplyTo: msg.ID, - Channel: msg.Channel, - } - - // Set parse mode for markdown formatting - if responseMsg.Raw == nil { - responseMsg.Raw = make(map[string]interface{}) - } - responseMsg.Raw["parse_mode"] = "Markdown" - - // Add game cover as attachment if available - if game.GameImage != "" { - imageURL := p.getFullImageURL(game.GameImage) - responseMsg.Raw["image_url"] = imageURL - } - - action := &model.MessageAction{ - Type: model.ActionSendMessage, - Message: responseMsg, - Chat: msg.Chat, - Channel: msg.Channel, - } - - return []*model.MessageAction{action} -} - -// searchGame searches for a game on HowLongToBeat using the API -func (p *HLTBPlugin) searchGame(gameName string) ([]HLTBGame, error) { - // Only the seek token endpoint works now - return p.searchWithSeekToken(gameName) -} - -// searchWithSeekToken attempts to search using the seek token approach -func (p *HLTBPlugin) searchWithSeekToken(gameName string) ([]HLTBGame, error) { - // Get the seek token from the main page - seekToken, err := p.getSeekToken() - if err != nil { - return nil, fmt.Errorf("failed to get seek token: %w", err) - } - - // Split search terms by words - searchTerms := strings.Fields(gameName) - - // Create search URL with seek token - searchURL := fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", seekToken) - - // Prepare search request - searchRequest := map[string]interface{}{ - "searchType": "games", - "searchTerms": searchTerms, - "searchPage": 1, - "size": 20, - "searchOptions": map[string]interface{}{ - "games": map[string]interface{}{ - "userId": 0, - "platform": "", - "sortCategory": "popular", - "rangeCategory": "main", - "rangeTime": map[string]interface{}{ - "min": nil, - "max": nil, - }, - "gameplay": map[string]interface{}{ - "perspective": "", - "flow": "", - "genre": "", - "difficulty": "", - }, - "rangeYear": map[string]interface{}{ - "min": "", - "max": "", - }, - "modifier": "", - }, - "users": map[string]interface{}{ - "sortCategory": "postcount", - }, - "lists": map[string]interface{}{ - "sortCategory": "follows", - }, - "filter": "", - "sort": 0, - "randomizer": 0, - }, - "useCache": true, - } - - return p.performAPISearch(searchURL, searchRequest) -} - -// performAPISearch performs the actual API search request -func (p *HLTBPlugin) performAPISearch(searchURL string, searchRequest map[string]interface{}) ([]HLTBGame, error) { - // Convert to JSON - jsonData, err := json.Marshal(searchRequest) - if err != nil { - return nil, fmt.Errorf("failed to marshal search request: %w", err) - } - - // Create HTTP request - req, err := http.NewRequest("POST", searchURL, bytes.NewBuffer(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers to match the working curl request - req.Header.Set("Accept", "*/*") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Cache-Control", "no-cache") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", "https://howlongtobeat.com") - req.Header.Set("Pragma", "no-cache") - req.Header.Set("Referer", "https://howlongtobeat.com/") - req.Header.Set("Sec-Fetch-Dest", "empty") - req.Header.Set("Sec-Fetch-Mode", "cors") - req.Header.Set("Sec-Fetch-Site", "same-origin") - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") - - // Send request - resp, err := p.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) - } - - // Read response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Parse response - var searchResponse struct { - Color string `json:"color"` - Title string `json:"title"` - Category string `json:"category"` - Count int `json:"count"` - Pagecurrent int `json:"pagecurrent"` - Pagesize int `json:"pagesize"` - Pagetotal int `json:"pagetotal"` - SearchTerm string `json:"searchTerm"` - SearchResults []HLTBGame `json:"data"` - } - - if err := json.Unmarshal(body, &searchResponse); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - return searchResponse.SearchResults, nil -} - -// formatGameInfo formats game information for display -func (p *HLTBPlugin) formatGameInfo(game HLTBGame) string { - var response strings.Builder - - response.WriteString(fmt.Sprintf("šŸŽ® **%s**\n\n", game.Name)) - - // Format completion times - if game.CompMain > 0 { - response.WriteString(fmt.Sprintf("šŸ“– **Main Story:** %s\n", p.formatTime(game.CompMain))) - } - - if game.CompPlus > 0 { - response.WriteString(fmt.Sprintf("āž• **Main + Extras:** %s\n", p.formatTime(game.CompPlus))) - } - - if game.CompComplete > 0 { - response.WriteString(fmt.Sprintf("šŸ’Æ **Completionist:** %s\n", p.formatTime(game.CompComplete))) - } - - if game.CompAll > 0 { - response.WriteString(fmt.Sprintf("šŸŽÆ **All Styles:** %s\n", p.formatTime(game.CompAll))) - } - - // Add review score if available - if game.ReviewScore > 0 { - response.WriteString(fmt.Sprintf("\n⭐ **User Score:** %d/100", game.ReviewScore)) - } - - // Add source attribution - response.WriteString("\n\n*Source: HowLongToBeat.com*") - - return response.String() -} - -// formatTime converts seconds to a readable time format -func (p *HLTBPlugin) formatTime(seconds int) string { - if seconds <= 0 { - return "N/A" - } - - hours := float64(seconds) / 3600.0 - - if hours < 1 { - minutes := seconds / 60 - return fmt.Sprintf("%d minutes", minutes) - } else if hours < 2 { - return fmt.Sprintf("%.1f hour", hours) - } else { - return fmt.Sprintf("%.1f hours", hours) - } -} - -// getFullImageURL constructs the full image URL -func (p *HLTBPlugin) getFullImageURL(imagePath string) string { - if imagePath == "" { - return "" - } - - // Remove leading slash if present - imagePath = strings.TrimPrefix(imagePath, "/") - - return fmt.Sprintf("https://howlongtobeat.com/games/%s", imagePath) -} - -// getSeekToken retrieves the seek token from HowLongToBeat -func (p *HLTBPlugin) getSeekToken() (string, error) { - // Get the main page to extract buildId - req, err := http.NewRequest("GET", "https://howlongtobeat.com", nil) - if err != nil { - return "", fmt.Errorf("failed to create token request: %w", err) - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") - - resp, err := p.httpClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch token: %w", err) - } - defer func() { - _ = resp.Body.Close() - }() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read token response: %w", err) - } - - bodyStr := string(body) - - // First, try to find buildId in the __NEXT_DATA__ or page source - buildIdPatterns := []string{ - `"buildId":"([a-zA-Z0-9_-]+)"`, - `buildId":"([a-zA-Z0-9_-]+)"`, - `/_next/static/([a-zA-Z0-9_-]+)/_buildManifest`, - } - - for _, pattern := range buildIdPatterns { - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(bodyStr) - if len(matches) > 1 { - buildId := matches[1] - // Now try to get the seek token from the JavaScript files using buildId - if token, err := p.getSeekTokenFromBuildId(buildId); err == nil { - return token, nil - } - } - } - - // If we can't find buildId, look for direct seek token patterns - seekPatterns := []string{ - `/api/seek/([a-f0-9]{16})`, - `"seek/([a-f0-9]{16})"`, - `api/seek/([a-f0-9]{16})`, - `seek/([a-f0-9]{12,})`, - } - - for _, pattern := range seekPatterns { - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(bodyStr) - if len(matches) > 1 { - return matches[1], nil - } - } - - // Last resort: try multiple known working tokens - knownTokens := []string{ - "6e17f7a193ef3188", // From your curl example - "d4b2e330db04dbf3", // Common fallback - } - - for _, token := range knownTokens { - if p.testSeekToken(token) { - return token, nil - } - } - - // Generate a token as last resort - return p.generateSeekToken(), nil -} - -// getSeekTokenFromBuildId attempts to extract seek token from build-specific files -func (p *HLTBPlugin) getSeekTokenFromBuildId(buildId string) (string, error) { - // Common build file patterns where seek tokens might be stored - fileURLs := []string{ - fmt.Sprintf("https://howlongtobeat.com/_next/static/%s/_buildManifest.js", buildId), - fmt.Sprintf("https://howlongtobeat.com/_next/static/%s/_ssgManifest.js", buildId), - fmt.Sprintf("https://howlongtobeat.com/_next/static/chunks/pages/index-%s.js", buildId[:12]), - } - - for _, fileURL := range fileURLs { - if token, err := p.extractSeekTokenFromFile(fileURL); err == nil && token != "" { - return token, nil - } - } - - return "", fmt.Errorf("no seek token found in build files") -} - -// extractSeekTokenFromFile downloads and searches a file for seek token -func (p *HLTBPlugin) extractSeekTokenFromFile(fileURL string) (string, error) { - req, err := http.NewRequest("GET", fileURL, nil) - if err != nil { - return "", err - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36") - - resp, err := p.httpClient.Do(req) - if err != nil { - return "", err - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to fetch file: %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - bodyStr := string(body) - patterns := []string{ - `seek/([a-f0-9]{16})`, - `"([a-f0-9]{16})"`, - `'([a-f0-9]{16})'`, - } - - for _, pattern := range patterns { - re := regexp.MustCompile(pattern) - matches := re.FindStringSubmatch(bodyStr) - if len(matches) > 1 { - return matches[1], nil - } - } - - return "", fmt.Errorf("no seek token found in file") -} - -// testSeekToken tests if a seek token works by making a simple API call -func (p *HLTBPlugin) testSeekToken(token string) bool { - searchURL := fmt.Sprintf("https://howlongtobeat.com/api/seek/%s", token) - searchRequest := map[string]interface{}{ - "searchType": "games", - "searchTerms": []string{"test"}, - "searchPage": 1, - "size": 1, - "searchOptions": map[string]interface{}{ - "games": map[string]interface{}{ - "userId": 0, - "platform": "", - "sortCategory": "popular", - "rangeCategory": "main", - "rangeTime": map[string]interface{}{ - "min": nil, - "max": nil, - }, - "gameplay": map[string]interface{}{ - "perspective": "", - "flow": "", - "genre": "", - "difficulty": "", - }, - "rangeYear": map[string]interface{}{ - "min": "", - "max": "", - }, - "modifier": "", - }, - "users": map[string]interface{}{ - "sortCategory": "postcount", - }, - "lists": map[string]interface{}{ - "sortCategory": "follows", - }, - "filter": "", - "sort": 0, - "randomizer": 0, - }, - "useCache": true, - } - - // Test the token with a simple search - if _, err := p.performAPISearch(searchURL, searchRequest); err == nil { - return true - } - return false -} - -// generateSeekToken generates a seek token based on current time -func (p *HLTBPlugin) generateSeekToken() string { - // Use a simple hash-like approach with current timestamp - // This is a fallback approach since the real token generation is unknown - now := time.Now().Unix() - return fmt.Sprintf("%x", now%0xffffffff)[:16] -} - -// createErrorResponse creates an error response message -func (p *HLTBPlugin) createErrorResponse(msg *model.Message, errorText string) []*model.MessageAction { - response := &model.Message{ - Text: fmt.Sprintf("āŒ %s", errorText), - Chat: msg.Chat, - ReplyTo: msg.ID, - Channel: msg.Channel, - } - - action := &model.MessageAction{ - Type: model.ActionSendMessage, - Message: response, - Chat: msg.Chat, - Channel: msg.Channel, - } - - return []*model.MessageAction{action} -} diff --git a/internal/plugin/fun/hltb_test.go b/internal/plugin/fun/hltb_test.go deleted file mode 100644 index 62810e3..0000000 --- a/internal/plugin/fun/hltb_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package fun - -import ( - "testing" - - "git.nakama.town/fmartingr/butterrobot/internal/model" - "git.nakama.town/fmartingr/butterrobot/internal/testutil" -) - -func TestHLTBPlugin_OnMessage(t *testing.T) { - plugin := NewHLTB() - - tests := []struct { - name string - messageText string - shouldRespond bool - }{ - { - name: "responds to !hltb command", - messageText: "!hltb The Witcher 3", - shouldRespond: true, - }, - { - name: "ignores non-hltb messages", - messageText: "hello world", - shouldRespond: false, - }, - { - name: "ignores !hltb without game name", - messageText: "!hltb", - shouldRespond: false, - }, - { - name: "ignores !hltb with only spaces", - messageText: "!hltb ", - shouldRespond: false, - }, - { - name: "ignores similar but incorrect commands", - messageText: "hltb The Witcher 3", - shouldRespond: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := &model.Message{ - Text: tt.messageText, - Chat: "test-chat", - Channel: &model.Channel{ID: 1}, - Author: "test-user", - } - - mockCache := &testutil.MockCache{} - actions := plugin.OnMessage(msg, make(map[string]interface{}), mockCache) - - if tt.shouldRespond && len(actions) == 0 { - t.Errorf("Expected plugin to respond to '%s', but it didn't", tt.messageText) - } - - if !tt.shouldRespond && len(actions) > 0 { - t.Errorf("Expected plugin to not respond to '%s', but it did", tt.messageText) - } - - // For messages that should respond, verify the response structure - if tt.shouldRespond && len(actions) > 0 { - action := actions[0] - if action.Type != model.ActionSendMessage { - t.Errorf("Expected ActionSendMessage, got %s", action.Type) - } - - if action.Message == nil { - t.Error("Expected action to have a message") - } - - if action.Message != nil && action.Message.ReplyTo != msg.ID { - t.Error("Expected response to reply to original message") - } - } - }) - } -} - -func TestHLTBPlugin_formatTime(t *testing.T) { - plugin := NewHLTB() - - tests := []struct { - seconds int - expected string - }{ - {0, "N/A"}, - {-1, "N/A"}, - {1800, "30 minutes"}, // 30 minutes - {3600, "1.0 hour"}, // 1 hour - {7200, "2.0 hours"}, // 2 hours - {10800, "3.0 hours"}, // 3 hours - {36000, "10.0 hours"}, // 10 hours - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - result := plugin.formatTime(tt.seconds) - if result != tt.expected { - t.Errorf("formatTime(%d) = %s, want %s", tt.seconds, result, tt.expected) - } - }) - } -} - -func TestHLTBPlugin_getFullImageURL(t *testing.T) { - plugin := NewHLTB() - - tests := []struct { - imagePath string - expected string - }{ - {"", ""}, - {"game.jpg", "https://howlongtobeat.com/games/game.jpg"}, - {"/game.jpg", "https://howlongtobeat.com/games/game.jpg"}, - {"folder/game.png", "https://howlongtobeat.com/games/folder/game.png"}, - } - - for _, tt := range tests { - t.Run(tt.imagePath, func(t *testing.T) { - result := plugin.getFullImageURL(tt.imagePath) - if result != tt.expected { - t.Errorf("getFullImageURL(%s) = %s, want %s", tt.imagePath, result, tt.expected) - } - }) - } -} diff --git a/internal/plugin/help/help.go b/internal/plugin/help/help.go index 4e6215a..381a95e 100644 --- a/internal/plugin/help/help.go +++ b/internal/plugin/help/help.go @@ -134,12 +134,12 @@ func (p *HelpPlugin) OnMessage(msg *model.Message, config map[string]interface{} return pluginList[i].GetName() < pluginList[j].GetName() }) - helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName)) + fmt.Fprintf(&helpText, "**%s:**\n", categoryName) for _, p := range pluginList { if p.GetHelp() == "" { continue } - helpText.WriteString(fmt.Sprintf("• **%s** - %s\n", p.GetName(), p.GetHelp())) + fmt.Fprintf(&helpText, "• **%s** - %s\n", p.GetName(), p.GetHelp()) } helpText.WriteString("\n") } diff --git a/internal/plugin/social/twitter_test.go b/internal/plugin/social/twitter_test.go index c0e1681..3fa4699 100644 --- a/internal/plugin/social/twitter_test.go +++ b/internal/plugin/social/twitter_test.go @@ -112,8 +112,12 @@ func TestTwitterExpander_OnMessage(t *testing.T) { t.Errorf("Expected ReplyTo '%s', got '%s'", msg.ID, action.Message.ReplyTo) } - if action.Message.Raw == nil || action.Message.Raw["parse_mode"] != "" { - t.Error("Expected parse_mode to be empty string to disable markdown parsing") + // If Raw is set, parse_mode should be empty string to disable markdown parsing. + // If Raw is nil, that's fine — it will default to empty string in the platform. + if action.Message.Raw != nil { + if parseMode, exists := action.Message.Raw["parse_mode"]; exists && parseMode != "" { + t.Error("Expected parse_mode to be empty string to disable markdown parsing") + } } }) }