ci: migrate from Woodpecker to Forgejo Actions and modernize build setup #1

Merged
fmartingr merged 4 commits from ci/migrate-to-forgejo-actions into master 2026-04-08 10:50:27 +02:00
13 changed files with 183 additions and 835 deletions

58
.github/workflows/ci.yml vendored Normal file
View file

@ -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

30
.github/workflows/release.yml vendored Normal file
View file

@ -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 }}

View file

@ -50,46 +50,43 @@ archives:
formats: ['zip'] formats: ['zip']
dockers: dockers:
- image_templates: - image_templates:
- &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64" - &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-amd64"
use: buildx use: buildx
dockerfile: &dockerfile Containerfile build_flag_templates:
- "--platform=linux/amd64"
dockerfile: Containerfile
goos: linux goos: linux
goarch: amd64 goarch: amd64
build_flag_templates: - image_templates:
- "--pull" - &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-arm64"
- "--platform=linux/amd64"
- image_templates:
- &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64"
use: buildx use: buildx
dockerfile: *dockerfile build_flag_templates:
- "--platform=linux/arm64"
dockerfile: Containerfile
goos: linux goos: linux
goarch: arm64 goarch: arm64
build_flag_templates: - image_templates:
- "--pull" - &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-armv7"
- "--platform=linux/arm64"
- image_templates:
- &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7"
use: buildx use: buildx
dockerfile: *dockerfile build_flag_templates:
- "--platform=linux/arm/v7"
dockerfile: Containerfile
goos: linux goos: linux
goarch: arm goarch: arm
goarm: "7" goarm: "7"
build_flag_templates:
- "--pull"
- "--platform=linux/arm/v7"
docker_manifests: 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: image_templates:
- *amd64_image - *amd64_image
- *arm64_image - *arm64_image
- *armv7_image - *armv7_image
# - name_template: "git.nakama.town/fmartingr/butterrobot:latest"
# image_templates:
# - *amd64_image
# - *arm64_image
# - *armv7_image
nfpms: nfpms:
- maintainer: Felipe Martin <me@fmartingr.com> - maintainer: Felipe Martin <me@fmartingr.com>
@ -147,4 +144,7 @@ changelog:
- "^chore\\(deps\\):" - "^chore\\(deps\\):"
release: release:
gitea:
owner: fmartingr
name: butterrobot
prerelease: auto prerelease: auto

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,17 @@
# This file is used directly by the goreleaser build FROM --platform=$BUILDPLATFORM alpine:3.23 AS base
# It is used to build the final container image 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 FROM scratch
WORKDIR / LABEL maintainer="Felipe Martin <me@fmartingr.com>"
COPY /butterrobot /usr/bin/butterrobot LABEL org.opencontainers.image.source="https://git.nakama.town/fmartingr/butterrobot"
ENTRYPOINT ["/usr/bin/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"]

117
Makefile
View file

@ -1,100 +1,57 @@
PROJECT_NAME := butterrobot 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 CGO_ENABLED := 0
BUILDS_PATH := ./dist DIST_PATH := ./dist
FROM_MAKEFILE := y TEST_OPTIONS := -v -failfast -race -cover
CONTAINERFILE_NAME := Containerfile GOLANGCI_LINT_VERSION := v1.64.5
CONTAINER_ALPINE_VERSION := 3.21
CONTAINER_SOURCE_URL := "https://git.nakama.town/fmartingr/${PROJECT_NAME}"
CONTAINER_MAINTAINER := "Felipe Martin <me@fmartingr.com>"
CONTAINER_BIN_NAME := ${PROJECT_NAME}
BUILDX_PLATFORMS := linux/amd64,linux/arm64,linux/arm/v7 .DEFAULT_GOAL := help
export PROJECT_NAME ## help: Display this help message
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
.PHONY: help .PHONY: help
help: ### this screen. Keep it first target to be default help:
ifeq ($(UNAME), Linux) @echo "Available targets:"
@grep -P '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \ @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' | 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
## build: Build project using goreleaser (snapshot)
.PHONY: build .PHONY: build
build: clean ### builds the project for the setup os/arch combinations build:
$(info: Make: Build) goreleaser build --snapshot --clean
@goreleaser --clean --snapshot
.PHONY: quick-run ## build-docker: Build Docker image locally via goreleaser
quick-run: ### Executes the project using golang .PHONY: build-docker
CGO_ENABLED=${CGO_ENABLED} go run ./cmd/${PROJECT_NAME}/*.go build-docker:
goreleaser release --snapshot --clean
## run: Run the server directly via go run
.PHONY: run .PHONY: run
run: ### Executes the project build locally run:
@make build CGO_ENABLED=$(CGO_ENABLED) go run ./cmd/$(PROJECT_NAME)
${BUILDS_PATH}/${PROJECT_NAME}
## format: Format code and tidy modules
.PHONY: format .PHONY: format
format: ### Executes the formatting pipeline on the project format:
$(info: Make: Format) go fmt ./...
@go fmt ./... go mod tidy
@go mod tidy
## ci-lint: Run golangci-lint (installs if missing)
.PHONY: ci-lint .PHONY: ci-lint
ci-lint: ### Check the project for errors ci-lint:
$(info: Make: Lint) @which golangci-lint > /dev/null 2>&1 || go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
@go install github.com/golangci/golangci-lint/cmd/golangci-lint@${GOLANGCI_LINT_VERSION} golangci-lint run ./...
@golangci-lint run ./...
## lint: Run linters
.PHONY: lint .PHONY: lint
lint: ### Check the project for errors lint: ci-lint
$(info: Make: Lint)
@golangci-lint run ./...
## test: Run tests with coverage
.PHONY: test .PHONY: test
test: ### Runs the test suite test:
$(info: Make: Test) CGO_ENABLED=1 go test $(TEST_OPTIONS) -timeout 1m ./...
CGO_ENABLED=1 go test ${TEST_OPTIONS} -timeout=${TEST_TIMEOUT} ${SOURCE_FILES}
## clean: Remove build artifacts
.PHONY: clean
clean:
rm -rf $(DIST_PATH)
go clean -cache

View file

@ -9,7 +9,6 @@
- Lo quito: What happens when you say _"lo quito"_...? (Spanish pun) - Lo quito: What happens when you say _"lo quito"_...? (Spanish pun)
- Dice: Put `!dice` and wathever roll you want to perform. - Dice: Put `!dice` and wathever roll you want to perform.
- Coin: Flip a coin and get heads or tails. - Coin: Flip a coin and get heads or tails.
- How Long To Beat: Get game completion times from HowLongToBeat.com using `!hltb <game name>`
### Utility ### Utility

View file

@ -89,7 +89,6 @@ func (a *App) Run() error {
plugin.Register(fun.NewCoin()) plugin.Register(fun.NewCoin())
plugin.Register(fun.NewDice()) plugin.Register(fun.NewDice())
plugin.Register(fun.NewLoquito()) plugin.Register(fun.NewLoquito())
plugin.Register(fun.NewHLTB())
plugin.Register(social.NewTwitterExpander()) plugin.Register(social.NewTwitterExpander())
plugin.Register(social.NewInstagramExpander()) plugin.Register(social.NewInstagramExpander())
plugin.Register(reminder.New(a.db)) plugin.Register(reminder.New(a.db))

View file

@ -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 <game name>`",
},
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 <game name>")
}
// 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}
}

View file

@ -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)
}
})
}
}

View file

@ -134,12 +134,12 @@ func (p *HelpPlugin) OnMessage(msg *model.Message, config map[string]interface{}
return pluginList[i].GetName() < pluginList[j].GetName() return pluginList[i].GetName() < pluginList[j].GetName()
}) })
helpText.WriteString(fmt.Sprintf("**%s:**\n", categoryName)) fmt.Fprintf(&helpText, "**%s:**\n", categoryName)
for _, p := range pluginList { for _, p := range pluginList {
if p.GetHelp() == "" { if p.GetHelp() == "" {
continue 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") helpText.WriteString("\n")
} }

View file

@ -112,9 +112,13 @@ func TestTwitterExpander_OnMessage(t *testing.T) {
t.Errorf("Expected ReplyTo '%s', got '%s'", msg.ID, action.Message.ReplyTo) t.Errorf("Expected ReplyTo '%s', got '%s'", msg.ID, action.Message.ReplyTo)
} }
if action.Message.Raw == nil || action.Message.Raw["parse_mode"] != "" { // 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") t.Error("Expected parse_mode to be empty string to disable markdown parsing")
} }
}
}) })
} }
} }