mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
4eb5dfe4a0
commit
8e8b6ac65f
254 changed files with 88 additions and 29437 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
3
services/mana-matrix-bot/.gitignore
vendored
3
services/mana-matrix-bot/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
dist/
|
||||
data/
|
||||
*.json.bak
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
)
|
||||
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()
|
||||
}
|
||||
|
|
@ -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>", "<script>"},
|
||||
{`"quotes"`, ""quotes""},
|
||||
{"a & b", "a & b"},
|
||||
{"it's", "it'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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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, "es); 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, "e); 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] = "e
|
||||
mc.Client.SendReply(ctx, mc.RoomID, mc.EventID, formatQuote("e))
|
||||
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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ../../"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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 $$`,
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: "{}"},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue