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 }}
|
||||||
|
|
@ -51,45 +51,42 @@ archives:
|
||||||
|
|
||||||
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:
|
|
||||||
- "--pull"
|
|
||||||
- "--platform=linux/amd64"
|
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-arm64"
|
- &arm64_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-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:
|
|
||||||
- "--pull"
|
|
||||||
- "--platform=linux/arm64"
|
|
||||||
- image_templates:
|
- image_templates:
|
||||||
- &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Version }}-armv7"
|
- &armv7_image "git.nakama.town/fmartingr/butterrobot:{{ .Tag }}-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
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
117
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue