feat: Add Google Calendar integration, GitHub tools, and Email enhancements
This commit is contained in:
parent
7a6a973ef3
commit
4cf95a4276
12 changed files with 1254 additions and 390 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
29
go.mod
29
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
|
||||
)
|
||||
|
||||
|
|
|
|||
87
go.sum
87
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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
// 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,
|
||||
}}
|
||||
}
|
||||
|
||||
func NewEmailChannel(cfg config.EmailConfig, bus *bus.MessageBus) (*EmailChannel, error) {
|
||||
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 <-c.stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.checkMail()
|
||||
c.checkAllMail()
|
||||
case <-c.manualCheck:
|
||||
c.checkMail()
|
||||
c.checkAllMail()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -157,8 +157,21 @@ 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"`
|
||||
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"`
|
||||
|
|
@ -168,6 +181,7 @@ type EmailConfig struct {
|
|||
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"`
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +242,19 @@ type WebToolsConfig struct {
|
|||
|
||||
type ToolsConfig struct {
|
||||
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 {
|
||||
|
|
|
|||
247
pkg/tools/calendar.go
Normal file
247
pkg/tools/calendar.go
Normal file
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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{},
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,72 +70,58 @@ 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)
|
||||
targetAccount, _ := args["account"].(string)
|
||||
|
||||
var sb strings.Builder
|
||||
totalFound := 0
|
||||
|
||||
// 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 t.config.IMAPPort == 993 {
|
||||
if acc.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))
|
||||
return fmt.Errorf("failed to connect to %s: %v", acc.Email, 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))
|
||||
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 ErrorResult(fmt.Sprintf("Failed to select INBOX: %v", err))
|
||||
return fmt.Errorf("failed to select INBOX for %s: %v", acc.Email, err)
|
||||
}
|
||||
|
||||
if mbox.Messages == 0 {
|
||||
return SilentResult("Inbox is empty.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search criteria
|
||||
// Search
|
||||
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))
|
||||
return fmt.Errorf("failed to search %s: %v", acc.Email, 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.")
|
||||
return nil
|
||||
}
|
||||
if len(uids) > count {
|
||||
uids = uids[len(uids)-count:]
|
||||
|
|
@ -156,18 +146,19 @@ func (t *ReadEmailTool) Execute(ctx context.Context, args map[string]interface{}
|
|||
done <- c.Fetch(seqset, items, messages)
|
||||
}()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d emails (showing last %d):\n\n", mbox.Messages, count)) // Approx count
|
||||
accountData := fmt.Sprintf("\n📧 Account: %s\n", acc.Email)
|
||||
hasEmails := false
|
||||
|
||||
for msg := range messages {
|
||||
sb.WriteString("---\n")
|
||||
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)
|
||||
}
|
||||
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))
|
||||
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 {
|
||||
|
|
@ -185,23 +176,54 @@ func (t *ReadEmailTool) Execute(ctx context.Context, args map[string]interface{}
|
|||
contentType, _, _ := h.ContentType()
|
||||
if contentType == "text/plain" {
|
||||
b, _ := io.ReadAll(p.Body)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(string(b))
|
||||
accountData += fmt.Sprintf("\n%s\n", string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
return ErrorResult(fmt.Sprintf("Failed to fetch emails: %v", err))
|
||||
return fmt.Errorf("fetch failed for %s: %v", acc.Email, err)
|
||||
}
|
||||
|
||||
if hasEmails {
|
||||
sb.WriteString(accountData)
|
||||
totalFound++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
219
pkg/tools/github.go
Normal file
219
pkg/tools/github.go
Normal file
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
73
pkg/tools/system.go
Normal file
73
pkg/tools/system.go
Normal file
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue