Home/Blogs/Setting up a Telegram Bot That Commits Your Daily Logs to GitHub

Setting up a Telegram Bot That Commits Your Daily Logs to GitHub

July 17, 20266 min read--- views

Every day I post a grind log on X — what I built, what I solved, what I learned. I also keep a structured learning-logs.ts file on GitHub that my portfolio reads to render a timeline. Manually copying the tweet into the file and committing it every night got old fast.

So I built a Telegram bot that does it for me. Send it a tweet URL, it fetches the content, parses it, shows a preview, and on confirmation commits it straight to GitHub. No API keys for Twitter, no manual copy-paste, no git commands.

This post walks through how it works, how it is built, and how to set it up yourself.

Architecture Overview

The bot has three external integrations and zero dependencies beyond dotenv:

  • Telegram — the interface. The bot uses long-polling (getUpdates) so there is no webhook server to manage.
  • Twitter oEmbed API — a free, public endpoint that returns a tweet's rendered HTML. No developer account or bearer token required.
  • GitHub Contents API — reads and writes a single file in a repository using a personal access token.

Everything runs as a single bot.ts process. There is no database — pending confirmations are held in a Map in memory.

Step 1 — Receiving a Message

The bot runs an infinite loop that calls getUpdates with a 30-second long-poll timeout. When a message arrives it is routed to handleMessage.

The first thing handleMessage does is check whether the text looks like a tweet URL:

const tweetUrlRegex = /^https?:\/\/(?:twitter\.com|x\.com)\/\w+\/status\/\d+/;

if (tweetUrlRegex.test(text.trim())) {
  const tweetId = extractTweetId(text.trim());
  // fetch tweet text, then continue to parse
}

If it is not a URL, the raw text is passed directly to the parser. Both paths end up at the same parseRawLog call, so you can also just paste the log text without posting a tweet first.

Step 2 — Fetching the Tweet (No API Key)

Rather than the paid Twitter API v2, the bot uses the public oEmbed endpoint that Twitter's own embed widget uses:

async function fetchTweetText(tweetId: string): Promise<string> {
  const tweetUrl = `https://x.com/i/status/${tweetId}`;
  const oEmbedUrl =
    `https://publish.twitter.com/oembed?url=${encodeURIComponent(tweetUrl)}&omit_script=true`;

  const res = await fetch(oEmbedUrl, {
    headers: { "User-Agent": "Mozilla/5.0 (compatible; TelegramBot/1.0)" },
  });

  const data = await res.json() as { html?: string };

  // The tweet body lives inside the first <p> tag
  const pMatch = data.html!.match(/<p[^>]*>([\s\S]*?)<\/p>/i)!;
  let text = pMatch[1];

  text = text.replace(/<br\s*\/?>/gi, "\n");               // line breaks
  text = text.replace(/<a[^>]+href="([^"]*)"[^>]*>.*?<\/a>/gi, "$1"); // links
  text = text.replace(/<[^>]+>/g, "");                     // remaining tags
  text = text.replace(/&amp;/g, "&").replace(/&nbsp;/g, " "); // entities

  return text.trim();
}

The oEmbed response is HTML, not plain text, so we pull the <p> tag, convert <br> to newlines, strip tags, and decode HTML entities. The result is the raw tweet text — identical to what you typed when you posted it.

Step 3 — Parsing the Log

The extracted text (or pasted raw text) is fed into parseRawLog in parser.ts. It expects a first line matching:

Grind Log #011 - 16 July 2026

The function pulls the log number and date from that header line, then processes every subsequent bullet point — stripping leading dashes, capitalising the first letter, and converting any parenthesised URLs into Markdown links.

export function parseRawLog(raw: string): LearningLogEntry {
  const titleMatch = lines[0].match(/Grind\s+Log\s+(#\d+)\s*-\s*([^-]+)/i);
  const logHeader = titleMatch[1]; // "#011"
  const formattedDate = "July 16, 2026";

  const bulletLines = lines.slice(1).map(line => {
    line = line.replace(/^[-*]\s*/, "").trim();
    line = line.charAt(0).toUpperCase() + line.slice(1);
    return `- ${formatInlineLinks(line)}`;
  });

  return {
    date: formattedDate,
    content: `### Grind Log ${logHeader}\n${bulletLines.join("\n")}`,
  };
}

The output is a LearningLogEntry — the same shape as every entry in learning-logs.ts.

Step 4 — Confirm Before Committing

Parsing does not immediately commit. The entry is stored in a Map<chatId, PendingLog> and the bot sends a preview with two inline buttons:

pendingLogsStore.set(chatId, {
  date: parsed.date,
  content: parsed.content,
  timestamp: Date.now(),
});

await sendTelegramRequest("sendMessage", {
  chat_id: chatId,
  text: `[BOT] Parsed Log Entry:\n\nDate: ${parsed.date}\n\n${parsed.content}\n\nCommit this to GitHub?`,
  reply_markup: {
    inline_keyboard: [[
      { text: "[OK] Commit", callback_data: `commit_${chatId}` },
      { text: "[X] Cancel", callback_data: `cancel_${chatId}` },
    ]],
  },
});

Tapping Cancel deletes the pending entry and edits the message. Tapping Commit moves to the GitHub write step.

Step 5 — Writing to GitHub

The bot does not clone the repository. It uses the GitHub Contents API to read and write a single file in two requests.

Read the current file:

const getUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${filePath}?ref=${branch}`;
const fileData = await fetch(getUrl, { headers: { Authorization: `Bearer ${githubToken}` } })
  .then(r => r.json()) as { content: string; sha: string };

const currentContent = Buffer.from(fileData.content, "base64").toString("utf8");

The sha returned here is required by the PUT request — GitHub uses it to detect conflicts.

Inject the new entry and write back:

const updatedContent = appendLogToSource(currentContent, pending);

await fetch(putUrl, {
  method: "PUT",
  headers: { Authorization: `Bearer ${githubToken}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    message: `docs: add grind log for ${pending.date}`,
    content: Buffer.from(updatedContent, "utf8").toString("base64"),
    sha: fileData.sha,
    branch,
  }),
});

appendLogToSource finds the closing ]; of the learningLogs array and splices the new entry in just before it, preserving the exact TypeScript source format.

Setting It Up Yourself

1. Create the Telegram bot

Open @BotFather on Telegram, send /newbot, and copy the token.

Find your own Telegram user ID by messaging @userinfobot.

2. Create a GitHub personal access token

Go to GitHub Settings -> Developer settings -> Personal access tokens -> Fine-grained tokens. Grant it Contents: Read and Write on the target repository only.

3. Clone and install

git clone https://github.com/Saurabh-2607/testing-bot
cd testing-bot
npm install

4. Create .env

TELEGRAM_BOT_TOKEN=your_bot_token_here
TELEGRAM_ALLOWED_USER_ID=your_numeric_user_id
GITHUB_TOKEN=github_pat_xxxxxxxx
GITHUB_FILE_URL=https://github.com/you/your-repo/blob/main/path/to/learning-logs.ts

GITHUB_FILE_URL is the only GitHub variable needed. The bot parses the owner, repo, branch, and file path directly from it.

5. Run locally

npm run dev

Nodemon watches for file changes and restarts via tsx.

6. Deploy to Render

Create a new Web Service on Render, connect the GitHub repo, and set the start command to npm run start. Add the four environment variables from your .env.

The bot includes a minimal HTTP server that binds to process.env.PORT — this is the only reason it is a Web Service rather than a Background Worker, since Render requires a bound port to mark a service as Deployed.

http.createServer((_, res) => {
  res.writeHead(200);
  res.end("OK");
}).listen(process.env.PORT || 3000);

Once deployed, send /start to your bot on Telegram to confirm it is live.

Using It

Send either of these to the bot:

A tweet URL:

https://x.com/SaurabhSharma_I/status/1234567890

The bot fetches the tweet text, parses it, and shows a preview.

Raw text directly:

Grind Log #012 - 17 July 2026

- Solved LeetCode 704, 35, 33.
- Read about binary search edge cases.
- Started writing this blog post.

Review the preview, tap [OK] Commit, and the entry is committed to your GitHub file within a couple of seconds.

Designed & Developed by Saurabh

© 2026 All rights reserved.

Visitors#...

Jaipur, India --:-- -- IST