fix(notifications): accept discordapp.com webhook URLs (#1363)

Discord's "Copy Webhook URL" button emits discordapp.com URLs; both
  hostnames serve the same webhooks. The validation now accepts either
  prefix while keeping the check itself in place to catch the
  paste-the-wrong-thing error.
This commit is contained in:
maziggy
2026-05-16 08:00:39 +02:00
parent 5e5edd47f5
commit 5e88ce13f0
4 changed files with 84 additions and 4 deletions
+2
View File
@@ -18,6 +18,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Slice modal: pick the build plate (#1337, reported by @digitalskies)** — Slicing a plain STL through the integrated slicer always defaulted to whatever `curr_bed_type` lived in the chosen process preset (typically `Cool Plate`), which the slicer CLI then rejected for high-temp filaments with `Plate 1: Cool Plate does not support filament 1`. The user had no way to switch plates short of cloning the process preset in BambuStudio, which defeats the point of the in-app slicer. The Slice modal now exposes a `Build plate` dropdown with the six canonical BambuStudio / OrcaSlicer plates (Cool Plate, Cool Plate SuperTack, Engineering Plate, High Temp Plate, Textured PEI Plate, Smooth PEI Plate) plus an explicit `Auto (use process preset)` option that preserves the previous behavior. The dropdown sits between Process profile and Filament rows so it stays visible regardless of how many filament slots the picked plate uses (a long filament list would otherwise push it off the modal's `max-h-[85vh]` scroll viewport) and is **always enabled** — including when the user picks a Printer Preset Bundle from the top BundlePicker. When the user picks a specific plate, the new `bed_type` field on `SliceRequest` ([`backend/app/schemas/slicer.py`](backend/app/schemas/slicer.py)) flows through the dispatcher via two paths: (1) **resolved-preset path** — the route helper `_patch_process_bed_type` in [`backend/app/api/routes/library.py`](backend/app/api/routes/library.py) overwrites `curr_bed_type` on the resolved process JSON before forwarding to the sidecar (no preset cloning required); (2) **bundle dispatch path** — `slice_with_bundle` in [`backend/app/services/slicer_api.py`](backend/app/services/slicer_api.py) adds a `bedType` form field to the sidecar multipart so the sidecar can pass `--curr_bed_type` through to the CLI, which lets the override take effect even though Bambuddy can't patch the bundle's process JSON locally (the sidecar materialises it from the stored .bbscfg). Sidecar versions that don't recognise the field silently no-op — the slice still runs, just with the bundle's default plate; the slicer-API fork at maziggy/orca-slicer-api will need the matching change for the bundle path to take full effect. **i18n parity:** 8 new keys (`slice.bedType.{label,auto,coolPlate,coolPlateSuperTack,engineering,highTemp,texturedPEI,smoothPEI}`) added to all 8 locales — full German translation, English fallbacks elsewhere per project convention. **Regression tests:** 4 in [`test_slice_request_bed_type.py`](backend/tests/unit/test_slice_request_bed_type.py) (`bed_type` defaults to None, accepts the six canonical strings, rejects overlong input via the schema's `max_length=64`; `_patch_process_bed_type` overwrites an existing value, adds the field when missing, and returns the input unchanged for malformed JSON or non-dict roots), 4 in [`test_library_slice_api.py`](backend/tests/integration/test_library_slice_api.py) (resolved-preset path: with `bed_type` set, the sidecar receives `"curr_bed_type": "Textured PEI Plate"` in the presetProfile multipart part; without it, `curr_bed_type` stays out of the body entirely. bundle dispatch path: `bedType` form field carries the override through to the sidecar; omitting `bed_type` keeps the form field out of the request so the bundle's own `curr_bed_type` is preserved), 2 in [`SliceModal.test.tsx`](frontend/src/__tests__/components/SliceModal.test.tsx) (dropdown selection puts `bed_type` on the request; leaving it on Auto omits the field). 59 backend slice tests + 34 SliceModal tests pass; build and i18n parity script clean.
### Fixed
- **Discord notification provider now accepts legacy `discordapp.com` webhook URLs** ([#1363](https://github.com/maziggy/bambuddy/issues/1363), reported by @mrfoureyed) — Discord's "Copy Webhook URL" button emits `https://discordapp.com/api/webhooks/...` while Bambuddy's validation in [`backend/app/services/notification_service.py`](backend/app/services/notification_service.py) only accepted `https://discord.com/api/webhooks/...`, raising "Invalid Discord webhook URL" on paste. Both hostnames are operational on Discord's side and serve the same webhooks. The validation now accepts either prefix; the check itself is retained (vs. removing it as suggested) because it still catches the common paste-the-wrong-thing-into-the-Discord-field error. **Regression tests** in [`backend/tests/unit/services/test_notification_service.py`](backend/tests/unit/services/test_notification_service.py) — new `TestDiscordProvider` class pins both hostnames accepted, non-Discord hosts rejected, empty URL rejected.
- **Multi-color print archives reported near-zero cost (e.g. $0.01 for 110g) when only some AMS trays were mapped to inventory spools** ([#1344](https://github.com/maziggy/bambuddy/issues/1344), reported by @nicktags) — On an H2C running multi-color prints from Bambuddy with the global default filament cost set to $10/kg, the reporter's 110.3g archive showed $0.01 instead of ~$1.10. **Root cause:** `archive.cost` was set in two stages — first in [`backend/app/services/archive.py:1100-1114`](backend/app/services/archive.py) at archive creation (total grams × primary filament `cost_per_kg`, which produced the correct ~$1.10), then **overwritten** in [`backend/app/services/usage_tracker.py:618-621`](backend/app/services/usage_tracker.py) with `sum(r.cost for r in results)` where `results` only contains AMS trays mapped to a spool in Bambuddy's inventory. On a multi-color print where 3 of 4 used trays had no inventory spool, the sum only included the one tracked slot's tiny share, e.g. 1g × $10/kg = $0.01. The overwrite logic (#505, Feb 2026) was correct for fully-tracked single-color prints but silently corrupted multi-color archives when inventory was incomplete. The multi-color slicer feature that shipped in 0.2.4 ([`988c0055`](https://github.com/maziggy/bambuddy/commit/988c0055)) made this state common — many more users started running multi-filament prints from Bambuddy without first setting up inventory entries for every tray. **Fix:** the overwrite block in `usage_tracker.py` now charges any filament weight not covered by an inventory spool at the global default rate. New computation: `total_cost = sum(tracked_costs) + (archive.filament_used_grams - sum(tracked_weights)) × default_filament_cost / 1000`. Fully-tracked prints are unchanged (untracked grams = 0, top-up = 0). Partial-tracked prints get the missing slots' grams charged at the default rate, so the archive reflects the whole print. Same correction applied to the manual rescan path in [`backend/app/api/routes/archives.py`](backend/app/api/routes/archives.py) (`update_metadata` and `recalculate-costs` both now read `SUM(SpoolUsageHistory.weight_used)` alongside `SUM(cost)` and top up by the untracked delta). The pre-#1344 `archive.cost not overwritten with zero` regression test stays green — when `total_cost` after top-up is still 0 (no inventory match, no default rate set), the pre-existing catalog-based cost is preserved. **Regression tests** in [`backend/tests/unit/test_cost_tracking.py`](backend/tests/unit/test_cost_tracking.py): `test_archive_cost_includes_untracked_filament_at_default_rate` — 110g archive, only 10g tracked by inventory at $10/kg, default rate $10/kg → archive.cost = $1.10 (was $0.01 pre-fix; this is the exact reporter scenario). `test_archive_cost_fully_tracked_unchanged_by_topup` — when tracked weight ≥ archive grams, no top-up is applied and cost is unchanged from the pre-fix sum. All 14 cost-tracking tests + 182 in the wider usage-tracker / archives / cost-statistics suites pass; ruff clean.
- **Plate-detection calibration captured the wrong camera when an external camera was configured** ([#1359](https://github.com/maziggy/bambuddy/issues/1359), reported by @Andlar94) — On the reporter's A1 with an external RTSP / go2rtc camera enabled, every print start raised "Build plate not empty" no matter how perfectly they calibrated. **Root cause:** the runtime auto-check at print start in [`backend/app/main.py:1819`](backend/app/main.py) called `check_plate_empty(..., use_external=printer.external_camera_enabled, ...)` — honouring the external camera setting. The manual UI check + calibration routes in [`backend/app/api/routes/camera.py`](backend/app/api/routes/camera.py) declared `use_external: bool = False`, and the frontend client at [`frontend/src/api/client.ts`](frontend/src/api/client.ts) always sent `use_external=false` explicitly (the UI call sites in [`PrintersPage.tsx`](frontend/src/pages/PrintersPage.tsx) never passed `useExternal`). So calibration captured a frame from the **built-in** chamber camera and saved it as the reference; the runtime auto-check captured a frame from the **external** camera and diffed it against that built-in reference — a permanent difference well above any sane threshold, hence "not empty" on every print. **Fix:** the two routes now use `use_external: bool | None = None`, and after the printer row is loaded they derive the default as `bool(printer.external_camera_enabled and printer.external_camera_url and printer.external_camera_type)` — identical to the runtime path's logic and the service-layer gate at [`plate_detection.py:605`](backend/app/services/plate_detection.py). Centralising the default on the backend means any current or future caller automatically gets the right camera without having to remember the flag. The frontend client now only forwards `use_external` when the caller explicitly sets it (default omitted → backend decides), so the existing UI buttons immediately benefit. Power-user override path stays open: passing `?use_external=false` on a printer with an external camera still wins, so anyone who deliberately wants a built-in-camera reference can still get one. **Regression tests** in [`backend/tests/integration/test_camera_api.py`](backend/tests/integration/test_camera_api.py): `test_check_plate_defaults_use_external_when_external_camera_enabled` and `test_calibrate_plate_defaults_use_external_when_external_camera_enabled` pin the new default for a printer with external camera + URL + type set; `test_check_plate_defaults_use_external_false_when_external_camera_disabled` pins the built-in default for the no-external-camera case (the common path stays untouched); `test_calibrate_plate_explicit_use_external_false_overrides_default` pins the explicit-override escape hatch. All 11 plate-tagged camera integration tests pass; ruff clean; frontend build clean.
+4 -1
View File
@@ -418,7 +418,10 @@ class NotificationService:
if not webhook_url:
return False, "Webhook URL is required"
if not webhook_url.startswith("https://discord.com/api/webhooks/"):
if not (
webhook_url.startswith("https://discord.com/api/webhooks/")
or webhook_url.startswith("https://discordapp.com/api/webhooks/")
):
return False, "Invalid Discord webhook URL"
# Discord embed format for nicer messages
+26 -3
View File
@@ -1319,7 +1319,7 @@ class TestEncryptLegacyMigration:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch):
async def test_init_db_propagates_unexpected_migration_error(self, monkeypatch, tmp_path):
"""B3: an unexpected error from _migrate_encrypt_legacy_secrets must
surface (re-raise) instead of being silently swallowed.
@@ -1331,7 +1331,27 @@ class TestEncryptLegacyMigration:
rather than poking the inner read phase, because that is the contract
boundary the rest of the codebase relies on (init_db -> migration).
"""
from sqlalchemy import event
from sqlalchemy.ext.asyncio import create_async_engine
import backend.app.core.database as db_mod
from backend.app.core.config import settings
# init_db() uses the module-level `engine`, which was bound at import
# time to settings.database_url — that resolves to the real shared
# bambuddy.db at the project root (or, when DATABASE_URL is set, the
# configured Postgres). The autouse DATA_DIR fixture runs too late to
# influence either. Letting this test write to that real DB makes it
# (a) non-hermetic and (b) flake under `-n 30` with "database is
# locked" when two workers race on the file. Substitute an isolated
# per-test SQLite engine — and override settings.database_url for
# this test so the is_sqlite() / is_postgres() dialect guards inside
# run_migrations pick the SQLite path against this engine.
test_db_url = f"sqlite+aiosqlite:///{tmp_path / 'init_db_test.db'}"
test_engine = create_async_engine(test_db_url, echo=False)
event.listen(test_engine.sync_engine, "connect", db_mod._set_sqlite_pragmas)
monkeypatch.setattr(db_mod, "engine", test_engine)
monkeypatch.setattr(settings, "database_url", test_db_url)
async def boom():
raise RuntimeError("simulated startup-fatal failure")
@@ -1346,8 +1366,11 @@ class TestEncryptLegacyMigration:
monkeypatch.setattr(db_mod, "seed_spool_catalog", lambda: _noop_async())
monkeypatch.setattr(db_mod, "seed_color_catalog", lambda: _noop_async())
with pytest.raises(RuntimeError, match="simulated startup-fatal failure"):
await db_mod.init_db()
try:
with pytest.raises(RuntimeError, match="simulated startup-fatal failure"):
await db_mod.init_db()
finally:
await test_engine.dispose()
async def _noop_async():
@@ -670,6 +670,58 @@ class TestNotificationProviderTypes:
assert "image" not in payload
class TestDiscordProvider:
"""Discord webhook URL host validation (#1363)."""
@pytest.fixture
def service(self):
return NotificationService()
@pytest.mark.asyncio
async def test_discord_accepts_discord_com_url(self, service):
config = {"webhook_url": "https://discord.com/api/webhooks/123/abc"}
mock_response = MagicMock()
mock_response.status_code = 204
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
mock_get_client.return_value = mock_client
success, _ = await service._send_discord(config, "Title", "Body")
assert success is True
mock_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_discord_accepts_legacy_discordapp_com_url(self, service):
"""Discord's 'Copy Webhook URL' button emits discordapp.com URLs (#1363)."""
config = {"webhook_url": "https://discordapp.com/api/webhooks/123/abc"}
mock_response = MagicMock()
mock_response.status_code = 204
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
mock_get_client.return_value = mock_client
success, _ = await service._send_discord(config, "Title", "Body")
assert success is True
mock_client.post.assert_called_once()
@pytest.mark.asyncio
async def test_discord_rejects_non_discord_host(self, service):
config = {"webhook_url": "https://evil.example.com/api/webhooks/123/abc"}
success, message = await service._send_discord(config, "Title", "Body")
assert success is False
assert "Invalid Discord webhook URL" in message
@pytest.mark.asyncio
async def test_discord_rejects_empty_url(self, service):
success, message = await service._send_discord({"webhook_url": ""}, "Title", "Body")
assert success is False
assert "required" in message.lower()
class TestNtfyPriority:
"""Per-event ntfy Priority header (#990)."""