Skip to content

rewrite: spec-driven rebuild around BotCat\ namespace (W1–W6)#11

Open
eric0324 wants to merge 8 commits into
mainfrom
rewrite/openspec
Open

rewrite: spec-driven rebuild around BotCat\ namespace (W1–W6)#11
eric0324 wants to merge 8 commits into
mainfrom
rewrite/openspec

Conversation

@eric0324
Copy link
Copy Markdown
Owner

@eric0324 eric0324 commented May 26, 2026

Summary

Wipes the old WordPress/Telegram/Slack notification plumbing and rebuilds the plugin from the ground up against the OpenSpec capability set in openspec/specs/. PSR-4 + BotCat\ namespace; PHP 8.1+ / WordPress 7+.

Eight commits, one per delivery phase:

  • W1: plugin-foundation / line-channel / subscriber-management (+UI polish)
  • W2: push-notification-core — Action Scheduler, 500-recipient batches, 30/min rate limit, retry policy (30s/5min/30min), idempotency
  • W3: message-template (tokens + length guard) + push-rules (eligibility, exclusions, throttle, per-post opt-out)
  • W4: Pro — flex-message (Flex v2 builder + fallback to text) + tag-segmentation (LINE /tag … commands, tag-aware audience, welcome message)
  • W5: Pro — link-shortener (/l/{code} redirect, signed tokens, click logging with IP hash) + analytics-dashboard (KPIs, per-post table, CSV export with UTF-8 BOM)
  • W6: Pro — license-management (Polar validate/deactivate, 7-day grace period, LicenseGate::is_pro_active() as the single Pro check, webhook for instant deactivation, auto-update channel)

Tooling: composer (phpunit / brain-monkey / mockery / wpcs / phpcompat / Action Scheduler), phpunit.xml.dist, phpcs.xml.dist. vendor/ is gitignored; composer.lock is committed for deterministic installs.

Tests: 260 / 570 assertions all green.
Lint: WPCS clean (0 errors, 0 warnings).
Files: 160 PHP files under src/ + tests/, 11 capability specs covered.
Schema: v1.0.0 → v1.3.0 (7 tables).

Known compromises (commit messages have details)

  • LINE multicast can't per-recipient substitute → W5 token signer is wired but multicast clicks attribute via IP hash; per-subscriber attribution waits on a single-recipient /push path.
  • Flex visual editor is a server-side form, not the LINE Flex Simulator iframe.
  • Chart.js is not bundled yet; dashboard uses server-rendered tables (data layer is ready).
  • Free-zip exclusion of src/Flex|Shortener|Analytics|License|Tags belongs to the release infra (W6 launch task, not in this PR).
  • Subscriber list "Tags" column is unimplemented (W4 trimmed).
  • All integration tests intentionally skipped this round (no local MySQL); 260 tests are unit tests with Brain Monkey + Mockery.

Test plan

  • composer install on a PHP 8.1+ machine
  • composer test → 260 / 570 assertions green
  • composer lint → 0 errors / 0 warnings
  • Install the plugin on a real WordPress site, activate it on PHP < 8.1 → expect activation refusal with admin notice
  • Activate on supported runtime → confirm wp_botcat_* tables exist and the bot-cat admin menu appears
  • Channel Settings → paste LINE credentials → "Test connection" returns OA display name
  • LINE Developers console → paste /wp-json/botcat/v1/webhook → follow the OA from a phone → confirm a wp_botcat_subscribers row with status = active
  • Publish a post of an eligible type → confirm a wp_botcat_push_jobs row, a queued botcat_push_job_run action, and a LINE message arriving within ~30 s
  • Push Logs admin page → confirm sent/failed counts and per-recipient log
  • Push rules tab → set throttle to 30 min, publish three posts → confirm jobs schedule 0 / +30 / +60 min apart
  • Templates page → edit, save, confirm the rendered preview matches the next pushed message byte-for-byte
  • Pro: activate via LicenseGate filter (add_filter('botcat_is_pro', '__return_true')), confirm Tags / Short Links / License / Dashboard submenus appear
  • Pro: subscribe to a tag from LINE chat (/tag 訂閱 <keyword>) → publish to mapped category → only matching subscribers receive the push
  • Pro: hit /l/<code> → 301 redirect, click row written with ip_hash populated
  • Pro: paste a Polar license key on the License page → status flips to Active, plan + renewal date shown

eric0324 and others added 8 commits May 26, 2026 10:13
- Add OpenSpec instructions block to CLAUDE.md (managed by openspec update)
- Ignore .DS_Store, .claude/, AGENTS.md, openspec/ for local-only usage

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ities

Wipes the old WordPress/Telegram/Slack notification plumbing and lays down
the spec-driven foundation for the LINE Official Account push-notification
plugin defined in openspec/specs/.

W1 capabilities now covered by 61 unit tests (135 assertions, WPCS clean):

- plugin-foundation: PSR-4 autoload, PHP 8.1+ / WordPress 7+ activation
  guards, dbDelta schema with versioned migrations, 6-page admin menu
  (License page gated by Edition::is_pro), i18n loader, daily retention
  cron with botcat_log_retention_days filter.

- line-channel: ChannelSettings value object + repository, libsodium
  AUTH_KEY-derived encryption-at-rest for secret/token, timing-safe
  X-Line-Signature verification, REST POST botcat/v1/webhook that
  dispatches follow/unfollow events, /v2/bot/info test-connection action.

- subscriber-management: SubscriberRepository with chunked active_ids()
  generator + soft-delete mark_unfollowed + ON DUPLICATE KEY upsert,
  FollowHandler (with Action Scheduler retry on profile fetch failure),
  UnfollowHandler, scaffolded Subscribers admin page.

Tooling: composer.json with phpunit/brain-monkey/mockery/wpcs/phpcompat
+ woocommerce/action-scheduler. phpcs.xml.dist excludes rules that fight
PSR-4 (file naming) or modern style (Yoda, exhaustive Squiz docblocks).
vendor/ gitignored; composer.lock committed for deterministic installs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gs UI

Fleshes out the W1 admin surface so each spec scenario has an interactive
counterpart. AdminMenu becomes a thin registry; rendering responsibilities
move to the feature-owned classes injected from Plugin::register_hooks.

Subscribers:
- SubscribersQuery: immutable query value object with orderby allow-list,
  ASC/DESC clamp, per_page upper bound, from_request($_GET) parser
- SubscriberPage: search-result envelope (rows + total)
- SubscriberRepository::search(): WHERE / LIKE / ORDER BY / LIMIT / OFFSET
  composition; recent_logs_for_subscriber() joins push_logs + push_jobs
- PushLogEntry: typed view for the detail-page log rows
- SubscriberListTable (extends WP_List_Table): cb / display name link /
  truncated LINE id / status pill / followed_at, status filter, search,
  bulk delete with nonce check
- SubscriberDetailPage: identity card + last 20 deliveries
- SubscribersPage: front controller — dispatches to detail when
  ?subscriber= is set, otherwise renders the list

Channel:
- SettingsPage::render(): full page wrap, Settings API form, separate
  admin-post.php form for Test connection, success/error banner driven
  by the redirect query args from handle_test_connection

Foundation:
- AdminMenu accepts a renderer map; slugs without a callback fall back
  to a "not implemented yet" placeholder
- Plugin::register_hooks wires SubscribersPage and ChannelSettingsPage
  into the menu in a single place

Tests: +14 (75 total, 175 assertions). List table / detail page UI is
intentionally not unit-tested (depends on WP_List_Table and admin
context); their data paths are covered by SubscriberSearchTest,
RecentLogsTest, and SubscriberRepositoryTest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements push-notification-core (W2 spec): publish → enqueue →
chunk → multicast → log, with retries, rate limiting, idempotency,
and the admin surface to inspect what shipped.

Schema (v1.1.0):
- push_jobs gains post_type, is_test, triggered_at, last_error and
  composite indexes (post_id, status) / (status, triggered_at) for
  idempotency + admin list queries
- push_logs gains attempts, is_test and (job_id, status) composite

Trigger path:
- EligiblePostTypes: option-backed allow-list (default ['post']) plus
  botcat_eligible_post_types filter
- PostPublishObserver: hooks transition_post_status, lets only
  non-publish → publish transitions of eligible types through
- PushJobScheduler: writes pending job row + queues botcat_push_job_run

Pipeline:
- PushJobRunner: idempotency check (find_completed_for_post →
  skipped_duplicate), 500-recipient chunking, one pending push_logs
  row per recipient, RateLimiter-spaced botcat_push_batch_run actions
- BatchRunner: loads logs, calls MulticastClient, consults RetryPolicy:
    success    → mark logs sent + auto-finalize parent job
    retry      → re-enqueue with attempt+1, delay 30s/5min/30min
    fail       → mark logs failed
    abort_auth → mark job aborted_auth (subsequent batches no-op)
- MulticastClient: POST /v2/bot/message/multicast, classifies 401/403
  as auth, 429 with Retry-After honored, 5xx vs 4xx split
- RetryPolicy: pure decision table (30s/5min/30min, 3 attempts)
- RateLimiter: even-spacing scheduler (30 multicasts/minute ceiling)
- MessageBuilder: W2 default text message; W3 will override via
  botcat_push_messages filter

Admin UI:
- PushJobsListTable: index of jobs (post, triggered_at, sent/total,
  failed, status), 50 rows per page
- PushJobDetailPage: summary + per-recipient log table + "Resend to
  failed" form (POSTs to admin-post, resets failed rows to pending
  and queues a single batch with their ids)
- PushLogsPage: front controller wired into AdminMenu

Send-test action:
- SettingsPage adds a "Send test push" form taking comma/newline
  separated LINE user IDs; handle_send_test parses ids and calls
  PushJobScheduler::enqueue_test which flags is_test on both job
  and log rows (excluded from analytics)

Tests: +59 (134 total, 335 assertions). UI classes (PushJobsListTable,
PushJobDetailPage) are intentionally not unit-tested — they depend on
WP_List_Table and admin globals; their data paths are covered by the
repository + runner + retry tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two W3 capabilities: a templated message body with a server-
rendered preview, and a Push rules surface (eligible post types,
excluded categories, per-post opt-out, throttle).

message-template:
- TokenResolver: maps {title/excerpt/permalink/author/category/date/
  site_name} to post data; unknown tokens remain literal.
- ExcerptFallback: when post_excerpt is empty, strip HTML from
  post_content and truncate at 100 multibyte chars with `…`.
- TemplateRenderer: pure-function strtr substitution.
- TemplateRepository: option I/O + length status (green/yellow/red)
  + 5000-char hard limit; sanitize callback rejects oversize.
- TemplatePage: admin editor with token help, server-rendered preview
  against the latest eligible published post (or synthetic when none),
  Save disabled at red.
- MessageBuilder now renders the template at push time; the
  botcat_push_messages filter still allows W4 flex-message to override.

push-rules:
- ExcludedCategories: option-backed list; is_excluded(post_id) checks
  the post's full category list (not just the primary).
- PerPostOptOut: post meta `_botcat_send_push` with explicit 0/1
  precedence over the global default option botcat_default_push_on.
  Registers a sidebar meta box on eligible post types and a save_post
  handler with nonce + capability checks.
- PushThrottle: cascading scheduler — each call to next_slot() advances
  the stored "next allowed" timestamp by one interval, so three
  publishes within the window fan out to time(0), +1×, +2×.
- RulesSettingsTab: registers the four push-rules options
  (eligible_post_types, excluded_categories, throttle minutes, default
  opt-in) and renders the form. page/attachment are unselectable.
- SettingsPage now uses a tabbed layout (Channel / Push rules).
- PostPublishObserver consults ExcludedCategories + PerPostOptOut on
  top of the existing eligibility check.
- PushJobScheduler runs real publishes through PushThrottle (test
  pushes bypass).

Tests: +38 (172 total, 384 assertions). Template + rules pure-logic
classes have full unit coverage; UI classes (TemplatePage,
RulesSettingsTab) are manual acceptance — their persistence and
sanitization paths are covered through the repository tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two Pro-edition W4 capabilities, all gated behind
Edition::is_pro() so the Free build keeps the same surface.

tag-segmentation:
- Schema v1.2.0: tags (unique slug + keyword index) and
  subscriber_tags (composite PK + reverse tag_id index).
- Tag / TagRepository: CRUD with JSON mapped_categories,
  find_by_slug / find_by_keyword / find_for_categories;
  delete cascades the join rows.
- SubscriberTagRepository: subscribe (ON DUPLICATE KEY UPDATE),
  unsubscribe, tag_ids_for_subscriber, chunked
  active_line_user_ids_for_tags generator.
- TagCommand / TagCommandParser: parses
    /tag 訂閱 <keyword>  |  /tag subscribe <keyword>
    /tag 取消 <keyword>  |  /tag unsubscribe <keyword>
    /tag 列表           |  /tag list
- TagCommandHandler: executes the parsed command and returns
  the reply text (or null for non-commands).
- MessageEventHandler + ReplyClient: glue the LINE webhook's
  `message` events into the command flow and POST replies via
  the LINE reply endpoint.
- AudienceResolver: at push time, compute the union of tags
  whose mapped_categories intersect the post; restrict to
  subscribers of any matching tag, otherwise fall back to the
  full active audience. Injected as an optional dependency on
  PushJobRunner.
- WelcomeMessageBuilder + FollowHandler: brand-new follows on a
  Pro site with ≥1 tag receive a single welcome reply listing
  the available keywords; re-follows do not.
- TagsPage: admin CRUD form for tags (Pro only).

flex-message:
- FlexTemplate value object (hero source + default image URL +
  headline + body + up to 3 CTAs).
- FlexTemplateRepository: option-backed, caps CTAs at MAX_CTAS.
- HeroImageResolver: featured → first attached → default URL →
  null (omit hero block). HERO_NONE strategy skips entirely.
- FlexMessageBuilder: produces LINE Flex v2 bubble JSON with
  altText capped at 400 chars; renders headline/body/CTA url
  templates through TemplateRenderer.
- MessageBuilder: when Edition::is_pro, tries Flex first; any
  Throwable from the builder fires `botcat_flex_render_failed`
  and falls through to the text template so delivery survives.
- TemplatePage: gains a "Flex Message" tab on Pro builds with
  hero / headline / body / CTA fields.
- Plugin: listens for `botcat_flex_render_failed` and writes
  the message to the push job's last_error column for admin
  visibility.

Tests: +49 (221 total, 490 assertions). UI-only classes
(TagsPage, the Flex tab section of TemplatePage) are manual
acceptance; their persistence and parsing paths are covered
by the repository / parser / handler tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two Pro-edition W5 capabilities. All new wiring is gated on
Edition::is_pro(); the Free build is unchanged.

Schema v1.3.0:
- botcat_short_links (id, unique code, post_id, original_url, created_at)
- botcat_short_link_clicks (id, short_link_id, subscriber_id NULL,
  ip_hash, user_agent, referer, clicked_at) with (short_link_id,
  clicked_at) and ip_hash indexes.

link-shortener:
- ShortCodeGenerator: 6-char base-62 via random_int.
- ShortLinkRepository: find-or-create per post (same post + URL reuses
  the existing code), find_by_code, list_paged. Collision retry up to
  COLLISION_MAX.
- ClickLogRepository: writes ip_hash + truncated UA/referer (raw IP
  is never stored). count_total / count_unique(DISTINCT ip_hash).
- SubscriberTokenSigner: HKDF-derived key, HMAC-SHA256 truncated to
  16 chars, base64url payload \`<id>.<sig>\`. Timing-safe verify
  returns null on any malformed/tampered input. Wired up for future
  push-API attribution — LINE multicast can't per-recipient
  substitute, so W5 multicast batches send the same URL to all 500
  recipients and attribute anonymously via ip_hash.
- ClickRedirectHandler: code lookup, optional token verify, salted
  SHA-256 IP hash, click insert, 301 + Location (or 404).
- RewriteRuleRegistrar: registers \`^l/([0-9A-Za-z]{6})/?$\` rewrite
  + query var, dispatches on template_redirect. Pro only.
- LinkRewriter: rewrites the post's own permalink to its short URL
  in text bodies AND Flex JSON (via encode/decode round-trip).
- MessageBuilder: optional LinkRewriter post-processes both text
  and Flex outputs.
- ShortLinksPage: admin list — code, target post, created at,
  total clicks, unique clicks.

analytics-dashboard:
- AnalyticsWindow: 7/30/90 day presets, default 30, clamped.
- DashboardKpiCalculator: active subscribers, deliveries-in-window,
  clicks-in-window, CTR%, median click latency.
- PostPerformanceQuery: per-post delivered/failed/clicks/CTR within
  the window, default sort by CTR descending per spec.
- SubscriberGrowthQuery: daily active series across the window.
- CsvExporter: UTF-8 with BOM so Excel auto-detects CJK without
  mojibake.
- DashboardPage: KPI cards + window selector (persisted in user
  meta) + growth + per-post table + CSV download. Server-side HTML
  (no Chart.js bundle yet — data layer in place).
- Plugin: on Pro the bot-cat top-level landing is the Dashboard;
  AdminMenu adds Short Links submenu.

Tests: +29 (250 total, 559 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Pro entitlement layer that gates every Pro feature.

License repository:
- LicenseStatus: 5-state constant (inactive/active/expired/invalid/pending).
- LicenseCache: typed snapshot of Polar's last response (entitled,
  verified_at, last_failure_at, consecutive_failures, plan_name,
  renews_at) — single source of truth, stored as JSON in
  botcat_license_cache.
- LicenseRepository: owns the three options
  (botcat_license_key / _status / _cache); no other class reads them.

Polar integration:
- PolarClient: wp_remote_post wrapper for /v1/license-keys/validate
  and /v1/license-keys/deactivate. Classifies 401/403/404 as invalid,
  5xx as upstream, network failure as a typed result.
- PolarValidationResult: ok flag, entitled flag, plan/renews_at, and
  error_code/_message for failures.

Status evaluator + gate (the contract Pro features consult):
- LicenseStatusEvaluator: pure function — verified_at within 7 days
  → ACTIVE, beyond 7 days → EXPIRED. Both network outages and revoked
  entitlements flow into the same EXPIRED path.
- LicenseGate::is_pro_active(): the ONE helper every Pro feature uses.
  Plugin hooks LicenseGate::filter_is_pro into the existing
  botcat_is_pro filter so Edition::is_pro() now reflects license
  state automatically. Pro classes still call $edition->is_pro();
  the gate fans the truth in.

Daily revalidation:
- LicenseRevalidationCron: pulls Polar with the stored key, refreshes
  cache on entitled=true, bumps consecutive_failures otherwise.
  Transitions status to EXPIRED once the cache crosses the 7-day grace
  threshold even though daily polling continues.
- Plugin schedules `botcat_license_revalidate` once per day on Pro
  builds.

Polar webhook:
- PolarWebhookEndpoint: REST POST /botcat/v1/polar-webhook with
  HMAC-SHA256 signature verification against
  botcat_polar_webhook_secret. subscription.cancelled, .refunded,
  and license_key.revoked all instantly mark the cache expired.
  Forged signature → 403 + no state change.

UI:
- LicensePage: status card + activation / deactivation forms.
- LicenseActivationHandler: admin-post handlers that call PolarClient,
  update the repository, and redirect back with a status banner.

Auto-update channel:
- UpdateChannel: pre_set_site_transient_update_plugins + plugins_api
  hooks, fetches a custom manifest with the license key so lapsed
  sites stop seeing the Pro update. Free builds skip this and let
  WordPress.org handle updates.

Tests: +10 (260 total, 570 assertions). The HTTP-bound classes
(PolarClient, PolarWebhookEndpoint, UpdateChannel) and admin UI are
manual acceptance; the gate, evaluator, and repository have full
unit coverage.

All six capability weeks (W1-W6) are now feature-complete:
plugin-foundation / line-channel / subscriber-management →
push-notification-core → message-template / push-rules →
flex-message / tag-segmentation → link-shortener /
analytics-dashboard → license-management.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant