mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:07:19 +01:00
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.
Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.
Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read
Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.
Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.
Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.
Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
125 lines
3.5 KiB
TOML
125 lines
3.5 KiB
TOML
[project]
|
|
name = "signal-bot"
|
|
version = "1.0.0"
|
|
description = "A Signal group bot: image commands and daily message tallies."
|
|
readme = "README.md"
|
|
license = "MIT"
|
|
license-files = ["LICENSE"]
|
|
requires-python = ">=3.14"
|
|
|
|
# Exact pins, newest stable, per the dependency-freshness rule. A caret range
|
|
# is "newest-compatible" by definition and gives the freshness gate nothing to
|
|
# compare against.
|
|
dependencies = [
|
|
"fastapi==0.141.1",
|
|
"httpx==0.28.1",
|
|
"python-dotenv==1.2.3",
|
|
"PyYAML==6.0.3",
|
|
"uvicorn==0.52.4",
|
|
"websockets==17.1",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"mypy==2.3.1",
|
|
"pre-commit==4.6.2",
|
|
"pytest==9.1.1",
|
|
"pytest-asyncio==1.4.0",
|
|
"pytest-cov==7.1.0",
|
|
"ruff==0.16.5",
|
|
"types-PyYAML==6.0.12.20260815",
|
|
]
|
|
|
|
[project.scripts]
|
|
signal-bot = "signal_bot.__main__:main"
|
|
|
|
[build-system]
|
|
requires = ["setuptools==80.9.0"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[tool.setuptools]
|
|
packages = ["signal_bot", "fake_signal"]
|
|
|
|
# ============================================================================
|
|
# RUFF
|
|
# ============================================================================
|
|
[tool.ruff]
|
|
target-version = "py314"
|
|
line-length = 100
|
|
|
|
[tool.ruff.lint]
|
|
select = ["ALL"]
|
|
ignore = [
|
|
"D203", # conflicts with D211, we use D211
|
|
"D213", # conflicts with D212, we use D212
|
|
"COM812", # formatter owns trailing commas
|
|
"ISC001", # formatter may create implicit string concatenation
|
|
]
|
|
fixable = ["ALL"]
|
|
|
|
# Verified empirically, not copied: with this block removed, ruff reports
|
|
# exactly these four rules across tests/ and nothing else. SLF001 was in
|
|
# the original list and fired zero times, so it is gone.
|
|
[tool.ruff.lint.per-file-ignores]
|
|
"tests/**/*.py" = [
|
|
"ARG", # fixture and mock params exist for their side effect
|
|
"D", # test names are the documentation
|
|
"PLR2004", # `assert count == 2` beats a named constant
|
|
"S101", # assert IS what tests do
|
|
]
|
|
|
|
[tool.ruff.lint.pylint]
|
|
# The test envelope builder takes 7 keyword-only fields, one per envelope
|
|
# attribute. Splitting it into a dataclass would only move the count.
|
|
max-args = 7
|
|
|
|
[tool.ruff.lint.pydocstyle]
|
|
convention = "google"
|
|
|
|
[tool.ruff.lint.flake8-quotes]
|
|
docstring-quotes = "double"
|
|
inline-quotes = "double"
|
|
|
|
[tool.ruff.lint.flake8-tidy-imports]
|
|
ban-relative-imports = "all"
|
|
|
|
[tool.ruff.format]
|
|
quote-style = "double"
|
|
docstring-code-format = true
|
|
|
|
# ============================================================================
|
|
# MYPY
|
|
# ============================================================================
|
|
[tool.mypy]
|
|
python_version = "3.14"
|
|
strict = true
|
|
warn_unreachable = true
|
|
disallow_any_explicit = false
|
|
|
|
# ============================================================================
|
|
# PYTEST / COVERAGE
|
|
# ============================================================================
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
asyncio_mode = "auto"
|
|
asyncio_default_fixture_loop_scope = "function"
|
|
addopts = [
|
|
"--cov=signal_bot",
|
|
"--cov=fake_signal",
|
|
"--cov-branch",
|
|
"--cov-report=term-missing",
|
|
"--cov-fail-under=100",
|
|
"--strict-markers",
|
|
]
|
|
|
|
[tool.coverage.run]
|
|
branch = true
|
|
# No omits. Every line of both packages is covered by tests.
|
|
|
|
[tool.coverage.report]
|
|
# No "pragma: no cover" escape hatch: nothing in this repo uses one, and
|
|
# leaving the pattern configured is an invitation to hide code from the
|
|
# 100% gate later. TYPE_CHECKING bodies are genuinely never executed.
|
|
exclude_lines = ["if TYPE_CHECKING:"]
|
|
show_missing = true
|