Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ OpenCrow supports multiple messaging backends:
- **Matrix** — E2EE chat rooms via mautrix
- **Nostr** — NIP-17 encrypted DMs via go-nostr
- **Signal** — Signal chats via `signal-cli`
- **Telegram** — Telegram Bot API (long polling)

```mermaid
graph LR
Transport[Matrix / Nostr / Signal] -->|message| Inbox[(Inbox)]
Transport[Matrix / Nostr / Signal / Telegram] -->|message| Inbox[(Inbox)]
Heartbeat -->|timer| Inbox
Reminders[(reminders)] -->|due| Inbox
Trigger["trigger.pipe"] -->|external| Inbox
Expand Down
75 changes: 68 additions & 7 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ import (
)

const (
backendMatrix = "matrix"
backendNostr = "nostr"
backendSignal = "signal"
backendSocket = "socket"
backendMatrix = "matrix"
backendNostr = "nostr"
backendSignal = "signal"
backendSocket = "socket"
backendTelegram = "telegram"
)

type Config struct {
BackendType string // backendMatrix, backendNostr, backendSignal, or backendSocket
BackendType string // backendMatrix, backendNostr, backendSignal, backendSocket, or backendTelegram
Matrix MatrixConfig
Nostr NostrConfig
Signal SignalConfig
Socket SocketConfig
Telegram TelegramConfig
Pi PiConfig
Heartbeat HeartbeatConfig
}
Expand All @@ -34,6 +36,13 @@ type SocketConfig struct {
Name string
}

type TelegramConfig struct {
Token string
APIBase string
AllowedUsers map[string]struct{}
PollTimeout time.Duration
}

type HeartbeatConfig struct {
Interval time.Duration // OPENCROW_HEARTBEAT_INTERVAL, default 0 (disabled)
Prompt string // OPENCROW_HEARTBEAT_PROMPT, default built-in
Expand Down Expand Up @@ -102,10 +111,10 @@ func loadConfig(getenv func(string) string) (*Config, error) {
backendType := env.or("OPENCROW_BACKEND", backendMatrix)

switch backendType {
case backendMatrix, backendNostr, backendSignal, backendSocket:
case backendMatrix, backendNostr, backendSignal, backendSocket, backendTelegram:
// valid
default:
return nil, fmt.Errorf("OPENCROW_BACKEND=%q is not supported (valid: matrix, nostr, signal, socket)", backendType)
return nil, fmt.Errorf("OPENCROW_BACKEND=%q is not supported (valid: matrix, nostr, signal, socket, telegram)", backendType)
}

idleTimeout, err := env.duration("OPENCROW_PI_IDLE_TIMEOUT", 30*time.Minute)
Expand All @@ -122,6 +131,11 @@ func loadConfig(getenv func(string) string) (*Config, error) {
return nil, err
}

telegramCfg, err := loadTelegramConfig(env, allowedUsers)
if err != nil {
return nil, err
}

cfg := &Config{
BackendType: backendType,
Matrix: MatrixConfig{
Expand All @@ -138,6 +152,7 @@ func loadConfig(getenv func(string) string) (*Config, error) {
SocketPath: env.or("OPENCROW_SOCKET_PATH", filepath.Join(workingDir, "sessions", "chat.sock")),
Name: env.or("OPENCROW_SOCKET_NAME", "OpenCrow"),
},
Telegram: telegramCfg,
Pi: PiConfig{
BinaryPath: env.or("OPENCROW_PI_BINARY", "pi"),
SessionDir: env.or("OPENCROW_PI_SESSION_DIR", "/var/lib/opencrow/sessions"),
Expand Down Expand Up @@ -178,6 +193,8 @@ func (cfg *Config) validateBackend(env envReader) error {
return cfg.Signal.validate()
case backendSocket:
// socket has sensible defaults, no validation needed
case backendTelegram:
return cfg.Telegram.validate()
}

return nil
Expand All @@ -191,6 +208,50 @@ func (m MatrixConfig) validate() error {
)
}

func loadTelegramConfig(env envReader, allowedUsers map[string]struct{}) (TelegramConfig, error) {
token, err := loadTelegramToken(env)
if err != nil {
return TelegramConfig{}, err
}

pollTimeout, err := env.duration("OPENCROW_TELEGRAM_POLL_TIMEOUT", 0)
if err != nil {
return TelegramConfig{}, err
}

telegramAllowed := allowedUsers
if raw := env.list("OPENCROW_TELEGRAM_ALLOWED_USERS"); len(raw) > 0 {
telegramAllowed = parseAllowedUsers(raw)
}

return TelegramConfig{
Token: token,
APIBase: env.str("OPENCROW_TELEGRAM_API_BASE"),
AllowedUsers: telegramAllowed,
PollTimeout: pollTimeout,
}, nil
}

// loadTelegramToken reads the bot token from OPENCROW_TELEGRAM_TOKEN_FILE
// (preferred) or OPENCROW_TELEGRAM_TOKEN. Empty is allowed at load time;
// validate() enforces the requirement when telegram is the active backend.
func loadTelegramToken(env envReader) (string, error) {
if path := env.str("OPENCROW_TELEGRAM_TOKEN_FILE"); path != "" {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading OPENCROW_TELEGRAM_TOKEN_FILE: %w", err)
}

return strings.TrimSpace(string(data)), nil
}

return env.str("OPENCROW_TELEGRAM_TOKEN"), nil
}

func (t TelegramConfig) validate() error {
return requireField(t.Token, "OPENCROW_TELEGRAM_TOKEN")
}

func loadSignalConfig(env envReader, workingDir string, allowedUsers map[string]struct{}) SignalConfig {
return SignalConfig{
Account: env.str("OPENCROW_SIGNAL_ACCOUNT"),
Expand Down
81 changes: 80 additions & 1 deletion config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,17 @@ func TestLoadConfig_Errors(t *testing.T) {
name: "unknown backend",
env: func() map[string]string {
m := baseMatrixEnv()
m["OPENCROW_BACKEND"] = "telegram"
m["OPENCROW_BACKEND"] = "discord"

return m
}(),
},
{
name: "telegram missing token",
env: map[string]string{
"OPENCROW_BACKEND": "telegram",
},
},
{
name: "nostr missing private key",
env: map[string]string{
Expand Down Expand Up @@ -243,6 +249,79 @@ func TestSocketConfig_CustomValues(t *testing.T) {
}
}

func TestTelegramConfig_Defaults(t *testing.T) {
t.Parallel()

cfg, err := loadConfig(testEnv(map[string]string{
"OPENCROW_BACKEND": "telegram",
"OPENCROW_TELEGRAM_TOKEN": "123:ABC",
}))
if err != nil {
t.Fatalf("loadConfig: %v", err)
}

if cfg.BackendType != backendTelegram {
t.Errorf("BackendType = %q, want %q", cfg.BackendType, backendTelegram)
}

if cfg.Telegram.Token != "123:ABC" {
t.Errorf("Token = %q, want %q", cfg.Telegram.Token, "123:ABC")
}

if cfg.Telegram.APIBase != "" {
t.Errorf("APIBase = %q, want empty (backend applies its own default)", cfg.Telegram.APIBase)
}
}

func TestTelegramConfig_TokenFile(t *testing.T) {
t.Parallel()

dir := t.TempDir()
tokenPath := filepath.Join(dir, "token")

if err := os.WriteFile(tokenPath, []byte(" 789:XYZ \n"), 0o600); err != nil {
t.Fatal(err)
}

cfg, err := loadConfig(testEnv(map[string]string{
"OPENCROW_BACKEND": "telegram",
"OPENCROW_TELEGRAM_TOKEN_FILE": tokenPath,
}))
if err != nil {
t.Fatalf("loadConfig: %v", err)
}

if cfg.Telegram.Token != "789:XYZ" {
t.Errorf("Token = %q, want %q (whitespace trimmed)", cfg.Telegram.Token, "789:XYZ")
}
}

func TestTelegramConfig_AllowedUsersOverride(t *testing.T) {
t.Parallel()

cfg, err := loadConfig(testEnv(map[string]string{
"OPENCROW_BACKEND": "telegram",
"OPENCROW_TELEGRAM_TOKEN": "1:abc",
"OPENCROW_ALLOWED_USERS": "shared",
"OPENCROW_TELEGRAM_ALLOWED_USERS": "12345, @alice",
}))
if err != nil {
t.Fatalf("loadConfig: %v", err)
}

if _, ok := cfg.Telegram.AllowedUsers["12345"]; !ok {
t.Errorf("expected 12345 in allowlist, got %v", cfg.Telegram.AllowedUsers)
}

if _, ok := cfg.Telegram.AllowedUsers["@alice"]; !ok {
t.Errorf("expected @alice in allowlist, got %v", cfg.Telegram.AllowedUsers)
}

if _, ok := cfg.Telegram.AllowedUsers["shared"]; ok {
t.Errorf("telegram-specific allowlist should override generic one, got %v", cfg.Telegram.AllowedUsers)
}
}

func TestDiscoverSkills_Symlinks(t *testing.T) {
t.Parallel()

Expand Down
26 changes: 25 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Set `OPENCROW_BACKEND` to choose the messaging backend. Defaults to `matrix`.
| `matrix` | Matrix rooms via mautrix (default, backwards compatible) |
| `nostr` | Nostr NIP-17 encrypted DMs |
| `signal` | Signal chats via signal-cli |
| `telegram` | Telegram Bot API (long polling) |

## Bot commands

Expand All @@ -27,7 +28,7 @@ Send these as plain text messages in any conversation with the bot:

| Variable | Default | Description |
|---|---|---|
| `OPENCROW_BACKEND` | `matrix` | Messaging backend (`matrix`, `nostr`, or `signal`) |
| `OPENCROW_BACKEND` | `matrix` | Messaging backend (`matrix`, `nostr`, `signal`, `socket`, or `telegram`) |
| `OPENCROW_PI_BINARY` | `pi` | Path to the pi binary |
| `OPENCROW_PI_SESSION_DIR` | `/var/lib/opencrow/sessions` | Session data directory |
| `OPENCROW_PI_PROVIDER` | `anthropic` | LLM provider |
Expand Down Expand Up @@ -116,6 +117,29 @@ sudo opencrow-signal-cli -a +12025550123 finishLink
Once linked, set `OPENCROW_SIGNAL_ACCOUNT = "+12025550123"` and start the
service. The account data persists in `OPENCROW_SIGNAL_CONFIG_DIR`.

## Telegram configuration

OpenCrow uses the Telegram Bot API in long-polling mode, so no public HTTPS
endpoint is needed. Create a bot with [@BotFather](https://t.me/BotFather) to
obtain a token.

| Variable | Required | Description |
|---|---|---|
| `OPENCROW_TELEGRAM_TOKEN` | Yes* | Bot token from @BotFather (e.g. `123456:ABC-DEF...`) |
| `OPENCROW_TELEGRAM_TOKEN_FILE` | Yes* | Path to a file containing the bot token (preferred for secrets) |
| `OPENCROW_TELEGRAM_API_BASE` | No | Override the API base URL (default `https://api.telegram.org`) |
| `OPENCROW_TELEGRAM_POLL_TIMEOUT` | No | Long-poll timeout duration (default `25s`) |
| `OPENCROW_TELEGRAM_ALLOWED_USERS` | No | Comma-separated user IDs (numeric) and/or `@usernames` permitted to interact. Falls back to `OPENCROW_ALLOWED_USERS` when unset. |

*Either `OPENCROW_TELEGRAM_TOKEN` or `OPENCROW_TELEGRAM_TOKEN_FILE` is required.

By default the bot replies to anyone who messages it. Restrict access with
`OPENCROW_TELEGRAM_ALLOWED_USERS` — find your numeric ID by messaging
[@userinfobot](https://t.me/userinfobot) (or any "what's my id" bot).

The bot privacy mode applied by @BotFather affects which messages the bot
sees in groups. For 1:1 chats no extra setup is needed.

## Secrets and authentication

### Nostr private key
Expand Down
20 changes: 20 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
nostrbackend "github.com/pinpox/opencrow/nostr"
signalbackend "github.com/pinpox/opencrow/signal"
socketbackend "github.com/pinpox/opencrow/socket"
telegrambackend "github.com/pinpox/opencrow/telegram"
// Register the pure-Go SQLite driver.
_ "modernc.org/sqlite"
)
Expand Down Expand Up @@ -231,6 +232,8 @@ func createBackend(ctx context.Context, cfg *Config, handler backend.MessageHand
return createSignalBackend(cfg, handler)
case backendSocket:
return createSocketBackend(cfg, handler)
case backendTelegram:
return createTelegramBackend(cfg, handler)
default:
return nil, fmt.Errorf("unsupported backend type: %q", cfg.BackendType)
}
Expand Down Expand Up @@ -323,6 +326,23 @@ func createSocketBackend(cfg *Config, handler backend.MessageHandler) (*socketba
return b, nil
}

func createTelegramBackend(cfg *Config, handler backend.MessageHandler) (*telegrambackend.Backend, error) {
tgCfg := telegrambackend.Config{
Token: cfg.Telegram.Token,
APIBase: cfg.Telegram.APIBase,
AllowedUsers: cfg.Telegram.AllowedUsers,
SessionBaseDir: cfg.Pi.SessionDir,
PollTimeout: cfg.Telegram.PollTimeout,
}

b, err := telegrambackend.New(tgCfg, handler)
if err != nil {
return nil, fmt.Errorf("creating telegram backend: %w", err)
}

return b, nil
}

func createSignalBackend(cfg *Config, handler backend.MessageHandler) (*signalbackend.Backend, error) {
signalCfg := signalbackend.Config{
BinaryPath: cfg.Signal.BinaryPath,
Expand Down
34 changes: 34 additions & 0 deletions nix/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ let
"nostr"
"signal"
"socket"
"telegram"
];
default = "matrix";
description = "Messaging backend to use.";
Expand Down Expand Up @@ -302,6 +303,30 @@ let
description = "Bot profile picture URL (NIP-01 kind 0 'picture' field).";
};

OPENCROW_TELEGRAM_TOKEN_FILE = lib.mkOption {
type = lib.types.str;
default = "";
description = "Path to file containing the Telegram bot token. Required when backend is telegram (unless token is in environment file).";
};

OPENCROW_TELEGRAM_API_BASE = lib.mkOption {
type = lib.types.str;
default = "";
description = "Override the Telegram Bot API base URL. Empty uses https://api.telegram.org.";
};

OPENCROW_TELEGRAM_POLL_TIMEOUT = lib.mkOption {
type = lib.types.str;
default = "";
description = "Long-poll timeout for getUpdates (Go duration, e.g. 25s). Empty uses backend default.";
};

OPENCROW_TELEGRAM_ALLOWED_USERS = lib.mkOption {
type = lib.types.str;
default = "";
description = "Comma-separated Telegram user IDs (numeric) or @usernames allowed to interact. Empty allows everyone.";
};

OPENCROW_PI_PROVIDER = lib.mkOption {
type = lib.types.str;
default = "anthropic";
Expand Down Expand Up @@ -481,6 +506,15 @@ let
|| icfg.credentialFiles != { };
message = "services.opencrow (${name}): OPENCROW_NOSTR_PRIVATE_KEY_FILE, environmentFiles, or credentialFiles is required when OPENCROW_BACKEND is nostr.";
}
{
assertion =
icfg.environment.OPENCROW_BACKEND != "telegram"
|| icfg.environment.OPENCROW_TELEGRAM_TOKEN_FILE != ""
# Token may also be provided via environmentFiles or credentialFiles
|| (builtins.length icfg.environmentFiles) > 0
|| icfg.credentialFiles != { };
message = "services.opencrow (${name}): OPENCROW_TELEGRAM_TOKEN_FILE, environmentFiles, or credentialFiles is required when OPENCROW_BACKEND is telegram.";
}
];

systemPackages = [
Expand Down
Loading