Where the project came from
Keeping up with Swift.org news meant checking their site regularly. I saw an opportunity: build a Discord bot that automatically notifies my community of new articles.
What started as a simple script turned into a full production-ready project, with CI/CD, Docker, monitoring and DevOps best practices. Here is the development journey and the technical decisions behind it.
TL;DR: EchoSwift is an open-source Discord bot that automatically posts Swift.org articles, deployed with GitHub Actions and Docker.
The need
The Swift community moves fast: major announcements, language improvements, lessons learned on the official blog. How do you stay up to date without visiting the site multiple times a day?
The answer: a Discord bot that automatically polls the Swift.org feed (noon and midnight), detects new articles, posts them in a clean format with a role mention, and deduplicates via a tracking system.
Picking the stack
Bun as the runtime
Instead of Node.js, I went with Bun for a few reasons.
Performance: near-instant startup (versus ~500ms for Node.js), lower memory footprint (~30 MB versus ~50 MB), bundler and test runner built in.
bun install # 10-20x faster than npm
bun run dev # Native hot reload
bun test # Ultra-fast test runnerNative TypeScript: no need for ts-node or compilation, Bun runs TypeScript directly.
Application architecture
src/
├── index.ts # Entry point, init
├── config.ts # Config and env validation
├── bot.ts # Discord client and event handlers
├── scheduler.ts # Cron jobs and orchestration
├── feed/
│ ├── fetcher.ts # HTTP client with retry
│ ├── parser.ts # Atom/RSS parsing
│ └── storage.ts # JSON persistence with atomic writes
└── discord/
├── embeds.ts # Discord embed creation
└── poster.ts # Posting with rate limiting
Each module owns a single responsibility, which makes tests and maintenance easier.
Technical challenges and fixes
Smart scheduler with node-cron
The challenge: run the bot at fixed times in the right timezone.
import cron from 'node-cron';
const cronPattern = '0 12,0 * * *'; // Noon and midnight
const task = cron.schedule(
cronPattern,
() => checkFeedAndPost(client, config),
{ timezone: config.timezone } // 'Europe/Paris'
);In development, immediate execution to make testing easier. In production, wait for the cron to avoid stray posts.
Deduplication and atomic storage
The challenge: never repost the same articles after a restart.
async function savePostedArticles(articleIds: Set<string>): Promise<void> {
const tempPath = STORAGE_PATH + '.tmp';
const backupPath = STORAGE_PATH + '.backup';
// 1. Backup the existing file
if (existsSync(STORAGE_PATH)) {
await copyFile(STORAGE_PATH, backupPath);
}
// 2. Write to a temporary file
await writeFile(tempPath, JSON.stringify(data, null, 2));
// 3. Atomic rename (atomic at the OS level)
await rename(tempPath, STORAGE_PATH);
}A cleanup pass removes entries older than 30 days to prevent unbounded growth.
Discord announcement channel support
The bot initially crashed on announcement channels (GuildAnnouncement). Fix: broaden the channel type check.
// Before (restrictive)
if (channel.type !== ChannelType.GuildText) {
throw new Error('Not a text channel');
}
// After (flexible)
if (channel.type !== ChannelType.GuildText &&
channel.type !== ChannelType.GuildAnnouncement) {
throw new Error('Not a text or announcement channel');
}Rate limiting and resilient posting
Discord caps at roughly 5 messages per 5 seconds. Fix: a 2-second wait between posts and error handling that continues with the next article when one fails.
for (let i = 0; i < articles.length; i++) {
try {
await postArticle(client, channelId, roleId, articles[i]);
if (i < articles.length - 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
console.error(`Error posting article ${i + 1}:`, error);
}
}Polished Discord embeds
Articles render with clean embeds: short description (200 characters max), clickable title, Swift orange colour, and publication timestamp.
return new EmbedBuilder()
.setTitle(article.title)
.setURL(article.link)
.setDescription(description)
.setColor(0xF05138)
.setFooter({ text: `swift.org - Published ${dateStr}` })
.setTimestamp(article.publishedAt);DevOps: from code to deployment
Multi-stage Docker build
The goal: a small, secure image.
# Stage 1: Build
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY src ./src
COPY tsconfig.json ./
# Stage 2: Production
FROM oven/bun:1-alpine
WORKDIR /app
RUN addgroup -g 1001 -S botuser && \
adduser -S -D -H -u 1001 -G botuser botuser
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/src ./src
COPY --from=builder /app/tsconfig.json ./
RUN mkdir -p /app/data && chown -R botuser:botuser /app/data
VOLUME ["/app/data"]
USER botuser
CMD ["bun", "run", "src/index.ts"]Final image is roughly 100 MB (versus 300+ MB with Node.js), with hardened security (non-root user) and reproducible builds.
CI/CD with GitHub Actions
The full workflow: automatic tests with Bun, build and push of the Docker image to GHCR, automatic SSH deploy to the VPS, and container restart.
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
- name: Run tests
run: bun test
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/echoswift
sudo docker compose pull
sudo docker compose up -d --force-recreateBenchmarks
- Memory: Bun ~30 MB vs Node.js ~50 MB (40% saving)
- Startup: Bun ~50ms vs Node.js ~500ms (10x faster)
- Docker cold start: Bun ~800ms vs Node.js ~2s (2.5x faster)
Lessons learned
What worked well: Bun (excellent for a light, fast project), atomic writes (data corruption prevention), CI/CD from day one (fast iteration), multi-stage builds (lightweight, secure images).
What I would do differently: add integration tests with a Discord mock, plug Sentry or Datadog for monitoring, set up HTTP health checks, collect metrics (articles posted, response times), and use PostgreSQL instead of JSON for scalability.
EchoSwift is much more than a simple Discord bot: it is a complete demonstration of how to build, test and deploy a production-ready application with modern technologies.

Commentaires
Aucun commentaire pour le moment. Sois le premier !