diff --git a/.github/workflows/audit-pr.yml b/.github/workflows/audit-pr.yml index 4742a55..9bc2141 100644 --- a/.github/workflows/audit-pr.yml +++ b/.github/workflows/audit-pr.yml @@ -9,9 +9,6 @@ on: types: [created] workflow_dispatch: -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - permissions: contents: read pull-requests: write @@ -29,7 +26,7 @@ jobs: steps: - name: Checkout PR head - uses: actions/checkout@v5 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 0 @@ -53,29 +50,16 @@ jobs: id: submit shell: bash run: | - HTTP_CODE=$(curl -s -o /tmp/audit-submit.json -w "%{http_code}" -X POST "${{ vars.AUDIT_SERVICE_URL }}/audit-pr/${{ vars.AUDIT_PR_SKILL }}" \ + RESPONSE=$(curl -s -X POST "${{ vars.AUDIT_SERVICE_URL }}/audit-pr/${{ vars.AUDIT_PR_SKILL }}" \ -H "X-API-Key: ${{ secrets.AUDIT_API_KEY }}" \ -F "file=@/tmp/${{ steps.archive.outputs.archive_name }}" \ -F "from_branch=${{ github.event.pull_request.base.ref }}" \ -F "to_branch=${{ github.event.pull_request.head.ref }}") - echo "Submit HTTP_CODE=$HTTP_CODE" echo "Submit response:" - cat /tmp/audit-submit.json - - if [ "$HTTP_CODE" != "200" ]; then - echo "Audit service request failed" - exit 1 - fi - - if ! jq empty /tmp/audit-submit.json >/dev/null 2>&1; then - echo "Audit service returned non-JSON response" - exit 1 - fi - - jq . /tmp/audit-submit.json + echo "$RESPONSE" | jq . - REPORT_URL=$(jq -r '.report_url // empty' /tmp/audit-submit.json) + REPORT_URL=$(echo "$RESPONSE" | jq -r '.report_url // empty') if [ -z "$REPORT_URL" ]; then echo "Audit service did not return report_url" @@ -144,7 +128,7 @@ jobs: PY - name: Comment markdown report to PR - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | const fs = require('fs'); @@ -182,7 +166,7 @@ jobs: steps: - name: Get PR info id: pr - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | const pr = await github.rest.pulls.get({ @@ -196,7 +180,7 @@ jobs: core.setOutput('head_sha', pr.data.head.sha); - name: Checkout PR head - uses: actions/checkout@v5 + uses: actions/checkout@v4 with: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 @@ -220,29 +204,16 @@ jobs: id: submit shell: bash run: | - HTTP_CODE=$(curl -s -o /tmp/audit-submit.json -w "%{http_code}" -X POST "${{ vars.AUDIT_SERVICE_URL }}/audit-pr/${{ vars.AUDIT_PR_SKILL }}" \ + RESPONSE=$(curl -s -X POST "${{ vars.AUDIT_SERVICE_URL }}/audit-pr/${{ vars.AUDIT_PR_SKILL }}" \ -H "X-API-Key: ${{ secrets.AUDIT_API_KEY }}" \ -F "file=@/tmp/${{ steps.archive.outputs.archive_name }}" \ -F "from_branch=${{ steps.pr.outputs.base_ref }}" \ -F "to_branch=${{ steps.pr.outputs.head_ref }}") - echo "Submit HTTP_CODE=$HTTP_CODE" echo "Submit response:" - cat /tmp/audit-submit.json - - if [ "$HTTP_CODE" != "200" ]; then - echo "Audit service request failed" - exit 1 - fi - - if ! jq empty /tmp/audit-submit.json >/dev/null 2>&1; then - echo "Audit service returned non-JSON response" - exit 1 - fi - - jq . /tmp/audit-submit.json + echo "$RESPONSE" | jq . - REPORT_URL=$(jq -r '.report_url // empty' /tmp/audit-submit.json) + REPORT_URL=$(echo "$RESPONSE" | jq -r '.report_url // empty') if [ -z "$REPORT_URL" ]; then echo "Audit service did not return report_url" @@ -309,7 +280,7 @@ jobs: PY - name: Comment markdown report to PR - uses: actions/github-script@v8 + uses: actions/github-script@v7 with: script: | const fs = require('fs'); diff --git a/QA_PR5_PR10_TEST_PLAN.md b/QA_PR5_PR10_TEST_PLAN.md deleted file mode 100644 index dc12aee..0000000 --- a/QA_PR5_PR10_TEST_PLAN.md +++ /dev/null @@ -1,408 +0,0 @@ -# QA Test Plan for PR #5 and PR #10 - -This document gives QA a practical manual test plan for: - -- PR `#5` `Add TRX Staking & SR Voting skill` -- PR `#10` `Add USDD / JUST Protocol skill` - -Target repo: - -- `git@github.com:BofAI/skills.git` - -Target PRs: - -- [PR #5](https://github.com/BofAI/skills/pull/5) -- [PR #10](https://github.com/BofAI/skills/pull/10) - -## Scope - -This plan covers: - -- install/build sanity -- read-only script behavior -- safe dry-run behavior -- basic error handling - -This plan does not require QA to perform irreversible production writes unless explicitly noted. - -## General QA Rules - -- Run each PR in a separate clean worktree or clone. -- Test on Node.js `20.x` or newer. -- Record command, exit code, stdout, stderr, and timestamp for every failure. -- When using TronGrid on mainnet, add a short delay between calls to reduce `429` rate-limit errors. -- If a step fails with `429`, retry once after `5-10` seconds before marking it failed. - -## Environment Setup - -### Common tools - -- `git` -- `node` -- `npm` - -### Recommended environment variables - -For TRON mainnet tests: - -```bash -export TRON_NETWORK=mainnet -export TRONGRID_API_KEY="" -``` - -For write-path or dry-run tests that require a wallet context: - -```bash -export TRON_PRIVATE_KEY="" -``` - -Use a dedicated QA wallet. Do not reuse a personal or production wallet. - -## PR #5: TRX Staking & SR Voting - -### Checkout - -```bash -git fetch origin pull/5/head:pr-5 -git switch pr-5 -cd trx-staking-skill -``` - -### Install - -```bash -npm ci -``` - -Expected: - -- install completes successfully -- `package-lock.json` is consistent - -### Syntax sanity - -```bash -for f in scripts/*.js; do - echo "CHECK $f" - node --check "$f" -done -``` - -Expected: - -- all files pass syntax check - -### Test Case 5.1: List SRs - -```bash -node scripts/sr-list.js --top 5 -``` - -Expected: - -- command exits `0` -- JSON output contains: - - `total_witnesses` - - `total_votes` - - `super_representatives` -- `super_representatives` contains `5` entries -- each entry has `rank`, `address`, `vote_count`, `is_active_sr` - -### Test Case 5.2: Wallet status with QA wallet - -```bash -node scripts/status.js -``` - -Expected: - -- command exits `0` -- JSON output contains: - - `wallet` - - `trx_balance` - - `tron_power` - - `votes` - - `pending_reward_trx` - -### Test Case 5.3: Wallet status by explicit address - -```bash -node scripts/status.js -``` - -Expected: - -- command exits `0` -- returns the requested wallet address in output - -### Test Case 5.4: Rewards read path - -```bash -node scripts/rewards.js -``` - -Expected: - -- command exits `0` -- JSON output contains: - - `wallet` - - `pending_reward_trx` - - `claimable` - -### Test Case 5.5: Vote dry-run without TRON Power - -Use a QA wallet with no staked TRX. - -```bash -node scripts/vote.js --dry-run -``` - -Expected: - -- command exits non-zero -- error clearly states no TRON Power is available - -### Test Case 5.6: Vote dry-run with invalid SR - -Use a wallet that has TRON Power for this case if available. - -```bash -node scripts/vote.js TPYmHEhy5n8TCEfYGqW2rPxsghSfzghPDn --dry-run -``` - -Expected: - -- command exits non-zero -- output clearly says the address is not a registered witness/SR - -### Optional Test Case 5.7: Vote dry-run with valid SR and funded staking wallet - -Precondition: - -- QA wallet has staked TRX and therefore non-zero TRON Power - -Command: - -```bash -node scripts/vote.js --dry-run -``` - -Expected: - -- command exits `0` -- output contains: - - `action: "vote"` - - `tron_power` - - `votes` - - `dry_run: true` - - `status: "dry_run"` - -### Pass Criteria for PR #5 - -- install succeeds -- read-only commands return structured JSON -- invalid or unsafe vote flows fail clearly -- dry-run path works when wallet preconditions are met - -## PR #10: USDD / JUST Protocol - -### Checkout - -```bash -git fetch origin pull/10/head:pr-10 -git switch pr-10 -cd usdd-skill -``` - -### Install - -```bash -npm ci -``` - -Expected: - -- install completes successfully - -### Syntax sanity - -```bash -for f in scripts/*.js; do - echo "CHECK $f" - node --check "$f" -done -``` - -Expected: - -- all files pass syntax check - -### Test Case 10.1: PSM info read path - -```bash -node scripts/psm-info.js -``` - -Expected: - -- command exits `0` -- JSON output contains: - - `network` - - `psm.address` - - `fees.tin` - - `fees.tout` - - `reserves.usdt_available` - - `usdd.total_supply` - -### Test Case 10.2: Balance lookup by address - -```bash -node scripts/balance.js --address T9yD14Nj9j7xAB4dbGeiX9h8unkKHxuWwb -``` - -Expected: - -- command exits `0` -- JSON output contains: - - `wallet` - - `network` - - `balances` -- `balances` includes entries for `USDD`, `USDT`, `USDC`, `TRX`, and `JST` - -### Test Case 10.3: Vault info read path - -```bash -node scripts/vault-info.js --vault TRX-A -``` - -Expected: - -- command exits `0` -- JSON output contains one vault entry with: - - `name: "TRX-A"` - - `collateral` - - `total_debt_usdd` - - `debt_ceiling_usdd` - - `dust_usdd` - -### Test Case 10.4: Invalid vault name - -```bash -node scripts/vault-info.js --vault INVALID-A -``` - -Expected: - -- command exits non-zero -- output clearly reports unknown vault - -### Test Case 10.5: PSM dry-run with insufficient USDT - -Use a QA wallet with zero or low USDT balance. - -```bash -node scripts/psm-swap.js sell 1 --dry-run -``` - -Expected: - -- command exits `0` or controlled non-zero depending on implementation path -- output must be structured JSON -- output must clearly state insufficient USDT balance -- no transaction is broadcast - -### Optional Test Case 10.6: PSM sell dry-run with funded QA wallet - -Precondition: - -- QA wallet holds at least `1` USDT on TRON mainnet - -Command: - -```bash -node scripts/psm-swap.js sell 1 --dry-run -``` - -Expected: - -- output contains: - - `action: "psm_sell"` - - `direction: "sell"` - - `amount_usdt: "1"` - - `dry_run: true` - - `needs_approval` -- no transaction is broadcast - -### Optional Test Case 10.7: PSM buy dry-run with funded QA wallet - -Precondition: - -- QA wallet holds enough USDD to buy `1` USDT - -Command: - -```bash -node scripts/psm-swap.js buy 1 --dry-run -``` - -Expected: - -- output contains: - - `action: "psm_buy"` - - `direction: "buy"` - - `amount_usdt: "1"` - - `dry_run: true` - - `needs_approval` -- no transaction is broadcast - -### Optional Test Case 10.8: CDP lookup by ID - -If QA has a known valid CDP ID: - -```bash -node scripts/vault-info.js --cdp -``` - -Expected: - -- command exits `0` -- output contains: - - `cdp_id` - - `owner` - - `ilk` - - `collateral_locked` - - `actual_debt_usdd` - - `collateralization_ratio` - -### Pass Criteria for PR #10 - -- install succeeds -- read-only PSM and vault commands return structured JSON -- invalid vault input fails clearly -- dry-run swap path performs validation without broadcasting transactions - -## Suggested QA Report Format - -For each case, report: - -- PR number -- test case ID -- environment -- command -- exit code -- actual result -- expected result -- pass/fail -- logs or screenshots if failed - -Example: - -```md -PR: #10 -Case: 10.3 -Env: mainnet, Node 20.19.5, TronGrid API key enabled -Command: node scripts/vault-info.js --vault TRX-A -Exit code: 0 -Result: Returned vault object with debt ceiling and total debt fields -Status: PASS -``` diff --git a/twitter-digest/DATA_COLLECTION.md b/twitter-digest/DATA_COLLECTION.md index 9fdd33a..926b77a 100644 --- a/twitter-digest/DATA_COLLECTION.md +++ b/twitter-digest/DATA_COLLECTION.md @@ -31,8 +31,8 @@ scripts/browser_x_digest.py 适合: - 无 API 配置的普通用户。 -- 需要读取 X Chat / DM 的浏览器来源场景。 -- API 权限不足时的 fallback。 +- 需要读取 X Chat / DM 内容的场景。 +- 没有 API 配置时的默认来源,或用户显式选择浏览器模式时使用。 ### 2. API 抓取脚本 @@ -45,7 +45,7 @@ scripts/api_x_digest.py - 使用已保存的 OAuth2 user-context token,或 `X_BEARER_TOKEN` / `TWITTER_BEARER_TOKEN` / `--bearer-token` 传入的 OAuth2 user token。 - 主路径是 OAuth2 PKCE:用户准备 X App 的 `Client ID`,使用 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure-api` 让脚本打开授权页并通过本地 callback 换取 user access token / refresh token。 - 如果用户已经有 OAuth2 user access token,使用 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure-api-token` 由脚本安全保存。 -- OAuth1 不再作为日报 API 配置路径。公开数据主路径使用 OAuth2;完整 DM 只走浏览器;API 来源不采集 DM。 +- OAuth1 不再作为日报 API 配置路径,因为它不能可靠读取 DM;需要完整 DM 时走 OAuth2,API DM 拿不到时走浏览器脚本。 - 读取 home timeline:`/2/users/:id/timelines/reverse_chronological`。 - 读取用户公开发帖。 - 读取 mentions。 @@ -55,17 +55,17 @@ scripts/api_x_digest.py 适合: -- API 已配置、需要更稳定公开数据的场景。 +- 用户显式选择 API,且需要更稳定公开数据的场景。 - 定时任务中减少浏览器页面变化影响。 限制: -- `run_daily_digest.py` 不使用 API DM 做最终判断;DM / X Chat 只由浏览器脚本读取;浏览器来源会合并 DM,API 来源不会采集 DM。 -- `api_x_digest.py` 不调用 DM endpoint;API 来源没有 DM。 +- `run_daily_digest.py` 的正常日报路径不使用 API DM 做最终判断;DM / X Chat 一律由浏览器脚本读取。 +- `api_x_digest.py --include-dms` 保留为 TODO/调试路径:它会尝试 `/2/dm_events`,但结果只作为 API 可见事件参考。 - Home timeline endpoint 需要可访问该用户上下文的 token;如果账号权限、套餐或 token 类型不支持,会在 `home` 页面写入具体 `collection_error`。 - App-only API key / app-only bearer token 不等于用户授权,不能保证读取用户 home timeline 或 DM。 -- 不需要为 API 日报配置 `dm.read`;DM 只通过浏览器来源采集。 -- API 来源不采集 DM。XChat / 加密私信只以浏览器采集为准。 +- DM API 需要 X App / token 有 `dm.read` 相关权限;权限不足、tier 不支持、rate limit、返回 0 条或无法确认是否需要回复时,会写入 `api_dm_todo` 和 data gap。 +- API DM 不等同于网页 X Chat。XChat / 加密私信可能不会出现在 `/2/dm_events`。现阶段 API DM 标记为 TODO,等待 X 修复或明确文档;日报 DM 以浏览器脚本为准。 - API 权限不足、额度不足或 endpoint 不可用时,会把错误写入对应页面的 `collection_error`。 ### 3. 上层入口脚本 @@ -79,25 +79,34 @@ scripts/run_daily_digest.py - 统一入口。 - 提供 `--configure-api`,由 Agent 在对话里触发 OAuth 配置。 - 默认 `--source auto`。 -- 如果检测到环境变量 token,或 `.state/api_config.json` 里保存的 OAuth2 user-context bearer token,走 API 抓取。 +- 如果检测到环境变量 token,或 `.state/api_config.json` 里保存的 OAuth2 user-context 配置,走 API 抓取。 - 如果没有 API token,走浏览器抓取。 +- 用户主动要求浏览器或命令显式传入 `--source browser` 时,强制浏览器抓取。 +- API source 已选择时不启动浏览器,不自动回退浏览器;API 不可用时失败或写入 data gap。 - 如果 OAuth2 access token 快过期且保存了 refresh token,自动刷新后再抓取。 - 抓取完成后调用 `digest_context.py` 生成 `digest-context.*`。 选择逻辑: ```text ---source api -> 强制 API ---source browser -> 强制浏览器 ---source auto -> 有 X_BEARER_TOKEN/TWITTER_BEARER_TOKEN 或已保存 OAuth2 user token 用 API,否则浏览器;API 不可用时回退浏览器 +--source api -> 强制 API,只采公开数据,不启动浏览器 +--source browser -> 强制浏览器,采公开网页和可见 X Chat / DM +--source auto -> 默认自动模式;有 X_BEARER_TOKEN/TWITTER_BEARER_TOKEN 或已保存 OAuth2 user token 用 API,否则浏览器;API 已配置时不回退浏览器 ``` +隔离规则: + +- API 来源只运行 `api_x_digest.py`,不启动浏览器、不读取浏览器 profile、不用浏览器补采 API 缺口。 +- 浏览器来源只运行 `browser_x_digest.py`,不读取 API token、不合并 API collector 输出。 +- 默认 `--source auto` 每次只选择一个来源,不合并 API 和浏览器两边的数据。 +- API 输出里的“需要浏览器确认 DM”只能作为 data gap 提示,不表示本次已经读取了浏览器 DM。 + ## 对话触发流程 底层只保留两个抓取脚本: ```text -scripts/api_x_digest.py -> 官方 API 抓取:home timeline、mentions、profile;不采集 DM +scripts/api_x_digest.py -> 官方 API 抓取:home timeline、mentions、profile;API DM 仅作为 TODO/调试 scripts/browser_x_digest.py -> 浏览器抓取:公开网页 + X Chat / 加密 DM ``` @@ -106,19 +115,10 @@ scripts/browser_x_digest.py -> 浏览器抓取:公开网页 + X Chat / 加密 ```text 用户:生成 X 日报 Agent:运行 scripts/run_daily_digest.py -脚本:自动选择 API 或浏览器;API 只采集公开数据,浏览器采集公开数据 + DM,生成 digest-context.md +脚本:自动选择 API 或浏览器,生成 digest-context.md Agent:读取 digest-context.md 写中文日报 ``` -浏览器日报(含 DM): - -```text -用户:生成带私信的日报 -Agent:运行 scripts/run_daily_digest.py --source browser -脚本:采集公开数据 + 浏览器 DM -Agent:读取 digest-context.md 写合并中文日报 -``` - 配置 API,已有 token: ```text @@ -127,7 +127,7 @@ Agent:运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest 脚本:弹出隐藏输入框 用户:粘贴 user access token 脚本:保存 token 到 .state/api_config.json -后续:`python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source auto` 自动走 API +后续普通日报仍运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py`,并自动走 API;要临时使用浏览器时显式运行 `--source browser` ``` 配置 API,OAuth2 授权: @@ -140,7 +140,7 @@ Agent:运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest 脚本:打开 X 授权页 用户:在浏览器里授权 app 脚本:通过本地 callback 换取 access token / refresh token 并保存 -后续:`python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source auto` 自动走 API +后续普通日报仍运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py`,并自动走 API;要临时使用浏览器时显式运行 `--source browser` ``` 后续运行: @@ -148,16 +148,15 @@ Agent:运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest ```text 用户:生成 X 日报 Agent:运行 scripts/run_daily_digest.py -脚本:读取 .state/api_config.json -脚本:OAuth2 如需 refresh 则自动 refresh -脚本:采集 API 数据并生成 digest-context.md +脚本:默认 auto;有 API 配置则使用 API,否则使用浏览器,生成 digest-context.md +脚本:只有显式 `--source browser` 时才在有 API 配置的情况下强制浏览器 Agent:读取 digest-context.md 写中文日报 ``` 如果凭据失效: ```text -脚本:把 API endpoint 错误写入 data gap +脚本:如果显式使用 API,则把 API endpoint 错误写入 data gap Agent:告知用户需要重新配置/授权 用户:在对话里说“重新配置 X API” Agent:再次运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure-api` @@ -168,7 +167,7 @@ Agent:再次运行 `python3 ~/.claude/skills/twitter-digest/scripts/run_daily_ ```text 用户:清除 X API 配置 Agent:运行 `python3 ~/.claude/skills/twitter-digest/scripts/configure_api.py --clear` -后续:`python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source auto` 自动回到浏览器抓取 +后续:`python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py` 因没有 API 配置而使用浏览器抓取 ``` ## 标准输出结构 @@ -218,11 +217,15 @@ DM page 尽量包含: - `dm_captured_message_count` - `dm_threads` -API 与 DM 规则: +API DM TODO / 调试规则: -- API 来源不调用 `/dm_events`,不读取 DM,也不启动浏览器。 -- 浏览器来源读取 X Messages,并把 DM 合并进同一份日报。 -- API 来源的日报不能写“没有私信”,只能说明“API 来源未采集 DM”。 +- 正常日报不使用 API DM 做最终判断;`run_daily_digest.py` 会用浏览器补 DM。 +- 只有直接运行 `api_x_digest.py --include-dms` 做调试时,才调用 `/2/dm_events`。 +- 按 `dm_conversation_id` 分组。 +- 只把最后一条不是用户发出的会话正文放进 `dm_threads`,用于判断是否需要回复。 +- 最后一条是用户发出的会话只计数,不展开正文。 +- 如果 `/2/dm_events` 返回权限、tier、认证或限流错误,写入 `api_dm_todo` data gap,不把失败当作“无私信”。 +- API 来源不启动浏览器,也不补采浏览器 DM;如果需要完整 DM,必须显式使用 `--source browser`。 ## 稳定性策略 @@ -241,7 +244,7 @@ API 与 DM 规则: - 每个页面独立采集,某个 endpoint 失败不会阻塞其他页面。 - API 错误写入页面级 `collection_error`。 - Home timeline 会先尝试官方 timeline endpoint,只有 endpoint 返回权限/额度/可用性错误时才进入 data gap。 -- 没有 API token 时不报错,由上层自动 fallback 到浏览器。 +- 没有 API token 时不报错;上层仅在完全没有 API 配置时使用浏览器。 ### 上层摘要 @@ -270,16 +273,10 @@ python3 twitter-digest/scripts/run_daily_digest.py --source browser X_BEARER_TOKEN=... python3 twitter-digest/scripts/run_daily_digest.py --source api --handle ``` -只跑浏览器公开抓取: +只跑浏览器抓取: ```bash -python3 twitter-digest/scripts/browser_x_digest.py -``` - -开启后续日报 DM: - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser +python3 twitter-digest/scripts/browser_x_digest.py --include-dms ``` 只跑 API 抓取: diff --git a/twitter-digest/FUNCTION_RULES_FLOW.md b/twitter-digest/FUNCTION_RULES_FLOW.md index 7da9f09..6ba0e69 100644 --- a/twitter-digest/FUNCTION_RULES_FLOW.md +++ b/twitter-digest/FUNCTION_RULES_FLOW.md @@ -8,15 +8,15 @@ twitter-digest/RUNBOOK.md ## 1. 功能 -`twitter-digest` 通过 X API 或本地已登录浏览器读取用户自己的 X/Twitter 数据,并生成中文日报。 +`twitter-digest` 默认 `--source auto`:如果用户已经配置过 X API,就通过 API 读取公开数据并生成中文日报;如果没有配置过 API,才通过本地已登录浏览器读取。用户主动要求浏览器或显式传入 `--source browser` 时强制浏览器。 -默认采集内容: +采集内容按来源区分: - Home timeline:时间线热点 - Mentions search:搜索谁 @ 了当前账号 -- Mentions notifications:通知里的 @ - Own profile:当前账号主页动态 -- DMs:浏览器来源默认读取私信页面可见会话并合并进日报;API 来源不读取 +- API 模式:公开数据为主,包括 home timeline、mentions / @ 搜索、own profile;不以 DM 作为日报依据。 +- 浏览器模式:包含 home timeline、mentions search、mentions notifications、own profile,以及私信页面可见会话。 默认不采集关键词。只有显式传 `--keywords` 时才做关键词搜索。 @@ -64,14 +64,14 @@ twitter-digest/.state/run/digest-context.md ## 3. 运行规则 -- 默认入口 `run_daily_digest.py --source auto`:有 OAuth2 user-context API 配置时优先用 API 抓公开数据;没有 API 配置时用浏览器抓取。 -- DM / X Chat 以本地浏览器抓取为准并合并进同一份日报;API 来源不读取 DM,API 来源不采集 DM,不用于判断是否有私信。 -- API 不可用、权限不足、tier 不支持或限流时,记录数据缺口并回退浏览器路径。 -- 不使用 MCP。 +- 默认入口 `run_daily_digest.py`:自动选择来源。已保存 OAuth2 user-context API 配置或环境里有 `X_BEARER_TOKEN` / `TWITTER_BEARER_TOKEN` 时走 API;没有 API 配置时走浏览器。 +- 用户主动要求浏览器或命令显式 `--source browser` 时强制浏览器;API 模式只抓公开数据,不打开浏览器。 +- DM / X Chat 以本地浏览器抓取为准;API DM 现阶段仅保留为 TODO/调试,不用于判断是否有私信。 +- API 不可用、权限不足、tier 不支持或限流时,记录数据缺口或失败,不回退浏览器路径。 - 不要求用户复制 cookie 或 token。 - 默认 headless 运行。 - 第一次没有登录态时,会自动打开可见浏览器让用户登录。 -- 浏览器来源默认读取浏览器页面上可见的 DM;API 来源不启动浏览器、不读取 DM。 +- 浏览器模式默认读取 DM,但只读取浏览器页面上可见的内容;API 模式不以 DM 作为日报依据。 - 支持 `--non-interactive`,定时任务遇到 passcode 时跳过 DM 恢复并记录数据缺口,不阻塞等待。 - 不自动发送消息、回复、点赞、关注、拉黑、打开可疑链接或接受 DM 请求。 - 只生成摘要和建议回复草稿。 @@ -93,15 +93,17 @@ twitter-digest/.state/run/digest-context.md 5. 脚本检测登录成功。 -6. 脚本自动识别当前 X 账号 handle。 +6. 脚本自动识别当前 X 账号 handle;如果 headless 识别失败,会打开可见浏览器重试。 -7. 脚本采集 timeline、mentions、own profile;如果是浏览器来源,也采集 DM 并合并进同一份日报。 +7. 如果仍无法识别 handle,脚本直接停止并要求用户带 `--handle <用户名>` 重跑,不能生成缺 `@你` 和自己动态的日报。 -8. 脚本生成 `twitter-digest/.state/run/*` 当次采集文件。 +8. 识别成功后,脚本采集 timeline、mentions、own profile、DM。 -9. Agent 只读取 `twitter-digest/.state/run/digest-context.md` 生成中文日报。 +9. 脚本生成 `twitter-digest/.state/run/*` 当次采集文件。 -10. `digest-input.*` 只在排查抓取问题时使用。 +10. Agent 只读取 `twitter-digest/.state/run/digest-context.md` 生成中文日报。 + +11. `digest-input.*` 只在排查抓取问题时使用。 ## 5. 后续运行流程 @@ -138,15 +140,7 @@ twitter-digest/.state/run/digest-context.md ## 7. DM 规则 -DM 不由开关控制。浏览器来源默认读取 X Messages 并合并到同一份日报;API 来源不启动浏览器、不读取 DM。用户要求日报带私信时,运行: - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser -``` - -浏览器来源会读取 X Messages 并合并到同一份日报。API 来源不启动浏览器、不读取 DM。`--dm-only` 仅保留为低层调试模式,不作为普通用户路径。 - -DM 采集依赖浏览器自动化访问 X Chat,可能触发 X 的账号风控、登录挑战、passcode 恢复或其他平台限制;不要用于长时间无人值守任务。 +DM 默认读取。 读取范围: @@ -193,16 +187,10 @@ DM 采集依赖浏览器自动化访问 X Chat,可能触发 X 的账号风控 python3 twitter-digest/scripts/run_daily_digest.py ``` -浏览器日报(含 DM): - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser -``` - -API 日报(无浏览器、无 DM): +跳过 DM: ```bash -python3 twitter-digest/scripts/run_daily_digest.py --source api +python3 twitter-digest/scripts/run_daily_digest.py --no-dms ``` 强制显示浏览器窗口: @@ -248,7 +236,7 @@ rm -rf twitter-digest/.state/chrome-profile **◆ 谁 @ 了你** -**◆ 私信(DM)**(仅浏览器来源) +**◆ 私信(DM)** **◆ 时间线热点** diff --git a/twitter-digest/README.md b/twitter-digest/README.md index 1c69159..4307815 100644 --- a/twitter-digest/README.md +++ b/twitter-digest/README.md @@ -1,6 +1,6 @@ # X/Twitter Digest -Skill for generating a Chinese daily digest from a user's own X/Twitter account through API collection when configured, otherwise through a saved local browser session. +Skill for generating a Chinese daily digest from a user's own X/Twitter account. It defaults to API collection after X API is configured, otherwise it uses a saved local browser session. ## Quick Install @@ -14,6 +14,14 @@ python3 twitter-digest/scripts/install.py To ask Codex to install this skill for itself, paste this into Codex: +```bash +curl -fsSL https://raw.githubusercontent.com/BofAI/skills/v1.5.11/twitter-digest/install.sh | env TWITTER_DIGEST_INSTALL_CLIENT=codex sh +``` + +When this one-line installer is launched from Codex, Claude Code, or another non-interactive agent on macOS, it opens a real Terminal window and re-runs the full installation there. This avoids agent permission/inspect prompts during `git clone`, Python checks, browser checks, and installer writes. Set `TWITTER_DIGEST_OPEN_TERMINAL=0` only when intentionally running in an already interactive Terminal or CI. + +Or use the natural-language prompt: + ```text 请帮我安装这个 Codex skill: @@ -26,6 +34,12 @@ git clone git@github.com:BofAI/skills.git bofai-skills \ To ask Claude Code to install this skill for itself, paste this into Claude Code: +```bash +curl -fsSL https://raw.githubusercontent.com/BofAI/skills/v1.5.11/twitter-digest/install.sh | env TWITTER_DIGEST_INSTALL_CLIENT=claude TWITTER_DIGEST_ALLOW_CLAUDE_COMMANDS=1 TWITTER_DIGEST_ALLOW_CLAUDE_STATE_READ=1 sh +``` + +Or use the natural-language prompt: + ```text 请帮我安装这个 Claude Code skill: @@ -47,8 +61,6 @@ It also checks for Python 3.10+ and a supported Chromium browser: Google Chrome, Reinstalling preserves the existing installed `.state` directory, including saved API and browser-session settings. The installer still excludes `.state` from the development checkout. After installation, configure and run the installed copy. If a script is accidentally started from a temporary clone while an installed copy exists, it automatically re-runs the installed copy so state is saved under `~/.claude/skills/twitter-digest/.state` or `~/.codex/skills/twitter-digest/.state`. -API and browser sources are isolated: API runs never start a browser and never collect DMs. Browser runs collect visible X Chat/DM and merge DM into the same daily digest. Browser DM automation may trigger X account risk controls, login challenges, or passcode recovery; avoid long-running unattended DM collection. - Use one stable installed command form for normal runs. This prevents repeated Claude Code Bash permission prompts across different projects: ```text @@ -71,11 +83,8 @@ On first run, a dedicated browser profile opens. Log in to X once in that browse There are three collection entry points: ```bash -# Browser-only public collector -python3 twitter-digest/scripts/browser_x_digest.py - -# Browser-only DM collector, for debugging only -python3 twitter-digest/scripts/browser_x_digest.py --dm-only +# Browser-only collector +python3 twitter-digest/scripts/browser_x_digest.py --include-dms # API-only collector, requires OAuth2 user-context credentials or --bearer-token X_BEARER_TOKEN=... python3 twitter-digest/scripts/api_x_digest.py --handle @@ -86,31 +95,25 @@ python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py `run_daily_digest.py` defaults to `--source auto`: -- If `X_BEARER_TOKEN` or `TWITTER_BEARER_TOKEN` is configured, it uses the API collector. -- Otherwise it falls back to the browser collector. +- A normal daily run uses API when saved API credentials or `X_BEARER_TOKEN` / `TWITTER_BEARER_TOKEN` exist. +- If no API credentials exist, it uses the browser collector. +- Use `--source browser` only when the user explicitly wants browser collection. -Force a source: +Source isolation: -```bash -python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source browser -X_BEARER_TOKEN=... python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source api --handle -``` - -API mode is for stable public-data collection, including the official home timeline endpoint when the configured token has user-context timeline access. API mode never starts a browser and never collects DMs. API source has no DM collection. XChat / encrypted DMs are only handled by browser source. App-only API keys are not enough for user-context data. +- API mode only runs `api_x_digest.py`; it never opens a browser or reads the browser profile. +- Browser mode only runs `browser_x_digest.py`; it does not use API tokens or API collector output. +- Default `--source auto` picks one source for the run and does not merge API and browser data. +- API DM data gaps are notes only; they do not mean browser DM data was collected. -To generate a browser digest with DM included: +Force a source: ```bash python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source browser +X_BEARER_TOKEN=... python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source api --handle ``` -To generate an API-only public digest with no browser and no DM: - -```bash -python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source api -``` - -DM browser automation may trigger X account risk controls, login challenges, or passcode recovery, so do not use it for long-running or unattended jobs. This risk is documented here and is not repeated as a runtime warning prompt. +API mode is for stable public-data collection, including the official home timeline endpoint when the configured token has user-context timeline access. API mode never starts a browser, never reads the browser profile, and never collects X Chat / DM content from the browser. Use browser mode when visible X Chat / DM is required. API DM lookup is marked TODO because XChat / encrypted DMs may not appear in `/2/dm_events`; do not use API DM to conclude there are no private messages. App-only API keys are not enough for user-context data. ## Configure API In Chat @@ -144,13 +147,13 @@ The app's callback URL in X Developer Portal must match the redirect URI shown b http://127.0.0.1:8765/callback ``` -For API public collection, include these scopes when configuring the X App / OAuth flow: +For API DM lookup through OAuth2, include these scopes when configuring the X App / OAuth flow: ```text -tweet.read users.read offline.access +dm.read tweet.read users.read offline.access ``` -DM is not collected through the API product path. Use browser source when visible X Chat / encrypted DM content is required. +Use `dm.write` only if the app will send or delete messages; the digest skill only reads. On macOS prompts appear as system dialogs; non-GUI terminals fall back to hidden terminal input. The token is saved to: @@ -158,20 +161,18 @@ On macOS prompts appear as system dialogs; non-GUI terminals fall back to hidden twitter-digest/.state/api_config.json ``` -The file is created with owner-only permissions where supported. Later runs of `run_daily_digest.py --source auto` read this saved config and use API automatically. To clear it: +The file is created with owner-only permissions where supported. Later normal runs read this saved config and use API without opening the browser. Use `run_daily_digest.py --source browser` only when the user explicitly wants browser collection. To clear API config: ```bash python3 ~/.claude/skills/twitter-digest/scripts/configure_api.py --clear ``` -If OAuth returns a refresh token, later daily runs refresh the saved access token automatically before collection. +If OAuth returns a refresh token, later API-source runs refresh the saved access token automatically before collection. Chat flow summary: ```text 生成 X 日报 -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py -浏览器日报带私信 -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source browser -API 日报不带私信 -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --source api 输入 X token -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure-api-token 配置 X API -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure-api 清除 X API 配置 -> agent runs python3 ~/.claude/skills/twitter-digest/scripts/configure_api.py --clear diff --git a/twitter-digest/RUNBOOK.md b/twitter-digest/RUNBOOK.md index da4f6b9..aa4300a 100644 --- a/twitter-digest/RUNBOOK.md +++ b/twitter-digest/RUNBOOK.md @@ -4,7 +4,7 @@ `twitter-digest` 读取用户自己的 X/Twitter 数据并生成中文日报。抓数据层支持 API 和本地已登录浏览器两种来源。 -默认入口是 `scripts/run_daily_digest.py`。如果已经通过对话内 OAuth 授权保存了 user-context token,或环境里配置了 X API token,它优先用 API 抓公开数据;如果没有 API 配置,它自动回退到浏览器抓取公开数据。API 和浏览器完全分开:走 API 时不会启动浏览器,也不会读取 DM。X Chat / DM 只属于浏览器来源:走浏览器时默认采集并合并 DM;走 API 时不启动浏览器、不采集 DM。 +默认入口是 `scripts/run_daily_digest.py`,默认 `--source auto`。如果已经通过对话内 OAuth 授权保存了 user-context token,或环境里配置了 X API token,普通日报自动走 API;如果没有 API 配置,才使用本地浏览器抓取。只有用户主动要求浏览器,或命令显式传入 `--source browser` 时,才强制浏览器。API 模式只抓公开数据,不打开浏览器。 核心链路: @@ -31,7 +31,9 @@ scripts/run_daily_digest.py 上层入口。默认 --source auto: - 检测到已保存 OAuth token 或 X_BEARER_TOKEN / TWITTER_BEARER_TOKEN 时走 API。 - 没有 API 配置时走浏览器。 - - 保存了 refresh token 时自动刷新过期 access token。 + - 用户主动要求浏览器或显式 `--source browser` 时强制浏览器。 + - API source 已选择时不回退浏览器;API 失败就报错或写 data gap。 + - 使用 API 且保存了 refresh token 时自动刷新过期 access token。 ``` 强制浏览器: @@ -46,7 +48,14 @@ python3 twitter-digest/scripts/run_daily_digest.py --source browser X_BEARER_TOKEN=... python3 twitter-digest/scripts/run_daily_digest.py --source api --handle ``` -API 模式重点用于更稳定地抓公开数据。API 模式不会启动浏览器,也不会读取 X Chat / DM;DM 只存在于浏览器模式。API 来源没有 DM 采集能力;XChat / 加密私信只走浏览器来源。 +API 模式重点用于更稳定地抓公开数据。API 模式不会打开浏览器。X Chat / DM 内容仍以浏览器模式为准;如果 API 模式无法读取 DM,会在 `digest-context` 的 Data Gaps 中标注。 + +来源隔离规则: + +- API 模式只运行 `api_x_digest.py`,不启动浏览器、不读取浏览器 profile、不用浏览器补采 API 缺口。 +- 浏览器模式只运行 `browser_x_digest.py`,不读取 API token、不合并 API collector 输出。 +- 默认 `--source auto` 每次只选择一个来源,不合并 API 和浏览器两边的数据。 +- API 模式输出的 DM 浏览器确认提示只是 data gap,不代表浏览器数据已被采集。 ## 对话内 API 授权 @@ -68,7 +77,7 @@ python3 ~/.claude/skills/twitter-digest/scripts/run_daily_digest.py --configure- 6. 脚本通过本地 callback 收到授权码。 7. 脚本换取 user access token 和 refresh token。 8. token 保存到已安装 skill 的 `.state/api_config.json`,文件权限尽量设为 owner-only。 -9. 后续日报 `--source auto` 自动走 API。 +9. 后续普通日报自动走 API;要临时使用浏览器时显式运行 `--source browser`。 用户只需要准备: @@ -79,7 +88,7 @@ CLIENT_SECRET(如 App 要求) `ACCESS_TOKEN` 和 `REFRESH_TOKEN` 由授权流程生成。 -配置成功后,后续日报不再要求用户输入 Client ID、Secret 或重新授权。`run_daily_digest.py` 会读取 `.state/api_config.json`: +配置成功后,后续日报不再要求用户输入 Client ID、Secret 或重新授权。普通 `run_daily_digest.py` 会读取 `.state/api_config.json` 并默认走 API: - OAuth2:如果保存了 refresh token,access token 快过期时自动 refresh。 - 如果 token 被撤销、权限变更、tier 不支持或 API 返回 401/403/429,脚本把 endpoint 错误写入 data gap,Agent 再提示用户是否重新配置。 @@ -145,6 +154,12 @@ python3 twitter-digest/scripts/install.py --skip-browser-check 3. 如果登录态有效,不弹浏览器。 4. 如果登录失效,再打开可见浏览器让用户重新登录。 +浏览器日报必须识别当前登录账号的 handle。脚本会先从 X 的账号切换器、Profile 导航和账号相关 DOM 自动识别;如果 headless 识别不到,会打开可见浏览器重试。仍识别不到时直接停止,不生成日报。此时让用户确认可见浏览器里登录的是正确账号,或显式运行: + +```bash +python3 twitter-digest/scripts/run_daily_digest.py --handle +``` + 不会读取用户常用浏览器 profile,也不会要求用户复制 cookie/token。 ## 默认采集范围 @@ -155,17 +170,7 @@ python3 twitter-digest/scripts/install.py --skip-browser-check - `own_profile`:自己的主页,用于看自己最近发帖/互动。 - `mentions_search`:搜索 `@当前账号`。 - `mentions_notifications`:通知里的 @ 提及。 -API 来源不采集 `messages`;浏览器来源会采集 `messages` 并合并到日报。如果用户要求日报带私信,运行浏览器来源: - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser -``` - -如果用户要求不启动浏览器、不采集私信,运行 API 来源: - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source api -``` +- `messages`:X Chat / DM。 默认不采集关键词。只有显式传 `--keywords` 时才增加关键词搜索页。 @@ -210,9 +215,7 @@ python3 twitter-digest/scripts/run_daily_digest.py --source api ## DM 采集逻辑 -DM 不由开关控制。浏览器来源默认读取 X Messages 并把 DM 合并进同一份日报;API 来源不启动浏览器、不读取 DM。`--dm-only` 仅保留为低层调试模式。 - -DM 采集依赖浏览器自动化访问 X Chat,可能触发 X 的账号风控、登录挑战、passcode 恢复或其他平台限制。不要把 DM 采集做成长时间无人值守任务。 +DM 默认读取。 先读取 X Chat 左侧会话列表,并统计今天可见会话: @@ -327,7 +330,7 @@ python3 twitter-digest/scripts/inspect_digest.py 该脚本只输出计数、加载状态和数据缺口,不输出 DM 正文。 -普通 X 日报默认结构: +默认结构: ```markdown ## X 日报 - YYYY-MM-DD @@ -338,6 +341,8 @@ python3 twitter-digest/scripts/inspect_digest.py **谁 @ 了你** +**私信(DM)** + **时间线热点** **你的动态** @@ -351,8 +356,7 @@ python3 twitter-digest/scripts/inspect_digest.py - 先判断今天有没有需要处理的风险、机会或回复。 - @ 提及按重要性分组。 -- API 来源时,最多在数据缺口里写“API 来源未采集 DM”,不能写“没有私信”。 -- 浏览器来源时,在同一份日报里写 **私信(DM)** 章节,只重点总结 `waiting_reply`。 +- DM 只重点总结 `waiting_reply`。 - `last_from_me` 不当作待处理。 - 垃圾、钓鱼、低质营销只计数,不展开。 - 私信内容只做必要摘要,不贴完整隐私历史。 @@ -365,8 +369,6 @@ python3 twitter-digest/scripts/inspect_digest.py - 生成今日 X 日报。 - 今天谁 @ 我了? -- 生成浏览器日报,带私信。 -- 生成 API 日报,不启动浏览器也不带私信。 - 哪些 DM 等我回复? - 有没有重要私信? - 哪些私信是垃圾/钓鱼? @@ -375,25 +377,20 @@ python3 twitter-digest/scripts/inspect_digest.py - 帮我起草回复,但不要发送。 - 这次哪些数据没读到? - 给我看 `digest-context`。 +- 跳过 DM 生成日报。 ## 常用命令 -普通 X 日报: +完整日报: ```bash python3 twitter-digest/scripts/run_daily_digest.py ``` -浏览器日报(含 DM): - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser -``` - -API 日报(无浏览器、无 DM): +跳过 DM: ```bash -python3 twitter-digest/scripts/run_daily_digest.py --source api +python3 twitter-digest/scripts/run_daily_digest.py --no-dms ``` 强制显示浏览器: diff --git a/twitter-digest/SKILL.md b/twitter-digest/SKILL.md index 957d88c..2d43b30 100644 --- a/twitter-digest/SKILL.md +++ b/twitter-digest/SKILL.md @@ -1,13 +1,13 @@ --- name: twitter-digest -description: Use when the user wants Claude Code or another agent to analyze their own X/Twitter mentions, home timeline, visible direct messages, reply opportunities, and daily social-media summaries through API or local logged-in browser collection. +description: Use when the user asks to generate an X/Twitter daily digest or says phrases such as "生成X日报", "生成 x 日报", "X日报", "推特日报", "Twitter digest", or wants an agent to analyze their own X/Twitter mentions, home timeline, visible direct messages, reply opportunities, and daily social-media summaries through API or local logged-in browser collection. --- # X/Twitter Digest ## Overview -Use this skill to produce a concise Chinese daily digest from the user's own X/Twitter account. The recommended entry point is `scripts/run_daily_digest.py`, which selects API collection when API credentials are configured and otherwise falls back to local browser collection with a persistent dedicated Chromium profile. +Use this skill to produce a concise Chinese daily digest from the user's own X/Twitter account. Use `scripts/run_daily_digest.py`, which defaults to `--source auto`: API collection when API credentials are configured, otherwise local browser collection with a persistent dedicated Chromium profile. Installing this skill does not configure X API and must not open an API configuration Terminal. After the user explicitly configures X API once, normal `RUN_DAILY_DIGEST` runs use API by default. Use `RUN_DAILY_DIGEST --source browser` when the user explicitly wants browser collection. Once API source is selected by default or explicitly, API failures should fail or report data gaps until the user fixes or clears the API configuration. After installation, configuration and daily runs should use the installed skill copy, not a temporary clone/source checkout. Installed locations are `~/.claude/skills/twitter-digest` for Claude Code and `~/.codex/skills/twitter-digest` for Codex. If `run_daily_digest.py` or `configure_api.py` is accidentally run from a source checkout while an installed copy exists, the script automatically re-runs the installed copy so `.state` is written to the installed skill directory. @@ -39,7 +39,7 @@ There are three collection scripts: ```bash python3 twitter-digest/scripts/browser_x_digest.py # browser collector python3 twitter-digest/scripts/api_x_digest.py # API collector -python3 twitter-digest/scripts/run_daily_digest.py # upper wrapper, --source auto +python3 twitter-digest/scripts/run_daily_digest.py # upper wrapper, default auto source ``` For chat usage, run the wrapper: @@ -48,7 +48,14 @@ For chat usage, run the wrapper: RUN_DAILY_DIGEST ``` -`run_daily_digest.py --source auto` uses saved OAuth2 user-context credentials, `X_BEARER_TOKEN`, or `TWITTER_BEARER_TOKEN` for public data when present; otherwise it uses the browser collector. API and browser are isolated: API source never starts a browser and never collects DMs. DM/X Chat is source-specific: browser source always collects visible X Chat/DM and merges it into the same daily digest; API source never starts a browser and never collects DM. API source has no DM collection; use browser source when DM is required. +`run_daily_digest.py` defaults to `--source auto`. Auto uses saved OAuth2 user-context credentials, `X_BEARER_TOKEN`, or `TWITTER_BEARER_TOKEN` for public data when present; otherwise it uses the browser collector. When API credentials are present, normal `RUN_DAILY_DIGEST` uses API and must not fall back to browser on API errors. Treat API DM lookup as TODO / waiting for X to fix XChat-encrypted DM coverage; do not use API DM to decide whether the user has private messages. + +Source isolation is strict: + +- API source runs only `api_x_digest.py`. It never starts a browser, never opens X pages, never reads the browser profile, and never supplements missing API data with browser data. +- Browser source runs only `browser_x_digest.py`. It uses the dedicated browser profile and does not use saved API tokens or API collector output. +- Default source is auto. It picks exactly one source for the run: API when API credentials exist, otherwise browser. It does not merge both sources. +- If API output says DM needs browser confirmation, treat that as a data gap note only. It does not mean browser data was collected. If the user asks to configure API access, trigger the OAuth/user-token setup from chat: @@ -58,15 +65,15 @@ RUN_DAILY_DIGEST --configure-api This is an agent-triggered flow. It supports OAuth2 user authorization: -- OAuth2 path: if the user has an X Developer App OAuth2 `Client ID` / `Client Secret` and local callback URL, run `RUN_DAILY_DIGEST --configure-api`. It goes directly into OAuth2 setup. Request `tweet.read users.read offline.access`. +- OAuth2 path: if the user has an X Developer App OAuth2 `Client ID` / `Client Secret` and local callback URL, run `RUN_DAILY_DIGEST --configure-api`. It goes directly into OAuth2 setup. Request `dm.read tweet.read users.read offline.access`. - Existing token path: if the user says they already have an OAuth2 user access token, run `RUN_DAILY_DIGEST --configure-api-token`. - If the agent is not inside an interactive Terminal, do not background `configure_api.py` yourself. Use `run_daily_digest.py --configure-api`; it opens a real Terminal window for secure input and OAuth callback handling. `configure_api.py --oauth` also self-opens Terminal when invoked non-interactively, but the wrapper is the primary path. - OAuth1 PIN is not a supported normal setup path for this skill because it did not reliably return DM data during validation. Do not guide users to Consumer Key / Consumer Secret / PIN unless they are explicitly debugging legacy API behavior. - Do not write ad-hoc inline Python or shell snippets to verify tokens. Use the built-in verifier: `CONFIGURE_API --verify`. It calls `/users/me`, backfills `handle` / `user_id`, and does not print the token. -If a refresh token is saved, later daily runs refresh the access token automatically. Do not ask the user to export environment variables manually. App-only API keys are not enough for user-context home timeline access. +If a refresh token is saved, API-source runs refresh the access token automatically. Do not ask the user to export environment variables manually. App-only API keys are not enough for user-context home timeline access. -After API setup succeeds once, future daily digest runs should not ask the user for credentials again. Run `RUN_DAILY_DIGEST`; it reads `.state/api_config.json` automatically. OAuth2 credentials are refreshed automatically when a refresh token is saved. Only rerun `--configure-api` when the saved credentials are missing, revoked, expired without refresh, or the user explicitly asks to change accounts/apps. +After API setup succeeds once, future daily digest runs should not ask the user for credentials again. Normal `RUN_DAILY_DIGEST` reads `.state/api_config.json` automatically and uses API by default. OAuth2 credentials are refreshed automatically when a refresh token is saved. Only rerun `--configure-api` when the saved credentials are missing, revoked, expired without refresh, or the user explicitly asks to change accounts/apps. Use `RUN_DAILY_DIGEST --source browser` only when the user explicitly wants browser collection. If the user asks to clear API access, run: @@ -76,9 +83,7 @@ CONFIGURE_API --clear All normal flows should be triggered from chat by the agent: -- X 日报 / 生成日报: run `RUN_DAILY_DIGEST`. -- 带私信的浏览器日报: run `RUN_DAILY_DIGEST --source browser`. -- 纯 API 公开数据日报: run `RUN_DAILY_DIGEST --source api`. +- X 日报 / 生成日报: run `RUN_DAILY_DIGEST`; this uses API if configured, otherwise browser. - 用户已有 token / 输入 X token: run `RUN_DAILY_DIGEST --configure-api-token`. - 配置 X API / 给 app 授权: run `RUN_DAILY_DIGEST --configure-api`. - 验证 X API 配置: run `CONFIGURE_API --verify`. @@ -86,32 +91,44 @@ All normal flows should be triggered from chat by the agent: - 清除 X API 配置: run `CONFIGURE_API --clear`. - 调试浏览器: run `RUN_DAILY_DIGEST --source browser --headed`. -Force a source: +Force a script source: ```bash RUN_DAILY_DIGEST --source browser X_BEARER_TOKEN=... RUN_DAILY_DIGEST --source api --handle ``` -The first browser run opens a dedicated browser profile at `twitter-digest/.state/chrome-profile`. The user logs in to X once in that browser. Later browser runs default to headless collection and reuse the saved local browser session. If the saved login is unavailable, the script automatically opens a visible browser window for manual login. The skill has two collector scripts: `scripts/api_x_digest.py` for official API public data, and `scripts/browser_x_digest.py` for browser-visible X Chat / encrypted DM content. API source does not collect DM. - -Browser source reads visible X Chat/DM by default and merges it into the same daily digest. API source never starts a browser and never collects DMs. `RUN_DAILY_DIGEST --dm-only` is retained only as a debug mode for DM collection, not the normal product path. Do not use browser DM collection for unattended scheduled runs. +Browser-source runs use a dedicated browser profile at `twitter-digest/.state/chrome-profile`. The user logs in to X once in that browser. Later browser runs default to headless collection and reuse the saved local browser session. If the saved login is unavailable during a browser-source run, the script automatically opens a visible browser window for manual login. API-source runs do not touch this profile and are used automatically after API credentials are configured unless the user explicitly selects `--source browser`. The skill has two collector scripts: `scripts/api_x_digest.py` for official API public data, and `scripts/browser_x_digest.py` for browser-visible X Chat / encrypted DM content. API-visible DM events remain TODO-only until X fixes or documents reliable XChat coverage. -DM collection uses browser automation against X Chat and may trigger X account risk controls, login challenges, passcode recovery, or other platform restrictions. This risk belongs in the documentation; do not add extra runtime warning prompts. +DM reading is enabled by default only for browser-source runs and only reads visible local browser content. API-source runs do not start a browser, even when `--include-dms` is passed. To skip DMs for a browser run: -X API DM endpoints are not used by this product path. API source has no DM; browser source is required for visible X Chat / encrypted DM content. +```bash +RUN_DAILY_DIGEST --no-dms +``` Default scope: - Mentions of the authenticated handle. - Home timeline hotspots. - Own profile activity. +- Browser source only: today's visible DM conversations, with only conversations whose latest preview is not from the user opened for content. - Optional keyword searches only when the user explicitly passes `--keywords`. -Public timeline/profile/mentions pages use the same daily-window loading model as DMs. API public collection keeps up to 300 items; browser public collection scrolls each public page up to 40 rounds, keeps up to 100 public items, waits for DOM growth after each scroll, and only allows early stop after at least 5 scroll rounds when loaded post timestamps show content beyond the 24-hour digest window (`--scrolls 40`, `--min-public-scrolls 5`, `--max-public-items 100`, `--public-window-hours 24`). +Public timeline/profile/mentions pages use the same daily-window loading model as DMs. API public collection keeps up to 300 items; browser public collection scrolls each public page up to 40 rounds, keeps up to 100 public items, waits for DOM growth after each scroll, and only allows early stop after at least 5 scroll rounds when loaded post timestamps show content beyond the 24-hour digest window (`--scrolls 40`, `--min-public-scrolls 5`, `--max-public-items 100`, `--public-window-hours 24`). The generated digest context applies a second strict filter: public timeline/profile/mention items must be inside `[now - 24 hours, now]` in the user's current local timezone. Items with missing or unparseable timestamps are excluded from final-summary facts and reported as `time-unverified` data gaps. + +Mention handling is strict: + +- Both mention sources must be considered when available: direct mention/notification collection and handle search (`@handle` / equivalent search page or API recent search). Do not conclude "no current mentions" from only one source unless the other source was attempted and failed or is unavailable. +- Do not include mentions older than the local 24-hour window in `该处理`, `谁 @ 了你`, or reply drafts. +- Do not present an already-replied mention as needing reply. Use own-profile/API own posts, browser-visible reply context, or context metadata when available to decide whether the authenticated account already replied after the mention timestamp. +- When `digest-context.md` marks a mention with `reply_state=already_replied` or `action_state=handled`, it must not appear as a pending reply opportunity. It may be omitted or summarized as already handled. +- When `digest-context.md` marks `reply_state=reply_unverified`, do not say the user "needs to reply"; write `回复状态未确认` and suggest review only if the content is important. +- If reply status cannot be verified from the current run's data, label the item as `回复状态未确认` instead of claiming the user still needs to reply. Read the installed `digest-context.md` with the file Read tool when writing the Chinese digest. Its `Final Summary Facts` section is the content source for the final summary. If more focused context is needed, use the file Read tool on the split current-run files: `digest-context-timeline.md` for home/profile/timeline items, `digest-context-mentions.md` for @ mentions, and `digest-context-dm.md` for visible DM conversations. Use `digest-input.md` only when debugging collection issues, not during normal summarization. Do not add content from older runs. Do not write ad-hoc `python3 -c`, shell, `cat`, `head`, `tail`, `grep`, or temporary scripts to inspect context structure during normal summarization. If counts or non-content structure must be checked, run the built-in `scripts/inspect_digest.py`, which does not print DM bodies. +If `digest-context.md` or `digest-context-mentions.md` shows missing mention sources, collection errors for `mentions_search` / `mentions_notifications`, or only stale mention data, report that as a data gap. Do not turn stale mentions into action items. + ## Install From the repository `skills/` directory: @@ -120,6 +137,8 @@ From the repository `skills/` directory: python3 twitter-digest/scripts/install.py ``` +For one-line installs through `twitter-digest/install.sh`, Codex, Claude Code, and other non-interactive macOS agents open a real Terminal window and re-run the full installation there. This keeps `git clone`, Python/browser prerequisite checks, and skill-directory writes out of the agent permission sandbox. To force direct in-process installation from an already interactive terminal or CI, set `TWITTER_DIGEST_OPEN_TERMINAL=0`. + Default install targets the current agent client: Codex installs to `~/.codex/skills/twitter-digest`, Claude Code installs to `~/.claude/skills/twitter-digest`. Use `--client codex`, `--client claude`, or `--skills-dir` to override. Local development can use `--symlink`. For Claude Code, the skill cannot silently grant itself Bash permission or file access outside the project. On first use, approve the visible `run_daily_digest.py` command and choose "don't ask again" if appropriate. For a global opt-in during install, run: @@ -132,8 +151,6 @@ This explicitly adds one Claude Code Bash allow rule for `python3 ~/.claude/skil The installer checks for Python 3.10+ and a supported Chromium browser before installing. Supported browsers are Google Chrome, Chromium, Microsoft Edge, and Brave. If the browser will be installed later, use `--skip-browser-check`. -The installer prints the source policy after install: API source never starts a browser and never collects DMs; browser source collects visible X Chat/DM and merges DM into the same X daily digest. Browser DM automation may trigger X account risk controls, login challenges, or passcode recovery, so avoid long-running unattended DM collection. - The installer moves old `twitter-briefing`, `twitter-briefing.bak`, or existing `twitter-digest` installs into the selected skills directory's `.backups/` folder and disables their `SKILL.md` files so the current agent does not load duplicate old skills. It preserves `.state` from an existing installed `twitter-digest` copy during reinstall, but does not copy `.state` from the development checkout. Claude Code or other agents can use the installed skill by running the same browser scripts. @@ -163,16 +180,10 @@ When the user asks for an X daily digest or X 日报, run: RUN_DAILY_DIGEST ``` -If they ask for a digest that includes DMs, use browser source: - -```bash -RUN_DAILY_DIGEST --source browser -``` - -If they ask for API-only public data without browser/DM, use API source: +If they ask to skip DMs: ```bash -RUN_DAILY_DIGEST --source api +RUN_DAILY_DIGEST --no-dms ``` If the authenticated handle is not detected or the user corrects it: @@ -181,6 +192,8 @@ If the authenticated handle is not detected or the user corrects it: RUN_DAILY_DIGEST --handle --account-name "<显示名>" --save-default ``` +Browser-source daily digests must identify the authenticated handle before collecting public pages. If automatic handle detection fails, the collector opens a visible browser window and retries. If the handle still cannot be identified, the run must stop and the agent must not generate a daily digest from partial browser data; ask the user to rerun with `--handle ` or confirm the correct account in the visible X window. + For debugging or manual inspection: ```bash @@ -199,7 +212,7 @@ Do not ask the user to copy cookies or configure another service. If the script Treat browser sessions, cookies observed internally by the script, DMs, phone numbers, emails, private handles, and screenshots as sensitive. Do not post, reply, like, follow, block, open suspicious links, accept DM requests, or send DMs unless the user explicitly asks after reviewing a draft. -Browser DM collection runs when source is browser. It reads message content visible in the logged-in local browser. If X Chat shows a passcode setup, passcode entry, or end-to-end-encryption recovery screen during headless collection, the script should automatically reopen X Messages in a visible browser window, wait for the user to complete it, then retry DM collection. In `--non-interactive` mode, record the DM data gap and continue without blocking. Do not choose, enter, or store a passcode for the user. +Browser DM collection only reads message content visible in the logged-in local browser. If X Chat shows a passcode setup, passcode entry, or end-to-end-encryption recovery screen during headless collection, the script should automatically reopen X Messages in a visible browser window, wait for the user to complete it, then retry DM collection. In `--non-interactive` mode, record the DM data gap and continue without blocking. Do not choose, enter, or store a passcode for the user. ### 3. Analyze @@ -211,7 +224,9 @@ Group mentions by reason to care: - Opportunity: partnership, hiring, customer lead, investor/media attention, community praise. - Noise: spam, generic tags, low-context reposts. -When browser-source DM collection runs, classify DMs as: +Before putting a mention into `✅ 该处理` or `🔴 值得回 / 需要处理`, confirm it is within the local 24-hour window and not already replied to. If it is already replied to, either omit it from action items or mark it as already handled. If the current run cannot verify reply status, mark `回复状态未确认` and avoid phrasing it as definitely needing a reply. For mentions, treat `reply_state` / `action_state` in `digest-context.md` as authoritative over the text of the mention. + +Classify DMs as: - `urgent`: time-sensitive, business-critical, safety/security, money, reputation, or deadline. - `important`: meaningful relationship, opportunity, unresolved issue, or action needed. @@ -234,7 +249,7 @@ For private messages, summarize minimally. Quote only the short phrase needed to ### 4. Produce The Daily Summary -除非用户另有要求,X 日报最终输出必须用中文,并使用这个结构。API 来源时如果 context says `dm_status: not_requested`, write "API 来源未采集 DM" only if needed, not "没有私信"。浏览器来源时,把 DM 作为同一份日报的一个章节,不要另起一份 DM 日报。 +除非用户另有要求,最终输出必须用中文,并使用这个结构: ```markdown ## 🐦 X 日报 - YYYY-MM-DD @@ -250,11 +265,13 @@ For private messages, summarize minimally. Quote only the short phrase needed to - 🔴 值得回 / 需要处理 - 🟡 一般互动 - ⚪ 噪音折叠统计 +- 只包含本地 24 小时窗口内的 mentions;已回复过的 mentions 不再作为待回复提醒 **◆ 私信(DM)** - 会话统计:今日可见会话 N 个,最后我发出 N 个,等我回复 N 个 - 消息统计:已打开等我回复会话中捕获消息 N 条 - 仅挑重点总结等我回复的私信;垃圾、钓鱼、低质营销只计数并归为忽略 +- 🔴 重要 / 🟡 一般 / ⚪ 忽略 **◆ 时间线热点** 1. 热点:一句话解释 + 代表观点 + 和用户的关系 @@ -266,7 +283,7 @@ For private messages, summarize minimally. Quote only the short phrase needed to - 只给草稿,不自动发送。 **⚠️ 数据缺口** -- 哪些公开页面没读到、是否只扫描了前 N 条;如果未采集 DM,写“本次未采集 DM”。 +- 哪些页面没读到、DM 是否不可见、是否只扫描了前 N 条。 ``` 保持简洁、有判断、可执行。价值在总结和行动建议,不是把页面内容全文搬运。 diff --git a/twitter-digest/agents/openai.yaml b/twitter-digest/agents/openai.yaml index 116d4ec..1a0d6fb 100644 --- a/twitter-digest/agents/openai.yaml +++ b/twitter-digest/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Twitter Digest" - short_description: "Analyze X mentions, trends, DMs, daily summaries, and local memory" - default_prompt: "Use $twitter-digest to summarize my X mentions, timeline hotspots, important DMs, and what changed since prior runs." + short_description: "Generate X/Twitter daily digests from own account" + default_prompt: "Use $twitter-digest to generate my X daily digest, including mentions, timeline hotspots, important DMs, and action items." diff --git a/twitter-digest/install.sh b/twitter-digest/install.sh new file mode 100755 index 0000000..a406493 --- /dev/null +++ b/twitter-digest/install.sh @@ -0,0 +1,137 @@ +#!/bin/sh +set -eu + +TAG="${TWITTER_DIGEST_INSTALL_TAG:-v1.5.11}" +REPO="${TWITTER_DIGEST_INSTALL_REPO:-https://github.com/BofAI/skills.git}" +CLIENT="${TWITTER_DIGEST_INSTALL_CLIENT:-auto}" +ALLOW_CLAUDE_COMMANDS="${TWITTER_DIGEST_ALLOW_CLAUDE_COMMANDS:-0}" +ALLOW_CLAUDE_STATE_READ="${TWITTER_DIGEST_ALLOW_CLAUDE_STATE_READ:-0}" +SKIP_BROWSER_CHECK="${TWITTER_DIGEST_SKIP_BROWSER_CHECK:-0}" +OPEN_TERMINAL="${TWITTER_DIGEST_OPEN_TERMINAL:-auto}" + +info() { + printf '==> %s\n' "$1" +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +shell_quote() { + value="$1" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" +} + +applescript_quote() { + value="$1" + printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +truthy() { + case "${1:-}" in + 1|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +running_under_agent() { + if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ "${__CFBundleIdentifier:-}" = "com.openai.codex" ]; then + return 0 + fi + if env | grep -Eq '^(CLAUDE|ANTHROPIC)'; then + return 0 + fi + return 1 +} + +should_open_terminal() { + if [ "${TWITTER_DIGEST_TERMINAL_CHILD:-}" = "1" ]; then + return 1 + fi + case "$OPEN_TERMINAL" in + 1|true|yes) return 0 ;; + 0|false|no) return 1 ;; + esac + if [ "$(uname -s)" != "Darwin" ]; then + return 1 + fi + if running_under_agent; then + return 0 + fi + if [ ! -t 0 ]; then + return 0 + fi + return 1 +} + +open_self_in_terminal_and_exit() { + command_exists osascript || fail "Cannot open macOS Terminal because osascript is unavailable." + command_exists curl || fail "curl is required to open the installer in Terminal." + + installer_url="https://raw.githubusercontent.com/BofAI/skills/${TAG}/twitter-digest/install.sh" + args_text="" + for arg in "$@"; do + args_text="${args_text} $(shell_quote "$arg")" + done + command_text="cd ~ && TMPDIR=\"\$(mktemp -d)\" && INSTALL_SH=\"\$TMPDIR/twitter-digest-install.sh\" && curl -fsSL $(shell_quote "$installer_url") -o \"\$INSTALL_SH\" && chmod 700 \"\$INSTALL_SH\" && env TWITTER_DIGEST_TERMINAL_CHILD=1 TWITTER_DIGEST_OPEN_TERMINAL=0 TWITTER_DIGEST_INSTALL_TAG=$(shell_quote "$TAG") TWITTER_DIGEST_INSTALL_REPO=$(shell_quote "$REPO") TWITTER_DIGEST_INSTALL_CLIENT=$(shell_quote "$CLIENT") TWITTER_DIGEST_ALLOW_CLAUDE_COMMANDS=$(shell_quote "$ALLOW_CLAUDE_COMMANDS") TWITTER_DIGEST_ALLOW_CLAUDE_STATE_READ=$(shell_quote "$ALLOW_CLAUDE_STATE_READ") TWITTER_DIGEST_SKIP_BROWSER_CHECK=$(shell_quote "$SKIP_BROWSER_CHECK") /bin/sh \"\$INSTALL_SH\"${args_text}; printf '\\nPress Enter to close this window...'; IFS= read -r _" + osascript >/dev/null </dev/null 2>&1; then + printf 'Error: git is required to install twitter-digest.\n' >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + printf 'Error: python3 is required to install twitter-digest.\n' >&2 + exit 1 +fi + +if command -v mktemp >/dev/null 2>&1; then + WORKDIR="$(mktemp -d 2>/dev/null || mktemp -d -t twitter-digest)" +else + WORKDIR="${TMPDIR:-/tmp}/twitter-digest-install.$$" + mkdir -p "$WORKDIR" +fi + +CLONE_DIR="$WORKDIR/skills" +info "Cloning ${REPO} at ${TAG}" +git clone --depth 1 --branch "$TAG" "$REPO" "$CLONE_DIR" + +args="" +case "$CLIENT" in + auto|codex|claude) args="$args --client $CLIENT" ;; + *) printf 'Error: TWITTER_DIGEST_INSTALL_CLIENT must be auto, codex, or claude.\n' >&2; exit 1 ;; +esac + +if truthy "$ALLOW_CLAUDE_COMMANDS"; then + args="$args --allow-claude-commands" +fi + +if truthy "$ALLOW_CLAUDE_STATE_READ"; then + args="$args --allow-claude-state-read" +fi + +if truthy "$SKIP_BROWSER_CHECK"; then + args="$args --skip-browser-check" +fi + +info "Running twitter-digest installer" +# shellcheck disable=SC2086 +exec python3 "$CLONE_DIR/twitter-digest/scripts/install.py" $args "$@" diff --git a/twitter-digest/references/x-twitter-digest.md b/twitter-digest/references/x-twitter-digest.md index 51fc079..262456e 100644 --- a/twitter-digest/references/x-twitter-digest.md +++ b/twitter-digest/references/x-twitter-digest.md @@ -6,9 +6,9 @@ Use this reference when implementing, auditing, or troubleshooting the X/Twitter The data collection layer has three scripts: -- `scripts/browser_x_digest.py`: local browser collector. It launches a dedicated Chromium profile at `twitter-digest/.state/chrome-profile`, reads X page DOM, and is required for opt-in X Chat / DM content. +- `scripts/browser_x_digest.py`: local browser collector. It launches a dedicated Chromium profile at `twitter-digest/.state/chrome-profile`, reads X page DOM, and is required for X Chat / DM content. - `scripts/api_x_digest.py`: official API collector. It uses saved OAuth2 user-context credentials, `X_BEARER_TOKEN` / `TWITTER_BEARER_TOKEN`, or `--bearer-token` and writes the same `digest-input.*` shape as the browser collector. -- `scripts/run_daily_digest.py`: upper wrapper. Default `--source auto` uses API when configured, otherwise browser. +- `scripts/run_daily_digest.py`: upper wrapper. Default source is auto: API when credentials are configured, otherwise browser. Use `--source browser` when the user explicitly wants browser collection. Browser mode: @@ -22,10 +22,10 @@ API mode: - Intended for stable public-data collection. - Requires user-context authorization for user-owned timelines. App-only keys are not enough for home timeline reliability. - Reads the official reverse chronological home timeline when the token has user-context timeline access. -- Browser-source daily runs collect visible X Chat/DM and merge it into one daily digest. API-source runs never start a browser and never collect DM. +- Normal daily runs use browser collection for DMs. API DM lookup is retained only as TODO/debug because XChat / encrypted messages may not appear in `/2/dm_events`. - Records endpoint-level API failures as data gaps instead of silently treating them as empty pages. -- API source does not collect DM. Use `run_daily_digest.py --source browser` when DM collection is required. -- Saved OAuth tokens are configured by the agent-triggered `run_daily_digest.py --configure-api` flow. OAuth2 PKCE is the supported path for user-owned local X Apps: the user provides the Client ID, authorizes the app in the browser, and the script saves the access token plus refresh token. OAuth2 tokens are refreshed automatically when a refresh token is saved. +- DM lookup failures, zero-event API responses, and inconclusive API DM results are recorded as `api_dm_todo`; do not summarize them as empty inboxes. Use browser collection for X Chat / encrypted DMs while waiting for X to fix or document reliable API coverage. +- Saved OAuth tokens are configured only by the agent-triggered `run_daily_digest.py --configure-api` flow, after the user explicitly asks to configure X API. OAuth2 PKCE is the supported path for user-owned local X Apps: the user provides the Client ID, authorizes the app in the browser, and the script saves the access token plus refresh token. OAuth2 tokens are refreshed automatically when a refresh token is saved. Chat-triggered API setup: @@ -42,26 +42,18 @@ python3 ~/.claude/skills/twitter-digest/scripts/configure_api.py --verify Do not write ad-hoc `python3 -c` snippets or temporary verification scripts in chat. The built-in verifier calls `/users/me`, backfills `handle` / `user_id`, and does not print the token. -Typical daily run: +Typical chat run: ```bash python3 twitter-digest/scripts/run_daily_digest.py ``` -Browser digest with DM: +Visible DM collection is enabled by default. To skip DMs: ```bash -python3 twitter-digest/scripts/run_daily_digest.py --source browser -``` - -API-only digest without browser/DM: - -```bash -python3 twitter-digest/scripts/run_daily_digest.py --source api +python3 twitter-digest/scripts/run_daily_digest.py --no-dms ``` -DM browser automation can trigger X account risk controls, login challenges, passcode recovery, or other platform restrictions. Keep it user-triggered and avoid long-running or unattended DM jobs. - Optional keyword search is off by default. Use it only when the user explicitly asks: ```bash @@ -108,20 +100,21 @@ This prints counts, load status, and data gaps without printing DM message bodie - Do not ask the user to copy cookies or tokens. - If X shows a login page, wait for the user to log in manually. - If X shows CAPTCHA or account challenge, stop and ask the user to resolve it in the browser. +- Browser daily digest collection must identify the authenticated handle before collecting `own_profile`, `mentions_search`, and `mentions_notifications`. If headless detection fails, retry in a visible browser. If detection still fails, stop the run; do not let the agent summarize partial browser data as a daily digest. - Do not post, like, follow, accept DM requests, open suspicious links, or reply. - Keep scrolling bounded by the digest goal. API public collection defaults to `--max-public-items 300`; browser public collection defaults to `--scrolls 40`, `--max-public-items 100`, and `--public-window-hours 24`, stopping early when loaded post timestamps are clearly outside the daily window. -- Read DM content only in browser source. API source must not start a browser and must not collect DM. +- Read only DM content that is visible in the local logged-in browser. Use `--no-dms` when the user does not want DMs processed. - If X Chat shows passcode setup, passcode entry, or encryption-key recovery, automatically reopen X Messages in a visible browser window, wait for the user to complete it, then retry DM collection. In `--non-interactive` mode, skip DM recovery and record a data gap. The script must not choose, enter, or store the passcode. ## Pages Collected -Default browser public pages: +Default browser pages: - `home`: home timeline for hotspot detection. - `own_profile`: the authenticated account profile. - `mentions_search`: live search for `@handle`. - `mentions_notifications`: notifications mentions page. -- `messages`: X Messages, only in browser source or low-level `--dm-only` debug mode. +- `messages`: X Messages. Optional pages: @@ -141,12 +134,25 @@ Privacy rule: do not write long-term memory or daily archives. Raw DM text may e Date rule: - Run dates use the user's local timezone, not UTC. +- Public timeline/profile/mentions facts use a strict `[now - 24 hours, now]` window in the user's current local timezone. +- Public items with missing or unparseable timestamps are excluded from final-summary facts and must be reported as `time-unverified` data gaps. ## Hotspot Detection Cluster home timeline posts by repeated topics, hashtags, URLs, named entities, accounts, and semantic similarity. Optional keyword-search posts may be included only when the user explicitly passes `--keywords`. -Public timeline, profile, and mentions collection follows the same bounded-window model as DM collection: load enough context for a 24-hour digest, cap the amount passed to the model, and stop early when timestamps show older content. Treat public item counts as browser-loaded items in this run, not exhaustive X history. +Public timeline, profile, and mentions collection follows the same bounded-window model as DM collection: load enough context for a 24-hour digest, cap the amount passed to the model, and stop early when timestamps show older content. Treat public item counts as browser-loaded items in this run, not exhaustive X history. The generated digest context applies a second strict local-time 24-hour filter before writing final-summary public facts; do not reintroduce older raw-capture items while summarizing. + +Mention handling is strict: + +- Direct mention/notification collection and handle search (`@handle` / equivalent search page or API recent search) are complementary. Use both sources when available before concluding there are no current mentions or no reply opportunities. +- If one mention source is missing, failed, unavailable, or only returned stale data, report the source status as a data gap and do not turn stale mentions into action items. +- Mentions older than the local 24-hour window must not appear in action items, reply drafts, or the `谁 @ 了你` section. +- Before labeling a mention as needing reply, verify from current-run data whether the authenticated account already replied after the mention timestamp. +- Already-replied mentions should be omitted from action items or marked as already handled. +- If `digest-context.md` includes `reply_state=already_replied` or `action_state=handled`, the mention is not a pending reply. Do not include it in `✅ 该处理` as a reply task. +- If `digest-context.md` includes `reply_state=reply_unverified`, report `回复状态未确认`; do not claim the user definitely needs to reply. +- If reply status cannot be verified, label the mention as `回复状态未确认` instead of claiming the user still needs to reply. A hotspot needs at least one of: @@ -241,5 +247,5 @@ Reply drafting rules: ## 中文每日 Prompt ```text -使用 $twitter-digest 读取我最近 24 小时的 X/Twitter 动态,生成中文日报。重点总结谁 @ 了我、时间线热点、需要处理的互动、我的账号动态;如果使用浏览器来源,也把需要处理的私信作为同一份日报的 DM 章节;如果使用 API 来源,不采集 DM。先给今日总结和行动建议,再给明细。回复只生成草稿,不要自动发送。读不到的数据要明确标注。 +使用 $twitter-digest 通过本地浏览器读取我最近 24 小时的 X/Twitter 动态,生成中文日报。重点总结谁 @ 了我、时间线热点、需要处理的私信或互动、我的账号动态。先给今日总结和行动建议,再给明细。回复只生成草稿,不要自动发送。读不到的数据要明确标注。 ``` diff --git a/twitter-digest/scripts/api_x_digest.py b/twitter-digest/scripts/api_x_digest.py index 0411ae9..2ce415e 100644 --- a/twitter-digest/scripts/api_x_digest.py +++ b/twitter-digest/scripts/api_x_digest.py @@ -30,8 +30,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--api-base", default=os.environ.get("X_API_BASE_URL") or API_BASE) parser.add_argument("--keywords", default="", help="Comma-separated keywords or queries for optional search.") parser.add_argument("--out", default=str(default_state_dir / "run")) - parser.add_argument("--include-dms", action="store_true", help=argparse.SUPPRESS) - parser.add_argument("--dm-max-events", type=int, default=300, help=argparse.SUPPRESS) + parser.add_argument("--include-dms", action="store_true", help="Try DM endpoints when API access supports them.") + parser.add_argument("--dm-max-events", type=int, default=300, help="Maximum Direct Message events kept from the API.") parser.add_argument("--max-public-items", type=int, default=300) parser.add_argument("--public-window-hours", type=int, default=24) parser.add_argument("--scrolls", type=int, default=0, help=argparse.SUPPRESS) @@ -101,6 +101,17 @@ def tweet_params(max_results: int, hours: int) -> dict[str, Any]: } +def dm_params(max_results: int, hours: int) -> dict[str, Any]: + return { + "max_results": max(10, min(100, max_results)), + "dm_event.fields": "id,text,event_type,created_at,dm_conversation_id,sender_id,participant_ids,attachments,referenced_tweets", + "expansions": "sender_id,participant_ids,attachments.media_keys,referenced_tweets.id", + "user.fields": "username,name", + "media.fields": "url,preview_image_url,alt_text,type", + "post.fields": "created_at,author_id,text,entities", + } + + def collect_paginated( args: argparse.Namespace, path: str, @@ -195,6 +206,9 @@ def normalize_tweets(raw: list[dict[str, Any]], includes: dict[str, Any], source out.append( { "api_source": source_kind, + "id": tweet_id, + "conversation_id": str(tweet.get("conversation_id") or ""), + "author_username": username, "text": text, "metrics": metrics, "url": url, @@ -204,6 +218,11 @@ def normalize_tweets(raw: list[dict[str, Any]], includes: dict[str, Any], source "cards": cards, "time": tweet.get("created_at"), "authorUrl": f"https://x.com/{username}" if username else "", + "referenced_tweets": [ + {"id": str(ref.get("id") or ""), "type": str(ref.get("type") or "")} + for ref in (tweet.get("referenced_tweets") or []) + if isinstance(ref, dict) + ], } ) return out @@ -218,6 +237,117 @@ def parse_time(value: Any) -> dt.datetime | None: return None +def normalize_dm_asset_links(event: dict[str, Any]) -> list[dict[str, str]]: + entities = event.get("entities") if isinstance(event.get("entities"), dict) else {} + links = [] + for link in entities.get("urls") or []: + if not isinstance(link, dict): + continue + url = str(link.get("expanded_url") or link.get("url") or "") + if url: + links.append({"url": url, "label": str(link.get("title") or link.get("display_url") or "")}) + return links + + +def normalize_dm_events(raw: list[dict[str, Any]], includes: dict[str, Any], current_user_id: str, max_threads: int = 50) -> dict[str, Any]: + users = index_by(includes.get("users", []), "id") + media = index_by(includes.get("media", []), "media_key") + tweets = index_by(includes.get("tweets", []), "id") + conversations: dict[str, list[dict[str, Any]]] = {} + for event in raw: + if not isinstance(event, dict): + continue + if str(event.get("event_type") or "").lower() not in {"message_create", "messagecreate", ""}: + continue + conversation_id = str(event.get("dm_conversation_id") or event.get("conversation_id") or event.get("id") or "") + if not conversation_id: + continue + conversations.setdefault(conversation_id, []).append(event) + + thread_rows: list[dict[str, Any]] = [] + for conversation_id, events in conversations.items(): + events.sort(key=lambda item: str(item.get("created_at") or "")) + latest = events[-1] if events else {} + participant_ids: set[str] = set() + for event in events: + sender_id = str(event.get("sender_id") or "") + if sender_id: + participant_ids.add(sender_id) + for participant_id in event.get("participant_ids") or []: + participant_ids.add(str(participant_id)) + other_ids = [pid for pid in participant_ids if pid and pid != current_user_id] + participant_names = [] + for pid in other_ids: + user = users.get(pid) or {} + username = str(user.get("username") or "") + name = str(user.get("name") or "") + participant_names.append(f"@{username}" if username else name or pid) + participant = ", ".join(participant_names) or conversation_id + replied = str(latest.get("sender_id") or "") == current_user_id + messages = [] + for event in events: + sender = "me" if str(event.get("sender_id") or "") == current_user_id else "other" + text = str(event.get("text") or "") + media_items = [] + attachments = event.get("attachments") if isinstance(event.get("attachments"), dict) else {} + for key in attachments.get("media_keys") or []: + item = media.get(str(key)) + if not item: + continue + url = item.get("url") or item.get("preview_image_url") or "" + if url: + media_items.append({"type": str(item.get("type") or "media"), "url": str(url), "alt": str(item.get("alt_text") or "")}) + links = normalize_dm_asset_links(event) + cards = [] + for ref in event.get("referenced_tweets") or []: + if not isinstance(ref, dict): + continue + tweet = tweets.get(str(ref.get("id"))) or {} + cards.append({"url": f"https://x.com/i/web/status/{ref.get('id')}", "text": str(tweet.get("text") or "")[:500]}) + messages.append({"sender": sender, "time": str(event.get("created_at") or ""), "text": text, "links": links + cards, "media": media_items}) + thread_text = "\n".join(f"{message['sender']} {message.get('time') or ''}: {message.get('text') or ''}" for message in messages) + thread_rows.append( + { + "participant": participant, + "label": participant, + "url": f"https://x.com/messages/{conversation_id}", + "replied": replied, + "message_count": len(messages), + "text": thread_text, + "messages": messages, + "dm_load_complete": True, + "dm_scrolls_used": 0, + "dm_window_exceeded": False, + "dm_hit_message_cap": False, + "latest_time": str(latest.get("created_at") or ""), + } + ) + + thread_rows.sort(key=lambda row: str(row.get("latest_time") or ""), reverse=True) + visible_threads = thread_rows[:max_threads] + waiting_threads = [thread for thread in visible_threads if not thread.get("replied")] + replied_threads = [thread for thread in visible_threads if thread.get("replied")] + return { + "threads": waiting_threads, + "visible_count": len(visible_threads), + "replied_count": len(replied_threads), + "unreplied_count": len(waiting_threads), + "captured_messages": sum(int(thread.get("message_count") or 0) for thread in waiting_threads), + } + + +def filter_events_by_window(raw: list[dict[str, Any]], hours: int) -> list[dict[str, Any]]: + cutoff = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=max(1, hours)) + filtered = [] + for event in raw: + if not isinstance(event, dict): + continue + event_time = parse_time(event.get("created_at")) + if event_time and event_time >= cutoff: + filtered.append(event) + return filtered + + def page(kind: str, url: str, items: list[dict[str, Any]], note: str = "", error: str = "") -> dict[str, Any]: result: dict[str, Any] = {"kind": kind, "url": url, "items": items} if note: @@ -296,6 +426,56 @@ def collect_api(args: argparse.Namespace) -> dict[str, Any]: ) pages.append(page(f"keyword_{index}", f"{args.api_base}/tweets/search/recent?q={urllib.parse.quote(keyword)}", normalize_tweets(raw, includes, f"keyword_{index}"), error="; ".join(errors))) + if args.include_dms: + dm_raw, dm_includes, dm_errors = collect_paginated( + args, + "/dm_events", + dm_params(max(10, int(args.dm_max_events)), hours), + max(1, int(args.dm_max_events)), + ) + dm_window_raw = filter_events_by_window(dm_raw, hours) + dm_summary = normalize_dm_events(dm_window_raw, dm_includes, user_id, max_threads=50) + if dm_errors: + dm_status = "api_dm_todo" + dm_note = "TODO: X API DM lookup failed. Do not treat this as an empty inbox; use browser DM collection if DM coverage is required." + dm_todo_detail = "API DM request failed; check OAuth2 dm.read scope, Project/API access, rate limits, or use browser DM collection." + elif dm_summary["visible_count"] == 0: + dm_status = "api_dm_todo" + dm_note = "TODO: X API returned 0 DM events in the digest window. XChat/encrypted messages may not be exposed through DM Events API; use browser DM collection if DM coverage is required." + dm_todo_detail = "API DM returned 0 events; verify with browser X Chat before saying there are no DMs." + elif dm_summary["unreplied_count"] == 0: + dm_status = "api_dm_todo" + dm_note = "TODO: X API returned DM events but no waiting-reply conversation. Browser X Chat should be checked before concluding no DM action is needed." + dm_todo_detail = "API DM has events but no waiting-reply thread; browser DM collection remains authoritative for XChat/encrypted conversations." + else: + dm_status = "captured_unreplied_threads" + dm_note = "X API DM lookup captured recent waiting-reply conversations. API events are limited by X API retention, permissions, and rate limits." + dm_todo_detail = "" + messages_page = { + "kind": "messages", + "url": f"{args.api_base}/dm_events", + "items": [], + "dm_status": dm_status, + "dm_note": dm_note, + "dm_threads": dm_summary["threads"], + "dm_visible_thread_count": dm_summary["visible_count"], + "dm_replied_thread_count": dm_summary["replied_count"], + "dm_unreplied_thread_count": dm_summary["unreplied_count"], + "dm_captured_message_count": dm_summary["captured_messages"], + } + if dm_todo_detail: + messages_page["todo_items"] = [ + { + "source": "messages", + "status": dm_status, + "detail": dm_todo_detail, + } + ] + if dm_errors: + messages_page["collection_status"] = "error" + messages_page["collection_error"] = "; ".join(dm_errors) + pages.append(messages_page) + return { "generated_at": dt.datetime.now().astimezone().isoformat(), "source": "api", diff --git a/twitter-digest/scripts/browser_x_digest.py b/twitter-digest/scripts/browser_x_digest.py index 4deb499..c5bb6ae 100644 --- a/twitter-digest/scripts/browser_x_digest.py +++ b/twitter-digest/scripts/browser_x_digest.py @@ -37,8 +37,8 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--public-window-hours", type=int, default=24, help="Stop loading older public timeline items once posts beyond this window are detected.") parser.add_argument("--login-timeout-sec", type=int, default=300) parser.add_argument("--include-dms", action="store_true", help="Also visit X messages and capture visible conversation text.") - parser.add_argument("--dm-only", action="store_true", help="Debug mode: collect only X messages.") - parser.add_argument("--dm-threads", type=int, default=5, help="Maximum recent DM threads to open when DM collection is enabled.") + parser.add_argument("--dm-only", action="store_true", help="Collect only X messages. Used when API already collected public data.") + parser.add_argument("--dm-threads", type=int, default=5, help="Maximum recent DM threads to open when --include-dms is set.") parser.add_argument("--dm-list-scrolls", type=int, default=20, help="Maximum downward scroll rounds used to scan today's DM conversation list.") parser.add_argument("--dm-scrolls", type=int, default=200, help="Maximum upward scroll rounds per opened DM thread.") parser.add_argument("--dm-max-messages", type=int, default=2000, help="Maximum message bubbles kept per opened DM thread.") @@ -157,6 +157,46 @@ def error_page(page: dict[str, str], exc: BaseException) -> dict[str, Any]: } +def resolve_required_handle( + args: argparse.Namespace, + profile_dir: Path, + proc: Any, + port: int, + headless: bool, +) -> tuple[str, Any, int, bool]: + explicit = args.handle.lstrip("@").strip() if args.handle else "" + if explicit: + return explicit, proc, port, headless + + handle = detect_handle(port) + if handle: + return handle, proc, port, headless + + if args.non_interactive: + raise SystemExit( + "Could not auto-detect the authenticated X handle from the saved browser session. " + "Rerun interactively so the browser can be inspected, or pass --handle . " + "The digest was stopped because mentions and own-profile collection require the authenticated account." + ) + + if headless and not args.headed: + print("Could not auto-detect X handle in headless mode. Opening a visible browser window to verify the logged-in account...", flush=True) + stop_browser(proc) + proc, port = launch_browser(profile_dir, "https://x.com/home", headless=False) + headless = False + wait_for_login(port, args.login_timeout_sec, interactive=True) + handle = detect_handle(port) + if handle: + return handle, proc, port, headless + + raise SystemExit( + "Could not auto-detect the authenticated X handle from the browser UI. " + "Open the visible X window, confirm the correct account is logged in and the left account/profile area is visible, " + "then rerun the digest or pass --handle . " + "The digest was stopped because @mentions and own-profile collection would be incomplete without this." + ) + + def main() -> None: args = parse_args() if args.dm_only: @@ -185,11 +225,11 @@ def main() -> None: write_digest_output(out_dir, data) print(json.dumps({"out_dir": str(out_dir), "pages": len(data["pages"]), "headless": headless, "login": "unavailable"}, indent=2)) return - handle = args.handle.lstrip("@") if args.handle else detect_handle(port) - if handle: - print(f"Using X handle: @{handle}") + if args.dm_only: + handle = args.handle.lstrip("@") if args.handle else None else: - print("Could not auto-detect X handle. Mention search will be skipped unless --handle is provided.") + handle, proc, port, headless = resolve_required_handle(args, profile_dir, proc, port, headless) + print(f"Using X handle: @{handle}") pages = build_pages(handle, args.keywords, args.include_dms, args.dm_only) data = { "generated_at": dt.datetime.now().astimezone().isoformat(), diff --git a/twitter-digest/scripts/configure_api.py b/twitter-digest/scripts/configure_api.py index d31d02d..4d9faa1 100644 --- a/twitter-digest/scripts/configure_api.py +++ b/twitter-digest/scripts/configure_api.py @@ -26,7 +26,7 @@ AUTHORIZE_URL = "https://x.com/i/oauth2/authorize" TOKEN_URL = "https://api.x.com/2/oauth2/token" DEFAULT_REDIRECT_URI = "http://127.0.0.1:8765/callback" -DEFAULT_SCOPES = "tweet.read users.read offline.access" +DEFAULT_SCOPES = "dm.read tweet.read users.read offline.access" def parse_args() -> argparse.Namespace: @@ -346,7 +346,7 @@ def main() -> None: "handle": saved.get("handle") or config["handle"], "user_id": saved.get("user_id") or config["user_id"], "verification": verification, - "next_step": "Run scripts/run_daily_digest.py; --source auto will use the saved API token.", + "next_step": "Normal scripts/run_daily_digest.py runs now use the saved API token. Use --source browser only when you explicitly want browser collection.", }, ensure_ascii=False, indent=2, diff --git a/twitter-digest/scripts/digest_context.py b/twitter-digest/scripts/digest_context.py index 349e8e6..0a9d96c 100644 --- a/twitter-digest/scripts/digest_context.py +++ b/twitter-digest/scripts/digest_context.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import datetime as dt import json import re from pathlib import Path @@ -82,6 +83,11 @@ def summarize_current_run(data: dict[str, Any]) -> dict[str, Any]: def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[str, Any]: + now = dt.datetime.now().astimezone() + cutoff = now - dt.timedelta(hours=24) + public_items: list[dict[str, Any]] = [] + loaded_public_kinds: set[str] = set() + kept_public_counts: dict[str, int] = {} facts: dict[str, Any] = { "schema_version": 1, "run": { @@ -89,6 +95,9 @@ def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[st "date": summary.get("date"), "source": summary.get("source"), "timezone": local_timezone_name(), + "window_start": cutoff.isoformat(), + "window_end": now.isoformat(), + "window_hours": 24, }, "account": { "handle": summary.get("handle") or clean_handle(data.get("handle")), @@ -104,9 +113,11 @@ def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[st "Only threads whose latest preview is not from the user are opened for content.", "Do not treat embedded post authors as DM senders.", "Count low-value waiting-reply DMs but do not expand spam, phishing, generic promotions, or repeated junk.", + "Public timeline/profile/mention items must be inside the local-time 24-hour window.", + "Do not present already-handled mentions as needing reply; if reply status is unclear, label it as unverified.", ], }, - "public": {"counts": summary.get("post_counts") or {}, "items": []}, + "public": {"counts": {}, "loaded_counts": summary.get("post_counts") or {}, "items": []}, "dms": { "status": summary.get("dm_status"), "counts": summary.get("dm_counts") or {}, @@ -120,6 +131,8 @@ def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[st if not isinstance(page, dict): continue kind = str(page.get("kind") or "unknown") + if kind != "messages": + loaded_public_kinds.add(kind) if page.get("collection_error"): facts["data_gaps"].append( { @@ -201,21 +214,25 @@ def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[st for item in page.get("items") or []: if not isinstance(item, dict): continue - facts["public"]["items"].append( - { - "kind": kind, - "time": format_local_time(item.get("time")), - "raw_time": item.get("time") or "", - "url": item.get("url") or "", - "author_url": item.get("authorUrl") or "", - "text_excerpt": compact_text(item.get("text"))[:700], - "external_links": normalize_context_assets(item.get("externalLinks")), - "media": normalize_context_assets(item.get("media")), - "cards": normalize_context_assets(item.get("cards")), - } - ) - if (summary.get("dm_status") or "") == "not_requested": - facts["dms"]["note"] = "DM was not collected in this run because the API source was used. Browser source includes DM; API source never starts a browser and never collects DM." + item_time = parse_item_time(item.get("time")) + if item_time is None: + facts["data_gaps"].append( + { + "source": kind, + "status": "time-unverified", + "detail": f"Excluded public item without parseable timestamp: {compact_text(item.get('url') or item.get('text'))[:180]}", + } + ) + continue + local_item_time = item_time.astimezone() + if local_item_time < cutoff or local_item_time > now: + continue + public_counts = facts["public"]["counts"].setdefault(kind, {"total": 0}) + public_counts["total"] = int(public_counts.get("total") or 0) + 1 + kept_public_counts[kind] = kept_public_counts.get(kind, 0) + 1 + public_items.append(normalize_public_item(kind, item)) + facts["public"]["items"] = annotate_public_reply_states(public_items, str((facts.get("account") or {}).get("handle") or "")) + add_public_source_gaps(facts, loaded_public_kinds, kept_public_counts, summary) if (summary.get("dm_status") or "") in {"blocked_by_x_chat_passcode", "visible_threads_unopened", "no_visible_threads", "dm_page_loading_timeout", "api_dm_unavailable", "api_dm_error", "api_dm_todo"}: facts["data_gaps"].append( { @@ -235,6 +252,126 @@ def build_digest_facts(data: dict[str, Any], summary: dict[str, Any]) -> dict[st return facts +def add_public_source_gaps( + facts: dict[str, Any], + loaded_public_kinds: set[str], + kept_public_counts: dict[str, int], + summary: dict[str, Any], +) -> None: + post_counts = summary.get("post_counts") if isinstance(summary.get("post_counts"), dict) else {} + required_mentions = ["mentions_search", "mentions_notifications"] + for kind in required_mentions: + if kind not in loaded_public_kinds: + facts["data_gaps"].append( + { + "source": kind, + "status": "missing_required_mention_source", + "detail": f"{kind} was not present in this run; do not conclude mention state from the other source alone.", + } + ) + continue + loaded_total = int((post_counts.get(kind) or {}).get("total") or 0) if isinstance(post_counts.get(kind), dict) else 0 + kept_total = int(kept_public_counts.get(kind, 0)) + if loaded_total > 0 and kept_total == 0: + facts["data_gaps"].append( + { + "source": kind, + "status": "no_in_window_items", + "detail": f"{kind} loaded {loaded_total} item(s), but none survived the local 24-hour filter.", + } + ) + + +def normalize_public_item(kind: str, item: dict[str, Any]) -> dict[str, Any]: + author_username = clean_handle(item.get("author_username") or item.get("authorUsername") or author_from_url(item.get("authorUrl"))) + references = item.get("referenced_tweets") if isinstance(item.get("referenced_tweets"), list) else [] + return { + "kind": kind, + "id": compact_text(item.get("id")), + "conversation_id": compact_text(item.get("conversation_id")), + "author_username": author_username, + "time": format_local_time(item.get("time")), + "raw_time": item.get("time") or "", + "url": item.get("url") or "", + "author_url": item.get("authorUrl") or "", + "text_excerpt": compact_text(item.get("text"))[:700], + "referenced_tweets": [ + {"id": compact_text(ref.get("id")), "type": compact_text(ref.get("type"))} + for ref in references + if isinstance(ref, dict) + ], + "external_links": normalize_context_assets(item.get("externalLinks")), + "media": normalize_context_assets(item.get("media")), + "cards": normalize_context_assets(item.get("cards")), + } + + +def annotate_public_reply_states(items: list[dict[str, Any]], handle: str) -> list[dict[str, Any]]: + clean_self = clean_handle(handle).lower() + own_items = [item for item in items if is_own_public_item(item, clean_self)] + for item in items: + if "mention" not in str(item.get("kind") or "").lower(): + continue + evidence = find_reply_evidence(item, own_items) + if evidence: + item["reply_state"] = "already_replied" + item["action_state"] = "handled" + item["reply_evidence"] = evidence + else: + item["reply_state"] = "reply_unverified" + item["action_state"] = "review_without_claiming_needs_reply" + return items + + +def is_own_public_item(item: dict[str, Any], clean_self: str) -> bool: + kind = str(item.get("kind") or "").lower() + author = clean_handle(item.get("author_username")).lower() + return kind == "own_profile" or (bool(clean_self) and author == clean_self) + + +def find_reply_evidence(mention: dict[str, Any], own_items: list[dict[str, Any]]) -> str: + mention_time = parse_item_time(mention.get("raw_time")) + mention_id = compact_text(mention.get("id")) + conversation_id = compact_text(mention.get("conversation_id")) + mention_author = clean_handle(mention.get("author_username")).lower() + for own in own_items: + own_time = parse_item_time(own.get("raw_time")) + if mention_time and own_time and own_time <= mention_time: + continue + if mention_id and referenced_ids(own) and mention_id in referenced_ids(own): + return f"own reply references mention tweet {mention_id}" + own_conversation_id = compact_text(own.get("conversation_id")) + if conversation_id and own_conversation_id and own_conversation_id == conversation_id and compact_text(own.get("id")) != mention_id: + return f"own post appears later in same conversation {conversation_id}" + if mention_author and f"@{mention_author}" in str(own.get("text_excerpt") or "").lower(): + return f"own post mentions @{mention_author} after the mention" + return "" + + +def referenced_ids(item: dict[str, Any]) -> set[str]: + refs = item.get("referenced_tweets") if isinstance(item.get("referenced_tweets"), list) else [] + return {compact_text(ref.get("id")) for ref in refs if isinstance(ref, dict) and ref.get("id")} + + +def author_from_url(value: Any) -> str: + text = str(value or "").strip() + match = re.search(r"https?://(?:www\.)?(?:x|twitter)\.com/([^/?#]+)", text) + return match.group(1) if match else "" + + +def parse_item_time(value: Any) -> dt.datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + parsed = dt.datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.datetime.now().astimezone().tzinfo) + return parsed + + def assess_dm_thread(thread: dict[str, Any]) -> dict[str, Any]: text = compact_text(thread.get("text") or thread.get("label")).lower() if not text: @@ -539,7 +676,10 @@ def render_public_slice_section(facts: dict[str, Any], slice_name: str) -> str: lines.append("| none | 0 |") lines.extend(["", "## Public Items", ""]) for item in items[:300]: - lines.append(f"- `{item.get('kind')}` `{item.get('time')}` {item.get('url') or '[no url]'} - {item.get('text_excerpt')}") + reply_state = f" reply_state=`{item.get('reply_state')}` action_state=`{item.get('action_state')}`" if item.get("reply_state") else "" + evidence = f" reply_evidence={item.get('reply_evidence')}" if item.get("reply_evidence") else "" + author = f" @{item.get('author_username')}" if item.get("author_username") else "" + lines.append(f"- `{item.get('kind')}` `{item.get('time')}`{author}{reply_state}{evidence} {item.get('url') or '[no url]'} - {item.get('text_excerpt')}") for asset in item.get("media") or []: alt = f" alt={asset.get('alt')}" if asset.get("alt") else "" poster = f" poster={asset.get('poster')}" if asset.get("poster") else "" @@ -591,6 +731,9 @@ def render_digest_facts(facts: dict[str, Any]) -> str: f"- generated_at: `{(facts.get('run') or {}).get('generated_at')}`", f"- source: `{(facts.get('run') or {}).get('source') or 'browser'}`", f"- timezone: `{(facts.get('run') or {}).get('timezone')}`", + f"- window_start: `{(facts.get('run') or {}).get('window_start')}`", + f"- window_end: `{(facts.get('run') or {}).get('window_end')}`", + f"- window_hours: `{(facts.get('run') or {}).get('window_hours')}`", f"- account: `@{(facts.get('account') or {}).get('handle') or ''}`", ] lines.extend(["", *render_dm_facts_section(facts).splitlines()]) @@ -607,7 +750,10 @@ def render_digest_facts(facts: dict[str, Any]) -> str: lines.extend(["", "## Public Items", ""]) for item in ((facts.get("public") or {}).get("items") or [])[:300]: - lines.append(f"- `{item.get('kind')}` `{item.get('time')}` {item.get('url') or '[no url]'} - {item.get('text_excerpt')}") + reply_state = f" reply_state=`{item.get('reply_state')}` action_state=`{item.get('action_state')}`" if item.get("reply_state") else "" + evidence = f" reply_evidence={item.get('reply_evidence')}" if item.get("reply_evidence") else "" + author = f" @{item.get('author_username')}" if item.get("author_username") else "" + lines.append(f"- `{item.get('kind')}` `{item.get('time')}`{author}{reply_state}{evidence} {item.get('url') or '[no url]'} - {item.get('text_excerpt')}") for asset in item.get("media") or []: alt = f" alt={asset.get('alt')}" if asset.get("alt") else "" poster = f" poster={asset.get('poster')}" if asset.get("poster") else "" diff --git a/twitter-digest/scripts/digest_io.py b/twitter-digest/scripts/digest_io.py index 823a960..9208e49 100644 --- a/twitter-digest/scripts/digest_io.py +++ b/twitter-digest/scripts/digest_io.py @@ -66,7 +66,7 @@ def render_markdown(data: dict[str, Any]) -> str: "", "- 浏览器采集依赖 X 页面结构和已加载的可见内容。", "- 日报默认使用较小滚动次数;需要更全覆盖时再提高 `--scrolls`。", - "- DM 属于私密内容。浏览器来源会读取并合并 DM;API 来源不启动浏览器、不读取 DM。", + "- DM 属于私密内容。只有用户明确同意本地读取时才使用 `--include-dms`。", ] ) return "\n".join(lines) + "\n" diff --git a/twitter-digest/scripts/dom_scripts/detect_handle.js b/twitter-digest/scripts/dom_scripts/detect_handle.js index d745438..27ef266 100644 --- a/twitter-digest/scripts/dom_scripts/detect_handle.js +++ b/twitter-digest/scripts/dom_scripts/detect_handle.js @@ -1,16 +1,75 @@ (() => { + const RESERVED_PATHS = new Set([ + 'home', 'explore', 'notifications', 'messages', 'i', 'search', 'settings', + 'compose', 'jobs', 'premium', 'verified-orgs', 'privacy', 'tos', + 'login', 'signup', 'logout', 'download', 'intent', 'share', 'hashtag', + ]); + const validHandle = (value) => /^[A-Za-z0-9_]{1,15}$/.test(value || '') && !RESERVED_PATHS.has(String(value).toLowerCase()); + const cleanHandle = (value) => { + const text = String(value || ''); + const match = text.match(/@([A-Za-z0-9_]{1,15})/); + return match && validHandle(match[1]) ? match[1] : null; + }; + const handleFromHref = (href) => { + if (!href) return null; + let parsed; + try { + parsed = new URL(href, location.href); + } catch (_) { + return null; + } + if (!/(^|\.)x\.com$|(^|\.)twitter\.com$/.test(parsed.hostname)) return null; + const parts = parsed.pathname.split('/').filter(Boolean); + if (parts.length !== 1) return null; + return validHandle(parts[0]) ? parts[0] : null; + }; + const fromText = (...values) => { + for (const value of values) { + const handle = cleanHandle(value); + if (handle) return handle; + } + return null; + }; + const account = document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]'); const accountText = account ? account.innerText : ''; - const accountMatch = accountText.match(/@([A-Za-z0-9_]{1,15})/); - if (accountMatch) return accountMatch[1]; + const accountMatch = fromText(accountText, account && account.getAttribute('aria-label')); + if (accountMatch) return accountMatch; + const profileLink = document.querySelector('[data-testid="AppTabBar_Profile_Link"]'); const profileHref = profileLink ? profileLink.getAttribute('href') : ''; - const profileMatch = profileHref.match(/^\/([A-Za-z0-9_]{1,15})$/); - if (profileMatch) return profileMatch[1]; - const labels = Array.from(document.querySelectorAll('[aria-label]')).map(el => el.getAttribute('aria-label') || ''); - for (const label of labels) { - const match = label.match(/@([A-Za-z0-9_]{1,15})/); - if (match && /account|profile|账号|帳號|账户/i.test(label)) return match[1]; + const profileHrefMatch = handleFromHref(profileHref); + if (profileHrefMatch) return profileHrefMatch; + const profileTextMatch = fromText(profileLink && profileLink.innerText, profileLink && profileLink.getAttribute('aria-label')); + if (profileTextMatch) return profileTextMatch; + + const accountLikeNodes = Array.from(document.querySelectorAll([ + '[data-testid*="Account"]', + '[data-testid*="account"]', + '[aria-label*="account" i]', + '[aria-label*="profile" i]', + '[aria-label*="账号"]', + '[aria-label*="帳號"]', + '[aria-label*="账户"]', + '[aria-label*="个人资料"]', + '[aria-label*="個人資料"]', + ].join(','))); + for (const node of accountLikeNodes) { + const handle = fromText(node.innerText, node.getAttribute('aria-label'), node.getAttribute('title')); + if (handle) return handle; + const linkHandle = handleFromHref(node.getAttribute('href') || ''); + if (linkHandle) return linkHandle; } + + const nav = document.querySelector('nav') || document.body; + const navProfileLinks = Array.from(nav.querySelectorAll('a[href]')).filter((link) => { + const text = `${link.innerText || ''} ${link.getAttribute('aria-label') || ''}`; + return /profile|account|账号|帳號|账户|个人资料|個人資料/i.test(text); + }); + for (const link of navProfileLinks) { + const handle = handleFromHref(link.getAttribute('href') || '') || fromText(link.innerText, link.getAttribute('aria-label')); + if (handle) return handle; + } + return null; })() diff --git a/twitter-digest/scripts/dom_scripts/extract_articles.js b/twitter-digest/scripts/dom_scripts/extract_articles.js index bfd0ea9..d1818fa 100644 --- a/twitter-digest/scripts/dom_scripts/extract_articles.js +++ b/twitter-digest/scripts/dom_scripts/extract_articles.js @@ -68,6 +68,13 @@ const text = (article.innerText || '').trim(); const links = Array.from(article.querySelectorAll('a[href]')).map(a => a.href).filter(Boolean); const status = links.map(statusUrl).find(Boolean) || null; + let tweetId = null; + if (status) { + try { + const match = new URL(status).pathname.match(/\/status\/(\d+)/); + tweetId = match ? match[1] : null; + } catch {} + } const times = Array.from(article.querySelectorAll('time')).map(t => t.getAttribute('datetime')).filter(Boolean); const authorLinks = links.filter(h => { try { @@ -75,7 +82,14 @@ return /^\/[^/]+$/.test(p) && !p.includes('/i/'); } catch { return false; } }); + let authorUsername = null; + if (authorLinks[0]) { + try { + authorUsername = new URL(authorLinks[0]).pathname.split('/').filter(Boolean)[0] || null; + } catch {} + } return { + id: tweetId, text, url: status, links, @@ -83,7 +97,8 @@ media: mediaInfo(article), cards: cardInfo(article), time: times[0] || null, - authorUrl: authorLinks[0] || null + authorUrl: authorLinks[0] || null, + authorUsername }; }).filter(item => item.text); })() diff --git a/twitter-digest/scripts/install.py b/twitter-digest/scripts/install.py index 87ac63b..ff19861 100644 --- a/twitter-digest/scripts/install.py +++ b/twitter-digest/scripts/install.py @@ -281,25 +281,6 @@ def write_claude_settings(target: Path, dry_run: bool, allow_commands: bool, all save_claude_settings(settings_path, settings) -def print_post_install_notes(target: Path) -> None: - run_script = target / "scripts" / "run_daily_digest.py" - try: - run_cmd = f"python3 ~/{run_script.relative_to(Path.home())}" - except ValueError: - run_cmd = f"python3 {run_script}" - print("", flush=True) - print("Collection source policy:", flush=True) - print(f"API-only digest: {run_cmd} --source api", flush=True) - print(f"Browser digest with DM: {run_cmd} --source browser", flush=True) - print( - "API source and browser source are isolated: API runs never start a browser and never collect DMs. " - "Browser source collects X Chat through a browser session and merges DM into the same daily digest. " - "Browser DM automation may trigger X account risk controls, login challenges, or passcode recovery; " - "avoid long-running unattended DM collection.", - flush=True, - ) - - def main() -> None: args = parse_args() check_runtime(args.skip_browser_check) @@ -317,7 +298,6 @@ def main() -> None: write_claude_settings(target, args.dry_run, args.allow_claude_commands, args.allow_claude_state_read) if not args.dry_run: print(f"Installed skill path: {display_path(target)}", flush=True) - print_post_install_notes(target) if __name__ == "__main__": diff --git a/twitter-digest/scripts/public_scraper.py b/twitter-digest/scripts/public_scraper.py index 2d1c2d7..718531c 100644 --- a/twitter-digest/scripts/public_scraper.py +++ b/twitter-digest/scripts/public_scraper.py @@ -26,7 +26,8 @@ PUBLIC_GROWTH_POLL_SEC = 0.5 PUBLIC_GROWTH_STABLE_TICKS = 3 PUBLIC_SCROLL_JS = "window.scrollBy(0, Math.max(900, window.innerHeight * 0.9));" -HANDLE_DETECT_SETTLE_SEC = 5 +HANDLE_DETECT_TIMEOUT_SEC = 20 +HANDLE_DETECT_POLL_SEC = 1 def wait_for_public_page_ready(ws_url: str, timeout_sec: int = 20) -> None: @@ -132,11 +133,17 @@ def detect_handle(port: int) -> str | None: ws_url = wait_for_cdp_page_ws(port) cdp_call(ws_url, "Page.enable") cdp_call(ws_url, "Runtime.enable") - cdp_call(ws_url, "Page.navigate", {"url": "https://x.com/home"}) - time.sleep(HANDLE_DETECT_SETTLE_SEC) script = load_dom_script("detect_handle.js") - value = cdp_eval(ws_url, script) - return str(value).lstrip("@") if value else None + for url in ("https://x.com/home", "https://x.com/notifications"): + cdp_call(ws_url, "Page.navigate", {"url": url}) + deadline = time.time() + HANDLE_DETECT_TIMEOUT_SEC + while time.time() < deadline: + value = cdp_eval(ws_url, script) + handle = str(value or "").lstrip("@").strip() + if handle: + return handle + time.sleep(HANDLE_DETECT_POLL_SEC) + return None except Exception as exc: print(f"Could not auto-detect X handle: {exc}") return None diff --git a/twitter-digest/scripts/run_daily_digest.py b/twitter-digest/scripts/run_daily_digest.py index e12163c..cf941a9 100644 --- a/twitter-digest/scripts/run_daily_digest.py +++ b/twitter-digest/scripts/run_daily_digest.py @@ -29,22 +29,21 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--account-name") parser.add_argument("--save-default", action="store_true", help="Save --handle/--account-name as the default account for future chat runs.") parser.add_argument("--configure-only", action="store_true", help="Only save default account config; do not collect data.") - parser.add_argument("--keywords", default="", help="Optional comma-separated search queries. Default is empty; the daily digest focuses on timeline and mentions.") + parser.add_argument("--keywords", default="", help="Optional comma-separated search queries. Default is empty; the daily digest focuses on timeline, mentions, and DMs.") parser.add_argument("--out", default=str(DEFAULT_OUT_DIR)) - parser.add_argument("--source", choices=("auto", "browser", "api"), default="auto", help="Data collection source. auto uses API when configured, otherwise browser.") + parser.add_argument("--source", choices=("auto", "browser", "api"), default="auto", help="Data collection source. Default auto: API when configured, otherwise browser. Use browser to force local browser collection.") parser.add_argument("--configure-api", action="store_true", help="Open a secure prompt to save X API credentials, then exit.") parser.add_argument("--configure-api-token", action="store_true", help="Open a secure prompt to paste an existing X user access token, then exit.") parser.add_argument("--api-base", default=os.environ.get("X_API_BASE_URL") or "") parser.add_argument("--user-id", default=os.environ.get("X_USER_ID") or os.environ.get("TWITTER_USER_ID") or "") parser.add_argument("--bearer-token", default=os.environ.get("X_BEARER_TOKEN") or os.environ.get("TWITTER_BEARER_TOKEN") or "") - parser.add_argument("--include-dms", action="store_true", help=argparse.SUPPRESS) - parser.add_argument("--dm-only", action="store_true", help="Debug mode: only collect visible X Chat/DM content through the browser.") - parser.add_argument("--no-dms", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--include-dms", action="store_true", help="Include visible DMs. This is already the default; kept for compatibility.") + parser.add_argument("--no-dms", action="store_true", help="Skip X Messages collection for this run.") parser.add_argument("--dm-threads", type=int, default=5) parser.add_argument("--dm-list-scrolls", type=int, default=20, help="Maximum downward scroll rounds used to scan today's DM conversation list.") parser.add_argument("--dm-scrolls", type=int, default=200, help="Maximum upward scroll rounds per opened DM thread.") parser.add_argument("--dm-max-messages", type=int, default=2000, help="Maximum message bubbles kept per opened DM thread.") - parser.add_argument("--dm-max-events", type=int, default=300, help=argparse.SUPPRESS) + parser.add_argument("--dm-max-events", type=int, default=300, help="Maximum Direct Message API events kept per run.") parser.add_argument("--dm-window-hours", type=int, default=0, help="Stop loading older DM history once messages beyond this window are detected. 0 means load full available thread history.") parser.add_argument("--scrolls", type=int, default=40, help="Maximum scroll rounds per public page.") parser.add_argument("--min-public-scrolls", type=int, default=5, help="Minimum public-page scroll rounds before early stop rules can end collection.") @@ -101,6 +100,10 @@ def api_configured(bearer_token: str) -> bool: return bool(bearer_token) +def has_saved_api_credentials(config: dict) -> bool: + return any(str(config.get(key) or "") for key in ("bearer_token", "refresh_token", "client_id")) + + def summarize_child_error(error: subprocess.CalledProcessError) -> str: text = "\n".join(part for part in [error.stdout or "", error.stderr or ""] if part) return summarize_collector_error(text, returncode=error.returncode) @@ -163,15 +166,6 @@ def run_browser_command(cmd: list[str]) -> None: subprocess.run(cmd, check=True) -def mark_dm_only_output(out_dir: Path) -> None: - input_path = out_dir / "digest-input.json" - if not input_path.exists(): - return - data = json.loads(input_path.read_text(encoding="utf-8")) - data["source"] = "browser_dm_only" - input_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - def main() -> None: rerun_from_installed_if_needed(__file__) args = parse_args() @@ -191,7 +185,9 @@ def main() -> None: print(json.dumps({"config": str(CONFIG_PATH), "saved": bool(args.save_default)}, ensure_ascii=False, indent=2)) return config = load_config() - api_config = refresh_oauth_token_if_needed(load_api_config()) + raw_api_config = load_api_config() + saved_api_configured = has_saved_api_credentials(raw_api_config) + api_config = refresh_oauth_token_if_needed(raw_api_config) explicit_bearer_token = bool(args.bearer_token) refresh_error = str(api_config.get("refresh_error") or "") if refresh_error and not explicit_bearer_token: @@ -204,41 +200,35 @@ def main() -> None: api_base = args.api_base or str(api_config.get("api_base") or "https://api.x.com/2") user_id = args.user_id or str(api_config.get("user_id") or "") handle = (args.handle or api_config.get("handle") or config.get("handle") or "").lstrip("@") - source = args.source if args.source != "auto" else ("api" if api_configured(bearer_token) else "browser") - if args.dm_only: - source = "browser" - if args.source == "api" and refresh_error and not explicit_bearer_token: - raise SystemExit("Saved X OAuth token refresh failed. Re-run --configure-api or pass X_BEARER_TOKEN to use API source.") - include_dms = source == "browser" + source = args.source + if source == "auto": + source = "api" if (explicit_bearer_token or saved_api_configured or api_configured(bearer_token)) else "browser" + if source == "api" and refresh_error and not explicit_bearer_token: + raise SystemExit("Saved X OAuth token refresh failed. Re-run --configure-api or pass X_BEARER_TOKEN to use API source. Browser collection will not be opened while API is configured.") + include_dms = not args.no_dms + if args.include_dms: + include_dms = True if source == "api": cmd = api_command(args, args.out, api_base, user_id, handle) child_env = os.environ.copy() if bearer_token: child_env["X_BEARER_TOKEN"] = bearer_token else: - cmd = browser_command(args, args.out, handle, include_dms, dm_only=args.dm_only) + cmd = browser_command(args, args.out, handle, include_dms) child_env = None + if source == "api" and include_dms: + print("API source selected. Browser DM collection is not run for API source; use --source browser when DMs are required.", flush=True) print(f"Collecting X digest data via {source} source.", flush=True) try: if source == "api": run_api_command(cmd, child_env) else: run_browser_command(cmd) - if args.dm_only: - mark_dm_only_output(Path(args.out)) - source = "browser_dm_only" except subprocess.CalledProcessError as exc: summary = summarize_child_error(exc) - if args.source == "auto" and source == "api": - print(f"API collection unavailable ({summary}). Falling back to browser collection.", flush=True) - source = "browser" - run_browser_command(browser_command(args, args.out, handle, include_dms, dm_only=args.dm_only)) - if args.dm_only: - mark_dm_only_output(Path(args.out)) - source = "browser_dm_only" - else: - print(f"API collection failed: {summary}", file=sys.stderr, flush=True) - raise SystemExit(exc.returncode) from exc + source_label = "API" if source == "api" else source.capitalize() + print(f"{source_label} collection failed: {summary}", file=sys.stderr, flush=True) + raise SystemExit(exc.returncode) from exc out_dir = Path(args.out) build_current_context_from_file( input_path=out_dir / "digest-input.json", diff --git a/twitter-mcp/SKILL.md b/twitter-mcp/SKILL.md new file mode 100644 index 0000000..df44ba6 --- /dev/null +++ b/twitter-mcp/SKILL.md @@ -0,0 +1,159 @@ +--- +name: twitter-mcp +description: Use when the user wants to install or authorize xurl for X/Twitter, generate "生成X日报", "X日报", "推特日报", or "Twitter digest" from the local xurl CLI, or optionally register/troubleshoot the hosted X MCP bridge. +--- + +# X/Twitter xurl Digest + +## Overview + +Use this skill to install and authorize `@xdevplatform/xurl`, and to generate X/Twitter daily digests from the local `xurl` CLI. The primary digest data source is `xurl` CLI output, not the hosted X MCP tool list. This skill does not use local API collectors, local browser collectors, or `twitter-digest` scripts. + +The hosted X MCP bridge can still be registered as `xapi` when explicitly requested, but daily digest generation should not depend on MCP tools being visible. `xurl` CLI exposes direct digest-relevant commands such as `whoami`, `timeline`, `mentions`, `posts`, and `search`. + +If the user asks to generate an X/Twitter daily digest, says `生成X日报`, `X日报`, `推特日报`, or asks for a Twitter digest, use this skill to guide direct `xurl` CLI collection. Do not create a generic report template. Do not run digest helper scripts, `twitter-digest` scripts, or local browser/API collectors from this skill. + +## xurl Digest Workflow + +For daily digest requests, run `xurl` commands directly from the agent shell and use their outputs as the digest source. Do not create or run wrapper/helper scripts for digest collection. + +Before collecting X data, determine the user's current local timezone and the exact 24-hour window: + +```bash +date '+%Y-%m-%d %H:%M:%S %Z %z' +``` + +Use that local time as `now`, compute `cutoff = now - 24 hours`, and include only X items whose timestamp is within `[cutoff, now]` in the user's local timezone. Convert UTC `created_at` timestamps from X into the user's local timezone before filtering or grouping. Do not use calendar-day-only filtering unless the user explicitly asks for "today" by date instead of "past 24 hours". Do not rely on `xurl` result ordering, labels such as "recent", or search date operators as proof that an item is inside the window; every item used in the digest must pass this timestamp check. + +```bash +xurl whoami +xurl timeline -n 100 +xurl mentions -n 100 +``` + +Detect the authenticated handle from `xurl whoami`. Then run: + +```bash +xurl posts -n 100 +xurl search "from:" -n 100 +xurl search "@" -n 100 +xurl search "to:" -n 100 +``` + +These commands are mandatory for daily digest collection. Do not write a final digest if any of them has not been attempted. If you notice during drafting that `from:`, `@`, or `to:` search was skipped, stop drafting, run the missing command(s), then rebuild the digest from the combined outputs. + +Rules for collection: + +- If `xurl whoami` does not reveal a handle, ask the user for the handle or skip handle-dependent commands and report the gap. +- Keep only posts, mentions, searches, and timeline items from the last 24 hours in the user's current local timezone. This rule is strict for `xurl mentions`: mentions older than `cutoff` must be discarded before analysis and must not appear in `需要处理`. +- `xurl mentions` is not enough to conclude current mention state. If `xurl mentions` returns only old items or no in-window items, the `@` and `to:` searches are still required before saying there are no current mentions or no reply tasks. +- Do not put "未跑关键词搜索" in the final data gaps for mandatory `from:/@/to:` searches. Missing mandatory searches are a collection error to fix by running the commands before the final answer. Only report a mandatory search as a data gap if it was attempted and failed with an error, auth limit, tier limit, or rate limit. +- If an item has no parseable timestamp, do not use it for time-bound facts; report it under data gaps as time-unverified. +- When using `xurl search`, search date operators may be used only as a coarse prefilter; still post-filter each returned item to the exact 24-hour window. +- Before adding any mention or direct ask to `需要处理` as "needs reply", verify whether the authenticated account has already replied after that mention's timestamp. Use `xurl posts ` and `xurl search "from: to:" -n 100` or equivalent `xurl` output to check for replies in the same conversation/thread or to the same author. If a reply from the authenticated account already exists after the mention timestamp, mark it as already handled or omit it from `需要处理`; do not ask the user to reply again. +- For every mention considered for `需要处理`, write down the reply verification result before summarizing: `already_replied`, `reply_unverified`, or `not_replied_found`. Only `not_replied_found` may be phrased as a pending reply. `already_replied` must be omitted from pending actions or shown as already handled. `reply_unverified` must be labeled `回复状态未确认`. +- If the reply status cannot be verified from available `xurl` output, label it as `回复状态未确认` instead of claiming the user still needs to reply. +- If a specific `xurl` command fails, report that command under data gaps and continue with successful command outputs. +- Do not run the DM command during normal daily digest collection. Current DM/API coverage is not reliable for this workflow and should not appear in the daily digest. +- If the user explicitly asks for private messages, explain that this skill does not collect them by default because the current API path is unavailable or unreliable; do not claim there are no private messages. +- Do not post, reply, like, repost, bookmark, follow, or send DMs without explicit approval after showing a draft/action summary. +- If `xurl` is missing or unauthenticated, help the user install or authorize it with this skill before generating the digest. + +Write the final response in Chinese by default for `X日报` requests. Use this structure: + +- 今日概览: 3-6 bullets with the highest-signal changes. +- 需要处理: unresolved direct asks, risks, and reply opportunities from public timeline, mentions, and searches. Exclude out-of-window mentions and already replied-to mentions. For mention rows, include the reply verification state; do not call a mention a reply task unless verification found no later reply from the authenticated account. +- 时间线热点: grouped by topic with why it matters. +- 我的账号动态: notable own posts or engagement. +- 数据缺口: failed `xurl` commands, auth/tier errors, rate limits, or items excluded because timestamps were missing/unparseable. +- 建议动作: concise reply/follow-up suggestions. Do not post or send anything without explicit approval. + +## Install And Register + +From the repository `skills/` directory: + +```bash +/bin/bash twitter-mcp/scripts/install_xmcp.sh +``` + +For a one-line Codex install from this beta tag: + +```bash +curl -fsSL https://raw.githubusercontent.com/BofAI/skills/v1.5.11/twitter-mcp/install.sh | env X_MCP_REGISTER_CODEX=1 X_MCP_REGISTER_CLAUDE=0 sh +``` + +For a one-line Claude Code install from this beta tag: + +```bash +curl -fsSL https://raw.githubusercontent.com/BofAI/skills/v1.5.11/twitter-mcp/install.sh | env X_MCP_REGISTER_CODEX=0 X_MCP_REGISTER_CLAUDE=1 sh +``` + +The installer: + +- Installs this `twitter-mcp` skill into the selected local skills directory. +- Requires Node.js 18+ and npm. Node.js 20 LTS+ is recommended. +- Installs `@xdevplatform/xurl` globally with npm. +- Opens the X OAuth2 authorization flow. +- Does not register the hosted X MCP bridge by default. To also register MCP, set `X_MCP_REGISTER_CODEX_MCP=1` or `X_MCP_REGISTER_CLAUDE_MCP=1`. + +When launched by Codex, Claude Code, or another non-interactive agent on macOS, the one-line installer immediately opens a real Terminal window and re-runs the full installation there. Node/npm checks, `xurl` installation, OAuth2 Client ID / Secret input, and browser authorization all happen in Terminal, not inside the agent permission sandbox. Do not ask the user to paste OAuth credentials into chat. + +## Configuration + +Environment controls: + +```bash +XMCP_PACKAGE=@xdevplatform/xurl +XMCP_VERSION=latest +X_MCP_APP_NAME=xmcp +X_MCP_REDIRECT_URI=http://localhost:8080/callback +X_MCP_SERVER_NAME=xapi +X_MCP_REGISTER_CODEX=1 +X_MCP_REGISTER_CLAUDE=auto +X_MCP_REGISTER_CODEX_MCP=0 +X_MCP_REGISTER_CLAUDE_MCP=0 +CODEX_CONFIG=~/.codex/config.toml +X_MCP_OPEN_TERMINAL=auto +X_MCP_CLIENT_ID= +X_MCP_CLIENT_SECRET= +``` + +Use `X_MCP_CLIENT_ID` and `X_MCP_CLIENT_SECRET` only when the user explicitly provides them through a secure environment. Prefer the interactive Terminal prompts for secrets. + +## Manual MCP Command + +For optional hosted MCP clients that need the bridge command directly: + +```bash +xurl --app xmcp mcp https://api.x.com/mcp +``` + +Codex config shape: + +```toml +[mcp_servers.xapi] +command = "/absolute/path/to/xurl" +args = ["--app", "xmcp", "mcp", "https://api.x.com/mcp"] +``` + +Claude Code command shape: + +```bash +claude mcp add xapi -- xurl --app xmcp mcp https://api.x.com/mcp +``` + +## Verification + +After installation: + +1. Run `xurl whoami` to verify the local CLI is authorized. +2. Run `xurl timeline -n 10` and `xurl mentions -n 10` to verify digest collection. +3. If optional MCP registration was enabled, start a new Codex or Claude Code session and check whether X MCP tools are visible under the `xapi` server. + +If an X MCP endpoint returns an auth, subscription, tier, or scope error, report the exact failing capability as a setup or account limitation. Do not infer that the requested X data does not exist. + +## Boundaries + +- Use `twitter-mcp` for installing and authorizing `xurl`, generating `xurl` CLI-sourced X/Twitter digests, and optionally registering/troubleshooting the hosted X MCP bridge. +- Do not use local API tokens, local browser sessions, cookies, screenshots, or `twitter-digest` run outputs from this skill. +- Do not install or modify unrelated skills from this skill. diff --git a/twitter-mcp/agents/openai.yaml b/twitter-mcp/agents/openai.yaml new file mode 100644 index 0000000..2edab43 --- /dev/null +++ b/twitter-mcp/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "X/Twitter xurl" + short_description: "Install xurl and generate X digests" + default_prompt: "Use $twitter-mcp to generate my X daily digest from the local xurl CLI." diff --git a/twitter-mcp/install.sh b/twitter-mcp/install.sh new file mode 100755 index 0000000..18decf1 --- /dev/null +++ b/twitter-mcp/install.sh @@ -0,0 +1,179 @@ +#!/bin/sh +set -eu + +TAG="${X_MCP_INSTALL_TAG:-v1.5.11}" +BASE_URL="${X_MCP_INSTALL_BASE_URL:-https://raw.githubusercontent.com/BofAI/skills/${TAG}/twitter-mcp}" +REGISTER_CODEX="${X_MCP_REGISTER_CODEX:-1}" +REGISTER_CLAUDE="${X_MCP_REGISTER_CLAUDE:-auto}" +REGISTER_CODEX_MCP="${X_MCP_REGISTER_CODEX_MCP:-0}" +REGISTER_CLAUDE_MCP="${X_MCP_REGISTER_CLAUDE_MCP:-0}" +OPEN_TERMINAL="${X_MCP_OPEN_TERMINAL:-auto}" + +info() { + printf '==> %s\n' "$1" +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +shell_quote() { + value="$1" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" +} + +applescript_quote() { + value="$1" + printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +truthy() { + case "${1:-}" in + 1|true|yes) return 0 ;; + *) return 1 ;; + esac +} + +running_under_agent() { + if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ "${__CFBundleIdentifier:-}" = "com.openai.codex" ]; then + return 0 + fi + if env | grep -Eq '^(CLAUDE|ANTHROPIC)'; then + return 0 + fi + return 1 +} + +should_open_terminal() { + if [ "${X_MCP_TERMINAL_CHILD:-}" = "1" ]; then + return 1 + fi + case "$OPEN_TERMINAL" in + 1|true|yes) return 0 ;; + 0|false|no) return 1 ;; + esac + if [ "$(uname -s)" != "Darwin" ]; then + return 1 + fi + if running_under_agent; then + return 0 + fi + if [ ! -t 0 ]; then + return 0 + fi + return 1 +} + +open_self_in_terminal_and_exit() { + command_exists osascript || fail "Cannot open macOS Terminal because osascript is unavailable." + command_exists curl || fail "curl is required to open the installer in Terminal." + + installer_url="${BASE_URL%/}/install.sh" + args_text="" + for arg in "$@"; do + args_text="${args_text} $(shell_quote "$arg")" + done + command_text="cd ~ && TMPDIR=\"\$(mktemp -d)\" && INSTALL_SH=\"\$TMPDIR/twitter-mcp-install.sh\" && curl -fsSL $(shell_quote "$installer_url") -o \"\$INSTALL_SH\" && chmod 700 \"\$INSTALL_SH\" && env X_MCP_TERMINAL_CHILD=1 X_MCP_OPEN_TERMINAL=0 X_MCP_INSTALL_TAG=$(shell_quote "$TAG") X_MCP_INSTALL_BASE_URL=$(shell_quote "$BASE_URL") X_MCP_REGISTER_CODEX=$(shell_quote "$REGISTER_CODEX") X_MCP_REGISTER_CLAUDE=$(shell_quote "$REGISTER_CLAUDE") X_MCP_REGISTER_CODEX_MCP=$(shell_quote "$REGISTER_CODEX_MCP") X_MCP_REGISTER_CLAUDE_MCP=$(shell_quote "$REGISTER_CLAUDE_MCP") /bin/sh \"\$INSTALL_SH\"${args_text}; printf '\\nPress Enter to close this window...'; IFS= read -r _" + osascript >/dev/null </dev/null 2>&1; then + WORKDIR="$(mktemp -d 2>/dev/null || mktemp -d -t twitter-mcp)" +else + WORKDIR="${TMPDIR:-/tmp}/twitter-mcp-install.$$" + mkdir -p "$WORKDIR" +fi + +INSTALLER="$WORKDIR/install_xmcp.sh" +INSTALLER_URL="${BASE_URL%/}/scripts/install_xmcp.sh" + +download_file() { + url="$1" + output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + elif command -v wget >/dev/null 2>&1; then + wget -qO "$output" "$url" + else + printf 'Error: curl or wget is required to download %s\n' "$url" >&2 + exit 1 + fi +} + +should_install_claude_skill() { + if truthy "$REGISTER_CLAUDE"; then + return 0 + fi + if [ "$REGISTER_CLAUDE" = "auto" ] && command -v claude >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +backup_existing_skill() { + target="$1" + skills_dir="$2" + if [ ! -e "$target" ] && [ ! -L "$target" ]; then + return 0 + fi + backup_dir="$skills_dir/.backups" + stamp="$(date +%Y%m%d-%H%M%S)" + backup="$backup_dir/twitter-mcp-$stamp" + suffix=1 + while [ -e "$backup" ] || [ -L "$backup" ]; do + suffix=$((suffix + 1)) + backup="$backup_dir/twitter-mcp-$stamp-$suffix" + done + mkdir -p "$backup_dir" + mv "$target" "$backup" + if [ -f "$backup/SKILL.md" ]; then + mv "$backup/SKILL.md" "$backup/SKILL.md.disabled" + fi + info "Existing twitter-mcp skill moved to $backup" +} + +install_skill_copy() { + skills_dir="$1" + target="$skills_dir/twitter-mcp" + staging="$WORKDIR/twitter-mcp-skill" + rm -rf "$staging" + mkdir -p "$staging/scripts" "$staging/agents" + download_file "${BASE_URL%/}/SKILL.md" "$staging/SKILL.md" + download_file "${BASE_URL%/}/install.sh" "$staging/install.sh" + download_file "${BASE_URL%/}/agents/openai.yaml" "$staging/agents/openai.yaml" + download_file "${BASE_URL%/}/scripts/install_xmcp.sh" "$staging/scripts/install_xmcp.sh" + chmod 700 "$staging/install.sh" "$staging/scripts/install_xmcp.sh" + mkdir -p "$skills_dir" + backup_existing_skill "$target" "$skills_dir" + mv "$staging" "$target" + info "Installed twitter-mcp skill to $target" +} + +if truthy "$REGISTER_CODEX"; then + install_skill_copy "$HOME/.codex/skills" +fi + +if should_install_claude_skill; then + install_skill_copy "$HOME/.claude/skills" +fi + +download_file "$INSTALLER_URL" "$INSTALLER" + +chmod 700 "$INSTALLER" +X_MCP_REGISTER_CODEX="$REGISTER_CODEX_MCP" X_MCP_REGISTER_CLAUDE="$REGISTER_CLAUDE_MCP" exec /bin/bash "$INSTALLER" "$@" diff --git a/twitter-mcp/scripts/install_xmcp.sh b/twitter-mcp/scripts/install_xmcp.sh new file mode 100755 index 0000000..f2f4e36 --- /dev/null +++ b/twitter-mcp/scripts/install_xmcp.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +set -euo pipefail + +PACKAGE="${XMCP_PACKAGE:-@xdevplatform/xurl}" +VERSION="${XMCP_VERSION:-latest}" +INSTALL_SPEC="${PACKAGE}@${VERSION}" +APP_NAME="${X_MCP_APP_NAME:-xmcp}" +REDIRECT_URI="${X_MCP_REDIRECT_URI:-http://localhost:8080/callback}" +SERVER_NAME="${X_MCP_SERVER_NAME:-xapi}" +REGISTER_CODEX="${X_MCP_REGISTER_CODEX:-0}" +REGISTER_CLAUDE="${X_MCP_REGISTER_CLAUDE:-0}" +CODEX_CONFIG="${CODEX_CONFIG:-$HOME/.codex/config.toml}" +OPEN_TERMINAL="${X_MCP_OPEN_TERMINAL:-auto}" +XURL_COMMAND="${X_MCP_XURL_COMMAND:-}" + +info() { + printf '==> %s\n' "$1" +} + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +shell_quote() { + local value="$1" + printf "'%s'" "$(printf '%s' "$value" | sed "s/'/'\\\\''/g")" +} + +applescript_quote() { + local value="$1" + printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +toml_quote() { + local value="$1" + printf '"%s"' "$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')" +} + +resolve_command_path() { + local command_name="$1" + local resolved="" + resolved="$(command -v "$command_name" 2>/dev/null || true)" + if [ -z "$resolved" ]; then + return 1 + fi + case "$resolved" in + /*) printf '%s' "$resolved" ;; + *) printf '%s/%s' "$(cd -P "$(dirname "$resolved")" >/dev/null 2>&1 && pwd)" "$(basename "$resolved")" ;; + esac +} + +script_path() { + local source="${BASH_SOURCE[0]}" + local dir="" + while [ -L "$source" ]; do + dir="$(cd -P "$(dirname "$source")" >/dev/null 2>&1 && pwd)" + source="$(readlink "$source")" + case "$source" in + /*) ;; + *) source="$dir/$source" ;; + esac + done + dir="$(cd -P "$(dirname "$source")" >/dev/null 2>&1 && pwd)" + printf '%s/%s' "$dir" "$(basename "$source")" +} + +running_under_agent() { + if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ "${__CFBundleIdentifier:-}" = "com.openai.codex" ]; then + return 0 + fi + if env | grep -Eq '^(CLAUDE|ANTHROPIC)'; then + return 0 + fi + return 1 +} + +should_open_terminal() { + if [ "${X_MCP_TERMINAL_CHILD:-}" = "1" ]; then + return 1 + fi + case "$OPEN_TERMINAL" in + 1|true|yes) return 0 ;; + 0|false|no) return 1 ;; + esac + if [ "$(uname -s)" != "Darwin" ]; then + return 1 + fi + if running_under_agent; then + return 0 + fi + if [ ! -t 0 ]; then + return 0 + fi + return 1 +} + +open_in_terminal_and_exit() { + if ! command_exists osascript; then + fail "Cannot open macOS Terminal because osascript is unavailable." + fi + local path + path="$(script_path)" + local command_text + command_text="cd ~ && X_MCP_TERMINAL_CHILD=1 /bin/bash $(shell_quote "$path"); printf '\\nPress Enter to close this window...'; IFS= read -r _" + osascript >/dev/null <&2 + else + printf '%s: ' "$name" >&2 + fi + IFS= read -r value + printf '%s' "${value:-$default_value}" +} + +prompt_secret() { + local name="$1" + local value="" + printf '%s: ' "$name" >&2 + if [ -t 0 ]; then + stty -echo + IFS= read -r value + stty echo + printf '\n' >&2 + else + IFS= read -r value + fi + printf '%s' "$value" +} + +register_codex() { + local config_path="$1" + local server_name="$2" + local app_name="$3" + local xurl_command="$4" + local marker="[mcp_servers.${server_name}]" + local subtable_prefix="[mcp_servers.${server_name}." + local tmp_config="" + + mkdir -p "$(dirname "$config_path")" + touch "$config_path" + tmp_config="${config_path}.tmp.$$" + + awk -v marker="$marker" -v subtable_prefix="$subtable_prefix" ' + /^\[/ { + if ($0 == marker || index($0, subtable_prefix) == 1) { + skip = 1 + next + } + skip = 0 + } + skip { next } + { print } + ' "$config_path" > "$tmp_config" + mv "$tmp_config" "$config_path" + + { + printf '\n[mcp_servers.%s]\n' "$server_name" + printf 'command = %s\n' "$(toml_quote "$xurl_command")" + printf 'args = [%s, %s, %s, %s]\n' \ + "$(toml_quote '--app')" \ + "$(toml_quote "$app_name")" \ + "$(toml_quote 'mcp')" \ + "$(toml_quote 'https://api.x.com/mcp')" + } >> "$config_path" + + info "Registered Codex MCP server '${server_name}' in ${config_path} with ${xurl_command}" +} + +register_claude() { + local server_name="$1" + local app_name="$2" + local xurl_command="$3" + + if ! command_exists claude; then + info "Claude Code CLI not found; skipped Claude MCP registration." + return + fi + + if claude mcp get "$server_name" >/dev/null 2>&1; then + info "Claude MCP server '${server_name}' already exists; leaving it unchanged." + return + fi + + info "Registering Claude Code MCP server '${server_name}'" + claude mcp add "$server_name" -- "$xurl_command" --app "$app_name" mcp https://api.x.com/mcp +} + +if should_open_terminal; then + open_in_terminal_and_exit +fi + +if ! command_exists node; then + fail "Node.js is required before installing xurl. Install Node.js 18 or newer, then rerun this script." +fi + +if ! command_exists npm; then + fail "npm is required before installing xurl. Install npm, then rerun this script." +fi + +info "Installing ${INSTALL_SPEC}" +npm install -g "${INSTALL_SPEC}" + +if [ -z "$XURL_COMMAND" ]; then + XURL_COMMAND="$(resolve_command_path xurl || true)" +fi + +if [ -z "$XURL_COMMAND" ] || [ ! -x "$XURL_COMMAND" ]; then + fail "xurl was installed by npm, but the xurl command is not on PATH." +fi + +info "Installed $("$XURL_COMMAND" --version 2>/dev/null || printf 'xurl') at ${XURL_COMMAND}" + +CLIENT_ID="${X_MCP_CLIENT_ID:-${X_OAUTH_CLIENT_ID:-}}" +CLIENT_SECRET="${X_MCP_CLIENT_SECRET:-${X_OAUTH_CLIENT_SECRET:-}}" + +if [ -z "$CLIENT_ID" ]; then + if [ ! -t 0 ]; then + fail "X_MCP_CLIENT_ID is required when stdin is not interactive." + fi + CLIENT_ID="$(prompt 'X OAuth Client ID')" +fi + +if [ -z "$CLIENT_ID" ]; then + fail "X OAuth Client ID is required." +fi + +if [ -z "${X_MCP_CLIENT_SECRET+x}" ] && [ -z "${X_OAUTH_CLIENT_SECRET+x}" ] && [ -t 0 ]; then + CLIENT_SECRET="$(prompt_secret 'X OAuth Client Secret (leave empty for public PKCE apps)')" +fi + +if [ -t 0 ]; then + APP_NAME="$(prompt 'xurl app name' "$APP_NAME")" + REDIRECT_URI="$(prompt 'OAuth callback URL' "$REDIRECT_URI")" +fi + +info "Registering xurl OAuth app '${APP_NAME}'" +app_cmd=("$XURL_COMMAND" auth apps add "$APP_NAME" --client-id "$CLIENT_ID" --redirect-uri "$REDIRECT_URI") +if [ -n "$CLIENT_SECRET" ]; then + app_cmd+=(--client-secret "$CLIENT_SECRET") +fi +"${app_cmd[@]}" + +info "Opening X OAuth authorization flow" +"$XURL_COMMAND" auth oauth2 --app "$APP_NAME" + +info "Setting '${APP_NAME}' as the default xurl auth app" +"$XURL_COMMAND" auth default "$APP_NAME" + +if [ "$REGISTER_CODEX" = "1" ] || [ "$REGISTER_CODEX" = "true" ]; then + register_codex "$CODEX_CONFIG" "$SERVER_NAME" "$APP_NAME" "$XURL_COMMAND" +else + info "Skipped Codex MCP registration." +fi + +if [ "$REGISTER_CLAUDE" = "1" ] || [ "$REGISTER_CLAUDE" = "true" ]; then + register_claude "$SERVER_NAME" "$APP_NAME" "$XURL_COMMAND" +elif [ "$REGISTER_CLAUDE" = "auto" ]; then + if command_exists claude; then + register_claude "$SERVER_NAME" "$APP_NAME" "$XURL_COMMAND" + else + info "Claude Code CLI not found; skipped Claude MCP registration." + fi +else + info "Skipped Claude Code MCP registration." +fi + +info "Done" +printf '\nOptional hosted X MCP bridge command:\n' +printf ' %s --app %s mcp https://api.x.com/mcp\n' "$XURL_COMMAND" "$APP_NAME" +printf '\nOptional Codex MCP config example:\n' +printf '[mcp_servers.%s]\n' "$SERVER_NAME" +printf 'command = "%s"\n' "$XURL_COMMAND" +printf 'args = ["--app", "%s", "mcp", "https://api.x.com/mcp"]\n' "$APP_NAME"