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:
parent
27f638e909
commit
4cc8e08515
8 changed files with 558 additions and 0 deletions
197
docs/channels/chatmail/README.md
Normal file
197
docs/channels/chatmail/README.md
Normal 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
3
go.mod
|
|
@ -12,6 +12,7 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
||||||
github.com/bwmarrin/discordgo v0.29.0
|
github.com/bwmarrin/discordgo v0.29.0
|
||||||
github.com/caarlos0/env/v11 v11.4.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/creack/pty v1.1.24
|
||||||
github.com/ergochat/irc-go v0.6.0
|
github.com/ergochat/irc-go v0.6.0
|
||||||
github.com/ergochat/readline v0.1.3
|
github.com/ergochat/readline v0.1.3
|
||||||
|
|
@ -61,6 +62,8 @@ require (
|
||||||
github.com/aws/smithy-go v1.24.2 // indirect
|
github.com/aws/smithy-go v1.24.2 // indirect
|
||||||
github.com/beeper/argo-go v1.1.2 // indirect
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // 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/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -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/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.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
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/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/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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
|
|
|
||||||
322
pkg/channels/chatmail/chatmail.go
Normal file
322
pkg/channels/chatmail/chatmail.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
16
pkg/channels/chatmail/init.go
Normal file
16
pkg/channels/chatmail/init.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -425,6 +425,10 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("irc", "IRC")
|
m.initChannel("irc", "IRC")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if channels.Chatmail.Enabled {
|
||||||
|
m.initChannel("chatmail", "Chatmail")
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -374,6 +374,7 @@ type ChannelsConfig struct {
|
||||||
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
Pico PicoConfig `json:"pico" yaml:"pico,omitempty"`
|
||||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||||
|
Chatmail ChatmailConfig `json:"chatmail" yaml:"chatmail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupTriggerConfig controls when the bot responds in group chats.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -628,6 +629,14 @@ type IRCConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" yaml:"-"`
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"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/dingtalk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue