GitHub avatar

Fox's Blog

I Used Git as a Database to Run a Free Bot on GitHub Actions

How I coded an AI email auto-responder that runs on GitHub

I used git as a database to run a free bot on GitHub Actions

I have an email auto-responder running 24/7.

It reads my mail, figures out what it's about, and replies on its own with AI. It remembers past conversations. It ignores newsletters and noreply@. It forwards to a human when things get too hot.

Monthly cost: 0€.

I skip the server, VPS, and database. Just GitHub Actions and a sick hack: using git as a database.

See where I'm going? No? Well, buckle up, it's dumb and awesome at the same time.


The problem: GitHub Actions is stateless

GitHub Actions is free. You can run a cron every 5 minutes, run your code, free.

But there's a catch: it's stateless.

Every run starts on a pristine machine. Nothing is saved between two runs. The previous run? Forgotten. Erased. As if it never existed.

For an email responder, that's a huge problem. Like:

"What's the last email I already processed?"

If the bot forgets that every run, it'll either re-reply to the same mails in a loop (catastrophe) or miss mails.

You need persistent state. And normally, persistent state = database. But a database is a server, and a server is no longer free.

That's where it gets interesting.


The solution: git tags as a database

Your GitHub repo is already persistent storage. Free. Versioned. Always there.

So why not store state in it?

The idea: on each run, the bot reads the last processed email UID from a git tag. It processes new mails. Then it re-pushes the tag with the new UID.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAP Server
    
    Note over GH: Run #1
    GH->>GIT: read tag "lastid"
    GIT-->>GH: empty (first run)
    GH->>IMAP: fetch mails 1-50
    IMAP-->>GH: 50 mails
    GH->>GIT: push tag "lastid" = 50
    
    Note over GH: Run #2
    GH->>GIT: read tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mails 51-73
    IMAP-->>GH: 23 mails
    GH->>GIT: push tag "lastid" = 73

The git tag IS the database. A single value, but that's all we need.

Reading state

At the start of the job, we fetch the value from the tag:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId means: "give me the content of file data/lastId as it was in tag lastid".

Boom. You have your value, no database.

Writing state

At the end, we re-create the tag with the new value:

git switch --orphan lastid-tmp   # pristine branch with no history
git rm -rf .                      # empty everything
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # force the tag onto this commit
git push --force ...origin lastid # push the tag

We create an orphan branch (no history), put just the lastId file in it, commit, tag, force push.

Why orphan? So we don't accumulate 10,000 state commits in the repo history. Each update overwrites the previous one. The tag always points to ONE commit containing ONE value.

It's clean. It's free. It's completely twisted and it works.


The second hack: the runtime snapshot

There's another problem with GitHub Actions: npm install.

If on every run (every 5 minutes) you do npm install + npm run build, you waste 60-90 seconds each time. On a frequent cron, that's minutes of compute wasted for nothing.

Solution: pre-compile the code ONCE, and store it in a git tag too.

The build workflow (which runs when you push to master) does this:

# compile the code
bun install
bun run build

# store dist/ + node_modules/ in a "runtime" tag
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

The runtime tag holds the compiled code AND the node_modules. All ready to run.

And the cron, in turn, directly checks out that tag:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # the pre-built code, not the source
    fetch-depth: 1

# no npm install, no build!
- name: Process emails
  run: node dist/index.js --action

The cron starts instantly and just runs node dist/index.js, no install or build.

Like, you have two tags doing two jobs:

  • runtime = the code ready to run (updated when you push code)
  • lastid = the persistent state (updated on every run)

It's elegant in a nasty way.


The bot itself: AI auto-responder

OK, the git hack is cool, but what does the bot actually do?

It reads your mails via IMAP, understands them with an AI (Groq + Llama 3.3 70B), and replies automatically.

Clean services architecture with dependency injection (InversifyJS):

App
├── ImapService      → reads mails (IMAP)
├── SmtpService      → sends replies (SMTP)
├── ParserService    → parses mail content
├── ReplyService     → generates the AI reply
├── SummaryService   → conversation memory
├── AccountsService  → manages multiple email accounts
└── ConfigService    → config / env vars

Two operating modes

The bot can run two ways:

Listener mode (real time): permanent IMAP connection with exponential reconnect. For a VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] New mail! Total: ${data.count}`);
  // process the new mail immediately
});

Action mode (batch): processes new mails since lastId, then exits. For the GitHub Actions cron.

node dist/index.js --action

The --action mode is the one using the git hack. It reads lastId, processes what's new, writes the new lastId, done.

Do NOT reply to robots

If your bot replies to ALL mails, it'll reply to newsletters, notifications, noreply@. Catastrophe. Worse: if two bots reply to each other, you get an infinite mail loop. The nightmare.

So aggressive filtering:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Plus detection via email headers:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletters have this
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe in the headers? It's a newsletter. Precedence: bulk? Mass mailing. X-Mailer: Mailchimp? You get the idea. We ignore them.

It's like a nightclub bouncer: robots don't get in.

The magic triggers

The AI can decide not to reply at all, or to hand off to a human. How? With special triggers in its response.

The system prompt tells it:

If it's an automated mail/newsletter → reply <no_reply> If it's too important/sensitive (legal, financial...) → reply <manual_reply_required> Otherwise → write a real reply

And the code reads it:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] The AI decided to ignore. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Too hot, forwarding to a human.");
  await this.smtpService.sendManualForward(...);
  return;
}

// otherwise send the AI reply
await this.smtpService.sendReply(...);

Like, the AI gets to say "nope, not touching that, call a real human". That's wisdom.


Conversation memory

One detail that changes everything: the bot remembers conversations.

When it replies to someone, it saves a summary of the exchange. Next time that person writes, the summary is re-injected into the prompt.

Storage: one JSON file per contact.

data/customers/
├── me%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

And the summary itself is generated by the AI, merging the old summary with the new message:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "You are a memory assistant. Merge the old summary with the new message without losing info." },
    { role: "user", content: `Existing summary:\n${existing}\n\nNew message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // deterministic, no creativity
  max_tokens: 800,
});

So the bot builds compressed memory over time. One summary that grows smarter is enough, no need to store every mail.

And those JSON files? Well... they're stored in git too, in the runtime tag. Git everywhere.


The clever bit about prompt length

A small technical detail that made me smile.

Models have a token limit. If your mail + summary + persona prompt overflows, the API returns an error.

The code handles that with cascading truncation + retry:

try {
  // first attempt with normal limits
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // it was a length error: retry with tighter limits
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // instead of 3000
      summaryChars: 1800,       // instead of 4000
      personaChars: 900,        // instead of 1500
      userContentChars: 2200,   // instead of 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

If it doesn't fit, cut shorter and retry. Simple, effective, no crash.


OK, and concretely, how does it run?

The full flow of a cron run:

1. GitHub Actions triggers (cron every 5 min)
2. Checkout the "runtime" tag (pre-built code)
3. git show refs/tags/lastid → fetch the last processed UID
4. node dist/index.js --action
   ├── IMAP connection
   ├── fetch mails since lastId+1
   ├── for each mail:
   │   ├── parse the content
   │   ├── filter robots (skip if automated)
   │   ├── match the recipient account
   │   ├── fetch conversation memory
   │   ├── generate the AI reply (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? forward to human
   │   ├── otherwise: send the reply (SMTP)
   │   └── update conversation memory
   └── write the new lastId
5. git push --force tag "lastid" with the new value

And it repeats in 5 minutes. Forever. Free.


3 key points:

  1. Git = free database -- An orphan tag can store your persistent state between two stateless runs. git show refs/tags/X:file to read, force-push to write. You save a whole DB.

  2. Pre-build into a runtime tag -- Instead of npm install on every cron run, store compiled code + node_modules in a git tag. The cron starts instantly.

  3. An AI bot must know when to stay quiet -- The <no_reply> and <manual_reply_required> triggers let the AI decide not to reply or to hand off. Plus anti-robot filtering. Otherwise you create an infinite mail loop.

Serverless cron with persistent state, AI, memory, all for 0€/month. It's completely broken and I love this setup.

J'ai utilisé git comme base de données pour faire tourner un bot gratos

Comment j'ai codé un auto-répondeur email IA qui tourne sur GitHub

J'ai utilisé git comme base de données pour faire tourner un bot gratos sur GitHub Actions

J'ai un répondeur email automatique qui tourne 24/7.

Il lit mes mails, comprend de quoi ça parle, et répond tout seul avec une IA. Il se souvient des conversations précédentes. Il ignore les newsletters et les noreply@. Il forward à un humain quand c'est trop chaud.

Coût mensuel : 0€.

Je me passe de serveur, de VPS et de base de données. Juste GitHub Actions et un hack de malade : utiliser git comme base de données.

Tu vois où je veux en venir ? Non ? Bon, accroche-toi, c'est débile et génial à la fois.


Le problème : GitHub Actions est stateless

GitHub Actions c'est gratuit. Tu peux lancer un cron toutes les 5 minutes, faire tourner ton code, gratos.

Mais y'a un souci : c'est stateless.

Chaque run démarre dans une machine vierge. Rien n'est sauvegardé entre deux exécutions. Le run d'avant ? Oublié. Effacé. Comme s'il avait jamais existé.

Pour un répondeur email, c'est un problème énorme. Genre :

"Quel est le dernier mail que j'ai déjà traité ?"

Si le bot oublie ça à chaque run, il va soit re-répondre aux mêmes mails en boucle (catastrophe), soit rater des mails.

Il faut un état persistant. Et normalement, état persistant = base de données. Mais une base de données c'est un serveur, et un serveur c'est plus gratuit.

C'est là que ça devient intéressant.


La solution : git tags comme base de données

Ton repo GitHub, c'est déjà du stockage persistant. Gratuit. Versionné. Toujours là.

Alors pourquoi pas y stocker l'état ?

L'idée : à chaque run, le bot lit le dernier UID email traité depuis un git tag. Il traite les nouveaux mails. Puis il re-push le tag avec le nouvel UID.

mermaid diagram

Le tag git EST la base de données. Une seule valeur, mais c'est tout ce dont on a besoin.

Lire le state

Au début du job, on récupère la valeur depuis le tag :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId ça veut dire : "donne-moi le contenu du fichier data/lastId tel qu'il était dans le tag lastid".

Boom. Tu as ta valeur, sans base de données.

Écrire le state

À la fin, on re-crée le tag avec la nouvelle valeur :

git switch --orphan lastid-tmp   # branche vierge sans historique
git rm -rf .                      # on vide tout
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # force le tag sur ce commit
git push --force ...origin lastid # push le tag

On crée une branche orpheline (sans historique), on met juste le fichier lastId, on commit, on tag, on force push.

Pourquoi orpheline ? Pour pas accumuler 10 000 commits de state dans l'historique du repo. Chaque update écrase le précédent. Le tag pointe toujours vers UN seul commit qui contient UNE seule valeur.

C'est propre. C'est gratuit. C'est complètement tordu et ça marche.


Le deuxième hack : le runtime snapshot

Y'a un autre problème avec GitHub Actions : le npm install.

Si à chaque run (toutes les 5 minutes) tu fais npm install + npm run build, tu gaspilles 60-90 secondes à chaque fois. Sur un cron fréquent, c'est des minutes de compute gaspillées pour rien.

Solution : pré-compiler le code UNE fois, et le stocker dans un tag git aussi.

Le workflow de build (qui tourne quand tu push sur master) fait ça :

# compile le code
bun install
bun run build

# stocke dist/ + node_modules/ dans un tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Le tag runtime contient le code compilé ET les node_modules. Tout prêt à tourner.

Et le cron, lui, checkout directement ce tag :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # le code pré-build, pas le source
    fetch-depth: 1

# pas de npm install, pas de build !
- name: Process emails
  run: node dist/index.js --action

Le cron démarre instantanément et exécute juste node dist/index.js, sans install ni build.

Genre, tu as deux tags qui font deux boulots :

  • runtime = le code prêt à tourner (mis à jour quand tu push du code)
  • lastid = l'état persistant (mis à jour à chaque run)

C'est élégant comme un sale.


Le bot lui-même : auto-répondeur IA

Bon, le hack git c'est cool, mais le bot fait quoi exactement ?

Il lit tes mails via IMAP, les comprend avec une IA (Groq + Llama 3.3 70B), et répond automatiquement.

Architecture en services propres avec injection de dépendances (InversifyJS) :

App
├── ImapService      → lit les mails (IMAP)
├── SmtpService      → envoie les réponses (SMTP)
├── ParserService    → parse le contenu des mails
├── ReplyService     → génère la réponse IA
├── SummaryService   → mémoire de conversation
├── AccountsService  → gère plusieurs comptes email
└── ConfigService    → config / env vars

Deux modes de fonctionnement

Le bot peut tourner de deux façons :

Mode listener (temps réel) : connexion IMAP permanente avec reconnect exponentiel. Pour un VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // traite le nouveau mail immédiatement
});

Mode action (batch) : traite les nouveaux mails depuis le lastId, puis se ferme. Pour le cron GitHub Actions.

node dist/index.js --action

Le mode --action c'est celui qui utilise le hack git. Il lit lastId, traite ce qui est nouveau, écrit le nouveau lastId, fin.

Ne PAS répondre aux robots

Si ton bot répond à TOUS les mails, il va répondre aux newsletters, aux notifications, aux noreply@. Catastrophe. Pire : si deux bots se répondent l'un à l'autre, t'as une boucle infinie de mails. Le cauchemar.

Donc filtrage agressif :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Et aussi détection via les headers email :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletters ont ça
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe dans les headers ? C'est une newsletter. Precedence: bulk ? Du mass-mailing. X-Mailer: Mailchimp ? Tu vois le genre. On ignore.

C'est comme un videur de boîte de nuit : les robots ne passent pas.

Les triggers magiques

L'IA peut décider de pas répondre du tout, ou de passer la main à un humain. Comment ? Avec des triggers spéciaux dans sa réponse.

Le prompt système lui dit :

Si c'est un mail automatique/newsletter → réponds <no_reply> Si c'est trop important/sensible (légal, financier...) → réponds <manual_reply_required> Sinon → écris une vraie réponse

Et le code lit ça :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// sinon on envoie la réponse IA
await this.smtpService.sendReply(...);

Genre l'IA a le droit de dire "non là je touche pas, appelle un vrai humain". C'est de la sagesse.


La mémoire de conversation

Un détail qui change tout : le bot se souvient des conversations.

Quand il répond à quelqu'un, il sauvegarde un résumé de l'échange. La prochaine fois que cette personne écrit, le résumé est réinjecté dans le prompt.

Stockage : un fichier JSON par contact.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Et le résumé est lui-même généré par l'IA, qui merge l'ancien résumé avec le nouveau message :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // déterministe, pas de créativité
  max_tokens: 800,
});

Donc le bot construit une mémoire compressée au fil du temps. Un résumé qui grossit intelligemment suffit, sans stocker tous les mails.

Et ces fichiers JSON ? Ben... ils sont stockés dans git aussi, dans le runtime tag. Git partout.


Le point malin avec la longueur du prompt

Petit détail technique qui m'a fait sourire.

Les modèles ont une limite de tokens. Si ton mail + le résumé + le persona prompt dépassent, l'API renvoie une erreur.

Le code gère ça avec une troncature en cascade + retry :

try {
  // premier essai avec les limites normales
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // c'était une erreur de longueur : on re-tente avec des limites plus serrées
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // au lieu de 3000
      summaryChars: 1800,       // au lieu de 4000
      personaChars: 900,        // au lieu de 1500
      userContentChars: 2200,   // au lieu de 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Si ça passe pas, on coupe plus court et on retente. Simple, efficace, pas de crash.


Bon, et concrètement, comment ça tourne ?

Le flow complet d'un run de cron :

1. GitHub Actions se déclenche (cron toutes les 5 min)
2. Checkout du tag "runtime" (code pré-build)
3. git show refs/tags/lastid → récupère le dernier UID traité
4. node dist/index.js --action
   ├── connexion IMAP
   ├── fetch des mails depuis lastId+1
   ├── pour chaque mail :
   │   ├── parse le contenu
   │   ├── filtre les robots (skip si automated)
   │   ├── match le compte destinataire
   │   ├── récupère la mémoire de conversation
   │   ├── génère la réponse IA (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? forward humain
   │   ├── sinon : envoie la réponse (SMTP)
   │   └── update la mémoire de conversation
   └── écrit le nouveau lastId
5. git push --force tag "lastid" avec la nouvelle valeur

Et ça recommence dans 5 minutes. Pour toujours. Gratos.


Les 3 points à retenir :

  1. Git = base de données gratuite -- Un tag orphelin peut stocker ton état persistant entre deux runs stateless. git show refs/tags/X:fichier pour lire, force-push pour écrire. Tu économises une DB complète.

  2. Pré-compile dans un tag runtime -- Au lieu de npm install à chaque run du cron, stocke le code compilé + node_modules dans un tag git. Le cron démarre instantanément.

  3. Un bot IA doit savoir se taire -- Les triggers <no_reply> et <manual_reply_required> laissent l'IA décider de pas répondre ou de passer la main. Plus le filtrage anti-robot. Sinon tu crées une boucle de mails infinie.

Serverless cron avec état persistant, IA, mémoire, le tout à 0€/mois. C'est complètement pété et j'adore ce montage.

我拿 git 当数据库,在 GitHub Actions 上白嫖了一个邮件机器人

我怎么写了一个跑在 GitHub Actions 上每月 0 元的 AI 自动回邮件机器人,用 git tag 当数据库,用 runtime tag 做预编译。

我拿 git 当数据库,在 GitHub Actions 上白嫖了一个邮件机器人

我有个 24/7 在线的邮件自动回复机器人。

它读我的邮件,理解在说什么,自己用 AI 回复。记得之前的对话。无视 newsletter 和 noreply@。太棘手的就转给人类。

每月成本:0 元。

没有服务器,没有 VPS,没有数据库。只有 GitHub Actions,加一个离谱的 hack:拿 git 当数据库。

看出我要干嘛了吗?没有?行,坐稳,这又蠢又天才。


问题:GitHub Actions 是无状态的

GitHub Actions 免费。可以每 5 分钟跑一次 cron,跑你的代码,白嫖。

但有个问题:它是 stateless 的。

每次运行都从一台干净的机器启动。上次运行的东西全没了。上一次?忘了。抹掉了。就当没存在过。

对回邮件机器人来说,这是大问题。比如:

"我上次处理到哪封邮件了?"

如果机器人每次都失忆,它要么把同一封邮件回一百遍(灾难),要么漏邮件。

得有个持久状态。而持久状态一般意味着数据库。数据库意味着服务器。服务器就不免费了。

有意思的地方来了。


解法:git tag 当数据库

你的 GitHub 仓库本身就是持久存储。免费。带版本。一直在。

那为什么不拿它存状态?

思路:每次运行,机器人从一个 git tag 读上次处理到的邮件 UID。处理新邮件。然后把 tag 推上去,带上新的 UID。

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAP 服务器

    Note over GH: Run #1
    GH->>GIT: 读 tag "lastid"
    GIT-->>GH: 空(第一次运行)
    GH->>IMAP: 取邮件 1-50
    IMAP-->>GH: 50 封邮件
    GH->>GIT: 推 tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: 读 tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: 取邮件 51-73
    IMAP-->>GH: 23 封邮件
    GH->>GIT: 推 tag "lastid" = 73

git tag 就是数据库。一个值,但够用了。

读状态

job 一开始从 tag 取值:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId 的意思是:"把 tag lastid 里 data/lastId 文件的内容给我"。

Boom。拿到值了,不要数据库。

写状态

结尾用新值重建 tag:

git switch --orphan lastid-tmp   # 无历史的孤儿分支
git rm -rf .                      # 全清空
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # 强制打 tag 到这个提交
git push --force ...origin lastid # 推 tag

建一个孤儿分支(没有历史),只放 lastId 文件,提交,打 tag,强制推。

为什么孤儿?为了不在仓库历史里攒一万个状态提交。每次更新覆盖上一次。tag 永远指向一个提交,里面一个值。

干净。免费。完全离谱,但能跑。


第二个 hack:runtime 快照

GitHub Actions 还有个问题:npm install。

如果每次运行(每 5 分钟)都 npm install + npm run build,每次白白烧 60-90 秒。cron 跑这么勤,全是浪费的算力。

解法:代码一次预编译好,也存在 git tag 里。

push 到 master 时跑的构建工作流做这个:

# 编译代码
bun install
bun run build

# 把 dist/ + node_modules/ 存进 "runtime" tag
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

runtime tag 里是编译好的代码加 node_modules。开箱即用。

cron 直接 checkout 这个 tag:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # 预编译好的代码,不是源码
    fetch-depth: 1

# 不用 npm install,不用 build!
- name: Process emails
  run: node dist/index.js --action

cron 瞬间启动,直接跑 node dist/index.js,不用 install 不用 build。

你看,两个 tag 两个活:

  • runtime = 能直接跑的代码(push 代码时更新)
  • lastid = 持久状态(每次运行更新)

优雅得像个脏招。


机器人本体:AI 自动回信

行,git hack 很酷,但机器人到底干嘛的?

它用 IMAP 读我的邮件,用 AI(Groq + Llama 3.3 70B)理解,自己回。

架构是干净的服务加依赖注入(InversifyJS):

App
├── ImapService      → 读邮件(IMAP)
├── SmtpService      → 发回复(SMTP)
├── ParserService    → 解析邮件内容
├── ReplyService     → 生成 AI 回复
├── SummaryService   → 对话记忆
├── AccountsService  → 管多个邮箱账号
└── ConfigService    → 配置 / 环境变量

两种运行模式

机器人有两种跑法:

listener 模式(实时):IMAP 长连接加指数重连。给 VPS 用的。

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] 新邮件!总数:${data.count}`);
  // 立刻处理新邮件
});

action 模式(批处理):从 lastId 开始处理新邮件,然后退出。给 GitHub Actions cron 用的。

node dist/index.js --action

--action 模式就是用 git hack 的那个。读 lastId,处理新增,写回新 lastId,结束。

别回机器人

如果机器人所有邮件都回,它会回 newsletter、通知、noreply@。灾难。更惨:如果两个机器人互回,你就得到无限邮件循环。噩梦。

所以要激进地过滤:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

还有邮件头检测:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletter 都有这个
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

头里有 List-Unsubscribe?newsletter。Precedence: bulk?群发。X-Mailer: Mailchimp?懂的都懂。无视。

就像夜店保安:机器人不让进。

魔法触发器

AI 可以决定完全不回,或者把活转给人类。怎么做?靠回复里的特殊触发器。

系统提示词这么告诉它:

如果是自动邮件/newsletter → 回 <no_reply> 如果太重要太敏感(法律、财务……)→ 回 <manual_reply_required> 否则 → 写个正常回复

代码这么读:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] AI 决定无视。跳过。");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] 太棘手,转给人类。");
  await this.smtpService.sendManualForward(...);
  return;
}

// 否则发 AI 回复
await this.smtpService.sendReply(...);

AI 有权说"这我不碰,叫个人类来"。这叫智慧。


对话记忆

改变游戏规则的细节:机器人记得对话。

它回复某人时会存一份交流摘要。下次这个人来信,摘要重新注入提示词。

存成每联系人一个 JSON 文件:

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

摘要本身也是 AI 生成的,把旧摘要和新消息合并:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "你是记忆助手。把旧摘要和新消息合并,不要丢信息。" },
    { role: "user", content: `已有摘要:\n${existing}\n\n新消息:\n${incomingContent}` }
  ],
  temperature: 0.0,  // 确定性,不要 creativity
  max_tokens: 800,
});

机器人随时间攒出压缩记忆。一份越长越聪明的摘要就够了,不用存全部邮件。

这些 JSON 文件呢?嗯……也存在 git 里,runtime tag 里。git 无处不在。


提示词长度的小心机

一个让我笑出来的技术细节。

模型有 token 上限。邮件加摘要加 persona 提示词一超,API 直接报错。

代码用级联截断加重试处理:

try {
  // 先用正常长度试一次
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // 是长度报错:收紧限制再试
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // 原来 3000
      summaryChars: 1800,       // 原来 4000
      personaChars: 900,        // 原来 1500
      userContentChars: 2200,   // 原来 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // 重试
}

一次不行就砍短再试。简单,有效,不崩。


好,具体怎么跑的?

一次 cron 运行的完整流程:

1. GitHub Actions 触发(每 5 分钟 cron)
2. Checkout "runtime" tag(预编译代码)
3. git show refs/tags/lastid → 取上次处理到的 UID
4. node dist/index.js --action
│   ├── IMAP 连接
│   ├── 从 lastId+1 开始取新邮件
│   ├── 每封邮件:
│   │   ├── 解析内容
│   │   ├── 机器人过滤(自动的跳过)
│   │   ├── 匹配收件账号
│   │   ├── 取对话记忆
│   │   ├── 生成 AI 回复(Groq)
│   │   ├── <no_reply>?跳过
│   │   ├── <manual_reply_required>?转人类
│   │   ├── 否则:发回复(SMTP)
│   │   └── 更新对话记忆
│   └── 写回新 lastId
5. git push --force tag "lastid",带新值

5 分钟后重来一遍。永远。免费。


三句话总结:

  1. git = 免费数据库,一个孤儿 tag 存两次无状态运行之间的持久状态。git show refs/tags/X:文件 读,强制推写。省掉整个数据库。

  2. runtime tag 预编译,cron 每次不用 npm install,把编译好的代码加 node_modules 存进 git tag。cron 瞬间启动。

  3. AI 机器人要懂得闭嘴,<no_reply> 和 <manual_reply_required> 触发器让 AI 决定不回或转交。加机器人过滤。不然就是无限邮件循环。

带持久状态、记忆、AI 的 serverless cron,每月 0 元。烂得漂亮,我太喜欢这个设计了。

gitをデータベースにして無料ボットをGitHub Actionsで回した

AI自動返信メールボットをGitHub Actionsで月0円で回すためにコーディングした話。gitタグをデータベースにして、runtimeタグにプリコンパイルする。

gitをデータベースにして無料ボットをGitHub Actionsで回した

自動返信メールボットが24時間365日動いてる。

メールを読んで、内容を理解して、自分でAI返信する。前の会話を覚えてる。ニュースレターやnoreply@は無視する。やばいのは人間に回す。

月額コスト:0円だ。

サーバーもVPSもデータベースも使わない。GitHub Actionsだけと、やばいハック1つだ。gitをデータベースとして使うことだ。

どういうことか分かる?分からないよね。いいからついてきて。馬鹿げてるけど天才的だよ。


問題:GitHub Actionsはステートレスだ

GitHub Actionsは無料だ。5分ごとにcronを走らせてコード回してタダだ。

でも問題がある。ステートレスなんだ。

各runはまっさらなマシンで始まる。前回までに保存されたものは何もない。前のrun?忘れた。消えた。存在しなかったみたいにね。

メール返信ボットには巨大な問題だ。たとえばこうだ。

「最後に処理したメールはどれだっけ?」

ボットがrunごとに忘れたら、同じメールにループ返信(大惨事)か、メールを取りこぼすかだ。

永続状態が要る。普通、永続状態イコールデータベースだ。でもデータベースはサーバーで、サーバーは無料じゃない。

ここから面白くなる。


解法:gitタグをデータベースにする

GitHubリポジトリはもう永続ストレージだ。無料で、バージョン付きで、いつでもある。

なら状態をそこに保存すればいいじゃないか。

考え方はこうだ。各runでボットは処理済み最終UIDをgitタグから読む。新着メールを処理する。新しいUIDでタグをpushし直す。

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAPサーバー
    
    Note over GH: Run #1
    GH->>GIT: タグ「lastid」を読む
    GIT-->>GH: 空(初回run)
    GH->>IMAP: メール1-50を取得
    IMAP-->>GH: 50通
    GH->>GIT: タグ「lastid」= 50をpush
    
    Note over GH: Run #2
    GH->>GIT: タグ「lastid」を読む
    GIT-->>GH: 50
    GH->>IMAP: メール51-73を取得
    IMAP-->>GH: 23通
    GH->>GIT: タグ「lastid」= 73をpush

gitタグがデータベースだ。値は1つだけ。でも必要なのはそれだけだ。

状態を読む

ジョブ頭でタグから値を取る。

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastIdの意味はこうだ。「タグlastid時点のファイルdata/lastIdの内容を出せ」だ。

はい値ゲット。データベースなしでね。

状態を書く

最後に新しい値でタグを作り直す。

git switch --orphan lastid-tmp   # 履歴なしの真っさらブランチ
git rm -rf .                      # 全部空にする
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # タグをこのコミットに強制
git push --force ...origin lastid # タグをpush

履歴なしのorphanブランチを作って、lastIdファイルだけ置いて、コミットして、タグ付けて、強制pushする。

orphanなのはなぜか。リポジトリ履歴に状態コミット1万個を溜めないためだ。各更新が前を上書きする。タグは値1つだけのコミット1つだけを常に指す。

きれいだ。無料だ。完全にどうかしてるけど動く。


2つ目のハック:runtimeスナップショット

GitHub Actionsには別の問題もある。npm installだ。

各run(5分ごと)でnpm installとnpm run buildをやったら毎回60秒から90秒無駄にする。頻繁なcronではただの計算浪費だ。

解法:コードを1回だけプリコンパイルして、gitタグにも保存する。

ビルド用ワークフロー(masterにpushしたら回る)はこうする。

# コードをコンパイル
bun install
bun run build

# dist/とnode_modules/を「runtime」タグに保存
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

runtimeタグにはコンパイル済みコードとnode_modulesが入る。回せる状態で全部だ。

cron側はこのタグを直接チェックアウトする。

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # ソースじゃなくプリビルドコード
    fetch-depth: 1

# npm installなし、buildなし!
- name: Process emails
  run: node dist/index.js --action

cronは即起動してnode dist/index.jsを実行するだけだ。installもbuildもなし。

つまりタグ2つで仕事2つだ。

  • runtimeは回せるコード(コードpush時に更新)
  • lastidは永続状態(各runで更新)

汚いくらい優雅だ。


ボット本体:AI自動返信

gitハックはかっこいいけど、ボット自体は何するのか。

IMAPでメールを読んで、AI(GroqとLlama 3.3 70B)で理解して、自動返信する。

きれいなサービス構成と依存性注入(InversifyJS)のアーキテクチャだ。

App
├── ImapService      → メールを読む(IMAP)
├── SmtpService      → 返信を送る(SMTP)
├── ParserService    → メール内容をパースする
├── ReplyService     → AI返信を作る
├── SummaryService   → 会話メモリ
├── AccountsService  → 複数メールアカウント管理
└── ConfigService    → 設定やenv vars

2つの動作モード

ボットは2通りに回せる。

リスナーモード(リアルタイム):指数バックオフ付き常時IMAP接続だ。VPS向け。

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // 新着メールを即処理
});

アクションモード(バッチ):lastId以降の新着メールを処理したら閉じる。GitHub Actionsのcron向けだ。

node dist/index.js --action

--actionモードがgitハックを使うやつだ。lastIdを読んで、新しい分を処理して、新しいlastIdを書いて終わりだ。

ロボットには返信しない

ボットが全メールに返信したら、ニュースレターや通知やnoreply@にも返信する。大惨事だ。もっと悪いのは、2つのボットが互いに返信したらメールの無限ループになる。悪夢だ。

だから積極フィルタだ。

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

メールヘッダーでの検出もある。

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // ニュースレターはこれがある
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

ヘッダーにList-Unsubscribe?ニュースレターだ。Precedence: bulk?大量配信だ。X-Mailer: Mailchimp?分かるよね。無視する。

ナイトクラブの用心棒みたいなものだ。ロボットは通さない。

魔法のトリガー

AIはまったく返信しないことも、人間に回すこともできる。どうやって?返答内の特殊トリガーでだ。

システムプロンプトがこう言う。

自動メールやニュースレターなら<no_reply>と返せ 重要すぎたり繊細すぎたり(法務、金融など)なら<manual_reply_required>と返せ そうじゃなければ本物の返事を書け

コードはこう読む。

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// そうじゃなければAI返信を送る
await this.smtpService.sendReply(...);

つまりAIには「いやここは触らない、本物の人間呼んで」と言う権利がある。賢い。


会話メモリ

全部変える細かい点だ。ボットは会話を覚えてるんだ。

誰かに返信したらやり取りの要約を保存する。次にその人が書いたら、要約がプロンプトに再注入される。

保存は連絡先ごとのJSONファイルだ。

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

要約自体もAIが作る。古い要約と新メッセージをマージする。

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // 決定的、創造性なし
  max_tokens: 800,
});

つまりボットは時間とともに圧縮メモリを作る。賢く育つ要約1つで十分で、全メール保存はいらない。

そのJSONファイルはね。もちろんgitにも保存される。runtimeタグにね。gitだらけだ。


プロンプト長のうまい点

ニヤッとした細かい技術点だ。

モデルにはトークン上限がある。メールと要約とpersonaプロンプトが超えたらAPIはエラーを返す。

コードはカスケード切り詰めとリトライでさばく。

try {
  // 通常上限で初回試行
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // 長さエラーだった:短め上限で再試行
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // 通常3000のところ
      summaryChars: 1800,       // 通常4000のところ
      personaChars: 900,        // 通常1500のところ
      userContentChars: 2200,   // 通常8000のところ
    },
  }));
  completion = await groq.chat.completions.create({...});  // リトライ
}

ダメなら短く切って再試行する。単純で、効いて、クラッシュしない。


で、具体的にどう回ってるの

cronの1runの完全フローだ。

1. GitHub Actionsが起動(5分ごとcron)
2. 「runtime」タグをチェックアウト(プリビルドコード)
3. git show refs/tags/lastidで処理済み最終UIDを取る
4. node dist/index.js --action
   ├── IMAP接続
   ├── lastId+1以降のメールを取得
   ├── 各メールに:
   │   ├── 内容をパース
   │   ├── ロボットをフィルタ(自動ならスキップ)
   │   ├── 宛先アカウントを照合
   │   ├── 会話メモリを取る
   │   ├── AI返信を作る(Groq)
   │   ├── <no_reply>?スキップ
   │   ├── <manual_reply_required>?人間に回す
   │   ├── そうじゃなければ返信を送る(SMTP)
   │   └── 会話メモリを更新
   └── 新しいlastIdを書く
5. git push --forceで新しい値の「lastid」タグ

5分後に繰り返す。永遠に。無料でね。


覚えるべき3点:

  1. gitは無料データベースだ。orphanタグ1つでステートレスrun間の永続状態を持てる。読むときはgit show refs/tags/X:ファイル、書くときは強制pushだ。DB1つ分まるっと節約できる。

  2. runtimeタグにプリコンパイルする。cronの各runでnpm installする代わり、コンパイル済みコードとnode_modulesをgitタグに保存する。cronは即起動する。

  3. AIボットは黙ることを知るべきだ。<no_reply>と<manual_reply_required>のトリガーでAIに返信しない判断や引継ぎ判断をさせる。ロボット対策フィルタと合わせてね。じゃないとメールループができる。

状態付きサーバーレスcronにAIとメモリ付きで、月0円だ。完全にどうかしてるけどこの構成大好きだ。

git을 데이터베이스 삼아 GitHub Actions에서 공짜 봇 돌리기

월 0유로로 GitHub Actions에서 도는 AI 자동 회신 메일을 코딩한 방법. git 태그를 데이터베이스로, runtime 태그에 미리 컴파일.

git을 데이터베이스 삼아 GitHub Actions에서 공짜 봇 돌리기

24시간 돌아가는 자동 메일 회신기가 있어요.

메일을 읽고, 무슨 이야기인지 이해하고, AI로 혼자 답장해요. 이전 대화를 기억해요. 뉴스레터와 noreply@는 무시해요. 너무 뜨거우면 사람에게 넘겨요.

월 비용: 0유로.

서버도 VPS도 데이터베이스도 없어요. GitHub Actions와 미친 핵 하나뿐이에요. git을 데이터베이스로 쓰기.

감 잡히시죠? 아니요? 좋아요, 정신 바짝 차리세요. 바보 같으면서도 genial해요.


문제: GitHub Actions는 무상태예요

GitHub Actions는 무료예요. 5분마다 크론을 실행하고 코드를 돌려요. 공짜로.

하지만 문제가 있어요. 무상태예요.

매 실행은 깨끗한 기기에서 시작해요. 두 실행 사이에는 아무것도 저장되지 않아요. 이전 실행? 잊은 거예요. 지워졌어요. 존재한 적 없던 것처럼요.

메일 회신기에는 엄청난 문제예요. 예를 들면요.

"내가 이미 처리한 마지막 메일이 뭐지?"

봇이 매 실행마다 이걸 잊으면 같은 메일에 계속 답장하거나(재앙), 메일을 놓쳐요.

영속 상태가 필요해요. 보통 영속 상태 = 데이터베이스. 하지만 데이터베이스는 서버고, 서버는 더 이상 무료가 아니에요.

바로 여기서 흥미로워져요.


해결책: 데이터베이스로서의 git 태그

여러분의 GitHub 저장소는 이미 영속 저장소예요. 무료. 버전 관리. 항상 거기 있어요.

그럼 상태를 거기에 저장하면 안 될까요?

아이디어: 매 실행마다 봇이 git 태그에서 처리한 마지막 이메일 UID를 읽어요. 새 메일을 처리해요. 그리고 새 UID로 태그를 다시 푸시해요.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAP 서버

    Note over GH: 실행 1
    GH->>GIT: "lastid" 태그 읽기
    GIT-->>GH: 비어 있음 (첫 실행)
    GH->>IMAP: 메일 1-50 가져오기
    IMAP-->>GH: 메일 50통
    GH->>GIT: "lastid" 태그 푸시 = 50

    Note over GH: 실행 2
    GH->>GIT: "lastid" 태그 읽기
    GIT-->>GH: 50
    GH->>IMAP: 메일 51-73 가져오기
    IMAP-->>GH: 메일 23통
    GH->>GIT: "lastid" 태그 푸시 = 73

git 태그가 곧 데이터베이스예요. 값 하나뿐이지만, 필요한 것은 그게 전부예요.

상태 읽기

작업 시작 때 태그에서 값을 가져와요.

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId의 뜻은 "lastid 태그에 있던 data/lastId 파일 내용을 줘"예요.

쾅. 데이터베이스 없이 값을 얻었어요.

상태 쓰기

끝나면 새 값으로 태그를 다시 만들어요.

git switch --orphan lastid-tmp   # 히스토리 없는 빈 브랜치
git rm -rf .                      # 전부 비워요
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # 이 커밋에 태그 강제
git push --force ...origin lastid # 태그 푸시

히스토리 없는 고아 브랜치를 만들고, lastId 파일만 넣고, 커밋하고, 태그하고, 강제 푸시해요.

왜 고아냐고요? 저장소 히스토리에 상태 커밋 만 개가 쌓이지 않게요. 매 업데이트가 이전을 덮어써요. 태그는 항상 값 하나를 담은 커밋 하나를 가리켜요.

깔끔해요. 무료예요. 완전히 비틀렸고 동작해요.


두 번째 핵: 런타임 스냅샷

GitHub Actions에는 다른 문제도 있어요. npm install이요.

매 실행(5분마다)마다 npm install과 npm run build를 하면 매번 60-90초를 낭비해요. 잦은 크론에서는 아무것도 아닌 데 컴퓨트 시간을 분 단위로 낭비해요.

해결책: 코드를 딱 한 번 미리 컴파일해서 git 태그에 같이 저장해요.

빌드 워크플로(master에 푸시될 때 도는)가 이렇게 해요.

# 코드 컴파일
bun install
bun run build

# dist/ + node_modules/를 "runtime" 태그에 저장
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

runtime 태그에는 컴파일된 코드와 node_modules가 들어 있어요. 바로 돌릴 준비가 됐어요.

그리고 크론은 이 태그를 바로 체크아웃해요.

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # 소스가 아닌 미리 빌드된 코드
    fetch-depth: 1

# npm install도 build도 없어요!
- name: Process emails
  run: node dist/index.js --action

크론은 바로 시작해서 install도 build도 없이 node dist/index.js만 실행해요.

두 태그가 두 가지 일을 하는 셈이에요.

  • runtime = 바로 돌릴 준비된 코드 (코드를 푸시하면 고쳐져요)
  • lastid = 영속 상태 (매 실행마다 고쳐져요)

지저분할 정도로 우아해요.


봇 자체: AI 자동 회신기

좋아요, git 핵은 멋지지만 봇은 정확히 뭘 할까요?

IMAP으로 메일을 읽고, AI(Groq + Llama 3.3 70B)로 이해하고, 자동으로 답장해요.

깔끔한 서비스 아키텍처와 의존성 주입(InversifyJS)이에요.

App
├── ImapService      → lit les mails (IMAP)
├── SmtpService      → envoie les réponses (SMTP)
├── ParserService    → parse le contenu des mails
├── ReplyService     → génère la réponse IA
├── SummaryService   → mémoire de conversation
├── AccountsService  → gère plusieurs comptes email
└── ConfigService    → config / env vars

두 가지 동작 방식

봇은 두 가지로 돌 수 있어요.

리스너 모드(실시간): 지수 재접속이 있는 영속 IMAP 연결. VPS용이에요.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // 새 메일을 바로 처리해요});

액션 모드(배치): lastId부터 새 메일을 처리한 뒤 종료. GitHub Actions 크론용이에요.

node dist/index.js --action

--action 모드가 git 핵을 쓰는 쪽이에요. lastId를 읽고, 새 것을 처리하고, 새 lastId를 쓰고, 끝.

로봇에게는 답장하지 않기

봇이 모든 메일에 답장하면 뉴스레터와 알림, noreply@에 답장해요. 재앙이에요. 더 나쁜 것은 두 봇이 서로 답장하면 무한 메일 루프가 생겨요. 악몽이에요.

그래서 공격적 필터링이에요.

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

그리고 이메일 헤더로도 감지해요.

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // 뉴스레터에는 이게 있어요
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

헤더에 List-Unsubscribe? 뉴스레터예요. Precedence: bulk? 대량 메일이에요. X-Mailer: Mailchimp? 그런 느낌 아시죠. 무시해요.

나이트클럽 경비원 같아요. 로봇은 통과 못 해요.

마법 트리거

AI는 아예 답장하지 않거나 사람에게 넘기기로 정할 수 있어요. 어떻게요? 답 안에 특수 트리거를 넣어서요.

시스템 프롬프트가 이렇게 말해요.

자동 메일과 뉴스레터면 <no_reply>라고 답해 너무 중요하거나 민감하면(법률, 금융...) <manual_reply_required>라고 답해 아니면 진짜 답장을 써

그리고 코드가 읽어요.

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// 아니면 AI 답장을 보내요
await this.smtpService.sendReply(...);

AI가 "아니, 여기는 손대지 않을래. 진짜 사람 불러"라고 말할 권리가 있는 셈이에요. 지혜예요.


대화 기억

모든 것을 바꾸는 세부 사항 하나: 봇이 대화를 기억해요.

누군가에게 답장하면 교환 요약을 저장해요. 그 사람이 다음에 글을 쓰면 요약이 프롬프트에 다시 주입돼요.

저장: 연락처별 JSON 파일 하나.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

그리고 요약 자체가 AI가 만들어요. 예전 요약과 새 메시지를 합쳐요.

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // 결정적, 창의성 없음
  max_tokens: 800,
});

그래서 봇은 시간이 지나면서 압축된 기억을 쌓아요. 모든 메일을 저장하지 않고도 영리하게 커지는 요약 하나면 충분해요.

그리고 이 JSON 파일들은요? 음... 역시 git에, runtime 태그에 저장돼요. 도처에 git.


프롬프트 길이의 영리한 점

웃음이 났던 작은 기술 세부 사항이에요.

모델에는 토큰 한도가 있어요. 메일과 요약, 페르소나 프롬프트를 합쳐서 넘기면 API가 오류를 돌려줘요.

코드는 계단식 자르기와 재시도로 처리해요.

try {
  // 정상 한도로 첫 시도
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // 길이 오류였어요. 더 빡빡한 한도로 다시 시도해요
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // 3000 대신
      summaryChars: 1800,       // 4000 대신
      personaChars: 900,        // 1500 대신
      userContentChars: 2200,   // 8000 대신
    },
  }));
  completion = await groq.chat.completions.create({...});  // 재시도
}

안 되면 더 짧게 자르고 다시 시도해요. 단순하고 효과적이고, 죽지 않아요.


좋아요, 실제로는 어떻게 돌아가는데요?

크론 한 번 실행의 전체 흐름이에요.

1. GitHub Actions 발동 (5분마다 크론)
2. "runtime" 태그 체크아웃 (미리 빌드된 코드)
3. git show refs/tags/lastid → 처리한 마지막 UID 가져오기
4. node dist/index.js --action
   ├── IMAP 연결
   ├── lastId+1부터 메일 가져오기
   ├── 메일마다:
   │   ├── 내용 파싱
   │   ├── 로봇 필터링 (자동이면 건너뜀)
   │   ├── 수신 계정 매칭
   │   ├── 대화 기억 가져오기
   │   ├── AI 답장 만들기 (Groq)
   │   ├── <no_reply>? 건너뜀
   │   ├── <manual_reply_required>? 사람에게 전달
   │   ├── 아니면: 답장 보내기 (SMTP)
   │   └── 대화 기억 고치기
   └── 새 lastId 쓰기
5. 새 값으로 "lastid" 태그 git push --force

그리고 5분 뒤에 다시. 영원히. 공짜로.


기억할 3가지:

  1. Git = 무료 데이터베이스: 고아 태그 하나가 무상태 실행 사이의 영속 상태를 저장할 수 있어요. 읽기는 git show refs/tags/X:파일, 쓰기는 강제 푸시. 데이터베이스 하나 통째로 아껴요.

  2. runtime 태그에 미리 컴파일: 크론이 매번 npm install을 도는 대신, 컴파일된 코드와 node_modules를 git 태그에 저장해요. 크론이 바로 시작해요.

  3. AI 봇은 침묵할 줄 알아야 해요: <no_reply>와 <manual_reply_required> 트리거로 AI가 답장하지 않거나 넘기기로 정하게 해요. 안티로봇 필터링도요. 아니면 무한 메일 루프를 만들어요.

영속 상태가 있는 서버리스 크론에 AI와 기억까지, 전부 월 0유로. 완전히 맛이 갔고 저는 이 구성이 좋아요.

GitHub Actions üzerinde bedava bot çalıştırmak için giti veritabanı olarak kullandım

Git taglerini veritabanı ve runtime taginde ön derleme kullanarak 0

GitHub Actions üzerinde bedava bot çalıştırmak için giti veritabanı olarak kullandım

7/24 çalışan otomatik bir e-posta yanıtlayıcım var.

Postalarımı okur, ne hakkında olduğunu anlar ve bir yapay zekâyla kendi kendine yanıtlar. Önceki konuşmaları hatırlar. Bültenleri ve noreply@ adreslerini görmezden gelir. İş çok kızışınca bir insana iletir.

Aylık maliyet: 0 €.

Sunucusuz, VPSsiz ve veritabanısız idare ediyorum. Sadece GitHub Actions ve hasta bir hack: giti veritabanı olarak kullanmak.

Nereye varmak istediğimi görüyor musun? Hayır mı? Tamam, sıkı tutun, hem aptalca hem harika.


Sorun: GitHub Actions stateless çalışır

GitHub Actions bedava. Her 5 dakikada bir cron çalıştırıp kodunu döndürebilirsin, bedava.

Ama bir sorun var: stateless.

Her run tertemiz bir makinede başlar. İki çalıştırma arasında hiçbir şey kaydedilmez. Önceki run mı? Unutuldu. Silindi. Hiç var olmamış gibi.

Bir e-posta yanıtlayıcı için bu devasa bir sorun. Şöyle bir şey:

"Zaten işlediğim son posta hangisiydi?"

Bot bunu her run unutursa ya aynı postalara döngüde tekrar yanıt verir (felaket) ya da postaları kaçırır.

Kalıcı duruma ihtiyaç var. Ve normalde kalıcı durum = veritabanı. Ama veritabanı bir sunucudur ve sunucu artık bedava değildir.

İşin ilginçleştiği yer burası.


Çözüm: veritabanı olarak git tagleri

GitHub depon zaten kalıcı depolamadır. Bedava. Sürümlü. Hep orada.

O zaman durumu neden orada saklamayalım?

Fikir: her run bot işlenmiş son e-posta UIDini bir git taginden okur. Yeni postaları işler. Sonra tagi yeni UID ile tekrar pushlar.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAP Sunucusu
    
    Note over GH: Run #1
    GH->>GIT: "lastid" tagini oku
    GIT-->>GH: boş (ilk run)
    GH->>IMAP: 1-50 postalarını çek
    IMAP-->>GH: 50 posta
    GH->>GIT: "lastid" tagini pushla = 50
    
    Note over GH: Run #2
    GH->>GIT: "lastid" tagini oku
    GIT-->>GH: 50
    GH->>IMAP: 51-73 postalarını çek
    IMAP-->>GH: 23 posta
    GH->>GIT: "lastid" tagini pushla = 73

Git tagi veritabanının TA kendisidir. Tek bir değer ama ihtiyacımız olan her şey bu.

Durumu okumak

İşin başında değer tagden alınır:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId şu demektir: "bana lastid tagindeki haliyle data/lastId dosyasının içeriğini ver".

Bum. Veritabanısız değerin elinde.

Durumu yazmak

Sonda tag yeni değerle yeniden oluşturulur:

git switch --orphan lastid-tmp   # tarihsiz tertemiz dal
git rm -rf .                      # her şeyi boşalt
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # tagi bu commite zorla
git push --force ...origin lastid # tagi pushla

Öksüz bir dal oluşturuyoruz (tarihsiz), sadece lastId dosyasını koyuyoruz, commitliyoruz, tagliyoruz, force pushluyoruz.

Neden öksüz? Deponun tarihçesinde 10.000 durum commiti biriktirmemek için. Her güncelleme öncekinin üzerine yazar. Tag her zaman TEK değer içeren TEK bir commiti gösterir.

Temiz. Bedava. Tamamen çarpık ve çalışıyor.


İkinci hack: runtime anlık görüntüsü

GitHub Actions ile başka bir sorun var: npm install.

Her run (5 dakikada bir) npm install + npm run build yaparsan her seferinde 60-90 saniye harcarsın. Sık bir cronda bu hiç yoktan harcanmış işlem dakikalarıdır.

Çözüm: kodu BİR kez önceden derleyip onu da bir git taginde saklamak.

Derleme workflowu (master üzerine pushlayınca çalışan) şunu yapar:

# kodu derle
bun install
bun run build

# dist/ + node_modules/ içeriğini "runtime" taginde sakla
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

runtime tagi derlenmiş kodu VE node_modules içeriğini taşır. Çalışmaya tamamen hazır.

Ve cron doğrudan bu tagi checkout eder:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # önceden derlenmiş kod, kaynak değil
    fetch-depth: 1

# npm install yok, derleme yok!
- name: Process emails
  run: node dist/index.js --action

Cron anında başlar ve kurulum ya da derleme olmadan sadece node dist/index.js çalıştırır.

Yani iki tag iki iş yapar:

  • runtime = çalışmaya hazır kod (kod pushlayınca güncellenir)
  • lastid = kalıcı durum (her run güncellenir)

Pisliğine zarif.


Botun kendisi: yapay zekâlı otomatik yanıtlayıcı

Tamam, git hacki havalı ama bot tam olarak ne yapıyor?

Postalarını IMAP üzerinden okur, bir yapay zekâyla anlar (Groq + Llama 3.3 70B) ve otomatik yanıtlar.

Bağımlılık enjeksiyonlu temiz servislerle mimari (InversifyJS):

App
├── ImapService      → postaları okur (IMAP)
├── SmtpService      → yanıtları gönderir (SMTP)
├── ParserService    → posta içeriğini ayrıştırır
├── ReplyService     → yapay zekâ yanıtı üretir
├── SummaryService   → konuşma belleği
├── AccountsService  → birden çok e-posta hesabını yönetir
└── ConfigService    → yapılandırma / ortam değişkenleri

İki çalışma modu

Bot iki şekilde dönebilir:

Dinleyici modu (gerçek zamanlı): üstel yeniden bağlanmalı kalıcı IMAP bağlantısı. VPS için.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Yeni posta! Toplam: ${data.count}`);
  // yeni postayı hemen işle
});

Aksiyon modu (toplu): lastId sonrasındaki yeni postaları işler, sonra kapanır. GitHub Actions cronu için.

node dist/index.js --action

--action modu git hackini kullanan moddur. lastId okur, yeni olanı işler, yeni lastId yazar, biter.

Robotlara yanıt VERME

Botun TÜM postalara yanıt verirse bültenlere, bildirimlere, noreply@ adreslerine yanıt verir. Felaket. Daha kötüsü: iki bot birbirine yanıt verirse sonsuz bir posta döngün olur. Kâbus.

O yüzden agresif filtreleme:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Ve e-posta başlıkları üzerinden tespit de var:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // bültenlerde bu vardır
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

Başlıklarda List-Unsubscribe mi var? O bir bültendir. Precedence: bulk mı? Toplu postadır. X-Mailer: Mailchimp mi? Anladın. Görmezden geliyoruz.

Gece kulübü fedaisi gibi: robotlar geçemez.

Sihirli tetikleyiciler

Yapay zekâ hiç yanıt vermemeye ya da işi bir insana bırakmaya karar verebilir. Nasıl? Yanıtındaki özel tetikleyicilerle.

Sistem istemi ona şunu söyler:

Otomatik posta/bültense → <no_reply> yanıtla Çok önemli/hassassa (hukuki, finansal...) → <manual_reply_required> yanıtla Yoksa → gerçek bir yanıt yaz

Ve kod bunu okur:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] Yapay zekâ görmezden gelmeye karar verdi. Atla.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Çok sıcak, bir insana iletiyorum.");
  await this.smtpService.sendManualForward(...);
  return;
}

// yoksa yapay zekâ yanıtını gönder
await this.smtpService.sendReply(...);

Yani yapay zekânın "yok buna dokunmuyorum, gerçek bir insan çağır" deme hakkı var. Bu bilgelik.


Konuşma belleği

Her şeyi değiştiren bir detay: bot konuşmaları hatırlar.

Birine yanıt verdiğinde alışverişin bir özetini kaydeder. Bu kişi bir dahaki sefere yazdığında özet isteme geri enjekte edilir.

Depolama: kişi başına bir JSON dosyası.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Ve özetin kendisi de eski özeti yeni mesajla birleştiren yapay zekâ tarafından üretilir:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // deterministik, yaratıcılık yok
  max_tokens: 800,
});

Yani bot zamanla sıkıştırılmış bir bellek inşa eder. Tüm postaları saklamadan akıllıca büyüyen bir özet yeter.

Ve bu JSON dosyaları mı? Onlar da gitte saklanır, runtime taginde. Her yerde git.


İstemin uzunluğundaki kurnaz nokta

Beni gülümseten küçük bir teknik detay.

Modellerin bir jeton limiti vardır. Postan + özet + persona istemi aşarsa API bir hata döndürür.

Kod bunu basamaklı kırpma + yeniden deneme ile yönetir:

try {
  // normal limitlerle ilk deneme
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // uzunluk hatasıydı: daha sıkı limitlerle yeniden dene
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // 3000 yerine
      summaryChars: 1800,       // 4000 yerine
      personaChars: 900,        // 1500 yerine
      userContentChars: 2200,   // 8000 yerine
    },
  }));
  completion = await groq.chat.completions.create({...});  // yeniden deneme
}

Geçmezse daha kısa kesip yeniden deniyoruz. Basit, etkili, çökme yok.


Peki somut olarak nasıl dönüyor?

Bir cron runının tam akışı:

1. GitHub Actions tetiklenir (5 dakikada bir cron)
2. "runtime" taginin checkoutu (önceden derlenmiş kod)
3. git show refs/tags/lastid → işlenmiş son UIDyi alır
4. node dist/index.js --action
    ├── IMAP bağlantısı
    ├── lastId+1 sonrasındaki postaları çek
    ├── her posta için:
    │   ├── içeriği ayrıştır
    │   ├── robotları filtrele (otomatikse atla)
    │   ├── alıcı hesabı eşleştir
    │   ├── konuşma belleğini al
    │   ├── yapay zekâ yanıtını üret (Groq)
    │   ├── <no_reply> mi? atla
    │   ├── <manual_reply_required> mi? insana ilet
    │   ├── yoksa: yanıtı gönder (SMTP)
    │   └── konuşma belleğini güncelle
    └── yeni lastId yazar
5. yeni değerle "lastid" taginin git push --force ile pushu

Ve 5 dakika sonra yeniden başlar. Sonsuza dek. Bedava.


Akılda tutulacak 3 nokta:

  1. Git = bedava veritabanı: Öksüz bir tag, stateless iki run arasında kalıcı durumunu saklayabilir. Okumak için git show refs/tags/X:dosya, yazmak için force-push. Tam bir veritabanından tasarruf edersin.

  2. Runtime taginde ön derleme: Cronun her runında npm install yerine derlenmiş kodu + node_modules içeriğini bir git taginde sakla. Cron anında başlar.

  3. Yapay zekâlı bir bot susmayı bilmeli: <no_reply> ve <manual_reply_required> tetikleyicileri yapay zekânın yanıt vermemesine ya da işi bırakmasına izin verir. Anti-robot filtrelemeyle birlikte. Yoksa sonsuz bir posta döngüsü yaratırsın.

Kalıcı durumlu sunucusuz cron, yapay zekâ, bellek, hepsi 0 €/ay. Tamamen kırık ve bu düzeneğe bayılıyorum.

Ho usato git come database per far girare un bot gratis su GitHub Actions

Come ho programmato un risponditore email IA che gira su GitHub Actions per 0€/mese: usando tag git come database e pre-compilato in un tag runtime.

Ho usato git come database per far girare un bot gratis su GitHub Actions

Ho un risponditore email automatico che gira 24/7.

Legge le mie mail, capisce di cosa parlano, e risponde da solo con un'IA. Si ricorda delle conversazioni precedenti. Ignora le newsletter e i noreply@. Inoltra a un umano quando è troppo caldo.

Costo mensile: 0€.

Faccio a meno di server, VPS e database. Solo GitHub Actions e un hack matto: usare git come database.

Vedi dove voglio arrivare? No? Bene, tieniti forte, è scemo e geniale insieme.


Il problema: GitHub Actions è stateless

GitHub Actions è gratis. Puoi lanciare un cron ogni 5 minuti, far girare il tuo codice, gratis.

Ma c'è un problema: è stateless.

Ogni run parte in una macchina vergine. Niente è salvato tra due esecuzioni. Il run prima? Dimenticato. Cancellato. Come se non fosse mai esistito.

Per un risponditore email, è un problema enorme. Tipo:

"Qual è l'ultima mail che ho già trattato?"

Se il bot dimentica questo a ogni run, o ri-risponde alle stesse mail in ciclo (catastrofe), o perde delle mail.

Serve uno stato persistente. E normalmente, stato persistente = database. Ma un database è un server, e un server non è più gratis.

È lì che diventa interessante.


La soluzione: tag git come database

Il tuo repo GitHub, è già storage persistente. Gratis. Versionato. Sempre lì.

Allora perché non salvarci lo stato?

L'idea: a ogni run, il bot legge l'ultimo UID email trattato da un tag git. Tratta le nuove mail. Poi ri-spinge il tag col nuovo UID.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Server IMAP

    Note over GH: Run #1
    GH->>GIT: legge tag "lastid"
    GIT-->>GH: vuoto (primo run)
    GH->>IMAP: fetch mail 1-50
    IMAP-->>GH: 50 mail
    GH->>GIT: push tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: legge tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mail 51-73
    IMAP-->>GH: 23 mail
    GH->>GIT: push tag "lastid" = 73

Il tag git È il database. Un solo valore, ma è tutto ciò di cui abbiamo bisogno.

Leggere lo stato

All'inizio del job, recuperiamo il valore dal tag:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId vuol dire: "dammi il contenuto del file data/lastId com'era nel tag lastid".

Boom. Hai il tuo valore, senza database.

Scrivere lo stato

Alla fine, ricreiamo il tag col nuovo valore:

git switch --orphan lastid-tmp   # branch vergine senza storico
git rm -rf .                      # svuotiamo tutto
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # forza il tag su questo commit
git push --force ...origin lastid # push del tag

Creiamo un branch orfano (senza storico), mettiamo solo il file lastId, committiamo, tagghiamo, force push.

Perché orfano? Per non accumulare 10 000 commit di stato nello storico del repo. Ogni update schiaccia il precedente. Il tag punta sempre a UN solo commit che contiene UN solo valore.

È pulito. È gratis. È completamente storto e funziona.


Il secondo hack: il runtime snapshot

C'è un altro problema con GitHub Actions: il npm install.

Se a ogni run (ogni 5 minuti) fai npm install + npm run build, sprechi 60-90 secondi ogni volta. Su un cron frequente, sono minuti di compute sprecati per niente.

Soluzione: pre-compilare il codice UNA volta, e salvarlo in un tag git anche lui.

Il workflow di build (che gira quando spingi su master) fa questo:

# compila il codice
bun install
bun run build

# salva dist/ + node_modules/ in un tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Il tag runtime contiene il codice compilato E i node_modules. Tutto pronto a girare.

E il cron, lui, fa checkout diretto di questo tag:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # il codice pre-build, non il sorgente
    fetch-depth: 1

# niente npm install, niente build!
- name: Process emails
  run: node dist/index.js --action

Il cron parte istantaneo ed esegue solo node dist/index.js, senza install né build.

Tipo, hai due tag che fanno due lavori:

  • runtime = il codice pronto a girare (aggiornato quando spingi codice)
  • lastid = lo stato persistente (aggiornato a ogni run)

È elegante come uno sporco.


Il bot in sé: risponditore IA

Bene, l'hack git è figo, ma il bot fa cosa esattamente?

Legge le tue mail via IMAP, le capisce con un'IA (Groq + Llama 3.3 70B), e risponde automaticamente.

Architettura in servizi puliti con injection di dipendenze (InversifyJS):

App
├── ImapService      → legge le mail (IMAP)
├── SmtpService      → invia le risposte (SMTP)
├── ParserService    → parsa il contenuto delle mail
├── ReplyService     → genera la risposta IA
├── SummaryService   → memoria di conversazione
├── AccountsService  → gestisce più account email
└── ConfigService    → config / env var

Due modalità di funzionamento

Il bot può girare in due modi:

Modalità listener (tempo reale): connessione IMAP permanente con reconnect esponenziale. Per un VPS.

// nuovo messaggio via listener IMAP
this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nuovo messaggio! Totale: ${data.count}`);
  // tratta il nuovo messaggio subito
});

Modalità action (batch): tratta le nuove mail dal lastId, poi si chiude. Per il cron GitHub Actions.

node dist/index.js --action

La modalità --action è quella che usa l'hack git. Legge lastId, tratta ciò che è nuovo, scrive il nuovo lastId, fine.

NON rispondere ai robot

Se il tuo bot risponde a TUTTE le mail, risponderà alle newsletter, alle notifiche, ai noreply@. Catastrofe. Peggio: se due bot si rispondono l'un l'altro, hai un ciclo infinito di mail. L'incubo.

Quindi filtraggio aggressivo:

// pattern di mittenti automatici da ignorare
export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

E anche rilevamento via header email:

// newsletter e mass-mailing via header
export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // le newsletter hanno questo
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe negli header? È una newsletter. Precedence: bulk? Del mass-mailing. X-Mailer: Mailchimp? Hai capito il genere. Ignoriamo.

È come un buttafuori di discoteca: i robot non passano.

I trigger magici

L'IA può decidere di non rispondere affatto, o di passare la mano a un umano. Come? Con trigger speciali nella sua risposta.

Il prompt di sistema le dice:

Se è una mail automatica/newsletter: rispondi <no_reply> Se è troppo importante/sensibile (legale, finanziario...): rispondi <manual_reply_required> Altrimenti: scrivi una vera risposta

E il codice legge questo:

// l'IA può rifiutare o passare a un umano
const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA ha deciso di ignorare. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Troppo caldo, inoltro a un umano.");
  await this.smtpService.sendManualForward(...);
  return;
}

// altrimenti inviamo la risposta IA
await this.smtpService.sendReply(...);

Tipo l'IA ha il diritto di dire "no qui non tocco, chiama un vero umano". È saggezza.


La memoria di conversazione

Un dettaglio che cambia tutto: il bot si ricorda delle conversazioni.

Quando risponde a qualcuno, salva un riassunto dello scambio. La prossima volta che questa persona scrive, il riassunto è reiniettato nel prompt.

Storage: un file JSON per contatto.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

E il riassunto è generato dall'IA stessa, che fonde il vecchio riassunto col nuovo messaggio:

// fonde il vecchio riassunto col nuovo messaggio
const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Sei un assistente di memoria. Fondi il vecchio riassunto col nuovo messaggio senza perdere info." },
    { role: "user", content: `Riassunto esistente:\n${existing}\n\nNuovo messaggio:\n${incomingContent}` }
  ],
  temperature: 0.0,  // deterministico, niente creatività
  max_tokens: 800,
});

Quindi il bot costruisce una memoria compressa nel tempo. Un riassunto che cresce in modo intelligente basta, senza salvare tutte le mail.

E questi file JSON? Beh... sono salvati in git anche loro, nel tag runtime. Git ovunque.


Il punto furbo con la lunghezza del prompt

Piccolo dettaglio tecnico che mi ha fatto sorridere.

I modelli hanno un limite di token. Se la tua mail + il riassunto + il prompt persona superano, l'API restituisce un errore.

Il codice gestisce questo con una troncatura a cascata + retry:

try {
  // primo tentativo con i limiti normali
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // era un errore di lunghezza: riproviamo con limiti più stretti
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // invece di 3000
      summaryChars: 1800,       // invece di 4000
      personaChars: 900,        // invece di 1500
      userContentChars: 2200,   // invece di 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Se non passa, tagliamo più corto e riproviamo. Semplice, efficace, niente crash.


Bene, e in concreto, come gira?

Il flusso completo di un run di cron:

1. GitHub Actions si attiva (cron ogni 5 min)
2. Checkout del tag "runtime" (codice pre-build)
3. git show refs/tags/lastid → recupera l'ultimo UID trattato
4. node dist/index.js --action
   ├── connessione IMAP
   ├── fetch delle mail da lastId+1
   ├── per ogni mail:
   │   ├── parsa il contenuto
   │   ├── filtra i robot (skip se automatico)
   │   ├── matcha l'account destinatario
   │   ├── recupera la memoria di conversazione
   │   ├── genera la risposta IA (Groq)
   │   ├── <no_reply>? skip
   │   ├── <manual_reply_required>? inoltro umano
   │   ├── altrimenti: invia la risposta (SMTP)
   │   └── aggiorna la memoria di conversazione
   └── scrive il nuovo lastId
5. git push --force tag "lastid" col nuovo valore

E ricomincia tra 5 minuti. Per sempre. Gratis.


I 3 punti da ricordare:

  1. Git = database gratis: un tag orfano può salvare il tuo stato persistente tra due run stateless. git show refs/tags/X:file per leggere, force-push per scrivere. Risparmi un DB completo.

  2. Pre-compila in un tag runtime: invece di npm install a ogni run del cron, salva il codice compilato + node_modules in un tag git. Il cron parte istantaneo.

  3. Un bot IA deve saper tacere: i trigger <no_reply> e <manual_reply_required> lasciano l'IA decidere di non rispondere o di passare la mano. Più il filtraggio anti-robot. Altrimenti crei un ciclo di mail infinito.

Cron serverless con stato persistente, IA, memoria, il tutto a 0€/mese. È completamente rotto e adoro questo montaggio.

Ich habe Git als Datenbank genutzt, um einen Gratis-Bot auf GitHub Actions laufen zu lassen

Wie ich einen KI-Mail-Autoresponder gecodet habe, der für 0€/Monat auf GitHub

Ich habe Git als Datenbank genutzt, um einen Gratis-Bot auf GitHub Actions laufen zu lassen

Ich habe einen automatischen Mail-Beantworter, der 24/7 läuft.

Er liest meine Mails, versteht, worum es geht, und antwortet allein mit einer KI. Er erinnert sich an frühere Gespräche. Er ignoriert Newsletter und noreply@. Er leitet an einen Menschen weiter, wenn es zu heikel wird.

Monatliche Kosten: 0€.

Ich verzichte auf Server, VPS und Datenbank. Nur GitHub Actions und ein kranker Hack: Git als Datenbank nutzen.

Du siehst, worauf ich hinauswill? Nein? Gut, halt dich fest, es ist dämlich und genial zugleich.


Das Problem: GitHub Actions ist stateless

GitHub Actions ist gratis. Du kannst alle 5 Minuten einen Cron starten, deinen Code laufen lassen, gratis.

Aber es gibt ein Problem: Es ist stateless.

Jeder Run startet auf einer jungfräulichen Maschine. Nichts wird zwischen zwei Ausführungen gespeichert. Der Run davor? Vergessen. Gelöscht. Als hätte er nie existiert.

Für einen Mail-Beantworter ist das ein riesiges Problem. So in der Art:

"Was ist die letzte Mail, die ich schon bearbeitet habe ?"

Wenn der Bot das bei jedem Run vergisst, wird er entweder dieselben Mails in Schleife erneut beantworten (Katastrophe) oder Mails verpassen.

Er braucht persistenten Zustand. Und normalerweise heißt persistenter Zustand Datenbank. Aber eine Datenbank ist ein Server, und ein Server ist nicht mehr gratis.

Hier wird es interessant.


Die Lösung: Git-Tags als Datenbank

Dein GitHub-Repo ist schon persistenter Speicher. Gratis. Versioniert. Immer da.

Warum also nicht den Zustand darin speichern?

Die Idee: Bei jedem Run liest der Bot die letzte bearbeitete Mail-UID aus einem Git-Tag. Er bearbeitet die neuen Mails. Dann pusht er den Tag mit der neuen UID erneut.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Serveur IMAP

    Note over GH: Run #1
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: vide (premier run)
    GH->>IMAP: fetch mails 1-50
    IMAP-->>GH: 50 mails
    GH->>GIT: push tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mails 51-73
    IMAP-->>GH: 23 mails
    GH->>GIT: push tag "lastid" = 73

Der Git-Tag IST die Datenbank. Ein einziger Wert, aber das ist alles, was wir brauchen.

Den State lesen

Am Jobanfang holen wir den Wert aus dem Tag:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId heißt: "Gib mir den Inhalt der Datei data/lastId, wie sie im Tag lastid war."

Boom. Du hast deinen Wert, ohne Datenbank.

Den State schreiben

Am Ende erstellen wir den Tag mit dem neuen Wert neu:

git switch --orphan lastid-tmp   # jungfräulicher Branch ohne Historie
git rm -rf .                      # alles leeren
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # Tag auf diesen Commit forcen
git push --force ...origin lastid # Tag pushen

Wir erstellen einen verwaisten Branch (ohne Historie), legen nur die Datei lastId hinein, committen, taggen, force-pushen.

Warum verwaist? Um nicht 10.000 State-Commits in der Repo-Historie anzuhäufen. Jedes Update überschreibt das vorherige. Der Tag zeigt immer auf EINEN einzigen Commit, der EINEN einzigen Wert enthält.

Das ist sauber. Das ist gratis. Das ist komplett verdreht und es funktioniert.


Der zweite Hack: der Runtime-Snapshot

Es gibt ein weiteres Problem mit GitHub Actions: Das npm install.

Wenn du bei jedem Run (alle 5 Minuten) npm install + npm run build machst, verschwendest du jedes Mal 60-90 Sekunden. Bei einem häufigen Cron sind das Minuten verschenkte Compute-Zeit für nichts.

Lösung: Den Code EINMAL vorkompilieren und auch in einem Git-Tag speichern.

Der Build-Workflow (der läuft, wenn du auf master pushst) macht das:

# Code kompilieren
bun install
bun run build

# dist/ + node_modules/ in einem "runtime"-Tag speichern
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Der runtime-Tag enthält den kompilierten Code UND die node_modules. Alles bereit zum Laufen.

Und der Cron checkt direkt diesen Tag aus:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # der vorgebaute Code, nicht der Source
    fetch-depth: 1

# kein npm install, kein Build !
- name: Process emails
  run: node dist/index.js --action

Der Cron startet augenblicklich und führt nur node dist/index.js aus, ohne Install oder Build.

So hast du zwei Tags, die zwei Jobs machen:

  • runtime = der lauffertige Code (aktualisiert, wenn du Code pushst)
  • lastid = der persistente Zustand (bei jedem Run aktualisiert)

Das ist elegant wie dreckig.


Der Bot selbst: KI-Autoresponder

Gut, der Git-Hack ist cool, aber was macht der Bot genau?

Er liest deine Mails via IMAP, versteht sie mit einer KI (Groq + Llama 3.3 70B) und antwortet automatisch.

Architektur in sauberen Services mit Dependency Injection (InversifyJS):

App
├── ImapService      → liest die Mails (IMAP)
├── SmtpService      → schickt die Antworten (SMTP)
├── ParserService    → parst den Mailinhalt
├── ReplyService     → generiert die KI-Antwort
├── SummaryService   → Gesprächsgedächtnis
├── AccountsService  → verwaltet mehrere Mailkonten
└── ConfigService    → Config / Env-Vars

Zwei Betriebsmodi

Der Bot kann auf zwei Arten laufen:

Listener-Modus (Echtzeit): Permanente IMAP-Verbindung mit exponentiellem Reconnect. Für einen VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Neue Mail ! Total: ${data.count}`);
  // bearbeitet die neue Mail sofort
});

Action-Modus (Batch): Bearbeitet die neuen Mails seit der lastId und schließt sich dann. Für den GitHub-Actions-Cron.

node dist/index.js --action

Der --action-Modus nutzt den Git-Hack. Er liest lastId, bearbeitet das Neue, schreibt die neue lastId, fertig.

NICHT an Roboter antworten

Wenn dein Bot ALLE Mails beantwortet, antwortet er Newslettern, Benachrichtigungen, noreply@. Katastrophe. Schlimmer: Wenn sich zwei Bots gegenseitig antworten, hast du eine endlose Mailschleife. Der Albtraum.

Also aggressives Filtern:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Und auch Erkennung via Mailheader:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // Newsletter haben das
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe in den Headern? Das ist ein Newsletter. Precedence: bulk? Massenmailing. X-Mailer: Mailchimp? Du siehst, worauf ich hinauswill. Wir ignorieren.

Das ist wie ein Club-Türsteher: Roboter kommen nicht rein.

Die magischen Trigger

Die KI kann entscheiden, gar nicht zu antworten oder an einen Menschen zu übergeben. Wie? Mit speziellen Triggern in ihrer Antwort.

Der Systemprompt sagt ihr:

Wenn es eine automatische Mail/ein Newsletter ist → antworte <no_reply> Wenn es zu wichtig/sensibel ist (rechtlich, finanziell...) → antworte <manual_reply_required> Sonst → schreibe eine echte Antwort

Und der Code liest das:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] Die KI hat entschieden zu ignorieren. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Zu heikel, ich leite an einen Menschen weiter.");
  await this.smtpService.sendManualForward(...);
  return;
}

// sonst schicken wir die KI-Antwort
await this.smtpService.sendReply(...);

So hat die KI das Recht zu sagen "nein, da fasse ich nicht an, ruf einen echten Menschen". Das ist Weisheit.


Das Gesprächsgedächtnis

Ein Detail, das alles ändert: Der Bot erinnert sich an Gespräche.

Wenn er jemandem antwortet, speichert er eine Zusammenfassung des Austauschs. Wenn diese Person das nächste Mal schreibt, wird die Zusammenfassung wieder in den Prompt injiziert.

Speicherung: Eine JSON-Datei pro Kontakt.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Und die Zusammenfassung wird selbst von der KI generiert, die die alte Zusammenfassung mit der neuen Nachricht mergt:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Du bist ein Gedächtnisassistent. Merge die alte Zusammenfassung mit der neuen Nachricht, ohne Info zu verlieren." },
    { role: "user", content: `Existierende Zusammenfassung:\n${existing}\n\nNeue Nachricht:\n${incomingContent}` }
  ],
  temperature: 0.0,  // deterministisch, keine Kreativität
  max_tokens: 800,
});

Der Bot baut also über die Zeit ein komprimiertes Gedächtnis auf. Eine Zusammenfassung, die intelligent wächst, reicht, ohne alle Mails zu speichern.

Und diese JSON-Dateien? Tja... sie liegen auch in Git, im Runtime-Tag. Git überall.


Der schlaue Punkt mit der Promptlänge

Kleines technisches Detail, das mich zum Schmunzeln brachte.

Modelle haben ein Tokenlimit. Wenn deine Mail + die Zusammenfassung + der Persona-Prompt darüber hinausgehen, gibt die API einen Fehler zurück.

Der Code handhabt das mit Kaskadentrunkierung + Retry:

try {
  // erster Versuch mit normalen Limits
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // es war ein Längenfehler : wir versuchen es mit engeren Limits erneut
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // statt 3000
      summaryChars: 1800,       // statt 4000
      personaChars: 900,        // statt 1500
      userContentChars: 2200,   // statt 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // Retry
}

Wenn es nicht durchgeht, schneiden wir kürzer und versuchen es erneut. Simpel, effektiv, kein Crash.


Gut, und konkret, wie läuft das?

Der komplette Flow eines Cron-Runs:

1. GitHub Actions löst aus (Cron alle 5 Min)
2. Checkout des "runtime"-Tags (vorgebauter Code)
3. git show refs/tags/lastid → holt die letzte bearbeitete UID
4. node dist/index.js --action
   ├── IMAP-Verbindung
   ├── Fetch der Mails seit lastId+1
   ├── für jede Mail :
   │   ├── Inhalt parsen
   │   ├── Roboter filtern (skip wenn automated)
   │   ├── Empfängerkonto matchen
   │   ├── Gesprächsgedächtnis holen
   │   ├── KI-Antwort generieren (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? Weiterleitung Mensch
   │   ├── sonst : Antwort schicken (SMTP)
   │   └── Gesprächsgedächtnis updaten
   └── neue lastId schreiben
5. git push --force Tag "lastid" mit neuem Wert

Und das beginnt in 5 Minuten von Neuem. Für immer. Gratis.


Die 3 Punkte zum Mitnehmen :

  1. Git = gratis Datenbank, ein verwaister Tag kann deinen persistenten Zustand zwischen zwei stateless Runs speichern. git show refs/tags/X:datei zum Lesen, Force-Push zum Schreiben. Du sparst eine komplette DB.

  2. Pre-Compile in einen Runtime-Tag, statt npm install bei jedem Cron-Run, speichere den kompilierten Code + node_modules in einem Git-Tag. Der Cron startet augenblicklich.

  3. Ein KI-Bot muss schweigen können, die Trigger <no_reply> und <manual_reply_required> lassen die KI entscheiden, nicht zu antworten oder zu übergeben. Plus Anti-Roboter-Filterung. Sonst erzeugst du eine endlose Mailschleife.

Serverless-Cron mit persistentem Zustand, KI, Gedächtnis, alles für 0€/Monat. Das ist komplett kaputt und ich liebe dieses Setup.

Я использовал git как базу данных, чтобы крутить бота бесплатно

Как я закодил email-автоответчик с ИИ, крутящийся на GitHub

Я использовал git как базу данных, чтобы крутить бота бесплатно на GitHub Actions

У меня есть автоматический email-ответчик, крутящийся 24/7.

Он читает мои письма, понимает, о чём речь, и сам отвечает с ИИ. Он помнит предыдущие разговоры. Он игнорирует рассылки и noreply@. Он форвардит человеку, когда слишком горячо.

Месячная стоимость : 0€.

Обхожусь без сервера, без VPS и без базы данных. Только GitHub Actions и больной хак : использовать git как базу данных.

Видишь, к чему веду? Нет? Ладно, пристегнись, это и тупо, и гениально одновременно.


Проблема : GitHub Actions stateless

GitHub Actions бесплатный. Можешь запускать cron каждые 5 минут, крутить свой код, бесплатно.

Но есть загвоздка : он stateless.

Каждый запуск стартует на чистой машине. Ничего не сохраняется между двумя запусками. Прошлый запуск? Забыт. Стёрт. Будто его никогда не было.

Для email-ответчика это огромная проблема. Типа :

"Какое последнее письмо я уже обработал?"

Если бот забывает это при каждом запуске, он будет либо отвечать на те же письма по кругу (катастрофа), либо пропускать письма.

Нужно персистентное состояние. А нормально персистентное состояние = база данных. Но база данных это сервер, а сервер это уже не бесплатно.

Здесь становится интересно.


Решение : git-теги как база данных

Твой репозиторий GitHub это уже персистентное хранилище. Бесплатное. Версионированное. Всегда здесь.

Так почему бы не хранить состояние в нём?

Идея : при каждом запуске бот читает последний обработанный UID письма из git-тега. Обрабатывает новые письма. Затем перепушивает тег с новым UID.

mermaid diagram

Git-тег И ЕСТЬ база данных. Одно значение, но это всё, что нужно.

Читать состояние

В начале джобы забираем значение из тега :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId значит : "дай содержимое файла data/lastId таким, каким оно было в теге lastid".

Бум. У тебя есть значение без базы данных.

Писать состояние

В конце пересоздаём тег с новым значением :

git switch --orphan lastid-tmp   # чистая ветка без истории
git rm -rf .                      # всё вычищаем
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # форсируем тег на этот коммит
git push --force ...origin lastid # пушим тег

Создаём сиротскую ветку (без истории), кладём только файл lastId, коммитим, тегаем, форспушим.

Почему сиротскую? Чтобы не копить 10 000 коммитов состояния в истории репо. Каждое обновление затирает предыдущее. Тег всегда указывает на ОДИН коммит, содержащий ОДНО значение.

Это чисто. Это бесплатно. Это совершенно криво, и это работает.


Второй хак : снапшот рантайма

Есть другая проблема с GitHub Actions : npm install.

Если при каждом запуске (каждые 5 минут) делать npm install + npm run build, тратишь 60-90 секунд каждый раз. На частом cron это минуты compute, потраченные ни на что.

Решение : прекомпилировать код ОДИН раз и хранить его тоже в git-теге.

Сборочный воркфлоу (крутящийся при push в master) делает это :

# компилируем код
bun install
bun run build

# храним dist/ + node_modules/ в теге "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Тег runtime содержит скомпилированный код И node_modules. Всё готово к запуску.

А cron чекаутит прямо этот тег :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # предсобранный код, не исходник
    fetch-depth: 1

# никакого npm install, никакой сборки !
- name: Process emails
  run: node dist/index.js --action

Cron стартует мгновенно и просто выполняет node dist/index.js, без install и сборки.

Типа, у тебя два тега на двух работах :

  • runtime = готовый к запуску код (обновляется, когда пушишь код)
  • lastid = персистентное состояние (обновляется при каждом запуске)

Это элегантно как чёрт.


Сам бот : ИИ-автоответчик

Ладно, git-хак это круто, но что именно делает бот?

Он читает твои письма через IMAP, понимает их с ИИ (Groq + Llama 3.3 70B) и отвечает автоматически.

Архитектура из чистых сервисов с инъекцией зависимостей (InversifyJS) :

App
├── ImapService      → читает письма (IMAP)
├── SmtpService      → отправляет ответы (SMTP)
├── ParserService    → парсит содержимое писем
├── ReplyService     → генерирует ИИ-ответ
├── SummaryService   → память разговоров
├── AccountsService  → управляет несколькими email-аккаунтами
└── ConfigService    → конфиг / env vars

Два режима работы

Бот может крутиться двумя способами :

Режим listener (реальное время) : постоянное IMAP-подключение с экспоненциальным переподключением. Для VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // обрабатываем новое письмо сразу
});

Режим action (батч) : обрабатывает новые письма начиная с lastId, затем закрывается. Для cron GitHub Actions.

node dist/index.js --action

Режим --action это тот, что использует git-хак. Он читает lastId, обрабатывает новое, пишет новый lastId, конец.

НЕ отвечать роботам

Если твой бот отвечает на ВСЕ письма, он будет отвечать на рассылки, уведомления, noreply@. Катастрофа. Хуже : если два бота отвечают друг другу, получаешь бесконечный цикл писем. Кошмар.

Поэтому агрессивная фильтрация :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

А также детект по заголовкам писем :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // рассылки имеют это
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe в заголовках? Это рассылка. Precedence: bulk? Массовая рассылка. X-Mailer: Mailchimp? Понимаешь. Игнорируем.

Это как вышибала в клубе : роботы не проходят.

Магические триггеры

ИИ может решить вообще не отвечать или передать человеку. Как? Специальными триггерами в своём ответе.

Системный промпт ему говорит :

Если это автоматическое письмо/рассылка → отвечай <no_reply> Если это слишком важное/чувствительное (легальное, финансовое...) → отвечай <manual_reply_required> Иначе → пиши настоящий ответ

А код это читает :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// иначе отправляем ИИ-ответ
await this.smtpService.sendReply(...);

Типа ИИ имеет право сказать "нет, сюда не лезу, зови настоящего человека". Это мудрость.


Память разговоров

Деталь, меняющая всё : бот помнит разговоры.

Когда он отвечает кому-то, он сохраняет резюме обмена. В следующий раз, когда этот человек пишет, резюме реинжектится в промпт.

Хранение : JSON-файл на контакт.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

А резюме генерируется самим ИИ, смердживающим старое резюме с новым сообщением :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // детерминированно, без креатива
  max_tokens: 800,
});

Так бот строит сжимающуюся память со временем. Умно растущего резюме достаточно, без хранения всех писем.

А эти JSON-файлы? Ну... они тоже хранятся в git, в теге runtime. Git повсюду.


Хитрый момент с длиной промпта

Маленькая техническая деталь, заставившая меня улыбнуться.

У моделей есть лимит токенов. Если твоё письмо + резюме + persona-промпт превышают, API возвращает ошибку.

Код разруливает это каскадным урезанием + ретраем :

try {
  // первая попытка с нормальными лимитами
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // это была ошибка длины : пробуем заново с более жёсткими лимитами
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // вместо 3000
      summaryChars: 1800,       // вместо 4000
      personaChars: 900,        // вместо 1500
      userContentChars: 2200,   // вместо 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Если не проходит, режем короче и пробуем заново. Просто, эффективно, без краша.


Ладно, а конкретно как это крутится?

Полный флоу одного запуска cron :

1. GitHub Actions триггерится (cron каждые 5 мин)
2. Checkout тега "runtime" (предсобранный код)
3. git show refs/tags/lastid → забирает последний обработанный UID
4. node dist/index.js --action
   ├── IMAP-подключение
   ├── fetch писем начиная с lastId+1
   ├── на каждое письмо :
   │   ├── парсит содержимое
   │   ├── фильтрует роботов (skip если automated)
   │   ├── матчит аккаунт-получатель
   │   ├── забирает память разговора
   │   ├── генерирует ИИ-ответ (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? форвард человеку
   │   ├── иначе : отправляет ответ (SMTP)
   │   └── обновляет память разговора
   └── пишет новый lastId
5. git push --force тега "lastid" с новым значением

И это повторяется через 5 минут. Всегда. Бесплатно.


3 пункта запомнить :

  1. Git = бесплатная база данных -- Сиротский тег может хранить твоё персистентное состояние между двумя stateless-запусками. git show refs/tags/X:файл для чтения, force-push для записи. Экономишь целую БД.

  2. Прекомпилируй в тег runtime -- Вместо npm install при каждом запуске cron храни скомпилированный код + node_modules в git-теге. Cron стартует мгновенно.

  3. ИИ-бот должен уметь молчать -- Триггеры <no_reply> и <manual_reply_required> дают ИИ решать не отвечать или передать человеку. Плюс антиробот-фильтрация. Иначе создашь бесконечный цикл писем.

Serverless cron с персистентным состоянием, ИИ, памятью, всё за 0€/мес. Это совершенно сломано, и я обожаю эту конструкцию.

Usé git como base de datos para correr un bot gratis

Cómo programé un autorespondedor email IA que corre en GitHub

Usé git como base de datos para correr un bot gratis en GitHub Actions

Tengo un contestador email automático que corre 24/7.

Lee mis mails, entiende de qué hablan, y responde solo con una IA. Recuerda las conversaciones previas. Ignora las newsletters y los noreply@. Deriva a un humano cuando está demasiado caliente.

Coste mensual: 0€.

Paso de servidor, de VPS y de base de datos. Solo GitHub Actions y un hack de locos: usar git como base de datos.

¿Ves por dónde voy? ¿No? Bueno, agárrate, es tonto y genial a la vez.


El problema: GitHub Actions es stateless

GitHub Actions es gratis. Puedes lanzar un cron cada 5 minutos, correr tu código, gratis.

Pero hay un problema: es stateless.

Cada run arranca en una máquina virgen. Nada se guarda entre dos ejecuciones. ¿El run anterior? Olvidado. Borrado. Como si nunca hubiera existido.

Para un contestador email, es un problema enorme. Tipo:

"¿Cuál es el último mail que ya procesé?"

Si el bot olvida eso en cada run, o bien vuelve a responder los mismos mails en bucle (catástrofe), o bien pierde mails.

Hace falta un estado persistente. Y normalmente, estado persistente = base de datos. Pero una base de datos es un servidor, y un servidor ya no es gratis.

Ahí se pone interesante.


La solución: git tags como base de datos

Tu repo GitHub ya es almacenamiento persistente. Gratis. Versionado. Siempre ahí.

¿Entonces por qué no guardar el estado ahí?

La idea: en cada run, el bot lee el último UID email procesado desde un git tag. Procesa los mails nuevos. Y luego vuelve a empujar el tag con el nuevo UID.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Servidor IMAP

    Note over GH: Run #1
    GH->>GIT: lee tag "lastid"
    GIT-->>GH: vacío (primer run)
    GH->>IMAP: fetch mails 1-50
    IMAP-->>GH: 50 mails
    GH->>GIT: push tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: lee tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mails 51-73
    IMAP-->>GH: 23 mails
    GH->>GIT: push tag "lastid" = 73

El tag git ES la base de datos. Un solo valor, pero es todo lo que necesitamos.

Leer el state

Al inicio del job, recuperamos el valor desde el tag:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId significa: "dame el contenido del archivo data/lastId tal como estaba en el tag lastid".

Boom. Tienes tu valor, sin base de datos.

Escribir el state

Al final, recreamos el tag con el nuevo valor:

git switch --orphan lastid-tmp   # rama virgen sin historial
git rm -rf .                      # vaciamos todo
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # fuerza el tag sobre ese commit
git push --force ...origin lastid # push del tag

Creamos una rama huérfana (sin historial), metemos solo el archivo lastId, commiteamos, tageamos, force push.

¿Por qué huérfana? Para no acumular 10 000 commits de state en el historial del repo. Cada update machaca el anterior. El tag siempre apunta a UN solo commit que contiene UN solo valor.

Es limpio. Es gratis. Es totalmente retorcido y funciona.


El segundo hack: el runtime snapshot

Hay otro problema con GitHub Actions: el npm install.

Si en cada run (cada 5 minutos) haces npm install + npm run build, desperdicias 60-90 segundos cada vez. En un cron frecuente, son minutos de cómputo desperdiciados para nada.

Solución: precompilar el código UNA vez, y guardarlo en un tag git también.

El workflow de build (que corre cuando haces push a master) hace eso:

# compila el código
bun install
bun run build

# guarda dist/ + node_modules/ en un tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

El tag runtime contiene el código compilado Y los node_modules. Todo listo para correr.

Y el cron, por su parte, hace checkout directo de ese tag:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # el código pre-build, no la fuente
    fetch-depth: 1

# ¡sin npm install, sin build!
- name: Process emails
  run: node dist/index.js --action

El cron arranca al instante y solo ejecuta node dist/index.js, sin install ni build.

O sea, tienes dos tags que hacen dos trabajos:

  • runtime = el código listo para correr (actualizado cuando haces push de código)
  • lastid = el estado persistente (actualizado en cada run)

Es elegante como un guarro.


El bot en sí: autorespondedor IA

Bueno, el hack git mola, pero ¿el bot qué hace exactamente?

Lee tus mails vía IMAP, los entiende con una IA (Groq + Llama 3.3 70B), y responde automáticamente.

Arquitectura en servicios limpios con inyección de dependencias (InversifyJS):

App
├── ImapService      → lee los mails (IMAP)
├── SmtpService      → envía las respuestas (SMTP)
├── ParserService    → parsea el contenido de los mails
├── ReplyService     → genera la respuesta IA
├── SummaryService   → memoria de conversación
├── AccountsService  → gestiona varias cuentas email
└── ConfigService    → config / env vars

Dos modos de funcionamiento

El bot puede correr de dos formas:

Modo listener (tiempo real): conexión IMAP permanente con reconnect exponencial. Para un VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nuevo mail ! Total: ${data.count}`);
  // procesa el nuevo mail de inmediato
});

Modo action (batch): procesa los mails nuevos desde el lastId, y luego se cierra. Para el cron GitHub Actions.

node dist/index.js --action

El modo --action es el que usa el hack git. Lee lastId, procesa lo nuevo, escribe el nuevo lastId, fin.

NO responder a los robots

Si tu bot responde a TODOS los mails, va a responder a las newsletters, a las notificaciones, a los noreply@. Catástrofe. Peor: si dos bots se responden mutuamente, tienes un bucle infinito de mails. La pesadilla.

Así que filtrado agresivo:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Y también detección vía las cabeceras email:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // las newsletters tienen eso
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

¿List-Unsubscribe en las cabeceras? Es una newsletter. ¿Precedence: bulk? Mass-mailing. ¿X-Mailer: Mailchimp? Ya ves. Lo ignoramos.

Es como un portero de discoteca: los robots no pasan.

Los triggers mágicos

La IA puede decidir no responder en absoluto, o pasar la mano a un humano. ¿Cómo? Con triggers especiales en su respuesta.

El prompt de sistema le dice:

Si es un mail automático/newsletter → responde <no_reply> Si es demasiado importante/sensible (legal, financiero...) → responde <manual_reply_required> Si no → escribe una verdadera respuesta

Y el código lee eso:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] La IA decidió ignorar. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Demasiado caliente, lo derivo a un humano.");
  await this.smtpService.sendManualForward(...);
  return;
}

// si no enviamos la respuesta IA
await this.smtpService.sendReply(...);

O sea la IA tiene derecho a decir "no ahí no toco, llama a un humano de verdad". Eso es sabiduría.


La memoria de conversación

Un detalle que lo cambia todo: el bot recuerda las conversaciones.

Cuando responde a alguien, guarda un resumen del intercambio. La próxima vez que esa persona escriba, el resumen se reinyecta en el prompt.

Almacenamiento: un archivo JSON por contacto.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Y el resumen lo genera la propia IA, que fusiona el resumen viejo con el mensaje nuevo:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Eres un asistente de memoria. Fusiona el resumen viejo con el mensaje nuevo sin perder info." },
    { role: "user", content: `Resumen existente:\n${existing}\n\nMensaje nuevo:\n${incomingContent}` }
  ],
  temperature: 0.0,  // determinista, sin creatividad
  max_tokens: 800,
});

Así el bot construye una memoria comprimida con el tiempo. Un resumen que crece con inteligencia basta, sin guardar todos los mails.

¿Y esos archivos JSON? Pues... también están guardados en git, en el runtime tag. Git en todas partes.


El punto ingenioso con la longitud del prompt

Pequeño detalle técnico que me hizo sonreír.

Los modelos tienen un límite de tokens. Si tu mail + el resumen + el persona prompt superan, la API devuelve un error.

El código gestiona eso con una truncación en cascada + retry:

try {
  // primer intento con los límites normales
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // era un error de longitud: reintentamos con límites más prietos
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // en vez de 3000
      summaryChars: 1800,       // en vez de 4000
      personaChars: 900,        // en vez de 1500
      userContentChars: 2200,   // en vez de 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Si no pasa, cortamos más corto y reintentamos. Simple, eficaz, sin crash.


Bueno, y en concreto, ¿cómo corre?

El flow completo de un run de cron:

1. GitHub Actions se dispara (cron cada 5 min)
2. Checkout del tag "runtime" (código pre-build)
3. git show refs/tags/lastid → recupera el último UID procesado
4. node dist/index.js --action
   ├── conexión IMAP
   ├── fetch de los mails desde lastId+1
   ├── para cada mail:
   │   ├── parsea el contenido
   │   ├── filtra los robots (skip si automated)
   │   ├── matchea la cuenta destinataria
   │   ├── recupera la memoria de conversación
   │   ├── genera la respuesta IA (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? forward humano
   │   ├── si no: envía la respuesta (SMTP)
   │   └── update la memoria de conversación
   └── escribe el nuevo lastId
5. git push --force tag "lastid" con el nuevo valor

Y vuelve a empezar en 5 minutos. Para siempre. Gratis.


Los 3 puntos a recordar:

  1. Git = base de datos gratis: Un tag huérfano puede guardar tu estado persistente entre dos runs stateless. git show refs/tags/X:fichero para leer, force-push para escribir. Te ahorras una DB completa.

  2. Precompila en un tag runtime: En vez de npm install en cada run del cron, guarda el código compilado + node_modules en un tag git. El cron arranca al instante.

  3. Un bot IA debe saber callarse: Los triggers <no_reply> y <manual_reply_required> dejan a la IA decidir no responder o pasar la mano. Más el filtrado antirrobot. Si no creas un bucle de mails infinito.

Cron serverless con estado persistente, IA, memoria, todo por 0€/mes. Está completamente roto y me encanta ese montaje.

Usei git como banco de dados para rodar um bot de graça

Como codei um autorespondedor de email com IA que roda no GitHub

Usei git como banco de dados para rodar um bot de graça no GitHub Actions

Tenho um respondedor automático de email rodando 24/7.

Ele lê meus emails, entende do que se trata, e responde sozinho com uma IA. Lembra das conversas anteriores. Ignora newsletters e noreply@. Encaminha para um humano quando esquenta.

Custo mensal : 0€.

Dispenso servidor, VPS e banco de dados. Só GitHub Actions e um hack doido : usar git como banco de dados.

Sacou onde quero chegar ? Não ? Então se segura, é idiota e genial ao mesmo tempo.


O problema : GitHub Actions é stateless

GitHub Actions é de graça. Dá para lançar um cron a cada 5 minutos, rodar seu código, grátis.

Mas tem um porém : é stateless.

Cada run começa em uma máquina limpa. Nada é salvo entre duas execuções. O run anterior ? Esquecido. Apagado. Como se nunca tivesse existido.

Para um respondedor de email, isso é um problema enorme. Tipo :

"Qual foi o último email que já processei ?"

Se o bot esquece isso a cada run, vai responder aos mesmos emails em loop (catástrofe), ou perder emails.

Precisa de um estado persistente. E normalmente, estado persistente = banco de dados. Mas banco de dados é servidor, e servidor não é mais grátis.

É aí que fica interessante.


A solução : git tags como banco de dados

Seu repo do GitHub já é armazenamento persistente. Grátis. Versionado. Sempre lá.

Então por que não guardar o estado nele ?

A ideia : a cada run, o bot lê o último UID de email processado a partir de uma git tag. Processa os emails novos. Depois reenvia a tag com o novo UID.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Servidor IMAP

    Note over GH: Run #1
    GH->>GIT: lê tag "lastid"
    GIT-->>GH: vazia (primeiro run)
    GH->>IMAP: busca emails 1-50
    IMAP-->>GH: 50 emails
    GH->>GIT: envia tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: lê tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: busca emails 51-73
    IMAP-->>GH: 23 emails
    GH->>GIT: envia tag "lastid" = 73

A tag git É o banco de dados. Um único valor, mas é tudo que precisamos.

Ler o estado

No início do job, pegamos o valor a partir da tag :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId quer dizer : "me dá o conteúdo do arquivo data/lastId como estava na tag lastid".

Boom. Você tem seu valor, sem banco de dados.

Escrever o estado

No fim, recriamos a tag com o novo valor :

git switch --orphan lastid-tmp   # branch limpo sem histórico
git rm -rf .                      # esvazia tudo
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # força a tag neste commit
git push --force ...origin lastid # envia a tag

Criamos um branch órfão (sem histórico), colocamos só o arquivo lastId, commitamos, marcamos, forçamos o push.

Por que órfão ? Para não acumular 10 000 commits de estado no histórico do repo. Cada update sobrescreve o anterior. A tag aponta sempre para UM único commit que contém UM único valor.

É limpo. É grátis. É completamente torto e funciona.


O segundo hack : o snapshot de runtime

Há outro problema com GitHub Actions : o npm install.

Se a cada run (a cada 5 minutos) você faz npm install + npm run build, desperdiça 60-90 segundos toda vez. Em um cron frequente, são minutos de compute jogados fora à toa.

Solução : pré-compilar o código UMA vez, e guardar em uma tag git também.

O workflow de build (que roda quando você dá push em master) faz isso :

# compile le code
bun install
bun run build

# stocke dist/ + node_modules/ dans un tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

A tag runtime contém o código compilado E os node_modules. Tudo pronto para rodar.

E o cron faz checkout direto dessa tag :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # o código pré-build, não o fonte
    fetch-depth: 1

# sem npm install, sem build !
- name: Process emails
  run: node dist/index.js --action

O cron inicia na hora e só executa node dist/index.js, sem install nem build.

Tipo, você tem duas tags com dois trabalhos :

  • runtime = o código pronto para rodar (atualizado quando você dá push no código)
  • lastid = o estado persistente (atualizado a cada run)

É elegante que só.


O bot em si : autorespondedor com IA

Bom, o hack git é legal, mas o que o bot faz exatamente ?

Ele lê seus emails via IMAP, entende com uma IA (Groq + Llama 3.3 70B), e responde automaticamente.

Arquitetura em serviços limpos com injeção de dependências (InversifyJS) :

App
├── ImapService      → lê os emails (IMAP)
├── SmtpService      → envia as respostas (SMTP)
├── ParserService    → interpreta o conteúdo dos emails
├── ReplyService     → gera a resposta com IA
├── SummaryService   → memória de conversa
├── AccountsService  → gerencia várias contas de email
└── ConfigService    → config / env vars

Dois modos de funcionamento

O bot pode rodar de dois jeitos :

Modo listener (tempo real) : conexão IMAP permanente com reconnect exponencial. Para um VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // processa o email novo imediatamente
});

Modo action (batch) : processa os emails novos desde o lastId, depois fecha. Para o cron do GitHub Actions.

# Modo batch para o cron : processa o que é novo e fecha
node dist/index.js --action

O modo --action é o que usa o hack git. Lê o lastId, processa o que é novo, escreve o novo lastId, fim.

NÃO responder a robôs

Se seu bot responder a TODOS os emails, vai responder a newsletters, notificações, noreply@. Catástrofe. Pior : se dois bots respondem um ao outro, você tem um loop infinito de emails. O pesadelo.

Então filtragem agressiva :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

E também detecção via headers do email :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletters têm isso
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe nos headers ? É newsletter. Precedence: bulk ? Email em massa. X-Mailer: Mailchimp ? Sacou. Ignoramos.

É como um segurança de balada : robôs não entram.

Os triggers mágicos

A IA pode decidir não responder nada, ou passar para um humano. Como ? Com triggers especiais na resposta dela.

O prompt de sistema diz :

Se for email automático/newsletter → responda <no_reply> Se for muito importante/sensível (legal, financeiro...) → responda <manual_reply_required> Senão → escreva uma resposta de verdade

E o código lê isso :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// senão enviamos a resposta da IA
await this.smtpService.sendReply(...);

Tipo a IA tem o direito de dizer "não, aqui não mexo, chama um humano de verdade". É sabedoria.


A memória de conversa

Um detalhe que muda tudo : o bot lembra das conversas.

Quando responde a alguém, salva um resumo da troca. Da próxima vez que essa pessoa escrever, o resumo é reinjetado no prompt.

Armazenamento : um arquivo JSON por contato.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

E o resumo é ele mesmo gerado pela IA, que junta o resumo antigo com a mensagem nova :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // determinístico, sem criatividade
  max_tokens: 800,
});

Então o bot constrói uma memória comprimida ao longo do tempo. Um resumo que cresce com inteligência basta, sem guardar todos os emails.

E esses arquivos JSON ? Bom... ficam guardados no git também, na tag runtime. Git por todo lado.


O ponto esperto com o tamanho do prompt

Pequeno detalhe técnico que me fez sorrir.

Os modelos têm um limite de tokens. Se seu email + o resumo + o prompt de persona passarem, a API devolve um erro.

O código lida com isso com truncamento em cascata + retry :

try {
  // primeira tentativa com os limites normais
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // foi erro de tamanho : tentamos de novo com limites mais apertados
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // em vez de 3000
      summaryChars: 1800,       // em vez de 4000
      personaChars: 900,        // em vez de 1500
      userContentChars: 2200,   // em vez de 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Se não passar, cortamos mais curto e tentamos de novo. Simples, eficaz, sem crash.


Bom, e concretamente, como roda ?

O fluxo completo de um run do cron :

1. GitHub Actions dispara (cron a cada 5 min)
2. Checkout da tag "runtime" (código pré-build)
3. git show refs/tags/lastid → pega o último UID processado
4. node dist/index.js --action
   ├── conexão IMAP
   ├── busca dos emails desde lastId+1
   ├── para cada email :
   │   ├── interpreta o conteúdo
   │   ├── filtra os robôs (pula se automatizado)
   │   ├── casa a conta destinatária
   │   ├── pega a memória de conversa
   │   ├── gera a resposta com IA (Groq)
   │   ├── <no_reply> ? pula
   │   ├── <manual_reply_required> ? encaminha para humano
   │   ├── senão : envia a resposta (SMTP)
   │   └── atualiza a memória de conversa
   └── escreve o novo lastId
5. git push --force da tag "lastid" com o novo valor

E recomeça em 5 minutos. Para sempre. Grátis.


Os 3 pontos para lembrar :

  1. Git = banco de dados gratuito, Uma tag órfã pode guardar seu estado persistente entre dois runs stateless. git show refs/tags/X:arquivo para ler, force-push para escrever. Você economiza um DB inteiro.

  2. Pré-compile em uma tag runtime, Em vez de npm install a cada run do cron, guarde o código compilado + node_modules em uma tag git. O cron inicia na hora.

  3. Um bot de IA precisa saber calar, Os triggers <no_reply> e <manual_reply_required> deixam a IA decidir não responder ou passar adiante. Mais a filtragem anti-robô. Senão você cria um loop infinito de emails.

Cron serverless com estado persistente, IA, memória, tudo por 0€/mês. É completamente maluco e adoro essa montagem.

Saya memakai git sebagai basis data untuk menjalankan bot gratis

Bagaimana saya mengoding penjawab email otomatis AI yang berjalan di GitHub

Saya memakai git sebagai basis data untuk menjalankan bot gratis di GitHub Actions

Saya punya penjawab email otomatis yang berjalan 24/7.

Ia membaca email saya, memahami isinya, dan menjawab sendiri dengan AI. Ia mengingat percakapan sebelumnya. Ia mengabaikan newsletter dan noreply@. Ia meneruskan ke manusia kalau terlalu panas.

Biaya bulanan : 0€.

Saya tidak memakai server, VPS, maupun basis data. Hanya GitHub Actions dan hack gila : memakai git sebagai basis data.

Kamu paham maksud saya? Tidak? Oke, pegangan, ini bodoh sekaligus jenius.


Masalahnya : GitHub Actions itu stateless

GitHub Actions itu gratis. Kamu bisa menjalankan cron setiap 5 menit, menjalankan kodemu, gratis.

Tapi ada masalah : ia stateless.

Setiap run dimulai di mesin perawan. Tidak ada yang disimpan antara dua eksekusi. Run sebelumnya? Lupa. Dihapus. Seolah tidak pernah ada.

Untuk penjawab email, ini masalah besar. Semisal :

"Email terakhir apa yang sudah saya proses?"

Kalau bot lupa itu di setiap run, ia akan menjawab email yang sama berulang-ulang (bencana), atau melewatkan email.

Butuh keadaan persisten. Dan normalnya, keadaan persisten = basis data. Tapi basis data berarti server, dan server berarti tidak gratis lagi.

Di sinilah jadi menarik.


Solusinya : tag git sebagai basis data

Repo GitHub-mu sudah penyimpanan persisten. Gratis. Berversi. Selalu ada.

Jadi kenapa tidak menyimpan keadaan di sana?

Idenya : di setiap run, bot membaca UID email terakhir yang diproses dari tag git. Ia memproses email baru. Lalu ia push ulang tag dengan UID baru.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Serveur IMAP

    Note over GH: Run #1
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: vide (premier run)
    GH->>IMAP: fetch mails 1-50
    IMAP-->>GH: 50 mails
    GH->>GIT: push tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mails 51-73
    IMAP-->>GH: 23 mails
    GH->>GIT: push tag "lastid" = 73

Tag git ADALAH basis datanya. Satu nilai saja, tapi itu semua yang kita butuhkan.

Membaca state

Di awal job, kita mengambil nilai dari tag :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId artinya : "kasih saya isi berkas data/lastId sebagaimana adanya di tag lastid".

Boom. Kamu dapat nilaimu, tanpa basis data.

Di akhir, kita membuat ulang tag dengan nilai baru :

git switch --orphan lastid-tmp   # branche vierge sans historique
git rm -rf .                      # on vide tout
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # force le tag sur ce commit
git push --force ...origin lastid # push le tag

Kita membuat branch yatim (tanpa riwayat), menaruh berkas lastId saja, commit, tag, force push.

Kenapa yatim? Supaya tidak menumpuk 10 000 commit state di riwayat repo. Setiap pembaruan menimpa sebelumnya. Tag selalu menunjuk ke SATU commit yang berisi SATU nilai.

Ini bersih. Gratis. Benar-benar bengkok dan berhasil.


Hack kedua : snapshot runtime

Ada masalah lain dengan GitHub Actions : npm install.

Kalau di setiap run (setiap 5 menit) kamu melakukan npm install + npm run build, kamu membuang 60-90 detik setiap kali. Pada cron yang sering, itu menit-menit komputasi yang terbuang percuma.

Solusi : pra-kompilasi kode SEKALI saja, dan simpan dalam tag git juga.

Workflow build (yang berjalan ketika kamu push ke master) melakukan ini :

# mengompilasi kode
bun install
bun run build

# menyimpan dist/ + node_modules/ dalam tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Tag runtime berisi kode terkompilasi DAN node_modules. Semua siap jalan.

Dan cron, checkout langsung tag ini :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # le code pré-build, pas le source
    fetch-depth: 1

# pas de npm install, pas de build !
- name: Process emails
  run: node dist/index.js --action

Cron mulai seketika dan hanya menjalankan node dist/index.js, tanpa install maupun build.

Jadi, kamu punya dua tag yang mengerjakan dua tugas :

  • runtime = kode siap jalan (diperbarui ketika kamu push kode)
  • lastid = keadaan persisten (diperbarui di setiap run)

Elegan seperti orang gila.


Botnya sendiri : penjawab otomatis AI

Oke, hack git keren, tapi bot ngapain tepatnya?

Ia membaca emailmu lewat IMAP, memahaminya dengan AI (Groq + Llama 3.3 70B), dan menjawab otomatis.

Arsitektur dalam layanan bersih dengan injeksi dependensi (InversifyJS) :

App
├── ImapService      → lit les mails (IMAP)
├── SmtpService      → envoie les réponses (SMTP)
├── ParserService    → parse le contenu des mails
├── ReplyService     → génère la réponse IA
├── SummaryService   → mémoire de conversation
├── AccountsService  → gère plusieurs comptes email
└── ConfigService    → config / env vars

Dua mode operasi

Bot bisa berjalan dua cara :

Mode listener (waktu nyata) : koneksi IMAP permanen dengan reconnect eksponensial. Untuk VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // memproses email baru segera
});

Mode action (batch) : memproses email baru sejak lastId, lalu menutup. Untuk cron GitHub Actions.

node dist/index.js --action

Mode --action itulah yang memakai hack git. Ia membaca lastId, memproses yang baru, menulis lastId baru, selesai.

JANGAN menjawab robot

Kalau botmu menjawab SEMUA email, ia akan menjawab newsletter, notifikasi, noreply@. Bencana. Lebih parah : kalau dua bot saling menjawab, kamu dapat loop email tak berujung. Mimpi buruk.

Jadi penyaringan agresif :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Dan juga deteksi lewat header email :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletter punya itu
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe di header? Itu newsletter. Precedence: bulk? Mass-mailing. X-Mailer: Mailchimp? Kamu paham maksudnya. Kita abaikan.

Ini seperti penjaga klub malam : robot tidak lewat.

Pemicu ajaib

AI bisa memutuskan tidak menjawab sama sekali, atau menyerahkan ke manusia. Bagaimana? Dengan pemicu khusus dalam responsnya.

Prompt sistem memberitahunya :

Kalau ini email otomatis/newsletter → jawab <no_reply> Kalau ini terlalu penting/sensitif (legal, finansial...) → jawab <manual_reply_required> Kalau tidak → tulis jawaban sungguhan

Dan kode membacanya :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] L'IA a décidé d'ignorer. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Trop chaud, je forward à un humain.");
  await this.smtpService.sendManualForward(...);
  return;
}

// kalau tidak kita kirim jawaban AI
await this.smtpService.sendReply(...);

Jadi AI berhak bilang "bukan, saya tidak sentuh, panggil manusia sungguhan". Itu kebijaksanaan.


Memori percakapan

Detail yang mengubah segalanya : bot mengingat percakapan.

Ketika ia menjawab seseorang, ia menyimpan ringkasan pertukaran. Lain kali orang itu menulis, ringkasan disuntikkan ulang ke prompt.

Penyimpanan : satu berkas JSON per kontak.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Dan ringkasannya sendiri dibangkitkan oleh AI, yang menggabung ringkasan lama dengan pesan baru :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // déterministe, pas de créativité
  max_tokens: 800,
});

Jadi bot membangun memori terkompresi seiring waktu. Satu ringkasan yang membesar dengan cerdas sudah cukup, tanpa menyimpan semua email.

Dan berkas-berkas JSON itu? Ya... mereka juga disimpan di git, di tag runtime. Git di mana-mana.


Poin cerdik soal panjang prompt

Detail teknis kecil yang membuat saya tersenyum.

Model punya batas token. Kalau emailmu + ringkasan + prompt persona melebihi, API mengembalikan error.

Kode menanganinya dengan pemotongan bertingkat + retry :

try {
  // percobaan pertama dengan batas normal
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // itu error panjang : kita coba lagi dengan batas lebih ketat
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // au lieu de 3000
      summaryChars: 1800,       // au lieu de 4000
      personaChars: 900,        // au lieu de 1500
      userContentChars: 2200,   // au lieu de 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

Kalau tidak lolos, kita potong lebih pendek dan coba lagi. Sederhana, efektif, tidak crash.


Oke, dan konkretnya, bagaimana jalannya?

Alur lengkap satu run cron :

1. GitHub Actions terpicu (cron setiap 5 mnt)
2. Checkout tag "runtime" (kode pra-build)
3. git show refs/tags/lastid → mengambil UID terakhir yang diproses
4. node dist/index.js --action
   ├── koneksi IMAP
   ├── fetch email sejak lastId+1
   ├── untuk setiap email :
   │   ├── parse isi
   │   ├── saring robot (skip kalau otomatis)
   │   ├── cocokkan akun penerima
   │   ├── ambil memori percakapan
   │   ├── bangkitkan jawaban AI (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? teruskan ke manusia
   │   ├── kalau tidak : kirim jawaban (SMTP)
   │   └── perbarui memori percakapan
   └── menulis lastId baru
5. git push --force tag "lastid" dengan nilai baru

Dan itu berulang dalam 5 menit. Selamanya. Gratis.


3 poin yang perlu diingat :

  1. Git = basis data gratis -- Tag yatim bisa menyimpan keadaan persistenmu antara dua run stateless. git show refs/tags/X:fichier untuk membaca, force-push untuk menulis. Kamu menghemat satu DB utuh.

  2. Pra-kompilasi dalam tag runtime -- Alih-alih npm install di setiap run cron, simpan kode terkompilasi + node_modules dalam tag git. Cron mulai seketika.

  3. Bot AI harus tahu diam -- Pemicu <no_reply> dan <manual_reply_required> membiarkan AI memutuskan tidak menjawab atau menyerahkan. Ditambah penyaringan anti-robot. Kalau tidak kamu menciptakan loop email tak berujung.

Cron serverless dengan keadaan persisten, AI, memori, semuanya seharga 0€/bulan. Ini benar-benar rusak dan saya suka rakitan ini.

मैंने GitHub Actions पर मुफ्त बॉट चलाने के लिए git को डेटाबेस की तरह इस्तेमाल किया

मैंने email auto responder AI कैसे कोड किया जो GitHub Actions पर 0€/महीना चलता है, git टैग को डेटाबेस बनाकर और runtime टैग में पूर्व संकलन करके।

मैंने GitHub Actions पर मुफ्त बॉट चलाने के लिए git को डेटाबेस की तरह इस्तेमाल किया

मेरा स्वचालित email उत्तरदाता है जो 24/7 चलता है।

वह मेरे मेल पढ़ता है, समझता है कि क्या बात है, और AI से खुद जवाब देता है। उसे पिछली बातचीत याद रहती है। वह newsletter और noreply@ को नजरअंदाज करता है। जब बहुत गरम हो तो मानव को forward करता है।

मासिक लागत : 0€।

मुझे सर्वर, VPS और डेटाबेस नहीं चाहिए। सिर्फ GitHub Actions और पागल hack : git को डेटाबेस की तरह इस्तेमाल करना।

समझ रहे हो मैं कहाँ जा रहा हूँ ? नहीं ? अच्छा, पकड़कर बैठो, यह बेवकूफी और शानदार दोनों है।


समस्या : GitHub Actions stateless है

GitHub Actions मुफ्त है। तुम हर 5 मिनट cron चला सकते हो, अपना कोड चला सकते हो, मुफ्त।

लेकिन एक दिक्कत है : यह stateless है।

हर run खाली मशीन में शुरू होता है। दो निष्पादनों के बीच कुछ सहेजा नहीं जाता। पिछला run ? भूला हुआ। मिटा हुआ। जैसे कभी मौजूद ही नहीं था।

email उत्तरदाता के लिए, यह बहुत बड़ी समस्या है। जैसे :

"आखिरी मेल कौन सा है जो मैं पहले ही प्रोसेस कर चुका हूँ ?"

अगर बॉट हर run पर यह भूल जाता है, तो वह या तो उन्हीं मेलों को लूप में फिर से जवाब देगा (तबाही), या मेल छूट जाएँगे।

स्थायी स्थिति चाहिए। और सामान्यतः, स्थायी स्थिति = डेटाबेस। लेकिन डेटाबेस मतलब सर्वर, और सर्वर मतलब मुफ्त नहीं।

यहीं यह दिलचस्प होता है।


समाधान : डेटाबेस के रूप में git टैग

तुम्हारा GitHub repo, पहले से स्थायी भंडारण है। मुफ्त। संस्करणबद्ध। हमेशा वहाँ।

तो उसमें स्थिति क्यों न संग्रहित की जाए ?

विचार : हर run पर, बॉट git टैग से आखिरी प्रोसेस किया email UID पढ़ता है। वह नए मेल प्रोसेस करता है। फिर नए UID के साथ टैग फिर से push करता है।

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as IMAP सर्वर

    Note over GH: Run #1
    GH->>GIT: "lastid" टैग पढ़ता है
    GIT-->>GH: खाली (पहला run)
    GH->>IMAP: मेल 1-50 लाता है
    IMAP-->>GH: 50 मेल
    GH->>GIT: "lastid" टैग push करता है = 50

    Note over GH: Run #2
    GH->>GIT: "lastid" टैग पढ़ता है
    GIT-->>GH: 50
    GH->>IMAP: मेल 51-73 लाता है
    IMAP-->>GH: 23 मेल
    GH->>GIT: "lastid" टैग push करता है = 73

git टैग ही डेटाबेस है। एक ही मान, लेकिन हमें बस इतना चाहिए।

स्थिति पढ़ना

job की शुरुआत में, टैग से मान लिया जाता है :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId का मतलब : "मुझे lastid टैग में जैसी थी वैसी data/lastId फाइल की सामग्री दो"।

धड़ाम। बिना डेटाबेस तुम्हारा मान मिल गया।

स्थिति लिखना

अंत में, नए मान के साथ टैग फिर से बनाया जाता है :

git switch --orphan lastid-tmp   # इतिहास रहित खाली शाखा
git rm -rf .                      # सब खाली करते हैं
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # इस कमिट पर टैग बल लगाता है
git push --force ...origin lastid # टैग push करता है

एक अनाथ शाखा बनाई जाती है (इतिहास रहित), सिर्फ lastId फाइल रखी जाती है, commit, tag, force push।

अनाथ क्यों ? ताकि repo इतिहास में राज्य के 10 000 commit जमा न हों। हर अद्यतन पिछले को मिटाता है। टैग हमेशा एक ही कमिट की ओर इशारा करता है जिसमें एक ही मान है।

यह साफ है। यह मुफ्त है। यह पूरी तरह टेढ़ा है और काम करता है।


दूसरा hack : runtime snapshot

GitHub Actions के साथ एक और समस्या है : npm install।

अगर हर run पर (हर 5 मिनट) तुम npm install + npm run build करते हो, तो हर बार 60-90 सेकंड बर्बाद करते हो। बार बार cron पर, यह बिना बात बर्बाद compute मिनट हैं।

समाधान : कोड एक बार पूर्व संकलित करना, और उसे भी git टैग में संग्रहित करना।

build workflow (जो master पर push करने पर चलता है) यह करता है :

# कोड संकलित करता है
bun install
bun run build

# dist/ + node_modules/ को "runtime" टैग में संग्रहित करता है
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

runtime टैग में संकलित कोड और node_modules हैं। चलने को सब तैयार।

और cron, सीधे इस टैग को checkout करता है :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # पूर्व निर्मित कोड, स्रोत नहीं
    fetch-depth: 1

# कोई npm install नहीं, कोई build नहीं !
- name: Process emails
  run: node dist/index.js --action

cron तुरंत शुरू होता है और install या build के बिना बस node dist/index.js निष्पादित करता है।

जैसे, तुम्हारे पास दो टैग हैं जो दो काम करते हैं :

  • runtime = चलने को तैयार कोड (master पर कोड push करने पर अद्यतन)
  • lastid = स्थायी स्थिति (हर run पर अद्यतन)

यह गंदे जितना सुंदर है।


बॉट खुद : AI auto responder

अच्छा, git hack अच्छा है, लेकिन बॉट ठीक क्या करता है ?

वह IMAP से तुम्हारे मेल पढ़ता है, AI (Groq + Llama 3.3 70B) से उन्हें समझता है, और अपने आप जवाब देता है।

निर्भरता इंजेक्शन (InversifyJS) के साथ साफ सेवा आर्किटेक्चर :

App
├── ImapService      → मेल पढ़ता है (IMAP)
├── SmtpService      → उत्तर भेजता है (SMTP)
├── ParserService    → मेल सामग्री parse करता है
├── ReplyService     → AI उत्तर बनाता है
├── SummaryService   → बातचीत स्मृति
├── AccountsService  → कई email खाते प्रबंधित करता है
└── ConfigService    → config / env vars

दो संचालन मोड

बॉट दो तरह से चल सकता है :

listener मोड (वास्तविक समय) : घातांकीय पुनः जुड़ाव के साथ स्थायी IMAP कनेक्शन। VPS के लिए।

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] नया मेल ! कुल: ${data.count}`);
  // नया मेल तुरंत प्रोसेस करता है
});

action मोड (batch) : lastId से नए मेल प्रोसेस करता है, फिर बंद हो जाता है। GitHub Actions cron के लिए।

node dist/index.js --action

--action मोड वही है जो git hack इस्तेमाल करता है। वह lastId पढ़ता है, जो नया है वह प्रोसेस करता है, नया lastId लिखता है, खत्म।

रोबोट को जवाब मत दो

अगर तुम्हारा बॉट सभी मेलों को जवाब देता है, तो वह newsletter, सूचनाओं, noreply@ को जवाब देगा। तबाही। बदतर : अगर दो बॉट एक दूसरे को जवाब देते हैं, तो तुम्हें मेलों का अनंत लूप मिलता है। दुःस्वप्न।

इसलिए आक्रामक छँटाई :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

और email हेडर से पहचान भी :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletter में यह होता है
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

हेडर में List-Unsubscribe ? यह newsletter है। Precedence: bulk ? सामूहिक मेलिंग। X-Mailer: Mailchimp ? समझ रहे हो। नजरअंदाज करते हैं।

यह नाइट क्लब बाउंसर जैसा है : रोबोट नहीं गुजरते।

जादुई trigger

AI बिल्कुल जवाब न देने, या मानव को सौंपने का फैसला कर सकता है। कैसे ? उसके उत्तर में विशेष trigger से।

तंत्र प्रॉम्प्ट उसे कहता है :

अगर स्वचालित मेल/newsletter है → <no_reply> जवाब दो अगर बहुत महत्वपूर्ण/संवेदनशील है (कानूनी, वित्तीय...) → <manual_reply_required> जवाब दो वरना → असली उत्तर लिखो

और कोड इसे पढ़ता है :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] AI ने नजरअंदाज करने का फैसला किया। छोड़ो।");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] बहुत गरम, मैं मानव को forward करता हूँ।");
  await this.smtpService.sendManualForward(...);
  return;
}

// वरना AI उत्तर भेजते हैं
await this.smtpService.sendReply(...);

जैसे AI को "नहीं यहाँ मैं नहीं छूता, असली मानव बुलाओ" कहने का अधिकार है। यह बुद्धिमानी है।


बातचीत स्मृति

एक विवरण जो सब बदलता है : बॉट बातचीत याद रखता है।

जब वह किसी को जवाब देता है, तो वह आदान प्रदान का सारांश सहेजता है। अगली बार जब वह व्यक्ति लिखता है, तो सारांश प्रॉम्प्ट में फिर से डाला जाता है।

भंडारण : प्रति संपर्क एक JSON फाइल।

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

और सारांश खुद AI द्वारा उत्पन्न होता है, जो पुराने सारांश को नए संदेश के साथ मिलाता है :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "तुम स्मृति सहायक हो। जानकारी खोए बिना पुराने सारांश को नए संदेश के साथ मिलाओ।" },
    { role: "user", content: `मौजूदा सारांश:\n${existing}\n\nनया संदेश:\n${incomingContent}` }
  ],
  temperature: 0.0,  // नियतात्मक, कोई रचनात्मकता नहीं
  max_tokens: 800,
});

इसलिए बॉट समय के साथ संपीड़ित स्मृति बनाता है। बुद्धिमानी से बढ़ता सारांश काफी है, बिना सभी मेल संग्रहित किए।

और ये JSON फाइलें ? खैर... वे भी git में संग्रहित हैं, runtime टैग में। हर जगह Git।


प्रॉम्प्ट लंबाई वाली चतुर बात

छोटा तकनीकी विवरण जिसने मुझे हँसाया।

मॉडलों की token सीमा होती है। अगर तुम्हारा मेल + सारांश + persona प्रॉम्प्ट पार कर जाते हैं, तो API त्रुटि लौटाता है।

कोड इसे झरना काट छाँट + retry से संभालता है :

try {
  // सामान्य सीमाओं के साथ पहला प्रयास
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // लंबाई त्रुटि थी : सख्त सीमाओं के साथ फिर से प्रयास करते हैं
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // 3000 के बजाय
      summaryChars: 1800,       // 4000 के बजाय
      personaChars: 900,        // 1500 के बजाय
      userContentChars: 2200,   // 8000 के बजाय
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

अगर पार नहीं होता, तो और छोटा काटकर फिर प्रयास करते हैं। सरल, प्रभावी, कोई crash नहीं।


अच्छा, और ठोस रूप में, यह कैसे चलता है ?

cron run का पूरा प्रवाह :

1. GitHub Actions ट्रिगर होता है (हर 5 मिनट cron)
2. "runtime" टैग का Checkout (पूर्व निर्मित कोड)
3. git show refs/tags/lastid → आखिरी प्रोसेस किया UID लेता है
4. node dist/index.js --action
   ├── IMAP कनेक्शन
   ├── lastId+1 से मेल लाता है
   ├── हर मेल के लिए :
   │   ├── सामग्री parse करता है
   │   ├── रोबोट छाँटता है (स्वचालित होने पर छोड़ता है)
   │   ├── प्राप्तकर्ता खाता मिलाता है
   │   ├── बातचीत स्मृति लेता है
   │   ├── AI उत्तर बनाता है (Groq)
   │   ├── <no_reply> ? छोड़ता है
   │   ├── <manual_reply_required> ? मानव forward
   │   ├── वरना : उत्तर भेजता है (SMTP)
   │   └── बातचीत स्मृति अद्यतन करता है
   └── नया lastId लिखता है
5. नए मान के साथ "lastid" टैग का git push --force

और यह 5 मिनट में फिर शुरू होता है। हमेशा के लिए। मुफ्त।


याद रखने के 3 बिंदु :

  1. Git = मुफ्त डेटाबेस , अनाथ टैग दो stateless run के बीच तुम्हारी स्थायी स्थिति संग्रहित कर सकता है। पढ़ने के लिए git show refs/tags/X:फाइल, लिखने के लिए force-push। तुम पूरी DB बचाते हो।
  2. runtime टैग में पूर्व संकलन , cron के हर run पर npm install के बजाय, संकलित कोड + node_modules git टैग में संग्रहित करो। cron तुरंत शुरू होता है।
  3. AI बॉट को चुप रहना आना चाहिए , <no_reply> और <manual_reply_required> trigger AI को जवाब न देने या सौंपने देते हैं। एंटी रोबोट छँटाई भी। वरना तुम मेलों का अनंत लूप बनाते हो।

स्थायी स्थिति वाला Serverless cron, AI, स्मृति, सब 0€/महीना पर। यह पूरी तरह टूटा हुआ है और मुझे यह जुगाड़ पसंद है।

استخدمت git كقاعدة بيانات لتشغيل بوت مجاني على GitHub Actions

كيف برمجت مجيبا آليا للبريد بالذكاء الاصطناعي يعمل على GitHub

استخدمت git كقاعدة بيانات لتشغيل بوت مجاني على GitHub Actions

لدي مجيب بريد آلي يعمل 24/7.

يقرأ رسائلي، ويفهم عما تتكلم، ويرد وحده بذكاء اصطناعي. يتذكر المحادثات السابقة. يتجاهل النشرات وnoreply@. يحوّل لإنسان عندما يكون الأمر ساخنا أكثر من اللازم.

الkost الشهري : 0€.

أستغني عن خادم وVPS وقاعدة بيانات. فقط GitHub Actions وحيلة مجنونة : استخدام git كقاعدة بيانات.

ترى إلى أين أريد الوصول ؟ لا ؟ حسنا، تمسك، فهو غبي وعبقري في آن واحد.


المشكلة : GitHub Actions عديم الحالة

GitHub Actions مجاني. تستطيع إطلاق cron كل 5 دقائق، وتشغيل كودك، مجانا.

لكن هناك مشكلة : إنه عديم الحالة.

كل تشغيل ينطلق في آلة عذراء. لا شيء يُحفَظ بين تنفيذين. التشغيل السابق ؟ منسي. ممحو. كأنه لم يوجد أبدا.

لمجيب بريد، هذه مشكلة ضخمة. مثلا :

"ما آخر بريد عالجته فعلا ؟"

إذا نسي البوت ذلك مع كل تشغيل، سيعيد الرد على نفس الرسائل في حلقة (كارثة)، أو سيفوّت رسائل.

يلزم حالة دائمة. وعادة، حالة دائمة = قاعدة بيانات. لكن قاعدة بيانات تعني خادما، والخادم لم يعد مجانيا.

هنا يصبح الأمر مثيرا.


الحل : وسوم git كقاعدة بيانات

مستودع GitHub الخاص بك تخزين دائم فعلا. مجاني. مُصدَر. موجود دائما.

فلماذا لا نخزن الحالة فيه ؟

الفكرة : مع كل تشغيل، يقرأ البوت آخر UID بريد معالَج من وسم git. يعالج الرسائل الجديدة. ثم يعيد دفع الوسم مع UID الجديد.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Serveur IMAP

    Note over GH: Run #1
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: vide (premier run)
    GH->>IMAP: fetch mails 1-50
    IMAP-->>GH: 50 mails
    GH->>GIT: push tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: lit tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mails 51-73
    IMAP-->>GH: 23 mails
    GH->>GIT: push tag "lastid" = 73

وسم git هو قاعدة البيانات. قيمة واحدة، لكنها كل ما نحتاجه.

قراءة الحالة

في بداية الوظيفة، نسترجع القيمة من الوسم :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

يعني git show refs/tags/lastid:data/lastId : "أعطني محتوى ملف data/lastId كما كان في وسم lastid".

بوم. لديك قيمتك، دون قاعدة بيانات.

كتابة الحالة

في النهاية، نعيد إنشاء الوسم مع القيمة الجديدة :

git switch --orphan lastid-tmp   # فرع عذراء دون سجل
git rm -rf .                      # نفرغ كل شيء
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # نجبر الوسم على هذا الـcommit
git push --force ...origin lastid # ندفع الوسم

ننشئ فرعا يتيما (دون سجل)، ونضع فقط ملف lastId، ونُدخل، ونسم، وندفع بالإجبار.

لماذا يتيما ؟ لعدم تراكم 10 000 commit حالة في سجل المستودع. كل تحديث يمحو سابقه. يشير الوسم دائما إلى commit واحد يحوي قيمة واحدة.

إنه نظيف. مجاني. ملتَوٍ تماما ويعمل.


الحيلة الثانية : لقطة runtime

هناك مشكلة أخرى مع GitHub Actions : الـnpm install.

إذا كنت مع كل تشغيل (كل 5 دقائق) تنفذ npm install + npm run build، تهدر 60-90 ثانية في كل مرة. في cron متكرر، هذه دقائق حساب مهدورة للا شيء.

الحل : تجميع الكود مرة واحدة، وتخزينه في وسم git أيضا.

يفعل workflow البناء (الذي يعمل عندما تدفع على master) هذا :

# تجميع الكود
bun install
bun run build

# تخزين dist/ + node_modules/ في وسم "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

يحوي وسم runtime الكود المُجمَّع وnode_modules. جاهز للعمل.

أما الـcron فيسحب هذا الوسم مباشرة :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # الكود المُجمَّع مسبقا، لا المصدر
    fetch-depth: 1

# لا npm install ولا build !
- name: Process emails
  run: node dist/index.js --action

ينطلق الـcron فورا وينفذ فقط node dist/index.js، دون install ولا build.

يعني لديك وسمان يقومان بعملين :

  • runtime = الكود الجاهز للعمل (يُحدَّث عندما تدفع كودا)
  • lastid = الحالة الدائمة (تُحدَّث مع كل تشغيل)

إنه أنيق بشكل قذر.


البوت نفسه : مجيب آلي بالذكاء الاصطناعي

حسنا، حيلة git رائعة، لكن ماذا يفعل البوت بالضبط ؟

يقرأ رسائلك عبر IMAP، ويفهمها بذكاء اصطناعي (Groq + Llama 3.3 70B)، ويرد تلقائيا.

بنية بخدمات نظيفة مع حقن تبعيات (InversifyJS) :

App
├── ImapService      ← يقرأ الرسائل (IMAP)
├── SmtpService      ← يرسل الردود (SMTP)
├── ParserService    ← يحلل محتوى الرسائل
├── ReplyService     ← يولد رد الذكاء الاصطناعي
├── SummaryService   ← ذاكرة المحادثة
├── AccountsService  ← يدير حسابات بريد متعددة
└── ConfigService    ← إعداد / متغيرات بيئة

وضعا التشغيل

يمكن للبوت العمل بطريقتين :

وضع الاستماع (وقت حقيقي) : اتصال IMAP دائم مع إعادة اتصال أسّية. لـVPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] بريد جديد ! الإجمالي: ${data.count}`);
  // يعالج البريد الجديد فورا
});

وضع الفعل (دفعي) : يعالج الرسائل الجديدة منذ lastId، ثم يُغلَق. لـcron في GitHub Actions.

node dist/index.js --action

وضع --action هو الذي يستخدم حيلة git. يقرأ lastId، ويعالج الجديد، ويكتب lastId الجديد، انتهى.

لا ترد على الآليين

إذا رد بوتك على كل الرسائل، سيرد على النشرات والإشعارات وnoreply@. كارثة. والأسوأ : إذا رد بوتان على بعضهما، لديك حلقة بريد لانهائية. الكابوس.

إذن ترشيح عدواني :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

وأيضا كشف عبر ترويسات البريد :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // النشرات لديها هذا
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

هل يوجد List-Unsubscribe في الترويسات ؟ إنها نشرة. Precedence: bulk ؟ بريد جماعي. X-Mailer: Mailchimp ؟ ترى النوع. نتجاهل.

إنه كبوّاب ملهى ليلي : الآليون لا يمرون.

المحفزات السحرية

يستطيع الذكاء الاصطناعي أن يقرر ألا يرد إطلاقا، أو أن يسلّم لإنسان. كيف ؟ بمحفزات خاصة في رده.

يقول له prompt النظام :

إذا كان بريدا آليا/نشرة ← رد <no_reply> إذا كان مهما/حساسا أكثر من اللازم (قانوني ومالي...) ← رد <manual_reply_required> وإلا ← اكتب ردا حقيقيا

ويقرأ الكود ذلك :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] قرر الذكاء الاصطناعي التجاهل. تخطٍّ.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] ساخن أكثر من اللازم، أحوّل لإنسان.");
  await this.smtpService.sendManualForward(...);
  return;
}

// وإلا نرسل رد الذكاء الاصطناعي
await this.smtpService.sendReply(...);

يعني للذكاء الاصطناعي حق قول "لا، هنا لا ألمس، نادِ إنسانا حقيقيا". إنها حكمة.


ذاكرة المحادثة

تفصيل يغيّر كل شيء : البوت يتذكر المحادثات.

عندما يرد على شخص، يحفظ ملخصا للتبادل. في المرة التالية التي يكتب فيها هذا الشخص، يُحقَن الملخص في الـprompt.

التخزين : ملف JSON لكل جهة.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

والملخص نفسه يولده الذكاء الاصطناعي، الذي يدمج الملخص القديم مع الرسالة الجديدة :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // حتمي، دون إبداع
  max_tokens: 800,
});

إذن يبني البوت ذاكرة مضغوطة مع الوقت. ملخص يكبر بذكاء يكفي، دون تخزين كل الرسائل.

وهذه الملفات JSON ؟ حسنا... إنها مخزنة في git أيضا، في وسم runtime. git في كل مكان.


النقطة الذكية في طول الـprompt

تفصيل تقني صغير جعلني أبتسم.

للنماذج حد رموز. إذا تجاوز بريدك + الملخص + prompt الشخصية، يعيد API خطأ.

يدير الكود ذلك بـاقتطاع متتالٍ + إعادة محاولة :

try {
  // محاولة أولى بالحدود العادية
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // كان خطأ طول : نعيد المحاولة بحدود أضيق
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // بدلا من 3000
      summaryChars: 1800,       // بدلا من 4000
      personaChars: 900,        // بدلا من 1500
      userContentChars: 2200,   // بدلا من 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // إعادة محاولة
}

إذا لم يمر، نقطع أقصر ونعيد المحاولة. بسيط وفعال، دون انهيار.


حسنا، وعمليا، كيف يعمل ؟

التدفق الكامل لتشغيل cron :

1. يُحفَّز GitHub Actions (cron كل 5 دقائق)
2. سحب وسم "runtime" (كود مُجمَّع مسبقا)
3. git show refs/tags/lastid ← يسترجع آخر UID معالَج
4. node dist/index.js --action
   ├── اتصال IMAP
   ├── جلب الرسائل منذ lastId+1
   ├── لكل بريد :
   │   ├── حلل المحتوى
   │   ├── رشّح الآليين (تخطَّ إذا آلي)
   │   ├── طابِق الحساب المتلقي
   │   ├── استرجع ذاكرة المحادثة
   │   ├── ولّد رد الذكاء الاصطناعي (Groq)
   │   ├── <no_reply> ؟ تخطَّ
   │   ├── <manual_reply_required> ؟ حوّل لإنسان
   │   ├── وإلا : أرسل الرد (SMTP)
   │   └── حدّث ذاكرة المحادثة
   └── اكتب lastId الجديد
5. git push --force للوسم "lastid" بالقيمة الجديدة

ويبدأ من جديد بعد 5 دقائق. للأبد. مجانا.


النقاط الثلاث للتذكر :

  1. git = قاعدة بيانات مجانية، وسم يتيم يستطيع تخزين حالتك الدائمة بين تشغيلين عديمي الحالة. git show refs/tags/X:fichier للقراءة، والدفع بالإجبار للكتابة. توفر قاعدة بيانات كاملة.

  2. جمّع مسبقا في وسم runtime، بدلا من npm install مع كل تشغيل للـcron، خزّن الكود المُجمَّع + node_modules في وسم git. ينطلق الـcron فورا.

  3. بوت الذكاء الاصطناعي يجب أن يعرف الصمت، المحفزان <no_reply> و<manual_reply_required> يتركان للذكاء الاصطناعي قرار عدم الرد أو التسليم. مع الترشيح ضد الآليين. وإلا أنشأت حلقة بريد لانهائية.

cron بلا خادم مع حالة دائمة وذكاء اصطناعي وذاكرة، الكل بـ0€/شهر. إنه معطوب تماما وأعشق هذا التركيب.

Tôi đã dùng git làm cơ sở dữ liệu để chạy bot miễn phí

Cách tôi code một bot tự trả lời email bằng AI chạy trên GitHub

Tôi đã dùng git làm cơ sở dữ liệu để chạy bot miễn phí trên GitHub Actions

Tôi có một bot tự trả lời email chạy 24/7.

Nó đọc mail của tôi, hiểu nội dung nói về gì, và tự trả lời bằng AI. Nó nhớ các cuộc trò chuyện trước. Nó bỏ qua newsletter và noreply@. Nó chuyển cho con người khi chuyện quá nóng.

Chi phí hằng tháng : 0€.

Tôi bỏ qua máy chủ, VPS và cơ sở dữ liệu. Chỉ GitHub Actions và một mẹo điên rồ : dùng git làm cơ sở dữ liệu.

Bạn thấy tôi định đi đâu chứ? Không à? Được, bám chắc vào, vừa ngớ ngẩn vừa thiên tài.


Vấn đề : GitHub Actions là stateless

GitHub Actions miễn phí. Bạn có thể chạy cron mỗi 5 phút, chạy mã của mình, miễn phí.

Nhưng có vấn đề : nó stateless.

Mỗi lần chạy khởi động trong máy trống. Không gì được lưu giữa hai lần thực thi. Lần chạy trước? Quên đi. Xóa sạch. Như thể chưa từng tồn tại.

Với bot trả lời email, đó là vấn đề khổng lồ. Kiểu :

"Mail cuối cùng tôi đã xử lý là mail nào?"

Nếu bot quên điều đó mỗi lần chạy, nó sẽ hoặc trả lời lại cùng mail vòng lặp (thảm họa), hoặc bỏ sót mail.

Cần trạng thái bền vững. Và bình thường, trạng thái bền vững = cơ sở dữ liệu. Nhưng cơ sở dữ liệu là máy chủ, và máy chủ thì không còn miễn phí.

Đó là lúc chuyện trở nên thú vị.


Giải pháp : tag git làm cơ sở dữ liệu

Repo GitHub của bạn vốn đã là lưu trữ bền vững. Miễn phí. Có phiên bản. Luôn ở đó.

Vậy sao không lưu trạng thái vào đó?

Ý tưởng : mỗi lần chạy, bot đọc UID email đã xử lý cuối từ một tag git. Nó xử lý mail mới. Rồi đẩy lại tag với UID mới.

sequenceDiagram
    participant GH as GitHub Actions
    participant GIT as Git Tags
    participant IMAP as Máy chủ IMAP

    Note over GH: Run #1
    GH->>GIT: đọc tag "lastid"
    GIT-->>GH: rỗng (lần chạy đầu)
    GH->>IMAP: fetch mail 1-50
    IMAP-->>GH: 50 mail
    GH->>GIT: đẩy tag "lastid" = 50

    Note over GH: Run #2
    GH->>GIT: đọc tag "lastid"
    GIT-->>GH: 50
    GH->>IMAP: fetch mail 51-73
    IMAP-->>GH: 23 mail
    GH->>GIT: đẩy tag "lastid" = 73

Tag git CHÍNH LÀ cơ sở dữ liệu. Một giá trị duy nhất, nhưng đó là tất cả những gì ta cần.

Đọc state

Đầu job, ta lấy giá trị từ tag :

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId nghĩa là : "đưa tôi nội dung tệp data/lastId như nó từng có trong tag lastid".

Bùm. Bạn có giá trị, không cần cơ sở dữ liệu.

Ghi state

Cuối cùng, ta tạo lại tag với giá trị mới :

git switch --orphan lastid-tmp   # nhánh trống không lịch sử
git rm -rf .                      # dọn sạch mọi thứ
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # ép tag lên commit này
git push --force ...origin lastid # đẩy tag

Ta tạo một nhánh mồ côi (không lịch sử), chỉ đặt tệp lastId, commit, tag, force push.

Vì sao mồ côi? Để không tích 10 000 commit state trong lịch sử repo. Mỗi lần cập nhật ghi đè lần trước. Tag luôn trỏ tới ĐÚNG MỘT commit chứa ĐÚNG MỘT giá trị.

Sạch sẽ. Miễn phí. Hoàn toàn quái đản mà chạy được.


Mẹo thứ hai : snapshot runtime

Còn một vấn đề nữa với GitHub Actions : npm install.

Nếu mỗi lần chạy (mỗi 5 phút) bạn làm npm install + npm run build, bạn phí 60-90 giây mỗi lần. Trên cron dày, đó là phút compute đổ sông đổ biển.

Giải pháp : tiền biên dịch mã MỘT lần, và lưu nó trong một tag git nữa.

Workflow build (chạy khi bạn đẩy lên master) làm việc này :

# biên dịch mã
bun install
bun run build

# lưu dist/ + node_modules/ vào tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Tag runtime chứa mã đã biên dịch VÀ node_modules. Sẵn sàng chạy.

Và cron thì checkout trực tiếp tag này :

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # mã tiền build, không phải mã nguồn
    fetch-depth: 1

# không npm install, không build!
- name: Process emails
  run: node dist/index.js --action

Cron khởi động tức thì và chỉ chạy node dist/index.js, không install hay build.

Kiểu, bạn có hai tag làm hai việc :

  • runtime = mã sẵn sàng chạy (cập nhật khi bạn đẩy mã)
  • lastid = trạng thái bền vững (cập nhật mỗi lần chạy)

Thanh lịch như quỷ.


Bản thân bot : bot tự trả lời bằng AI

Được, mẹo git hay đấy, nhưng bot thực sự làm gì?

Nó đọc mail của bạn qua IMAP, hiểu chúng bằng AI (Groq + Llama 3.3 70B), và tự trả lời.

Kiến trúc thành các service sạch với tiêm phụ thuộc (InversifyJS) :

App
├── ImapService      → đọc mail (IMAP)
├── SmtpService      → gửi trả lời (SMTP)
├── ParserService    → phân tích nội dung mail
├── ReplyService     → sinh trả lời AI
├── SummaryService   → trí nhớ hội thoại
├── AccountsService  → quản lý nhiều tài khoản email
└── ConfigService    → cấu hình / biến env

Hai chế độ vận hành

Bot có thể chạy hai cách :

Chế độ listener (thời gian thực) : kết nối IMAP thường trực với reconnect lũy thừa. Cho VPS.

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] Nouveau mail ! Total: ${data.count}`);
  // xử lý mail mới ngay lập tức
});

Chế độ action (batch) : xử lý mail mới từ lastId, rồi đóng. Cho cron GitHub Actions.

node dist/index.js --action

Chế độ --action là chế độ dùng mẹo git. Nó đọc lastId, xử lý cái mới, ghi lastId mới, xong.

KHÔNG trả lời robot

Nếu bot của bạn trả lời MỌI mail, nó sẽ trả lời newsletter, thông báo, noreply@. Thảm họa. Tệ hơn : nếu hai bot trả lời nhau, bạn có vòng lặp mail vô hạn. Cơn ác mộng.

Vì vậy lọc mạnh tay :

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

Và cả phát hiện qua header email :

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletter có cái này
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe trong header? Đó là newsletter. Precedence: bulk? Mail hàng loạt. X-Mailer: Mailchimp? Bạn hiểu kiểu rồi. Bỏ qua.

Như bảo vệ hộp đêm : robot không được vào.

Các trigger kỳ diệu

AI có thể quyết định không trả lời gì cả, hoặc chuyển cho con người. Bằng cách nào? Với các trigger đặc biệt trong phản hồi của nó.

Prompt hệ thống bảo nó :

Nếu là mail tự động/newsletter → trả lời <no_reply> Nếu quá quan trọng/nhạy cảm (pháp lý, tài chính...) → trả lời <manual_reply_required> Ngược lại → viết trả lời thật

Và mã đọc điều đó :

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] AI đã quyết định bỏ qua. Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] Quá nóng, tôi chuyển cho con người.");
  await this.smtpService.sendManualForward(...);
  return;
}

// ngược lại gửi trả lời AI
await this.smtpService.sendReply(...);

Kiểu AI có quyền nói "không, chỗ này tôi không đụng, gọi con người thật đi". Đó là khôn ngoan.


Trí nhớ hội thoại

Một chi tiết thay đổi tất cả : bot nhớ các cuộc trò chuyện.

Khi nó trả lời ai đó, nó lưu tóm tắt cuộc trao đổi. Lần tới người đó viết, tóm tắt được bơm lại vào prompt.

Lưu trữ : một tệp JSON cho mỗi liên hệ.

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

Và tóm tắt do chính AI tạo, hợp nhất tóm tắt cũ với tin nhắn mới :

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "Tu es un assistant de mémoire. Merge l'ancien résumé avec le nouveau message sans perdre d'info." },
    { role: "user", content: `Résumé existant:\n${existing}\n\nNouveau message:\n${incomingContent}` }
  ],
  temperature: 0.0,  // xác định, không sáng tạo
  max_tokens: 800,
});

Vậy bot xây trí nhớ nén dần theo thời gian. Một tóm tắt phình ra thông minh là đủ, không cần lưu mọi mail.

Còn các tệp JSON này? Thì... chúng cũng lưu trong git, trong tag runtime. Git khắp nơi.


Điểm láu về độ dài prompt

Chi tiết kỹ thuật nhỏ khiến tôi mỉm cười.

Model có giới hạn token. Nếu mail + tóm tắt + prompt persona vượt quá, API trả lỗi.

Mã xử lý việc đó bằng cắt ngắn tầng + retry :

try {
  // lần thử đầu với giới hạn bình thường
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // lỗi độ dài : thử lại với giới hạn chặt hơn
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // thay vì 3000
      summaryChars: 1800,       // thay vì 4000
      personaChars: 900,        // thay vì 1500
      userContentChars: 2200,   // thay vì 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // thử lại
}

Nếu không qua, ta cắt ngắn hơn và thử lại. Đơn giản, hiệu quả, không crash.


Được, cụ thể nó chạy thế nào?

Flow đầy đủ của một lần chạy cron :

1. GitHub Actions kích hoạt (cron mỗi 5 phút)
2. Checkout tag "runtime" (mã tiền build)
3. git show refs/tags/lastid → lấy UID đã xử lý cuối
4. node dist/index.js --action
   ├── kết nối IMAP
   ├── fetch mail từ lastId+1
   ├── với mỗi mail :
   │   ├── phân tích nội dung
   │   ├── lọc robot (skip nếu tự động)
   │   ├── khớp tài khoản nhận
   │   ├── lấy trí nhớ hội thoại
   │   ├── sinh trả lời AI (Groq)
   │   ├── <no_reply> ? skip
   │   ├── <manual_reply_required> ? chuyển con người
   │   ├── ngược lại : gửi trả lời (SMTP)
   │   └── cập nhật trí nhớ hội thoại
   └── ghi lastId mới
5. git push --force tag "lastid" với giá trị mới

Và lặp lại sau 5 phút. Mãi mãi. Miễn phí.


3 điểm cần nhớ :

  1. Git = cơ sở dữ liệu miễn phí -- Một tag mồ côi có thể lưu trạng thái bền vững giữa hai lần chạy stateless. git show refs/tags/X:tệp để đọc, force-push để ghi. Bạn tiết kiệm cả một DB.

  2. Tiền biên dịch trong tag runtime -- Thay vì npm install mỗi lần chạy cron, lưu mã đã biên dịch + node_modules trong tag git. Cron khởi động tức thì.

  3. Bot AI phải biết im lặng -- Các trigger <no_reply> và <manual_reply_required> để AI quyết định không trả lời hay chuyển cho người. Cộng thêm lọc chống robot. Nếu không bạn tạo vòng lặp mail vô hạn.

Cron serverless với trạng thái bền vững, AI, trí nhớ, tất cả với 0€/tháng. Hoàn toàn điên rồ và tôi mê cách dựng này.

ผมใช้ git เป็นฐานข้อมูลเพื่อรันบอทฟรีบน GitHub Actions

วิธีที่ผมเขียน AI auto-reply email ที่รันบน GitHub Actions ด้วยค่าใช้จ่าย 0€/เดือน -- โดยใช้ git tags เป็นฐานข้อมูลและ

ผมใช้ git เป็นฐานข้อมูลเพื่อรันบอทฟรีบน GitHub Actions

ผมมีระบบตอบอีเมลอัตโนมัติที่ทำงาน 24/7

มันอ่านอีเมลของผม ทำความเข้าใจว่าพูดถึงเรื่องอะไร และตอบกลับเองด้วย AI มันจำบทสนทนาก่อนหน้านี้ได้ มันข้าม newsletters และ noreply@ มันส่งต่อไปยังมนุษย์เมื่อเรื่องร้อนเกินไป

ค่าใช้จ่ายต่อเดือน: 0€.

ไม่มีเซิร์ฟเวอร์ ไม่มี VPS ไม่มีฐานข้อมูล แค่ GitHub Actions และ hack สุดบ้า: ใช้ git เป็นฐานข้อมูล

เห็นภาพกันหรือยัง? ไม่? เอาล่ะ เกาะให้แน่นนะ นี่มันทั้งบ้าและเจ๋งในเวลาเดียวกัน


ปัญหา: GitHub Actions เป็น stateless

GitHub Actions ฟรี คุณสามารถรัน cron ทุก 5 นาที รันโค้ดของคุณได้ฟรี

แต่มีปัญหา: มันเป็น stateless

แต่ละ run เริ่มต้นบนเครื่องที่สะอาด ไม่มีอะไรถูกเก็บไว้ระหว่างการทำงานสองครั้ง รอบที่แล้ว? ถูกลืม ถูกลบ เหมือนไม่เคยมีมาก่อน

สำหรับระบบตอบอีเมล นี่เป็นปัญหาใหญ่ แบบ:

"อีเมลล่าสุดที่ฉันจัดการไปคืออันไหน?"

ถ้าบอทลืมข้อมูลนี้ทุกครั้งที่รัน มันจะตอบกลับอีเมลเดิมซ้ำแล้วซ้ำเล่า (หายนะ) หรือไม่ก็พลาดอีเมลบางฉบับ

จำเป็นต้องมีสถานะที่คงอยู่ และโดยปกติแล้ว สถานะที่คงอยู่ = ฐานข้อมูล แต่ฐานข้อมูลต้องใช้เซิร์ฟเวอร์ และเซิร์ฟเวอร์ก็ไม่ฟรีอีกต่อไป

นี่คือจุดที่เริ่มน่าสนใจ


ทางออก: git tags เป็นฐานข้อมูล

Repo GitHub ของคุณคือพื้นที่เก็บข้อมูลแบบถาวรอยู่แล้ว ฟรี มี version ตลอดไป

แล้วทำไมไม่เก็บสถานะไว้ที่นั่นล่ะ?

แนวคิด: ในแต่ละ run บอทจะอ่าน UID อีเมลล่าสุดที่ประมวลผลแล้วจาก git tag จากนั้นประมวลผลอีเมลใหม่ แล้ว push tag กลับด้วย UID ใหม่

mermaid diagram

git tag คือฐานข้อมูล แค่ค่าเดียว แต่เท่านี้ก็เพียงพอแล้ว

การอ่านสถานะ

ตอนเริ่ม job เราดึงค่าจาก tag:

git fetch origin --tags lastid || true
git show refs/tags/lastid:data/lastId > data/lastId || true

git show refs/tags/lastid:data/lastId แปลว่า "เอาคอนเทนต์ของไฟล์ data/lastId ตอนที่อยู่ใน tag lastid มาให้ฉัน"

บู้ม. คุณได้ค่าแล้ว โดยไม่ต้องมีฐานข้อมูล

การเขียนสถานะ

ตอนจบ เราสร้าง tag ใหม่ด้วยค่าใหม่:

git switch --orphan lastid-tmp   # สาขาเปล่าไร้ประวัติ
git rm -rf .                      # ล้างทุกอย่าง
mkdir -p data
printf "%s\n" "${LAST_ID_CONTENT}" > data/lastId

git add data/lastId
git commit -m "lastId snapshot"
git tag -f lastid                 # บังคับ tag บน commit นี้
git push --force ...origin lastid # push tag

เราสร้างสาขา orphan (ไร้ประวัติ) วางแค่ไฟล์ lastId commit tag force push

ทำไมต้อง orphan? เพื่อไม่ให้สะสม commits สถานะ 10,000 รายการในประวัติ repo การอัปเดตแต่ละครั้งจะเขียนทับอันก่อนหน้า Tag จะชี้ไปที่ commit เดียวที่มีค่าเดียวเท่านั้น

สะอาด ฟรี และบ้าระห่ำ xD


Hack ที่สอง: runtime snapshot

มีอีกปัญหากับ GitHub Actions: npm install

ถ้าทุก run (ทุก 5 นาที) คุณต้อง npm install + npm run build คุณจะเสียเวลา 60-90 วินาทีในแต่ละครั้ง บน cron ที่ถี่บ่อย นี่คือนาทีของการประมวลผลที่สูญเปล่า

ทางออก: pre-compile โค้ดเพียงครั้งเดียว และเก็บไว้ใน git tag เช่นกัน

workflow ของ build (ที่รันเมื่อคุณ push ไปที่ master) ทำแบบนี้:

# compile โค้ด
bun install
bun run build

# เก็บ dist/ + node_modules/ ไว้ใน tag "runtime"
git checkout --orphan temp-runtime
git rm -rf .
cp -r "$TMPDIR"/* .
git add dist node_modules package.json bun.lock data
git commit -m "runtime build"
git tag -f runtime
git push --force ...origin runtime

Tag runtime ประกอบด้วยโค้ดที่ compile แล้วและ node_modules พร้อมทำงานทันที

และ cron จะ checkout tag นี้โดยตรง:

- name: Checkout runtime snapshot
  uses: actions/checkout@v4
  with:
    ref: refs/tags/runtime    # โค้ดที่ build แล้ว ไม่ใช่ source
    fetch-depth: 1

# ไม่มี npm install, ไม่มี build!
- name: Process emails
  run: node dist/index.js --action

ไม่ต้องติดตั้ง ไม่ต้อง build Cron เริ่มต้นทันทีและรันแค่ node dist/index.js

แบบว่า คุณมีสอง tags ที่ทำสองหน้าที่:

  • runtime = โค้ดพร้อมทำงาน (อัปเดตเมื่อคุณ push โค้ด)
  • lastid = สถานะถาวร (อัปเดตทุกครั้งที่รัน)

มันโคตรจะหรูหรา


ตัวบอท: AI auto-reply

เอาล่ะ hack git มันเจ๋ง แต่บอททำอะไรได้บ้าง?

มันอ่านอีเมลของคุณผ่าน IMAP ทำความเข้าใจด้วย AI (Groq + Llama 3.3 70B) และตอบกลับโดยอัตโนมัติ

สถาปัตยกรรมแบบ services พร้อม dependency injection (InversifyJS):

App
├── ImapService      → อ่านอีเมล (IMAP)
├── SmtpService      → ส่งคำตอบ (SMTP)
├── ParserService    → แยกวิเคราะห์เนื้อหาอีเมล
├── ReplyService     → สร้างคำตอบด้วย AI
├── SummaryService   → หน่วยความจำการสนทนา
├── AccountsService  → จัดการหลายบัญชีอีเมล
└── ConfigService    → ตั้งค่า / env vars

สองโหมดการทำงาน

บอทสามารถทำงานได้สองวิธี:

โหมด listener (เรียลไทม์): การเชื่อมต่อ IMAP แบบถาวรพร้อม reconnect แบบ exponential สำหรับ VPS

this.imapService.on("exists", async (data) => {
  console.log(`[MAIL] อีเมลใหม่! ทั้งหมด: ${data.count}`);
  // ประมวลผลอีเมลใหม่ทันที
});

โหมด action (batch): ประมวลผลอีเมลใหม่ตั้งแต่ lastId แล้วปิดตัวลง สำหรับ cron GitHub Actions

node dist/index.js --action

โหมด --action คือโหมดที่ใช้ hack git มันอ่าน lastId ประมวลผลของใหม่ เขียน lastId ใหม่ จบ

อย่าตอบกลับหุ่นยนต์

ถ้าบอทของคุณตอบกลับทุกอีเมล มันจะตอบ newsletters, notifications, noreply@ หายนะ ยิ่งกว่านั้น: ถ้าบอทสองตัวตอบกลับกันเอง คุณจะได้ loop อีเมลไม่มีที่สิ้นสุด ฝันร้าย

ดังนั้นกรองอย่างเข้มข้น:

export function isAutomatedSender(address) {
  const automatedPatterns = [
    "noreply", "no-reply", "donotreply",
    "mailer-daemon", "postmaster", "bounce",
    "newsletter", "notification", "marketing",
    "billing", "receipt", "promo", ...
  ];
  const local = address.split("@")[0].toLowerCase();
  return automatedPatterns.some(p => local.includes(p));
}

และตรวจจับผ่าน headers อีเมลด้วย:

export function isAutomatedByHeaders(headers) {
  return (
    ["bulk", "list", "junk"].includes(headers.precedence) ||
    headers["auto-submitted"] !== "no" ||
    headers["list-unsubscribe"] !== "" ||   // newsletters มีอันนี้
    /mailchimp|sendgrid|mailgun|brevo/i.test(headers["x-mailer"])
  );
}

List-Unsubscribe ใน headers? นั่นคือ newsletter Precedence: bulk? mass-mailing X-Mailer: Mailchimp? เข้าใจใช่ไหม เราข้ามไป

เหมือนบouncer หน้าไนท์คลับ: หุ่นยนต์เข้าข้างในไม่ได้ xD

ทริกเกอร์มหัศจรรย์

AI สามารถตัดสินใจไม่ตอบเลย หรือส่งต่อให้มนุษย์ ทำยังไง? ด้วยทริกเกอร์พิเศษในคำตอบ

system prompt บอกว่า:

ถ้าเป็นอีเมลอัตโนมัติ/newsletter → ตอบ <no_reply> ถ้าสำคัญ/อ่อนไหวเกินไป (กฎหมาย, การเงิน...) → ตอบ <manual_reply_required> ที่เหลือ → เขียนคำตอบจริง

และโค้ดอ่านค่าตรงนี้:

const aiReply = completion.choices[0].message.content.trim();
const manualTrigger = aiReply.includes(MANUAL_REPLY_TRIGGER);
const noReply = aiReply.includes(NO_REPLY_TRIGGER);

if (noReply) {
  console.log("[MAIL] AI ตัดสินใจข้ามไป Skip.");
  return;
}

if (manualTrigger) {
  console.log("[MAIL] ร้อนเกินไป ส่งต่อไปให้มนุษย์");
  await this.smtpService.sendManualForward(...);
  return;
}

// ถ้าไม่ใช่ก็ส่งคำตอบ AI
await this.smtpService.sendReply(...);

แบบว่า AI มีสิทธิ์พูดว่า "ไม่เอาดีกว่า เรียกมนุษย์จริงเถอะ" นี่คือปัญญา


หน่วยความจำการสนทนา

รายละเอียดที่เปลี่ยนทุกอย่าง: บอท จำ การสนทนาได้

เมื่อมันตอบกลับใครสักคน มันจะบันทึกสรุปของการแลกเปลี่ยน คราวหน้าที่คนนั้นเขียนมา สรุปจะถูกใส่กลับเข้าไปใน prompt

การจัดเก็บ: ไฟล์ JSON หนึ่งไฟล์ต่อผู้ติดต่อหนึ่งคน

data/customers/
├── moi%40gmail.com/
│   ├── alice%40example.com.json
│   └── bob%40client.fr.json

และสรุปนี้ถูกสร้างโดย AI เช่นกัน ซึ่งรวมสรุปเก่ากับข้อความใหม่:

const completion = await groq.chat.completions.create({
  model: "llama-3.3-70b-versatile",
  messages: [
    { role: "system", content: "คุณคือผู้ช่วยด้านความจำ รวมสรุปเดิมกับข้อความใหม่โดยไม่สูญเสียข้อมูล" },
    { role: "user", content: `สรุปที่มีอยู่:\n${existing}\n\nข้อความใหม่:\n${incomingContent}` }
  ],
  temperature: 0.0,  // deterministic, ไม่ต้องสร้างสรรค์
  max_tokens: 800,
});

ดังนั้นบอทจะสร้างหน่วยความจำที่ถูกบีบอัดเมื่อเวลาผ่านไป ไม่ต้องเก็บอีเมลทั้งหมด แค่สรุปที่ขยายตัวอย่างชาญฉลาด

และไฟล์ JSON พวกนี้? ก็... ถูกเก็บใน git ด้วย อยู่ใน runtime tag Git ทุกที่ xD


เทคนิคเจ๋งกับความยาว prompt

รายละเอียดทางเทคนิคเล็กน้อยที่ทำให้ผมหัวเราะ

โมเดลมีขีดจำกัด tokens ถ้าอีเมลของคุณ + สรุป + persona prompt เกิน API จะคืน error

โค้ดจัดการด้วย การตัดทอนแบบ cascade + retry:

try {
  // ลองครั้งแรกด้วยขีดจำกัดปกติ
  completion = await groq.chat.completions.create({...});
} catch (error) {
  if (!this.isLengthError(error)) throw error;

  // เป็น error เรื่องความยาว: ลองใหม่ด้วยขีดจำกัดที่แคบลง
  ({ systemContent, userContent } = this.buildPromptPayload({
    ...,
    limits: {
      systemPromptChars: 2200,  // จาก 3000
      summaryChars: 1800,       // จาก 4000
      personaChars: 900,        // จาก 1500
      userContentChars: 2200,   // จาก 8000
    },
  }));
  completion = await groq.chat.completions.create({...});  // retry
}

ถ้าไม่ผ่านก็ตัดให้สั้นลงแล้วลองใหม่ ง่าย มีประสิทธิภาพ ไม่แครช


แล้วในทางปฏิบัติมันทำงานยังไง?

flow เต็มของ cron run:

1. GitHub Actions ถูกกระตุ้น (cron ทุก 5 นาที)
2. Checkout tag "runtime" (โค้ดที่ build แล้ว)
3. git show refs/tags/lastid → ดึง UID ล่าสุดที่ประมวลผลแล้ว
4. node dist/index.js --action
   ├── เชื่อมต่อ IMAP
   ├── ดึงอีเมลตั้งแต่ lastId+1
   ├── สำหรับแต่ละอีเมล:
   │   ├── แยกวิเคราะห์เนื้อหา
   │   ├── กรองหุ่นยนต์ (ข้ามถ้า automated)
   │   ├── จับคู่บัญชีผู้รับ
   │   ├── ดึงหน่วยความจำการสนทนา
   │   ├── สร้างคำตอบ AI (Groq)
   │   ├── <no_reply>? ข้าม
   │   ├── <manual_reply_required>? ส่งต่อไปมนุษย์
   │   ├── ไม่ใช่: ส่งคำตอบ (SMTP)
   │   └── อัปเดตหน่วยความจำการสนทนา
   └── เขียน lastId ใหม่
5. git push --force tag "lastid" ด้วยค่าใหม่

และมันเริ่มใหม่ใน 5 นาที ตลอดไป ฟรี


3 ข้อที่ต้องจำ:

  1. Git = ฐานข้อมูลฟรี -- orphan tag สามารถเก็บสถานะถาวรระหว่างสอง runs ที่เป็น stateless ได้ git show refs/tags/X:fichier สำหรับอ่าน, force-push สำหรับเขียน ไม่ต้องใช้ DB

  2. Pre-compile ใน runtime tag -- แทนที่จะ npm install ทุกครั้งที่ cron รัน ให้เก็บโค้ดที่ compile แล้ว + node_modules ไว้ใน git tag Cron เริ่มต้นทันที

  3. AI บอทต้องรู้จักเงียบ -- ทริกเกอร์ <no_reply> และ <manual_reply_required> ให้ AI ตัดสินใจไม่ตอบหรือส่งต่อได้ บวกกับการกรอง anti-robot ไม่งั้นคุณจะสร้าง loop อีเมลไม่มีที่สิ้นสุด

Serverless cron พร้อมสถานะถาวร, AI, หน่วยความจำ ทั้งหมดที่ 0€/เดือน มันบ้าระห่ำและผมชอบมัน xD

Related Articles