mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
feat(infra): integrate matrix-mana-bot into deployment
- Add matrix-mana-bot to docker-compose.macmini.yml - Add setup-mana-bot.sh script for bot registration - Add dev:matrix:* scripts to root package.json - Add devlog entry documenting the new architecture The gateway bot is now ready for deployment alongside the existing standalone Matrix bots. https://claude.ai/code/session_015bwcqVRiFmSydYTjvDJGTc
This commit is contained in:
parent
2d879b327e
commit
e96d76ab8e
4 changed files with 476 additions and 0 deletions
|
|
@ -0,0 +1,268 @@
|
|||
---
|
||||
title: 'Matrix Mana Gateway Bot: Unified Bot Architecture'
|
||||
description: 'Einführung des matrix-mana-bot als zentraler Gateway mit Shared Business Logic Package für alle Matrix Bots'
|
||||
date: 2026-01-29
|
||||
author: 'Till Schneider'
|
||||
category: 'architecture'
|
||||
tags:
|
||||
[
|
||||
'matrix',
|
||||
'bot',
|
||||
'gateway',
|
||||
'architecture',
|
||||
'nestjs',
|
||||
'monorepo',
|
||||
'shared-packages',
|
||||
]
|
||||
featured: true
|
||||
commits: 3
|
||||
readTime: 8
|
||||
---
|
||||
|
||||
Einführung einer neuen Bot-Architektur mit dem **Matrix Mana Gateway Bot** - ein zentraler Bot, der alle Features vereint, während die Einzelbots weiterhin verfügbar bleiben.
|
||||
|
||||
---
|
||||
|
||||
## Das Problem
|
||||
|
||||
Bisher hatten wir **8 separate Matrix Bots**, jeder für eine spezifische Funktion:
|
||||
|
||||
- matrix-ollama-bot (AI Chat)
|
||||
- matrix-todo-bot (Aufgaben)
|
||||
- matrix-calendar-bot (Termine)
|
||||
- matrix-clock-bot (Timer/Alarme)
|
||||
- matrix-nutriphi-bot (Ernährung)
|
||||
- matrix-zitare-bot (Zitate)
|
||||
- matrix-stats-bot (Analytics)
|
||||
- matrix-project-doc-bot (Dokumentation)
|
||||
|
||||
**Nachteile:**
|
||||
- User müssen 8 verschiedene Bots einladen
|
||||
- Kein Cross-Feature-Support ("Erstelle Todo aus Kalender-Event")
|
||||
- Code-Duplikation zwischen Bots
|
||||
- 8 Matrix-Verbindungen zu Synapse
|
||||
- Hoher Ressourcenverbrauch
|
||||
|
||||
---
|
||||
|
||||
## Die Lösung: Hybrid-Architektur
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ User Wahl │
|
||||
├──────────────────────────────┬──────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ @mana:mana.how │ @todo:mana.how │
|
||||
│ (Gateway - alles) │ (Nur Todos) │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ ▼ │
|
||||
│ ┌─────────────────┐ │ ┌─────────────────┐ │
|
||||
│ │ matrix-mana-bot │ │ │ matrix-todo-bot │ │
|
||||
│ └────────┬────────┘ │ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┴────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────────────────┐ │
|
||||
│ │ @manacore/bot-services │ │
|
||||
│ │ (Shared Business Logic) │ │
|
||||
│ └──────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**User können wählen:**
|
||||
- **@mana** für alle Features in einem Bot
|
||||
- **@todo/@calendar/etc.** für dedizierte Nutzung
|
||||
|
||||
---
|
||||
|
||||
## Neues Package: @manacore/bot-services
|
||||
|
||||
Ein **Shared Package** mit transport-agnostischer Business Logic:
|
||||
|
||||
```
|
||||
packages/bot-services/
|
||||
├── src/
|
||||
│ ├── todo/
|
||||
│ │ ├── todo.service.ts # CRUD, Parsing, Stats
|
||||
│ │ ├── todo.module.ts # NestJS Module
|
||||
│ │ └── types.ts
|
||||
│ ├── calendar/
|
||||
│ │ ├── calendar.service.ts
|
||||
│ │ └── ...
|
||||
│ ├── ai/
|
||||
│ │ ├── ai.service.ts # Ollama Integration
|
||||
│ │ └── ...
|
||||
│ ├── clock/
|
||||
│ │ ├── clock.service.ts # Timer, Alarm, WorldClock
|
||||
│ │ └── ...
|
||||
│ └── shared/
|
||||
│ ├── storage.ts # File/Memory Provider
|
||||
│ └── utils.ts
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Kein Matrix-Code in Services
|
||||
- Testbar ohne Matrix
|
||||
- Wiederverwendbar in Gateway und Einzelbots
|
||||
- Pluggable Storage (File, Memory, Database)
|
||||
|
||||
### Beispiel: TodoService
|
||||
|
||||
```typescript
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class TodoService {
|
||||
// Pure business logic - kein Matrix!
|
||||
|
||||
async createTask(userId: string, input: CreateTaskInput): Promise<Task> {
|
||||
const parsed = this.parseTaskInput(input.text);
|
||||
return this.storage.create({ userId, ...parsed });
|
||||
}
|
||||
|
||||
parseTaskInput(text: string): ParsedTask {
|
||||
// "Einkaufen !p1 @morgen #haushalt"
|
||||
// → { title: "Einkaufen", priority: 1, dueDate: "...", project: "haushalt" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gateway Bot: matrix-mana-bot
|
||||
|
||||
Der neue **Unified Gateway** kombiniert alle Features:
|
||||
|
||||
```
|
||||
services/matrix-mana-bot/
|
||||
├── src/
|
||||
│ ├── bot/
|
||||
│ │ ├── matrix.service.ts # Matrix-Verbindung
|
||||
│ │ └── command-router.service.ts # Routing
|
||||
│ ├── handlers/
|
||||
│ │ ├── ai.handler.ts # !model, !all, chat
|
||||
│ │ ├── todo.handler.ts # !todo, !list, !done
|
||||
│ │ ├── calendar.handler.ts # !cal, !event
|
||||
│ │ ├── clock.handler.ts # !timer, !alarm
|
||||
│ │ └── help.handler.ts
|
||||
│ └── orchestration/
|
||||
│ └── orchestration.service.ts # Cross-Feature AI
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
| Kategorie | Commands |
|
||||
|-----------|----------|
|
||||
| **AI Chat** | Einfach tippen, `!model`, `!models`, `!all` |
|
||||
| **Todos** | `!todo`, `!list`, `!today`, `!done`, `!delete` |
|
||||
| **Kalender** | `!cal`, `!week`, `!event` |
|
||||
| **Timer** | `!timer`, `!alarm`, `!time`, `!timers` |
|
||||
| **Smart** | `!summary`, `!ai-todo` |
|
||||
|
||||
### Cross-Feature Orchestration
|
||||
|
||||
Der große Vorteil des Gateways - Features die mehrere Services kombinieren:
|
||||
|
||||
```typescript
|
||||
// !summary - AI-generierte Tages-Zusammenfassung
|
||||
async dailySummary(ctx: CommandContext): Promise<string> {
|
||||
const [todoStats, todayTodos, todayEvents] = await Promise.all([
|
||||
this.todoService.getStats(ctx.userId),
|
||||
this.todoService.getTodayTasks(ctx.userId),
|
||||
this.calendarService.getTodayEvents(ctx.userId),
|
||||
]);
|
||||
|
||||
const prompt = `Erstelle eine motivierende Tages-Zusammenfassung:
|
||||
Todos: ${todoStats.pending} offen, ${todoStats.completed} erledigt
|
||||
Termine: ${todayEvents.map(e => e.title).join(', ')}`;
|
||||
|
||||
return this.aiService.chat(ctx.userId, prompt);
|
||||
}
|
||||
|
||||
// !ai-todo - AI extrahiert Todos aus Text
|
||||
async aiToTodos(ctx: CommandContext, text: string): Promise<string> {
|
||||
const extracted = await this.aiService.extract(text);
|
||||
for (const todo of extracted) {
|
||||
await this.todoService.createTask(ctx.userId, todo);
|
||||
}
|
||||
return `✅ ${extracted.length} Todos erstellt`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & Deployment
|
||||
|
||||
### Bot registrieren
|
||||
|
||||
```bash
|
||||
./scripts/mac-mini/setup-mana-bot.sh
|
||||
```
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
matrix-mana-bot:
|
||||
image: matrix-mana-bot:latest
|
||||
environment:
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
CLOCK_API_URL: http://matrix-clock-bot:3318/api/v1
|
||||
volumes:
|
||||
- matrix_mana_bot_data:/app/data
|
||||
ports:
|
||||
- "3310:3310"
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
pnpm dev:matrix:mana # Gateway starten
|
||||
pnpm dev:matrix:todo # Todo-Bot starten
|
||||
pnpm build:matrix:all # Alle Bots bauen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Entscheidungen
|
||||
|
||||
### Warum Hybrid statt nur Gateway?
|
||||
|
||||
| Aspekt | Nur Gateway | Nur Einzelbots | Hybrid ✓ |
|
||||
|--------|-------------|----------------|----------|
|
||||
| User Experience | ⭐⭐⭐ Einfach | ⭐ Komplex | ⭐⭐⭐ Flexibel |
|
||||
| Cross-Features | ✅ Ja | ❌ Nein | ✅ Ja |
|
||||
| Fehler-Isolation | ❌ | ✅ | ✅ |
|
||||
| Power-User | ❌ | ✅ | ✅ |
|
||||
| Ressourcen | ⭐⭐⭐ | ⭐ | ⭐⭐ |
|
||||
|
||||
### Warum Shared Package?
|
||||
|
||||
- **Kein Code-Duplikation** - Services einmal geschrieben
|
||||
- **Testbarkeit** - Services ohne Matrix testbar
|
||||
- **Flexibilität** - Neue Clients (CLI, Web) nutzen gleiche Logic
|
||||
- **Konsistenz** - Gleiche Daten in Gateway und Einzelbots
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. **Bestehende Bots refactoren** um `@manacore/bot-services` zu nutzen
|
||||
2. **Weitere Services implementieren** (Nutrition, Quotes, Stats, Docs)
|
||||
3. **E2EE Support** für verschlüsselte Räume
|
||||
4. **Reactions** für Feedback (`✅` = verstanden)
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Mit dem **matrix-mana-bot** haben wir jetzt:
|
||||
|
||||
- ✅ Einen zentralen Bot für alle Features
|
||||
- ✅ Shared Business Logic Package
|
||||
- ✅ Cross-Feature AI-Orchestration
|
||||
- ✅ Weiterhin Einzelbots für Power-User
|
||||
- ✅ DSGVO-konform (Self-Hosted)
|
||||
- ✅ Natürliche Sprache + Commands
|
||||
|
|
@ -869,6 +869,42 @@ services:
|
|||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ============================================
|
||||
# Matrix Mana Bot (Unified Gateway - All Features)
|
||||
# ============================================
|
||||
|
||||
matrix-mana-bot:
|
||||
image: matrix-mana-bot:latest
|
||||
container_name: manacore-matrix-mana-bot
|
||||
restart: always
|
||||
depends_on:
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3310
|
||||
TZ: Europe/Berlin
|
||||
MATRIX_HOMESERVER_URL: http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN: ${MATRIX_MANA_BOT_TOKEN}
|
||||
MATRIX_ALLOWED_ROOMS: ${MATRIX_MANA_BOT_ROOMS:-}
|
||||
MATRIX_STORAGE_PATH: /app/data/mana-bot-storage.json
|
||||
OLLAMA_URL: http://host.docker.internal:11434
|
||||
OLLAMA_MODEL: ${OLLAMA_MODEL:-gemma3:4b}
|
||||
OLLAMA_TIMEOUT: 120000
|
||||
CLOCK_API_URL: http://matrix-clock-bot:3318/api/v1
|
||||
TODO_STORAGE_PATH: /app/data/todos.json
|
||||
CALENDAR_STORAGE_PATH: /app/data/calendar.json
|
||||
volumes:
|
||||
- matrix_mana_bot_data:/app/data
|
||||
ports:
|
||||
- "3310:3310"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3310/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# ============================================
|
||||
# Matrix Ollama Bot (GDPR-compliant AI Chat)
|
||||
# ============================================
|
||||
|
|
@ -1172,6 +1208,8 @@ volumes:
|
|||
name: manacore-n8n
|
||||
synapse_data:
|
||||
name: manacore-synapse
|
||||
matrix_mana_bot_data:
|
||||
name: manacore-matrix-mana-bot
|
||||
matrix_ollama_bot_data:
|
||||
name: manacore-matrix-ollama-bot
|
||||
matrix_stats_bot_data:
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -226,6 +226,16 @@
|
|||
"dev:nutriphi-bot:full": "./scripts/setup-databases.sh nutriphi_bot && pnpm dev:nutriphi-bot",
|
||||
"nutriphi-bot:db:push": "pnpm --filter @manacore/telegram-nutriphi-bot db:push",
|
||||
"nutriphi-bot:db:studio": "pnpm --filter @manacore/telegram-nutriphi-bot db:studio",
|
||||
"dev:matrix:mana": "pnpm --filter matrix-mana-bot start:dev",
|
||||
"dev:matrix:ollama": "pnpm --filter matrix-ollama-bot start:dev",
|
||||
"dev:matrix:todo": "pnpm --filter matrix-todo-bot start:dev",
|
||||
"dev:matrix:calendar": "pnpm --filter matrix-calendar-bot start:dev",
|
||||
"dev:matrix:clock": "pnpm --filter matrix-clock-bot start:dev",
|
||||
"dev:matrix:stats": "pnpm --filter matrix-stats-bot start:dev",
|
||||
"dev:matrix:zitare": "pnpm --filter matrix-zitare-bot start:dev",
|
||||
"dev:matrix:nutriphi": "pnpm --filter matrix-nutriphi-bot start:dev",
|
||||
"build:matrix:mana": "pnpm --filter matrix-mana-bot build",
|
||||
"build:matrix:all": "pnpm --filter 'matrix-*-bot' build",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
160
scripts/mac-mini/setup-mana-bot.sh
Executable file
160
scripts/mac-mini/setup-mana-bot.sh
Executable file
|
|
@ -0,0 +1,160 @@
|
|||
#!/bin/bash
|
||||
# Register and setup Matrix Mana Bot (Gateway)
|
||||
# Run this after Matrix Synapse is running
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo "============================================"
|
||||
echo " Matrix Mana Bot Setup"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# Default values
|
||||
HOMESERVER_URL="${MATRIX_HOMESERVER_URL:-http://localhost:8008}"
|
||||
BOT_USERNAME="mana"
|
||||
BOT_DISPLAY_NAME="Mana"
|
||||
|
||||
# Check if Synapse is running
|
||||
echo "Checking Synapse..."
|
||||
if ! curl -s "${HOMESERVER_URL}/health" > /dev/null 2>&1; then
|
||||
echo -e "${RED}Error: Synapse is not reachable at ${HOMESERVER_URL}${NC}"
|
||||
echo "Start it with: docker compose -f docker-compose.macmini.yml up -d synapse"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}Synapse is running${NC}"
|
||||
echo ""
|
||||
|
||||
# Check if registration secret is available
|
||||
if [ -z "$SYNAPSE_REGISTRATION_SECRET" ]; then
|
||||
echo -e "${YELLOW}SYNAPSE_REGISTRATION_SECRET not set.${NC}"
|
||||
echo "Please provide the registration secret from your .env file:"
|
||||
read -sp "Registration Secret: " SYNAPSE_REGISTRATION_SECRET
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Generate bot password
|
||||
BOT_PASSWORD=$(openssl rand -base64 24)
|
||||
|
||||
echo "Registering bot user @${BOT_USERNAME}..."
|
||||
|
||||
# Generate HMAC for registration
|
||||
generate_mac() {
|
||||
local nonce=$1
|
||||
local user=$2
|
||||
local password=$3
|
||||
local user_type=$4
|
||||
local admin=$5
|
||||
|
||||
local mac_input="${nonce}\x00${user}\x00${password}\x00${user_type}\x00${admin}"
|
||||
echo -n "$mac_input" | openssl dgst -sha1 -hmac "$SYNAPSE_REGISTRATION_SECRET" | cut -d' ' -f2
|
||||
}
|
||||
|
||||
# Get nonce
|
||||
NONCE=$(curl -s "${HOMESERVER_URL}/_synapse/admin/v1/register" | jq -r '.nonce')
|
||||
|
||||
if [ -z "$NONCE" ] || [ "$NONCE" = "null" ]; then
|
||||
echo -e "${RED}Failed to get registration nonce. Is admin registration enabled?${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate MAC
|
||||
MAC=$(generate_mac "$NONCE" "$BOT_USERNAME" "$BOT_PASSWORD" "bot" "false")
|
||||
|
||||
# Register user
|
||||
REGISTER_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_synapse/admin/v1/register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"nonce\": \"${NONCE}\",
|
||||
\"username\": \"${BOT_USERNAME}\",
|
||||
\"password\": \"${BOT_PASSWORD}\",
|
||||
\"displayname\": \"${BOT_DISPLAY_NAME}\",
|
||||
\"user_type\": \"bot\",
|
||||
\"admin\": false,
|
||||
\"mac\": \"${MAC}\"
|
||||
}")
|
||||
|
||||
# Check if registration was successful
|
||||
if echo "$REGISTER_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then
|
||||
ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.access_token')
|
||||
USER_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.user_id')
|
||||
|
||||
echo -e "${GREEN}Bot registered successfully!${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}User ID:${NC} ${USER_ID}"
|
||||
echo ""
|
||||
else
|
||||
ERROR=$(echo "$REGISTER_RESPONSE" | jq -r '.error // .errcode // "Unknown error"')
|
||||
|
||||
# Check if user already exists
|
||||
if echo "$ERROR" | grep -qi "user.*exists\|already.*registered\|M_USER_IN_USE"; then
|
||||
echo -e "${YELLOW}User @${BOT_USERNAME} already exists. Getting access token via login...${NC}"
|
||||
|
||||
echo "Please enter the existing bot password:"
|
||||
read -sp "Password: " EXISTING_PASSWORD
|
||||
echo ""
|
||||
|
||||
LOGIN_RESPONSE=$(curl -s -X POST "${HOMESERVER_URL}/_matrix/client/r0/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"type\": \"m.login.password\",
|
||||
\"user\": \"${BOT_USERNAME}\",
|
||||
\"password\": \"${EXISTING_PASSWORD}\"
|
||||
}")
|
||||
|
||||
if echo "$LOGIN_RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then
|
||||
ACCESS_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.access_token')
|
||||
USER_ID=$(echo "$LOGIN_RESPONSE" | jq -r '.user_id')
|
||||
echo -e "${GREEN}Login successful!${NC}"
|
||||
else
|
||||
echo -e "${RED}Login failed. Please check the password.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${RED}Registration failed: ${ERROR}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Add to .env file"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo -e "${CYAN}# Matrix Mana Bot (Gateway)${NC}"
|
||||
echo "MATRIX_MANA_BOT_TOKEN=${ACCESS_TOKEN}"
|
||||
echo ""
|
||||
|
||||
# Optional: Set display name and avatar
|
||||
echo "Setting display name..."
|
||||
curl -s -X PUT "${HOMESERVER_URL}/_matrix/client/r0/profile/${USER_ID}/displayname" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"displayname\": \"🤖 ${BOT_DISPLAY_NAME}\"}" > /dev/null
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Next Steps"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "1. Add the MATRIX_MANA_BOT_TOKEN to your .env file"
|
||||
echo ""
|
||||
echo "2. Build the bot image:"
|
||||
echo " docker build -t matrix-mana-bot ./services/matrix-mana-bot"
|
||||
echo ""
|
||||
echo "3. Start the bot:"
|
||||
echo " docker compose -f docker-compose.macmini.yml up -d matrix-mana-bot"
|
||||
echo ""
|
||||
echo "4. Invite the bot to a room in Element:"
|
||||
echo " /invite @mana:mana.how"
|
||||
echo ""
|
||||
echo -e "${GREEN}Setup complete!${NC}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue