feat: Discovery alert filters + notification reasons (v0.9.6)
- Add 4th "Discovery" tab in Alert Filters for member/topic follow notifications, with per-source enable toggle, independent event-type filters, and per-entity mute chips (mute specific members/topics without unfollowing) - Enrich notification event payloads with follow_mode, matched_member_name, matched_member_id, and matched_topic so each event knows why it was created - Dispatcher branches on payload.source for member_follow/topic_follow events, checking source-level enabled toggle, per-event-type filters, and muted_ids/muted_tags - Add _build_reason helper; ntfy messages append a "why" line (📌/👤/🏷) - EventRow in notification history shows a small italic reason line - Update How It Works: fix stale member/topic paragraph, add Discovery alerts item Authored-by: Jack Levy
This commit is contained in:
@@ -54,6 +54,7 @@ _FILTER_DEFAULTS = {
|
||||
|
||||
def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
|
||||
payload = event.payload or {}
|
||||
source = payload.get("source", "bill_follow")
|
||||
|
||||
# Map event type directly for document events
|
||||
if event.event_type == "new_document":
|
||||
@@ -69,6 +70,25 @@ def _should_dispatch(event, prefs: dict, follow_mode: str = "neutral") -> bool:
|
||||
all_filters = prefs.get("alert_filters")
|
||||
if all_filters is None:
|
||||
return True # user hasn't configured filters yet — send everything
|
||||
|
||||
if source in ("member_follow", "topic_follow"):
|
||||
source_filters = all_filters.get(source)
|
||||
if source_filters is None:
|
||||
return True # section not configured — send everything
|
||||
if not source_filters.get("enabled", True):
|
||||
return False # master toggle off
|
||||
# Per-entity mute checks
|
||||
if source == "member_follow":
|
||||
muted_ids = source_filters.get("muted_ids") or []
|
||||
if payload.get("matched_member_id") in muted_ids:
|
||||
return False
|
||||
if source == "topic_follow":
|
||||
muted_tags = source_filters.get("muted_tags") or []
|
||||
if payload.get("matched_topic") in muted_tags:
|
||||
return False
|
||||
return bool(source_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
|
||||
|
||||
# Bill follow — use follow mode filters (existing behaviour)
|
||||
mode_filters = all_filters.get(follow_mode) or {}
|
||||
return bool(mode_filters.get(key, _FILTER_DEFAULTS.get(key, True)))
|
||||
|
||||
@@ -240,6 +260,21 @@ def send_notification_digest(self):
|
||||
db.close()
|
||||
|
||||
|
||||
def _build_reason(payload: dict) -> str | None:
|
||||
source = payload.get("source", "bill_follow")
|
||||
mode_labels = {"pocket_veto": "Pocket Veto", "pocket_boost": "Pocket Boost", "neutral": "Following"}
|
||||
if source == "bill_follow":
|
||||
mode = payload.get("follow_mode", "neutral")
|
||||
return f"\U0001f4cc {mode_labels.get(mode, 'Following')} this bill"
|
||||
if source == "member_follow":
|
||||
name = payload.get("matched_member_name")
|
||||
return f"\U0001f464 You follow {name}" if name else "\U0001f464 Member you follow"
|
||||
if source == "topic_follow":
|
||||
topic = payload.get("matched_topic")
|
||||
return f"\U0001f3f7 You follow \"{topic}\"" if topic else "\U0001f3f7 Topic you follow"
|
||||
return None
|
||||
|
||||
|
||||
def _send_ntfy(
|
||||
event: NotificationEvent,
|
||||
topic_url: str,
|
||||
@@ -260,6 +295,10 @@ def _send_ntfy(
|
||||
if payload.get("brief_summary"):
|
||||
lines.append("")
|
||||
lines.append(payload["brief_summary"][:300])
|
||||
reason = _build_reason(payload)
|
||||
if reason:
|
||||
lines.append("")
|
||||
lines.append(reason)
|
||||
message = "\n".join(lines) or bill_label
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -75,7 +75,7 @@ def emit_bill_notification(
|
||||
user_id=follow.user_id,
|
||||
bill_id=bill.bill_id,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
payload={**payload, "follow_mode": follow.follow_mode},
|
||||
))
|
||||
count += 1
|
||||
if count:
|
||||
@@ -97,7 +97,11 @@ def emit_member_follow_notifications(
|
||||
if not followers:
|
||||
return 0
|
||||
|
||||
from app.models.member import Member
|
||||
member = db.get(Member, bill.sponsor_id)
|
||||
payload = _build_payload(bill, action_summary, action_category, source="member_follow")
|
||||
payload["matched_member_name"] = member.name if member else None
|
||||
payload["matched_member_id"] = bill.sponsor_id
|
||||
count = 0
|
||||
for follow in followers:
|
||||
if _is_duplicate(db, follow.user_id, bill.bill_id, event_type):
|
||||
@@ -125,14 +129,16 @@ def emit_topic_follow_notifications(
|
||||
from app.models.follow import Follow
|
||||
from app.models.notification import NotificationEvent
|
||||
|
||||
# Collect unique followers across all matching tags
|
||||
# Collect unique followers across all matching tags, recording the first matching tag per user
|
||||
seen_user_ids: set[int] = set()
|
||||
followers = []
|
||||
follower_topic: dict[int, str] = {}
|
||||
for tag in topic_tags:
|
||||
for follow in db.query(Follow).filter_by(follow_type="topic", follow_value=tag).all():
|
||||
if follow.user_id not in seen_user_ids:
|
||||
seen_user_ids.add(follow.user_id)
|
||||
followers.append(follow)
|
||||
follower_topic[follow.user_id] = tag
|
||||
|
||||
if not followers:
|
||||
return 0
|
||||
@@ -146,7 +152,7 @@ def emit_topic_follow_notifications(
|
||||
user_id=follow.user_id,
|
||||
bill_id=bill.bill_id,
|
||||
event_type=event_type,
|
||||
payload=payload,
|
||||
payload={**payload, "matched_topic": follower_topic.get(follow.user_id)},
|
||||
))
|
||||
count += 1
|
||||
if count:
|
||||
|
||||
Reference in New Issue
Block a user