diff --git a/chatops-lark/README.md b/chatops-lark/README.md index a4ae471c..b6e96f5f 100644 --- a/chatops-lark/README.md +++ b/chatops-lark/README.md @@ -8,6 +8,15 @@ You can run it by following steps: ```yaml cherry-pick-invite.audit_webhook: cherry-pick-invite.github_token: + ask.llm.system_prompt: + ask.llm.model: + ask.llm.azure_config: + api_key: + base_url: + api_version: + ask.llm.mcp_servers: + : + base_url: ``` 2. Run the lark bot app: ```bash diff --git a/chatops-lark/go.mod b/chatops-lark/go.mod index 83903850..dcb02808 100644 --- a/chatops-lark/go.mod +++ b/chatops-lark/go.mod @@ -9,12 +9,16 @@ require ( github.com/go-resty/resty/v2 v2.16.5 github.com/google/go-github/v68 v68.0.0 github.com/larksuite/oapi-sdk-go/v3 v3.4.7 + github.com/mark3labs/mcp-go v0.17.0 + github.com/openai/openai-go v0.1.0-beta.4 github.com/rs/zerolog v1.33.0 gopkg.in/yaml.v3 v3.0.1 ) require ( dario.cat/mergo v1.0.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/bndr/gojenkins v1.1.0 // indirect @@ -34,9 +38,15 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.7.0 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect ) diff --git a/chatops-lark/go.sum b/chatops-lark/go.sum index 955f4aeb..84d05eed 100644 --- a/chatops-lark/go.sum +++ b/chatops-lark/go.sum @@ -1,5 +1,13 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= @@ -26,6 +34,8 @@ github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -50,8 +60,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/larksuite/oapi-sdk-go/v3 v3.4.7 h1:SWy0sL48+njjOqzHnAeAnG9vHtJwOtEDA12PNoGUyZU= github.com/larksuite/oapi-sdk-go/v3 v3.4.7/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/mark3labs/mcp-go v0.17.0 h1:5Ps6T7qXr7De/2QTqs9h6BKeZ/qdeUeGrgM5lPzi930= +github.com/mark3labs/mcp-go v0.17.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -68,8 +82,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/openai/openai-go v0.1.0-beta.4 h1:35KCMm7RP+/bt6cmD0iN8SG5uydSPfHJ1SMJ8mDCHw4= +github.com/openai/openai-go v0.1.0-beta.4/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -86,10 +102,22 @@ github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cA github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -101,8 +129,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -110,8 +138,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -125,6 +153,8 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -136,8 +166,8 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/chatops-lark/pkg/events/handler/ask.go b/chatops-lark/pkg/events/handler/ask.go new file mode 100644 index 00000000..a0a8c3a8 --- /dev/null +++ b/chatops-lark/pkg/events/handler/ask.go @@ -0,0 +1,196 @@ +package handler + +import ( + "context" + "fmt" + "strings" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/openai/openai-go" + "github.com/openai/openai-go/azure" + "github.com/rs/zerolog/log" +) + +const ( + cfgKeyAskLlmCfg = "ask.llm.azure_config" + cfgKeyAskLlmModel = "ask.llm.model" + cfgKeyAskLlmSystemPrompt = "ask.llm.system_prompt" + cfgKeyAskLlmMcpServers = "ask.llm.mcp_servers" + + ctxKeyLlmClient = "llm.client" + ctxKeyLlmModel = "llm.model" + ctxKeyLlmSystemPrompt = "llm.system_prompt" + ctxKeyLLmTools = "llm.tools" + ctxKeyMcpClients = "llm.mcp_clients" +) + +const ( + askHelpText = `missing question + +Usage: /ask + +Example: + /ask What is the on-call schedule for the infra team this week? + /ask How do I debug error code 1234 in service X? + +For more details, use: /ask --help +` + + askDetailedHelpText = `Usage: /ask + +Description: + Asks an AI assistant a question. The assistant may leverage internal tools (MCP context) + to provide relevant and up-to-date information alongside its general knowledge. + +Examples: + /ask What is the current status of the main production cluster? + /ask Explain the purpose of the 'widget-processor' microservice. + /ask Summarize the recent alerts for the database tier. + /ask How do I request access to the staging environment? + +Use '/ask --help' or '/ask -h' to see this message. +` +) + +// runCommandAsk handles the /ask command logic. +func runCommandAsk(ctx context.Context, args []string) (string, error) { + if len(args) == 0 { + // No question provided + return "", fmt.Errorf(askHelpText) + } + + // Check for help flags explicitly, as there are no subcommands + firstArg := args[0] + if firstArg == "-h" || firstArg == "--help" { + if len(args) == 1 { + return askDetailedHelpText, nil + } + // Allow asking help *about* something, e.g. /ask --help tools + // But for now, just treat any args after --help as part of the help request itself. + // Let's just return the detailed help for simplicity. + // Alternatively, could error out: + // return "", fmt.Errorf("unknown arguments after %s: %v", firstArg, args[1:]) + return askDetailedHelpText, nil + } + + // The entire argument list forms the question + question := strings.Join(args, " ") + + // AI/Tool interaction + // Here you would: + // 1. Parse the question for intent or specific tool requests (if applicable). + // 2. Potentially query MCP tools based on the question to gather context. + // 3. Format a prompt including the user's question and any gathered context. + // 4. Send the prompt to the configured LLM. + // 5. Receive the LLM's response. + // 6. Format the response for Lark. + result, err := processAskRequest(ctx, question) + if err != nil { + return "", fmt.Errorf("failed to process ask request: %w", err) + } + + return result, nil +} + +// processAskRequest will interact with the LLM and tools. +func processAskRequest(ctx context.Context, question string) (string, error) { + client := ctx.Value(ctxKeyLlmClient).(*openai.Client) + // openaiModel := ctx.Value(ctxKeyLlmModel).(shared.ChatModel) + systemPrompt := ctx.Value(ctxKeyLlmSystemPrompt).(string) + tools := ctx.Value(ctxKeyLLmTools).([]openai.ChatCompletionToolParam) + + llmParams := openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(question), + }, + Tools: tools, + Model: openai.ChatModelGPT4o, + Seed: openai.Int(1), + } + + clients := ctx.Value(ctxKeyMcpClients).([]mcpclient.MCPClient) + mcpToolMap := getFunctionMCPClientMap(ctx, clients) + + for { + completion, err := client.Chat.Completions.New(ctx, llmParams) + if err != nil { + log.Err(err).Msg("failed to create chat completion") + return "", fmt.Errorf("failed to create chat completion: %w", err) + } + + toolCalls := completion.Choices[0].Message.ToolCalls + if len(toolCalls) == 0 { + return completion.Choices[0].Message.Content, nil + } + + // If there is a was a function call, continue the conversation + llmParams.Messages = append(llmParams.Messages, completion.Choices[0].Message.ToParam()) + for _, toolCall := range toolCalls { + if client, ok := mcpToolMap[toolCall.Function.Name]; ok { + toolResData, err := processMcpToolCall(ctx, client, toolCall) + if err != nil { + log.Err(err).Msg("failed to process tool call") + return "", fmt.Errorf("failed to process tool call: %w", err) + } + + llmParams.Messages = append(llmParams.Messages, openai.ToolMessage(toolResData, toolCall.ID)) + log.Debug().Any("message", llmParams.Messages[len(llmParams.Messages)-1]).Msg("message") + } + } + } +} + +func setupAskCtx(ctx context.Context, config map[string]any, _ *CommandSender) context.Context { + // Initialize LLM client + var client openai.Client + { + log.Debug().Msg("initializing LLM client") + llmCfg := config[cfgKeyAskLlmCfg] + switch v := llmCfg.(type) { + case map[string]any: + apiKey := v["api_key"].(string) + endpointURL := v["base_url"].(string) + apiVesion := v["api_version"].(string) + client = openai.NewClient( + azure.WithAPIKey(apiKey), + azure.WithEndpoint(endpointURL, apiVesion), + ) + default: + client = openai.NewClient() + } + + log.Debug().Msg("initialized LLM client") + } + + // Initialize LLM tools + var mcpClients []mcpclient.MCPClient + var toolDeclarations []openai.ChatCompletionToolParam + { + mcpCfg := config[cfgKeyAskLlmMcpServers] + switch v := mcpCfg.(type) { + case map[string]any: + for name, cfg := range v { + url := cfg.(map[string]any)["base_url"].(string) + log.Debug().Str("name", name).Str("url", url).Msg("initializing MCP SSE client") + client, declarations, err := initializeMCPClient(ctx, name, url) + if err != nil { + log.Err(err).Str("name", name).Str("url", url).Msg("failed to initialize MCP SSE client") + continue + } + mcpClients = append(mcpClients, client) + toolDeclarations = append(toolDeclarations, declarations...) + log.Debug().Str("name", name).Str("url", url).Msg("initialized MCP SSE client") + } + } + } + + // Setup context + newCtx := context.WithValue(ctx, ctxKeyLlmClient, &client) + newCtx = context.WithValue(newCtx, ctxKeyLlmModel, config[cfgKeyAskLlmModel]) + newCtx = context.WithValue(newCtx, ctxKeyLlmSystemPrompt, config[cfgKeyAskLlmSystemPrompt]) + newCtx = context.WithValue(newCtx, ctxKeyLLmTools, toolDeclarations) + newCtx = context.WithValue(newCtx, ctxKeyMcpClients, mcpClients) + + return newCtx +} diff --git a/chatops-lark/pkg/events/handler/ask_mcp.go b/chatops-lark/pkg/events/handler/ask_mcp.go new file mode 100644 index 00000000..1c704db3 --- /dev/null +++ b/chatops-lark/pkg/events/handler/ask_mcp.go @@ -0,0 +1,170 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/openai/openai-go" + "github.com/openai/openai-go/shared/constant" + "github.com/rs/zerolog/log" +) + +const ( + sseMcpConnectTimeout = 5 * time.Second + + McpClientName = "ee-chatops-lark" + McpClientVersion = "1.0.0" +) + +// newSSEMcpClient creates a new SSE MCP client. +// connects it and initializes it. +func newSSEMcpClient(ctx context.Context, baseURL string) (*client.SSEMCPClient, error) { + client, err := client.NewSSEMCPClient(baseURL + "/sse") + if err != nil { + return nil, fmt.Errorf("Failed to create client: %v", err) + } + + // Connect + if err := client.Start(ctx); err != nil { + return nil, fmt.Errorf("Failed to start client: %v", err) + } + + // Initialize + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: McpClientName, + Version: McpClientVersion, + } + + _, err = client.Initialize(ctx, initRequest) + if err != nil { + return nil, fmt.Errorf("Failed to initialize: %v", err) + } + + return client, nil +} + +func processMcpToolCall(ctx context.Context, client client.MCPClient, toolCall openai.ChatCompletionMessageToolCall) ([]openai.ChatCompletionContentPartTextParam, error) { + // convert the params format from openai style to mcp style. + var params map[string]any + err := json.Unmarshal([]byte(toolCall.Function.Arguments), ¶ms) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal tool call arguments: %w", err) + } + + // call the MCP tool + contents, err := callMcpTool(ctx, client, toolCall.Function.Name, params) + if err != nil { + return nil, fmt.Errorf("failed to call MCP tool: %w", err) + } + + // convert the response format from mcp style to openai style. + var toolResData []openai.ChatCompletionContentPartTextParam + for _, content := range contents { + switch v := content.(type) { + case mcp.TextContent: + toolResData = append(toolResData, openai.ChatCompletionContentPartTextParam{ + Text: v.Text, + Type: constant.Text(v.Type), + }) + default: + return nil, fmt.Errorf("unknown content type: %T", v) + } + } + + return toolResData, nil +} + +func callMcpTool(ctx context.Context, client client.MCPClient, name string, args map[string]any) ([]mcp.Content, error) { + request := mcp.CallToolRequest{} + request.Params.Name = name + request.Params.Arguments = args + + result, err := client.CallTool(ctx, request) + if err != nil { + return nil, err + } + if result.IsError { + return nil, fmt.Errorf("tool call failed: %v", result.Result) + } + if len(result.Content) != 1 { + return nil, fmt.Errorf("empty content") + } + + return result.Content, nil +} + +func initializeMCPClient(ctx context.Context, name, url string) (client.MCPClient, []openai.ChatCompletionToolParam, error) { + // Create a logger with the provided name and URL + logger := log.With().Str("name", name).Str("url", url).Logger() + + // Create a new SSE MCP client + c, err := newSSEMcpClient(ctx, url) + if err != nil { + logger.Err(err).Msg("failed to create mcp client") + return nil, nil, err + } + + // List available tools from the MCP client + ret, err := c.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + logger.Err(err).Msg("failed to list tools") + return nil, nil, err + } + + // Check if any tools were found + if len(ret.Tools) == 0 { + logger.Warn().Msg("no tools found") + return nil, nil, nil + } + + // Prepare tool declarations for OpenAI ChatCompletion + var llmToolDeclaration []openai.ChatCompletionToolParam + for _, tool := range ret.Tools { + schemaBytes, err := json.Marshal(tool.InputSchema) + if err != nil { + logger.Err(err).Msg("marshal MCP tool input schema failed") + return nil, nil, err + } + toolParams := make(openai.FunctionParameters) + if err := json.Unmarshal(schemaBytes, &toolParams); err != nil { + logger.Err(err).Msg("unmarshal MCP tool input schema failed") + return nil, nil, err + } + + toolParam := openai.ChatCompletionToolParam{ + Type: "function", + Function: openai.FunctionDefinitionParam{ + Name: tool.Name, + Description: openai.String(tool.Description), + Parameters: toolParams, + }, + } + llmToolDeclaration = append(llmToolDeclaration, toolParam) + } + + return c, llmToolDeclaration, nil +} + +func getFunctionMCPClientMap(ctx context.Context, clients []client.MCPClient) map[string]client.MCPClient { + ret := make(map[string]client.MCPClient) + + for _, client := range clients { + result, err := client.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + log.Err(err).Msg("failed to list tools") + continue + } + + for _, tool := range result.Tools { + ret[tool.Name] = client + } + } + + return ret +} diff --git a/chatops-lark/pkg/events/handler/cherrypick.go b/chatops-lark/pkg/events/handler/cherrypick.go index d214a599..b392af25 100644 --- a/chatops-lark/pkg/events/handler/cherrypick.go +++ b/chatops-lark/pkg/events/handler/cherrypick.go @@ -84,3 +84,7 @@ func cherryPickInvite(prUrl string, collaboratorUsername string, gc *github.Clie return "", fmt.Errorf("Fail to invite collaborator, Please contact the EE team members for feedback.") } } + +func setupCtxCherryPickInvite(ctx context.Context, config map[string]any, _ *CommandSender) context.Context { + return context.WithValue(ctx, ctxKeyGithubToken, config["cherry-pick-invite.github_token"]) +} diff --git a/chatops-lark/pkg/events/handler/devbuild.go b/chatops-lark/pkg/events/handler/devbuild.go index 0dcff1a6..c81f28a6 100644 --- a/chatops-lark/pkg/events/handler/devbuild.go +++ b/chatops-lark/pkg/events/handler/devbuild.go @@ -71,3 +71,7 @@ func runCommandDevbuild(ctx context.Context, args []string) (string, error) { return "", fmt.Errorf("unknown subcommand: %s", subCmd) } } + +func setupCtxDevbuild(ctx context.Context, _ map[string]any, sender *CommandSender) context.Context { + return context.WithValue(ctx, ctxKeyLarkSenderEmail, sender.Email) +} diff --git a/chatops-lark/pkg/events/handler/root.go b/chatops-lark/pkg/events/handler/root.go index 352831b1..faca95a6 100644 --- a/chatops-lark/pkg/events/handler/root.go +++ b/chatops-lark/pkg/events/handler/root.go @@ -42,15 +42,15 @@ var commandConfigs = map[string]CommandConfig{ Handler: runCommandCherryPickInvite, NeedsAudit: true, AuditWebhook: "cherry-pick-invite.audit_webhook", - SetupContext: func(ctx context.Context, config map[string]any, sender *CommandSender) context.Context { - return context.WithValue(ctx, ctxKeyGithubToken, config["cherry-pick-invite.github_token"]) - }, + SetupContext: setupCtxCherryPickInvite, }, "/devbuild": { - Handler: runCommandDevbuild, - SetupContext: func(ctx context.Context, config map[string]any, sender *CommandSender) context.Context { - return context.WithValue(ctx, ctxKeyLarkSenderEmail, sender.Email) - }, + Handler: runCommandDevbuild, + SetupContext: setupCtxDevbuild, + }, + "/ask": { + Handler: runCommandAsk, + SetupContext: setupAskCtx, }, }