rewrite: spec-driven rebuild around BotCat\ namespace (W1–W6)#11
Open
eric0324 wants to merge 8 commits into
Open
rewrite: spec-driven rebuild around BotCat\ namespace (W1–W6)#11eric0324 wants to merge 8 commits into
eric0324 wants to merge 8 commits into
Conversation
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
/tag …commands, tag-aware audience, welcome message)/l/{code}redirect, signed tokens, click logging with IP hash) + analytics-dashboard (KPIs, per-post table, CSV export with UTF-8 BOM)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.lockis 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)
/pushpath.src/Flex|Shortener|Analytics|License|Tagsbelongs to the release infra (W6 launch task, not in this PR).Test plan
composer installon a PHP 8.1+ machinecomposer test→ 260 / 570 assertions greencomposer lint→ 0 errors / 0 warningswp_botcat_*tables exist and the bot-cat admin menu appears/wp-json/botcat/v1/webhook→ follow the OA from a phone → confirm awp_botcat_subscribersrow withstatus = activewp_botcat_push_jobsrow, a queuedbotcat_push_job_runaction, and a LINE message arriving within ~30 sLicenseGatefilter (add_filter('botcat_is_pro', '__return_true')), confirm Tags / Short Links / License / Dashboard submenus appear/tag 訂閱 <keyword>) → publish to mapped category → only matching subscribers receive the push/l/<code>→ 301 redirect, click row written withip_hashpopulated