From 350460e386cddff4e66d158f6263b3e8853783b0 Mon Sep 17 00:00:00 2001 From: Ambrose Chua Date: Fri, 4 Oct 2019 17:59:37 +0800 Subject: [PATCH] Initial commit --- .gitignore | 3 + Dockerfile | 10 ++ README.md | 39 +++++ server.ts | 466 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 518 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 server.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..610b67f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store + +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c76b128 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM maxmcd/deno + +COPY . . + +RUN deno fetch server.ts + +EXPOSE 8080 +ENV LISTEN=0.0.0.0:8080 + +CMD ["deno", "--allow-env", "--allow-net=0.0.0.0:8080,api.telegram.org", "server.ts"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfe77fd --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ + +# GitHub Bot + +A simple Telegram Bot to notify of events. Works with both private and public repositories using Webhooks. + +# Usage + +Add @serverwentdown_githubbot to your chat and type "/setup@serverwentdown_githubbot" + +# Configuring Your Own Instance + +Run in Docker with the below command. Remember to configure the required environmental variables. + +``` +docker run --rm -it -p 8080:8080 -e ... serverwentdown/githubbot +``` + +## Environmental Variables + +### TELEGRAM_TOKEN + +This variable is mandatory. Specify your telegram bot token. Use @BotFather to obtain this. + +### TELEGRAM_SECRET + +This variable is mandatory. Generate a random string of length greater that 12 for this. It is used as an internal secret by the bot to secure communication with Telegram. + +### GITHUBBOT_BASE_URL + +This variable is mandatory. This is the base URL that must be externally accessible by your bot, without a trailing slash. If you mount your bot on a different path with a reverse proxy, include the directory in the base URL. + +### GITHUBBOT_SECRET + +This variable is mandatory. Generate a random string of length greater that 12 for this. It is used as an internal secret to authenticate Webhook URLs. + +## Limitations + +Webhook URLs can't be revoked, thus be careful when sharing them. This is a result of the stateless implementation of this bot. + diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..1876d35 --- /dev/null +++ b/server.ts @@ -0,0 +1,466 @@ +const { env } = Deno; +import { Response, ServerRequest, listenAndServe } from "https://deno.land/std/http/server.ts"; +import { hmac } from "https://deno.land/x/hmac/mod.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot"; + +type Options = { + telegramToken: string, + telegramSecret: string, + secret: string, + baseURL: string, + listen: string, +}; + +type Params = { + silent: boolean, +}; + +type GitHubUser = { + login: string, + avatar_url: string, + html_url: string, +}; + +type GitHubRepository = { + name: string, + full_name: string, + private: boolean, + owner: GitHubUser, + html_url: string, + url: string, +}; + +type GitHubCommit = { + id: string, + message: string, + timestamp: string, + url: string, + author: GitHubCommitAuthor, + added: string[], + removed: string[], + modified: string[], +}; + +type GitHubCommitAuthor = { + name: string, + email: string, + username: string, +}; + +type GitHubEventPing = { + zen: string, + hook_id: number, + hook: object, +}; + +type GitHubEventPush = { + ref: string, + sender: GitHubUser, + repository: GitHubRepository, + commits: GitHubCommit[], + compare: string, +}; + +const githubEventFormatters = { + 'ping': (ping: GitHubEventPing): string => { + return `You successfully set up GitHub notifications. +GitHub Zen: ${ping.zen}`; + }, + 'push': (push: GitHubEventPush): string => { + return `[${push.sender.login}](${push.sender.html_url}): +[[${push.repository.name}:${push.ref.replace('refs/heads/', '')}] ${push.commits.length} new commits](${push.compare}) +${push.commits.map(commit => `\`${commit.id.slice(0, 7)}\` ${commit.message} - ${commit.author.username}`).join('\n')}`; + }, +}; + +type TelegramUpdate = { + update_id: number, + message?: TelegramMessage, + callback_query?: TelegramCallbackQuery, +}; + +type TelegramMessage = { + message_id: number, + from?: TelegramUser, + date: number, + chat: TelegramChat, + text?: string, + entities?: TelegramMessageEntity[], +}; + +type TelegramUser = { + id: number, + is_bot: boolean, + first_name: string, + last_name?: string, + username?: string, + language_code?: string, +}; + +type TelegramChat = { + id: number, + type: string, + title?: string, + username?: string, + first_name?: string, + last_name?: string, +}; + +type TelegramMessageEntity = { + type: string, + offset: number, + length: number, +}; + +type TelegramCallbackQuery = { + id: number, + message?: TelegramMessage, + data?: string, +}; + +type TelegramActionSendMessage = { + chat_id?: string | number, + text: string, + parse_mode?: string, + disable_web_page_preview?: boolean, + disable_notification?: boolean, + reply_markup?: TelegramInlineKeyboardMarkup, +}; + +type TelegramInlineKeyboardMarkup = { + inline_keyboard: TelegramInlineKeyboardButton[][], +}; + +type TelegramInlineKeyboardButton = { + text: string, + callback_data?: string, +}; + +class GitHubBot { + + private options: Options; + + constructor(options: Options) { + this.options = options; + } + + public async setup(): Promise { + await this.setupTelegramWebhook(); + } + + safeHMAC(data: string): string { + const hash = hmac("sha1", this.options.secret, data, "utf8", "base64"); + return hash.toString().replace("+", "-").replace("/", "_").replace("=", ""); + } + + public generateWebhookURL(chatID: number): string { + const chatToken = this.safeHMAC(`${chatID}`); + return `${this.options.baseURL}/github/${chatID}/${chatToken}`; + } + + public verifyWebhookURL(chatID: number, chatToken: string): boolean { + return this.safeHMAC(`${chatID}`) === chatToken; + } + + public async githubEvent(chatID: number, chatToken: string, params: Params, event: string, data: object): Promise { + //console.debug("githubEvent", chatID, chatToken, JSON.stringify(data)); + if (!this.verifyWebhookURL(chatID, chatToken)) { + throw new Error("invalid: chatToken"); + } + + const message: TelegramActionSendMessage = this.formatEventMessage(event, data); + await this.telegramApi("sendMessage", { + chat_id: chatID, + disable_notification: params.silent, + ...message, + }); + + return {}; + } + + public async telegramUpdate(telegramSecret: string, data: TelegramUpdate): Promise { + //console.debug("telegramUpdate", telegramSecret, JSON.stringify(data.message)); + if (telegramSecret !== this.options.telegramSecret) { + throw new Error("invalid: telegramSecret"); + } + + if (data.message) { + + // Probably a command + const message: TelegramMessage = data.message; + + const entities: TelegramMessageEntity[] = message.entities; + if (!entities || entities.length < 1) { + console.warn(`ignored: No entities found in Telegram message: ${message}`); + return; + } + + // Assume encoding is UTF-8, not UTF-16 as stated in https://core.telegram.org/bots/api#messageentity + const firstEntity = message.text.slice(entities[0].offset, entities[0].offset + entities[0].length); + const command = firstEntity.split("@")[0]; + + if (command == "/setup") { + return { + method: "sendMessage", + chat_id: message.chat.id, + ...this.formatSetupMessage(message.chat.id), + reply_markup: { + inline_keyboard: [[{ + text: "Advanced Setup", + callback_data: "updateMessageSetupAdvanced", + }]], + }, + }; + } + + return { + method: "sendMessage", + chat_id: message.chat.id, + ...this.formatUnknownCommandMessage(command), + }; + + } + if (data.callback_query) { + + // Callback query + const callback_query: TelegramCallbackQuery = data.callback_query; + + if (!callback_query.message || !callback_query.data) { + console.warn(`ignored: Callback query mode is not known`); + return; + } + + const message: TelegramMessage = callback_query.message; + const action = callback_query.data; + + if (action == "updateMessageSetupAdvanced") { + await this.updateMessageSetupAdvanced(message); + } + + return { + method: "answerCallbackQuery", + }; + + } + + console.warn(`ignored: Unknown Telegram update received: ${data}`); + return; + + } + + async updateMessageSetupAdvanced(message: TelegramMessage) { + await this.telegramApi("editMessageText", { + chat_id: message.chat.id, + message_id: message.message_id, + ...this.formatSetupAdvancedMessage(message.chat.id), + }); + } + + formatEventMessage(event: string, data: object): TelegramActionSendMessage { + if (!(event in githubEventFormatters)) { + throw new Error(`invalid: Event type "${event}" is not known`); + } + const text = githubEventFormatters[event](data); + return { + parse_mode: "Markdown", + text, + }; + } + + formatSetupMessage(chatID: number): TelegramActionSendMessage { + const url = this.generateWebhookURL(chatID); + return { + parse_mode: "Markdown", + text: `*Steps* + +1. Open "Settings" → "Webhooks" → "Add Webhook" in your GitHub repository. +2. Paste the following URL as "Payload URL": +${url} +3. Under "Content-Type", choose "application/json" + +Press "Add webhook" and you're done. You can optionally choose specific events to send a message for.`, + disable_web_page_preview: true, + }; + } + + formatSetupAdvancedMessage(chatID: number): TelegramActionSendMessage { + const { text }: TelegramActionSendMessage = this.formatSetupMessage(chatID); + return { + parse_mode: "Markdown", + text: text + ` + +*Advanced Flags* + +Pass these flags as query parameters. For example: ?silent + +• \`silent\` - Makes events silent + +`, + disable_web_page_preview: true, + }; + } + + formatUnknownCommandMessage(command: string): TelegramActionSendMessage { + return { + text: `Oops, I don't understand your command ${command}`, + }; + } + + async setupTelegramWebhook(): Promise { + console.info("Setting up webhook..."); + await this.telegramApi("setWebhook", { + url: `${this.options.baseURL}/telegramUpdate/${this.options.telegramSecret}`, + }); + console.log("Webhook ready"); + } + + async telegramApi(action: string, data: object): Promise { + const method = "POST"; + const url = `${TELEGRAM_API_BASE_URL}${this.options.telegramToken}/${action}`; + const body = JSON.stringify(data); + const res = await fetch(url, { + method, + headers: [ + ["Content-Type", "application/json"], + ], + body, + }); + if (!res.ok) { + let err = await res.text(); + throw new Error(`Failure to perform API request: ${err}`); + } + const obj = await res.json(); + return obj; + } + +} + +async function serveGitHubWebhook(req: ServerRequest, bot: GitHubBot, parts: [string, string], urlParams: URLSearchParams): Promise { + const chatID = parseInt(parts[0], 10); + const chatToken = parts[1]; + const params: Params = { + silent: urlParams.has("silent"), + }; + + const body = await req.body(); + const data = JSON.parse(decoder.decode(body)); + const event = req.headers.get("X-GitHub-Event"); + const reply = await bot.githubEvent(chatID, chatToken, params, event, data); + return respondIfReply(reply); +} + +async function serveTelegramWebhook(req: ServerRequest, bot: GitHubBot, parts: [string], params: URLSearchParams): Promise { + const telegramSecret = parts[0]; + + const body = await req.body(); + const data = JSON.parse(decoder.decode(body)); + const reply = await bot.telegramUpdate(telegramSecret, data); + return respondIfReply(reply); +} + +async function respondIfReply(reply?: object): Promise { + if (reply) { + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + return { + status: 200, + headers, + body: encoder.encode(JSON.stringify(reply)), + }; + } + return { + status: 200, + body: encoder.encode("Success"), + }; +} + +async function serveBadRequest(): Promise { + return { + status: 400, + body: encoder.encode("Bad Request"), + }; +} + +async function serveNotFound(): Promise { + return { + status: 404, + body: encoder.encode("Not Found"), + }; +} + +async function serveMethodNotAllowed(): Promise { + return { + status: 405, + body: encoder.encode("Method Not Allowed"), + }; +} + +async function serveInternalServerError(): Promise { + return { + status: 500, + body: encoder.encode("Unknown Internal Server Error"), + }; +} + +async function route(req: ServerRequest, bot: GitHubBot): Promise { + const url = new URL(req.url, "https://localhost"); // Need a better URL parser + const params = url.searchParams; + const parts = url.pathname.split("/").filter(part => part); + if (parts.length === 2) { + if (req.method !== "POST") { + return await serveMethodNotAllowed(); + } + if (parts[0] === "telegramUpdate") { + return await serveTelegramWebhook(req, bot, [parts[1]], params); + } + } + if (parts.length === 3) { + if (req.method !== "POST") { + return await serveMethodNotAllowed(); + } + if (parts[0] === "github") { + return await serveGitHubWebhook(req, bot, [parts[1], parts[2]], params); + } + } + return await serveNotFound(); +} + +async function main(): Promise { + const telegramToken = env()["TELEGRAM_TOKEN"]; + const telegramSecret = env()["TELEGRAM_SECRET"] || "insecure"; + const secret = env()["GITHUBBOT_SECRET"]; + const baseURL = env()["GITHUBBOT_BASE_URL"]; + const listen = env()["LISTEN"] || "127.0.0.1:8080"; + + const bot = new GitHubBot({ + telegramToken, + telegramSecret, + secret, + baseURL, + listen, + }); + + listenAndServe(listen, async (req): Promise => { + let response: Response; + + try { + response = await route(req, bot); + } catch (e) { + if (e.message.startsWith('invalid')) { + response = await serveBadRequest(); + } else { + response = await serveInternalServerError(); + console.error(e); + } + } + req.respond(response); + }); + + await bot.setup(); +} + +main();