ci: migrate from Woodpecker to Forgejo Actions and modernize build setup #1
13 changed files with 183 additions and 835 deletions
58
.github/workflows/ci.yml
vendored
Normal file
58
.github/workflows/ci.yml
vendored
Normal 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
30
.github/workflows/release.yml
vendored
Normal 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 }}
|
||||
|
|
@ -50,46 +50,43 @@ archives:
|
|||
formats: ['zip']
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-amd64"
|
||||
- image_templates:
|
||||
- &amd64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-amd64"
|
||||
use: buildx
|
||||
dockerfile: &dockerfile Containerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
dockerfile: Containerfile
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/amd64"
|
||||
- image_templates:
|
||||
- &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64"
|
||||
- image_templates:
|
||||
- &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-arm64"
|
||||
use: buildx
|
||||
dockerfile: *dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
dockerfile: Containerfile
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/arm64"
|
||||
- image_templates:
|
||||
- &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7"
|
||||
- image_templates:
|
||||
- &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-armv7"
|
||||
use: buildx
|
||||
dockerfile: *dockerfile
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
dockerfile: Containerfile
|
||||
goos: linux
|
||||
goarch: arm
|
||||
goarm: "7"
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--platform=linux/arm/v7"
|
||||
|
||||
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 <me@fmartingr.com>
|
||||
|
|
@ -147,4 +144,7 @@ changelog:
|
|||
- "^chore\\(deps\\):"
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: fmartingr
|
||||
name: butterrobot
|
||||
prerelease: auto
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 <me@fmartingr.com>"
|
||||
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"]
|
||||
|
|
|
|||
117
Makefile
117
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 <me@fmartingr.com>"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 <game name>`
|
||||
|
||||
### Utility
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,9 +112,13 @@ 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"] != "" {
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue