Pilot on washanyanya.ru lost the cf7stg_dispatch cron event somewhere
between 21.04 and 01.05 — wp_next_scheduled('cf7stg_dispatch') returned
NULL, queue had 6 pending rows, sent-24h=0, failed=0, no error trail.
Settings/token were intact. The most likely cause is the PUC self-update
to 1.2.0: WordPress plugin upgrades replace files but do NOT call
register_activation_hook, so Activator::activate() never re-ran and any
cron event Activator::deactivate() had cleared (or that was lost for any
other reason) stayed missing.
Two-part fix:
1. Activator::ensure_scheduled() — extracted from the old private
schedule_events(), now public and idempotent. wp_next_scheduled()
short-circuits when events are already queued, so it's safe to call
on every request. Plugin::boot() invokes it on plugins_loaded — every
admin or front-end hit auto-heals lost cron events.
2. Activator::on_upgrade_complete() bound to upgrader_process_complete.
When WordPress finishes upgrading THIS plugin (matched via
plugin_basename + $hook_extra['plugins']), we re-run install_table()
(idempotent dbDelta), seed_silent_drop_options() (uses add_option,
preserves user values), and ensure_scheduled(). guard_requirements
is intentionally skipped — wp_die mid-upgrade would leave admin in
a broken state, and the host obviously meets requirements since the
old version is currently running.
Tests untouched (this is integration code wrapping WP-only APIs); full
suite still 126/126 green.
Changelog entry for 1.2.1 added to readme.txt.
Stable tag and CF7STG_VERSION will be bumped by release.sh.
Refs: cp-1lg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Installing/updating the plugin on client sites. Covers the two scenarios
(first-time manual install vs. auto-update after 1.2.0), SSH/admin install
paths, smoke tests, and common troubleshooting.
Gitea's POST /releases/{id}/assets endpoint returns an internal
/attachments/<uuid> URL. GET /releases/{id} returns the pretty
/<owner>/<repo>/releases/download/<tag>/<name> URL for the same asset.
Both resolve to the same bytes (verified: 207949 for 1.2.0 zip).
Pretty URL is preferred: filename is visible in the URL (better for
WP admin «View version details» and manual downloads), consistent
across Gitea instances (UUID format is git.netranking.ru-specific
Postgres attachment storage).
Also: switch `echo "$VAR" | jq` to `jq ... <<< "$VAR"`
herestring for CREATE_RESPONSE parsing — macOS /bin/echo can
interpret backslash-escapes in multi-line JSON bodies and corrupt
jq input.
wp-plugin-info.json for 1.2.0 patched in-place to use the pretty URL
for consistency with future releases. Both URLs continue to work;
domработница.рус's next PUC poll will refresh its local transient
to the pretty URL (version comparison stays 1.2.0 == 1.2.0, so no
update notice).
Closes cf7stg-pmf (T20).
Issue 1: `unzip -l "$ZIP_PATH" | grep -q ...` combined with set -o
pipefail silently fails when grep -q exits on first match and SIGPIPEs
the upstream unzip (which has not yet finished streaming the long file
list). pipefail sees unzip's non-zero exit and fails the pipeline, so
the guard thinks the file is missing. Fix: capture unzip -l output to
a variable once, then grep the string via herestring. The first two
sanity checks using `unzip -p` (short output, tight race window) were
not affected.
Issue 2: test_wp_plugin_info_json_download_url_contains_expected_asset_name
assumed Gitea asset URLs always contain the asset filename. This is
not true on git.netranking.ru, which returns /attachments/<uuid> — a
UUID-only URL. Relaxed the test to assert the URL is an HTTPS Gitea
URL; freshness of download_url is enforced indirectly via the
version-match test.
Post-mortem: 1.2.0 release was completed via a manual recovery script
after release.sh failed at the zip-sanity step. State on main is
consistent: tag v1.2.0 -> release commit, JSON commit directly on top
of release commit, JSON points to the live Gitea asset.
Critical: macOS BSD sed does not support \s character class.
The two substitutions in the version-bump step silently no-oped, the
grep fail-fast guard caught it (no remote damage), but release.sh
couldn't complete. Using POSIX [[:space:]] works on both BSD and GNU sed.
Also: extend test_git_archive_excludes_dev_files to cover .phpunit.result.cache,
.beads/, .claude/, dist/, .DS_Store — the test now fully enforces .gitattributes
export-ignore invariant.
register_update_checker() guards on is_file + class_exists so the plugin
degrades cleanly on installs that ship without the vendored lib (no update
notices, but plugin core remains functional).
Brainstorm session ran out of context — saving fully-formed design as
DRAFT so the next session can resume from spec self-review + finalize +
writing-plans without redoing the brainstorm.
Beads epic: cf7stg-5wy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds Classifier label 'drop' via hard-rule (no positive signals + short
latin name OR ≥3 placeholder fields) or score ≤ -5. Drop submissions are
silently skipped from the TG queue (or marked [БЫЛО БЫ DROPPED] when
dry-run mode is on, default after install). Settings page block + dashboard
widget counter. New filters: cf7stg_should_enqueue, cf7stg_hard_drop_rules.
Tests: 53 → 118 (+65). Spec: docs/superpowers/specs/2026-04-21-cf7stg-silent-drop-design.md.
Plan: docs/superpowers/plans/2026-04-21-cf7stg-silent-drop.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings::register_classifier_filters() registers priority-5 callbacks on
cf7stg_classifier_thresholds and cf7stg_hard_drop_rules so the UI options
actually influence Classifier behaviour. Called from Plugin::boot(). User
filters at priority 10 override these defaults as expected. Adds 4 tests
covering the wired behaviour and override priority. Also adds dry_run_drop?
to MessageFormatter::build() @param docblock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace date('Y-m-d') with wp_date('Y-m-d') in increment_drop_counter and
default_drop_counters to respect WordPress timezone settings.
- Update corresponding test assertions to use wp_date() for consistency.
- Clarify in docblock that increment_drop_counter is not atomic under concurrent
submissions; read-modify-write pattern via update_option is acceptable for
low-volume dashboard counters.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add DEFAULT_THRESHOLDS['drop'] = -5 and DEFAULT_HARD_DROP_RULES const
- Add evaluate_hard_drop(): fires when no positive signals + short latin name OR ≥3 placeholder fields
- Wire into classify(): drop overrides all other labels; synthetic reason explains trigger
- Add drop_reason_label() and 'drop' => '⛔ авто-дроп' to MessageFormatter::LABEL_MAP
- Update ClassifierTest/MessageFormatterTest to extend Cf7stgTestCase (filter isolation)
- Adjust pre-existing assertions that now correctly yield 'drop' instead of 'spam'
Fixes issues from code review in commit 249f195:
1. Tighten fixture assertions to exact counts (assertSame instead of assertGreaterThanOrEqual):
- test_count_placeholder_fields_screenshot_1_exact_count: expect 5 placeholders
- test_count_placeholder_fields_screenshot_2_exact_count: expect 5 placeholders
- test_count_placeholder_fields_screenshot_3_exact_count: expect 1 placeholder
2. Document SERVICE_FIELD_KEYS prefix-matching semantics in comment:
clarify that keys starting with _wpcf7 are skipped via strpos prefix match,
so explicit enumeration of every _wpcf7_* variant is unnecessary.
Test results: 75/75 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Header 'Version: 1.0.0' in the main plugin file was never bumped
while pilot fixes piled up (1.0.1 → 1.0.4 existed only in zip file
names). wp-admin → Plugins screen therefore kept showing 1.0.0
regardless of what was deployed.
Bumped:
- cf7-spam-to-telegram.php header Version 1.0.0 → 1.0.4
- CF7STG_VERSION constant 1.0.0 → 1.0.4
- readme.txt Stable tag 1.0.0 → 1.0.4
- readme.txt Changelog: entries for 1.0.2 / 1.0.3 / 1.0.4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pilot-surfaced issues in one pass:
1. PhoneParser regex used '\s' in the candidate character class, so a
digit on a following flattened line bled into the phone. '+7977
6277470\n1' (phone + trailing checkbox value) parsed as 12 digits,
producing '+797762774701'. Replaced '\s' with a literal space so
newlines/tabs stop the candidate.
2. Custom-themed forms don't use 'your-name' / 'your-email' keys
(washanyanya.ru has text-211 / text-212 / name_user). Added
FieldDetector::find_name + find_email as fallbacks. Classifier
and MessageFormatter now call them if no standard key matches,
so 'Александр' in text-211 is recognised as a cyrillic name and
scored / displayed accordingly.
3. FlamingoHelper copied '_from_email' into 'your-email' without
validating it. Flamingo derives that meta from whatever field it
considers "first"; on phone-first forms it ends up holding the
phone number. Now we only copy _from_email if it contains '@',
and dropped the _from_name shortcut entirely — FieldDetector
handles the name via the actual field values.
Tests: +1 FieldDetectorTest (13 cases); +2 PhoneParserTest cases for
newline/tab bleeds; +1 ClassifierTest case; +1 MessageFormatterTest
case for custom field keys. Full suite 53/53 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Dashboard widget is now registered unconditionally. Header + content
adapt: shows '⚠️ ошибки доставки' only when failed > 0, otherwise
plain title with queue state (pending / sent-24h / failed).
Pilot feedback: the conditional widget felt like the plugin was
missing — users expect a persistent status readout.
- Retro default window changed from 7 days to 0 (no limit). Most
pilot uses were 'dump everything since install', so 0 is the more
useful default. User can still narrow to N days in the form.
- Spec §10 + §14 updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pilot on washanyanya.ru exposed that Telegram Bot API rejects tel:
URLs in inline_keyboard with HTTP 400 "Wrong port number specified
in the URL". Queue was stuck on 5 items retrying the same failure
4 times each.
- MessageFormatter: always returns reply_markup = null. The phone
number is already rendered in the '📱 ...' line, and mobile
Telegram clients linkify it automatically.
- TelegramClient::is_permanent: treat all 4xx except 429 as permanent
client-side errors, so future payload-shape bugs surface in the
dashboard widget immediately instead of burning five retry cycles.
- MessageFormatterTest updated: the old test asserting tel: in
reply_markup is replaced with one asserting reply_markup is null
and the phone still appears in the text body.
- Spec §9 + §19 updated, §19's open question closed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flamingo's '_fields' meta stores only the list of field NAMES with
null values; the actual values live in separate per-field meta keys
prefixed with '_field_'. The old extract_fields() returned the
null-valued '_fields' array on the first branch and never fell
through to the '_field_*' collector, so every retro Telegram message
showed '(не указан)' / '(пусто)' for every slot.
Also collect '_from_name' / '_from_email' (set by Akismet) as
convenience mappings to 'your-name' / 'your-email' so the formatter's
standard slots are populated for most submissions even when the form
uses custom field names like text-211 / name_user.
Pilot on washanyanya.ru caught this (retro delivered empty skeletons).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>