chore: initial commit
This commit is contained in:
86
README.md
Normal file
86
README.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# LaodingBot (MVP)
|
||||||
|
|
||||||
|
Go-based personal Telegram Agent with:
|
||||||
|
|
||||||
|
- Telegram polling transport
|
||||||
|
- OpenAI-compatible LLM client
|
||||||
|
- SQLite conversation memory + simple compression
|
||||||
|
- Tool registry with built-in `file` and `shell` tools
|
||||||
|
- Default-deny security policy via allowlists
|
||||||
|
- Soul markdown loading for bot personality
|
||||||
|
- Skills markdown loading for capability context
|
||||||
|
- ReAct decision loop with automatic tool execution
|
||||||
|
|
||||||
|
Now supports mutually exclusive message channels:
|
||||||
|
|
||||||
|
- `telegram` (long polling)
|
||||||
|
- `feishu` (official SDK websocket long connection)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Prepare env variables (see `configs/env.sample`).
|
||||||
|
- The app auto-loads `configs/env` (or `.env`) if present.
|
||||||
|
- You can also set `CONFIG_ENV_FILE=/path/to/env`.
|
||||||
|
- Process environment variables override file values.
|
||||||
|
2. Choose exactly one channel with `MESSAGE_CHANNEL=telegram|feishu`.
|
||||||
|
- If `telegram`: set `TELEGRAM_BOT_TOKEN`, keep `FEISHU_*` empty.
|
||||||
|
- If `feishu`: set `FEISHU_APP_ID` and `FEISHU_APP_SECRET`, keep `TELEGRAM_BOT_TOKEN` empty.
|
||||||
|
3. Set log level with `LOG_LEVEL=debug|info|warn|error`.
|
||||||
|
4. Configure knowledge and reasoning:
|
||||||
|
- `SOUL_PATH` for bot personality markdown.
|
||||||
|
- `SKILLS_DIR` for skills markdown directory.
|
||||||
|
- `REACT_MAX_STEPS` for maximum ReAct steps.
|
||||||
|
5. Create runtime directories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
go run ./cmd/bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram Usage
|
||||||
|
|
||||||
|
- Normal text: forwarded to LLM with compressed recent memory.
|
||||||
|
- Agent uses ReAct loop and may call tools automatically before final answer.
|
||||||
|
- Tool call command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/tool <name> <input>
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/tool shell pwd
|
||||||
|
/tool file read ./workspace/note.txt
|
||||||
|
/tool file write ./workspace/note.txt
|
||||||
|
hello world
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feishu Usage
|
||||||
|
|
||||||
|
- Bot uses Feishu official SDK long connection (`ws`) to subscribe `im.message.receive_v1` text events.
|
||||||
|
- Received text is forwarded to the same agent pipeline and replied back to the same chat.
|
||||||
|
|
||||||
|
## Knowledge Files
|
||||||
|
|
||||||
|
- Soul file default path: `bot_context/soul.md`
|
||||||
|
- Skills directory default path: `skills/`
|
||||||
|
- Add new markdown files into `skills/` to describe capabilities; they are loaded at startup.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- `shell` only allows commands listed in `ALLOWED_COMMANDS`.
|
||||||
|
- `file` only allows paths inside `ALLOWED_DIRS`.
|
||||||
|
- Working directory for shell is limited by `WORK_DIR`.
|
||||||
|
|
||||||
|
## Next Iteration
|
||||||
|
|
||||||
|
- Add skill runtime (process-level hot-plug via RPC)
|
||||||
|
- Add bootstrap pipeline (generate -> vet/test -> sandbox run -> register)
|
||||||
|
- Add approval gate for risky commands
|
||||||
13
bot_context/soul.md
Normal file
13
bot_context/soul.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
你是 LaodingBot,一名可靠、务实、有温度的个人 AI 助手。
|
||||||
|
|
||||||
|
人格与语气:
|
||||||
|
- 先给结论,再给关键依据。
|
||||||
|
- 语气简洁直接,但保持礼貌。
|
||||||
|
- 遇到不确定时明确说明不确定点,并给出下一步可执行动作。
|
||||||
|
- 优先执行、验证、反馈,不空谈。
|
||||||
|
- 对用户目标保持主动:能用工具验证就不用猜。
|
||||||
|
|
||||||
|
行为原则:
|
||||||
|
- 涉及文件、目录、命令时,优先调用工具获取真实结果。
|
||||||
|
- 结果回复要结构清晰,包含必要的风险提示。
|
||||||
|
- 如果任务失败,给出失败原因和最短修复路径。
|
||||||
111
cmd/bot/main.go
Normal file
111
cmd/bot/main.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/agent"
|
||||||
|
"laodingbot/internal/config"
|
||||||
|
"laodingbot/internal/knowledge"
|
||||||
|
"laodingbot/internal/llm"
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
"laodingbot/internal/memory"
|
||||||
|
"laodingbot/internal/tools"
|
||||||
|
"laodingbot/internal/tools/filetool"
|
||||||
|
"laodingbot/internal/tools/shelltool"
|
||||||
|
"laodingbot/internal/transport/feishu"
|
||||||
|
"laodingbot/internal/transport/telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("load config failed: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger, err := logger.New(cfg.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("init logger failed: %v", err))
|
||||||
|
}
|
||||||
|
appLogger = appLogger.WithComponent("main")
|
||||||
|
appLogger.Infof("config loaded; channel=%s, log_level=%s", cfg.MessageChannel, cfg.LogLevel)
|
||||||
|
|
||||||
|
store, err := memory.NewSQLiteStore(cfg.SQLitePath, appLogger.WithComponent("memory"))
|
||||||
|
if err != nil {
|
||||||
|
appLogger.Errorf("init memory store failed: %v", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
toolRegistry := tools.NewRegistry(appLogger.WithComponent("tools.registry"))
|
||||||
|
toolRegistry.Register(filetool.New(cfg.Security.AllowedDirs, appLogger.WithComponent("tools.file")))
|
||||||
|
toolRegistry.Register(shelltool.New(cfg.Security.AllowedCommands, cfg.Security.WorkDir, 15*time.Second, appLogger.WithComponent("tools.shell")))
|
||||||
|
|
||||||
|
soul, err := knowledge.LoadSoul(cfg.SoulPath)
|
||||||
|
if err != nil {
|
||||||
|
appLogger.Errorf("load soul failed path=%s err=%v", cfg.SoulPath, err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
skillsDoc, err := knowledge.LoadSkills(cfg.SkillsDir)
|
||||||
|
if err != nil {
|
||||||
|
appLogger.Errorf("load skills failed dir=%s err=%v", cfg.SkillsDir, err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
appLogger.Infof("knowledge loaded soul_path=%s skills_dir=%s", cfg.SoulPath, cfg.SkillsDir)
|
||||||
|
|
||||||
|
llmClient := llm.NewOpenAICompatibleClient(cfg.LLM, appLogger.WithComponent("llm"))
|
||||||
|
engine := agent.NewOrchestrator(
|
||||||
|
llmClient,
|
||||||
|
store,
|
||||||
|
toolRegistry,
|
||||||
|
soul,
|
||||||
|
skillsDoc,
|
||||||
|
cfg.ReactMaxSteps,
|
||||||
|
appLogger.WithComponent("agent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
appLogger.Infof("LaodingBot started, channel=%s", cfg.MessageChannel)
|
||||||
|
if err := runMessageChannel(ctx, cfg, engine, appLogger); err != nil && ctx.Err() == nil {
|
||||||
|
appLogger.Errorf("message channel run failed: %v", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
appLogger.Infof("LaodingBot stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMessageChannel(ctx context.Context, cfg config.Config, engine *agent.Orchestrator, lg *logger.Logger) error {
|
||||||
|
switch cfg.MessageChannel {
|
||||||
|
case "telegram":
|
||||||
|
tg, err := telegram.NewBot(cfg.Telegram.Token, cfg.Telegram.PollTimeoutSeconds, lg.WithComponent("transport.telegram"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init telegram bot failed: %w", err)
|
||||||
|
}
|
||||||
|
lg.Infof("starting telegram transport")
|
||||||
|
return tg.Run(ctx, func(ctx context.Context, msg telegram.IncomingMessage) (string, error) {
|
||||||
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
|
})
|
||||||
|
case "feishu":
|
||||||
|
fs, err := feishu.NewBot(
|
||||||
|
cfg.Feishu.AppID,
|
||||||
|
cfg.Feishu.AppSecret,
|
||||||
|
cfg.Feishu.VerifyToken,
|
||||||
|
cfg.Feishu.ListenAddr,
|
||||||
|
cfg.Feishu.EventPath,
|
||||||
|
lg.WithComponent("transport.feishu"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init feishu bot failed: %w", err)
|
||||||
|
}
|
||||||
|
lg.Infof("starting feishu transport")
|
||||||
|
return fs.Run(ctx, func(ctx context.Context, msg feishu.IncomingMessage) (string, error) {
|
||||||
|
return engine.HandleMessage(ctx, msg.ChatID, msg.UserID, msg.Text)
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported message channel: %s", cfg.MessageChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
configs/env.sample
Normal file
21
configs/env.sample
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MESSAGE_CHANNEL=telegram
|
||||||
|
LOG_LEVEL=info
|
||||||
|
SOUL_PATH=./bot_context/soul.md
|
||||||
|
SKILLS_DIR=./skills
|
||||||
|
REACT_MAX_STEPS=4
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_POLL_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
FEISHU_APP_ID=
|
||||||
|
FEISHU_APP_SECRET=
|
||||||
|
FEISHU_VERIFY_TOKEN=
|
||||||
|
|
||||||
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
|
LLM_API_KEY=
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
SQLITE_PATH=./data/laodingbot.db
|
||||||
|
ALLOWED_DIRS=./workspace,./data
|
||||||
|
ALLOWED_COMMANDS=pwd,ls,cat,echo,grep,find,head,tail
|
||||||
|
WORK_DIR=./workspace
|
||||||
BIN
data/laodingbot.db
Normal file
BIN
data/laodingbot.db
Normal file
Binary file not shown.
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
module laodingbot
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
78
go.sum
Normal file
78
go.sum
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||||
|
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||||
|
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
244
internal/agent/orchestrator.go
Normal file
244
internal/agent/orchestrator.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"laodingbot/internal/llm"
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
"laodingbot/internal/memory"
|
||||||
|
"laodingbot/internal/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Orchestrator struct {
|
||||||
|
llm llm.Client
|
||||||
|
store *memory.SQLiteStore
|
||||||
|
tools *tools.Registry
|
||||||
|
soul string
|
||||||
|
skillsDoc string
|
||||||
|
reactMaxStep int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(
|
||||||
|
llmClient llm.Client,
|
||||||
|
store *memory.SQLiteStore,
|
||||||
|
registry *tools.Registry,
|
||||||
|
soul string,
|
||||||
|
skillsDoc string,
|
||||||
|
reactMaxStep int,
|
||||||
|
log *logger.Logger,
|
||||||
|
) *Orchestrator {
|
||||||
|
if reactMaxStep <= 0 {
|
||||||
|
reactMaxStep = 4
|
||||||
|
}
|
||||||
|
return &Orchestrator{
|
||||||
|
llm: llmClient,
|
||||||
|
store: store,
|
||||||
|
tools: registry,
|
||||||
|
soul: soul,
|
||||||
|
skillsDoc: skillsDoc,
|
||||||
|
reactMaxStep: reactMaxStep,
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) HandleMessage(ctx context.Context, chatID, userID, text string) (string, error) {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("handle message chat_id=%s user_id=%s text_len=%d", chatID, userID, len(text))
|
||||||
|
}
|
||||||
|
if err := o.store.SaveMessage(chatID, userID, "user", text); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("save user message failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(text), "/tool ") {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("detected tool command chat_id=%s", chatID)
|
||||||
|
}
|
||||||
|
response, err := o.handleToolCommand(ctx, strings.TrimSpace(strings.TrimPrefix(text, "/tool ")))
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("tool command failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("save assistant tool response failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("tool command success chat_id=%s response_len=%d", chatID, len(response))
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
recent, err := o.store.LoadRecent(chatID, 16)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("load recent failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
compressed := memory.CompressForPrompt(recent, 6000)
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("prompt context prepared chat_id=%s recent_count=%d compressed_len=%d", chatID, len(recent), len(compressed))
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := o.runReAct(ctx, compressed, text)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("llm generate failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.store.SaveMessage(chatID, userID, "assistant", response); err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Errorf("save assistant response failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Infof("message handled chat_id=%s response_len=%d", chatID, len(response))
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type reactDecision struct {
|
||||||
|
Thought string `json:"thought"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ActionInput string `json:"action_input"`
|
||||||
|
Final string `json:"final"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) runReAct(ctx context.Context, compressedContext, userInput string) (string, error) {
|
||||||
|
systemPrompt := strings.Join([]string{
|
||||||
|
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
|
||||||
|
o.soul,
|
||||||
|
"",
|
||||||
|
"当前可用 skills 文档:",
|
||||||
|
o.skillsDoc,
|
||||||
|
"",
|
||||||
|
"你必须使用 ReAct 模式做决策。",
|
||||||
|
"如果问题需要外部信息(如文件系统、目录内容、命令执行),优先通过工具获取证据再回答。",
|
||||||
|
"当用户询问目录中文件时,应优先使用 shell 工具(例如 ls/find)。",
|
||||||
|
"你的输出必须是 JSON,对象字段为 thought, action, action_input, final。",
|
||||||
|
"规则:",
|
||||||
|
"1) 当需要调工具时:final 置空,action 为 shell 或 file,action_input 为工具输入。",
|
||||||
|
"2) 当可以最终回答时:action 置 none,action_input 置空,final 填最终回复。",
|
||||||
|
"3) 不要输出 JSON 之外内容。",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
scratchpad := ""
|
||||||
|
for step := 1; step <= o.reactMaxStep; step++ {
|
||||||
|
prompt := strings.Join([]string{
|
||||||
|
"历史上下文:",
|
||||||
|
compressedContext,
|
||||||
|
"",
|
||||||
|
"用户问题:",
|
||||||
|
userInput,
|
||||||
|
"",
|
||||||
|
"当前推理记录(按时间顺序):",
|
||||||
|
scratchpad,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("请输出下一步 JSON 决策。当前步骤: %d/%d", step, o.reactMaxStep),
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
raw, err := o.llm.Generate(ctx, systemPrompt, prompt)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
decision, err := parseDecision(raw)
|
||||||
|
if err != nil {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("react parse failed, use raw as final err=%v", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
action := strings.ToLower(strings.TrimSpace(decision.Action))
|
||||||
|
if action == "" {
|
||||||
|
action = "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "none" {
|
||||||
|
finalText := strings.TrimSpace(decision.Final)
|
||||||
|
if finalText == "" {
|
||||||
|
finalText = "我已完成思考,但当前没有足够信息给出稳定结论。"
|
||||||
|
}
|
||||||
|
return finalText, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tool, ok := o.tools.Get(action)
|
||||||
|
if !ok {
|
||||||
|
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Observation: tool %s 不存在\n", step, decision.Thought, step, action)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
toolOut, toolErr := tool.Call(ctx, decision.ActionInput)
|
||||||
|
obs := strings.TrimSpace(toolOut)
|
||||||
|
if obs == "" {
|
||||||
|
obs = "(empty output)"
|
||||||
|
}
|
||||||
|
if toolErr != nil {
|
||||||
|
obs = obs + "\nERROR: " + toolErr.Error()
|
||||||
|
}
|
||||||
|
if len(obs) > 2000 {
|
||||||
|
obs = obs[:2000]
|
||||||
|
}
|
||||||
|
scratchpad += fmt.Sprintf("Step %d Thought: %s\nStep %d Action: %s\nStep %d ActionInput: %s\nStep %d Observation: %s\n", step, decision.Thought, step, action, step, decision.ActionInput, step, obs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "我尝试了多轮思考与工具调用,但仍未得到稳定结论。请给我更具体的约束或允许我继续尝试。", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDecision(raw string) (reactDecision, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
start := strings.Index(raw, "{")
|
||||||
|
end := strings.LastIndex(raw, "}")
|
||||||
|
if start < 0 || end < start {
|
||||||
|
return reactDecision{}, fmt.Errorf("no json object found")
|
||||||
|
}
|
||||||
|
raw = raw[start : end+1]
|
||||||
|
|
||||||
|
var out reactDecision
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||||
|
return reactDecision{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) handleToolCommand(ctx context.Context, payload string) (string, error) {
|
||||||
|
parts := strings.SplitN(payload, " ", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("invalid tool command payload=%q", payload)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("tool command format: /tool <name> <input>")
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(parts[0])
|
||||||
|
input := parts[1]
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Debugf("dispatch tool name=%s input_len=%d", name, len(input))
|
||||||
|
}
|
||||||
|
t, ok := o.tools.Get(name)
|
||||||
|
if !ok {
|
||||||
|
if o.log != nil {
|
||||||
|
o.log.Warnf("unknown tool requested name=%s", name)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unknown tool: %s", name)
|
||||||
|
}
|
||||||
|
return t.Call(ctx, input)
|
||||||
|
}
|
||||||
215
internal/config/config.go
Normal file
215
internal/config/config.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
MessageChannel string
|
||||||
|
LogLevel string
|
||||||
|
SoulPath string
|
||||||
|
SkillsDir string
|
||||||
|
ReactMaxSteps int
|
||||||
|
|
||||||
|
Telegram TelegramConfig
|
||||||
|
Feishu FeishuConfig
|
||||||
|
LLM LLMConfig
|
||||||
|
Security SecurityConfig
|
||||||
|
|
||||||
|
SQLitePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TelegramConfig struct {
|
||||||
|
Token string
|
||||||
|
PollTimeoutSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeishuConfig struct {
|
||||||
|
AppID string
|
||||||
|
AppSecret string
|
||||||
|
VerifyToken string
|
||||||
|
ListenAddr string
|
||||||
|
EventPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMConfig struct {
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
Model string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SecurityConfig struct {
|
||||||
|
AllowedDirs []string
|
||||||
|
AllowedCommands []string
|
||||||
|
WorkDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (Config, error) {
|
||||||
|
if err := preloadEnvFiles(); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"),
|
||||||
|
LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"),
|
||||||
|
SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), "./bot_context/soul.md"),
|
||||||
|
SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), "./skills"),
|
||||||
|
ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 4),
|
||||||
|
Telegram: TelegramConfig{
|
||||||
|
Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")),
|
||||||
|
PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30),
|
||||||
|
},
|
||||||
|
Feishu: FeishuConfig{
|
||||||
|
AppID: strings.TrimSpace(os.Getenv("FEISHU_APP_ID")),
|
||||||
|
AppSecret: strings.TrimSpace(os.Getenv("FEISHU_APP_SECRET")),
|
||||||
|
VerifyToken: strings.TrimSpace(os.Getenv("FEISHU_VERIFY_TOKEN")),
|
||||||
|
ListenAddr: defaultIfEmpty(os.Getenv("FEISHU_LISTEN_ADDR"), ":8080"),
|
||||||
|
EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"),
|
||||||
|
},
|
||||||
|
LLM: LLMConfig{
|
||||||
|
BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"),
|
||||||
|
APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")),
|
||||||
|
Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"),
|
||||||
|
},
|
||||||
|
SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), "./data/laodingbot.db"),
|
||||||
|
Security: SecurityConfig{
|
||||||
|
AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), "./workspace,./data")),
|
||||||
|
AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail")),
|
||||||
|
WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), "./workspace"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel))
|
||||||
|
cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel))
|
||||||
|
if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" {
|
||||||
|
return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram or feishu")
|
||||||
|
}
|
||||||
|
if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" {
|
||||||
|
return Config{}, fmt.Errorf("LOG_LEVEL must be debug, info, warn, or error")
|
||||||
|
}
|
||||||
|
if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 {
|
||||||
|
return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MessageChannel == "telegram" {
|
||||||
|
if cfg.Telegram.Token == "" {
|
||||||
|
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required when MESSAGE_CHANNEL=telegram")
|
||||||
|
}
|
||||||
|
if cfg.Feishu.AppID != "" || cfg.Feishu.AppSecret != "" {
|
||||||
|
return Config{}, fmt.Errorf("feishu config must be empty when MESSAGE_CHANNEL=telegram")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.MessageChannel == "feishu" {
|
||||||
|
if cfg.Feishu.AppID == "" || cfg.Feishu.AppSecret == "" {
|
||||||
|
return Config{}, fmt.Errorf("FEISHU_APP_ID and FEISHU_APP_SECRET are required when MESSAGE_CHANNEL=feishu")
|
||||||
|
}
|
||||||
|
if cfg.Telegram.Token != "" {
|
||||||
|
return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN must be empty when MESSAGE_CHANNEL=feishu")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.LLM.APIKey == "" {
|
||||||
|
return Config{}, fmt.Errorf("LLM_API_KEY is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preloadEnvFiles() error {
|
||||||
|
paths := []string{}
|
||||||
|
if explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")); explicit != "" {
|
||||||
|
paths = append(paths, explicit)
|
||||||
|
}
|
||||||
|
paths = append(paths, "configs/env", ".env")
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := loadEnvFile(p); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("load env file %s failed: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEnvFile(path string) error {
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
absPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimPrefix(line, "export ")
|
||||||
|
idx := strings.Index(line, "=")
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(line[:idx])
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.TrimSpace(line[idx+1:])
|
||||||
|
if len(val) >= 2 {
|
||||||
|
if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') {
|
||||||
|
val = val[1 : len(val)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := os.LookupEnv(key); !exists {
|
||||||
|
if err := os.Setenv(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(v, d string) string {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func intFromEnv(name string, d int) int {
|
||||||
|
raw := strings.TrimSpace(os.Getenv(name))
|
||||||
|
if raw == "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
v, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(raw string) []string {
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
v := strings.TrimSpace(p)
|
||||||
|
if v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
63
internal/knowledge/loader.go
Normal file
63
internal/knowledge/loader.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package knowledge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadSoul(path string) (string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read soul file failed: %w", err)
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(string(b))
|
||||||
|
if content == "" {
|
||||||
|
return "", fmt.Errorf("soul file is empty")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadSkills(dir string) (string, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read skills dir failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if strings.HasSuffix(strings.ToLower(name), ".md") {
|
||||||
|
files = append(files, filepath.Join(dir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
builder := strings.Builder{}
|
||||||
|
for _, file := range files {
|
||||||
|
b, err := os.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read skill file failed: %w", err)
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(string(b))
|
||||||
|
if content == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builder.WriteString("## ")
|
||||||
|
builder.WriteString(filepath.Base(file))
|
||||||
|
builder.WriteString("\n")
|
||||||
|
builder.WriteString(content)
|
||||||
|
builder.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
out := strings.TrimSpace(builder.String())
|
||||||
|
if out == "" {
|
||||||
|
return "", fmt.Errorf("no non-empty markdown skills loaded from %s", dir)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
134
internal/llm/client.go
Normal file
134
internal/llm/client.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/config"
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenAICompatibleClient struct {
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
model string
|
||||||
|
http *http.Client
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOpenAICompatibleClient(cfg config.LLMConfig, log *logger.Logger) *OpenAICompatibleClient {
|
||||||
|
return &OpenAICompatibleClient{
|
||||||
|
baseURL: cfg.BaseURL,
|
||||||
|
apiKey: cfg.APIKey,
|
||||||
|
model: cfg.Model,
|
||||||
|
http: &http.Client{Timeout: 60 * time.Second},
|
||||||
|
log: log,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message chatMessage `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OpenAICompatibleClient) Generate(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Debugf("llm request start model=%s system_len=%d user_len=%d", c.model, len(systemPrompt), len(userPrompt))
|
||||||
|
}
|
||||||
|
body := chatRequest{
|
||||||
|
Model: c.model,
|
||||||
|
Messages: []chatMessage{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("marshal llm request failed err=%v", err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(c.baseURL, "/") + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("build llm request failed err=%v", err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("llm http request failed err=%v", err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("llm read response failed err=%v", err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out chatResponse
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("llm response unmarshal failed status=%d err=%v", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("llm bad status=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if out.Error != nil && out.Error.Message != "" {
|
||||||
|
return "", fmt.Errorf("llm error: %s", out.Error.Message)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("llm error status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out.Choices) == 0 {
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Errorf("llm returned empty choices status=%d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("llm returned empty choices")
|
||||||
|
}
|
||||||
|
if c.log != nil {
|
||||||
|
c.log.Infof("llm response success model=%s output_len=%d", c.model, len(out.Choices[0].Message.Content))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
89
internal/logger/logger.go
Normal file
89
internal/logger/logger.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelDebug Level = iota
|
||||||
|
LevelInfo
|
||||||
|
LevelWarn
|
||||||
|
LevelError
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
base *log.Logger
|
||||||
|
level Level
|
||||||
|
component string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(level string) (*Logger, error) {
|
||||||
|
parsed, err := ParseLevel(level)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Logger{
|
||||||
|
base: log.New(os.Stdout, "", log.LstdFlags|log.Lmicroseconds),
|
||||||
|
level: parsed,
|
||||||
|
component: "app",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseLevel(raw string) (Level, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||||
|
case "debug":
|
||||||
|
return LevelDebug, nil
|
||||||
|
case "info", "":
|
||||||
|
return LevelInfo, nil
|
||||||
|
case "warn", "warning":
|
||||||
|
return LevelWarn, nil
|
||||||
|
case "error":
|
||||||
|
return LevelError, nil
|
||||||
|
default:
|
||||||
|
return LevelInfo, fmt.Errorf("invalid LOG_LEVEL=%q, expected debug|info|warn|error", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) WithComponent(component string) *Logger {
|
||||||
|
if component == "" {
|
||||||
|
component = "app"
|
||||||
|
}
|
||||||
|
return &Logger{
|
||||||
|
base: l.base,
|
||||||
|
level: l.level,
|
||||||
|
component: component,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Level() Level {
|
||||||
|
return l.level
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Debugf(format string, args ...any) {
|
||||||
|
l.logf(LevelDebug, "DEBUG", format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Infof(format string, args ...any) {
|
||||||
|
l.logf(LevelInfo, "INFO", format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warnf(format string, args ...any) {
|
||||||
|
l.logf(LevelWarn, "WARN", format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Errorf(format string, args ...any) {
|
||||||
|
l.logf(LevelError, "ERROR", format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) logf(level Level, label, format string, args ...any) {
|
||||||
|
if level < l.level {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
l.base.Printf("[%s] [%s] %s", label, l.component, msg)
|
||||||
|
}
|
||||||
19
internal/memory/compress.go
Normal file
19
internal/memory/compress.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func CompressForPrompt(messages []Message, maxChars int) string {
|
||||||
|
if maxChars <= 0 {
|
||||||
|
maxChars = 8000
|
||||||
|
}
|
||||||
|
|
||||||
|
builder := strings.Builder{}
|
||||||
|
for _, msg := range messages {
|
||||||
|
line := msg.Role + ": " + msg.Content + "\n"
|
||||||
|
if builder.Len()+len(line) > maxChars {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
builder.WriteString(line)
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
132
internal/memory/store_sqlite.go
Normal file
132
internal/memory/store_sqlite.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID int64
|
||||||
|
ChatID string
|
||||||
|
UserID string
|
||||||
|
Role string
|
||||||
|
Content string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SQLiteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSQLiteStore(path string, log *logger.Logger) (*SQLiteStore, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
store := &SQLiteStore{db: db, log: log}
|
||||||
|
if err := store.migrate(); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Infof("sqlite store initialized path=%s", abs)
|
||||||
|
}
|
||||||
|
return store, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) Close() error {
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) SaveMessage(chatID, userID, role, content string) error {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Debugf("save message chat_id=%s role=%s content_len=%d", chatID, role, len(content))
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
INSERT INTO messages(chat_id, user_id, role, content, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, chatID, userID, role, content, time.Now().UTC())
|
||||||
|
if err != nil && s.log != nil {
|
||||||
|
s.log.Errorf("save message failed chat_id=%s role=%s err=%v", chatID, role, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) LoadRecent(chatID string, limit int) ([]Message, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
SELECT id, chat_id, user_id, role, content, created_at
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
`, chatID, limit)
|
||||||
|
if err != nil {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Errorf("load recent query failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
messages := make([]Message, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
var m Message
|
||||||
|
if err := rows.Scan(&m.ID, &m.ChatID, &m.UserID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Errorf("load recent row iteration failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 {
|
||||||
|
messages[left], messages[right] = messages[right], messages[left]
|
||||||
|
}
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Debugf("load recent success chat_id=%s count=%d", chatID, len(messages))
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) migrate() error {
|
||||||
|
stmt := `
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chat_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_chat_id_id ON messages(chat_id, id);
|
||||||
|
`
|
||||||
|
if _, err := s.db.Exec(stmt); err != nil {
|
||||||
|
return fmt.Errorf("migrate schema: %w", err)
|
||||||
|
}
|
||||||
|
if s.log != nil {
|
||||||
|
s.log.Infof("sqlite schema migration completed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
111
internal/tools/filetool/filetool.go
Normal file
111
internal/tools/filetool/filetool.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package filetool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
allowedDirs []string
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(allowedDirs []string, log *logger.Logger) *Tool {
|
||||||
|
normalized := make([]string, 0, len(allowedDirs))
|
||||||
|
for _, dir := range allowedDirs {
|
||||||
|
abs, err := filepath.Abs(strings.TrimSpace(dir))
|
||||||
|
if err == nil {
|
||||||
|
normalized = append(normalized, filepath.Clean(abs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Infof("file tool initialized allowed_dirs=%d", len(normalized))
|
||||||
|
}
|
||||||
|
return &Tool{allowedDirs: normalized, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "file" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return "File operations with command format: read <path> | write <path>\\n<content>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(_ context.Context, input string) (string, error) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("file tool call input_len=%d", len(input))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(input, "read ") {
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(input, "read "))
|
||||||
|
resolved, err := t.resolveAllowed(path)
|
||||||
|
if err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Warnf("file read denied path=%s err=%v", path, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(resolved)
|
||||||
|
if err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("file read failed path=%s err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("file read success path=%s bytes=%d", resolved, len(b))
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(input, "write ") {
|
||||||
|
parts := strings.SplitN(input, "\n", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return "", fmt.Errorf("write requires content in second line")
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(parts[0], "write "))
|
||||||
|
resolved, err := t.resolveAllowed(path)
|
||||||
|
if err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Warnf("file write denied path=%s err=%v", path, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("file write mkdir failed path=%s err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(resolved, []byte(parts[1]), 0o644); err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("file write failed path=%s err=%v", resolved, err)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("file write success path=%s bytes=%d", resolved, len(parts[1]))
|
||||||
|
}
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("unsupported file command")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) resolveAllowed(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
abs = filepath.Clean(abs)
|
||||||
|
for _, allowed := range t.allowedDirs {
|
||||||
|
if strings.HasPrefix(abs, allowed+string(filepath.Separator)) || abs == allowed {
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("path not allowed: %s", path)
|
||||||
|
}
|
||||||
85
internal/tools/shelltool/shelltool.go
Normal file
85
internal/tools/shelltool/shelltool.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package shelltool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool struct {
|
||||||
|
allowedCommands map[string]struct{}
|
||||||
|
workDir string
|
||||||
|
timeout time.Duration
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(allowed []string, workDir string, timeout time.Duration, log *logger.Logger) *Tool {
|
||||||
|
set := make(map[string]struct{}, len(allowed))
|
||||||
|
for _, c := range allowed {
|
||||||
|
cmd := strings.TrimSpace(c)
|
||||||
|
if cmd != "" {
|
||||||
|
set[cmd] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
absDir, err := filepath.Abs(workDir)
|
||||||
|
if err != nil {
|
||||||
|
absDir = workDir
|
||||||
|
}
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 15 * time.Second
|
||||||
|
}
|
||||||
|
if log != nil {
|
||||||
|
log.Infof("shell tool initialized allowed_commands=%d work_dir=%s timeout=%s", len(set), absDir, timeout)
|
||||||
|
}
|
||||||
|
return &Tool{allowedCommands: set, workDir: absDir, timeout: timeout, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Name() string { return "shell" }
|
||||||
|
|
||||||
|
func (t *Tool) Description() string {
|
||||||
|
return "Execute allowlisted shell commands in Linux"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tool) Call(ctx context.Context, input string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(input)
|
||||||
|
if trimmed == "" {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Warnf("shell tool rejected empty command")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("empty command")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Fields(trimmed)
|
||||||
|
base := parts[0]
|
||||||
|
if _, ok := t.allowedCommands[base]; !ok {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Warnf("shell command denied command=%s", base)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("command not allowed: %s", base)
|
||||||
|
}
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Infof("shell command start command=%s args=%d", base, len(parts)-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, t.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, base, parts[1:]...)
|
||||||
|
cmd.Dir = t.workDir
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Errorf("shell command failed command=%s err=%v output_bytes=%d", base, err, len(out))
|
||||||
|
}
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
if t.log != nil {
|
||||||
|
t.log.Debugf("shell command success command=%s output_bytes=%d", base, len(out))
|
||||||
|
}
|
||||||
|
return string(out), nil
|
||||||
|
}
|
||||||
49
internal/tools/types.go
Normal file
49
internal/tools/types.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Call(ctx context.Context, input string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
tools map[string]Tool
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(log *logger.Logger) *Registry {
|
||||||
|
return &Registry{tools: make(map[string]Tool), log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Register(tool Tool) {
|
||||||
|
r.tools[tool.Name()] = tool
|
||||||
|
if r.log != nil {
|
||||||
|
r.log.Infof("registered tool name=%s", tool.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(name string) (Tool, bool) {
|
||||||
|
t, ok := r.tools[name]
|
||||||
|
if r.log != nil {
|
||||||
|
if ok {
|
||||||
|
r.log.Debugf("resolved tool name=%s", name)
|
||||||
|
} else {
|
||||||
|
r.log.Warnf("tool not found name=%s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) List() []Tool {
|
||||||
|
out := make([]Tool, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
237
internal/transport/feishu/bot.go
Normal file
237
internal/transport/feishu/bot.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package feishu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
|
||||||
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||||
|
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||||
|
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
appID string
|
||||||
|
appSecret string
|
||||||
|
verifyToken string
|
||||||
|
|
||||||
|
apiClient *lark.Client
|
||||||
|
log *logger.Logger
|
||||||
|
|
||||||
|
dedupTTL time.Duration
|
||||||
|
dedupMu sync.Mutex
|
||||||
|
dedupSeen map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingMessage struct {
|
||||||
|
MessageID string
|
||||||
|
ChatID string
|
||||||
|
UserID string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(appID, appSecret, verifyToken, _ string, _ string, log *logger.Logger) (*Bot, error) {
|
||||||
|
if appID == "" || appSecret == "" {
|
||||||
|
return nil, fmt.Errorf("empty feishu app credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Bot{
|
||||||
|
appID: appID,
|
||||||
|
appSecret: appSecret,
|
||||||
|
verifyToken: verifyToken,
|
||||||
|
apiClient: lark.NewClient(appID, appSecret,
|
||||||
|
lark.WithLogLevel(toLarkLogLevel(log)),
|
||||||
|
lark.WithReqTimeout(10*time.Second),
|
||||||
|
lark.WithEnableTokenCache(true),
|
||||||
|
),
|
||||||
|
log: log,
|
||||||
|
dedupTTL: 10 * time.Minute,
|
||||||
|
dedupSeen: make(map[string]time.Time),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Run(ctx context.Context, handler func(context.Context, IncomingMessage) (string, error)) error {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("feishu websocket transport started")
|
||||||
|
}
|
||||||
|
eventHandler := dispatcher.NewEventDispatcher(b.verifyToken, "").
|
||||||
|
OnP2MessageReceiveV1(func(evtCtx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
|
incoming, ok := parseIncoming(event)
|
||||||
|
if !ok {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Debugf("skip non-text or invalid feishu event")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !b.shouldProcessMessage(incoming.MessageID) {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Warnf("skip duplicated feishu message message_id=%s chat_id=%s", incoming.MessageID, incoming.ChatID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("feishu message received message_id=%s chat_id=%s user_id=%s text=%s", incoming.MessageID, incoming.ChatID, incoming.UserID, incoming.Text)
|
||||||
|
}
|
||||||
|
reply, err := handler(evtCtx, incoming)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("feishu handler failed chat_id=%s err=%v", incoming.ChatID, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(reply) == "" {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Debugf("feishu empty reply chat_id=%s", incoming.ChatID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.sendText(evtCtx, incoming.ChatID, reply)
|
||||||
|
})
|
||||||
|
|
||||||
|
wsClient := larkws.NewClient(
|
||||||
|
b.appID,
|
||||||
|
b.appSecret,
|
||||||
|
larkws.WithEventHandler(eventHandler),
|
||||||
|
larkws.WithLogLevel(toLarkLogLevel(b.log)),
|
||||||
|
)
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- wsClient.Start(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("feishu websocket transport stopped: %v", ctx.Err())
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
case err := <-errCh:
|
||||||
|
if err != nil && b.log != nil {
|
||||||
|
b.log.Errorf("feishu websocket transport failed err=%v", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendText(ctx context.Context, chatID, text string) error {
|
||||||
|
resp, err := b.apiClient.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder().
|
||||||
|
ReceiveIdType("chat_id").
|
||||||
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||||
|
ReceiveId(chatID).
|
||||||
|
MsgType("text").
|
||||||
|
Content(fmt.Sprintf(`{"text":%q}`, text)).
|
||||||
|
Uuid(fmt.Sprintf("%d", time.Now().UnixNano())).
|
||||||
|
Build()).
|
||||||
|
Build())
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("feishu send message request failed chat_id=%s err=%v", chatID, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.Success() {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Warnf("feishu send message unsuccessful chat_id=%s code=%d msg=%s", chatID, resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("feishu send message failed: code=%d msg=%s log_id=%s", resp.Code, resp.Msg, resp.LogId())
|
||||||
|
}
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Debugf("feishu message sent chat_id=%s text_len=%d", chatID, len(text))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractText(content string) (string, error) {
|
||||||
|
var parsed struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parsed.Text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIncoming(event *larkim.P2MessageReceiveV1) (IncomingMessage, bool) {
|
||||||
|
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
||||||
|
return IncomingMessage{}, false
|
||||||
|
}
|
||||||
|
if event.Event.Sender.SenderType != nil && *event.Event.Sender.SenderType != "user" {
|
||||||
|
return IncomingMessage{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := event.Event.Message
|
||||||
|
if msg.MessageType == nil || *msg.MessageType != "text" || msg.ChatId == nil || msg.Content == nil || msg.MessageId == nil {
|
||||||
|
return IncomingMessage{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
text, err := extractText(*msg.Content)
|
||||||
|
if err != nil {
|
||||||
|
return IncomingMessage{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := ""
|
||||||
|
if event.Event.Sender.SenderId.OpenId != nil {
|
||||||
|
userID = *event.Event.Sender.SenderId.OpenId
|
||||||
|
} else if event.Event.Sender.SenderId.UserId != nil {
|
||||||
|
userID = *event.Event.Sender.SenderId.UserId
|
||||||
|
} else if event.Event.Sender.SenderId.UnionId != nil {
|
||||||
|
userID = *event.Event.Sender.SenderId.UnionId
|
||||||
|
}
|
||||||
|
|
||||||
|
return IncomingMessage{
|
||||||
|
MessageID: *msg.MessageId,
|
||||||
|
ChatID: *msg.ChatId,
|
||||||
|
UserID: userID,
|
||||||
|
Text: text,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) shouldProcessMessage(messageID string) bool {
|
||||||
|
messageID = strings.TrimSpace(messageID)
|
||||||
|
if messageID == "" {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Warnf("feishu message without message_id; skip idempotency check")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
b.dedupMu.Lock()
|
||||||
|
defer b.dedupMu.Unlock()
|
||||||
|
|
||||||
|
for id, seenAt := range b.dedupSeen {
|
||||||
|
if now.Sub(seenAt) > b.dedupTTL {
|
||||||
|
delete(b.dedupSeen, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := b.dedupSeen[messageID]; exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.dedupSeen[messageID] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func toLarkLogLevel(log *logger.Logger) larkcore.LogLevel {
|
||||||
|
if log == nil {
|
||||||
|
return larkcore.LogLevelInfo
|
||||||
|
}
|
||||||
|
switch log.Level() {
|
||||||
|
case logger.LevelDebug:
|
||||||
|
return larkcore.LogLevelDebug
|
||||||
|
case logger.LevelWarn:
|
||||||
|
return larkcore.LogLevelWarn
|
||||||
|
case logger.LevelError:
|
||||||
|
return larkcore.LogLevelError
|
||||||
|
default:
|
||||||
|
return larkcore.LogLevelInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
181
internal/transport/telegram/bot.go
Normal file
181
internal/transport/telegram/bot.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"laodingbot/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Bot struct {
|
||||||
|
token string
|
||||||
|
baseURL string
|
||||||
|
http *http.Client
|
||||||
|
pollTimeout int
|
||||||
|
log *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
type IncomingMessage struct {
|
||||||
|
ChatID string
|
||||||
|
UserID string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBot(token string, pollTimeout int, log *logger.Logger) (*Bot, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("empty telegram token")
|
||||||
|
}
|
||||||
|
if pollTimeout <= 0 {
|
||||||
|
pollTimeout = 30
|
||||||
|
}
|
||||||
|
return &Bot{
|
||||||
|
token: token,
|
||||||
|
baseURL: "https://api.telegram.org/bot" + token,
|
||||||
|
http: &http.Client{Timeout: 70 * time.Second},
|
||||||
|
pollTimeout: pollTimeout,
|
||||||
|
log: log,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) Run(ctx context.Context, handler func(context.Context, IncomingMessage) (string, error)) error {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("telegram polling started timeout=%ds", b.pollTimeout)
|
||||||
|
}
|
||||||
|
offset := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("telegram polling stopped: %v", ctx.Err())
|
||||||
|
}
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
updates, err := b.getUpdates(ctx, offset)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("telegram getUpdates failed err=%v", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.log != nil && len(updates) > 0 {
|
||||||
|
b.log.Debugf("telegram updates received count=%d offset=%d", len(updates), offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range updates {
|
||||||
|
offset = u.UpdateID + 1
|
||||||
|
if u.Message.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in := IncomingMessage{
|
||||||
|
ChatID: strconv.FormatInt(u.Message.Chat.ID, 10),
|
||||||
|
UserID: strconv.FormatInt(u.Message.From.ID, 10),
|
||||||
|
Text: u.Message.Text,
|
||||||
|
}
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Infof("telegram message received chat_id=%s user_id=%s text_len=%d", in.ChatID, in.UserID, len(in.Text))
|
||||||
|
}
|
||||||
|
resp, err := handler(ctx, in)
|
||||||
|
if err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("telegram handler failed chat_id=%s err=%v", in.ChatID, err)
|
||||||
|
}
|
||||||
|
resp = "处理失败: " + err.Error()
|
||||||
|
}
|
||||||
|
if err := b.sendMessage(ctx, in.ChatID, resp); err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("telegram sendMessage failed chat_id=%s err=%v", in.ChatID, err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Debugf("telegram message sent chat_id=%s text_len=%d", in.ChatID, len(resp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type updatesResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Result []update `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type update struct {
|
||||||
|
UpdateID int `json:"update_id"`
|
||||||
|
Message struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Chat struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"chat"`
|
||||||
|
From struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"from"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) getUpdates(ctx context.Context, offset int) ([]update, error) {
|
||||||
|
url := fmt.Sprintf("%s/getUpdates?timeout=%d&offset=%d", b.baseURL, b.pollTimeout, offset)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := b.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out updatesResponse
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Errorf("telegram parse getUpdates response failed err=%v", err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !out.OK {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Warnf("telegram getUpdates not ok")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("telegram getUpdates failed")
|
||||||
|
}
|
||||||
|
return out.Result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) sendMessage(ctx context.Context, chatID, text string) error {
|
||||||
|
payload := map[string]string{
|
||||||
|
"chat_id": chatID,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
bts, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, b.baseURL+"/sendMessage", bytes.NewReader(bts))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := b.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
if b.log != nil {
|
||||||
|
b.log.Warnf("telegram sendMessage bad status=%d chat_id=%s", resp.StatusCode, chatID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("telegram sendMessage status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
4
skills/README.md
Normal file
4
skills/README.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Skills
|
||||||
|
|
||||||
|
本目录中的 Markdown 文件用于描述 bot 已具备能力。
|
||||||
|
程序启动时会自动加载这些文档,并将其注入到决策上下文中。
|
||||||
19
skills/filesystem_query.md
Normal file
19
skills/filesystem_query.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Skill: Filesystem Query
|
||||||
|
|
||||||
|
用途:
|
||||||
|
- 查询目录下文件
|
||||||
|
- 检查路径是否存在
|
||||||
|
- 快速列出文件树
|
||||||
|
|
||||||
|
建议工具:
|
||||||
|
- `shell`
|
||||||
|
|
||||||
|
常见动作:
|
||||||
|
- 查看目录内容:`ls -la <dir>`
|
||||||
|
- 递归列出文件:`find <dir> -maxdepth 3 -type f`
|
||||||
|
- 查询特定后缀:`find <dir> -name "*.md"`
|
||||||
|
|
||||||
|
触发信号:
|
||||||
|
- 用户提问“某目录有什么文件”
|
||||||
|
- 用户提问“帮我查一下 data 目录内容”
|
||||||
|
- 用户提问“列出/检索/查找 文件”
|
||||||
Reference in New Issue
Block a user