mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Maintainer tooling in scripts/pr-bot/: a daemon (systemd user unit
codeman-pr-bot) that lists open PRs with gh, reviews each head commit once in
a Codeman claude session (`prbot-<n>`) running in a private `git clone
--shared`, and sends the verdict, ranked findings, checks and a recommendation
to Telegram with action buttons. Merge, close, post-comment and approve-CI
happen only from a Telegram command or button plus a confirmation tap; the
bot never writes to GitHub on its own. The Telegram token and chat id come
from the existing notifier bot's env file.
Verified live: three PRs reviewed end to end (383, 363, 368), reports
delivered with buttons, reviewer sessions on the pinned model. Findings
along the way, each fixed and documented: a linked worktree inherits the
main checkout's model pin (hence the shared clone), undici's 5-minute header
timeout cut off the first review, gh was missing from the service PATH, and
the periodic scan orphaned an in-flight review's record.
typecheck/lint/format now cover scripts/pr-bot; tests in
test/pr-bot-{report,state,commands}.test.ts; guide in docs/pr-bot.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
189 lines
6.3 KiB
TypeScript
189 lines
6.3 KiB
TypeScript
/**
|
|
* @fileoverview Minimal Telegram Bot API client (long polling, no webhook: the box sits
|
|
* behind Tailscale) plus the pure command / callback parsers.
|
|
*
|
|
* Only updates from the configured chat are ever acted on; everything else is dropped
|
|
* without an answer, so a stranger who finds the bot gets silence, not a menu.
|
|
*/
|
|
|
|
export interface TelegramMessage {
|
|
message_id: number;
|
|
chat: { id: number | string };
|
|
from?: { id: number; username?: string };
|
|
text?: string;
|
|
reply_to_message?: { message_id: number; text?: string };
|
|
}
|
|
|
|
export interface TelegramCallbackQuery {
|
|
id: string;
|
|
from: { id: number; username?: string };
|
|
message?: TelegramMessage;
|
|
data?: string;
|
|
}
|
|
|
|
export interface TelegramUpdate {
|
|
update_id: number;
|
|
message?: TelegramMessage;
|
|
callback_query?: TelegramCallbackQuery;
|
|
}
|
|
|
|
export interface SendOptions {
|
|
replyMarkup?: unknown;
|
|
replyToMessageId?: number;
|
|
disablePreview?: boolean;
|
|
}
|
|
|
|
export class TelegramClient {
|
|
private readonly base: string;
|
|
|
|
constructor(
|
|
token: string,
|
|
private readonly chatId: string
|
|
) {
|
|
this.base = `https://api.telegram.org/bot${token}`;
|
|
}
|
|
|
|
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
|
|
const res = await fetch(`${this.base}/${method}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body ?? {}),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
const json = (await res.json()) as { ok: boolean; result?: T; description?: string };
|
|
if (!json.ok) throw new Error(`telegram ${method}: ${json.description ?? res.status}`);
|
|
return json.result as T;
|
|
}
|
|
|
|
isOurChat(chatId: number | string | undefined): boolean {
|
|
return chatId !== undefined && String(chatId) === this.chatId;
|
|
}
|
|
|
|
async getMe(): Promise<{ username?: string }> {
|
|
return this.call<{ username?: string }>('getMe');
|
|
}
|
|
|
|
async sendMessage(text: string, opts: SendOptions = {}): Promise<number> {
|
|
const result = await this.call<{ message_id: number }>('sendMessage', {
|
|
chat_id: this.chatId,
|
|
text,
|
|
parse_mode: 'HTML',
|
|
disable_web_page_preview: opts.disablePreview ?? true,
|
|
reply_markup: opts.replyMarkup,
|
|
reply_to_message_id: opts.replyToMessageId,
|
|
});
|
|
return result.message_id;
|
|
}
|
|
|
|
/** Plain text, no parse mode: for content the bot did not write (reviewer answers, drafts). */
|
|
async sendPlain(text: string, opts: SendOptions = {}): Promise<number> {
|
|
const result = await this.call<{ message_id: number }>('sendMessage', {
|
|
chat_id: this.chatId,
|
|
text,
|
|
disable_web_page_preview: opts.disablePreview ?? true,
|
|
reply_markup: opts.replyMarkup,
|
|
reply_to_message_id: opts.replyToMessageId,
|
|
});
|
|
return result.message_id;
|
|
}
|
|
|
|
async editReplyMarkup(messageId: number, replyMarkup: unknown): Promise<void> {
|
|
try {
|
|
await this.call('editMessageReplyMarkup', {
|
|
chat_id: this.chatId,
|
|
message_id: messageId,
|
|
reply_markup: replyMarkup,
|
|
});
|
|
} catch (err) {
|
|
// "message is not modified" is Telegram's way of saying the keyboard already looks like that.
|
|
if (!String(err).includes('not modified')) throw err;
|
|
}
|
|
}
|
|
|
|
async deleteMessage(messageId: number): Promise<void> {
|
|
try {
|
|
await this.call('deleteMessage', { chat_id: this.chatId, message_id: messageId });
|
|
} catch {
|
|
// Already gone, or older than Telegram allows a bot to delete; the message was informational.
|
|
}
|
|
}
|
|
|
|
async answerCallback(callbackId: string, text?: string): Promise<void> {
|
|
await this.call('answerCallbackQuery', { callback_query_id: callbackId, text });
|
|
}
|
|
|
|
async sendDocument(filename: string, content: string, caption?: string): Promise<void> {
|
|
const form = new FormData();
|
|
form.set('chat_id', this.chatId);
|
|
if (caption) form.set('caption', caption);
|
|
form.set('document', new Blob([content], { type: 'text/markdown' }), filename);
|
|
const res = await fetch(`${this.base}/sendDocument`, {
|
|
method: 'POST',
|
|
body: form,
|
|
signal: AbortSignal.timeout(60_000),
|
|
});
|
|
const json = (await res.json()) as { ok: boolean; description?: string };
|
|
if (!json.ok) throw new Error(`telegram sendDocument: ${json.description ?? res.status}`);
|
|
}
|
|
|
|
async getUpdates(offset: number, timeoutSec: number): Promise<TelegramUpdate[]> {
|
|
return this.call<TelegramUpdate[]>(
|
|
'getUpdates',
|
|
{ offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] },
|
|
(timeoutSec + 15) * 1000
|
|
);
|
|
}
|
|
|
|
async setMyCommands(commands: { command: string; description: string }[]): Promise<void> {
|
|
await this.call('setMyCommands', { commands });
|
|
}
|
|
}
|
|
|
|
export interface ParsedCommand {
|
|
command: string;
|
|
prNumber?: number;
|
|
rest: string;
|
|
}
|
|
|
|
/** `/merge 381 force` -> {command:'merge', prNumber:381, rest:'force'}; `/help@botname` is handled. */
|
|
export function parseCommand(text: string | undefined): ParsedCommand | null {
|
|
if (!text) return null;
|
|
const m = text.trim().match(/^\/([a-zA-Z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
|
|
if (!m) return null;
|
|
const command = m[1].toLowerCase();
|
|
const argText = (m[2] ?? '').trim();
|
|
const numMatch = argText.match(/^#?(\d+)\b\s*([\s\S]*)$/);
|
|
if (numMatch) return { command, prNumber: parseInt(numMatch[1], 10), rest: numMatch[2].trim() };
|
|
return { command, rest: argText };
|
|
}
|
|
|
|
export interface ParsedCallback {
|
|
action: string;
|
|
prNumber: number;
|
|
nonce?: string;
|
|
/** For confirm/cancel: the action being confirmed. */
|
|
target?: string;
|
|
}
|
|
|
|
export function parseCallback(data: string | undefined): ParsedCallback | null {
|
|
if (!data) return null;
|
|
const parts = data.split(':');
|
|
if (parts[0] === 'confirm' || parts[0] === 'cancel') {
|
|
if (parts.length !== 4) return null;
|
|
const prNumber = parseInt(parts[2], 10);
|
|
if (!Number.isFinite(prNumber)) return null;
|
|
return { action: parts[0], target: parts[1], prNumber, nonce: parts[3] };
|
|
}
|
|
if (parts.length !== 2) return null;
|
|
const prNumber = parseInt(parts[1], 10);
|
|
if (!Number.isFinite(prNumber)) return null;
|
|
return { action: parts[0], prNumber };
|
|
}
|
|
|
|
/** Find the PR number a report message is about, from its first line (`🔍 PR #381 · ...`). */
|
|
export function prNumberFromMessageText(text: string | undefined): number | null {
|
|
if (!text) return null;
|
|
const m = text.match(/PR #(\d+)/);
|
|
return m ? parseInt(m[1], 10) : null;
|
|
}
|