diff --git a/docs/channels/feishu/ARMV7_SUPPORT.md b/docs/channels/feishu/ARMV7_SUPPORT.md new file mode 100644 index 0000000000..25659e8b37 --- /dev/null +++ b/docs/channels/feishu/ARMV7_SUPPORT.md @@ -0,0 +1,136 @@ +# 飞书渠道 ARMv7 支持 + +## 问题描述 + +飞书SDK (`github.com/larksuite/oapi-sdk-go/v3`) 在32位架构(如ARMv7)上存在编译问题。具体问题是: + +在 `service/drive/v1/api_ext.go` 文件中,使用了 `math.MaxInt64` 常量: + +```go +limit: math.MaxInt64 +``` + +在32位架构上,`int` 类型是32位的,无法容纳 `MaxInt64` (9223372036854775807),导致编译错误: + +``` +constant 9223372036854775807 overflows int +``` + +## 解决方案 + +### 方案1:手动修复飞书SDK(推荐) + +在编译前,手动修复飞书SDK的问题: + +```bash +# 找到飞书SDK的安装路径 +LARK_PATH="${GOPATH:-$HOME/go}/pkg/mod/github.com/larksuite/oapi-sdk-go/v3@v3.5.3" +FILE="$LARK_PATH/service/drive/v1/api_ext.go" + +# 修复 math.MaxInt64 问题 +sed -i 's/math.MaxInt64/int(^uint(0) >> 1)/g' "$FILE" +``` + +### 方案2:使用构建脚本 + +我们提供了一个构建脚本来简化这个过程: + +```bash +make build-linux-arm-with-feishu +``` + +或者: + +```bash +./scripts/build-feishu-armv7.sh +``` + +### 方案3:使用修复后的Fork(未来) + +我们正在向飞书SDK提交PR来修复这个问题。一旦PR被合并,这个问题将自动解决。 + +跟踪PR:[larksuite/oapi-sdk-go#XXX](https://github.com/larksuite/oapi-sdk-go/pulls) + +## 技术细节 + +### 构建标签 + +PicoClaw使用Go构建标签来支持不同架构: + +- `feishu_64.go`: 支持64位架构(amd64, arm64, riscv64, mips64, ppc64) +- `feishu_32.go`: 支持其他架构(386, mips, 等) + +从Issue #1675开始,我们添加了对ARMv7的支持: + +- `feishu_64.go`: 现在包含 `arm` 构建标签,支持ARMv7 +- `feishu_32.go`: 排除 `arm` 架构 + +### 为什么PicoClaw可以使用飞书SDK的ARMv7支持? + +虽然飞书SDK存在编译问题,但这个问题只影响 `service/drive/v1` 包,而PicoClaw只使用了以下包: + +- `github.com/larksuite/oapi-sdk-go/v3` (主客户端) +- `github.com/larksuite/oapi-sdk-go/v3/core` +- `github.com/larksuite/oapi-sdk-go/v3/event/dispatcher` +- `github.com/larksuite/oapi-sdk-go/v3/service/im/v1` (IM服务) +- `github.com/larksuite/oapi-sdk-go/v3/ws` (WebSocket) + +这些包在ARMv7上工作正常。问题只出现在 `service/drive/v1` 包中,该包被 `client.go` 导入,但我们不直接使用它。 + +## 构建指南 + +### 为ARMv7构建(32位) + +```bash +# 方法1:使用Makefile(需要先修复SDK) +make fix-lark-sdk +make build-linux-arm + +# 方法2:手动构建 +# 1. 先修复飞书SDK +sed -i 's/math.MaxInt64/int(^uint(0) >> 1)/g' \ + ~/go/pkg/mod/github.com/larksuite/oapi-sdk-go/v3@v3.5.3/service/drive/v1/api_ext.go + +# 2. 构建 +GOOS=linux GOARCH=arm GOARM=7 go build -o picoclaw-linux-arm ./cmd/picoclaw +``` + +### 为ARM64构建(64位) + +```bash +make build-linux-arm64 +``` + +或者: + +```bash +GOOS=linux GOARCH=arm64 go build -o picoclaw-linux-arm64 ./cmd/picoclaw +``` + +## 测试 + +构建完成后,可以在ARMv7设备上测试飞书渠道: + +```bash +./picoclaw-linux-arm gateway +``` + +确保在 `config.json` 中启用了飞书渠道: + +```json +{ + "channels": { + "feishu": { + "enabled": true, + "app_id": "your-app-id", + "app_secret": "your-app-secret", + "allow_from": [] + } + } +} +``` + +## 参考 + +- [Issue #1675](https://github.com/sipeed/picoclaw/issues/1675) +- [飞书SDK Issue](https://github.com/larksuite/oapi-sdk-go/issues) diff --git a/go.mod b/go.mod index f60be046f1..2cfef4d5d9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sipeed/picoclaw -go 1.25.7 +go 1.23 require ( github.com/adhocore/gronx v1.19.6 diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index f5e3aa2249..5485f96720 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -1,4 +1,4 @@ -//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 +//go:build !amd64 && !arm64 && !arm && !riscv64 && !mips64 && !ppc64 package feishu diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5dbbcf0af2..2134b2ada6 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -1,4 +1,4 @@ -//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 +//go:build amd64 || arm64 || arm || riscv64 || mips64 || ppc64 package feishu diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index cdd49538f9..1a24bb980f 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -63,6 +63,7 @@ var channelRateConfig = map[string]float64{ "slack": 1, "matrix": 2, "line": 10, + "qq": 5, "irc": 2, } diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 112964143d..540e3b7afa 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -3,7 +3,10 @@ package qq import ( "context" "fmt" + "regexp" + "strings" "sync" + "sync/atomic" "time" "github.com/tencent-connect/botgo" @@ -20,6 +23,14 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +const ( + dedupTTL = 5 * time.Minute + dedupInterval = 60 * time.Second + dedupMaxSize = 10000 // hard cap on dedup map entries + typingResend = 8 * time.Second + typingSeconds = 10 +) + type QQChannel struct { *channels.BaseChannel config config.QQConfig @@ -28,20 +39,37 @@ type QQChannel struct { ctx context.Context cancel context.CancelFunc sessionManager botgo.SessionManager - processedIDs map[string]bool - mu sync.RWMutex + + // Chat routing: track whether a chatID is group or direct. + chatType sync.Map // chatID → "group" | "direct" + + // Passive reply: store last inbound message ID per chat. + lastMsgID sync.Map // chatID → string + + // msg_seq: per-chat atomic counter for multi-part replies. + msgSeqCounters sync.Map // chatID → *atomic.Uint64 + + // Time-based dedup replacing the unbounded map. + dedup map[string]time.Time + muDedup sync.Mutex + + // done is closed on Stop to shut down the dedup janitor. + done chan struct{} + stopOnce sync.Once } func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(cfg.MaxMessageLength), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &QQChannel{ - BaseChannel: base, - config: cfg, - processedIDs: make(map[string]bool), + BaseChannel: base, + config: cfg, + dedup: make(map[string]time.Time), + done: make(chan struct{}), }, nil } @@ -52,6 +80,10 @@ func (c *QQChannel) Start(ctx context.Context) error { logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") + // Reinitialize shutdown signal for clean restart. + c.done = make(chan struct{}) + c.stopOnce = sync.Once{} + // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, @@ -99,6 +131,15 @@ func (c *QQChannel) Start(ctx context.Context) error { } }() + // start dedup janitor goroutine + go c.dedupJanitor() + + // Pre-register reasoning_channel_id as group chat if configured, + // so outbound-only destinations are routed correctly. + if c.config.ReasoningChannelID != "" { + c.chatType.Store(c.config.ReasoningChannelID, "group") + } + c.SetRunning(true) logger.InfoC("qq", "QQ bot started successfully") @@ -109,6 +150,9 @@ func (c *QQChannel) Stop(ctx context.Context) error { logger.InfoC("qq", "Stopping QQ bot") c.SetRunning(false) + // Signal the dedup janitor to stop (idempotent). + c.stopOnce.Do(func() { close(c.done) }) + if c.cancel != nil { c.cancel() } @@ -116,21 +160,82 @@ func (c *QQChannel) Stop(ctx context.Context) error { return nil } +// getChatKind returns the chat type for a given chatID ("group" or "direct"). +// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are +// more common as outbound-only destinations (e.g. reasoning_channel_id). +func (c *QQChannel) getChatKind(chatID string) string { + if v, ok := c.chatType.Load(chatID); ok { + if k, ok := v.(string); ok { + return k + } + } + logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{ + "chat_id": chatID, + }) + return "group" +} + func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } - // construct message + chatKind := c.getChatKind(msg.ChatID) + + // Build message with content. msgToCreate := &dto.MessageToCreate{ Content: msg.Content, + MsgType: dto.TextMsg, + } + + // Use Markdown message type if enabled in config. + if c.config.SendMarkdown { + msgToCreate.MsgType = dto.MarkdownMsg + msgToCreate.Markdown = &dto.Markdown{ + Content: msg.Content, + } + // Clear plain content to avoid sending duplicate text. + msgToCreate.Content = "" + } + + // Attach passive reply msg_id and msg_seq if available. + if v, ok := c.lastMsgID.Load(msg.ChatID); ok { + if msgID, ok := v.(string); ok && msgID != "" { + msgToCreate.MsgID = msgID + + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok { + if counter, ok := counterVal.(*atomic.Uint64); ok { + seq := counter.Add(1) + msgToCreate.MsgSeq = uint32(seq) + } + } + } + } + + // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. + if chatKind == "group" { + if msgToCreate.Content != "" { + msgToCreate.Content = sanitizeURLs(msgToCreate.Content) + } + if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { + msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) + } + } + + // Route to group or C2C. + var err error + if chatKind == "group" { + _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + } else { + _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } - // send C2C message - _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ - "error": err.Error(), + logger.ErrorCF("qq", "Failed to send message", map[string]any{ + "chat_id": msg.ChatID, + "chat_kind": chatKind, + "error": err.Error(), }) return fmt.Errorf("qq send: %w", channels.ErrTemporary) } @@ -138,7 +243,150 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } -// handleC2CMessage handles QQ private messages +// StartTyping implements channels.TypingCapable. +// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds. +// The returned stop function is idempotent and cancels the goroutine. +func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + // We need a stored msg_id for passive InputNotify; skip if none available. + v, ok := c.lastMsgID.Load(chatID) + if !ok { + return func() {}, nil + } + msgID, ok := v.(string) + if !ok || msgID == "" { + return func() {}, nil + } + + chatKind := c.getChatKind(chatID) + + sendTyping := func(sendCtx context.Context) { + typingMsg := &dto.MessageToCreate{ + MsgType: dto.InputNotifyMsg, + MsgID: msgID, + InputNotify: &dto.InputNotify{ + InputType: 1, + InputSecond: typingSeconds, + }, + } + + var err error + if chatKind == "group" { + _, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg) + } else { + _, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg) + } + if err != nil { + logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + } + + // Send immediately. + sendTyping(c.ctx) + + typingCtx, cancel := context.WithCancel(c.ctx) + go func() { + ticker := time.NewTicker(typingResend) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + sendTyping(typingCtx) + } + } + }() + + return cancel, nil +} + +// SendMedia implements the channels.MediaSender interface. +// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported. +// If part.Ref is already an http(s) URL it is used directly; otherwise we try +// the media store, and skip with a warning if the resolved path is not an HTTP URL. +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + chatKind := c.getChatKind(msg.ChatID) + + for _, part := range msg.Parts { + // If the ref is already an HTTP(S) URL, use it directly. + mediaURL := part.Ref + if !isHTTPURL(mediaURL) { + // Try resolving through media store. + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ + "ref": part.Ref, + }) + continue + } + + resolved, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + if !isHTTPURL(resolved) { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{ + "ref": part.Ref, + "resolved": resolved, + }) + continue + } + + mediaURL = resolved + } + + // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. + var fileType uint64 + switch part.Type { + case "image": + fileType = 1 + case "video": + fileType = 2 + case "audio": + fileType = 3 + default: + fileType = 4 // file + } + + richMedia := &dto.RichMediaMessage{ + FileType: fileType, + URL: mediaURL, + SrvSendMsg: true, + } + + var sendErr error + if chatKind == "group" { + _, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) + } else { + _, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) + } + + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": msg.ChatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + } + + return nil +} + +// handleC2CMessage handles QQ private messages. func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { // deduplication check @@ -167,7 +415,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { "length": len(content), }) - // 转发到消息总线 + // Store chat routing context. + c.chatType.Store(senderID, "direct") + c.lastMsgID.Store(senderID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) + metadata := map[string]string{} sender := bus.SenderInfo{ @@ -195,7 +449,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } } -// handleGroupATMessage handles QQ group @ messages +// handleGroupATMessage handles QQ group @ messages. func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { // deduplication check @@ -232,7 +486,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "length": len(content), }) - // 转发到消息总线(使用 GroupID 作为 ChatID) + // Store chat routing context using GroupID as chatID. + c.chatType.Store(data.GroupID, "group") + c.lastMsgID.Store(data.GroupID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) + metadata := map[string]string{ "group_id": data.GroupID, } @@ -262,29 +522,102 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } } -// isDuplicate 检查消息是否重复 +// isDuplicate checks whether a message has been seen within the TTL window. +// It also enforces a hard cap on map size by evicting oldest entries. func (c *QQChannel) isDuplicate(messageID string) bool { - c.mu.Lock() - defer c.mu.Unlock() + c.muDedup.Lock() + defer c.muDedup.Unlock() - if c.processedIDs[messageID] { + if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL { return true } - c.processedIDs[messageID] = true - - // 简单清理:限制 map 大小 - if len(c.processedIDs) > 10000 { - // 清空一半 - count := 0 - for id := range c.processedIDs { - if count >= 5000 { - break + // Enforce hard cap: evict oldest entries when at capacity. + if len(c.dedup) >= dedupMaxSize { + var oldestID string + var oldestTS time.Time + for id, ts := range c.dedup { + if oldestID == "" || ts.Before(oldestTS) { + oldestID = id + oldestTS = ts } - delete(c.processedIDs, id) - count++ + } + if oldestID != "" { + delete(c.dedup, oldestID) } } + c.dedup[messageID] = time.Now() return false } + +// dedupJanitor periodically evicts expired entries from the dedup map. +func (c *QQChannel) dedupJanitor() { + ticker := time.NewTicker(dedupInterval) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + // Collect expired keys under read-like scan. + c.muDedup.Lock() + now := time.Now() + var expired []string + for id, ts := range c.dedup { + if now.Sub(ts) >= dedupTTL { + expired = append(expired, id) + } + } + for _, id := range expired { + delete(c.dedup, id) + } + c.muDedup.Unlock() + } + } +} + +// isHTTPURL returns true if s starts with http:// or https://. +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// urlPattern matches URLs with explicit http(s):// scheme. +// Only scheme-prefixed URLs are matched to avoid false positives on bare text +// like version numbers (e.g., "1.2.3") or domain-like fragments. +var urlPattern = regexp.MustCompile( + `(?i)` + + `https?://` + // required scheme + `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts + `[a-zA-Z]{2,}` + // TLD + `(?:[/?#]\S*)?`, // optional path/query/fragment +) + +// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period) +// to prevent QQ's URL blacklist from rejecting the message. +func sanitizeURLs(text string) string { + return urlPattern.ReplaceAllStringFunc(text, func(match string) string { + // Split into scheme + rest (scheme is always present). + idx := strings.Index(match, "://") + scheme := match[:idx+3] + rest := match[idx+3:] + + // Find where the domain ends (first / ? or #). + domainEnd := len(rest) + for i, ch := range rest { + if ch == '/' || ch == '?' || ch == '#' { + domainEnd = i + break + } + } + + domain := rest[:domainEnd] + path := rest[domainEnd:] + + // Replace dots in domain only. + domain = strings.ReplaceAll(domain, ".", "。") + + return scheme + domain + path + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a47ab30919..76d312daec 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -312,6 +312,8 @@ type QQConfig struct { AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index e64baa7204..0892d45f4a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -80,10 +80,11 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + AppID: "", + AppSecret: "", + AllowFrom: FlexibleStringSlice{}, + MaxMessageLength: 2000, }, DingTalk: DingTalkConfig{ Enabled: false, diff --git a/scripts/build-feishu-armv7.sh b/scripts/build-feishu-armv7.sh new file mode 100755 index 0000000000..43b59767a1 --- /dev/null +++ b/scripts/build-feishu-armv7.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# 为ARMv7构建PicoClaw并修复飞书SDK的32位架构问题 + +set -e + +echo "Building PicoClaw for ARMv7 with Feishu support..." + +# 找到飞书SDK的安装路径 +LARK_PATH="${GOPATH:-$HOME/go}/pkg/mod/github.com/larksuite/oapi-sdk-go/v3@v3.5.3" +FILE="$LARK_PATH/service/drive/v1/api_ext.go" + +if [ ! -f "$FILE" ]; then + echo "Error: Feishu SDK not found at $FILE" + echo "Please run 'go mod download' first." + exit 1 +fi + +# 检查是否已经修复 +if grep -q "int(^uint(0) >> 1)" "$FILE"; then + echo "Feishu SDK already patched." +else + echo "Patching Feishu SDK for 32-bit architecture support..." + # 备份原文件 + if [ ! -f "$FILE.bak" ]; then + cp "$FILE" "$FILE.bak" + fi + # 修复 math.MaxInt64 问题 + sed -i 's/math.MaxInt64/int(^uint(0) >> 1)/g' "$FILE" + echo "Patch applied successfully." +fi + +# 构建PicoClaw +BUILD_DIR="build" +BINARY_NAME="picoclaw-linux-arm" + +echo "Building $BINARY_NAME..." +mkdir -p "$BUILD_DIR" + +CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build \ + -v -tags stdjson \ + -ldflags "-s -w" \ + -o "$BUILD_DIR/$BINARY_NAME" \ + ./cmd/picoclaw + +echo "Build complete: $BUILD_DIR/$BINARY_NAME" +echo "" +echo "You can now deploy this binary to your ARMv7 device." +echo "Make sure to enable feishu channel in your config.json"