re-architect to a new integration system, ensure that signal is optional, adding telegram support

This commit is contained in:
Egor 2026-02-05 15:46:28 -08:00
parent ba4c245189
commit 303e093e89
55 changed files with 1928 additions and 912 deletions

View file

@ -1,30 +1,47 @@
# Required: API key for authentication
# API_KEY=your-secret-key-here
# Optional: Feature flags to enable/disable integrations
# FEATURE_ENABLE_SIGNAL=false
# FEATURE_ENABLE_PROTON=false
# FEATURE_ENABLE_TELEGRAM=false
# Advanced Configuration
# Optional: Server port
# Default: 8080
# PORT=8080
# Optional: Rate limit for requests (per 15 minute window)
# Default: 100 (requests per 15 minutes)
# RATE_LIMIT=100
# Optional: Device name shown in Signal app's linked devices list
# Default: Prism
# DEVICE_NAME=Prism Dev
# DEVICE_NAME=Prism
# Optional: Enable verbose logging
# Default: false
# VERBOSE_LOGGING=true
# Optional: signal-cli version to install/use
# Default: 0.13.23
# SIGNAL_CLI_VERSION=0.13.23
# Optional: Proton Mail integration - receive email notifications via Signal
# Get credentials by running: docker compose run --rm protonmail-bridge init
# Then use 'login' and 'info' commands to get IMAP username/password
# Signal Integration (requires FEATURE_ENABLE_SIGNAL=true)
# Optional: Path to the signal-cli daemon Unix socket
# SIGNAL_SOCKET=/run/signal-cli/socket
# Proton Mail Integration (requires FEATURE_ENABLE_PROTON=true)
# Required: IMAP credentials for Proton Mail Bridge
# PROTON_IMAP_USERNAME=your-email@proton.me
# PROTON_IMAP_PASSWORD=bridge-generated-password
# PROTON_BRIDGE_HOST=protonmail-bridge
# PROTON_BRIDGE_PORT=143
# Optional: Address of the Proton Mail Bridge service
# PROTON_BRIDGE_ADDR=protonmail-bridge:143
# Telegram Integration (requires FEATURE_ENABLE_TELEGRAM=true)
# Required: Bot token from @BotFather
# TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
# Required: Your personal chat ID (message @userinfobot on Telegram to get it)
# TELEGRAM_CHAT_ID=123456789

35
.github/copilot-instructions.md vendored Normal file
View file

@ -0,0 +1,35 @@
# Copilot Instructions
## Code Style
- **NO USELESS COMMENTS**: Don't add comments that just restate what the code does. If the code needs a comment to be understood, refactor it to be clearer instead.
- **Self-documenting code**: Use clear variable and function names. The code should explain itself.
- **Only comment WHY, not WHAT**: If you must add a comment, explain WHY something is done, not WHAT is being done.
### Bad Comments (Don't Do This)
```go
// Check what channels are actually available
signalLinked := false
// Build channel selector with only available options
var channelOptions string
```
### Good Comments (Rare, Only When Necessary)
```go
// HACK: QR service has 60s timeout, cache to avoid repeated calls
if time.Since(l.generatedAt) < l.ttl {
return l.qrCode, nil
}
```
## General Rules
- Code must be idiomatic and follow Go best practices
- Keep functions focused and small
- Use clear, descriptive names
- Prefer composition over inheritance
- Handle errors properly, don't ignore them

View file

@ -0,0 +1,30 @@
name: Release Signal CLI
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push signal-cli Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.signal-cli
platforms: linux/amd64,linux/arm64
push: true
tags: |
ghcr.io/lone-cloud/prism-signal-cli:latest

View file

@ -1,14 +1,7 @@
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., 0.1.0)'
required: true
type: string
jobs:
release:
@ -29,11 +22,7 @@ jobs:
- name: Extract version
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ inputs.version }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
fi
echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT
- name: Build and push Docker image
uses: docker/build-push-action@v6

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ data/
.env
/prism
prism-*
tmp
.VSCodeCounter/

View file

@ -9,13 +9,14 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo \
RUN CGO_ENABLED=1 GOOS=linux go build \
-trimpath \
-ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \
-o prism .
FROM alpine:latest
RUN apk --no-cache add ca-certificates signal-cli openjdk21-jre
RUN apk --no-cache add ca-certificates
WORKDIR /app
@ -23,14 +24,11 @@ COPY --from=builder /build/prism .
COPY public ./public
RUN adduser -D -u 1000 prism && \
mkdir -p /var/run/signal-cli /app/data && \
chown -R prism:prism /app /var/run/signal-cli
mkdir -p /app/data && \
chown -R prism:prism /app
USER prism
ENV SIGNAL_CLI_BINARY=signal-cli
ENV SIGNAL_CLI_SOCKET=/var/run/signal-cli/socket
EXPOSE 8080
CMD ["./prism"]

12
Dockerfile.signal-cli Normal file
View file

@ -0,0 +1,12 @@
FROM alpine:latest
RUN apk add --no-cache signal-cli openjdk21-jre bash su-exec
RUN adduser -D -u 1000 signal && \
mkdir -p /home/signal/.signal-cli /home/.local/share/signal-cli && \
chown -R signal:signal /home/signal /home/.local/share/signal-cli
RUN printf '#!/bin/bash\nset -e\nmkdir -p /home/signal/.signal-cli\nchown signal:signal /home/signal/.signal-cli\nchmod 755 /home/signal/.signal-cli\nexec su-exec signal signal-cli --config /home/.local/share/signal-cli daemon --socket /home/signal/.signal-cli/socket\n' > /entrypoint.sh && \
chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -3,7 +3,7 @@
BINARY_NAME=prism
VERSION?=$(shell cat VERSION 2>/dev/null || echo "dev")
COMMIT?=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
GOBIN?=$(shell go env GOPATH)/bin
GOBIN?=$(shell command -v go >/dev/null 2>&1 && go env GOPATH || echo "${HOME}/go")/bin
export PATH := $(GOBIN):$(PATH)
all: fmt lint build
@ -39,8 +39,6 @@ install-tools:
@echo "Installing Go tools to $(GOBIN)..."
go install golang.org/x/tools/cmd/goimports@latest
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v2.8.0
@echo "Installing signal-cli..."
@bash scripts/install-signal-cli.sh
deps:
go mod download
@ -59,7 +57,13 @@ docker-down:
docker compose -f docker-compose.dev.yml down
docker-up-proton:
docker compose -f docker-compose.dev.yml --profile protonmail up -d
docker compose -f docker-compose.dev.yml up -d --build protonmail-bridge
docker-up-signal:
docker compose -f docker-compose.dev.yml up -d --build signal-cli
docker-up-telegram:
docker compose -f docker-compose.dev.yml up -d --build telegram-bot
release:
@if [ ! -f VERSION ]; then \
@ -70,4 +74,8 @@ release:
echo "Releasing v$$VERSION..."; \
git tag -a "v$$VERSION" -m "Release v$$VERSION"; \
git push origin "v$$VERSION"; \
echo "Tag pushed. GitHub Actions will build and push Docker image."
gh workflow run release.yml
release-signal:
@echo "Triggering signal-cli image release via GitHub Actions..."
gh workflow run release-signal-cli.yml

191
README.md
View file

@ -4,7 +4,7 @@
# Prism
**Self-hosted notification gateway using Signal and WebPush for transport**
**Self-hosted notification gateway using WebPush and optional Signal for transport**
[Setup](#setup) • [Real-World Examples](#real-world-examples) • [Architecture](#architecture)
@ -12,128 +12,147 @@
<!-- markdownlint-enable MD033 -->
Prism is a self-hosted notification gateway that receives HTTP requests and routes them through Signal groups or WebPush apps. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling.
Prism is a self-hosted notification gateway that receives HTTP requests and routes them through WebPush apps or optionally through Signal groups. Route notifications through Signal to avoid exposing unique network fingerprints, or forward them to your own WebPush apps for custom handling.
## Setup
### 1. Proton Mail Integration
A Proton Mail Bridge is optionally available if you want to receive push notifications for incoming emails.
> **Note:** The default Proton Mail Bridge image uses `shenxn/protonmail-bridge:build` which compiles from source and supports multiple architectures. For x86_64 systems, you can use `shenxn/protonmail-bridge:latest` (pre-built binary, smaller and faster). For ARM devices (Raspberry Pi), stick with `:build`.
To receive Proton Mail notifications via Signal:
1. **Initialize Proton Mail Bridge** (one-time setup):
```bash
# Download docker-compose.yml
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
docker compose run --rm protonmail-bridge init
```
2.**Login to Proton Mail Bridge**:
- At the `>>>` prompt, run: `login`
- Enter your email
- Enter your password
- Enter your 2FA code
3.**Get IMAP credentials**:
- Run: `info`
- Copy the Username and Password shown
- Run: `exit` to quit
4.**Add credentials to .env**:
```bash
# Add these to your .env file
PROTON_IMAP_USERNAME=bridge-username-from-info-command
PROTON_IMAP_PASSWORD=bridge-generated-password-from-info-command
```
5.**Start all services with Proton Mail**:
```bash
docker compose --profile protonmail up -d
```
Your phone will now receive Signal notifications when Proton Mail receives new emails.
Note that the bridge will first need to sync all of your old emails before you can start getting new email notifications which may take a while, but this is a one-time setup.
### 2. Install Prism Server
```bash
# Download docker-compose.yml
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/docker-compose.yml
# Download .env.example (optional)
# Download .env.example
curl -L -O https://raw.githubusercontent.com/lone-cloud/prism/master/.env.example
# Configure Prism server through environment variables (optional)
# Configure your API key
cp .env.example .env
nano .env
nano .env # Set API_KEY=your-secret-key-here
# Start Prism server
# Start Prism
docker compose up -d
```
### 3. Link Your Signal Account
Prism is now running at <http://localhost:8080>. By default, all notifications use WebPush (encrypted push notifications or plain HTTP webhooks). Enable optional integrations below for Signal or Telegram delivery, or Proton Mail monitoring.
Visit <http://localhost:8080> and link your Signal account (one-time setup):
## Integrations
#### 1. Authenticate with your API_KEY
All integrations are optional. Enable only what you need.
![Admin login screen](assets/screenshots/1.webp)
### Signal
#### 2. Scan the QR code from your Signal app
Send notifications through Signal groups instead of WebPush.
Go to **Settings → Linked Devices → Link New Device** in Signal.
**1. Enable Signal in `.env`:**
```bash
FEATURE_ENABLE_SIGNAL=true
```
**2. Restart Prism:**
```bash
docker compose down
docker compose up -d
```
**3. Link your Signal account:**
Visit <http://localhost:8080>, authenticate with your API_KEY, and scan the QR code from your Signal app:
**Settings → Linked Devices → Link New Device**
![QR code linking screen](assets/screenshots/2.webp)
#### 3. Verify the setup
Once linked, new apps will default to Signal delivery.
Once linked, you'll see the status dashboard:
### Telegram
![Healthy setup with linked account](assets/screenshots/3.webp)
Send notifications through Telegram instead of WebPush.
With optional Proton Mail integration:
**1. Create a Telegram bot:**
![Healthy setup with Proton Mail](assets/screenshots/4.webp)
- Message [@BotFather](https://t.me/BotFather) on Telegram
- Send `/newbot` and follow the prompts
- Copy the bot token (looks like `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`)
### Development
For local development, install Go and signal-cli:
**2. Enable Telegram in `.env`:**
```bash
git clone https://github.com/lone-cloud/prism.git
cd prism
# Install development tools and signal-cli
make install-tools
# Run locally
make dev
FEATURE_ENABLE_TELEGRAM=true
TELEGRAM_BOT_TOKEN=your-bot-token-here
```
Then build and run with docker-compose.dev.yml:
**3. Restart Prism:**
```bash
docker compose --profile protonmail -f docker-compose.dev.yml up -d
docker compose down
docker compose up -d
```
or just the proton-bridge:
**4. Get your chat ID:**
- Message [@userinfobot](https://t.me/userinfobot) on Telegram
- Copy your Chat ID from the bot's response
**5. Add chat ID to `.env`:**
```bash
docker compose -f docker-compose.dev.yml up protonmail-bridge
TELEGRAM_CHAT_ID=123456789
```
**6. Restart Prism:**
```bash
docker compose down
docker compose up -d
```
All notifications will now be sent to your Telegram chat. Unlike Signal, Telegram doesn't create separate groups per app - all notifications go to the configured chat ID.
### Proton Mail
Receive notifications when new Proton Mail emails arrive.
> **Note:** The default image (`shenxn/protonmail-bridge:build`) compiles from source and supports all architectures. For x86_64 only, you can use `shenxn/protonmail-bridge:latest` (smaller, faster).
**1. Initialize the bridge:**
```bash
docker compose run --rm protonmail-bridge init
```
**2. Login at the `>>>` prompt:**
```bash
>>> login
# Enter your Proton Mail email
# Enter your password
# Enter your 2FA code
```
**3. Get IMAP credentials:**
```bash
>>> info
# Copy the Username and Password
>>> exit
```
**4. Add credentials to `.env`:**
```bash
FEATURE_ENABLE_PROTON=true
PROTON_IMAP_USERNAME=username-from-info-command
PROTON_IMAP_PASSWORD=password-from-info-command
```
**5. Start with Proton profile:**
```bash
docker compose --profile proton up -d
```
Prism will now forward Proton Mail notifications to your configured channel (Signal, Telegram, or WebPush).
## Real-World Examples
### Proton Mail Notifications
@ -245,10 +264,10 @@ For API-based monitoring, call `/api/health` which returns JSON:
Prism accepts notifications via HTTP POST requests and routes them based on your configured delivery method:
- **Signal groups**: Uses [signal-cli](https://github.com/AsamK/signal-cli) to create a Signal group for each app and send notifications as messages
- **WebPush**: Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks
- **WebPush** (default): Supports both encrypted WebPush (with VAPID signing and payload encryption) and plain HTTP webhooks
- **Encrypted WebPush**: Full WebPush protocol with end-to-end encryption - requires `appName`, `pushEndpoint`, `p256dh`, `auth`, and `vapidPrivateKey`
- **Plain webhooks**: Simple JSON POST to any HTTP endpoint - only requires `appName` and `pushEndpoint`
- **Signal groups** (optional): Uses [signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) to create a Signal group for each app and send notifications as messages
Each app can be independently configured to use either delivery method through the admin UI.

View file

@ -11,19 +11,33 @@ services:
- API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100}
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
volumes:
- signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism
- signal-socket:/home/signal/.signal-cli:ro
restart: unless-stopped
signal-cli:
container_name: signal-cli
build:
context: .
dockerfile: Dockerfile.signal-cli
profiles: ['signal']
volumes:
- signal-data:/home/.local/share/signal-cli
- signal-socket:/home/signal/.signal-cli
- /tmp/signal-cli:/home/signal/.signal-cli
restart: unless-stopped
protonmail-bridge:
container_name: protonmail-bridge
image: shenxn/protonmail-bridge:build
profiles: ['protonmail']
profiles: ['proton']
ports:
- '127.0.0.1:143:143'
volumes:
@ -33,5 +47,6 @@ services:
volumes:
signal-data:
signal-socket:
prism-data:
proton-bridge-data:

View file

@ -9,19 +9,33 @@ services:
- API_KEY=${API_KEY:-}
- VERBOSE_LOGGING=${VERBOSE_LOGGING:-false}
- RATE_LIMIT=${RATE_LIMIT:-100}
- FEATURE_ENABLE_SIGNAL=${FEATURE_ENABLE_SIGNAL:-false}
- SIGNAL_SOCKET=/home/signal/.signal-cli/socket
- FEATURE_ENABLE_PROTON=${FEATURE_ENABLE_PROTON:-false}
- PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-}
- PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-}
- PROTON_BRIDGE_HOST=protonmail-bridge
- PROTON_BRIDGE_PORT=${PROTON_BRIDGE_PORT:-143}
- PROTON_BRIDGE_ADDR=${PROTON_BRIDGE_ADDR:-protonmail-bridge:143}
- FEATURE_ENABLE_TELEGRAM=${FEATURE_ENABLE_TELEGRAM:-false}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN:-}
- TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID:-}
volumes:
- signal-data:/root/.local/share/signal-cli
- prism-data:/root/.local/share/prism
- signal-socket:/home/signal/.signal-cli:ro
restart: unless-stopped
signal-cli:
container_name: signal-cli
image: ghcr.io/lone-cloud/prism-signal-cli:latest
profiles: ['signal']
volumes:
- signal-data:/home/.local/share/signal-cli
- signal-socket:/home/signal/.signal-cli
restart: unless-stopped
protonmail-bridge:
container_name: protonmail-bridge
image: shenxn/protonmail-bridge:build
profiles: ['protonmail']
profiles: ['proton']
volumes:
- proton-bridge-data:/root
- /tmp/bridge-updates:/root/.local/share/protonmail/bridge-v3/updates:ro
@ -29,5 +43,6 @@ services:
volumes:
signal-data:
signal-socket:
prism-data:
proton-bridge-data:

17
go.mod
View file

@ -8,12 +8,27 @@ require (
github.com/go-chi/chi/v5 v5.2.4
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.33
github.com/mymmrac/telego v1.5.1
golang.org/x/time v0.14.0
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-message v0.18.1 // indirect
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
golang.org/x/crypto v0.31.0 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
github.com/valyala/fastjson v1.6.7 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sys v0.39.0 // indirect
)

56
go.sum
View file

@ -1,5 +1,18 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/emersion/go-imap/v2 v2.0.0-beta.7 h1:lNznYWa5uhMrngnSYEklzCeye4DBq9TEJ+pr0K593+8=
github.com/emersion/go-imap/v2 v2.0.0-beta.7/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk=
github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E=
@ -11,18 +24,53 @@ github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfup
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mymmrac/telego v1.5.1 h1:BnPPo158ABpHdS6xsTymLb8ut1gLwS927y87c+14mV8=
github.com/mymmrac/telego v1.5.1/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -54,6 +102,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -81,3 +131,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

19
main.go
View file

@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
@ -29,22 +30,22 @@ func main() {
return
}
if err := runServer(); err != nil {
logger := util.NewLogger(false)
logger.Error("Fatal error", "error", err)
os.Exit(1)
}
}
func runServer() error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
}
logger := util.NewLogger(cfg.VerboseLogging)
logger.Info("Starting Prism", "version", version)
if err := runServer(cfg, logger); err != nil {
logger.Error("Fatal error", "error", err)
os.Exit(1)
}
}
func runServer(cfg *config.Config, logger *slog.Logger) error {
srv, err := server.New(cfg)
if err != nil {
return fmt.Errorf("failed to create server: %w", err)

View file

@ -3,41 +3,53 @@
--bg-secondary: #fdfdfd;
--text-primary: #000;
--text-secondary: #666;
--text-on-color: #fafafa;
--border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8;
--success: #28a745;
--error: #dc3545;
--spinner-track: #e0e0e0;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: rgba(40, 167, 69, 0.2);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
:root:not([data-theme="light"]):not([data-theme="dark"]) {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa;
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745;
}
}
[data-theme="dark"] {
:root[data-theme="light"] {
--bg-primary: #f5f5f5;
--bg-secondary: #fdfdfd;
--text-primary: #000;
--text-secondary: #666;
--text-on-color: #fafafa;
--border-color: rgba(0, 0, 0, 0.1);
--accent: #8159b8;
--success: #28a745;
--error: #dc3545;
--spinner-track: #e0e0e0;
}
:root[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--text-on-color: #fff;
--border-color: rgba(255, 255, 255, 0.1);
--accent: #a78bfa;
--success: #28a745;
--error: #ef4444;
--spinner-track: #4a4a4a;
--badge-signal-bg: #8159b8;
--badge-webhook-bg: #28a745;
}
/* CSS Reset */
@ -87,7 +99,7 @@ h6 {
body {
font-family: system-ui;
max-width: 50rem;
margin: 1.25rem auto;
margin: 1rem auto;
padding: 0 1.25rem 1.25rem;
background: var(--bg-primary);
color: var(--text-primary);
@ -96,15 +108,56 @@ body {
.card {
background: var(--bg-secondary);
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1.25rem;
padding: 0;
margin-bottom: 1rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color);
}
.card-header {
font-weight: 600;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.card-header::-webkit-details-marker {
display: none;
}
.card-header::before {
content: "▶";
font-size: 0.7em;
margin-right: 0.5rem;
transition: transform 0.2s;
display: inline-block;
}
details[open] > .card-header::before {
transform: rotate(90deg);
}
.card #apps-list {
padding: 0 1.25rem 1.25rem 1.25rem;
}
h2 {
margin-bottom: 1.25rem;
}
a {
color: var(--accent);
text-decoration: underline;
}
a:hover {
opacity: 0.8;
}
.status {
display: flex;
gap: 1rem;
@ -119,12 +172,14 @@ h2 {
}
.status-item:has(.tooltip),
.channel-badge:has(.tooltip) {
.channel-badge:has(.tooltip),
.integration-status:has(.tooltip) {
cursor: help;
}
.status-item .tooltip,
.channel-badge .tooltip {
.channel-badge .tooltip,
.integration-status .tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
@ -147,7 +202,8 @@ h2 {
}
.status-item .tooltip::after,
.channel-badge .tooltip::after {
.channel-badge .tooltip::after,
.integration-status .tooltip::after {
content: "";
position: absolute;
top: 100%;
@ -158,14 +214,16 @@ h2 {
}
.status-item:hover .tooltip,
.channel-badge:hover .tooltip {
.channel-badge:hover .tooltip,
.integration-status:hover .tooltip {
visibility: visible;
opacity: 1;
}
.theme-toggle {
display: block;
margin-left: auto;
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: transparent;
border: none;
cursor: pointer;
@ -182,12 +240,12 @@ h2 {
.status-ok {
background: var(--success);
color: white;
color: var(--text-on-color);
}
.status-error {
background: var(--error);
color: white;
color: var(--text-on-color);
}
.app-list {
@ -235,13 +293,18 @@ h2 {
}
.channel-signal {
background: var(--badge-signal-bg);
color: white;
background: var(--accent);
color: var(--text-on-color);
}
.channel-webhook {
background: var(--badge-webhook-bg);
color: white;
.channel-webpush {
background: var(--success);
color: var(--text-on-color);
}
.channel-telegram {
background: #0088cc;
color: var(--text-on-color);
}
.app-detail {
@ -268,7 +331,7 @@ h2 {
.btn-delete {
padding: 0.375rem 0.75rem;
background: var(--error);
color: white;
color: var(--text-on-color);
border: none;
border-radius: 0.25rem;
cursor: pointer;
@ -284,7 +347,7 @@ h2 {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--text-secondary);
color: white;
color: var(--text-on-color);
border: none;
border-radius: 0.25rem;
cursor: pointer;
@ -312,30 +375,11 @@ h2 {
margin-left: 1.25rem;
}
.qr-instructions {
font-size: 1.05em;
}
.qr-container {
margin-top: 1rem;
width: 18.75rem;
height: 18.75rem;
display: flex;
align-items: center;
justify-content: center;
}
.qr-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.link-button {
display: inline-block;
padding: 0.625rem 1.25rem;
background: var(--accent);
color: white;
color: var(--text-on-color);
text-decoration: none;
border-radius: 0.25rem;
margin-top: 0.625rem;
@ -375,3 +419,95 @@ h2 {
color: var(--text-secondary);
margin-top: 0.5rem;
}
.integration-card {
background: var(--bg-secondary);
border-radius: 0.5rem;
margin-bottom: 1.25rem;
box-shadow: 0 0.125rem 0.25rem var(--border-color);
padding: 0;
}
.integration-container {
margin-bottom: 1.25rem;
}
.integration-header {
font-weight: 600;
font-size: 1.1em;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.integration-name {
font-size: 1.2em;
}
.integration-header::-webkit-details-marker {
display: none;
}
.integration-header::before {
content: "▶";
font-size: 0.7em;
margin-right: 0.5rem;
transition: transform 0.2s;
display: inline-block;
}
details[open] > .integration-header::before {
transform: rotate(90deg);
}
.integration-content {
padding: 0 1.25rem 1.25rem 1.25rem;
}
.integration-status {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.875em;
font-weight: 500;
position: relative;
}
.integration-status.connected,
.integration-status.available {
background: var(--success);
color: var(--text-on-color);
}
.integration-status.disconnected {
background: var(--error);
color: var(--text-on-color);
}
.integration-status.unlinked {
background: var(--text-secondary);
color: var(--text-on-color);
}
.link-instructions {
margin: 0.25rem 0;
padding-left: 1.25rem;
line-height: 1.8;
}
.qr-code-container {
margin-top: 0.5rem;
text-align: center;
}
.qr-code {
height: 22rem;
width: 22rem;
border: 2px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.625rem;
background: var(--bg-secondar);
}

View file

@ -7,34 +7,15 @@
<meta name="theme-color" content="#8159b8">
<link rel="icon" type="image/webp" href="/favicon.webp">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/index.css">
<link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script>
<script src="/theme.js"></script>
<script src="/theme.js?v={{.Version}}"></script>
</head>
<body>
<div class="card">
<div id="health-status"
hx-get="/fragment/health"
hx-trigger="load, every 10s">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="card">
<h2>Signal</h2>
<div id="signal-info"
hx-get="/fragment/signal-info"
hx-trigger="load">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
<div class="card">
<h2>Registered Applications</h2>
<details class="card" open>
<summary class="card-header">
<span class="integration-name">Registered Applications</span>
</summary>
<div id="apps-list"
hx-get="/fragment/apps"
hx-trigger="load">
@ -42,6 +23,11 @@
<div class="spinner"></div>
</div>
</div>
</details>
<div id="integrations"
hx-get="/fragment/integrations"
hx-trigger="load">
</div>
<button id="theme-toggle" class="theme-toggle"></button>

View file

@ -1,27 +0,0 @@
#!/bin/bash
set -e
VERSION="0.13.23"
INSTALL_DIR="$(pwd)/signal-cli"
echo "Installing signal-cli $VERSION..."
if [ -d "$INSTALL_DIR" ]; then
echo "signal-cli already installed at $INSTALL_DIR"
exit 0
fi
TARBALL="signal-cli-${VERSION}.tar.gz"
URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/${TARBALL}"
echo "Downloading from $URL..."
curl -L -o "/tmp/$TARBALL" "$URL"
echo "Extracting..."
tar -xzf "/tmp/$TARBALL" -C "$(pwd)"
mv "signal-cli-${VERSION}" "$INSTALL_DIR"
rm "/tmp/$TARBALL"
echo "✓ signal-cli installed to $INSTALL_DIR"
echo "Add to PATH: export PATH=\"$INSTALL_DIR/bin:\$PATH\""

View file

@ -14,48 +14,40 @@ type Config struct {
DeviceName string
PrismEndpointPrefix string
SignalCLISocketPath string
SignalCLIDataPath string
SignalCLIBinaryPath string
EnableSignal bool
SignalSocket string
EnableProton bool
ProtonIMAPUsername string
ProtonIMAPPassword string
ProtonBridgeHost string
ProtonBridgePort int
ProtonPrismTopic string
ProtonBridgeAddr string
IMAPInbox string
IMAPSeenFlag string
IMAPReconnectBaseDelay int
IMAPMaxReconnectDelay int
IMAPMaxReconnectAttempts int
EnableTelegram bool
TelegramBotToken string
TelegramChatID int64
StoragePath string
}
func Load() (*Config, error) {
cfg := &Config{
Port: getEnvInt("PORT", 8080),
APIKey: os.Getenv("API_KEY"),
Port: getEnvInt("PORT", 8080),
VerboseLogging: getEnvBool("VERBOSE_LOGGING", false),
RateLimit: getEnvInt("RATE_LIMIT", 100),
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
SignalCLISocketPath: getEnvString("SIGNAL_CLI_SOCKET", "./data/signal-cli.sock"),
SignalCLIDataPath: getEnvString("SIGNAL_CLI_DATA", "./data/prism"),
SignalCLIBinaryPath: getEnvString("SIGNAL_CLI_BINARY", "./signal-cli/bin/signal-cli"),
EnableSignal: getEnvBool("FEATURE_ENABLE_SIGNAL", false),
SignalSocket: getEnvString("SIGNAL_SOCKET", "/run/signal-cli/socket"),
EnableProton: getEnvBool("FEATURE_ENABLE_PROTON", false),
ProtonIMAPUsername: os.Getenv("PROTON_IMAP_USERNAME"),
ProtonIMAPPassword: os.Getenv("PROTON_IMAP_PASSWORD"),
ProtonBridgeHost: getEnvString("PROTON_BRIDGE_HOST", "protonmail-bridge"),
ProtonBridgePort: getEnvInt("PROTON_BRIDGE_PORT", 143),
ProtonPrismTopic: "Proton Mail",
ProtonBridgeAddr: getEnvString("PROTON_BRIDGE_ADDR", "protonmail-bridge:143"),
IMAPInbox: "INBOX",
IMAPSeenFlag: "\\Seen",
IMAPReconnectBaseDelay: 10000,
IMAPMaxReconnectDelay: 300000,
IMAPMaxReconnectAttempts: 50,
EnableTelegram: getEnvBool("FEATURE_ENABLE_TELEGRAM", false),
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
TelegramChatID: getEnvInt64("TELEGRAM_CHAT_ID", 0),
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
}
@ -77,7 +69,15 @@ func (c *Config) Validate() error {
}
func (c *Config) IsProtonEnabled() bool {
return c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != ""
return c.EnableProton && c.ProtonIMAPUsername != "" && c.ProtonIMAPPassword != "" && c.ProtonBridgeAddr != ""
}
func (c *Config) IsSignalEnabled() bool {
return c.EnableSignal
}
func (c *Config) IsTelegramEnabled() bool {
return c.EnableTelegram
}
func getEnvString(key, defaultValue string) string {
@ -96,6 +96,15 @@ func getEnvInt(key string, defaultValue int) int {
return defaultValue
}
func getEnvInt64(key string, defaultValue int64) int64 {
if value := os.Getenv(key); value != "" {
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
return value == "true" || value == "1"

View file

@ -0,0 +1,70 @@
package integration
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/integration/proton"
"prism/service/integration/signal"
"prism/service/integration/telegram"
"prism/service/integration/webpush"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration interface {
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler)
Start(ctx context.Context, logger *slog.Logger)
IsEnabled() bool
}
type Integrations struct {
Dispatcher *notification.Dispatcher
Signal *signal.Integration
integrations []Integration
}
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integrations {
signalIntegration := signal.NewIntegration(cfg, store, logger)
telegramIntegration := telegram.NewIntegration(cfg, store, logger)
dispatcher := notification.NewDispatcher(store, logger)
if signalSender := signalIntegration.GetSender(); signalSender != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
}
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
}
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
integrations := []Integration{
signalIntegration,
telegramIntegration,
proton.NewIntegration(cfg, dispatcher, logger),
webpush.NewIntegration(store, logger),
}
return &Integrations{
Dispatcher: dispatcher,
Signal: signalIntegration,
integrations: integrations,
}
}
func (i *Integrations) Start(ctx context.Context, cfg *config.Config, logger *slog.Logger) {
for _, integration := range i.integrations {
if integration.IsEnabled() {
integration.Start(ctx, logger)
}
}
}
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
auth := authMiddleware(cfg.APIKey)
for _, integration := range integrations.integrations {
integration.RegisterRoutes(router, auth)
}
}

View file

@ -9,7 +9,7 @@ import (
)
func (m *Monitor) connect() error {
addr := fmt.Sprintf("%s:%d", m.cfg.ProtonBridgeHost, m.cfg.ProtonBridgePort)
addr := m.cfg.ProtonBridgeAddr
options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
@ -41,7 +41,7 @@ func (m *Monitor) connect() error {
}
func (m *Monitor) monitor(ctx context.Context) error {
selectCmd := m.client.Select(m.cfg.IMAPInbox, nil)
selectCmd := m.client.Select(imapInbox, nil)
_, err := selectCmd.Wait()
if err != nil {
return fmt.Errorf("failed to select inbox: %w", err)
@ -66,9 +66,7 @@ func (m *Monitor) monitor(ctx context.Context) error {
case <-m.newMessagesChan:
idleCmd.Close()
if err := m.sendNotification(); err != nil {
m.logger.Error("Failed to send notification", "error", err)
}
m.sendNotification() // Errors already logged in sendNotification()
idleCmd, err = m.client.Idle()
if err != nil {

View file

@ -9,7 +9,7 @@ import (
)
func (m *Monitor) sendNotification() error {
selectData, err := m.client.Select(m.cfg.IMAPInbox, nil).Wait()
selectData, err := m.client.Select(imapInbox, nil).Wait()
if err != nil {
m.logger.Error("Failed to select inbox", "error", err)
return err
@ -49,6 +49,14 @@ func (m *Monitor) sendNotification() error {
return nil
}
if msgData.Envelope.Date.Before(m.monitorStartTime) {
m.logger.Debug("Skipping notification for old email",
"subject", msgData.Envelope.Subject,
"date", msgData.Envelope.Date,
"monitorStart", m.monitorStartTime)
return nil
}
var from string
if len(msgData.Envelope.From) > 0 {
addr := msgData.Envelope.From[0]
@ -81,7 +89,7 @@ func (m *Monitor) sendNotification() error {
},
}
if err := m.dispatcher.Send(m.cfg.ProtonPrismTopic, notif); err != nil {
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "error", err)
return err
}

View file

@ -0,0 +1,120 @@
package proton
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
)
type Handlers struct {
monitor *Monitor
username string
logger *slog.Logger
}
func NewHandlers(monitor *Monitor, username string, logger *slog.Logger) *Handlers {
return &Handlers{
monitor: monitor,
username: username,
logger: logger,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
if h.monitor == nil {
return // Proton not enabled
}
w.Header().Set("Content-Type", "text/html")
statusBadge := `<span class="integration-status disconnected">Unlinked</span>`
var content string
var openAttr string
if h.monitor.IsConnected() {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, h.username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run protonmail-bridge init</code></li>
<li>In the bridge CLI, use: <code>logout</code></li>
<li>Then: <code>exit</code> to close the bridge</li>
</ol>
`
openAttr = ""
} else {
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li>
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li>
<li>Run: <code>info</code> to get your IMAP username and password</li>
<li>Add credentials to <code>.env</code> and restart</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p>
`
openAttr = " open"
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Proton Mail</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return h.monitor != nil
}
func (h *Handlers) GetMonitor() *Monitor {
return h.monitor
}
type markReadRequest struct {
UID uint32 `json:"uid"`
}
func (h *Handlers) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"})
return
}
if req.UID == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"})
return
}
if h.monitor == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Proton integration not enabled"})
return
}
if err := h.monitor.MarkAsRead(req.UID); err != nil {
h.logger.Error("failed to mark email as read", "uid", req.UID, "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"})
return
}
h.logger.Info("marked email as read", "uid", req.UID)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
h.logger.Error("failed to encode response", "error", err)
}
}

View file

@ -0,0 +1,51 @@
package proton
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
dispatcher *notification.Dispatcher
logger *slog.Logger
handlers *Handlers
}
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Integration {
return &Integration{
cfg: cfg,
dispatcher: dispatcher,
logger: logger,
}
}
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth)
}
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
if p.handlers != nil && p.handlers.IsEnabled() {
logger.Info("Proton Mail configured", "status", "connecting in background", "bridge", p.cfg.ProtonBridgeAddr)
go func() {
monitor := p.handlers.GetMonitor()
if err := monitor.Start(ctx); err != nil && ctx.Err() == nil {
logger.Error("Proton monitor error", "error", err)
}
}()
}
}
func (p *Integration) IsEnabled() bool {
return p.cfg.IsProtonEnabled()
}
func (p *Integration) GetHandlers() *Handlers {
return p.handlers
}

View file

@ -11,6 +11,14 @@ import (
"github.com/emersion/go-imap/v2/imapclient"
)
const (
imapInbox = "INBOX"
imapReconnectBaseDelay = 10 * time.Second
imapMaxReconnectDelay = 5 * time.Minute
imapMaxReconnectAttempts = 50
prismTopic = "Proton Mail"
)
type Monitor struct {
cfg *config.Config
dispatcher *notification.Dispatcher
@ -31,7 +39,6 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
func (m *Monitor) Start(ctx context.Context) error {
if !m.cfg.IsProtonEnabled() {
m.logger.Info("Proton Mail monitoring disabled")
return nil
}
@ -44,7 +51,7 @@ func (m *Monitor) Start(ctx context.Context) error {
default:
if err := m.connect(); err != nil {
m.logger.Error("Failed to connect to IMAP", "error", err)
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond)
time.Sleep(imapReconnectBaseDelay)
continue
}
@ -59,7 +66,7 @@ func (m *Monitor) Start(ctx context.Context) error {
m.client = nil
}
time.Sleep(time.Duration(m.cfg.IMAPReconnectBaseDelay) * time.Millisecond)
time.Sleep(imapReconnectBaseDelay)
}
}
}

View file

@ -0,0 +1,29 @@
package proton
import (
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) *Handlers {
if !cfg.IsProtonEnabled() {
return nil
}
monitor := NewMonitor(cfg, dispatcher, logger)
handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger)
router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment)
router.Route("/api/proton-mail", func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/mark-read", handlers.HandleMarkRead)
})
return handlers
}

View file

@ -10,11 +10,13 @@ import (
var rpcID int32
type Client struct {
socketPath string
SocketPath string
}
func NewClient(socketPath string) *Client {
return &Client{socketPath: socketPath}
return &Client{
SocketPath: socketPath,
}
}
type RPCRequest struct {
@ -37,7 +39,7 @@ type RPCError struct {
}
func (c *Client) Call(method string, params map[string]interface{}) (json.RawMessage, error) {
conn, err := net.Dial("unix", c.socketPath)
conn, err := net.Dial("unix", c.SocketPath)
if err != nil {
return nil, fmt.Errorf("failed to connect: %w", err)
}
@ -79,7 +81,15 @@ type AccountInfo struct {
Name string `json:"name,omitempty"`
}
func (c *Client) GetLinkedAccount() (*AccountInfo, error) {
type Account struct {
Number string
}
func (c *Client) GetLinkedAccount() (*Account, error) {
if c == nil {
return nil, nil
}
result, err := c.Call("listAccounts", nil)
if err != nil {
return nil, err
@ -94,5 +104,67 @@ func (c *Client) GetLinkedAccount() (*AccountInfo, error) {
return nil, nil
}
return &accounts[0], nil
return &Account{Number: accounts[0].Number}, nil
}
// CreateGroup creates a Signal group using updateGroup JSON-RPC method
func (c *Client) CreateGroup(name string) (string, string, error) {
if c == nil {
return "", "", fmt.Errorf("signal client not initialized")
}
account, err := c.GetLinkedAccount()
if err != nil {
return "", "", fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
return "", "", fmt.Errorf("no linked Signal account")
}
params := map[string]interface{}{
"name": name,
"member": []string{}, // Empty group
}
result, err := c.CallWithAccount("updateGroup", params, account.Number)
if err != nil {
return "", "", err
}
var response struct {
GroupID string `json:"groupId"`
}
if err := json.Unmarshal(result, &response); err != nil {
return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err)
}
if response.GroupID == "" {
return "", "", fmt.Errorf("empty groupId in response")
}
return response.GroupID, account.Number, nil
}
// SendGroupMessage sends a message to a Signal group
func (c *Client) SendGroupMessage(groupID, message string) error {
if c == nil {
return fmt.Errorf("signal client not initialized")
}
account, err := c.GetLinkedAccount()
if err != nil {
return fmt.Errorf("failed to get account: %w", err)
}
if account == nil {
return fmt.Errorf("no linked account")
}
params := map[string]interface{}{
"groupId": groupID,
"message": message,
"notify-self": true,
}
_, err = c.CallWithAccount("send", params, account.Number)
return err
}

View file

@ -0,0 +1,47 @@
package signal
import (
"fmt"
"strings"
)
func FormatPhoneNumber(number string) string {
if number == "" {
return number
}
if strings.HasPrefix(number, "+1") && len(number) == 12 {
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
}
if strings.HasPrefix(number, "+") && len(number) > 4 {
digits := number[1:]
var countryCode, rest string
for i := 1; i <= 3 && i < len(digits); i++ {
if digits[i] < '0' || digits[i] > '9' {
break
}
countryCode = digits[:i+1]
rest = digits[i+1:]
}
if len(rest) >= 3 {
var parts []string
for len(rest) > 0 {
size := 3
if len(rest) == 4 || len(rest) == 8 {
size = 4
}
if size > len(rest) {
size = len(rest)
}
parts = append(parts, rest[:size])
rest = rest[size:]
}
return "+" + countryCode + " " + strings.Join(parts, " ")
}
}
return number
}

View file

@ -0,0 +1,84 @@
package signal
import (
"fmt"
"net/http"
)
type Handlers struct {
client *Client
linkDevice *LinkDevice
}
func NewHandlers(client *Client, linkDevice *LinkDevice) *Handlers {
return &Handlers{
client: client,
linkDevice: linkDevice,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
if h.client == nil {
return // Signal not enabled
}
w.Header().Set("Content-Type", "text/html")
account, _ := h.client.GetLinkedAccount()
var content string
var statusBadge string
var openAttr string
var pollAttrs string
if account != nil {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, FormatPhoneNumber(account.Number))
content = fmt.Sprintf(`
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Find and remove <strong>%s</strong></li>
</ol>
`, h.linkDevice.deviceName)
openAttr = ""
pollAttrs = ""
} else {
statusBadge = `<span class="integration-status unlinked">Unlinked</span>`
qrCode, err := h.linkDevice.GenerateQR()
if err != nil {
content = fmt.Sprintf(`<p>Error generating QR code: %s</p>`, err)
} else {
content = fmt.Sprintf(`
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Scan the QR code below</li>
</ol>
<div class="qr-code-container">
<img src="%s" alt="Signal QR Code" class="qr-code"/>
</div>
`, qrCode)
}
openAttr = " open"
pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"`
}
html := fmt.Sprintf(`<details class="integration-card"%s%s>
<summary class="integration-header">
<span class="integration-name">Signal</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, pollAttrs, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return h.client != nil
}
func (h *Handlers) GetClient() *Client {
return h.client
}

View file

@ -0,0 +1,58 @@
package signal
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
handlers *Handlers
sender *Sender
}
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration {
var sender *Sender
if cfg.IsSignalEnabled() {
client := NewClient(cfg.SignalSocket)
sender = NewSender(client, store, logger)
}
return &Integration{
cfg: cfg,
sender: sender,
}
}
func (s *Integration) GetSender() *Sender {
return s.sender
}
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
s.handlers = RegisterRoutes(router, s.cfg, auth)
}
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
if s.handlers != nil && s.handlers.IsEnabled() {
client := s.handlers.GetClient()
account, _ := client.GetLinkedAccount()
if account != nil {
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
} else {
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
}
}
}
func (s *Integration) IsEnabled() bool {
return s.cfg.IsSignalEnabled()
}
func (s *Integration) GetHandlers() *Handlers {
return s.handlers
}

View file

@ -1,11 +1,8 @@
package signal
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
@ -14,6 +11,7 @@ type LinkDevice struct {
client *Client
deviceName string
qrCode string
deviceLinkUri string
generatedAt time.Time
ttl time.Duration
}
@ -31,10 +29,15 @@ type StartLinkResponse struct {
}
func (l *LinkDevice) GenerateQR() (string, error) {
if l.client == nil {
return "", fmt.Errorf("signal client not initialized")
}
if l.qrCode != "" && time.Since(l.generatedAt) < l.ttl {
return l.qrCode, nil
}
// Call signal-cli's startLink JSON-RPC method directly
result, err := l.client.Call("startLink", nil)
if err != nil {
return "", fmt.Errorf("failed to start link: %w", err)
@ -50,31 +53,18 @@ func (l *LinkDevice) GenerateQR() (string, error) {
return "", fmt.Errorf("empty device link URI")
}
l.deviceLinkUri = uri
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri))
//nolint:gosec
resp, err := http.Get(qrURL)
if err != nil {
return "", fmt.Errorf("failed to fetch QR code: %w", err)
}
defer resp.Body.Close()
qrData, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read QR code: %w", err)
}
base64Data := base64.StdEncoding.EncodeToString(qrData)
l.qrCode = fmt.Sprintf("data:image/png;base64,%s", base64Data)
l.qrCode = qrURL
l.generatedAt = time.Now()
go l.finishLink(uri)
go l.finishLink()
return l.qrCode, nil
}
func (l *LinkDevice) finishLink(uri string) {
func (l *LinkDevice) finishLink() {
params := map[string]interface{}{
"deviceLinkUri": uri,
"deviceLinkUri": l.deviceLinkUri,
"deviceName": l.deviceName,
}

View file

@ -0,0 +1,23 @@
package signal
import (
"net/http"
"prism/service/config"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler) *Handlers {
if !cfg.IsSignalEnabled() {
return nil
}
client := NewClient(cfg.SignalSocket)
linkDevice := NewLinkDevice(client, cfg.DeviceName)
handlers := NewHandlers(client, linkDevice)
router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment)
return handlers
}

View file

@ -0,0 +1,83 @@
package signal
import (
"fmt"
"log/slog"
"prism/service/notification"
)
type Sender struct {
client *Client
store *notification.Store
logger *slog.Logger
}
func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *Sender {
return &Sender{
client: client,
store: store,
logger: logger,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if s.client == nil {
return fmt.Errorf("signal integration not enabled")
}
account, err := s.client.GetLinkedAccount()
if err != nil {
s.logger.Error("Failed to get linked account", "error", err)
return fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
s.logger.Error("No linked Signal account found")
return fmt.Errorf("no linked Signal account")
}
var signalGroupID string
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
if !needsNewGroup && mapping.Signal.Account != account.Number {
s.logger.Info("Signal account changed, recreating group",
"app", mapping.AppName,
"oldAccount", mapping.Signal.Account,
"newAccount", account.Number)
needsNewGroup = true
}
if needsNewGroup {
s.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number)
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName)
if err != nil {
s.logger.Error("Failed to create group", "app", mapping.AppName, "error", err)
return fmt.Errorf("failed to create group: %w", err)
}
signalGroupID = newGroupID
s.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber)
if err := s.store.UpdateSignal(mapping.AppName, &notification.SignalSubscription{
GroupID: signalGroupID,
Account: accountNumber,
}); err != nil {
s.logger.Warn("Failed to update signal subscription", "error", err)
}
} else {
signalGroupID = mapping.Signal.GroupID
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
}
if err := s.client.SendGroupMessage(signalGroupID, message); err != nil {
s.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
return err
}
return nil
}

View file

@ -0,0 +1,55 @@
package telegram
import (
"context"
"fmt"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil"
)
type Client struct {
bot *telego.Bot
}
func NewClient(token string) (*Client, error) {
if token == "" {
return nil, nil
}
bot, err := telego.NewBot(token)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
return &Client{bot: bot}, nil
}
func (c *Client) GetMe() (*telego.User, error) {
if c == nil || c.bot == nil {
return nil, fmt.Errorf("telegram client not initialized")
}
return c.bot.GetMe(context.Background())
}
func (c *Client) SendMessage(chatID int64, text string) error {
if c == nil || c.bot == nil {
return fmt.Errorf("telegram client not initialized")
}
msg := telegoutil.Message(
telegoutil.ID(chatID),
text,
).WithParseMode(telego.ModeHTML)
_, err := c.bot.SendMessage(context.Background(), msg)
return err
}
func (c *Client) IsAvailable() bool {
if c == nil || c.bot == nil {
return false
}
_, err := c.bot.GetMe(context.Background())
return err == nil
}

View file

@ -0,0 +1,89 @@
package telegram
import (
"fmt"
"net/http"
)
type Handlers struct {
client *Client
chatID int64
}
func NewHandlers(client *Client, chatID int64) *Handlers {
return &Handlers{
client: client,
chatID: chatID,
}
}
func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
var content string
var statusBadge string
var openAttr string
if h.client == nil {
statusBadge = `<span class="integration-status disconnected">Not Configured</span>`
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token and add to <code>.env</code>: <code>TELEGRAM_BOT_TOKEN=your-token</code></li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p>
`
openAttr = " open"
} else {
bot, err := h.client.GetMe()
if err != nil {
statusBadge = `<span class="integration-status disconnected">Error</span>`
content = fmt.Sprintf(`<p>Error: %s</p>`, err)
openAttr = " open"
} else if h.chatID == 0 {
statusBadge = fmt.Sprintf(`<span class="integration-status disconnected">Needs Chat ID<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Complete Setup:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
`
openAttr = " open"
} else {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Remove <code>TELEGRAM_BOT_TOKEN</code> and <code>TELEGRAM_CHAT_ID</code> from <code>.env</code></li>
<li>Restart: <code>docker compose restart prism</code></li>
</ol>
`
openAttr = ""
}
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Telegram</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, openAttr, statusBadge, content)
_, _ = fmt.Fprint(w, html)
}
func (h *Handlers) IsEnabled() bool {
return true
}
func (h *Handlers) GetClient() *Client {
return h.client
}

View file

@ -0,0 +1,62 @@
package telegram
import (
"context"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
cfg *config.Config
handlers *Handlers
sender *Sender
}
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration {
client, err := NewClient(cfg.TelegramBotToken)
if err != nil {
logger.Error("Failed to create telegram client", "error", err)
}
var sender *Sender
if client != nil {
sender = NewSender(client, store, logger, cfg.TelegramChatID)
}
handlers := NewHandlers(client, cfg.TelegramChatID)
return &Integration{
cfg: cfg,
handlers: handlers,
sender: sender,
}
}
func (t *Integration) GetSender() *Sender {
return t.sender
}
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
RegisterRoutes(router, t.handlers, auth)
}
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
if t.handlers != nil && t.handlers.IsEnabled() {
client := t.handlers.GetClient()
bot, err := client.GetMe()
if err != nil {
logger.Error("Telegram bot error", "error", err)
} else {
logger.Info("Telegram enabled", "bot", bot.Username, "id", bot.ID)
}
}
}
func (t *Integration) IsEnabled() bool {
return t.cfg.IsTelegramEnabled()
}

View file

@ -0,0 +1,15 @@
package telegram
import (
"net/http"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) {
if handlers == nil {
return
}
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
}

View file

@ -0,0 +1,46 @@
package telegram
import (
"fmt"
"log/slog"
"prism/service/notification"
)
type Sender struct {
client *Client
store *notification.Store
logger *slog.Logger
DefaultChatID int64
}
func NewSender(client *Client, store *notification.Store, logger *slog.Logger, defaultChatID int64) *Sender {
return &Sender{
client: client,
store: store,
logger: logger,
DefaultChatID: defaultChatID,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if s.client == nil {
return fmt.Errorf("telegram integration not enabled")
}
if s.DefaultChatID == 0 {
return fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)")
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
}
if err := s.client.SendMessage(s.DefaultChatID, message); err != nil {
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
return err
}
return nil
}

View file

@ -1,7 +1,8 @@
package server
package webpush
import (
"encoding/json"
"log/slog"
"net/http"
"net/url"
@ -10,7 +11,19 @@ import (
"github.com/go-chi/chi/v5"
)
type registerWebPushRequest struct {
type Handlers struct {
store *notification.Store
logger *slog.Logger
}
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers {
return &Handlers{
store: store,
logger: logger,
}
}
type registerRequest struct {
AppName string `json:"appName"`
PushEndpoint string `json:"pushEndpoint"`
P256dh *string `json:"p256dh,omitempty"`
@ -18,8 +31,8 @@ type registerWebPushRequest struct {
VapidPrivateKey *string `json:"vapidPrivateKey,omitempty"`
}
func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
var req registerWebPushRequest
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
@ -28,20 +41,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
if req.AppName == "" || req.PushEndpoint == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"}) //nolint:errcheck
_ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and pushEndpoint are required"})
return
}
if _, err := url.Parse(req.PushEndpoint); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"}) //nolint:errcheck
_ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid pushEndpoint URL"})
return
}
existing, err := s.store.GetApp(req.AppName)
existing, err := h.store.GetApp(req.AppName)
if err != nil {
s.logger.Error("Failed to check existing app", "error", err)
h.logger.Error("Failed to check existing app", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@ -61,20 +74,20 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
}
if existing != nil {
if err := s.store.UpdateWebPush(req.AppName, webPush); err != nil {
s.logger.Error("Failed to update webpush endpoint", "error", err)
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
h.logger.Error("Failed to update webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
s.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
} else {
channel := notification.ChannelWebPush
if err := s.store.Register(req.AppName, &channel, nil, webPush); err != nil {
s.logger.Error("Failed to register webpush endpoint", "error", err)
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
h.logger.Error("Failed to register webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
s.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
}
w.Header().Set("Content-Type", "application/json")
@ -82,32 +95,28 @@ func (s *Server) handleWebPushRegister(w http.ResponseWriter, r *http.Request) {
"appName": req.AppName,
"channel": "webpush",
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
_ = json.NewEncoder(w).Encode(response)
}
func (s *Server) handleWebPushUnregister(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
http.Error(w, "appName is required", http.StatusBadRequest)
return
}
if err := s.store.ClearWebPush(appName); err != nil {
s.logger.Error("Failed to clear webpush endpoint", "error", err)
if err := h.store.ClearWebPush(appName); err != nil {
h.logger.Error("Failed to clear webpush endpoint", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
s.logger.Info("Cleared webpush subscription", "app", appName)
h.logger.Info("Cleared webpush subscription", "app", appName)
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"status": "unregistered",
"appName": appName,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
_ = json.NewEncoder(w).Encode(response)
}

View file

@ -0,0 +1,33 @@
package webpush
import (
"context"
"log/slog"
"net/http"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
type Integration struct {
store *notification.Store
logger *slog.Logger
}
func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration {
return &Integration{
store: store,
logger: logger,
}
}
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) {
RegisterRoutes(router, w.store, w.logger, auth)
}
func (w *Integration) Start(ctx context.Context, logger *slog.Logger) {}
func (w *Integration) IsEnabled() bool {
return true
}

View file

@ -0,0 +1,20 @@
package webpush
import (
"log/slog"
"net/http"
"prism/service/notification"
"github.com/go-chi/chi/v5"
)
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
handlers := NewHandlers(store, logger)
router.Route("/webpush/app", func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/", handlers.HandleRegister)
r.Delete("/{appName}", handlers.HandleUnregister)
})
}

View file

@ -0,0 +1,73 @@
package webpush
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"prism/service/notification"
webpush "github.com/SherClockHolmes/webpush-go"
)
type Sender struct {
logger *slog.Logger
}
func NewSender(logger *slog.Logger) *Sender {
return &Sender{
logger: logger,
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if mapping.WebPush == nil {
return fmt.Errorf("no push endpoint configured for %s", mapping.AppName)
}
payload, err := json.Marshal(notif)
if err != nil {
return fmt.Errorf("failed to marshal notification: %w", err)
}
if mapping.WebPush.HasEncryption() {
subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint,
Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh,
Auth: mapping.WebPush.Auth,
},
}
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey,
TTL: 86400,
})
if err != nil {
return fmt.Errorf("failed to send webpush: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
}
s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
} else {
resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("failed to send webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
}
return nil
}

View file

@ -1,31 +1,58 @@
package notification
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"prism/service/signal"
webpush "github.com/SherClockHolmes/webpush-go"
)
type NotificationSender interface {
Send(mapping *Mapping, notif Notification) error
}
type Dispatcher struct {
store *Store
signalClient *signal.Client
senders map[Channel]NotificationSender
logger *slog.Logger
}
func NewDispatcher(store *Store, signalClient *signal.Client, logger *slog.Logger) *Dispatcher {
func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher {
return &Dispatcher{
store: store,
signalClient: signalClient,
senders: make(map[Channel]NotificationSender),
logger: logger,
}
}
func (d *Dispatcher) RegisterSender(channel Channel, sender NotificationSender) {
d.senders[channel] = sender
}
func (d *Dispatcher) HasSignal() bool {
_, ok := d.senders[ChannelSignal]
return ok
}
func (d *Dispatcher) HasTelegram() bool {
_, ok := d.senders[ChannelTelegram]
return ok
}
func (d *Dispatcher) GetAvailableChannels() []Channel {
var channels []Channel
if d.HasSignal() {
channels = append(channels, ChannelSignal)
}
if d.HasTelegram() {
channels = append(channels, ChannelTelegram)
}
channels = append(channels, ChannelWebPush)
return channels
}
func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
}
func (d *Dispatcher) Send(appName string, notif Notification) error {
mapping, err := d.store.GetApp(appName)
if err != nil {
@ -36,7 +63,8 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
if mapping == nil {
d.logger.Info("Registering new app", "app", appName)
if err := d.store.RegisterDefault(appName); err != nil {
availableChannels := d.GetAvailableChannels()
if err := d.store.RegisterDefault(appName, availableChannels); err != nil {
d.logger.Error("Failed to register app", "app", appName, "error", err)
return fmt.Errorf("failed to register app: %w", err)
}
@ -48,176 +76,11 @@ func (d *Dispatcher) Send(appName string, notif Notification) error {
}
}
switch mapping.Channel {
case ChannelWebPush:
return d.sendWebPush(mapping, notif)
case ChannelSignal:
return d.sendSignal(mapping, notif)
default:
d.logger.Error("Unknown channel type", "channel", mapping.Channel)
return fmt.Errorf("unknown channel: %s", mapping.Channel)
sender, ok := d.senders[mapping.Channel]
if !ok {
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
}
}
func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error {
account, err := d.signalClient.GetLinkedAccount()
if err != nil {
d.logger.Error("Failed to get linked account", "error", err)
return fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
d.logger.Error("No linked Signal account found")
return fmt.Errorf("no linked Signal account")
}
var signalGroupID string
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
if !needsNewGroup && mapping.Signal.Account != account.Number {
d.logger.Info("Signal account changed, recreating group",
"app", mapping.AppName,
"oldAccount", mapping.Signal.Account,
"newAccount", account.Number)
needsNewGroup = true
}
if needsNewGroup {
d.logger.Info("Creating new group", "app", mapping.AppName, "account", account.Number)
newGroupID, accountNumber, err := d.createGroup(mapping.AppName)
if err != nil {
d.logger.Error("Failed to create group", "app", mapping.AppName, "error", err)
return fmt.Errorf("failed to create group: %w", err)
}
signalGroupID = newGroupID
d.logger.Info("Created new group", "app", mapping.AppName, "groupID", signalGroupID, "account", accountNumber)
if err := d.store.UpdateSignal(mapping.AppName, &SignalSubscription{
GroupID: signalGroupID,
Account: accountNumber,
}); err != nil {
d.logger.Warn("Failed to update signal subscription", "error", err)
}
} else {
signalGroupID = mapping.Signal.GroupID
}
if err := d.sendGroupMessage(signalGroupID, notif); err != nil {
d.logger.Error("Failed to send group message", "groupID", signalGroupID, "error", err)
}
return nil
}
func (d *Dispatcher) createGroup(appName string) (string, string, error) {
account, err := d.signalClient.GetLinkedAccount()
if err != nil {
return "", "", fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
return "", "", fmt.Errorf("no linked Signal account")
}
params := map[string]interface{}{
"name": appName,
"member": []string{},
}
result, err := d.signalClient.CallWithAccount("updateGroup", params, account.Number)
if err != nil {
return "", "", err
}
var response struct {
GroupID string `json:"groupId"`
}
if err := json.Unmarshal(result, &response); err != nil {
return "", "", fmt.Errorf("failed to parse updateGroup response: %w", err)
}
if response.GroupID == "" {
return "", "", fmt.Errorf("empty groupId in response")
}
return response.GroupID, account.Number, nil
}
func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error {
account, err := d.signalClient.GetLinkedAccount()
if err != nil {
d.logger.Error("Failed to get linked account", "error", err)
return fmt.Errorf("failed to get linked account: %w", err)
}
if account == nil {
d.logger.Error("No linked Signal account found")
return fmt.Errorf("no linked Signal account")
}
message := notif.Message
if notif.Title != "" {
message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message)
}
params := map[string]interface{}{
"groupId": groupID,
"message": message,
"notify-self": true,
}
_, err = d.signalClient.CallWithAccount("send", params, account.Number)
if err != nil {
d.logger.Error("Signal send API call failed", "error", err)
}
return err
}
func (d *Dispatcher) sendWebPush(mapping *Mapping, notif Notification) error {
if mapping.WebPush == nil {
return fmt.Errorf("no push endpoint configured for %s", mapping.AppName)
}
payload, err := json.Marshal(notif)
if err != nil {
return fmt.Errorf("failed to marshal notification: %w", err)
}
if mapping.WebPush.HasEncryption() {
subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint,
Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh,
Auth: mapping.WebPush.Auth,
},
}
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey,
TTL: 86400,
})
if err != nil {
return fmt.Errorf("failed to send webpush: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
}
d.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
} else {
resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("failed to send webhook: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
d.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
}
return nil
return sender.Send(mapping, notif)
}

View file

@ -18,8 +18,22 @@ type Channel string
const (
ChannelSignal Channel = "signal"
ChannelWebPush Channel = "webpush"
ChannelTelegram Channel = "telegram"
)
func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
switch c {
case ChannelWebPush:
return true
case ChannelSignal:
return signalEnabled
case ChannelTelegram:
return telegramEnabled
default:
return false
}
}
type WebPushSubscription struct {
Endpoint string
P256dh string

View file

@ -42,7 +42,7 @@ func (s *Store) createTables() error {
appName TEXT PRIMARY KEY,
signalGroupId TEXT,
signalAccount TEXT,
channel TEXT NOT NULL DEFAULT 'signal',
channel TEXT NOT NULL DEFAULT 'webpush',
pushEndpoint TEXT,
p256dh TEXT,
auth TEXT,
@ -75,7 +75,7 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
vapidPrivateKey = &webPush.VapidPrivateKey
}
ch := ChannelSignal
ch := ChannelWebPush
if channel != nil {
ch = *channel
}
@ -96,8 +96,15 @@ func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscri
return err
}
func (s *Store) RegisterDefault(appName string) error {
return s.Register(appName, nil, nil, nil)
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
var channel Channel
if len(availableChannels) > 0 {
channel = availableChannels[0]
} else {
channel = ChannelWebPush
}
return s.Register(appName, &channel, nil, nil)
}
func (s *Store) GetApp(appName string) (*Mapping, error) {

View file

@ -0,0 +1,18 @@
package server
import (
"net/http"
)
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
data := struct {
Version string
}{
Version: s.version,
}
if err := s.indexTmpl.Execute(w, data); err != nil {
s.logger.Error("failed to execute index template", "error", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}

View file

@ -39,7 +39,7 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques
return
}
if channel != string(notification.ChannelSignal) && channel != string(notification.ChannelWebPush) {
if !s.dispatcher.IsValidChannel(notification.Channel(channel)) {
http.Error(w, "Invalid channel", http.StatusBadRequest)
return
}

View file

@ -45,7 +45,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) {
}
if req.Channel == "" {
req.Channel = notification.ChannelSignal
req.Channel = notification.ChannelWebPush
}
if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil {

View file

@ -6,117 +6,8 @@ import (
"net/url"
"prism/service/notification"
"prism/service/signal"
"prism/service/util"
)
func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) {
linked := s.getLinkedAccount()
signalOk := s.signalDaemon.IsRunning()
hasProton := s.cfg.IsProtonEnabled()
w.Header().Set("Content-Type", "text/html")
statusClass := "status-error"
statusText := "Disconnected and Unlinked"
var tooltip string
if signalOk && linked != nil {
statusClass = "status-ok"
statusText = "Connected and Linked"
tooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, util.FormatPhoneNumber(linked.Number))
} else if signalOk {
statusText = "Connected and Unlinked"
}
html := fmt.Sprintf(`<div class="status">
<div class="status-item %s">Signal: %s%s</div>`, statusClass, statusText, tooltip)
if hasProton {
protonStatus := "Disconnected"
protonClass := "status-error"
protonTooltip := ""
if s.protonMonitor.IsConnected() {
protonStatus = "Connected"
protonClass = "status-ok"
protonTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, s.cfg.ProtonIMAPUsername)
}
html += fmt.Sprintf(`
<div class="status-item %s">Proton Mail: %s%s</div>`, protonClass, protonStatus, protonTooltip)
}
html += `</div>`
currentLinked := linked != nil
if s.lastSignalLinked == nil || *s.lastSignalLinked != currentLinked {
html += `
<div id="signal-info" hx-swap-oob="true">`
html += s.getSignalInfoHTML()
html += `</div>`
s.lastSignalLinked = &currentLinked
}
// Check if apps changed
mappings, err := s.store.GetAllMappings()
if err == nil {
currentAppsCount := len(mappings)
if s.lastAppsCount == nil || *s.lastAppsCount != currentAppsCount {
html += `
<div id="apps-list" hx-swap-oob="true">`
html += s.getAppsListHTML(mappings)
html += `</div>`
s.lastAppsCount = &currentAppsCount
}
}
_, _ = fmt.Fprint(w, html) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
}
func (s *Server) handleFragmentSignalInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, s.getSignalInfoHTML()) //nolint:errcheck
}
func (s *Server) getSignalInfoHTML() string {
if s.getLinkedAccount() != nil {
return fmt.Sprintf(`<div id="signal-info"><details class="unlink-details">
<summary class="unlink-summary">Unlink and remove device</summary>
<div class="unlink-instructions">
<ol>
<li>Open Signal app <strong>Settings Linked Devices</strong></li>
<li>Find <strong>"%s"</strong> and tap it</li>
<li>Tap <strong>"Unlink Device"</strong></li>
</ol>
</div>
</details></div>`, s.cfg.DeviceName)
}
return fmt.Sprintf(`<div id="signal-info" hx-get="/fragment/signal-info" hx-trigger="every 3s">%s</div>`, s.getQRCodeHTML())
}
func (s *Server) getLinkedAccount() *signal.AccountInfo {
client := signal.NewClient(s.cfg.SignalCLISocketPath)
account, _ := client.GetLinkedAccount() //nolint:errcheck // Nil account is valid, indicates not linked
return account
}
func (s *Server) getQRCodeHTML() string {
if s.getLinkedAccount() != nil {
return `<p>Account already linked</p>`
}
qrCode, err := s.linkDevice.GenerateQR()
if err != nil {
s.logger.Error("Failed to generate QR code", "error", err)
return `<p>Signal daemon is starting up, please refresh in a few seconds...</p>`
}
return fmt.Sprintf(`<p>Scan this QR code with your Signal app:</p>
<p class="qr-instructions"><strong>Settings Linked Devices Link New Device</strong></p>
<div class="qr-container">
<img src="%s" class="qr-image" alt="QR Code" />
</div>`, qrCode)
}
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings()
if err != nil {
@ -131,35 +22,57 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
func (s *Server) getAppsListHTML(mappings []notification.Mapping) string {
if len(mappings) == 0 {
return `<p>No apps registered</p>`
return `<p>No apps registered yet. See <a href="https://github.com/lone-cloud/prism?tab=readme-ov-file#real-world-examples" target="_blank" rel="noopener noreferrer">real-world examples</a> to get started.</p>`
}
signalLinked := false
if s.integrations.Signal != nil {
handlers := s.integrations.Signal.GetHandlers()
if handlers != nil {
account, _ := handlers.GetClient().GetLinkedAccount()
signalLinked = account != nil
}
}
telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0
var html string
html += `<ul class="app-list">`
for _, m := range mappings {
isSignal := m.Channel == notification.ChannelSignal
isWebPush := m.Channel == notification.ChannelWebPush
channelBadge := "Signal"
isTelegram := m.Channel == notification.ChannelTelegram
channelBadge := ""
channelTooltip := ""
if isWebPush && m.WebPush != nil {
if m.WebPush.HasEncryption() {
channelBadge = "WebPush"
} else {
channelBadge = "Webhook"
}
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, m.WebPush.Endpoint)
}
if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
channelBadge = "Signal"
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, m.Signal.GroupID)
} else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" {
channelBadge = "WebPush"
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, m.WebPush.Endpoint)
} else if isTelegram && telegramConfigured {
channelBadge = "Telegram"
} else {
channelBadge = "Not Configured"
if isSignal {
channelTooltip = `<span class="tooltip">No Signal group created yet. Will auto-create on first notification.</span>`
} else if isTelegram {
channelTooltip = `<span class="tooltip">Set TELEGRAM_CHAT_ID in .env</span>`
} else {
channelTooltip = `<span class="tooltip">No WebPush endpoint registered.</span>`
}
}
itemHTML := fmt.Sprintf(`<li class="app-item">
<div class="app-info">
<div class="app-name"><strong>%s</strong></div>
<div class="app-channel">
<span class="channel-badge channel-%s">%s%s</span>`, m.AppName, m.Channel, channelBadge, channelTooltip)
<div class="app-channel">`, m.AppName)
if channelBadge != "Not Configured" {
itemHTML += fmt.Sprintf(`<span class="channel-badge channel-%s">%s%s</span>`, m.Channel, channelBadge, channelTooltip)
} else {
itemHTML += fmt.Sprintf(`<span class="channel-badge" style="background: var(--error); color: var(--text-on-color);">%s%s</span>`, channelBadge, channelTooltip)
}
if m.WebPush != nil && isWebPush {
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
@ -169,18 +82,29 @@ func (s *Server) getAppsListHTML(mappings []notification.Mapping) string {
itemHTML += `</div></div><div class="app-actions">`
if m.WebPush != nil {
webpushLabel := "WebPush"
if !m.WebPush.HasEncryption() {
webpushLabel = "Webhook"
var channelOptions string
optionCount := 0
if signalLinked {
channelOptions += fmt.Sprintf(`<option value="signal"%s>Signal</option>`, map[bool]string{true: " selected", false: ""}[isSignal])
optionCount++
}
if telegramConfigured {
channelOptions += fmt.Sprintf(`<option value="telegram"%s>Telegram</option>`, map[bool]string{true: " selected", false: ""}[isTelegram])
optionCount++
}
if m.WebPush != nil && m.WebPush.Endpoint != "" {
channelOptions += fmt.Sprintf(`<option value="webpush"%s>WebPush</option>`, map[bool]string{true: " selected", false: ""}[isWebPush])
optionCount++
}
if optionCount > 1 {
itemHTML += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="app" value="%s" />
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
<option value="signal"%s>Signal</option>
<option value="webpush"%s>%s</option>
%s
</select>
</form>`, m.AppName, map[bool]string{true: " selected", false: ""}[isSignal], map[bool]string{true: " selected", false: ""}[isWebPush], webpushLabel)
</form>`, m.AppName, channelOptions)
}
itemHTML += fmt.Sprintf(`<button class="btn-delete" hx-delete="/action/app/%s" hx-target="#apps-list" hx-swap="innerHTML">Delete</button></div></li>`, m.AppName)

View file

@ -0,0 +1,26 @@
package server
import (
"fmt"
"net/http"
)
func (s *Server) handleFragmentIntegrations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
html := ""
if s.cfg.IsSignalEnabled() {
html += `<div id="signal-integration" class="integration-container" hx-get="/fragment/signal" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
if s.cfg.IsProtonEnabled() {
html += `<div id="proton-integration" class="integration-container" hx-get="/fragment/proton" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
if s.cfg.IsTelegramEnabled() {
html += `<div id="telegram-integration" class="integration-container" hx-get="/fragment/telegram" hx-trigger="load"><div class="loading"><div class="spinner"></div></div></div>`
}
_, _ = fmt.Fprint(w, html)
}

View file

@ -1,42 +0,0 @@
package server
import (
"encoding/json"
"net/http"
)
type markReadRequest struct {
UID uint32 `json:"uid"`
}
func (s *Server) handleProtonMarkRead(w http.ResponseWriter, r *http.Request) {
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) //nolint:errcheck
return
}
if req.UID == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"}) //nolint:errcheck
return
}
if err := s.protonMonitor.MarkAsRead(req.UID); err != nil {
s.logger.Error("failed to mark email as read", "uid", req.UID, "error", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"}) //nolint:errcheck
return
}
s.logger.Info("marked email as read", "uid", req.UID)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil {
s.logger.Error("failed to encode response", "error", err)
}
}

View file

@ -11,6 +11,10 @@ import (
"golang.org/x/time/rate"
)
var noisyPaths = map[string]bool{
"/.well-known/appspecific/com.chrome.devtools.json": true,
}
func authMiddleware(apiKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -32,6 +36,7 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
if !noisyPaths[r.URL.Path] {
logger.Debug("HTTP request",
"method", r.Method,
"path", r.URL.Path,
@ -39,6 +44,7 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
"duration", time.Since(start),
"ip", util.GetClientIP(r),
)
}
})
}
}
@ -49,7 +55,9 @@ type responseWriter struct {
}
func (rw *responseWriter) WriteHeader(code int) {
if rw.statusCode == http.StatusOK {
rw.statusCode = code
}
rw.ResponseWriter.WriteHeader(code)
}
@ -58,7 +66,7 @@ func securityHeadersMiddleware() func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; object-src 'none'")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://api.qrserver.com; form-action 'self'; frame-ancestors 'none'; object-src 'none'")
next.ServeHTTP(w, r)
})

View file

@ -3,14 +3,16 @@ package server
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"strings"
"time"
"prism/service/config"
"prism/service/integration"
"prism/service/notification"
"prism/service/proton"
"prism/service/signal"
"prism/service/util"
"github.com/go-chi/chi/v5"
@ -21,15 +23,13 @@ type Server struct {
cfg *config.Config
store *notification.Store
dispatcher *notification.Dispatcher
protonMonitor *proton.Monitor
signalDaemon *signal.Daemon
linkDevice *signal.LinkDevice
integrations *integration.Integrations
logger *slog.Logger
router *chi.Mux
httpServer *http.Server
startTime time.Time
lastSignalLinked *bool // Track signal linked status for change detection
lastAppsCount *int // Track apps count for change detection
version string
indexTmpl *template.Template
}
func New(cfg *config.Config) (*Server, error) {
@ -40,23 +40,27 @@ func New(cfg *config.Config) (*Server, error) {
return nil, fmt.Errorf("failed to create store: %w", err)
}
signalClient := signal.NewClient(cfg.SignalCLISocketPath)
dispatcher := notification.NewDispatcher(store, signalClient, logger)
integrations := integration.Initialize(cfg, store, logger)
protonMonitor := proton.NewMonitor(cfg, dispatcher, logger)
version := "dev"
if versionBytes, err := os.ReadFile("VERSION"); err == nil {
version = strings.TrimSpace(string(versionBytes))
}
signalDaemon := signal.NewDaemon(cfg.SignalCLIBinaryPath, cfg.SignalCLIDataPath, cfg.SignalCLISocketPath)
linkDevice := signal.NewLinkDevice(signalClient, cfg.DeviceName)
tmpl, err := template.ParseFiles("public/index.html")
if err != nil {
return nil, fmt.Errorf("failed to parse index template: %w", err)
}
s := &Server{
cfg: cfg,
store: store,
dispatcher: dispatcher,
protonMonitor: protonMonitor,
signalDaemon: signalDaemon,
linkDevice: linkDevice,
dispatcher: integrations.Dispatcher,
integrations: integrations,
logger: logger,
startTime: time.Now(),
version: version,
indexTmpl: tmpl,
}
s.setupRoutes()
@ -76,14 +80,13 @@ func (s *Server) setupRoutes() {
r.Use(middleware.StripSlashes)
r.Use(rateLimitMiddleware(s.cfg.RateLimit))
r.Get("/", s.handleIndex)
r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public"))))
r.Route("/fragment", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Get("/health", s.handleFragmentHealth)
r.Get("/signal-info", s.handleFragmentSignalInfo)
r.Get("/apps", s.handleFragmentApps)
})
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
r.Route("/action", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
@ -100,33 +103,13 @@ func (s *Server) setupRoutes() {
r.Get("/stats", s.handleGetStats)
})
r.Route("/webpush/app", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Post("/", s.handleWebPushRegister)
r.Delete("/{appName}", s.handleWebPushUnregister)
})
r.Route("/api/proton-mail", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Post("/mark-read", s.handleProtonMarkRead)
})
r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish)
s.router = r
}
func (s *Server) Start(ctx context.Context) error {
s.logger.Info("Starting Signal daemon")
if err := s.signalDaemon.Start(); err != nil {
return fmt.Errorf("failed to start signal daemon: %w", err)
}
go func() {
if err := s.protonMonitor.Start(ctx); err != nil && err != context.Canceled {
s.logger.Error("Proton monitor error", "error", err)
}
}()
s.integrations.Start(ctx, s.cfg, s.logger)
addr := fmt.Sprintf(":%d", s.cfg.Port)
s.httpServer = &http.Server{

View file

@ -1,96 +0,0 @@
package signal
import (
"fmt"
"os"
"os/exec"
"syscall"
"time"
)
type Daemon struct {
binaryPath string
dataPath string
socketPath string
cmd *exec.Cmd
}
func NewDaemon(binaryPath, dataPath, socketPath string) *Daemon {
return &Daemon{
binaryPath: binaryPath,
dataPath: dataPath,
socketPath: socketPath,
}
}
func (d *Daemon) Start() error {
if d.IsRunning() {
return nil
}
_ = os.Remove(d.socketPath)
d.cmd = exec.Command(
d.binaryPath,
"--config", d.dataPath,
"daemon",
"--socket", d.socketPath,
"--send-read-receipts",
)
d.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := d.cmd.Start(); err != nil {
return fmt.Errorf("failed to start daemon: %w", err)
}
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeout:
_ = d.Stop() //nolint:errcheck
return fmt.Errorf("daemon failed to start within timeout")
case <-ticker.C:
if _, err := os.Stat(d.socketPath); err == nil {
return nil
}
}
}
}
func (d *Daemon) Stop() error {
if d.cmd == nil || d.cmd.Process == nil {
return nil
}
if err := d.cmd.Process.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to send SIGTERM: %w", err)
}
done := make(chan error, 1)
go func() {
done <- d.cmd.Wait()
}()
select {
case <-time.After(5 * time.Second):
_ = d.cmd.Process.Kill()
case <-done:
}
_ = os.Remove(d.socketPath)
return nil
}
func (d *Daemon) IsRunning() bool {
if _, err := os.Stat(d.socketPath); os.IsNotExist(err) {
return false
}
client := NewClient(d.socketPath)
_, err := client.Call("listAccounts", nil)
return err == nil
}

View file

@ -6,49 +6,6 @@ import (
"time"
)
func FormatPhoneNumber(number string) string {
if number == "" {
return number
}
// US/Canada: +1 (234) 567-8901
if strings.HasPrefix(number, "+1") && len(number) == 12 {
return fmt.Sprintf("+1 (%s) %s-%s", number[2:5], number[5:8], number[8:])
}
// International: +XX XXXX XXXX
if strings.HasPrefix(number, "+") && len(number) > 4 {
digits := number[1:]
var countryCode, rest string
for i := 1; i <= 3 && i < len(digits); i++ {
if digits[i] < '0' || digits[i] > '9' {
break
}
countryCode = digits[:i+1]
rest = digits[i+1:]
}
if len(rest) >= 3 {
var parts []string
for len(rest) > 0 {
size := 3
if len(rest) == 4 || len(rest) == 8 {
size = 4
}
if size > len(rest) {
size = len(rest)
}
parts = append(parts, rest[:size])
rest = rest[size:]
}
return "+" + countryCode + " " + strings.Join(parts, " ")
}
}
return number
}
func FormatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24

View file

@ -18,33 +18,31 @@ const (
)
type ColorHandler struct {
handler slog.Handler
w io.Writer
level slog.Level
}
func NewColorHandler(w io.Writer, opts *slog.HandlerOptions) *ColorHandler {
level := slog.LevelInfo
if opts != nil && opts.Level != nil {
level = opts.Level.Level()
}
return &ColorHandler{
handler: slog.NewTextHandler(w, opts),
w: w,
level: level,
}
}
func (h *ColorHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
return level >= h.level
}
func (h *ColorHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &ColorHandler{
handler: h.handler.WithAttrs(attrs),
w: h.w,
}
return h
}
func (h *ColorHandler) WithGroup(name string) slog.Handler {
return &ColorHandler{
handler: h.handler.WithGroup(name),
w: h.w,
}
return h
}
func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {