chore: initial commit

This commit is contained in:
whlaoding
2026-02-21 23:01:39 +08:00
commit c2bebb3457
21 changed files with 1913 additions and 0 deletions

View 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 或 fileaction_input 为工具输入。",
"2) 当可以最终回答时action 置 noneaction_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
View 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
}

View 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
View 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
View 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)
}

View 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()
}

View 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
}

View 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)
}

View 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
View 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
}

View 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
}
}

View 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
}