WIP: Initial support for deltachat gateway
Features: - Support voice messages - Optional crossposting - Automatic account creation - Custom avatar image
This commit is contained in:
parent
2cf030d2fd
commit
612e485d4e
14 changed files with 3435 additions and 7 deletions
|
|
@ -494,6 +494,7 @@ Talk to your PicoClaw through 19+ messaging platforms:
|
||||||
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
| **QQ** | Easy (AppID + AppSecret) | WebSocket | [Guide](docs/channels/qq/README.md) |
|
||||||
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
| **Slack** | Easy (bot + app token) | Socket Mode | [Guide](docs/channels/slack/README.md) |
|
||||||
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
| **Matrix** | Medium (homeserver + token) | Sync API | [Guide](docs/channels/matrix/README.md) |
|
||||||
|
| **Delta Chat** | Easy (account script or email/password) | JSON-RPC (email/E2EE) | [Guide](docs/channels/deltachat/README.md) |
|
||||||
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
|
| **DingTalk** | Medium (client credentials) | Stream | [Guide](docs/channels/dingtalk/README.md) |
|
||||||
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
| **Feishu / Lark** | Medium (App ID + Secret) | WebSocket/SDK | [Guide](docs/channels/feishu/README.md) |
|
||||||
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
| **LINE** | Medium (credentials + webhook) | Webhook | [Guide](docs/channels/line/README.md) |
|
||||||
|
|
|
||||||
148
docs/channels/deltachat/README.md
Normal file
148
docs/channels/deltachat/README.md
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
> Back to [README](../../../README.md)
|
||||||
|
|
||||||
|
# Delta Chat Channel
|
||||||
|
|
||||||
|
PicoClaw can run as a Delta Chat bot by launching a local
|
||||||
|
`deltachat-rpc-server` process and talking to it over JSON-RPC. The RPC server
|
||||||
|
handles the email account, IMAP/SMTP connection, message store, and encryption
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Install the Delta Chat RPC server. If `deltachat-rpc-server` is on `PATH`,
|
||||||
|
PicoClaw can find it automatically; otherwise set `rpc_server_path` to the
|
||||||
|
exact binary path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install deltachat-rpc-server
|
||||||
|
which deltachat-rpc-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Prebuilt binaries are also available from the
|
||||||
|
[Delta Chat core releases](https://github.com/deltachat/deltachat-core-rust/releases).
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
The easiest setup is to let PicoClaw create a chatmail account in Delta
|
||||||
|
Chat's local account store. Put a relay marker in `email` using an empty local
|
||||||
|
part, for example `@nine.testrun.org`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel_list": {
|
||||||
|
"deltachat": {
|
||||||
|
"enabled": true,
|
||||||
|
"type": "deltachat",
|
||||||
|
"allow_from": ["friend@example.org"],
|
||||||
|
"group_trigger": {
|
||||||
|
"mention_only": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"email": "@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
"avatar_image": "/home/me/bot-avatar.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, PicoClaw creates the account through `deltachat-rpc-server`, then
|
||||||
|
stops with an error that contains the generated address. Replace the relay
|
||||||
|
marker with that full email address and run PicoClaw again:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "bot123@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
"avatar_image": "/home/me/bot-avatar.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `email` is missing, the startup error lists the built-in relay choices copied
|
||||||
|
from Parla. You can use one of those relay markers, or a custom chatmail relay
|
||||||
|
with the same `@server.name` form.
|
||||||
|
|
||||||
|
`password` is not needed for PicoClaw-created chatmail accounts. Omit it when
|
||||||
|
`email` points to an already configured account in `data_dir`; the JSON-RPC
|
||||||
|
server owns the mailbox password. The legacy password-based path remains only
|
||||||
|
for classic email accounts that PicoClaw must configure itself. In that mode,
|
||||||
|
`password` is a secure field; on first config load it is moved to
|
||||||
|
`~/.picoclaw/.security.yml`, and it can also be set with
|
||||||
|
`PICOCLAW_CHANNELS_DELTACHAT_PASSWORD`.
|
||||||
|
|
||||||
|
`display_name` and `avatar_image` are optional profile settings. When present,
|
||||||
|
PicoClaw applies them on every startup, so changing the avatar path in config is
|
||||||
|
enough to update the bot profile.
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `email` | Yes | Full bot mailbox address, or first-run relay marker such as `@nine.testrun.org` |
|
||||||
|
| `rpc_server_path` | No | Path to `deltachat-rpc-server`; only needed when it is not on `PATH` |
|
||||||
|
| `password` | No | Legacy only; required when PicoClaw must configure/reconfigure a classic mailbox itself |
|
||||||
|
| `display_name` | No | Startup-applied profile name shown to contacts and used for group mention detection |
|
||||||
|
| `avatar_image` | No | Startup-applied profile avatar image path; `~` is expanded. Missing files are warned and ignored |
|
||||||
|
| `data_dir` | No | Account database directory. Default: `~/.picoclaw/deltachat/<channel-name>` |
|
||||||
|
| `invite_link` | No | Delta Chat invite link to join on startup |
|
||||||
|
| `allow_crosspost` | No | Default `false`. When `true`, senders allowed by `allow_from` may use `message` tool targets outside the current chat, or resolve recipients by email/contact/chat name |
|
||||||
|
| `imap_server`, `imap_port` | No | Manual IMAP override for password-based configuration |
|
||||||
|
| `smtp_server`, `smtp_port` | No | Manual SMTP override for password-based configuration |
|
||||||
|
|
||||||
|
Standard channel fields such as `allow_from`, `group_trigger`, and
|
||||||
|
`reasoning_channel_id` also apply.
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
|
||||||
|
With `email` set to `@server`, PicoClaw creates the chatmail account, prints
|
||||||
|
the generated full email in the startup error, and exits. Update `email` to that
|
||||||
|
full address and run PicoClaw again. On later runs, PicoClaw selects the
|
||||||
|
configured account by `email`, applies optional profile settings, marks it as a
|
||||||
|
bot, and starts IO.
|
||||||
|
|
||||||
|
With a new `data_dir` plus legacy `password`, PicoClaw can still configure a
|
||||||
|
classic email account and validate the mailbox credentials; after that, the
|
||||||
|
account is reused from the local data directory.
|
||||||
|
|
||||||
|
Delta Chat requires peers to learn the bot's encryption key before messaging
|
||||||
|
it. On startup PicoClaw prints the bot invite link and QR code. Add the bot from
|
||||||
|
Delta Chat with that invite, not by typing the bare email address.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Direct chats always respond after `allow_from` passes.
|
||||||
|
- Group chats follow `group_trigger`; without one, every group message is
|
||||||
|
handled.
|
||||||
|
- Messages from the bot itself, device chats, and info/system messages are
|
||||||
|
ignored.
|
||||||
|
- Accepted inbound messages are marked seen after the allow-list check.
|
||||||
|
- Incoming attachments (images, audio, video, documents) are registered with
|
||||||
|
the media store and handed to the agent, so it can view images or operate on
|
||||||
|
the files directly. If no media store is available, the path is appended
|
||||||
|
inline as `[attachment: /path]` instead.
|
||||||
|
- Outbound attachments are supported: when the agent emits media, each file is
|
||||||
|
sent as a Delta Chat message (with the caption as text). Delta Chat infers the
|
||||||
|
view type from the file, so images, GIFs, and videos render natively.
|
||||||
|
- Crosspost recipient lookup is disabled by default. The agent can always reply
|
||||||
|
to the current numeric chat ID, but sending to another numeric chat ID or
|
||||||
|
resolving an email/contact/chat name requires `allow_crosspost: true`
|
||||||
|
and the current sender must pass `allow_from`; `allow_from: ["*"]` allows this
|
||||||
|
for any sender.
|
||||||
|
- Voice is supported in both directions when voice providers are configured:
|
||||||
|
incoming voice notes are transcribed by the agent's ASR and the transcript is
|
||||||
|
passed to the model; the agent can reply with synthesized speech, which is
|
||||||
|
delivered as a native Delta Chat voice message (`send_tts`). This requires an
|
||||||
|
ASR and/or TTS provider under `voice` — it is not Delta Chat-specific config.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Fix |
|
||||||
|
|---------|-----|
|
||||||
|
| `deltachat-rpc-server not found on PATH` or `rpc_server_path ... not found` | Install the RPC server on PATH, or set `rpc_server_path` to an absolute path |
|
||||||
|
| `email is required` | Choose one listed chatmail server, set `email` to a first-run marker such as `@nine.testrun.org`, run `picoclaw g`, then replace it with the generated full email |
|
||||||
|
| `created chatmail account ...` | Replace the `@server` marker in `email` with the generated full email and run PicoClaw again |
|
||||||
|
| `account ... is not configured in data_dir` | Point `data_dir` at the existing JSON-RPC account store, or use `email="@server"` to create one |
|
||||||
|
| `configure (check email/password/server)` | Check credentials, app password requirements, or IMAP/SMTP overrides |
|
||||||
|
| Bot does not answer in a group | Check `group_trigger`; mention `display_name` or use a configured prefix |
|
||||||
|
| Bot ignores a sender | Add the sender email to `allow_from`, or use `["*"]` for open access |
|
||||||
|
| Sender cannot message the bot | Re-add the bot with the startup QR/invite so Delta Chat can establish encryption |
|
||||||
|
| Agent cannot send to an email/name/other chat ID | Enable `settings.allow_crosspost` and allow the controlling sender in `allow_from`; this capability is disabled by default for privacy |
|
||||||
|
|
@ -100,10 +100,15 @@ func resolveMediaRefs(
|
||||||
|
|
||||||
localPath, meta, err := store.ResolveWithMeta(ref)
|
localPath, meta, err := store.ResolveWithMeta(ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnCF("agent", "Failed to resolve media ref", map[string]any{
|
fields := map[string]any{
|
||||||
"ref": ref,
|
"ref": ref,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
}
|
||||||
|
if idx < currentTurnStart {
|
||||||
|
logger.DebugCF("agent", "Skipped stale historical media ref", fields)
|
||||||
|
} else {
|
||||||
|
logger.WarnCF("agent", "Failed to resolve media ref", fields)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||||
media []string,
|
media []string,
|
||||||
inboundCtx bus.InboundContext,
|
inboundCtx bus.InboundContext,
|
||||||
senderOpts ...bus.SenderInfo,
|
senderOpts ...bus.SenderInfo,
|
||||||
) {
|
) error {
|
||||||
// Use SenderInfo-based allow check when available, else fall back to string
|
// Use SenderInfo-based allow check when available, else fall back to string
|
||||||
var sender bus.SenderInfo
|
var sender bus.SenderInfo
|
||||||
if len(senderOpts) > 0 {
|
if len(senderOpts) > 0 {
|
||||||
|
|
@ -275,11 +275,11 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||||
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
senderID := strings.TrimSpace(inboundCtx.SenderID)
|
||||||
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
if sender.CanonicalID != "" || sender.PlatformID != "" {
|
||||||
if !c.IsAllowedSender(sender) {
|
if !c.IsAllowedSender(sender) {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if !c.IsAllowed(senderID) {
|
if !c.IsAllowed(senderID) {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,7 +350,9 @@ func (c *BaseChannel) HandleMessageWithContext(
|
||||||
"chat_id": deliveryChatID,
|
"chat_id": deliveryChatID,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleInboundContext publishes a normalized inbound message using only the
|
// HandleInboundContext publishes a normalized inbound message using only the
|
||||||
|
|
@ -361,8 +363,8 @@ func (c *BaseChannel) HandleInboundContext(
|
||||||
media []string,
|
media []string,
|
||||||
inboundCtx bus.InboundContext,
|
inboundCtx bus.InboundContext,
|
||||||
senderOpts ...bus.SenderInfo,
|
senderOpts ...bus.SenderInfo,
|
||||||
) {
|
) error {
|
||||||
c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
return c.HandleMessageWithContext(ctx, deliveryChatID, content, media, inboundCtx, senderOpts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) SetRunning(running bool) {
|
func (c *BaseChannel) SetRunning(running bool) {
|
||||||
|
|
|
||||||
1330
pkg/channels/deltachat/deltachat.go
Normal file
1330
pkg/channels/deltachat/deltachat.go
Normal file
File diff suppressed because it is too large
Load diff
1254
pkg/channels/deltachat/deltachat_test.go
Normal file
1254
pkg/channels/deltachat/deltachat_test.go
Normal file
File diff suppressed because it is too large
Load diff
398
pkg/channels/deltachat/handler.go
Normal file
398
pkg/channels/deltachat/handler.go
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listen is the inbound message loop. It blocks on wait_next_msgs and feeds
|
||||||
|
// each new message into the PicoClaw inbound pipeline.
|
||||||
|
func (c *DeltaChatChannel) listen() {
|
||||||
|
logger.InfoCF("deltachat", "Listening for messages", map[string]any{
|
||||||
|
"account_id": c.accountID,
|
||||||
|
"email": c.selfAddr,
|
||||||
|
})
|
||||||
|
for c.IsRunning() && c.ctx.Err() == nil {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "wait_next_msgs", c.accountID)
|
||||||
|
if err != nil {
|
||||||
|
if c.ctx.Err() != nil || !c.IsRunning() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.ErrorCF("deltachat", "wait_next_msgs failed", map[string]any{"error": err.Error()})
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var messageIDs []int64
|
||||||
|
if err := json.Unmarshal(raw, &messageIDs); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messageIDs) > 0 {
|
||||||
|
logger.DebugCF("deltachat", "Received message batch", map[string]any{
|
||||||
|
"count": len(messageIDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, messageID := range messageIDs {
|
||||||
|
c.handleMessage(messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessage fetches one message, applies inbound filtering, and publishes it.
|
||||||
|
func (c *DeltaChatChannel) handleMessage(messageID int64) {
|
||||||
|
msg, err := c.getMessage(messageID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("deltachat", "get_message failed", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.IsInfo || (strings.TrimSpace(msg.Text) == "" && msg.File == "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
senderAddr := ""
|
||||||
|
if msg.Sender != nil {
|
||||||
|
senderAddr = msg.Sender.Address
|
||||||
|
}
|
||||||
|
if senderAddr != "" && strings.EqualFold(senderAddr, c.selfAddr) {
|
||||||
|
logger.DebugCF("deltachat", "Drop: own message", map[string]any{"message_id": messageID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chat, err := c.getFullChat(msg.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
logger.DebugCF("deltachat", "get_full_chat_by_id failed", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Device messages are core-generated notices, not real conversations.
|
||||||
|
if chat.IsDeviceChat {
|
||||||
|
logger.DebugCF("deltachat", "Drop: device message", map[string]any{"chat_id": msg.ChatID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isGroup := chat.ChatType != chatTypeSingle
|
||||||
|
|
||||||
|
logger.DebugCF("deltachat", "Inbound message", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"from": senderAddr,
|
||||||
|
"is_group": isGroup,
|
||||||
|
"has_file": msg.File != "",
|
||||||
|
"text_len": len(strings.TrimSpace(msg.Text)),
|
||||||
|
})
|
||||||
|
|
||||||
|
senderName := senderAddr
|
||||||
|
if msg.Sender != nil {
|
||||||
|
if msg.Sender.DisplayName != "" {
|
||||||
|
senderName = msg.Sender.DisplayName
|
||||||
|
} else if msg.Sender.Name != "" {
|
||||||
|
senderName = msg.Sender.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if senderName == "" {
|
||||||
|
senderName = "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := strconv.FormatInt(msg.ChatID, 10)
|
||||||
|
messageIDStr := strconv.FormatInt(msg.ID, 10)
|
||||||
|
|
||||||
|
content := strings.TrimSpace(msg.Text)
|
||||||
|
|
||||||
|
// Register any attachment with the media store so the agent pipeline can
|
||||||
|
// view images and operate on files. The ref is scoped to the same key the
|
||||||
|
// BaseChannel derives for this message, so it is released with the turn.
|
||||||
|
var mediaRefs []string
|
||||||
|
if msg.File != "" {
|
||||||
|
scope := channels.BuildMediaScope(c.Name(), chatID, messageIDStr)
|
||||||
|
if ref := c.registerInboundFile(scope, msg); ref != "" {
|
||||||
|
mediaRefs = append(mediaRefs, ref)
|
||||||
|
} else {
|
||||||
|
// Fallback when no media store is available: surface the path inline
|
||||||
|
// so the attachment is not silently lost.
|
||||||
|
annotation := fmt.Sprintf("[attachment: %s]", msg.File)
|
||||||
|
if content == "" {
|
||||||
|
content = annotation
|
||||||
|
} else {
|
||||||
|
content = content + "\n" + annotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file with no caption still warrants a turn; give the agent a minimal
|
||||||
|
// placeholder so the message survives the empty-content guard below. Audio
|
||||||
|
// gets a "[voice]" tag specifically, so the agent's transcription step can
|
||||||
|
// substitute the transcript in place rather than appending it.
|
||||||
|
if content == "" && len(mediaRefs) > 0 {
|
||||||
|
if utils.IsAudioFile(msg.FileName, msg.FileMime) {
|
||||||
|
content = "[voice]"
|
||||||
|
} else {
|
||||||
|
content = "[media]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := bus.SenderInfo{
|
||||||
|
Platform: config.ChannelDeltaChat,
|
||||||
|
PlatformID: senderAddr,
|
||||||
|
CanonicalID: identity.BuildCanonicalID(config.ChannelDeltaChat, senderAddr),
|
||||||
|
Username: senderAddr,
|
||||||
|
DisplayName: senderName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.IsAllowedSender(sender) {
|
||||||
|
logger.DebugCF("deltachat", "Drop: sender not in allow_from", map[string]any{
|
||||||
|
"from": senderAddr,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMentioned := false
|
||||||
|
if isGroup {
|
||||||
|
botName := c.config.DisplayName
|
||||||
|
if botName == "" {
|
||||||
|
botName = c.selfAddr
|
||||||
|
}
|
||||||
|
isMentioned = mentionsBot(content, botName, c.selfAddr)
|
||||||
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
|
if !respond {
|
||||||
|
logger.DebugCF("deltachat", "Drop: group trigger not satisfied", map[string]any{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"mentioned": isMentioned,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content = cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := map[string]string{
|
||||||
|
"platform": config.ChannelDeltaChat,
|
||||||
|
"chat_name": chat.Name,
|
||||||
|
}
|
||||||
|
if msg.File != "" {
|
||||||
|
metadata["file"] = msg.File
|
||||||
|
metadata["file_name"] = msg.FileName
|
||||||
|
metadata["file_mime"] = msg.FileMime
|
||||||
|
}
|
||||||
|
|
||||||
|
inboundCtx := bus.InboundContext{
|
||||||
|
Channel: config.ChannelDeltaChat,
|
||||||
|
ChatID: chatID,
|
||||||
|
SenderID: senderAddr,
|
||||||
|
MessageID: messageIDStr,
|
||||||
|
Mentioned: isMentioned,
|
||||||
|
Raw: metadata,
|
||||||
|
}
|
||||||
|
if isGroup {
|
||||||
|
inboundCtx.ChatType = "group"
|
||||||
|
} else {
|
||||||
|
inboundCtx.ChatType = "direct"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("deltachat", "Dispatching to agent", map[string]any{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"chat_type": inboundCtx.ChatType,
|
||||||
|
"from": senderAddr,
|
||||||
|
})
|
||||||
|
if err := c.HandleInboundContext(c.ctx, chatID, content, mediaRefs, inboundCtx, sender); err != nil {
|
||||||
|
logger.ErrorCF("deltachat", "Dispatch failed; leaving message unseen", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := c.rpc.call(c.ctx, "markseen_msgs", c.accountID, []int64{messageID}); err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to mark message seen", map[string]any{
|
||||||
|
"message_id": messageID,
|
||||||
|
"chat_id": chatID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerInboundFile records an inbound attachment with the media store under
|
||||||
|
// the given scope and returns its media:// ref. Returns "" when there is no
|
||||||
|
// media store or registration fails, letting the caller fall back to an inline
|
||||||
|
// path annotation.
|
||||||
|
//
|
||||||
|
// Delta Chat stores attachments inside the account directory, next to the
|
||||||
|
// credential database — a location tools are intentionally NOT allowed to read.
|
||||||
|
// We therefore copy the single attachment out into the shared media temp dir
|
||||||
|
// (which read_file/load_image are permitted to access) and register that copy,
|
||||||
|
// so the agent can actually open the file. The copy is store-managed and deleted
|
||||||
|
// when the turn's scope is released.
|
||||||
|
func (c *DeltaChatChannel) registerInboundFile(scope string, msg *dcMessage) string {
|
||||||
|
store := c.GetMediaStore()
|
||||||
|
if store == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
filename := msg.FileName
|
||||||
|
if filename == "" {
|
||||||
|
filename = filepath.Base(msg.File)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPath, err := copyToMediaTemp(msg.File, filename)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to copy attachment into media dir", map[string]any{
|
||||||
|
"file": msg.File,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
|
Filename: filename,
|
||||||
|
ContentType: msg.FileMime,
|
||||||
|
Source: config.ChannelDeltaChat,
|
||||||
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
||||||
|
}, scope)
|
||||||
|
if err != nil {
|
||||||
|
logger.WarnCF("deltachat", "Failed to register attachment with media store", map[string]any{
|
||||||
|
"file": localPath,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
_ = os.Remove(localPath)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyToMediaTemp copies srcPath into the shared media temp directory under a
|
||||||
|
// unique name and returns the destination path. The media temp dir is the
|
||||||
|
// location the read_file/load_image tools are permitted to read, so copying here
|
||||||
|
// makes the attachment readable without exposing Delta Chat's account directory.
|
||||||
|
func copyToMediaTemp(srcPath, filename string) (string, error) {
|
||||||
|
if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
safe := utils.SanitizeFilename(filename)
|
||||||
|
if safe == "" {
|
||||||
|
safe = filepath.Base(srcPath)
|
||||||
|
}
|
||||||
|
dstPath := filepath.Join(media.TempDir(), uuid.NewString()[:8]+"_"+safe)
|
||||||
|
|
||||||
|
src, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
dst, err := os.Create(dstPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
dst.Close()
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := dst.Close(); err != nil {
|
||||||
|
_ = os.Remove(dstPath)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dstPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeltaChatChannel) getMessage(messageID int64) (*dcMessage, error) {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "get_message", c.accountID, messageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var msg dcMessage
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeltaChatChannel) getFullChat(chatID int64) (*dcChat, error) {
|
||||||
|
raw, err := c.rpc.call(c.ctx, "get_full_chat_by_id", c.accountID, chatID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var chat dcChat
|
||||||
|
if err := json.Unmarshal(raw, &chat); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mentionsBot reports whether the message references the bot by display name or
|
||||||
|
// the local-part of its email address (a common addressing convention).
|
||||||
|
func mentionsBot(content, displayName, email string) bool {
|
||||||
|
if containsMentionToken(content, displayName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if local, _, ok := strings.Cut(email, "@"); ok && local != "" {
|
||||||
|
if containsMentionToken(content, "@"+local) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsMentionToken(content, token string) bool {
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
contentRunes := []rune(strings.ToLower(content))
|
||||||
|
tokenRunes := []rune(strings.ToLower(token))
|
||||||
|
if len(tokenRunes) == 0 || len(tokenRunes) > len(contentRunes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i <= len(contentRunes)-len(tokenRunes); i++ {
|
||||||
|
if !sameRunes(contentRunes[i:i+len(tokenRunes)], tokenRunes) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
before := i == 0 || !isMentionWordRune(contentRunes[i-1])
|
||||||
|
afterIdx := i + len(tokenRunes)
|
||||||
|
after := afterIdx >= len(contentRunes) || !isMentionWordRune(contentRunes[afterIdx])
|
||||||
|
if before && after {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameRunes(a, b []rune) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMentionWordRune(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||||
|
}
|
||||||
35
pkg/channels/deltachat/init.go
Normal file
35
pkg/channels/deltachat/init.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory(
|
||||||
|
config.ChannelDeltaChat,
|
||||||
|
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
bc := cfg.Channels[channelName]
|
||||||
|
if bc == nil || !bc.Enabled {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
decoded, err := bc.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, ok := decoded.(*config.DeltaChatSettings)
|
||||||
|
if !ok {
|
||||||
|
return nil, channels.ErrSendFailed
|
||||||
|
}
|
||||||
|
ch, err := NewDeltaChatChannel(bc, c, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if channelName != config.ChannelDeltaChat {
|
||||||
|
ch.SetName(channelName)
|
||||||
|
}
|
||||||
|
return ch, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
188
pkg/channels/deltachat/rpc.go
Normal file
188
pkg/channels/deltachat/rpc.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
package deltachat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rpcRequest is a single JSON-RPC 2.0 request. Delta Chat uses positional
|
||||||
|
// (array) parameters.
|
||||||
|
type rpcRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params []any `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcResponse is a single JSON-RPC 2.0 response.
|
||||||
|
type rpcResponse struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error *rpcError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rpcError) Error() string {
|
||||||
|
return fmt.Sprintf("deltachat rpc error %d: %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcClient drives a `deltachat-rpc-server` child process over stdio using
|
||||||
|
// newline-delimited JSON-RPC 2.0. The server answers requests asynchronously
|
||||||
|
// and out of order, so every call is correlated back to its caller by id.
|
||||||
|
type rpcClient struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
stdout io.ReadCloser
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
nextID uint64
|
||||||
|
pending map[uint64]chan rpcResponse
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRPC spawns the RPC server with DC_ACCOUNTS_PATH pointing at dataDir and
|
||||||
|
// begins the background read loop.
|
||||||
|
func startRPC(serverPath, dataDir string) (*rpcClient, error) {
|
||||||
|
cmd := exec.Command(serverPath)
|
||||||
|
cmd.Env = append(cmd.Environ(), "DC_ACCOUNTS_PATH="+dataDir)
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deltachat rpc stdin: %w", err)
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("deltachat rpc stdout: %w", err)
|
||||||
|
}
|
||||||
|
// Let the server's logs flow to our stderr for easy diagnostics.
|
||||||
|
cmd.Stderr = logWriter{}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, fmt.Errorf("start deltachat-rpc-server (%s): %w", serverPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c := &rpcClient{
|
||||||
|
cmd: cmd,
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
pending: make(map[uint64]chan rpcResponse),
|
||||||
|
}
|
||||||
|
go c.readLoop()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop reads newline-delimited responses and dispatches them to waiters.
|
||||||
|
func (c *rpcClient) readLoop() {
|
||||||
|
reader := bufio.NewReader(c.stdout)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadBytes('\n')
|
||||||
|
if len(line) > 0 {
|
||||||
|
var resp rpcResponse
|
||||||
|
if jsonErr := json.Unmarshal(line, &resp); jsonErr == nil && resp.ID != 0 {
|
||||||
|
c.mu.Lock()
|
||||||
|
ch := c.pending[resp.ID]
|
||||||
|
delete(c.pending, resp.ID)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.failAll(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// failAll wakes every pending caller with an error (used on EOF / shutdown).
|
||||||
|
func (c *rpcClient) failAll(err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.closed = true
|
||||||
|
for id, ch := range c.pending {
|
||||||
|
ch <- rpcResponse{Error: &rpcError{Code: -1, Message: "rpc closed: " + err.Error()}}
|
||||||
|
delete(c.pending, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// call issues one request and blocks until the matching response arrives, the
|
||||||
|
// context is canceled, or the server goes away.
|
||||||
|
func (c *rpcClient) call(ctx context.Context, method string, params ...any) (json.RawMessage, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.closed {
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("deltachat rpc: connection closed")
|
||||||
|
}
|
||||||
|
c.nextID++
|
||||||
|
id := c.nextID
|
||||||
|
ch := make(chan rpcResponse, 1)
|
||||||
|
c.pending[id] = ch
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
||||||
|
data, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
_, err = c.stdin.Write(data)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, fmt.Errorf("deltachat rpc write %s: %w", method, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.clearPending(id)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case resp := <-ch:
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, resp.Error
|
||||||
|
}
|
||||||
|
return resp.Result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *rpcClient) clearPending(id uint64) {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.pending, id)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// close terminates the RPC server process.
|
||||||
|
func (c *rpcClient) close() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.closed = true
|
||||||
|
c.mu.Unlock()
|
||||||
|
if c.stdin != nil {
|
||||||
|
_ = c.stdin.Close()
|
||||||
|
}
|
||||||
|
if c.cmd != nil && c.cmd.Process != nil {
|
||||||
|
_ = c.cmd.Process.Kill()
|
||||||
|
_ = c.cmd.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logWriter forwards deltachat-rpc-server stderr lines into the logger.
|
||||||
|
type logWriter struct{}
|
||||||
|
|
||||||
|
func (logWriter) Write(p []byte) (int, error) {
|
||||||
|
logger.DebugC("deltachat", string(p))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
@ -599,6 +599,31 @@ type MatrixSettings struct {
|
||||||
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
CryptoPassphrase string `json:"crypto_passphrase,omitempty" yaml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeltaChatSettings configures the Delta Chat channel. Delta Chat is an
|
||||||
|
// email-based, end-to-end encrypted messenger; PicoClaw talks to a local
|
||||||
|
// `deltachat-rpc-server` process over JSON-RPC (stdio).
|
||||||
|
//
|
||||||
|
// Email is the only required setting. A full address selects an already
|
||||||
|
// configured account in DataDir; a first-run marker such as "@nine.testrun.org"
|
||||||
|
// creates a chatmail account and tells the user which full email to save.
|
||||||
|
// Mailbox credentials stay in the Delta Chat account store. DisplayName and
|
||||||
|
// AvatarImage are optional profile settings applied on startup. Password remains
|
||||||
|
// only for legacy PicoClaw-managed email configuration.
|
||||||
|
type DeltaChatSettings struct {
|
||||||
|
Email string `json:"email" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_EMAIL"`
|
||||||
|
Password SecureString `json:"password,omitzero" yaml:"password,omitempty" env:"PICOCLAW_CHANNELS_DELTACHAT_PASSWORD"`
|
||||||
|
DisplayName string `json:"display_name,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DISPLAY_NAME"`
|
||||||
|
AvatarImage string `json:"avatar_image,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_AVATAR_IMAGE"`
|
||||||
|
DataDir string `json:"data_dir,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_DATA_DIR"`
|
||||||
|
RPCServerPath string `json:"rpc_server_path,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_RPC_SERVER_PATH"`
|
||||||
|
InviteLink string `json:"invite_link,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_INVITE_LINK"`
|
||||||
|
AllowCrosspost bool `json:"allow_crosspost,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_DELTACHAT_ALLOW_CROSSPOST"`
|
||||||
|
IMAPServer string `json:"imap_server,omitempty" yaml:"-"`
|
||||||
|
IMAPPort int `json:"imap_port,omitempty" yaml:"-"`
|
||||||
|
SMTPServer string `json:"smtp_server,omitempty" yaml:"-"`
|
||||||
|
SMTPPort int `json:"smtp_port,omitempty" yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type LINESettings struct {
|
type LINESettings struct {
|
||||||
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
ChannelSecret SecureString `json:"channel_secret,omitzero" yaml:"channel_secret,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||||
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
ChannelAccessToken SecureString `json:"channel_access_token,omitzero" yaml:"channel_access_token,omitempty" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ const (
|
||||||
ChannelDingTalk = "dingtalk"
|
ChannelDingTalk = "dingtalk"
|
||||||
ChannelSlack = "slack"
|
ChannelSlack = "slack"
|
||||||
ChannelMatrix = "matrix"
|
ChannelMatrix = "matrix"
|
||||||
|
ChannelDeltaChat = "deltachat"
|
||||||
ChannelLINE = "line"
|
ChannelLINE = "line"
|
||||||
ChannelOneBot = "onebot"
|
ChannelOneBot = "onebot"
|
||||||
ChannelQQ = "qq"
|
ChannelQQ = "qq"
|
||||||
|
|
@ -669,6 +670,7 @@ var channelSettingsFactory = map[string]any{
|
||||||
ChannelDingTalk: (DingTalkSettings{}),
|
ChannelDingTalk: (DingTalkSettings{}),
|
||||||
ChannelSlack: (SlackSettings{}),
|
ChannelSlack: (SlackSettings{}),
|
||||||
ChannelMatrix: (MatrixSettings{}),
|
ChannelMatrix: (MatrixSettings{}),
|
||||||
|
ChannelDeltaChat: (DeltaChatSettings{}),
|
||||||
ChannelLINE: (LINESettings{}),
|
ChannelLINE: (LINESettings{}),
|
||||||
ChannelOneBot: (OneBotSettings{}),
|
ChannelOneBot: (OneBotSettings{}),
|
||||||
ChannelQQ: (QQSettings{}),
|
ChannelQQ: (QQSettings{}),
|
||||||
|
|
|
||||||
|
|
@ -1019,6 +1019,38 @@ func TestDefaultConfig_ChannelStreamingDisabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_DeltaChatExample(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
deltachat := cfg.Channels.Get(ChannelDeltaChat)
|
||||||
|
if deltachat == nil {
|
||||||
|
t.Fatal("DefaultConfig() missing deltachat channel")
|
||||||
|
}
|
||||||
|
if deltachat.Enabled {
|
||||||
|
t.Fatal("DefaultConfig().deltachat should be disabled")
|
||||||
|
}
|
||||||
|
if !deltachat.GroupTrigger.MentionOnly {
|
||||||
|
t.Fatal("DefaultConfig().deltachat should use mention-only group trigger")
|
||||||
|
}
|
||||||
|
decoded, err := deltachat.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("deltachat GetDecoded() error = %v", err)
|
||||||
|
}
|
||||||
|
settings, ok := decoded.(*DeltaChatSettings)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("deltachat settings type = %T, want *DeltaChatSettings", decoded)
|
||||||
|
}
|
||||||
|
if settings.Email != "@nine.testrun.org" {
|
||||||
|
t.Fatalf("DefaultConfig().deltachat.settings.email = %q, want @nine.testrun.org", settings.Email)
|
||||||
|
}
|
||||||
|
if settings.Password.String() != "" {
|
||||||
|
t.Fatal("DefaultConfig().deltachat.settings.password should be empty")
|
||||||
|
}
|
||||||
|
if settings.DisplayName == "" {
|
||||||
|
t.Fatal("DefaultConfig().deltachat.settings.display_name should be populated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
func TestValidateSingletonChannels_RejectsMultipleInstances(t *testing.T) {
|
||||||
channels := ChannelsConfig{
|
channels := ChannelsConfig{
|
||||||
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
"pico1": &Channel{Enabled: true, Type: ChannelPico},
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,13 @@ func defaultChannels() ChannelsConfig {
|
||||||
"join_on_invite": true,
|
"join_on_invite": true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"deltachat": map[string]any{
|
||||||
|
"group_trigger": map[string]any{"mention_only": true},
|
||||||
|
"settings": map[string]any{
|
||||||
|
"email": "@nine.testrun.org",
|
||||||
|
"display_name": "PicoClaw Bot",
|
||||||
|
},
|
||||||
|
},
|
||||||
"line": map[string]any{
|
"line": map[string]any{
|
||||||
"group_trigger": map[string]any{"mention_only": true},
|
"group_trigger": map[string]any{"mention_only": true},
|
||||||
"settings": map[string]any{
|
"settings": map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
||||||
"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/deltachat"
|
||||||
_ "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…
Reference in a new issue