thth 2.3.0__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thth-2.3.0 → thth-2.3.1}/PKG-INFO +2 -2
- {thth-2.3.0 → thth-2.3.1}/README.md +1 -1
- {thth-2.3.0 → thth-2.3.1}/mcp/server.py +11 -1
- {thth-2.3.0 → thth-2.3.1}/tests/test_adapter_boundary.py +49 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_engagements.py +41 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_send.py +114 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_send_cli.py +50 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_thread_read.py +125 -3
- {thth-2.3.0 → thth-2.3.1}/tests/test_threads_read_permissions.py +6 -3
- thth-2.3.1/thth/VERSION +1 -0
- {thth-2.3.0 → thth-2.3.1}/thth/adapters/base.py +36 -0
- {thth-2.3.0 → thth-2.3.1}/thth/cli.py +20 -0
- {thth-2.3.0 → thth-2.3.1}/thth/core.py +100 -25
- {thth-2.3.0 → thth-2.3.1}/thth/runs.py +9 -1
- {thth-2.3.0 → thth-2.3.1}/thth/thread_read.py +80 -9
- thth-2.3.0/thth/VERSION +0 -1
- {thth-2.3.0 → thth-2.3.1}/.gitignore +0 -0
- {thth-2.3.0 → thth-2.3.1}/LICENSE +0 -0
- {thth-2.3.0 → thth-2.3.1}/accounts.example/bluesky.json +0 -0
- {thth-2.3.0 → thth-2.3.1}/accounts.example/mastodon.json +0 -0
- {thth-2.3.0 → thth-2.3.1}/accounts.example/threads.json +0 -0
- {thth-2.3.0 → thth-2.3.1}/bin/thth +0 -0
- {thth-2.3.0 → thth-2.3.1}/bin/thth-remote +0 -0
- {thth-2.3.0 → thth-2.3.1}/bin/thth-run +0 -0
- {thth-2.3.0 → thth-2.3.1}/pyproject.toml +0 -0
- {thth-2.3.0 → thth-2.3.1}/skills/thth/SKILL.md +0 -0
- {thth-2.3.0 → thth-2.3.1}/systemd/thth@.service +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/__init__.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/conftest.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/fixtures/build_injection_eval.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/fixtures/callback/worker_responses.json +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/fixtures/injection_eval.json +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/fixtures/umami-bile.md +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/__init__.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_oauth_server.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_push_adapter.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_redirect_server.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_search_adapter.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_threads_reader.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/fake_threads_writes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/hold_lock.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/hold_repo_lock.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/helpers/worker_probe.mjs +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_account_name_check.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_account_report.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_accounts_dir.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_after_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_api_error_body.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_app_env_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_approval.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_approve_cmd.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_ask.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_fifth_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_fourth_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_second_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_atlas_third_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit1_fixes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit2_fixes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_counterexamples.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_m1_semantics.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_n1_depth.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_b_r1_closure.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_v201.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_audit_v23_details.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_auth.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_auth_identity_required.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_bluesky_adapter.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_bluesky_secret_scrub.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_board_needs_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_board_running.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_bundle.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_charcount.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_cli_entrypoint.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_cli_help.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_collect.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_collect_runs.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_collect_sent.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_comparability.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_control_chars_refused.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_conversation_paging.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_count_for.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_docs_links.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_doctor.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_doctor_all_permissions.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_doctor_next_step.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_duplicate_front_matter_keys.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_editorial_audit.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_editorial_n3_n4.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_external_acceptance.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_fake_api.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_form_spec.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_form_spec_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_forms.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_fresh_install.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_front_matter_injection.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_hypotheses.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_hypothesis_boundaries.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_hypothesis_units.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_impact_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_inbox_granted_scopes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_independent_thread_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_independent_topic_review.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_independent_topic_review_r2.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_inflight.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_jst.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_lint.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_list_projections.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_llm_trial.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_llm_trial_engage.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_lock.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_m2_independent.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_maintain.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_mastodon_adapter.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_mastodon_visibility.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_max_per_run.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_mcp.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_mcp_robustness.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_measured.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_media_accounts.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_media_next_steps.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_media_roundtrip.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_mismatch_diagnostics.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_n5_reply_null_element.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_n6_replies_incomplete_ledger.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_n7_doctor_error_body.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_no_limits.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_observation_provenance.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_packaging.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_preview.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_production_mode.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_queue.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_quiet_hours.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_r2_r4_r5.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_reason_adoption.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_redact.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_redirect_token_leak.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_refresh.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_refresh_destination.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_rejudge_new.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_rejudge_r2_r3.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_replies.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_replies_refresh.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_repo_lock.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_repo_sync.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_reverse_audit.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_case_id_cluster_m2.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_case_id_cluster_r2.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_evidence_account.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_form_spec_and_case_id.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_provenance.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_review_records.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_revoke.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_roundtrip.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_run_missing_env.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_run_record_failclosed.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_runs_collected.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_runs_read_rows.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_schedule_multiple.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_schedule_view.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_scopes_source_requested.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_search_engage_targets.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_secret_value_redact.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_secrets_grep.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_security_small_fixes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_select.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_selfupdate.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_send_media.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_send_visibility.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_sent_integrity.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_server_json.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_site.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_skill_doc.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_systemd.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_thread_publish.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_threadrun.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_threadshape.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_thth_root.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_token_exists.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_token_scopes_record.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_token_set.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_advice.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_models.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_review_fixes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_shelf_observers.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topic_store.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topics_record_concurrent.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_topics_tool.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_trial_frictions.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_v21b_approved_writes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_value_domains_shown.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_version.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_where_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_who_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_writeback_rebase.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/tests/test_writeback_validate.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/__init__.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/__main__.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/account_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/account_report.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/accounts.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/adapters/__init__.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/adapters/bluesky.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/adapters/mastodon.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/adapters/threads.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/after_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/appenv.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/approval.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/ask.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/ask_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/bundle.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/collect.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/doctor.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/engagements.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/forms.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/httpsafe.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/inflight.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/jst.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/lint.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/lock.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/maintain.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/measured.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/oauth.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/postid.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/queuefile.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/redact.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/replies.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/report.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/retract_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/scopes.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/secrets_fs.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/select.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/selfupdate.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/sent.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/systemd_gen.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/threadrun.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/threads_read_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/threadshape.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/threadthrow.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/topic_advice.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/topic_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/topic_models.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/topic_store.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/topics.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/where_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/who_cli.py +0 -0
- {thth-2.3.0 → thth-2.3.1}/thth/writeback.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: thth
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: 承認済みの投稿だけを投げる門(Threads・Bluesky・Mastodon)。人が承認し、記録は利用者の git に残る。
|
|
5
5
|
Project-URL: Homepage, https://thth.me
|
|
6
6
|
Project-URL: Source, https://github.com/aokings/thth
|
|
@@ -45,7 +45,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
45
45
|
|
|
46
46
|
## いまの状態(2026-09-13)
|
|
47
47
|
|
|
48
|
-
版 **2.3.
|
|
48
|
+
版 **2.3.1**。全件テスト **2287 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
|
|
49
49
|
|
|
50
50
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
51
51
|
|
|
@@ -21,7 +21,7 @@ watchtower(`~/Developer/watchtower`)の隣に同じ流儀で並べる。watc
|
|
|
21
21
|
|
|
22
22
|
## いまの状態(2026-09-13)
|
|
23
23
|
|
|
24
|
-
版 **2.3.
|
|
24
|
+
版 **2.3.1**。全件テスト **2287 件**(`python -m pytest tests/ -q -n auto -p no:cacheprovider`・1 skip・rc=0)。
|
|
25
25
|
|
|
26
26
|
**媒体**: Threads(稼働)・Bluesky・Mastodon(同席用の台帳あり・未稼働)。
|
|
27
27
|
|
|
@@ -180,7 +180,17 @@ TOOLS = [
|
|
|
180
180
|
"type": "object",
|
|
181
181
|
"properties": {
|
|
182
182
|
"account": {"type": "string", "description": "account 名"},
|
|
183
|
-
"post_id": {
|
|
183
|
+
"post_id": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"description": (
|
|
186
|
+
"枝の根の post_id。各 message の already_replied は"
|
|
187
|
+
" 3 値(T7-3): object(source 付き。ledger=絡みの台帳/"
|
|
188
|
+
"queue=queue の下書き/thread=枝の中の自分の返信)=返した"
|
|
189
|
+
"/false=台帳・queue・枝のどこにも見当たらない"
|
|
190
|
+
"(「返していない」の確定ではない)"
|
|
191
|
+
"/null=台帳か queue が読めず判らない"
|
|
192
|
+
"(理由は provenance.ledgers_unreadable)"),
|
|
193
|
+
},
|
|
184
194
|
"since": {"type": "string", "description": "この時刻以降だけ"},
|
|
185
195
|
"max_messages": {"type": "integer",
|
|
186
196
|
"description": "読む上限(既定 200・上限 1000)"},
|
|
@@ -204,6 +204,55 @@ def test_TB0_conversationはmediumとauthor_keyを足すが既存の鍵は変え
|
|
|
204
204
|
assert row["author_key"] == adapter_base.author_key("threads", "someone")
|
|
205
205
|
|
|
206
206
|
|
|
207
|
+
# --- T7-1: normalize_message() ----------------------------------------------
|
|
208
|
+
# `thth/thread_read.py`が`conversation()`・`fetch_post()`の生の行を読む直前に
|
|
209
|
+
# 通す変換(設計「自分の泉」§2.1)。**`conversation()`自身は変えない**——ここは
|
|
210
|
+
# 変換の単体テストだけ(`thread_read`側の配線は`tests/test_thread_read.py`)。
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def test_T71_normalize_messageはidをmessage_idに写しreplied_toのdictをidに開く():
|
|
214
|
+
row = {"id": "R1", "username": "someone", "text": "やあ",
|
|
215
|
+
"timestamp": "2026-09-12T10:00:00+0000",
|
|
216
|
+
"replied_to": {"id": "P1"}, "root_post": {"id": "P0"}}
|
|
217
|
+
out = adapter_base.normalize_message(row, medium="threads")
|
|
218
|
+
assert out["message_id"] == "R1"
|
|
219
|
+
assert out["replied_to"] == "P1"
|
|
220
|
+
assert out["root_post"] == "P0"
|
|
221
|
+
assert out["author_key"] == adapter_base.author_key("threads", "someone")
|
|
222
|
+
# 他の鍵はそのまま素通し。
|
|
223
|
+
assert out["text"] == "やあ" and out["timestamp"] == "2026-09-12T10:00:00+0000"
|
|
224
|
+
# 元の行は書き換えない(コピーを返す)。
|
|
225
|
+
assert "message_id" not in row
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_T71_normalize_messageはreplied_toが文字列ならそのまま無ければNoneのまま():
|
|
229
|
+
already_str = adapter_base.normalize_message(
|
|
230
|
+
{"message_id": "R2", "username": "u", "replied_to": "P1", "root_post": "P1"},
|
|
231
|
+
medium="bluesky")
|
|
232
|
+
assert already_str["replied_to"] == "P1" and already_str["root_post"] == "P1"
|
|
233
|
+
|
|
234
|
+
無し = adapter_base.normalize_message(
|
|
235
|
+
{"id": "R3", "username": "u"}, medium="mastodon")
|
|
236
|
+
assert 無し.get("replied_to") is None and 無し.get("root_post") is None
|
|
237
|
+
assert 無し["message_id"] == "R3"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_T71_normalize_messageは既にmessage_idやauthor_keyがあれば変えない():
|
|
241
|
+
already = {"message_id": "R4", "id": "RAW4", "username": "someone",
|
|
242
|
+
"author_key": "0" * 16, "replied_to": {"id": "P1"}}
|
|
243
|
+
out = adapter_base.normalize_message(already, medium="threads")
|
|
244
|
+
assert out["message_id"] == "R4" # `id` ではなく既存の値のまま
|
|
245
|
+
assert out["author_key"] == "0" * 16 # 上書きしない
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_T71_normalize_messageは冪等():
|
|
249
|
+
row = {"id": "R5", "username": "someone", "replied_to": {"id": "P1"},
|
|
250
|
+
"root_post": {"id": "P1"}}
|
|
251
|
+
once = adapter_base.normalize_message(row, medium="threads")
|
|
252
|
+
twice = adapter_base.normalize_message(once, medium="threads")
|
|
253
|
+
assert once == twice
|
|
254
|
+
|
|
255
|
+
|
|
207
256
|
def _select_1件(tmp_path, *, media: str, topic: str, char_limit=None):
|
|
208
257
|
"""`select` に 1 本だけ流して、落ちた理由を返す(時刻の関門より手前を見る)。"""
|
|
209
258
|
path = tmp_path / "a.md"
|
|
@@ -21,6 +21,7 @@ from thth import accounts as accounts_mod
|
|
|
21
21
|
from thth import core
|
|
22
22
|
from thth import engagements as engagements_mod
|
|
23
23
|
from thth import lint as lint_mod
|
|
24
|
+
from thth import runs as runs_mod
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _row(**over):
|
|
@@ -151,6 +152,46 @@ def test_c_reply_to付きの原稿を出すと台帳に1行_author_keyも写る(
|
|
|
151
152
|
assert forbidden not in row, row
|
|
152
153
|
|
|
153
154
|
|
|
155
|
+
# --- T7-2: queue の門(`_throw_chosen`)も best-effort に `fetch_post` を試す ---
|
|
156
|
+
# kopicha の実物の所見が挙げた「queue の門でも同じ best-effort を入れる」の側。
|
|
157
|
+
# `fake_threads_server`(`tests/test_fake_api.py`)は POST しか持たない偽サーバ
|
|
158
|
+
# なので、`fetch_post()` の `GET /v1.0/<reply_to>` は 501 になる——「読めなかった」
|
|
159
|
+
# を自然に再現できる(`AdapterError` に化けて `author_key` は `None` のまま・
|
|
160
|
+
# 公開は止まらない)。
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_c_reply_to_author_key省略でもfetch_postをbesteffortに試し失敗してもauthor_keyはnullのまま(
|
|
164
|
+
isolated_account_factory):
|
|
165
|
+
account = isolated_account_factory(production=True)
|
|
166
|
+
text = make_queue_text(fm_overrides={"reply_to": "THIRDPARTY777", "topic": None})
|
|
167
|
+
os.makedirs(account["queue_dir"], exist_ok=True)
|
|
168
|
+
path = os.path.join(account["queue_dir"], "with-reply-no-key.md")
|
|
169
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
170
|
+
f.write(text)
|
|
171
|
+
commit_and_push_path(path, message="test: with-reply-no-key")
|
|
172
|
+
|
|
173
|
+
with fake_threads_server({"create": "ok", "publish": "ok"}) as base_url:
|
|
174
|
+
def factory(_cfg, _token):
|
|
175
|
+
return _adapter(base_url)
|
|
176
|
+
|
|
177
|
+
result = core.throw_once(account["name"], production_flag=True,
|
|
178
|
+
adapter_factory=factory, now=NOW)
|
|
179
|
+
|
|
180
|
+
assert result.exit_code == 0 and result.action == "post", result
|
|
181
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
182
|
+
rows = engagements_mod.records(cfg, account["name"])
|
|
183
|
+
assert len(rows) == 1, rows
|
|
184
|
+
assert rows[0]["author_key"] is None # 偽サーバに GET が無いので best-effort は失敗する
|
|
185
|
+
assert rows[0]["reply_to"] == "THIRDPARTY777"
|
|
186
|
+
|
|
187
|
+
state_dir = accounts_mod.state_dir_for(account["name"])
|
|
188
|
+
run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == result.post_id]
|
|
189
|
+
assert len(run_rows) == 1, run_rows
|
|
190
|
+
assert run_rows[0]["status"] == "ok"
|
|
191
|
+
assert run_rows[0]["engagement_write_failed"] is False
|
|
192
|
+
assert run_rows[0]["engagement_author_lookup_failed"] is True
|
|
193
|
+
|
|
194
|
+
|
|
154
195
|
def test_c_reply_to無しなら0行(isolated_account_factory):
|
|
155
196
|
account = isolated_account_factory(production=True)
|
|
156
197
|
write_queue_file(account["queue_dir"], "no-reply.md")
|
|
@@ -11,7 +11,10 @@ import os
|
|
|
11
11
|
from thth import accounts as accounts_mod
|
|
12
12
|
from thth import approval as approval_mod
|
|
13
13
|
from thth import core
|
|
14
|
+
from thth import engagements as engagements_mod
|
|
14
15
|
from thth import inflight as inflight_mod
|
|
16
|
+
from thth import runs as runs_mod
|
|
17
|
+
from thth.adapters import base as adapter_base
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
def _digest_for(account_name: str, text: str, *, reply_to=None, topic=None) -> str:
|
|
@@ -136,3 +139,114 @@ def test_inflightが残っていれば何もしない(isolated_account_factory):
|
|
|
136
139
|
r = core.send_once(account["name"], text="本文", production_flag=True,
|
|
137
140
|
adapter_factory=lambda *_: _Spy())
|
|
138
141
|
assert r.exit_code == 1 and r.action == "inflight"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- T7-2: `send --reply-to` も絡みの台帳に 1 行書く(設計「自分の泉」§4) --------
|
|
145
|
+
# kopicha の実物の所見: `_throw_chosen()`(queue の門)は絡みの台帳に書くが、
|
|
146
|
+
# 同席の様態(`send_once()`)には配線が無く、`thth send --reply-to <id>` で
|
|
147
|
+
# 出した返信は台帳に残らなかった。
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _SpyWithFetch:
|
|
151
|
+
"""`publish()` に加えて `fetch_post()` を持つ偽アダプタ(T7-2)。
|
|
152
|
+
|
|
153
|
+
`reply_to_author_key` を渡さないときの best-effort 経路を確かめる。
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, *, fetch_row=None, fetch_error=None):
|
|
157
|
+
self.fetch_calls: list = []
|
|
158
|
+
self._fetch_row = fetch_row
|
|
159
|
+
self._fetch_error = fetch_error
|
|
160
|
+
|
|
161
|
+
def publish(self, post, *, dry_run, on_container_created=None):
|
|
162
|
+
return adapter_base.PublishResult(
|
|
163
|
+
post_id="ENG1", url=None, ts="2026-09-16T10:00:00+09:00")
|
|
164
|
+
|
|
165
|
+
def fetch_post(self, post_id):
|
|
166
|
+
self.fetch_calls.append(post_id)
|
|
167
|
+
if self._fetch_error is not None:
|
|
168
|
+
raise self._fetch_error
|
|
169
|
+
return self._fetch_row
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_replyToがあれば絡みの台帳に1行書く_author_keyは明示した値を使う(isolated_account_factory):
|
|
173
|
+
account = isolated_account_factory(production=True)
|
|
174
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
175
|
+
spy = _SpyWithFetch()
|
|
176
|
+
digest = _digest_for(account["name"], "返信です", reply_to="R1")
|
|
177
|
+
r = core.send_once(
|
|
178
|
+
account["name"], text="返信です", reply_to="R1", reply_to_root="ROOT1",
|
|
179
|
+
reply_to_author_key="a" * 16, found_by="manual",
|
|
180
|
+
production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
|
|
181
|
+
assert r.exit_code == 0 and r.post_id == "ENG1"
|
|
182
|
+
|
|
183
|
+
# 明示した author_key があるので fetch_post は best-effort でも叩かない。
|
|
184
|
+
assert spy.fetch_calls == []
|
|
185
|
+
|
|
186
|
+
rows = engagements_mod.records(cfg, account["name"])
|
|
187
|
+
assert len(rows) == 1, rows
|
|
188
|
+
row = rows[0]
|
|
189
|
+
assert row["post_id"] == "ENG1" and row["reply_to"] == "R1"
|
|
190
|
+
assert row["root_post"] == "ROOT1"
|
|
191
|
+
assert row["author_key"] == "a" * 16
|
|
192
|
+
assert row["found_by"] == "manual"
|
|
193
|
+
assert row["account"] == account["name"] and row["medium"] == "threads"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_reply_to_author_keyが無ければfetch_postでbesteffortに埋める(isolated_account_factory):
|
|
197
|
+
account = isolated_account_factory(production=True)
|
|
198
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
199
|
+
expected_key = adapter_base.author_key("threads", "someone")
|
|
200
|
+
spy = _SpyWithFetch(fetch_row={"message_id": "R1", "username": "someone",
|
|
201
|
+
"author_key": expected_key})
|
|
202
|
+
digest = _digest_for(account["name"], "返信です", reply_to="R1")
|
|
203
|
+
r = core.send_once(
|
|
204
|
+
account["name"], text="返信です", reply_to="R1",
|
|
205
|
+
production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
|
|
206
|
+
assert r.exit_code == 0
|
|
207
|
+
assert spy.fetch_calls == ["R1"] # 1 回だけ叩く
|
|
208
|
+
|
|
209
|
+
rows = engagements_mod.records(cfg, account["name"])
|
|
210
|
+
assert len(rows) == 1
|
|
211
|
+
assert rows[0]["author_key"] == expected_key
|
|
212
|
+
# best-effort が成功しているので、runs に失敗の印は立たない。
|
|
213
|
+
state_dir = accounts_mod.state_dir_for(account["name"])
|
|
214
|
+
run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == "ENG1"]
|
|
215
|
+
assert len(run_rows) == 1
|
|
216
|
+
assert run_rows[0]["engagement_write_failed"] is False
|
|
217
|
+
assert run_rows[0]["engagement_author_lookup_failed"] is False
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_fetch_postが失敗してもauthor_keyがnullになるだけで公開は成功する(isolated_account_factory):
|
|
221
|
+
account = isolated_account_factory(production=True)
|
|
222
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
223
|
+
spy = _SpyWithFetch(fetch_error=adapter_base.AdapterError("投稿の取得: HTTP 400 不明"))
|
|
224
|
+
digest = _digest_for(account["name"], "返信です", reply_to="R1")
|
|
225
|
+
r = core.send_once(
|
|
226
|
+
account["name"], text="返信です", reply_to="R1",
|
|
227
|
+
production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
|
|
228
|
+
assert r.exit_code == 0, "author_key が埋まらなくても公開は止めない"
|
|
229
|
+
assert spy.fetch_calls == ["R1"]
|
|
230
|
+
|
|
231
|
+
rows = engagements_mod.records(cfg, account["name"])
|
|
232
|
+
assert len(rows) == 1
|
|
233
|
+
assert rows[0]["author_key"] is None # 「見当たらない」であって公開の失敗ではない
|
|
234
|
+
|
|
235
|
+
state_dir = accounts_mod.state_dir_for(account["name"])
|
|
236
|
+
run_rows = [r for r in runs_mod.read_runs(state_dir) if r.get("post_id") == "ENG1"]
|
|
237
|
+
assert len(run_rows) == 1
|
|
238
|
+
assert run_rows[0]["status"] == "ok"
|
|
239
|
+
assert run_rows[0]["engagement_write_failed"] is False
|
|
240
|
+
assert run_rows[0]["engagement_author_lookup_failed"] is True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_reply_toが無ければ絡みの台帳には何も書かない(isolated_account_factory):
|
|
244
|
+
account = isolated_account_factory(production=True)
|
|
245
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
246
|
+
spy = _SpyWithFetch()
|
|
247
|
+
digest = _digest_for(account["name"], "独り言です")
|
|
248
|
+
r = core.send_once(account["name"], text="独り言です",
|
|
249
|
+
production_flag=True, confirm=digest, adapter_factory=lambda *_: spy)
|
|
250
|
+
assert r.exit_code == 0
|
|
251
|
+
assert spy.fetch_calls == []
|
|
252
|
+
assert engagements_mod.records(cfg, account["name"]) == []
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import re
|
|
11
12
|
|
|
12
13
|
from tests.conftest import run_thth
|
|
@@ -58,3 +59,52 @@ def test_違うconfirmの本番は拒否される(isolated_account_factory, tmp_
|
|
|
58
59
|
["send", account["name"], "--text-file", str(text_path),
|
|
59
60
|
"--production", "--confirm", "0" * 12], env=env)
|
|
60
61
|
assert prod.returncode == 1
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- T7-2: `send --reply-to` が絡みの台帳に残り、`after`・`who` から拾える -------
|
|
65
|
+
# kopicha の実物の所見: `thth send --reply-to <id>` で出した返信が絡みの台帳に
|
|
66
|
+
# 残らず、`after --reply-to`・`who` の「→」が埋まらなかった。偽 Threads(POST
|
|
67
|
+
# だけの `fake_threads_server`)越しに実際に送り、ディスク上の台帳・`thth after`・
|
|
68
|
+
# `thth who` で拾えることを end-to-end で確かめる。
|
|
69
|
+
|
|
70
|
+
REPLY_TO_ID = "R1"
|
|
71
|
+
REPLY_ROOT_ID = "ROOT1"
|
|
72
|
+
REPLY_AUTHOR_KEY = "b" * 16
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_replyToで送るとafterとwhoで拾える(isolated_account_factory, tmp_path):
|
|
76
|
+
account = isolated_account_factory(production=True)
|
|
77
|
+
text_path = tmp_path / "body.txt"
|
|
78
|
+
text_path.write_text("お返事です\n", encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
dry = run_thth(["send", account["name"], "--text-file", str(text_path),
|
|
81
|
+
"--reply-to", REPLY_TO_ID])
|
|
82
|
+
assert dry.returncode == 0
|
|
83
|
+
m = re.search(r"digest: (\S+)", dry.stdout)
|
|
84
|
+
assert m is not None
|
|
85
|
+
digest = m.group(1)
|
|
86
|
+
|
|
87
|
+
with fake_threads_server({"create": "ok", "publish": "ok"}) as base_url:
|
|
88
|
+
env = {"THTH_THREADS_BASE_URL": base_url, "THTH_THREADS_WAIT_SECONDS": "0"}
|
|
89
|
+
prod = run_thth(
|
|
90
|
+
["send", account["name"], "--text-file", str(text_path),
|
|
91
|
+
"--reply-to", REPLY_TO_ID, "--reply-to-root", REPLY_ROOT_ID,
|
|
92
|
+
"--reply-to-author-key", REPLY_AUTHOR_KEY, "--found-by", "manual",
|
|
93
|
+
"--production", "--confirm", digest], env=env)
|
|
94
|
+
assert prod.returncode == 0, prod.stdout + prod.stderr
|
|
95
|
+
assert "投稿しました" in prod.stdout
|
|
96
|
+
|
|
97
|
+
after = run_thth(["after", account["name"], "--reply-to", REPLY_TO_ID, "--json"])
|
|
98
|
+
assert after.returncode == 0, after.stdout + after.stderr
|
|
99
|
+
payload = json.loads(after.stdout)
|
|
100
|
+
assert payload["engagements"]["n"] == 1
|
|
101
|
+
branch = payload["engagements"]["by_branch"][0]
|
|
102
|
+
assert branch["reply_to"] == REPLY_TO_ID
|
|
103
|
+
assert branch["root"] == REPLY_ROOT_ID
|
|
104
|
+
assert branch["author_key"] == REPLY_AUTHOR_KEY
|
|
105
|
+
|
|
106
|
+
who = run_thth(["who", account["name"], REPLY_AUTHOR_KEY, "--json"])
|
|
107
|
+
assert who.returncode == 0, who.stdout + who.stderr
|
|
108
|
+
who_payload = json.loads(who.stdout)
|
|
109
|
+
roles = [t["role"] for t in who_payload["threads"]]
|
|
110
|
+
assert "i_replied_to_them" in roles, who_payload
|
|
@@ -185,13 +185,67 @@ def test_already_repliedは絡みの台帳とqueueの両方から引く(bsky_acc
|
|
|
185
185
|
result = json.loads(r.stdout)
|
|
186
186
|
by_id = {m["message_id"]: m for m in result["messages"]}
|
|
187
187
|
|
|
188
|
+
# T7-3: object には `source`(ledger/queue/thread のどこで見つかったか)が付く。
|
|
188
189
|
assert by_id[R1_URI]["already_replied"] == {
|
|
189
190
|
"post_id": "at://" + DID + "/app.bsky.feed.post/myreply1",
|
|
190
|
-
"at": "2026-09-16T05:00:00+09:00"}
|
|
191
|
-
assert by_id[R4_URI]["already_replied"] == {"status": "draft"}
|
|
192
|
-
#
|
|
191
|
+
"at": "2026-09-16T05:00:00+09:00", "source": "ledger"}
|
|
192
|
+
assert by_id[R4_URI]["already_replied"] == {"status": "draft", "source": "queue"}
|
|
193
|
+
# まだ絡んでいない相手には`False`(台帳・queueは両方読めているので
|
|
194
|
+
# 「見当たらない」と言い切れる・「返していない」の確定ではない・T7-3)。
|
|
195
|
+
assert by_id[R3_URI]["already_replied"] is False
|
|
196
|
+
assert by_id[R5_URI]["already_replied"] is False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# --- T7-3: already_replied を枝の中の自分の返信からも埋める(3 値の意味) -----
|
|
200
|
+
# 設計「自分の泉」§2.1・T7-3 発注書「その枝に is_own: true の返信があり、
|
|
201
|
+
# その replied_to がこの message なら、台帳に無くても already_replied を
|
|
202
|
+
# 埋める」。`false`=見当たらない(返していない、の確定ではない)/
|
|
203
|
+
# `null`=台帳か queue が読めず判らない、の 2 つも別々に確かめる。
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_台帳queueに無くても枝の中の自分の返信からalready_repliedが埋まる(bsky_account):
|
|
207
|
+
account, service = bsky_account
|
|
208
|
+
# 台帳にも queue にも何も無い状態(このテストは追加しない)。
|
|
209
|
+
r = _cli(account["name"], ROOT_URI, service=service)
|
|
210
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
211
|
+
result = json.loads(r.stdout)
|
|
212
|
+
by_id = {m["message_id"]: m for m in result["messages"]}
|
|
213
|
+
|
|
214
|
+
# R2(自分)は R1(bob)への返信——台帳に無くても枝そのものから拾う。
|
|
215
|
+
assert by_id[R1_URI]["already_replied"] == {
|
|
216
|
+
"post_id": R2_URI, "at": "2026-09-16T02:00:00.000Z", "source": "thread"}
|
|
217
|
+
# R2・R3・R4・R5 には自分からの返信がぶら下がっていない
|
|
218
|
+
# ——台帳・queue も空なので`False`(見当たらない。「返していない」の確定ではない)。
|
|
219
|
+
assert by_id[R2_URI]["already_replied"] is False
|
|
220
|
+
assert by_id[R3_URI]["already_replied"] is False
|
|
221
|
+
assert by_id[R4_URI]["already_replied"] is False
|
|
222
|
+
assert by_id[R5_URI]["already_replied"] is False
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_台帳が読めなければ見当たらなくてもFalseでなくNoneになる(bsky_account, tmp_path):
|
|
226
|
+
account, service = bsky_account
|
|
227
|
+
cfg = accounts_mod.load_account(account["name"])
|
|
228
|
+
eng_dir = accounts_mod.data_dirs(cfg, account["name"])["engagements"]
|
|
229
|
+
os.makedirs(eng_dir, exist_ok=True)
|
|
230
|
+
# 壊れた ndjson(JSON として読めない行)を 1 本置く——
|
|
231
|
+
# `engagements.load()` はこのファイルを `broken` として中身を捨てる
|
|
232
|
+
# (`thth/engagements.py::_read_ndjson()` と同じ流儀)。
|
|
233
|
+
with open(os.path.join(eng_dir, "2026-09.ndjson"), "w", encoding="utf-8") as f:
|
|
234
|
+
f.write("これは JSON ではありません\n")
|
|
235
|
+
|
|
236
|
+
r = _cli(account["name"], ROOT_URI, service=service)
|
|
237
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
238
|
+
result = json.loads(r.stdout)
|
|
239
|
+
by_id = {m["message_id"]: m for m in result["messages"]}
|
|
240
|
+
|
|
241
|
+
# R1 は枝の中の自分の返信(R2)があるので、台帳が読めなくても object のまま
|
|
242
|
+
# (見つかったものを取り消さない)。
|
|
243
|
+
assert by_id[R1_URI]["already_replied"]["source"] == "thread"
|
|
244
|
+
# どこにも見当たらない行は、`False`(見当たらない)ではなく`None`
|
|
245
|
+
# (「台帳が読めないので判らない」・T7-3 規約)。
|
|
193
246
|
assert by_id[R3_URI]["already_replied"] is None
|
|
194
247
|
assert by_id[R5_URI]["already_replied"] is None
|
|
248
|
+
assert any("絡みの台帳" in reason for reason in result["provenance"]["ledgers_unreadable"])
|
|
195
249
|
|
|
196
250
|
|
|
197
251
|
def test_max_messagesを超えたら新しい側を切りcontinue_fromを返す(bsky_account):
|
|
@@ -268,6 +322,74 @@ def test_you_and_themにlast_reactionがある(bsky_account):
|
|
|
268
322
|
assert you_and_them[dave_key]["last_reaction"] is None
|
|
269
323
|
|
|
270
324
|
|
|
325
|
+
# --- T7-1: Threadsの偽サーバで枝を読む(conversation()の生の行をMessageの形に揃える) ---
|
|
326
|
+
# `ThreadsAdapter.conversation()`は生の行(`id`・`replied_to: {"id": …}`・
|
|
327
|
+
# `root_post: {"id": …}`)を返す(返信の台帳との互換のため adapter 自身は
|
|
328
|
+
# 変えない・設計「自分の泉」T7-1発注書)。`thread_read`は`normalize_message()`
|
|
329
|
+
# を通してから読むので、Threadsでも`message_id`・`depth`・`already_replied`が
|
|
330
|
+
# ちゃんと埋まることをここで確かめる(T1-2はBluesky・Mastodonの偽サーバだけで
|
|
331
|
+
# 枝を確かめていた・Threadsは400の経路だけだった、という所見の穴)。
|
|
332
|
+
|
|
333
|
+
C1_ID, C2_ID, C3_ID = "C1", "C2", "C3"
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _threads_conversation_rows():
|
|
337
|
+
"""根(`OTHER_POST_ID`・alice)→ C1(bob)→ C2(nigamilab・自分)→ C3(carol)。"""
|
|
338
|
+
return [
|
|
339
|
+
{"id": C1_ID, "username": "bob", "text": "bobの返信",
|
|
340
|
+
"timestamp": "2026-09-16T01:00:00+0000",
|
|
341
|
+
"replied_to": {"id": OTHER_POST_ID}, "root_post": {"id": OTHER_POST_ID},
|
|
342
|
+
"permalink": "https://t/c1", "has_replies": True, "is_reply": True},
|
|
343
|
+
{"id": C2_ID, "username": "nigamilab", "text": "自分の返信",
|
|
344
|
+
"timestamp": "2026-09-16T02:00:00+0000",
|
|
345
|
+
"replied_to": {"id": C1_ID}, "root_post": {"id": OTHER_POST_ID},
|
|
346
|
+
"permalink": "https://t/c2", "has_replies": True, "is_reply": True},
|
|
347
|
+
{"id": C3_ID, "username": "carol", "text": "孫の返信",
|
|
348
|
+
"timestamp": "2026-09-16T03:00:00+0000",
|
|
349
|
+
"replied_to": {"id": C2_ID}, "root_post": {"id": OTHER_POST_ID},
|
|
350
|
+
"permalink": "https://t/c3", "has_replies": False, "is_reply": True},
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def test_Threadsの枝も根から時刻順でdepthとis_ownつきで返る(isolated_account_factory, tmp_path):
|
|
355
|
+
token_path = str(tmp_path / "threads.token")
|
|
356
|
+
_write_token(token_path, {"access_token": "FAKE-SECRET", "user_id": "999999",
|
|
357
|
+
"username": "nigamilab", "scopes": None,
|
|
358
|
+
"obtained_at": "2026-09-16T09:00:00+09:00"})
|
|
359
|
+
account = isolated_account_factory(
|
|
360
|
+
"nigamilab-threads-branch-test", media="threads", handle="nigamilab",
|
|
361
|
+
token=token_path, production=False)
|
|
362
|
+
|
|
363
|
+
with _server(conversation_rows=_threads_conversation_rows()) as (base_url, requests):
|
|
364
|
+
r = run_thth(["thread", account["name"], OTHER_POST_ID, "--json"],
|
|
365
|
+
env={"THTH_THREADS_BASE_URL": base_url})
|
|
366
|
+
assert r.returncode == 0, r.stdout + r.stderr
|
|
367
|
+
result = json.loads(r.stdout)
|
|
368
|
+
|
|
369
|
+
assert result["root"]["post_id"] == OTHER_POST_ID
|
|
370
|
+
assert result["root"]["username"] == "alice"
|
|
371
|
+
|
|
372
|
+
ids = [m["message_id"] for m in result["messages"]]
|
|
373
|
+
assert ids == [C1_ID, C2_ID, C3_ID], ids # Threadsの生の`id`から写った
|
|
374
|
+
|
|
375
|
+
by_id = {m["message_id"]: m for m in result["messages"]}
|
|
376
|
+
assert by_id[C1_ID]["depth"] == 1 and by_id[C1_ID]["replied_to"] == OTHER_POST_ID
|
|
377
|
+
assert by_id[C2_ID]["depth"] == 2 and by_id[C2_ID]["replied_to"] == C1_ID
|
|
378
|
+
assert by_id[C3_ID]["depth"] == 3 and by_id[C3_ID]["replied_to"] == C2_ID
|
|
379
|
+
|
|
380
|
+
# `replied_to`は`{"id": …}`のdictではなく、id文字列に開かれていること。
|
|
381
|
+
for m in result["messages"]:
|
|
382
|
+
assert isinstance(m["replied_to"], str)
|
|
383
|
+
|
|
384
|
+
assert by_id[C2_ID]["is_own"] is True
|
|
385
|
+
assert by_id[C1_ID]["is_own"] is False
|
|
386
|
+
assert by_id[C3_ID]["is_own"] is False
|
|
387
|
+
assert by_id[C2_ID]["author_key"] is not None
|
|
388
|
+
|
|
389
|
+
counts = result["counts"]
|
|
390
|
+
assert counts["messages"] == 3 and counts["own"] == 1 and counts["participants"] == 4
|
|
391
|
+
|
|
392
|
+
|
|
271
393
|
def test_Threadsで他人の根が400ならPermissionMissingのままrc1(isolated_account_factory, tmp_path):
|
|
272
394
|
with _server({f"/{OTHER_POST_ID}": "permission"}) as (base_url, requests):
|
|
273
395
|
token_path = str(tmp_path / "threads.token")
|
|
@@ -80,6 +80,8 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
|
|
|
80
80
|
"""
|
|
81
81
|
behavior: dict = {}
|
|
82
82
|
requests: list = []
|
|
83
|
+
# `/conversation` が返す行(既定は空・T7-1 の枝テストで差し替える)。
|
|
84
|
+
conversation_rows: list = []
|
|
83
85
|
|
|
84
86
|
def _json(self, status, payload):
|
|
85
87
|
body = json.dumps(payload).encode("utf-8")
|
|
@@ -121,7 +123,7 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
|
|
|
121
123
|
if p.endswith("/insights"):
|
|
122
124
|
return self._json(200, {"data": [{"name": "views", "values": [{"value": 7}]}]})
|
|
123
125
|
if p.endswith("/conversation"):
|
|
124
|
-
return self._json(200, {"data":
|
|
126
|
+
return self._json(200, {"data": self.conversation_rows})
|
|
125
127
|
if p == f"/v1.0/{OTHER_POST_ID}":
|
|
126
128
|
return self._json(200, OTHER_POST)
|
|
127
129
|
return self._json(200, {"data": []})
|
|
@@ -138,10 +140,11 @@ class _ReadOnlyThreads(http.server.BaseHTTPRequestHandler):
|
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
@contextlib.contextmanager
|
|
141
|
-
def _server(behavior=None):
|
|
143
|
+
def _server(behavior=None, *, conversation_rows=None):
|
|
142
144
|
requests: list = []
|
|
143
145
|
handler = type("H", (_ReadOnlyThreads,), {"behavior": dict(behavior or {}),
|
|
144
|
-
"requests": requests
|
|
146
|
+
"requests": requests,
|
|
147
|
+
"conversation_rows": list(conversation_rows or [])})
|
|
145
148
|
server = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
|
146
149
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
147
150
|
thread.start()
|
thth-2.3.1/thth/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.3.1
|
|
@@ -158,6 +158,42 @@ def author_key(medium: str, username: str | None) -> str | None:
|
|
|
158
158
|
return digest[:16]
|
|
159
159
|
|
|
160
160
|
|
|
161
|
+
def normalize_message(row: dict, *, medium: str) -> dict:
|
|
162
|
+
"""媒体の生の行を`Message`の形(`message_id`・`replied_to`・`root_post`は
|
|
163
|
+
id の文字列)に揃える(T7-1・設計「自分の泉」§2.1)。
|
|
164
|
+
|
|
165
|
+
**`conversation()`自身の戻りは変えない**——`collect`・`threadshape`・
|
|
166
|
+
`replies`の台帳(ndjson)は媒体ごとの生の鍵(Threadsなら`id`・
|
|
167
|
+
`replied_to: {"id": …}`)をそのまま読むので、そこを揃えると既存の台帳の
|
|
168
|
+
互換が壊れる(設計「自分の泉」T7-1発注書)。ここは`thread_read`など
|
|
169
|
+
「読んで見せる」口が、adapterの生の行を使う直前にだけ通す変換。
|
|
170
|
+
|
|
171
|
+
やること(3 つ):
|
|
172
|
+
- `message_id`が無ければ`id`から写す(あれば触らない)。
|
|
173
|
+
- `replied_to`・`root_post`が dict(Threadsの`{"id": …}`)なら`.get("id")`
|
|
174
|
+
を、文字列ならそのまま、無ければ`None`のまま。
|
|
175
|
+
- `author_key`が無ければ`author_key(medium, username)`で埋める。
|
|
176
|
+
|
|
177
|
+
他の鍵(`text`・`username`・`timestamp`・`permalink`・`has_replies`・
|
|
178
|
+
`is_reply`等)はそのまま素通しする。
|
|
179
|
+
|
|
180
|
+
**冪等**: 既に`Message`の形の行(Bluesky・Mastodonの`conversation()`・
|
|
181
|
+
Threadsの`_message_row()`・`fetch_post()`の戻り)を渡しても同じ行が返る
|
|
182
|
+
——2 回通しても壊れない。
|
|
183
|
+
"""
|
|
184
|
+
out = dict(row)
|
|
185
|
+
if not out.get("message_id"):
|
|
186
|
+
out["message_id"] = out.get("id")
|
|
187
|
+
for key in ("replied_to", "root_post"):
|
|
188
|
+
value = out.get(key)
|
|
189
|
+
if isinstance(value, dict):
|
|
190
|
+
out[key] = value.get("id")
|
|
191
|
+
# 文字列・None はそのまま(既に揃っている・またはそもそも無い)。
|
|
192
|
+
if not out.get("author_key"):
|
|
193
|
+
out["author_key"] = author_key(medium, out.get("username"))
|
|
194
|
+
return out
|
|
195
|
+
|
|
196
|
+
|
|
161
197
|
@dataclasses.dataclass
|
|
162
198
|
class Message:
|
|
163
199
|
"""会話の 1 行(設計 v2 §4.2。旧 `Reply` の改名)。
|
|
@@ -26,6 +26,7 @@ from . import where_cli as where_cli_mod
|
|
|
26
26
|
from . import who_cli as who_cli_mod
|
|
27
27
|
from . import collect as collect_mod
|
|
28
28
|
from . import core
|
|
29
|
+
from . import engagements as engagements_mod
|
|
29
30
|
from . import jst
|
|
30
31
|
from . import lint as lint_mod
|
|
31
32
|
from . import lock as lock_mod
|
|
@@ -2173,6 +2174,13 @@ def cmd_send(args) -> int:
|
|
|
2173
2174
|
**`--confirm`**(外部レビュー §1b・受け入れ 6): dry-run(`--production` を
|
|
2174
2175
|
付けない実行)が表示する短い digest を、`--production` のときに
|
|
2175
2176
|
`--confirm <digest>` として渡す。省略・不一致はどちらも送らない。
|
|
2177
|
+
|
|
2178
|
+
**`--reply-to-author-key`・`--reply-to-root`・`--found-by`**(T7-2・設計
|
|
2179
|
+
「自分の泉」§4): `--reply-to` で返信として出すとき、絡みの台帳(誰に・
|
|
2180
|
+
どの枝へ絡みに行ったか)に残す任意項目。queue の front-matter
|
|
2181
|
+
`reply_to_author_key`・`reply_to_root`・`found_by` と同じ意味。
|
|
2182
|
+
`--reply-to-author-key` を省略すると、`reply_to` の投稿を 1 回
|
|
2183
|
+
best-effort に読みに行って埋める(失敗しても送信は止めない)。
|
|
2176
2184
|
"""
|
|
2177
2185
|
import sys as _sys
|
|
2178
2186
|
from . import core as core_mod
|
|
@@ -2183,6 +2191,8 @@ def cmd_send(args) -> int:
|
|
|
2183
2191
|
text = _sys.stdin.read()
|
|
2184
2192
|
result = core_mod.send_once(
|
|
2185
2193
|
args.account, text=text, topic=args.topic, reply_to=args.reply_to,
|
|
2194
|
+
reply_to_root=args.reply_to_root, reply_to_author_key=args.reply_to_author_key,
|
|
2195
|
+
found_by=args.found_by,
|
|
2186
2196
|
production_flag=args.production, confirm=args.confirm, log=print)
|
|
2187
2197
|
return result.exit_code
|
|
2188
2198
|
|
|
@@ -2709,6 +2719,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2709
2719
|
help="本文のファイル。省略時は標準入力から読む")
|
|
2710
2720
|
p_send.add_argument("--topic", default=None)
|
|
2711
2721
|
p_send.add_argument("--reply-to", dest="reply_to", default=None)
|
|
2722
|
+
# T7-2(設計「自分の泉」§4): queue の front-matter と同じ意味の 3 つ。
|
|
2723
|
+
# `--reply-to` があるときだけ絡みの台帳に 1 行残る。
|
|
2724
|
+
p_send.add_argument("--reply-to-author-key", dest="reply_to_author_key", default=None,
|
|
2725
|
+
help="相手の仮名(16 進 16 桁)。省略すると reply_to の投稿を"
|
|
2726
|
+
"best-effort に読みに行って埋める(失敗しても送信は止めない)")
|
|
2727
|
+
p_send.add_argument("--reply-to-root", dest="reply_to_root", default=None,
|
|
2728
|
+
help="この返信がぶら下がる枝の根の post_id(判れば)")
|
|
2729
|
+
p_send.add_argument("--found-by", dest="found_by", default=None,
|
|
2730
|
+
choices=sorted(engagements_mod.FOUND_BY_VALUES),
|
|
2731
|
+
help="絡みに行った先をどう見つけたか(where_to_appear/manual/mention)")
|
|
2712
2732
|
p_send.add_argument("--production", action="store_true",
|
|
2713
2733
|
help="本番で出す(台帳 production: true が無ければ dry-run のまま)")
|
|
2714
2734
|
p_send.add_argument("--confirm", default=None,
|