feat: add Chatmail channel for Delta Chat integration

- Add new channel 'chatmail' using rpc-client-go library
- Implement Channel interface with Start, Stop, Send methods
- Implement ReactionCapable interface for message reactions
- Auto-mark messages as seen on receipt
- Reject unauthorized users with helpful message containing their account ID
- Ignore pending messages on startup to avoid responding to old messages
- Add comprehensive documentation in docs/channels/chatmail/README.md

Configuration:
- enabled: Enable/disable the channel
- account_path: Path to store Delta Chat account (default: ~/.accounts/chatmail)
- allow_from: List of authorized account IDs
- group_trigger: Trigger configuration for group chats
- reasoning_channel_id: Channel ID for reasoning output
This commit is contained in:
Ivan Agosto 2026-03-29 15:43:41 -06:00
parent 27f638e909
commit 4cc8e08515
8 changed files with 558 additions and 0 deletions

View file

@ -0,0 +1,197 @@
> Back to [README](../../../README.md)
# Chatmail (Delta Chat)
The Chatmail channel enables PicoClaw to communicate via [Delta Chat](https://delta.chat/), a decentralized messaging platform based on email. It uses the [chatmail/rpc-client-go](https://github.com/chatmail/rpc-client-go) library to interact with Delta Chat through its RPC interface.
## Prerequisites
- **deltachat-rpc-server**: The Delta Chat RPC server binary must be installed and available in your system's PATH.
- **Compatible chatemail account**: Check compatibility in [https://providers.delta.chat/](https://providers.delta.chat/).
### Installing deltachat-rpc-server
#### Cargo
```bash
# Server
cargo install --git https://github.com/chatmail/core/ deltachat-rpc-server
# Repl for configure account
cargo install --git https://github.com/chatmail/core/ deltachat-repl
```
#### From Source
```bash
# Server
git clone https://github.com/chatmail/core.git
cd deltachat-rpc-server
cargo build --release
sudo cp target/release/deltachat-rpc-server /usr/local/bin/
# Repl for configure account
cd ../deltachat-repl
cargo build --release
sudo cp target/release/deltachat-repl /usr/local/bin/
```
## Configuration
Add this to your `config.json`:
```json
{
"channels": {
"chatmail": {
"enabled": true,
"account_path": "",
"allow_from": [],
"group_trigger": {
"mention_only": false,
"prefixes": []
},
"reasoning_channel_id": ""
}
}
}
```
## Field Reference
| Field | Type | Required | Description |
|----------------------|----------|----------|------------------------------------------------------------------------------------------------------|
| enabled | bool | Yes | Whether to enable the Chatmail channel |
| account_path | string | No | Path to store the Delta Chat account database. Default: `~/.accounts/chatmail` |
| allow_from | []string | No | Allowlist of contact IDs; empty means all contacts are allowed |
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| reasoning_channel_id | string | No | Target channel ID for reasoning output |
### Group Trigger Configuration
| Field | Type | Description |
|--------------|----------|------------------------------------------------------------------------------------------------|
| mention_only | bool | When `true`, the bot only responds when mentioned in group chats |
| prefixes | []string | List of prefixes that trigger bot responses in groups (e.g., `["!", "/"]`) |
## Setup
### Step 1: Enable the Channel
Set `enabled: true` in the configuration file.
### Step 2: Start PicoClaw
When you start PicoClaw with the Chatmail channel enabled, you will see an invite link in the console:
```
Chatmail invite link: https://i.delta.chat/#B2AE34...
Scan this QR code with your Delta Chat app to start chatting.
```
Open the url and scan the QR code.
### Step 3: Configure Your Bot Account
1. **First Run**: On the first startup, the channel creates a new Delta Chat account.
2. **Scan the QR Code**: Use your Delta Chat app to scan the invite link QR code or manually enter the invite code.
3. **Start Chatting**: Once connected, you can send messages to the bot from your Delta Chat app.
### Step 4: Send Messages
- **Direct Messages**: Send any message directly to the bot's chat.
- **Group Chats**: Add the bot to a group chat. Configure `group_trigger` if you want the bot to only respond when mentioned.
## Account Storage
The Delta Chat account is stored at the path specified by `account_path` (default: `~/.accounts/chatmail`). This includes:
- Account database
- Encryption keys
- Message cache
**Important**: Keep this directory secure as it contains your private keys.
## Behavior
### Startup Behavior
When the channel starts:
1. Creates or loads the Delta Chat account
2. Generates and displays an invite link for pairing
3. **Ignores all pending messages** - only processes new messages received after startup
4. Starts listening for incoming messages
### Message Handling
- **Direct messages**: All messages are processed
- **Group messages**: Controlled by `group_trigger` configuration
- **Bot replies**: Sent as regular chat messages with full markdown support
## Supported Features
| Feature | Status | Notes |
|----------------------|--------|----------------------------------------------------------|
| Text messages | ✅ | Send and receive |
| Direct messages | ✅ | Full support |
| Group chats | ✅ | With trigger configuration |
| Markdown rendering | ✅ | Messages formatted with markdown |
| Reactions | ✅ | 👀 reaction on incoming messages, removed after response |
| Media attachments | ❌ | Not yet supported |
| Typing indicators | ❌ | Not yet supported |
| Message editing | ❌ | Not yet supported |
## Security Considerations
1. **End-to-End Encryption**: Delta Chat uses Autocrypt for automatic E2E encryption. Messages are encrypted by default.
2. **Account Keys**: Private keys are stored in `account_path`. Protect this directory.
3. **Allowlist**: Use `allow_from` to restrict which contacts can interact with your bot.
## Troubleshooting
### "deltachat-rpc-server not found"
Ensure `deltachat-rpc-server` is installed and in your PATH:
```bash
which deltachat-rpc-server
```
### Bot not receiving messages
1. Verify the account is properly configured by checking the logs
2. Ensure you've scanned the QR code or entered the invite code
3. Check that the contact sending messages is in the `allow_from` list (if configured)
### Invite link not appearing
If no invite link appears in the logs:
1. Check that the channel is enabled in the configuration
2. Verify write permissions for `account_path`
3. Check for errors in the PicoClaw logs
## Example Configuration with Group Trigger
```json
{
"channels": {
"chatmail": {
"enabled": true,
"account_path": "/var/lib/picoclaw/chatmail",
"allow_from": [],
"group_trigger": {
"mention_only": true,
"prefixes": ["!", "/"]
}
}
}
}
```
With this configuration, the bot will only respond in group chats when:
- The bot is directly mentioned (`@botname your message`), OR
- The message starts with one of the configured prefixes (`!command` or `/command`)
## Multiple Accounts
Each PicoClaw instance can have one Chatmail channel configured. To run multiple bot accounts, use separate configuration files with different `account_path` values.

3
go.mod
View file

@ -12,6 +12,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/chatmail/rpc-client-go/v2 v2.44.0
github.com/creack/pty v1.1.24
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
@ -61,6 +62,8 @@ require (
github.com/aws/smithy-go v1.24.2 // indirect
github.com/beeper/argo-go v1.1.2 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/creachadair/jrpc2 v1.3.5 // indirect
github.com/creachadair/mds v0.26.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect

6
go.sum
View file

@ -63,12 +63,18 @@ github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoG
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chatmail/rpc-client-go/v2 v2.44.0 h1:DjuI2jZVdLLU5/jEp8QvaR0z1QpKrDOESOdne+s3nu8=
github.com/chatmail/rpc-client-go/v2 v2.44.0/go.mod h1:FQq2gE3wIWj48/uunoDjaRjwdsSDAr4I8QJvYVcHGHU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creachadair/jrpc2 v1.3.5 h1:onJko+1u6xoiRph3xwWmfNISR91teCRhbJwSyS9Svzo=
github.com/creachadair/jrpc2 v1.3.5/go.mod h1:YXDmS53AavsiytbAwskrczJPcVHvKC9GoyWzwfSQXoE=
github.com/creachadair/mds v0.26.1 h1:CQG8f4cueHX/c20q5Sy/Ubk8Bvy+aRzVgbpxVieMBAs=
github.com/creachadair/mds v0.26.1/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=

View file

@ -0,0 +1,322 @@
package chatmail
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/chatmail/rpc-client-go/v2/deltachat"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
)
type ChatmailChannel struct {
*channels.BaseChannel
config config.ChatmailConfig
rpc *deltachat.Rpc
bot *deltachat.Bot
transport *deltachat.IOTransport
ctx context.Context
cancel context.CancelFunc
accId uint32
mu sync.RWMutex
}
func NewChatmailChannel(cfg config.ChatmailConfig, messageBus *bus.MessageBus) (*ChatmailChannel, error) {
base := channels.NewBaseChannel("chatmail", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &ChatmailChannel{
BaseChannel: base,
config: cfg,
}, nil
}
func (c *ChatmailChannel) Start(ctx context.Context) error {
logger.InfoC("chatmail", "Starting Chatmail channel")
c.ctx, c.cancel = context.WithCancel(ctx)
accountPath := c.config.AccountPath
if accountPath == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
accountPath = filepath.Join(homeDir, ".accounts", "chatmail")
}
if err := os.MkdirAll(accountPath, 0700); err != nil {
return fmt.Errorf("failed to create account directory: %w", err)
}
transport := deltachat.NewIOTransport()
transport.AccountsDir = accountPath
if err := transport.Open(); err != nil {
return fmt.Errorf("failed to open transport: %w", err)
}
c.transport = transport
c.rpc = &deltachat.Rpc{Context: c.ctx, Transport: transport}
accounts, err := c.rpc.GetAllAccountIds()
if err != nil {
transport.Close()
return fmt.Errorf("failed to get accounts: %w", err)
}
var accId uint32
if len(accounts) == 0 {
accId, err = c.rpc.AddAccount()
if err != nil {
transport.Close()
return fmt.Errorf("failed to add account: %w", err)
}
logger.InfoCF("chatmail", "Created new account", map[string]any{"account_id": accId})
} else {
accId = accounts[0]
logger.InfoCF("chatmail", "Using existing account", map[string]any{"account_id": accId})
}
c.accId = accId
isConfigured, err := c.rpc.IsConfigured(accId)
if err != nil {
transport.Close()
return fmt.Errorf("failed to check account configuration: %w", err)
}
if !isConfigured {
botFlag := "1"
if err := c.rpc.SetConfig(accId, "bot", &botFlag); err != nil {
transport.Close()
return fmt.Errorf("failed to set bot flag: %w", err)
}
logger.InfoC("chatmail", "Account configured as bot")
}
inviteLink, err := c.rpc.GetChatSecurejoinQrCode(accId, nil)
if err != nil {
logger.WarnCF("chatmail", "Failed to get invite link", map[string]any{"error": err.Error()})
} else {
logger.InfoCF("chatmail", "Invite link", map[string]any{"link": inviteLink})
fmt.Printf("\nChatmail invite link: %s\n", inviteLink)
fmt.Println("Scan this QR code with your Delta Chat app to start chatting.")
}
msgIds, err := c.rpc.GetNextMsgs(accId)
if err != nil {
logger.WarnCF("chatmail", "Failed to fetch pending messages", map[string]any{"error": err.Error()})
} else if len(msgIds) > 0 {
lastMsgId := fmt.Sprintf("%v", msgIds[len(msgIds)-1])
if err := c.rpc.SetConfig(accId, "last_msg_id", &lastMsgId); err != nil {
logger.WarnCF("chatmail", "Failed to set last_msg_id", map[string]any{"error": err.Error()})
} else {
// Mark all pending messages as seen
if err := c.rpc.MarkseenMsgs(accId, msgIds); err != nil {
logger.DebugCF("chatmail", "Failed to mark pending messages as seen", map[string]any{
"count": len(msgIds),
"error": err.Error(),
})
}
logger.InfoCF("chatmail", "Ignored pending messages on startup", map[string]any{"count": len(msgIds)})
}
}
c.bot = deltachat.NewBot(c.rpc)
c.bot.OnNewMsg(c.onNewMessage)
go func() {
if err := c.bot.Run(); err != nil {
logger.ErrorCF("chatmail", "Bot run error", map[string]any{"error": err.Error()})
}
}()
c.SetRunning(true)
logger.InfoC("chatmail", "Chatmail channel started")
return nil
}
func (c *ChatmailChannel) Stop(ctx context.Context) error {
logger.InfoC("chatmail", "Stopping Chatmail channel")
c.SetRunning(false)
if c.bot != nil {
c.bot.Stop()
}
if c.cancel != nil {
c.cancel()
}
if c.transport != nil {
c.transport.Close()
}
logger.InfoC("chatmail", "Chatmail channel stopped")
return nil
}
func (c *ChatmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
chatId, err := c.parseChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID: %w", channels.ErrSendFailed)
}
if msg.Content == "" {
return nil
}
text := msg.Content
msgData := deltachat.MessageData{Text: &text}
_, err = c.rpc.SendMsg(c.accId, chatId, msgData)
if err != nil {
logger.ErrorCF("chatmail", "Failed to send message", map[string]any{
"chat_id": chatId,
"error": err.Error(),
})
return fmt.Errorf("send failed: %w", channels.ErrSendFailed)
}
logger.DebugCF("chatmail", "Message sent", map[string]any{"chat_id": chatId})
return nil
}
func (c *ChatmailChannel) parseChatID(chatID string) (uint32, error) {
var chatId uint32
_, err := fmt.Sscanf(chatID, "%d", &chatId)
if err != nil {
return 0, err
}
return chatId, nil
}
func (c *ChatmailChannel) parseMsgID(messageID string) (uint32, error) {
var msgId uint32
_, err := fmt.Sscanf(messageID, "%d", &msgId)
if err != nil {
return 0, err
}
return msgId, nil
}
func (c *ChatmailChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
if !c.IsRunning() {
return func() {}, nil
}
msgId, err := c.parseMsgID(messageID)
if err != nil {
return func() {}, nil
}
reactions := []string{"\U0001F440"}
if _, err := c.rpc.SendReaction(c.accId, msgId, reactions); err != nil {
logger.DebugCF("chatmail", "Failed to add reaction", map[string]any{"error": err.Error()})
return func() {}, nil
}
// Keep the reaction permanently - return no-op undo function
return func() {}, nil
}
func (c *ChatmailChannel) onNewMessage(bot *deltachat.Bot, accId uint32, msgId uint32) {
msg, err := c.rpc.GetMessage(accId, msgId)
if err != nil {
logger.ErrorCF("chatmail", "Failed to get message", map[string]any{
"msg_id": msgId,
"error": err.Error(),
})
return
}
if msg.FromId <= deltachat.ContactLastSpecial {
return
}
// Build sender info early for permission check
senderID := fmt.Sprintf("%d", msg.FromId)
sender := bus.SenderInfo{
Platform: "chatmail",
PlatformID: senderID,
CanonicalID: identity.BuildCanonicalID("chatmail", senderID),
}
contact, _ := c.rpc.GetContact(accId, msg.FromId)
if contact.DisplayName != "" {
sender.DisplayName = contact.DisplayName
}
// Check authorization BEFORE processing
if !c.IsAllowedSender(sender) {
accountInfo := senderID
rejectionText := "⛔ Access denied. You are not authorized to use this bot.\n\n" +
"To get access, ask the administrator to add this account to the configuration:\n\n" +
"Account ID: " + accountInfo
text := rejectionText
msgData := deltachat.MessageData{Text: &text}
c.rpc.SendMsg(c.accId, msg.ChatId, msgData)
c.rpc.MarkseenMsgs(c.accId, []uint32{msgId})
logger.InfoCF("chatmail", "Unauthorized user rejected", map[string]any{
"sender_id": senderID,
"display_name": sender.DisplayName,
})
return
}
// Authorized - continue with normal flow
chatIdStr := fmt.Sprintf("%d", msg.ChatId)
chat, err := c.rpc.GetBasicChatInfo(accId, msg.ChatId)
if err != nil {
logger.ErrorCF("chatmail", "Failed to get chat info", map[string]any{
"chat_id": msg.ChatId,
"error": err.Error(),
})
return
}
var peer bus.Peer
isGroup := chat.ChatType == deltachat.ChatTypeGroup
if isGroup {
peer = bus.Peer{Kind: "group", ID: chatIdStr}
} else {
peer = bus.Peer{Kind: "direct", ID: chatIdStr}
}
content := msg.Text
metadata := map[string]string{
"platform": "chatmail",
"chat_type": string(chat.ChatType),
}
c.mu.RLock()
ctx := c.ctx
c.mu.RUnlock()
c.HandleMessage(ctx, peer, fmt.Sprintf("%d", msgId), senderID, chatIdStr, content, nil, metadata, sender)
// Mark the message as seen after processing
if err := c.rpc.MarkseenMsgs(accId, []uint32{msgId}); err != nil {
logger.DebugCF("chatmail", "Failed to mark message as seen", map[string]any{
"msg_id": msgId,
"error": err.Error(),
})
}
}

View file

@ -0,0 +1,16 @@
package chatmail
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("chatmail", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
if !cfg.Channels.Chatmail.Enabled {
return nil, nil
}
return NewChatmailChannel(cfg.Channels.Chatmail, b)
})
}

View file

@ -425,6 +425,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
m.initChannel("irc", "IRC")
}
if channels.Chatmail.Enabled {
m.initChannel("chatmail", "Chatmail")
}
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})

View file

@ -374,6 +374,7 @@ type ChannelsConfig struct {
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
Chatmail ChatmailConfig `json:"chatmail" yaml:"chatmail,omitempty"`
}
// GroupTriggerConfig controls when the bot responds in group chats.
@ -628,6 +629,14 @@ type IRCConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
}
type ChatmailConfig struct {
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_CHATMAIL_ENABLED"`
AccountPath string `json:"account_path" yaml:"-" env:"PICOCLAW_CHANNELS_CHATMAIL_ACCOUNT_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" yaml:"-" env:"PICOCLAW_CHANNELS_CHATMAIL_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty" yaml:"-"`
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-" env:"PICOCLAW_CHANNELS_CHATMAIL_REASONING_CHANNEL_ID"`
}
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/chatmail"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"