Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { webhookCallback } from "grammy";
import { Hono } from "hono";
import * as z from "zod";

import { reportWebhookError } from "./lib/github/webhooks/report.ts";

export async function createApi() {
const api = new Hono();

if (!config.bot.polling)
api.use(`/api/webhook/telegram`, webhookCallback(bot, "hono", { secretToken: config.bot.webhookSecret }));

api.post(`/api/webhook/github`, async (ctx) => {
const deliveryId = ctx.req.header("X-GitHub-Delivery");
const eventName = ctx.req.header("X-GitHub-Event");
const signature = ctx.req.header("X-Hub-Signature-256");
const payload = z
.object({
id: z.string(),
Expand All @@ -20,19 +25,33 @@ export async function createApi() {
payload: z.string(),
})
.safeParse({
id: ctx.req.header("X-GitHub-Delivery"),
name: ctx.req.header("X-GitHub-Event"),
signature: ctx.req.header("X-Hub-Signature-256"),
id: deliveryId,
name: eventName,
signature,
payload: await ctx.req.text(),
});

if (!payload.success) {
// eslint-disable-next-line no-console
console.error("Invalid payload:", payload.error);
await reportWebhookError(payload.error, {
eventId: deliveryId,
eventName,
source: "request_validation",
});
return ctx.text("Bad Request", 400);
}

await webhooks.verifyAndReceive(payload.data);
try {
await webhooks.verifyAndReceive(payload.data);
} catch (error) {
await reportWebhookError(error, {
eventId: payload.data.id,
eventName: payload.data.name,
source: "request_processing",
});
throw error;
}

return ctx.text("Accepted", 202);
});
Expand Down
28 changes: 14 additions & 14 deletions src/bot/middleware/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,51 +4,51 @@ import type { BotContext } from "#bot";
import { config } from "#config";
import { GrammyError } from "grammy";

import { formatErrorDetails } from "../../lib/error-details.ts";
import { escapeHtml } from "../../lib/escape-html.ts";
import { sendReport } from "../../lib/telegram/report.ts";

export const logger = async (ctx: BotContext, next: () => Promise<unknown>) => {
ctx.logger = {
log: async (message: string) => {
console.log(message);
const reportId = config.bot.reportChatId;
if (!reportId) return;

return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" });
return sendReport(ctx.api, message);
},
error: async (message: string) => {
console.log("Report for", config.bot.reportChatId);

console.error(message);
const reportId = config.bot.reportChatId;
if (!reportId) return;

return ctx.api.sendMessage(reportId, message, { parse_mode: "HTML" });
return sendReport(ctx.api, message);
},
};

ctx.report = async (e: unknown) => {
let message = "";
const update = ctx.update.message;
const command = update?.text ? escapeHtml(update.text) : "N/A";
const firstName = update?.from?.first_name ? escapeHtml(update.from.first_name) : "Unknown";
const link = update?.from.username
? `@${update.from.username}`
: `<a href="tg://user?id=${update?.from.id}">${update?.from.first_name}</a>`;
? `@${escapeHtml(update.from.username)}`
: `<a href="tg://user?id=${update?.from.id}">${firstName}</a>`;

message += [
"<b>Error:</b>",
`Command: <code>${update?.text}</code>`,
`Command: <code>${command}</code>`,
`Sender Name: ${link}`,
"",
"<b>Message:</b>",
"",
].join("\n");

if (e instanceof GrammyError) {
message += `<pre>${e.description}</pre>\n`;
message += `<pre>${escapeHtml(e.description)}</pre>\n`;
} else {
message += `<pre>${e}</pre>\n`;
message += `<pre>${escapeHtml(formatErrorDetails(e))}</pre>\n`;
}

message += `\n#error`;

ctx.logger.error(message);
return ctx.logger.error(message);
};

return next();
Expand Down
28 changes: 28 additions & 0 deletions src/lib/error-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
function stringifyUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.stack ?? `${error.name}: ${error.message}`;
}

if (typeof error === "string") return error;

try {
return JSON.stringify(error, null, 2) ?? String(error);
} catch {
return String(error);
}
}

export function formatErrorDetails(error: unknown): string {
if (error instanceof AggregateError && error.errors.length > 0) {
const showLabels = error.errors.length > 1;

return error.errors
.map((item, index) => {
const details = formatErrorDetails(item);
return showLabels ? `Error ${index + 1}:\n${details}` : details;
})
.join("\n\n");
}

return stringifyUnknownError(error);
}
2 changes: 2 additions & 0 deletions src/lib/github/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { pullRequestOpenedCallback } from "./handlers/pull-request-opened.ts";
import { releaseCreatedCallback } from "./handlers/release-created.ts";
import { repositoryCreatedCallback } from "./handlers/repository-created.ts";
import { starCreatedCallback } from "./handlers/star-created.ts";
import { reportWebhookError } from "./report.ts";
import { withGuards } from "./withGuards.ts";

export const webhooks = new Webhooks({ secret: config.github.webhookSecret });
Expand All @@ -24,3 +25,4 @@ webhooks.on("star.created", withGuards(starCreatedCallback));
webhooks.on("issue_comment.created", withGuards(commentCreatedCallback));
webhooks.on("pull_request_review_comment.created", withGuards(commentCreatedCallback));
webhooks.on("projects_v2_item.edited", withGuards(projectItemEditedCallback, { skipRepositoryCheck: true }));
webhooks.onError(reportWebhookError);
74 changes: 74 additions & 0 deletions src/lib/github/webhooks/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* eslint-disable no-console */
import { bot } from "#bot";

import { formatErrorDetails } from "../../error-details.ts";
import { escapeHtml } from "../../escape-html.ts";
import { sendReport } from "../../telegram/report.ts";

const reportedWebhookError = Symbol("reportedWebhookError");

interface WebhookReportContext {
eventId?: string;
eventName?: string;
source?: string;
}

function isObject(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === "object" && value !== null;
}

function markWebhookErrorReported(error: unknown) {
if (!isObject(error)) return;

error[reportedWebhookError] = true;
}

function hasReportedWebhookError(error: unknown) {
return isObject(error) && error[reportedWebhookError] === true;
}

function getEventName(event: unknown) {
if (!isObject(event)) return undefined;

const name = typeof event.name === "string" ? event.name : undefined;
const payload = isObject(event.payload) ? event.payload : undefined;
const action = typeof payload?.action === "string" ? payload.action : undefined;

if (!name) return undefined;
if (!action || name.includes(".")) return name;

return `${name}.${action}`;
}

function getEventInfo(error: unknown): WebhookReportContext {
if (!isObject(error) || !isObject(error.event)) return {};

return {
eventId: typeof error.event.id === "string" ? error.event.id : undefined,
eventName: getEventName(error.event),
};
}

export function buildWebhookErrorReport(error: unknown, context: WebhookReportContext = {}) {
const event = getEventInfo(error);
const eventName = context.eventName ?? event.eventName;
const eventId = context.eventId ?? event.eventId;
const lines = ["<b>GitHub Webhook Error:</b>"];

if (eventName) lines.push(`Event: <code>${escapeHtml(eventName)}</code>`);
if (eventId) lines.push(`Delivery: <code>${escapeHtml(eventId)}</code>`);
if (context.source) lines.push(`Source: <code>${escapeHtml(context.source)}</code>`);

lines.push("", "<b>Message:</b>", "", `<pre>${escapeHtml(formatErrorDetails(error))}</pre>`, "", "#webhook #error");

return lines.join("\n");
}

export async function reportWebhookError(error: unknown, context: WebhookReportContext = {}) {
if (hasReportedWebhookError(error)) return;

markWebhookErrorReported(error);
console.error(error);

return sendReport(bot.api, buildWebhookErrorReport(error, context));
}
12 changes: 12 additions & 0 deletions src/lib/telegram/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { config } from "#config";

interface TelegramReportApi {
sendMessage: (chatId: number, text: string, other?: { parse_mode?: "HTML" }) => Promise<unknown>;
}

export async function sendReport(api: TelegramReportApi, message: string) {
const reportId = config.bot.reportChatId;
if (!reportId) return;

return api.sendMessage(reportId, message, { parse_mode: "HTML" });
}
Loading