feat(telegram-chat-bot): implement AI chat bot with multi-model support

- Add NestJS service with Telegraf for Telegram bot
- Implement commands: /models, /model, /new, /convos, /history, /clear
- Create Chat API client for completions and conversations
- Support multiple AI models (local Gemma + cloud via OpenRouter)
- Sync conversations with Chat web/mobile app
- Add message splitting for long responses
- Include Dockerfile, CLAUDE.md, and setup script

Commands:
- Send text → AI responds
- /models - List available models
- /model [name] - Switch model (gemma, claude, gpt, etc.)
- /new [title] - New conversation
- /convos - List conversations
- /history - Show recent messages
- /clear - Clear context

https://claude.ai/code/session_01LwmhvhKpEsvVtY1ZKhYu6f
This commit is contained in:
Claude 2026-01-28 01:14:35 +00:00
parent be748d2f9c
commit faadc413cc
No known key found for this signature in database
23 changed files with 5398 additions and 0 deletions

View file

@ -11,6 +11,7 @@ Dokumentation aller Telegram-Bots im ManaCore Monorepo.
| [telegram-project-doc-bot](#telegram-project-doc-bot) | 3302 | Projektdokumentation & Blogpost-Generierung | ✅ Aktiv |
| [telegram-calendar-bot](#telegram-calendar-bot) | 3303 | Kalender-Termine & Erinnerungen | 🚧 In Entwicklung |
| [telegram-contacts-bot](#telegram-contacts-bot) | 3304 | Kontaktsuche & Geburtstags-Erinnerungen | 🚧 In Entwicklung |
| [telegram-chat-bot](#telegram-chat-bot) | 3305 | AI-Chat mit verschiedenen Modellen | 🚧 In Entwicklung |
## Gemeinsame Architektur

View file

@ -0,0 +1,18 @@
# Server
PORT=3305
NODE_ENV=development
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx # Bot Token from @BotFather
TELEGRAM_ALLOWED_USERS= # Optional: Comma-separated user IDs
# Chat Backend API
CHAT_API_URL=http://localhost:3002
MANA_CORE_AUTH_URL=http://localhost:3001
# Database (for telegram user links and settings)
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat_bot
# Default AI Model
DEFAULT_MODEL=gemma3:4b

View file

@ -0,0 +1,166 @@
# Telegram Chat Bot
Telegram Bot für AI-Chat mit verschiedenen Modellen und Konversations-History. Kommuniziert mit der Chat-App API.
## Tech Stack
- **Framework**: NestJS 10
- **Telegram**: nestjs-telegraf + Telegraf
- **Database**: PostgreSQL + Drizzle ORM
- **Date Handling**: date-fns
## Commands
```bash
# Development
pnpm start:dev # Start with hot reload
# Build
pnpm build # Production build
# Type check
pnpm type-check # Check TypeScript types
# Database
pnpm db:generate # Generate migrations
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
```
## Telegram Commands
| Command | Beschreibung |
|---------|--------------|
| `/start` | Hilfe & Account verknüpfen |
| `/help` | Verfügbare Befehle |
| `/models` | AI-Modelle anzeigen |
| `/model [name]` | Modell wechseln |
| `/new [titel]` | Neue Konversation |
| `/convos` | Konversationen auflisten |
| `/history` | Letzte Nachrichten |
| `/clear` | Kontext löschen |
| `/status` | Verbindungsstatus |
| `/link` | Account verknüpfen |
| `/unlink` | Verknüpfung trennen |
**Chatten:** Einfach Text senden - der Bot antwortet mit AI!
## Unterschied zu telegram-ollama-bot
| Feature | telegram-chat-bot | telegram-ollama-bot |
|---------|-------------------|---------------------|
| API | Chat-App Backend | Direkt Ollama |
| Modelle | Lokal + Cloud | Nur Ollama |
| History | In DB gespeichert | Nur Session |
| Sync | Mit Web/Mobile App | Standalone |
| Konversationen | Mehrere, benannt | Eine pro User |
## Environment Variables
```env
PORT=3305
NODE_ENV=development
TZ=Europe/Berlin
# Telegram
TELEGRAM_BOT_TOKEN=xxx
TELEGRAM_ALLOWED_USERS=
# Chat Backend
CHAT_API_URL=http://localhost:3002
MANA_CORE_AUTH_URL=http://localhost:3001
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat_bot
# Default Model
DEFAULT_MODEL=gemma3:4b
```
## Projekt-Struktur
```
services/telegram-chat-bot/
├── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── health.controller.ts
│ ├── config/
│ │ └── configuration.ts
│ ├── database/
│ │ ├── database.module.ts
│ │ └── schema.ts
│ ├── bot/
│ │ ├── bot.module.ts
│ │ ├── bot.update.ts
│ │ └── formatters.ts
│ ├── chat/
│ │ ├── chat.module.ts
│ │ └── chat.client.ts
│ └── user/
│ ├── user.module.ts
│ └── user.service.ts
├── drizzle/
├── drizzle.config.ts
├── package.json
└── Dockerfile
```
## AI-Modelle
### Lokal (kostenlos)
- 🏠 Gemma 3 4B - Läuft auf Mac Mini
### Cloud (via OpenRouter)
- ☁️ Llama 3.1 8B/70B
- ☁️ DeepSeek V3
- ☁️ Mistral Small
- ☁️ Claude 3.5 Sonnet
- ☁️ GPT-4o Mini
## Health Check
```bash
curl http://localhost:3305/health
```
## Lokale Entwicklung
```bash
# Docker starten
pnpm docker:up
# Datenbank erstellen
psql -h localhost -U manacore -c "CREATE DATABASE chat_bot;"
# In Bot-Verzeichnis
cd services/telegram-chat-bot
cp .env.example .env
# Token eintragen
pnpm install
pnpm db:push
pnpm start:dev
```
## Deployment
### macOS (launchd)
```bash
launchctl load ~/Library/LaunchAgents/com.manacore.telegram-chat-bot.plist
```
### Docker
```yaml
telegram-chat-bot:
build:
dockerfile: services/telegram-chat-bot/Dockerfile
environment:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_CHAT_BOT_TOKEN}
CHAT_API_URL: http://chat-backend:3002
DATABASE_URL: ${CHAT_BOT_DATABASE_URL}
ports:
- "3305:3305"
```

View file

@ -0,0 +1,35 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY package.json pnpm-lock.yaml* ./
COPY services/telegram-chat-bot/package.json ./services/telegram-chat-bot/
RUN pnpm install --frozen-lockfile --filter @manacore/telegram-chat-bot
COPY services/telegram-chat-bot ./services/telegram-chat-bot
WORKDIR /app/services/telegram-chat-bot
RUN pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
COPY --from=builder /app/services/telegram-chat-bot/dist ./dist
COPY --from=builder /app/services/telegram-chat-bot/package.json ./
COPY --from=builder /app/services/telegram-chat-bot/node_modules ./node_modules
ENV NODE_ENV=production
ENV PORT=3305
EXPOSE 3305
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3305/health || exit 1
CMD ["node", "dist/src/main.js"]

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/database/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/chat_bot',
},
});

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,44 @@
{
"name": "@manacore/telegram-chat-bot",
"version": "1.0.0",
"description": "Telegram bot for AI chat with multiple models and conversation history",
"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/src/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^4.1.2",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"drizzle-orm": "^0.38.3",
"nestjs-telegraf": "^2.8.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"telegraf": "^4.16.3"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.5",
"drizzle-kit": "^0.30.1",
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
}
}

3807
services/telegram-chat-bot/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,112 @@
#!/bin/bash
set -e
echo "🚀 Setting up Telegram Chat Bot on Mac Mini..."
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}📂 Navigating to project...${NC}"
cd ~/projects/manacore-monorepo
echo -e "${YELLOW}📥 Fetching latest code...${NC}"
git fetch origin claude/research-telegram-bots-LrdWJ
git checkout claude/research-telegram-bots-LrdWJ
git pull origin claude/research-telegram-bots-LrdWJ
cd services/telegram-chat-bot
echo -e "${YELLOW}🗄️ Creating database...${NC}"
psql -U manacore -d postgres -c "CREATE DATABASE chat_bot;" 2>/dev/null || echo "Database already exists"
echo -e "${YELLOW}📦 Installing dependencies...${NC}"
pnpm install
echo -e "${YELLOW}⚙️ Creating .env file...${NC}"
cat > .env << 'EOF'
PORT=3305
NODE_ENV=production
TZ=Europe/Berlin
TELEGRAM_BOT_TOKEN=YOUR_TOKEN_HERE
TELEGRAM_ALLOWED_USERS=
CHAT_API_URL=http://localhost:3002
MANA_CORE_AUTH_URL=http://localhost:3001
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat_bot
DEFAULT_MODEL=gemma3:4b
EOF
echo -e "${YELLOW}⚠️ WICHTIG: Telegram Bot Token in .env eintragen!${NC}"
echo "Datei: ~/projects/manacore-monorepo/services/telegram-chat-bot/.env"
echo -e "${YELLOW}🗂️ Pushing database schema...${NC}"
pnpm db:push
echo -e "${YELLOW}🔨 Building...${NC}"
pnpm build
echo -e "${YELLOW}🍎 Creating launchd service...${NC}"
cat > ~/Library/LaunchAgents/com.manacore.telegram-chat-bot.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.manacore.telegram-chat-bot</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/node</string>
<string>/Users/till/projects/manacore-monorepo/services/telegram-chat-bot/dist/src/main.js</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/till/projects/manacore-monorepo/services/telegram-chat-bot</string>
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
<key>PORT</key>
<string>3305</string>
<key>TZ</key>
<string>Europe/Berlin</string>
<key>CHAT_API_URL</key>
<string>http://localhost:3002</string>
<key>MANA_CORE_AUTH_URL</key>
<string>http://localhost:3001</string>
<key>DATABASE_URL</key>
<string>postgresql://manacore:devpassword@localhost:5432/chat_bot</string>
<key>DEFAULT_MODEL</key>
<string>gemma3:4b</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/till/projects/manacore-monorepo/services/telegram-chat-bot/logs/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/till/projects/manacore-monorepo/services/telegram-chat-bot/logs/stderr.log</string>
</dict>
</plist>
EOF
mkdir -p logs
echo -e "${GREEN}✅ Setup abgeschlossen!${NC}"
echo ""
echo "⚠️ NÄCHSTE SCHRITTE:"
echo "1. Telegram Bot Token in .env eintragen"
echo "2. Token auch im LaunchAgent plist eintragen:"
echo " nano ~/Library/LaunchAgents/com.manacore.telegram-chat-bot.plist"
echo " (füge TELEGRAM_BOT_TOKEN key/string Paar hinzu)"
echo ""
echo "3. Service starten:"
echo " launchctl load ~/Library/LaunchAgents/com.manacore.telegram-chat-bot.plist"
echo ""
echo "Nützliche Befehle:"
echo " View logs: tail -f logs/stdout.log"
echo " Stop bot: launchctl unload ~/Library/LaunchAgents/com.manacore.telegram-chat-bot.plist"
echo " Health check: curl http://localhost:3305/health"

View file

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TelegrafModule } from 'nestjs-telegraf';
import { ScheduleModule } from '@nestjs/schedule';
import configuration from './config/configuration';
import { DatabaseModule } from './database/database.module';
import { BotModule } from './bot/bot.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],
}),
ScheduleModule.forRoot(),
DatabaseModule,
BotModule,
],
controllers: [HealthController],
})
export class AppModule {}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BotUpdate } from './bot.update';
import { ChatModule } from '../chat/chat.module';
import { UserModule } from '../user/user.module';
@Module({
imports: [ChatModule, UserModule],
providers: [BotUpdate],
})
export class BotModule {}

View file

@ -0,0 +1,337 @@
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 { ChatClient } from '../chat/chat.client';
import { UserService } from '../user/user.service';
import {
formatHelpMessage,
formatModels,
formatConversations,
formatMessages,
formatStatusMessage,
formatModelChanged,
formatNewConversation,
} from './formatters';
import { MODEL_DISPLAY_NAMES } from '../config/configuration';
@Update()
export class BotUpdate {
private readonly logger = new Logger(BotUpdate.name);
private readonly allowedUsers: number[];
constructor(
private readonly configService: ConfigService,
private readonly chatClient: ChatClient,
private readonly userService: UserService
) {
this.allowedUsers = this.configService.get<number[]>('telegram.allowedUsers') || [];
}
private isAllowed(userId: number): boolean {
if (this.allowedUsers.length === 0) return true;
return this.allowedUsers.includes(userId);
}
private async getAccessToken(telegramUserId: number): Promise<string | null> {
const user = await this.userService.getUserByTelegramId(telegramUserId);
if (!user || !user.isActive || !user.accessToken) {
return null;
}
return user.accessToken;
}
@Start()
async start(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/start from user ${userId}`);
if (!userId || !this.isAllowed(userId)) {
await ctx.reply('❌ Du bist nicht berechtigt, diesen Bot zu nutzen.');
return;
}
await ctx.replyWithHTML(formatHelpMessage());
}
@Help()
async help(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
if (!userId || !this.isAllowed(userId)) return;
await ctx.replyWithHTML(formatHelpMessage());
}
@Command('models')
async models(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/models from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await ctx.reply('🤖 Lade Modelle...');
const models = await this.chatClient.getModels(accessToken);
const userSettings = await this.userService.getUserSettings(userId);
await ctx.replyWithHTML(formatModels(models, userSettings?.currentModel));
}
@Command('model')
async model(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
this.logger.log(`/model from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const modelName = text.replace(/^\/model\s*/i, '').trim().toLowerCase();
if (!modelName) {
const models = await this.chatClient.getModels(accessToken);
await ctx.replyWithHTML(
`⚙️ <b>Modell wählen</b>\n\nNutze: /model [name]\n\nBeispiele:\n• /model gemma\n• /model claude\n• /model gpt\n• /model deepseek\n\n${formatModels(models)}`
);
return;
}
// Find matching model
const models = await this.chatClient.getModels(accessToken);
const model = models.find(
(m) =>
m.name.toLowerCase().includes(modelName) || m.id.toLowerCase().includes(modelName)
);
if (!model) {
await ctx.reply(`❌ Modell "${modelName}" nicht gefunden.\n\nNutze /models für eine Liste.`);
return;
}
await this.userService.updateUserSettings(userId, { currentModel: model.id });
await ctx.replyWithHTML(formatModelChanged(model));
}
@Command('convos')
async convos(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/convos from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const conversations = await this.chatClient.getConversations(accessToken, 10);
const userSettings = await this.userService.getUserSettings(userId);
await ctx.replyWithHTML(formatConversations(conversations, userSettings?.currentConversationId));
}
@Command('new')
async newConversation(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
this.logger.log(`/new from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const title = text.replace(/^\/new\s*/i, '').trim() || 'Telegram Chat';
const userSettings = await this.userService.getUserSettings(userId);
const conversation = await this.chatClient.createConversation(
accessToken,
title,
userSettings?.currentModel || undefined
);
if (!conversation) {
await ctx.reply('❌ Fehler beim Erstellen der Konversation.');
return;
}
await this.userService.updateUserSettings(userId, { currentConversationId: conversation.id });
await ctx.replyWithHTML(formatNewConversation(conversation));
}
@Command('history')
async history(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/history from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
const userSettings = await this.userService.getUserSettings(userId);
if (!userSettings?.currentConversationId) {
await ctx.reply('❌ Keine aktive Konversation. Starte eine mit /new');
return;
}
const messages = await this.chatClient.getMessages(
accessToken,
userSettings.currentConversationId,
10
);
await ctx.replyWithHTML(formatMessages(messages));
}
@Command('clear')
async clear(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/clear from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
await this.userService.updateUserSettings(userId, { currentConversationId: null });
await ctx.reply('🗑️ Konversation gewechselt. Die nächste Nachricht startet einen neuen Chat.');
}
@Command('status')
async status(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/status from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const user = await this.userService.getUserByTelegramId(userId);
const settings = await this.userService.getUserSettings(userId);
await ctx.replyWithHTML(
formatStatusMessage(
!!user?.isActive && !!user?.accessToken,
user?.telegramUsername || user?.telegramFirstName,
settings?.currentModel,
user?.lastActiveAt || undefined
)
);
}
@Command('link')
async link(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/link from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
await ctx.replyWithHTML(`🔗 <b>Account verknüpfen</b>
Um deinen ManaCore Account zu verknüpfen:
1. Öffne die Chat Web-App: <b>chat.mana.how</b>
2. Gehe zu <b>Einstellungen Telegram</b>
3. Gib deine Telegram User-ID ein: <code>${userId}</code>
Nach der Verknüpfung kannst du direkt chatten!`);
}
@Command('unlink')
async unlink(@Ctx() ctx: Context) {
const userId = ctx.from?.id;
this.logger.log(`/unlink from user ${userId}`);
if (!userId || !this.isAllowed(userId)) return;
const success = await this.userService.unlinkUser(userId);
if (success) {
await ctx.reply('✅ Account-Verknüpfung wurde aufgehoben.');
} else {
await ctx.reply('❌ Fehler beim Aufheben der Verknüpfung.');
}
}
@On('text')
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
const userId = ctx.from?.id;
// Ignore commands
if (text.startsWith('/')) return;
if (!userId || !this.isAllowed(userId)) return;
const accessToken = await this.getAccessToken(userId);
if (!accessToken) {
await ctx.reply('❌ Bitte verknüpfe zuerst deinen Account mit /link');
return;
}
await this.userService.updateLastActive(userId);
// Get user settings
const settings = await this.userService.getUserSettings(userId);
// Send typing indicator
await ctx.sendChatAction('typing');
// Send message to Chat API
const response = await this.chatClient.sendMessage(
accessToken,
text,
settings?.currentConversationId || undefined,
settings?.currentModel || undefined
);
if (!response) {
await ctx.reply('❌ Fehler bei der AI-Antwort. Versuche es erneut.');
return;
}
// Update current conversation if new
if (response.conversationId && response.conversationId !== settings?.currentConversationId) {
await this.userService.updateUserSettings(userId, {
currentConversationId: response.conversationId,
});
}
// Split long messages (Telegram limit: 4096 chars)
const maxLength = 4000;
const message = response.message;
if (message.length <= maxLength) {
await ctx.replyWithHTML(message);
} else {
// Split into chunks
const chunks: string[] = [];
let remaining = message;
while (remaining.length > 0) {
if (remaining.length <= maxLength) {
chunks.push(remaining);
break;
}
// Find a good split point
let splitAt = remaining.lastIndexOf('\n\n', maxLength);
if (splitAt === -1 || splitAt < maxLength / 2) {
splitAt = remaining.lastIndexOf('\n', maxLength);
}
if (splitAt === -1 || splitAt < maxLength / 2) {
splitAt = remaining.lastIndexOf(' ', maxLength);
}
if (splitAt === -1) {
splitAt = maxLength;
}
chunks.push(remaining.substring(0, splitAt));
remaining = remaining.substring(splitAt).trim();
}
for (const chunk of chunks) {
await ctx.reply(chunk, { parse_mode: 'HTML' });
}
}
}
}

View file

@ -0,0 +1,210 @@
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
import { AIModel, Conversation, Message } from '../chat/chat.client';
import { MODEL_DISPLAY_NAMES } from '../config/configuration';
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Get model display name
*/
function getModelDisplayName(modelId: string, models?: AIModel[]): string {
// Check predefined names
if (MODEL_DISPLAY_NAMES[modelId]) {
return MODEL_DISPLAY_NAMES[modelId];
}
// Check from models list
const model = models?.find((m) => m.id === modelId);
if (model) {
const emoji = model.isLocal ? '🏠' : '☁️';
return `${emoji} ${model.name}`;
}
// Fallback: extract name from ID
const parts = modelId.split('/');
const name = parts[parts.length - 1].replace(/:.*$/, '');
return `🤖 ${name}`;
}
/**
* Format models list
*/
export function formatModels(models: AIModel[], currentModel?: string | null): string {
if (models.length === 0) {
return '🤖 <b>AI-Modelle</b>\n\nKeine Modelle verfügbar.';
}
let text = '🤖 <b>Verfügbare AI-Modelle</b>\n\n';
// Group by local/cloud
const localModels = models.filter((m) => m.isLocal);
const cloudModels = models.filter((m) => !m.isLocal);
if (localModels.length > 0) {
text += '<b>🏠 Lokal (kostenlos)</b>\n';
for (const model of localModels) {
const current = model.id === currentModel ? ' ✓' : '';
const isDefault = model.isDefault ? ' (Standard)' : '';
text += `${escapeHtml(model.name)}${isDefault}${current}\n`;
}
text += '\n';
}
if (cloudModels.length > 0) {
text += '<b>☁️ Cloud</b>\n';
for (const model of cloudModels) {
const current = model.id === currentModel ? ' ✓' : '';
text += `${escapeHtml(model.name)}${current}\n`;
}
text += '\n';
}
text += '───────────────\n';
text += 'Wechseln mit: /model [name]';
return text;
}
/**
* Format model changed message
*/
export function formatModelChanged(model: AIModel): string {
const emoji = model.isLocal ? '🏠' : '☁️';
return `✅ Modell gewechselt zu:\n\n${emoji} <b>${escapeHtml(model.name)}</b>\n\nDeine nächste Nachricht wird mit diesem Modell beantwortet.`;
}
/**
* Format conversations list
*/
export function formatConversations(
conversations: Conversation[],
currentId?: string | null
): string {
if (conversations.length === 0) {
return '💬 <b>Deine Konversationen</b>\n\nKeine Konversationen vorhanden.\n\nStarte eine mit /new oder sende einfach eine Nachricht!';
}
let text = '💬 <b>Deine Konversationen</b>\n\n';
for (const convo of conversations.slice(0, 10)) {
const current = convo.id === currentId ? ' 📍' : '';
const date = format(new Date(convo.updatedAt), 'd. MMM', { locale: de });
const title =
convo.title.length > 30 ? convo.title.substring(0, 30) + '...' : convo.title;
text += `• <b>${escapeHtml(title)}</b>${current}\n ${date}\n\n`;
}
text += '───────────────\n';
text += '/new [titel] - Neue Konversation\n';
text += '/clear - Kontext löschen';
return text;
}
/**
* Format new conversation created message
*/
export function formatNewConversation(conversation: Conversation): string {
return `✅ <b>Neue Konversation gestartet</b>
📝 ${escapeHtml(conversation.title)}
Du kannst jetzt chatten! Sende einfach eine Nachricht.`;
}
/**
* Format messages history
*/
export function formatMessages(messages: Message[]): string {
if (messages.length === 0) {
return '📜 <b>Verlauf</b>\n\nKeine Nachrichten in dieser Konversation.';
}
let text = '📜 <b>Letzte Nachrichten</b>\n\n';
// Reverse to show oldest first
const sorted = [...messages].reverse().slice(-10);
for (const msg of sorted) {
const role = msg.role === 'user' ? '👤' : '🤖';
const content =
msg.content.length > 200 ? msg.content.substring(0, 200) + '...' : msg.content;
text += `${role} ${escapeHtml(content)}\n\n`;
}
return text;
}
/**
* Format status message
*/
export function formatStatusMessage(
isLinked: boolean,
username?: string | null,
currentModel?: string | null,
lastActive?: Date
): string {
if (!isLinked) {
return `📊 <b>Status</b>
Nicht mit ManaCore verknüpft
Nutze /link um deinen Account zu verknüpfen.`;
}
const lastActiveText = lastActive ? format(lastActive, 'd. MMM HH:mm', { locale: de }) : 'Nie';
const modelName = currentModel ? getModelDisplayName(currentModel) : '🏠 Gemma 3 4B (Standard)';
return `📊 <b>Status</b>
Verknüpft mit ManaCore
👤 ${username || 'Unbekannt'}
🤖 Modell: ${modelName}
🕐 Letzte Aktivität: ${lastActiveText}
/models - Modell wechseln
/unlink - Verknüpfung trennen`;
}
/**
* Format help message
*/
export function formatHelpMessage(): string {
return `🤖 <b>Chat Bot - Hilfe</b>
Chatte mit verschiedenen AI-Modellen direkt in Telegram!
<b>Chatten:</b>
Sende einfach eine Nachricht - der Bot antwortet mit AI.
<b>Modelle:</b>
/models - Verfügbare Modelle anzeigen
/model [name] - Modell wechseln
z.B. /model claude, /model gpt
<b>Konversationen:</b>
/new [titel] - Neue Konversation
/convos - Konversationen auflisten
/history - Letzte Nachrichten
/clear - Neuer Kontext
<b>Account:</b>
/status - Verbindungsstatus
/link - ManaCore Account verknüpfen
/unlink - Verknüpfung trennen
🏠 Lokal: Gemma 3 (kostenlos, schnell)
Cloud: Claude, GPT-4, DeepSeek, etc.`;
}

View file

@ -0,0 +1,226 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface AIModel {
id: string;
name: string;
provider: string;
isLocal: boolean;
isDefault: boolean;
}
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: string;
}
export interface Conversation {
id: string;
title: string;
modelId: string;
createdAt: string;
updatedAt: string;
messageCount?: number;
}
export interface ChatCompletionResponse {
message: string;
conversationId: string;
model: string;
}
@Injectable()
export class ChatClient {
private readonly logger = new Logger(ChatClient.name);
private readonly apiUrl: string;
constructor(private configService: ConfigService) {
this.apiUrl = this.configService.get<string>('chat.apiUrl') || 'http://localhost:3002';
}
/**
* Get available AI models
*/
async getModels(accessToken: string): Promise<AIModel[]> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/chat/models`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
this.logger.error(`Failed to get models: ${response.status}`);
return [];
}
return await response.json();
} catch (error) {
this.logger.error(`Error fetching models: ${error}`);
return [];
}
}
/**
* Get user conversations
*/
async getConversations(accessToken: string, limit = 10): Promise<Conversation[]> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/conversations?limit=${limit}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
this.logger.error(`Failed to get conversations: ${response.status}`);
return [];
}
const data = await response.json();
return data.conversations || data || [];
} catch (error) {
this.logger.error(`Error fetching conversations: ${error}`);
return [];
}
}
/**
* Get conversation messages
*/
async getMessages(accessToken: string, conversationId: string, limit = 20): Promise<Message[]> {
try {
const response = await fetch(
`${this.apiUrl}/api/v1/conversations/${conversationId}/messages?limit=${limit}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);
if (!response.ok) {
this.logger.error(`Failed to get messages: ${response.status}`);
return [];
}
const data = await response.json();
return data.messages || data || [];
} catch (error) {
this.logger.error(`Error fetching messages: ${error}`);
return [];
}
}
/**
* Create a new conversation
*/
async createConversation(
accessToken: string,
title: string,
modelId?: string
): Promise<Conversation | null> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/conversations`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, modelId }),
});
if (!response.ok) {
this.logger.error(`Failed to create conversation: ${response.status}`);
return null;
}
return await response.json();
} catch (error) {
this.logger.error(`Error creating conversation: ${error}`);
return null;
}
}
/**
* Send a chat message and get AI response
*/
async sendMessage(
accessToken: string,
message: string,
conversationId?: string,
modelId?: string
): Promise<ChatCompletionResponse | null> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationId,
model: modelId,
}),
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`Failed to send message: ${response.status} - ${errorText}`);
return null;
}
return await response.json();
} catch (error) {
this.logger.error(`Error sending message: ${error}`);
return null;
}
}
/**
* Stream chat completion (returns async generator)
*/
async *streamMessage(
accessToken: string,
message: string,
conversationId?: string,
modelId?: string
): AsyncGenerator<string, void, unknown> {
try {
const response = await fetch(`${this.apiUrl}/api/v1/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message,
conversationId,
model: modelId,
stream: true,
}),
});
if (!response.ok || !response.body) {
this.logger.error(`Failed to stream message: ${response.status}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
yield chunk;
}
} catch (error) {
this.logger.error(`Error streaming message: ${error}`);
}
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ChatClient } from './chat.client';
@Module({
providers: [ChatClient],
exports: [ChatClient],
})
export class ChatModule {}

View file

@ -0,0 +1,47 @@
export default () => ({
port: parseInt(process.env.PORT || '3305', 10),
nodeEnv: process.env.NODE_ENV || 'development',
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN || '',
allowedUsers: process.env.TELEGRAM_ALLOWED_USERS
? process.env.TELEGRAM_ALLOWED_USERS.split(',').map((id) => parseInt(id.trim(), 10))
: [],
},
chat: {
apiUrl: process.env.CHAT_API_URL || 'http://localhost:3002',
authUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
defaultModel: process.env.DEFAULT_MODEL || 'gemma3:4b',
},
database: {
url: process.env.DATABASE_URL,
},
});
// Command descriptions for BotFather
export const COMMANDS = [
{ command: 'start', description: 'Hilfe & Account verknüpfen' },
{ command: 'help', description: 'Verfügbare Befehle anzeigen' },
{ command: 'models', description: 'Verfügbare AI-Modelle anzeigen' },
{ command: 'model', description: 'Modell wechseln (z.B. /model claude)' },
{ command: 'new', description: 'Neue Konversation starten' },
{ command: 'convos', description: 'Konversationen auflisten' },
{ command: 'history', description: 'Letzte Nachrichten anzeigen' },
{ command: 'clear', description: 'Kontext löschen, neue Konversation' },
{ command: 'status', description: 'Verbindungsstatus prüfen' },
{ command: 'link', description: 'ManaCore Account verknüpfen' },
{ command: 'unlink', description: 'Account-Verknüpfung trennen' },
];
// Model display names
export const MODEL_DISPLAY_NAMES: Record<string, string> = {
'gemma3:4b': '🏠 Gemma 3 4B (Lokal)',
'meta-llama/llama-3.1-8b-instruct:free': '☁️ Llama 3.1 8B',
'meta-llama/llama-3.1-70b-instruct': '☁️ Llama 3.1 70B',
'deepseek/deepseek-chat': '☁️ DeepSeek V3',
'mistralai/mistral-small': '☁️ Mistral Small',
'anthropic/claude-3.5-sonnet': '☁️ Claude 3.5 Sonnet',
'openai/gpt-4o-mini': '☁️ GPT-4o Mini',
};

View file

@ -0,0 +1,39 @@
import { Module, Global, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { drizzle, PostgresJsDatabase } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
export type Database = PostgresJsDatabase<typeof schema>;
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: async (configService: ConfigService): Promise<Database | null> => {
const logger = new Logger('Database');
const databaseUrl = configService.get<string>('database.url');
if (!databaseUrl) {
logger.warn('DATABASE_URL not set, database features will be disabled');
return null;
}
try {
const client = postgres(databaseUrl);
const db = drizzle(client, { schema });
logger.log('Database connection established');
return db;
} catch (error) {
logger.error(`Failed to connect to database: ${error}`);
return null;
}
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}

View file

@ -0,0 +1,41 @@
import { pgTable, uuid, text, timestamp, bigint, boolean, jsonb } from 'drizzle-orm/pg-core';
/**
* Telegram users linked to ManaCore accounts
*/
export const telegramUsers = pgTable('telegram_users', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' }).notNull().unique(),
telegramUsername: text('telegram_username'),
telegramFirstName: text('telegram_first_name'),
manaUserId: text('mana_user_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }),
isActive: boolean('is_active').default(true),
linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow(),
lastActiveAt: timestamp('last_active_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
/**
* User settings for the chat bot
*/
export const chatBotSettings = pgTable('chat_bot_settings', {
id: uuid('id').primaryKey().defaultRandom(),
telegramUserId: bigint('telegram_user_id', { mode: 'number' })
.notNull()
.unique()
.references(() => telegramUsers.telegramUserId, { onDelete: 'cascade' }),
currentModel: text('current_model'),
currentConversationId: text('current_conversation_id'),
preferences: jsonb('preferences').default({}),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export type TelegramUser = typeof telegramUsers.$inferSelect;
export type NewTelegramUser = typeof telegramUsers.$inferInsert;
export type ChatBotSettings = typeof chatBotSettings.$inferSelect;
export type NewChatBotSettings = typeof chatBotSettings.$inferInsert;

View file

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Controller('health')
export class HealthController {
constructor(private configService: ConfigService) {}
@Get()
health() {
return {
status: 'ok',
service: 'telegram-chat-bot',
timestamp: new Date().toISOString(),
environment: this.configService.get<string>('nodeEnv'),
};
}
}

View file

@ -0,0 +1,16 @@
import { NestFactory } from '@nestjs/core';
import { Logger } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create(AppModule);
const port = process.env.PORT || 3305;
await app.listen(port);
logger.log(`Telegram Chat Bot running on port ${port}`);
logger.log(`Chat API: ${process.env.CHAT_API_URL || 'http://localhost:3002'}`);
}
bootstrap();

View file

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

View file

@ -0,0 +1,187 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION, Database } from '../database/database.module';
import {
telegramUsers,
chatBotSettings,
TelegramUser,
NewTelegramUser,
ChatBotSettings,
} from '../database/schema';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(@Inject(DATABASE_CONNECTION) private db: Database | null) {}
async getUserByTelegramId(telegramUserId: number): Promise<TelegramUser | null> {
if (!this.db) return null;
try {
const result = await this.db
.select()
.from(telegramUsers)
.where(eq(telegramUsers.telegramUserId, telegramUserId))
.limit(1);
return result[0] || null;
} catch (error) {
this.logger.error(`Error getting user: ${error}`);
return null;
}
}
async getUserSettings(telegramUserId: number): Promise<ChatBotSettings | null> {
if (!this.db) return null;
try {
const result = await this.db
.select()
.from(chatBotSettings)
.where(eq(chatBotSettings.telegramUserId, telegramUserId))
.limit(1);
return result[0] || null;
} catch (error) {
this.logger.error(`Error getting user settings: ${error}`);
return null;
}
}
async linkUser(data: {
telegramUserId: number;
telegramUsername?: string;
telegramFirstName?: string;
manaUserId: string;
accessToken: string;
refreshToken?: string;
tokenExpiresAt?: Date;
}): Promise<TelegramUser | null> {
if (!this.db) return null;
try {
const existing = await this.getUserByTelegramId(data.telegramUserId);
if (existing) {
const result = await this.db
.update(telegramUsers)
.set({
telegramUsername: data.telegramUsername,
telegramFirstName: data.telegramFirstName,
manaUserId: data.manaUserId,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
tokenExpiresAt: data.tokenExpiresAt,
isActive: true,
updatedAt: new Date(),
})
.where(eq(telegramUsers.telegramUserId, data.telegramUserId))
.returning();
return result[0] || null;
} else {
const newUser: NewTelegramUser = {
telegramUserId: data.telegramUserId,
telegramUsername: data.telegramUsername,
telegramFirstName: data.telegramFirstName,
manaUserId: data.manaUserId,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
tokenExpiresAt: data.tokenExpiresAt,
};
const result = await this.db.insert(telegramUsers).values(newUser).returning();
// Create default settings
if (result[0]) {
await this.db.insert(chatBotSettings).values({
telegramUserId: data.telegramUserId,
});
}
return result[0] || null;
}
} catch (error) {
this.logger.error(`Error linking user: ${error}`);
return null;
}
}
async unlinkUser(telegramUserId: number): Promise<boolean> {
if (!this.db) return false;
try {
await this.db
.update(telegramUsers)
.set({
isActive: false,
accessToken: null,
refreshToken: null,
updatedAt: new Date(),
})
.where(eq(telegramUsers.telegramUserId, telegramUserId));
return true;
} catch (error) {
this.logger.error(`Error unlinking user: ${error}`);
return false;
}
}
async updateLastActive(telegramUserId: number): Promise<void> {
if (!this.db) return;
try {
await this.db
.update(telegramUsers)
.set({ lastActiveAt: new Date() })
.where(eq(telegramUsers.telegramUserId, telegramUserId));
} catch (error) {
this.logger.error(`Error updating last active: ${error}`);
}
}
async updateUserSettings(
telegramUserId: number,
settings: Partial<{
currentModel: string | null;
currentConversationId: string | null;
}>
): Promise<ChatBotSettings | null> {
if (!this.db) return null;
try {
// Check if settings exist
const existing = await this.getUserSettings(telegramUserId);
if (existing) {
const result = await this.db
.update(chatBotSettings)
.set({
...settings,
updatedAt: new Date(),
})
.where(eq(chatBotSettings.telegramUserId, telegramUserId))
.returning();
return result[0] || null;
} else {
// Create settings
const result = await this.db
.insert(chatBotSettings)
.values({
telegramUserId,
currentModel: settings.currentModel,
currentConversationId: settings.currentConversationId,
})
.returning();
return result[0] || null;
}
} catch (error) {
this.logger.error(`Error updating user settings: ${error}`);
return null;
}
}
}

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
}
}