mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
✨ feat(services): add telegram-project-doc-bot service
Add new NestJS-based Telegram bot for project documentation with: - Drizzle ORM for database access - OpenAI integration for AI features - S3 storage support via AWS SDK - Monorepo integration (dev scripts, database setup, MinIO bucket) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bff80b552a
commit
7c20d88649
28 changed files with 2365 additions and 180 deletions
|
|
@ -86,6 +86,7 @@ services:
|
|||
mc mb --ignore-existing myminio/storage-storage;
|
||||
mc mb --ignore-existing myminio/inventory-storage;
|
||||
mc mb --ignore-existing myminio/planta-storage;
|
||||
mc mb --ignore-existing myminio/projectdoc-storage;
|
||||
mc anonymous set download myminio/picture-storage;
|
||||
mc anonymous set download myminio/planta-storage;
|
||||
mc anonymous set download myminio/inventory-storage;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,10 @@
|
|||
"cf:login": "npx wrangler login",
|
||||
"cf:projects:list": "npx wrangler pages project list",
|
||||
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",
|
||||
"dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev",
|
||||
"dev:projectdoc:full": "./scripts/setup-databases.sh projectdoc && pnpm dev:projectdoc",
|
||||
"projectdoc:db:push": "pnpm --filter @manacore/telegram-project-doc-bot db:push",
|
||||
"projectdoc:db:studio": "pnpm --filter @manacore/telegram-project-doc-bot db:studio",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
803
pnpm-lock.yaml
generated
803
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -74,6 +74,7 @@ ALL_DATABASES=(
|
|||
"figgos"
|
||||
"planta"
|
||||
"nutriphi"
|
||||
"projectdoc"
|
||||
)
|
||||
|
||||
# Check if specific service requested
|
||||
|
|
@ -155,6 +156,10 @@ setup_service() {
|
|||
create_db_if_not_exists "storage"
|
||||
push_schema "@storage/backend" "storage"
|
||||
;;
|
||||
projectdoc)
|
||||
create_db_if_not_exists "projectdoc"
|
||||
push_schema "@manacore/telegram-project-doc-bot" "projectdoc"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown service: $service${NC}"
|
||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage"
|
||||
|
|
|
|||
24
services/telegram-project-doc-bot/.env.example
Normal file
24
services/telegram-project-doc-bot/.env.example
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Server
|
||||
PORT=3302
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token-from-botfather
|
||||
TELEGRAM_ALLOWED_USERS= # Optional: comma-separated user IDs
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
|
||||
|
||||
# Storage (MinIO)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=projectdoc-storage
|
||||
|
||||
# AI - Transcription (OpenAI Whisper)
|
||||
OPENAI_API_KEY=sk-your-openai-key
|
||||
|
||||
# AI - Generation
|
||||
LLM_PROVIDER=ollama # ollama | openai
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:4b
|
||||
245
services/telegram-project-doc-bot/CLAUDE.md
Normal file
245
services/telegram-project-doc-bot/CLAUDE.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# Telegram Project Doc Bot
|
||||
|
||||
Telegram Bot zum Sammeln von Projektdokumentation (Fotos, Sprachnotizen, Text) und automatischer Blogbeitrag-Generierung.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: NestJS 10
|
||||
- **Telegram**: nestjs-telegraf + Telegraf
|
||||
- **Database**: PostgreSQL + Drizzle ORM
|
||||
- **Storage**: S3 (MinIO lokal, Hetzner in Produktion)
|
||||
- **AI - Transcription**: OpenAI Whisper
|
||||
- **AI - Generation**: Ollama (lokal) oder OpenAI GPT
|
||||
|
||||
## 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 anzeigen |
|
||||
| `/help` | Hilfe anzeigen |
|
||||
| `/new [Name]` | Neues Projekt erstellen |
|
||||
| `/projects` | Alle Projekte auflisten |
|
||||
| `/switch [ID]` | Projekt wechseln |
|
||||
| `/status` | Status des aktiven Projekts |
|
||||
| `/archive` | Projekt archivieren |
|
||||
| `/generate` | Blogbeitrag generieren |
|
||||
| `/generate [Stil]` | Mit bestimmtem Stil generieren |
|
||||
| `/styles` | Verfügbare Stile anzeigen |
|
||||
| `/export` | Letzte Generierung als Datei |
|
||||
|
||||
## Blog-Stile
|
||||
|
||||
| Stil | Beschreibung |
|
||||
|------|--------------|
|
||||
| `casual` | Locker & persönlich |
|
||||
| `formal` | Professionell & sachlich |
|
||||
| `tutorial` | Anleitung mit Schritten |
|
||||
| `diary` | Tagebuch-Stil |
|
||||
|
||||
## User Flow
|
||||
|
||||
```
|
||||
1. /new Gartenhaus-Renovierung → Projekt erstellen
|
||||
2. 📷 Foto senden → Wird gespeichert
|
||||
3. 🎤 Sprachnotiz senden → Transkribiert + gespeichert
|
||||
4. "Heute das Fundament gegossen" → Text-Notiz
|
||||
5. /status → Übersicht
|
||||
6. /generate tutorial → Blogbeitrag erstellen
|
||||
7. /export → Als .md Datei
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```env
|
||||
# Server
|
||||
PORT=3302
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=xxx # Bot Token von @BotFather
|
||||
TELEGRAM_ALLOWED_USERS=123,456 # Optional: Nur diese User IDs erlauben
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/projectdoc
|
||||
|
||||
# Storage (MinIO)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY=minioadmin
|
||||
S3_SECRET_KEY=minioadmin
|
||||
S3_BUCKET=projectdoc-storage
|
||||
|
||||
# AI - Transcription (optional, aber empfohlen)
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
|
||||
# AI - Generation
|
||||
LLM_PROVIDER=ollama # ollama oder openai
|
||||
OLLAMA_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=gemma3:4b
|
||||
```
|
||||
|
||||
## Projekt-Struktur
|
||||
|
||||
```
|
||||
services/telegram-project-doc-bot/
|
||||
├── src/
|
||||
│ ├── main.ts # Entry point
|
||||
│ ├── app.module.ts # Root module
|
||||
│ ├── health.controller.ts # Health endpoint
|
||||
│ ├── config/
|
||||
│ │ └── configuration.ts # Config + Blog-Stile
|
||||
│ ├── database/
|
||||
│ │ ├── database.module.ts # Drizzle connection
|
||||
│ │ └── schema.ts # DB schema
|
||||
│ ├── bot/
|
||||
│ │ ├── bot.module.ts
|
||||
│ │ └── bot.update.ts # Command handlers
|
||||
│ ├── project/
|
||||
│ │ ├── project.module.ts
|
||||
│ │ └── project.service.ts # Projekt CRUD
|
||||
│ ├── media/
|
||||
│ │ ├── media.module.ts
|
||||
│ │ ├── media.service.ts # Foto/Voice/Text verarbeiten
|
||||
│ │ └── storage.service.ts # S3 Upload/Download
|
||||
│ ├── transcription/
|
||||
│ │ ├── transcription.module.ts
|
||||
│ │ └── transcription.service.ts # Whisper API
|
||||
│ └── generation/
|
||||
│ ├── generation.module.ts
|
||||
│ └── generation.service.ts # Blogpost AI
|
||||
├── drizzle/ # Migrations
|
||||
├── drizzle.config.ts
|
||||
├── package.json
|
||||
└── Dockerfile
|
||||
```
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### 1. Bot bei Telegram erstellen
|
||||
|
||||
1. Öffne @BotFather in Telegram
|
||||
2. Sende `/newbot`
|
||||
3. Wähle einen Namen (z.B. "Project Doc Bot")
|
||||
4. Wähle einen Username (z.B. "my_projectdoc_bot")
|
||||
5. Kopiere den Token
|
||||
|
||||
### 2. Umgebung vorbereiten
|
||||
|
||||
```bash
|
||||
# Docker Services starten (PostgreSQL, MinIO, Ollama)
|
||||
pnpm docker:up
|
||||
|
||||
# Datenbank erstellen
|
||||
psql -h localhost -U postgres -c "CREATE DATABASE projectdoc;"
|
||||
|
||||
# Schema pushen
|
||||
cd services/telegram-project-doc-bot
|
||||
cp .env.example .env
|
||||
# Token und Keys eintragen
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
### 3. Bot starten
|
||||
|
||||
```bash
|
||||
pnpm start:dev
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Projekt**: Mehrere Projekte pro User
|
||||
- **Foto-Speicherung**: Fotos in S3 mit Metadaten
|
||||
- **Voice-Transkription**: Automatisch via Whisper
|
||||
- **Text-Notizen**: Einfache Nachrichten werden gespeichert
|
||||
- **Chronologisch**: Alle Einträge behalten ihre Reihenfolge
|
||||
- **Mehrere Stile**: casual, formal, tutorial, diary
|
||||
- **Export**: Markdown-Datei zum Download
|
||||
|
||||
## Datenbank-Schema
|
||||
|
||||
```
|
||||
projects
|
||||
├── id (UUID)
|
||||
├── telegram_user_id (INT)
|
||||
├── name (TEXT)
|
||||
├── description (TEXT)
|
||||
├── status (TEXT: active, archived, completed)
|
||||
├── created_at, updated_at
|
||||
|
||||
media_items
|
||||
├── id (UUID)
|
||||
├── project_id (UUID FK)
|
||||
├── type (TEXT: photo, voice, text)
|
||||
├── storage_key (TEXT)
|
||||
├── caption (TEXT)
|
||||
├── transcription (TEXT)
|
||||
├── ai_description (TEXT)
|
||||
├── metadata (JSONB)
|
||||
├── telegram_file_id (TEXT)
|
||||
├── order_index (INT)
|
||||
├── created_at
|
||||
|
||||
generations
|
||||
├── id (UUID)
|
||||
├── project_id (UUID FK)
|
||||
├── style (TEXT)
|
||||
├── content (TEXT - Markdown)
|
||||
├── pdf_key (TEXT)
|
||||
├── is_latest (BOOL)
|
||||
├── created_at
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:3302/health
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker (empfohlen)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
telegram-project-doc-bot:
|
||||
build: ./services/telegram-project-doc-bot
|
||||
restart: always
|
||||
environment:
|
||||
PORT: 3302
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_BUCKET: projectdoc-storage
|
||||
LLM_PROVIDER: ollama
|
||||
OLLAMA_URL: http://ollama:11434
|
||||
ports:
|
||||
- "3302:3302"
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Foto-Vision-Analyse (was ist auf dem Bild?)
|
||||
- [ ] PDF-Export
|
||||
- [ ] Bilder im Blogpost einbetten
|
||||
- [ ] Projekt-Templates
|
||||
- [ ] Web-Dashboard zur Ansicht
|
||||
- [ ] Telegram Mini App für bessere UX
|
||||
41
services/telegram-project-doc-bot/Dockerfile
Normal file
41
services/telegram-project-doc-bot/Dockerfile
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN pnpm build
|
||||
|
||||
# Production image
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
# Copy package files and install prod dependencies only
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3302
|
||||
|
||||
EXPOSE 3302
|
||||
|
||||
CMD ["node", "dist/main.js"]
|
||||
10
services/telegram-project-doc-bot/drizzle.config.ts
Normal file
10
services/telegram-project-doc-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://postgres:postgres@localhost:5432/projectdoc',
|
||||
},
|
||||
});
|
||||
8
services/telegram-project-doc-bot/nest-cli.json
Normal file
8
services/telegram-project-doc-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-project-doc-bot/package.json
Normal file
44
services/telegram-project-doc-bot/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@manacore/telegram-project-doc-bot",
|
||||
"version": "1.0.0",
|
||||
"description": "Telegram bot for project documentation - collect photos and voice notes, generate blog posts",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"prebuild": "rimraf dist",
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"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",
|
||||
"@aws-sdk/client-s3": "^3.721.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.721.0",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"nestjs-telegraf": "^2.8.0",
|
||||
"openai": "^4.77.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"
|
||||
}
|
||||
}
|
||||
27
services/telegram-project-doc-bot/src/app.module.ts
Normal file
27
services/telegram-project-doc-bot/src/app.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TelegrafModule } from 'nestjs-telegraf';
|
||||
import configuration from './config/configuration';
|
||||
import { 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],
|
||||
}),
|
||||
DatabaseModule,
|
||||
BotModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
11
services/telegram-project-doc-bot/src/bot/bot.module.ts
Normal file
11
services/telegram-project-doc-bot/src/bot/bot.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { BotUpdate } from './bot.update';
|
||||
import { ProjectModule } from '../project/project.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { GenerationModule } from '../generation/generation.module';
|
||||
|
||||
@Module({
|
||||
imports: [ProjectModule, MediaModule, GenerationModule],
|
||||
providers: [BotUpdate],
|
||||
})
|
||||
export class BotModule {}
|
||||
490
services/telegram-project-doc-bot/src/bot/bot.update.ts
Normal file
490
services/telegram-project-doc-bot/src/bot/bot.update.ts
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
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 { ProjectService } from '../project/project.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
import { GenerationService } from '../generation/generation.service';
|
||||
import { BLOG_STYLES } from '../config/configuration';
|
||||
|
||||
interface PhotoSize {
|
||||
file_id: string;
|
||||
file_unique_id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
interface Voice {
|
||||
file_id: string;
|
||||
file_unique_id: string;
|
||||
duration: number;
|
||||
mime_type?: string;
|
||||
file_size?: number;
|
||||
}
|
||||
|
||||
@Update()
|
||||
export class BotUpdate {
|
||||
private readonly logger = new Logger(BotUpdate.name);
|
||||
private readonly allowedUsers: number[];
|
||||
|
||||
// Active project per user (userId -> projectId)
|
||||
private activeProjects: Map<number, string> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly mediaService: MediaService,
|
||||
private readonly generationService: GenerationService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
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 formatHelp(): string {
|
||||
const styles = Object.entries(BLOG_STYLES)
|
||||
.map(([key, value]) => `• <code>${key}</code> - ${value.name}`)
|
||||
.join('\n');
|
||||
|
||||
return `<b>📸 Project Doc Bot</b>
|
||||
|
||||
Sammle Fotos, Sprachnotizen und Text für deine Projekte und erstelle daraus Blogbeiträge.
|
||||
|
||||
<b>Projekt-Commands:</b>
|
||||
/new [Name] - Neues Projekt starten
|
||||
/projects - Alle Projekte anzeigen
|
||||
/switch [ID] - Projekt wechseln
|
||||
/status - Status des aktiven Projekts
|
||||
/archive - Aktives Projekt archivieren
|
||||
|
||||
<b>Content:</b>
|
||||
📷 Foto senden - Wird gespeichert
|
||||
🎤 Sprachnotiz - Wird transkribiert
|
||||
💬 Text-Nachricht - Als Notiz gespeichert
|
||||
|
||||
<b>Generierung:</b>
|
||||
/generate - Blogbeitrag erstellen
|
||||
/generate [Stil] - Mit bestimmtem Stil
|
||||
/styles - Verfügbare Stile anzeigen
|
||||
/export - Letzte Generierung exportieren
|
||||
|
||||
<b>Verfügbare Stile:</b>
|
||||
${styles}
|
||||
|
||||
<b>Tipp:</b> Starte mit /new Projektname`;
|
||||
}
|
||||
|
||||
@Start()
|
||||
async start(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`/start from user ${userId}`);
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Help()
|
||||
async help(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.replyWithHTML(this.formatHelp());
|
||||
}
|
||||
|
||||
@Command('new')
|
||||
async newProject(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = text.replace('/new', '').trim();
|
||||
if (!name) {
|
||||
await ctx.reply('Verwendung: /new Projektname\n\nBeispiel: /new Gartenhaus-Renovierung');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Creating project "${name}" for user ${userId}`);
|
||||
|
||||
const project = await this.projectService.create({
|
||||
telegramUserId: userId,
|
||||
name,
|
||||
});
|
||||
|
||||
this.activeProjects.set(userId, project.id);
|
||||
this.logger.log(`User ${userId} created project "${name}" with id ${project.id}`);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`✅ <b>Projekt erstellt!</b>\n\n` +
|
||||
`<b>Name:</b> ${project.name}\n` +
|
||||
`<b>ID:</b> <code>${project.id.slice(0, 8)}</code>\n\n` +
|
||||
`Sende jetzt:\n` +
|
||||
`📷 Fotos\n` +
|
||||
`🎤 Sprachnotizen\n` +
|
||||
`💬 Text-Nachrichten\n\n` +
|
||||
`Mit /generate erstellst du den Blogbeitrag.`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to create project:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`Fehler beim Erstellen des Projekts: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Command('projects')
|
||||
async listProjects(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = await this.projectService.findByUser(userId);
|
||||
|
||||
if (projects.length === 0) {
|
||||
await ctx.reply('Keine Projekte gefunden.\n\nStarte mit: /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = this.activeProjects.get(userId);
|
||||
|
||||
const projectList = await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
const stats = await this.projectService.getStats(p.id);
|
||||
const active = p.id === activeId ? ' ✓' : '';
|
||||
const status = p.status === 'archived' ? ' 📦' : '';
|
||||
return `• <b>${p.name}</b>${active}${status}\n ID: <code>${p.id.slice(0, 8)}</code> | ${stats.total} Einträge`;
|
||||
})
|
||||
);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>📂 Deine Projekte:</b>\n\n${projectList.join('\n\n')}\n\n` + `Wechseln mit: /switch [ID]`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('switch')
|
||||
async switchProject(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const idPrefix = text.replace('/switch', '').trim();
|
||||
if (!idPrefix) {
|
||||
await ctx.reply('Verwendung: /switch [ID]\n\nZeige Projekte mit /projects');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find project by ID prefix
|
||||
const projects = await this.projectService.findByUser(userId);
|
||||
const project = projects.find((p) => p.id.startsWith(idPrefix));
|
||||
|
||||
if (!project) {
|
||||
await ctx.reply(`Projekt mit ID "${idPrefix}" nicht gefunden.`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeProjects.set(userId, project.id);
|
||||
const stats = await this.projectService.getStats(project.id);
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`✅ Gewechselt zu: <b>${project.name}</b>\n\n` +
|
||||
`📷 ${stats.photos} Fotos\n` +
|
||||
`🎤 ${stats.voices} Sprachnotizen\n` +
|
||||
`📝 ${stats.texts} Textnotizen`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('status')
|
||||
async status(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await this.projectService.findById(projectId);
|
||||
if (!project) {
|
||||
this.activeProjects.delete(userId);
|
||||
await ctx.reply('Projekt nicht gefunden. Starte ein neues mit /new');
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = await this.projectService.getStats(projectId);
|
||||
const latest = await this.generationService.getLatestGeneration(projectId);
|
||||
|
||||
let statusText =
|
||||
`<b>📊 Projekt-Status</b>\n\n` +
|
||||
`<b>Name:</b> ${project.name}\n` +
|
||||
`<b>Status:</b> ${project.status}\n` +
|
||||
`<b>Erstellt:</b> ${project.createdAt.toLocaleDateString('de-DE')}\n\n` +
|
||||
`<b>Inhalte:</b>\n` +
|
||||
`📷 ${stats.photos} Fotos\n` +
|
||||
`🎤 ${stats.voices} Sprachnotizen\n` +
|
||||
`📝 ${stats.texts} Textnotizen\n` +
|
||||
`<b>Gesamt:</b> ${stats.total} Einträge`;
|
||||
|
||||
if (latest) {
|
||||
statusText += `\n\n<b>Letzte Generierung:</b>\n${latest.createdAt.toLocaleString('de-DE')} (${latest.style})`;
|
||||
}
|
||||
|
||||
await ctx.replyWithHTML(statusText);
|
||||
}
|
||||
|
||||
@Command('archive')
|
||||
async archiveProject(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.projectService.update(projectId, { status: 'archived' });
|
||||
this.activeProjects.delete(userId);
|
||||
|
||||
await ctx.reply('📦 Projekt archiviert.\n\nStarte ein neues mit /new');
|
||||
}
|
||||
|
||||
@Command('styles')
|
||||
async showStyles(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = Object.entries(BLOG_STYLES)
|
||||
.map(
|
||||
([key, value]) => `<b>${key}</b> - ${value.name}\n<i>${value.prompt.slice(0, 80)}...</i>`
|
||||
)
|
||||
.join('\n\n');
|
||||
|
||||
await ctx.replyWithHTML(
|
||||
`<b>📝 Verfügbare Blog-Stile:</b>\n\n${styles}\n\nVerwendung: /generate [stil]`
|
||||
);
|
||||
}
|
||||
|
||||
@Command('generate')
|
||||
async generate(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
const style = text.replace('/generate', '').trim().toLowerCase() || 'casual';
|
||||
const validStyles = Object.keys(BLOG_STYLES);
|
||||
|
||||
if (!validStyles.includes(style)) {
|
||||
await ctx.reply(
|
||||
`Unbekannter Stil: "${style}"\n\nVerfügbar: ${validStyles.join(', ')}\n\nZeige Details mit /styles`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.reply('🚀 Generiere Blogbeitrag...\n\nDas kann einen Moment dauern.');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
const content = await this.generationService.generateBlogpost(
|
||||
projectId,
|
||||
style as keyof typeof BLOG_STYLES
|
||||
);
|
||||
|
||||
// Split if too long for Telegram
|
||||
if (content.length <= 4000) {
|
||||
await ctx.reply(content);
|
||||
} else {
|
||||
// Send as document
|
||||
const buffer = Buffer.from(content, 'utf-8');
|
||||
await ctx.replyWithDocument(
|
||||
{
|
||||
source: buffer,
|
||||
filename: 'blogpost.md',
|
||||
},
|
||||
{
|
||||
caption: '📄 Blogbeitrag (zu lang für Telegram-Nachricht)',
|
||||
}
|
||||
);
|
||||
|
||||
// Also send a preview
|
||||
const preview = content.slice(0, 1000) + '\n\n[...gekürzt, siehe Datei]';
|
||||
await ctx.reply(preview);
|
||||
}
|
||||
|
||||
await ctx.reply('✅ Blogbeitrag erstellt!\n\nExportieren mit /export');
|
||||
} catch (error) {
|
||||
this.logger.error('Generation failed:', error);
|
||||
const message = error instanceof Error ? error.message : 'Unbekannter Fehler';
|
||||
await ctx.reply(`❌ Fehler: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Command('export')
|
||||
async exportGeneration(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.');
|
||||
return;
|
||||
}
|
||||
|
||||
const latest = await this.generationService.getLatestGeneration(projectId);
|
||||
if (!latest) {
|
||||
await ctx.reply('Noch kein Blogbeitrag generiert.\n\nErstelle einen mit /generate');
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await this.projectService.findById(projectId);
|
||||
const filename = `${project?.name.replace(/[^a-zA-Z0-9]/g, '_') || 'blogpost'}.md`;
|
||||
|
||||
const buffer = Buffer.from(latest.content, 'utf-8');
|
||||
await ctx.replyWithDocument(
|
||||
{
|
||||
source: buffer,
|
||||
filename,
|
||||
},
|
||||
{
|
||||
caption: `📄 ${filename}\nGeneriert: ${latest.createdAt.toLocaleString('de-DE')}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@On('photo')
|
||||
async onPhoto(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = ctx.message as { photo?: PhotoSize[]; caption?: string };
|
||||
const photos = message.photo;
|
||||
if (!photos || photos.length === 0) return;
|
||||
|
||||
// Get largest photo
|
||||
const photo = photos[photos.length - 1];
|
||||
const caption = message.caption;
|
||||
|
||||
await ctx.sendChatAction('upload_photo');
|
||||
|
||||
try {
|
||||
await this.mediaService.processPhoto(projectId, photo.file_id, caption);
|
||||
|
||||
const stats = await this.projectService.getStats(projectId);
|
||||
await ctx.reply(`📷 Foto gespeichert! (${stats.photos} Fotos gesamt)`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process photo:', error);
|
||||
await ctx.reply('❌ Fehler beim Speichern des Fotos.');
|
||||
}
|
||||
}
|
||||
|
||||
@On('voice')
|
||||
async onVoice(@Ctx() ctx: Context) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
await ctx.reply('Kein aktives Projekt.\n\nStarte mit: /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = ctx.message as { voice?: Voice };
|
||||
const voice = message.voice;
|
||||
if (!voice) return;
|
||||
|
||||
await ctx.reply('🎤 Verarbeite Sprachnotiz...');
|
||||
await ctx.sendChatAction('typing');
|
||||
|
||||
try {
|
||||
const item = await this.mediaService.processVoice(projectId, voice.file_id, voice.duration);
|
||||
|
||||
const stats = await this.projectService.getStats(projectId);
|
||||
let reply = `✅ Sprachnotiz gespeichert! (${stats.voices} gesamt)`;
|
||||
|
||||
if (item.transcription) {
|
||||
reply += `\n\n📝 Transkription:\n"${item.transcription}"`;
|
||||
}
|
||||
|
||||
await ctx.reply(reply);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to process voice:', error);
|
||||
await ctx.reply('❌ Fehler beim Verarbeiten der Sprachnotiz.');
|
||||
}
|
||||
}
|
||||
|
||||
@On('text')
|
||||
async onText(@Ctx() ctx: Context, @Message('text') text: string) {
|
||||
const userId = ctx.from?.id;
|
||||
if (!userId || !this.isAllowed(userId)) {
|
||||
await ctx.reply('Zugriff verweigert.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore commands
|
||||
if (text.startsWith('/')) return;
|
||||
|
||||
const projectId = this.activeProjects.get(userId);
|
||||
if (!projectId) {
|
||||
// No active project - show hint
|
||||
await ctx.reply('💡 Tipp: Starte ein Projekt mit /new Projektname');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.mediaService.addTextNote(projectId, text);
|
||||
|
||||
const stats = await this.projectService.getStats(projectId);
|
||||
await ctx.reply(`📝 Notiz gespeichert! (${stats.texts} Notizen gesamt)`);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to add text note:', error);
|
||||
await ctx.reply('❌ Fehler beim Speichern der Notiz.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3302', 10),
|
||||
telegram: {
|
||||
token: process.env.TELEGRAM_BOT_TOKEN,
|
||||
allowedUsers:
|
||||
process.env.TELEGRAM_ALLOWED_USERS?.split(',')
|
||||
.map((id) => parseInt(id.trim(), 10))
|
||||
.filter((id) => !isNaN(id)) || [],
|
||||
},
|
||||
database: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/projectdoc',
|
||||
},
|
||||
s3: {
|
||||
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
accessKey: process.env.S3_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.S3_SECRET_KEY || 'minioadmin',
|
||||
bucket: process.env.S3_BUCKET || 'projectdoc-storage',
|
||||
},
|
||||
openai: {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
llm: {
|
||||
provider: process.env.LLM_PROVIDER || 'ollama',
|
||||
ollama: {
|
||||
url: process.env.OLLAMA_URL || 'http://localhost:11434',
|
||||
model: process.env.OLLAMA_MODEL || 'gemma3:4b',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const BLOG_STYLES = {
|
||||
casual: {
|
||||
name: 'Locker & Persönlich',
|
||||
prompt:
|
||||
'Schreibe einen lockeren, persönlichen Blogbeitrag. Verwende "ich" und erzähle die Geschichte authentisch.',
|
||||
},
|
||||
formal: {
|
||||
name: 'Professionell',
|
||||
prompt:
|
||||
'Schreibe einen professionellen, sachlichen Blogbeitrag. Verwende eine neutrale Sprache.',
|
||||
},
|
||||
tutorial: {
|
||||
name: 'Anleitung/Tutorial',
|
||||
prompt:
|
||||
'Schreibe einen anleitenden Blogbeitrag mit klaren Schritten. Nummeriere die Schritte und gib praktische Tipps.',
|
||||
},
|
||||
diary: {
|
||||
name: 'Tagebuch',
|
||||
prompt:
|
||||
'Schreibe einen Tagebuch-Eintrag mit persönlichen Eindrücken und Gefühlen. Sehr authentisch und emotional.',
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService) => {
|
||||
const connectionString = configService.get<string>('database.url');
|
||||
const client = postgres(connectionString!);
|
||||
return drizzle(client, { schema });
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
85
services/telegram-project-doc-bot/src/database/schema.ts
Normal file
85
services/telegram-project-doc-bot/src/database/schema.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { pgTable, uuid, text, timestamp, integer, jsonb, boolean } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
// Projects table
|
||||
export const projects = pgTable('projects', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
telegramUserId: integer('telegram_user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
status: text('status').default('active').notNull(), // active, archived, completed
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Media items (photos, voice notes, text)
|
||||
export const mediaItems = pgTable('media_items', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id')
|
||||
.references(() => projects.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
type: text('type').notNull(), // photo, voice, text
|
||||
|
||||
// Storage
|
||||
storageKey: text('storage_key'), // S3 key for photo/voice
|
||||
thumbnailKey: text('thumbnail_key'), // Thumbnail for photos
|
||||
|
||||
// Content
|
||||
caption: text('caption'), // Original caption/text
|
||||
transcription: text('transcription'), // Voice → Text
|
||||
aiDescription: text('ai_description'), // Vision → Description
|
||||
|
||||
// Metadata
|
||||
metadata: jsonb('metadata').$type<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
mimeType?: string;
|
||||
fileSize?: number;
|
||||
}>(),
|
||||
telegramFileId: text('telegram_file_id'),
|
||||
orderIndex: integer('order_index').default(0).notNull(),
|
||||
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Generated blog posts
|
||||
export const generations = pgTable('generations', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id')
|
||||
.references(() => projects.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
style: text('style').default('casual').notNull(),
|
||||
content: text('content').notNull(), // Generated markdown
|
||||
pdfKey: text('pdf_key'), // S3 key for PDF export
|
||||
isLatest: boolean('is_latest').default(true).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const projectsRelations = relations(projects, ({ many }) => ({
|
||||
mediaItems: many(mediaItems),
|
||||
generations: many(generations),
|
||||
}));
|
||||
|
||||
export const mediaItemsRelations = relations(mediaItems, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [mediaItems.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const generationsRelations = relations(generations, ({ one }) => ({
|
||||
project: one(projects, {
|
||||
fields: [generations.projectId],
|
||||
references: [projects.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Types
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
export type MediaItem = typeof mediaItems.$inferSelect;
|
||||
export type NewMediaItem = typeof mediaItems.$inferInsert;
|
||||
export type Generation = typeof generations.$inferSelect;
|
||||
export type NewGeneration = typeof generations.$inferInsert;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GenerationService } from './generation.service';
|
||||
|
||||
@Module({
|
||||
providers: [GenerationService],
|
||||
exports: [GenerationService],
|
||||
})
|
||||
export class GenerationModule {}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import OpenAI from 'openai';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { Generation, Project, MediaItem } from '../database/schema';
|
||||
import { BLOG_STYLES } from '../config/configuration';
|
||||
|
||||
type BlogStyle = keyof typeof BLOG_STYLES;
|
||||
|
||||
@Injectable()
|
||||
export class GenerationService {
|
||||
private readonly logger = new Logger(GenerationService.name);
|
||||
private readonly llmProvider: string;
|
||||
private readonly ollamaUrl: string;
|
||||
private readonly ollamaModel: string;
|
||||
private readonly openai: OpenAI | null;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
this.llmProvider = this.configService.get<string>('llm.provider') || 'ollama';
|
||||
this.ollamaUrl = this.configService.get<string>('llm.ollama.url') || 'http://localhost:11434';
|
||||
this.ollamaModel = this.configService.get<string>('llm.ollama.model') || 'gemma3:4b';
|
||||
|
||||
const apiKey = this.configService.get<string>('openai.apiKey');
|
||||
this.openai = apiKey ? new OpenAI({ apiKey }) : null;
|
||||
|
||||
this.logger.log(`LLM Provider: ${this.llmProvider}`);
|
||||
}
|
||||
|
||||
async generateBlogpost(projectId: string, style: BlogStyle = 'casual'): Promise<string> {
|
||||
// 1. Load project
|
||||
const project = await this.db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId),
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Projekt nicht gefunden');
|
||||
}
|
||||
|
||||
// 2. Load all media items
|
||||
const items = await this.db.query.mediaItems.findMany({
|
||||
where: eq(schema.mediaItems.projectId, projectId),
|
||||
orderBy: [schema.mediaItems.orderIndex, schema.mediaItems.createdAt],
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error(
|
||||
'Keine Inhalte im Projekt. Füge zuerst Fotos, Sprachnotizen oder Text hinzu.'
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Build context from media items
|
||||
const context = this.buildContext(items);
|
||||
|
||||
// 4. Build prompt
|
||||
const styleConfig = BLOG_STYLES[style] || BLOG_STYLES.casual;
|
||||
const prompt = this.buildPrompt(project, context, styleConfig.prompt);
|
||||
|
||||
// 5. Generate with LLM
|
||||
this.logger.log(`Generating blogpost for "${project.name}" with style "${style}"`);
|
||||
const content = await this.callLlm(prompt);
|
||||
|
||||
// 6. Mark previous generations as not latest
|
||||
await this.db
|
||||
.update(schema.generations)
|
||||
.set({ isLatest: false })
|
||||
.where(eq(schema.generations.projectId, projectId));
|
||||
|
||||
// 7. Save generation
|
||||
const [generation] = await this.db
|
||||
.insert(schema.generations)
|
||||
.values({
|
||||
projectId,
|
||||
style,
|
||||
content,
|
||||
isLatest: true,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Generated blogpost: ${generation.id} (${content.length} chars)`);
|
||||
return content;
|
||||
}
|
||||
|
||||
private buildContext(items: MediaItem[]): string {
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const num = index + 1;
|
||||
const timestamp = item.createdAt.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (item.type === 'photo') {
|
||||
const desc = item.aiDescription || item.caption || 'Keine Beschreibung';
|
||||
return `[Foto ${num}] (${timestamp})\n${desc}`;
|
||||
}
|
||||
|
||||
if (item.type === 'voice') {
|
||||
const text = item.transcription || '(Keine Transkription verfügbar)';
|
||||
return `[Sprachnotiz ${num}] (${timestamp})\n"${text}"`;
|
||||
}
|
||||
|
||||
// text
|
||||
return `[Notiz ${num}] (${timestamp})\n${item.caption}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
private buildPrompt(project: Project, context: string, stylePrompt: string): string {
|
||||
return `Du bist ein erfahrener Blogger und Content Creator.
|
||||
|
||||
${stylePrompt}
|
||||
|
||||
## Projekt-Informationen
|
||||
**Name:** ${project.name}
|
||||
${project.description ? `**Beschreibung:** ${project.description}` : ''}
|
||||
|
||||
## Gesammelte Inhalte (chronologisch)
|
||||
|
||||
${context}
|
||||
|
||||
## Aufgabe
|
||||
|
||||
Erstelle einen gut strukturierten Blogbeitrag in Markdown basierend auf den obigen Inhalten.
|
||||
|
||||
**Anforderungen:**
|
||||
- Verwende eine passende, ansprechende Überschrift (# Titel)
|
||||
- Strukturiere den Beitrag mit Zwischenüberschriften (## Abschnitte)
|
||||
- Verweise im Text auf die Fotos mit [Foto X], damit sie später eingebettet werden können
|
||||
- Integriere die Sprachnotizen und Textnotizen natürlich in den Fließtext
|
||||
- Füge am Ende eine kurze Zusammenfassung oder "Lessons Learned" hinzu
|
||||
- Schreibe auf Deutsch
|
||||
- Der Beitrag sollte authentisch und persönlich klingen
|
||||
|
||||
Beginne direkt mit dem Blogbeitrag (ohne Einleitung wie "Hier ist der Blogbeitrag"):`;
|
||||
}
|
||||
|
||||
private async callLlm(prompt: string): Promise<string> {
|
||||
if (this.llmProvider === 'openai' && this.openai) {
|
||||
return this.callOpenAI(prompt);
|
||||
}
|
||||
|
||||
return this.callOllama(prompt);
|
||||
}
|
||||
|
||||
private async callOpenAI(prompt: string): Promise<string> {
|
||||
if (!this.openai) {
|
||||
throw new Error('OpenAI not configured');
|
||||
}
|
||||
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 4000,
|
||||
});
|
||||
|
||||
return response.choices[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
private async callOllama(prompt: string): Promise<string> {
|
||||
const response = await fetch(`${this.ollamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.ollamaModel,
|
||||
prompt,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000), // 3 minutes timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response || '';
|
||||
}
|
||||
|
||||
async getLatestGeneration(projectId: string): Promise<Generation | undefined> {
|
||||
return this.db.query.generations.findFirst({
|
||||
where: eq(schema.generations.projectId, projectId),
|
||||
orderBy: [desc(schema.generations.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableStyles(): { key: string; name: string }[] {
|
||||
return Object.entries(BLOG_STYLES).map(([key, value]) => ({
|
||||
key,
|
||||
name: value.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
13
services/telegram-project-doc-bot/src/health.controller.ts
Normal file
13
services/telegram-project-doc-bot/src/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'telegram-project-doc-bot',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
18
services/telegram-project-doc-bot/src/main.ts
Normal file
18
services/telegram-project-doc-bot/src/main.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('port') || 3302;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Telegram Project Doc Bot running on port ${port}`);
|
||||
logger.log(`LLM Provider: ${configService.get<string>('llm.provider')}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
11
services/telegram-project-doc-bot/src/media/media.module.ts
Normal file
11
services/telegram-project-doc-bot/src/media/media.module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { MediaService } from './media.service';
|
||||
import { StorageService } from './storage.service';
|
||||
import { TranscriptionModule } from '../transcription/transcription.module';
|
||||
|
||||
@Module({
|
||||
imports: [TranscriptionModule],
|
||||
providers: [MediaService, StorageService],
|
||||
exports: [MediaService, StorageService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
164
services/telegram-project-doc-bot/src/media/media.service.ts
Normal file
164
services/telegram-project-doc-bot/src/media/media.service.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, asc } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { MediaItem, NewMediaItem } from '../database/schema';
|
||||
import { StorageService } from './storage.service';
|
||||
import { TranscriptionService } from '../transcription/transcription.service';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly logger = new Logger(MediaService.name);
|
||||
private readonly telegramApiUrl: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>,
|
||||
private storageService: StorageService,
|
||||
private transcriptionService: TranscriptionService,
|
||||
private configService: ConfigService
|
||||
) {
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
this.telegramApiUrl = `https://api.telegram.org/bot${token}`;
|
||||
}
|
||||
|
||||
// Get file URL from Telegram
|
||||
private async getTelegramFileUrl(fileId: string): Promise<string> {
|
||||
const response = await fetch(`${this.telegramApiUrl}/getFile?file_id=${fileId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(`Telegram API error: ${data.description}`);
|
||||
}
|
||||
|
||||
const token = this.configService.get<string>('telegram.token');
|
||||
return `https://api.telegram.org/file/bot${token}/${data.result.file_path}`;
|
||||
}
|
||||
|
||||
// Download file from URL
|
||||
private async downloadFile(url: string): Promise<Buffer> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
}
|
||||
|
||||
// Process a photo from Telegram
|
||||
async processPhoto(projectId: string, fileId: string, caption?: string): Promise<MediaItem> {
|
||||
this.logger.log(`Processing photo for project ${projectId}`);
|
||||
|
||||
// 1. Download from Telegram
|
||||
const fileUrl = await this.getTelegramFileUrl(fileId);
|
||||
const buffer = await this.downloadFile(fileUrl);
|
||||
|
||||
// 2. Generate storage key and upload
|
||||
const filename = `photo_${Date.now()}.jpg`;
|
||||
const storageKey = this.storageService.generateKey(projectId, 'photo', filename);
|
||||
await this.storageService.upload(storageKey, buffer, 'image/jpeg');
|
||||
|
||||
// 3. Get next order index
|
||||
const orderIndex = await this.getNextOrderIndex(projectId);
|
||||
|
||||
// 4. Save to database
|
||||
const [item] = await this.db
|
||||
.insert(schema.mediaItems)
|
||||
.values({
|
||||
projectId,
|
||||
type: 'photo',
|
||||
storageKey,
|
||||
caption,
|
||||
telegramFileId: fileId,
|
||||
orderIndex,
|
||||
metadata: { fileSize: buffer.length },
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Photo saved: ${item.id}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Process a voice note from Telegram
|
||||
async processVoice(projectId: string, fileId: string, duration?: number): Promise<MediaItem> {
|
||||
this.logger.log(`Processing voice for project ${projectId}`);
|
||||
|
||||
// 1. Download from Telegram
|
||||
const fileUrl = await this.getTelegramFileUrl(fileId);
|
||||
const buffer = await this.downloadFile(fileUrl);
|
||||
|
||||
// 2. Transcribe with Whisper
|
||||
let transcription: string | undefined;
|
||||
if (this.transcriptionService.isAvailable()) {
|
||||
try {
|
||||
transcription = await this.transcriptionService.transcribe(buffer);
|
||||
} catch (error) {
|
||||
this.logger.warn('Transcription failed, saving without:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate storage key and upload
|
||||
const filename = `voice_${Date.now()}.ogg`;
|
||||
const storageKey = this.storageService.generateKey(projectId, 'voice', filename);
|
||||
await this.storageService.upload(storageKey, buffer, 'audio/ogg');
|
||||
|
||||
// 4. Get next order index
|
||||
const orderIndex = await this.getNextOrderIndex(projectId);
|
||||
|
||||
// 5. Save to database
|
||||
const [item] = await this.db
|
||||
.insert(schema.mediaItems)
|
||||
.values({
|
||||
projectId,
|
||||
type: 'voice',
|
||||
storageKey,
|
||||
transcription,
|
||||
telegramFileId: fileId,
|
||||
orderIndex,
|
||||
metadata: { duration, fileSize: buffer.length },
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Voice saved: ${item.id}, transcription: ${transcription ? 'yes' : 'no'}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Add a text note
|
||||
async addTextNote(projectId: string, text: string): Promise<MediaItem> {
|
||||
const orderIndex = await this.getNextOrderIndex(projectId);
|
||||
|
||||
const [item] = await this.db
|
||||
.insert(schema.mediaItems)
|
||||
.values({
|
||||
projectId,
|
||||
type: 'text',
|
||||
caption: text,
|
||||
orderIndex,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Text note saved: ${item.id}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Get all media items for a project
|
||||
async getByProject(projectId: string): Promise<MediaItem[]> {
|
||||
return this.db.query.mediaItems.findMany({
|
||||
where: eq(schema.mediaItems.projectId, projectId),
|
||||
orderBy: [asc(schema.mediaItems.orderIndex), asc(schema.mediaItems.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
// Get next order index for a project
|
||||
private async getNextOrderIndex(projectId: string): Promise<number> {
|
||||
const items = await this.db.query.mediaItems.findMany({
|
||||
where: eq(schema.mediaItems.projectId, projectId),
|
||||
});
|
||||
return items.length;
|
||||
}
|
||||
|
||||
// Delete a media item
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db.delete(schema.mediaItems).where(eq(schema.mediaItems.id, id));
|
||||
return (result as unknown as { rowCount: number }).rowCount > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
CreateBucketCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnModuleInit {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly s3: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.bucket = this.configService.get<string>('s3.bucket')!;
|
||||
|
||||
this.s3 = new S3Client({
|
||||
endpoint: this.configService.get<string>('s3.endpoint'),
|
||||
region: this.configService.get<string>('s3.region'),
|
||||
credentials: {
|
||||
accessKeyId: this.configService.get<string>('s3.accessKey')!,
|
||||
secretAccessKey: this.configService.get<string>('s3.secretKey')!,
|
||||
},
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureBucket();
|
||||
}
|
||||
|
||||
private async ensureBucket(): Promise<void> {
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
this.logger.log(`Bucket "${this.bucket}" exists`);
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'name' in error && error.name === 'NotFound') {
|
||||
this.logger.log(`Creating bucket "${this.bucket}"...`);
|
||||
await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||
this.logger.log(`Bucket "${this.bucket}" created`);
|
||||
} else {
|
||||
this.logger.warn(`Could not check bucket: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async upload(key: string, buffer: Buffer, contentType: string): Promise<string> {
|
||||
await this.s3.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
this.logger.debug(`Uploaded ${key} (${buffer.length} bytes)`);
|
||||
return key;
|
||||
}
|
||||
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.s3, command, { expiresIn });
|
||||
}
|
||||
|
||||
generateKey(projectId: string, type: 'photo' | 'voice' | 'pdf', filename: string): string {
|
||||
return `${projectId}/${type}/${filename}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
@Module({
|
||||
providers: [ProjectService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
|
||||
import { DATABASE_CONNECTION } from '../database/database.module';
|
||||
import * as schema from '../database/schema';
|
||||
import { Project, NewProject } from '../database/schema';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
private readonly logger = new Logger(ProjectService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION)
|
||||
private db: PostgresJsDatabase<typeof schema>
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
telegramUserId: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<Project> {
|
||||
const [project] = await this.db
|
||||
.insert(schema.projects)
|
||||
.values({
|
||||
telegramUserId: data.telegramUserId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Created project "${project.name}" for user ${data.telegramUserId}`);
|
||||
return project;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Project | undefined> {
|
||||
return this.db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(telegramUserId: number): Promise<Project[]> {
|
||||
return this.db.query.projects.findMany({
|
||||
where: eq(schema.projects.telegramUserId, telegramUserId),
|
||||
orderBy: [desc(schema.projects.updatedAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async findActiveByUser(telegramUserId: number): Promise<Project[]> {
|
||||
return this.db.query.projects.findMany({
|
||||
where: and(
|
||||
eq(schema.projects.telegramUserId, telegramUserId),
|
||||
eq(schema.projects.status, 'active')
|
||||
),
|
||||
orderBy: [desc(schema.projects.updatedAt)],
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<NewProject>): Promise<Project | undefined> {
|
||||
const [project] = await this.db
|
||||
.update(schema.projects)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(schema.projects.id, id))
|
||||
.returning();
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db.delete(schema.projects).where(eq(schema.projects.id, id));
|
||||
return (result as unknown as { rowCount: number }).rowCount > 0;
|
||||
}
|
||||
|
||||
async getStats(projectId: string): Promise<{
|
||||
photos: number;
|
||||
voices: number;
|
||||
texts: number;
|
||||
total: number;
|
||||
}> {
|
||||
const items = await this.db.query.mediaItems.findMany({
|
||||
where: eq(schema.mediaItems.projectId, projectId),
|
||||
});
|
||||
|
||||
return {
|
||||
photos: items.filter((i) => i.type === 'photo').length,
|
||||
voices: items.filter((i) => i.type === 'voice').length,
|
||||
texts: items.filter((i) => i.type === 'text').length,
|
||||
total: items.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TranscriptionService } from './transcription.service';
|
||||
|
||||
@Module({
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
})
|
||||
export class TranscriptionModule {}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
@Injectable()
|
||||
export class TranscriptionService {
|
||||
private readonly logger = new Logger(TranscriptionService.name);
|
||||
private readonly openai: OpenAI | null;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('openai.apiKey');
|
||||
|
||||
if (apiKey) {
|
||||
this.openai = new OpenAI({ apiKey });
|
||||
this.logger.log('OpenAI Whisper initialized');
|
||||
} else {
|
||||
this.openai = null;
|
||||
this.logger.warn('OpenAI API key not configured - transcription disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async transcribe(audioBuffer: Buffer, filename = 'audio.ogg'): Promise<string> {
|
||||
if (!this.openai) {
|
||||
throw new Error('Transcription not available - OpenAI API key not configured');
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a File object from the buffer using Uint8Array
|
||||
const uint8Array = new Uint8Array(audioBuffer);
|
||||
const file = new File([uint8Array], filename, { type: 'audio/ogg' });
|
||||
|
||||
const response = await this.openai.audio.transcriptions.create({
|
||||
file,
|
||||
model: 'whisper-1',
|
||||
language: 'de', // Default to German, could be made configurable
|
||||
});
|
||||
|
||||
this.logger.debug(`Transcribed ${audioBuffer.length} bytes -> ${response.text.length} chars`);
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
this.logger.error('Transcription failed:', error);
|
||||
throw new Error('Transkription fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.openai !== null;
|
||||
}
|
||||
}
|
||||
22
services/telegram-project-doc-bot/tsconfig.json
Normal file
22
services/telegram-project-doc-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