From 41d3a08c7c17228fcd95b28b05aa8c0eb37a3b80 Mon Sep 17 00:00:00 2001
From: bglglzd <248948303+bglglzd@users.noreply.github.com>
Date: Wed, 11 Nov 2026 02:14:46 +0300
Subject: [PATCH 1/3] fix: validate report recipes and surface delivery
failures
---
.github/workflows/checks.yml | 25 +++++
CHANGELOG.md | 15 +++
CONTRIBUTING.md | 20 ++++
INSTALL.md | 2 +-
README.md | 32 ++++--
VALIDATION.md | 37 +++++++
commands/voice.md | 9 ++
docs/index.html | 4 +-
recipes/cli-tool.md | 13 ++-
recipes/email.md | 4 +-
recipes/github-issues.md | 79 +++++---------
recipes/jsonl-file.md | 12 +-
recipes/slack-discord.md | 66 +++++++----
recipes/telegram-bot.md | 172 +++++++++++++++++++----------
recipes/web-app-full.md | 67 ++++++++----
skills/adding-voice/SKILL.md | 18 +--
tests/helpers.mjs | 28 +++++
tests/jsonl-recipe.test.mjs | 24 ++++
tests/provider-contracts.test.mjs | 176 ++++++++++++++++++++++++++++++
tests/recipe-contracts.test.mjs | 84 ++++++++++++++
20 files changed, 706 insertions(+), 181 deletions(-)
create mode 100644 .github/workflows/checks.yml
create mode 100644 CHANGELOG.md
create mode 100644 CONTRIBUTING.md
create mode 100644 VALIDATION.md
create mode 100644 commands/voice.md
create mode 100644 tests/helpers.mjs
create mode 100644 tests/provider-contracts.test.mjs
create mode 100644 tests/recipe-contracts.test.mjs
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
new file mode 100644
index 0000000..44a90ab
--- /dev/null
+++ b/.github/workflows/checks.yml
@@ -0,0 +1,25 @@
+name: Recipe checks
+on:
+ push:
+ branches: [main]
+ pull_request:
+permissions:
+ contents: read
+concurrency:
+ group: recipes-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ recipes:
+ strategy:
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+ with:
+ node-version: 24
+ - name: Execute recipe contracts without external delivery
+ run: node --test tests/*.test.mjs
+ - name: Verify patch whitespace
+ run: git diff --check
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a385208
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+## Unreleased
+
+- Match install commands to the registered marketplace and provide the advertised
+ optional voice command.
+- Preserve canonical report context, full IDs and storage-specific close hints.
+- Validate Telegram callback authorization and delivery outcomes; send long
+ context as a file and retain real reasons for Won't Fix.
+- Surface chat/email delivery rejection, split Slack text within its limit and
+ attach full Discord context with mentions disabled.
+- Pass complete GitHub CLI bodies through stdin without a shell, preserve
+ finalized voice segments and fix picker coordinates after scrolling.
+- Reject invalid terminal resolutions and propagate actual JSONL storage errors.
+- Add executable recipe contracts and Linux/Windows CI with synthetic boundaries.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..ed1546c
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,20 @@
+# Contributing
+
+Keep the portable contract in `SPEC.md` compatible. Prefer the canonical context
+formatter to a second, shorter copy. Describe which installing-project helpers
+an example expects and which environment values stay on its server.
+
+For a behavioral recipe change, reproduce the problem and run:
+
+```bash
+node --test tests/*.test.mjs
+```
+
+Use synthetic reports, temporary files and loopback services. Never run a test
+against real recipients, provider credentials or a user's report store. Clearly
+label mocked SDK/browser boundaries and document what still needs verification
+in the installing project. Keep optional voice input optional; browser speech
+may use a browser-provider service, while local Whisper is a separate setup.
+
+Keep PRs focused, retain licenses/attribution, and list the relevant checks and
+limits. Do not commit local agent instructions, private reports or credentials.
diff --git a/INSTALL.md b/INSTALL.md
index 74a5df4..047e94a 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -12,7 +12,7 @@
>
> - **Any AI agent that doesn't auto-fetch URLs:** copy the content below the `---` line into the chat directly.
>
-> **Claude Code users should NOT use this file.** Run `/plugin marketplace add bglglzd/bugkit`, `/plugin install bugkit@bglglzd-bugkit`, then `/bugkit:install` instead.
+> **Claude Code users should NOT use this file.** Run `/plugin marketplace add bglglzd/bugkit`, `/plugin install bugkit@bugkit`, then `/bugkit:install` instead.
---
diff --git a/README.md b/README.md
index b4a4fb7..7d54746 100644
--- a/README.md
+++ b/README.md
@@ -4,9 +4,9 @@
# bugkit
-**Users click. AI fixes. Done.**
+**Bug reports with context your coding agent can use.**
-*AI-friendly bug reports for any project. Paste the report into Claude Code, Cursor, or any AI agent โ and the fix ships in minutes.*
+*Capture the page, selected element and errors with a report. Give the structured context to your coding agent, verify its patch, and record the resolution.*
@@ -25,10 +25,10 @@
## Who this is for
**๐ Building for a client, boss, or team lead**
-Send them the staging link. They click the bug icon, pick what's broken, speak or type what they want. You paste into your AI agent. Fix ships the same day.
+Send them the staging link. They select the affected element and type or speak a description. Copy the structured report to your agent, then verify and record the fix.
**๐ Running a product with real users**
-Users click the bug icon from inside your app. Reports arrive with full context (URL, selector, stack trace, voice transcript). Paste โ AI fixes โ user gets notified. No more "can you repro?" back-and-forth.
+Reports can include the URL, selector, stack trace and voice transcript. Choose a recipe for storage/delivery and connect its status and notification flow to your application.
---
@@ -47,7 +47,7 @@ Three commands. Paste each one separately and wait for it to finish before the n
**2.** Install the plugin:
```
-/plugin install bugkit@bglglzd-bugkit
+/plugin install bugkit@bugkit
```
**3.** Start the installer in your project:
@@ -68,7 +68,9 @@ Install bugkit in this project from https://raw.githubusercontent.com/bglglzd/bu
Either path detects your stack, asks **one** question (where should bug reports go โ DB, GitHub Issues, Telegram, JSONL, email, Slack, Discord), and implements the matching [recipe](./recipes/). Voice transcription is offered too โ ๐ค mic button next to the description field, Web Speech API by default, local Whisper as an offline upgrade, never paid APIs.
-Full install takes about 5โ10 minutes for a web app.
+This repository ships a specification, plugin prompts and integration recipes.
+The installer adapts them to your project; it is not a hosted reporting service.
+Verify the generated integration using that project's actual UI or CLI.
---
@@ -77,9 +79,9 @@ Full install takes about 5โ10 minutes for a web app.
| Traditional feedback form | bugkit |
|---|---|
| "Something is broken" | Selector, XPath, HTML, viewport, stack trace, voice transcript |
-| Fix takes days | Fix ships in minutes |
+| Rebuild context in every conversation | Copy one predictable context block |
| Format differs per project | Standardized spec โ same ritual across every project |
-| No closed-loop status | Reporter notified at each state change |
+| No defined status flow | Status lifecycle and notification recipes |
| Typing long descriptions is a chore | ๐ค press-to-speak, auto-transcribed |
---
@@ -91,6 +93,20 @@ Full install takes about 5โ10 minutes for a web app.
- **[INSTALL.md](./INSTALL.md)** โ the universal paste-in prompt for non-Claude-Code agents
- **[Landing page](https://bglglzd.github.io/bugkit)** โ one-page tour with copy-ready install commands
+## Verify the recipes
+
+With Node.js 24 LTS, no package installation is required:
+
+```bash
+node --test tests/*.test.mjs
+```
+
+The checks execute documented snippets with real temporary JSONL files and
+loopback HTTP services. Browser speech, provider SDKs and report stores used by
+adapter tests are explicit synthetic fixtures. Tests do not contact Telegram,
+Slack, Discord, email, GitHub Issues or a paid AI service. See
+[validation boundaries](./VALIDATION.md) and [contributing](./CONTRIBUTING.md).
+
---
MIT โ use it anywhere. โญ Star or watch this repo for new releases.
diff --git a/VALIDATION.md b/VALIDATION.md
new file mode 100644
index 0000000..d0878ba
--- /dev/null
+++ b/VALIDATION.md
@@ -0,0 +1,37 @@
+# Validation boundaries
+
+bugkit contains a portable report specification, plugin commands/skills and
+recipes that an installing agent adapts to a project. It has no runtime package
+manifest or hosted delivery service. Recipe-specific SDKs belong to the target
+project; keep them on supported versions there.
+
+Run `node --test tests/*.test.mjs` with Node 24 LTS. CI runs the same checks on
+Linux and Windows. Tests extract the actual documented code blocks, rather than
+keeping a separate implementation of each adapter.
+
+The suite exercises:
+
+- Registered marketplace/install-command consistency and the advertised voice
+ command asset.
+- Canonical SPEC context fields, section order and storage-specific close hints.
+- Actual temporary JSONL create/list/show/copy/close, full UUID/numeric selection,
+ failure visibility and terminal resolution validation.
+- Telegram complete IDs, webhook secret and chat/user authorization, reason
+ replies, large context files and delivery failures using loopback HTTP.
+- Slack chunk reconstruction and Discord file delivery with long Unicode text,
+ controlled mentions, acknowledgement requests and rejected deliveries.
+- Resend's resolved-promise error contract and GitHub issue-body reconstruction
+ with explicit fake SDKs. No actual email or issue is created.
+- Literal CLI argv/stdin, synthetic speech-result sequences/restarts, and picker
+ coordinates with a synthetic scrolled browser object.
+
+These checks establish the supplied examples' contracts. They do not establish
+live provider credentials, a generated project's framework integration, real
+microphone recognition, email deliverability, rate-limit behavior or AI fix
+quality. Plugin validation/clean local installation can be performed without an
+inference request using an isolated Claude Code configuration directory.
+
+Store a report before mirroring it. Delivery errors must remain distinguishable
+from a saved report; retrying a partially accepted multi-message operation may
+duplicate a notification. Telegram pending-reason storage in the example is for
+a single process; use durable storage for multiple workers/serverless handlers.
diff --git a/commands/voice.md b/commands/voice.md
new file mode 100644
index 0000000..7081bf2
--- /dev/null
+++ b/commands/voice.md
@@ -0,0 +1,9 @@
+---
+description: Add optional voice transcription to an existing bugkit web form.
+---
+
+The user wants optional voice input for their existing bugkit installation.
+Invoke the `adding-voice` skill. Preserve typed input, explain the browser/local
+transcription choice, and verify the supported path without paid API fallback.
+If the project has no browser-facing form, explain that this command does not
+apply to its sink; do not add a microphone flow to a CLI or bot.
diff --git a/docs/index.html b/docs/index.html
index 31ae076..e32fefe 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -80,8 +80,8 @@
Install โ pick your AI agent
Install the plugin
-
/plugin install bugkit@bglglzd-bugkit
-
+
/plugin install bugkit@bugkit
+
diff --git a/recipes/cli-tool.md b/recipes/cli-tool.md
index 2f8bc77..fa18184 100644
--- a/recipes/cli-tool.md
+++ b/recipes/cli-tool.md
@@ -164,10 +164,15 @@ If your CLI is open-source and you want reports to go to GitHub:
```ts
async function createGhIssue(report: BugReport) {
const body = buildAIContext(report)
- const cmd = `gh issue create --title ${quote(report.title)} --body - --label bugkit,bug`
- const child = spawn('sh', ['-c', cmd], { stdio: ['pipe', 'inherit', 'inherit'] })
- child.stdin.write(body)
- child.stdin.end()
+ const child = spawn('gh', ['issue', 'create', '--title', report.title, '--body-file', '-', '--label', 'bugkit,bug'], {
+ shell: false, stdio: ['pipe', 'inherit', 'inherit'],
+ })
+ await new Promise((resolve, reject) => {
+ child.once('error', reject)
+ child.stdin.on('error', reject)
+ child.once('close', code => code === 0 ? resolve() : reject(new Error(`GitHub issue creation failed (${code})`)))
+ child.stdin.end(body)
+ })
}
```
diff --git a/recipes/email.md b/recipes/email.md
index 5521bd7..bc40f2d 100644
--- a/recipes/email.md
+++ b/recipes/email.md
@@ -60,13 +60,15 @@ const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendBugByEmail(report: BugReport) {
const aiContext = buildAIContext(report) // SPEC.md ยง3 template
- await resend.emails.send({
+ const { data, error } = await resend.emails.send({
from: process.env.BUGKIT_EMAIL_FROM!,
to: process.env.BUGKIT_EMAIL_TO!,
subject: `[${report.type.toUpperCase()} #${report.id}] ${report.title}`,
html: renderHtml(report, aiContext),
text: renderPlainText(report, aiContext), // fallback
})
+ if (error || !data?.id) throw new Error('Email provider rejected delivery or returned no receipt')
+ return data
}
```
diff --git a/recipes/github-issues.md b/recipes/github-issues.md
index ab006d9..52b7402 100644
--- a/recipes/github-issues.md
+++ b/recipes/github-issues.md
@@ -28,55 +28,13 @@ No database, no admin UI. Bug reports open as GitHub Issues with a `bugkit` labe
Since there's no admin UI to add a "Copy Context" button later, **write the AI context into the issue body at creation time** inside a collapsible `` block. Admins click to expand, copy, paste into Claude.
-Issue body template:
-
-```markdown
-**Reporter:** @{reporter.name}
-**Type:** {type}
-**Page:** {page.url}
-**Viewport:** {viewport}
-
-## Description
-{description}
-
-
-๐ค Copy this into Claude Code
-
-```
-=== BUG REPORT #{issue_number} ===
-Type: {type}
-Status: open
-Reported: {created_at} by @{reporter.name}
-
-## Description
-{description}
-
-## Page
-URL: {page.url}
-Viewport: {viewport}
-User Agent: {user_agent}
-
-## Selected Element
-Selector: {element.selector}
-XPath: {element.xpath}
-Tag: {element.tag}
-Visible text: {element.text}
-HTML:
-{element.html}
-
-## Instructions
-{Fix this bug | Implement this feature}. When done:
-1. Summarize root cause and files changed.
-2. Provide a short "how to verify" note.
-3. Close this issue with a comment using `/resolved ` or via:
- gh issue close {issue_number} --comment ""
- (Or `/wontfix ` to refuse.)
-```
-
-
-```
-
-**Note:** The `#{issue_number}` goes in AFTER GitHub assigns the number on creation. Easiest: create the issue with a placeholder `#PENDING`, then immediately edit the body substituting the real number. Or use the returned `issue.number` to construct a second request.
+Use the canonical `buildAIContext` function from `web-app-full.md`, passing the
+GitHub issue number and a GitHub-specific close command as `updateHint`. Do not
+maintain a shorter second template: populated environment, page, element, error,
+console and attachment sections must all remain available. The implementation
+below chooses a code fence longer than any run inside the report, so report text
+stays literal. After creation it rebuilds the context using the assigned issue
+number rather than replacing matching text inside the user's description.
---
@@ -102,13 +60,23 @@ Create these labels once during install.
import { Octokit } from '@octokit/rest'
const gh = new Octokit({ auth: process.env.GITHUB_TOKEN })
+function renderIssueBody(report: BugReport & { issue_number: number | string }) {
+ const context = buildAIContext({ ...report, id: report.issue_number }, `gh issue close ${report.issue_number} --comment ""`)
+ const fence = '`'.repeat(Math.max(3, ...[...(context.match(/`+/g) || [])].map(run => run.length + 1)))
+ return ['', '', '๐ค Copy this into Claude Code', '', fence + 'text', context, fence, '', ''].join('\n')
+}
+
export async function createBugReport(input: BugReportInput) {
+ const report: BugReport = {
+ ...input, id: 'PENDING', bugkit_spec: '1', type: input.type || 'bug',
+ title: extractTitle(input.description), status: 'open', created_at: new Date().toISOString(),
+ }
const issue = await gh.issues.create({
owner: process.env.GITHUB_OWNER!,
repo: process.env.GITHUB_REPO!,
- title: `[${input.type}] ${extractTitle(input.description)}`,
- body: renderIssueBody({ ...input, issue_number: 'PENDING' }),
- labels: ['bugkit', input.type === 'feature' ? 'feature' : 'bug'],
+ title: `[${report.type}] ${extractTitle(report.description)}`,
+ body: renderIssueBody({ ...report, issue_number: 'PENDING' }),
+ labels: ['bugkit', report.type === 'feature' ? 'feature' : 'bug'],
})
// Second pass: replace PENDING with real issue number
@@ -116,7 +84,7 @@ export async function createBugReport(input: BugReportInput) {
owner: process.env.GITHUB_OWNER!,
repo: process.env.GITHUB_REPO!,
issue_number: issue.data.number,
- body: issue.data.body!.replace(/#PENDING/g, `#${issue.data.number}`),
+ body: renderIssueBody({ ...report, issue_number: issue.data.number }),
})
return { id: issue.data.number, url: issue.data.html_url }
@@ -131,7 +99,10 @@ Admin workflow:
1. Triage in github.com โ expand `๐ค Copy this into Claude Code` details โ copy โ paste into Claude Code
2. Claude fixes, commits, references the issue (`Fixes #42`)
3. Merging the PR auto-closes the issue
-4. Reporter is notified by GitHub (they're subscribed automatically as the issue author)
+4. Notify the app reporter through your application's status channel. A server
+ token makes the token owner/bot the GitHub issue author; the app reporter is
+ not automatically subscribed. GitHub notifications cover the actual author
+ and subscribers, or users you deliberately and correctly associate with it.
Notifications are free โ GitHub handles them.
diff --git a/recipes/jsonl-file.md b/recipes/jsonl-file.md
index fdb14b0..406424e 100644
--- a/recipes/jsonl-file.md
+++ b/recipes/jsonl-file.md
@@ -39,11 +39,11 @@ const FILE = './.bugreports.jsonl'
export async function createBugReport(input: BugReportInput) {
const report = {
+ ...input,
bugkit_spec: '1',
id: randomUUID(),
status: 'open',
created_at: new Date().toISOString(),
- ...input,
title: extractTitle(input.description),
}
await appendFile(FILE, JSON.stringify(report) + '\n', 'utf8')
@@ -57,7 +57,10 @@ export async function createBugReport(input: BugReportInput) {
```ts
export async function listReports(filter?: { status?: string, type?: string }) {
- const raw = await readFile(FILE, 'utf8').catch(() => '')
+ const raw = await readFile(FILE, 'utf8').catch(error => {
+ if (error.code === 'ENOENT') return ''
+ throw error
+ })
const reports = raw.split('\n').filter(Boolean).map(l => JSON.parse(l))
if (!filter) return reports.reverse() // newest first
return reports.filter(r =>
@@ -159,11 +162,12 @@ else if (cmd === 'show') {
}
else if (cmd === 'copy') {
const r = await findReport(args[0])
- process.stdout.write(buildAIContext(r))
+ process.stdout.write(buildAIContext(r, `node scripts/bugkit.js close ${JSON.stringify(String(r.id))} ""`))
}
else if (cmd === 'close') {
const r = await findReport(args[0])
- const resolution = args.slice(1).join(' ')
+ const resolution = args.slice(1).join(' ').trim()
+ if (!resolution || resolution.length > 2000) { console.error('A resolution note of 1โ2000 characters is required'); process.exit(1) }
await updateReport(r.id, { status: 'resolved', resolution, resolved_at: new Date().toISOString() })
console.log(`#${r.id} resolved.`)
}
diff --git a/recipes/slack-discord.md b/recipes/slack-discord.md
index 9173da0..78ffa6b 100644
--- a/recipes/slack-discord.md
+++ b/recipes/slack-discord.md
@@ -80,43 +80,69 @@ Pick one or both.
}
```
-Follow with a second message posted to the same channel containing `\`\`\`\n{SPEC.md ยง3 formatted}\n\`\`\`` so admins can copy-long-press.
+The examples above are optional previews, not containers for the complete context.
+Discord's content field is limited to 2,000 characters. The implementation below
+uploads the full UTF-8 context as a file; Slack receives ordered plain-text blocks
+under its 3,000-character limit. No context is silently truncated.
---
## Implementation (minimal)
```ts
+function splitText(text: string, limit = 2800): string[] {
+ const chunks: string[] = []
+ let current = ''
+ for (const character of text) {
+ if (current.length + character.length > limit) { chunks.push(current); current = '' }
+ current += character
+ }
+ if (current) chunks.push(current)
+ return chunks
+}
+
export async function postToChat(report: BugReport) {
const aiContext = buildAIContext(report) // SPEC.md ยง3
+ if (!process.env.BUGKIT_SLACK_WEBHOOK && !process.env.BUGKIT_DISCORD_WEBHOOK) throw new Error('No chat destination configured')
if (process.env.BUGKIT_SLACK_WEBHOOK) {
- await fetch(process.env.BUGKIT_SLACK_WEBHOOK, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify(buildSlackMessage(report, aiContext)),
- })
+ const chunks = splitText(aiContext)
+ for (let i = 0; i < chunks.length; i++) {
+ const response = await fetch(process.env.BUGKIT_SLACK_WEBHOOK, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ text: `bugkit report context (${i + 1}/${chunks.length})`,
+ blocks: [{ type: 'section', text: { type: 'plain_text', text: chunks[i], emoji: false } }],
+ unfurl_links: false, unfurl_media: false,
+ }), signal: AbortSignal.timeout(10000),
+ })
+ if (!response.ok || (await response.text()).trim() !== 'ok') throw new Error(`Slack delivery failed (${response.status})`)
+ }
}
if (process.env.BUGKIT_DISCORD_WEBHOOK) {
- const hook = process.env.BUGKIT_DISCORD_WEBHOOK
- await fetch(hook, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify(buildDiscordEmbed(report)),
- })
- // Follow-up: full context in a code block for easy copy
- await fetch(hook, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- content: '```\n' + aiContext + '\n```',
- }),
- })
+ const hook = new URL(process.env.BUGKIT_DISCORD_WEBHOOK)
+ hook.searchParams.set('wait', 'true') // require Discord's delivery acknowledgement
+ const filename = `bug-${String(report.id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)}.txt`
+ const form = new FormData()
+ form.append('payload_json', JSON.stringify({
+ content: 'bugkit report context attached', allowed_mentions: { parse: [] },
+ attachments: [{ id: 0, filename }],
+ }))
+ form.append('files[0]', new Blob([aiContext], { type: 'text/plain;charset=utf-8' }), filename)
+ const response = await fetch(hook, { method: 'POST', body: form, signal: AbortSignal.timeout(10000) })
+ if (!response.ok) throw new Error(`Discord delivery failed (${response.status})`)
}
}
```
+Use Node 24 LTS built-in fetch/FormData/Blob. The report store remains the source of
+truth. Propagate delivery errors and expose partial delivery separately from a
+stored report; retrying after a partially accepted multi-message delivery can
+duplicate notifications. Configure request-size/rate limits for your application.
+See [Slack text limits](https://docs.slack.dev/reference/block-kit/composition-objects/text-object/)
+and [Discord webhook/file parameters](https://docs.discord.com/developers/resources/webhook#execute-webhook).
+
---
## Status tracking
diff --git a/recipes/telegram-bot.md b/recipes/telegram-bot.md
index 85c9db2..7678ab8 100644
--- a/recipes/telegram-bot.md
+++ b/recipes/telegram-bot.md
@@ -10,9 +10,16 @@ Bug reports arrive as messages in a Telegram chat via a bot. Admin reads on thei
- A Telegram bot token (get one from [@BotFather](https://t.me/botfather))
- The chat ID where reports should be posted (personal chat, group, or channel)
- - For personal DM: send any message to your bot, then visit `https://api.telegram.org/bot/getUpdates` โ look for `message.chat.id`
- - For a group: add the bot, send a message mentioning it, visit the same URL
-- Stored as `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_CHAT_ID` in env
+ - Send a test message to your bot (or mention it in the group), then read
+ `message.chat.id` using `getUpdates` in a server-side admin client with its
+ token supplied from the environment. Avoid token-bearing browser URLs.
+- Store `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_CHAT_ID` in server-only env.
+- Pattern B also requires `BUGKIT_TELEGRAM_TRACKING=true`, a random
+ `TELEGRAM_WEBHOOK_SECRET` (32โ256 allowed characters), and comma-separated
+ numeric `TELEGRAM_ADMIN_USER_IDS`. A group chat alone is not authorization.
+- The examples target Node 24 LTS built-in fetch, FormData and Blob. Reuse the canonical
+ `buildAIContext` from `web-app-full.md`; adapt `getReport` and `updateStatus`
+ to your store, comparing the complete string representation of IDs.
---
@@ -26,7 +33,9 @@ No persistence. Each bug report just sends a formatted message to the admin chat
### B. Persist + mirror (recommended)
-Store reports in a JSONL file or tiny SQLite DB so you have status tracking. Also send them to Telegram for notification. Admin can reply to the Telegram message with `/resolve 42 ` and the bot updates the JSONL/DB.
+Store reports in JSONL or SQLite, then mirror them to Telegram. Authorized admin
+buttons retrieve context or resolve a report; Won't Fix asks for an actual reason.
+Without tracking, the example sends a notification and context file without buttons.
Start with A. Upgrade to B if you have more than a handful of reports per week.
@@ -52,7 +61,7 @@ Text: "Bookmark"
```
With inline keyboard:
-- `๐ค Copy for AI` โ replies with the full AI context in a code block
+- `๐ค Copy for AI` โ sends the full AI context as a UTF-8 file
- `โ Mark Resolved` โ moves status (pattern B only)
- `๐ซ Won't Fix` โ ditto
- `๐ Open` โ link to the full detail (if you have a web admin page)
@@ -62,40 +71,50 @@ With inline keyboard:
## Implementation sketch (Node)
```ts
-import fetch from 'node-fetch'
-
const TG_API = `https://api.telegram.org/bot${process.env.TELEGRAM_BOT_TOKEN}`
const ADMIN_CHAT = process.env.TELEGRAM_ADMIN_CHAT_ID!
+const TRACKING = process.env.BUGKIT_TELEGRAM_TRACKING === 'true'
-export async function sendBugToTelegram(report: BugReport) {
- const text = [
- `๐ *Bug Report #${report.id}*`,
- `โโโโโโโโโโโโโโโ`,
- `*Reporter:* @${report.reporter.name}`,
- report.page_url ? `*Page:* ${report.page_url}` : '',
- report.viewport ? `*Viewport:* ${report.viewport}` : '',
- '',
- report.description,
- '',
- report.element_context?.selector ? `*Element:* \`${report.element_context.selector}\`` : '',
- ].filter(Boolean).join('\n')
-
- await fetch(`${TG_API}/sendMessage`, {
+async function tg(method: string, payload: Record | FormData) {
+ const form = payload instanceof FormData
+ const response = await fetch(`${TG_API}/${method}`, {
method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({
- chat_id: ADMIN_CHAT,
- text,
- parse_mode: 'Markdown',
- reply_markup: {
- inline_keyboard: [[
- { text: '๐ค Copy for AI', callback_data: `ctx:${report.id}` },
- { text: 'โ Resolved', callback_data: `res:${report.id}` },
- { text: '๐ซ Won\'t Fix', callback_data: `wfx:${report.id}` },
- ]],
- },
- }),
+ headers: form ? undefined : { 'content-type': 'application/json' },
+ body: form ? payload : JSON.stringify(payload),
+ signal: AbortSignal.timeout(10000),
+ })
+ const body = await response.json().catch(() => null)
+ if (!response.ok || body?.ok !== true) throw new Error(`Telegram delivery failed (${response.status})`)
+ return body.result
+}
+
+async function sendContext(report: BugReport) {
+ const hint = TRACKING
+ ? `Use the authorized admin workflow for the full report ID ${report.id}`
+ : 'Notification-only sink: record the resolution in your project tracker'
+ const form = new FormData()
+ form.append('chat_id', ADMIN_CHAT)
+ const filename = `bug-${String(report.id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)}.txt`
+ form.append('document', new Blob([buildAIContext(report, hint)], { type: 'text/plain;charset=utf-8' }), filename)
+ return tg('sendDocument', form)
+}
+
+export async function sendBugToTelegram(report: BugReport) {
+ if (!process.env.TELEGRAM_BOT_TOKEN || !ADMIN_CHAT) throw new Error('Telegram delivery is not configured')
+ const buttons = [
+ { text: '๐ค Copy for AI', callback_data: `ctx:${report.id}` },
+ { text: 'โ Resolved', callback_data: `res:${report.id}` },
+ { text: "๐ซ Won't Fix", callback_data: `wfx:${report.id}` },
+ ]
+ if (TRACKING && buttons.some(button => Buffer.byteLength(button.callback_data, 'utf8') > 64)) {
+ throw new Error('Report ID exceeds Telegram callback limit; use persisted opaque callback tokens')
+ }
+ await tg('sendMessage', {
+ chat_id: ADMIN_CHAT,
+ text: `๐ Bug Report #${String(report.id).slice(0, 64)}\nReporter: ${String(report.reporter.name).slice(0, 80)}\n${report.description.slice(0, 700)}`,
+ ...(TRACKING ? { reply_markup: { inline_keyboard: [buttons] } } : {}),
})
+ await sendContext(report)
}
```
@@ -103,7 +122,9 @@ For the "Copy for AI" button, Telegram doesn't have a native copy-to-clipboard.
1. **Post the context as a reply in a Markdown code block.** User long-presses the block on their phone to copy.
2. **Send as a `.txt` file attachment.** User taps โ shares โ pastes into Claude.
-Option 1 is smoother on mobile. Build the AI context from `SPEC.md ยง3` and send it inside triple-backtick fences.
+The example uses a UTF-8 `.txt` document, retaining the full context without
+Telegram's message-size or Markdown-escaping limits. Preview text is deliberately
+short; the context document contains the complete report.
---
@@ -112,43 +133,76 @@ Option 1 is smoother on mobile. Build the AI context from `SPEC.md ยง3` and send
You need a webhook endpoint to receive button clicks:
```ts
+import { timingSafeEqual } from 'node:crypto'
+
+// A single-process sketch. Persist pending prompts for serverless/multi-worker
+// deployments, keyed by chat/message/admin identity with the same expiry.
+const pendingReasons = new Map()
+const ADMIN_USERS = new Set((process.env.TELEGRAM_ADMIN_USER_IDS || '').split(',').map(id => id.trim()).filter(Boolean))
+function authorized(chat: unknown, user: unknown) {
+ return String(chat) === ADMIN_CHAT && ADMIN_USERS.has(String(user))
+}
+
// POST /api/telegram/webhook
export async function handler(req: Request) {
- const update = await req.json()
+ if (!TRACKING) return new Response('disabled', { status: 404 })
+ const secret = process.env.TELEGRAM_WEBHOOK_SECRET || ''
+ if (!/^[a-zA-Z0-9_-]{32,256}$/.test(secret) || !ADMIN_CHAT || !ADMIN_USERS.size) return new Response('not configured', { status: 503 })
+ const provided = Buffer.from(req.headers.get('x-telegram-bot-api-secret-token') || '')
+ const expected = Buffer.from(secret)
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return new Response('forbidden', { status: 403 })
+ let update
+ try { update = await req.json() } catch { return new Response('invalid update', { status: 400 }) }
+ for (const [key, pending] of pendingReasons) if (pending.expires < Date.now()) pendingReasons.delete(key)
const callback = update.callback_query
- if (!callback) return new Response('ok')
-
- const [action, idStr] = callback.data.split(':')
- const id = parseInt(idStr, 10)
+ if (!callback) {
+ const message = update.message
+ if (!message) return new Response('ok')
+ if (!authorized(message.chat?.id, message.from?.id)) return new Response('forbidden', { status: 403 })
+ const key = `${ADMIN_CHAT}:${message.reply_to_message?.message_id}`
+ const pending = pendingReasons.get(key)
+ if (!pending || pending.user !== String(message.from.id)) return new Response('ok')
+ const reason = typeof message.text === 'string' ? message.text.trim() : ''
+ if (!reason || reason.length > 2000) return new Response('reason required (1โ2000 characters)', { status: 400 })
+ await updateStatus(pending.id, 'wontfix', reason)
+ pendingReasons.delete(key)
+ await tg('sendMessage', { chat_id: ADMIN_CHAT, text: 'Reason saved.' })
+ return new Response('ok')
+ }
+ if (!authorized(callback.message?.chat?.id, callback.from?.id)) return new Response('forbidden', { status: 403 })
+ const data = typeof callback.data === 'string' ? callback.data : ''
+ const separator = data.indexOf(':')
+ const action = data.slice(0, separator), id = data.slice(separator + 1)
+ if (separator < 1 || !id || !['ctx', 'res', 'wfx'].includes(action) || Buffer.byteLength(data, 'utf8') > 64) return new Response('invalid callback', { status: 400 })
if (action === 'ctx') {
const report = await getReport(id)
- const ctx = buildAIContext(report) // SPEC.md ยง3 format
- await tg('sendMessage', {
- chat_id: callback.message.chat.id,
- reply_to_message_id: callback.message.message_id,
- text: '```\n' + ctx + '\n```',
- parse_mode: 'MarkdownV2',
- })
+ if (!report) return new Response('not found', { status: 404 })
+ await sendContext(report)
+ await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Context sent as a file' })
} else if (action === 'res') {
- await updateStatus(id, 'resolved', 'Fixed (via Telegram)')
+ await updateStatus(id, 'resolved', 'Marked resolved by an authorized administrator via Telegram')
await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Marked resolved' })
} else if (action === 'wfx') {
- // Ask the admin to reply with a reason
- await tg('sendMessage', {
- chat_id: callback.message.chat.id,
- reply_to_message_id: callback.message.message_id,
- text: `Reply with reason for marking #${id} as won\\'t fix:`,
+ if (pendingReasons.size >= 1000) return new Response('too many pending prompts', { status: 503 })
+ const prompt = await tg('sendMessage', {
+ chat_id: ADMIN_CHAT,
+ text: `Reply with a reason for marking #${id} as won't fix:`,
reply_markup: { force_reply: true, selective: true },
})
- // Track the pending reply in memory/DB; on reply, call updateStatus(id, 'wontfix', reply.text)
+ if (!Number.isInteger(prompt?.message_id)) throw new Error('Telegram did not return a prompt ID')
+ pendingReasons.set(`${ADMIN_CHAT}:${prompt.message_id}`, { id, user: String(callback.from.id), expires: Date.now() + 15 * 60 * 1000 })
+ await tg('answerCallbackQuery', { callback_query_id: callback.id, text: 'Reply with a reason' })
}
return new Response('ok')
}
```
-Register the webhook: `curl "https://api.telegram.org/bot/setWebhook?url=https://yourapp.com/api/telegram/webhook"`.
+Register an HTTPS webhook with Telegram's `setWebhook` API and the same
+`secret_token` stored in `TELEGRAM_WEBHOOK_SECRET`. Keep tokens out of shell
+history/logs and source. Apply your framework's request-size and rate limits.
+See the [official webhook contract](https://core.telegram.org/bots/api#setwebhook).
---
@@ -168,8 +222,12 @@ If reporter submitted from a web app: reuse the web app's in-app notification sy
## Acceptance checklist
-- [ ] Bot token + admin chat ID in env vars
+- [ ] Server-only bot token and admin chat ID configured
+- [ ] Tracking webhook requires its secret header plus allowed admin chat/user IDs
+- [ ] Missing/incorrect authorization causes no storage read, mutation or delivery
+- [ ] UUID IDs remain intact; callback data fits Telegram's 64-byte limit
- [ ] Bug submission sends a formatted message to the admin chat
-- [ ] Inline "Copy for AI" button replies with SPEC.md ยง3 formatted text in a code block
+- [ ] Full SPEC.md ยง3 context arrives as a UTF-8 file, including long reports
+- [ ] HTTP errors and Telegram `ok: false` reject the operation
- [ ] Admin can mark reports resolved/wontfix via inline buttons
- [ ] Webhook is registered and receives callback queries
diff --git a/recipes/web-app-full.md b/recipes/web-app-full.md
index 695b583..7d0bf16 100644
--- a/recipes/web-app-full.md
+++ b/recipes/web-app-full.md
@@ -79,8 +79,8 @@ function onMove(e: MouseEvent) {
const rect = el.getBoundingClientRect()
outline.value = {
display: 'block',
- top: rect.top + scrollY + 'px',
- left: rect.left + scrollX + 'px',
+ top: rect.top + 'px',
+ left: rect.left + 'px',
width: rect.width + 'px',
height: rect.height + 'px',
}
@@ -110,7 +110,7 @@ function extractContext(el: Element) {
html: el.outerHTML.slice(0, 1200), // truncate
rect: {
x: Math.round(rect.x),
- y: Math.round(rect.y + scrollY),
+ y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height),
},
@@ -197,7 +197,7 @@ Full report detail + resolution note (if status is terminal) + a link back to th
This is the heart of bugkit. The output MUST match `SPEC.md ยง3` exactly. Rough TypeScript:
```ts
-function buildAIContext(r: BugReport): string {
+function buildAIContext(r: BugReport, updateHint = `PATCH /api/bug-reports/${encodeURIComponent(String(r.id))}`): string {
const lines: string[] = []
lines.push(`=== BUG REPORT #${r.id} ===`)
lines.push(`Type: ${r.type}`)
@@ -207,15 +207,27 @@ function buildAIContext(r: BugReport): string {
lines.push('## Description')
lines.push(r.description)
lines.push('')
- if (r.page_url || r.viewport || r.user_agent) {
+ const environment = r.environment || {}
+ if (environment.app_version || environment.commit || environment.platform || environment.os) {
+ lines.push('## Environment')
+ if (environment.app_version || environment.commit) lines.push(`App: ${environment.app_version || ''} (commit ${environment.commit || ''})`)
+ if (environment.platform) lines.push(`Platform: ${environment.platform}`)
+ if (environment.os) lines.push(`OS: ${environment.os}`)
+ lines.push('')
+ }
+ // Canonical SPEC v1 fields; the fallback reads older flattened app records.
+ const page = r.page || { url: r.page_url }
+ const client = r.client || { viewport: r.viewport, user_agent: r.user_agent }
+ if (page.url || page.route || client.viewport || client.user_agent) {
lines.push('## Page')
- if (r.page_url) lines.push(`URL: ${r.page_url}`)
- if (r.viewport) lines.push(`Viewport: ${r.viewport}`)
- if (r.user_agent) lines.push(`User Agent: ${r.user_agent}`)
+ if (page.url) lines.push(`URL: ${page.url}`)
+ if (page.route) lines.push(`Route: ${page.route}`)
+ if (client.viewport) lines.push(`Viewport: ${client.viewport}`)
+ if (client.user_agent) lines.push(`User Agent: ${client.user_agent}`)
lines.push('')
}
- if (r.element_context) {
- const e = r.element_context
+ const e = r.element || r.element_context
+ if (e && Object.values(e).some(value => value !== undefined && value !== null && value !== '')) {
lines.push('## Selected Element')
if (e.selector) lines.push(`Selector: ${e.selector}`)
if (e.xpath) lines.push(`XPath: ${e.xpath}`)
@@ -227,13 +239,25 @@ function buildAIContext(r: BugReport): string {
if (e.html) { lines.push('HTML:'); lines.push(e.html) }
lines.push('')
}
+ if (r.error?.message || r.error?.stack) {
+ lines.push('## Error')
+ if (r.error.message) lines.push(`Message: ${r.error.message}`)
+ if (r.error.stack) lines.push('Stack:', r.error.stack)
+ lines.push('')
+ }
+ if (r.logs?.console?.length) {
+ lines.push('## Recent Console', r.logs.console.join('\n'), '')
+ }
+ if (r.attachments?.length) {
+ lines.push('## Attachments', ...r.attachments.map(a => `- ${a.type}: ${a.url}`), '')
+ }
lines.push('## Instructions')
lines.push(`${r.type === 'bug' ? 'Fix this bug' : 'Implement this feature'}. When done:`)
lines.push('1. Summarize root cause and files changed.')
lines.push('2. Provide a short "how to verify" note.')
- lines.push(`3. Update the report via: PATCH /api/bug-reports/${r.id}`)
- lines.push(` With: { status: 'resolved', resolution: '', commit_sha: '' }`)
- lines.push(` (Or status 'wontfix' / 'closed' with a reason.)`)
+ lines.push(`3. Update the report via: ${updateHint}`)
+ lines.push(' With: { status: "resolved", resolution: "", commit_sha: "" }')
+ lines.push(' (Or status "wontfix" / "closed" with a reason.)')
return lines.join('\n')
}
@@ -321,16 +345,17 @@ function attachVoice(textarea: HTMLTextAreaElement, micBtn: HTMLButtonElement) {
let base = ''
rec.onresult = (e: any) => {
- let interim = '', final = ''
- for (let i = e.resultIndex; i < e.results.length; i++) {
- const t = e.results[i][0].transcript
- if (e.results[i].isFinal) final += t
- else interim += t
- }
- textarea.value = (base + final + interim).trimStart()
+ const speech = Array.from(e.results, (result: any) => result[0].transcript.trim()).filter(Boolean).join(' ')
+ textarea.value = (base + speech).trimStart()
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
rec.onerror = () => { stop() }
- rec.onend = () => { if (recording) rec.start() }
+ rec.onend = () => {
+ if (recording) {
+ base = textarea.value ? textarea.value + ' ' : ''
+ try { rec.start() } catch { stop() }
+ }
+ }
function start() {
base = textarea.value ? textarea.value + ' ' : ''
diff --git a/skills/adding-voice/SKILL.md b/skills/adding-voice/SKILL.md
index 62128da..007ef3c 100644
--- a/skills/adding-voice/SKILL.md
+++ b/skills/adding-voice/SKILL.md
@@ -40,17 +40,17 @@ function attachVoice(textarea: HTMLTextAreaElement, micButton: HTMLButtonElement
let baseText = ''
recognition.onresult = (e: any) => {
- let interim = ''
- let finalText = ''
- for (let i = e.resultIndex; i < e.results.length; i++) {
- const t = e.results[i][0].transcript
- if (e.results[i].isFinal) finalText += t
- else interim += t
- }
- textarea.value = (baseText + finalText + interim).trimStart()
+ const speech = Array.from(e.results, (result: any) => result[0].transcript.trim()).filter(Boolean).join(' ')
+ textarea.value = (baseText + speech).trimStart()
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
recognition.onerror = () => { stopRecording() }
- recognition.onend = () => { if (recording) recognition.start() /* keep alive */ }
+ recognition.onend = () => {
+ if (recording) {
+ baseText = textarea.value ? textarea.value + ' ' : ''
+ try { recognition.start() } catch { stopRecording() }
+ }
+ }
function startRecording() {
baseText = textarea.value ? textarea.value + ' ' : ''
diff --git a/tests/helpers.mjs b/tests/helpers.mjs
new file mode 100644
index 0000000..4aa884a
--- /dev/null
+++ b/tests/helpers.mjs
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict'
+import {readFile} from 'node:fs/promises'
+import {stripTypeScriptTypes} from 'node:module'
+
+export async function snippet(file, heading, index = 0) {
+ const text = await readFile(new URL(`../${file}`, import.meta.url), 'utf8')
+ const section = text.split(heading)[1]?.split(/\r?\n## /)[0]
+ const blocks = [...(section ?? '').matchAll(/^```ts\r?\n([\s\S]*?)^```\s*$/gm)]
+ assert.ok(blocks[index], `${file}: missing example under ${heading}`)
+ return stripTypeScriptTypes(blocks[index][1], {mode: 'strip'})
+ .replace(/^import[^\n]*\n/gm, '').replace(/^export /gm, '')
+}
+export function load(code, names, globals = {}) {
+ const scope = {process: {env: {}}, fetch: () => { throw Error('External requests forbidden') }, ...globals}
+ return new Function(...Object.keys(scope), code + `\nreturn {${names.join(',')}}`)(...Object.values(scope))
+}
+export const report = {
+ bugkit_spec: '1', id: 'synthetic-report', type: 'bug', status: 'open',
+ title: 'Synthetic report', description: 'Synthetic broken button report',
+ created_at: '2026-01-01T00:00:00Z', reporter: {id: 'fixture', name: 'fixture'},
+ environment: {app_version: '1', commit: 'abc123', platform: 'web', os: 'Synthetic OS'},
+ page: {url: 'https://example.invalid/problem', route: 'problem'},
+ client: {viewport: '1200x800', user_agent: 'Synthetic UA'},
+ element: {selector: '#fixture', xpath: '/html/body/button', tag: 'BUTTON', id: 'fixture', classes: 'fixture', text: 'Synthetic button', html: '', rect: {x: 1, y: 2, w: 3, h: 4}},
+ error: {message: 'Synthetic error', stack: 'Synthetic stack'},
+ logs: {console: ['Synthetic console', 'Second line']},
+ attachments: [{type: 'screenshot', url: 'https://example.invalid/synthetic.png'}],
+}
diff --git a/tests/jsonl-recipe.test.mjs b/tests/jsonl-recipe.test.mjs
index 1dcfe6c..1ef3cdb 100644
--- a/tests/jsonl-recipe.test.mjs
+++ b/tests/jsonl-recipe.test.mjs
@@ -98,3 +98,27 @@ test('a partial numeric ID cannot silently select a different report', async ()
assert.match(result.stderr, /Not found/)
})
})
+
+test('empty and oversized resolution notes fail without changing stored reports', async () => {
+ await fixture(async directory => {
+ const original = record('synthetic-close')
+ await writeFile(join(directory, '.bugreports.jsonl'), JSON.stringify(original) + '\n')
+ for (const note of ['', ' ', 'x'.repeat(2001)]) {
+ const result = spawnSync(process.execPath, [join(directory, 'bugkit.js'), 'close', original.id, note], {cwd: directory, encoding: 'utf8'})
+ assert.equal(result.status, 1)
+ assert.match(result.stderr, /resolution note/)
+ assert.deepEqual(JSON.parse(cli(directory, 'show', original.id)), original)
+ }
+ })
+})
+
+test('missing storage starts empty while real read errors are reported', async () => {
+ await fixture(async directory => {
+ assert.equal(cli(directory, 'list'), '')
+ const {mkdir} = await import('node:fs/promises')
+ await mkdir(join(directory, '.bugreports.jsonl'))
+ const result = spawnSync(process.execPath, [join(directory, 'bugkit.js'), 'list'], {cwd: directory, encoding: 'utf8'})
+ assert.notEqual(result.status, 0)
+ assert.notEqual(result.stderr, '')
+ })
+})
diff --git a/tests/provider-contracts.test.mjs b/tests/provider-contracts.test.mjs
new file mode 100644
index 0000000..bb527d0
--- /dev/null
+++ b/tests/provider-contracts.test.mjs
@@ -0,0 +1,176 @@
+// Actual recipe functions, loopback HTTP and explicitly fake SDK/store boundaries.
+import assert from 'node:assert/strict'
+import {createServer} from 'node:http'
+import {timingSafeEqual} from 'node:crypto'
+import test from 'node:test'
+import {snippet, load, report} from './helpers.mjs'
+
+async function contextBuilder() {
+ return load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext']).buildAIContext
+}
+async function localService(run) {
+ const requests = [], mode = {httpError: false, telegramError: false, slackError: false}
+ const server = createServer(async (req, res) => {
+ let raw = ''; for await (const chunk of req) raw += chunk
+ requests.push({path: req.url, headers: req.headers, raw})
+ if (mode.httpError) { res.writeHead(503); res.end('synthetic rejection'); return }
+ if (req.url.startsWith('/slack')) { res.end(mode.slackError ? 'invalid_payload' : 'ok'); return }
+ res.setHeader('content-type', 'application/json')
+ res.end(JSON.stringify(req.url.startsWith('/telegram')
+ ? {ok: !mode.telegramError, result: {message_id: 501}}
+ : {id: 'synthetic-message'}))
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ const origin = `http://127.0.0.1:${server.address().port}`
+ const guardedFetch = (url, init) => {
+ const address = String(url)
+ if (address.startsWith(origin + '/')) return fetch(address, init)
+ const telegram = 'https://api.telegram.org/botsynthetic-token/'
+ if (address.startsWith(telegram)) return fetch(origin + '/telegram/' + address.slice(telegram.length), init)
+ throw Error('External provider request blocked by synthetic test')
+ }
+ try { await run({origin, guardedFetch, requests, mode}) }
+ finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) }
+}
+const syntheticSecret = 'synthetic_webhook_secret_for_tests_only_1234'
+const telegramEnv = {
+ TELEGRAM_BOT_TOKEN: 'synthetic-token', TELEGRAM_ADMIN_CHAT_ID: '-10042',
+ TELEGRAM_ADMIN_USER_IDS: '7,8', TELEGRAM_WEBHOOK_SECRET: syntheticSecret,
+ BUGKIT_TELEGRAM_TRACKING: 'true',
+}
+const callback = (data, chat = -10042, user = 7) => ({callback_query: {id: 'synthetic-callback', data, from: {id: user}, message: {chat: {id: chat}, message_id: 10}}})
+const webhookRequest = (body, secret = syntheticSecret) => new Request('http://127.0.0.1/synthetic-webhook', {
+ method: 'POST', headers: {'content-type': 'application/json', 'x-telegram-bot-api-secret-token': secret}, body: JSON.stringify(body),
+})
+async function telegram(globals) {
+ const code = await snippet('recipes/telegram-bot.md', '## Implementation sketch (Node)') + '\n' + await snippet('recipes/telegram-bot.md', '## Webhook (for callback buttons)')
+ return load(code, ['handler', 'sendBugToTelegram'], {process: {env: telegramEnv}, timingSafeEqual, buildAIContext: await contextBuilder(), ...globals})
+}
+
+test('Telegram preserves full UUIDs and sends the complete long canonical context as a file', async () => {
+ await localService(async ({guardedFetch, requests}) => {
+ const id = '6588a8c7-9437-47cf-a30c-6dbe49797f08'
+ const input = {...report, id, description: 'Synthetic long description. '.repeat(220)}
+ let selected
+ const {handler, sendBugToTelegram} = await telegram({fetch: guardedFetch, getReport: async value => { selected = value; return input }, updateStatus: async () => { throw Error('Unexpected mutation') }})
+ assert.equal((await handler(webhookRequest(callback(`ctx:${id}`)))).status, 200)
+ assert.equal(selected, id)
+ assert.ok(requests.some(request => request.path === '/telegram/sendDocument' && request.raw.includes(input.description)))
+ assert.ok(requests.some(request => request.raw.includes('## Environment') && request.raw.includes('## Attachments')))
+ await sendBugToTelegram(input)
+ const notice = requests.filter(request => request.path === '/telegram/sendMessage').at(-1)
+ const message = JSON.parse(notice.raw)
+ assert.ok(message.text.length < 4096)
+ assert.equal(message.reply_markup.inline_keyboard[0][0].callback_data, `ctx:${id}`)
+ })
+})
+
+test('Telegram webhook authorization denies disclosure/mutation before storage or delivery', async () => {
+ await localService(async ({guardedFetch, requests}) => {
+ let reads = 0, mutations = 0
+ const {handler} = await telegram({fetch: guardedFetch, getReport: async () => { reads++; return report }, updateStatus: async () => { mutations++ }})
+ for (const request of [
+ webhookRequest(callback('ctx:synthetic'), ''),
+ webhookRequest(callback('res:synthetic'), 'synthetic_wrong_secret_123456789012345'),
+ webhookRequest(callback('ctx:synthetic', -99)),
+ webhookRequest(callback('res:synthetic', -10042, 999)),
+ ]) assert.equal((await handler(request)).status, 403)
+ const missing = await telegram({process: {env: {...telegramEnv, TELEGRAM_WEBHOOK_SECRET: ''}}, fetch: guardedFetch, getReport: async () => { reads++; return report }, updateStatus: async () => { mutations++ }})
+ assert.equal((await missing.handler(webhookRequest(callback('ctx:synthetic')))).status, 503)
+ const disabled = await telegram({process: {env: {...telegramEnv, BUGKIT_TELEGRAM_TRACKING: 'false'}}, fetch: guardedFetch})
+ assert.equal((await disabled.handler(webhookRequest(callback('ctx:synthetic')))).status, 404)
+ assert.equal(reads, 0); assert.equal(mutations, 0); assert.equal(requests.length, 0)
+ })
+})
+
+test('Telegram authorized resolution and reason replies target the same complete ID', async () => {
+ await localService(async ({guardedFetch}) => {
+ const mutations = []
+ const {handler} = await telegram({fetch: guardedFetch, getReport: async () => report, updateStatus: async (...args) => mutations.push(args)})
+ const id = '6588a8c7-9437-47cf-a30c-6dbe49797f08'
+ assert.equal((await handler(webhookRequest(callback(`res:${id}`)))).status, 200)
+ assert.equal(mutations[0][0], id)
+ assert.equal((await handler(webhookRequest(callback(`wfx:${id}`)))).status, 200)
+ const reply = {message: {chat: {id: -10042}, from: {id: 7}, reply_to_message: {message_id: 501}, text: 'Synthetic explicit reason'}}
+ assert.equal((await handler(webhookRequest({...reply, message: {...reply.message, from: {id: 8}}}))).status, 200)
+ assert.equal(mutations.length, 1)
+ assert.equal((await handler(webhookRequest(reply))).status, 200)
+ assert.deepEqual(mutations[1], [id, 'wontfix', 'Synthetic explicit reason'])
+ })
+})
+
+test('Telegram HTTP and application-level rejections reject instead of reporting success', async () => {
+ await localService(async ({guardedFetch, mode}) => {
+ const {sendBugToTelegram} = await telegram({fetch: guardedFetch})
+ mode.httpError = true
+ await assert.rejects(sendBugToTelegram(report), /Telegram delivery failed/)
+ mode.httpError = false; mode.telegramError = true
+ await assert.rejects(sendBugToTelegram(report), /Telegram delivery failed/)
+ })
+})
+
+test('Slack and Discord preserve large context within transport limits and suppress mentions', async () => {
+ await localService(async ({origin, guardedFetch, requests}) => {
+ const context = 'Synthetic <@everyone> ``` context ๐\n'.repeat(400)
+ const {postToChat} = load(await snippet('recipes/slack-discord.md', '## Implementation (minimal)'), ['postToChat'], {
+ process: {env: {BUGKIT_SLACK_WEBHOOK: origin + '/slack', BUGKIT_DISCORD_WEBHOOK: origin + '/discord'}},
+ fetch: guardedFetch, buildAIContext: () => context,
+ })
+ await postToChat(report)
+ const slack = requests.filter(request => request.path === '/slack').map(request => JSON.parse(request.raw))
+ assert.ok(slack.length > 1)
+ assert.ok(slack.every(message => message.blocks[0].text.type === 'plain_text' && message.blocks[0].text.text.length <= 3000))
+ assert.equal(slack.map(message => message.blocks[0].text.text).join(''), context)
+ const discord = requests.find(request => request.path === '/discord?wait=true')
+ assert.ok(discord.raw.includes(context))
+ assert.ok(discord.raw.includes('"allowed_mentions":{"parse":[]}'))
+ assert.ok(discord.headers['content-type'].startsWith('multipart/form-data;'))
+ })
+})
+
+for (const provider of ['Slack', 'Discord']) {
+ test(`${provider} delivery errors reject`, async () => {
+ await localService(async ({origin, guardedFetch, mode}) => {
+ const {postToChat} = load(await snippet('recipes/slack-discord.md', '## Implementation (minimal)'), ['postToChat'], {
+ process: {env: {[`BUGKIT_${provider.toUpperCase()}_WEBHOOK`]: origin + '/' + provider.toLowerCase()}},
+ fetch: guardedFetch, buildAIContext: () => 'Synthetic context',
+ })
+ mode.httpError = true
+ await assert.rejects(postToChat(report), /delivery failed/)
+ if (provider === 'Slack') {
+ mode.httpError = false; mode.slackError = true
+ await assert.rejects(postToChat(report), /delivery failed/)
+ }
+ })
+ })
+}
+
+test('Resend SDK rejection is surfaced even when its promise resolves', async () => {
+ let rejected = true
+ class Resend { constructor() { this.emails = {send: async () => rejected ? {data: null, error: {message: 'synthetic error'}} : {data: {id: 'synthetic-mail'}, error: null}} } }
+ const {sendBugByEmail} = load(await snippet('recipes/email.md', '## Implementation sketch (Resend)'), ['sendBugByEmail'], {
+ Resend, buildAIContext: await contextBuilder(), renderHtml: () => '
Synthetic
', renderPlainText: () => 'Synthetic',
+ })
+ await assert.rejects(sendBugByEmail(report), /Email provider rejected/)
+ rejected = false; assert.equal((await sendBugByEmail(report)).id, 'synthetic-mail')
+})
+
+test('GitHub issue rendering retains canonical context and assigns server IDs without rewriting user text', async () => {
+ const requests = []
+ let rejectUpdate = false
+ class Octokit { constructor() { this.issues = {
+ create: async input => { requests.push(input); return {data: {number: 42, html_url: 'https://example.invalid/issues/42', body: input.body}} },
+ update: async input => { if (rejectUpdate) throw Error('synthetic update rejection'); requests.push(input) },
+ } } }
+ const {createBugReport} = load(await snippet('recipes/github-issues.md', '## Implementation sketch (Node)'), ['createBugReport'], {
+ Octokit, buildAIContext: await contextBuilder(), extractTitle: text => text.split('\n')[0].slice(0, 200),
+ })
+ const input = {...report, description: 'Synthetic literal #PENDING ```\n\nSecond line'}
+ assert.equal((await createBugReport(input)).id, 42)
+ const body = requests.at(-1).body
+ assert.ok(body.includes('=== BUG REPORT #42 ===') && body.includes(input.description))
+ assert.ok(body.includes('gh issue close 42 --comment'))
+ assert.ok(body.includes('````text'))
+ for (const section of ['Environment', 'Selected Element', 'Error', 'Recent Console', 'Attachments']) assert.ok(body.includes('## ' + section))
+ rejectUpdate = true; await assert.rejects(createBugReport(input), /synthetic update rejection/)
+})
diff --git a/tests/recipe-contracts.test.mjs b/tests/recipe-contracts.test.mjs
new file mode 100644
index 0000000..477e079
--- /dev/null
+++ b/tests/recipe-contracts.test.mjs
@@ -0,0 +1,84 @@
+// Execute the documented snippets with synthetic browser/SDK boundaries.
+// Never contact a provider or send an actual report from these tests.
+import assert from 'node:assert/strict'
+import {readFile} from 'node:fs/promises'
+import {EventEmitter} from 'node:events'
+import test from 'node:test'
+
+import {snippet, load, report} from './helpers.mjs'
+
+test('install instructions name the registered marketplace and advertised voice command exists', async () => {
+ const marketplace = JSON.parse(await readFile(new URL('../.claude-plugin/marketplace.json', import.meta.url)))
+ for (const file of ['README.md', 'INSTALL.md', 'docs/index.html']) {
+ const text = await readFile(new URL(`../${file}`, import.meta.url), 'utf8')
+ assert.ok(text.includes(`bugkit@${marketplace.name}`), file)
+ assert.ok(!text.includes('bugkit@bglglzd-bugkit'), file)
+ }
+ assert.match(await readFile(new URL('../commands/voice.md', import.meta.url), 'utf8'), /adding-voice/)
+})
+
+test('canonical context preserves every populated SPEC section in order and sink-specific update hints', async () => {
+ const {buildAIContext} = load(await snippet('recipes/web-app-full.md', '## The "Copy Context for AI" function'), ['buildAIContext'])
+ const context = buildAIContext(report, 'node scripts/bugkit.js close synthetic-report ""')
+ const headings = ['Description', 'Environment', 'Page', 'Selected Element', 'Error', 'Recent Console', 'Attachments', 'Instructions']
+ assert.deepEqual([...context.matchAll(/^## (.*)$/gm)].map(match => match[1]), headings)
+ for (const value of ['App: 1 (commit abc123)', 'Platform: web', 'OS: Synthetic OS', 'Route: problem', 'Viewport: 1200x800', 'User Agent: Synthetic UA', 'Position: x=1 y=2 w=3 h=4', 'Message: Synthetic error', 'Stack:\nSynthetic stack', 'Synthetic console\nSecond line', '- screenshot: https://example.invalid/synthetic.png']) assert.ok(context.includes(value), value)
+ assert.ok(context.includes('Update the report via: node scripts/bugkit.js close synthetic-report'))
+ assert.ok(!context.includes('PATCH /api/bug-reports/'))
+ const minimal = buildAIContext({id: 1, type: 'feature', status: 'open', created_at: 'synthetic', description: 'Synthetic feature', reporter: {name: 'fixture'}})
+ assert.deepEqual([...minimal.matchAll(/^## (.*)$/gm)].map(match => match[1]), ['Description', 'Instructions'])
+ assert.match(minimal, /Implement this feature/)
+})
+
+for (const [file, heading] of [
+ ['recipes/web-app-full.md', '### Web Speech path (default)'],
+ ['skills/adding-voice/SKILL.md', '### Mode 1: Web Speech API (default)'],
+]) {
+ test(`${file}: voice retains finalized segments, interim updates and restarted sessions`, async () => {
+ let recognition
+ class SpeechRecognition { constructor() { recognition = this } start() {} stop() {} }
+ const clicks = new Map(), inputEvents = []
+ const textarea = {value: 'Typed', addEventListener() {}, dispatchEvent: event => inputEvents.push(event.type)}
+ const button = {style: {}, classList: {add() {}, remove() {}}, addEventListener: (name, fn) => clicks.set(name, fn)}
+ const {attachVoice} = load(await snippet(file, heading), ['attachVoice'], {window: {SpeechRecognition}, navigator: {language: 'en-US'}, Event})
+ attachVoice(textarea, button); clicks.get('click')()
+ const result = (text, final = true) => Object.assign([{transcript: text}], {isFinal: final})
+ recognition.onresult({resultIndex: 0, results: [result('First')]})
+ recognition.onresult({resultIndex: 1, results: [result('First'), result('Second', false)]})
+ recognition.onresult({resultIndex: 1, results: [result('First'), result('Second')]})
+ assert.equal(textarea.value, 'Typed First Second')
+ recognition.onend()
+ recognition.onresult({resultIndex: 0, results: [result('Third')]})
+ assert.equal(textarea.value, 'Typed First Second Third')
+ assert.ok(inputEvents.every(type => type === 'input') && inputEvents.length === 4)
+ })
+}
+
+test('fixed picker outline and element coordinates share viewport space after scrolling', async () => {
+ const element = {closest: () => false, getBoundingClientRect: () => ({top: 30, left: 20, x: 20, y: 30, width: 50, height: 10}), tagName: 'BUTTON', id: 'fixture', className: '', innerText: 'Synthetic', outerHTML: ''}
+ const {onMove, outline, extractContext} = load(await snippet('recipes/web-app-full.md', '## Element picker'), ['onMove', 'outline', 'extractContext'], {
+ ref: value => ({value}), document: {elementFromPoint: () => element}, scrollY: 500, scrollX: 300,
+ buildSelector: () => '#fixture', buildXPath: () => '/button',
+ })
+ onMove({clientX: 20, clientY: 30})
+ assert.equal(outline.value.top, '30px'); assert.equal(outline.value.left, '20px')
+ assert.deepEqual(extractContext(element).rect, {x: 20, y: 30, w: 50, h: 10})
+})
+
+test('GitHub CLI uses direct argv/body-file stdin, preserves literal title and propagates failure', async () => {
+ let code = 0, captured, body
+ const spawn = (command, args, options) => {
+ captured = {command, args, options}
+ const child = new EventEmitter(); child.stdin = new EventEmitter()
+ child.stdin.end = value => { body = value; queueMicrotask(() => child.emit('close', code)) }
+ return child
+ }
+ const {createGhIssue} = load(await snippet('recipes/cli-tool.md', '## Publishing via GitHub Issues instead'), ['createGhIssue'], {spawn, buildAIContext: () => 'Synthetic complete context'})
+ const title = 'Synthetic $(literal) `title` "quoted"'
+ await createGhIssue({...report, title})
+ assert.equal(captured.command, 'gh'); assert.equal(captured.options.shell, false)
+ assert.equal(captured.args[captured.args.indexOf('--title') + 1], title)
+ assert.equal(captured.args[captured.args.indexOf('--body-file') + 1], '-')
+ assert.equal(body, 'Synthetic complete context')
+ code = 1; await assert.rejects(createGhIssue(report), /failed/)
+})
From e8dbef64402a9fbae9c513ff129f372ed141c609 Mon Sep 17 00:00:00 2001
From: bglglzd <248948303+bglglzd@users.noreply.github.com>
Date: Wed, 11 Nov 2026 02:29:35 +0300
Subject: [PATCH 2/3] fix: align secondary recipe paths with supported
contracts
---
CHANGELOG.md | 4 +++
VALIDATION.md | 6 +++-
assets/og-image.svg | 4 +--
docs/assets/og-image.svg | 4 +--
docs/index.html | 22 +++++++-------
recipes/cli-tool.md | 49 +++++++++----------------------
recipes/email.md | 9 ++++--
recipes/web-app-full.md | 29 +++++++++++++-----
skills/adding-voice/SKILL.md | 30 ++++++++++++++-----
tests/provider-contracts.test.mjs | 44 +++++++++++++++++++++++++++
tests/recipe-contracts.test.mjs | 21 ++++++++++++-
11 files changed, 151 insertions(+), 71 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a385208..278f40d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,3 +13,7 @@
finalized voice segments and fix picker coordinates after scrolling.
- Reject invalid terminal resolutions and propagate actual JSONL storage errors.
- Add executable recipe contracts and Linux/Windows CI with synthetic boundaries.
+- Reuse the canonical CLI context, surface reporter-notification failures, handle
+ SVG picker classes and feature labels, and correct local Whisper setup/format
+ conversion instructions. Replace unsupported fix-time promises on the landing
+ page and share images with factual copy.
diff --git a/VALIDATION.md b/VALIDATION.md
index d0878ba..6a90430 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -23,7 +23,11 @@ The suite exercises:
- Resend's resolved-promise error contract and GitHub issue-body reconstruction
with explicit fake SDKs. No actual email or issue is created.
- Literal CLI argv/stdin, synthetic speech-result sequences/restarts, and picker
- coordinates with a synthetic scrolled browser object.
+ coordinates with synthetic scrolled HTML/SVG browser objects.
+- Both Whisper proxy snippets' input validation, multipart byte forwarding and
+ upstream HTTP rejection using a synthetic loopback endpoint. Setup commands
+ are checked against the linked upstream source; no actual Whisper server or
+ model is executed by this suite.
These checks establish the supplied examples' contracts. They do not establish
live provider credentials, a generated project's framework integration, real
diff --git a/assets/og-image.svg b/assets/og-image.svg
index 651b244..38004d9 100644
--- a/assets/og-image.svg
+++ b/assets/og-image.svg
@@ -1,5 +1,5 @@
-