mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:41:09 +02:00
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:
parent
be748d2f9c
commit
faadc413cc
23 changed files with 5398 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
services/telegram-chat-bot/.env.example
Normal file
18
services/telegram-chat-bot/.env.example
Normal 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
|
||||
166
services/telegram-chat-bot/CLAUDE.md
Normal file
166
services/telegram-chat-bot/CLAUDE.md
Normal 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"
|
||||
```
|
||||
35
services/telegram-chat-bot/Dockerfile
Normal file
35
services/telegram-chat-bot/Dockerfile
Normal 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"]
|
||||
10
services/telegram-chat-bot/drizzle.config.ts
Normal file
10
services/telegram-chat-bot/drizzle.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
8
services/telegram-chat-bot/nest-cli.json
Normal file
8
services/telegram-chat-bot/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
44
services/telegram-chat-bot/package.json
Normal file
44
services/telegram-chat-bot/package.json
Normal 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
3807
services/telegram-chat-bot/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
112
services/telegram-chat-bot/setup-mac-mini.sh
Executable file
112
services/telegram-chat-bot/setup-mac-mini.sh
Executable 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"
|
||||
29
services/telegram-chat-bot/src/app.module.ts
Normal file
29
services/telegram-chat-bot/src/app.module.ts
Normal 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 {}
|
||||
10
services/telegram-chat-bot/src/bot/bot.module.ts
Normal file
10
services/telegram-chat-bot/src/bot/bot.module.ts
Normal 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 {}
|
||||
337
services/telegram-chat-bot/src/bot/bot.update.ts
Normal file
337
services/telegram-chat-bot/src/bot/bot.update.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
210
services/telegram-chat-bot/src/bot/formatters.ts
Normal file
210
services/telegram-chat-bot/src/bot/formatters.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.`;
|
||||
}
|
||||
226
services/telegram-chat-bot/src/chat/chat.client.ts
Normal file
226
services/telegram-chat-bot/src/chat/chat.client.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
8
services/telegram-chat-bot/src/chat/chat.module.ts
Normal file
8
services/telegram-chat-bot/src/chat/chat.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ChatClient } from './chat.client';
|
||||
|
||||
@Module({
|
||||
providers: [ChatClient],
|
||||
exports: [ChatClient],
|
||||
})
|
||||
export class ChatModule {}
|
||||
47
services/telegram-chat-bot/src/config/configuration.ts
Normal file
47
services/telegram-chat-bot/src/config/configuration.ts
Normal 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',
|
||||
};
|
||||
39
services/telegram-chat-bot/src/database/database.module.ts
Normal file
39
services/telegram-chat-bot/src/database/database.module.ts
Normal 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 {}
|
||||
41
services/telegram-chat-bot/src/database/schema.ts
Normal file
41
services/telegram-chat-bot/src/database/schema.ts
Normal 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;
|
||||
17
services/telegram-chat-bot/src/health.controller.ts
Normal file
17
services/telegram-chat-bot/src/health.controller.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
16
services/telegram-chat-bot/src/main.ts
Normal file
16
services/telegram-chat-bot/src/main.ts
Normal 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();
|
||||
8
services/telegram-chat-bot/src/user/user.module.ts
Normal file
8
services/telegram-chat-bot/src/user/user.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@Module({
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
187
services/telegram-chat-bot/src/user/user.service.ts
Normal file
187
services/telegram-chat-bot/src/user/user.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
services/telegram-chat-bot/tsconfig.json
Normal file
22
services/telegram-chat-bot/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue