/** * dispatch-webhook skill * 發送 Webhook 到 VPS,處理重試與錯誤 */ interface DispatchInput { target: 'vps-a' | 'vps-b'; payload: Record; webhookUrl: string; webhookToken: string; timeoutMs?: number; retries?: number; } const ALLOWED_TARGETS = new Set(['vps-a', 'vps-b']); function clampInt(value: unknown, min: number, max: number, fallback: number): number { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, Math.floor(n))); } function sanitizeTaskId(taskId: unknown): string { if (taskId == null) return ''; return String(taskId).replace(/[\r\n]/g, '').slice(0, 128); } function validateInput(raw: any): DispatchInput { const input = raw as Partial; if (!input || typeof input !== 'object') { throw new Error('dispatch-webhook 輸入格式錯誤:必須提供 input 物件'); } if (!input.target || !ALLOWED_TARGETS.has(input.target as DispatchInput['target'])) { throw new Error('dispatch-webhook 參數錯誤:target 必須是 vps-a 或 vps-b'); } if (!input.webhookUrl || typeof input.webhookUrl !== 'string') { throw new Error(`${input.target.toUpperCase()} Webhook URL 未設定。請在環境變數設定 VPS_A_WEBHOOK_URL 或 VPS_B_WEBHOOK_URL`); } let parsedUrl: URL; try { parsedUrl = new URL(input.webhookUrl); } catch { throw new Error('Webhook URL 格式錯誤,請提供有效的 http/https URL'); } if (!['http:', 'https:'].includes(parsedUrl.protocol)) { throw new Error('Webhook URL 協定不支援,僅允許 http 或 https'); } if (!input.webhookToken || typeof input.webhookToken !== 'string') { throw new Error(`${input.target.toUpperCase()} Webhook Token 未設定`); } if (!input.payload || typeof input.payload !== 'object' || Array.isArray(input.payload)) { throw new Error('dispatch-webhook 參數錯誤:payload 必須是 JSON 物件'); } return input as DispatchInput; } async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } export async function handler(ctx: any) { const input = validateInput(ctx.input || ctx.params); const timeoutMs = clampInt(input.timeoutMs, 1000, 120000, 30000); const maxRetries = clampInt(input.retries, 1, 5, 3); const taskIdHeader = sanitizeTaskId(input.payload.task_id); let lastError: Error | null = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetchWithTimeout( input.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${input.webhookToken}`, 'X-OpenClaw-Version': '1.0', 'X-OpenClaw-Task-Id': taskIdHeader, }, body: JSON.stringify(input.payload), }, timeoutMs ); if (response.status === 401) { throw new Error('Webhook Token 驗證失敗(401),請確認 VPS_WEBHOOK_TOKEN 設定正確'); } if (!response.ok && response.status !== 202) { const body = await response.text().catch(() => ''); throw new Error(`VPS 回應錯誤 ${response.status}:${body.slice(0, 200)}`); } const result = await response.json().catch(() => ({ status: 'accepted' })); return { success: true, status: result.status || 'accepted', task_id: input.payload.task_id, target: input.target, attempt, }; } catch (err: any) { lastError = err instanceof Error ? err : new Error(String(err)); if (lastError.message.includes('401') || lastError.message.includes('Token')) { break; // 認證錯誤不重試 } if (attempt < maxRetries) { await new Promise(r => setTimeout(r, 5000 * attempt)); // 指數退避 } } } throw lastError || new Error('Webhook 發送失敗'); }