From c2bebb345713e26f1ef0dc679ae402a1bab9caa9 Mon Sep 17 00:00:00 2001 From: whlaoding Date: Sat, 21 Feb 2026 23:01:39 +0800 Subject: [PATCH] chore: initial commit --- README.md | 86 +++++++++ bot_context/soul.md | 13 ++ cmd/bot/main.go | 111 ++++++++++++ configs/env.sample | 21 +++ data/laodingbot.db | Bin 0 -> 24576 bytes go.mod | 22 +++ go.sum | 78 ++++++++ internal/agent/orchestrator.go | 244 ++++++++++++++++++++++++++ internal/config/config.go | 215 +++++++++++++++++++++++ internal/knowledge/loader.go | 63 +++++++ internal/llm/client.go | 134 ++++++++++++++ internal/logger/logger.go | 89 ++++++++++ internal/memory/compress.go | 19 ++ internal/memory/store_sqlite.go | 132 ++++++++++++++ internal/tools/filetool/filetool.go | 111 ++++++++++++ internal/tools/shelltool/shelltool.go | 85 +++++++++ internal/tools/types.go | 49 ++++++ internal/transport/feishu/bot.go | 237 +++++++++++++++++++++++++ internal/transport/telegram/bot.go | 181 +++++++++++++++++++ skills/README.md | 4 + skills/filesystem_query.md | 19 ++ 21 files changed, 1913 insertions(+) create mode 100644 README.md create mode 100644 bot_context/soul.md create mode 100644 cmd/bot/main.go create mode 100644 configs/env.sample create mode 100644 data/laodingbot.db create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/agent/orchestrator.go create mode 100644 internal/config/config.go create mode 100644 internal/knowledge/loader.go create mode 100644 internal/llm/client.go create mode 100644 internal/logger/logger.go create mode 100644 internal/memory/compress.go create mode 100644 internal/memory/store_sqlite.go create mode 100644 internal/tools/filetool/filetool.go create mode 100644 internal/tools/shelltool/shelltool.go create mode 100644 internal/tools/types.go create mode 100644 internal/transport/feishu/bot.go create mode 100644 internal/transport/telegram/bot.go create mode 100644 skills/README.md create mode 100644 skills/filesystem_query.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cb6926 --- /dev/null +++ b/README.md @@ -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 +``` + +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 diff --git a/bot_context/soul.md b/bot_context/soul.md new file mode 100644 index 0000000..7617d13 --- /dev/null +++ b/bot_context/soul.md @@ -0,0 +1,13 @@ +你是 LaodingBot,一名可靠、务实、有温度的个人 AI 助手。 + +人格与语气: +- 先给结论,再给关键依据。 +- 语气简洁直接,但保持礼貌。 +- 遇到不确定时明确说明不确定点,并给出下一步可执行动作。 +- 优先执行、验证、反馈,不空谈。 +- 对用户目标保持主动:能用工具验证就不用猜。 + +行为原则: +- 涉及文件、目录、命令时,优先调用工具获取真实结果。 +- 结果回复要结构清晰,包含必要的风险提示。 +- 如果任务失败,给出失败原因和最短修复路径。 \ No newline at end of file diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..ac1754b --- /dev/null +++ b/cmd/bot/main.go @@ -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) + } +} diff --git a/configs/env.sample b/configs/env.sample new file mode 100644 index 0000000..e52a9e9 --- /dev/null +++ b/configs/env.sample @@ -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 diff --git a/data/laodingbot.db b/data/laodingbot.db new file mode 100644 index 0000000000000000000000000000000000000000..08af2c0ad352162267282b1bcc1477af48f62aac GIT binary patch literal 24576 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 Y(gM>=PtLd(o5g6dGFFb`*_8GE4^wv&EdT%j literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b42cb37 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0f3dd0b --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/orchestrator.go b/internal/agent/orchestrator.go new file mode 100644 index 0000000..9362799 --- /dev/null +++ b/internal/agent/orchestrator.go @@ -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 := 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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b0552d6 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/knowledge/loader.go b/internal/knowledge/loader.go new file mode 100644 index 0000000..d420a30 --- /dev/null +++ b/internal/knowledge/loader.go @@ -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 +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..4a21845 --- /dev/null +++ b/internal/llm/client.go @@ -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 +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..4f153d9 --- /dev/null +++ b/internal/logger/logger.go @@ -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) +} diff --git a/internal/memory/compress.go b/internal/memory/compress.go new file mode 100644 index 0000000..dcf4a39 --- /dev/null +++ b/internal/memory/compress.go @@ -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() +} diff --git a/internal/memory/store_sqlite.go b/internal/memory/store_sqlite.go new file mode 100644 index 0000000..dd6868f --- /dev/null +++ b/internal/memory/store_sqlite.go @@ -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 +} diff --git a/internal/tools/filetool/filetool.go b/internal/tools/filetool/filetool.go new file mode 100644 index 0000000..1f0a31b --- /dev/null +++ b/internal/tools/filetool/filetool.go @@ -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 | write \\n" +} + +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) +} diff --git a/internal/tools/shelltool/shelltool.go b/internal/tools/shelltool/shelltool.go new file mode 100644 index 0000000..64070f1 --- /dev/null +++ b/internal/tools/shelltool/shelltool.go @@ -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 +} diff --git a/internal/tools/types.go b/internal/tools/types.go new file mode 100644 index 0000000..76eda75 --- /dev/null +++ b/internal/tools/types.go @@ -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 +} diff --git a/internal/transport/feishu/bot.go b/internal/transport/feishu/bot.go new file mode 100644 index 0000000..52ba5bb --- /dev/null +++ b/internal/transport/feishu/bot.go @@ -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 + } +} diff --git a/internal/transport/telegram/bot.go b/internal/transport/telegram/bot.go new file mode 100644 index 0000000..0458c2f --- /dev/null +++ b/internal/transport/telegram/bot.go @@ -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 +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..0e8fda7 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,4 @@ +# Skills + +本目录中的 Markdown 文件用于描述 bot 已具备能力。 +程序启动时会自动加载这些文档,并将其注入到决策上下文中。 \ No newline at end of file diff --git a/skills/filesystem_query.md b/skills/filesystem_query.md new file mode 100644 index 0000000..cd3eb12 --- /dev/null +++ b/skills/filesystem_query.md @@ -0,0 +1,19 @@ +# Skill: Filesystem Query + +用途: +- 查询目录下文件 +- 检查路径是否存在 +- 快速列出文件树 + +建议工具: +- `shell` + +常见动作: +- 查看目录内容:`ls -la ` +- 递归列出文件:`find -maxdepth 3 -type f` +- 查询特定后缀:`find -name "*.md"` + +触发信号: +- 用户提问“某目录有什么文件” +- 用户提问“帮我查一下 data 目录内容” +- 用户提问“列出/检索/查找 文件” \ No newline at end of file