Files
openclaw-skill/skills/dispatch-webhook/handler.ts

131 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* dispatch-webhook skill
* 發送 Webhook 到 VPS處理重試與錯誤
*/
interface DispatchInput {
target: 'vps-a' | 'vps-b';
payload: Record<string, unknown>;
webhookUrl: string;
webhookToken: string;
timeoutMs?: number;
retries?: number;
}
const ALLOWED_TARGETS = new Set<DispatchInput['target']>(['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<DispatchInput>;
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<Response> {
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 發送失敗');
}