feat: Add Qwen CLI provider support and merge upstream

This commit is contained in:
Dome C. 2026-03-18 21:40:06 +07:00
commit 9dd2d9f069
36 changed files with 793 additions and 195 deletions

3
.gitignore vendored
View file

@ -52,6 +52,9 @@ dist/
# Windows Application Icon/Resource # Windows Application Icon/Resource
*.syso *.syso
# Test telegram integration
cmd/telegram/
# Keep embedded backend dist directory placeholder in VCS # Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/ !web/backend/dist/
web/backend/dist/* web/backend/dist/*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View file

@ -12,7 +12,7 @@ const Logo = "🦞"
// GetPicoclawHome returns the picoclaw home directory. // GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string { func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return home return home
} }
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
@ -20,7 +20,7 @@ func GetPicoclawHome() string {
} }
func GetConfigPath() string { func GetConfigPath() string {
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { if configPath := os.Getenv(config.EnvConfig); configPath != "" {
return configPath return configPath
} }
return filepath.Join(GetPicoclawHome(), "config.json") return filepath.Join(GetPicoclawHome(), "config.json")

View file

@ -78,9 +78,8 @@
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "", "base_url": "",
"proxy": "", "proxy": "",
"allow_from": [ "allow_from": ["YOUR_USER_ID"],
"YOUR_USER_ID" "use_markdown_v2": false,
],
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"discord": { "discord": {

View file

@ -42,7 +42,8 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
"telegram": { "telegram": {
"enabled": true, "enabled": true,
"token": "YOUR_BOT_TOKEN", "token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"] "allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": false,
} }
} }
} }
@ -63,6 +64,9 @@ Telegram command menu registration remains channel-local discovery UX; generic c
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
**4. Advanced Formatting**
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
</details> </details>
<details> <details>

View file

@ -52,7 +52,7 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
} }
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return home return home
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@ -65,7 +65,7 @@ func getGlobalConfigDir() string {
func NewContextBuilder(workspace string) *ContextBuilder { func NewContextBuilder(workspace string) *ContextBuilder {
// builtin skills: skills directory in current project // builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory // Use the skills/ directory under the current working directory
builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS")) builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" { if builtinSkillsDir == "" {
wd, _ := os.Getwd() wd, _ := os.Getwd()
builtinSkillsDir = filepath.Join(wd, "skills") builtinSkillsDir = filepath.Join(wd, "skills")

View file

@ -6,6 +6,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
) )
@ -39,7 +40,7 @@ func (c *AuthCredential) NeedsRefresh() bool {
} }
func authFilePath() string { func authFilePath() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return filepath.Join(home, "auth.json") return filepath.Join(home, "auth.json")
} }
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()

View file

@ -29,11 +29,17 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
// errCodeTenantTokenInvalid is the Feishu API error code for an expired/revoked
// tenant_access_token. The Lark SDK's built-in retry does not clear its cache
// on this error, so we do it ourselves.
const errCodeTenantTokenInvalid = 99991663
type FeishuChannel struct { type FeishuChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.FeishuConfig config config.FeishuConfig
client *lark.Client client *lark.Client
wsClient *larkws.Client wsClient *larkws.Client
tokenCache *tokenCache // custom cache that supports invalidation
botOpenID atomic.Value // stores string; populated lazily for @mention detection botOpenID atomic.Value // stores string; populated lazily for @mention detection
@ -47,10 +53,12 @@ func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChan
channels.WithReasoningChannelID(cfg.ReasoningChannelID), channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
tc := newTokenCache()
ch := &FeishuChannel{ ch := &FeishuChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
client: lark.NewClient(cfg.AppID, cfg.AppSecret), tokenCache: tc,
client: lark.NewClient(cfg.AppID, cfg.AppSecret, lark.WithTokenCache(tc)),
} }
ch.SetOwner(ch) ch.SetOwner(ch)
return ch, nil return ch, nil
@ -147,6 +155,7 @@ func (c *FeishuChannel) EditMessage(ctx context.Context, chatID, messageID, cont
return fmt.Errorf("feishu edit: %w", err) return fmt.Errorf("feishu edit: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu edit api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -186,6 +195,7 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
return "", fmt.Errorf("feishu placeholder send: %w", err) return "", fmt.Errorf("feishu placeholder send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg) return "", fmt.Errorf("feishu placeholder api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
@ -226,6 +236,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
return func() {}, fmt.Errorf("feishu react: %w", err) return func() {}, fmt.Errorf("feishu react: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Reaction API error", map[string]any{ logger.ErrorCF("feishu", "Reaction API error", map[string]any{
"emoji": chosenEmoji, "emoji": chosenEmoji,
"message_id": messageID, "message_id": messageID,
@ -451,6 +462,7 @@ func (c *FeishuChannel) fetchBotOpenID(ctx context.Context) error {
return fmt.Errorf("bot info parse: %w", err) return fmt.Errorf("bot info parse: %w", err)
} }
if result.Code != 0 { if result.Code != 0 {
c.invalidateTokenOnAuthError(result.Code)
return fmt.Errorf("bot info api error (code=%d)", result.Code) return fmt.Errorf("bot info api error (code=%d)", result.Code)
} }
if result.Bot.OpenID == "" { if result.Bot.OpenID == "" {
@ -593,6 +605,7 @@ func (c *FeishuChannel) downloadResource(
return "" return ""
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
logger.ErrorCF("feishu", "Resource download api error", map[string]any{ logger.ErrorCF("feishu", "Resource download api error", map[string]any{
"code": resp.Code, "code": resp.Code,
"msg": resp.Msg, "msg": resp.Msg,
@ -705,6 +718,7 @@ func (c *FeishuChannel) sendCard(ctx context.Context, chatID, cardContent string
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary) return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
} }
@ -730,6 +744,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
return fmt.Errorf("feishu image upload: %w", err) return fmt.Errorf("feishu image upload: %w", err)
} }
if !uploadResp.Success() { if !uploadResp.Success() {
c.invalidateTokenOnAuthError(uploadResp.Code)
return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) return fmt.Errorf("feishu image upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
} }
if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil { if uploadResp.Data == nil || uploadResp.Data.ImageKey == nil {
@ -754,6 +769,7 @@ func (c *FeishuChannel) sendImage(ctx context.Context, chatID string, file *os.F
return fmt.Errorf("feishu image send: %w", err) return fmt.Errorf("feishu image send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu image send api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -784,6 +800,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
return fmt.Errorf("feishu file upload: %w", err) return fmt.Errorf("feishu file upload: %w", err)
} }
if !uploadResp.Success() { if !uploadResp.Success() {
c.invalidateTokenOnAuthError(uploadResp.Code)
return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg) return fmt.Errorf("feishu file upload api error (code=%d msg=%s)", uploadResp.Code, uploadResp.Msg)
} }
if uploadResp.Data == nil || uploadResp.Data.FileKey == nil { if uploadResp.Data == nil || uploadResp.Data.FileKey == nil {
@ -808,6 +825,7 @@ func (c *FeishuChannel) sendFile(ctx context.Context, chatID string, file *os.Fi
return fmt.Errorf("feishu file send: %w", err) return fmt.Errorf("feishu file send: %w", err)
} }
if !resp.Success() { if !resp.Success() {
c.invalidateTokenOnAuthError(resp.Code)
return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg) return fmt.Errorf("feishu file send api error (code=%d msg=%s)", resp.Code, resp.Msg)
} }
return nil return nil
@ -830,3 +848,14 @@ func extractFeishuSenderID(sender *larkim.EventSender) string {
return "" return ""
} }
// invalidateTokenOnAuthError clears the cached tenant_access_token when the
// Feishu API reports it as invalid (99991663), so the next request fetches a
// fresh one. The Lark SDK's built-in retry does not clear the cache, causing
// all API calls to fail until the token naturally expires (~2 hours).
func (c *FeishuChannel) invalidateTokenOnAuthError(code int) {
if code == errCodeTenantTokenInvalid {
c.tokenCache.InvalidateAll()
logger.WarnCF("feishu", "Invalidated cached token due to auth error", nil)
}
}

View file

@ -0,0 +1,52 @@
package feishu
import (
"context"
"sync"
"time"
)
// tokenCache implements larkcore.Cache with an extra InvalidateAll method.
// This works around a bug in the Lark SDK v3 where the built-in token retry
// loop does not clear stale tokens from cache on auth errors.
type tokenCache struct {
mu sync.RWMutex
store map[string]*tokenEntry
}
type tokenEntry struct {
value string
expireAt time.Time
}
func newTokenCache() *tokenCache {
return &tokenCache{store: make(map[string]*tokenEntry)}
}
func (c *tokenCache) Set(_ context.Context, key, value string, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.store[key] = &tokenEntry{value: value, expireAt: time.Now().Add(ttl)}
return nil
}
func (c *tokenCache) Get(_ context.Context, key string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.store[key]
if !ok {
return "", nil
}
if e.expireAt.Before(time.Now()) {
delete(c.store, key)
return "", nil
}
return e.value, nil
}
// InvalidateAll removes all cached tokens, forcing fresh acquisition.
func (c *tokenCache) InvalidateAll() {
c.mu.Lock()
defer c.mu.Unlock()
clear(c.store)
}

View file

@ -0,0 +1,197 @@
package telegram
import (
"regexp"
"strings"
)
// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2
var mdV2SpecialChars = map[rune]bool{
'*': true,
'_': true,
'[': true,
']': true,
'(': true,
')': true,
'~': true,
'`': true,
'>': true,
'<': true,
'#': true,
'+': true,
'-': true,
'=': true,
'|': true,
'{': true,
'}': true,
'.': true,
'!': true,
'\\': true,
}
// entityPattern describes one Telegram MarkdownV2 inline entity type.
type entityPattern struct {
re *regexp.Regexp
open string
close string
}
// allEntityPatterns lists every recognized entity in priority order
// (longer / more-specific delimiters first so they win over shorter ones).
// Each entry's regex is anchored to find the first occurrence in a string.
var allEntityPatterns = []entityPattern{
// fenced code block — content is completely verbatim
{re: regexp.MustCompile("(?s)```(?:[\\w]*\\n)?[\\s\\S]*?```"), open: "```", close: "```"},
// inline code — content is completely verbatim
{re: regexp.MustCompile("`(?:[^`\\\n]|\\\\.)*`"), open: "`", close: "`"},
// expandable block-quote opener **>…
{re: regexp.MustCompile(`(?m)\*\*>(?:[^\n]*)`), open: "**>", close: ""},
// block-quote line >…
{re: regexp.MustCompile(`(?m)^>(?:[^\n]*)`), open: ">", close: ""},
// custom emoji / timestamp ![…](…) — must come before plain link
{re: regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`), open: "!", close: ""},
// inline URL / user mention […](…)
{re: regexp.MustCompile(`\[[^\]]*\]\([^)]*\)`), open: "[", close: ""},
// spoiler ||…|| — before single | so it wins
{re: regexp.MustCompile(`\|\|(?:[^|\\\n]|\\.)*\|\|`), open: "||", close: "||"},
// underline __…__ — before single _ so it wins
{re: regexp.MustCompile(`__(?:[^_\\\n]|\\.)*__`), open: "__", close: "__"},
// bold *…*
{re: regexp.MustCompile(`\*(?:[^*\\\n]|\\.)*\*`), open: "*", close: "*"},
// italic _…_
{re: regexp.MustCompile(`_(?:[^_\\\n]|\\.)*_`), open: "_", close: "_"},
// strikethrough ~…~
{re: regexp.MustCompile(`~(?:[^~\\\n]|\\.)*~`), open: "~", close: "~"},
}
// verbatimEntities are entity types whose inner content must never be
// touched (code blocks, URLs, quotes, custom emoji).
// Their content is passed through completely unchanged.
var verbatimEntities = map[string]bool{
"```": true,
"`": true,
"**>": true,
">": true,
"!": true,
"[": true,
}
// markdownToTelegramMarkdownV2 converts a Markdown string into a string safe
// for sending with Telegram's MarkdownV2 parse mode.
//
// Rules:
// - Markdown headings (# … ######) are converted to *bold*.
// - **bold** Markdown syntax is converted to *bold*.
// - Recognized Telegram MarkdownV2 entity spans are preserved; their inner
// content is processed recursively so that nested valid entities are kept
// intact while stray special characters are escaped.
// - All plain-text segments have their MarkdownV2 special characters escaped.
//
// Reference: https://core.telegram.org/bots/api#formatting-options
func markdownToTelegramMarkdownV2(text string) string {
// 1. Convert Markdown headings → *escaped heading text*
text = reHeading.ReplaceAllStringFunc(text, func(match string) string {
sub := reHeading.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
// The heading content is fresh plain text — escape everything
// including * so the resulting *…* bold span stays valid.
return "*" + escapeMarkdownV2(sub[1]) + "*"
})
// 2. Convert **bold** → *bold*
text = reBoldStar.ReplaceAllString(text, "*$1*")
// 3. Recursively escape the full string.
return processText(text)
}
// processText walks `text`, finds the leftmost / longest matching entity,
// escapes the gap before it, processes the entity (recursing into its inner
// content when appropriate), then continues with the remainder.
func processText(text string) string {
if text == "" {
return ""
}
// Find the leftmost match among all entity patterns.
bestStart := -1
bestEnd := -1
var bestPat *entityPattern
for i := range allEntityPatterns {
p := &allEntityPatterns[i]
loc := p.re.FindStringIndex(text)
if loc == nil {
continue
}
if bestStart == -1 || loc[0] < bestStart ||
(loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) {
bestStart = loc[0]
bestEnd = loc[1]
bestPat = p
}
}
if bestPat == nil {
// No entity found — escape everything.
return escapeMarkdownV2(text)
}
var b strings.Builder
// Plain text before the entity.
if bestStart > 0 {
b.WriteString(escapeMarkdownV2(text[:bestStart]))
}
// The matched entity span.
matched := text[bestStart:bestEnd]
if verbatimEntities[bestPat.open] {
// Code blocks, URLs, quotes: pass through completely untouched.
b.WriteString(matched)
} else {
// Inline formatting (bold, italic, underline, strikethrough, spoiler):
// keep the delimiters and recursively process the inner content so that
// nested entities survive but stray specials get escaped.
openLen := len(bestPat.open)
closeLen := len(bestPat.close)
inner := matched[openLen : len(matched)-closeLen]
b.WriteString(bestPat.open)
b.WriteString(processText(inner))
b.WriteString(bestPat.close)
}
// Continue with the remainder of the string.
b.WriteString(processText(text[bestEnd:]))
return b.String()
}
// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text
// segment (i.e. a segment that is not part of any recognized entity).
// Already-escaped sequences (backslash + char) are forwarded verbatim to avoid
// double-escaping.
func escapeMarkdownV2(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
ch := runes[i]
// Forward an existing escape sequence verbatim.
if ch == '\\' && i+1 < len(runes) {
b.WriteRune(ch)
b.WriteRune(runes[i+1])
i++
continue
}
if mdV2SpecialChars[ch] {
b.WriteByte('\\')
}
b.WriteRune(ch)
}
return b.String()
}

View file

@ -0,0 +1,68 @@
package telegram
import (
_ "embed"
"testing"
"github.com/stretchr/testify/require"
)
//go:embed testdata/md2_all_formats.txt
var md2AllFormats string
func Test_markdownToTelegramMarkdownV2(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "heading -> bolding",
input: `## HeadingH2 #`,
expected: "*HeadingH2 \\#*",
},
{
name: "strikethrough",
input: "~strikethroughMD~",
expected: "~strikethroughMD~",
},
{
name: "inline URL",
input: "[inline URL](http://www.example.com/)",
expected: "[inline URL](http://www.example.com/)",
},
{
name: "all telegram formats",
input: md2AllFormats,
expected: md2AllFormats,
},
{
name: "empty",
input: "",
expected: "",
},
{
name: "one letter",
input: "o",
expected: "o",
},
{
name: "",
input: "*Last update: ~10 24h*",
expected: "*Last update: \\~10 24h*",
},
{
name: "",
input: "<Market Capitalization>",
expected: "\\<Market Capitalization\\>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual := markdownToTelegramMarkdownV2(tc.input)
require.EqualValues(t, tc.expected, actual)
})
}
}

View file

@ -0,0 +1,111 @@
package telegram
import (
"fmt"
"strings"
)
func markdownToTelegramHTML(text string) string {
if text == "" {
return ""
}
codeBlocks := extractCodeBlocks(text)
text = codeBlocks.text
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text
text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text)
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
return s
}
return "<i>" + match[1] + "</i>"
})
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
}
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(
text,
fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
)
}
return text
}
type codeBlockMatch struct {
text string
codes []string
}
func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
})
return codeBlockMatch{text: text, codes: codes}
}
type inlineCodeMatch struct {
text string
codes []string
}
func extractInlineCodes(text string) inlineCodeMatch {
matches := reInlineCode.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++
return placeholder
})
return inlineCodeMatch{text: text, codes: codes}
}
func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}

View file

@ -27,7 +27,7 @@ import (
) )
var ( var (
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
@ -170,6 +170,8 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return channels.ErrNotRunning return channels.ErrNotRunning
} }
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
chatID, threadID, err := parseTelegramChatID(msg.ChatID) chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil { if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
@ -188,11 +190,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
chunk := queue[0] chunk := queue[0]
queue = queue[1:] queue = queue[1:]
htmlContent := markdownToTelegramHTML(chunk) content := parseContent(chunk, useMarkdownV2)
if len([]rune(htmlContent)) > 4096 { if len([]rune(content)) > 4096 {
runeChunk := []rune(chunk) runeChunk := []rune(chunk)
ratio := float64(len(runeChunk)) / float64(len([]rune(htmlContent))) ratio := float64(len(runeChunk)) / float64(len([]rune(content)))
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
// Guarantee progress: if estimated length is >= chunk length, force it smaller // Guarantee progress: if estimated length is >= chunk length, force it smaller
@ -201,7 +203,14 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
} }
if smallerLen <= 0 { if smallerLen <= 0 {
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil { if err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
}); err != nil {
return err return err
} }
replyToID = "" replyToID = ""
@ -232,7 +241,14 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue continue
} }
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil { if err := c.sendChunk(ctx, sendChunkParams{
chatID: chatID,
threadID: threadID,
content: content,
replyToID: replyToID,
mdFallback: chunk,
useMarkdownV2: useMarkdownV2,
}); err != nil {
return err return err
} }
// Only the first chunk should be a reply; subsequent chunks are normal messages. // Only the first chunk should be a reply; subsequent chunks are normal messages.
@ -242,17 +258,31 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil return nil
} }
// sendHTMLChunk sends a single HTML message, falling back to the original type sendChunkParams struct {
// markdown as plain text on parse failure so users never see raw HTML tags. chatID int64
func (c *TelegramChannel) sendHTMLChunk( threadID int
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string, content string
) error { replyToID string
tgMsg := tu.Message(tu.ID(chatID), htmlContent) mdFallback string
tgMsg.ParseMode = telego.ModeHTML useMarkdownV2 bool
tgMsg.MessageThreadID = threadID }
if replyToID != "" { // sendChunk sends a single HTML/MarkdownV2 message, falling back to the original
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil { // markdown as plain text on parse failure so users never see raw HTML/MarkdownV2 tags.
func (c *TelegramChannel) sendChunk(
ctx context.Context,
params sendChunkParams,
) error {
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
tgMsg.MessageThreadID = params.threadID
if params.useMarkdownV2 {
tgMsg.WithParseMode(telego.ModeMarkdownV2)
} else {
tgMsg.WithParseMode(telego.ModeHTML)
}
if params.replyToID != "" {
if mid, parseErr := strconv.Atoi(params.replyToID); parseErr == nil {
tgMsg.ReplyParameters = &telego.ReplyParameters{ tgMsg.ReplyParameters = &telego.ReplyParameters{
MessageID: mid, MessageID: mid,
} }
@ -260,15 +290,15 @@ func (c *TelegramChannel) sendHTMLChunk(
} }
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ logParseFailed(err, params.useMarkdownV2)
"error": err.Error(),
}) tgMsg.Text = params.mdFallback
tgMsg.Text = mdFallback
tgMsg.ParseMode = "" tgMsg.ParseMode = ""
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
return fmt.Errorf("telegram send: %w", channels.ErrTemporary) return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
} }
} }
return nil return nil
} }
@ -309,6 +339,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
useMarkdownV2 := c.config.Channels.Telegram.UseMarkdownV2
cid, _, err := parseTelegramChatID(chatID) cid, _, err := parseTelegramChatID(chatID)
if err != nil { if err != nil {
return err return err
@ -317,10 +348,19 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
if err != nil { if err != nil {
return err return err
} }
htmlContent := markdownToTelegramHTML(content) parsedContent := parseContent(content, useMarkdownV2)
editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent)
editMsg.ParseMode = telego.ModeHTML if useMarkdownV2 {
editMsg.WithParseMode(telego.ModeMarkdownV2)
} else {
editMsg.WithParseMode(telego.ModeHTML)
}
_, err = c.bot.EditMessageText(ctx, editMsg) _, err = c.bot.EditMessageText(ctx, editMsg)
if err != nil {
logParseFailed(err, useMarkdownV2)
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
}
return err return err
} }
@ -668,6 +708,14 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
return c.downloadFileWithInfo(file, ext) return c.downloadFileWithInfo(file, ext)
} }
func parseContent(text string, useMarkdownV2 bool) string {
if useMarkdownV2 {
return markdownToTelegramMarkdownV2(text)
}
return markdownToTelegramHTML(text)
}
// parseTelegramChatID splits "chatID/threadID" into its components. // parseTelegramChatID splits "chatID/threadID" into its components.
// Returns threadID=0 when no "/" is present (non-forum messages). // Returns threadID=0 when no "/" is present (non-forum messages).
func parseTelegramChatID(chatID string) (int64, int, error) { func parseTelegramChatID(chatID string) (int64, int, error) {
@ -687,109 +735,18 @@ func parseTelegramChatID(chatID string) (int64, int, error) {
return cid, tid, nil return cid, tid, nil
} }
func markdownToTelegramHTML(text string) string { func logParseFailed(err error, useMarkdownV2 bool) {
if text == "" { parsingName := "HTML"
return "" if useMarkdownV2 {
parsingName = "MarkdownV2"
} }
codeBlocks := extractCodeBlocks(text) logger.ErrorCF("telegram",
text = codeBlocks.text fmt.Sprintf("%s parse failed, falling back to plain text", parsingName),
map[string]any{
inlineCodes := extractInlineCodes(text) "error": err.Error(),
text = inlineCodes.text },
text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text)
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
return s
}
return "<i>" + match[1] + "</i>"
})
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
}
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(
text,
fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
) )
}
return text
}
type codeBlockMatch struct {
text string
codes []string
}
func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
})
return codeBlockMatch{text: text, codes: codes}
}
type inlineCodeMatch struct {
text string
codes []string
}
func extractInlineCodes(text string) inlineCodeMatch {
matches := reInlineCode.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++
return placeholder
})
return inlineCodeMatch{text: text, codes: codes}
}
func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
} }
// isBotMentioned checks if the bot is mentioned in the message via entities. // isBotMentioned checks if the bot is mentioned in the message via entities.

View file

@ -17,6 +17,7 @@ import (
"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/config"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
) )
@ -131,6 +132,7 @@ func newTestChannelWithConstructor(
BaseChannel: base, BaseChannel: base,
bot: bot, bot: bot,
chatIDs: make(map[string]int64), chatIDs: make(map[string]int64),
config: config.DefaultConfig(),
} }
} }

View file

@ -0,0 +1,31 @@
*bold \*text*
_italic \*text_
__underline__
~strikethrough~
||spoiler||
*bold _italic bold ~italic bold strikethrough ||italic bold strikethrough spoiler||~ __underline italic bold___ bold*
[inline URL](http://www.example.com/)
[inline mention of a user](tg://user?id=123456789)
![👍](tg://emoji?id=5368324170671202286)
![22:45 tomorrow](tg://time?unix=1647531900&format=wDT)
![22:45 tomorrow](tg://time?unix=1647531900&format=t)
![22:45 tomorrow](tg://time?unix=1647531900&format=r)
![22:45 tomorrow](tg://time?unix=1647531900)
`inline fixed-width code`
```
pre-formatted fixed-width code block
```
```python
pre-formatted fixed-width code block written in the Python programming language
```
>Block quotation started
>Block quotation continued
>Block quotation continued
>Block quotation continued
>The last line of the block quotation
**>The expandable block quotation started right after the previous block quotation
>It is separated from the previous block quotation by an empty bold entity
>Expandable block quotation continued
>Hidden by default part of the expandable block quotation started
>Expandable block quotation continued
>The last line of the expandable block quotation with the expandability mark||

View file

@ -311,6 +311,7 @@ type TelegramConfig struct {
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
UseMarkdownV2 bool `json:"use_markdown_v2" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
} }
type FeishuConfig struct { type FeishuConfig struct {
@ -531,6 +532,7 @@ type ProvidersConfig struct {
Minimax ProviderConfig `json:"minimax"` Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"` LongCat ProviderConfig `json:"longcat"`
ModelScope ProviderConfig `json:"modelscope"` ModelScope ProviderConfig `json:"modelscope"`
Novita ProviderConfig `json:"novita"`
} }
// IsEmpty checks if all provider configs are empty (no API keys or API bases set) // IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -559,7 +561,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Avian.APIKey == "" && p.Avian.APIBase == "" && p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == "" && p.LongCat.APIKey == "" && p.LongCat.APIBase == "" &&
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" &&
p.Novita.APIKey == "" && p.Novita.APIBase == ""
} }
// MarshalJSON implements custom JSON marshaling for ProvidersConfig // MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -589,7 +592,9 @@ type OpenAIProviderConfig struct {
// ModelConfig represents a model-centric provider configuration. // ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only. // It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier // The model field uses protocol prefix format: [protocol/]model-identifier
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot // Supported protocols include openai, anthropic, antigravity, claude-cli,
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
// groq, deepseek, modelscope, and novita.
// Default protocol is "openai" if no prefix is specified. // Default protocol is "openai" if no prefix is specified.
type ModelConfig struct { type ModelConfig struct {
// Required fields // Required fields

View file

@ -77,6 +77,22 @@ func TestAgentModelConfig_MarshalObject(t *testing.T) {
} }
} }
func TestProvidersConfig_IsEmpty(t *testing.T) {
var empty ProvidersConfig
if !empty.IsEmpty() {
t.Fatal("empty ProvidersConfig should report empty")
}
novita := ProvidersConfig{
Novita: ProviderConfig{
APIKey: "test-key",
},
}
if novita.IsEmpty() {
t.Fatal("ProvidersConfig with novita settings should not report empty")
}
}
func TestAgentConfig_FullParse(t *testing.T) { func TestAgentConfig_FullParse(t *testing.T) {
jsonData := `{ jsonData := `{
"agents": { "agents": {

View file

@ -15,7 +15,7 @@ func DefaultConfig() *Config {
// Determine the base path for the workspace. // Determine the base path for the workspace.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
var homePath string var homePath string
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { if picoclawHome := os.Getenv(EnvHome); picoclawHome != "" {
homePath = picoclawHome homePath = picoclawHome
} else { } else {
userHome, _ := os.UserHomeDir() userHome, _ := os.UserHomeDir()
@ -58,6 +58,7 @@ func DefaultConfig() *Config {
Enabled: true, Enabled: true,
Text: "Thinking... 💭", Text: "Thinking... 💭",
}, },
UseMarkdownV2: false,
}, },
Feishu: FeishuConfig{ Feishu: FeishuConfig{
Enabled: false, Enabled: false,

37
pkg/config/envkeys.go Normal file
View file

@ -0,0 +1,37 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package config
// Runtime environment variable keys for the picoclaw process.
// These control the location of files and binaries at runtime and are read
// directly via os.Getenv / os.LookupEnv. All picoclaw-specific keys use the
// PICOCLAW_ prefix. Reference these constants instead of inline string
// literals to keep all supported knobs visible in one place and to prevent
// typos.
const (
// EnvHome overrides the base directory for all picoclaw data
// (config, workspace, skills, auth store, …).
// Default: ~/.picoclaw
EnvHome = "PICOCLAW_HOME"
// EnvConfig overrides the full path to the JSON config file.
// Default: $PICOCLAW_HOME/config.json
EnvConfig = "PICOCLAW_CONFIG"
// EnvBuiltinSkills overrides the directory from which built-in
// skills are loaded.
// Default: <cwd>/skills
EnvBuiltinSkills = "PICOCLAW_BUILTIN_SKILLS"
// EnvBinary overrides the path to the picoclaw executable.
// Used by the web launcher when spawning the gateway subprocess.
// Default: resolved from the same directory as the current executable.
EnvBinary = "PICOCLAW_BINARY"
// EnvGatewayHost overrides the host address for the gateway server.
// Default: "127.0.0.1"
EnvGatewayHost = "PICOCLAW_GATEWAY_HOST"
)

View file

@ -66,6 +66,14 @@ var ErrPassphraseRequired = errors.New("credential: enc:// passphrase required")
// indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is. // indicating a wrong passphrase or SSH key. Callers can detect this with errors.Is.
var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)") var ErrDecryptionFailed = errors.New("credential: enc:// decryption failed (wrong passphrase or SSH key?)")
// SSHKeyPathEnvVar is the environment variable that specifies the path to the
// SSH private key used for enc:// credential encryption and decryption.
const SSHKeyPathEnvVar = "PICOCLAW_SSH_KEY_PATH"
// picoclawHome is a package-local copy of config.EnvHome. It is kept here to
// avoid a circular import between pkg/credential and pkg/config.
const picoclawHome = "PICOCLAW_HOME"
const ( const (
fileScheme = "file://" fileScheme = "file://"
encScheme = "enc://" encScheme = "enc://"
@ -73,7 +81,6 @@ const (
saltLen = 16 saltLen = 16
nonceLen = 12 nonceLen = 12
keyLen = 32 keyLen = 32
sshKeyEnv = "PICOCLAW_SSH_KEY_PATH"
) )
// Resolver resolves raw credential strings for model_list api_key fields. // Resolver resolves raw credential strings for model_list api_key fields.
@ -248,14 +255,14 @@ func allowedSSHKeyPath(path string) bool {
clean := filepath.Clean(path) clean := filepath.Clean(path)
// Exact match with PICOCLAW_SSH_KEY_PATH. // Exact match with PICOCLAW_SSH_KEY_PATH.
if envPath, ok := os.LookupEnv(sshKeyEnv); ok && envPath != "" { if envPath, ok := os.LookupEnv(SSHKeyPathEnvVar); ok && envPath != "" {
if clean == filepath.Clean(envPath) { if clean == filepath.Clean(envPath) {
return true return true
} }
} }
// Within PICOCLAW_HOME. // Within PICOCLAW_HOME.
if picoHome := os.Getenv("PICOCLAW_HOME"); picoHome != "" { if picoHome := os.Getenv(picoclawHome); picoHome != "" {
if isWithinDir(clean, picoHome) { if isWithinDir(clean, picoHome) {
return true return true
} }
@ -316,7 +323,7 @@ func pickSSHKeyPath(override string) string {
if override != "" { if override != "" {
return override return override
} }
if p, ok := os.LookupEnv(sshKeyEnv); ok { if p, ok := os.LookupEnv(SSHKeyPathEnvVar); ok {
return p // respect explicit setting, even if "" return p // respect explicit setting, even if ""
} }
return findDefaultSSHKey() return findDefaultSSHKey()

View file

@ -5,13 +5,15 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"github.com/sipeed/picoclaw/pkg/config"
) )
func ResolveTargetHome(override string) (string, error) { func ResolveTargetHome(override string) (string, error) {
if override != "" { if override != "" {
return ExpandHome(override), nil return ExpandHome(override), nil
} }
if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { if envHome := os.Getenv(config.EnvHome); envHome != "" {
return ExpandHome(envHome), nil return ExpandHome(envHome), nil
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()

View file

@ -137,6 +137,7 @@ type OpenClawTelegramConfig struct {
GroupPolicy *string `json:"groupPolicy"` GroupPolicy *string `json:"groupPolicy"`
DmPolicy *string `json:"dmPolicy"` DmPolicy *string `json:"dmPolicy"`
Enabled *bool `json:"enabled"` Enabled *bool `json:"enabled"`
UseMarkdownV2 *bool `json:"useMarkdownV2"`
} }
type OpenClawDiscordConfig struct { type OpenClawDiscordConfig struct {
@ -649,6 +650,7 @@ type TelegramConfig struct {
Token string `json:"token"` Token string `json:"token"`
Proxy string `json:"proxy"` Proxy string `json:"proxy"`
AllowFrom []string `json:"allow_from"` AllowFrom []string `json:"allow_from"`
UseMarkdownV2 bool `json:"use_markdown_v2"`
} }
type FeishuConfig struct { type FeishuConfig struct {
@ -777,9 +779,11 @@ func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig {
if c.Channels.Telegram != nil { if c.Channels.Telegram != nil {
enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled
useMarkdownV2 := c.Channels.Telegram.UseMarkdownV2 != nil && *c.Channels.Telegram.UseMarkdownV2
channels.Telegram = TelegramConfig{ channels.Telegram = TelegramConfig{
Enabled: enabled, Enabled: enabled,
AllowFrom: c.Channels.Telegram.AllowFrom, AllowFrom: c.Channels.Telegram.AllowFrom,
UseMarkdownV2: useMarkdownV2,
} }
if c.Channels.Telegram.BotToken != nil { if c.Channels.Telegram.BotToken != nil {
channels.Telegram.Token = *c.Channels.Telegram.BotToken channels.Telegram.Token = *c.Channels.Telegram.BotToken

View file

@ -10,6 +10,11 @@ import (
"github.com/sipeed/picoclaw/pkg/migrate/internal" "github.com/sipeed/picoclaw/pkg/migrate/internal"
) )
// OpenclawHomeEnvVar is the environment variable that overrides the source
// openclaw home directory when migrating from openclaw to picoclaw.
// Default: ~/.openclaw
const OpenclawHomeEnvVar = "OPENCLAW_HOME"
var providerMapping = map[string]string{ var providerMapping = map[string]string{
"anthropic": "anthropic", "anthropic": "anthropic",
"claude": "anthropic", "claude": "anthropic",
@ -112,7 +117,7 @@ func resolveSourceHome(override string) (string, error) {
if override != "" { if override != "" {
return internal.ExpandHome(override), nil return internal.ExpandHome(override), nil
} }
if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { if envHome := os.Getenv(OpenclawHomeEnvVar); envHome != "" {
return internal.ExpandHome(envHome), nil return internal.ExpandHome(envHome), nil
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()

View file

@ -180,6 +180,10 @@ func buildParams(
blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
} }
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
// Skip tool calls with empty names to avoid API errors
if tc.Name == "" {
continue
}
args := tc.Arguments args := tc.Arguments
if args == nil && tc.Function != nil && tc.Function.Arguments != "" { if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {

View file

@ -8,6 +8,11 @@ import (
"time" "time"
) )
// CodexHomeEnvVar is the environment variable that overrides the Codex CLI
// home directory when resolving the codex auth.json credentials file.
// Default: ~/.codex
const CodexHomeEnvVar = "CODEX_HOME"
// CodexCliAuth represents the ~/.codex/auth.json file structure. // CodexCliAuth represents the ~/.codex/auth.json file structure.
type CodexCliAuth struct { type CodexCliAuth struct {
Tokens struct { Tokens struct {
@ -69,7 +74,7 @@ func CreateCodexCliTokenSource() func() (string, string, error) {
} }
func resolveCodexAuthPath() (string, error) { func resolveCodexAuthPath() (string, error) {
codexHome := os.Getenv("CODEX_HOME") codexHome := os.Getenv(CodexHomeEnvVar)
if codexHome == "" { if codexHome == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {

View file

@ -55,8 +55,8 @@ func ExtractProtocol(model string) (protocol, modelID string) {
// CreateProviderFromConfig creates a provider based on the ModelConfig. // CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create. // It uses the protocol prefix in the Model field to determine which provider to create.
// Supported protocols: openai, litellm, anthropic, anthropic-messages, antigravity, // Supported protocols: openai, litellm, novita, anthropic, anthropic-messages,
// claude-cli, codex-cli, qwen-cli, github-copilot // antigravity, claude-cli, codex-cli, qwen-cli, github-copilot
// Returns the provider, the model ID (without protocol prefix), and any error. // Returns the provider, the model ID (without protocol prefix), and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil { if cfg == nil {
@ -116,7 +116,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian",
"minimax", "longcat", "modelscope": "minimax", "longcat", "modelscope", "novita":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" { if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@ -226,6 +226,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://openrouter.ai/api/v1" return "https://openrouter.ai/api/v1"
case "litellm": case "litellm":
return "http://localhost:4000/v1" return "http://localhost:4000/v1"
case "novita":
return "https://api.novita.ai/openai"
case "groq": case "groq":
return "https://api.groq.com/openai/v1" return "https://api.groq.com/openai/v1"
case "zhipu": case "zhipu":

View file

@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
}{ }{
{"openai", "openai"}, {"openai", "openai"},
{"groq", "groq"}, {"groq", "groq"},
{"novita", "novita"},
{"openrouter", "openrouter"}, {"openrouter", "openrouter"},
{"cerebras", "cerebras"}, {"cerebras", "cerebras"},
{"vivgrid", "vivgrid"}, {"vivgrid", "vivgrid"},
@ -222,6 +223,34 @@ func TestGetDefaultAPIBase_ModelScope(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_Novita(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-novita",
Model: "novita/deepseek/deepseek-v3.2",
APIKey: "test-key",
}
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "deepseek/deepseek-v3.2" {
t.Errorf("modelID = %q, want %q", modelID, "deepseek/deepseek-v3.2")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestGetDefaultAPIBase_Novita(t *testing.T) {
if got := getDefaultAPIBase("novita"); got != "https://api.novita.ai/openai" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "novita", got, "https://api.novita.ai/openai")
}
}
func TestCreateProviderFromConfig_Anthropic(t *testing.T) { func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-anthropic", ModelName: "test-anthropic",

View file

@ -191,7 +191,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(before) prefix := strings.ToLower(before)
switch prefix { switch prefix {
case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google",
"openrouter", "zhipu", "mistral", "vivgrid", "minimax": "openrouter", "zhipu", "mistral", "vivgrid", "minimax", "novita":
return after return after
default: default:
return model return model

View file

@ -432,7 +432,28 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin
} }
} }
func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { func TestProviderChat_StripsGroqOllamaDeepseekVivgridNovitaPrefixes(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
tests := []struct { tests := []struct {
name string name string
input string input string
@ -463,31 +484,25 @@ func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) {
input: "vivgrid/auto", input: "vivgrid/auto",
wantModel: "auto", wantModel: "auto",
}, },
{
name: "strips novita prefix deepseek model",
input: "novita/deepseek/deepseek-v3.2",
wantModel: "deepseek/deepseek-v3.2",
},
{
name: "strips novita prefix zai model",
input: "novita/zai-org/glm-5",
wantModel: "zai-org/glm-5",
},
{
name: "strips novita prefix minimax model",
input: "novita/minimax/minimax-m2.5",
wantModel: "minimax/minimax-m2.5",
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var requestBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
resp := map[string]any{
"choices": []map[string]any{
{
"message": map[string]any{"content": "ok"},
"finish_reason": "stop",
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
p := NewProvider("key", server.URL, "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
if err != nil { if err != nil {
t.Fatalf("Chat() error = %v", err) t.Fatalf("Chat() error = %v", err)
@ -573,6 +588,12 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" {
t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto")
} }
if got := normalizeModel(
"novita/deepseek/deepseek-v3.2",
"https://api.novita.ai/openai",
); got != "deepseek/deepseek-v3.2" {
t.Fatalf("normalizeModel(novita) = %q, want %q", got, "deepseek/deepseek-v3.2")
}
} }
func TestProvider_RequestTimeoutDefault(t *testing.T) { func TestProvider_RequestTimeoutDefault(t *testing.T) {

View file

@ -80,8 +80,6 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF'
<true/> <true/>
<key>LSUIElement</key> <key>LSUIElement</key>
<string>1</string> <string>1</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict> </dict>
</plist> </plist>
EOF EOF

View file

@ -92,5 +92,5 @@ lint:
# Clean build artifacts # Clean build artifacts
clean: clean:
rm -rf frontend/dist backend/dist $(BUILD_DIR)/* rm -rf frontend/dist backend/dist $(BUILD_DIR)
mkdir -p backend/dist && touch backend/dist/.gitkeep mkdir -p backend/dist && touch backend/dist/.gitkeep

View file

@ -387,10 +387,10 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// GetConfigPath() already reads, so the gateway sub-process uses the same // GetConfigPath() already reads, so the gateway sub-process uses the same
// config file without requiring a --config flag on the gateway subcommand. // config file without requiring a --config flag on the gateway subcommand.
if h.configPath != "" { if h.configPath != "" {
cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath) cmd.Env = append(cmd.Env, config.EnvConfig+"="+h.configPath)
} }
if host := h.gatewayHostOverride(); host != "" { if host := h.gatewayHostOverride(); host != "" {
cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host) cmd.Env = append(cmd.Env, config.EnvGatewayHost+"="+host)
} }
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
@ -566,6 +566,8 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
} }
// handleGatewayStop stops the running gateway subprocess gracefully. // handleGatewayStop stops the running gateway subprocess gracefully.
// Note: Unlike StopGateway (which only stops self-started processes), this API endpoint
// stops any gateway process, including attached ones. This is intentional for user control.
// //
// POST /api/gateway/stop // POST /api/gateway/stop
func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {

View file

@ -309,7 +309,7 @@ func loadSkillContent(path string) (string, error) {
} }
func globalConfigDir() string { func globalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return home return home
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@ -320,7 +320,7 @@ func globalConfigDir() string {
} }
func builtinSkillsDir() string { func builtinSkillsDir() string {
if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" { if path := os.Getenv(config.EnvBuiltinSkills); path != "" {
return path return path
} }
wd, err := os.Getwd() wd, err := os.Getwd()

View file

@ -81,6 +81,7 @@ func main() {
logPath := filepath.Join(picoHome, "logs", "web.log") logPath := filepath.Join(picoHome, "logs", "web.log")
if err := logger.EnableFileLogging(logPath); err != nil { if err := logger.EnableFileLogging(logPath); err != nil {
// FIXME: https://github.com/sipeed/picoclaw/issues/1734
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err) fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1) os.Exit(1)
} }

View file

@ -5,6 +5,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/config"
) )
var execCommand = exec.Command var execCommand = exec.Command
@ -19,7 +21,7 @@ func EnsureOnboarded(configPath string) error {
} }
cmd := execCommand(FindPicoclawBinary(), "onboard") cmd := execCommand(FindPicoclawBinary(), "onboard")
cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath) cmd.Env = append(os.Environ(), config.EnvConfig+"="+configPath)
cmd.Stdin = strings.NewReader("n\n") cmd.Stdin = strings.NewReader("n\n")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()

View file

@ -7,20 +7,23 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"github.com/sipeed/picoclaw/pkg/config"
) )
// GetPicoclawHome returns the picoclaw home directory. // GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string { func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv(config.EnvHome); home != "" {
return home return home
} }
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw") return filepath.Join(home, ".picoclaw")
} }
// GetDefaultConfigPath returns the default path to the picoclaw config file.
func GetDefaultConfigPath() string { func GetDefaultConfigPath() string {
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { if configPath := os.Getenv(config.EnvConfig); configPath != "" {
return configPath return configPath
} }
return filepath.Join(GetPicoclawHome(), "config.json") return filepath.Join(GetPicoclawHome(), "config.json")
@ -37,7 +40,7 @@ func FindPicoclawBinary() string {
binaryName = "picoclaw.exe" binaryName = "picoclaw.exe"
} }
if p := os.Getenv("PICOCLAW_BINARY"); p != "" { if p := os.Getenv(config.EnvBinary); p != "" {
if info, _ := os.Stat(p); info != nil && !info.IsDir() { if info, _ := os.Stat(p); info != nil && !info.IsDir() {
return p return p
} }