The profile check was allowed to set state 'unestablished', which made
isSendable() false, which made canAcceptPost() false — so a flagged
account never attempted an invitation and the functional probe could
never run. The one signal with real authority was gated behind the one
that is guessing, and because each check pushed retryAt forward by
another interval, accounts that send perfectly well were held
indefinitely.
Reddit does not publish the thresholds its chat gate applies, so ours are
a guess and are now advisory only. Sending is held solely by a live
rejection, tracked as sendingHeld on the account summary and keyed off by
the attention feed, the sending state and the Accounts page.
Reason strings now say "below the configured floor of N" rather than
"needs N", and the Accounts page distinguishes "Sending on hold" from
"Below thresholds", stating outright that the latter is our own number
and the account is still sending.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
New accounts cannot start chats until Reddit considers them established.
Until now the bot would burn a candidate on every invitation Reddit was
always going to reject, with nothing in the dashboard explaining why the
account looked idle.
Two signals feed the verdict. A periodic profile check reads the
account's own age, karma and email verification from about.json and
compares them against configurable thresholds — that is what flags a
brand new account before it wastes anything. A rejected invitation is
authoritative: Reddit has told us directly, so the account is held out of
sending with a backoff that doubles per rejection, and a delivered
invitation clears the flag outright.
An unestablished account takes no new posts (canAcceptPost), raises an
attention item, reports an `unestablished` sending state, and shows its
age/karma evidence on the Accounts page. An unchecked account is always
allowed through, so a first run is never blocked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rooms that Reddit returns without timeline events in /sync fell back to
isFromUs=false, so an unanswered opener of ours was projected as inbound
and surfaced in the dashboard's "Needs reply" queue. loadDetails already
paginates the authoritative history for lastMessageSnippet/Time, so take
isFromUs from the same newest message.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.
The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.
- Best-effort delivery: a rejecting or unreachable ntfy server is logged
and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
secrets: stripped before the settings record is written, never returned
by the API, which reports only `hasAuthToken`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a "Scan for sellers" button to the conversations header, backed by
POST /api/conversations/seller-scan.
The sweep reuses the existing seller detection rather than inventing a
second one: the configured keyword/domain list, the seller-link funnel
patterns, and the aiReplyScreening confidences all decide the verdict, so
a swept conversation lands in the state the live reply loop would have
given it. Being operator-forced, it deliberately ignores filterSellers
and aiReplyScreening.enabled.
AI is the last resort. Conversations are skipped when already flagged or
killed, settled for free by the deterministic rules, or skipped against a
durable sellerScreenedKey watermark when nothing new has arrived since
the last clean sweep. Only the remainder reaches the model, batched eight
at a time through a new screenConversationsForSellers, so the system
prompt and round trip are paid per batch instead of per conversation. A
classifier outage degrades the sweep instead of failing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `conversations` section was only reachable by editing config.yaml, which
production ignores: config is database-backed and the YAML is imported once,
when the database has not yet been seeded. `AdvancedConfigSchema` is also a
strict object, so a PUT carrying the section was rejected outright.
- Add `conversations` to the advanced config schema and payload, so it appears
in the dashboard's advanced JSON editor and survives a save.
- Apply the edited section field by field rather than replacing the objects,
and read the flags through to the live config from the account runtime, so a
save applies to a running account without a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seller detection on replies was a case-insensitive substring match over the
configured keyword list, and every hit landed in `human-required` alongside
genuine prospects that need a person. Off-platform pushes were only checked
for r4r policy modes, as were the under-18 signals.
Three layers now:
- New `off-platform-signals` module matches on normalized text (unicode
folding, zero-width stripping, leet mapping, separator removal), so
`0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms
need context: `snap` on its own stays a photo request, never a kill.
- New terminal `killed` automation status. It records the evidence, sets the
durable seller flag, supersedes the pending claim, and resolves triage to
`done`, so killed conversations leave every actionable queue. Off-platform
kills are independent of `reddit.filterSellers`.
- Optional AI screening of inbound replies behind
`conversations.aiReplyScreening` (off by default). Runs only after the
deterministic gates pass, memoizes per inbound event, and fails open.
Under-18 and in-person/contact-detail signals now apply to every policy mode
rather than r4r only, and remain human handoffs rather than kills.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.
Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.
Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".
Read state is now ours:
- conversation_read_state table (migration 19) plus
ConversationReadStateRepository, kept separate from conversation_states
so a read write can never be lost to mergeConversationState. The
watermark is monotonic, so a stale poll cannot resurrect an unread
badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
every conversation. Matrix's `unread` stays on the contract but the
dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
POST /conversations/bulk-read, with optimistic cache updates.
Inbox rework:
- List rows are a real grid (checkbox, open button, chip strip) instead of
a full-bleed absolute overlay button, which previously forced a
pointer-events escape hatch for anything interactive in a row. Rows use
aria-current rather than aria-pressed. Not a listbox: options must not
contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
the collapsed status word, plus a sort control (including oldest
unanswered), a per-account filter, age-coloured timestamps, and an
explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
open dialog so Escape dismisses the modal, not the conversation behind
it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
mount (deriving it from the live watermark made it vanish a second after
opening), triage actions in the header, the automation banner moved out
of the composer, day dividers with time-only stamps, and a flex column
so history scrolls in its own region instead of painting over the
composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
pill, so a background poll cannot reorder rows mid-triage.
Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.
Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expired Matrix bearers no longer pause an account until a new extension
dump is uploaded. BrowserMatrixTokenRefresher loads chat.reddit.com in
the account's persistent browser profile (whose Reddit cookies stay
valid), lifts the fresh bearer off the first authenticated Matrix
request, verifies it via whoami, swaps it into the live MatrixClient
and persists it to the account row.
Wired into the per-account runtime at boot verification, the auth-gate
authenticator, and ChatService's 401 retry path. A dump is now only
needed when the Reddit cookies themselves no longer authenticate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>