From 9ee147c828dff3e8daad2c337f7cab2d5c065c04 Mon Sep 17 00:00:00 2001 From: Dam Minh Tuan Anh Date: Wed, 23 Sep 2026 23:43:49 +0700 Subject: [PATCH] feat(playwright-cdp): post to Slack, and finish the rename The skill could read a thread but not answer in it, which is where a question raised in a document actually gets resolved. slack-post.mjs replies in a thread, or posts to a channel, as the signed -in user. It is a dry run unless --send is passed: the destination is the easy thing to get wrong, a channel id in a URL says nothing about which channel it is, and a message cannot be unsent from the notifications people already received. The dry run names the workspace, the channel - or, for a DM, who it is with rather than a raw D0... id - and the thread it would land in. SKILL.md drops its blanket "read-only" claim, which is no longer true, and states when the agent may post. Slack's URL parsing, token read and API call move to slack-api.mjs so both scripts share one definition. The rename left references behind: both READMEs still linked .agents/skills/playwright-notion/, a path that no longer exists, and the profile directory and its environment variables still said notion though they now hold a Slack session too. The existing profile directory was moved rather than abandoned, so the logins survive. Verified: posted to the author's own DM and read it back. --- .agents/skills/playwright-cdp/SKILL.md | 29 +++++++- .../playwright-cdp/scripts/agent-browser.sh | 6 +- .../playwright-cdp/scripts/package.json | 4 +- .../playwright-cdp/scripts/slack-api.mjs | 56 +++++++++++++++ .../playwright-cdp/scripts/slack-post.mjs | 69 +++++++++++++++++++ .../skills/playwright-cdp/scripts/slack.mjs | 41 +---------- README.md | 4 +- README.vi.md | 4 +- 8 files changed, 161 insertions(+), 52 deletions(-) create mode 100644 .agents/skills/playwright-cdp/scripts/slack-api.mjs create mode 100644 .agents/skills/playwright-cdp/scripts/slack-post.mjs diff --git a/.agents/skills/playwright-cdp/SKILL.md b/.agents/skills/playwright-cdp/SKILL.md index f2edf7e..cf848d9 100644 --- a/.agents/skills/playwright-cdp/SKILL.md +++ b/.agents/skills/playwright-cdp/SKILL.md @@ -13,7 +13,7 @@ Core principle: **do not scrape the DOM, and do not copy a browser profile.** At For GitHub there is nothing to build: `gh pr view --json body,comments,reviews` and `gh api repos//pulls//comments` already return everything, with credentials `gh` holds. Use those directly. -Read-only. Every endpoint used fetches data or produces a download; nothing is created, edited or deleted. +Reading is the main job and touches nothing: those endpoints only fetch data or produce a download. **`slack-post.mjs` is the one exception — it writes.** See *Posting to Slack* below before using it. ## What comes out @@ -48,7 +48,7 @@ Skipping either one wastes an hour. Both were verified by failure on macOS. scripts/agent-browser.sh # headless; opens a window only on first run ``` -It launches the Chrome for Testing that ships with Playwright against a profile under `~/.cache/playwright-notion-profile`, so **the everyday browser is never touched**. The first run shows a window: sign in to Notion and Slack there once. Every run after that is headless and the session persists. +It launches the Chrome for Testing that ships with Playwright against a profile under `~/.cache/playwright-cdp-profile`, so **the everyday browser is never touched**. The first run shows a window: sign in to Notion and Slack there once. Every run after that is headless and the session persists. | Need | Command | |---|---| @@ -57,7 +57,7 @@ It launches the Chrome for Testing that ships with Playwright against a profile | Throw the profile away (loses the login) | `scripts/agent-browser.sh --reset` | | Check CDP is up | `curl -s http://127.0.0.1:9222/json/version` | -No automation browser on the machine → `npx playwright install chromium`, or point `NOTION_BROWSER_BIN` at a Chromium-family binary. +No automation browser on the machine → `npx playwright install chromium`, or point `CDP_BROWSER_BIN` at a Chromium-family binary. `scripts/start-browser.sh [brave|chrome|edge]` remains for the one case the owned profile cannot cover: a page reachable only from the personal profile. It **closes the user's browser** (the profile lock blocks the debug port) — say so before running it. If it reports `remote debugging requires a non-default data directory`, that build refuses CDP on its default profile dir; Brave commonly works where Chrome refuses. @@ -107,6 +107,26 @@ Replies are comments: the body keeps the top-level messages, `comments.md` holds The web client's token lives only on the `app.slack.com` origin — an `/archives/` link is a stub page that redirects to the desktop app, so the script hops origins by itself. `no localConfig_v2` / `no token for team` means that profile is not signed in to Slack: `agent-browser.sh --headed`, sign in, retry. +## Posting to Slack + +`slack-post.mjs` posts a message, or a reply in a thread, as the signed-in user. + +```bash +node scripts/slack-post.mjs '' # shows what would be posted +node scripts/slack-post.mjs '' --send # actually posts +``` + +A URL pointing at a message or thread replies in that thread; a channel URL posts a new message. Long or multi-line text: `-` reads stdin, `@file.md` reads a file, which also avoids fighting the shell over quoting. + +**Without `--send` it is a dry run**: it prints the workspace, the channel name and the thread it would land in, and posts nothing. That default exists because the destination is the easy thing to get wrong — a channel id in a URL says nothing about which channel it is, and a message cannot be unsent from the notifications people already got. + +Rules for the agent, not just the script: + +- **Never post without the user having seen the exact text and the exact destination.** Run the dry run, show its output, wait for a yes. A previous yes does not cover the next message. +- Never post on your own initiative — only when asked to post something specific. +- It posts **as the user**, under their name. Write what they would write, not a bot announcement. +- Report the permalink the script prints, so they can check or delete it. + ## Step 4 — verify Never report success from an exit code. Each script prints its counts per document: @@ -123,6 +143,8 @@ OK 9 comments 27 files /.md node scripts/test-doc.mjs # self-check for the renderers, needs no browser ``` +A `slack-post.mjs` run is verified by its own output: a dry run ends in `DRY RUN — nothing was posted`, a real one in `POSTED `. Never claim something was posted without that line. + ## Common mistakes | Mistake | What happens | Fix | @@ -158,6 +180,7 @@ Keep the output contract: import `commentBook`, `writeDoc` and `saveAsset` from ## Warn the user before starting +- Posting to Slack is visible to everyone in the channel and cannot be unsent from their notifications. - These are internal company documents being copied to a local disk. Whether that fits their company policy is their call, not something to assume. - While a run is active the browser listens on a local debug port, so any local process can drive it. `agent-browser.sh --stop` when done. - `start-browser.sh` (the fallback path only) closes their everyday browser; unsaved work in it is at risk. diff --git a/.agents/skills/playwright-cdp/scripts/agent-browser.sh b/.agents/skills/playwright-cdp/scripts/agent-browser.sh index a49893d..4790cd4 100755 --- a/.agents/skills/playwright-cdp/scripts/agent-browser.sh +++ b/.agents/skills/playwright-cdp/scripts/agent-browser.sh @@ -7,7 +7,7 @@ # usage: ./agent-browser.sh [port] | --headed | --stop | --reset set -uo pipefail -PROFILE="${NOTION_PROFILE_DIR:-$HOME/.cache/playwright-notion-profile}" +PROFILE="${CDP_PROFILE_DIR:-$HOME/.cache/playwright-cdp-profile}" PORT="9222" HEADED="${NOTION_HEADED:-0}" @@ -27,7 +27,7 @@ esac # Chrome for Testing ships with Playwright and is built for automation, so it # takes CDP on a custom profile dir without the refusals a branded build makes. -BIN="${NOTION_BROWSER_BIN:-}" +BIN="${CDP_BROWSER_BIN:-}" if [ -z "$BIN" ]; then BIN=$(find "$HOME/Library/Caches/ms-playwright" -maxdepth 6 -type f \ -path "*Chrome for Testing.app/Contents/MacOS/*" 2>/dev/null | sort | tail -1) @@ -35,7 +35,7 @@ fi [ -x "$BIN" ] || { echo "no automation browser found. Install one with:" >&2 echo " npx playwright install chromium" >&2 - echo "or point NOTION_BROWSER_BIN at a Chromium-family binary." >&2 + echo "or point CDP_BROWSER_BIN at a Chromium-family binary." >&2 exit 1 } diff --git a/.agents/skills/playwright-cdp/scripts/package.json b/.agents/skills/playwright-cdp/scripts/package.json index 2f9c0a2..6c38a1f 100644 --- a/.agents/skills/playwright-cdp/scripts/package.json +++ b/.agents/skills/playwright-cdp/scripts/package.json @@ -1,8 +1,8 @@ { - "name": "playwright-notion-scripts", + "name": "playwright-cdp-scripts", "private": true, "type": "module", - "description": "Dependencies for the playwright-notion skill scripts. Run `npm install` here once. playwright-core only: the scripts attach to a running browser over CDP, so there is nothing to download.", + "description": "Dependencies for the playwright-cdp skill scripts. Run `npm install` here once. playwright-core only: the scripts attach to a running browser over CDP, so there is nothing to download.", "dependencies": { "playwright-core": "^1.40.0" } diff --git a/.agents/skills/playwright-cdp/scripts/slack-api.mjs b/.agents/skills/playwright-cdp/scripts/slack-api.mjs new file mode 100644 index 0000000..ca7830f --- /dev/null +++ b/.agents/skills/playwright-cdp/scripts/slack-api.mjs @@ -0,0 +1,56 @@ +// Slack access shared by the read and write scripts: which conversation a link +// points at, and the web client's own token, which every call needs alongside +// the session cookie. +// channel id and, when the link points at one message, its timestamp +export function parseUrl(u) { + const url = new URL(u); + let channel = null, ts = null; + const arch = url.pathname.match(/\/archives\/([A-Z0-9]+)(?:\/p(\d{10})(\d{6}))?/i); + if (arch) { channel = arch[1]; if (arch[2]) ts = `${arch[2]}.${arch[3]}`; } + const thread = url.pathname.match(/\/client\/[A-Z0-9]+\/([A-Z0-9]+)(?:\/thread\/[A-Z0-9]+-(\d+\.\d+))?/i); + if (thread) { channel = channel || thread[1]; ts = ts || thread[2] || null; } + ts = url.searchParams.get('thread_ts') || ts; + channel = url.searchParams.get('cid') || channel; + if (!channel) throw new Error('no channel id in URL: ' + u); + return { channel, ts }; +} + +export const when = (ts) => new Date(Number(String(ts).split('.')[0]) * 1000).toISOString().replace('T', ' ').slice(0, 16); + +// The web client's own token, which the in-page API calls need alongside the +// session cookie. It only exists on the app.slack.com origin - a /archives/ +// link is a stub page that redirects to the desktop app. +export async function readToken(page) { + const read = () => page.evaluate(() => { + const raw = localStorage.getItem('localConfig_v2'); + if (!raw) return { error: 'no localConfig_v2 (not signed in to Slack in this profile?)' }; + const cfg = JSON.parse(raw); + const teams = cfg.teams || {}; + const fromUrl = (location.pathname.match(/\/client\/(T[A-Z0-9]+)/i) || [])[1]; + const id = (fromUrl && teams[fromUrl] && fromUrl) || cfg.lastActiveTeamId || Object.keys(teams)[0]; + const team = teams[id]; + if (!team?.token) return { error: 'no token for team ' + id }; + return { token: team.token, domain: team.domain, name: team.name }; + }); + let cfg = await read(); + if (cfg.error && !page.url().startsWith('https://app.slack.com/')) { + await page.goto('https://app.slack.com/client', { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.waitForTimeout(10000); + cfg = await read(); + } + return cfg; +} + +// One authenticated call, made from inside the page so the session cookie rides +// along. Slack's web API takes the token as a form field, not a header. +export async function slackApi(page, token, method, params) { + return page.evaluate(async ({ token, method, params }) => { + const fd = new FormData(); + fd.append('token', token); + for (const [k, v] of Object.entries(params)) fd.append(k, String(v)); + const r = await fetch('/api/' + method, { method: 'POST', body: fd, credentials: 'include' }); + const j = await r.json(); + if (!j.ok) throw new Error(method + ' -> ' + (j.error || 'unknown')); + return j; + }, { token, method, params }); +} diff --git a/.agents/skills/playwright-cdp/scripts/slack-post.mjs b/.agents/skills/playwright-cdp/scripts/slack-post.mjs new file mode 100644 index 0000000..53b291b --- /dev/null +++ b/.agents/skills/playwright-cdp/scripts/slack-post.mjs @@ -0,0 +1,69 @@ +import fs from 'fs'; +import { connectCdp } from './doc.mjs'; +import { parseUrl, readToken, slackApi, when } from './slack-api.mjs'; + +// usage: node slack-post.mjs [--send] [cdp-port] +// url pointing at a message or thread -> replies in that thread +// url pointing at a channel -> posts a new message in the channel +// Without --send this only shows what would be posted and where. Posting is +// not undoable for the people who get the notification, so the default is to +// look before sending. +const args = process.argv.slice(2); +const SEND = args.includes('--send'); +const rest = args.filter((a) => a !== '--send'); +const [URL_ARG, TEXT_ARG, PORT = '9222'] = rest; + +if (!URL_ARG || !TEXT_ARG) { + console.error('usage: node slack-post.mjs [--send] [cdp-port]'); + process.exit(1); +} + +const text = TEXT_ARG === '-' ? fs.readFileSync(0, 'utf8') + : TEXT_ARG.startsWith('@') ? fs.readFileSync(TEXT_ARG.slice(1), 'utf8') + : TEXT_ARG; +if (!text.trim()) { console.error('refusing to post an empty message'); process.exit(1); } + +const { channel, ts } = parseUrl(URL_ARG); +const browser = await connectCdp(PORT); +const page = await browser.contexts()[0].newPage(); +try { + await page.goto(URL_ARG, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForTimeout(5000); + const cfg = await readToken(page); + if (cfg.error) throw new Error(cfg.error + ' — run agent-browser.sh --headed and sign in to Slack'); + + const host = `https://${cfg.domain}.slack.com`; + if (!page.url().startsWith(host)) { + await page.goto(`${host}/archives/${channel}`, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForTimeout(4000); + } + + // name the destination in full, so a wrong channel is caught before sending + const info = await slackApi(page, cfg.token, 'conversations.info', { channel }).catch(() => null); + const c = info?.channel; + // a DM has no name, and a raw D0... id tells the reader nothing + let where = c?.name ? `#${c.name}` : channel; + if (c?.is_im) { + const u = await slackApi(page, cfg.token, 'users.info', { user: c.user }).catch(() => null); + const who = u?.user?.profile?.display_name || u?.user?.profile?.real_name || u?.user?.name || c.user; + where = `DM with @${who}`; + } + where += ts ? ` · reply in thread ${when(ts)}` : ' · new message'; + console.log(`to: ${cfg.name} ${where}`); + console.log(`text: ${text.trim().split('\n').map((l) => ' ' + l).join('\n').trim()}`); + + if (!SEND) { + console.log('\nDRY RUN — nothing was posted. Add --send to actually post.'); + } else { + const res = await slackApi(page, cfg.token, 'chat.postMessage', { + channel, text, ...(ts ? { thread_ts: ts } : {}) + }); + console.log(`\nPOSTED ${host}/archives/${channel}/p${String(res.ts).replace('.', '')}`); + } +} catch (e) { + console.error('FAIL:', e.message); + process.exitCode = 1; +} finally { + await page.close().catch(() => {}); + process.exit(process.exitCode || 0); +} diff --git a/.agents/skills/playwright-cdp/scripts/slack.mjs b/.agents/skills/playwright-cdp/scripts/slack.mjs index acad71f..0d2250d 100644 --- a/.agents/skills/playwright-cdp/scripts/slack.mjs +++ b/.agents/skills/playwright-cdp/scripts/slack.mjs @@ -1,5 +1,6 @@ import path from 'path'; import { commentBook, writeDoc, saveAsset, docName, readUrls, connectCdp } from './doc.mjs'; +import { parseUrl, readToken, when } from './slack-api.mjs'; // usage: node slack.mjs [cdp-port] // Accepts a channel link, a message permalink, or a thread link. @@ -10,46 +11,6 @@ const LIMIT = Number(process.env.SLACK_LIMIT || 200); const MAX_MB = Number(process.env.SLACK_MAX_MB || 30); const WITH_MEDIA = process.env.SLACK_MEDIA === '1'; -// channel id and, when the link points at one message, its timestamp -function parseUrl(u) { - const url = new URL(u); - let channel = null, ts = null; - const arch = url.pathname.match(/\/archives\/([A-Z0-9]+)(?:\/p(\d{10})(\d{6}))?/i); - if (arch) { channel = arch[1]; if (arch[2]) ts = `${arch[2]}.${arch[3]}`; } - const thread = url.pathname.match(/\/client\/[A-Z0-9]+\/([A-Z0-9]+)(?:\/thread\/[A-Z0-9]+-(\d+\.\d+))?/i); - if (thread) { channel = channel || thread[1]; ts = ts || thread[2] || null; } - ts = url.searchParams.get('thread_ts') || ts; - channel = url.searchParams.get('cid') || channel; - if (!channel) throw new Error('no channel id in URL: ' + u); - return { channel, ts }; -} - -const when = (ts) => new Date(Number(String(ts).split('.')[0]) * 1000).toISOString().replace('T', ' ').slice(0, 16); - -// The web client's own token, which the in-page API calls need alongside the -// session cookie. It only exists on the app.slack.com origin - a /archives/ -// link is a stub page that redirects to the desktop app. -async function readToken(page) { - const read = () => page.evaluate(() => { - const raw = localStorage.getItem('localConfig_v2'); - if (!raw) return { error: 'no localConfig_v2 (not signed in to Slack in this profile?)' }; - const cfg = JSON.parse(raw); - const teams = cfg.teams || {}; - const fromUrl = (location.pathname.match(/\/client\/(T[A-Z0-9]+)/i) || [])[1]; - const id = (fromUrl && teams[fromUrl] && fromUrl) || cfg.lastActiveTeamId || Object.keys(teams)[0]; - const team = teams[id]; - if (!team?.token) return { error: 'no token for team ' + id }; - return { token: team.token, domain: team.domain, name: team.name }; - }); - let cfg = await read(); - if (cfg.error && !page.url().startsWith('https://app.slack.com/')) { - await page.goto('https://app.slack.com/client', { waitUntil: 'domcontentloaded', timeout: 90000 }); - await page.waitForTimeout(10000); - cfg = await read(); - } - return cfg; -} - // Slack virtual-scrolls, so the DOM only ever holds a few dozen messages. // These calls run in the page: same origin, session cookie attached. async function fetchConversation(page, { token, channel, ts, limit }) { diff --git a/README.md b/README.md index a1d67a0..411b2d4 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Four diagrams of that pipeline, and of how `goalrun` decides a row and proves a | Skill | Does | Trigger | | ----- | ---- | ------- | -| 📥 [`playwright-notion`](.agents/skills/playwright-notion/SKILL.md) | Notion → markdown through a logged-in browser, when API token and Export are both unavailable | "download these Notion pages" | +| 📥 [`playwright-cdp`](.agents/skills/playwright-cdp/SKILL.md) | Notion pages and Slack threads → markdown through a logged-in browser: body, every comment, and the attachments downloaded | "read this Notion page", "pull this Slack thread" | ### Authoring @@ -90,7 +90,7 @@ Conventions every skill here follows, so a new one is predictable before you ope Setup beyond cloning, where a skill needs it: ```bash -cd .agents/skills/playwright-notion/scripts && npm install # once +cd .agents/skills/playwright-cdp/scripts && npm install # once ``` ## Adding a skill diff --git a/README.vi.md b/README.vi.md index 92feba4..b58823c 100644 --- a/README.vi.md +++ b/README.vi.md @@ -70,7 +70,7 @@ Bốn sơ đồ về chuỗi đó, và về cách `goalrun` quyết định mộ | Skill | Làm gì | Kích hoạt | | ----- | ------ | --------- | -| 📥 [`playwright-notion`](.agents/skills/playwright-notion/SKILL.md) | Tải trang Notion về markdown qua browser đang đăng nhập, khi không có API token lẫn nút Export | "tải các trang Notion này về" | +| 📥 [`playwright-cdp`](.agents/skills/playwright-cdp/SKILL.md) | Trang Notion và thread Slack → markdown qua browser đang đăng nhập: nội dung, toàn bộ comment, và file đính kèm tải về | "đọc trang Notion này", "lấy thread Slack này" | ### Soạn prompt @@ -90,7 +90,7 @@ Quy ước mà mọi skill ở đây tuân theo, để đoán được một ski Bước cài thêm, với skill nào cần: ```bash -cd .agents/skills/playwright-notion/scripts && npm install # một lần +cd .agents/skills/playwright-cdp/scripts && npm install # một lần ``` ## Thêm skill mới