Commit Graph
196 Commits
Author SHA1 Message Date
Jamie BruntonandClaude Opus 5 3a40651930 fix: never let a guessed threshold hold an account out of sending
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>
2026-08-11 09:49:40 +01:00
Jamie BruntonandClaude Opus 5 89eb4583f8 feat: flag accounts Reddit considers unestablished and re-test them
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>
2026-08-11 09:14:47 +01:00
Jamie BruntonandClaude Opus 5 c43e534cf1 refactor(dashboard): rethink settings, split out subreddits and accounts
The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:07:31 +01:00
jjbrunton 11b6300113 fix: revalidate authentication before bot start 2026-08-10 21:10:12 +01:00
jjbrunton 8ea1cf16d4 fix: persist dedicated physique targeting settings 2026-08-10 18:44:17 +01:00
jjbrunton 5c57ed337b fix: keep outreach funnel response rollout-compatible 2026-08-10 18:38:39 +01:00
jjbrunton 1515b3e291 fix: enforce current physique range in funnel 2026-08-10 18:35:22 +01:00
jjbrunton d8d635e1ee fix: reconcile physique targeting for queued posts 2026-08-10 18:23:23 +01:00
jjbrunton d0a8bd0e2f feat: expand outreach targeting and messaging 2026-08-10 17:31:05 +01:00
Jamie BruntonandClaude Opus 5 7defff422d fix: re-derive conversation authorship from history
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>
2026-08-10 15:31:19 +01:00
Jamie BruntonandClaude Opus 5 38d175e4a1 feat: push selected events to a phone over ntfy
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>
2026-08-10 13:55:00 +01:00
Jamie BruntonandClaude Opus 5 827eb3a1e0 feat: force a seller scan over active conversations from the inbox
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>
2026-08-10 13:42:55 +01:00
Jamie BruntonandClaude Opus 5 0a4e0064f2 feat: make the conversation kill controls settable from the dashboard
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>
2026-08-10 12:57:56 +01:00
Jamie BruntonandClaude Opus 5 4404ae75ff feat: kill conversations on off-platform and seller pushes
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>
2026-08-10 12:53:00 +01:00
Jamie BruntonandClaude Opus 5 ac12a3146a feat: recover missing conversations, fix media attachment, add manual inbox tab
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>
2026-08-10 11:03:33 +01:00
Jamie BruntonandClaude Opus 5 725b5fc4e6 feat: application-level read state and inbox triage for conversations
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>
2026-08-10 10:06:03 +01:00
Jamie BruntonandClaude Opus 4.8 c8689af04d feat: decouple manual image sharing from automation gating
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>
2026-08-10 09:18:37 +01:00
Jamie BruntonandClaude Fable 5 e984bf20ac feat: dashboard UI/UX overhaul from full review
Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:07:42 +01:00
Jamie BruntonandClaude Fable 5 82be197b79 feat: self-refresh Matrix tokens from the account browser session
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>
2026-08-10 08:56:17 +01:00
jjbrunton f34129666e fix: show media from stopped accounts 2026-08-09 22:31:51 +01:00
jjbrunton b573b88b08 feat: add conversation image previews and sharing 2026-08-09 22:28:41 +01:00
jjbrunton 488145340d fix: hide outbound media from gallery 2026-08-09 22:18:10 +01:00
jjbrunton c0cc661a2f Fix false autonomous safety handoffs 2026-08-09 21:27:53 +01:00
jjbrunton 84a133451f Pause automation after manual Reddit replies 2026-08-09 19:30:16 +01:00
jjbrunton bf474dfe10 Add consent-gated media exchange library 2026-08-09 19:24:38 +01:00
jjbrunton b8e4a1bb88 Harden conversation replies and prioritize newest candidates 2026-08-09 18:16:57 +01:00
jjbrunton f98c5c732d Treat Telegram references as seller signals 2026-08-09 17:09:41 +01:00
jjbrunton dc3fc6373b Fix missed conversation replies 2026-08-09 16:56:25 +01:00
jjbrunton 70c156e1f7 Pace candidate funnel to send capacity 2026-08-09 16:28:12 +01:00
jjbrunton 910576921b Allow replies during room creation cooldowns 2026-08-09 16:22:48 +01:00
jjbrunton c630303b47 docs: add agent codebase guide 2026-08-09 16:21:57 +01:00
jjbrunton 742c06d0c4 Fix account cooldown stats contract 2026-08-09 16:16:28 +01:00
jjbrunton e9083281a8 feat: configure ready outreach funnel limit 2026-08-09 16:13:58 +01:00
jjbrunton 22eb32e5bf Show per-account delivery status 2026-08-09 16:12:05 +01:00
jjbrunton 9b86cee9be Prioritize conversation replies 2026-08-09 16:04:39 +01:00
jjbrunton f55ae7ddf8 Add active outreach funnel dashboard 2026-08-09 15:54:05 +01:00
jjbrunton deaf86c364 Fix cross-account candidate delivery 2026-08-09 15:33:18 +01:00
jjbrunton df35648920 Add durable candidate funnel 2026-08-09 15:08:06 +01:00
jjbrunton 5abac31c28 Accept post creation time in message contract 2026-08-09 14:43:43 +01:00
jjbrunton 92c54feedc Filter detected sellers from conversations 2026-08-09 14:38:03 +01:00
jjbrunton d5ebdec076 Prevent location matching in bot messages 2026-08-09 14:37:23 +01:00
jjbrunton c21932389e Fix conversation automation counter validation 2026-08-09 14:27:20 +01:00
jjbrunton b73e305cf3 Add hybrid R4R media conversation mode 2026-08-09 11:45:45 +01:00
jjbrunton 927636ec8b Add married disclosure chat experiment 2026-08-09 11:36:41 +01:00
jjbrunton 0f76bcf59a Add per-subreddit R4R conversation modes 2026-08-09 11:30:32 +01:00
jjbrunton 23d4c3a320 Add varied messaging experiments 2026-08-09 10:28:55 +01:00
jjbrunton 43f459a024 Ground image analysis and add explicit hooks 2026-08-09 09:58:30 +01:00
jjbrunton 50f9f8a6aa Show Reddit post age in conversations 2026-08-09 09:54:53 +01:00
jjbrunton 7db60e8839 Support Reddit gallery posts 2026-08-09 09:42:15 +01:00
jjbrunton 5c9839926d Screen all older fallback candidates 2026-08-09 09:14:52 +01:00