├── .gitignore ├── Dockerfile ├── README.md └── server.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | .env 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maxmcd/deno 2 | 3 | COPY . . 4 | 5 | RUN deno fetch server.ts 6 | 7 | EXPOSE 8080 8 | ENV LISTEN=0.0.0.0:8080 9 | 10 | CMD ["deno", "--allow-env", "--allow-net=0.0.0.0:8080,api.telegram.org", "server.ts"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # GitHub Bot 3 | 4 | A simple Telegram Bot to notify of events. Works with both private and public repositories using Webhooks. 5 | 6 | # Usage 7 | 8 | Add @serverwentdown_githubbot to your chat and type "/setup@serverwentdown_githubbot" 9 | 10 | # Limitations 11 | 12 | Webhook URLs can't be revoked, thus be careful when sharing them. This is a result of the stateless implementation of this bot. 13 | 14 | # Configuring Your Own Instance 15 | 16 | Run in Docker with the below command. Remember to configure the required environmental variables. 17 | 18 | ``` 19 | docker run --rm -it -p 8080:8080 -e ... serverwentdown/githubbot 20 | ``` 21 | 22 | ## Environmental Variables 23 | 24 | ### `TELEGRAM_TOKEN` 25 | 26 | This variable is mandatory. Specify your telegram bot token. Use @BotFather to obtain this. 27 | 28 | ### `TELEGRAM_SECRET` 29 | 30 | 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. 31 | 32 | ### `GITHUBBOT_BASE_URL` 33 | 34 | 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. 35 | 36 | ### `GITHUBBOT_SECRET` 37 | 38 | 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. 39 | 40 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | const { env } = Deno; 2 | import { Response, ServerRequest, listenAndServe } from "https://deno.land/std/http/server.ts"; 3 | import { hmac } from "https://deno.land/x/hmac/mod.ts"; 4 | 5 | const encoder = new TextEncoder(); 6 | const decoder = new TextDecoder(); 7 | 8 | const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot"; 9 | 10 | type Options = { 11 | telegramToken: string, 12 | telegramSecret: string, 13 | secret: string, 14 | baseURL: string, 15 | listen: string, 16 | }; 17 | 18 | type Params = { 19 | silent: boolean, 20 | }; 21 | 22 | type GitHubUser = { 23 | login: string, 24 | avatar_url: string, 25 | html_url: string, 26 | }; 27 | 28 | type GitHubRepository = { 29 | name: string, 30 | full_name: string, 31 | private: boolean, 32 | owner: GitHubUser, 33 | html_url: string, 34 | branches_url: string, 35 | url: string, 36 | }; 37 | 38 | type GitHubCommit = { 39 | id: string, 40 | message: string, 41 | timestamp: string, 42 | url: string, 43 | author: GitHubCommitAuthor, 44 | added: string[], 45 | removed: string[], 46 | modified: string[], 47 | }; 48 | 49 | type GitHubCommitAuthor = { 50 | name: string, 51 | email: string, 52 | username: string, 53 | }; 54 | 55 | type GitHubEventPing = { 56 | zen: string, 57 | hook_id: number, 58 | hook: object, 59 | }; 60 | 61 | type GitHubEventPush = { 62 | ref: string, 63 | sender: GitHubUser, 64 | repository: GitHubRepository, 65 | commits: GitHubCommit[], 66 | compare: string, 67 | forced: boolean, 68 | }; 69 | 70 | const githubEventFormatters = { 71 | 'ping': (ping: GitHubEventPing): string => { 72 | return `You successfully set up GitHub notifications. 73 | GitHub Zen: ${ping.zen}`; 74 | }, 75 | 'push': (push: GitHubEventPush): string => { 76 | const branch = push.ref.replace('refs/heads/', ''); 77 | return `👤 [${push.sender.login}](${push.sender.html_url}): 78 | [${push.commits.length} new commit${push.commits.length == 1 ? "" : "s"}](${push.compare}) ${push.forced ? "force-" : ""}pushed to \`${branch}\` [🔗](${push.repository.branches_url.replace('{/branch}', '/' + branch)}) 79 | ${push.commits.map(commit => `\`${commit.id.slice(0, 7)}\` ${commit.message} [🔗](${commit.url}) - ${commit.author.username}`).join('\n')} 80 | _${push.repository.full_name.replace('_', '_____')}_ [🏠](${push.repository.html_url})`; 81 | }, 82 | }; 83 | 84 | type TelegramUpdate = { 85 | update_id: number, 86 | message?: TelegramMessage, 87 | callback_query?: TelegramCallbackQuery, 88 | }; 89 | 90 | type TelegramMessage = { 91 | message_id: number, 92 | from?: TelegramUser, 93 | date: number, 94 | chat: TelegramChat, 95 | text?: string, 96 | entities?: TelegramMessageEntity[], 97 | }; 98 | 99 | type TelegramUser = { 100 | id: number, 101 | is_bot: boolean, 102 | first_name: string, 103 | last_name?: string, 104 | username?: string, 105 | language_code?: string, 106 | }; 107 | 108 | type TelegramChat = { 109 | id: number, 110 | type: string, 111 | title?: string, 112 | username?: string, 113 | first_name?: string, 114 | last_name?: string, 115 | }; 116 | 117 | type TelegramMessageEntity = { 118 | type: string, 119 | offset: number, 120 | length: number, 121 | }; 122 | 123 | type TelegramCallbackQuery = { 124 | id: number, 125 | message?: TelegramMessage, 126 | data?: string, 127 | }; 128 | 129 | type TelegramActionSendMessage = { 130 | chat_id?: string | number, 131 | text: string, 132 | parse_mode?: string, 133 | disable_web_page_preview?: boolean, 134 | disable_notification?: boolean, 135 | reply_markup?: TelegramInlineKeyboardMarkup, 136 | }; 137 | 138 | type TelegramInlineKeyboardMarkup = { 139 | inline_keyboard: TelegramInlineKeyboardButton[][], 140 | }; 141 | 142 | type TelegramInlineKeyboardButton = { 143 | text: string, 144 | callback_data?: string, 145 | }; 146 | 147 | class GitHubBot { 148 | 149 | private options: Options; 150 | 151 | constructor(options: Options) { 152 | this.options = options; 153 | } 154 | 155 | public async setup(): Promise { 156 | await this.setupTelegramWebhook(); 157 | } 158 | 159 | safeHMAC(data: string): string { 160 | const hash = hmac("sha1", this.options.secret, data, "utf8", "base64"); 161 | return hash.toString().replace("+", "-").replace("/", "_").replace("=", ""); 162 | } 163 | 164 | public generateWebhookURL(chatID: number): string { 165 | const chatToken = this.safeHMAC(`${chatID}`); 166 | return `${this.options.baseURL}/github/${chatID}/${chatToken}`; 167 | } 168 | 169 | public verifyWebhookURL(chatID: number, chatToken: string): boolean { 170 | return this.safeHMAC(`${chatID}`) === chatToken; 171 | } 172 | 173 | public async githubEvent(chatID: number, chatToken: string, params: Params, event: string, data: object): Promise { 174 | //console.debug("githubEvent", chatID, chatToken, JSON.stringify(data)); 175 | if (!this.verifyWebhookURL(chatID, chatToken)) { 176 | throw new Error("invalid: chatToken"); 177 | } 178 | 179 | const message: TelegramActionSendMessage = this.formatEventMessage(event, data); 180 | await this.telegramApi("sendMessage", { 181 | chat_id: chatID, 182 | disable_notification: params.silent, 183 | ...message, 184 | }); 185 | 186 | return {}; 187 | } 188 | 189 | public async telegramUpdate(telegramSecret: string, data: TelegramUpdate): Promise { 190 | //console.debug("telegramUpdate", telegramSecret, JSON.stringify(data.message)); 191 | if (telegramSecret !== this.options.telegramSecret) { 192 | throw new Error("invalid: telegramSecret"); 193 | } 194 | 195 | if (data.message) { 196 | 197 | // Probably a command 198 | const message: TelegramMessage = data.message; 199 | 200 | const entities: TelegramMessageEntity[] = message.entities; 201 | if (!entities || entities.length < 1) { 202 | console.warn(`ignored: No entities found in Telegram message: ${message}`); 203 | return; 204 | } 205 | 206 | // Assume encoding is UTF-8, not UTF-16 as stated in https://core.telegram.org/bots/api#messageentity 207 | const firstEntity = message.text.slice(entities[0].offset, entities[0].offset + entities[0].length); 208 | const command = firstEntity.split("@")[0]; 209 | 210 | if (command == "/setup") { 211 | return { 212 | method: "sendMessage", 213 | chat_id: message.chat.id, 214 | ...this.formatSetupMessage(message.chat.id), 215 | reply_markup: { 216 | inline_keyboard: [[{ 217 | text: "Advanced Setup", 218 | callback_data: "updateMessageSetupAdvanced", 219 | }]], 220 | }, 221 | }; 222 | } 223 | 224 | return { 225 | method: "sendMessage", 226 | chat_id: message.chat.id, 227 | ...this.formatUnknownCommandMessage(command), 228 | }; 229 | 230 | } 231 | if (data.callback_query) { 232 | 233 | // Callback query 234 | const callback_query: TelegramCallbackQuery = data.callback_query; 235 | 236 | if (!callback_query.message || !callback_query.data) { 237 | console.warn(`ignored: Callback query mode is not known`); 238 | return; 239 | } 240 | 241 | const message: TelegramMessage = callback_query.message; 242 | const action = callback_query.data; 243 | 244 | if (action == "updateMessageSetupAdvanced") { 245 | await this.updateMessageSetupAdvanced(message); 246 | } 247 | 248 | return { 249 | method: "answerCallbackQuery", 250 | }; 251 | 252 | } 253 | 254 | console.warn(`ignored: Unknown Telegram update received: ${data}`); 255 | return; 256 | 257 | } 258 | 259 | async updateMessageSetupAdvanced(message: TelegramMessage) { 260 | await this.telegramApi("editMessageText", { 261 | chat_id: message.chat.id, 262 | message_id: message.message_id, 263 | ...this.formatSetupAdvancedMessage(message.chat.id), 264 | }); 265 | } 266 | 267 | formatEventMessage(event: string, data: object): TelegramActionSendMessage { 268 | if (!(event in githubEventFormatters)) { 269 | throw new Error(`invalid: Event type "${event}" is not known`); 270 | } 271 | const text = githubEventFormatters[event](data); 272 | return { 273 | parse_mode: "Markdown", 274 | text, 275 | disable_web_page_preview: true, 276 | }; 277 | } 278 | 279 | formatSetupMessage(chatID: number): TelegramActionSendMessage { 280 | const url = this.generateWebhookURL(chatID); 281 | return { 282 | parse_mode: "Markdown", 283 | text: `*Steps* 284 | 285 | 1. Open "Settings" → "Webhooks" → "Add Webhook" in your GitHub repository. 286 | 2. Paste the following URL as "Payload URL": 287 | ${url} 288 | 3. Under "Content-Type", choose "application/json" 289 | 290 | Press "Add webhook" and you're done. You can optionally choose specific events to send a message for.`, 291 | disable_web_page_preview: true, 292 | }; 293 | } 294 | 295 | formatSetupAdvancedMessage(chatID: number): TelegramActionSendMessage { 296 | const { text }: TelegramActionSendMessage = this.formatSetupMessage(chatID); 297 | return { 298 | parse_mode: "Markdown", 299 | text: text + ` 300 | 301 | *Advanced Flags* 302 | 303 | Pass these flags as query parameters. For example: ?silent 304 | 305 | • \`silent\` - Makes events silent 306 | 307 | `, 308 | disable_web_page_preview: true, 309 | }; 310 | } 311 | 312 | formatUnknownCommandMessage(command: string): TelegramActionSendMessage { 313 | return { 314 | text: `Oops, I don't understand your command ${command}`, 315 | }; 316 | } 317 | 318 | async setupTelegramWebhook(): Promise { 319 | console.info("Setting up webhook..."); 320 | await this.telegramApi("setWebhook", { 321 | url: `${this.options.baseURL}/telegramUpdate/${this.options.telegramSecret}`, 322 | }); 323 | console.log("Webhook ready"); 324 | } 325 | 326 | async telegramApi(action: string, data: object): Promise { 327 | const method = "POST"; 328 | const url = `${TELEGRAM_API_BASE_URL}${this.options.telegramToken}/${action}`; 329 | const body = JSON.stringify(data); 330 | const res = await fetch(url, { 331 | method, 332 | headers: [ 333 | ["Content-Type", "application/json"], 334 | ], 335 | body, 336 | }); 337 | if (!res.ok) { 338 | let err = await res.text(); 339 | throw new Error(`Failure to perform API request: ${err}`); 340 | } 341 | const obj = await res.json(); 342 | return obj; 343 | } 344 | 345 | } 346 | 347 | async function serveGitHubWebhook(req: ServerRequest, bot: GitHubBot, parts: [string, string], urlParams: URLSearchParams): Promise { 348 | const chatID = parseInt(parts[0], 10); 349 | const chatToken = parts[1]; 350 | const params: Params = { 351 | silent: urlParams.has("silent"), 352 | }; 353 | 354 | const body = await req.body(); 355 | const data = JSON.parse(decoder.decode(body)); 356 | const event = req.headers.get("X-GitHub-Event"); 357 | const reply = await bot.githubEvent(chatID, chatToken, params, event, data); 358 | return respondIfReply(reply); 359 | } 360 | 361 | async function serveTelegramWebhook(req: ServerRequest, bot: GitHubBot, parts: [string], params: URLSearchParams): Promise { 362 | const telegramSecret = parts[0]; 363 | 364 | const body = await req.body(); 365 | const data = JSON.parse(decoder.decode(body)); 366 | const reply = await bot.telegramUpdate(telegramSecret, data); 367 | return respondIfReply(reply); 368 | } 369 | 370 | async function respondIfReply(reply?: object): Promise { 371 | if (reply) { 372 | const headers = new Headers(); 373 | headers.set("Content-Type", "application/json"); 374 | return { 375 | status: 200, 376 | headers, 377 | body: encoder.encode(JSON.stringify(reply)), 378 | }; 379 | } 380 | return { 381 | status: 200, 382 | body: encoder.encode("Success"), 383 | }; 384 | } 385 | 386 | async function serveBadRequest(): Promise { 387 | return { 388 | status: 400, 389 | body: encoder.encode("Bad Request"), 390 | }; 391 | } 392 | 393 | async function serveNotFound(): Promise { 394 | return { 395 | status: 404, 396 | body: encoder.encode("Not Found"), 397 | }; 398 | } 399 | 400 | async function serveMethodNotAllowed(): Promise { 401 | return { 402 | status: 405, 403 | body: encoder.encode("Method Not Allowed"), 404 | }; 405 | } 406 | 407 | async function serveInternalServerError(): Promise { 408 | return { 409 | status: 500, 410 | body: encoder.encode("Unknown Internal Server Error"), 411 | }; 412 | } 413 | 414 | async function route(req: ServerRequest, bot: GitHubBot): Promise { 415 | const url = new URL(req.url, "https://localhost"); // Need a better URL parser 416 | const params = url.searchParams; 417 | const parts = url.pathname.split("/").filter(part => part); 418 | if (parts.length === 2) { 419 | if (req.method !== "POST") { 420 | return await serveMethodNotAllowed(); 421 | } 422 | if (parts[0] === "telegramUpdate") { 423 | return await serveTelegramWebhook(req, bot, [parts[1]], params); 424 | } 425 | } 426 | if (parts.length === 3) { 427 | if (req.method !== "POST") { 428 | return await serveMethodNotAllowed(); 429 | } 430 | if (parts[0] === "github") { 431 | return await serveGitHubWebhook(req, bot, [parts[1], parts[2]], params); 432 | } 433 | } 434 | return await serveNotFound(); 435 | } 436 | 437 | async function main(): Promise { 438 | const telegramToken = env()["TELEGRAM_TOKEN"]; 439 | const telegramSecret = env()["TELEGRAM_SECRET"] || "insecure"; 440 | const secret = env()["GITHUBBOT_SECRET"]; 441 | const baseURL = env()["GITHUBBOT_BASE_URL"]; 442 | const listen = env()["LISTEN"] || "127.0.0.1:8080"; 443 | 444 | const bot = new GitHubBot({ 445 | telegramToken, 446 | telegramSecret, 447 | secret, 448 | baseURL, 449 | listen, 450 | }); 451 | 452 | listenAndServe(listen, async (req): Promise => { 453 | let response: Response; 454 | 455 | try { 456 | response = await route(req, bot); 457 | } catch (e) { 458 | if (e.message.startsWith('invalid')) { 459 | response = await serveBadRequest(); 460 | } else { 461 | response = await serveInternalServerError(); 462 | console.error(e); 463 | } 464 | } 465 | req.respond(response); 466 | }); 467 | 468 | await bot.setup(); 469 | } 470 | 471 | main(); 472 | --------------------------------------------------------------------------------