From 4cf95a4276c7c2b8f0a8ced568be8a06fb51d5d1 Mon Sep 17 00:00:00 2001 From: Udbhav Date: Tue, 17 Feb 2026 11:22:21 +0530 Subject: [PATCH] feat: Add Google Calendar integration, GitHub tools, and Email enhancements --- config/config.example.json | 15 +- go.mod | 29 +++ go.sum | 87 ++++++- pkg/agent/loop.go | 7 +- pkg/channels/email.go | 521 ++++++++++++++++++++++--------------- pkg/channels/manager.go | 8 +- pkg/config/config.go | 51 +++- pkg/tools/calendar.go | 247 ++++++++++++++++++ pkg/tools/check_mail.go | 53 ++-- pkg/tools/email.go | 334 ++++++++++++++---------- pkg/tools/github.go | 219 ++++++++++++++++ pkg/tools/system.go | 73 ++++++ 12 files changed, 1254 insertions(+), 390 deletions(-) create mode 100644 pkg/tools/calendar.go create mode 100644 pkg/tools/github.go create mode 100644 pkg/tools/system.go diff --git a/config/config.example.json b/config/config.example.json index a7139ccf4..5e198a2be 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -14,7 +14,9 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", - "allow_from": ["YOUR_USER_ID"] + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -80,7 +82,10 @@ "smtp_user": "your-email@gmail.com", "smtp_password": "your-app-password", "poll_interval": 30, - "allow_from": ["allowed-sender@example.com"] + "forward_to": "telegram:YOUR_CHAT_ID", + "allow_from": [ + "allowed-sender@example.com" + ] } }, "providers": { @@ -132,6 +137,10 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "github": { + "enabled": false, + "token": "YOUR_GITHUB_TOKEN" } }, "heartbeat": { @@ -146,4 +155,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file diff --git a/go.mod b/go.mod index f4d9d89c3..05a3a29fb 100644 --- a/go.mod +++ b/go.mod @@ -10,23 +10,52 @@ require ( github.com/chzyer/readline v1.5.1 github.com/emersion/go-imap v1.2.1 github.com/emersion/go-message v0.18.2 + github.com/google/go-github/v60 v60.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/slack-go/slack v0.17.3 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 golang.org/x/oauth2 v0.35.0 + google.golang.org/api v0.266.0 ) require ( + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2d28274de..dcf47137e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,10 @@ +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= @@ -17,6 +23,8 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= @@ -39,10 +47,19 @@ github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTe github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -61,19 +78,32 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= +github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -89,14 +119,17 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -114,9 +147,18 @@ github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbD github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -145,6 +187,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -158,6 +204,22 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= @@ -204,11 +266,13 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h 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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -218,6 +282,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -235,8 +300,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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= @@ -248,6 +313,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T 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= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -256,6 +333,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 06c9925cb..4b326c486 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -104,7 +104,12 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg // Email tools registry.Register(tools.NewReadEmailTool(cfg.Channels.Email)) registry.Register(tools.NewSendEmailTool(cfg.Channels.Email)) - registry.Register(tools.NewCheckMailTool(msgBus)) // Keep for background sync trigger if needed + registry.Register(tools.NewCheckMailTool(cfg.Channels.Email)) // Now fetches content + + // Developer tools + registry.Register(tools.NewGitHubTool(cfg.Tools.GitHub)) + registry.Register(tools.NewSystemTool()) + registry.Register(tools.NewCalendarTool(cfg.Tools.Calendar)) return registry } diff --git a/pkg/channels/email.go b/pkg/channels/email.go index 1e9ca30ab..9f121b768 100644 --- a/pkg/channels/email.go +++ b/pkg/channels/email.go @@ -7,6 +7,7 @@ import ( "io" "net/smtp" "strings" + "sync" "time" "github.com/emersion/go-imap" @@ -21,33 +22,111 @@ import ( type EmailChannel struct { *BaseChannel config config.EmailConfig - imapClient *client.Client + bus *bus.MessageBus + imapClients map[string]*client.Client // Map email address -> client + stopChan chan struct{} manualCheck chan bool + mu sync.Mutex } -func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) { +func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) *EmailChannel { + // If Accounts is empty but single fields are set, populate Accounts with one entry + if len(cfg.Accounts) == 0 && cfg.IMAPServer != "" { + cfg.Accounts = []config.EmailAccountConfig{{ + Email: cfg.IMAPUser, // Default to IMAP user as email + IMAPServer: cfg.IMAPServer, + IMAPPort: cfg.IMAPPort, + IMAPUser: cfg.IMAPUser, + IMAPPassword: cfg.IMAPPassword, + SMTPServer: cfg.SMTPServer, + SMTPPort: cfg.SMTPPort, + SMTPUser: cfg.SMTPUser, + SMTPPassword: cfg.SMTPPassword, + }} + } + base := NewBaseChannel("email", cfg, bus, cfg.AllowFrom) + return &EmailChannel{ BaseChannel: base, config: cfg, + bus: bus, + imapClients: make(map[string]*client.Client), + stopChan: make(chan struct{}), manualCheck: make(chan bool, 1), - }, nil + } } func (c *EmailChannel) Start(ctx context.Context) error { logger.InfoC("email", "Starting Email channel polling...") + // Initial connection + c.connectAllIMAP() + c.setRunning(true) go c.pollLoop(ctx) - return nil } +func (c *EmailChannel) connectAllIMAP() { + c.mu.Lock() + defer c.mu.Unlock() + + for _, acc := range c.config.Accounts { + if acc.Email == "" { + continue // Skip invalid config + } + + // Skip if already connected + if client, ok := c.imapClients[acc.Email]; ok && client.State() == imap.AuthenticatedState { + continue + } + + logger.DebugCF("email", "Connecting to IMAP", map[string]interface{}{"email": acc.Email, "server": acc.IMAPServer}) + client, err := c.connectIMAPAccount(acc) + if err != nil { + logger.ErrorCF("email", "Failed to connect to IMAP account", map[string]interface{}{ + "email": acc.Email, + "error": err.Error(), + }) + continue + } + c.imapClients[acc.Email] = client + logger.InfoCF("email", "Connected to IMAP account", map[string]interface{}{"email": acc.Email}) + } +} + +func (c *EmailChannel) connectIMAPAccount(acc config.EmailAccountConfig) (*client.Client, error) { + addr := fmt.Sprintf("%s:%d", acc.IMAPServer, acc.IMAPPort) + var cClient *client.Client + var err error + + if acc.IMAPPort == 993 { + cClient, err = client.DialTLS(addr, nil) + } else { + cClient, err = client.Dial(addr) + } + if err != nil { + return nil, err + } + + if err := cClient.Login(acc.IMAPUser, acc.IMAPPassword); err != nil { + cClient.Logout() + return nil, err + } + + return cClient, nil +} + func (c *EmailChannel) Stop(ctx context.Context) error { logger.InfoC("email", "Stopping Email channel...") c.setRunning(false) - if c.imapClient != nil { - c.imapClient.Logout() + close(c.stopChan) + + c.mu.Lock() + defer c.mu.Unlock() + for _, client := range c.imapClients { + client.Logout() } return nil } @@ -56,205 +135,23 @@ func (c *EmailChannel) pollLoop(ctx context.Context) { ticker := time.NewTicker(time.Duration(c.config.PollInterval) * time.Second) defer ticker.Stop() - // Initial poll - c.checkMail() + // Initial check + c.checkAllMail() for { select { case <-ctx.Done(): return - case <-ticker.C: - c.checkMail() - case <-c.manualCheck: - c.checkMail() - } - } -} - -func (c *EmailChannel) checkMail() { - // User feedback for manual check visibility - fmt.Println("šŸ“§ Checking for new emails...") - - // Reconnect if needed - if c.imapClient == nil || c.imapClient.State() == imap.LogoutState { - fmt.Printf("šŸ“§ Connecting to IMAP server %s:%d...\n", c.config.IMAPServer, c.config.IMAPPort) - if err := c.connectIMAP(); err != nil { - logger.ErrorCF("email", "Failed to connect to IMAP", map[string]interface{}{"error": err.Error()}) - fmt.Printf("āŒ Failed to connect to IMAP: %v\n", err) + case <-c.stopChan: return + case <-ticker.C: + c.checkAllMail() + case <-c.manualCheck: + c.checkAllMail() } - fmt.Println("āœ… Connected to IMAP") - } - - // Select INBOX - mbox, err := c.imapClient.Select("INBOX", false) - if err != nil { - logger.ErrorCF("email", "Failed to select INBOX", map[string]interface{}{"error": err.Error()}) - fmt.Printf("āŒ Failed to select INBOX: %v\n", err) - // Force reconnect next time - c.imapClient.Logout() - c.imapClient = nil - return - } - - if mbox.Messages == 0 { - fmt.Println("šŸ“­ Inbox is empty.") - return - } - - // Search for unread messages - criteria := imap.NewSearchCriteria() - criteria.WithoutFlags = []string{imap.SeenFlag} - uids, err := c.imapClient.Search(criteria) - if err != nil { - logger.ErrorCF("email", "Failed to search emails", map[string]interface{}{"error": err.Error()}) - fmt.Printf("āŒ Failed to search emails: %v\n", err) - return - } - - if len(uids) == 0 { - fmt.Println("šŸ“­ No new unread emails.") - return - } - - // Limit to last 10 emails to avoid overwhelming the system - const maxEmails = 10 - if len(uids) > maxEmails { - fmt.Printf("āš ļø Too many emails (%d). Fetching last %d only.\n", len(uids), maxEmails) - uids = uids[len(uids)-maxEmails:] - } - - fmt.Printf("šŸ“§ Fetching %d new emails...\n", len(uids)) - - seqset := new(imap.SeqSet) - seqset.AddNum(uids...) - - section := &imap.BodySectionName{} - items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} - - messages := make(chan *imap.Message) - done := make(chan error, 1) - - go func() { - done <- c.imapClient.Fetch(seqset, items, messages) - }() - - for msg := range messages { - c.processMessage(msg, section) - - // Mark as seen - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.SeenFlag} - seq := new(imap.SeqSet) - seq.AddNum(msg.Uid) - if err := c.imapClient.Store(seq, item, flags, nil); err != nil { - logger.ErrorCF("email", "Failed to mark email as seen", map[string]interface{}{"uid": msg.Uid}) - } - } - - if err := <-done; err != nil { - logger.ErrorCF("email", "Fetch failed", map[string]interface{}{"error": err.Error()}) } } -func (c *EmailChannel) connectIMAP() error { - addr := fmt.Sprintf("%s:%d", c.config.IMAPServer, c.config.IMAPPort) - logger.DebugCF("email", "Connecting to IMAP", map[string]interface{}{"addr": addr}) - - var err error - if c.config.IMAPPort == 993 { - c.imapClient, err = client.DialTLS(addr, nil) - } else { - c.imapClient, err = client.Dial(addr) - } - if err != nil { - return err - } - - if err := c.imapClient.Login(c.config.IMAPUser, c.config.IMAPPassword); err != nil { - return err - } - - return nil -} - -func (c *EmailChannel) processMessage(msg *imap.Message, section *imap.BodySectionName) { - if msg == nil { - return - } - - sender := "unknown" - if len(msg.Envelope.From) > 0 { - sender = fmt.Sprintf("%s@%s", msg.Envelope.From[0].MailboxName, msg.Envelope.From[0].HostName) - } - - subject := msg.Envelope.Subject - - // Check allowlist - if !c.IsAllowed(sender) { - logger.DebugCF("email", "Message rejected by allowlist", map[string]interface{}{"sender": sender}) - return - } - - r := msg.GetBody(section) - if r == nil { - logger.WarnC("email", "Message has no body") - return - } - - // Create a new mail reader - mr, err := mail.CreateReader(r) - if err != nil { - logger.ErrorCF("email", "Failed to create mail reader", map[string]interface{}{"error": err.Error()}) - return - } - - body := "" - - // Read each part - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - logger.ErrorCF("email", "Failed to read email part", map[string]interface{}{"error": err.Error()}) - break - } - - switch h := p.Header.(type) { - case *mail.InlineHeader: - // checks explicitly for text/plain - contentType, _, _ := h.ContentType() - if contentType == "text/plain" { - b, _ := io.ReadAll(p.Body) - body += string(b) - } - case *mail.AttachmentHeader: - // Handle attachments if needed - } - } - - if body == "" { - // Try to read simple body if multipart failed or wasn't multipart - // Reset reader if possible or handle non-multipart - simplified for this implementation - // For the MVP, we assume most emails have a text/plain part. - body = "[Content could not be parsed or was empty]" - } - - content := fmt.Sprintf("Subject: %s\n\n%s", subject, body) - - // Sender is the "User ID", ChatID is also the sender email for direct replies - chatID := sender // In email, the chat ID is effectively the sender's address - - c.HandleMessage(sender, chatID, content, nil, map[string]string{ - "subject": subject, - "email": sender, - }) - fmt.Printf("āœ… Processed email from %s: %s\n", sender, subject) -} - -// HandleCustomCommand handles internal commands -// HandleCustomCommand handles internal commands func (c *EmailChannel) CheckNow() { select { case c.manualCheck <- true: @@ -264,42 +161,235 @@ func (c *EmailChannel) CheckNow() { } } +func (c *EmailChannel) checkAllMail() { + fmt.Println("šŸ“§ Checking for new emails on all accounts...") + + // Ensure connections + c.connectAllIMAP() + + c.mu.Lock() + defer c.mu.Unlock() + + for _, acc := range c.config.Accounts { + client, ok := c.imapClients[acc.Email] + if !ok || client.State() == imap.LogoutState { + continue + } + c.checkAccountMail(client, acc) + } +} + +func (c *EmailChannel) checkAccountMail(imapClient *client.Client, acc config.EmailAccountConfig) { + // Select INBOX + mbox, err := imapClient.Select("INBOX", false) + if err != nil { + logger.ErrorCF("email", "Failed to select INBOX", map[string]interface{}{"email": acc.Email, "error": err.Error()}) + // Invalidate connection + imapClient.Logout() + return + } + + if mbox.Messages == 0 { + return + } + + // Search for unread messages + criteria := imap.NewSearchCriteria() + criteria.WithoutFlags = []string{imap.SeenFlag} + uids, err := imapClient.Search(criteria) + if err != nil { + logger.ErrorCF("email", "Failed to search emails", map[string]interface{}{"email": acc.Email, "error": err.Error()}) + return + } + + if len(uids) == 0 { + return + } + + // Limit to last 10 emails + const maxEmails = 10 + if len(uids) > maxEmails { + // Take the newest ones (highest UIDs) + uids = uids[len(uids)-maxEmails:] + } + + fmt.Printf("šŸ“§ [%s] Found %d unread emails, fetching details...\n", acc.Email, len(uids)) + + seqset := new(imap.SeqSet) + seqset.AddNum(uids...) + + // Fetch envelope and body structure to check dates first? + // Actually just fetch everything for the small batch. + section := &imap.BodySectionName{} + items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} + + messages := make(chan *imap.Message) + done := make(chan error, 1) + + go func() { + done <- imapClient.Fetch(seqset, items, messages) + }() + + for msg := range messages { + // Time filter: Ignore emails older than 1 hour to prevent backlog flood on restart + if msg.Envelope != nil && time.Since(msg.Envelope.Date) > 1*time.Hour { + logger.DebugCF("email", "Skipping old unread email", map[string]interface{}{ + "subject": msg.Envelope.Subject, + "date": msg.Envelope.Date, + }) + continue + } + + c.processMessage(msg, section, acc.Email) // Pass account email to know recipient context + + // Mark as seen + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + seq := new(imap.SeqSet) + seq.AddNum(msg.Uid) + if err := imapClient.Store(seq, item, flags, nil); err != nil { + logger.ErrorCF("email", "Failed to mark email as seen", map[string]interface{}{"uid": msg.Uid}) + } + } + + if err := <-done; err != nil { + logger.ErrorCF("email", "Fetch failed", map[string]interface{}{"error": err.Error()}) + } +} + +func (c *EmailChannel) processMessage(msg *imap.Message, section *imap.BodySectionName, accountEmail string) { + if msg == nil || msg.Envelope == nil { + return + } + + subject := msg.Envelope.Subject + from := msg.Envelope.From + + if len(from) == 0 { + return + } + + sender := fmt.Sprintf("%s@%s", from[0].MailboxName, from[0].HostName) + + // Check allowlist + if !c.IsAllowed(sender) { + logger.DebugCF("email", "Ignoring email from non-allowed sender", map[string]interface{}{"sender": sender}) + return + } + + // Get body + r := msg.GetBody(section) + if r == nil { + return + } + + mr, err := mail.CreateReader(r) + if err != nil { + logger.ErrorCF("email", "Failed to create mail reader", map[string]interface{}{"error": err.Error()}) + return + } + + var body string + // Simple body extraction + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + break + } + + switch h := p.Header.(type) { + case *mail.InlineHeader: + contentType, _, _ := h.ContentType() + if contentType == "text/plain" { + b, _ := io.ReadAll(p.Body) + body = string(b) + } else if contentType == "text/html" && body == "" { + // Fallback to HTML if no text/plain yet, ideally strip tags + b, _ := io.ReadAll(p.Body) + body = string(b) // TODO: Strip HTML + } + } + } + + // ChatID logic: + // For now, we treat the sender as the ChatID. + // To distinguish which account received it, we could prepend/append, but let's keep it simple. + // The User ID is the sender. + chatID := sender + + // Forwarding logic + if c.config.ForwardTo != "" { + parts := strings.SplitN(c.config.ForwardTo, ":", 2) + if len(parts) == 2 { + channel, targetChatID := parts[0], parts[1] + forwardContent := fmt.Sprintf("šŸ“§ **New Email [%s]**\n**From:** %s\n**Subject:** %s\n\n%s", accountEmail, sender, subject, body) + + // Truncate body + if len(body) > 500 { + forwardContent = fmt.Sprintf("šŸ“§ **New Email [%s]**\n**From:** %s\n**Subject:** %s\n\n%s...", accountEmail, sender, subject, body[:500]) + } + + c.bus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: targetChatID, + Content: forwardContent, + }) + fmt.Printf("āž”ļø Forwarded email to %s:%s\n", channel, targetChatID) + } + } + + c.HandleMessage(sender, chatID, contentWithContext(accountEmail, subject, body), nil, map[string]string{ + "subject": subject, + "email": sender, + "to": accountEmail, + }) + fmt.Printf("āœ… Processed email from %s to %s: %s\n", sender, accountEmail, subject) +} + +func contentWithContext(account, subject, body string) string { + return fmt.Sprintf("[Received at %s]\nSubject: %s\n\n%s", account, subject, body) +} + func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { logger.DebugCF("email", "Send received", map[string]interface{}{"content": msg.Content, "chat_id": msg.ChatID}) - // Check for command (handle potential whitespace) + // Check for command if strings.TrimSpace(msg.Content) == "CMD:CHECK" { c.CheckNow() return nil } - addr := fmt.Sprintf("%s:%d", c.config.SMTPServer, c.config.SMTPPort) - auth := smtp.PlainAuth("", c.config.SMTPUser, c.config.SMTPPassword, c.config.SMTPServer) + // For simple replies, we don't know which account to send FROM unless we track state or infer. + // We'll Default to the first account, or try to find one. + // Ideally, the tool `send_email` should be used which calls this. + // If this is a direct reply from Agent, it might lack context. + // However, we can use the first account as default. + + account := c.config.Accounts[0] + + // If msg.Metadata has "from", use it + // But OutboundMessage doesn't have arbitrary metadata map on struct usually? + // It does NOT. + // So we use default account for general replies. + + addr := fmt.Sprintf("%s:%d", account.SMTPServer, account.SMTPPort) + auth := smtp.PlainAuth("", account.SMTPUser, account.SMTPPassword, account.SMTPServer) to := []string{msg.ChatID} - - // Simple email construction subject := "Re: PicoClaw Response" - // If we preserved the original subject in session metadata, we could use "Re: " + original_subject + body := fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", msg.ChatID, subject, msg.Content) - body := fmt.Sprintf("To: %s\r\n"+ - "Subject: %s\r\n"+ - "\r\n"+ - "%s\r\n", msg.ChatID, subject, msg.Content) - - // Handle TLS for port 465 (SMTPS) vs 587 (STARTTLS) - if c.config.SMTPPort == 465 { - // Direct TLS - tlsConfig := &tls.Config{ - ServerName: c.config.SMTPServer, - } + if account.SMTPPort == 465 { + tlsConfig := &tls.Config{ServerName: account.SMTPServer} conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { return err } defer conn.Close() - client, err := smtp.NewClient(conn, c.config.SMTPServer) + client, err := smtp.NewClient(conn, account.SMTPServer) if err != nil { return err } @@ -308,7 +398,7 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error if err = client.Auth(auth); err != nil { return err } - if err = client.Mail(c.config.SMTPUser); err != nil { + if err = client.Mail(account.SMTPUser); err != nil { return err } if err = client.Rcpt(to[0]); err != nil { @@ -324,7 +414,6 @@ func (c *EmailChannel) Send(ctx context.Context, msg bus.OutboundMessage) error } return w.Close() } else { - // STARTTLS (Standard for 587) - return smtp.SendMail(addr, auth, c.config.SMTPUser, to, []byte(body)) + return smtp.SendMail(addr, auth, account.SMTPUser, to, []byte(body)) } } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 04c6eee5e..287d0cfae 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -178,11 +178,9 @@ func (m *Manager) initChannels() error { if m.config.Channels.Email.Enabled { logger.DebugC("channels", "Attempting to initialize Email channel") - email, err := NewEmailChannel(m.config.Channels.Email, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Email channel", map[string]interface{}{ - "error": err.Error(), - }) + email := NewEmailChannel(m.config.Channels.Email, m.bus) + if email == nil { + logger.ErrorC("channels", "Failed to initialize Email channel") } else { m.channels["email"] = email logger.InfoC("channels", "Email channel enabled successfully") diff --git a/pkg/config/config.go b/pkg/config/config.go index 15b1a3dbb..913d03a2d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -157,18 +157,32 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` } +type EmailAccountConfig struct { + Email string `json:"email"` + IMAPServer string `json:"imap_server"` + IMAPPort int `json:"imap_port"` + IMAPUser string `json:"imap_user"` + IMAPPassword string `json:"imap_password"` + SMTPServer string `json:"smtp_server"` + SMTPPort int `json:"smtp_port"` + SMTPUser string `json:"smtp_user"` + SMTPPassword string `json:"smtp_password"` +} + type EmailConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_EMAIL_ENABLED"` - IMAPServer string `json:"imap_server" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_SERVER"` - IMAPPort int `json:"imap_port" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"` - IMAPUser string `json:"imap_user" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"` - IMAPPassword string `json:"imap_password" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"` - SMTPServer string `json:"smtp_server" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_SERVER"` - SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"` - SMTPUser string `json:"smtp_user" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"` - SMTPPassword string `json:"smtp_password" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"` - PollInterval int `json:"poll_interval" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL"` // seconds - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_EMAIL_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_EMAIL_ENABLED"` + Accounts []EmailAccountConfig `json:"accounts"` + IMAPServer string `json:"imap_server" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_SERVER"` + IMAPPort int `json:"imap_port" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PORT"` + IMAPUser string `json:"imap_user" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_USER"` + IMAPPassword string `json:"imap_password" env:"PICOCLAW_CHANNELS_EMAIL_IMAP_PASSWORD"` + SMTPServer string `json:"smtp_server" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_SERVER"` + SMTPPort int `json:"smtp_port" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PORT"` + SMTPUser string `json:"smtp_user" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_USER"` + SMTPPassword string `json:"smtp_password" env:"PICOCLAW_CHANNELS_EMAIL_SMTP_PASSWORD"` + PollInterval int `json:"poll_interval" env:"PICOCLAW_CHANNELS_EMAIL_POLL_INTERVAL"` // seconds + ForwardTo string `json:"forward_to" env:"PICOCLAW_CHANNELS_EMAIL_FORWARD_TO"` // e.g. "telegram:123456789" + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_EMAIL_ALLOW_FROM"` } type HeartbeatConfig struct { @@ -227,7 +241,20 @@ type WebToolsConfig struct { } type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + GitHub GitHubConfig `json:"github"` + Calendar CalendarConfig `json:"calendar"` +} + +type CalendarConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_CALENDAR_ENABLED"` + CredentialsJSON string `json:"credentials_json" env:"PICOCLAW_TOOLS_CALENDAR_CREDENTIALS"` // Path to credentials.json + CalendarID string `json:"calendar_id" env:"PICOCLAW_TOOLS_CALENDAR_ID"` // Target calendar ID (usually user's email) +} + +type GitHubConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_GITHUB_ENABLED"` + Token string `json:"token" env:"PICOCLAW_TOOLS_GITHUB_TOKEN"` } func DefaultConfig() *Config { diff --git a/pkg/tools/calendar.go b/pkg/tools/calendar.go new file mode 100644 index 000000000..0b30bcbea --- /dev/null +++ b/pkg/tools/calendar.go @@ -0,0 +1,247 @@ +package tools + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "golang.org/x/oauth2/google" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type CalendarTool struct { + config config.CalendarConfig + service *calendar.Service +} + +func NewCalendarTool(cfg config.CalendarConfig) *CalendarTool { + return &CalendarTool{ + config: cfg, + } +} + +func (t *CalendarTool) Name() string { + return "calendar" +} + +func (t *CalendarTool) Description() string { + return "Manage Google Calendar events. Can list upcoming events and add new events." +} + +func (t *CalendarTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "Action to perform: list_events, add_event", + "enum": []string{"list_events", "add_event"}, + }, + "count": map[string]interface{}{ + "type": "integer", + "description": "Number of events to list (default: 10). For list_events.", + }, + "summary": map[string]interface{}{ + "type": "string", + "description": "Event title/summary. For add_event.", + }, + "description": map[string]interface{}{ + "type": "string", + "description": "Event description. For add_event.", + }, + "location": map[string]interface{}{ + "type": "string", + "description": "Event location. For add_event.", + }, + "start_time": map[string]interface{}{ + "type": "string", + "description": "Start time in RFC3339 format (e.g. 2023-10-27T10:00:00Z). For add_event.", + }, + "end_time": map[string]interface{}{ + "type": "string", + "description": "End time in RFC3339 format. For add_event. If omitted, defaults to 1 hour after start.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *CalendarTool) getService(ctx context.Context) (*calendar.Service, error) { + if t.service != nil { + return t.service, nil + } + + if t.config.CredentialsJSON == "" { + return nil, fmt.Errorf("calendar credentials_json not configured") + } + + // Expand home directory if needed + credPath := t.config.CredentialsJSON + if strings.HasPrefix(credPath, "~/") { + home, _ := os.UserHomeDir() + credPath = filepathJoin(home, credPath[2:]) + } + + b, err := os.ReadFile(credPath) + if err != nil { + return nil, fmt.Errorf("unable to read client secret file: %v", err) + } + + // If using Service Account + // conf, err := google.JWTConfigFromJSON(b, calendar.CalendarScope) + + // If using OAuth2 Client ID (more common for personal calendars) + // We need a token. For a CLI tool, we might need a stored token. + // Implementing robust OAuth flow in a tool is hard. + // Let's assume Service Account for now as it's easier for server-side, + // BUT Service Accounts can't access personal Gmail calendars without Domain-Wide Delegation (Workspace only). + // For personal Gmail, we need OAuth2 User Credentials. + + // Strategy: Use "Application Default Credentials" or specific OAuth token if provided. + // Simplest for personal: User provides `token.json` generated elsewhere, or we use a Service Account shared with the personal email? + // Sharing personal calendar with Service Account email is the easiest way! + // 1. User creates Service Account. + // 2. User shares their calendar with Service Account email. + // 3. Tool uses Service Account credentials. + + config, err := google.JWTConfigFromJSON(b, calendar.CalendarScope) + if err != nil { + // Try standard credentials (could be OAuth client secret) + // But for now let's stick to Service Account as primary recommendation for headless agents. + return nil, fmt.Errorf("unable to parse service account key file: %v. Please ensure you are using a Service Account key.", err) + } + + client := config.Client(ctx) + srv, err := calendar.NewService(ctx, option.WithHTTPClient(client)) + if err != nil { + return nil, fmt.Errorf("unable to retrieve Calendar client: %v", err) + } + + t.service = srv + return srv, nil +} + +func (t *CalendarTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if !t.config.Enabled { + return &ToolResult{Err: fmt.Errorf("calendar tool is disabled in config")} + } + + action, _ := args["action"].(string) + srv, err := t.getService(ctx) + if err != nil { + return &ToolResult{Err: err} + } + + switch action { + case "list_events": + return t.listEvents(srv, args) + case "add_event": + return t.addEvent(srv, args) + default: + return &ToolResult{Err: fmt.Errorf("unknown action: %s", action)} + } +} + +func (t *CalendarTool) listEvents(srv *calendar.Service, args map[string]interface{}) *ToolResult { + count := 10 + if c, ok := args["count"].(float64); ok { + count = int(c) + } + + tMin := time.Now().Format(time.RFC3339) + + calendarId := "primary" + if t.config.CalendarID != "" { + calendarId = t.config.CalendarID + } + + events, err := srv.Events.List(calendarId).ShowDeleted(false). + SingleEvents(true).TimeMin(tMin).MaxResults(int64(count)).OrderBy("startTime").Do() + if err != nil { + return &ToolResult{Err: fmt.Errorf("unable to retrieve next ten of the user's upcoming events: %v", err)} + } + + if len(events.Items) == 0 { + return &ToolResult{ForLLM: "No upcoming events found."} + } + + var sb strings.Builder + sb.WriteString("Upcoming events:\n") + for _, item := range events.Items { + date := item.Start.DateTime + if date == "" { + date = item.Start.Date + } + sb.WriteString(fmt.Sprintf("- %s (%s)\n", item.Summary, date)) + } + + return &ToolResult{ForLLM: sb.String(), ForUser: sb.String()} +} + +func (t *CalendarTool) addEvent(srv *calendar.Service, args map[string]interface{}) *ToolResult { + summary, _ := args["summary"].(string) + description, _ := args["description"].(string) + location, _ := args["location"].(string) + startTimeStr, _ := args["start_time"].(string) + endTimeStr, _ := args["end_time"].(string) + + if summary == "" || startTimeStr == "" { + return &ToolResult{Err: fmt.Errorf("summary and start_time are required")} + } + + // Parse start time + // Try RFC3339 first + // If failed, maybe try other formats? LLM usually gives ISO/RFC. + + event := &calendar.Event{ + Summary: summary, + Location: location, + Description: description, + Start: &calendar.EventDateTime{ + DateTime: startTimeStr, + TimeZone: "UTC", // Should ideally be user's timezone or inferred + }, + End: &calendar.EventDateTime{ + DateTime: endTimeStr, + TimeZone: "UTC", + }, + } + + if endTimeStr == "" { + // Default to 1 hour later + t, err := time.Parse(time.RFC3339, startTimeStr) + if err == nil { + event.End.DateTime = t.Add(1 * time.Hour).Format(time.RFC3339) + } else { + return &ToolResult{Err: fmt.Errorf("invalid start_time format, expected RFC3339: %v", err)} + } + } + + calendarId := "primary" + if t.config.CalendarID != "" { + calendarId = t.config.CalendarID + } + + event, err := srv.Events.Insert(calendarId, event).Do() + if err != nil { + return &ToolResult{Err: fmt.Errorf("unable to create event: %v", err)} + } + + msg := fmt.Sprintf("Event created: %s (%s)", event.HtmlLink, event.Id) + return &ToolResult{ForLLM: msg, ForUser: fmt.Sprintf("āœ… Created event '%s' at %s", summary, startTimeStr)} +} + +// Helper to avoid circular dependency or just duplicating logic +func filepathJoin(elem ...string) string { + for i, e := range elem { + if e != "" { + return strings.Join(elem[i:], string(os.PathSeparator)) + } + } + return "" +} diff --git a/pkg/tools/check_mail.go b/pkg/tools/check_mail.go index 775f58d5c..1ce68ff22 100644 --- a/pkg/tools/check_mail.go +++ b/pkg/tools/check_mail.go @@ -2,17 +2,18 @@ package tools import ( "context" + "strings" - "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" ) type CheckMailTool struct { - bus *bus.MessageBus + config config.EmailConfig } -func NewCheckMailTool(bus *bus.MessageBus) *CheckMailTool { +func NewCheckMailTool(cfg config.EmailConfig) *CheckMailTool { return &CheckMailTool{ - bus: bus, + config: cfg, } } @@ -21,27 +22,45 @@ func (t *CheckMailTool) Name() string { } func (t *CheckMailTool) Description() string { - return "Manually check for new emails immediately. Use this when the user asks to check email." + return "Check for new emails and return them. This tool fetches recent unread emails from all accounts." } func (t *CheckMailTool) Parameters() map[string]interface{} { return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{}, - "required": []string{}, + "type": "object", + "properties": map[string]interface{}{ + "count": map[string]interface{}{ + "type": "integer", + "description": "Number of emails to fetch (default: 5)", + }, + }, } } func (t *CheckMailTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { - // Publish a command to the email channel via outbound bus - t.bus.PublishOutbound(bus.OutboundMessage{ - Channel: "email", - ChatID: "system", // Dummy ID to prevent empty recipient error if it falls through - Content: "CMD:CHECK", - }) + // Delegate to ReadEmailTool + readTool := NewReadEmailTool(t.config) - return &ToolResult{ - ForLLM: "Initiated manual check for new emails. Any new emails will appear as messages shortly.", - ForUser: "Checking for new emails...", + // Default to unread only for "check" + readArgs := map[string]interface{}{ + "unread_only": true, + "count": 5.0, } + + if c, ok := args["count"]; ok { + readArgs["count"] = c + } + + result := readTool.Execute(ctx, readArgs) + + // If no unread, try fetching latest 3 just to show something (optional, but "check" usually implies new) + if result.ForLLM == "" || strings.Contains(result.ForLLM, "No unread emails") { + // Fallback to fetch recent 3 if no unread, to confirm connection? + // No, user said "no mails came through", implies they expected new ones or just wanted to see *something*. + // But "check mail" usually means "sync and show new". + // Let's stick to unread. + return result + } + + return result } diff --git a/pkg/tools/email.go b/pkg/tools/email.go index b7f75ef45..ba9032f9e 100644 --- a/pkg/tools/email.go +++ b/pkg/tools/email.go @@ -29,7 +29,7 @@ func (t *ReadEmailTool) Name() string { } func (t *ReadEmailTool) Description() string { - return "Fetch and read recent emails. Returns the sender, subject, and body of the last N emails." + return "Fetch and read recent emails from the inbox. Use this when the user asks to 'check mail', 'read email', or sees a notification. Returns the actual email content." } func (t *ReadEmailTool) Parameters() map[string]interface{} { @@ -44,6 +44,10 @@ func (t *ReadEmailTool) Parameters() map[string]interface{} { "type": "boolean", "description": "If true, only fetch unread emails (default: false)", }, + "account": map[string]interface{}{ + "type": "string", + "description": "Optional: Specific email account to check (e.g. 'user@gmail.com'). If omitted, checks all.", + }, }, } } @@ -66,142 +70,160 @@ func (t *ReadEmailTool) Execute(ctx context.Context, args map[string]interface{} unreadOnly = u } - // Connect to IMAP - addr := fmt.Sprintf("%s:%d", t.config.IMAPServer, t.config.IMAPPort) - var c *client.Client - var err error - - if t.config.IMAPPort == 993 { - c, err = client.DialTLS(addr, nil) - } else { - c, err = client.Dial(addr) - } - if err != nil { - return ErrorResult(fmt.Sprintf("Failed to connect to IMAP: %v", err)) - } - defer c.Logout() - - if err := c.Login(t.config.IMAPUser, t.config.IMAPPassword); err != nil { - return ErrorResult(fmt.Sprintf("Failed to login to IMAP: %v", err)) - } - - mbox, err := c.Select("INBOX", false) - if err != nil { - return ErrorResult(fmt.Sprintf("Failed to select INBOX: %v", err)) - } - - if mbox.Messages == 0 { - return SilentResult("Inbox is empty.") - } - - // Search criteria - criteria := imap.NewSearchCriteria() - if unreadOnly { - criteria.WithoutFlags = []string{imap.SeenFlag} - } else { - // Fetch all (limited by range) - // imap.SearchCriteria doesn't have "ALL" by default, empty means all? - // Actually, we can just use sequence numbers if we want "recent" - } - - var uids []uint32 - if unreadOnly { - uids, err = c.Search(criteria) - if err != nil { - return ErrorResult(fmt.Sprintf("Failed to search emails: %v", err)) - } - } else { - // Just get the last N messages by sequence number - from := uint32(1) - if mbox.Messages > uint32(count) { - from = mbox.Messages - uint32(count) + 1 - } - to := mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - - // We need to fetch UIDs for these sequence numbers or just fetch directly - // Let's fetch directly by SeqNum - // But the processing logic uses UIDs usually. Let's stick to UIDs for consistency if possible, - // but fetching by sequence is easier for "last N". - // Let's use Fetch directly with the seqset. - } - - // Reuse uids logic if unreadOnly, otherwise construct seqset - seqset := new(imap.SeqSet) - if unreadOnly { - if len(uids) == 0 { - return SilentResult("No unread emails.") - } - if len(uids) > count { - uids = uids[len(uids)-count:] - } - seqset.AddNum(uids...) - } else { - from := uint32(1) - if mbox.Messages > uint32(count) { - from = mbox.Messages - uint32(count) + 1 - } - to := mbox.Messages - seqset.AddRange(from, to) - } - - section := &imap.BodySectionName{} - items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope} - - messages := make(chan *imap.Message) - done := make(chan error, 1) - - go func() { - done <- c.Fetch(seqset, items, messages) - }() + targetAccount, _ := args["account"].(string) var sb strings.Builder - sb.WriteString(fmt.Sprintf("Found %d emails (showing last %d):\n\n", mbox.Messages, count)) // Approx count + totalFound := 0 - for msg := range messages { - sb.WriteString("---\n") - sender := "unknown" - if len(msg.Envelope.From) > 0 { - sender = fmt.Sprintf("%s@%s", msg.Envelope.From[0].MailboxName, msg.Envelope.From[0].HostName) + // Helper function to check one account + checkAccount := func(acc config.EmailAccountConfig) error { + addr := fmt.Sprintf("%s:%d", acc.IMAPServer, acc.IMAPPort) + var c *client.Client + var err error + + if acc.IMAPPort == 993 { + c, err = client.DialTLS(addr, nil) + } else { + c, err = client.Dial(addr) } - sb.WriteString(fmt.Sprintf("From: %s\n", sender)) - sb.WriteString(fmt.Sprintf("Subject: %s\n", msg.Envelope.Subject)) - sb.WriteString(fmt.Sprintf("Date: %s\n", msg.Envelope.Date)) + if err != nil { + return fmt.Errorf("failed to connect to %s: %v", acc.Email, err) + } + defer c.Logout() - r := msg.GetBody(section) - if r != nil { - mr, err := mail.CreateReader(r) - if err == nil { - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - break - } - switch h := p.Header.(type) { - case *mail.InlineHeader: - contentType, _, _ := h.ContentType() - if contentType == "text/plain" { - b, _ := io.ReadAll(p.Body) - sb.WriteString("\n") - sb.WriteString(string(b)) + if err := c.Login(acc.IMAPUser, acc.IMAPPassword); err != nil { + return fmt.Errorf("failed to login to %s: %v", acc.Email, err) + } + + mbox, err := c.Select("INBOX", false) + if err != nil { + return fmt.Errorf("failed to select INBOX for %s: %v", acc.Email, err) + } + + if mbox.Messages == 0 { + return nil + } + + // Search + criteria := imap.NewSearchCriteria() + if unreadOnly { + criteria.WithoutFlags = []string{imap.SeenFlag} + } + + var uids []uint32 + if unreadOnly { + uids, err = c.Search(criteria) + if err != nil { + return fmt.Errorf("failed to search %s: %v", acc.Email, err) + } + } + + seqset := new(imap.SeqSet) + if unreadOnly { + if len(uids) == 0 { + return nil + } + if len(uids) > count { + uids = uids[len(uids)-count:] + } + seqset.AddNum(uids...) + } else { + from := uint32(1) + if mbox.Messages > uint32(count) { + from = mbox.Messages - uint32(count) + 1 + } + to := mbox.Messages + seqset.AddRange(from, to) + } + + section := &imap.BodySectionName{} + items := []imap.FetchItem{section.FetchItem(), imap.FetchEnvelope} + + messages := make(chan *imap.Message) + done := make(chan error, 1) + + go func() { + done <- c.Fetch(seqset, items, messages) + }() + + accountData := fmt.Sprintf("\nšŸ“§ Account: %s\n", acc.Email) + hasEmails := false + + for msg := range messages { + hasEmails = true + accountData += "---\n" + sender := "unknown" + if len(msg.Envelope.From) > 0 { + sender = fmt.Sprintf("%s@%s", msg.Envelope.From[0].MailboxName, msg.Envelope.From[0].HostName) + } + accountData += fmt.Sprintf("From: %s\n", sender) + accountData += fmt.Sprintf("Subject: %s\n", msg.Envelope.Subject) + accountData += fmt.Sprintf("Date: %s\n", msg.Envelope.Date) + + r := msg.GetBody(section) + if r != nil { + mr, err := mail.CreateReader(r) + if err == nil { + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + break + } + switch h := p.Header.(type) { + case *mail.InlineHeader: + contentType, _, _ := h.ContentType() + if contentType == "text/plain" { + b, _ := io.ReadAll(p.Body) + accountData += fmt.Sprintf("\n%s\n", string(b)) + } } } } } } - sb.WriteString("\n") + + if err := <-done; err != nil { + return fmt.Errorf("fetch failed for %s: %v", acc.Email, err) + } + + if hasEmails { + sb.WriteString(accountData) + totalFound++ + } + return nil } - if err := <-done; err != nil { - return ErrorResult(fmt.Sprintf("Failed to fetch emails: %v", err)) + // Iterate over accounts + accountsToCheck := t.config.Accounts + // Fallback to legacy single account if Accounts is empty + if len(accountsToCheck) == 0 && t.config.IMAPServer != "" { + accountsToCheck = []config.EmailAccountConfig{{ + Email: t.config.IMAPUser, + IMAPServer: t.config.IMAPServer, + IMAPPort: t.config.IMAPPort, + IMAPUser: t.config.IMAPUser, + IMAPPassword: t.config.IMAPPassword, + }} + } + + for _, acc := range accountsToCheck { + if targetAccount != "" && !strings.EqualFold(acc.Email, targetAccount) { + continue + } + if err := checkAccount(acc); err != nil { + sb.WriteString(fmt.Sprintf("\nāŒ Error fetching from %s: %v\n", acc.Email, err)) + } + } + + if sb.Len() == 0 { + return SilentResult("No recent emails found.") } return &ToolResult{ ForLLM: sb.String(), - ForUser: "Read recent emails.", + ForUser: fmt.Sprintf("Checked emails for %d accounts.", len(accountsToCheck)), } } @@ -238,6 +260,10 @@ func (t *SendEmailTool) Parameters() map[string]interface{} { "type": "string", "description": "Email body content", }, + "from_account": map[string]interface{}{ + "type": "string", + "description": "Optional: Email address to send FROM. Must match a configured account.", + }, }, "required": []string{"to", "subject", "body"}, } @@ -251,13 +277,54 @@ func (t *SendEmailTool) Execute(ctx context.Context, args map[string]interface{} to, _ := args["to"].(string) subject, _ := args["subject"].(string) bodyContent, _ := args["body"].(string) + fromAccount, _ := args["from_account"].(string) if to == "" { return ErrorResult("Recipient (to) is required.") } - addr := fmt.Sprintf("%s:%d", t.config.SMTPServer, t.config.SMTPPort) - auth := smtp.PlainAuth("", t.config.SMTPUser, t.config.SMTPPassword, t.config.SMTPServer) + // Select account + var account config.EmailAccountConfig + found := false + + // Fallback legacy + if len(t.config.Accounts) == 0 && t.config.SMTPServer != "" { + account = config.EmailAccountConfig{ + Email: t.config.IMAPUser, // best guess + SMTPServer: t.config.SMTPServer, + SMTPPort: t.config.SMTPPort, + SMTPUser: t.config.SMTPUser, + SMTPPassword: t.config.SMTPPassword, + } + found = true + } else { + // Try to match requested account + if fromAccount != "" { + for _, acc := range t.config.Accounts { + if strings.EqualFold(acc.Email, fromAccount) { + account = acc + found = true + break + } + } + if !found { + return ErrorResult(fmt.Sprintf("Configured account not found for email: %s", fromAccount)) + } + } else { + // Default to first account + if len(t.config.Accounts) > 0 { + account = t.config.Accounts[0] + found = true + } + } + } + + if !found { + return ErrorResult("No valid email account configuration found.") + } + + addr := fmt.Sprintf("%s:%d", account.SMTPServer, account.SMTPPort) + auth := smtp.PlainAuth("", account.SMTPUser, account.SMTPPassword, account.SMTPServer) // RFC 822 format msg := fmt.Sprintf("To: %s\r\n"+ @@ -265,25 +332,28 @@ func (t *SendEmailTool) Execute(ctx context.Context, args map[string]interface{} "\r\n"+ "%s\r\n", to, subject, bodyContent) - logger.InfoCF("email", "Sending email via tool", map[string]interface{}{"to": to, "subject": subject}) + logger.InfoCF("email", "Sending email via tool", map[string]interface{}{ + "to": to, + "from": account.Email, + }) - err := t.sendMail(addr, auth, t.config.SMTPUser, []string{to}, []byte(msg)) + err := t.sendMail(addr, auth, account.SMTPUser, []string{to}, []byte(msg), account) if err != nil { return ErrorResult(fmt.Sprintf("Failed to send email: %v", err)) } return &ToolResult{ - ForLLM: fmt.Sprintf("Email sent successfully to %s", to), - ForUser: fmt.Sprintf("Sent email to %s", to), + ForLLM: fmt.Sprintf("Email sent successfully to %s using account %s", to, account.Email), + ForUser: fmt.Sprintf("Sent email to %s (via %s)", to, account.Email), } } -func (t *SendEmailTool) sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte) error { - // Handle TLS logic similar to EmailChannel - if t.config.SMTPPort == 465 { +func (t *SendEmailTool) sendMail(addr string, a smtp.Auth, from string, to []string, msg []byte, acc config.EmailAccountConfig) error { + // Handle TLS logic + if acc.SMTPPort == 465 { // Direct TLS tlsConfig := &tls.Config{ - ServerName: t.config.SMTPServer, + ServerName: acc.SMTPServer, } conn, err := tls.Dial("tcp", addr, tlsConfig) if err != nil { @@ -291,7 +361,7 @@ func (t *SendEmailTool) sendMail(addr string, a smtp.Auth, from string, to []str } defer conn.Close() - c, err := smtp.NewClient(conn, t.config.SMTPServer) + c, err := smtp.NewClient(conn, acc.SMTPServer) if err != nil { return err } diff --git a/pkg/tools/github.go b/pkg/tools/github.go new file mode 100644 index 000000000..ca54540af --- /dev/null +++ b/pkg/tools/github.go @@ -0,0 +1,219 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/google/go-github/v60/github" + "github.com/sipeed/picoclaw/pkg/config" +) + +type GitHubTool struct { + config config.GitHubConfig + client *github.Client +} + +func NewGitHubTool(cfg config.GitHubConfig) *GitHubTool { + t := &GitHubTool{ + config: cfg, + } + if cfg.Enabled { + if cfg.Token != "" { + t.client = github.NewClient(nil).WithAuthToken(cfg.Token) + } else { + t.client = github.NewClient(nil) + } + } + return t +} + +func (t *GitHubTool) Name() string { + return "github" +} + +func (t *GitHubTool) Description() string { + return "Interact with GitHub to list issues, pull requests, or read files from repositories." +} + +func (t *GitHubTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "action": map[string]interface{}{ + "type": "string", + "description": "Action to perform: list_issues, get_pr, read_file, list_repos", + "enum": []string{"list_issues", "get_pr", "read_file", "list_repos"}, + }, + "owner": map[string]interface{}{ + "type": "string", + "description": "Repository owner (user or organization)", + }, + "repo": map[string]interface{}{ + "type": "string", + "description": "Repository name", + }, + "number": map[string]interface{}{ + "type": "integer", + "description": "Issue or PR number (required for get_pr)", + }, + "path": map[string]interface{}{ + "type": "string", + "description": "File path in repository (required for read_file)", + }, + "count": map[string]interface{}{ + "type": "integer", + "description": "Number of items to list (default: 5, max: 20)", + }, + }, + "required": []string{"action"}, + } +} + +func (t *GitHubTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if !t.config.Enabled { + return ErrorResult("GitHub tool is not enabled in configuration.") + } + if t.client == nil { + return ErrorResult("GitHub client not initialized.") + } + + action, _ := args["action"].(string) + owner, _ := args["owner"].(string) + repo, _ := args["repo"].(string) + + // Defaults + count := 5 + if c, ok := args["count"].(float64); ok { + count = int(c) + } + if count > 20 { + count = 20 + } + + switch action { + case "list_issues": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for list_issues.") + } + return t.listIssues(ctx, owner, repo, count) + case "get_pr": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for get_pr.") + } + number, ok := args["number"].(float64) + if !ok { + return ErrorResult("Number is required for get_pr.") + } + return t.getPR(ctx, owner, repo, int(number)) + case "read_file": + if owner == "" || repo == "" { + return ErrorResult("Owner and repo are required for read_file.") + } + path, _ := args["path"].(string) + if path == "" { + return ErrorResult("Path is required for read_file.") + } + return t.readFile(ctx, owner, repo, path) + case "list_repos": + // If owner is provided, list user's repos, else authenticated user's repos + return t.listRepos(ctx, owner, count) + default: + return ErrorResult(fmt.Sprintf("Unknown action: %s", action)) + } +} + +func (t *GitHubTool) listIssues(ctx context.Context, owner, repo string, count int) *ToolResult { + opts := &github.IssueListByRepoOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: count}, + } + issues, _, err := t.client.Issues.ListByRepo(ctx, owner, repo, opts) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to list issues: %v", err)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Open issues in %s/%s:\n", owner, repo)) + for _, issue := range issues { + sb.WriteString(fmt.Sprintf("- #%d: %s (by %s)\n", issue.GetNumber(), issue.GetTitle(), issue.User.GetLogin())) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *GitHubTool) getPR(ctx context.Context, owner, repo string, number int) *ToolResult { + pr, _, err := t.client.PullRequests.Get(ctx, owner, repo, number) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to get PR #%d: %v", number, err)) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("PR #%d: %s\n", pr.GetNumber(), pr.GetTitle())) + sb.WriteString(fmt.Sprintf("State: %s\n", pr.GetState())) + sb.WriteString(fmt.Sprintf("User: %s\n", pr.User.GetLogin())) + if pr.Body != nil { + sb.WriteString(fmt.Sprintf("\nBody:\n%s\n", *pr.Body)) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} + +func (t *GitHubTool) readFile(ctx context.Context, owner, repo, path string) *ToolResult { + content, _, _, err := t.client.Repositories.GetContents(ctx, owner, repo, path, nil) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to read file: %v", err)) + } + + decoded, err := content.GetContent() + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to decode file content: %v", err)) + } + + return &ToolResult{ + ForLLM: decoded, + ForUser: fmt.Sprintf("Read file %s from %s/%s", path, owner, repo), + } +} + +func (t *GitHubTool) listRepos(ctx context.Context, user string, count int) *ToolResult { + opts := &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: count}, + Sort: "updated", + } + var repos []*github.Repository + var err error + + if user != "" { + repos, _, err = t.client.Repositories.List(ctx, user, opts) + } else { + // Authenticated user + repos, _, err = t.client.Repositories.List(ctx, "", opts) + } + + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to list repos: %v", err)) + } + + var sb strings.Builder + if user != "" { + sb.WriteString(fmt.Sprintf("Repositories for %s:\n", user)) + } else { + sb.WriteString("Your repositories:\n") + } + + for _, repo := range repos { + sb.WriteString(fmt.Sprintf("- %s: %s (⭐ %d)\n", repo.GetName(), repo.GetDescription(), repo.GetStargazersCount())) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +} diff --git a/pkg/tools/system.go b/pkg/tools/system.go new file mode 100644 index 000000000..d7cce5b20 --- /dev/null +++ b/pkg/tools/system.go @@ -0,0 +1,73 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/mem" +) + +type SystemTool struct{} + +func NewSystemTool() *SystemTool { + return &SystemTool{} +} + +func (t *SystemTool) Name() string { + return "system_stats" +} + +func (t *SystemTool) Description() string { + return "Get current system statistics including CPU, Memory, Disk usage, and Host info." +} + +func (t *SystemTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{}, + "required": []string{}, + } +} + +func (t *SystemTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + var sb strings.Builder + + // Host Info + hInfo, err := host.Info() + if err == nil { + sb.WriteString(fmt.Sprintf("Host: %s (%s %s)\n", hInfo.Hostname, hInfo.Platform, hInfo.PlatformVersion)) + sb.WriteString(fmt.Sprintf("Uptime: %s\n", time.Duration(hInfo.Uptime)*time.Second)) + } + + // CPU + percent, err := cpu.Percent(time.Second, false) + if err == nil && len(percent) > 0 { + sb.WriteString(fmt.Sprintf("CPU Usage: %.2f%%\n", percent[0])) + } + + // Memory + vMem, err := mem.VirtualMemory() + if err == nil { + usedGB := float64(vMem.Used) / 1024 / 1024 / 1024 + totalGB := float64(vMem.Total) / 1024 / 1024 / 1024 + sb.WriteString(fmt.Sprintf("Memory: %.2f GB / %.2f GB (%.2f%%)\n", usedGB, totalGB, vMem.UsedPercent)) + } + + // Disk (Root) + dUsage, err := disk.Usage("/") + if err == nil { + usedGB := float64(dUsage.Used) / 1024 / 1024 / 1024 + totalGB := float64(dUsage.Total) / 1024 / 1024 / 1024 + sb.WriteString(fmt.Sprintf("Disk (/): %.2f GB / %.2f GB (%.2f%%)\n", usedGB, totalGB, dUsage.UsedPercent)) + } + + return &ToolResult{ + ForLLM: sb.String(), + ForUser: sb.String(), + } +}