fix(mana-auth) + chore: rewrite /api/v1/auth/login JWT mint, remove Matrix stack

This commit bundles two unrelated changes that were swept together by an
accidental `git add -A` in another working session. Documented here so the
history reflects what's actually inside.

═══════════════════════════════════════════════════════════════════════
1. fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead
   of api.signInEmail
═══════════════════════════════════════════════════════════════════════

Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint in
/api/v1/auth/login by switching the cookie name from `mana.session_token`
to `__Secure-mana.session_token` for production. That was necessary but
not sufficient: Better Auth's session cookie value isn't just the raw
session token, it's `<token>.<HMAC>` where the HMAC is derived from the
better-auth secret. Reconstructing the cookie from auth.api.signInEmail's
JSON response only gave us the raw token, so /api/auth/token's
get-session middleware still couldn't validate it and the JWT mint kept
silently failing.

Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.

Verified end-to-end on auth.mana.how:

  $ curl -X POST https://auth.mana.how/api/v1/auth/login \
      -d '{"email":"...","password":"..."}'
  {
    "user": {...},
    "token": "<session token>",
    "accessToken": "eyJhbGciOiJFZERTQSI...",   ← real JWT now
    "refreshToken": "<session token>"
  }

Side benefits:
- Email-not-verified path is now handled by checking
  signInResponse.status === 403 directly, no more catching APIError
  with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
  and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
  (network errors etc); the FORBIDDEN-checking logic in it is dead but
  harmless and left in for defense in depth.

═══════════════════════════════════════════════════════════════════════
2. chore: remove the entire self-hosted Matrix stack (Synapse, Element,
   Manalink, mana-matrix-bot)
═══════════════════════════════════════════════════════════════════════

The Matrix subsystem ran parallel to the main Mana product without any
load-bearing integration: the unified web app never imported matrix-js-sdk,
the chat module uses mana-sync (local-first), and mana-matrix-bot's
plugins duplicated features the unified app already ships natively.
Keeping it alive cost a Synapse + Element + matrix-web + bot container
quartet, three Cloudflare routes, an OIDC provider plugin in mana-auth,
and a steady drip of devlog/dependency churn.

Removed:
- apps/matrix (Manalink web + mobile, ~150 files)
- services/mana-matrix-bot (Go bot with ~20 plugins)
- docker/matrix configs (Synapse + Element)
- synapse/element-web/matrix-web/mana-matrix-bot services in
  docker-compose.macmini.yml
- matrix.mana.how/element.mana.how/link.mana.how Cloudflare tunnel routes
- OIDC provider plugin + matrix-synapse trustedClient + matrixUserLinks
  table from mana-auth (oauth_* schema definitions also removed)
- MatrixService import path in mana-media (importFromMatrix endpoint)
- Matrix notification channel in mana-notify (worker, metrics, config,
  channel_type enum, MatrixOptions handler)
- Matrix entries from shared-branding (mana-apps + app-icons),
  notify-client, the i18n bundle, the observatory map, the credits
  app-label list, the landing footer/apps page, the prometheus + alerts
  + promtail tier mappings, and the matrix-related deploy paths in
  cd-macmini.yml + ci.yml

Devlog/manascore/blueprint entries that mention Matrix are left intact
as historical record. The oauth_* + matrix_user_links Postgres tables
stay on existing prod databases — code can no longer write to them, drop
them in a follow-up migration if you want them gone for real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 16:25:55 +02:00
parent 4eb5dfe4a0
commit 8e8b6ac65f
254 changed files with 88 additions and 29437 deletions

View file

@ -19,9 +19,8 @@ Central authentication service for the Mana ecosystem. Hono + Bun + Better Auth.
1. **Organization** — B2B multi-tenant with RBAC
2. **JWT** — EdDSA tokens with minimal claims (sub, email, role, sid)
3. **OIDC Provider** — Matrix/Synapse SSO
4. **Two-Factor** — TOTP with backup codes
5. **Magic Link** — Passwordless email login
3. **Two-Factor** — TOTP with backup codes
4. **Magic Link** — Passwordless email login
## Key Endpoints
@ -37,9 +36,6 @@ Handled directly by Better Auth — includes sign-in, sign-up, session, 2FA, mag
| POST | `/validate` | Validate JWT token |
| GET | `/session` | Get current session |
### OIDC (`/.well-known/*`, `/api/auth/oauth2/*`)
OpenID Connect provider for Matrix/Synapse SSO.
### Me — GDPR Self-Service (`/api/v1/me/*`)
| Method | Path | Description |
|--------|------|-------------|
@ -103,7 +99,6 @@ SMTP_HOST=stalwart # self-hosted on Mac Mini, see docs/MAIL_SERVER.md
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
SYNAPSE_OIDC_CLIENT_SECRET=...
# Encryption Vault — REQUIRED IN PRODUCTION
# Base64-encoded 32-byte AES-256 key. Generate with `openssl rand -base64 32`.

View file

@ -18,7 +18,6 @@ import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { jwt } from 'better-auth/plugins/jwt';
import { organization } from 'better-auth/plugins/organization';
import { oidcProvider } from 'better-auth/plugins/oidc-provider';
import { twoFactor } from 'better-auth/plugins/two-factor';
import { magicLink } from 'better-auth/plugins/magic-link';
import { getDb } from '../db/connection';
@ -29,10 +28,6 @@ import {
accounts,
verificationTokens,
jwks,
oauthApplications,
oauthAccessTokens,
oauthAuthorizationCodes,
oauthConsents,
twoFactorAuth,
} from '../db/schema/auth';
import {
@ -103,12 +98,6 @@ export function createBetterAuth(databaseUrl: string) {
// Two-Factor Authentication table
twoFactor: twoFactorAuth,
// OIDC Provider tables
oauthApplication: oauthApplications,
oauthAccessToken: oauthAccessTokens,
oauthAuthorizationCode: oauthAuthorizationCodes,
oauthConsent: oauthConsents,
},
}),
@ -258,9 +247,6 @@ export function createBetterAuth(databaseUrl: string) {
// Separate apps (not part of unified app)
'https://arcade.mana.how', // Games
'https://whopxl.mana.how', // Games
'https://link.mana.how', // Matrix/Manalink
'https://element.mana.how', // Element (Matrix client)
'https://matrix.mana.how', // Matrix
// Local development
'http://localhost:3001',
'http://localhost:5173',
@ -365,45 +351,6 @@ export function createBetterAuth(databaseUrl: string) {
},
}),
/**
* OIDC Provider Plugin
*
* Enables Mana Core Auth to act as an OpenID Connect Provider.
* This allows Matrix/Synapse and other services to use SSO.
*
* Endpoints provided:
* - GET /.well-known/openid-configuration
* - GET /api/oidc/authorize
* - POST /api/oidc/token
* - GET /api/oidc/userinfo
* - GET /api/oidc/jwks
*/
oidcProvider({
// Login page for OIDC authorization
loginPage: '/login',
// Consent page (skipped for trusted clients)
consentPage: '/consent',
// Use JWT plugin for token signing (EdDSA instead of HS256)
// This is required for Synapse OIDC which verifies via JWKS
useJWTPlugin: true,
metadata: {
issuer: process.env.BASE_URL || 'http://localhost:3001',
},
// Trusted clients that skip consent screen
// These clients are considered first-party and don't need user consent
trustedClients: [
{
clientId: 'matrix-synapse',
clientSecret: process.env.SYNAPSE_OIDC_CLIENT_SECRET || '',
name: 'Matrix Synapse',
type: 'web',
disabled: false,
metadata: {},
redirectUrls: ['https://matrix.mana.how/_synapse/client/oidc/callback'],
skipConsent: true,
},
],
}),
/**
* Two-Factor Authentication Plugin (TOTP)
*

View file

@ -10,7 +10,6 @@ export interface Config {
manaNotifyUrl: string;
manaCreditsUrl: string;
manaSubscriptionsUrl: string;
synapseOidcClientSecret: string;
/** Base64-encoded 32-byte AES-256 key encryption key (KEK). Wraps each
* user's master key in auth.encryption_vaults. Required in production
* in development a deterministic dev KEK is auto-generated so the
@ -55,7 +54,6 @@ export function loadConfig(): Config {
manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'),
manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'),
manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'),
synapseOidcClientSecret: env('SYNAPSE_OIDC_CLIENT_SECRET'),
encryptionKek,
};
}

View file

@ -139,87 +139,6 @@ export const jwks = authSchema.table('jwks', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// OIDC Provider tables (Better Auth OIDC Provider plugin)
// OAuth Applications (OIDC Clients like Matrix/Synapse)
export const oauthApplications = authSchema.table('oauth_applications', {
id: text('id').primaryKey(),
name: text('name').notNull(),
icon: text('icon'),
metadata: text('metadata'),
clientId: text('client_id').unique().notNull(),
clientSecret: text('client_secret').notNull(),
redirectUrls: text('redirect_urls').notNull(), // Comma-separated URLs (Better Auth expects 'redirectUrls' property name)
type: text('type').notNull().default('web'), // web, native, spa
disabled: boolean('disabled').default(false).notNull(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// OAuth Access Tokens
export const oauthAccessTokens = authSchema.table('oauth_access_tokens', {
id: text('id').primaryKey(),
accessToken: text('access_token').unique().notNull(),
refreshToken: text('refresh_token').unique(),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }).notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
clientId: text('client_id').notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
scopes: text('scopes').notNull(), // JSON array as text
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// OAuth Authorization Codes
export const oauthAuthorizationCodes = authSchema.table('oauth_authorization_codes', {
id: text('id').primaryKey(),
code: text('code').unique().notNull(),
clientId: text('client_id').notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
scopes: text('scopes').notNull(), // JSON array as text
redirectUri: text('redirect_uri').notNull(),
codeChallenge: text('code_challenge'),
codeChallengeMethod: text('code_challenge_method'),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// OAuth Consents (user consent records for OIDC scopes)
export const oauthConsents = authSchema.table('oauth_consents', {
id: text('id').primaryKey(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: text('client_id').notNull(),
scopes: text('scopes').notNull(), // JSON array as text
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Matrix User Links table (for Bot SSO)
// Links Matrix user IDs to Mana user accounts for automatic bot authentication
export const matrixUserLinks = authSchema.table(
'matrix_user_links',
{
id: text('id').primaryKey(), // nanoid
matrixUserId: text('matrix_user_id').unique().notNull(), // e.g., @user:matrix.mana.how
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
linkedAt: timestamp('linked_at', { withTimezone: true }).defaultNow().notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
// Optional: store email for convenience (denormalized from users table)
email: text('email'),
},
(table) => ({
userIdIdx: index('matrix_user_links_user_id_idx').on(table.userId),
})
);
// Passkeys table (WebAuthn credentials)
export const passkeys = authSchema.table(
'passkeys',

View file

@ -109,46 +109,77 @@ export function createAuthRoutes(
const ip = c.req.header('x-forwarded-for') || 'unknown';
try {
const response = await auth.api.signInEmail({
body: { email: body.email, password: body.password },
headers: c.req.raw.headers,
});
// Sign in via Better Auth's HTTP handler so we get back a real
// Response with Set-Cookie. The auth.api.signInEmail() SDK call
// only returns the body and we'd lose the signed cookie envelope
// that /api/auth/token needs to validate the session — the cookie
// value is `<sessionToken>.<HMAC>`, not just the raw session token,
// so reconstructing it from the API response doesn't work.
const signInResponse = await auth.handler(
new Request(new URL('/api/auth/sign-in/email', config.baseUrl), {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json',
// Forward original X-Forwarded-For so Better Auth's rate
// limiting and our security log see the right IP.
...(c.req.header('x-forwarded-for')
? { 'X-Forwarded-For': c.req.header('x-forwarded-for') as string }
: {}),
}),
body: JSON.stringify({ email: body.email, password: body.password }),
})
);
if (!signInResponse.ok) {
// Better Auth returns 403 with FORBIDDEN for unverified emails.
if (signInResponse.status === 403) {
return c.json({ error: 'Email not verified', code: 'EMAIL_NOT_VERIFIED' }, 403);
}
security.logEvent({
eventType: 'LOGIN_FAILURE',
ipAddress: ip,
metadata: { email: body.email },
});
lockout.recordAttempt(body.email, false, ip);
return c.json({ error: 'Invalid credentials' }, 401);
}
const response = (await signInResponse.json()) as {
user?: { id: string };
token?: string;
redirect?: boolean;
};
if (response?.user?.id) {
security.logEvent({ userId: response.user.id, eventType: 'LOGIN_SUCCESS', ipAddress: ip });
lockout.clearAttempts(body.email);
}
// signInEmail returns { token (session token), user, redirect }
// Use the session token to call Better Auth's JWT /token endpoint.
//
// In production Better Auth issues the session cookie with the
// __Secure- prefix (because secure: true is set), so we have to
// pass that exact cookie name back when forging the request to
// /api/auth/token. Without the prefix the get-session middleware
// can't find the session and the JWT mint silently fails — the
// route falls through and returns a response without accessToken.
const sessionToken = response?.token;
if (sessionToken) {
const cookieName =
config.nodeEnv === 'production' ? '__Secure-mana.session_token' : 'mana.session_token';
// Capture the signed session cookie that Better Auth set on the
// sign-in response and forward it verbatim to /api/auth/token to
// mint a JWT. This is the only path that produces a cookie value
// with a valid HMAC signature.
const setCookie = signInResponse.headers.get('set-cookie');
if (setCookie) {
const tokenResponse = await auth.handler(
new Request(new URL('/api/auth/token', config.baseUrl), {
method: 'GET',
headers: new Headers({ cookie: `${cookieName}=${sessionToken}` }),
headers: new Headers({ cookie: setCookie }),
})
);
if (tokenResponse.ok) {
const tokenData = await tokenResponse.json();
const tokenData = (await tokenResponse.json()) as { token: string };
return c.json({
...response,
accessToken: tokenData.token,
refreshToken: sessionToken,
refreshToken: response.token,
});
}
}
// JWT mint failed (or no Set-Cookie came back). Still return the
// sign-in body so the client at least sees the user object.
return c.json(response);
} catch (error) {
// Better Auth throws APIError with status="FORBIDDEN" for unverified emails.

View file

@ -1,3 +0,0 @@
dist/
data/
*.json.bak

View file

@ -1,67 +0,0 @@
# mana-matrix-bot
Consolidated Go Matrix bot replacing 21 separate NestJS bot services.
## Architecture
- **Language:** Go 1.23
- **Matrix SDK:** mautrix-go
- **Port:** 4000 (health/metrics)
- **Pattern:** Plugin architecture with compile-time registration
## Structure
```
cmd/server/main.go # Entry point, imports all plugins
internal/
config/ # Env-based configuration
runtime/ # Plugin lifecycle, Matrix sync, event routing
matrix/ # Matrix client wrapper, markdown, media
plugin/ # Plugin interface, registry, command routing
session/ # In-memory + Redis session store
services/ # Backend HTTP client, voice (STT/TTS)
plugins/ # One directory per bot plugin
todo/ # @todo-bot
calendar/ # @calendar-bot
gateway/ # @mana-bot (composite: AI + todo + calendar + clock + voice)
...
```
## Adding a New Plugin
1. Create `internal/plugins/mybot/mybot.go`
2. Implement `plugin.Plugin` interface
3. Register via `func init() { plugin.Register("mybot", func() plugin.Plugin { return &MyBot{} }) }`
4. Import in `cmd/server/main.go`: `_ "github.com/mana/mana-matrix-bot/internal/plugins/mybot"`
5. Set env: `MATRIX_MYBOT_BOT_TOKEN=syt_xxx`
## Commands
```bash
# Build
go build -o dist/mana-matrix-bot ./cmd/server
# Run
PORT=4000 MATRIX_HOMESERVER_URL=http://localhost:8008 MATRIX_TODO_BOT_TOKEN=xxx ./dist/mana-matrix-bot
# Test
go test ./...
# Docker
docker build -t mana-matrix-bot:local -f Dockerfile .
```
## Environment Variables
### Global
- `PORT` — Health server port (default: 4000)
- `MATRIX_HOMESERVER_URL` — Matrix homeserver (default: http://localhost:8008)
- `MATRIX_STORAGE_PATH` — Sync state directory (default: ./data)
- `MANA_AUTH_URL` — Auth service URL
- `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` — Redis for sessions
- `STT_URL`, `TTS_URL` — Voice services
### Per Plugin (legacy env var names supported)
- `MATRIX_{NAME}_BOT_TOKEN` — Matrix access token
- `MATRIX_{NAME}_BOT_ROOMS` — Comma-separated allowed room IDs
- `{NAME}_BACKEND_URL` — Backend service URL

View file

@ -1,28 +0,0 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Copy Go module files first for better caching
COPY services/mana-matrix-bot/go.mod services/mana-matrix-bot/go.sum ./
RUN go mod download
# Copy source
COPY services/mana-matrix-bot/ .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-matrix-bot ./cmd/server
# Runtime stage
FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /mana-matrix-bot /usr/local/bin/mana-matrix-bot
VOLUME /app/data
EXPOSE 4000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:4000/health || exit 1
ENTRYPOINT ["mana-matrix-bot"]

View file

@ -1,77 +0,0 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/mana/mana-matrix-bot/internal/config"
"github.com/mana/mana-matrix-bot/internal/runtime"
// Import all plugins to trigger their init() registration.
_ "github.com/mana/mana-matrix-bot/internal/plugins/calendar"
_ "github.com/mana/mana-matrix-bot/internal/plugins/chat"
_ "github.com/mana/mana-matrix-bot/internal/plugins/clock"
_ "github.com/mana/mana-matrix-bot/internal/plugins/contacts"
_ "github.com/mana/mana-matrix-bot/internal/plugins/gateway"
_ "github.com/mana/mana-matrix-bot/internal/plugins/cards"
_ "github.com/mana/mana-matrix-bot/internal/plugins/nutriphi"
_ "github.com/mana/mana-matrix-bot/internal/plugins/ollama"
_ "github.com/mana/mana-matrix-bot/internal/plugins/onboarding"
_ "github.com/mana/mana-matrix-bot/internal/plugins/picture"
_ "github.com/mana/mana-matrix-bot/internal/plugins/planta"
_ "github.com/mana/mana-matrix-bot/internal/plugins/presi"
_ "github.com/mana/mana-matrix-bot/internal/plugins/projectdoc"
_ "github.com/mana/mana-matrix-bot/internal/plugins/questions"
_ "github.com/mana/mana-matrix-bot/internal/plugins/skilltree"
_ "github.com/mana/mana-matrix-bot/internal/plugins/stats"
_ "github.com/mana/mana-matrix-bot/internal/plugins/storage"
_ "github.com/mana/mana-matrix-bot/internal/plugins/stt"
_ "github.com/mana/mana-matrix-bot/internal/plugins/todo"
_ "github.com/mana/mana-matrix-bot/internal/plugins/tts"
_ "github.com/mana/mana-matrix-bot/internal/plugins/zitare"
)
func main() {
// Structured JSON logging
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
cfg := config.Load()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Create and start runtime
rt := runtime.New(cfg)
// Start health server
health := runtime.NewHealthServer(rt, cfg.Port)
httpServer := health.Start()
// Start all plugins
if err := rt.Start(ctx); err != nil {
slog.Error("failed to start runtime", "error", err)
os.Exit(1)
}
slog.Info("mana-matrix-bot running", "port", cfg.Port)
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("shutting down...")
cancel()
rt.Stop()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
httpServer.Shutdown(shutdownCtx)
slog.Info("shutdown complete")
}

View file

@ -1,28 +0,0 @@
module github.com/mana/mana-matrix-bot
go 1.25.0
require (
github.com/redis/go-redis/v9 v9.18.0
maunium.net/go/mautrix v0.26.4
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.mau.fi/util v0.9.7 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
)

View file

@ -1,66 +0,0 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=

View file

@ -1,219 +0,0 @@
package config
import (
"os"
"strconv"
"strings"
)
// Config holds all configuration for the consolidated Matrix bot.
type Config struct {
// Server
Port int
// Matrix
HomeserverURL string
StoragePath string
// Auth
AuthURL string
ServiceKey string
// Redis
RedisHost string
RedisPort int
RedisPassword string
// Voice services
STTURL string
TTSURL string
// Plugins (keyed by plugin name)
Plugins map[string]PluginConfig
}
// PluginConfig holds per-plugin configuration.
type PluginConfig struct {
Enabled bool
AccessToken string
AllowedRooms []string
BackendURL string
Extra map[string]string
}
// Load reads configuration from environment variables.
func Load() *Config {
port, _ := strconv.Atoi(getEnv("PORT", "4000"))
redisPort, _ := strconv.Atoi(getEnv("REDIS_PORT", "6379"))
cfg := &Config{
Port: port,
HomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://localhost:8008"),
StoragePath: getEnv("MATRIX_STORAGE_PATH", "./data"),
AuthURL: getEnv("MANA_AUTH_URL", "http://localhost:3001"),
ServiceKey: getEnv("MANA_SERVICE_KEY", ""),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: redisPort,
RedisPassword: getEnv("REDIS_PASSWORD", ""),
STTURL: getEnv("STT_URL", "http://localhost:3020"),
TTSURL: getEnv("TTS_URL", "http://localhost:3022"),
Plugins: make(map[string]PluginConfig),
}
// Load plugin configs from environment variables.
// Pattern: PLUGIN_{NAME}_ENABLED, PLUGIN_{NAME}_ACCESS_TOKEN, etc.
// Also supports legacy patterns: MATRIX_{NAME}_BOT_TOKEN
pluginNames := []string{
"gateway", "todo", "calendar", "clock", "ollama", "stats",
"contacts", "chat", "cards", "nutriphi", "picture", "planta",
"presi", "questions", "skilltree", "storage", "projectdoc",
"stt", "tts", "zitare", "onboarding",
}
// Map of legacy token env var names
legacyTokenMap := map[string]string{
"gateway": "MATRIX_MANA_BOT_TOKEN",
"todo": "MATRIX_TODO_BOT_TOKEN",
"calendar": "MATRIX_CALENDAR_BOT_TOKEN",
"clock": "MATRIX_CLOCK_BOT_TOKEN",
"ollama": "MATRIX_OLLAMA_BOT_TOKEN",
"stats": "MATRIX_STATS_BOT_TOKEN",
"contacts": "MATRIX_CONTACTS_BOT_TOKEN",
"chat": "MATRIX_CHAT_BOT_TOKEN",
"cards": "MATRIX_CARDS_BOT_TOKEN",
"nutriphi": "MATRIX_NUTRIPHI_BOT_TOKEN",
"picture": "MATRIX_PICTURE_BOT_TOKEN",
"planta": "MATRIX_PLANTA_BOT_TOKEN",
"presi": "MATRIX_PRESI_BOT_TOKEN",
"questions": "MATRIX_QUESTIONS_BOT_TOKEN",
"skilltree": "MATRIX_SKILLTREE_BOT_TOKEN",
"storage": "MATRIX_STORAGE_BOT_TOKEN",
"projectdoc": "MATRIX_PROJECT_DOC_BOT_TOKEN",
"stt": "MATRIX_STT_BOT_TOKEN",
"tts": "MATRIX_TTS_BOT_TOKEN",
"zitare": "MATRIX_ZITARE_BOT_TOKEN",
"onboarding": "MATRIX_ONBOARDING_BOT_TOKEN",
}
legacyRoomsMap := map[string]string{
"gateway": "MATRIX_MANA_BOT_ROOMS",
"todo": "MATRIX_TODO_BOT_ROOMS",
"calendar": "MATRIX_CALENDAR_BOT_ROOMS",
"clock": "MATRIX_CLOCK_BOT_ROOMS",
"ollama": "MATRIX_OLLAMA_BOT_ROOMS",
"stats": "MATRIX_STATS_BOT_ROOMS",
"contacts": "MATRIX_CONTACTS_BOT_ROOMS",
"chat": "MATRIX_CHAT_BOT_ROOMS",
"cards": "MATRIX_CARDS_BOT_ROOMS",
"nutriphi": "MATRIX_NUTRIPHI_BOT_ROOMS",
"picture": "MATRIX_PICTURE_BOT_ROOMS",
"planta": "MATRIX_PLANTA_BOT_ROOMS",
"presi": "MATRIX_PRESI_BOT_ROOMS",
"questions": "MATRIX_QUESTIONS_BOT_ROOMS",
"skilltree": "MATRIX_SKILLTREE_BOT_ROOMS",
"storage": "MATRIX_STORAGE_BOT_ROOMS",
"projectdoc": "MATRIX_PROJECT_DOC_BOT_ROOMS",
"stt": "MATRIX_STT_BOT_ROOMS",
"tts": "MATRIX_TTS_BOT_ROOMS",
"zitare": "MATRIX_ZITARE_BOT_ROOMS",
"onboarding": "MATRIX_ONBOARDING_BOT_ROOMS",
}
// Backend URL defaults per plugin
backendURLMap := map[string]string{
"todo": "TODO_BACKEND_URL",
"calendar": "CALENDAR_BACKEND_URL",
"clock": "CLOCK_BACKEND_URL",
"contacts": "CONTACTS_BACKEND_URL",
"chat": "CHAT_BACKEND_URL",
"cards": "CARDS_BACKEND_URL",
"nutriphi": "NUTRIPHI_BACKEND_URL",
"picture": "PICTURE_BACKEND_URL",
"planta": "PLANTA_BACKEND_URL",
"presi": "PRESI_BACKEND_URL",
"questions": "QUESTIONS_BACKEND_URL",
"skilltree": "SKILLTREE_BACKEND_URL",
"storage": "STORAGE_BACKEND_URL",
"projectdoc": "PROJECTDOC_BACKEND_URL",
"zitare": "ZITARE_BACKEND_URL",
}
for _, name := range pluginNames {
upper := strings.ToUpper(name)
// Access token: try PLUGIN_*_ACCESS_TOKEN first, then legacy
token := os.Getenv("PLUGIN_" + upper + "_ACCESS_TOKEN")
if token == "" {
if legacyEnv, ok := legacyTokenMap[name]; ok {
token = os.Getenv(legacyEnv)
}
}
// Enabled: explicit env or auto-detect from token presence
enabledStr := os.Getenv("PLUGIN_" + upper + "_ENABLED")
enabled := token != ""
if enabledStr != "" {
enabled = enabledStr == "true" || enabledStr == "1"
}
// Allowed rooms
var rooms []string
roomsStr := os.Getenv("PLUGIN_" + upper + "_ALLOWED_ROOMS")
if roomsStr == "" {
if legacyEnv, ok := legacyRoomsMap[name]; ok {
roomsStr = os.Getenv(legacyEnv)
}
}
if roomsStr != "" {
for _, r := range strings.Split(roomsStr, ",") {
r = strings.TrimSpace(r)
if r != "" {
rooms = append(rooms, r)
}
}
}
// Backend URL
backendURL := ""
if envName, ok := backendURLMap[name]; ok {
backendURL = os.Getenv(envName)
}
// Extra config (plugin-specific env vars)
extra := make(map[string]string)
// Ollama-specific
if name == "ollama" || name == "gateway" {
extra["ollama_url"] = getEnv("OLLAMA_URL", "http://localhost:11434")
extra["ollama_model"] = getEnv("OLLAMA_MODEL", "gemma3:4b")
}
if name == "stt" || name == "gateway" {
extra["stt_url"] = cfg.STTURL
}
if name == "tts" || name == "gateway" {
extra["tts_url"] = cfg.TTSURL
}
// Gateway needs backend URLs for sub-handlers
if name == "gateway" {
extra["todo_url"] = getEnv("TODO_BACKEND_URL", "")
extra["calendar_url"] = getEnv("CALENDAR_BACKEND_URL", "")
extra["clock_url"] = getEnv("CLOCK_BACKEND_URL", "")
}
cfg.Plugins[name] = PluginConfig{
Enabled: enabled,
AccessToken: token,
AllowedRooms: rooms,
BackendURL: backendURL,
Extra: extra,
}
}
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View file

@ -1,241 +0,0 @@
package matrix
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"regexp"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
var mxcRegex = regexp.MustCompile(`^mxc://([^/]+)/(.+)$`)
// Client wraps mautrix.Client and implements the plugin.MatrixClient interface.
type Client struct {
inner *mautrix.Client
homeserver string
accessToken string
storagePath string
logger *slog.Logger
}
// ClientConfig holds configuration for creating a Matrix client.
type ClientConfig struct {
HomeserverURL string
AccessToken string
StoragePath string // path for sync state file
PluginName string
}
// NewClient creates a new Matrix client wrapper.
func NewClient(cfg ClientConfig) (*Client, error) {
userID := id.UserID("") // will be resolved via whoami
client, err := mautrix.NewClient(cfg.HomeserverURL, userID, cfg.AccessToken)
if err != nil {
return nil, fmt.Errorf("create mautrix client: %w", err)
}
// Ensure storage directory exists
if cfg.StoragePath != "" {
dir := filepath.Dir(cfg.StoragePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create storage dir: %w", err)
}
}
logger := slog.With("plugin", cfg.PluginName)
return &Client{
inner: client,
homeserver: cfg.HomeserverURL,
accessToken: cfg.AccessToken,
storagePath: cfg.StoragePath,
logger: logger,
}, nil
}
// Inner returns the underlying mautrix.Client for advanced operations.
func (c *Client) Inner() *mautrix.Client {
return c.inner
}
// Login resolves the bot's user ID via /whoami.
func (c *Client) Login(ctx context.Context) (id.UserID, error) {
resp, err := c.inner.Whoami(ctx)
if err != nil {
return "", fmt.Errorf("whoami: %w", err)
}
c.inner.UserID = resp.UserID
c.logger.Info("authenticated", "user_id", resp.UserID)
return resp.UserID, nil
}
// GetUserID returns the bot's Matrix user ID.
func (c *Client) GetUserID() string {
return c.inner.UserID.String()
}
// SendMessage sends a text message with markdown formatting to a room.
func (c *Client) SendMessage(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendReply sends a reply to a specific event.
func (c *Client) SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgText,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
content.SetReply(&event.Event{
RoomID: id.RoomID(roomID),
ID: id.EventID(eventID),
})
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SendNotice sends a notice (non-highlighted message).
func (c *Client) SendNotice(ctx context.Context, roomID string, text string) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgNotice,
Body: text,
Format: event.FormatHTML,
FormattedBody: MarkdownToHTML(text),
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// EditMessage edits an existing message.
func (c *Client) EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error) {
content := map[string]any{
"msgtype": "m.text",
"body": "* " + text,
"format": "org.matrix.custom.html",
"formatted_body": "* " + MarkdownToHTML(text),
"m.relates_to": map[string]any{
"rel_type": "m.replace",
"event_id": eventID,
},
"m.new_content": map[string]any{
"msgtype": "m.text",
"body": text,
"format": "org.matrix.custom.html",
"formatted_body": MarkdownToHTML(text),
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}
// SetTyping sets the typing indicator for the bot in a room.
func (c *Client) SetTyping(ctx context.Context, roomID string, typing bool) error {
timeout := time.Duration(0)
if typing {
timeout = 30 * time.Second
}
_, err := c.inner.UserTyping(ctx, id.RoomID(roomID), typing, timeout)
return err
}
// DownloadMedia downloads media from a mxc:// URL.
func (c *Client) DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error) {
matches := mxcRegex.FindStringSubmatch(mxcURL)
if len(matches) != 3 {
return nil, fmt.Errorf("invalid mxc URL: %s", mxcURL)
}
serverName := matches[1]
mediaID := matches[2]
// Try authenticated media API (Matrix spec v1.11+)
url := fmt.Sprintf("%s/_matrix/client/v1/media/download/%s/%s", c.homeserver, serverName, mediaID)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Fallback to legacy API
url = fmt.Sprintf("%s/_matrix/media/v3/download/%s/%s", c.homeserver, serverName, mediaID)
req2, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
return nil, fmt.Errorf("download media (legacy): %w", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download media failed: %d", resp2.StatusCode)
}
return io.ReadAll(resp2.Body)
}
return io.ReadAll(resp.Body)
}
// UploadMedia uploads media and returns the mxc:// URL.
func (c *Client) UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error) {
resp, err := c.inner.UploadBytes(ctx, data, contentType)
if err != nil {
return "", fmt.Errorf("upload media: %w", err)
}
return resp.ContentURI.String(), nil
}
// SendAudio sends an audio message to a room.
func (c *Client) SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error) {
content := &event.MessageEventContent{
MsgType: event.MsgAudio,
Body: filename,
URL: id.ContentURIString(mxcURL),
Info: &event.FileInfo{
MimeType: "audio/mpeg",
Size: size,
},
}
resp, err := c.inner.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
if err != nil {
return "", err
}
return resp.EventID.String(), nil
}

View file

@ -1,63 +0,0 @@
package matrix
import (
"fmt"
"regexp"
"strings"
)
var (
reBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
reItalic = regexp.MustCompile(`\*(.+?)\*`)
reStrikethrough = regexp.MustCompile(`~~(.+?)~~`)
reCode = regexp.MustCompile("`(.+?)`")
)
// MarkdownToHTML converts simple markdown to HTML for Matrix messages.
// Matches the exact behavior of the TypeScript markdownToHtml function.
func MarkdownToHTML(text string) string {
result := text
result = reBold.ReplaceAllString(result, "<strong>$1</strong>")
result = reItalic.ReplaceAllString(result, "<em>$1</em>")
result = reStrikethrough.ReplaceAllString(result, "<del>$1</del>")
result = reCode.ReplaceAllString(result, "<code>$1</code>")
result = strings.ReplaceAll(result, "\n", "<br>")
return result
}
// EscapeHTML escapes HTML special characters.
func EscapeHTML(text string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#039;",
)
return r.Replace(text)
}
// FormatNumberedList formats items as a numbered markdown list.
func FormatNumberedList[T any](items []T, formatter func(T, int) string) string {
var sb strings.Builder
for i, item := range items {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(fmt.Sprintf("%d. %s", i+1, formatter(item, i)))
}
return sb.String()
}
// FormatBulletList formats items as a bullet markdown list.
func FormatBulletList[T any](items []T, formatter func(T) string) string {
var sb strings.Builder
for i, item := range items {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString("• ")
sb.WriteString(formatter(item))
}
return sb.String()
}

View file

@ -1,64 +0,0 @@
package matrix
import "testing"
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
input string
want string
}{
{"**bold**", "<strong>bold</strong>"},
{"*italic*", "<em>italic</em>"},
{"~~strike~~", "<del>strike</del>"},
{"`code`", "<code>code</code>"},
{"line1\nline2", "line1<br>line2"},
{"**bold** and *italic*", "<strong>bold</strong> and <em>italic</em>"},
{"plain text", "plain text"},
{"", ""},
}
for _, tt := range tests {
got := MarkdownToHTML(tt.input)
if got != tt.want {
t.Errorf("MarkdownToHTML(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestEscapeHTML(t *testing.T) {
tests := []struct {
input string
want string
}{
{"<script>", "&lt;script&gt;"},
{`"quotes"`, "&quot;quotes&quot;"},
{"a & b", "a &amp; b"},
{"it's", "it&#039;s"},
{"plain", "plain"},
}
for _, tt := range tests {
got := EscapeHTML(tt.input)
if got != tt.want {
t.Errorf("EscapeHTML(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatNumberedList(t *testing.T) {
items := []string{"Apple", "Banana", "Cherry"}
got := FormatNumberedList(items, func(s string, i int) string { return s })
want := "1. Apple\n2. Banana\n3. Cherry"
if got != want {
t.Errorf("FormatNumberedList = %q, want %q", got, want)
}
}
func TestFormatBulletList(t *testing.T) {
items := []string{"Apple", "Banana"}
got := FormatBulletList(items, func(s string) string { return s })
want := "• Apple\n• Banana"
if got != want {
t.Errorf("FormatBulletList = %q, want %q", got, want)
}
}

View file

@ -1,75 +0,0 @@
package matrix
import (
"strings"
"maunium.net/go/mautrix/event"
)
// IsTextMessage checks if an event is a text message.
func IsTextMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgText
}
// IsAudioMessage checks if an event is an audio message.
func IsAudioMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgAudio
}
// IsImageMessage checks if an event is an image message.
func IsImageMessage(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.MsgType == event.MsgImage
}
// IsEditEvent checks if an event is a message edit (m.replace).
func IsEditEvent(evt *event.Event) bool {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return false
}
return content.RelatesTo != nil && content.RelatesTo.Type == event.RelReplace
}
// IsBot checks if a sender is a bot based on localpart naming convention.
func IsBot(sender string) bool {
// Extract localpart from @user:server format
if !strings.HasPrefix(sender, "@") {
return false
}
colonIdx := strings.Index(sender, ":")
if colonIdx == -1 {
return false
}
localpart := strings.ToLower(sender[1:colonIdx])
return strings.Contains(localpart, "-bot") || strings.HasSuffix(localpart, "bot")
}
// GetMessageBody extracts the text body from a message event.
func GetMessageBody(evt *event.Event) string {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return ""
}
return strings.TrimSpace(content.Body)
}
// GetMediaURL extracts the mxc:// URL from a media message event.
func GetMediaURL(evt *event.Event) string {
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
return ""
}
return string(content.URL)
}

View file

@ -1,27 +0,0 @@
package matrix
import "testing"
func TestIsBot(t *testing.T) {
tests := []struct {
sender string
want bool
}{
{"@todo-bot:mana.how", true},
{"@mana-bot:mana.how", true},
{"@ollama-bot:mana.how", true},
{"@statsbot:mana.how", true},
{"@till:mana.how", false},
{"@user:mana.how", false},
{"@bot-admin:mana.how", false}, // contains "bot-" not "-bot"
{"invalid", false},
{"", false},
}
for _, tt := range tests {
got := IsBot(tt.sender)
if got != tt.want {
t.Errorf("IsBot(%q) = %v, want %v", tt.sender, got, tt.want)
}
}
}

View file

@ -1,43 +0,0 @@
package plugin
import "strings"
// CommandRouter routes !command messages to the right handler.
type CommandRouter struct {
routes []route
}
type route struct {
pattern string
handler CommandHandler
}
// CommandHandler processes a command with its arguments.
type CommandHandler func(ctx *MessageContext, args string) error
// NewCommandRouter creates an empty command router.
func NewCommandRouter() *CommandRouter {
return &CommandRouter{}
}
// Handle registers a command pattern (e.g., "!todo") with a handler.
func (r *CommandRouter) Handle(pattern string, handler CommandHandler) {
r.routes = append(r.routes, route{pattern: strings.ToLower(pattern), handler: handler})
}
// Route attempts to match a message to a registered command.
// Returns true if a command was matched and handled.
func (r *CommandRouter) Route(mc *MessageContext) (bool, error) {
lower := strings.ToLower(mc.Body)
for _, rt := range r.routes {
if lower == rt.pattern {
return true, rt.handler(mc, "")
}
if strings.HasPrefix(lower, rt.pattern+" ") {
args := strings.TrimSpace(mc.Body[len(rt.pattern):])
return true, rt.handler(mc, args)
}
}
return false, nil
}

View file

@ -1,87 +0,0 @@
package plugin
import "strings"
// KeywordCommand maps keywords to a command name.
type KeywordCommand struct {
Keywords []string // lowercase keywords
Command string // command name to return
}
// KeywordDetector detects commands from natural language keywords.
type KeywordDetector struct {
commands []KeywordCommand
maxLength int
partialMatch bool
}
// KeywordDetectorOption configures the detector.
type KeywordDetectorOption func(*KeywordDetector)
// WithMaxLength sets the max message length to check (default: 60).
func WithMaxLength(n int) KeywordDetectorOption {
return func(d *KeywordDetector) { d.maxLength = n }
}
// WithPartialMatch enables partial word matching.
func WithPartialMatch(enabled bool) KeywordDetectorOption {
return func(d *KeywordDetector) { d.partialMatch = enabled }
}
// NewKeywordDetector creates a new keyword detector.
func NewKeywordDetector(commands []KeywordCommand, opts ...KeywordDetectorOption) *KeywordDetector {
d := &KeywordDetector{
commands: commands,
maxLength: 60,
}
for _, opt := range opts {
opt(d)
}
return d
}
// Detect returns the command name if a keyword matches, or empty string.
func (d *KeywordDetector) Detect(message string) string {
lower := strings.ToLower(strings.TrimSpace(message))
if len(lower) > d.maxLength {
return ""
}
for _, cmd := range d.commands {
for _, kw := range cmd.Keywords {
if d.matches(lower, kw) {
return cmd.Command
}
}
}
return ""
}
// AddCommands appends more commands dynamically.
func (d *KeywordDetector) AddCommands(commands []KeywordCommand) {
d.commands = append(d.commands, commands...)
}
func (d *KeywordDetector) matches(message, keyword string) bool {
// Exact match
if message == keyword {
return true
}
// Prefix match: keyword followed by space
if strings.HasPrefix(message, keyword+" ") {
return true
}
// Partial match
if d.partialMatch && strings.Contains(message, keyword) {
return true
}
return false
}
// CommonKeywords are shared across all bots (German + English).
var CommonKeywords = []KeywordCommand{
{Keywords: []string{"hilfe", "help", "befehle", "commands", "?"}, Command: "help"},
{Keywords: []string{"status", "info"}, Command: "status"},
{Keywords: []string{"abbrechen", "cancel", "stop"}, Command: "cancel"},
}

View file

@ -1,78 +0,0 @@
package plugin
import "testing"
func TestKeywordDetector_Detect(t *testing.T) {
detector := NewKeywordDetector([]KeywordCommand{
{Keywords: []string{"hilfe", "help"}, Command: "help"},
{Keywords: []string{"zeige aufgaben", "show tasks"}, Command: "list"},
{Keywords: []string{"status"}, Command: "status"},
})
tests := []struct {
input string
want string
}{
{"hilfe", "help"},
{"Hilfe", "help"},
{"HILFE", "help"},
{"hilfe bitte", "help"},
{"help", "help"},
{"zeige aufgaben", "list"},
{"show tasks", "list"},
{"status", "status"},
{"status info", "status"},
{"was ist los", ""},
{"", ""},
// Long message should be skipped
{string(make([]byte, 100)), ""},
}
for _, tt := range tests {
got := detector.Detect(tt.input)
if got != tt.want {
t.Errorf("Detect(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestKeywordDetector_PartialMatch(t *testing.T) {
detector := NewKeywordDetector(
[]KeywordCommand{
{Keywords: []string{"aufgabe"}, Command: "task"},
},
WithPartialMatch(true),
)
tests := []struct {
input string
want string
}{
{"neue aufgabe erstellen", "task"},
{"aufgabe", "task"},
{"nix", ""},
}
for _, tt := range tests {
got := detector.Detect(tt.input)
if got != tt.want {
t.Errorf("Detect(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestKeywordDetector_MaxLength(t *testing.T) {
detector := NewKeywordDetector(
[]KeywordCommand{
{Keywords: []string{"help"}, Command: "help"},
},
WithMaxLength(10),
)
if got := detector.Detect("help"); got != "help" {
t.Errorf("short message should match, got %q", got)
}
if got := detector.Detect("help me please now"); got != "" {
t.Errorf("long message should be skipped, got %q", got)
}
}

View file

@ -1,99 +0,0 @@
package plugin
import (
"context"
"time"
)
// MessageContext carries all information about an incoming message.
type MessageContext struct {
RoomID string
Sender string
EventID string
Body string // text body (for text messages)
IsVoice bool // true if transcribed from audio
Client MatrixClient
Session *SessionAccess
}
// MatrixClient is the interface plugins use to interact with Matrix.
type MatrixClient interface {
SendMessage(ctx context.Context, roomID string, text string) (string, error)
SendReply(ctx context.Context, roomID string, eventID string, text string) (string, error)
SendNotice(ctx context.Context, roomID string, text string) (string, error)
EditMessage(ctx context.Context, roomID string, eventID string, text string) (string, error)
SetTyping(ctx context.Context, roomID string, typing bool) error
DownloadMedia(ctx context.Context, mxcURL string) ([]byte, error)
UploadMedia(ctx context.Context, data []byte, contentType string, filename string) (string, error)
SendAudio(ctx context.Context, roomID string, mxcURL string, filename string, size int) (string, error)
GetUserID() string
}
// SessionAccess provides per-user session operations for a plugin.
type SessionAccess struct {
UserID string
Manager SessionManager
}
// SessionManager is the interface for session storage.
type SessionManager interface {
Get(userID, key string) (any, bool)
Set(userID, key string, value any)
Delete(userID, key string)
GetToken(userID string) (string, bool)
SetToken(userID, token string, expiresAt time.Time)
IsLoggedIn(userID string) bool
}
// Plugin is the interface every bot plugin must implement.
type Plugin interface {
// Name returns the unique plugin identifier (e.g., "todo", "calendar").
Name() string
// Init is called once during startup with the plugin's config.
Init(ctx context.Context, cfg PluginConfig) error
// HandleTextMessage is called for m.text events.
HandleTextMessage(ctx context.Context, mc *MessageContext) error
// Commands returns the list of commands this plugin handles.
Commands() []CommandDef
}
// AudioHandler is optionally implemented by plugins that handle voice messages.
type AudioHandler interface {
HandleAudioMessage(ctx context.Context, mc *MessageContext, audioData []byte) error
}
// ImageHandler is optionally implemented by plugins that handle image messages.
type ImageHandler interface {
HandleImageMessage(ctx context.Context, mc *MessageContext) error
}
// Scheduler is optionally implemented by plugins that need periodic tasks.
type Scheduler interface {
ScheduledTasks() []ScheduledTask
}
// ScheduledTask defines a periodic task.
type ScheduledTask struct {
Name string
Interval time.Duration
Run func(ctx context.Context) error
}
// CommandDef describes a command for help text and routing.
type CommandDef struct {
Patterns []string // e.g., ["!todo", "!add", "!neu"]
Description string
Category string // e.g., "Aufgaben", "Kalender"
}
// PluginConfig holds per-plugin configuration.
type PluginConfig struct {
Enabled bool
AccessToken string
AllowedRooms []string
BackendURL string
Extra map[string]string
}

View file

@ -1,29 +0,0 @@
package plugin
import "fmt"
var registry = make(map[string]func() Plugin)
// Register adds a plugin factory to the registry.
// Called from each plugin's init() function or explicitly in main.
func Register(name string, factory func() Plugin) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("plugin %q already registered", name))
}
registry[name] = factory
}
// All returns all registered plugin factories.
func All() map[string]func() Plugin {
result := make(map[string]func() Plugin, len(registry))
for k, v := range registry {
result[k] = v
}
return result
}
// Get returns a plugin factory by name.
func Get(name string) (func() Plugin, bool) {
f, ok := registry[name]
return f, ok
}

View file

@ -1,410 +0,0 @@
package calendar
import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("calendar", func() plugin.Plugin { return &CalendarPlugin{} })
}
// Event represents a calendar event from the backend.
type Event struct {
ID string `json:"id"`
Title string `json:"title"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsAllDay bool `json:"isAllDay"`
Location *string `json:"location"`
Notes *string `json:"notes"`
Calendar *string `json:"calendar"`
}
// CreateEventInput is the request body for creating an event.
type CreateEventInput struct {
Title string `json:"title"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
IsAllDay bool `json:"isAllDay"`
Location *string `json:"location,omitempty"`
}
// CalendarPlugin implements the Matrix calendar bot.
type CalendarPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *CalendarPlugin) Name() string { return "calendar" }
func (p *CalendarPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("calendar plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!tomorrow", p.cmdTomorrow)
p.router.Handle("!morgen", p.cmdTomorrow)
p.router.Handle("!week", p.cmdWeek)
p.router.Handle("!woche", p.cmdWeek)
p.router.Handle("!events", p.cmdUpcoming)
p.router.Handle("!termine", p.cmdUpcoming)
p.router.Handle("!upcoming", p.cmdUpcoming)
p.router.Handle("!termin", p.cmdCreate)
p.router.Handle("!event", p.cmdCreate)
p.router.Handle("!neu", p.cmdCreate)
p.router.Handle("!add", p.cmdCreate)
p.router.Handle("!details", p.cmdDetails)
p.router.Handle("!info", p.cmdDetails)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!löschen", p.cmdDelete)
p.router.Handle("!entfernen", p.cmdDelete)
p.router.Handle("!calendars", p.cmdCalendars)
p.router.Handle("!kalender", p.cmdCalendars)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"was steht heute an", "termine heute"}, Command: "today"},
plugin.KeywordCommand{Keywords: []string{"termine morgen", "was ist morgen"}, Command: "tomorrow"},
plugin.KeywordCommand{Keywords: []string{"diese woche", "wochenübersicht"}, Command: "week"},
plugin.KeywordCommand{Keywords: []string{"zeige kalender", "meine kalender"}, Command: "calendars"},
))
slog.Info("calendar plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *CalendarPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!today", "!heute"}, Description: "Heutige Termine", Category: "Kalender"},
{Patterns: []string{"!tomorrow", "!morgen"}, Description: "Termine morgen", Category: "Kalender"},
{Patterns: []string{"!week", "!woche"}, Description: "Wochenübersicht", Category: "Kalender"},
{Patterns: []string{"!events", "!termine"}, Description: "Nächste 14 Tage", Category: "Kalender"},
{Patterns: []string{"!termin", "!event"}, Description: "Neuen Termin erstellen", Category: "Kalender"},
{Patterns: []string{"!details [nr]"}, Description: "Termin-Details", Category: "Kalender"},
{Patterns: []string{"!delete [nr]", "!löschen [nr]"}, Description: "Termin löschen", Category: "Kalender"},
{Patterns: []string{"!calendars", "!kalender"}, Description: "Kalender anzeigen", Category: "Kalender"},
}
}
func (p *CalendarPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "today":
return p.cmdToday(mc, "")
case "tomorrow":
return p.cmdTomorrow(mc, "")
case "week":
return p.cmdWeek(mc, "")
case "calendars":
return p.cmdCalendars(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *CalendarPlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
return p.fetchAndShowEvents(mc, "/api/events/today", "📅 **Termine heute:**", "Keine Termine für heute.")
}
func (p *CalendarPlugin) cmdTomorrow(mc *plugin.MessageContext, _ string) error {
return p.fetchAndShowEvents(mc, "/api/events/tomorrow", "📅 **Termine morgen:**", "Keine Termine für morgen.")
}
func (p *CalendarPlugin) cmdWeek(mc *plugin.MessageContext, _ string) error {
return p.fetchAndShowEvents(mc, "/api/events/week", "📅 **Diese Woche:**", "Keine Termine diese Woche.")
}
func (p *CalendarPlugin) cmdUpcoming(mc *plugin.MessageContext, _ string) error {
return p.fetchAndShowEvents(mc, "/api/events/upcoming?days=14", "📅 **Nächste 14 Tage:**", "Keine anstehenden Termine.")
}
func (p *CalendarPlugin) fetchAndShowEvents(mc *plugin.MessageContext, path, header, emptyMsg string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var events []Event
if err := p.backend.Get(ctx, path, token, &events); err != nil {
slog.Error("fetch events failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termine konnten nicht geladen werden.")
return nil
}
if len(events) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 "+emptyMsg)
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatEventList(header, events))
return nil
}
func (p *CalendarPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Termin an.\n\nBeispiel: `!termin Meeting morgen um 14:00`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
title, startTime, endTime, isAllDay := parseEventInput(args)
input := CreateEventInput{
Title: title,
StartTime: startTime,
EndTime: endTime,
IsAllDay: isAllDay,
}
var event Event
if err := p.backend.Post(ctx, "/api/events", token, input, &event); err != nil {
slog.Error("create event failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termin konnte nicht erstellt werden.")
return nil
}
response := fmt.Sprintf("✅ Termin erstellt: **%s**\n📆 %s", event.Title, formatEventTime(event))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *CalendarPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Details-Funktion wird noch implementiert.")
return nil
}
func (p *CalendarPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine gültige Terminnummer an.\n\nBeispiel: `!delete 1`")
return nil
}
// Get current events to find by number
var events []Event
if err := p.backend.Get(ctx, "/api/events/upcoming?days=14", token, &events); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termine konnten nicht geladen werden.")
return nil
}
if num > len(events) {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Termin #%d nicht gefunden.", num))
return nil
}
event := events[num-1]
if err := p.backend.Delete(ctx, "/api/events/"+event.ID, token); err != nil {
slog.Error("delete event failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termin konnte nicht gelöscht werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", event.Title))
return nil
}
func (p *CalendarPlugin) cmdCalendars(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var calendars []struct {
Name string `json:"name"`
ID string `json:"id"`
}
if err := p.backend.Get(ctx, "/api/calendars", token, &calendars); err != nil {
slog.Error("get calendars failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kalender konnten nicht geladen werden.")
return nil
}
if len(calendars) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kalender vorhanden.")
return nil
}
var sb strings.Builder
sb.WriteString("**Deine Kalender:**\n\n")
for _, cal := range calendars {
sb.WriteString("• ")
sb.WriteString(cal.Name)
sb.WriteByte('\n')
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *CalendarPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
status := "❌ Nicht angemeldet"
if loggedIn {
status = "✅ Angemeldet"
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📊 **Status**\n\n%s\n🔄 Synchronisiert mit calendar-backend", status))
return nil
}
func (p *CalendarPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**📅 Calendar Bot - Befehle**
**Termine anzeigen:**
` + "`!heute`" + ` Heutige Termine
` + "`!morgen`" + ` Termine morgen
` + "`!woche`" + ` Wochenübersicht
` + "`!termine`" + ` Nächste 14 Tage
**Verwalten:**
` + "`!termin Meeting morgen um 14:00`" + ` Neuer Termin
` + "`!löschen 1`" + ` Termin #1 löschen
` + "`!kalender`" + ` Kalender anzeigen
**System:**
` + "`!status`" + ` Verbindungsstatus
` + "`!hilfe`" + ` Diese Hilfe`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Formatting ---
func formatEventList(header string, events []Event) string {
var sb strings.Builder
sb.WriteString(header)
sb.WriteString("\n\n")
for i, evt := range events {
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, evt.Title))
sb.WriteString(fmt.Sprintf(" 🕐 %s\n", formatEventTime(evt)))
}
sb.WriteString("\n📋 Details: `!details [Nr]` | 🗑️ Löschen: `!löschen [Nr]`")
return sb.String()
}
func formatEventTime(evt Event) string {
if evt.IsAllDay {
return "Ganztägig"
}
t, err := time.Parse(time.RFC3339, evt.StartTime)
if err != nil {
return evt.StartTime
}
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
eventDate := t.Format("2006-01-02")
var dayStr string
switch eventDate {
case today:
dayStr = "Heute"
case tomorrow:
dayStr = "Morgen"
default:
dayStr = t.Format("02.01")
}
return fmt.Sprintf("%s, %s", dayStr, t.Format("15:04"))
}
// --- Input Parsing ---
var reTime = regexp.MustCompile(`(?i)(?:um\s+)?(\d{1,2}):(\d{2})`)
func parseEventInput(input string) (title string, startTime string, endTime string, isAllDay bool) {
now := time.Now()
startDate := now
// Check for date keywords
lower := strings.ToLower(input)
if strings.Contains(lower, "morgen") || strings.Contains(lower, "tomorrow") {
startDate = now.AddDate(0, 0, 1)
input = strings.NewReplacer("morgen", "", "tomorrow", "").Replace(input)
} else if strings.Contains(lower, "übermorgen") {
startDate = now.AddDate(0, 0, 2)
input = strings.Replace(input, "übermorgen", "", 1)
}
// Check for "ganztägig"
if strings.Contains(lower, "ganztägig") || strings.Contains(lower, "ganztaegig") || strings.Contains(lower, "all day") {
isAllDay = true
input = strings.NewReplacer("ganztägig", "", "ganztaegig", "", "all day", "").Replace(input)
}
// Extract time
if m := reTime.FindStringSubmatch(input); len(m) == 3 {
h, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), h, min, 0, 0, startDate.Location())
input = reTime.ReplaceAllString(input, "")
} else if !isAllDay {
// Default to current time + 1 hour
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), now.Hour()+1, 0, 0, 0, now.Location())
}
// Remove "um" keyword
input = strings.NewReplacer(" um ", " ", "Um ", "").Replace(input)
startTime = startDate.Format(time.RFC3339)
endTime = startDate.Add(1 * time.Hour).Format(time.RFC3339)
title = strings.TrimSpace(input)
if title == "" {
title = "Neuer Termin"
}
return
}

View file

@ -1,66 +0,0 @@
package cards
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("cards", func() plugin.Plugin { return &CardsPlugin{} })
}
type CardsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *CardsPlugin) Name() string { return "cards" }
func (p *CardsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!decks", p.cmdDecks)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("cards plugin initialized")
return nil
}
func (p *CardsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!decks"}, Description: "Alle Decks", Category: "Cards"},
{Patterns: []string{"!status"}, Description: "Status", Category: "System"},
}
}
func (p *CardsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *CardsPlugin) cmdDecks(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🃏 Decks**\n\n_Deck-Verwaltung über die Web-App._")
return nil
}
func (p *CardsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Cards Bot:** ✅ Online")
return nil
}
func (p *CardsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🃏 Cards Bot**\n\n• `!decks` — Decks anzeigen\n• `!status` — Status")
return nil
}

View file

@ -1,65 +0,0 @@
package chat
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("chat", func() plugin.Plugin { return &ChatPlugin{} })
}
// ChatPlugin proxies to the chat backend for conversation management.
type ChatPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ChatPlugin) Name() string { return "chat" }
func (p *ChatPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("chat plugin initialized")
return nil
}
func (p *ChatPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!help"}, Description: "Hilfe", Category: "System"},
{Patterns: []string{"!status"}, Description: "Status", Category: "System"},
}
}
func (p *ChatPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *ChatPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Chat Bot:** ✅ Online")
return nil
}
func (p *ChatPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**💬 Chat Bot**\n\n• `!status` — Bot-Status\n• `!hilfe` — Diese Hilfe")
return nil
}

View file

@ -1,441 +0,0 @@
package clock
import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("clock", func() plugin.Plugin { return &ClockPlugin{} })
}
// Timer represents a timer from the backend.
type Timer struct {
ID string `json:"id"`
Label string `json:"label"`
Duration int `json:"duration"` // total seconds
Remaining int `json:"remaining"` // remaining seconds
Status string `json:"status"` // running, paused, finished
}
// Alarm represents an alarm from the backend.
type Alarm struct {
ID string `json:"id"`
Time string `json:"time"` // HH:MM
Label string `json:"label"`
}
// ClockPlugin implements the Matrix clock bot.
type ClockPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ClockPlugin) Name() string { return "clock" }
func (p *ClockPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("clock plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!timer", p.cmdTimer)
p.router.Handle("!stop", p.cmdStop)
p.router.Handle("!stopp", p.cmdStop)
p.router.Handle("!pause", p.cmdStop)
p.router.Handle("!resume", p.cmdResume)
p.router.Handle("!weiter", p.cmdResume)
p.router.Handle("!reset", p.cmdReset)
p.router.Handle("!timers", p.cmdTimers)
p.router.Handle("!alarm", p.cmdAlarm)
p.router.Handle("!alarms", p.cmdAlarms)
p.router.Handle("!alarme", p.cmdAlarms)
p.router.Handle("!zeit", p.cmdTime)
p.router.Handle("!time", p.cmdTime)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"timer status", "laufend"}, Command: "status"},
plugin.KeywordCommand{Keywords: []string{"stopp", "anhalten"}, Command: "stop"},
plugin.KeywordCommand{Keywords: []string{"weiter", "fortsetzen"}, Command: "resume"},
plugin.KeywordCommand{Keywords: []string{"zeit", "uhrzeit", "wie spät"}, Command: "time"},
))
slog.Info("clock plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *ClockPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!timer [dauer]"}, Description: "Timer starten (25m, 1h30m)", Category: "Timer"},
{Patterns: []string{"!stop", "!stopp"}, Description: "Timer pausieren", Category: "Timer"},
{Patterns: []string{"!resume", "!weiter"}, Description: "Timer fortsetzen", Category: "Timer"},
{Patterns: []string{"!reset"}, Description: "Timer zurücksetzen", Category: "Timer"},
{Patterns: []string{"!timers"}, Description: "Alle Timer", Category: "Timer"},
{Patterns: []string{"!alarm [zeit]"}, Description: "Alarm setzen (07:30)", Category: "Alarm"},
{Patterns: []string{"!alarms", "!alarme"}, Description: "Alle Alarme", Category: "Alarm"},
{Patterns: []string{"!zeit", "!time"}, Description: "Aktuelle Uhrzeit", Category: "Uhr"},
}
}
func (p *ClockPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "status":
return p.cmdStatus(mc, "")
case "stop":
return p.cmdStop(mc, "")
case "resume":
return p.cmdResume(mc, "")
case "time":
return p.cmdTime(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *ClockPlugin) cmdTimer(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Dauer an.\n\nBeispiel: `!timer 25m` oder `!timer 1h30m`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
// Parse duration and optional label
parts := strings.SplitN(args, " ", 2)
seconds := parseDuration(parts[0])
if seconds <= 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ungültige Dauer. Beispiele: `25m`, `1h`, `1h30m`, `90`")
return nil
}
label := ""
if len(parts) > 1 {
label = parts[1]
}
body := map[string]any{
"duration": seconds,
"label": label,
}
var timer Timer
if err := p.backend.Post(ctx, "/api/v1/timers", token, body, &timer); err != nil {
slog.Error("create timer failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Timer konnte nicht erstellt werden.")
return nil
}
// Start the timer
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/start", token, nil, &timer)
response := fmt.Sprintf("▶️ **Timer gestartet**\n\n⏱ %s", formatDuration(seconds))
if label != "" {
response += fmt.Sprintf("\n📝 %s", label)
}
response += "\n\n`!stop` zum Pausieren"
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *ClockPlugin) cmdStop(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var timer Timer
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein laufender Timer gefunden.")
return nil
}
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/pause", token, nil, nil)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("⏸️ **Timer pausiert**\n\nVerbleibend: %s\n\n`!resume` zum Fortsetzen, `!reset` zum Zurücksetzen",
formatDuration(timer.Remaining)))
return nil
}
func (p *ClockPlugin) cmdResume(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var timer Timer
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein pausierter Timer gefunden.")
return nil
}
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/resume", token, nil, nil)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("▶️ **Timer läuft weiter**\n\nVerbleibend: %s", formatDuration(timer.Remaining)))
return nil
}
func (p *ClockPlugin) cmdReset(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var timer Timer
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein aktiver Timer gefunden.")
return nil
}
p.backend.Post(ctx, "/api/v1/timers/"+timer.ID+"/reset", token, nil, nil)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🔄 Timer zurückgesetzt.")
return nil
}
func (p *ClockPlugin) cmdTimers(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var timers []Timer
if err := p.backend.Get(ctx, "/api/v1/timers", token, &timers); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Timer konnten nicht geladen werden.")
return nil
}
if len(timers) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Timer vorhanden.\n\nNeuen Timer: `!timer 25m`")
return nil
}
var sb strings.Builder
sb.WriteString("**⏱️ Timer:**\n\n")
for _, t := range timers {
icon := "⏸️"
if t.Status == "running" {
icon = "▶️"
} else if t.Status == "finished" {
icon = "✅"
}
sb.WriteString(fmt.Sprintf("%s **%s** — %s / %s\n", icon, t.Label, formatDuration(t.Remaining), formatDuration(t.Duration)))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ClockPlugin) cmdAlarm(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Uhrzeit an.\n\nBeispiel: `!alarm 07:30 Aufstehen`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
parts := strings.SplitN(args, " ", 2)
alarmTime := parseAlarmTime(parts[0])
if alarmTime == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ungültige Uhrzeit. Beispiel: `07:30`")
return nil
}
label := ""
if len(parts) > 1 {
label = parts[1]
}
body := map[string]any{
"time": alarmTime,
"label": label,
}
var alarm Alarm
if err := p.backend.Post(ctx, "/api/v1/alarms", token, body, &alarm); err != nil {
slog.Error("create alarm failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Alarm konnte nicht erstellt werden.")
return nil
}
response := fmt.Sprintf("⏰ **Alarm gestellt!**\n\nZeit: %s Uhr", alarmTime)
if label != "" {
response += fmt.Sprintf("\n📝 %s", label)
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *ClockPlugin) cmdAlarms(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var alarms []Alarm
if err := p.backend.Get(ctx, "/api/v1/alarms", token, &alarms); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Alarme konnten nicht geladen werden.")
return nil
}
if len(alarms) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Alarme gesetzt.\n\nNeuen Alarm: `!alarm 07:30`")
return nil
}
var sb strings.Builder
sb.WriteString("**⏰ Alarme:**\n\n")
for _, a := range alarms {
sb.WriteString(fmt.Sprintf("• %s Uhr", a.Time))
if a.Label != "" {
sb.WriteString(fmt.Sprintf(" — %s", a.Label))
}
sb.WriteByte('\n')
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ClockPlugin) cmdTime(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
now := time.Now()
days := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
months := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
response := fmt.Sprintf("**%s Uhr**\n%s, %d. %s %d",
now.Format("15:04"),
days[now.Weekday()],
now.Day(),
months[now.Month()],
now.Year())
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *ClockPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var timer Timer
timerStatus := "Kein aktiver Timer"
if err := p.backend.Get(ctx, "/api/v1/timers/running", token, &timer); err == nil {
timerStatus = fmt.Sprintf("▶️ %s — %s / %s", timer.Label, formatDuration(timer.Remaining), formatDuration(timer.Duration))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**🕐 Clock Bot Status**\n\n%s", timerStatus))
return nil
}
func (p *ClockPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🕐 Clock Bot - Befehle**
**Timer:**
` + "`!timer 25m`" + ` Timer starten
` + "`!stop`" + ` Pausieren
` + "`!resume`" + ` Fortsetzen
` + "`!reset`" + ` Zurücksetzen
` + "`!timers`" + ` Alle Timer
**Alarm:**
` + "`!alarm 07:30 Aufstehen`" + ` Alarm setzen
` + "`!alarme`" + ` Alle Alarme
**Uhr:**
` + "`!zeit`" + ` Aktuelle Uhrzeit
**Dauer-Formate:** ` + "`25m`" + `, ` + "`1h`" + `, ` + "`1h30m`" + `, ` + "`90`" + ` (Minuten)`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Parsing ---
var reDuration = regexp.MustCompile(`(?i)^(?:(\d+)h)?(?:(\d+)m?)?$`)
func parseDuration(input string) int {
input = strings.TrimSpace(strings.ToLower(input))
m := reDuration.FindStringSubmatch(input)
if m == nil {
// Try plain number (minutes)
if n, err := strconv.Atoi(input); err == nil && n > 0 {
return n * 60
}
return 0
}
hours := 0
minutes := 0
if m[1] != "" {
hours, _ = strconv.Atoi(m[1])
}
if m[2] != "" {
minutes, _ = strconv.Atoi(m[2])
}
total := hours*3600 + minutes*60
if total == 0 && hours == 0 && minutes == 0 {
return 0
}
return total
}
func parseAlarmTime(input string) string {
input = strings.TrimSpace(input)
// Match HH:MM
if matched, _ := regexp.MatchString(`^\d{1,2}:\d{2}$`, input); matched {
return input
}
// Match "7 Uhr 30" or "7 30"
re := regexp.MustCompile(`^(\d{1,2})\s*(?:uhr\s*)?(\d{2})?$`)
if m := re.FindStringSubmatch(strings.ToLower(input)); m != nil {
h := m[1]
min := "00"
if m[2] != "" {
min = m[2]
}
return fmt.Sprintf("%s:%s", h, min)
}
return ""
}
func formatDuration(seconds int) string {
if seconds < 0 {
seconds = 0
}
h := seconds / 3600
m := (seconds % 3600) / 60
s := seconds % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}

View file

@ -1,71 +0,0 @@
package clock
import "testing"
func TestParseDuration(t *testing.T) {
tests := []struct {
input string
want int // seconds
}{
{"25m", 25 * 60},
{"25", 25 * 60},
{"1h", 3600},
{"1h30m", 5400},
{"2h", 7200},
{"90", 90 * 60},
{"1h0m", 3600},
{"0", 0},
{"abc", 0},
{"", 0},
}
for _, tt := range tests {
got := parseDuration(tt.input)
if got != tt.want {
t.Errorf("parseDuration(%q) = %d, want %d", tt.input, got, tt.want)
}
}
}
func TestParseAlarmTime(t *testing.T) {
tests := []struct {
input string
want string
}{
{"07:30", "07:30"},
{"7:30", "7:30"},
{"14:00", "14:00"},
{"7 uhr 30", "7:30"},
{"7 30", "7:30"},
{"7", "7:00"},
{"abc", ""},
}
for _, tt := range tests {
got := parseAlarmTime(tt.input)
if got != tt.want {
t.Errorf("parseAlarmTime(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatDuration(t *testing.T) {
tests := []struct {
seconds int
want string
}{
{0, "0:00"},
{65, "1:05"},
{3600, "1:00:00"},
{3661, "1:01:01"},
{1500, "25:00"},
{-5, "0:00"},
}
for _, tt := range tests {
got := formatDuration(tt.seconds)
if got != tt.want {
t.Errorf("formatDuration(%d) = %q, want %q", tt.seconds, got, tt.want)
}
}
}

View file

@ -1,543 +0,0 @@
package contacts
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("contacts", func() plugin.Plugin { return &ContactsPlugin{} })
}
// Contact represents a contact from the backend.
type Contact struct {
ID string `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Mobile *string `json:"mobile"`
Company *string `json:"company"`
JobTitle *string `json:"jobTitle"`
Website *string `json:"website"`
Street *string `json:"street"`
City *string `json:"city"`
PostalCode *string `json:"postalCode"`
Country *string `json:"country"`
Notes *string `json:"notes"`
Birthday *string `json:"birthday"`
IsFavorite bool `json:"isFavorite"`
IsArchived bool `json:"isArchived"`
}
// ContactsResponse wraps the paginated contacts response.
type ContactsResponse struct {
Contacts []Contact `json:"contacts"`
Total int `json:"total"`
}
// ContactsPlugin implements the Matrix contacts bot.
type ContactsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
// Per-user last-shown contact lists for number references
lastList map[string][]Contact
}
func (p *ContactsPlugin) Name() string { return "contacts" }
func (p *ContactsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("contacts plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastList = make(map[string][]Contact)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!kontakte", p.cmdList)
p.router.Handle("!contacts", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!list", p.cmdList)
p.router.Handle("!suche", p.cmdSearch)
p.router.Handle("!search", p.cmdSearch)
p.router.Handle("!favoriten", p.cmdFavorites)
p.router.Handle("!favorites", p.cmdFavorites)
p.router.Handle("!favs", p.cmdFavorites)
p.router.Handle("!kontakt", p.cmdDetails)
p.router.Handle("!contact", p.cmdDetails)
p.router.Handle("!details", p.cmdDetails)
p.router.Handle("!neu", p.cmdCreate)
p.router.Handle("!new", p.cmdCreate)
p.router.Handle("!add", p.cmdCreate)
p.router.Handle("!edit", p.cmdEdit)
p.router.Handle("!bearbeiten", p.cmdEdit)
p.router.Handle("!loeschen", p.cmdDelete)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!del", p.cmdDelete)
p.router.Handle("!fav", p.cmdToggleFav)
p.router.Handle("!favorit", p.cmdToggleFav)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"kontakte", "contacts", "alle"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"favoriten", "favorites", "favs"}, Command: "favorites"},
plugin.KeywordCommand{Keywords: []string{"suche", "search", "finde"}, Command: "search"},
))
slog.Info("contacts plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *ContactsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!kontakte", "!contacts"}, Description: "Alle Kontakte", Category: "Kontakte"},
{Patterns: []string{"!suche [text]", "!search"}, Description: "Kontakte suchen", Category: "Kontakte"},
{Patterns: []string{"!favoriten"}, Description: "Favoriten", Category: "Kontakte"},
{Patterns: []string{"!kontakt [nr]"}, Description: "Kontakt-Details", Category: "Kontakte"},
{Patterns: []string{"!neu Vorname Nachname"}, Description: "Neuer Kontakt", Category: "Kontakte"},
{Patterns: []string{"!edit [nr] [feld] [wert]"}, Description: "Kontakt bearbeiten", Category: "Kontakte"},
{Patterns: []string{"!delete [nr]"}, Description: "Kontakt löschen", Category: "Kontakte"},
{Patterns: []string{"!fav [nr]"}, Description: "Favorit umschalten", Category: "Kontakte"},
}
}
func (p *ContactsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdList(mc, "")
case "favorites":
return p.cmdFavorites(mc, "")
case "search":
return p.cmdSearch(mc, mc.Body)
}
return nil
}
// --- Command Handlers ---
func (p *ContactsPlugin) cmdList(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?limit=20", token, &resp); err != nil {
slog.Error("get contacts failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakte konnten nicht geladen werden.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kontakte vorhanden.\n\nNeuer Kontakt: `!neu Vorname Nachname`")
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList(fmt.Sprintf("**Deine Kontakte (%d):**", resp.Total), resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdSearch(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Suchbegriff an.\n\nBeispiel: `!suche Max`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?search="+args+"&limit=20", token, &resp); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Suche fehlgeschlagen.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Keine Ergebnisse für \"%s\".", args))
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList(fmt.Sprintf("**Suchergebnisse für \"%s\" (%d):**", args, resp.Total), resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdFavorites(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var resp ContactsResponse
if err := p.backend.Get(ctx, "/api/contacts?isFavorite=true&limit=20", token, &resp); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favoriten konnten nicht geladen werden.")
return nil
}
if len(resp.Contacts) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Favoriten.\n\nMarkiere mit: `!fav [Nr]`")
return nil
}
p.lastList[mc.Sender] = resp.Contacts
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactList("**⭐ Favoriten:**", resp.Contacts))
return nil
}
func (p *ContactsPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatContactDetails(contact))
return nil
}
func (p *ContactsPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Namen an.\n\nBeispiel: `!neu Max Mustermann`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
parts := strings.SplitN(args, " ", 2)
firstName := parts[0]
lastName := ""
if len(parts) > 1 {
lastName = parts[1]
}
body := map[string]string{
"firstName": firstName,
"lastName": lastName,
}
var contact Contact
if err := p.backend.Post(ctx, "/api/contacts", token, body, &contact); err != nil {
slog.Error("create contact failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht erstellt werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("✅ Kontakt **%s** erstellt!\n\nNutze `!kontakte` um die Liste zu sehen oder `!edit` um weitere Daten hinzuzufügen.", name))
return nil
}
func (p *ContactsPlugin) cmdEdit(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
// Parse: [nr] [field] [value]
parts := strings.SplitN(args, " ", 3)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!edit [Nr] [Feld] [Wert]`\n\nFelder: email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday")
return nil
}
contact, err := p.getContactByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
field := mapFieldName(parts[1])
value := parts[2]
body := map[string]string{field: value}
if err := p.backend.Put(ctx, "/api/contacts/"+contact.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht aktualisiert werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("✅ Kontakt **%s** aktualisiert!\n\n**%s:** %s", name, field, value))
return nil
}
func (p *ContactsPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
if err := p.backend.Delete(ctx, "/api/contacts/"+contact.ID, token); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kontakt konnte nicht gelöscht werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", name))
return nil
}
func (p *ContactsPlugin) cmdToggleFav(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
contact, err := p.getContactByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
var updated Contact
if err := p.backend.Post(ctx, "/api/contacts/"+contact.ID+"/favorite", token, nil, &updated); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favorit konnte nicht geändert werden.")
return nil
}
name := contact.FirstName
if contact.LastName != "" {
name += " " + contact.LastName
}
if updated.IsFavorite {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("⭐ **%s** als Favorit markiert", name))
} else {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**%s** aus Favoriten entfernt", name))
}
return nil
}
func (p *ContactsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
status := "❌ Nicht angemeldet"
if loggedIn {
status = "✅ Angemeldet"
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**Contacts Bot Status**\n\n%s", status))
return nil
}
func (p *ContactsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**👥 Contacts Bot - Befehle**
**Anzeigen:**
` + "`!kontakte`" + ` Alle Kontakte
` + "`!suche Max`" + ` Kontakte suchen
` + "`!favoriten`" + ` Favoriten
` + "`!kontakt 1`" + ` Details zu Kontakt #1
**Verwalten:**
` + "`!neu Max Mustermann`" + ` Neuer Kontakt
` + "`!edit 1 email max@test.de`" + ` Feld bearbeiten
` + "`!fav 1`" + ` Favorit umschalten
` + "`!delete 1`" + ` Kontakt löschen
**Felder:** email, phone, mobile, company, job, website, street, city, zip, country, notes, birthday`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Helpers ---
func (p *ContactsPlugin) getContactByNumber(mc *plugin.MessageContext, args string) (*Contact, error) {
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
return nil, fmt.Errorf("Bitte gib eine gültige Nummer an.\n\nBeispiel: `!kontakt 1`")
}
list, ok := p.lastList[mc.Sender]
if !ok || len(list) == 0 {
return nil, fmt.Errorf("Bitte zeige zuerst eine Liste an: `!kontakte` oder `!suche`")
}
if num > len(list) {
return nil, fmt.Errorf("❌ Kontakt #%d nicht gefunden.", num)
}
return &list[num-1], nil
}
func formatContactList(header string, contacts []Contact) string {
var sb strings.Builder
sb.WriteString(header)
sb.WriteString("\n\n")
for i, c := range contacts {
name := c.FirstName
if c.LastName != "" {
name += " " + c.LastName
}
fav := ""
if c.IsFavorite {
fav = " ⭐"
}
company := ""
if c.Company != nil && *c.Company != "" {
company = " - " + *c.Company
}
sb.WriteString(fmt.Sprintf("**%d.** %s%s%s\n", i+1, name, fav, company))
}
sb.WriteString("\nNutze `!kontakt [Nr]` für Details.")
return sb.String()
}
func formatContactDetails(c *Contact) string {
name := c.FirstName
if c.LastName != "" {
name += " " + c.LastName
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%s**\n\n", name))
if c.IsFavorite {
sb.WriteString("⭐ Favorit\n\n")
}
if c.Company != nil && *c.Company != "" {
sb.WriteString(fmt.Sprintf("**Firma:** %s", *c.Company))
if c.JobTitle != nil && *c.JobTitle != "" {
sb.WriteString(fmt.Sprintf(" — %s", *c.JobTitle))
}
sb.WriteByte('\n')
}
if c.Email != nil && *c.Email != "" {
sb.WriteString(fmt.Sprintf("**E-Mail:** %s\n", *c.Email))
}
if c.Phone != nil && *c.Phone != "" {
sb.WriteString(fmt.Sprintf("**Telefon:** %s\n", *c.Phone))
}
if c.Mobile != nil && *c.Mobile != "" {
sb.WriteString(fmt.Sprintf("**Mobil:** %s\n", *c.Mobile))
}
// Address
var addr []string
if c.Street != nil && *c.Street != "" {
addr = append(addr, *c.Street)
}
parts := ""
if c.PostalCode != nil && *c.PostalCode != "" {
parts += *c.PostalCode + " "
}
if c.City != nil && *c.City != "" {
parts += *c.City
}
if parts != "" {
addr = append(addr, strings.TrimSpace(parts))
}
if c.Country != nil && *c.Country != "" {
addr = append(addr, *c.Country)
}
if len(addr) > 0 {
sb.WriteString(fmt.Sprintf("**Adresse:** %s\n", strings.Join(addr, ", ")))
}
if c.Website != nil && *c.Website != "" {
sb.WriteString(fmt.Sprintf("**Website:** %s\n", *c.Website))
}
if c.Birthday != nil && *c.Birthday != "" {
sb.WriteString(fmt.Sprintf("**Geburtstag:** %s\n", *c.Birthday))
}
if c.Notes != nil && *c.Notes != "" {
sb.WriteString(fmt.Sprintf("\n**Notizen:** %s\n", *c.Notes))
}
return sb.String()
}
func mapFieldName(input string) string {
switch strings.ToLower(input) {
case "email":
return "email"
case "phone", "telefon":
return "phone"
case "mobile", "mobil", "handy":
return "mobile"
case "company", "firma":
return "company"
case "job", "jobtitle", "beruf":
return "jobTitle"
case "website", "web":
return "website"
case "street", "strasse":
return "street"
case "city", "stadt":
return "city"
case "zip", "plz":
return "postalCode"
case "country", "land":
return "country"
case "notes", "notizen":
return "notes"
case "birthday", "geburtstag":
return "birthday"
case "firstname", "vorname":
return "firstName"
case "lastname", "nachname":
return "lastName"
default:
return input
}
}

View file

@ -1,586 +0,0 @@
package gateway
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("gateway", func() plugin.Plugin { return &GatewayPlugin{} })
}
// GatewayPlugin is the composite mana-bot that combines AI chat, todo,
// calendar, clock, and voice into a single bot identity.
type GatewayPlugin struct {
// Sub-handler clients
todoBackend *services.BackendClient
calendarBackend *services.BackendClient
clockBackend *services.BackendClient
voice *services.VoiceClient
// AI chat
ollamaURL string
defaultModel string
// Command infrastructure
router *plugin.CommandRouter
detector *plugin.KeywordDetector
// Per-user AI sessions
mu sync.RWMutex
sessions map[string]*AISession
}
// AISession holds per-user AI chat state.
type AISession struct {
Model string
Mode string
History []ChatMessage
}
// ChatMessage for Ollama API.
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
func (p *GatewayPlugin) Name() string { return "gateway" }
func (p *GatewayPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.sessions = make(map[string]*AISession)
// Ollama config
p.ollamaURL = cfg.Extra["ollama_url"]
if p.ollamaURL == "" {
p.ollamaURL = "http://localhost:11434"
}
p.defaultModel = cfg.Extra["ollama_model"]
if p.defaultModel == "" {
p.defaultModel = "gemma3:4b"
}
// Backend clients (optional — gateway works even without backends)
if url := cfg.Extra["todo_url"]; url != "" {
p.todoBackend = services.NewBackendClient(url)
}
if url := cfg.Extra["calendar_url"]; url != "" {
p.calendarBackend = services.NewBackendClient(url)
}
if url := cfg.Extra["clock_url"]; url != "" {
p.clockBackend = services.NewBackendClient(url)
}
// Voice
sttURL := cfg.Extra["stt_url"]
ttsURL := cfg.Extra["tts_url"]
if sttURL != "" || ttsURL != "" {
p.voice = services.NewVoiceClient(sttURL, ttsURL)
}
// Build command router with all sub-handler commands
p.router = plugin.NewCommandRouter()
// Help
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
// AI
p.router.Handle("!models", p.cmdModels)
p.router.Handle("!modelle", p.cmdModels)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!clear", p.cmdClear)
p.router.Handle("!all", p.cmdAll)
p.router.Handle("!mode", p.cmdMode)
// Todo
p.router.Handle("!todo", p.cmdTodoAdd)
p.router.Handle("!add", p.cmdTodoAdd)
p.router.Handle("!list", p.cmdTodoList)
p.router.Handle("!liste", p.cmdTodoList)
p.router.Handle("!done", p.cmdTodoDone)
p.router.Handle("!erledigt", p.cmdTodoDone)
// Calendar
p.router.Handle("!cal", p.cmdCalToday)
p.router.Handle("!heute", p.cmdCalToday)
p.router.Handle("!week", p.cmdCalWeek)
p.router.Handle("!woche", p.cmdCalWeek)
p.router.Handle("!termin", p.cmdCalCreate)
// Clock
p.router.Handle("!timer", p.cmdTimer)
p.router.Handle("!timers", p.cmdTimers)
p.router.Handle("!stop", p.cmdTimerStop)
p.router.Handle("!zeit", p.cmdTime)
p.router.Handle("!time", p.cmdTime)
// Morning summary
p.router.Handle("!morning", p.cmdMorning)
p.router.Handle("!guten morgen", p.cmdMorning)
// Status
p.router.Handle("!status", p.cmdStatus)
// Keyword detector
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"meine aufgaben", "show tasks"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"modelle", "welche modelle"}, Command: "models"},
plugin.KeywordCommand{Keywords: []string{"guten morgen", "good morning"}, Command: "morning"},
plugin.KeywordCommand{Keywords: []string{"wie spät", "uhrzeit"}, Command: "time"},
plugin.KeywordCommand{Keywords: []string{"lösche verlauf", "vergiss alles"}, Command: "clear"},
))
slog.Info("gateway plugin initialized", "ollama", p.ollamaURL, "model", p.defaultModel)
return nil
}
func (p *GatewayPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!todo [text]"}, Description: "Aufgabe erstellen", Category: "Aufgaben"},
{Patterns: []string{"!list"}, Description: "Offene Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!done [nr]"}, Description: "Aufgabe erledigen", Category: "Aufgaben"},
{Patterns: []string{"!cal", "!heute"}, Description: "Heutige Termine", Category: "Kalender"},
{Patterns: []string{"!week", "!woche"}, Description: "Wochenübersicht", Category: "Kalender"},
{Patterns: []string{"!timer [dauer]"}, Description: "Timer starten", Category: "Timer"},
{Patterns: []string{"!models"}, Description: "KI-Modelle", Category: "AI"},
{Patterns: []string{"!model [name]"}, Description: "Modell wechseln", Category: "AI"},
{Patterns: []string{"!morning"}, Description: "Morgenzusammenfassung", Category: "System"},
}
}
func (p *GatewayPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdTodoList(mc, "")
case "models":
return p.cmdModels(mc, "")
case "morning":
return p.cmdMorning(mc, "")
case "time":
return p.cmdTime(mc, "")
case "clear":
return p.cmdClear(mc, "")
}
// Default: AI chat
return p.cmdChat(mc, mc.Body)
}
// HandleAudioMessage transcribes audio then routes the text.
func (p *GatewayPlugin) HandleAudioMessage(ctx context.Context, mc *plugin.MessageContext, audioData []byte) error {
if p.voice == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Spracherkennung nicht konfiguriert.")
return nil
}
result, err := p.voice.Transcribe(ctx, audioData, "de")
if err != nil {
slog.Error("transcription failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Spracherkennung fehlgeschlagen.")
return nil
}
text := strings.TrimSpace(result.Text)
if text == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🎤 Ich konnte nichts verstehen.")
return nil
}
// Show transcription
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🎤 *\"%s\"*", text))
// Route transcribed text as if it were a text message
mc.Body = text
mc.IsVoice = true
return p.HandleTextMessage(ctx, mc)
}
// --- AI Sub-Handler ---
func (p *GatewayPlugin) cmdChat(mc *plugin.MessageContext, message string) error {
ctx := context.Background()
session := p.getAISession(mc.Sender)
messages := buildMessages(session, message)
response, err := ollamaChat(ctx, p.ollamaURL, session.Model, messages)
if err != nil {
slog.Error("ollama chat failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ KI ist nicht erreichbar.")
return nil
}
// Update history
session.History = append(session.History, ChatMessage{Role: "user", Content: message})
session.History = append(session.History, ChatMessage{Role: "assistant", Content: response})
if len(session.History) > 20 {
session.History = session.History[len(session.History)-20:]
}
p.mu.Lock()
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *GatewayPlugin) cmdModels(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
models, err := ollamaListModels(ctx, p.ollamaURL)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
session := p.getAISession(mc.Sender)
var sb strings.Builder
sb.WriteString("**Verfügbare Modelle:**\n\n")
for _, m := range models {
current := ""
if m.Name == session.Model {
current = " ✓"
}
sb.WriteString(fmt.Sprintf("• `%s` (%d MB)%s\n", m.Name, m.Size/(1024*1024), current))
}
sb.WriteString("\nWechseln: `!model [name]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
s := p.getAISession(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("Aktuelles Modell: `%s`", s.Model))
return nil
}
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.Model = args
s.History = nil
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell: `%s` — Verlauf gelöscht.", args))
return nil
}
func (p *GatewayPlugin) cmdMode(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
valid := map[string]bool{"default": true, "code": true, "translate": true, "summarize": true}
if !valid[args] {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Modi:** `default`, `code`, `translate`, `summarize`")
return nil
}
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.Mode = args
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modus: `%s`", args))
return nil
}
func (p *GatewayPlugin) cmdClear(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
p.mu.Lock()
s := p.getAISession(mc.Sender)
s.History = nil
p.sessions[mc.Sender] = s
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🗑️ Chat-Verlauf gelöscht.")
return nil
}
func (p *GatewayPlugin) cmdAll(mc *plugin.MessageContext, question string) error {
ctx := context.Background()
if question == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Beispiel: `!all Was ist 2+2?`")
return nil
}
models, err := ollamaListModels(ctx, p.ollamaURL)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle nicht erreichbar.")
return nil
}
eventID, _ := mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**Vergleich:** \"%s\" — %d Modelle...", question, len(models)))
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Modellvergleich:** \"%s\"\n\n---\n", question))
msgs := []ChatMessage{{Role: "user", Content: question}}
for _, m := range models {
start := time.Now()
resp, err := ollamaChat(ctx, p.ollamaURL, m.Name, msgs)
dur := time.Since(start)
sb.WriteString(fmt.Sprintf("\n**%s** %.1fs\n", m.Name, dur.Seconds()))
if err != nil {
sb.WriteString("_Fehler_\n")
} else {
if len(resp) > 500 {
resp = resp[:500] + "..."
}
sb.WriteString(resp + "\n")
}
sb.WriteString("\n---\n")
}
if eventID != "" {
mc.Client.EditMessage(ctx, mc.RoomID, eventID, sb.String())
}
return nil
}
// --- Todo Sub-Handler ---
func (p *GatewayPlugin) cmdTodoAdd(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if p.todoBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Todo-Backend nicht konfiguriert.")
return nil
}
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Beispiel: `!todo Einkaufen @morgen !p1`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden: `!login email passwort`")
return nil
}
body := map[string]string{"title": args}
var task struct{ Title string `json:"title"` }
if err := p.todoBackend.Post(ctx, "/api/tasks", token, body, &task); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erstellt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s**", task.Title))
return nil
}
func (p *GatewayPlugin) cmdTodoList(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
if p.todoBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Todo-Backend nicht konfiguriert.")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden: `!login email passwort`")
return nil
}
var tasks []struct {
Title string `json:"title"`
Priority int `json:"priority"`
}
if err := p.todoBackend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine offenen Aufgaben.")
return nil
}
var sb strings.Builder
sb.WriteString("📋 **Aufgaben:**\n\n")
for i, t := range tasks {
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, t.Title))
}
sb.WriteString("\nErledigen: `!done [Nr]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdTodoDone(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Aufgabe erledigt. (Nutze den Todo-Bot für vollständige Funktionalität)")
return nil
}
// --- Calendar Sub-Handler ---
func (p *GatewayPlugin) cmdCalToday(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
if p.calendarBackend == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kalender-Backend nicht konfiguriert.")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte zuerst anmelden.")
return nil
}
var events []struct {
Title string `json:"title"`
StartTime string `json:"startTime"`
}
if err := p.calendarBackend.Get(ctx, "/api/events/today", token, &events); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Termine konnten nicht geladen werden.")
return nil
}
if len(events) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Termine heute.")
return nil
}
var sb strings.Builder
sb.WriteString("📅 **Heute:**\n\n")
for i, e := range events {
sb.WriteString(fmt.Sprintf("**%d.** %s\n", i+1, e.Title))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdCalWeek(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📅 Nutze den Kalender-Bot für die Wochenübersicht.")
return nil
}
func (p *GatewayPlugin) cmdCalCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📅 Nutze den Kalender-Bot: `!termin Meeting morgen um 14:00`")
return nil
}
// --- Clock Sub-Handler ---
func (p *GatewayPlugin) cmdTimer(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏱️ Nutze den Clock-Bot für Timer: `!timer 25m`")
return nil
}
func (p *GatewayPlugin) cmdTimers(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏱️ Nutze den Clock-Bot: `!timers`")
return nil
}
func (p *GatewayPlugin) cmdTimerStop(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⏸️ Nutze den Clock-Bot: `!stop`")
return nil
}
func (p *GatewayPlugin) cmdTime(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
now := time.Now()
days := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**%s Uhr** — %s, %d.%d.%d", now.Format("15:04"), days[now.Weekday()], now.Day(), now.Month(), now.Year()))
return nil
}
// --- Morning Summary ---
func (p *GatewayPlugin) cmdMorning(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
var sb strings.Builder
sb.WriteString("**☀️ Guten Morgen!**\n\n")
now := time.Now()
days := []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
months := []string{"", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
sb.WriteString(fmt.Sprintf("📅 %s, %d. %s %d\n\n", days[now.Weekday()], now.Day(), months[now.Month()], now.Year()))
// Try to fetch today's tasks
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
if p.todoBackend != nil && token != "" {
var tasks []struct{ Title string `json:"title"` }
if err := p.todoBackend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err == nil && len(tasks) > 0 {
sb.WriteString(fmt.Sprintf("📋 **%d offene Aufgaben**\n", len(tasks)))
}
}
// Try to fetch today's events
if p.calendarBackend != nil && token != "" {
var events []struct{ Title string `json:"title"` }
if err := p.calendarBackend.Get(ctx, "/api/events/today", token, &events); err == nil && len(events) > 0 {
sb.WriteString(fmt.Sprintf("📅 **%d Termine heute**\n", len(events)))
}
}
sb.WriteString("\nSchönen Tag! 🌟")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
// --- Status & Help ---
func (p *GatewayPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
session := p.getAISession(mc.Sender)
var sb strings.Builder
sb.WriteString("**🤖 Mana Bot Status**\n\n")
sb.WriteString(fmt.Sprintf("**KI-Modell:** `%s`\n", session.Model))
sb.WriteString(fmt.Sprintf("**Chat-Verlauf:** %d Nachrichten\n", len(session.History)))
sb.WriteString(fmt.Sprintf("**Modus:** `%s`\n", session.Mode))
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
if loggedIn {
sb.WriteString("**Auth:** ✅ Angemeldet\n")
} else {
sb.WriteString("**Auth:** ❌ Nicht angemeldet\n")
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *GatewayPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🤖 Mana Bot Befehle**
**KI Chat:**
Einfach eine Nachricht schreiben!
` + "`!models`" + ` Modelle | ` + "`!model [name]`" + ` Wechseln
` + "`!all [frage]`" + ` Alle Modelle vergleichen
` + "`!clear`" + ` Verlauf löschen
**Aufgaben:**
` + "`!todo Einkaufen`" + ` Neue Aufgabe
` + "`!list`" + ` Offene Aufgaben | ` + "`!done 1`" + ` Erledigen
**Kalender:**
` + "`!heute`" + ` Heutige Termine
**Uhr:**
` + "`!zeit`" + ` Aktuelle Uhrzeit
**System:**
` + "`!morning`" + ` Morgenzusammenfassung
` + "`!status`" + ` Bot-Status
🎤 Sprachnachrichten werden automatisch verarbeitet.`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- AI Session ---
func (p *GatewayPlugin) getAISession(userID string) *AISession {
p.mu.RLock()
s, ok := p.sessions[userID]
p.mu.RUnlock()
if !ok {
return &AISession{Model: p.defaultModel, Mode: "default"}
}
return s
}

View file

@ -1,120 +0,0 @@
package gateway
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// OllamaModel represents an available model.
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
type ollamaModelsResponse struct {
Models []OllamaModel `json:"models"`
}
type ollamaChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
}
type ollamaChatResponse struct {
Message ChatMessage `json:"message"`
}
var httpClient = &http.Client{Timeout: 120 * time.Second}
// ollamaChat sends a chat request to the Ollama API.
func ollamaChat(ctx context.Context, baseURL, model string, messages []ChatMessage) (string, error) {
body := ollamaChatRequest{Model: model, Messages: messages, Stream: false}
data, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/api/chat", bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ollama: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ollama %d: %s", resp.StatusCode, string(b))
}
var result ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.Message.Content, nil
}
// ollamaListModels lists available models from the Ollama API.
func ollamaListModels(ctx context.Context, baseURL string) ([]OllamaModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list models: %d", resp.StatusCode)
}
var result ollamaModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
// buildMessages constructs the message array for an Ollama chat request.
func buildMessages(session *AISession, userMessage string) []ChatMessage {
msgs := make([]ChatMessage, 0, len(session.History)+2)
// System prompt
prompt := getSystemPrompt(session.Mode)
if prompt != "" {
msgs = append(msgs, ChatMessage{Role: "system", Content: prompt})
}
// History
msgs = append(msgs, session.History...)
// New user message
msgs = append(msgs, ChatMessage{Role: "user", Content: userMessage})
return msgs
}
func getSystemPrompt(mode string) string {
switch mode {
case "code":
return "Du bist ein erfahrener Programmierer. Antworte mit klaren Code-Beispielen."
case "translate":
return "Du bist ein Übersetzer. Übersetze Deutsch↔Englisch. Gib nur die Übersetzung zurück."
case "summarize":
return "Fasse den Text kurz und prägnant zusammen."
default:
return "Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, außer der Nutzer schreibt auf Englisch."
}
}

View file

@ -1,72 +0,0 @@
package nutriphi
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("nutriphi", func() plugin.Plugin { return &NutriPhiPlugin{} })
}
type NutriPhiPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *NutriPhiPlugin) Name() string { return "nutriphi" }
func (p *NutriPhiPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!log", p.cmdLog)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("nutriphi plugin initialized")
return nil
}
func (p *NutriPhiPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!heute", "!today"}, Description: "Heutige Mahlzeiten", Category: "Ernährung"},
{Patterns: []string{"!log [mahlzeit]"}, Description: "Mahlzeit loggen", Category: "Ernährung"},
}
}
func (p *NutriPhiPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *NutriPhiPlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ Heute**\n\n_Keine Mahlzeiten geloggt._")
return nil
}
func (p *NutriPhiPlugin) cmdLog(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ Mahlzeit loggen**\n\n_Sende ein Foto oder beschreibe die Mahlzeit._")
return nil
}
func (p *NutriPhiPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**NutriPhi Bot:** ✅ Online")
return nil
}
func (p *NutriPhiPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🍽️ NutriPhi Bot**\n\n• `!heute` — Heutige Mahlzeiten\n• `!log Pizza` — Mahlzeit loggen\n• `!status` — Status")
return nil
}

View file

@ -1,462 +0,0 @@
package ollama
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/mana/mana-matrix-bot/internal/plugin"
)
func init() {
plugin.Register("ollama", func() plugin.Plugin { return &OllamaPlugin{} })
}
// ChatMessage represents a message in the chat history.
type ChatMessage struct {
Role string `json:"role"` // user, assistant, system
Content string `json:"content"`
}
// OllamaModel represents an available model.
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
}
// OllamaModelsResponse is the response from /api/tags.
type OllamaModelsResponse struct {
Models []OllamaModel `json:"models"`
}
// OllamaChatRequest is the request body for /api/chat.
type OllamaChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Stream bool `json:"stream"`
}
// OllamaChatResponse is the response from /api/chat.
type OllamaChatResponse struct {
Message ChatMessage `json:"message"`
}
// UserSession holds per-user chat state.
type UserSession struct {
Model string
History []ChatMessage
Mode string // default, code, translate, summarize
}
// OllamaPlugin implements the Matrix AI chat bot.
type OllamaPlugin struct {
ollamaURL string
defaultModel string
httpClient *http.Client
router *plugin.CommandRouter
detector *plugin.KeywordDetector
mu sync.RWMutex
sessions map[string]*UserSession
}
func (p *OllamaPlugin) Name() string { return "ollama" }
func (p *OllamaPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.ollamaURL = cfg.Extra["ollama_url"]
if p.ollamaURL == "" {
p.ollamaURL = "http://localhost:11434"
}
p.defaultModel = cfg.Extra["ollama_model"]
if p.defaultModel == "" {
p.defaultModel = "gemma3:4b"
}
p.httpClient = &http.Client{Timeout: 120 * time.Second}
p.sessions = make(map[string]*UserSession)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!models", p.cmdModels)
p.router.Handle("!modelle", p.cmdModels)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!modell", p.cmdModel)
p.router.Handle("!clear", p.cmdClear)
p.router.Handle("!status", p.cmdStatus)
p.router.Handle("!all", p.cmdAll)
p.router.Handle("!mode", p.cmdMode)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"modelle", "welche modelle", "models"}, Command: "models"},
plugin.KeywordCommand{Keywords: []string{"lösche verlauf", "neustart", "reset", "vergiss alles"}, Command: "clear"},
plugin.KeywordCommand{Keywords: []string{"verbindung", "connection", "online"}, Command: "status"},
))
slog.Info("ollama plugin initialized", "url", p.ollamaURL, "model", p.defaultModel)
return nil
}
func (p *OllamaPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!models", "!modelle"}, Description: "Verfügbare Modelle", Category: "AI"},
{Patterns: []string{"!model [name]"}, Description: "Modell wechseln", Category: "AI"},
{Patterns: []string{"!mode [name]"}, Description: "Modus ändern (default, code, translate)", Category: "AI"},
{Patterns: []string{"!clear"}, Description: "Chat-Verlauf löschen", Category: "AI"},
{Patterns: []string{"!all [frage]"}, Description: "Alle Modelle vergleichen", Category: "AI"},
{Patterns: []string{"!status"}, Description: "Ollama-Status", Category: "System"},
}
}
func (p *OllamaPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "models":
return p.cmdModels(mc, "")
case "clear":
return p.cmdClear(mc, "")
case "status":
return p.cmdStatus(mc, "")
}
// Default: treat as chat message
return p.cmdChat(mc, mc.Body)
}
// --- Command Handlers ---
func (p *OllamaPlugin) cmdChat(mc *plugin.MessageContext, message string) error {
ctx := context.Background()
session := p.getSession(mc.Sender)
// Build messages with history
messages := make([]ChatMessage, 0, len(session.History)+2)
// System prompt
systemPrompt := getSystemPrompt(session.Mode)
if systemPrompt != "" {
messages = append(messages, ChatMessage{Role: "system", Content: systemPrompt})
}
// History
messages = append(messages, session.History...)
// New user message
messages = append(messages, ChatMessage{Role: "user", Content: message})
// Call Ollama
response, err := p.chat(ctx, session.Model, messages)
if err != nil {
slog.Error("ollama chat failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Ollama ist nicht erreichbar.")
return nil
}
// Update history (keep last 10 messages)
session.History = append(session.History, ChatMessage{Role: "user", Content: message})
session.History = append(session.History, ChatMessage{Role: "assistant", Content: response})
if len(session.History) > 20 {
session.History = session.History[len(session.History)-20:]
}
p.mu.Lock()
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *OllamaPlugin) cmdModels(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
models, err := p.listModels(ctx)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
if len(models) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Modelle verfügbar.")
return nil
}
session := p.getSession(mc.Sender)
var sb strings.Builder
sb.WriteString("**Verfügbare Modelle:**\n\n")
for _, m := range models {
sizeMB := m.Size / (1024 * 1024)
current := ""
if m.Name == session.Model {
current = " ✓"
}
sb.WriteString(fmt.Sprintf("• `%s` (%d MB)%s\n", m.Name, sizeMB, current))
}
sb.WriteString(fmt.Sprintf("\nWechseln mit: `!model [name]`"))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *OllamaPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
session := p.getSession(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("Aktuelles Modell: `%s`\n\nWechseln: `!model gemma3:4b`", session.Model))
return nil
}
p.mu.Lock()
session := p.getSession(mc.Sender)
session.Model = args
session.History = nil // Clear history on model switch
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell gewechselt zu `%s`\nChat-Verlauf gelöscht.", args))
return nil
}
func (p *OllamaPlugin) cmdMode(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validModes := map[string]bool{"default": true, "code": true, "translate": true, "summarize": true}
if args == "" || !validModes[args] {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Modi:** `default`, `code`, `translate`, `summarize`\n\nBeispiel: `!mode code`")
return nil
}
p.mu.Lock()
session := p.getSession(mc.Sender)
session.Mode = args
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modus gewechselt zu `%s`", args))
return nil
}
func (p *OllamaPlugin) cmdClear(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
p.mu.Lock()
session := p.getSession(mc.Sender)
session.History = nil
p.sessions[mc.Sender] = session
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🗑️ Chat-Verlauf gelöscht.")
return nil
}
func (p *OllamaPlugin) cmdAll(mc *plugin.MessageContext, question string) error {
ctx := context.Background()
if question == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte stelle eine Frage.\n\nBeispiel: `!all Was ist 2+2?`")
return nil
}
models, err := p.listModels(ctx)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Modelle konnten nicht geladen werden.")
return nil
}
// Send initial message
initialMsg := fmt.Sprintf("**Modellvergleich**\n\n**Frage:** \"%s\"\n\n_Frage %d Modelle ab..._", question, len(models))
eventID, _ := mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, initialMsg)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Modellvergleich**\n\n**Frage:** \"%s\"\n\n---\n", question))
messages := []ChatMessage{{Role: "user", Content: question}}
for _, m := range models {
start := time.Now()
response, err := p.chat(ctx, m.Name, messages)
duration := time.Since(start)
sb.WriteString(fmt.Sprintf("\n**%s** %.1fs\n", m.Name, duration.Seconds()))
if err != nil {
sb.WriteString("_Fehler_\n")
} else {
// Truncate long responses
if len(response) > 500 {
response = response[:500] + "..."
}
sb.WriteString(response)
sb.WriteByte('\n')
}
sb.WriteString("\n---\n")
}
// Edit the initial message with results
if eventID != "" {
mc.Client.EditMessage(ctx, mc.RoomID, eventID, sb.String())
} else {
mc.Client.SendMessage(ctx, mc.RoomID, sb.String())
}
return nil
}
func (p *OllamaPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
session := p.getSession(mc.Sender)
// Check connection
connStatus := "❌ Offline"
modelCount := 0
models, err := p.listModels(ctx)
if err == nil {
connStatus = "✅ Online"
modelCount = len(models)
}
response := fmt.Sprintf("**Ollama Status**\n\n**Verbindung:** %s\n**Modelle:** %d\n**Dein Modell:** `%s`\n**Chat-Verlauf:** %d Nachrichten\n**DSGVO:** Alle Daten lokal",
connStatus, modelCount, session.Model, len(session.History))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *OllamaPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🤖 Ollama Bot - Befehle**
**Chat:**
Einfach eine Nachricht schreiben der Bot antwortet mit KI.
**Modelle:**
` + "`!models`" + ` Verfügbare Modelle
` + "`!model gemma3:4b`" + ` Modell wechseln
` + "`!all Was ist 2+2?`" + ` Alle Modelle vergleichen
**Modi:**
` + "`!mode code`" + ` Code-Modus
` + "`!mode translate`" + ` Übersetzer
` + "`!mode summarize`" + ` Zusammenfasser
**System:**
` + "`!clear`" + ` Chat-Verlauf löschen
` + "`!status`" + ` Ollama-Status
Alle Daten bleiben lokal (DSGVO-konform).`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Ollama API ---
func (p *OllamaPlugin) chat(ctx context.Context, model string, messages []ChatMessage) (string, error) {
body := OllamaChatRequest{
Model: model,
Messages: messages,
Stream: false,
}
data, err := json.Marshal(body)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", p.ollamaURL+"/api/chat", bytes.NewReader(data))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("ollama error %d: %s", resp.StatusCode, string(respBody))
}
var result OllamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode ollama response: %w", err)
}
return result.Message.Content, nil
}
func (p *OllamaPlugin) listModels(ctx context.Context) ([]OllamaModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", p.ollamaURL+"/api/tags", nil)
if err != nil {
return nil, err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list models: %d", resp.StatusCode)
}
var result OllamaModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
// --- Session Management ---
func (p *OllamaPlugin) getSession(userID string) *UserSession {
p.mu.RLock()
session, ok := p.sessions[userID]
p.mu.RUnlock()
if !ok {
return &UserSession{
Model: p.defaultModel,
Mode: "default",
}
}
return session
}
// --- System Prompts ---
func getSystemPrompt(mode string) string {
switch mode {
case "code":
return "Du bist ein erfahrener Programmierer. Antworte mit klaren Code-Beispielen und Erklärungen. Nutze Markdown-Codeblöcke."
case "translate":
return "Du bist ein Übersetzer. Übersetze den Text in die jeweils andere Sprache (Deutsch↔Englisch). Gib nur die Übersetzung zurück."
case "summarize":
return "Du bist ein Zusammenfasser. Fasse den Text kurz und prägnant zusammen. Nutze Stichpunkte wenn sinnvoll."
default:
return "Du bist ein hilfreicher KI-Assistent. Antworte auf Deutsch, außer der Nutzer schreibt auf Englisch."
}
}

View file

@ -1,61 +0,0 @@
package onboarding
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
)
func init() {
plugin.Register("onboarding", func() plugin.Plugin { return &OnboardingPlugin{} })
}
type OnboardingPlugin struct {
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *OnboardingPlugin) Name() string { return "onboarding" }
func (p *OnboardingPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!start", p.cmdStart)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("onboarding plugin initialized")
return nil
}
func (p *OnboardingPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!start"}, Description: "Onboarding starten", Category: "Onboarding"},
}
}
func (p *OnboardingPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *OnboardingPlugin) cmdStart(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID,
"**👋 Willkommen bei Mana!**\n\nIch helfe dir bei den ersten Schritten.\n\n1. Erstelle einen Account: `!register`\n2. Melde dich an: `!login email passwort`\n3. Erkunde die Apps!")
return nil
}
func (p *OnboardingPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Onboarding Bot:** ✅ Online")
return nil
}
func (p *OnboardingPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**👋 Onboarding Bot**\n\n• `!start` — Onboarding starten\n• `!status` — Status")
return nil
}

View file

@ -1,74 +0,0 @@
package picture
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("picture", func() plugin.Plugin { return &PicturePlugin{} })
}
type PicturePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *PicturePlugin) Name() string { return "picture" }
func (p *PicturePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!generate", p.cmdGenerate)
p.router.Handle("!generiere", p.cmdGenerate)
p.router.Handle("!bild", p.cmdGenerate)
p.router.Handle("!gallery", p.cmdGallery)
p.router.Handle("!galerie", p.cmdGallery)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("picture plugin initialized")
return nil
}
func (p *PicturePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!generate", "!bild"}, Description: "Bild generieren", Category: "Bilder"},
{Patterns: []string{"!gallery", "!galerie"}, Description: "Galerie anzeigen", Category: "Bilder"},
}
}
func (p *PicturePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *PicturePlugin) cmdGenerate(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🎨 Bildgenerierung**\n\n_Beschreibe das gewünschte Bild._")
return nil
}
func (p *PicturePlugin) cmdGallery(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🖼️ Galerie**\n\n_Verfügbar über die Web-App._")
return nil
}
func (p *PicturePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Picture Bot:** ✅ Online")
return nil
}
func (p *PicturePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🎨 Picture Bot**\n\n• `!bild [beschreibung]` — Bild generieren\n• `!galerie` — Galerie\n• `!status` — Status")
return nil
}

View file

@ -1,543 +0,0 @@
package planta
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("planta", func() plugin.Plugin { return &PlantaPlugin{} })
}
// Plant represents a plant from the backend.
type Plant struct {
ID string `json:"id"`
Name string `json:"name"`
ScientificName *string `json:"scientificName"`
Light *string `json:"light"`
WaterInterval *int `json:"waterInterval"` // days
Humidity *string `json:"humidity"`
Temperature *string `json:"temperature"`
Soil *string `json:"soil"`
Health string `json:"health"` // healthy, needs_attention, sick
Notes *string `json:"notes"`
AcquiredAt *string `json:"acquiredAt"`
}
// WateringEntry represents a watering event.
type WateringEntry struct {
Date string `json:"date"`
Notes *string `json:"notes"`
}
// UpcomingWatering represents when a plant needs water.
type UpcomingWatering struct {
PlantID string `json:"plantId"`
PlantName string `json:"plantName"`
DueDays int `json:"dueDays"` // negative = overdue
}
// PlantaPlugin implements the Matrix plant care bot.
type PlantaPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
lastList map[string][]Plant // per-user
}
func (p *PlantaPlugin) Name() string { return "planta" }
func (p *PlantaPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("planta plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastList = make(map[string][]Plant)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!pflanzen", p.cmdList)
p.router.Handle("!plants", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!pflanze", p.cmdDetails)
p.router.Handle("!plant", p.cmdDetails)
p.router.Handle("!details", p.cmdDetails)
p.router.Handle("!neu", p.cmdCreate)
p.router.Handle("!new", p.cmdCreate)
p.router.Handle("!add", p.cmdCreate)
p.router.Handle("!loeschen", p.cmdDelete)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!entfernen", p.cmdDelete)
p.router.Handle("!edit", p.cmdEdit)
p.router.Handle("!bearbeiten", p.cmdEdit)
p.router.Handle("!giessen", p.cmdWater)
p.router.Handle("!water", p.cmdWater)
p.router.Handle("!faellig", p.cmdDue)
p.router.Handle("!due", p.cmdDue)
p.router.Handle("!upcoming", p.cmdDue)
p.router.Handle("!historie", p.cmdHistory)
p.router.Handle("!history", p.cmdHistory)
p.router.Handle("!verlauf", p.cmdHistory)
p.router.Handle("!intervall", p.cmdInterval)
p.router.Handle("!interval", p.cmdInterval)
p.router.Handle("!frequenz", p.cmdInterval)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"pflanzen", "plants", "meine pflanzen"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"giessen", "water", "bewässern", "wasser geben"}, Command: "water"},
plugin.KeywordCommand{Keywords: []string{"fällig", "due", "anstehend"}, Command: "due"},
plugin.KeywordCommand{Keywords: []string{"neue pflanze"}, Command: "create"},
plugin.KeywordCommand{Keywords: []string{"historie", "history", "verlauf"}, Command: "history"},
))
slog.Info("planta plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *PlantaPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!pflanzen", "!plants"}, Description: "Alle Pflanzen", Category: "Pflanzen"},
{Patterns: []string{"!pflanze [nr]"}, Description: "Pflanzen-Details", Category: "Pflanzen"},
{Patterns: []string{"!neu [name]"}, Description: "Neue Pflanze", Category: "Pflanzen"},
{Patterns: []string{"!giessen [nr]"}, Description: "Pflanze gießen", Category: "Pflege"},
{Patterns: []string{"!fällig", "!due"}, Description: "Gieß-Status", Category: "Pflege"},
{Patterns: []string{"!historie [nr]"}, Description: "Gieß-Verlauf", Category: "Pflege"},
{Patterns: []string{"!intervall [nr] [tage]"}, Description: "Gieß-Intervall setzen", Category: "Pflege"},
{Patterns: []string{"!edit [nr] [feld] [wert]"}, Description: "Pflanze bearbeiten", Category: "Pflanzen"},
{Patterns: []string{"!delete [nr]"}, Description: "Pflanze löschen", Category: "Pflanzen"},
}
}
func (p *PlantaPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdList(mc, "")
case "water":
return p.cmdWater(mc, "")
case "due":
return p.cmdDue(mc, "")
case "create":
return p.cmdCreate(mc, mc.Body)
case "history":
return p.cmdHistory(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *PlantaPlugin) cmdList(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var plants []Plant
if err := p.backend.Get(ctx, "/api/plants", token, &plants); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanzen konnten nicht geladen werden.")
return nil
}
if len(plants) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Pflanzen vorhanden.\n\nNeue Pflanze: `!neu Monstera`")
return nil
}
p.lastList[mc.Sender] = plants
var sb strings.Builder
sb.WriteString("**🌱 Deine Pflanzen:**\n\n")
for i, plant := range plants {
icon := healthIcon(plant.Health)
sb.WriteString(fmt.Sprintf("**%d.** %s **%s**", i+1, icon, plant.Name))
if plant.ScientificName != nil && *plant.ScientificName != "" {
sb.WriteString(fmt.Sprintf(" *(%s)*", *plant.ScientificName))
}
sb.WriteByte('\n')
}
sb.WriteString("\nNutze `!pflanze [Nr]` für Details oder `!fällig` für Gieß-Status")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdDetails(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatPlantDetails(plant))
return nil
}
func (p *PlantaPlugin) cmdCreate(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Pflanzennamen an.\n\nBeispiel: `!neu Monstera Deliciosa`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
body := map[string]string{"name": args}
var plant Plant
if err := p.backend.Post(ctx, "/api/plants", token, body, &plant); err != nil {
slog.Error("create plant failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht erstellt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🌱 Pflanze **%s** erstellt!\n\nNutze `!pflanzen` für die Liste.", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
if err := p.backend.Delete(ctx, "/api/plants/"+plant.ID, token); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht gelöscht werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdEdit(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 3)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!edit [Nr] [Feld] [Wert]`\n\nFelder: name, art, licht, wasser, feuchtigkeit, temperatur, erde, notizen")
return nil
}
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
field := mapPlantField(parts[1])
body := map[string]string{field: parts[2]}
if err := p.backend.Put(ctx, "/api/plants/"+plant.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Pflanze konnte nicht aktualisiert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s** aktualisiert: **%s** = %s", plant.Name, field, parts[2]))
return nil
}
func (p *PlantaPlugin) cmdWater(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 2)
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Pflanzennummer an.\n\nBeispiel: `!giessen 1`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
body := map[string]string{}
if len(parts) > 1 {
body["notes"] = parts[1]
}
if err := p.backend.Post(ctx, "/api/watering/"+plant.ID+"/water", token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Gießen konnte nicht gespeichert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("💧 **%s** gegossen!", plant.Name))
return nil
}
func (p *PlantaPlugin) cmdDue(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var upcoming []UpcomingWatering
if err := p.backend.Get(ctx, "/api/watering/upcoming", token, &upcoming); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Gieß-Status konnte nicht geladen werden.")
return nil
}
if len(upcoming) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Pflanzen mit Gieß-Intervall.")
return nil
}
var sb strings.Builder
sb.WriteString("**💧 Gieß-Status:**\n\n")
for _, w := range upcoming {
if w.DueDays < 0 {
sb.WriteString(fmt.Sprintf("• **%s**: **Überfällig (%d Tage)**\n", w.PlantName, -w.DueDays))
} else if w.DueDays == 0 {
sb.WriteString(fmt.Sprintf("• **%s**: **Heute**\n", w.PlantName))
} else {
sb.WriteString(fmt.Sprintf("• **%s**: in %d Tagen\n", w.PlantName, w.DueDays))
}
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdHistory(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
plant, err := p.getPlantByNumber(mc, args)
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Pflanzennummer an.\n\nBeispiel: `!historie 1`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
var history []WateringEntry
if err := p.backend.Get(ctx, "/api/watering/"+plant.ID+"/history", token, &history); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Verlauf konnte nicht geladen werden.")
return nil
}
if len(history) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Kein Gieß-Verlauf für %s.", plant.Name))
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**💧 Gieß-Historie: %s**\n\n", plant.Name))
limit := len(history)
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
entry := history[i]
sb.WriteString(fmt.Sprintf("• %s", entry.Date))
if entry.Notes != nil && *entry.Notes != "" {
sb.WriteString(fmt.Sprintf(" - %s", *entry.Notes))
}
sb.WriteByte('\n')
}
if len(history) > 10 {
sb.WriteString(fmt.Sprintf("\n_...und %d weitere Einträge_", len(history)-10))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *PlantaPlugin) cmdInterval(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
parts := strings.SplitN(args, " ", 2)
if len(parts) < 2 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Format: `!intervall [Nr] [Tage]`\n\nBeispiel: `!intervall 1 7`")
return nil
}
plant, err := p.getPlantByNumber(mc, parts[0])
if err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, err.Error())
return nil
}
days, err := strconv.Atoi(parts[1])
if err != nil || days < 1 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Bitte gib eine gültige Anzahl Tage an.")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an.")
return nil
}
body := map[string]int{"intervalDays": days}
if err := p.backend.Put(ctx, "/api/watering/"+plant.ID, token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Intervall konnte nicht gesetzt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ **%s**: Gieß-Intervall auf %d Tage gesetzt.", plant.Name, days))
return nil
}
func (p *PlantaPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
status := "❌ Nicht angemeldet"
if loggedIn {
status = "✅ Angemeldet"
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**🌱 Planta Bot Status**\n\n%s", status))
return nil
}
func (p *PlantaPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🌱 Planta Bot - Befehle**
**Pflanzen:**
` + "`!pflanzen`" + ` Alle Pflanzen
` + "`!pflanze 1`" + ` Details zu Pflanze #1
` + "`!neu Monstera`" + ` Neue Pflanze
` + "`!edit 1 licht hell`" + ` Pflanze bearbeiten
` + "`!delete 1`" + ` Pflanze löschen
**Pflege:**
` + "`!giessen 1`" + ` Pflanze #1 gießen
` + "`!fällig`" + ` Gieß-Status
` + "`!historie 1`" + ` Gieß-Verlauf
` + "`!intervall 1 7`" + ` Alle 7 Tage gießen
**Felder:** name, art, licht, wasser, feuchtigkeit, temperatur, erde, notizen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Helpers ---
func (p *PlantaPlugin) getPlantByNumber(mc *plugin.MessageContext, args string) (*Plant, error) {
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
return nil, fmt.Errorf("Bitte gib eine gültige Nummer an.")
}
list, ok := p.lastList[mc.Sender]
if !ok || len(list) == 0 {
return nil, fmt.Errorf("Bitte zeige zuerst die Liste an: `!pflanzen`")
}
if num > len(list) {
return nil, fmt.Errorf("❌ Pflanze #%d nicht gefunden.", num)
}
return &list[num-1], nil
}
func formatPlantDetails(plant *Plant) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**%s %s**\n\n", healthIcon(plant.Health), plant.Name))
if plant.ScientificName != nil && *plant.ScientificName != "" {
sb.WriteString(fmt.Sprintf("*%s*\n\n", *plant.ScientificName))
}
if plant.Light != nil && *plant.Light != "" {
sb.WriteString(fmt.Sprintf("• ☀️ Licht: %s\n", *plant.Light))
}
if plant.WaterInterval != nil {
sb.WriteString(fmt.Sprintf("• 💧 Gießen: alle %d Tage\n", *plant.WaterInterval))
}
if plant.Humidity != nil && *plant.Humidity != "" {
sb.WriteString(fmt.Sprintf("• 💨 Feuchtigkeit: %s\n", *plant.Humidity))
}
if plant.Temperature != nil && *plant.Temperature != "" {
sb.WriteString(fmt.Sprintf("• 🌡️ Temperatur: %s\n", *plant.Temperature))
}
if plant.Soil != nil && *plant.Soil != "" {
sb.WriteString(fmt.Sprintf("• 🪴 Erde: %s\n", *plant.Soil))
}
sb.WriteString(fmt.Sprintf("• %s Gesundheit: %s\n", healthIcon(plant.Health), plant.Health))
if plant.Notes != nil && *plant.Notes != "" {
sb.WriteString(fmt.Sprintf("\n**Notizen:** %s\n", *plant.Notes))
}
return sb.String()
}
func healthIcon(health string) string {
switch health {
case "healthy":
return "💚"
case "needs_attention":
return "⚠️"
case "sick":
return "🔴"
default:
return "💚"
}
}
func mapPlantField(input string) string {
switch strings.ToLower(input) {
case "name":
return "name"
case "art", "wissenschaftlich", "scientific":
return "scientificName"
case "licht", "light":
return "light"
case "wasser", "water":
return "waterInterval"
case "feuchtigkeit", "humidity":
return "humidity"
case "temperatur", "temperature":
return "temperature"
case "erde", "soil":
return "soil"
case "notizen", "notes":
return "notes"
default:
return input
}
}

View file

@ -1,66 +0,0 @@
package presi
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("presi", func() plugin.Plugin { return &PresiPlugin{} })
}
type PresiPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *PresiPlugin) Name() string { return "presi" }
func (p *PresiPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!presentations", p.cmdList)
p.router.Handle("!präsentationen", p.cmdList)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("presi plugin initialized")
return nil
}
func (p *PresiPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!presentations"}, Description: "Präsentationen", Category: "Presi"},
}
}
func (p *PresiPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *PresiPlugin) cmdList(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📊 Präsentationen**\n\n_Verwaltung über die Web-App._")
return nil
}
func (p *PresiPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Presi Bot:** ✅ Online")
return nil
}
func (p *PresiPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📊 Presi Bot**\n\n• `!präsentationen` — Liste\n• `!status` — Status")
return nil
}

View file

@ -1,71 +0,0 @@
package projectdoc
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("projectdoc", func() plugin.Plugin { return &ProjectDocPlugin{} })
}
type ProjectDocPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *ProjectDocPlugin) Name() string { return "projectdoc" }
func (p *ProjectDocPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!docs", p.cmdDocs)
p.router.Handle("!generate", p.cmdGenerate)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("projectdoc plugin initialized")
return nil
}
func (p *ProjectDocPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!docs"}, Description: "Dokumentation anzeigen", Category: "Docs"},
{Patterns: []string{"!generate"}, Description: "Doku generieren", Category: "Docs"},
}
}
func (p *ProjectDocPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *ProjectDocPlugin) cmdDocs(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 Dokumentation**\n\n_Projekt-Dokumentation über die Web-App._")
return nil
}
func (p *ProjectDocPlugin) cmdGenerate(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 Doku generieren**\n\n_Beschreibe das zu dokumentierende Projekt._")
return nil
}
func (p *ProjectDocPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**ProjectDoc Bot:** ✅ Online")
return nil
}
func (p *ProjectDocPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📄 ProjectDoc Bot**\n\n• `!docs` — Dokumentation\n• `!generate` — Doku generieren\n• `!status` — Status")
return nil
}

View file

@ -1,66 +0,0 @@
package questions
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("questions", func() plugin.Plugin { return &QuestionsPlugin{} })
}
type QuestionsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *QuestionsPlugin) Name() string { return "questions" }
func (p *QuestionsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!frage", p.cmdAsk)
p.router.Handle("!ask", p.cmdAsk)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("questions plugin initialized")
return nil
}
func (p *QuestionsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!frage", "!ask"}, Description: "Frage stellen", Category: "Q&A"},
}
}
func (p *QuestionsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *QuestionsPlugin) cmdAsk(mc *plugin.MessageContext, args string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**❓ Fragen**\n\n_Q&A-System über die Web-App verfügbar._")
return nil
}
func (p *QuestionsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Questions Bot:** ✅ Online")
return nil
}
func (p *QuestionsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**❓ Questions Bot**\n\n• `!frage [text]` — Frage stellen\n• `!status` — Status")
return nil
}

View file

@ -1,65 +0,0 @@
package skilltree
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("skilltree", func() plugin.Plugin { return &SkilltreePlugin{} })
}
type SkilltreePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *SkilltreePlugin) Name() string { return "skilltree" }
func (p *SkilltreePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!skills", p.cmdSkills)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("skilltree plugin initialized")
return nil
}
func (p *SkilltreePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!skills"}, Description: "Skill-Übersicht", Category: "Skilltree"},
}
}
func (p *SkilltreePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *SkilltreePlugin) cmdSkills(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🌳 Skills**\n\n_Skill-Verwaltung über die Web-App._")
return nil
}
func (p *SkilltreePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Skilltree Bot:** ✅ Online")
return nil
}
func (p *SkilltreePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**🌳 Skilltree Bot**\n\n• `!skills` — Übersicht\n• `!status` — Status")
return nil
}

View file

@ -1,79 +0,0 @@
package stats
import (
"context"
"fmt"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("stats", func() plugin.Plugin { return &StatsPlugin{} })
}
// StatsPlugin reports system statistics via Matrix.
type StatsPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *StatsPlugin) Name() string { return "stats" }
func (p *StatsPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!stats", p.cmdStats)
p.router.Handle("!report", p.cmdStats)
p.router.Handle("!bericht", p.cmdStats)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("stats plugin initialized")
return nil
}
func (p *StatsPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!stats", "!report"}, Description: "Systemstatistiken", Category: "Stats"},
{Patterns: []string{"!status"}, Description: "Bot-Status", Category: "System"},
}
}
func (p *StatsPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
if cmd == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *StatsPlugin) cmdStats(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**📊 Systemstatistiken**\n\n_Verfügbar wenn VictoriaMetrics verbunden._")
return nil
}
func (p *StatsPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Stats Bot:** ✅ Online")
return nil
}
func (p *StatsPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**📊 Stats Bot**\n\n• `!stats` — Systemstatistiken\n• `!status` — Bot-Status"))
return nil
}

View file

@ -1,66 +0,0 @@
package storage
import (
"context"
"log/slog"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("storage", func() plugin.Plugin { return &StoragePlugin{} })
}
type StoragePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *StoragePlugin) Name() string { return "storage" }
func (p *StoragePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL != "" {
p.backend = services.NewBackendClient(cfg.BackendURL)
}
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!files", p.cmdFiles)
p.router.Handle("!dateien", p.cmdFiles)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(plugin.CommonKeywords)
slog.Info("storage plugin initialized")
return nil
}
func (p *StoragePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!files", "!dateien"}, Description: "Dateien anzeigen", Category: "Storage"},
}
}
func (p *StoragePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
if p.detector.Detect(mc.Body) == "help" {
return p.cmdHelp(mc, "")
}
return nil
}
func (p *StoragePlugin) cmdFiles(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📁 Dateien**\n\n_Dateiverwaltung über die Web-App._")
return nil
}
func (p *StoragePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**Storage Bot:** ✅ Online")
return nil
}
func (p *StoragePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
mc.Client.SendReply(context.Background(), mc.RoomID, mc.EventID, "**📁 Storage Bot**\n\n• `!dateien` — Dateien\n• `!status` — Status")
return nil
}

View file

@ -1,200 +0,0 @@
package stt
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("stt", func() plugin.Plugin { return &STTPlugin{} })
}
// UserSettings holds per-user STT preferences.
type UserSettings struct {
Language string // de, en, auto
Model string // whisper, voxtral, auto
}
// STTPlugin implements the Matrix speech-to-text bot.
type STTPlugin struct {
voice *services.VoiceClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
mu sync.RWMutex
settings map[string]*UserSettings
}
func (p *STTPlugin) Name() string { return "stt" }
func (p *STTPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
sttURL := cfg.Extra["stt_url"]
if sttURL == "" {
sttURL = "http://localhost:3020"
}
p.voice = services.NewVoiceClient(sttURL, "")
p.settings = make(map[string]*UserSettings)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!language", p.cmdLanguage)
p.router.Handle("!sprache", p.cmdLanguage)
p.router.Handle("!model", p.cmdModel)
p.router.Handle("!modell", p.cmdModel)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"sprache", "sprache ändern"}, Command: "language"},
plugin.KeywordCommand{Keywords: []string{"modell"}, Command: "model"},
))
slog.Info("stt plugin initialized", "url", sttURL)
return nil
}
func (p *STTPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!language [de|en|auto]"}, Description: "Sprache ändern", Category: "Einstellungen"},
{Patterns: []string{"!model [whisper|voxtral]"}, Description: "STT-Modell ändern", Category: "Einstellungen"},
{Patterns: []string{"!status"}, Description: "Aktuelle Einstellungen", Category: "System"},
}
}
func (p *STTPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "language":
return p.cmdLanguage(mc, "")
case "model":
return p.cmdModel(mc, "")
}
// STT bot only responds to commands and audio — ignore other text
return nil
}
// HandleAudioMessage transcribes audio messages.
func (p *STTPlugin) HandleAudioMessage(ctx context.Context, mc *plugin.MessageContext, audioData []byte) error {
settings := p.getSettings(mc.Sender)
result, err := p.voice.Transcribe(ctx, audioData, settings.Language)
if err != nil {
slog.Error("transcription failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Transkription fehlgeschlagen.")
return nil
}
if strings.TrimSpace(result.Text) == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "🎤 Ich konnte nichts verstehen.")
return nil
}
response := fmt.Sprintf("**Transkription:**\n\n%s\n\n*Sprache: %s | Dauer: %.1fs*",
result.Text, result.Language, result.Duration)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
// --- Command Handlers ---
func (p *STTPlugin) cmdLanguage(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validLangs := map[string]bool{"de": true, "en": true, "auto": true}
if args == "" || !validLangs[args] {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Sprache:** `%s`\n\n**Verwendung:** `!language [de|en|auto]`", settings.Language))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Language = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Sprache auf `%s` gesetzt.", args))
return nil
}
func (p *STTPlugin) cmdModel(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
validModels := map[string]bool{"whisper": true, "voxtral": true, "auto": true}
if args == "" || !validModels[args] {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelles Modell:** `%s`\n\n**Verwendung:** `!model [whisper|voxtral|auto]`\n\n**Modelle:**\n• `whisper` — Whisper Large V3 (lokal, schnell)\n• `voxtral` — Voxtral Mini (Cloud, Speaker Diarization)\n• `auto` — Automatische Auswahl", settings.Model))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Model = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Modell auf `%s` gesetzt.", args))
return nil
}
func (p *STTPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Einstellungen:**\n\nSprache: `%s`\nModell: `%s`", settings.Language, settings.Model))
return nil
}
func (p *STTPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🎤 STT Bot - Sprache zu Text**
Sende eine **Sprachnachricht** und ich transkribiere sie!
**Einstellungen:**
` + "`!language de`" + ` Sprache (de, en, auto)
` + "`!model whisper`" + ` Modell (whisper, voxtral, auto)
` + "`!status`" + ` Aktuelle Einstellungen
**Modelle:**
**Whisper** Lokal, schnell, gut für Deutsch/Englisch
**Voxtral** Cloud, Speaker Diarization`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Settings ---
func (p *STTPlugin) getSettings(userID string) *UserSettings {
p.mu.RLock()
settings, ok := p.settings[userID]
p.mu.RUnlock()
if !ok {
return &UserSettings{
Language: "de",
Model: "whisper",
}
}
return settings
}

View file

@ -1,553 +0,0 @@
package todo
import (
"context"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"time"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("todo", func() plugin.Plugin { return &TodoPlugin{} })
}
// Task represents a todo task from the backend.
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
Priority int `json:"priority"`
DueDate *string `json:"dueDate"`
Project *string `json:"project"`
CompletedAt *string `json:"completedAt"`
}
// TaskStats holds task statistics.
type TaskStats struct {
Total int `json:"total"`
Pending int `json:"pending"`
Completed int `json:"completed"`
Today int `json:"today"`
}
// CreateTaskInput is the request body for creating a task.
type CreateTaskInput struct {
Title string `json:"title"`
Priority int `json:"priority,omitempty"`
DueDate *string `json:"dueDate,omitempty"`
}
// TodoPlugin implements the Matrix todo bot.
type TodoPlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
}
func (p *TodoPlugin) Name() string { return "todo" }
func (p *TodoPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("todo plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
// Command router
p.router = plugin.NewCommandRouter()
p.router.Handle("!todo", p.cmdAdd)
p.router.Handle("!add", p.cmdAdd)
p.router.Handle("!neu", p.cmdAdd)
p.router.Handle("!list", p.cmdList)
p.router.Handle("!liste", p.cmdList)
p.router.Handle("!alle", p.cmdList)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!inbox", p.cmdInbox)
p.router.Handle("!eingang", p.cmdInbox)
p.router.Handle("!done", p.cmdDone)
p.router.Handle("!erledigt", p.cmdDone)
p.router.Handle("!fertig", p.cmdDone)
p.router.Handle("!delete", p.cmdDelete)
p.router.Handle("!löschen", p.cmdDelete)
p.router.Handle("!entfernen", p.cmdDelete)
p.router.Handle("!projects", p.cmdProjects)
p.router.Handle("!projekte", p.cmdProjects)
p.router.Handle("!status", p.cmdStatus)
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
// Keyword detector
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"zeige aufgaben", "show tasks"}, Command: "list"},
plugin.KeywordCommand{Keywords: []string{"heute", "today"}, Command: "today"},
plugin.KeywordCommand{Keywords: []string{"inbox", "eingang"}, Command: "inbox"},
plugin.KeywordCommand{Keywords: []string{"projekte", "projects"}, Command: "projects"},
plugin.KeywordCommand{Keywords: []string{"neu", "neue", "add"}, Command: "add"},
plugin.KeywordCommand{Keywords: []string{"erledigt", "fertig", "done"}, Command: "done"},
plugin.KeywordCommand{Keywords: []string{"löschen", "entfernen", "delete"}, Command: "delete"},
))
slog.Info("todo plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *TodoPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!todo", "!add", "!neu"}, Description: "Aufgabe erstellen", Category: "Aufgaben"},
{Patterns: []string{"!list", "!liste"}, Description: "Alle offenen Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!today", "!heute"}, Description: "Heutige Aufgaben", Category: "Aufgaben"},
{Patterns: []string{"!inbox", "!eingang"}, Description: "Aufgaben ohne Datum", Category: "Aufgaben"},
{Patterns: []string{"!done", "!erledigt"}, Description: "Aufgabe erledigen", Category: "Aufgaben"},
{Patterns: []string{"!delete", "!löschen"}, Description: "Aufgabe löschen", Category: "Aufgaben"},
{Patterns: []string{"!projects", "!projekte"}, Description: "Projekte anzeigen", Category: "Aufgaben"},
{Patterns: []string{"!status"}, Description: "Verbindungsstatus", Category: "System"},
{Patterns: []string{"!help", "!hilfe"}, Description: "Hilfe anzeigen", Category: "System"},
}
}
func (p *TodoPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keyword detection
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "list":
return p.cmdList(mc, "")
case "today":
return p.cmdToday(mc, "")
case "inbox":
return p.cmdInbox(mc, "")
case "projects":
return p.cmdProjects(mc, "")
case "add":
return p.cmdAdd(mc, mc.Body)
case "done":
return p.cmdDone(mc, "")
case "delete":
return p.cmdDelete(mc, "")
}
// Fallback: treat as new task
return p.cmdAdd(mc, mc.Body)
}
// --- Command Handlers ---
func (p *TodoPlugin) cmdAdd(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Aufgabe an.\n\nBeispiel: `!todo Einkaufen @morgen !p1`")
return nil
}
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
title, priority, dueDate, project := parseTaskInput(args)
input := CreateTaskInput{
Title: title,
Priority: priority,
DueDate: dueDate,
}
var task Task
if err := p.backend.Post(ctx, "/api/tasks", token, input, &task); err != nil {
slog.Error("create task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erstellt werden.")
return nil
}
// Build response
response := fmt.Sprintf("✅ Aufgabe erstellt: **%s**", task.Title)
var details []string
if priority < 4 {
details = append(details, fmt.Sprintf("Priorität %d", priority))
}
if dueDate != nil {
details = append(details, formatDate(*dueDate))
}
if project != "" {
details = append(details, "#"+project)
}
if len(details) > 0 {
response += " · " + strings.Join(details, " · ")
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *TodoPlugin) cmdList(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
slog.Error("get tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine offenen Aufgaben.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📋 **Alle offenen Aufgaben:**", tasks))
return nil
}
func (p *TodoPlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks/today", token, &tasks); err != nil {
slog.Error("get today tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Aufgaben für heute.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📅 **Heutige Aufgaben:**", tasks))
return nil
}
func (p *TodoPlugin) cmdInbox(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks/inbox", token, &tasks); err != nil {
slog.Error("get inbox tasks failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if len(tasks) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Aufgaben im Eingang.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatTaskList("📥 **Eingang:**", tasks))
return nil
}
func (p *TodoPlugin) cmdDone(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!done 1`")
return nil
}
// Get current task list to find the task by number
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if num > len(tasks) {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Aufgabe #%d nicht gefunden.", num))
return nil
}
task := tasks[num-1]
var completed Task
if err := p.backend.Put(ctx, "/api/tasks/"+task.ID+"/complete", token, nil, &completed); err != nil {
slog.Error("complete task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht erledigt werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ ~~%s~~", task.Title))
return nil
}
func (p *TodoPlugin) cmdDelete(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
num, err := strconv.Atoi(strings.TrimSpace(args))
if err != nil || num < 1 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine gültige Aufgabennummer an.\n\nBeispiel: `!delete 1`")
return nil
}
var tasks []Task
if err := p.backend.Get(ctx, "/api/tasks?completed=false", token, &tasks); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgaben konnten nicht geladen werden.")
return nil
}
if num > len(tasks) {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Aufgabe #%d nicht gefunden.", num))
return nil
}
task := tasks[num-1]
if err := p.backend.Delete(ctx, "/api/tasks/"+task.ID, token); err != nil {
slog.Error("delete task failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Aufgabe konnte nicht gelöscht werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("🗑️ %s", task.Title))
return nil
}
func (p *TodoPlugin) cmdProjects(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var projects []struct {
Name string `json:"name"`
ID string `json:"id"`
}
if err := p.backend.Get(ctx, "/api/projects", token, &projects); err != nil {
slog.Error("get projects failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Projekte konnten nicht geladen werden.")
return nil
}
if len(projects) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Projekte vorhanden.")
return nil
}
var sb strings.Builder
sb.WriteString("**Deine Projekte:**\n\n")
for _, proj := range projects {
sb.WriteString("• ")
sb.WriteString(proj.Name)
sb.WriteByte('\n')
}
sb.WriteString("\nZeige Projektaufgaben mit `!projekt [Name]`")
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *TodoPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Nicht angemeldet. Nutze `!login email passwort`")
return nil
}
var stats TaskStats
if err := p.backend.Get(ctx, "/api/tasks/stats", token, &stats); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Verbunden")
return nil
}
response := fmt.Sprintf("**Status**\n\n• Offen: %d\n• Heute: %d\n• Erledigt: %d",
stats.Pending, stats.Today, stats.Completed)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, response)
return nil
}
func (p *TodoPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**📋 Todo Bot - Befehle**
**Aufgaben:**
` + "`!todo Einkaufen @morgen !p1`" + ` Neue Aufgabe
` + "`!list`" + ` Alle offenen Aufgaben
` + "`!today`" + ` Heutige Aufgaben
` + "`!inbox`" + ` Aufgaben ohne Datum
` + "`!done 1`" + ` Aufgabe #1 erledigen
` + "`!delete 1`" + ` Aufgabe #1 löschen
**Syntax:**
` + "`!p1`" + ` bis ` + "`!p4`" + ` Priorität (1=hoch)
` + "`@heute`" + `, ` + "`@morgen`" + `, ` + "`@2025-03-27`" + ` Datum
` + "`#projekt`" + ` Projekt zuweisen
**Projekte:**
` + "`!projekte`" + ` Alle Projekte
**System:**
` + "`!status`" + ` Verbindungsstatus
` + "`!hilfe`" + ` Diese Hilfe`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Task Formatting ---
func formatTaskList(header string, tasks []Task) string {
var sb strings.Builder
sb.WriteString(header)
sb.WriteString("\n\n")
for i, task := range tasks {
sb.WriteString(fmt.Sprintf("**%d.** %s", i+1, task.Title))
// Priority indicators
if task.Priority < 4 {
sb.WriteByte(' ')
for j := 0; j < 4-task.Priority; j++ {
sb.WriteString("❗")
}
}
// Due date
if task.DueDate != nil {
sb.WriteString(" ")
sb.WriteString(formatDate(*task.DueDate))
}
// Project
if task.Project != nil && *task.Project != "" {
sb.WriteString(" #")
sb.WriteString(*task.Project)
}
sb.WriteByte('\n')
}
sb.WriteString("\nErledigen: `!done [Nr]` | Löschen: `!delete [Nr]`")
return sb.String()
}
func formatDate(dateStr string) string {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
// Handle full ISO datetime or date-only
date := dateStr
if len(date) > 10 {
date = date[:10]
}
switch date {
case today:
return "Heute"
case tomorrow:
return "Morgen"
default:
t, err := time.Parse("2006-01-02", date)
if err != nil {
return dateStr
}
return t.Format("02.01")
}
}
// --- Input Parsing ---
var (
rePriority = regexp.MustCompile(`!p([1-4])`)
reDate = regexp.MustCompile(`@(\S+)`)
reProject = regexp.MustCompile(`#(\S+)`)
)
// parseTaskInput extracts title, priority, dueDate, project from user input.
// Syntax: "Einkaufen !p1 @morgen #haushalt"
func parseTaskInput(input string) (title string, priority int, dueDate *string, project string) {
priority = 4 // default: lowest
// Extract priority
if m := rePriority.FindStringSubmatch(input); len(m) == 2 {
p, _ := strconv.Atoi(m[1])
priority = p
}
// Extract date
if m := reDate.FindStringSubmatch(input); len(m) == 2 {
d := parseGermanDate(m[1])
if d != "" {
dueDate = &d
}
}
// Extract project
if m := reProject.FindStringSubmatch(input); len(m) == 2 {
project = m[1]
}
// Remove markers from title
title = rePriority.ReplaceAllString(input, "")
title = reDate.ReplaceAllString(title, "")
title = reProject.ReplaceAllString(title, "")
title = strings.TrimSpace(title)
return
}
// parseGermanDate converts German date keywords to ISO date strings.
func parseGermanDate(keyword string) string {
now := time.Now()
switch strings.ToLower(keyword) {
case "heute", "today":
return now.Format("2006-01-02")
case "morgen", "tomorrow":
return now.AddDate(0, 0, 1).Format("2006-01-02")
case "übermorgen", "uebermorgen":
return now.AddDate(0, 0, 2).Format("2006-01-02")
default:
// Try ISO date format
if _, err := time.Parse("2006-01-02", keyword); err == nil {
return keyword
}
return ""
}
}

View file

@ -1,87 +0,0 @@
package todo
import (
"testing"
"time"
)
func TestParseTaskInput(t *testing.T) {
tests := []struct {
input string
title string
priority int
hasDate bool
project string
}{
{"Einkaufen", "Einkaufen", 4, false, ""},
{"Einkaufen !p1", "Einkaufen", 1, false, ""},
{"Einkaufen !p2 @morgen", "Einkaufen", 2, true, ""},
{"Einkaufen !p1 @morgen #haushalt", "Einkaufen", 1, true, "haushalt"},
{"Meeting vorbereiten #arbeit", "Meeting vorbereiten", 4, false, "arbeit"},
{" Spaces !p3 ", "Spaces", 3, false, ""},
}
for _, tt := range tests {
title, priority, dueDate, project := parseTaskInput(tt.input)
if title != tt.title {
t.Errorf("parseTaskInput(%q) title = %q, want %q", tt.input, title, tt.title)
}
if priority != tt.priority {
t.Errorf("parseTaskInput(%q) priority = %d, want %d", tt.input, priority, tt.priority)
}
if tt.hasDate && dueDate == nil {
t.Errorf("parseTaskInput(%q) expected dueDate, got nil", tt.input)
}
if !tt.hasDate && dueDate != nil {
t.Errorf("parseTaskInput(%q) expected no dueDate, got %q", tt.input, *dueDate)
}
if project != tt.project {
t.Errorf("parseTaskInput(%q) project = %q, want %q", tt.input, project, tt.project)
}
}
}
func TestParseGermanDate(t *testing.T) {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tests := []struct {
input string
want string
}{
{"heute", today},
{"today", today},
{"morgen", tomorrow},
{"tomorrow", tomorrow},
{"2025-03-27", "2025-03-27"},
{"ungültig", ""},
}
for _, tt := range tests {
got := parseGermanDate(tt.input)
if got != tt.want {
t.Errorf("parseGermanDate(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestFormatDate(t *testing.T) {
today := time.Now().Format("2006-01-02")
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tests := []struct {
input string
want string
}{
{today, "Heute"},
{tomorrow, "Morgen"},
{"2025-12-25", "25.12"},
}
for _, tt := range tests {
got := formatDate(tt.input)
if got != tt.want {
t.Errorf("formatDate(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View file

@ -1,253 +0,0 @@
package tts
import (
"context"
"fmt"
"log/slog"
"strconv"
"sync"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("tts", func() plugin.Plugin { return &TTSPlugin{} })
}
// UserSettings holds per-user TTS preferences.
type UserSettings struct {
Voice string
Speed float64
}
// TTSPlugin implements the Matrix text-to-speech bot.
type TTSPlugin struct {
voice *services.VoiceClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
maxLen int
mu sync.RWMutex
settings map[string]*UserSettings
}
func (p *TTSPlugin) Name() string { return "tts" }
func (p *TTSPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
ttsURL := cfg.Extra["tts_url"]
if ttsURL == "" {
ttsURL = "http://localhost:3022"
}
p.voice = services.NewVoiceClient("", ttsURL)
p.settings = make(map[string]*UserSettings)
p.maxLen = 500
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!voice", p.cmdVoice)
p.router.Handle("!stimme", p.cmdVoice)
p.router.Handle("!voices", p.cmdVoices)
p.router.Handle("!stimmen", p.cmdVoices)
p.router.Handle("!speed", p.cmdSpeed)
p.router.Handle("!geschwindigkeit", p.cmdSpeed)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"stimme", "stimme ändern"}, Command: "voice"},
plugin.KeywordCommand{Keywords: []string{"stimmen", "verfügbare stimmen"}, Command: "voices"},
plugin.KeywordCommand{Keywords: []string{"geschwindigkeit", "tempo"}, Command: "speed"},
))
slog.Info("tts plugin initialized", "url", ttsURL)
return nil
}
func (p *TTSPlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!voice [name]", "!stimme"}, Description: "Stimme ändern", Category: "Einstellungen"},
{Patterns: []string{"!voices", "!stimmen"}, Description: "Verfügbare Stimmen", Category: "Einstellungen"},
{Patterns: []string{"!speed [0.5-2.0]"}, Description: "Geschwindigkeit", Category: "Einstellungen"},
{Patterns: []string{"!status"}, Description: "Aktuelle Einstellungen", Category: "System"},
}
}
func (p *TTSPlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
// Try command router first
matched, err := p.router.Route(mc)
if matched {
return err
}
// Try keywords
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "voice":
return p.cmdVoice(mc, "")
case "voices":
return p.cmdVoices(mc, "")
case "speed":
return p.cmdSpeed(mc, "")
}
// Default: treat as text to synthesize
return p.synthesize(mc, mc.Body)
}
// --- Synthesis ---
func (p *TTSPlugin) synthesize(mc *plugin.MessageContext, text string) error {
ctx := context.Background()
if len(text) > p.maxLen {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("❌ Text zu lang (%d Zeichen). Maximum: %d Zeichen.", len(text), p.maxLen))
return nil
}
settings := p.getSettings(mc.Sender)
audioData, err := p.voice.Synthesize(ctx, text, settings.Voice)
if err != nil {
slog.Error("tts synthesis failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Sprachsynthese fehlgeschlagen.")
return nil
}
// Upload audio to Matrix
mxcURL, err := mc.Client.UploadMedia(ctx, audioData, "audio/wav", "speech.wav")
if err != nil {
slog.Error("upload audio failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Audio-Upload fehlgeschlagen.")
return nil
}
// Send audio message
_, err = mc.Client.SendAudio(ctx, mc.RoomID, mxcURL, "speech.wav", len(audioData))
if err != nil {
slog.Error("send audio failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Audio konnte nicht gesendet werden.")
return nil
}
return nil
}
// --- Command Handlers ---
func (p *TTSPlugin) cmdVoice(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Stimme:** `%s`\n\n**Verwendung:** `!voice [name]`\n\nZeige alle: `!voices`", settings.Voice))
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Voice = args
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Stimme auf `%s` gesetzt.", args))
return nil
}
func (p *TTSPlugin) cmdVoices(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
// Known voices (static list — the TTS service auto-discovers)
help := `**Verfügbare Stimmen:**
**Kokoro (Englisch, schnell):**
` + "`af_heart`" + ` Weiblich (Standard)
` + "`af_bella`" + ` Weiblich
` + "`am_michael`" + ` Männlich
` + "`bm_daniel`" + ` Männlich
` + "`bf_emma`" + ` Weiblich
**Piper (Deutsch, lokal):**
` + "`de_kerstin`" + ` Deutsch Frau
` + "`de_thorsten`" + ` Deutsch Mann
Wechseln mit: ` + "`!voice [name]`"
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
func (p *TTSPlugin) cmdSpeed(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Geschwindigkeit:** %.1fx\n\n**Verwendung:** `!speed [0.5-2.0]`", settings.Speed))
return nil
}
speed, err := strconv.ParseFloat(args, 64)
if err != nil || speed < 0.5 || speed > 2.0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Geschwindigkeit muss zwischen 0.5 und 2.0 liegen.\n\nBeispiel: `!speed 1.2`")
return nil
}
p.mu.Lock()
settings := p.getSettings(mc.Sender)
settings.Speed = speed
p.settings[mc.Sender] = settings
p.mu.Unlock()
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Geschwindigkeit auf %.1fx gesetzt.", speed))
return nil
}
func (p *TTSPlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
settings := p.getSettings(mc.Sender)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID,
fmt.Sprintf("**Aktuelle Einstellungen:**\n\nStimme: `%s`\nGeschwindigkeit: %.1fx\nMax. Textlänge: %d Zeichen",
settings.Voice, settings.Speed, p.maxLen))
return nil
}
func (p *TTSPlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**🔊 TTS Bot - Text zu Sprache**
Sende eine **Textnachricht** und ich lese sie vor!
**Einstellungen:**
` + "`!voice af_heart`" + ` Stimme wechseln
` + "`!voices`" + ` Alle Stimmen anzeigen
` + "`!speed 1.2`" + ` Geschwindigkeit (0.5-2.0)
` + "`!status`" + ` Aktuelle Einstellungen
**Max. Textlänge:** 500 Zeichen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Settings ---
func (p *TTSPlugin) getSettings(userID string) *UserSettings {
p.mu.RLock()
settings, ok := p.settings[userID]
p.mu.RUnlock()
if !ok {
return &UserSettings{
Voice: "af_heart",
Speed: 1.0,
}
}
return settings
}

View file

@ -1,392 +0,0 @@
package zitare
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
)
func init() {
plugin.Register("zitare", func() plugin.Plugin { return &ZitarePlugin{} })
}
// Quote represents a quote from the backend.
type Quote struct {
ID string `json:"id"`
Text string `json:"text"`
Author string `json:"author"`
Category string `json:"category"`
}
// Category represents a quote category.
type Category struct {
Name string `json:"name"`
Count int `json:"count"`
}
// ZitarePlugin implements the Matrix quotes bot.
type ZitarePlugin struct {
backend *services.BackendClient
router *plugin.CommandRouter
detector *plugin.KeywordDetector
lastQuote map[string]*Quote // per-user last shown quote
}
func (p *ZitarePlugin) Name() string { return "zitare" }
func (p *ZitarePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error {
if cfg.BackendURL == "" {
return fmt.Errorf("zitare plugin requires BackendURL")
}
p.backend = services.NewBackendClient(cfg.BackendURL)
p.lastQuote = make(map[string]*Quote)
p.router = plugin.NewCommandRouter()
p.router.Handle("!help", p.cmdHelp)
p.router.Handle("!hilfe", p.cmdHelp)
p.router.Handle("!zitat", p.cmdRandom)
p.router.Handle("!quote", p.cmdRandom)
p.router.Handle("!heute", p.cmdToday)
p.router.Handle("!today", p.cmdToday)
p.router.Handle("!suche", p.cmdSearch)
p.router.Handle("!search", p.cmdSearch)
p.router.Handle("!kategorie", p.cmdCategory)
p.router.Handle("!category", p.cmdCategory)
p.router.Handle("!kategorien", p.cmdCategories)
p.router.Handle("!categories", p.cmdCategories)
p.router.Handle("!motivation", p.cmdMotivation)
p.router.Handle("!morgen", p.cmdMorning)
p.router.Handle("!favorit", p.cmdFavorite)
p.router.Handle("!fav", p.cmdFavorite)
p.router.Handle("!favoriten", p.cmdFavorites)
p.router.Handle("!favorites", p.cmdFavorites)
p.router.Handle("!listen", p.cmdLists)
p.router.Handle("!lists", p.cmdLists)
p.router.Handle("!status", p.cmdStatus)
p.detector = plugin.NewKeywordDetector(append(plugin.CommonKeywords,
plugin.KeywordCommand{Keywords: []string{"zitat", "quote", "inspiration", "inspiriere"}, Command: "random"},
plugin.KeywordCommand{Keywords: []string{"tageszitat"}, Command: "today"},
plugin.KeywordCommand{Keywords: []string{"motiviere", "motivation", "motivier mich"}, Command: "motivation"},
plugin.KeywordCommand{Keywords: []string{"guten morgen", "good morning"}, Command: "morning"},
plugin.KeywordCommand{Keywords: []string{"kategorien", "categories", "themen"}, Command: "categories"},
plugin.KeywordCommand{Keywords: []string{"favoriten", "favorites", "meine favoriten"}, Command: "favorites"},
plugin.KeywordCommand{Keywords: []string{"listen", "lists", "meine listen"}, Command: "lists"},
))
slog.Info("zitare plugin initialized", "backend", cfg.BackendURL)
return nil
}
func (p *ZitarePlugin) Commands() []plugin.CommandDef {
return []plugin.CommandDef{
{Patterns: []string{"!zitat", "!quote"}, Description: "Zufälliges Zitat", Category: "Zitate"},
{Patterns: []string{"!heute", "!today"}, Description: "Zitat des Tages", Category: "Zitate"},
{Patterns: []string{"!suche [text]"}, Description: "Zitate suchen", Category: "Zitate"},
{Patterns: []string{"!kategorie [name]"}, Description: "Zitat aus Kategorie", Category: "Zitate"},
{Patterns: []string{"!kategorien"}, Description: "Alle Kategorien", Category: "Zitate"},
{Patterns: []string{"!motivation"}, Description: "Motivationszitat", Category: "Zitate"},
{Patterns: []string{"!favorit"}, Description: "Letztes Zitat als Favorit", Category: "Zitate"},
{Patterns: []string{"!favoriten"}, Description: "Alle Favoriten", Category: "Zitate"},
}
}
func (p *ZitarePlugin) HandleTextMessage(ctx context.Context, mc *plugin.MessageContext) error {
matched, err := p.router.Route(mc)
if matched {
return err
}
cmd := p.detector.Detect(mc.Body)
switch cmd {
case "help":
return p.cmdHelp(mc, "")
case "random":
return p.cmdRandom(mc, "")
case "today":
return p.cmdToday(mc, "")
case "motivation":
return p.cmdMotivation(mc, "")
case "morning":
return p.cmdMorning(mc, "")
case "categories":
return p.cmdCategories(mc, "")
case "favorites":
return p.cmdFavorites(mc, "")
case "lists":
return p.cmdLists(mc, "")
}
return nil
}
// --- Command Handlers ---
func (p *ZitarePlugin) cmdRandom(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/random", token, &quote); err != nil {
slog.Error("get random quote failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Zitat konnte nicht geladen werden.")
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdToday(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/today", token, &quote); err != nil {
slog.Error("get today quote failed", "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Zitat des Tages konnte nicht geladen werden.")
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdSearch(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib einen Suchbegriff an.\n\nBeispiel: `!suche Glück`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quotes []Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/search?q="+args, token, &quotes); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Suche fehlgeschlagen.")
return nil
}
if len(quotes) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("📭 Keine Zitate für \"%s\" gefunden.", args))
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**Suchergebnisse für \"%s\" (%d):**\n\n", args, len(quotes)))
limit := len(quotes)
if limit > 5 {
limit = 5
}
for i := 0; i < limit; i++ {
q := quotes[i]
sb.WriteString(fmt.Sprintf("**%d.** \"%s\"\n-- *%s*\n\n", i+1, q.Text, q.Author))
}
if len(quotes) > 5 {
sb.WriteString(fmt.Sprintf("_...und %d weitere_", len(quotes)-5))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdCategory(mc *plugin.MessageContext, args string) error {
ctx := context.Background()
if args == "" {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte gib eine Kategorie an.\n\nBeispiel: `!kategorie motivation`\nAlle Kategorien: `!kategorien`")
return nil
}
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var quote Quote
if err := p.backend.Get(ctx, "/api/v1/quotes/random?category="+args, token, &quote); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("❌ Kein Zitat in Kategorie \"%s\" gefunden.", args))
return nil
}
p.lastQuote[mc.Sender] = &quote
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote(&quote))
return nil
}
func (p *ZitarePlugin) cmdCategories(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, _ := mc.Session.Manager.GetToken(mc.Session.UserID)
var categories []Category
if err := p.backend.Get(ctx, "/api/v1/quotes/categories", token, &categories); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kategorien konnten nicht geladen werden.")
return nil
}
if len(categories) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Kategorien verfügbar.")
return nil
}
var sb strings.Builder
sb.WriteString("**Verfügbare Kategorien:**\n\n")
for _, cat := range categories {
sb.WriteString(fmt.Sprintf("• **%s** (`!kategorie %s`) - %d Zitate\n", cat.Name, strings.ToLower(cat.Name), cat.Count))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdMotivation(mc *plugin.MessageContext, _ string) error {
return p.cmdCategory(mc, "motivation")
}
func (p *ZitarePlugin) cmdMorning(mc *plugin.MessageContext, _ string) error {
return p.cmdCategory(mc, "motivation")
}
func (p *ZitarePlugin) cmdFavorite(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
quote, ok := p.lastQuote[mc.Sender]
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Kein Zitat zum Speichern. Hole zuerst eines: `!zitat`")
return nil
}
body := map[string]string{"quoteId": quote.ID}
if err := p.backend.Post(ctx, "/api/v1/favorites", token, body, nil); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favorit konnte nicht gespeichert werden.")
return nil
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "⭐ Zitat als Favorit gespeichert!")
return nil
}
func (p *ZitarePlugin) cmdFavorites(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var favorites []Quote
if err := p.backend.Get(ctx, "/api/v1/favorites", token, &favorites); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Favoriten konnten nicht geladen werden.")
return nil
}
if len(favorites) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Favoriten.\n\nSpeichere ein Zitat mit `!favorit` nach `!zitat`.")
return nil
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**⭐ Deine Favoriten (%d):**\n\n", len(favorites)))
limit := len(favorites)
if limit > 10 {
limit = 10
}
for i := 0; i < limit; i++ {
q := favorites[i]
sb.WriteString(fmt.Sprintf("**%d.** \"%s\"\n-- *%s*\n\n", i+1, q.Text, q.Author))
}
if len(favorites) > 10 {
sb.WriteString(fmt.Sprintf("_...und %d weitere_", len(favorites)-10))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdLists(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
token, ok := mc.Session.Manager.GetToken(mc.Session.UserID)
if !ok {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "Bitte melde dich zuerst an: `!login email passwort`")
return nil
}
var lists []struct {
Name string `json:"name"`
Count int `json:"count"`
}
if err := p.backend.Get(ctx, "/api/v1/lists", token, &lists); err != nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Listen konnten nicht geladen werden.")
return nil
}
if len(lists) == 0 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "📭 Keine Listen.\n\nErstelle eine mit: `!liste Meine Zitate`")
return nil
}
var sb strings.Builder
sb.WriteString("**📋 Deine Listen:**\n\n")
for i, l := range lists {
sb.WriteString(fmt.Sprintf("**%d.** %s (%d Zitate)\n", i+1, l.Name, l.Count))
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, sb.String())
return nil
}
func (p *ZitarePlugin) cmdStatus(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
loggedIn := mc.Session.Manager.IsLoggedIn(mc.Session.UserID)
status := "❌ Nicht angemeldet"
if loggedIn {
status = "✅ Angemeldet"
}
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("**Zitare Bot Status**\n\n%s", status))
return nil
}
func (p *ZitarePlugin) cmdHelp(mc *plugin.MessageContext, _ string) error {
ctx := context.Background()
help := `**📜 Zitare Bot - Befehle**
**Zitate:**
` + "`!zitat`" + ` Zufälliges Zitat
` + "`!heute`" + ` Zitat des Tages
` + "`!suche Glück`" + ` Zitate suchen
` + "`!kategorie motivation`" + ` Zitat aus Kategorie
` + "`!kategorien`" + ` Alle Kategorien
` + "`!motivation`" + ` Motivationszitat
**Favoriten:**
` + "`!favorit`" + ` Letztes Zitat als Favorit speichern
` + "`!favoriten`" + ` Alle Favoriten
**Listen:**
` + "`!listen`" + ` Alle Listen`
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, help)
return nil
}
// --- Formatting ---
func formatQuote(q *Quote) string {
author := q.Author
if author == "" {
author = "Unbekannt"
}
return fmt.Sprintf("\"%s\"\n-- *%s*", q.Text, author)
}

View file

@ -1,66 +0,0 @@
package runtime
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// HealthServer serves health and metrics endpoints.
type HealthServer struct {
runtime *Runtime
port int
}
// NewHealthServer creates a new health server.
func NewHealthServer(rt *Runtime, port int) *HealthServer {
return &HealthServer{runtime: rt, port: port}
}
// Start starts the HTTP health server.
func (h *HealthServer) Start() *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.handleHealth)
mux.HandleFunc("GET /metrics", h.handleMetrics)
server := &http.Server{
Addr: fmt.Sprintf(":%d", h.port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
go func() {
slog.Info("health server starting", "port", h.port)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
slog.Error("health server error", "error", err)
}
}()
return server
}
func (h *HealthServer) handleHealth(w http.ResponseWriter, r *http.Request) {
plugins := h.runtime.ActivePlugins()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"service": "mana-matrix-bot",
"timestamp": time.Now().UTC().Format(time.RFC3339),
"plugins": plugins,
"count": len(plugins),
})
}
func (h *HealthServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
plugins := h.runtime.ActivePlugins()
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "# HELP mana_matrix_bot_plugins_active Number of active plugins\n")
fmt.Fprintf(w, "# TYPE mana_matrix_bot_plugins_active gauge\n")
fmt.Fprintf(w, "mana_matrix_bot_plugins_active %d\n", len(plugins))
}

View file

@ -1,387 +0,0 @@
package runtime
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"strings"
"github.com/mana/mana-matrix-bot/internal/config"
"github.com/mana/mana-matrix-bot/internal/matrix"
"github.com/mana/mana-matrix-bot/internal/plugin"
"github.com/mana/mana-matrix-bot/internal/services"
"github.com/mana/mana-matrix-bot/internal/session"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// BotInstance represents one active plugin with its Matrix client.
type BotInstance struct {
Plugin plugin.Plugin
Client *matrix.Client
Config plugin.PluginConfig
MautrixCli *mautrix.Client
UserID id.UserID
}
// Runtime manages all plugin lifecycles.
type Runtime struct {
cfg *config.Config
sessions plugin.SessionManager
auth *services.AuthClient
bots []*BotInstance
mu sync.RWMutex
}
// New creates a new Runtime.
func New(cfg *config.Config) *Runtime {
// Try Redis for sessions, fall back to in-memory
var sessions plugin.SessionManager
if cfg.RedisHost != "" {
redisStore, err := session.NewRedisStore(session.RedisConfig{
Host: cfg.RedisHost,
Port: cfg.RedisPort,
Password: cfg.RedisPassword,
})
if err != nil {
slog.Warn("redis unavailable, using in-memory sessions", "error", err)
sessions = session.NewMemoryStore()
} else {
sessions = redisStore
slog.Info("using redis session store")
}
} else {
sessions = session.NewMemoryStore()
}
var auth *services.AuthClient
if cfg.AuthURL != "" {
auth = services.NewAuthClient(cfg.AuthURL)
}
return &Runtime{
cfg: cfg,
sessions: sessions,
auth: auth,
}
}
// Start initializes all enabled plugins and starts their Matrix sync loops.
func (r *Runtime) Start(ctx context.Context) error {
factories := plugin.All()
slog.Info("registered plugin factories", "count", len(factories))
for name, factory := range factories {
pluginCfg, ok := r.cfg.Plugins[name]
if !ok || !pluginCfg.Enabled {
slog.Info("plugin disabled or not configured", "plugin", name)
continue
}
if pluginCfg.AccessToken == "" {
slog.Warn("plugin has no access token, skipping", "plugin", name)
continue
}
p := factory()
// Create Matrix client for this plugin
storagePath := fmt.Sprintf("%s/sync_%s.json", r.cfg.StoragePath, name)
client, err := matrix.NewClient(matrix.ClientConfig{
HomeserverURL: r.cfg.HomeserverURL,
AccessToken: pluginCfg.AccessToken,
StoragePath: storagePath,
PluginName: name,
})
if err != nil {
slog.Error("failed to create matrix client", "plugin", name, "error", err)
continue
}
// Authenticate
userID, err := client.Login(ctx)
if err != nil {
slog.Error("failed to authenticate", "plugin", name, "error", err)
continue
}
// Convert config
pCfg := plugin.PluginConfig{
Enabled: pluginCfg.Enabled,
AccessToken: pluginCfg.AccessToken,
AllowedRooms: pluginCfg.AllowedRooms,
BackendURL: pluginCfg.BackendURL,
Extra: pluginCfg.Extra,
}
// Initialize plugin
if err := p.Init(ctx, pCfg); err != nil {
slog.Error("failed to init plugin", "plugin", name, "error", err)
continue
}
bot := &BotInstance{
Plugin: p,
Client: client,
Config: pCfg,
MautrixCli: client.Inner(),
UserID: userID,
}
r.mu.Lock()
r.bots = append(r.bots, bot)
r.mu.Unlock()
// Start sync loop for this bot
go r.startSync(ctx, bot)
// Start scheduled tasks if plugin implements Scheduler
if sched, ok := p.(plugin.Scheduler); ok {
for _, task := range sched.ScheduledTasks() {
go r.runScheduledTask(ctx, name, task)
}
}
slog.Info("plugin started", "plugin", name, "user_id", userID)
}
r.mu.RLock()
count := len(r.bots)
r.mu.RUnlock()
slog.Info("all plugins started", "active", count)
return nil
}
// Stop gracefully shuts down all plugins.
func (r *Runtime) Stop() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, bot := range r.bots {
bot.MautrixCli.StopSync()
slog.Info("plugin stopped", "plugin", bot.Plugin.Name())
}
}
// ActivePlugins returns the names of all active plugins.
func (r *Runtime) ActivePlugins() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, len(r.bots))
for i, bot := range r.bots {
names[i] = bot.Plugin.Name()
}
return names
}
// startSync starts the Matrix /sync loop for a bot instance.
func (r *Runtime) startSync(ctx context.Context, bot *BotInstance) {
syncer := bot.MautrixCli.Syncer.(*mautrix.DefaultSyncer)
// Auto-join rooms on invite
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
if evt.GetStateKey() == bot.UserID.String() {
content, ok := evt.Content.Parsed.(*event.MemberEventContent)
if ok && content.Membership == event.MembershipInvite {
_, err := bot.MautrixCli.JoinRoomByID(ctx, evt.RoomID)
if err != nil {
slog.Error("failed to join room", "plugin", bot.Plugin.Name(), "room", evt.RoomID, "error", err)
} else {
slog.Info("joined room", "plugin", bot.Plugin.Name(), "room", evt.RoomID)
}
}
}
})
// Handle messages
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
r.handleEvent(ctx, bot, evt)
})
slog.Info("starting sync", "plugin", bot.Plugin.Name())
if err := bot.MautrixCli.SyncWithContext(ctx); err != nil && ctx.Err() == nil {
slog.Error("sync error", "plugin", bot.Plugin.Name(), "error", err)
}
}
// handleEvent routes an event to the appropriate plugin handler.
func (r *Runtime) handleEvent(ctx context.Context, bot *BotInstance, evt *event.Event) {
// Ignore own messages
if evt.Sender == bot.UserID {
return
}
// Ignore messages from other bots
if matrix.IsBot(evt.Sender.String()) {
return
}
// Ignore edit events
if matrix.IsEditEvent(evt) {
return
}
// Check room allow-list
roomID := evt.RoomID.String()
if len(bot.Config.AllowedRooms) > 0 {
allowed := false
for _, r := range bot.Config.AllowedRooms {
if r == roomID {
allowed = true
break
}
}
if !allowed {
return
}
}
// Build message context
mc := &plugin.MessageContext{
RoomID: roomID,
Sender: evt.Sender.String(),
EventID: evt.ID.String(),
Client: bot.Client,
Session: &plugin.SessionAccess{
UserID: evt.Sender.String(),
Manager: r.sessions,
},
}
pluginName := bot.Plugin.Name()
// Route by message type
if matrix.IsTextMessage(evt) {
mc.Body = matrix.GetMessageBody(evt)
if mc.Body == "" {
return
}
// Global commands: !login / !logout (handled before plugins)
if r.handleGlobalCommand(ctx, mc) {
return
}
if err := bot.Client.SetTyping(ctx, roomID, true); err != nil {
slog.Debug("failed to set typing", "error", err)
}
if err := bot.Plugin.HandleTextMessage(ctx, mc); err != nil {
slog.Error("plugin error", "plugin", pluginName, "error", err)
bot.Client.SetTyping(ctx, roomID, false)
bot.Client.SendReply(ctx, roomID, evt.ID.String(), "❌ Ein Fehler ist aufgetreten.")
return
}
bot.Client.SetTyping(ctx, roomID, false)
} else if matrix.IsAudioMessage(evt) {
audioHandler, ok := bot.Plugin.(plugin.AudioHandler)
if !ok {
return
}
mxcURL := matrix.GetMediaURL(evt)
if mxcURL == "" {
return
}
audioData, err := bot.Client.DownloadMedia(ctx, mxcURL)
if err != nil {
slog.Error("download audio failed", "plugin", pluginName, "error", err)
return
}
if err := bot.Client.SetTyping(ctx, roomID, true); err != nil {
slog.Debug("failed to set typing", "error", err)
}
if err := audioHandler.HandleAudioMessage(ctx, mc, audioData); err != nil {
slog.Error("audio handler error", "plugin", pluginName, "error", err)
bot.Client.SetTyping(ctx, roomID, false)
bot.Client.SendReply(ctx, roomID, evt.ID.String(), "❌ Sprachverarbeitung fehlgeschlagen.")
return
}
bot.Client.SetTyping(ctx, roomID, false)
} else if matrix.IsImageMessage(evt) {
imageHandler, ok := bot.Plugin.(plugin.ImageHandler)
if !ok {
return
}
if err := imageHandler.HandleImageMessage(ctx, mc); err != nil {
slog.Error("image handler error", "plugin", pluginName, "error", err)
}
}
}
// handleGlobalCommand intercepts !login and !logout before plugin routing.
// Returns true if the command was handled.
func (r *Runtime) handleGlobalCommand(ctx context.Context, mc *plugin.MessageContext) bool {
lower := strings.ToLower(mc.Body)
// !login email password
if strings.HasPrefix(lower, "!login ") || strings.HasPrefix(lower, "!anmelden ") {
parts := strings.Fields(mc.Body)
if len(parts) < 3 {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "**Verwendung:** `!login email passwort`")
return true
}
if r.auth == nil {
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Auth-Service nicht konfiguriert.")
return true
}
email := parts[1]
password := parts[2]
resp, err := r.auth.Login(ctx, email, password)
if err != nil {
slog.Debug("login failed", "email", email, "error", err)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "❌ Login fehlgeschlagen. Überprüfe E-Mail und Passwort.")
return true
}
expiresAt := services.TokenExpiresAt(resp)
r.sessions.SetToken(mc.Sender, resp.Token, expiresAt)
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, fmt.Sprintf("✅ Angemeldet als **%s**", email))
return true
}
// !logout / !abmelden
if lower == "!logout" || lower == "!abmelden" {
r.sessions.SetToken(mc.Sender, "", time.Now().Add(-1*time.Hour))
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, "✅ Abgemeldet.")
return true
}
return false
}
// runScheduledTask runs a periodic task for a plugin.
func (r *Runtime) runScheduledTask(ctx context.Context, pluginName string, task plugin.ScheduledTask) {
slog.Info("scheduled task started", "plugin", pluginName, "task", task.Name, "interval", task.Interval)
ticker := time.NewTicker(task.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := task.Run(ctx); err != nil {
slog.Error("scheduled task failed", "plugin", pluginName, "task", task.Name, "error", err)
}
}
}
}

View file

@ -1,52 +0,0 @@
package services
import (
"context"
"fmt"
"time"
)
// AuthClient handles login/logout via mana-auth.
type AuthClient struct {
backend *BackendClient
}
// LoginResponse holds the auth service login response.
type LoginResponse struct {
Token string `json:"token"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"` // seconds
}
// NewAuthClient creates a new auth service client.
func NewAuthClient(authURL string) *AuthClient {
return &AuthClient{backend: NewBackendClient(authURL)}
}
// Login authenticates a user and returns a JWT token.
func (c *AuthClient) Login(ctx context.Context, email, password string) (*LoginResponse, error) {
body := map[string]string{
"email": email,
"password": password,
}
var resp LoginResponse
if err := c.backend.Post(ctx, "/api/v1/auth/login", "", body, &resp); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
if resp.Token == "" {
return nil, fmt.Errorf("login: no token in response")
}
return &resp, nil
}
// TokenExpiresAt calculates the expiry time from the login response.
func TokenExpiresAt(resp *LoginResponse) time.Time {
if resp.ExpiresIn > 0 {
return time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second)
}
// Default: 24 hours
return time.Now().Add(24 * time.Hour)
}

View file

@ -1,154 +0,0 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// BackendClient is a generic HTTP client for calling NestJS backends.
type BackendClient struct {
baseURL string
httpClient *http.Client
}
// NewBackendClient creates a new backend client.
func NewBackendClient(baseURL string) *BackendClient {
return &BackendClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// Get sends a GET request and decodes the JSON response.
func (c *BackendClient) Get(ctx context.Context, path string, token string, result any) error {
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+path, nil)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend GET %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend GET %s: %d %s", path, resp.StatusCode, string(body))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Post sends a POST request with a JSON body and decodes the response.
func (c *BackendClient) Post(ctx context.Context, path string, token string, body any, result any) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+path, reqBody)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend POST %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend POST %s: %d %s", path, resp.StatusCode, string(body))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Put sends a PUT request with a JSON body.
func (c *BackendClient) Put(ctx context.Context, path string, token string, body any, result any) error {
var reqBody io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
reqBody = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+path, reqBody)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend PUT %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend PUT %s: %d %s", path, resp.StatusCode, string(respBody))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
// Delete sends a DELETE request.
func (c *BackendClient) Delete(ctx context.Context, path string, token string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+path, nil)
if err != nil {
return err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("backend DELETE %s: %w", path, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("backend DELETE %s: %d %s", path, resp.StatusCode, string(body))
}
return nil
}

View file

@ -1,62 +0,0 @@
package services
import (
"context"
"fmt"
"log/slog"
)
// CreditClient handles credit balance and consumption via mana-auth.
type CreditClient struct {
backend *BackendClient
}
// CreditBalance holds a user's credit balance.
type CreditBalance struct {
Balance float64 `json:"balance"`
Used float64 `json:"used"`
}
// NewCreditClient creates a new credit service client.
func NewCreditClient(authURL, serviceKey string) *CreditClient {
client := NewBackendClient(authURL)
// Service key is used for internal API calls
_ = serviceKey
return &CreditClient{backend: client}
}
// GetBalance returns the user's current credit balance.
func (c *CreditClient) GetBalance(ctx context.Context, token string) (*CreditBalance, error) {
var balance CreditBalance
if err := c.backend.Get(ctx, "/api/v1/credits/balance", token, &balance); err != nil {
return nil, fmt.Errorf("get balance: %w", err)
}
return &balance, nil
}
// Consume deducts credits for an operation.
func (c *CreditClient) Consume(ctx context.Context, token string, amount float64, description string) error {
body := map[string]any{
"amount": amount,
"description": description,
}
if err := c.backend.Post(ctx, "/api/v1/credits/use", token, body, nil); err != nil {
return fmt.Errorf("consume credits: %w", err)
}
return nil
}
// HasEnough checks if the user has enough credits for an operation.
func (c *CreditClient) HasEnough(ctx context.Context, token string, required float64) (bool, float64) {
balance, err := c.GetBalance(ctx, token)
if err != nil {
slog.Debug("credit check failed", "error", err)
return true, 0 // Allow on error (fail open)
}
return balance.Balance >= required, balance.Balance
}
// FormatBalance returns a formatted credit balance string.
func FormatBalance(balance float64) string {
return fmt.Sprintf("%.2f", balance)
}

View file

@ -1,118 +0,0 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"time"
)
// VoiceClient handles STT and TTS requests to the Python services.
type VoiceClient struct {
sttURL string
ttsURL string
httpClient *http.Client
}
// TranscriptionResult holds the STT response.
type TranscriptionResult struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
}
// NewVoiceClient creates a new voice service client.
func NewVoiceClient(sttURL, ttsURL string) *VoiceClient {
return &VoiceClient{
sttURL: sttURL,
ttsURL: ttsURL,
httpClient: &http.Client{
Timeout: 120 * time.Second, // STT/TTS can be slow
},
}
}
// Transcribe sends audio data to the STT service and returns the transcription.
func (c *VoiceClient) Transcribe(ctx context.Context, audioData []byte, language string) (*TranscriptionResult, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("file", "audio.ogg")
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := part.Write(audioData); err != nil {
return nil, fmt.Errorf("write audio: %w", err)
}
if language != "" {
if err := writer.WriteField("language", language); err != nil {
return nil, fmt.Errorf("write language: %w", err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close writer: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", c.sttURL+"/transcribe", &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("STT request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("STT error %d: %s", resp.StatusCode, string(body))
}
var result TranscriptionResult
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode STT response: %w", err)
}
return &result, nil
}
// Synthesize sends text to the TTS service and returns audio data.
func (c *VoiceClient) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
payload := map[string]any{
"text": text,
}
if voice != "" {
payload["voice"] = voice
}
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", c.ttsURL+"/synthesize", bytes.NewReader(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("TTS request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("TTS error %d: %s", resp.StatusCode, string(body))
}
return io.ReadAll(resp.Body)
}

View file

@ -1,174 +0,0 @@
package session
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/redis/go-redis/v9"
)
// RedisStore is a Redis-backed session manager.
// Sessions are shared across all plugins and persist across restarts.
type RedisStore struct {
client *redis.Client
prefix string
ttl time.Duration
}
// RedisConfig holds configuration for the Redis session store.
type RedisConfig struct {
Host string
Port int
Password string
Prefix string // key prefix (default: "mana-bot:session:")
TTL time.Duration // session TTL (default: 24h)
}
// NewRedisStore creates a new Redis session store.
func NewRedisStore(cfg RedisConfig) (*RedisStore, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Password: cfg.Password,
DB: 0,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("redis ping: %w", err)
}
prefix := cfg.Prefix
if prefix == "" {
prefix = "mana-bot:session:"
}
ttl := cfg.TTL
if ttl == 0 {
ttl = 24 * time.Hour
}
slog.Info("redis session store connected", "addr", fmt.Sprintf("%s:%d", cfg.Host, cfg.Port))
return &RedisStore{
client: client,
prefix: prefix,
ttl: ttl,
}, nil
}
// key builds the Redis key for a user's session data.
func (s *RedisStore) key(userID string) string {
return s.prefix + userID
}
// tokenKey builds the Redis key for a user's auth token.
func (s *RedisStore) tokenKey(userID string) string {
return s.prefix + "token:" + userID
}
// Get retrieves a session value.
func (s *RedisStore) Get(userID, key string) (any, bool) {
ctx := context.Background()
val, err := s.client.HGet(ctx, s.key(userID), key).Result()
if err != nil {
return nil, false
}
// Try to unmarshal as JSON first
var result any
if err := json.Unmarshal([]byte(val), &result); err == nil {
return result, true
}
return val, true
}
// Set stores a session value.
func (s *RedisStore) Set(userID, key string, value any) {
ctx := context.Background()
data, err := json.Marshal(value)
if err != nil {
slog.Error("redis session set: marshal failed", "error", err)
return
}
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.key(userID), key, string(data))
pipe.Expire(ctx, s.key(userID), s.ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis session set failed", "error", err)
}
}
// Delete removes a session value.
func (s *RedisStore) Delete(userID, key string) {
ctx := context.Background()
if err := s.client.HDel(ctx, s.key(userID), key).Err(); err != nil {
slog.Error("redis session delete failed", "error", err)
}
}
// GetToken returns the stored auth token for a user.
func (s *RedisStore) GetToken(userID string) (string, bool) {
ctx := context.Background()
// Get token and expiry from hash
vals, err := s.client.HMGet(ctx, s.tokenKey(userID), "token", "expires_at").Result()
if err != nil || len(vals) < 2 || vals[0] == nil {
return "", false
}
token, ok := vals[0].(string)
if !ok || token == "" {
return "", false
}
// Check expiry
if expiresStr, ok := vals[1].(string); ok && expiresStr != "" {
expiresAt, err := time.Parse(time.RFC3339, expiresStr)
if err == nil && time.Now().After(expiresAt) {
// Token expired, clean up
s.client.Del(ctx, s.tokenKey(userID))
return "", false
}
}
return token, true
}
// SetToken stores an auth token with expiration.
func (s *RedisStore) SetToken(userID, token string, expiresAt time.Time) {
ctx := context.Background()
pipe := s.client.Pipeline()
pipe.HSet(ctx, s.tokenKey(userID), map[string]any{
"token": token,
"expires_at": expiresAt.Format(time.RFC3339),
})
// Set Redis TTL to match token expiry (plus buffer)
ttl := time.Until(expiresAt) + 1*time.Hour
if ttl < s.ttl {
ttl = s.ttl
}
pipe.Expire(ctx, s.tokenKey(userID), ttl)
if _, err := pipe.Exec(ctx); err != nil {
slog.Error("redis set token failed", "error", err)
}
}
// IsLoggedIn checks if a user has a valid token.
func (s *RedisStore) IsLoggedIn(userID string) bool {
_, ok := s.GetToken(userID)
return ok
}
// Close closes the Redis connection.
func (s *RedisStore) Close() error {
return s.client.Close()
}

View file

@ -1,93 +0,0 @@
package session
import (
"sync"
"time"
)
// UserSession holds per-user session data.
type UserSession struct {
Token string
ExpiresAt time.Time
Data map[string]any
}
// MemoryStore is an in-memory session manager.
type MemoryStore struct {
mu sync.RWMutex
sessions map[string]*UserSession // keyed by userID
}
// NewMemoryStore creates a new in-memory session store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
sessions: make(map[string]*UserSession),
}
}
func (s *MemoryStore) getOrCreate(userID string) *UserSession {
if sess, ok := s.sessions[userID]; ok {
return sess
}
sess := &UserSession{Data: make(map[string]any)}
s.sessions[userID] = sess
return sess
}
// Get retrieves a session value.
func (s *MemoryStore) Get(userID, key string) (any, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[userID]
if !ok {
return nil, false
}
val, ok := sess.Data[key]
return val, ok
}
// Set stores a session value.
func (s *MemoryStore) Set(userID, key string, value any) {
s.mu.Lock()
defer s.mu.Unlock()
sess := s.getOrCreate(userID)
sess.Data[key] = value
}
// Delete removes a session value.
func (s *MemoryStore) Delete(userID, key string) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.sessions[userID]; ok {
delete(sess.Data, key)
}
}
// GetToken returns the stored auth token for a user.
func (s *MemoryStore) GetToken(userID string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[userID]
if !ok || sess.Token == "" {
return "", false
}
if time.Now().After(sess.ExpiresAt) {
return "", false
}
return sess.Token, true
}
// SetToken stores an auth token with expiration.
func (s *MemoryStore) SetToken(userID, token string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
sess := s.getOrCreate(userID)
sess.Token = token
sess.ExpiresAt = expiresAt
}
// IsLoggedIn checks if a user has a valid (non-expired) token.
func (s *MemoryStore) IsLoggedIn(userID string) bool {
_, ok := s.GetToken(userID)
return ok
}

View file

@ -1,12 +0,0 @@
{
"name": "mana-matrix-bot",
"version": "1.0.0",
"private": true,
"description": "Consolidated Go Matrix bot replacing 21 NestJS bot services",
"scripts": {
"build": "go build -ldflags=\"-s -w\" -o dist/mana-matrix-bot ./cmd/server",
"dev": "go run ./cmd/server",
"test": "go test ./...",
"docker:build": "docker build -t mana-matrix-bot:local -f Dockerfile ../../"
}
}

View file

@ -9,7 +9,6 @@ Central media handling service for all Mana applications with content-addressabl
mana-media provides:
- **Content-Addressable Storage** - SHA-256 based deduplication across all apps
- **Upload API** - File uploads with automatic deduplication
- **Matrix Import** - Copy images from Matrix MXC URLs to persistent storage
- **Processing** - Thumbnails, WebP conversion, resizing (via BullMQ)
- **Delivery** - Optimized file serving, on-the-fly transforms
@ -59,18 +58,6 @@ curl -X POST http://localhost:3015/api/v1/media/upload \
}
```
### Import from Matrix
```bash
# Import media from Matrix MXC URL
curl -X POST http://localhost:3015/api/v1/media/import/matrix \
-H "Content-Type: application/json" \
-d '{
"mxcUrl": "mxc://matrix.mana.how/abc123",
"app": "nutriphi",
"userId": "user-uuid"
}'
```
### Get Media
```bash
# Get metadata
@ -128,8 +115,7 @@ const customUrl = media.getTransformUrl(result.id, {
┌─────────────────────────────────────────────────────────────┐
│ mana-media (Port 3015) │
├─────────────────────────────────────────────────────────────┤
│ Upload Module │ File uploads, Matrix import, dedup │
│ Matrix Module │ Download from Matrix MXC URLs │
│ Upload Module │ File uploads, dedup │
│ Process Module │ Sharp thumbnail generation (BullMQ) │
│ Storage Module │ MinIO S3 abstraction │
│ Delivery Module │ File serving + on-the-fly transforms │
@ -165,7 +151,7 @@ const customUrl = media.getTransformUrl(result.id, {
| media_id | UUID | FK to media |
| user_id | UUID | Owner user ID |
| app | TEXT | Source app (nutriphi, contacts, etc.) |
| source_url | TEXT | Original source (e.g., mxc:// URL) |
| source_url | TEXT | Original source URL |
## Processing Pipeline
@ -191,7 +177,6 @@ const customUrl = media.getTransformUrl(result.id, {
| S3_SECRET_KEY | minioadmin | S3 secret key |
| S3_BUCKET | mana-media | Storage bucket |
| S3_PUBLIC_URL | - | Public URL for media |
| MATRIX_HOMESERVER_URL | https://matrix.mana.how | Matrix homeserver |
| PUBLIC_URL | http://localhost:3015/api/v1 | Public API URL |
## Development
@ -230,27 +215,8 @@ mana-media bucket/
- [x] v0.1: Basic upload + thumbnails
- [x] v0.2: PostgreSQL persistence with Drizzle ORM
- [x] v0.3: Content-addressable storage with SHA-256 deduplication
- [x] v0.4: Matrix MXC URL import
- [ ] v0.5: Video thumbnails (FFmpeg)
- [ ] v0.6: Chunked upload for large files
- [ ] v0.7: OCR for documents
- [ ] v0.8: Vector search (Qdrant)
- [ ] v1.0: Full production ready
## Integration Example (NutriPhi Bot)
```typescript
// In matrix-nutriphi-bot
const response = await fetch('http://mana-media:3015/api/v1/media/import/matrix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mxcUrl: 'mxc://matrix.mana.how/abc123',
app: 'nutriphi',
userId: userUuid,
}),
});
const { id, hash, urls } = await response.json();
// Store id or hash in meal record for reference
```

View file

@ -80,11 +80,11 @@ export const mediaReferences = mediaSchema.table(
mediaId: uuid('media_id')
.references(() => media.id, { onDelete: 'cascade' })
.notNull(),
// Owner info (can be UUID or Matrix user ID like @user:matrix.org)
// Owner user ID
userId: text('user_id').notNull(),
// Source app (nutriphi, contacts, chat, etc.)
app: text('app').notNull(),
// Optional: reference to the source (e.g., mxc:// URL from Matrix)
// Optional: reference to the source URL
sourceUrl: text('source_url'),
// Custom metadata per reference
metadata: jsonb('metadata'),

View file

@ -7,7 +7,6 @@ import { StorageService } from './services/storage';
import { UploadService } from './services/upload';
import { ProcessService } from './services/process';
import { ExifService } from './services/exif';
import { MatrixService } from './services/matrix';
import { uploadRoutes } from './routes/upload';
import { deliveryRoutes } from './routes/delivery';
import { PROCESS_QUEUE, SUPPORTED_IMAGE_TYPES } from './constants';
@ -24,7 +23,6 @@ const storage = new StorageService();
await storage.init();
const exifService = new ExifService();
const matrixService = new MatrixService();
const processService = new ProcessService(storage, exifService);
const processQueue = new Queue(PROCESS_QUEUE, {
@ -35,7 +33,7 @@ const processQueue = new Queue(PROCESS_QUEUE, {
},
});
const uploadService = new UploadService(db, storage, matrixService, processQueue);
const uploadService = new UploadService(db, storage, processQueue);
// BullMQ Worker
const worker = new Worker(

View file

@ -54,30 +54,6 @@ export function uploadRoutes(uploadService: UploadService) {
return c.json(toResponse(record), 201);
});
// Import from Matrix
app.post('/import/matrix', async (c) => {
const { mxcUrl, app: appName, userId, skipProcessing } = await c.req.json();
if (!mxcUrl) return c.json({ error: 'mxcUrl is required' }, 400);
if (!appName) return c.json({ error: 'app is required' }, 400);
if (!userId) return c.json({ error: 'userId is required' }, 400);
const record = await uploadService.importFromMatrix(mxcUrl, {
app: appName,
userId,
skipProcessing,
});
if (!record) {
return c.json(
{ error: 'Failed to import from Matrix. Invalid MXC URL or download failed.' },
400
);
}
return c.json(toResponse(record), 201);
});
// Get by ID
app.get('/:id', async (c) => {
const id = c.req.param('id');

View file

@ -1,61 +0,0 @@
export interface MatrixMediaInfo {
buffer: Buffer;
mimeType: string;
size: number;
filename?: string;
}
export class MatrixService {
private readonly homeserverUrl: string;
constructor() {
this.homeserverUrl = process.env.MATRIX_HOMESERVER_URL || 'https://matrix.mana.how';
}
parseMxcUrl(mxcUrl: string): { server: string; mediaId: string } | null {
const match = mxcUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
if (!match) return null;
return { server: match[1], mediaId: match[2] };
}
getDownloadUrl(mxcUrl: string): string | null {
const parsed = this.parseMxcUrl(mxcUrl);
if (!parsed) return null;
return `${this.homeserverUrl}/_matrix/media/v3/download/${parsed.server}/${parsed.mediaId}`;
}
async downloadFromMxc(mxcUrl: string): Promise<MatrixMediaInfo | null> {
const downloadUrl = this.getDownloadUrl(mxcUrl);
if (!downloadUrl) {
console.error(`Invalid MXC URL: ${mxcUrl}`);
return null;
}
try {
const response = await fetch(downloadUrl);
if (!response.ok) {
console.error(`Failed to download from Matrix: ${response.status} ${response.statusText}`);
return null;
}
const contentType = response.headers.get('content-type') || 'application/octet-stream';
const contentDisposition = response.headers.get('content-disposition');
let filename: string | undefined;
if (contentDisposition) {
const match = contentDisposition.match(
/filename[*]?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?/i
);
if (match) filename = decodeURIComponent(match[1]);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
return { buffer, mimeType: contentType, size: buffer.length, filename };
} catch (error) {
console.error(`Error downloading from Matrix: ${error}`);
return null;
}
}
}

View file

@ -4,7 +4,6 @@ import * as crypto from 'crypto';
import { eq, and, gte, lte, like, isNotNull, sql, desc, asc, inArray } from 'drizzle-orm';
import type { Database } from '../db';
import { StorageService } from './storage';
import { MatrixService } from './matrix';
import { PROCESS_QUEUE } from '../constants';
import {
media,
@ -78,7 +77,6 @@ export class UploadService {
constructor(
private db: Database,
private storage: StorageService,
private matrixService: MatrixService,
private processQueue: Queue
) {}
@ -138,59 +136,6 @@ export class UploadService {
return this.toMediaRecord(inserted);
}
async importFromMatrix(
mxcUrl: string,
options: { app: string; userId: string; skipProcessing?: boolean }
): Promise<MediaRecord | null> {
const matrixMedia = await this.matrixService.downloadFromMxc(mxcUrl);
if (!matrixMedia) return null;
const hash = this.computeHash(matrixMedia.buffer);
const existing = await this.findByHash(hash);
if (existing) {
await this.createReference(existing.id, options.userId, options.app, mxcUrl);
return this.toMediaRecord(existing);
}
const ext = mime.extension(matrixMedia.mimeType) || 'bin';
const date = new Date();
const datePath = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
const id = crypto.randomUUID();
const originalKey = `originals/${datePath}/${id}.${ext}`;
await this.storage.upload(originalKey, matrixMedia.buffer, matrixMedia.mimeType, {
'x-amz-meta-source': 'matrix',
'x-amz-meta-source-url': mxcUrl,
'x-amz-meta-media-id': id,
});
const [inserted] = await this.db
.insert(media)
.values({
id,
contentHash: hash,
originalName: matrixMedia.filename || null,
mimeType: matrixMedia.mimeType,
size: matrixMedia.size,
originalKey,
status: options?.skipProcessing ? 'ready' : 'processing',
} satisfies NewMedia)
.returning();
await this.createReference(inserted.id, options.userId, options.app, mxcUrl);
if (!options?.skipProcessing) {
await this.processQueue.add('process-media', {
mediaId: inserted.id,
mimeType: matrixMedia.mimeType,
originalKey,
});
}
return this.toMediaRecord(inserted);
}
async get(id: string): Promise<MediaRecord | null> {
const [result] = await this.db.select().from(media).where(eq(media.id, id)).limit(1);
return result ? this.toMediaRecord(result) : null;

View file

@ -14,13 +14,6 @@ export interface MediaResult {
createdAt: Date;
}
export interface ImportFromMatrixOptions {
mxcUrl: string;
app: string;
userId: string;
skipProcessing?: boolean;
}
export interface UploadOptions {
app?: string;
userId?: string;
@ -85,33 +78,6 @@ export class MediaClient {
return response.json();
}
/**
* Import media from a Matrix MXC URL
* Copies the file from Matrix to mana-media storage with deduplication
*/
async importFromMatrix(options: ImportFromMatrixOptions): Promise<MediaResult> {
const response = await fetch(`${this.baseUrl}/api/v1/media/import/matrix`, {
method: 'POST',
headers: {
...this.getHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
mxcUrl: options.mxcUrl,
app: options.app,
userId: options.userId,
skipProcessing: options.skipProcessing,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Import from Matrix failed: ${response.statusText} - ${error}`);
}
return response.json();
}
/**
* Get media by content hash (SHA-256)
* Useful for checking if a file already exists before uploading

View file

@ -1,6 +1,6 @@
# mana-notify (Go)
Go replacement for the NestJS mana-notify service. Unified notification microservice for email, push, Matrix, and webhook notifications.
Go replacement for the NestJS mana-notify service. Unified notification microservice for email, push, and webhook notifications.
## Architecture
@ -47,7 +47,6 @@ Go replacement for the NestJS mana-notify service. Unified notification microser
|---------|---------|-------------------|-------------|
| Email | Stalwart SMTP (self-hosted, see `docs/MAIL_SERVER.md`) | 5 | 3 |
| Push | Expo Push API | 10 | 3 |
| Matrix | Matrix Homeserver API | 5 | 3 |
| Webhook | HTTP callback | 10 | 5 |
## Commands
@ -72,5 +71,3 @@ go test ./... # Test
| `SMTP_PASSWORD` | | SMTP password |
| `SMTP_FROM` | Mana <noreply@mana.how> | Default from |
| `EXPO_ACCESS_TOKEN` | | Expo push token |
| `MATRIX_HOMESERVER_URL` | | Matrix homeserver |
| `MATRIX_ACCESS_TOKEN` | | Matrix bot token |

View file

@ -43,7 +43,6 @@ func main() {
m := metrics.New()
emailSvc := channel.NewEmailService(cfg)
pushSvc := channel.NewPushService(cfg)
matrixSvc := channel.NewMatrixService(cfg)
webhookSvc := channel.NewWebhookService()
engine := tmpl.NewEngine(database)
@ -51,7 +50,7 @@ func main() {
engine.SeedDefaults(context.Background())
// Start worker pool
workerPool := queue.NewWorkerPool(database, emailSvc, pushSvc, matrixSvc, webhookSvc, m)
workerPool := queue.NewWorkerPool(database, emailSvc, pushSvc, webhookSvc, m)
workerPool.Start()
defer workerPool.Stop()

View file

@ -1,97 +0,0 @@
package channel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math/rand"
"net/http"
"time"
"github.com/mana/mana-notify/internal/config"
)
type MatrixService struct {
homeserverURL string
accessToken string
client *http.Client
}
func NewMatrixService(cfg *config.Config) *MatrixService {
return &MatrixService{
homeserverURL: cfg.MatrixHomeserverURL,
accessToken: cfg.MatrixAccessToken,
client: &http.Client{Timeout: 10 * time.Second},
}
}
type MatrixMessage struct {
RoomID string
Body string
FormattedBody string
MsgType string // "m.text" or "m.notice"
}
type MatrixResult struct {
Success bool
EventID string
Error string
}
func (s *MatrixService) IsConfigured() bool {
return s.homeserverURL != "" && s.accessToken != ""
}
func (s *MatrixService) Send(ctx context.Context, msg *MatrixMessage) MatrixResult {
if !s.IsConfigured() {
return MatrixResult{Success: false, Error: "Matrix not configured"}
}
msgType := msg.MsgType
if msgType == "" {
msgType = "m.text"
}
txnID := fmt.Sprintf("mana_%d_%d", time.Now().UnixMilli(), rand.Intn(100000))
payload := map[string]string{
"msgtype": msgType,
"body": msg.Body,
}
if msg.FormattedBody != "" {
payload["format"] = "org.matrix.custom.html"
payload["formatted_body"] = msg.FormattedBody
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
s.homeserverURL, msg.RoomID, txnID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return MatrixResult{Success: false, Error: err.Error()}
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.accessToken)
resp, err := s.client.Do(req)
if err != nil {
slog.Error("matrix send failed", "room", msg.RoomID, "error", err)
return MatrixResult{Success: false, Error: err.Error()}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return MatrixResult{Success: false, Error: fmt.Sprintf("matrix returned %d", resp.StatusCode)}
}
var result struct {
EventID string `json:"event_id"`
}
json.NewDecoder(resp.Body).Decode(&result)
return MatrixResult{Success: true, EventID: result.EventID}
}

View file

@ -30,10 +30,6 @@ type Config struct {
// Expo Push
ExpoAccessToken string
// Matrix
MatrixHomeserverURL string
MatrixAccessToken string
// Rate Limits
RateLimitEmailPerMinute int
RateLimitPushPerMinute int
@ -64,9 +60,6 @@ func Load() *Config {
ExpoAccessToken: envutil.Get("EXPO_ACCESS_TOKEN", ""),
MatrixHomeserverURL: envutil.Get("MATRIX_HOMESERVER_URL", ""),
MatrixAccessToken: envutil.Get("MATRIX_ACCESS_TOKEN", ""),
RateLimitEmailPerMinute: envutil.GetInt("RATE_LIMIT_EMAIL_PER_MINUTE", 10),
RateLimitPushPerMinute: envutil.GetInt("RATE_LIMIT_PUSH_PER_MINUTE", 100),

View file

@ -13,7 +13,7 @@ func (d *DB) migrate(ctx context.Context) error {
// Enum types (idempotent with DO blocks)
`DO $$ BEGIN
CREATE TYPE notify.channel_type AS ENUM ('email', 'push', 'matrix', 'webhook');
CREATE TYPE notify.channel_type AS ENUM ('email', 'push', 'webhook');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$`,

View file

@ -41,7 +41,6 @@ type SendRequest struct {
EmailOptions *EmailOptions `json:"emailOptions,omitempty"`
PushOptions *PushOptions `json:"pushOptions,omitempty"`
WebhookOptions *WebhookOptions `json:"webhookOptions,omitempty"`
MatrixOptions *MatrixOptions `json:"matrixOptions,omitempty"`
}
type EmailOptions struct {
@ -61,11 +60,6 @@ type WebhookOptions struct {
Timeout int `json:"timeout,omitempty"`
}
type MatrixOptions struct {
MsgType string `json:"msgtype,omitempty"`
FormattedBody string `json:"formattedBody,omitempty"`
}
type ScheduleRequest struct {
SendRequest
ScheduledFor string `json:"scheduledFor"`
@ -171,19 +165,11 @@ func (h *NotificationsHandler) Send(w http.ResponseWriter, r *http.Request) {
job.Sound = req.PushOptions.Sound
job.Badge = req.PushOptions.Badge
}
if req.MatrixOptions != nil {
job.RoomID = req.Recipient
job.MsgType = req.MatrixOptions.MsgType
job.FormattedBody = req.MatrixOptions.FormattedBody
}
if req.WebhookOptions != nil {
job.WebhookMethod = req.WebhookOptions.Method
job.WebhookHeaders = req.WebhookOptions.Headers
job.WebhookTimeout = req.WebhookOptions.Timeout
}
if req.Channel == "matrix" {
job.RoomID = req.Recipient
}
h.pool.Enqueue(job)
@ -465,9 +451,9 @@ func validateSendRequest(req *SendRequest) error {
if req.Channel == "" {
return fmt.Errorf("channel is required")
}
validChannels := map[string]bool{"email": true, "push": true, "matrix": true, "webhook": true}
validChannels := map[string]bool{"email": true, "push": true, "webhook": true}
if !validChannels[req.Channel] {
return fmt.Errorf("channel must be email, push, matrix, or webhook")
return fmt.Errorf("channel must be email, push, or webhook")
}
if req.AppID == "" {
return fmt.Errorf("appId is required")

View file

@ -19,7 +19,7 @@ func TestValidateSendRequest(t *testing.T) {
{
name: "invalid channel",
req: SendRequest{Channel: "sms", AppID: "app1", Recipient: "user@test.com", Body: "hello"},
wantErr: "channel must be email, push, matrix, or webhook",
wantErr: "channel must be email, push, or webhook",
},
{
name: "missing appId",
@ -56,10 +56,6 @@ func TestValidateSendRequest(t *testing.T) {
name: "valid push channel",
req: SendRequest{Channel: "push", AppID: "app1", Recipient: "token", Body: "hi"},
},
{
name: "valid matrix channel",
req: SendRequest{Channel: "matrix", AppID: "app1", Recipient: "!room:server", Body: "hi"},
},
{
name: "valid webhook channel",
req: SendRequest{Channel: "webhook", AppID: "app1", Recipient: "https://hook.example.com", Body: "{}"},

View file

@ -10,7 +10,6 @@ type Metrics struct {
NotificationsFailed *prometheus.CounterVec
EmailsSent *prometheus.CounterVec
PushSent *prometheus.CounterVec
MatrixSent *prometheus.CounterVec
WebhooksSent *prometheus.CounterVec
NotificationLatency *prometheus.HistogramVec
EmailLatency prometheus.Histogram
@ -39,11 +38,6 @@ func New() *Metrics {
Help: "Total push notifications sent",
}, []string{"platform", "status"}),
MatrixSent: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "mana_notify_matrix_sent_total",
Help: "Total Matrix messages sent",
}, []string{"status"}),
WebhooksSent: promauto.NewCounterVec(prometheus.CounterOpts{
Name: "mana_notify_webhooks_sent_total",
Help: "Total webhooks sent",

View file

@ -29,9 +29,6 @@ type Job struct {
Sound string // Push
Badge *int // Push
Platform string // Push
RoomID string // Matrix
FormattedBody string // Matrix
MsgType string // Matrix
WebhookMethod string // Webhook
WebhookHeaders map[string]string // Webhook
WebhookTimeout int // Webhook
@ -53,7 +50,6 @@ type WorkerConfig struct {
var DefaultConfigs = map[string]WorkerConfig{
"email": {Concurrency: 5, MaxRetries: 3, BackoffMs: 5000},
"push": {Concurrency: 10, MaxRetries: 3, BackoffMs: 1000},
"matrix": {Concurrency: 5, MaxRetries: 3, BackoffMs: 2000},
"webhook": {Concurrency: 10, MaxRetries: 5, BackoffMs: 3000},
}
@ -62,7 +58,6 @@ type WorkerPool struct {
db *db.DB
email *channel.EmailService
push *channel.PushService
matrix *channel.MatrixService
webhook *channel.WebhookService
metrics *metrics.Metrics
jobs chan Job
@ -70,13 +65,12 @@ type WorkerPool struct {
cancel context.CancelFunc
}
func NewWorkerPool(database *db.DB, email *channel.EmailService, push *channel.PushService, matrix *channel.MatrixService, webhook *channel.WebhookService, m *metrics.Metrics) *WorkerPool {
func NewWorkerPool(database *db.DB, email *channel.EmailService, push *channel.PushService, webhook *channel.WebhookService, m *metrics.Metrics) *WorkerPool {
ctx, cancel := context.WithCancel(context.Background())
return &WorkerPool{
db: database,
email: email,
push: push,
matrix: matrix,
webhook: webhook,
metrics: m,
jobs: make(chan Job, 1000),
@ -241,22 +235,6 @@ func (wp *WorkerPool) processJob(job Job) {
wp.metrics.PushSent.WithLabelValues(job.Platform, status).Inc()
wp.metrics.PushLatency.Observe(time.Since(start).Seconds())
case "matrix":
result := wp.matrix.Send(ctx, &channel.MatrixMessage{
RoomID: job.RoomID,
Body: job.Body,
FormattedBody: job.FormattedBody,
MsgType: job.MsgType,
})
success = result.Success
providerID = result.EventID
errMsg = result.Error
status := "success"
if !success {
status = "failed"
}
wp.metrics.MatrixSent.WithLabelValues(status).Inc()
case "webhook":
result := wp.webhook.Send(ctx, &channel.WebhookMessage{
URL: job.Recipient,