Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c087b1b
docs: add CLAUDE.md with style, test and commit conventions
claude Sep 29, 2026
c525440
fix(logger): add warn level used by TwitchChat
claude Sep 29, 2026
f865ecd
refactor(logger): dispatch log records to exchangeable handlers
claude Sep 29, 2026
726349b
feat(logfami): add standalone structured logging core
claude Oct 1, 2026
610e8fb
feat(logfami): add text, JSON Lines and logfmt formatters
claude Oct 1, 2026
0139e18
feat(logfami): add redaction processor for credentials
claude Oct 1, 2026
5dd25fb
feat(logfami): add rolling file sink
claude Oct 1, 2026
edd470a
feat(logfami): add stdout sink for headless servers
claude Oct 1, 2026
a59f545
feat(logfami): capture engine errors via OS.add_logger
claude Oct 1, 2026
5948e6c
feat(logger): write Twitcher logs to a rolling file via Logfami
claude Oct 1, 2026
28fad22
fix(logger): remove lambda handlers before the engine shuts down
claude Oct 1, 2026
34260a4
fix(logger): address code review findings on the logging pipeline
claude Oct 1, 2026
461db5a
ci: add a release workflow that writes both version strings
claude Oct 2, 2026
8ba01c9
test(logfami): pin that a failing file sink can't loop through the ca…
claude Oct 2, 2026
bf32724
fix(lib): remove lambda loggers before shutdown to prevent quit crash
kanimaru Oct 3, 2026
913b42a
docs(logfami): state how often the file backend counts lines
claude Oct 3, 2026
5e79df2
refactor(logger): use LogfamiLevel and LogfamiRecord keys in Twitcher…
claude Oct 3, 2026
94217fa
refactor(logger): reference static methods directly instead of by name
claude Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/scripts/check-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Verifies that plugin.cfg and Twitcher.VERSION carry the same version and,
# when one is given, that both equal it (e.g. the tag being pushed).
#
# .github/scripts/check-version.sh # the two files must agree
# .github/scripts/check-version.sh 2.6.0 # ... and both must be 2.6.0
set -euo pipefail

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cfg_file="$root/addons/twitcher/plugin.cfg"
gd_file="$root/addons/twitcher/twitcher.gd"

cfg_version="$(sed -nE 's/^version="([^"]*)".*/\1/p' "$cfg_file")"
gd_version="$(sed -nE 's/^const VERSION: String = "([^"]*)".*/\1/p' "$gd_file")"
expected="${1:-$cfg_version}"

status=0
if [[ -z "$cfg_version" ]]; then
echo "::error file=addons/twitcher/plugin.cfg::no version=\"...\" line found" >&2
status=1
fi
if [[ -z "$gd_version" ]]; then
echo "::error file=addons/twitcher/twitcher.gd::no 'const VERSION: String = \"...\"' line found" >&2
status=1
fi
if [[ "$cfg_version" != "$expected" ]]; then
echo "::error file=addons/twitcher/plugin.cfg::version is '$cfg_version', expected '$expected'" >&2
status=1
fi
if [[ "$gd_version" != "$expected" ]]; then
echo "::error file=addons/twitcher/twitcher.gd::Twitcher.VERSION is '$gd_version', expected '$expected'" >&2
status=1
fi

if [[ "$status" -eq 0 ]]; then
echo "version $expected: plugin.cfg and Twitcher.VERSION agree"
fi
exit "$status"
20 changes: 20 additions & 0 deletions .github/scripts/set-version.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Writes a version into every file that carries it: plugin.cfg (what the
# editor shows) and Twitcher.VERSION (what exported games report, since they
# don't ship plugin.cfg). Used by the Release workflow.
#
# .github/scripts/set-version.sh 2.6.0
set -euo pipefail

version="${1:?usage: set-version.sh <version>, e.g. 2.6.0}"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
echo "::error::'$version' is not a version like 2.6 or 2.6.0" >&2
exit 1
fi

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
sed -i -E "s/^version=\"[^\"]*\"/version=\"$version\"/" "$root/addons/twitcher/plugin.cfg"
sed -i -E "s/^const VERSION: String = \"[^\"]*\"/const VERSION: String = \"$version\"/" \
"$root/addons/twitcher/twitcher.gd"

"$root/.github/scripts/check-version.sh" "$version"
96 changes: 96 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Release

# One input, no files to edit by hand: writes the version into plugin.cfg and
# Twitcher.VERSION, runs the test suite, commits, tags, publishes the GitHub
# release and posts to Discord.
#
# The release is created with GITHUB_TOKEN, and events raised by that token
# don't start other workflows, so the Discord notification lives here rather
# than relying on release-notification.yaml (which still covers releases
# published by hand).
on:
workflow_dispatch:
inputs:
version:
description: "Version to release, e.g. 2.6.0. Becomes the tag and the release name."
required: true
type: string

env:
GODOT_VERSION: "4.7.0"
GODOT_DISABLE_LEAK_CHECKS: "1"

jobs:
release:
name: Release ${{ inputs.version }}
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write

steps:
- uses: actions/checkout@v4
with:
# Full history, so the release notes see the previous tag.
fetch-depth: 0

- name: Refuse to release over an existing tag
env:
VERSION: ${{ inputs.version }}
run: |
if git rev-parse -q --verify "refs/tags/$VERSION" > /dev/null; then
echo "::error::tag $VERSION already exists"
exit 1
fi

- name: Write the version into plugin.cfg and Twitcher.VERSION
env:
VERSION: ${{ inputs.version }}
run: .github/scripts/set-version.sh "$VERSION"

- name: Set up Godot ${{ env.GODOT_VERSION }}
uses: chickensoft-games/setup-godot@v2
with:
version: ${{ env.GODOT_VERSION }}
use-dotnet: false
include-templates: false

- name: Import project
run: godot --headless --path . --import --quit
continue-on-error: true

- name: Run GUT
run: |
godot --headless --path . \
-s res://addons/gut/gut_cmdln.gd \
-gdir=res://test/unit,res://test/generator \
-ginclude_subdirs \
-glog=1 \
-gexit

- name: Commit, tag and push
env:
VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add addons/twitcher/plugin.cfg addons/twitcher/twitcher.gd
git commit -m "chore: release $VERSION"
git tag "$VERSION"
git push origin "HEAD:${GITHUB_REF_NAME}" "$VERSION"

- name: Publish the GitHub release
id: publish
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
url="$(gh release create "$VERSION" --title "$VERSION" --generate-notes)"
echo "url=$url" >> "$GITHUB_OUTPUT"

- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@master
with:
args: 'Twitcher ${{ inputs.version }} got released. \n\n [Get it here](${{ steps.publish.outputs.url }})'
23 changes: 23 additions & 0 deletions .github/workflows/version-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Version guard

# For tags pushed by hand: the tag must match plugin.cfg and Twitcher.VERSION.
# Tags pushed by the Release workflow don't trigger this (GITHUB_TOKEN events
# don't start workflows); that workflow checks the same thing itself.
on:
push:
tags:
- "*"

jobs:
check:
name: Tag matches the version strings
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Compare the tag with plugin.cfg and Twitcher.VERSION
run: .github/scripts/check-version.sh "$GITHUB_REF_NAME"
74 changes: 74 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Twitcher

Godot 4.7 addon for Twitch integration. Source lives in `addons/twitcher/`,
tests in `test/` (GUT 9.7.1, see `test/README.md`).

## GDScript style

Twitcher follows the official GDScript style guide precisely:
https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_styleguide.html

Project rules that are stricter than the guide:

- **Never use `:=`.** Always declare concrete types explicitly, for variables,
constants, parameters and return values:
`var direction: Vector3 = Vector3(1, 2, 3)`, not `var direction := Vector3(1, 2, 3)`.
- Every function has an explicit return type, including `-> void`.
- Prefer typed containers (`Array[String]`, `Dictionary[String, int]`) where the
element type is known.

Highlights from the guide that are easy to miss:

- File order: `@tool` → `class_name` → `extends` → `##` doc comment → signals →
enums → constants → static vars → `@export` vars → vars → `@onready` vars →
`_static_init()` → static methods → virtual methods → public → private →
inner classes. Public before private.
- Tabs, lines under 100 characters, continuation lines indented twice.
- Two blank lines between functions, one statement per line (no `if x: y`).
- `and` / `or` / `not` instead of `&&` / `||` / `!`.
- Trailing comma in multi-line arrays, dictionaries and enums; enum members one
per line; enum names singular PascalCase.
- Private members and methods start with `_`.

When editing existing files, reformat only the lines you touch.

## Architecture

- Modules under `addons/twitcher/lib/` (`http`, `oOuch`, `regex`, …) are
standalone and must never reference Twitcher classes. They receive logging via
`set_logger(error, info, debug)` callables.
- Twitcher logging goes through `TwitchLogger` → `TwitchLoggerManager` →
handlers (`Callable(record: Dictionary)`). The record contract is documented in
`addons/twitcher/logger/twitch_log_record.gd`.
- `lib/logfami/` is a standalone logging library (file, stdout, formats,
redaction). Twitcher uses its `LogfamiLevel` and `LogfamiRecord` keys as the one
definition of severities and record shape; `TwitchLogfamiBridge` and
`TwitchLogSettings` wire the pipelines. A test fails if Logfami references
Twitcher.

## Tests

```bash
godot --headless --path . --import --quit
GODOT_DISABLE_LEAK_CHECKS=1 godot --headless --path . \
-s res://addons/gut/gut_cmdln.gd -gdir=res://test/unit,res://test/generator \
-ginclude_subdirs -gexit
```

Every code change comes with tests. One test script per production script at the
mirrored path under `test/unit/`. Tests extend `TwitcherTest`; any new static
state must be registered in `test/helpers/static_state_guard.gd`.

## Releases

Run the **Release** workflow (Actions → Release → *Run workflow*) with the
version, e.g. `2.6.0`. It writes the version into `addons/twitcher/plugin.cfg`
and `Twitcher.VERSION` (exported games don't ship `plugin.cfg`), runs the
suite, commits, tags and publishes the GitHub release. Don't edit the two
version strings by hand; a test and the **Version guard** workflow fail when
they disagree. The scripts live in `.github/scripts/`.

## Commits

Conventional commits (`feat(logger): …`, `fix(chat): …`, `refactor: …`,
`test: …`, `docs: …`).
6 changes: 4 additions & 2 deletions addons/twitcher/chat/twitch_chat.gd
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,12 @@ func send_message(message: String, reply_parent_message_id: String = "") -> Arra
message_body.reply_parent_message_id = reply_parent_message_id

var response: TwitchSendChatMessage.Response = await api.send_chat_message(message_body)
if _log.enabled:
if _log.wants(LogfamiLevel.Severity.WARN):
for message_data: TwitchSendChatMessage.ResponseData in response.data:
if not message_data.is_sent:
_log.w(message_data.drop_reason)
_log.w("Message couldn't be sent cause of [%s]: %s" % [
message_data.drop_reason.code, message_data.drop_reason.message,
])

return response.data

Expand Down
3 changes: 2 additions & 1 deletion addons/twitcher/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ Because Godot doesn't have a good system to handle dependencies and
I want to have standalone modules that could be technically their own plugins.

Requirements:
twitcher requires http, oOuch
twitcher requires http, oOuch, logfami
oOuch requires http
logfami requires nothing
12 changes: 11 additions & 1 deletion addons/twitcher/lib/http/buffered_http_client.gd
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@icon("./buffered-http-icon.svg")
@tool
extends Twitcher
extends Node

## Http client that buffers the requests and sends at most [member max_parallel_requests]
## of them at the same time. Everything above that limit waits in a queue and is
Expand Down Expand Up @@ -235,12 +235,22 @@ static func set_logger(error: Callable, info: Callable, debug: Callable) -> void
logger.debug = debug
logger.info = info
logger.error = error
if LambdaLoggerCleanup.has_lambda([error, info, debug]):
LambdaLoggerCleanup.remove_on_shutdown(BufferedHTTPClient._remove_lambda_loggers)


## Drops lambda loggers; Godot frees them before this static dictionary at shutdown.
static func _remove_lambda_loggers() -> void:
LambdaLoggerCleanup.remove_lambdas(logger)


static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)


static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)


static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)
10 changes: 10 additions & 0 deletions addons/twitcher/lib/http/http_server.gd
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,22 @@ static func set_logger(error: Callable, info: Callable, debug: Callable) -> void
logger.debug = debug
logger.info = info
logger.error = error
if LambdaLoggerCleanup.has_lambda([error, info, debug]):
LambdaLoggerCleanup.remove_on_shutdown(HTTPServer._remove_lambda_loggers)


## Drops lambda loggers; Godot frees them before this static dictionary at shutdown.
static func _remove_lambda_loggers() -> void:
LambdaLoggerCleanup.remove_lambdas(logger)


static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)


static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)


static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)
53 changes: 53 additions & 0 deletions addons/twitcher/lib/http/lambda_logger_cleanup.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class_name LambdaLoggerCleanup
extends Object
## Removes lambda loggers before Godot frees them at shutdown.
##
## Godot frees a lambda together with the script that defines it. At shutdown
## that happens before the static variables of other scripts are cleared, so a
## lambda still kept in a static [code]logger[/code] dictionary crashes the game
## on quit. Bound methods are not affected.
##
## Usage from a class with a static [code]logger[/code] dictionary:
## [codeblock]
## static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
## logger.debug = debug
## logger.info = info
## logger.error = error
## if LambdaLoggerCleanup.has_lambda([error, info, debug]):
## LambdaLoggerCleanup.remove_on_shutdown(MyClass._remove_lambda_loggers)
##
## static func _remove_lambda_loggers() -> void:
## LambdaLoggerCleanup.remove_lambdas(logger)
## [/codeblock]


## True when one of [param callables] is a lambda (or another custom callable).
static func has_lambda(callables: Array[Callable]) -> bool:
for callable: Callable in callables:
if callable.is_custom():
return true
return false


## Calls [param remover] once the scene tree's root leaves the tree:
## after every node logged its last lines, before Godot frees the scripts.
## Registering the same callable twice connects it once.
static func remove_on_shutdown(remover: Callable) -> void:
var main_loop: MainLoop = Engine.get_main_loop()
if main_loop == null:
# A SceneTree script's _init runs before it becomes the main loop.
LambdaLoggerCleanup.remove_on_shutdown.call_deferred(remover)
return
var tree: SceneTree = main_loop as SceneTree
if tree == null or tree.root == null:
return
if not tree.root.tree_exiting.is_connected(remover):
tree.root.tree_exiting.connect(remover)


## Erases every lambda (or other custom callable) from [param logger].
static func remove_lambdas(logger: Dictionary) -> void:
for key: Variant in logger.keys():
var callable: Callable = logger[key]
if callable.is_custom():
logger.erase(key)
1 change: 1 addition & 0 deletions addons/twitcher/lib/http/lambda_logger_cleanup.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://sovgp2uyal2x
Loading
Loading