diff --git a/discord_rss_bot/main.py b/discord_rss_bot/main.py
index 197d31a..f6c02db 100644
--- a/discord_rss_bot/main.py
+++ b/discord_rss_bot/main.py
@@ -308,6 +308,52 @@ async def post_create_feed(
return RedirectResponse(url=f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}", status_code=303)
+@app.post("/attach_feed_webhook")
+async def post_attach_feed_webhook(
+ feed_url: Annotated[str, Form()],
+ webhook_dropdown: Annotated[str, Form()],
+ reader: Annotated[Reader, Depends(get_reader_dependency)],
+ redirect_to: Annotated[str, Form()] = "",
+) -> RedirectResponse:
+ """Attach an existing feed to one of the configured webhooks.
+
+ Args:
+ feed_url: The feed URL to update.
+ webhook_dropdown: The webhook name selected from the dropdown.
+ reader: The Reader instance.
+ redirect_to: Optional redirect URL after update.
+
+ Returns:
+ RedirectResponse: Redirect to index or feed page.
+
+ Raises:
+ HTTPException: If feed or webhook cannot be found.
+ """
+ clean_feed_url: str = urllib.parse.unquote(feed_url.strip())
+ selected_webhook_name: str = webhook_dropdown.strip()
+
+ try:
+ reader.get_feed(clean_feed_url)
+ except FeedNotFoundError as e:
+ raise HTTPException(status_code=404, detail="Feed not found") from e
+
+ webhook_url: str = ""
+ hooks = cast("list[dict[str, str]]", list(reader.get_tag((), "webhooks", [])))
+ for hook in hooks:
+ if hook.get("name") == selected_webhook_name:
+ webhook_url = hook.get("url", "").strip()
+ break
+
+ if not webhook_url:
+ raise HTTPException(status_code=404, detail="Webhook not found")
+
+ reader.set_tag(clean_feed_url, "webhook", webhook_url) # pyright: ignore[reportArgumentType]
+ commit_state_change(reader, f"Attach feed {clean_feed_url} to webhook {selected_webhook_name}")
+
+ redirect_url: str = redirect_to.strip() or f"/feed?feed_url={urllib.parse.quote(clean_feed_url)}"
+ return RedirectResponse(url=redirect_url, status_code=303)
+
+
@app.post("/pause")
async def post_pause_feed(
feed_url: Annotated[str, Form()],
diff --git a/discord_rss_bot/templates/index.html b/discord_rss_bot/templates/index.html
index 341ec38..6c12656 100644
--- a/discord_rss_bot/templates/index.html
+++ b/discord_rss_bot/templates/index.html
@@ -133,20 +133,41 @@
diff --git a/tests/test_main.py b/tests/test_main.py
index 6c88005..e40724d 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -559,6 +559,60 @@ def test_delete_webhook() -> None:
assert webhook_name not in response3.text, f"Webhook found in /webhooks: {response3.text}"
+def test_attach_feed_webhook_from_index() -> None:
+ """Feeds without attached webhook should be attachable from the index page."""
+ original_webhook_name = "original-webhook"
+ original_webhook_url = "https://discord.com/api/webhooks/111/original"
+ replacement_webhook_name = "replacement-webhook"
+ replacement_webhook_url = "https://discord.com/api/webhooks/222/replacement"
+
+ # Start clean.
+ client.post(url="/remove", data={"feed_url": feed_url})
+ client.post(url="/delete_webhook", data={"webhook_url": original_webhook_url})
+ client.post(url="/delete_webhook", data={"webhook_url": replacement_webhook_url})
+
+ # Add a webhook and a feed attached to it.
+ response = client.post(
+ url="/add_webhook",
+ data={"webhook_name": original_webhook_name, "webhook_url": original_webhook_url},
+ )
+ assert response.status_code == 200, f"Failed to add original webhook: {response.text}"
+
+ response = client.post(url="/add", data={"feed_url": feed_url, "webhook_dropdown": original_webhook_name})
+ assert response.status_code == 200, f"Failed to add feed: {response.text}"
+
+ # Remove the original webhook so feed becomes "without attached webhook".
+ response = client.post(url="/delete_webhook", data={"webhook_url": original_webhook_url})
+ assert response.status_code == 200, f"Failed to delete original webhook: {response.text}"
+
+ # Add a replacement webhook we can attach to.
+ response = client.post(
+ url="/add_webhook",
+ data={"webhook_name": replacement_webhook_name, "webhook_url": replacement_webhook_url},
+ )
+ assert response.status_code == 200, f"Failed to add replacement webhook: {response.text}"
+
+ # The feed should now be listed in "Feeds without attached webhook" section.
+ response = client.get(url="/")
+ assert response.status_code == 200, f"Failed to get /: {response.text}"
+ assert "Feeds without attached webhook:" in response.text
+ assert "/attach_feed_webhook" in response.text
+
+ # Attach the feed to the new webhook.
+ response = client.post(
+ url="/attach_feed_webhook",
+ data={"feed_url": feed_url, "webhook_dropdown": replacement_webhook_name, "redirect_to": "/"},
+ )
+ assert response.status_code == 200, f"Failed to attach feed to webhook: {response.text}"
+
+ reader = get_reader_dependency()
+ assert reader.get_tag(feed_url, "webhook", "") == replacement_webhook_url
+
+ # Cleanup.
+ client.post(url="/remove", data={"feed_url": feed_url})
+ client.post(url="/delete_webhook", data={"webhook_url": replacement_webhook_url})
+
+
def test_update_feed_not_found() -> None:
"""Test updating a non-existent feed."""
# Generate a feed URL that does not exist