diff --git a/apps/scout-bot/Dockerfile b/apps/scout-bot/Dockerfile deleted file mode 100644 index 8fbe97b..0000000 --- a/apps/scout-bot/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM node:20-alpine -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable - -WORKDIR /app - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/scout-bot/package.json apps/scout-bot/ - -RUN pnpm install --frozen-lockfile - -COPY apps/scout-bot ./apps/scout-bot - -CMD ["pnpm", "--dir", "apps/scout-bot", "exec", "tsx", "src/index.ts"] diff --git a/apps/scout-bot/package.json b/apps/scout-bot/package.json deleted file mode 100644 index ff93ef9..0000000 --- a/apps/scout-bot/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "scout-bot", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", - "dependencies": { - "@octokit/rest": "^22.0.1", - "dotenv": "^17.4.2", - "node-cron": "^4.2.1", - "zod": "^4.4.3" - }, - "devDependencies": { - "@types/node": "^20.19.42", - "@types/node-cron": "^3.0.11", - "tsx": "^4.22.4", - "typescript": "^5.9.3" - } -} diff --git a/apps/scout-bot/src/index.ts b/apps/scout-bot/src/index.ts deleted file mode 100644 index bdaeeb4..0000000 --- a/apps/scout-bot/src/index.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { Octokit } from "@octokit/rest"; -import cron from "node-cron"; -import dotenv from "dotenv"; - -dotenv.config(); - -const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -const VIBEWORK_API_URL = process.env.VIBEWORK_API_URL || "https://agent.wooo.work/api"; -const SCOUT_API_KEY = process.env.SCOUT_API_KEY; -const SCOUT_AGENT_ID = process.env.SCOUT_AGENT_ID || "scout_official_1"; -const SCOUT_CRON_EXPRESSION = process.env.SCOUT_CRON_EXPRESSION || "*/3 * * * *"; -const SCOUT_ISSUE_LABELS_RAW = process.env.SCOUT_ISSUE_LABELS || process.env.SCOUT_ISSUE_LABEL || ""; -const SCOUT_ENABLED = (process.env.SCOUT_ENABLED || "true").toLowerCase() !== "false"; -const SCOUT_TARGET_REPOS_RAW = - process.env.SCOUT_TARGET_REPOS || - "open-webui/open-webui,microsoft/vscode,vercel/next.js,langchain-ai/langgraph,facebook/react,microsoft/TypeScript,openai/openai-cookbook,astral-sh/ruff,sequelize/sequelize,pnpm/pnpm,prisma/prisma"; -const SCOUT_PER_PAGE = Math.min(Math.max(parseInt(process.env.SCOUT_PER_PAGE || "50", 10), 1), 100); -const SCOUT_MAX_ISSUES_PER_SCAN = Math.max(parseInt(process.env.SCOUT_MAX_ISSUES_PER_SCAN || "60", 10), 1); -const SCOUT_COMMENT_DELAY_SECONDS = Math.max(parseInt(process.env.SCOUT_COMMENT_DELAY_SECONDS || "2", 10), 0); -const SCOUT_COMMENT_REPOS_RAW = process.env.SCOUT_COMMENT_REPOS || ""; - -const SCOUT_COMMENT_REPOS = SCOUT_COMMENT_REPOS_RAW === "*" - ? ["*"] - : SCOUT_COMMENT_REPOS_RAW.split(",").map(r => r.trim().toLowerCase()).filter(Boolean); - - -const SCOUT_ISSUE_LABELS = SCOUT_ISSUE_LABELS_RAW - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); - -const SCOUT_TARGET_REPOS = SCOUT_TARGET_REPOS_RAW - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean) - .map((entry) => { - const [owner, repo] = entry.split("/"); - return { owner, repo }; - }) - .filter((repo) => repo.owner && repo.repo); - -const fallbackRepo = { owner: "open-webui", repo: "open-webui" }; -const TARGET_REPOS = SCOUT_TARGET_REPOS.length > 0 ? SCOUT_TARGET_REPOS : [fallbackRepo]; - -if (!SCOUT_API_KEY) { - console.warn("WARNING: SCOUT_API_KEY is not set. Draft API may be rejected by server."); -} -if (!GITHUB_TOKEN) { - console.warn("WARNING: GITHUB_TOKEN is not set. Scout bot cannot post comments."); -} - -function normalizeText(value: string, maxLength: number, fallback = "N/A") { - const cleanText = (value || "").replace(/\s+/g, " ").trim(); - if (!cleanText) { - return fallback; - } - return cleanText.length <= maxLength ? cleanText : `${cleanText.slice(0, Math.max(maxLength - 1, 0))}…`; -} - -function buildDraftPayload(issueTitle: string, issueBody: string, issueUrl: string) { - const prefix = "GitHub Issue: "; - const titleMax = 120; - const descriptionMax = 2000; - const sourceSuffix = `\n\nSource: ${issueUrl}`; - const sourceAllowance = Math.max(descriptionMax - sourceSuffix.length, 20); - - const safeTitle = normalizeText(issueTitle, Math.max(titleMax - prefix.length, 20), "Open Source Issue"); - const cleanBody = normalizeText(issueBody || "", sourceAllowance, "請參考 issue 連結取得完整需求內容。"); - - return { - title: `${prefix}${safeTitle}`, - description: `${cleanBody}${sourceSuffix}`, - reward_amount: 0, // $0.00 Free AI Bounty - reward_currency: "USD", - required_stack: ["TypeScript", "GitHub"], - test_file_content: "// Needs manual test writing based on issue", - }; -} - -const octokit = new Octokit({ - auth: GITHUB_TOKEN, -}); - -async function wait(seconds: number) { - return new Promise((resolve) => setTimeout(resolve, seconds * 1000)); -} - -function getGitHubBackoffSeconds(error: unknown): number { - const status = - typeof error === "object" && - error !== null && - "status" in error && - typeof (error as { status?: unknown }).status === "number" - ? (error as { status: number }).status - : undefined; - - const message = - typeof error === "object" && - error !== null && - "message" in error && - typeof (error as { message?: unknown }).message === "string" - ? (error as { message: string }).message.toLowerCase() - : ""; - - const responseHeaders = - typeof error === "object" && - error !== null && - "response" in error && - error.response && - typeof error.response === "object" && - "headers" in error.response - ? (error.response as { headers?: Record }).headers - : undefined; - - const lowerCaseHeaders = responseHeaders - ? Object.fromEntries( - Object.entries(responseHeaders).map(([key, value]) => [key.toLowerCase(), value]) - ) - : undefined; - - if ( - typeof status === "number" && - status === 403 && - message.includes("secondary rate limit") - ) { - const resetAtRaw = lowerCaseHeaders?.["x-ratelimit-reset"]; - if (resetAtRaw) { - const resetAt = parseInt(resetAtRaw, 10); - const now = Math.floor(Date.now() / 1000); - const resetWait = resetAt - now + 5; - if (resetWait > 0) { - return resetWait; - } - } - return 60; - } - - if (typeof status === "number" && status === 403 && message.includes("rate limit exceeded")) { - const resetAtRaw = lowerCaseHeaders?.["x-ratelimit-reset"]; - if (resetAtRaw) { - const resetAt = parseInt(resetAtRaw, 10); - const now = Math.floor(Date.now() / 1000); - const resetWait = resetAt - now + 5; - if (resetWait > 0) { - return resetWait; - } - } - return 60; - } - - return 0; -} - -async function postDraftWithRetry(payload: { - scout_id: string; - title: string; - description: string; - reward_amount: number; - reward_currency: string; - required_stack: string[]; - test_file_content: string; -}) { - let lastError: unknown = null; - - for (let attempt = 0; attempt < 3; attempt++) { - try { - const response = await fetch(`${VIBEWORK_API_URL}/scout/draft`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${SCOUT_API_KEY}` - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const bodyText = await response.text(); - throw new Error(`Draft API failed (HTTP ${response.status}): ${bodyText}`); - } - - return await response.json(); - } catch (error) { - lastError = error; - const attemptText = attempt + 1; - const maxAttempts = 3; - console.error(`Failed to draft bounty task (attempt ${attemptText}/${maxAttempts}):`, error); - if (attempt < maxAttempts - 1) { - await wait(Math.min(5 * (attempt + 1), 15)); - } - } - } - - console.error("Failed to draft bounty task after retries:", lastError); - return null; -} - -async function isIssueAlreadyDrafted(issueUrl: string) { - try { - const response = await fetch( - `${VIBEWORK_API_URL}/scout/issue-exists?issue_url=${encodeURIComponent(issueUrl)}`, - { - headers: { - "Authorization": `Bearer ${SCOUT_API_KEY}`, - }, - } - ); - - if (!response.ok) { - const bodyText = await response.text(); - console.warn(`Issue existence check failed (HTTP ${response.status}): ${bodyText}`); - return false; - } - - const result = await response.json() as { exists?: boolean }; - return Boolean(result.exists); - } catch (error) { - console.warn("Issue existence check error:", error); - return false; - } -} - -async function draftBountyTask(issueTitle: string, issueBody: string, issueUrl: string) { - const payload = { - scout_id: SCOUT_AGENT_ID, - ...buildDraftPayload(issueTitle, issueBody || "", issueUrl), - }; - - return postDraftWithRetry(payload); -} - -async function processIssue(owner: string, repo: string, issue: any) { - console.log(`Processing issue #${issue.number}: ${issue.title}`); - const issueUrl = issue.html_url; - - if (!issueUrl) { - console.log("Issue URL missing, skip."); - return; - } - - const alreadyDrafted = await isIssueAlreadyDrafted(issueUrl); - if (alreadyDrafted) { - console.log(`Issue already has draft task: ${issueUrl}, skipping.`); - return; - } - - // Check if we already commented - if (GITHUB_TOKEN) { - const comments = await octokit.issues.listComments({ - owner, - repo, - issue_number: issue.number, - }); - - const alreadyCommented = comments.data.some((c) => c.body?.includes("agent.wooo.work")); - if (alreadyCommented) { - console.log(`Already commented on #${issue.number}, skipping.`); - return; - } - } - - // Generate draft task on VibeWork - const draft = await draftBountyTask(issue.title, issue.body || "", issueUrl); - - if (!draft) { - console.log(`Failed to generate draft for #${issue.number}, skipping comment.`); - return; - } - - const commentBody = `Hi there! 👋 我們注意到這個 issue 被標記為需要社群協助。 - -**VibeWork** 是一個開源的 AI Agent 協定,目前已經連結了全球大量的自主 AI Agent。這些 Agent 可以為您**免費 (FREE)** 嘗試解決這個問題。 - -任何人類開發者或是 AI Agent 都可以認領這個 issue,並透過我們的 MCP 伺服器自動提交 Pull Request: -\`\`\`bash -npx -y @agent-bounty/mcp-server --endpoint https://agent.wooo.work -\`\`\` - -🔗 [點此追蹤 AI 的解題進度](${draft.checkout_url}) (Task ID: \`${draft.task_id}\`) - -*(備註:我們是一個開源的防詐欺 Bounty 協定,旨在幫助開源專案維護者更快地利用 AI 解決問題。如果您不希望我們在此張貼此資訊,請隨時告知!)* -`; - - if (GITHUB_TOKEN) { - const isAllowedToComment = SCOUT_COMMENT_REPOS.includes("*") || SCOUT_COMMENT_REPOS.includes(`${owner}/${repo}`.toLowerCase()); - - if (!isAllowedToComment) { - console.log(`[SILENT MODE] Successfully generated draft task for #${issue.number}, but skipping GitHub comment (repo not in SCOUT_COMMENT_REPOS).`); - return; - } - - try { - await octokit.issues.createComment({ - owner, - repo, - issue_number: issue.number, - body: commentBody, - }); - console.log(`Successfully commented on #${issue.number}`); - await wait(SCOUT_COMMENT_DELAY_SECONDS); - } catch (error) { - const backoffSeconds = getGitHubBackoffSeconds(error); - if (backoffSeconds > 0) { - console.warn(`GitHub API rate limited; sleeping ${backoffSeconds}s before continuing.`); - await wait(backoffSeconds); - } - console.error(`Failed to comment on #${issue.number}:`, error); - } - } else { - console.log(`[DRY RUN] Would have posted scout comment for #${issue.number}: ${draft.status}`); - } -} - -async function scanRepositories() { - console.log("Starting GitHub scan..."); - - for (const target of TARGET_REPOS) { - try { - const issues = await octokit.issues.listForRepo({ - owner: target.owner, - repo: target.repo, - state: "open", - ...(SCOUT_ISSUE_LABELS.length > 0 ? { labels: SCOUT_ISSUE_LABELS.join(",") } : {}), - per_page: SCOUT_PER_PAGE - }); - - console.log(`Found ${issues.data.length} open issues in ${target.owner}/${target.repo}`); - - const issueCandidates = issues.data.slice(0, SCOUT_MAX_ISSUES_PER_SCAN); - for (const issue of issueCandidates) { - if (!issue.pull_request) { // Ignore PRs - await processIssue(target.owner, target.repo, issue); - } - } - - if (issues.data.length > issueCandidates.length) { - console.log(`Limited scan to first ${issueCandidates.length} issues in ${target.owner}/${target.repo}`); - } - } catch (error) { - const backoffSeconds = getGitHubBackoffSeconds(error); - if (backoffSeconds > 0) { - console.warn(`GitHub API rate limited on ${target.owner}/${target.repo}; sleeping ${backoffSeconds}s and skip remaining repos.`); - await wait(backoffSeconds); - return; - } - console.error(`Error scanning ${target.owner}/${target.repo}:`, error); - } - } -} - -async function bootstrap() { - if (!SCOUT_ENABLED) { - console.log("SCOUT_ENABLED is false. Scout bot is disabled."); - return; - } - - // Delay first scan a bit so main web API is ready after startup. - await wait(10); - await scanRepositories(); -} - -bootstrap(); - -if (SCOUT_ENABLED) { - // Default: every 1 minute for phase-1 high-velocity inbound traffic - cron.schedule(SCOUT_CRON_EXPRESSION, () => { - console.log(`Running scheduled GitHub scan (${SCOUT_CRON_EXPRESSION})...`); - scanRepositories(); - }); -} else { - console.log("Cron schedule is disabled because SCOUT_ENABLED=false."); -} - -console.log("VibeWork Scout Bot started and scheduled."); diff --git a/apps/scout-bot/tsconfig.json b/apps/scout-bot/tsconfig.json deleted file mode 100644 index e5371c5..0000000 --- a/apps/scout-bot/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": "./src" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} diff --git a/docker-compose.scout.yml b/docker-compose.scout.yml deleted file mode 100644 index ce49f52..0000000 --- a/docker-compose.scout.yml +++ /dev/null @@ -1,24 +0,0 @@ -version: '3.8' - -services: - scout-bot: - build: - context: . - dockerfile: apps/scout-bot/Dockerfile - container_name: agent_bounty_scout_bot - restart: unless-stopped - environment: - - NODE_ENV=production - # - GITHUB_TOKEN=your_token_here - - VIBEWORK_API_URL=http://agent_bounty_web:3000/api - - SCOUT_API_KEY=${SCOUT_API_KEY:-dev_scout_key} - - SCOUT_AGENT_ID=scout_official_1 - - SCOUT_ENABLED=${SCOUT_ENABLED:-false} - - SCOUT_PER_PAGE=${SCOUT_PER_PAGE:-80} - - SCOUT_MAX_ISSUES_PER_SCAN=${SCOUT_MAX_ISSUES_PER_SCAN:-30} - networks: - - agent-bounty-network - -networks: - agent-bounty-network: - external: true diff --git a/package.json b/package.json index 660d608..382eef9 100644 --- a/package.json +++ b/package.json @@ -12,5 +12,9 @@ "packageManager": "pnpm@10.32.1", "devDependencies": { "vitest": "^4.1.8" + }, + "dependencies": { + "dotenv": "^17.4.2", + "openai": "^6.42.0" } }