131 lines
4.1 KiB
TypeScript
131 lines
4.1 KiB
TypeScript
/**
|
||
* 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 發送失敗');
|
||
}
|