Free Telegram Community Report Bot (GitHub Actions + Gemini)

I run community channels on Telegram, and for a while an n8n workflow did the boring part for me. Every evening it dropped a report into a private alert group. Then I hit the point where keeping it running meant paying, the reports stopped, and I was back to scrolling through hundreds of messages by hand.

I did not want another subscription. I wanted a free n8n alternative with no server to look after and nothing to babysit. So I rebuilt it from scratch with two free things: GitHub Actions to run a small Python script, and the Gemini free tier to read the chat.

It works. It also broke in five different ways on the way there, and I will walk through each one. This guide has the full code, the exact steps, and real screenshots (names, numbers and links masked).

Quick summary
  • You get: a daily community report, instant urgent-word alerts, and automatic FAQ replies.
  • Cost: zero, on free tiers (limits change, so check them).
  • Setup time: about 30 to 45 minutes, all in the browser.
  • The catch: it runs about every 30 minutes, so it is not instant.

What is in this guide

  1. What the bot does
  2. How it works (and why it costs nothing)
  3. What you need
  4. Step 1: Create the bot
  5. Step 2: Find your group IDs
  6. Step 3: Create a private GitHub repo
  7. Step 4: Add the files
  8. Step 5: Add the workflow
  9. Step 6: Add your secrets
  10. Step 7: Get a free Gemini key
  11. Step 8: Run your first test
  12. Customise it
  13. The five things that broke
  14. Limitations
  15. Privacy
  16. FAQ

What the Telegram community bot does

Three jobs, all automatic:

  • Daily report. Sent to a private alert group once a day (9 PM by default). It shows the current member count, how many joined and left, how many messages members posted, which announcements went out, and the top positive, negative and neutral themes with a count for each. It also splits the comments by segment, for example by campus, course or city.
  • Urgent alerts. If a member posts a word like "scam", "refund" or "complaint", the bot sends the message to your alert group with a link straight to it.
  • FAQ auto-replies. When someone asks "when is the exam?", the bot replies to that message with your prepared answer. It matches words in any order, so Hinglish like "exam kab hai" works too.

How it works, and why it costs nothing

A Telegram bot normally needs a server that is always on. Mine does not. Telegram keeps the messages a bot has not read yet for up to 24 hours. So GitHub wakes my script up every 30 minutes, the script collects everything new, handles it, saves it, and goes back to sleep.

Diagram showing a Telegram community group, GitHub Actions running a script every 30 minutes, the Gemini API and a private alert group
How the pieces connect. Telegram holds the messages, GitHub Actions does the work, Gemini reads the chat, and the report lands in a private group.

Each run takes 10 to 30 seconds. At 48 runs a day that is roughly 1,400 billed minutes a month, which fits inside the free allowance for a private repo. If you want a wider safety margin, switch to hourly runs (I show how below).

Why not a free web host? Many free hosts put your app to sleep when nobody is visiting. A bot that has to stay connected to Telegram does not survive that. Running on a schedule avoids the problem completely.

What you need

  • A Telegram community group where you can add a bot as an admin.
  • A small private alert group (just you and the bot). Reports and alerts go here, not into the community.
  • A free GitHub account.
  • A Google account for a free Gemini key. This one is optional, but without it you only get counts, alerts and FAQ replies. The positive, negative and segment sections need it.

Step 1: Create the bot

  1. In Telegram, message @BotFather and send /newbot. Follow the prompts and copy the token. Treat it like a password.
  2. Send /setprivacy, pick your bot and choose Disable. This lets the bot read normal group messages.
  3. Add the bot to your community group and make it an admin. Admin status is what lets it reliably see who joins and leaves.
  4. Add the bot to your alert group as well.

Step 2: Find your two group IDs

  1. Open https://api.telegram.org/bot<YOUR_TOKEN>/deleteWebhook in your browser with your real token. You should see "ok":true. If you used another automation tool before, this clears the webhook it left behind, which would otherwise block the bot.
  2. Post one message in the community group and one in the alert group.
  3. Open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates and look for "chat":{"id":-100…. You will see two numbers. Write down which is which, including the minus sign.
Careful: those URLs contain your token. Never share a screenshot of them.

Step 3: Create a private GitHub repo

Go to github.com, click New repository, give it any name, tick Private and add a README. Private matters: the bot saves recent messages in a data folder inside this repo, so it must not be public.

Step 4: Add the files

Click Add file, then Upload files, and upload two files: run.py (the script) and faq.json (your auto-replies). The full script is below. Copy it into a file called run.py.

Show the full run.py (550 lines, copy and paste)
"""
Telegram community bot - runs FREE on GitHub Actions (no server needed).

Every ~30 min GitHub runs this script. Each run it:
  1. reads new messages + join/leave events from the community group(s)
  2. saves them in the data/ folder
  3. sends instant alerts for urgent keywords to your alert group
  4. auto-replies to FAQs (exam date, website link ...) from faq.json
  5. after REPORT_HOUR (IST) sends the daily report once a day

Only standard Python is used - nothing to install.
"""
import os, re, json, time, urllib.request, urllib.parse, urllib.error
from datetime import datetime
from zoneinfo import ZoneInfo
from collections import Counter
from html import escape

# ---------- settings (come from GitHub Secrets) ----------
TOKEN = os.environ["BOT_TOKEN"]
ADMIN = int(os.environ["ADMIN_CHAT_ID"])                       # alert/report group id
GROUPS = {int(x) for x in os.getenv("GROUP_IDS", "").replace(" ", "").split(",") if x}
GEMINI_KEY = os.getenv("GEMINI_API_KEY", "").strip()           # free key from aistudio.google.com
GEMINI_MODEL = os.getenv("GEMINI_MODEL") or "gemini-2.5-flash"
REPORT_HOUR = int(os.getenv("REPORT_HOUR") or "21")            # 21 = 9 PM IST
FORCE_REPORT = os.getenv("FORCE_REPORT", "") == "true"
TZ = ZoneInfo("Asia/Kolkata")
FAQ_USER_COOLDOWN = 600    # seconds: same person asking the same FAQ again within 10 min gets no 2nd reply
FAQ_MAX_PER_RUN = 3        # max replies per FAQ per group in one run (avoids spamming)
KEEP_DAYS = 14
MAX_AI_MSGS = 800          # max student messages sent to Gemini per report
TOP_POS, TOP_NEG, TOP_NEU = 6, 6, 10

# --- EDIT THESE TWO to describe your community --------------------------------------------
ORG = "an online learning community"          # one line the AI reads to understand your members
SEGMENT_TITLE = "Segment-wise"                # heading shown in the report
SEGMENTS = {                                   # campuses, courses, cities, product lines ... (any 2-6)
    "Segment A": "words that identify it, e.g. the city or campus name",
    "Segment B": "words that identify it",
    "Segment C": "words that identify it",
}
# --------------------------------------------------------------------------------------------
ANON_IDS = {1087, 136817688}   # Telegram's "anonymous admin" / channel sender ids (treated as admin posts)

URGENT = [k.strip() for k in (os.getenv("URGENT_KEYWORDS") or
    "scam,fraud,fake,cheat,refund,legal,lawyer,court,consumer forum,complaint,harass,ragging,"
    "suicide,unsafe,police,media,worst,waste of money,deposit").lower().split(",") if k.strip()]

HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, "data")
MSGS = os.path.join(DATA, "messages.jsonl")
EVENTS = os.path.join(DATA, "events.jsonl")
STATE = os.path.join(DATA, "state.json")
FAQS = json.load(open(os.path.join(HERE, "faq.json"), encoding="utf-8"))
IN_STATUS = {"member", "administrator", "creator"}


# ---------- telegram ----------
def tg(method, **params):
    data = urllib.parse.urlencode(params).encode()
    req = urllib.request.Request(f"https://api.telegram.org/bot{TOKEN}/{method}", data=data)
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)


def send(chat_id, text, **extra):
    try:
        tg("sendMessage", chat_id=chat_id, text=text, disable_web_page_preview="true", **extra)
    except Exception as e:
        print("send failed:", type(e).__name__, e)


def msg_link(chat, mid):
    s = str(chat)
    return f"https://t.me/c/{s[4:]}/{mid}" if s.startswith("-100") else ""


_admin_cache = {}


def admins_of(chat_id):
    if chat_id not in _admin_cache:
        try:
            res = tg("getChatAdministrators", chat_id=chat_id)["result"]
            _admin_cache[chat_id] = {a["user"]["id"] for a in res}
        except Exception as e:
            print("getChatAdministrators failed:", type(e).__name__, e)
            _admin_cache[chat_id] = set()
    return _admin_cache[chat_id]


# ---------- state / files ----------
def load_state():
    try:
        s = json.load(open(STATE))
    except Exception:
        s = {}
    s.setdefault("offset", 0)
    s.setdefault("last_report", "")
    s.setdefault("faq_last", {})
    s.setdefault("chats", {})
    s.setdefault("counts", {})
    return s


def save_state(state):
    json.dump(state, open(STATE, "w"))


def read_jsonl(path):
    rows = []
    if os.path.exists(path):
        for line in open(path, encoding="utf-8"):
            try:
                rows.append(json.loads(line))
            except Exception:
                pass
    return rows


def write_jsonl(path, rows):
    with open(path, "w", encoding="utf-8") as f:
        for r in rows:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")


# ---------- 1. fetch new messages and join/leave events ----------
def wanted(chat):
    return (chat["type"] in ("group", "supergroup") and chat["id"] != ADMIN
            and (not GROUPS or chat["id"] in GROUPS))


def poll(state):
    tg("deleteWebhook")   # makes sure an old n8n webhook doesn't block us
    msgs, events = [], []
    allowed = json.dumps(["message", "chat_member"])
    while True:
        res = tg("getUpdates", offset=state["offset"], limit=100, timeout=0, allowed_updates=allowed)["result"]
        if not res:
            break
        for u in res:
            state["offset"] = u["update_id"] + 1

            cm = u.get("chat_member")                      # join / leave (bot must be admin)
            if cm:
                if wanted(cm["chat"]):
                    old, new = cm["old_chat_member"], cm["new_chat_member"]
                    was_in = old["status"] in IN_STATUS or (old["status"] == "restricted" and old.get("is_member"))
                    is_in = new["status"] in IN_STATUS or (new["status"] == "restricted" and new.get("is_member"))
                    if not new["user"].get("is_bot") and was_in != is_in:
                        events.append({"chat": cm["chat"]["id"], "uid": new["user"]["id"],
                                       "kind": "join" if is_in else "left", "ts": cm["date"]})
                continue

            m = u.get("message")
            if not m or not wanted(m["chat"]):
                continue
            chat = m["chat"]
            state["chats"][str(chat["id"])] = chat.get("title", "")

            # fallback join/leave from service messages (de-duplicated later)
            for nm in m.get("new_chat_members", []):
                if not nm.get("is_bot"):
                    events.append({"chat": chat["id"], "uid": nm["id"], "kind": "join", "ts": m["date"]})
            lm = m.get("left_chat_member")
            if lm and not lm.get("is_bot"):
                events.append({"chat": chat["id"], "uid": lm["id"], "kind": "left", "ts": m["date"]})

            text = m.get("text") or m.get("caption")
            if not text:
                continue
            frm = m.get("from", {})
            uid = frm.get("id", 0)
            if frm.get("is_bot") and uid not in ANON_IDS:
                continue
            is_admin = (uid in ANON_IDS or "sender_chat" in m or uid in admins_of(chat["id"]))
            msgs.append({
                "chat": chat["id"], "title": chat.get("title", ""), "mid": m["message_id"], "uid": uid,
                "user": frm.get("username") or frm.get("first_name") or str(uid),
                "text": text, "ts": m["date"], "admin": is_admin,
                "reply_to": (m.get("reply_to_message") or {}).get("message_id"),
            })
    return msgs, events


# ---------- 2/3/4. store, alert, auto-reply ----------
_sent_run = Counter()
_warned = set()


def _has(low, phrase):
    return re.search(r"\b" + re.escape(phrase.lower()) + r"\b", low) is not None


def match_faq(low):
    """A keyword is either a phrase ("exam date") or a list of words that must ALL appear (["exam","kab"])."""
    for faq in FAQS:
        for kw in faq["keywords"]:
            ok = all(_has(low, w) for w in kw) if isinstance(kw, list) else _has(low, kw)
            if ok:
                return faq
    return None


def handle(r, state):
    if r["admin"]:
        print(f"msg {r['mid']}: skipped (sent by an admin)")
        return
    low = r["text"].lower()
    hits = [k for k in URGENT if k in low]
    faq = match_faq(low)
    print(f"msg {r['mid']}: faq={faq['name'] if faq else None} urgent={bool(hits)}")
    if hits:
        send(ADMIN,
             f" <b>Possible urgent message</b> ({escape(', '.join(hits))})\n"
             f"<b>{escape(r['title'])}</b> · @{escape(r['user'])}\n\n{escape(r['text'][:500])}\n\n"
             f"{msg_link(r['chat'], r['mid'])}", parse_mode="HTML")
    if not faq:
        return
    if "<FILL_IN" in faq["reply"]:     # never post unfinished placeholder text to the community
        if faq["name"] not in _warned:
            _warned.add(faq["name"])
            send(ADMIN, f"⚠️ FAQ '{faq['name']}' matched a message, but its reply in faq.json still has "
                        f"<FILL_IN...> placeholders, so I did not post it. Please edit faq.json.")
        return
    key = f"{r['chat']}:{faq['name']}:{r['uid']}"
    if r["ts"] - state["faq_last"].get(key, 0) < FAQ_USER_COOLDOWN:
        return
    cap = (r["chat"], faq["name"])
    if _sent_run[cap] >= FAQ_MAX_PER_RUN:
        return
    _sent_run[cap] += 1
    state["faq_last"][key] = r["ts"]
    send(r["chat"], faq["reply"], reply_to_message_id=r["mid"])


def store(new_msgs, new_events):
    os.makedirs(DATA, exist_ok=True)
    cutoff = time.time() - KEEP_DAYS * 86400
    msgs = [r for r in read_jsonl(MSGS) + new_msgs if r["ts"] >= cutoff]
    for r in msgs:                      # records saved by older versions lack these fields
        r.setdefault("admin", False)
        r.setdefault("uid", 0)
        r.setdefault("reply_to", None)
        r.setdefault("title", "")
    write_jsonl(MSGS, msgs)

    events = read_jsonl(EVENTS)
    seen = {(e["chat"], e["uid"], e["kind"], e["ts"] // 60) for e in events}
    for e in new_events:   # same join reported twice (chat_member + service msg) is counted once
        k = e["ts"] // 60
        if not any((e["chat"], e["uid"], e["kind"], k + d) in seen for d in (-1, 0, 1)):
            events.append(e)
            seen.add((e["chat"], e["uid"], e["kind"], k))
    events = [e for e in events if e["ts"] >= cutoff]
    write_jsonl(EVENTS, events)
    return msgs, events


# ---------- 5. report ----------
STOP = set("""the and for are you this that with have not can what how when will from your but all any has was get got
does did who why where which there their they them its our out about just know please pls hai hain kya kaise kab
mein main hum aap nahi nhi ho hoga kar karna ke ki ka ko se to ye yeh wo woh bhi aur ya koi sir mam bhai hello hii
hey thanks thank okay sure yes yeah also only more some been were would could should than then into like one two
after before over still need want much many very https http www com guys guy group""".split())


def keyword_summary(rows):
    words = Counter()
    for r in rows:
        for w in re.findall(r"[a-zA-Z][a-zA-Z0-9+#]{2,}", r["text"].lower()):
            if w not in STOP:
                words[w] += 1
    return ", ".join(f"{w} ({n})" for w, n in words.most_common(10))


def one_line(t, n):
    return re.sub(r"\s+", " ", t).strip()[:n]


def _gemini_error(e, model):
    try:
        err = json.load(e)["error"]
        return f"{model}: {err.get('code')} {err.get('status', '')} - {str(err.get('message', ''))[:160]}"
    except Exception:
        return f"{model}: HTTP {e.code}"


def _gemini_post(model, body):
    req = urllib.request.Request(
        f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
        data=body, headers={"Content-Type": "application/json", "x-goog-api-key": GEMINI_KEY})
    with urllib.request.urlopen(req, timeout=120) as r:
        res = json.load(r)
    text = res["candidates"][0]["content"]["parts"][0]["text"]
    return json.loads(re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip())


def _flash_models():
    """Ask Google which 'flash' models this key can use (newest first) - model names change over time."""
    req = urllib.request.Request("https://generativelanguage.googleapis.com/v1beta/models?pageSize=200",
                                 headers={"x-goog-api-key": GEMINI_KEY})
    with urllib.request.urlopen(req, timeout=30) as r:
        res = json.load(r)
    found = []
    for m in res.get("models", []):
        name = m["name"].split("/")[-1]
        mt = re.fullmatch(r"gemini-(\d+(?:\.\d+)?)-flash", name)
        if mt and "generateContent" in m.get("supportedGenerationMethods", []):
            found.append((float(mt.group(1)), name))
    return [n for _, n in sorted(found, reverse=True)]


def gemini_call_with_fallback(body):
    queue, extended, errors = [GEMINI_MODEL], False, []
    while queue and len(errors) < 4:
        model = queue.pop(0)
        try:
            out = _gemini_post(model, body)
            print("Gemini model used:", model)
            return out
        except urllib.error.HTTPError as e:
            errors.append(_gemini_error(e, model))
        except Exception as e:
            errors.append(f"{model}: {type(e).__name__}")
        print("Gemini failed:", errors[-1])
        if not extended:
            extended = True
            try:
                queue += [m for m in _flash_models() if m != model][:3]
            except Exception as e:
                print("Gemini model list failed:", type(e).__name__)
    raise RuntimeError(errors[0] if errors else "unknown error")


def gemini_analysis(students, admins):
    lines = "\n".join(f"{i}. {one_line(r['text'], 250)}" for i, r in enumerate(students, 1))
    admin_lines = "\n".join(f"- {one_line(r['text'], 200)}" for r in admins[:40]) or "(none)"
    seg_schema = ",\n".join(f'   "{n}": {{"positive": [...], "negative": [...]}}' for n in SEGMENTS)
    seg_rules = "\n".join(f"  {n} = {hint}" for n, hint in SEGMENTS.items())
    prompt = f"""You are analysing one day of a Telegram community: {ORG}.
Messages may be in several languages or mixed languages.

MEMBER MESSAGES (numbered):
{lines}

ADMIN POSTS:
{admin_lines}

Return ONLY JSON in exactly this shape:
{{
 "positive": [{{"theme": str, "ids": [int]}}],
 "negative": [{{"theme": str, "ids": [int]}}],
 "neutral":  [{{"theme": str, "ids": [int]}}],
 "segments": {{
{seg_schema}
 }},
 "announcements": [str]
}}

Rules:
- "ids" are the numbers of the MEMBER MESSAGES that belong to that theme. Every id appears in at most one theme
  of the overall positive/negative/neutral lists. Skip greetings, chit-chat and messages with no clear topic.
- Merge similar messages into ONE short, specific theme label, e.g. "Exam preparation tips and past papers",
  "Product out of stock concerns", "Confusion about the schedule".
- positive = praise, gratitude, helpful peer support, excitement. negative = complaints, worries, anger, confusion
  with a bad tone, unavailability. neutral = factual questions and discussions with no clear sentiment.
- Segment lists (key "segments"): only messages that clearly refer to that segment. Hints:
{seg_rules}
  A message can appear both in the overall lists and in a segment list. Keep segment theme labels short.
- "announcements": short labels (2-5 words) for genuine community announcements in ADMIN POSTS
  (e.g. "QOTD", "Interview webinar", "Scam awareness", "Admission updates", "Poll"). Skip small replies.
"""
    body = json.dumps({"contents": [{"parts": [{"text": prompt}]}],
                       "generationConfig": {"responseMimeType": "application/json",
                                            "temperature": 0.2, "maxOutputTokens": 8192}}).encode()
    return gemini_call_with_fallback(body)


def themes(groups, n_msgs, limit):
    """Count = number of valid message ids in the group (counted by us, not guessed by the AI)."""
    out = []
    for g in groups or []:
        ids = set()
        for i in g.get("ids", []):
            try:
                if 1 <= int(i) <= n_msgs:
                    ids.add(int(i))
            except Exception:
                pass
        if ids and str(g.get("theme", "")).strip():
            out.append((escape(str(g["theme"]).strip()), len(ids)))
    out.sort(key=lambda x: -x[1])
    return out[:limit]


def bullets(items):
    return [f"• {t} – {n}" for t, n in items] or ["• –"]


def build_report(all_msgs, all_events, state):
    now = time.time()
    since = now - 86400
    rows = [r for r in all_msgs if r["ts"] >= since]
    students = [r for r in rows if not r["admin"]]
    admins = [r for r in rows if r["admin"]]
    ev = [e for e in all_events if e["ts"] >= since]
    joined = sum(1 for e in ev if e["kind"] == "join")
    left = sum(1 for e in ev if e["kind"] == "left")

    # members count (+ change since the previous report)
    today = datetime.now(TZ).strftime("%Y-%m-%d")
    chats = list(GROUPS) or [int(c) for c in state["chats"]]
    counts, total = {}, 0
    for c in chats:
        try:
            counts[str(c)] = tg("getChatMemberCount", chat_id=c)["result"]
            total += counts[str(c)]
        except Exception as e:
            print("member count failed:", type(e).__name__, e)
    prev_days = sorted(d for d in state["counts"] if d < today)
    delta = ""
    if counts and prev_days:
        prev = state["counts"][prev_days[-1]]
        prev_total = sum(prev.get(k, 0) for k in counts)
        delta = f" ({total - prev_total:+d} since last report)"
    if counts and not FORCE_REPORT:
        state["counts"][today] = counts
        for d in sorted(state["counts"])[:-30]:
            del state["counts"][d]
    members_line = f"{total}{delta}" if counts else "unavailable"

    # announcements + participation (students replying to admin posts)
    admin_mids = {(r["chat"], r["mid"]) for r in all_msgs if r["admin"]}
    qotd_mids = {(r["chat"], r["mid"]) for r in all_msgs if r["admin"] and
                 re.search(r"qotd|question of the day", r["text"], re.I)}
    part_all = {r["uid"] for r in students if (r["chat"], r.get("reply_to")) in admin_mids}
    part_qotd = {r["uid"] for r in students if (r["chat"], r.get("reply_to")) in qotd_mids}
    qotd_today = any((r["chat"], r["mid"]) in qotd_mids for r in admins)

    # AI analysis
    analysis, note = None, ""
    ai_students = students[-MAX_AI_MSGS:]
    if not GEMINI_KEY:
        note = "⚠️ Sentiment & campus sections need a GEMINI_API_KEY secret."
    elif students:
        try:
            analysis = gemini_analysis(ai_students, admins)
            if len(students) > len(ai_students):
                note = f"ℹ️ Sentiment based on the latest {len(ai_students)} of {len(students)} student messages."
        except Exception as e:
            print("AI analysis failed:", e)
            note = f"⚠️ AI analysis failed: {escape(str(e)[:250].rstrip('.'))}. Showing basic summary."

    labels = [escape(str(a)) for a in (analysis or {}).get("announcements", [])][:8]
    if not analysis:
        labels = [escape(one_line(r["text"], 45)) for r in admins if len(r["text"]) >= 40][:6]
    if qotd_today and not any("qotd" in l.lower() for l in labels):
        labels.insert(0, "QOTD")
    labels = [f"{l} ({len(part_qotd)} members replied)" if "qotd" in l.lower() else l for l in labels]

    out = [f" <b>Daily report</b> · {datetime.now(TZ):%d %b %Y} (last 24h)\n",
           f"<b>Current members count:</b> {members_line}",
           f"<b>Joined:</b> {joined}",
           f"<b>Left:</b> {left}",
           f"<b>Member-led engagement:</b> (Messages - {len(students)})",
           f"<b>Announcements:</b> {', '.join(labels) if labels else '–'}. (Members participated - {len(part_all)})",
           "<b>Text deleted by admin:</b> – (Telegram doesn't tell bots; copy from the group's Admin log)"]

    if analysis:
        n = len(ai_students)
        out += ["", "<b>Top +ve Comments:</b>"] + bullets(themes(analysis.get("positive"), n, TOP_POS))
        out += ["", "<b>Top -ve Comments:</b>"] + bullets(themes(analysis.get("negative"), n, TOP_NEG))
        out += ["", "<b>Neutral:</b>"] + bullets(themes(analysis.get("neutral"), n, TOP_NEU))
        out += ["", f" <b>{SEGMENT_TITLE}</b>"]
        camp = analysis.get("segments", {}) or {}
        for c in SEGMENTS:
            d = camp.get(c, {}) or {}
            pos = themes(d.get("positive"), n, 4)
            neg = themes(d.get("negative"), n, 4)
            out.append(f"\n<b>{c}</b>")
            if not pos and not neg:
                out.append("No segment-specific comments")
                continue
            out.append("➕ " + ("; ".join(f"{t} – {k}" for t, k in pos) or "–"))
            out.append("➖ " + ("; ".join(f"{t} – {k}" for t, k in neg) or "–"))
    elif students:
        out += ["", f"<b>Most used words:</b> {escape(keyword_summary(students)) or '–'}"]

    flagged = [r for r in students if any(k in r["text"].lower() for k in URGENT)]
    out += ["", f" <b>Needs attention (urgent keywords): {len(flagged)}</b>" if flagged
            else "✅ <b>No urgent keywords today</b>"]
    for r in flagged[:6]:
        out.append(f"• @{escape(r['user'])}: {escape(one_line(r['text'], 120))} {msg_link(r['chat'], r['mid'])}")
    if note:
        out += ["", note]
    return "\n".join(out)


def chunks(text, limit=3900):
    cur = ""
    for line in text.split("\n"):
        if len(cur) + len(line) + 1 > limit:
            yield cur
            cur = ""
        cur += line + "\n"
    if cur.strip():
        yield cur


def maybe_report(state, msgs, events):
    now = datetime.now(TZ)
    today = now.strftime("%Y-%m-%d")
    if FORCE_REPORT or (now.hour >= REPORT_HOUR and state.get("last_report") != today):
        text = build_report(msgs, events, state)
        for part in chunks(text):
            send(ADMIN, part, parse_mode="HTML")
        if not FORCE_REPORT:
            state["last_report"] = today


# ---------- main ----------
def main():
    os.makedirs(DATA, exist_ok=True)
    state = load_state()
    try:
        new_msgs, new_events = poll(state)
        msgs, events = store(new_msgs, new_events)
        for r in new_msgs:
            try:
                handle(r, state)
            except Exception as e:
                print("handle failed:", type(e).__name__, e)
        maybe_report(state, msgs, events)
        state["faq_last"] = {k: v for k, v in state["faq_last"].items() if v > time.time() - 86400}
        state["last_error"] = ""
        print(f"done: {len(new_msgs)} new messages, {len(new_events)} join/leave events")
    except Exception as e:
        err = f"{type(e).__name__}: {e}"[:300]
        print("RUN FAILED:", err)
        if state.get("last_error") != err:      # tell you in Telegram once, not every 30 min
            send(ADMIN, "⚠️ Bot run failed: " + err)
            state["last_error"] = err
        raise
    finally:
        save_state(state)


if __name__ == "__main__":
    main()

Here is faq.json. Each keyword is either a phrase (like "exam date") or a list of words that must all appear in any order (like ["exam","kab"]):

[
  {
    "name": "exam_date",
    "keywords": [
      "exam date",
      "exam dates",
      "when is the exam",
      "exam schedule",
      ["exam", "kab"],
      ["exam", "when"],
      ["test", "date"]
    ],
    "reply": "Hi!  The next exam date is <FILL_IN_DATE>.\nRegister here: <FILL_IN_EXAM_LINK>"
  },
  {
    "name": "website",
    "keywords": [
      "website link",
      "official website",
      "site link",
      "apply link",
      "how to apply",
      ["website", "link"],
      ["apply", "link"]
    ],
    "reply": "Official website: <FILL_IN_WEBSITE_LINK>"
  },
  {
    "name": "fees",
    "keywords": [
      "fee structure",
      "total fees",
      "how much fee",
      ["fees", "how much"],
      ["fee", "kitna"]
    ],
    "reply": "Fee details are here: <FILL_IN_FEES_LINK>. For specifics, contact: <FILL_IN_CONTACT>"
  }
]

Replace every <FILL_IN_...> with your real date and links. If a reply still contains a placeholder, the bot refuses to post it and warns you in the alert group instead. I added that guard so a half-finished reply can never go out to the whole community.

Step 5: Add the workflow

Click Add file, then Create new file. In the name box type exactly .github/workflows/bot.yml. Typing the slashes creates the folders. Paste this and commit:

name: Community bot

on:
  schedule:
    - cron: "*/30 * * * *"      # runs every 30 minutes
  workflow_dispatch:
    inputs:
      force_report:
        description: "Send the report right now?"
        type: boolean
        default: false

concurrency:
  group: community-bot
  cancel-in-progress: false

permissions:
  contents: write

jobs:
  run:
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v5

      - name: Run bot
        env:
          BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
          ADMIN_CHAT_ID: ${{ secrets.ADMIN_CHAT_ID }}
          GROUP_IDS: ${{ secrets.GROUP_IDS }}
          GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
          GEMINI_MODEL: ${{ secrets.GEMINI_MODEL }}
          FORCE_REPORT: ${{ github.event.inputs.force_report }}
        run: python3 run.py

      - name: Save data
        if: always()
        run: |
          git config user.name "community-bot"
          git config user.email "bot@users.noreply.github.com"
          git add data
          if ! git diff --cached --quiet; then
            git commit -m "update data"
            for i in 1 2 3; do
              if git pull --rebase -X theirs && git push; then
                break
              fi
              git rebase --abort 2>/dev/null || true
              sleep 5
            done
          fi

What the important lines do:

  • cron: "*/30 * * * *" runs it every 30 minutes.
  • workflow_dispatch adds a Run workflow button so you can test without waiting.
  • concurrency stops two runs from overlapping.
  • The Save data step commits the bot's notes back to the repo. It retries with a rebase, and runs even if the bot step fails. Both came from real failures, which I describe below.

Step 6: Add your secrets

Go to Settings, Secrets and variables, Actions, New repository secret and add these:

Secret nameWhat to put in it
BOT_TOKENThe token from BotFather
ADMIN_CHAT_IDThe alert group ID (reports and alerts go here)
GROUP_IDSThe community group ID only. Keep your alert group out of this.
GEMINI_API_KEYYour free Gemini key (next step)
GEMINI_MODELOptional. Only if you want to pin a specific model name.

Step 7: Get a free Gemini key

Open Google AI Studio (aistudio.google.com/apikey), create an API key and paste it into the GEMINI_API_KEY secret. The free tier has rate limits, but one report a day is tiny.

Read this first: on the free tier, Google may use the text you send to improve its products. The bot sends only message text, with no usernames, but members' words still leave Telegram. If your community talks about anything sensitive, skip the key or use a paid plan with different data terms.

Model names change often. If the model you configured stops working, the bot asks Google which models your key can use and tries the newest ones automatically.

Step 8: Run your first test

  1. From a normal member account (not an admin), post a few varied messages in your community group. Include one question like "when is the exam?" and one with a word like "refund".
  2. Open the Actions tab, click your workflow, press Run workflow and tick Send the report now?.
  3. Wait about a minute. The report should appear in your alert group.
Daily Telegram community report showing members count, joined, left, engagement, positive, negative and neutral themes and segment-wise comments
A real report from my first successful run. The member count and exam name are masked, and the segment names are replaced with generic labels.

Before you run it, open run.py and edit the two settings near the top: ORG (one line describing your community, which helps the AI) and SEGMENTS (the groups you want a breakdown for, with hint words for each).

Is it really running by itself?

Open the Actions tab and look at the run list. Runs you started yourself say "Manually run". Automatic ones say "Scheduled". Seeing a Scheduled run with a green tick is your proof that it works without you.

GitHub Actions run list showing a workflow run labelled Scheduled with a green tick
Run #5 says Scheduled. Nobody clicked anything. The red runs at the bottom are my early mistakes, covered below.

Customise it

  • Urgent words. Edit the URGENT list in run.py, or add an URGENT_KEYWORDS secret. Matching is simple substring matching, so "media" also catches "social media". Remove noisy words.
  • Report time. Change REPORT_HOUR (24-hour clock). The time zone is set by the TZ line in run.py.
  • Segments. Edit SEGMENTS to match your community: campuses, courses, cities, product lines.
  • Fewer minutes. Change */30 * * * * to 0 * * * * in the workflow for hourly runs. That halves your usage.
  • More FAQs. Copy a block in faq.json. Check the "recent questions" in your reports to see what people keep asking.

The five things that broke (so you do not have to)

1. "Updates were rejected" on the Save data step

The bot ran fine but could not save its notes. The usual cause is the repo changing while a run is in progress, for example when you edit a file or start another run at the same moment. Git then refuses the push.

GitHub Actions log showing a git push rejected error in the Save data step
The push was rejected. Repo and account names are masked.

Fix: pull and rebase before pushing, retry a few times, and run the save step even if the bot step failed. That is the version in the workflow above. Also: do not edit files while a run is in progress.

2. The report crashed right after I updated the code

Right after I updated the code, a run failed. The culprit was almost certainly records saved by the earlier version, which lacked fields the new report code expected. Fix: fill in missing fields when loading old data. If you change what you store, always handle old records.

3. "Auto-reply is not working"

When replies do not appear, there are three things to check. First, the bot deliberately ignores messages from admins (so it never replies to your own announcements), which means testing from an admin account shows nothing. Second, if a reply in faq.json still has a placeholder, the bot refuses to post it. Third, replies only go out when a run happens, so do not expect an instant answer from something that runs every 30 minutes. Test from a normal member account, fill in the placeholders, and read the run log, which prints whether each message matched an FAQ.

4. "AI analysis failed"

At first the report only said the analysis failed, with no reason. Model names change often and a retired name is a common cause, but a wrong key or a rate limit looks similar. The report now shows Google's actual error message, and the bot discovers a working model on its own. Good error messages saved me more time than anything else in this project.

5. Alerts only arrived when I clicked Run

The schedule had not kicked in yet. GitHub's timer is best effort, and a brand-new schedule can take a while to fire the first time. Mine did start by itself after a while. If yours does not start after a couple of hours, check that the workflow file is on your default branch and that Actions is enabled.

Limitations (read before you rely on it)

  • Not instant. Replies and alerts arrive on the next run, so expect up to about 30 minutes, sometimes more when GitHub is busy.
  • Deleted messages are invisible. Telegram does not tell bots when a message is deleted. The report line for this says so, and you can copy the number from the group's Admin log.
  • Joined and left need the bot to be an admin. "Left" includes members who were removed.
  • Participation counts members who reply to an admin post. People who answer without replying are not counted.
  • Sentiment is a judgement call. The AI groups messages into themes and decides which are positive or negative. The script does the counting from the message numbers the AI assigns, but you should still spot-check the first few reports against your own reading.
  • Gaps are possible. Telegram keeps unread messages for 24 hours. If GitHub skips several runs in a row, some messages can be missed.

Privacy: be a good citizen

  • Keep the repo private. It stores recent messages for 14 days (change KEEP_DAYS if you want less).
  • Only the message text goes to Gemini, with no usernames, but remember the free-tier data terms above.
  • Tell your members. A line in the group rules such as "messages may be analysed in aggregate to improve this community" is honest and costs nothing.
  • Use the data to understand themes and problems, not to profile individual people.

Frequently asked questions

Is this Telegram report bot really free?

Yes, as long as you stay inside the free tiers of GitHub Actions and the Gemini API. At the time of writing, a private GitHub repo on the free plan gets about 2,000 Actions minutes a month, and this bot uses roughly 1,400 at a 30-minute schedule. Free tiers change, so check the current limits before you rely on them.

Can the bot reply to messages instantly?

No. It wakes up about every 30 minutes, so replies and urgent alerts arrive on the next run. Instant replies need a server that is always on, which usually costs money or needs a card.

Do I need to know how to code?

No. You upload two files, paste one workflow file and add a few secrets in your browser. There is no terminal and nothing to install.

Can I use a different AI instead of Gemini?

Yes. All the AI work sits in one function in run.py. Swap the request inside it for any provider you like, but check that provider's pricing and data rules first. Without any AI key you still get message counts, joins and leaves, urgent-word alerts and FAQ replies.

What happens if a run fails?

The bot sends one short error message to your alert group, so you find out in Telegram instead of digging through GitHub logs. If the same error repeats, it does not spam you every 30 minutes.

Wrapping up

The whole thing is one Python file, one workflow file and a handful of secrets. It will not replace a real-time moderation tool, and it is not meant to. What it does is give me a calm daily picture of my community and flag the few messages that need me, without paying for a platform. That was the point.

If you build your own version, I would like to hear what you changed. Tell me in the comments, and share this with the person in your team who still reads the whole group by hand.

Last updated: 5 October 2026. Free-tier limits, model names and GitHub behaviour change, so check the current docs if something here no longer matches.

Pratik Kumar

Pratik Kumar

Your journey begins here. At the intersection of mindful self-care and deliberate growth, I guide you through the terrain of becoming. This isn't just about improvement it's about revelation. Uncovering what already exists within you, waiting to emerge. Here, we explore practical wisdom that nurtures both inner peace and outward achievement. No empty promises just honest pathways to living with greater purpose and authentic joy. Whether you're seeking subtle shifts or profound reinvention, you'll find tools, insights, and a companion for the journey. Step in. The path to your most fulfilled self unfolds one conscious choice with the flow.

More about me

Comments (0)