Initial commit.
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 9s
CI / Tests (Python 3.13) (push) Successful in 12s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 7s
Audit / Dependencies (push) Successful in 8s
CI / Formatting (push) Successful in 4s
CI / Linting (push) Successful in 5s
CI / Tests (Python 3.12) (push) Successful in 9s
CI / Tests (Python 3.13) (push) Successful in 12s
CI / Tests (Python 3.14) (push) Successful in 10s
CI / Type Checking (push) Successful in 10s
CI / Spelling (push) Successful in 7s
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
name: Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1"
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths: [uv.lock]
|
||||||
|
pull_request:
|
||||||
|
paths: [uv.lock]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
name: Dependencies
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Audit dependencies with pip-audit
|
||||||
|
run: uv run pip-audit --skip-editable
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
formatting:
|
||||||
|
name: Formatting
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Check formatting with Ruff
|
||||||
|
run: uv run ruff format --check --diff .
|
||||||
|
|
||||||
|
linting:
|
||||||
|
name: Linting
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Check linting with Ruff
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
tests:
|
||||||
|
name: Tests (Python ${{ matrix.python-version }})
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.12", "3.13", "3.14"]
|
||||||
|
env:
|
||||||
|
UV_PYTHON: ${{ matrix.python-version }}
|
||||||
|
UV_PYTHON_PREFERENCE: system
|
||||||
|
UV_PYTHON_DOWNLOADS: automatic
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv managed Python installs
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.local/share/uv/python
|
||||||
|
key: uv-python-${{ matrix.python-version }}-${{ runner.os }}-${{ runner.arch }}
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Run unit tests with Pytest
|
||||||
|
run: uv run pytest -v --cov --cov-report=
|
||||||
|
|
||||||
|
- name: Report code coverage
|
||||||
|
run: uv run coverage report
|
||||||
|
|
||||||
|
type-checking:
|
||||||
|
name: Type Checking
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Check types with Mypy
|
||||||
|
run: uv run mypy .
|
||||||
|
|
||||||
|
spelling:
|
||||||
|
name: Spelling
|
||||||
|
runs-on: logaldeveloper-archlinux-ci
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cache uv packages
|
||||||
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
|
||||||
|
- name: Check spelling with codespell
|
||||||
|
run: uv run codespell
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Test / coverage artifacts
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Local configuration
|
||||||
|
config.yaml
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Extra Owlbot Modules
|
||||||
|
|
||||||
|
This repository contains standalone Owlbot modules that are useful but not
|
||||||
|
built into upstream Owlbot.
|
||||||
|
|
||||||
|
It also acts as a reference project for developing modules outside the main
|
||||||
|
Owlbot repository. Each module is a top-level Python package directory,
|
||||||
|
dependencies are declared in `pyproject.toml`, the environment is managed with
|
||||||
|
`uv`, and CI runs the same style, type, test, and spelling checks used by
|
||||||
|
Owlbot.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
- [`chat_archive`](chat_archive/): archives Owncast chat activity and stream
|
||||||
|
context updates to JSON files.
|
||||||
|
|
||||||
|
Each module package is kept at the repository root so it can be copied or
|
||||||
|
symlinked independently into the directory Owlbot scans for user modules.
|
||||||
|
|
||||||
|
## Loading Modules
|
||||||
|
|
||||||
|
Place the module folders you want to use inside your Owlbot user modules
|
||||||
|
directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
modules/
|
||||||
|
chat_archive/
|
||||||
|
__init__.py
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
If you do not already have a user modules directory, create one and configure
|
||||||
|
Owlbot to scan it with `owlbot.modules_dir`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
owlbot:
|
||||||
|
modules_dir: "/path/to/modules"
|
||||||
|
```
|
||||||
|
|
||||||
|
Module-specific configuration belongs under `modules.<module_name>` in
|
||||||
|
`config.yaml`. See the README inside each module package for its config and
|
||||||
|
runtime behavior.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# chat_archive
|
||||||
|
|
||||||
|
This package contains an Owlbot module that writes Owncast chat activity
|
||||||
|
and stream context updates to JSON files for later processing.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
An archive is the JSON file this module writes for one live stream. The module
|
||||||
|
starts an archive when Owlbot sees a live stream, including streams that are
|
||||||
|
already live when the module loads.
|
||||||
|
|
||||||
|
While an archive is active, the module records chat messages, user joins, user
|
||||||
|
parts, name changes, stream title updates, and message visibility updates in the
|
||||||
|
order Owlbot dispatches them. Events outside an active stream are ignored.
|
||||||
|
|
||||||
|
Archive files are stream-scoped. When a stream ends, the active archive is
|
||||||
|
finalized, and the next live stream starts a new archive file. Module unload
|
||||||
|
also finalizes the active archive.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
modules:
|
||||||
|
chat_archive:
|
||||||
|
enabled: true
|
||||||
|
archive_dir: "data/chat_archives"
|
||||||
|
pretty_json: true
|
||||||
|
```
|
||||||
|
|
||||||
|
- `archive_dir`: directory where archive files are written. The module creates
|
||||||
|
it if needed.
|
||||||
|
- `pretty_json`: when true, write indented JSON. When false, write compact JSON.
|
||||||
|
|
||||||
|
## Archive Format
|
||||||
|
|
||||||
|
Archive filenames are based on the detected stream start time when available:
|
||||||
|
|
||||||
|
```text
|
||||||
|
chat-archive-20260526T120000Z.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If a file already exists for that timestamp, the module adds a suffix such as
|
||||||
|
`chat-archive-20260526T120000Z-2.json`.
|
||||||
|
|
||||||
|
Each archive is a single JSON document with these top-level fields:
|
||||||
|
|
||||||
|
- `archive_start_reason`: one of `module_startup` or `stream_started_event`.
|
||||||
|
- `archive_started_at`: ISO 8601 timestamp for when the archive file was
|
||||||
|
started.
|
||||||
|
- `archive_updated_at`: ISO 8601 timestamp for the most recent archive write.
|
||||||
|
- `archive_stopped_at`: ISO 8601 timestamp for when the archive was finalized,
|
||||||
|
or `null` while the archive is active.
|
||||||
|
- `archive_stop_reason`: reason the archive was finalized, or `null` while the
|
||||||
|
archive is active. Current values are `stream_stopped_event`,
|
||||||
|
`module_teardown`, and `replaced_by_stream_started_event`.
|
||||||
|
- `stream_title`: stream title when the archive started, or `null` if
|
||||||
|
unavailable.
|
||||||
|
- `stream_stopped_at`: stream stop timestamp recorded when finalized by a
|
||||||
|
stream stop event, or `null` otherwise.
|
||||||
|
- `event_count`: number of objects in `events`.
|
||||||
|
- `events`: ordered list of captured activity events.
|
||||||
|
|
||||||
|
Each object in `events` contains:
|
||||||
|
|
||||||
|
- `event_type`: one of `CHAT`, `USER_JOINED`, `USER_PARTED`, `NAME_CHANGE`,
|
||||||
|
`STREAM_TITLE_UPDATED`, or `VISIBILITY-UPDATE`.
|
||||||
|
- `archived_at`: ISO 8601 timestamp for when the event was written.
|
||||||
|
- `event_data`: parsed Owlbot event data converted to JSON-compatible values.
|
||||||
|
The exact fields depend on `event_type`.
|
||||||
|
|
||||||
|
Abridged example archive:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"archive_start_reason": "stream_started_event",
|
||||||
|
"archive_started_at": "2026-05-26T12:00:01.000000+00:00",
|
||||||
|
"archive_updated_at": "2026-05-26T12:01:00.000000+00:00",
|
||||||
|
"archive_stopped_at": null,
|
||||||
|
"archive_stop_reason": null,
|
||||||
|
"stream_title": "Game Night",
|
||||||
|
"stream_stopped_at": null,
|
||||||
|
"event_count": 1,
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"event_type": "CHAT",
|
||||||
|
"archived_at": "2026-05-26T12:01:00.000000+00:00",
|
||||||
|
"event_data": {
|
||||||
|
"message_id": "msg-1",
|
||||||
|
"raw_body": "hello chat",
|
||||||
|
"body": "hello chat",
|
||||||
|
"is_visible": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Owlbot modules receive parsed event dataclasses rather than the untouched
|
||||||
|
Owncast webhook payload. `event_data` stores that parsed event data after
|
||||||
|
converting values such as datetimes and sets into JSON-compatible values.
|
||||||
|
|
||||||
|
The archive is rewritten after each captured event through a temporary sibling
|
||||||
|
file and atomic replace. For an existing archive file, readers should see either
|
||||||
|
the previous complete JSON document or the next complete JSON document.
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Archive Owncast chat activity to JSON files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
from owlbot.api import (
|
||||||
|
ChatEvent,
|
||||||
|
Event,
|
||||||
|
EventContext,
|
||||||
|
EventType,
|
||||||
|
ModuleContext,
|
||||||
|
NameChangedEvent,
|
||||||
|
Priority,
|
||||||
|
StreamStartedEvent,
|
||||||
|
StreamStoppedEvent,
|
||||||
|
StreamTitleUpdatedEvent,
|
||||||
|
UserJoinedEvent,
|
||||||
|
UserPartedEvent,
|
||||||
|
VisibilityUpdateEvent,
|
||||||
|
on_event,
|
||||||
|
on_setup,
|
||||||
|
on_teardown,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STATE_KEY = "chat_archive_state"
|
||||||
|
|
||||||
|
type ArchiveStartReason = Literal["module_startup", "stream_started_event"]
|
||||||
|
type ArchiveStopReason = Literal[
|
||||||
|
"module_teardown",
|
||||||
|
"replaced_by_stream_started_event",
|
||||||
|
"stream_stopped_event",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ArchiveState:
|
||||||
|
"""Runtime state for the chat archive module."""
|
||||||
|
|
||||||
|
archive_dir: Path
|
||||||
|
pretty_json: bool
|
||||||
|
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||||
|
current_path: Path | None = None
|
||||||
|
current_data: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_state(ctx: ModuleContext) -> ArchiveState:
|
||||||
|
"""Return the typed ArchiveState stored in Owlbot's ctx.state dict."""
|
||||||
|
state = ctx.state.get(_STATE_KEY)
|
||||||
|
if not isinstance(state, ArchiveState):
|
||||||
|
msg = "chat_archive state is not initialized"
|
||||||
|
raise RuntimeError(msg) # noqa: TRY004 # state error, not a type error
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@on_setup
|
||||||
|
async def setup(ctx: ModuleContext) -> None:
|
||||||
|
"""Initialize config/runtime state and start an archive if the stream is live."""
|
||||||
|
ctx.config.register_defaults(
|
||||||
|
{
|
||||||
|
"archive_dir": "data/chat_archives",
|
||||||
|
"pretty_json": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_dir_value = ctx.config.get("archive_dir", "data/chat_archives")
|
||||||
|
pretty_json = bool(ctx.config.get("pretty_json", True))
|
||||||
|
|
||||||
|
state = ArchiveState(
|
||||||
|
archive_dir=Path(str(archive_dir_value)),
|
||||||
|
pretty_json=pretty_json,
|
||||||
|
)
|
||||||
|
state.archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
ctx.state[_STATE_KEY] = state
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await ctx.owncast_client.get_status()
|
||||||
|
except Exception: # noqa: BLE001 # best-effort startup check; stream events still work
|
||||||
|
ctx.logger.warning(
|
||||||
|
"Could not check stream status. Chat archiving will start on stream start.",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if status.get("online", False):
|
||||||
|
last_connect_time = status.get("lastConnectTime")
|
||||||
|
started_at = datetime.now(UTC)
|
||||||
|
if isinstance(last_connect_time, str) and last_connect_time:
|
||||||
|
try:
|
||||||
|
started_at = datetime.fromisoformat(last_connect_time)
|
||||||
|
except ValueError:
|
||||||
|
ctx.logger.warning(
|
||||||
|
"Owncast status returned lastConnectTime in unexpected "
|
||||||
|
"format: %r. Falling back to the current time.",
|
||||||
|
last_connect_time,
|
||||||
|
)
|
||||||
|
async with state.lock:
|
||||||
|
path = await _start_archive(
|
||||||
|
ctx,
|
||||||
|
state,
|
||||||
|
archive_start_reason="module_startup",
|
||||||
|
stream_started_at=started_at,
|
||||||
|
stream_title=status.get("streamTitle"),
|
||||||
|
)
|
||||||
|
ctx.logger.info("Stream is already live. Chat archive started: %s", path)
|
||||||
|
else:
|
||||||
|
ctx.logger.info("Stream is offline. Chat archiving will start on stream start.")
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.STREAM_STARTED)
|
||||||
|
async def handle_stream_started(ctx: EventContext[StreamStartedEvent]) -> None:
|
||||||
|
"""Create a new archive when Owncast reports that the stream went live."""
|
||||||
|
state = _get_state(ctx.module)
|
||||||
|
started_at = ctx.event.timestamp or datetime.now(UTC)
|
||||||
|
|
||||||
|
async with state.lock:
|
||||||
|
path = await _start_archive(
|
||||||
|
ctx.module,
|
||||||
|
state,
|
||||||
|
archive_start_reason="stream_started_event",
|
||||||
|
stream_started_at=started_at,
|
||||||
|
stream_title=ctx.event.stream_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.logger.info("Chat archive started: %s", path)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.CHAT, priority=Priority.HIGHEST)
|
||||||
|
async def archive_chat_message(ctx: EventContext[ChatEvent]) -> None:
|
||||||
|
"""Append an incoming chat message to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.CHAT, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.VISIBILITY_UPDATE, priority=Priority.HIGHEST)
|
||||||
|
async def archive_visibility_update(
|
||||||
|
ctx: EventContext[VisibilityUpdateEvent],
|
||||||
|
) -> None:
|
||||||
|
"""Append a message visibility update to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.VISIBILITY_UPDATE, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.USER_JOINED, priority=Priority.HIGHEST)
|
||||||
|
async def archive_user_joined(ctx: EventContext[UserJoinedEvent]) -> None:
|
||||||
|
"""Append a user join event to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.USER_JOINED, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.USER_PARTED, priority=Priority.HIGHEST)
|
||||||
|
async def archive_user_parted(ctx: EventContext[UserPartedEvent]) -> None:
|
||||||
|
"""Append a user part event to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.USER_PARTED, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.NAME_CHANGE, priority=Priority.HIGHEST)
|
||||||
|
async def archive_name_change(ctx: EventContext[NameChangedEvent]) -> None:
|
||||||
|
"""Append a name change event to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.NAME_CHANGE, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.STREAM_TITLE_UPDATED, priority=Priority.HIGHEST)
|
||||||
|
async def archive_stream_title_update(
|
||||||
|
ctx: EventContext[StreamTitleUpdatedEvent],
|
||||||
|
) -> None:
|
||||||
|
"""Append a stream title update event to the active archive file."""
|
||||||
|
await _archive_event(ctx.module, EventType.STREAM_TITLE_UPDATED, ctx.event)
|
||||||
|
|
||||||
|
|
||||||
|
@on_event(EventType.STREAM_STOPPED)
|
||||||
|
async def handle_stream_stopped(ctx: EventContext[StreamStoppedEvent]) -> None:
|
||||||
|
"""Finalize the active archive when Owncast reports that the stream stopped."""
|
||||||
|
state = _get_state(ctx.module)
|
||||||
|
|
||||||
|
async with state.lock:
|
||||||
|
path = await _finalize_archive(
|
||||||
|
ctx.module,
|
||||||
|
state,
|
||||||
|
reason="stream_stopped_event",
|
||||||
|
stream_stopped_at=ctx.event.timestamp or datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
if path is not None:
|
||||||
|
ctx.logger.info("Chat archive finalized after stream stopped: %s", path)
|
||||||
|
|
||||||
|
|
||||||
|
@on_teardown
|
||||||
|
async def teardown(ctx: ModuleContext) -> None:
|
||||||
|
"""Finalize any active archive when Owlbot unloads the module."""
|
||||||
|
state = _get_state(ctx)
|
||||||
|
|
||||||
|
async with state.lock:
|
||||||
|
await _finalize_archive(ctx, state, reason="module_teardown")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_archive(
|
||||||
|
ctx: ModuleContext,
|
||||||
|
state: ArchiveState,
|
||||||
|
*,
|
||||||
|
archive_start_reason: ArchiveStartReason,
|
||||||
|
stream_started_at: datetime,
|
||||||
|
stream_title: str | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Open a new archive file, finalizing any active file first."""
|
||||||
|
if state.current_data is not None:
|
||||||
|
await _finalize_archive(ctx, state, reason="replaced_by_stream_started_event")
|
||||||
|
|
||||||
|
archive_started_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
if stream_started_at.tzinfo is None:
|
||||||
|
stream_started_at = stream_started_at.replace(tzinfo=UTC)
|
||||||
|
timestamp = stream_started_at.astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
stem = f"chat-archive-{timestamp}"
|
||||||
|
path = state.archive_dir / f"{stem}.json"
|
||||||
|
index = 2
|
||||||
|
while path.exists():
|
||||||
|
path = state.archive_dir / f"{stem}-{index}.json"
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
data: dict[str, Any] = {
|
||||||
|
"archive_start_reason": archive_start_reason,
|
||||||
|
"archive_started_at": archive_started_at.isoformat(),
|
||||||
|
"archive_updated_at": archive_started_at.isoformat(),
|
||||||
|
"archive_stopped_at": None,
|
||||||
|
"archive_stop_reason": None,
|
||||||
|
"stream_title": stream_title,
|
||||||
|
"stream_stopped_at": None,
|
||||||
|
"event_count": 0,
|
||||||
|
"events": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await _write_json(path, data, pretty_json=state.pretty_json)
|
||||||
|
|
||||||
|
state.current_path = path
|
||||||
|
state.current_data = data
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def _archive_event(
|
||||||
|
ctx: ModuleContext,
|
||||||
|
event_type: EventType,
|
||||||
|
event: Event,
|
||||||
|
) -> None:
|
||||||
|
"""Record one event and rewrite the active archive, if a stream is live."""
|
||||||
|
state = _get_state(ctx)
|
||||||
|
|
||||||
|
async with state.lock:
|
||||||
|
data = state.current_data
|
||||||
|
path = state.current_path
|
||||||
|
if data is None or path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
archived_at = datetime.now(UTC)
|
||||||
|
events = cast("list[dict[str, Any]]", data["events"])
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"event_type": event_type.value,
|
||||||
|
"archived_at": archived_at.isoformat(),
|
||||||
|
"event_data": asdict(event),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data["event_count"] = len(events)
|
||||||
|
data["archive_updated_at"] = archived_at.isoformat()
|
||||||
|
await _write_json(path, data, pretty_json=state.pretty_json)
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize_archive(
|
||||||
|
ctx: ModuleContext,
|
||||||
|
state: ArchiveState,
|
||||||
|
*,
|
||||||
|
reason: ArchiveStopReason,
|
||||||
|
stream_stopped_at: datetime | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Mark the active archive complete and write its final JSON state."""
|
||||||
|
data = state.current_data
|
||||||
|
path = state.current_path
|
||||||
|
if data is None or path is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stopped_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
data["archive_stopped_at"] = stopped_at.isoformat()
|
||||||
|
data["archive_stop_reason"] = reason
|
||||||
|
data["archive_updated_at"] = stopped_at.isoformat()
|
||||||
|
if stream_stopped_at is not None:
|
||||||
|
data["stream_stopped_at"] = stream_stopped_at.isoformat()
|
||||||
|
|
||||||
|
await _write_json(path, data, pretty_json=state.pretty_json)
|
||||||
|
ctx.logger.info("Wrote chat archive: %s", path)
|
||||||
|
state.current_path = None
|
||||||
|
state.current_data = None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_json(path: Path, data: dict[str, Any], *, pretty_json: bool) -> None:
|
||||||
|
"""Atomically write a valid JSON document to disk without blocking the loop."""
|
||||||
|
|
||||||
|
def json_default(value: object) -> object:
|
||||||
|
if isinstance(value, (set, frozenset)):
|
||||||
|
return sorted(value, key=str)
|
||||||
|
|
||||||
|
raise TypeError(f"Type is not JSON serializable: {type(value).__name__}")
|
||||||
|
|
||||||
|
def write_sync() -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
option = orjson.OPT_INDENT_2 if pretty_json else 0
|
||||||
|
payload = orjson.dumps(data, option=option, default=json_default) + b"\n"
|
||||||
|
|
||||||
|
tmp_path = path.with_name(f".{path.name}.tmp")
|
||||||
|
tmp_path.write_bytes(payload)
|
||||||
|
tmp_path.replace(path)
|
||||||
|
|
||||||
|
await asyncio.to_thread(write_sync)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
[project]
|
||||||
|
name = "owlbot-extra-modules"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Standalone Owlbot modules that are useful but not built into upstream Owlbot."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
authors = [
|
||||||
|
{ name = "Logan Fick" },
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"orjson>=3.11.8",
|
||||||
|
"owlbot==1.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://git.logal.dev/LogalDeveloper/Owlbot-Extra-Modules"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"codespell>=2.4.2",
|
||||||
|
"mypy>=1.20.2",
|
||||||
|
"owlbot[testing]==1.2.0",
|
||||||
|
"pip-audit>=2.10.0",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
|
"ruff>=0.15.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
extra-index-url = ["https://git.logal.dev/api/packages/LogalDeveloper/pypi/simple/"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
explicit_package_bases = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
|
||||||
|
"C901", # McCabe complexity: noisy and not actionable
|
||||||
|
"COM812", # handled by the formatter
|
||||||
|
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||||
|
"D213", # incompatible with D212 (summary on first line)
|
||||||
|
"EM", # exception message style: inline literals are fine
|
||||||
|
"PLR0911", # too many return statements: flat early-returns are clear
|
||||||
|
"PLR0912", # too many branches: inherent in parsers, validators, CLI
|
||||||
|
"PLR0913", # too many arguments: API surfaces and constructors need them
|
||||||
|
"PLR0915", # too many statements: inherent in parsers, validators, CLI
|
||||||
|
"TRY003", # inline exception messages are fine (complements EM ignore)
|
||||||
|
"TRY301", # raise inside try: guard clauses don't need helper functions
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = [
|
||||||
|
"S101", # assert is standard for pytest
|
||||||
|
"SLF001", # tests legitimately inspect module state and test fixture internals
|
||||||
|
"ARG001", # unused args are normal for fixtures
|
||||||
|
"PLR2004", # magic values are clear in test assertions
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.codespell]
|
||||||
|
skip = "uv.lock"
|
||||||
|
ignore-words-list = "ECT,IST,PONT,TAHT,THA,WAST,WIT"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["."]
|
||||||
|
omit = [
|
||||||
|
"tests/**",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
|
skip_empty = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Extra Owlbot modules test suite."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Pytest configuration for extra Owlbot modules."""
|
||||||
|
|
||||||
|
pytest_plugins = ["owlbot.testing.plugin"]
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for the chat_archive module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
import pytest
|
||||||
|
from owlbot.api import EventType
|
||||||
|
from owlbot.testing import (
|
||||||
|
make_chat_event,
|
||||||
|
make_name_changed_event,
|
||||||
|
make_stream_started_event,
|
||||||
|
make_stream_stopped_event,
|
||||||
|
make_stream_title_updated_event,
|
||||||
|
make_user,
|
||||||
|
make_user_joined_event,
|
||||||
|
make_user_parted_event,
|
||||||
|
make_visibility_update_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
import chat_archive
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
from owlbot.api import ModuleContext
|
||||||
|
from owlbot.registries.events import EventDispatcher
|
||||||
|
from owlbot.testing import RecordingOwncastClient
|
||||||
|
|
||||||
|
|
||||||
|
def _config_data(archive_dir: Path, *, pretty_json: bool = True) -> dict[str, Any]:
|
||||||
|
"""Build plugin config data with chat_archive pointed at a temp directory."""
|
||||||
|
return {
|
||||||
|
"owncast": {"url": "http://localhost:8080"},
|
||||||
|
"owlbot": {"public_base_url": "http://localhost:8081"},
|
||||||
|
"modules": {
|
||||||
|
"chat_archive": {
|
||||||
|
"archive_dir": str(archive_dir),
|
||||||
|
"pretty_json": pretty_json,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def module_pkg() -> ModuleType:
|
||||||
|
"""Auto-wire chat_archive handler registration and lifecycle."""
|
||||||
|
return chat_archive
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def archive_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Return the per-test archive output directory."""
|
||||||
|
return tmp_path / "archives"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_data(archive_dir: Path) -> dict[str, Any]:
|
||||||
|
"""Seed module config so archive writes stay inside tmp_path."""
|
||||||
|
return _config_data(archive_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_files(archive_dir: Path) -> list[Path]:
|
||||||
|
"""Return archive JSON files in stable filename order."""
|
||||||
|
return sorted(archive_dir.glob("chat-archive-*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_archive(path: Path) -> dict[str, Any]:
|
||||||
|
"""Read one archive JSON document."""
|
||||||
|
data = orjson.loads(path.read_bytes())
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _events(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Return the typed events list from an archive document."""
|
||||||
|
events = data["events"]
|
||||||
|
assert isinstance(events, list)
|
||||||
|
return cast("list[dict[str, Any]]", events)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_iso_datetime(value: object) -> None:
|
||||||
|
"""Assert that a value is a timezone-aware ISO datetime string."""
|
||||||
|
assert isinstance(value, str)
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
assert parsed.tzinfo is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("module_lifecycle")
|
||||||
|
class TestChatArchive:
|
||||||
|
"""End-to-end behavior through Owlbot's real event dispatch pipeline."""
|
||||||
|
|
||||||
|
async def test_setup_creates_archive_dir_when_stream_is_offline(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Offline startup initializes the output directory without an archive."""
|
||||||
|
assert await asyncio.to_thread(archive_dir.is_dir)
|
||||||
|
assert _archive_files(archive_dir) == []
|
||||||
|
|
||||||
|
async def test_stream_started_creates_active_archive(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""STREAM_STARTED creates an active stream-scoped archive file."""
|
||||||
|
started_at = datetime(2026, 5, 26, 12, tzinfo=UTC)
|
||||||
|
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
stream_title="Game Night",
|
||||||
|
timestamp=started_at,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
files = _archive_files(archive_dir)
|
||||||
|
assert [path.name for path in files] == ["chat-archive-20260526T120000Z.json"]
|
||||||
|
|
||||||
|
data = _read_archive(files[0])
|
||||||
|
assert data["archive_start_reason"] == "stream_started_event"
|
||||||
|
assert data["archive_stop_reason"] is None
|
||||||
|
assert data["archive_stopped_at"] is None
|
||||||
|
assert data["stream_title"] == "Game Night"
|
||||||
|
assert data["stream_stopped_at"] is None
|
||||||
|
assert data["event_count"] == 0
|
||||||
|
assert data["events"] == []
|
||||||
|
_assert_iso_datetime(data["archive_started_at"])
|
||||||
|
_assert_iso_datetime(data["archive_updated_at"])
|
||||||
|
|
||||||
|
async def test_archives_events_while_stream_is_active(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""Supported events append JSON-compatible snapshots in dispatch order."""
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
stream_title="Event Test",
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
user = make_user(
|
||||||
|
id="alice-id",
|
||||||
|
display_name="Alice",
|
||||||
|
is_authenticated=True,
|
||||||
|
is_moderator=True,
|
||||||
|
scopes=frozenset({"CUSTOM"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.CHAT,
|
||||||
|
make_chat_event(
|
||||||
|
user=user,
|
||||||
|
raw_body="hello chat",
|
||||||
|
body="<p>hello chat</p>",
|
||||||
|
client_id=7,
|
||||||
|
message_id="msg-1",
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, 1, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.USER_JOINED,
|
||||||
|
make_user_joined_event(
|
||||||
|
user=make_user(id="bob-id", display_name="Bob"),
|
||||||
|
client_id=8,
|
||||||
|
event_id="join-1",
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, 2, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.VISIBILITY_UPDATE,
|
||||||
|
make_visibility_update_event(
|
||||||
|
event_id="vis-1",
|
||||||
|
message_ids=["msg-1"],
|
||||||
|
is_visible=False,
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, 3, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
data = _read_archive(_archive_files(archive_dir)[0])
|
||||||
|
events = _events(data)
|
||||||
|
assert data["event_count"] == 3
|
||||||
|
assert [event["event_type"] for event in events] == [
|
||||||
|
EventType.CHAT.value,
|
||||||
|
EventType.USER_JOINED.value,
|
||||||
|
EventType.VISIBILITY_UPDATE.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
chat_data = events[0]["event_data"]
|
||||||
|
assert chat_data["message_id"] == "msg-1"
|
||||||
|
assert chat_data["raw_body"] == "hello chat"
|
||||||
|
assert chat_data["body"] == "<p>hello chat</p>"
|
||||||
|
assert chat_data["timestamp"] == "2026-05-26T12:01:00+00:00"
|
||||||
|
assert chat_data["user"]["id"] == "alice-id"
|
||||||
|
assert chat_data["user"]["display_name"] == "Alice"
|
||||||
|
assert chat_data["user"]["scopes"] == ["CUSTOM", "MODERATOR"]
|
||||||
|
|
||||||
|
joined_data = events[1]["event_data"]
|
||||||
|
assert joined_data["event_id"] == "join-1"
|
||||||
|
assert joined_data["user"]["display_name"] == "Bob"
|
||||||
|
|
||||||
|
visibility_data = events[2]["event_data"]
|
||||||
|
assert visibility_data["message_ids"] == ["msg-1"]
|
||||||
|
assert visibility_data["is_visible"] is False
|
||||||
|
_assert_iso_datetime(events[2]["archived_at"])
|
||||||
|
assert data["archive_updated_at"] == events[2]["archived_at"]
|
||||||
|
|
||||||
|
async def test_archives_remaining_supported_event_wrappers(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""User part, name change, and title update handlers all archive events."""
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.USER_PARTED,
|
||||||
|
make_user_parted_event(
|
||||||
|
user=make_user(id="alice-id", display_name="Alice"),
|
||||||
|
event_id="part-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.NAME_CHANGE,
|
||||||
|
make_name_changed_event(
|
||||||
|
user=make_user(id="alice-id", display_name="AliceNew"),
|
||||||
|
new_name="AliceNew",
|
||||||
|
event_id="name-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_TITLE_UPDATED,
|
||||||
|
make_stream_title_updated_event(stream_title="New Title"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = _events(_read_archive(_archive_files(archive_dir)[0]))
|
||||||
|
assert [event["event_type"] for event in events] == [
|
||||||
|
EventType.USER_PARTED.value,
|
||||||
|
EventType.NAME_CHANGE.value,
|
||||||
|
EventType.STREAM_TITLE_UPDATED.value,
|
||||||
|
]
|
||||||
|
assert events[0]["event_data"]["event_id"] == "part-1"
|
||||||
|
assert events[1]["event_data"]["new_name"] == "AliceNew"
|
||||||
|
assert events[2]["event_data"]["stream_title"] == "New Title"
|
||||||
|
|
||||||
|
async def test_naive_stream_start_timestamp_is_treated_as_utc(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""Naive stream start timestamps are normalized as UTC for filenames."""
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
timestamp=datetime(2026, 5, 26, 12), # noqa: DTZ001
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [path.name for path in _archive_files(archive_dir)] == [
|
||||||
|
"chat-archive-20260526T120000Z.json"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_stream_stopped_finalizes_archive_and_ignores_later_events(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""STREAM_STOPPED finalizes the active archive and closes capture."""
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.CHAT,
|
||||||
|
make_chat_event(raw_body="before stop", message_id="msg-before"),
|
||||||
|
)
|
||||||
|
|
||||||
|
stopped_at = datetime(2026, 5, 26, 13, 30, tzinfo=UTC)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STOPPED,
|
||||||
|
make_stream_stopped_event(timestamp=stopped_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
path = _archive_files(archive_dir)[0]
|
||||||
|
data = _read_archive(path)
|
||||||
|
assert data["archive_stop_reason"] == "stream_stopped_event"
|
||||||
|
assert data["stream_stopped_at"] == "2026-05-26T13:30:00+00:00"
|
||||||
|
assert data["event_count"] == 1
|
||||||
|
_assert_iso_datetime(data["archive_stopped_at"])
|
||||||
|
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.CHAT,
|
||||||
|
make_chat_event(raw_body="after stop", message_id="msg-after"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _read_archive(path)["event_count"] == 1
|
||||||
|
|
||||||
|
async def test_new_stream_replaces_active_archive_and_uses_filename_suffix(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""A second stream start finalizes the first archive and opens a suffix file."""
|
||||||
|
started_at = datetime(2026, 5, 26, 12, tzinfo=UTC)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(stream_title="First", timestamp=started_at),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(stream_title="Second", timestamp=started_at),
|
||||||
|
)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.CHAT,
|
||||||
|
make_chat_event(raw_body="new stream message", message_id="msg-new"),
|
||||||
|
)
|
||||||
|
|
||||||
|
files = _archive_files(archive_dir)
|
||||||
|
assert {path.name for path in files} == {
|
||||||
|
"chat-archive-20260526T120000Z.json",
|
||||||
|
"chat-archive-20260526T120000Z-2.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
old_archive = _read_archive(archive_dir / "chat-archive-20260526T120000Z.json")
|
||||||
|
assert old_archive["archive_stop_reason"] == "replaced_by_stream_started_event"
|
||||||
|
assert old_archive["event_count"] == 0
|
||||||
|
_assert_iso_datetime(old_archive["archive_stopped_at"])
|
||||||
|
|
||||||
|
new_archive = _read_archive(
|
||||||
|
archive_dir / "chat-archive-20260526T120000Z-2.json"
|
||||||
|
)
|
||||||
|
assert new_archive["archive_start_reason"] == "stream_started_event"
|
||||||
|
assert new_archive["archive_stop_reason"] is None
|
||||||
|
assert new_archive["stream_title"] == "Second"
|
||||||
|
assert new_archive["event_count"] == 1
|
||||||
|
assert _events(new_archive)[0]["event_data"]["message_id"] == "msg-new"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupAndTeardown:
|
||||||
|
"""Cases that need direct control around setup and teardown timing."""
|
||||||
|
|
||||||
|
def test_get_state_raises_before_setup(self, module_context: ModuleContext) -> None:
|
||||||
|
"""Accessing module state before setup reports a clear runtime error."""
|
||||||
|
with pytest.raises(RuntimeError, match="chat_archive state is not initialized"):
|
||||||
|
chat_archive._get_state(module_context)
|
||||||
|
|
||||||
|
async def test_setup_continues_when_status_check_fails(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
owncast_client: RecordingOwncastClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""A startup status failure leaves the module ready for future events."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
owncast_client,
|
||||||
|
"get_status",
|
||||||
|
AsyncMock(side_effect=RuntimeError("status failed")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await chat_archive.setup(module_context)
|
||||||
|
try:
|
||||||
|
assert await asyncio.to_thread(archive_dir.is_dir)
|
||||||
|
assert _archive_files(archive_dir) == []
|
||||||
|
assert "Could not check stream status" in caplog.text
|
||||||
|
finally:
|
||||||
|
await chat_archive.teardown(module_context)
|
||||||
|
|
||||||
|
async def test_setup_starts_archive_when_stream_is_already_online(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
owncast_client: RecordingOwncastClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Direct setup can observe a stubbed live Owncast status response."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
owncast_client,
|
||||||
|
"get_status",
|
||||||
|
AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"online": True,
|
||||||
|
"lastConnectTime": "2026-05-26T12:00:00+00:00",
|
||||||
|
"streamTitle": "Already Live",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await chat_archive.setup(module_context)
|
||||||
|
try:
|
||||||
|
files = _archive_files(archive_dir)
|
||||||
|
assert [path.name for path in files] == [
|
||||||
|
"chat-archive-20260526T120000Z.json"
|
||||||
|
]
|
||||||
|
data = _read_archive(files[0])
|
||||||
|
assert data["archive_start_reason"] == "module_startup"
|
||||||
|
assert data["stream_title"] == "Already Live"
|
||||||
|
assert data["archive_stop_reason"] is None
|
||||||
|
finally:
|
||||||
|
await chat_archive.teardown(module_context)
|
||||||
|
|
||||||
|
async def test_setup_logs_bad_last_connect_time_and_starts_archive(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
owncast_client: RecordingOwncastClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Malformed lastConnectTime falls back to the current time."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
owncast_client,
|
||||||
|
"get_status",
|
||||||
|
AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"online": True,
|
||||||
|
"lastConnectTime": "not-a-date",
|
||||||
|
"streamTitle": "Bad Timestamp",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await chat_archive.setup(module_context)
|
||||||
|
try:
|
||||||
|
files = _archive_files(archive_dir)
|
||||||
|
assert len(files) == 1
|
||||||
|
data = _read_archive(files[0])
|
||||||
|
assert data["archive_start_reason"] == "module_startup"
|
||||||
|
assert data["stream_title"] == "Bad Timestamp"
|
||||||
|
assert "lastConnectTime in unexpected format" in caplog.text
|
||||||
|
finally:
|
||||||
|
await chat_archive.teardown(module_context)
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("registered_handlers")
|
||||||
|
async def test_teardown_finalizes_active_archive(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
module_context: ModuleContext,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""Module teardown writes the final archive state for an active stream."""
|
||||||
|
await chat_archive.setup(module_context)
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
path = _archive_files(archive_dir)[0]
|
||||||
|
await chat_archive.teardown(module_context)
|
||||||
|
|
||||||
|
data = _read_archive(path)
|
||||||
|
assert data["archive_stop_reason"] == "module_teardown"
|
||||||
|
assert data["stream_stopped_at"] is None
|
||||||
|
_assert_iso_datetime(data["archive_stopped_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfiguredOutput:
|
||||||
|
"""Configuration-dependent archive output behavior."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_data(self, archive_dir: Path) -> dict[str, Any]:
|
||||||
|
"""Disable pretty JSON for tests in this class."""
|
||||||
|
return _config_data(archive_dir, pretty_json=False)
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("module_lifecycle")
|
||||||
|
async def test_pretty_json_false_writes_compact_json(
|
||||||
|
self,
|
||||||
|
archive_dir: Path,
|
||||||
|
event_dispatcher: EventDispatcher,
|
||||||
|
) -> None:
|
||||||
|
"""pretty_json=False writes one compact JSON line plus a trailing newline."""
|
||||||
|
await event_dispatcher.dispatch(
|
||||||
|
EventType.STREAM_STARTED,
|
||||||
|
make_stream_started_event(
|
||||||
|
timestamp=datetime(2026, 5, 26, 12, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
content = _archive_files(archive_dir)[0].read_bytes()
|
||||||
|
assert content.startswith(b'{"archive_start_reason"')
|
||||||
|
assert b'\n "' not in content
|
||||||
|
assert content.endswith(b"\n")
|
||||||
|
assert _read_archive(_archive_files(archive_dir)[0])["event_count"] == 0
|
||||||
|
|
||||||
|
async def test_write_json_rejects_unserializable_objects(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The JSON writer surfaces unsupported value types."""
|
||||||
|
with pytest.raises(TypeError, match="Type is not JSON serializable: object"):
|
||||||
|
await chat_archive._write_json(
|
||||||
|
tmp_path / "bad.json",
|
||||||
|
{"bad": object()},
|
||||||
|
pretty_json=True,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user