fix traffic observability, tg alerting robustness and prod compose ports
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
Some checks failed
Deploy to 110 WOOO Server / deploy (push) Failing after 7s
This commit is contained in:
@@ -190,6 +190,9 @@ export async function GET(request: NextRequest) {
|
||||
totalRows,
|
||||
latestEvents,
|
||||
judgeCompleteRows,
|
||||
demandPoolCount,
|
||||
demandPoolNewRows,
|
||||
demandPoolScopeStats,
|
||||
] = await Promise.all([
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["action"],
|
||||
@@ -247,8 +250,48 @@ export async function GET(request: NextRequest) {
|
||||
metadata: true,
|
||||
},
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
created_at: { gte: since },
|
||||
},
|
||||
}),
|
||||
prisma.task.aggregate({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
_avg: {
|
||||
scope_clarity_score: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const demandSupply = {
|
||||
open_task_count: demandPoolCount,
|
||||
new_open_tasks_in_window: demandPoolNewRows,
|
||||
avg_scope_clarity: demandPoolScopeStats._avg.scope_clarity_score,
|
||||
};
|
||||
|
||||
let capturedPayoutCount = 0;
|
||||
let releasedPayoutCount = 0;
|
||||
try {
|
||||
@@ -429,9 +472,12 @@ export async function GET(request: NextRequest) {
|
||||
latest_surface: string;
|
||||
latest_source_ip: string;
|
||||
latest_user_agent: string;
|
||||
latest_task_id: string;
|
||||
latest_response_status: number | null;
|
||||
latest_response_summary: string;
|
||||
latest_reason: string;
|
||||
latest_payload_summary: string;
|
||||
latest_request_id: string;
|
||||
latest_created_at_ms: number;
|
||||
}> = new Map();
|
||||
|
||||
@@ -471,13 +517,34 @@ export async function GET(request: NextRequest) {
|
||||
latest_surface: normalizedSurface,
|
||||
latest_source_ip: normalizedIp,
|
||||
latest_user_agent: normalizedUa,
|
||||
latest_task_id: taskId,
|
||||
latest_response_status: responseStatus,
|
||||
latest_response_summary: responseSummary,
|
||||
latest_reason: event.reason || "unknown",
|
||||
latest_payload_summary:
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
latest_request_id:
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown",
|
||||
latest_created_at_ms: eventAt,
|
||||
});
|
||||
} else {
|
||||
existingActorActivity.events += 1;
|
||||
|
||||
if (eventAt > existingActorActivity.latest_created_at_ms) {
|
||||
existingActorActivity.latest_action = event.action;
|
||||
existingActorActivity.latest_surface = normalizedSurface;
|
||||
existingActorActivity.latest_source_ip = normalizedIp;
|
||||
existingActorActivity.latest_user_agent = normalizedUa;
|
||||
existingActorActivity.latest_task_id = taskId;
|
||||
existingActorActivity.latest_response_status = responseStatus;
|
||||
existingActorActivity.latest_response_summary = responseSummary;
|
||||
existingActorActivity.latest_reason = event.reason || "unknown";
|
||||
existingActorActivity.latest_payload_summary =
|
||||
typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown";
|
||||
existingActorActivity.latest_request_id =
|
||||
typeof metadata?.request_id === "string" ? metadata.request_id : "unknown";
|
||||
existingActorActivity.latest_created_at_ms = eventAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -566,6 +633,7 @@ export async function GET(request: NextRequest) {
|
||||
period_minutes: minutes,
|
||||
total_events: totalRows,
|
||||
action_summary: actionSummary,
|
||||
demand_supply: demandSupply,
|
||||
channel_summary: channelSummary,
|
||||
actor_summary: actorSummary,
|
||||
external_actor_summary: externalActorSummary,
|
||||
|
||||
@@ -98,9 +98,12 @@ type ExternalActorActivity = {
|
||||
latestSurface: string;
|
||||
latestSourceIp: string;
|
||||
latestUserAgent: string;
|
||||
latestTaskId: string;
|
||||
latestResponseStatus: number | null;
|
||||
latestResponseSummary: string;
|
||||
latestReason: string;
|
||||
latestPayloadSummary: string;
|
||||
latestRequestId: string;
|
||||
latestCreatedAt: number;
|
||||
};
|
||||
|
||||
@@ -116,6 +119,9 @@ async function getTrafficSummary(minutes: number) {
|
||||
judgeCompleteRows,
|
||||
capturedPayoutCount,
|
||||
releasedPayoutCount,
|
||||
demandPoolCount,
|
||||
demandPoolNewRows,
|
||||
demandPoolScopeStats,
|
||||
] = await Promise.all([
|
||||
prisma.auditEvent.groupBy({
|
||||
by: ["action"],
|
||||
@@ -187,8 +193,48 @@ async function getTrafficSummary(minutes: number) {
|
||||
response_status: "SUCCESS",
|
||||
},
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.task.count({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
created_at: { gte: since },
|
||||
},
|
||||
}),
|
||||
prisma.task.aggregate({
|
||||
where: {
|
||||
status: "OPEN",
|
||||
title: {
|
||||
not: {
|
||||
startsWith: "GitHub Issue:",
|
||||
},
|
||||
},
|
||||
},
|
||||
_avg: {
|
||||
scope_clarity_score: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const demandSupply = {
|
||||
openTaskCount: demandPoolCount,
|
||||
newOpenTasksInWindow: demandPoolNewRows,
|
||||
avgScopeClarity: demandPoolScopeStats._avg.scope_clarity_score,
|
||||
};
|
||||
|
||||
const actionSummary = Object.fromEntries(summaryRows.map((row) => [row.action, row._count._all]));
|
||||
const actorSummary = Object.fromEntries(actorSummaryRows.map((row) => [row.actorType, row._count._all]));
|
||||
const externalActorSummary = externalActorRows
|
||||
@@ -228,6 +274,8 @@ async function getTrafficSummary(minutes: number) {
|
||||
level: metadata?.level,
|
||||
response_status: typeof metadata?.response_status === "number" ? metadata.response_status : null,
|
||||
response_summary: typeof metadata?.response_summary === "string" ? metadata.response_summary : "unknown",
|
||||
payload_summary: typeof metadata?.payload_summary === "string" ? metadata.payload_summary : "unknown",
|
||||
request_id: typeof metadata?.request_id === "string" ? metadata.request_id : "n/a",
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
@@ -267,6 +315,9 @@ async function getTrafficSummary(minutes: number) {
|
||||
.filter((event) => event.action.includes("ERROR"))
|
||||
.map((event) => event.action);
|
||||
|
||||
const demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
|
||||
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300";
|
||||
|
||||
const externalActorActivityMap = new Map<string, ExternalActorActivity>();
|
||||
for (const event of recentEvents) {
|
||||
if (!event.action.startsWith("EXTERNAL_")) {
|
||||
@@ -293,6 +344,15 @@ async function getTrafficSummary(minutes: number) {
|
||||
const value = typeof metadata?.user_agent === "string" ? metadata.user_agent : "unknown";
|
||||
return value.trim().length > 0 ? value.trim() : "unknown";
|
||||
})();
|
||||
const taskId = (() => {
|
||||
const value =
|
||||
typeof event.entityId === "string"
|
||||
? event.entityId
|
||||
: typeof metadata?.task_id === "string"
|
||||
? metadata.task_id
|
||||
: "-";
|
||||
return value;
|
||||
})();
|
||||
|
||||
externalActorActivityMap.set(actorId, {
|
||||
actorId,
|
||||
@@ -301,16 +361,54 @@ async function getTrafficSummary(minutes: number) {
|
||||
latestSurface: String(event.surface || "unknown"),
|
||||
latestSourceIp: normalizedIp,
|
||||
latestUserAgent: normalizedUa,
|
||||
latestTaskId: taskId,
|
||||
latestResponseStatus:
|
||||
typeof event.response_status === "number" ? event.response_status : null,
|
||||
latestResponseSummary: typeof event.response_summary === "string"
|
||||
? event.response_summary
|
||||
: "unknown",
|
||||
latestReason: typeof event.reason === "string" ? event.reason : "unknown",
|
||||
latestPayloadSummary:
|
||||
typeof event.payload_summary === "string" ? event.payload_summary : "unknown",
|
||||
latestRequestId: typeof event.request_id === "string" ? event.request_id : "n/a",
|
||||
latestCreatedAt: event.createdAt.getTime(),
|
||||
});
|
||||
} else {
|
||||
existing.events += 1;
|
||||
if (event.createdAt.getTime() > existing.latestCreatedAt) {
|
||||
const metadata = asRecordJson(event.metadata);
|
||||
const taskId = (() => {
|
||||
const value =
|
||||
typeof event.entityId === "string"
|
||||
? event.entityId
|
||||
: typeof metadata?.task_id === "string"
|
||||
? metadata.task_id
|
||||
: "-";
|
||||
return value;
|
||||
})();
|
||||
const normalizedIp = (() => {
|
||||
const value = typeof metadata?.source_ip === "string" ? metadata.source_ip : "unknown";
|
||||
return value.trim().length > 0 ? value.trim() : "unknown";
|
||||
})();
|
||||
const normalizedUa = (() => {
|
||||
const value = typeof metadata?.user_agent === "string" ? metadata.user_agent : "unknown";
|
||||
return value.trim().length > 0 ? value.trim() : "unknown";
|
||||
})();
|
||||
existing.latestAction = event.action;
|
||||
existing.latestSurface = String(event.surface || "unknown");
|
||||
existing.latestSourceIp = normalizedIp;
|
||||
existing.latestUserAgent = normalizedUa;
|
||||
existing.latestTaskId = taskId;
|
||||
existing.latestResponseStatus =
|
||||
typeof event.response_status === "number" ? event.response_status : null;
|
||||
existing.latestResponseSummary =
|
||||
typeof event.response_summary === "string" ? event.response_summary : "unknown";
|
||||
existing.latestReason = typeof event.reason === "string" ? event.reason : "unknown";
|
||||
existing.latestPayloadSummary =
|
||||
typeof event.payload_summary === "string" ? event.payload_summary : "unknown";
|
||||
existing.latestRequestId = typeof event.request_id === "string" ? event.request_id : "n/a";
|
||||
existing.latestCreatedAt = event.createdAt.getTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,6 +440,7 @@ async function getTrafficSummary(minutes: number) {
|
||||
conversionRates,
|
||||
externalActorActivities,
|
||||
externalErrors,
|
||||
demandSupply,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -418,8 +517,11 @@ export default async function TrafficDashboard({
|
||||
}
|
||||
|
||||
const summary = await getTrafficSummary(minutes);
|
||||
const { conversionSummary, conversionRates } = summary;
|
||||
const { conversionSummary, conversionRates, demandSupply } = summary;
|
||||
const conversionHints = buildConversionTips(conversionRates, conversionSummary);
|
||||
const hasClaimStall = summary.externalEventTypes.some((event) => event.action === "EXTERNAL_FUNNEL_CLAIM_STALL");
|
||||
const demandHealthLabel = demandSupply.openTaskCount > 0 ? "有可接需求" : "無可接需求";
|
||||
const demandHealthTone = demandSupply.openTaskCount > 0 ? "text-emerald-300" : "text-amber-300";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-950 text-gray-100 p-8 font-sans">
|
||||
@@ -435,7 +537,7 @@ export default async function TrafficDashboard({
|
||||
觀測區間:最近 {summary.periodMinutes} 分鐘(外部事件=`EXTERNAL_*`)
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">總事件</div>
|
||||
<div className="text-3xl font-bold mt-2">{summary.totalEvents}</div>
|
||||
@@ -456,6 +558,35 @@ export default async function TrafficDashboard({
|
||||
<div className="text-gray-400 text-sm">外部提交</div>
|
||||
<div className="text-3xl font-bold mt-2 text-amber-300">{conversionSummary.submit_events}</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-4">
|
||||
<div className="text-gray-400 text-sm">需求池可接任務</div>
|
||||
<div className={`text-3xl font-bold mt-2 ${demandHealthTone}`}>{demandSupply.openTaskCount}</div>
|
||||
<div className="text-xs text-gray-400 mt-2">{demandHealthLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">需求方供給面(當前觀測)</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span>當前 OPEN 任務</span><span className="text-emerald-300">{demandSupply.openTaskCount}</span></div>
|
||||
<div className="flex justify-between"><span>最近 {summary.periodMinutes} 分鐘新上架</span><span className="text-emerald-300">{demandSupply.newOpenTasksInWindow}</span></div>
|
||||
<div className="flex justify-between"><span>平均需求清晰度</span><span className="text-emerald-300">{demandSupply.avgScopeClarity === null ? "n/a" : `${Number(demandSupply.avgScopeClarity).toFixed(2)}`}</span></div>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-gray-400">
|
||||
來源條件:Task.status = OPEN 且 title 不以 GitHub Issue: 開頭。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-2xl p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">外部未接案自動化排查</h2>
|
||||
<ul className="text-sm text-gray-200 space-y-2 list-disc list-inside">
|
||||
<li>1) 先確認需求池:若 <span className="text-emerald-300">{demandSupply.openTaskCount}</span> 筆,請先確認任務文案是否有可執行 step。</li>
|
||||
<li>2) 重試建議:再打 <span className="font-mono text-xs">/api/open-tasks</span> 3 次(每次間隔 30~60 秒)確認回傳一致。</li>
|
||||
<li>3) 直接驗證任務描述摘要,避免只用含「GitHub Issue:」字頭(現已過濾)。</li>
|
||||
<li>4) 若仍未有接單:將任務 reward、required_stack 與 developer_wallet 指引補齊為固定欄位範例。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
@@ -520,6 +651,16 @@ export default async function TrafficDashboard({
|
||||
<li>系統 PASS 才有收款機會:JUDGE_COMPLETE + overall_result=PASS,接著做 CAPTURE。</li>
|
||||
<li>你要追的是「曝光→接案→提交→PASS→收款」連續率,而不是單一事件數量。</li>
|
||||
</ol>
|
||||
{hasClaimStall && demandSupply.openTaskCount > 0 ? (
|
||||
<div className="mt-4 p-3 border border-amber-500/40 rounded-lg text-sm text-amber-200">
|
||||
本輪有 EXTERNAL_FUNNEL_CLAIM_STALL 告警。可直接採取:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>先取一筆最新 OPEN 任務,核對 `scope_clarity_score` 與 reward 欄位。</li>
|
||||
<li>發送 1 次固定模板 `claim_task` 試探,若失敗 403 則先修正 API Key/權限。</li>
|
||||
<li>若返回 200 仍無接案,調整任務文案再行發佈,讓外部判斷標準更明確。</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 text-sm text-gray-400">建議每 30 分鐘查看一次此漏斗,針對斷崖段落補任務內容或加標籤。</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -566,15 +707,17 @@ export default async function TrafficDashboard({
|
||||
<th className="text-left py-2">Actor</th>
|
||||
<th className="text-left py-2">事件</th>
|
||||
<th className="text-left py-2">最新行為</th>
|
||||
<th className="text-left py-2">任務</th>
|
||||
<th className="text-left py-2">來源 IP</th>
|
||||
<th className="text-left py-2">User-Agent</th>
|
||||
<th className="text-left py-2">Request-Id</th>
|
||||
<th className="text-left py-2">最新回應</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.externalActorActivities.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-gray-500 py-3">
|
||||
<td colSpan={8} className="text-gray-500 py-3">
|
||||
尚無可追蹤的外部 AGENT 行為,可能目前仍未有 AGENT 類型入口流量。
|
||||
</td>
|
||||
</tr>
|
||||
@@ -587,11 +730,14 @@ export default async function TrafficDashboard({
|
||||
<div className="text-gray-200">{actor.latestAction}</div>
|
||||
<div className="text-xs text-gray-500">{actor.latestSurface}</div>
|
||||
</td>
|
||||
<td className="py-2 text-gray-300">{actor.latestTaskId}</td>
|
||||
<td className="py-2 text-gray-300">{actor.latestSourceIp}</td>
|
||||
<td className="py-2 text-gray-300">{actor.latestUserAgent}</td>
|
||||
<td className="py-2 text-gray-300">{actor.latestRequestId}</td>
|
||||
<td className="py-2">
|
||||
<div className="text-gray-200">{actor.latestResponseStatus ?? "n/a"}</div>
|
||||
<div className="text-xs text-gray-500">{actor.latestResponseSummary}</div>
|
||||
<div className="text-xs text-gray-600">{actor.latestPayloadSummary}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
type RedisLike = {
|
||||
set(...args: any[]): Promise<string | null | undefined>;
|
||||
};
|
||||
|
||||
export const redis =
|
||||
const globalForRedis = global as unknown as { redis?: RedisLike };
|
||||
const configuredRedisUrl = process.env.REDIS_URL?.trim();
|
||||
|
||||
class InMemoryRedisDedupe implements RedisLike {
|
||||
private entries = new Map<string, number>();
|
||||
|
||||
private cleanup(now: number) {
|
||||
for (const [key, expiryAt] of this.entries) {
|
||||
if (expiryAt <= now) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async set(...args: any[]): Promise<string | null | undefined> {
|
||||
const key = args[0] as string;
|
||||
const mode = args[2] as string | undefined;
|
||||
const ttlSeconds = typeof args[3] === "number" ? args[3] : undefined;
|
||||
const condition = args[4] as string | undefined;
|
||||
|
||||
const now = Date.now();
|
||||
this.cleanup(now);
|
||||
|
||||
if (condition === "NX" && this.entries.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mode && mode !== "EX") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ttl = ttlSeconds && ttlSeconds > 0 ? ttlSeconds * 1000 : 10 * 60 * 1000;
|
||||
this.entries.set(key, now + ttl);
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
|
||||
export const redis: RedisLike =
|
||||
globalForRedis.redis ||
|
||||
new Redis(process.env.REDIS_URL || "redis://localhost:6379");
|
||||
(() => {
|
||||
if (!configuredRedisUrl) {
|
||||
if (process.env.NODE_ENV !== "test") {
|
||||
console.info("[traffic-monitor] REDIS_URL is not set, fallback to in-memory dedupe." );
|
||||
}
|
||||
return new InMemoryRedisDedupe();
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForRedis.redis = redis;
|
||||
const redisClient = new Redis(configuredRedisUrl, {
|
||||
maxRetriesPerRequest: 2,
|
||||
enableOfflineQueue: true,
|
||||
retryStrategy: (times) => {
|
||||
if (times > 8) {
|
||||
return 2000;
|
||||
}
|
||||
return Math.min(times * 200, 1000);
|
||||
},
|
||||
reconnectOnError: (error) => {
|
||||
const message = error.message || "";
|
||||
if (message.includes("READONLY")) return true;
|
||||
if (message.includes("ECONNREFUSED")) return true;
|
||||
if (message.includes("ENETUNREACH")) return true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
})();
|
||||
|
||||
if (!globalForRedis.redis && configuredRedisUrl) {
|
||||
let lastWarnAt = 0;
|
||||
(redis as Redis).on("error", (error: Error) => {
|
||||
const now = Date.now();
|
||||
if (now - lastWarnAt < 60_000) return;
|
||||
lastWarnAt = now;
|
||||
console.warn("[Redis error]", error.message || String(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { Prisma } from "../../prisma/generated/client";
|
||||
import dns from "node:dns";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
|
||||
const TELEGRAM_BOT_ID_FROM_TOKEN = (() => {
|
||||
if (!process.env.TELEGRAM_BOT_TOKEN) return undefined;
|
||||
@@ -46,6 +48,10 @@ const TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS = Math.max(
|
||||
100,
|
||||
Number.parseInt(process.env.TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS?.trim() || "400", 10) || 400
|
||||
);
|
||||
const TELEGRAM_IP_FAMILY = Number.parseInt(
|
||||
process.env.TELEGRAM_IP_FAMILY?.trim() || "4",
|
||||
10
|
||||
) as 4 | 6;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -71,18 +77,18 @@ async function resolveTelegramFallbackChatId(): Promise<string | undefined> {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=20`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(TELEGRAM_NOTIFY_TIMEOUT_MS),
|
||||
});
|
||||
const response = await requestTelegram(
|
||||
`https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=20`,
|
||||
"GET"
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
const data = (JSON.parse(response.text) as {
|
||||
result?: Array<Record<string, unknown>>;
|
||||
};
|
||||
}) || {};
|
||||
|
||||
const updates = Array.isArray(data.result) ? data.result : [];
|
||||
for (let index = updates.length - 1; index >= 0; index -= 1) {
|
||||
@@ -144,24 +150,26 @@ function buildTelegramMessage(event: TrafficAlertEvent) {
|
||||
}
|
||||
|
||||
async function sendViaHttps(url: string, body: Record<string, unknown>) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
return new Promise<{ messageId?: string }>((resolve, reject) => {
|
||||
try {
|
||||
const payload = JSON.stringify(body);
|
||||
let responseBody = "";
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(Buffer.byteLength(payload)),
|
||||
},
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(TELEGRAM_NOTIFY_TIMEOUT_MS),
|
||||
})
|
||||
requestTelegram(url, "POST", payload)
|
||||
.then(async (response) => {
|
||||
responseBody = await response.text();
|
||||
const responseBody = response.text;
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
resolve();
|
||||
let messageId: string | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(responseBody) as { result?: { message_id?: number } };
|
||||
const parsedMessageId = parsed?.result?.message_id;
|
||||
if (typeof parsedMessageId === "number") {
|
||||
messageId = String(parsedMessageId);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors for non-JSON success responses
|
||||
}
|
||||
|
||||
resolve({ messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,6 +188,72 @@ async function sendViaHttps(url: string, body: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
type TelegramRequestResult = {
|
||||
status: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
function requestTelegram(
|
||||
url: string,
|
||||
method: "GET" | "POST",
|
||||
payload?: string
|
||||
): Promise<TelegramRequestResult> {
|
||||
return new Promise<TelegramRequestResult>((resolve, reject) => {
|
||||
try {
|
||||
const requestUrl = new URL(url);
|
||||
const request = httpsRequest(
|
||||
{
|
||||
protocol: requestUrl.protocol,
|
||||
hostname: requestUrl.hostname,
|
||||
port: requestUrl.port,
|
||||
path: `${requestUrl.pathname}${requestUrl.search}`,
|
||||
method,
|
||||
headers: {
|
||||
...(payload
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(Buffer.byteLength(payload)),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
timeout: TELEGRAM_NOTIFY_TIMEOUT_MS,
|
||||
family: TELEGRAM_IP_FAMILY,
|
||||
lookup: (hostname, options, callback) => {
|
||||
dns.lookup(hostname, { ...options, family: TELEGRAM_IP_FAMILY }, callback);
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let responseText = "";
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
responseText += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolve({
|
||||
status: response.statusCode || 0,
|
||||
text: responseText,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on("timeout", () => {
|
||||
request.destroy(new Error(`Telegram API timeout ${TELEGRAM_NOTIFY_TIMEOUT_MS}ms`));
|
||||
});
|
||||
request.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
if (payload) {
|
||||
request.write(payload);
|
||||
}
|
||||
request.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEntityFromTrafficEvent(event: TrafficAlertEvent) {
|
||||
if (event.taskId) {
|
||||
return { entityType: "TASK", entityId: event.taskId };
|
||||
@@ -291,16 +365,31 @@ export async function sendTrafficAlert(event: TrafficAlertEvent): Promise<void>
|
||||
? JSON.parse(typeof target.init.body === "string" ? target.init.body : "{}")
|
||||
: {};
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
await sendViaHttps(target.url, payload);
|
||||
const result = await sendViaHttps(target.url, payload);
|
||||
console.log(
|
||||
"[Traffic alert notify ok] telegram",
|
||||
`action=${event.action}`,
|
||||
`surface=${event.surface}`,
|
||||
`actor=${event.actorType}/${event.actorId}`,
|
||||
`request_id=${typeof event.metadata?.request_id === "string" ? event.metadata.request_id : "n/a"}`,
|
||||
`attempt=${attempt}`,
|
||||
`message_id=${result.messageId || "n/a"}`
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
attempt += 1;
|
||||
if (attempt >= TELEGRAM_NOTIFY_MAX_ATTEMPTS) {
|
||||
console.warn(
|
||||
`[Traffic alert] telegram notify failed: ${event.action} attempt=${attempt}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`[Traffic alert] telegram notify retrying action=${event.action} attempt=${attempt}`,
|
||||
error
|
||||
);
|
||||
await sleep(TELEGRAM_NOTIFY_RETRY_BASE_DELAY_MS * attempt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: agent_bounty_redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks:
|
||||
- agent-bounty-network
|
||||
|
||||
db:
|
||||
image: postgres:14-alpine
|
||||
container_name: agent_bounty_db
|
||||
@@ -10,7 +24,7 @@ services:
|
||||
POSTGRES_PASSWORD: agent_password_secure
|
||||
POSTGRES_DB: agent_bounty
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- agent_bounty_pgdata:/var/lib/postgresql/data
|
||||
networks:
|
||||
@@ -28,11 +42,12 @@ services:
|
||||
container_name: agent_bounty_web
|
||||
restart: always
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3000"
|
||||
environment:
|
||||
# Use the docker internal network to connect to postgres
|
||||
- DATABASE_URL=postgresql://agent:agent_password_secure@db:5432/agent_bounty?schema=public
|
||||
- NODE_ENV=production
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- API_KEY=${API_KEY:-super-secret-mcp-key}
|
||||
- E2B_API_KEY=${E2B_API_KEY:-""}
|
||||
- AUTO_WHITELIST_EXTERNAL_AGENTS=${AUTO_WHITELIST_EXTERNAL_AGENTS:-true}
|
||||
@@ -43,6 +58,8 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
# We use a command override to run database push before starting next.js
|
||||
command: >
|
||||
sh -c "npx prisma@6.4.1 db push --schema=apps/web/prisma/schema.prisma --skip-generate && node apps/web/server.js"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/expect -f
|
||||
set timeout -1
|
||||
spawn ssh -J wooo@192.168.0.110 wooo@192.168.0.188 "cat /home/wooo/deployments/agent-bounty-protocol/.env"
|
||||
set jump_host "wooo@192.168.0.110"
|
||||
set target_host "ollama@192.168.0.188"
|
||||
set repo_dir "/home/ollama/vibework-git"
|
||||
|
||||
spawn ssh -J $jump_host $target_host "cat $repo_dir/.env"
|
||||
expect {
|
||||
"*assword:*" {
|
||||
send "0936223270\r"
|
||||
|
||||
Reference in New Issue
Block a user