feat: Add Qwen CLI provider support and merge upstream
This commit is contained in:
commit
9dd2d9f069
36 changed files with 793 additions and 195 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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 |
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
52
pkg/channels/feishu/token_cache.go
Normal file
52
pkg/channels/feishu/token_cache.go
Normal 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)
|
||||||
|
}
|
||||||
197
pkg/channels/telegram/parse_markdown_to_md_v2.go
Normal file
197
pkg/channels/telegram/parse_markdown_to_md_v2.go
Normal 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()
|
||||||
|
}
|
||||||
68
pkg/channels/telegram/parse_markdown_to_md_v2_test.go
Normal file
68
pkg/channels/telegram/parse_markdown_to_md_v2_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
111
pkg/channels/telegram/parser_markdown_to_html.go
Normal file
111
pkg/channels/telegram/parser_markdown_to_html.go
Normal 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, "&", "&")
|
||||||
|
text = strings.ReplaceAll(text, "<", "<")
|
||||||
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
@ -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, "&", "&")
|
|
||||||
text = strings.ReplaceAll(text, "<", "<")
|
|
||||||
text = strings.ReplaceAll(text, ">", ">")
|
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
pkg/channels/telegram/testdata/md2_all_formats.txt
vendored
Normal file
31
pkg/channels/telegram/testdata/md2_all_formats.txt
vendored
Normal 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)
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
`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||
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
37
pkg/config/envkeys.go
Normal 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"
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue