feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

493
lib/gitea-webhook.js Normal file
View File

@@ -0,0 +1,493 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { parseWorkflowMarker } = require('./gitea-workflow-codex');
const DEFAULT_MAX_BODY_BYTES = 2 * 1024 * 1024;
const DEFAULT_BOT_LOGIN = 'ccweb-bot';
function cloneJson(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function safeString(value) {
return typeof value === 'string' ? value.trim() : '';
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function timingSafeHexEqual(expectedHex, receivedHex) {
const expected = Buffer.from(String(expectedHex || '').toLowerCase(), 'hex');
const received = Buffer.from(String(receivedHex || '').toLowerCase(), 'hex');
return expected.length > 0
&& expected.length === received.length
&& crypto.timingSafeEqual(expected, received);
}
function normalizeSignature(value) {
const raw = safeString(value).replace(/^sha256[=:]/i, '').trim();
return /^[0-9a-f]{64}$/i.test(raw) ? raw.toLowerCase() : '';
}
function verifyWebhookSignature(rawBody, signature, secret) {
const configuredSecret = String(secret || '');
if (!configuredSecret) return false;
const expected = crypto.createHmac('sha256', configuredSecret)
.update(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || '')))
.digest('hex');
return timingSafeHexEqual(expected, normalizeSignature(signature));
}
function getHeader(headers, names) {
const source = headers || {};
for (const name of names) {
const value = source[name] ?? source[name.toLowerCase()];
if (Array.isArray(value)) return safeString(value[0]);
if (value !== undefined) return safeString(value);
}
const lowerNames = new Set(names.map((name) => name.toLowerCase()));
for (const [name, value] of Object.entries(source)) {
if (!lowerNames.has(name.toLowerCase())) continue;
return Array.isArray(value) ? safeString(value[0]) : safeString(value);
}
return '';
}
function atomicWriteJson(filePath, value) {
const target = path.resolve(filePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
const tempPath = `${target}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
try {
fs.renameSync(tempPath, target);
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
}
}
class DeliveryStore {
constructor(options = {}) {
this.filePath = options.filePath ? path.resolve(options.filePath) : '';
this.maxEntries = Number.isSafeInteger(options.maxEntries) && options.maxEntries > 0
? options.maxEntries : 10_000;
this.entries = new Map();
this.load();
}
load() {
if (!this.filePath || !fs.existsSync(this.filePath)) return;
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
const source = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed.entries || parsed : {};
for (const [key, value] of Object.entries(source)) {
if (key && value && typeof value === 'object') this.entries.set(key, value);
}
} catch {
// 损坏的去重文件不应阻止服务启动;新请求会覆盖为可恢复的合法文件。
this.entries.clear();
}
}
get(key) {
const value = this.entries.get(String(key));
return value ? cloneJson(value) : null;
}
has(key) {
return this.entries.has(String(key));
}
put(key, value) {
const normalizedKey = String(key);
this.entries.set(normalizedKey, cloneJson(value));
while (this.entries.size > this.maxEntries) {
const oldest = this.entries.keys().next().value;
this.entries.delete(oldest);
}
if (this.filePath) {
atomicWriteJson(this.filePath, {
schemaVersion: 1,
entries: Object.fromEntries(this.entries),
});
}
}
claimDelivery(key, value = {}) {
const existing = this.get(key);
if (existing) return { duplicate: true, record: existing };
this.put(key, value);
return { duplicate: false, record: this.get(key) };
}
getDelivery(key) { return this.get(key); }
}
function repositoryFromPayload(payload) {
const repository = payload?.repository || payload?.repo || {};
const owner = repository.owner || repository.organization || {};
const ownerName = typeof owner === 'string'
? safeString(owner)
: safeString(owner.login || owner.username || owner.name || repository.owner_name);
const repoName = safeString(repository.name || repository.repo_name);
const fullName = safeString(repository.full_name || (ownerName && repoName ? `${ownerName}/${repoName}` : ''));
const parts = fullName.split('/').filter(Boolean);
const normalizedOwner = ownerName || parts.slice(0, -1).join('/');
const normalizedRepo = repoName || parts.at(-1) || '';
if (!normalizedOwner || !normalizedRepo || normalizedOwner.includes('/') || normalizedRepo.includes('/')) {
return null;
}
return {
owner: normalizedOwner,
name: normalizedRepo,
fullName: `${normalizedOwner}/${normalizedRepo}`,
cloneUrl: safeString(repository.clone_url || repository.cloneUrl || repository.html_url || ''),
defaultBranch: safeString(repository.default_branch || repository.defaultBranch || ''),
private: repository.private === true,
};
}
function resourceFromPayload(payload, eventName) {
const issue = payload?.issue || payload?.pull_request || payload?.pullRequest || {};
const pullRequest = payload?.pull_request || payload?.pullRequest || issue?.pull_request;
const number = Number(issue.number || payload?.number || pullRequest?.number);
if (!Number.isSafeInteger(number) || number <= 0) return null;
const isPullRequest = eventName.includes('pull_request')
|| !!issue.pull_request
|| !!payload?.pull_request
|| !!payload?.pullRequest;
const kind = isPullRequest ? 'pull_request' : 'issue';
return {
kind,
number,
key: `${kind}:${number}`,
title: safeString(issue.title || pullRequest?.title),
baseRef: safeString(pullRequest?.base?.ref || pullRequest?.base_ref),
headRef: safeString(pullRequest?.head?.ref || pullRequest?.head_ref),
headRepo: safeString(pullRequest?.head?.repo?.full_name || pullRequest?.head_repo),
};
}
function commentFromPayload(payload) {
const comment = payload?.comment || payload?.review?.comment || {};
const body = typeof comment.body === 'string'
? comment.body
: (typeof payload?.comment_body === 'string' ? payload.comment_body : '');
if (!body) return null;
const author = comment.user || comment.author || payload?.sender || payload?.user || {};
return {
id: String(comment.id || comment.number || payload?.comment_id || '').trim(),
body,
author: {
id: author.id ?? null,
login: safeString(author.login || author.username || author.name),
type: safeString(author.type),
},
createdAt: safeString(comment.created_at || comment.createdAt || payload?.created_at),
updatedAt: safeString(comment.updated_at || comment.updatedAt || payload?.updated_at),
};
}
function normalizeEventName(headerValue, payload) {
const header = safeString(headerValue).toLowerCase().replace(/[-\s]/g, '_');
const action = safeString(payload?.action).toLowerCase();
if (['issue_comment', 'issues_comment', 'pull_request_comment', 'pullrequest_comment'].includes(header)) {
return header.includes('pull') ? 'pull_request_comment' : 'issue_comment';
}
// 某些 Gitea 版本把普通 Issue 评论复用为 issues + comment 字段;
// 只有存在 comment 时才归一化,避免新建 Issue 被误触发。
if (header === 'issues' && (action === 'comment' || payload?.comment)) return 'issue_comment';
if (header === 'pull_request' && (['comment', 'review_comment'].includes(action) || payload?.comment)) return 'pull_request_comment';
return header;
}
function parseBotMention(body, botLogin = DEFAULT_BOT_LOGIN) {
const login = safeString(botLogin) || DEFAULT_BOT_LOGIN;
const mention = new RegExp(`(^|[^\\w@])@${escapeRegExp(login)}(?=$|[^\\w-])`, 'i');
const match = mention.exec(String(body || ''));
if (!match) return null;
const before = String(body).slice(0, match.index);
const after = String(body).slice(match.index + match[0].length);
const instruction = `${before}${after}`
.replace(/^[\s:,,、-]+/, '')
.replace(/[\s]+$/, '')
.trim();
return {
botLogin: login,
mention: `@${login}`,
instruction,
index: match.index + match[1].length,
};
}
function isBotAuthor(author, botIdentity = {}) {
const login = safeString(author?.login).toLowerCase();
const configuredLogin = safeString(botIdentity.login || botIdentity.username || DEFAULT_BOT_LOGIN).toLowerCase();
if (login && configuredLogin && login === configuredLogin) return true;
if (botIdentity.id !== undefined && botIdentity.id !== null && author?.id !== null && author?.id !== undefined) {
return String(botIdentity.id) === String(author.id);
}
return false;
}
function normalizeWebhookEvent({ payload, headers = {}, instanceId = 'default', botIdentity }) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return { ignored: true, reason: 'invalid_payload' };
}
const eventName = normalizeEventName(getHeader(headers, ['x-gitea-event', 'x-event-key', 'x-github-event']), payload);
if (!['issue_comment', 'pull_request_comment'].includes(eventName)) {
return { ignored: true, reason: 'unsupported_event', eventName };
}
const comment = commentFromPayload(payload);
if (!comment) return { ignored: true, reason: 'not_comment', eventName };
// 带完整关联标识的 cc-web 回帖即使 Gitea 版本未携带作者字段,也不得再次触发。
if (parseWorkflowMarker(comment.body)) {
return { ignored: true, reason: 'workflow_marker_comment', eventName, commentId: comment.id || null };
}
if (isBotAuthor(comment.author, botIdentity)) {
return { ignored: true, reason: 'bot_self_comment', eventName, commentId: comment.id || null };
}
const mention = parseBotMention(comment.body, botIdentity?.login || DEFAULT_BOT_LOGIN);
if (!mention) return { ignored: true, reason: 'missing_mention', eventName, commentId: comment.id || null };
const repository = repositoryFromPayload(payload);
if (!repository) return { ignored: true, reason: 'repository_unresolved', eventName, commentId: comment.id || null };
const resource = resourceFromPayload(payload, eventName);
if (!resource) return { ignored: true, reason: 'resource_unresolved', eventName, commentId: comment.id || null };
const sender = payload.sender || payload.user || null;
return {
ignored: false,
eventName,
eventType: eventName,
instanceId: safeString(instanceId) || 'default',
deliveryId: getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery']) || safeString(payload.delivery_id),
repository,
repo: repository,
resource,
refs: { base: resource.baseRef || null, head: resource.headRef || null },
comment,
mention,
sender,
actor: {
login: safeString(sender?.login || sender?.username || sender?.name),
id: sender?.id ?? null,
isBot: sender?.is_bot === true || String(sender?.type || '').toLowerCase() === 'bot',
},
receivedAt: new Date().toISOString(),
};
}
function createTaskId(deliveryKey) {
return `gitea-task-${crypto.createHash('sha256').update(String(deliveryKey)).digest('hex').slice(0, 24)}`;
}
function readRawBody(req, maxBytes = DEFAULT_MAX_BODY_BYTES) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
reject(error);
};
req.on('data', (chunk) => {
if (settled) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.length;
if (total > maxBytes) {
fail(Object.assign(new Error('Webhook 请求体过大。'), { code: 'body_too_large' }));
try { req.destroy(); } catch {}
return;
}
chunks.push(buffer);
});
req.on('end', () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks));
});
req.on('error', fail);
});
}
function readDeliveryRecord(store, key) {
if (typeof store?.get === 'function') return store.get(key);
if (typeof store?.getDelivery === 'function') return store.getDelivery(key);
return null;
}
function claimDeliveryRecord(store, key, record) {
if (typeof store?.claimDelivery === 'function') {
return store.claimDelivery(key, record);
}
if (typeof store?.put === 'function') {
store.put(key, record);
return { duplicate: false, record };
}
throw new TypeError('deliveryStore 必须提供 get/put 或 getDelivery/claimDelivery。');
}
function createGiteaWebhookReceiver(options = {}) {
let secret = options.secret ?? process.env.CC_WEB_GITEA_WEBHOOK_SECRET ?? '';
let botToken = options.botToken ?? '';
const instanceId = safeString(options.instanceId || process.env.CC_WEB_GITEA_INSTANCE_ID || process.env.GITEA_INSTANCE_ID) || 'default';
const botIdentity = {
login: safeString(options.botLogin || process.env.CC_WEB_GITEA_BOT_LOGIN || process.env.GITEA_BOT_LOGIN) || DEFAULT_BOT_LOGIN,
id: options.botId ?? process.env.CC_WEB_GITEA_BOT_ID ?? process.env.GITEA_BOT_ID ?? null,
};
const maxBodyBytes = Number.isSafeInteger(options.maxBodyBytes) && options.maxBodyBytes > 0
? options.maxBodyBytes : DEFAULT_MAX_BODY_BYTES;
const deliveryStore = options.deliveryStore || new DeliveryStore({
filePath: options.deliveryFile || process.env.CC_WEB_GITEA_DELIVERY_FILE,
});
const onTask = typeof options.onTask === 'function' ? options.onTask : async () => {};
const inflight = new Set();
async function processWebhook({ headers = {}, rawBody }) {
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''));
if (options.requireBotToken && !String(botToken || '').trim()) {
return { statusCode: 503, payload: { ok: false, error: 'gitea_workflow_not_configured' } };
}
const signature = getHeader(headers, ['x-gitea-signature', 'x-hub-signature-256', 'x-signature']);
if (secret && !verifyWebhookSignature(body, signature, secret)) {
return { statusCode: 401, payload: { ok: false, error: 'invalid_signature' } };
}
let payload;
try {
payload = JSON.parse(body.toString('utf8'));
} catch {
return { statusCode: 400, payload: { ok: false, error: 'invalid_json' } };
}
const deliveryId = getHeader(headers, ['x-gitea-delivery', 'x-delivery-id', 'x-github-delivery'])
|| safeString(payload.delivery_id);
if (!deliveryId) return { statusCode: 400, payload: { ok: false, error: 'missing_delivery_id' } };
const deliveryKey = `${instanceId}:${deliveryId}`;
const existing = readDeliveryRecord(deliveryStore, deliveryKey);
if (existing) {
return {
statusCode: 200,
payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: existing.taskId || null, reason: existing.reason || null },
record: existing,
};
}
const event = normalizeWebhookEvent({ payload, headers, instanceId, botIdentity });
event.deliveryId = deliveryId;
const record = {
deliveryKey,
deliveryId,
instanceId,
taskId: event.ignored ? null : createTaskId(deliveryKey),
status: event.ignored ? 'ignored' : 'accepted',
reason: event.reason || null,
eventName: event.eventName || null,
receivedAt: event.receivedAt || new Date().toISOString(),
};
const claimed = claimDeliveryRecord(deliveryStore, deliveryKey, record);
if (claimed?.duplicate) {
const duplicateRecord = claimed.record || readDeliveryRecord(deliveryStore, deliveryKey) || record;
return {
statusCode: 200,
payload: { ok: true, duplicate: true, status: 'duplicate', deliveryId, taskId: duplicateRecord.taskId || null },
record: duplicateRecord,
};
}
if (event.ignored) {
return { statusCode: 200, payload: { ok: true, ignored: true, status: 'ignored', reason: event.reason }, record, event };
}
const task = {
taskId: record.taskId,
deliveryKey,
deliveryId,
instanceId,
event,
receivedAt: record.receivedAt,
};
// Webhook 先快速确认,工作区 clone/fetch 等副作用由上层异步处理。
const taskPromise = Promise.resolve().then(() => onTask(cloneJson(task))).catch((error) => {
if (typeof options.onTaskError === 'function') {
try { options.onTaskError(error, task); } catch {}
}
}).finally(() => inflight.delete(taskPromise));
inflight.add(taskPromise);
return {
statusCode: 202,
payload: { ok: true, accepted: true, state: 'queued', status: 'accepted', deliveryId, taskId: record.taskId },
record,
event,
task,
};
}
async function handle(req, res) {
let result;
try {
const rawBody = await readRawBody(req, maxBodyBytes);
result = await processWebhook({ headers: req.headers || {}, rawBody });
} catch (error) {
const statusCode = error?.code === 'body_too_large' ? 413 : 400;
result = { statusCode, payload: { ok: false, error: error?.code || 'invalid_request' } };
}
if (!res.headersSent) {
res.writeHead(result.statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-cache',
});
res.end(JSON.stringify(result.payload));
}
return result;
}
async function drain() {
await Promise.all(Array.from(inflight));
}
function updateConfig(next = {}) {
if (Object.prototype.hasOwnProperty.call(next, 'secret')) secret = String(next.secret || '');
if (Object.prototype.hasOwnProperty.call(next, 'botToken')) botToken = String(next.botToken || '');
if (Object.prototype.hasOwnProperty.call(next, 'botLogin')) {
botIdentity.login = safeString(next.botLogin) || DEFAULT_BOT_LOGIN;
}
if (Object.prototype.hasOwnProperty.call(next, 'botId')) botIdentity.id = next.botId ?? null;
return {
webhookSecretConfigured: Boolean(secret),
botTokenConfigured: Boolean(botToken),
botLogin: botIdentity.login,
botId: botIdentity.id,
};
}
return { handle, processWebhook, drain, updateConfig, deliveryStore, instanceId, botIdentity };
}
function createGiteaWebhookRoute(receiver, options = {}) {
if (!receiver || typeof receiver.handle !== 'function') {
throw new TypeError('receiver.handle 必须是函数。');
}
const endpoint = String(options.path || '/api/gitea/webhook');
return function handleGiteaWebhookRoute(req, res, urlLike) {
let pathname = typeof urlLike === 'string' ? urlLike : urlLike?.pathname;
if (!pathname) {
try { pathname = new URL(req.url || '/', 'http://localhost').pathname; } catch { pathname = ''; }
}
if (req.method !== 'POST' || pathname !== endpoint) return false;
receiver.handle(req, res);
return true;
};
}
module.exports = {
DEFAULT_MAX_BODY_BYTES,
DeliveryStore,
createDeliveryStore: (options) => new DeliveryStore(options),
createGiteaWebhookRoute,
createGiteaWebhookReceiver,
createTaskId,
normalizeWebhookEvent,
parseBotMention,
verifyWebhookSignature,
};