feat(telegram-ollama-bot): add Telegram bot for local LLM inference via Ollama

- NestJS-based Telegram bot with nestjs-telegraf
- Ollama service for API communication with Gemma 3 4B
- Commands: /start, /help, /models, /model, /mode, /clear, /status
- Multiple modes: default, classify, summarize, translate, code
- Chat history with context (last 10 messages)
- User access control via TELEGRAM_ALLOWED_USERS
- Health endpoint for monitoring
- Updated MAC_MINI_SERVER.md with Ollama documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-26 15:43:41 +01:00
parent 2975e5d2a1
commit 3f64c7422f
15 changed files with 1061 additions and 121 deletions

View file

@ -0,0 +1,130 @@
# Telegram Ollama Bot
Telegram Bot für lokale LLM-Inferenz via Ollama auf dem Mac Mini Server.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **LLM**: Ollama API (Gemma 3 4B)
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Hilfe anzeigen |
| `/help` | Hilfe anzeigen |
| `/models` | Verfügbare Modelle auflisten |
| `/model [name]` | Modell wechseln |
| `/mode [modus]` | System-Prompt ändern |
| `/clear` | Chat-Verlauf löschen |
| `/status` | Ollama-Status prüfen |
## Modi
| Modus | Beschreibung |
|-------|--------------|
| `default` | Allgemeiner Assistent |
| `classify` | Text-Klassifizierung |
| `summarize` | Zusammenfassungen |
| `translate` | Übersetzungen |
| `code` | Programmier-Hilfe |
## Environment Variables
```env
# Server
PORT=3301
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben
# Ollama
OLLAMA_URL=http://localhost:11434 # Ollama API URL
OLLAMA_MODEL=gemma3:4b # Standard-Modell
OLLAMA_TIMEOUT=120000 # Timeout in ms
```
## Projekt-Struktur
```
services/telegram-ollama-bot/
├── src/
│ ├── main.ts # Entry point
│ ├── app.module.ts # Root module
│ ├── health.controller.ts # Health endpoint
│ ├── config/
│ │ └── configuration.ts # Config & System Prompts
│ ├── bot/
│ │ ├── bot.module.ts
│ │ └── bot.update.ts # Command handlers
│ └── ollama/
│ ├── ollama.module.ts
│ └── ollama.service.ts # Ollama API client
└── Dockerfile
```
## Deployment auf Mac Mini
### Option 1: Docker
```yaml
# In docker-compose.macmini.yml
telegram-ollama-bot:
image: ghcr.io/memo-2023/telegram-ollama-bot:latest
container_name: manacore-telegram-ollama-bot
restart: always
environment:
PORT: 3301
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
OLLAMA_URL: http://host.docker.internal:11434
OLLAMA_MODEL: gemma3:4b
ports:
- "3301:3301"
```
### Option 2: Nativ (empfohlen für beste Ollama-Performance)
```bash
# Auf dem Mac Mini
cd ~/projects/manacore-monorepo/services/telegram-ollama-bot
pnpm install
pnpm build
TELEGRAM_BOT_TOKEN=xxx OLLAMA_URL=http://localhost:11434 pnpm start:prod
```
## Neuen Bot erstellen
1. Öffne @BotFather in Telegram
2. Sende `/newbot`
3. Wähle einen Namen (z.B. "ManaCore Ollama")
4. Wähle einen Username (z.B. "manacore_ollama_bot")
5. Kopiere den Token
## Health Check
```bash
curl http://localhost:3301/health
```
## Features
- **Chat-Verlauf**: Behält die letzten 10 Nachrichten für Kontext
- **Mehrere Modi**: Verschiedene System-Prompts für unterschiedliche Aufgaben
- **Modell-Wechsel**: Dynamisch zwischen installierten Modellen wechseln
- **User-Beschränkung**: Optional nur bestimmte Telegram-User erlauben
- **Lange Antworten**: Automatisches Splitting bei >4000 Zeichen

View file

@ -0,0 +1,44 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile || pnpm install
# Copy source
COPY . .
# Build
RUN pnpm build
# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Copy package files
COPY package.json pnpm-lock.yaml* ./
# Install production dependencies only
RUN pnpm install --prod --frozen-lockfile || pnpm install --prod
# Copy built application
COPY --from=builder /app/dist ./dist
# Set environment
ENV NODE_ENV=production
ENV PORT=3301
EXPOSE 3301
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,35 @@
{
"name": "@manacore/telegram-ollama-bot",
"version": "1.0.0",
"description": "Telegram bot for local LLM inference via Ollama",
"private": true,
"license": "MIT",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"nestjs-telegraf": "^2.8.0",
"telegraf": "^4.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.5",
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import configuration from './config/configuration';
import { BotModule } from './bot/bot.module';
import { OllamaModule } from './ollama/ollama.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
}),
TelegrafModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
token: configService.get<string>('telegram.token') || '',
}),
inject: [ConfigService],
}),
BotModule,
OllamaModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { OllamaModule } from '../ollama/ollama.module';
@Module({
imports: [OllamaModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -0,0 +1,278 @@
import { Logger } from '@nestjs/common';
import { Update, Ctx, Start, Help, Command, On, Message } from 'nestjs-telegraf';
import { Context } from 'telegraf';
import { ConfigService } from '@nestjs/config';
import { OllamaService } from '../ollama/ollama.service';
import { SYSTEM_PROMPTS } from '../config/configuration';
interface UserSession {
systemPrompt: string;
model: string;
history: { role: 'user' | 'assistant'; content: string }[];
}
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
private sessions: Map<number, UserSession> = new Map();
constructor(
private readonly ollamaService: OllamaService,
private configService: ConfigService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
}
private isAllowed(userId: number): boolean {
// If no users configured, allow all
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private getSession(userId: number): UserSession {
if (!this.sessions.has(userId)) {
this.sessions.set(userId, {
systemPrompt: SYSTEM_PROMPTS.default,
model: this.ollamaService.getDefaultModel(),
history: [],
});
}
return this.sessions.get(userId)!;
}
private formatHelp(): string {
return `<b>Ollama Bot - Lokale KI</b>
<b>Commands:</b>
/start - Diese Hilfe anzeigen
/help - Diese Hilfe anzeigen
/models - Verfügbare Modelle anzeigen
/model [name] - Modell wechseln
/mode [modus] - System-Prompt ändern
/clear - Chat-Verlauf löschen
/status - Ollama Status prüfen
<b>Modi:</b>
<code>default</code> - Allgemeiner Assistent
<code>classify</code> - Text-Klassifizierung
<code>summarize</code> - Zusammenfassungen
<code>translate</code> - Übersetzungen
<code>code</code> - Programmier-Hilfe
<b>Verwendung:</b>
Schreibe einfach eine Nachricht und ich antworte!
<b>Aktuelles Modell:</b> <code>${this.ollamaService.getDefaultModel()}</code>`;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/start from user ${userId}`);
await ctx.replyWithHTML(this.formatHelp());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
await ctx.replyWithHTML(this.formatHelp());
}
@Command('models')
async models(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
this.logger.log(`/models from user ${userId}`);
const models = await this.ollamaService.listModels();
if (models.length === 0) {
await ctx.reply('Keine Modelle gefunden. Ist Ollama gestartet?');
return;
}
const session = this.getSession(userId);
const modelList = models
.map((m) => {
const sizeMB = (m.size / 1024 / 1024).toFixed(0);
const active = m.name === session.model ? ' ✓' : '';
return `• <code>${m.name}</code> (${sizeMB} MB)${active}`;
})
.join('\n');
await ctx.replyWithHTML(
`<b>Verfügbare Modelle:</b>\n\n${modelList}\n\nWechseln mit: /model [name]`
);
}
@Command('model')
async setModel(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const modelName = text.replace('/model', '').trim();
if (!modelName) {
const session = this.getSession(userId);
await ctx.reply(`Aktuelles Modell: ${session.model}\n\nVerwendung: /model gemma3:4b`);
return;
}
const models = await this.ollamaService.listModels();
const exists = models.some((m) => m.name === modelName);
if (!exists) {
await ctx.reply(
`Modell "${modelName}" nicht gefunden. Verfügbar: ${models.map((m) => m.name).join(', ')}`
);
return;
}
const session = this.getSession(userId);
session.model = modelName;
session.history = []; // Clear history on model change
this.logger.log(`User ${userId} switched to model ${modelName}`);
await ctx.reply(`Modell gewechselt zu: ${modelName}`);
}
@Command('mode')
async setMode(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const mode = text.replace('/mode', '').trim().toLowerCase();
const availableModes = Object.keys(SYSTEM_PROMPTS);
if (!mode) {
const session = this.getSession(userId);
const currentMode =
Object.entries(SYSTEM_PROMPTS).find(([_, v]) => v === session.systemPrompt)?.[0] ||
'custom';
await ctx.reply(`Aktueller Modus: ${currentMode}\n\nVerfügbar: ${availableModes.join(', ')}`);
return;
}
if (!SYSTEM_PROMPTS[mode]) {
await ctx.reply(`Unbekannter Modus: ${mode}\n\nVerfügbar: ${availableModes.join(', ')}`);
return;
}
const session = this.getSession(userId);
session.systemPrompt = SYSTEM_PROMPTS[mode];
session.history = []; // Clear history on mode change
this.logger.log(`User ${userId} switched to mode ${mode}`);
await ctx.reply(`Modus gewechselt zu: ${mode}`);
}
@Command('clear')
async clear(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const session = this.getSession(userId);
session.history = [];
this.logger.log(`User ${userId} cleared history`);
await ctx.reply('Chat-Verlauf gelöscht.');
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
const connected = await this.ollamaService.checkConnection();
const models = await this.ollamaService.listModels();
const session = this.getSession(userId);
const statusText = `<b>Ollama Status</b>
<b>Verbindung:</b> ${connected ? '✅ Online' : '❌ Offline'}
<b>Modelle:</b> ${models.length}
<b>Dein Modell:</b> <code>${session.model}</code>
<b>Chat-Verlauf:</b> ${session.history.length} Nachrichten`;
await ctx.replyWithHTML(statusText);
}
@On('text')
async onMessage(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('Zugriff verweigert.');
return;
}
// Ignore commands
if (text.startsWith('/')) return;
this.logger.log(`Message from user ${userId}: ${text.substring(0, 50)}...`);
const session = this.getSession(userId);
// Show typing indicator
await ctx.sendChatAction('typing');
try {
// Add user message to history
session.history.push({ role: 'user', content: text });
// Keep only last 10 messages to avoid context overflow
if (session.history.length > 10) {
session.history = session.history.slice(-10);
}
// Build messages with system prompt
const messages: { role: 'user' | 'assistant' | 'system'; content: string }[] = [
{ role: 'system', content: session.systemPrompt },
...session.history,
];
const response = await this.ollamaService.chat(messages, session.model);
// Add assistant response to history
session.history.push({ role: 'assistant', content: response });
// Split long messages (Telegram limit is 4096 chars)
if (response.length > 4000) {
const chunks = response.match(/.{1,4000}/gs) || [];
for (const chunk of chunks) {
await ctx.reply(chunk);
}
} else {
await ctx.reply(response);
}
} catch (error) {
this.logger.error(`Error processing message:`, error);
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
await ctx.reply(`Fehler: ${errorMessage}`);
}
}
}

View file

@ -0,0 +1,25 @@
export default () => ({
port: parseInt(process.env.PORT || '3301', 10),
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN,
allowedUsers:
process.env.TELEGRAM_ALLOWED_USERS?.split(',').map((id) => parseInt(id, 10)) || [],
},
ollama: {
url: process.env.OLLAMA_URL || 'http://localhost:11434',
model: process.env.OLLAMA_MODEL || 'gemma3:4b',
timeout: parseInt(process.env.OLLAMA_TIMEOUT || '120000', 10),
},
});
export const SYSTEM_PROMPTS: Record<string, string> = {
default:
'Du bist ein hilfreicher Assistent. Antworte präzise und auf Deutsch, wenn der User Deutsch schreibt.',
classify:
'Du bist ein Klassifikations-Experte. Analysiere den gegebenen Text und ordne ihn einer passenden Kategorie zu. Antworte kurz und präzise.',
summarize:
'Du bist ein Zusammenfassungs-Experte. Fasse den gegebenen Text kurz und prägnant zusammen. Behalte die wichtigsten Informationen bei.',
translate:
'Du bist ein Übersetzer. Übersetze den Text in die gewünschte Sprache. Behalte den Ton und Stil bei.',
code: 'Du bist ein Programmier-Assistent. Hilf bei Code-Fragen, erkläre Konzepte und schlage Verbesserungen vor.',
};

View file

@ -0,0 +1,21 @@
import { Controller, Get } from '@nestjs/common';
import { OllamaService } from './ollama/ollama.service';
@Controller()
export class HealthController {
constructor(private readonly ollamaService: OllamaService) {}
@Get('health')
async health() {
const ollamaConnected = await this.ollamaService.checkConnection();
return {
status: ollamaConnected ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
ollama: {
connected: ollamaConnected,
model: this.ollamaService.getDefaultModel(),
},
};
}
}

View file

@ -0,0 +1,19 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('port') || 3301;
await app.listen(port);
logger.log(`Telegram Ollama Bot running on port ${port}`);
logger.log(`Ollama URL: ${configService.get<string>('ollama.url')}`);
logger.log(`Default model: ${configService.get<string>('ollama.model')}`);
}
bootstrap();

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { OllamaService } from './ollama.service';
@Module({
providers: [OllamaService],
exports: [OllamaService],
})
export class OllamaModule {}

View file

@ -0,0 +1,138 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
interface OllamaGenerateResponse {
model: string;
response: string;
done: boolean;
total_duration?: number;
eval_count?: number;
eval_duration?: number;
}
interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
@Injectable()
export class OllamaService implements OnModuleInit {
private readonly logger = new Logger(OllamaService.name);
private readonly baseUrl: string;
private readonly defaultModel: string;
private readonly timeout: number;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>('ollama.url') || 'http://localhost:11434';
this.defaultModel = this.configService.get<string>('ollama.model') || 'gemma3:4b';
this.timeout = this.configService.get<number>('ollama.timeout') || 120000;
}
async onModuleInit() {
await this.checkConnection();
}
async checkConnection(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/version`, {
signal: AbortSignal.timeout(5000),
});
const data = await response.json();
this.logger.log(`Ollama connected: v${data.version}`);
return true;
} catch (error) {
this.logger.error(`Failed to connect to Ollama at ${this.baseUrl}:`, error);
return false;
}
}
async listModels(): Promise<OllamaModel[]> {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
const data = await response.json();
return data.models || [];
} catch (error) {
this.logger.error('Failed to list models:', error);
return [];
}
}
async generate(prompt: string, systemPrompt?: string, model?: string): Promise<string> {
const selectedModel = model || this.defaultModel;
const body: Record<string, unknown> = {
model: selectedModel,
prompt,
stream: false,
};
if (systemPrompt) {
body.system = systemPrompt;
}
try {
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data: OllamaGenerateResponse = await response.json();
// Log performance metrics
if (data.eval_count && data.eval_duration) {
const tokensPerSec = (data.eval_count / data.eval_duration) * 1e9;
this.logger.debug(`Generated ${data.eval_count} tokens at ${tokensPerSec.toFixed(1)} t/s`);
}
return data.response;
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error('Ollama Timeout - Antwort dauerte zu lange');
}
throw error;
}
}
async chat(
messages: { role: 'user' | 'assistant' | 'system'; content: string }[],
model?: string
): Promise<string> {
const selectedModel = model || this.defaultModel;
try {
const response = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: selectedModel,
messages,
stream: false,
}),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
return data.message?.content || '';
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error('Ollama Timeout - Antwort dauerte zu lange');
}
throw error;
}
}
getDefaultModel(): string {
return this.defaultModel;
}
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"esModuleInterop": true
}
}