---
title: 'Discord Updater Bot'
url: 'https://wiki.teletypegames.org/services/updater'
markdown: 'https://wiki.teletypegames.org/services/updater.md'
lang: en
date: '2026-03-06'
description: 'The Updater is a small Go tool that watches the Grav wiki, Redmine and Gitea for changes and posts new activity to Discord webhooks. A local cache makes sure every change is announced only once. How it works Every INTERVAL_MINUTES minutes the updater walks through its sources: Fetch — asks each …'
taxonomy:
  tag:
    - howto
---

# Discord Updater Bot

> The Updater is a small Go tool that watches the Grav wiki, Redmine and Gitea for changes and posts new activity to Discord webhooks. A local cache makes sure every change is announced only once.

# How it works

Every `INTERVAL_MINUTES` minutes the updater walks through its sources:

1. **Fetch** — asks each source for its latest `*_CONTENT_LIMIT` items.
2. **Compare** — checks each item's change signal against `cache.json`.
3. **Notify** — sends whatever is new or changed as a Markdown link to that source's webhook.
4. **Save the cache** — writes `cache.json` back at the end of the round so the next one does not repeat it.

| Source | Request | Change signal | Message |
|---|---|---|---|
| Grav wiki | `GET /custom/updates.json?limit=N` | the page's `modified` timestamp | `📖 [Grav] - Page title` |
| Redmine | `GET /issues.json?limit=N&sort=updated_on:desc` | the issue's `updated_on` | `🎫 [Redmine] - #123 Subject` |
| Gitea | `GET /api/v1/repos/ORG/REPO/commits?limit=N` per repo | the commit SHA | `📝 [Gitea] - (org/repo) commit message` |

## The Grav source

The wiki lists its pages at `/custom/updates.json`, newest file modification first (template: `themes/ttgwiki/templates/updates.json.twig` in the [wiki-pages](https://git.teletypegames.org/services/wiki-pages) repo). The updater only reads `route`, `title` and `modified`; the link is `GRAV_BASE_URL` + `route`.

```json
{"lang":"en","count":1,"pages":[
  {"route":"/team/onboarding","title":"Onboarding","modified":1792479011,
   "updatedAt":"2026-10-20T06:50:11+00:00","locale":"en","tags":["team"],"private":true}
]}
```

- The cache key is `grav_` + route, so every save of a page (a new `modified`) produces a new message.
- Login-only pages (`private: true`) are listed with their titles too; the endpoint does not return their bodies, and the updater does not ask for them.
- It requests the unprefixed route, so it posts English titles and URLs.

# Configuration

Environment variables; copy `env-example` to `.env`. Every source runs in every round — one without a URL quietly returns an empty list.

## General

| Variable | Description | Default |
|---|---|---|
| `INTERVAL_MINUTES` | Time between two rounds (minutes). | `5` |
| `DISCORD_WEBHOOK` | Fallback webhook; every source has its own now, so nothing uses it. | - |
| `DISCORD_FAKE` | If `true`, messages only go to the log, not to Discord. | `false` |

## Grav wiki

| Variable | Description |
|---|---|
| `GRAV_BASE_URL` | The wiki base URL, **without a trailing slash**: `https://wiki.teletypegames.org`. |
| `GRAV_DISCORD_WEBHOOK` | Discord webhook for wiki notifications. |
| `GRAV_CONTENT_LIMIT` | How many recently modified pages to check. |

The Grav endpoint needs no token.

## Redmine

| Variable | Description |
|---|---|
| `REDMINE_BASE_URL` | The Redmine base URL (`https://pm.teletypegames.org`). |
| `REDMINE_KEY` | Redmine API key (`X-Redmine-API-Key`). |
| `REDMINE_DISCORD_WEBHOOK` | Discord webhook for Redmine notifications. |
| `REDMINE_CONTENT_LIMIT` | How many recently updated issues to check. |

## Gitea

| Variable | Description |
|---|---|
| `GITEA_BASE_URL` | The Gitea base URL (`https://git.teletypegames.org`). |
| `GITEA_TOKEN` | Gitea API token. |
| `GITEA_REPOS` | Comma-separated repos to watch, e.g. `games/mranderson,games/impostor`. Only commits are watched, not issues or PRs. |
| `GITEA_DISCORD_WEBHOOK` | Discord webhook for Gitea notifications. |
| `GITEA_CONTENT_LIMIT` | How many recent commits to check per repo. |

# Running

Set up `.env` first, then either:

```bash
go run main.go          # locally
docker compose up -d    # in a container
```

The container mounts the repo folder as `/app` and runs `go run main.go` on start. `cache.json` lives in the same folder and survives restarts. The container runs on the `interstack` network with a `384m` memory limit.

In production: `/srv/stacks/updater` on the server, logs via `docker logs updater-app`. A push to master that changes the Go sources, the `Dockerfile` or `compose.yaml` deploys it from Woodpecker (`ci-deploy updater`, see [Deployment](https://wiki.teletypegames.org/infrastructure/deployment)) after `go vet` and a build; `.env` and `cache.json` stay on the server.

# Files

| File | Contents |
|---|---|
| `main.go` | Entry point, calls `lib.Runner()`. |
| `lib/runner.go` | Reads the config, builds the source and webhook list, runs the main loop. |
| `lib/fetcher.grav.go`, `lib/fetcher.redmine.go`, `lib/fetcher.gitea.go` | The three live sources. |
| `lib/fetcher.go` | Shared HTTP request and cache check. |
| `lib/sender.discord.go`, `lib/message.go` | The Discord webhook and the message format. |
| `lib/cache.go` | Loads and saves `cache.json`. |
| `env-example` | Configuration template. |

# Repo

https://git.teletypegames.org/services/updater

'; var prevLevel = 2; for (var i = 0; i < headings.length; i++) { var h = headings\[i\]; var text = h.textContent.trim(); var slug = text.toLowerCase() .replace(/\[áàâä\]/g,'a').replace(/\[éèêë\]/g,'e').replace(/\[íìîï\]/g,'i') .replace(/\[óòôöő\]/g,'o').replace(/\[úùûüű\]/g,'u') .replace(/\[^a-z0-9\]+/g, '-').replace(/^-|-$/g, ''); if (!slug) slug = 'heading-' + i; if (used\[slug\]) { slug += '-' + i; } used\[slug\] = true; h.id = slug; var level = parseInt(h.tagName.charAt(1)); var cls = level > 2 ? ' class="toc-child"' : ''; html += '- [' + text + '](#'%20+%20slug%20+%20')
'; } html += '
'; toc.innerHTML = html; // Scroll-spy: highlight current heading var links = toc.querySelectorAll('a'); var ids = \[\]; for (var j = 0; j < links.length; j++) ids.push(links\[j\].getAttribute('href').slice(1)); function updateActive() { var current = ''; for (var k = 0; k < ids.length; k++) { var el = document.getElementById(ids\[k\]); if (el && el.getBoundingClientRect().top <= 80) current = ids\[k\]; } for (var m = 0; m < links.length; m++) { links\[m\].parentNode.classList.toggle('active', links\[m\].getAttribute('href') === '#' + current); } } window.addEventListener('scroll', updateActive, { passive: true }); updateActive(); })();

---

## Navigation

- Parent: [Services](https://wiki.teletypegames.org/services.md)
- Previous: [TTG Gate BBS](https://wiki.teletypegames.org/services/ttg-gate-bbs.md)
- Next: [WarpEngine BBS](https://wiki.teletypegames.org/services/warp-engine-bbs.md)
