From 70de9f87961d70c2e7502bf0c126c6e4c721ebcd Mon Sep 17 00:00:00 2001 From: 0x0101010 <0x0101010.plus@gmail.com> Date: Thu, 4 Jun 2026 07:40:24 +0800 Subject: [PATCH 1/3] feat(lark): add Lark bot channel frontend via larksuite/oapi-sdk-go - New internal/larkbot package with session manager, event adapter, approval handler, and streaming output via SDK Channel module - LarkConfig with env-first credential resolution (app_id_env / app_secret_env) - Progressive streaming with local buffer + async flush (200ms ticker) - Interactive approval cards and ask select menus via OnCardAction - Multi-tenant session router with TTL expiry and concurrency limits - CLI: reasonix lark subcommand - Config: RenderTOML includes [lark] section for setup scaffolding - Clean output: model text in stream, tool summary + token count in one markdown message - Dependency: github.com/larksuite/oapi-sdk-go/v3 v3.9.4 - Tests: config resolution, session router lifecycle --- go.mod | 3 + go.sum | 75 +++-- internal/cli/cli.go | 3 + internal/cli/lark.go | 51 +++ internal/config/config.go | 112 +++++++ internal/config/lark_test.go | 162 ++++++++++ internal/config/render.go | 61 ++++ internal/larkbot/adapter/adapter.go | 386 +++++++++++++++++++++++ internal/larkbot/approval/approval.go | 348 ++++++++++++++++++++ internal/larkbot/bot.go | 327 +++++++++++++++++++ internal/larkbot/session/session.go | 313 ++++++++++++++++++ internal/larkbot/session/session_test.go | 159 ++++++++++ reasonix.example.toml | 31 ++ 13 files changed, 2011 insertions(+), 20 deletions(-) create mode 100644 internal/cli/lark.go create mode 100644 internal/config/lark_test.go create mode 100644 internal/larkbot/adapter/adapter.go create mode 100644 internal/larkbot/approval/approval.go create mode 100644 internal/larkbot/bot.go create mode 100644 internal/larkbot/session/session.go create mode 100644 internal/larkbot/session/session_test.go diff --git a/go.mod b/go.mod index df5e51c7d..068dc7d9d 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,9 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2/v2 v2.1.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/larksuite/oapi-sdk-go/v3 v3.9.4 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index 70736883e..23d5290ec 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,27 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= -charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= -github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78= -github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y= -github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= -github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74= -github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= @@ -39,14 +39,22 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtthmKw= -github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI= +github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -61,22 +69,49 @@ github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PK github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +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/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +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= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 19fb400bb..8ba548621 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -89,6 +89,9 @@ func Run(args []string, version string) int { case "doctor": configureCLIThemeFromConfigNoProbe() return doctorCommand(rest, version) + case "lark": + configureCLIThemeFromConfigNoProbe() + return runLark(rest) case "version", "--version", "-v": fmt.Println("reasonix", version) return 0 diff --git a/internal/cli/lark.go b/internal/cli/lark.go new file mode 100644 index 000000000..7cbc836f5 --- /dev/null +++ b/internal/cli/lark.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "flag" + "fmt" + "os" + "os/signal" + + "reasonix/internal/config" + "reasonix/internal/larkbot" +) + +func runLark(args []string) int { + fs := flag.NewFlagSet("lark", flag.ContinueOnError) + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load() + if err != nil { + fmt.Fprintln(os.Stderr, "config load:", err) + return 1 + } + + if !cfg.Lark.Enabled() { + fmt.Fprintln(os.Stderr, "lark bot is not configured β€” set app_id and app_secret in [lark] section of reasonix.toml") + return 1 + } + + bot, err := larkbot.New(larkbot.Options{ + AppID: cfg.Lark.ResolvedAppID(), + AppSecret: cfg.Lark.ResolvedAppSecret(), + Cfg: &cfg.Lark, + }) + if err != nil { + fmt.Fprintln(os.Stderr, "lark bot:", err) + return 1 + } + defer bot.Close() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + fmt.Println("reasonix lark β€” connected to Lark bot") + if err := bot.Run(ctx); err != nil { + fmt.Fprintln(os.Stderr, "lark bot:", err) + return 1 + } + return 0 +} diff --git a/internal/config/config.go b/internal/config/config.go index 26e2de71a..256b346de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -55,6 +55,8 @@ type Config struct { Codegraph CodegraphConfig `toml:"codegraph"` Statusline StatuslineConfig `toml:"statusline"` LSP LSPConfig `toml:"lsp"` + Lark LarkConfig `toml:"lark"` +} } // UIConfig controls CLI presentation-only settings. Desktop appearance is kept in @@ -586,6 +588,116 @@ func resolvedMCPTier(tier string) string { } } +// LarkConfig configures the Lark bot frontend. When absent the bot is +// disabled. Credentials follow the same pattern as providers: app_id_env / +// app_secret_env name an environment variable holding the value; the raw +// app_id / app_secret fields are a direct fallback. Both support ${ENV_VAR} +// expansion. All other fields are optional with sensible defaults. +type LarkConfig struct { + AppID string `toml:"app_id"` + AppSecret string `toml:"app_secret"` + AppIDEnv string `toml:"app_id_env"` + AppSecretEnv string `toml:"app_secret_env"` + + // Session management + SessionTTL string `toml:"session_ttl"` + MaxSessions int `toml:"max_sessions"` + + // Permission mode per context: "read-only", "interactive", "bypass" + GroupPermission string `toml:"group_permission"` + DMPermission string `toml:"dm_permission"` + + // SDK Channel policy + RequireMention bool `toml:"require_mention"` + RespondToMentionAll bool `toml:"respond_to_mention_all"` + AllowGroups []string `toml:"allow_groups"` + AllowDMs []string `toml:"allow_dms"` + + // Response formatting + ShowToolProgress bool `toml:"show_tool_progress"` + ShowReasoning bool `toml:"show_reasoning"` + MaxResponseLength int `toml:"max_response_length"` + ApprovalTimeout string `toml:"approval_timeout"` +} + +// ResolvedAppID returns the app ID via env var (app_id_env) or raw value with +// ${VAR} expansion on the fallback. +func (c LarkConfig) ResolvedAppID() string { + if c.AppIDEnv != "" { + if v := os.Getenv(c.AppIDEnv); v != "" { + return v + } + } + return ExpandVars(c.AppID) +} + +// ResolvedAppSecret returns the app secret with the same env-first strategy. +func (c LarkConfig) ResolvedAppSecret() string { + if c.AppSecretEnv != "" { + if v := os.Getenv(c.AppSecretEnv); v != "" { + return v + } + } + return ExpandVars(c.AppSecret) +} + +// Enabled reports whether the Lark bot is configured. +func (c LarkConfig) Enabled() bool { return c.ResolvedAppID() != "" && c.ResolvedAppSecret() != "" } + +// ResolvedSessionTTL returns the session TTL duration, defaulting to 1h. +func (c LarkConfig) ResolvedSessionTTL() string { + if c.SessionTTL == "" { + return "1h" + } + return c.SessionTTL +} + +// ResolvedMaxSessions returns the max concurrent sessions, 0 = unlimited. +func (c LarkConfig) ResolvedMaxSessions() int { + if c.MaxSessions < 0 { + return 0 + } + return c.MaxSessions +} + +// ResolvedGroupPermission returns the normalized group permission mode. +func (c LarkConfig) ResolvedGroupPermission() string { + return resolveLarkPermission(c.GroupPermission, "read-only") +} + +// ResolvedDMPermission returns the normalized DM permission mode. +func (c LarkConfig) ResolvedDMPermission() string { + return resolveLarkPermission(c.DMPermission, "interactive") +} + +// ResolvedMaxResponseLength returns the response truncation length, defaulting to 8000. +func (c LarkConfig) ResolvedMaxResponseLength() int { + if c.MaxResponseLength <= 0 { + return 8000 + } + return c.MaxResponseLength +} + +// ResolvedApprovalTimeout returns the approval timeout duration, defaulting to 5m. +func (c LarkConfig) ResolvedApprovalTimeout() string { + if c.ApprovalTimeout == "" { + return "5m" + } + return c.ApprovalTimeout +} + +func resolveLarkPermission(raw, defaultVal string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "read-only", "interactive", "bypass": + return strings.ToLower(strings.TrimSpace(raw)) + default: + if raw == "" { + return defaultVal + } + return defaultVal + } +} + func (c *Config) AutoStartPlugins() []PluginEntry { out := make([]PluginEntry, 0, len(c.Plugins)) for _, p := range c.Plugins { diff --git a/internal/config/lark_test.go b/internal/config/lark_test.go new file mode 100644 index 000000000..baa8a18a1 --- /dev/null +++ b/internal/config/lark_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "testing" +) + +func TestLarkConfigDefaults(t *testing.T) { + cfg := Default() + + if cfg.Lark.Enabled() { + t.Error("Lark should be disabled by default (empty credentials)") + } + + if got := cfg.Lark.ResolvedSessionTTL(); got != "1h" { + t.Errorf("default session TTL = %q, want %q", got, "1h") + } + + if got := cfg.Lark.ResolvedMaxSessions(); got != 0 { + t.Errorf("default max sessions = %d, want 0 (unlimited)", got) + } + + if got := cfg.Lark.ResolvedGroupPermission(); got != "read-only" { + t.Errorf("default group permission = %q, want %q", got, "read-only") + } + + if got := cfg.Lark.ResolvedDMPermission(); got != "interactive" { + t.Errorf("default DM permission = %q, want %q", got, "interactive") + } + + if got := cfg.Lark.ResolvedMaxResponseLength(); got != 8000 { + t.Errorf("default max response length = %d, want 8000", got) + } + + if got := cfg.Lark.ResolvedApprovalTimeout(); got != "5m" { + t.Errorf("default approval timeout = %q, want %q", got, "5m") + } +} + +func TestLarkConfigEnabled(t *testing.T) { + cfg := LarkConfig{} + if cfg.Enabled() { + t.Error("empty config should not be enabled") + } + + cfg = LarkConfig{AppID: "cli_xxx"} + if cfg.Enabled() { + t.Error("config with only app_id should not be enabled") + } + + cfg = LarkConfig{AppID: "cli_xxx", AppSecret: "secret"} + if !cfg.Enabled() { + t.Error("config with both app_id and app_secret should be enabled") + } +} + +func TestLarkConfigEnvResolution(t *testing.T) { + t.Setenv("LARK_TEST_ID", "cli_from_env") + t.Setenv("LARK_TEST_SECRET", "secret_from_env") + + cfg := LarkConfig{ + AppIDEnv: "LARK_TEST_ID", + AppSecretEnv: "LARK_TEST_SECRET", + } + if got := cfg.ResolvedAppID(); got != "cli_from_env" { + t.Errorf("AppID from env = %q, want %q", got, "cli_from_env") + } + if got := cfg.ResolvedAppSecret(); got != "secret_from_env" { + t.Errorf("AppSecret from env = %q, want %q", got, "secret_from_env") + } + if !cfg.Enabled() { + t.Error("should be enabled when env vars are set") + } +} + +func TestLarkConfigEnvFallback(t *testing.T) { + cfg := LarkConfig{ + AppIDEnv: "LARK_TEST_MISSING", + AppSecretEnv: "LARK_TEST_MISSING", + AppID: "cli_fallback", + AppSecret: "secret_fallback", + } + if got := cfg.ResolvedAppID(); got != "cli_fallback" { + t.Errorf("AppID fallback = %q, want %q", got, "cli_fallback") + } + if got := cfg.ResolvedAppSecret(); got != "secret_fallback" { + t.Errorf("AppSecret fallback = %q, want %q", got, "secret_fallback") + } +} + +func TestLarkConfigEnvVarExpansion(t *testing.T) { + t.Setenv("MY_ID", "cli_expanded") + cfg := LarkConfig{ + AppID: "${MY_ID}", + AppSecret: "direct_secret", + } + if got := cfg.ResolvedAppID(); got != "cli_expanded" { + t.Errorf("AppID expanded = %q, want %q", got, "cli_expanded") + } + if got := cfg.ResolvedAppSecret(); got != "direct_secret" { + t.Errorf("AppSecret direct = %q, want %q", got, "direct_secret") + } +} + +func TestLarkConfigCustomValues(t *testing.T) { + cfg := LarkConfig{ + AppID: "cli_abc", + AppSecret: "secret", + SessionTTL: "30m", + MaxSessions: 20, + GroupPermission: "interactive", + DMPermission: "bypass", + ShowReasoning: true, + MaxResponseLength: 4000, + ApprovalTimeout: "10m", + } + + if got := cfg.ResolvedSessionTTL(); got != "30m" { + t.Errorf("session TTL = %q, want %q", got, "30m") + } + + if got := cfg.ResolvedMaxSessions(); got != 20 { + t.Errorf("max sessions = %d, want 20", got) + } + + if got := cfg.ResolvedGroupPermission(); got != "interactive" { + t.Errorf("group permission = %q, want %q", got, "interactive") + } + + if got := cfg.ResolvedDMPermission(); got != "bypass" { + t.Errorf("DM permission = %q, want %q", got, "bypass") + } + + if got := cfg.ResolvedMaxResponseLength(); got != 4000 { + t.Errorf("max response length = %d, want 4000", got) + } + + if got := cfg.ResolvedApprovalTimeout(); got != "10m" { + t.Errorf("approval timeout = %q, want %q", got, "10m") + } +} + +func TestLarkConfigInvalidPermission(t *testing.T) { + cfg := LarkConfig{ + GroupPermission: "invalid", + DMPermission: "", + } + + if got := cfg.ResolvedGroupPermission(); got != "read-only" { + t.Errorf("invalid group permission should fall back to 'read-only', got %q", got) + } + + if got := cfg.ResolvedDMPermission(); got != "interactive" { + t.Errorf("empty DM permission should fall back to 'interactive', got %q", got) + } +} + +func TestLarkConfigNegativeMaxSessions(t *testing.T) { + cfg := LarkConfig{MaxSessions: -1} + if got := cfg.ResolvedMaxSessions(); got != 0 { + t.Errorf("negative max sessions = %d, want 0", got) + } +} diff --git a/internal/config/render.go b/internal/config/render.go index 7384a28f3..181a17baa 100644 --- a/internal/config/render.go +++ b/internal/config/render.go @@ -351,6 +351,67 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string { } } + if c.Lark.Enabled() { + b.WriteString("\n[lark]\n") + if c.Lark.AppIDEnv != "" { + fmt.Fprintf(&b, "app_id_env = %q\n", c.Lark.AppIDEnv) + } else if c.Lark.AppID != "" { + fmt.Fprintf(&b, "app_id = %q\n", c.Lark.AppID) + } + if c.Lark.AppSecretEnv != "" { + fmt.Fprintf(&b, "app_secret_env = %q\n", c.Lark.AppSecretEnv) + } else if c.Lark.AppSecret != "" { + fmt.Fprintf(&b, "app_secret = %q\n", c.Lark.AppSecret) + } + if c.Lark.SessionTTL != "" { + fmt.Fprintf(&b, "session_ttl = %q\n", c.Lark.SessionTTL) + } + if c.Lark.MaxSessions > 0 { + fmt.Fprintf(&b, "max_sessions = %d\n", c.Lark.MaxSessions) + } + if c.Lark.GroupPermission != "" { + fmt.Fprintf(&b, "group_permission = %q\n", c.Lark.ResolvedGroupPermission()) + } + if c.Lark.DMPermission != "" { + fmt.Fprintf(&b, "dm_permission = %q\n", c.Lark.ResolvedDMPermission()) + } + if c.Lark.RequireMention { + b.WriteString("require_mention = true\n") + } + if c.Lark.RespondToMentionAll { + b.WriteString("respond_to_mention_all = true\n") + } + if len(c.Lark.AllowGroups) > 0 { + fmt.Fprintf(&b, "allow_groups = %s\n", renderStringArray(c.Lark.AllowGroups)) + } + if len(c.Lark.AllowDMs) > 0 { + fmt.Fprintf(&b, "allow_dms = %s\n", renderStringArray(c.Lark.AllowDMs)) + } + if c.Lark.ShowToolProgress { + b.WriteString("show_tool_progress = true\n") + } + if c.Lark.ShowReasoning { + b.WriteString("show_reasoning = true\n") + } + if c.Lark.MaxResponseLength > 0 && c.Lark.MaxResponseLength != 8000 { + fmt.Fprintf(&b, "max_response_length = %d\n", c.Lark.MaxResponseLength) + } + if c.Lark.ApprovalTimeout != "" && c.Lark.ApprovalTimeout != "5m" { + fmt.Fprintf(&b, "approval_timeout = %q\n", c.Lark.ApprovalTimeout) + } + } else { + b.WriteString("\n# [lark]\n") + b.WriteString("# # Lark bot frontend: operate Reasonix through chat.\n") + b.WriteString("# # Credentials follow the provider pattern: app_id_env names the env var.\n") + b.WriteString("# app_id_env = \"LARK_APP_ID\"\n") + b.WriteString("# app_secret_env = \"LARK_APP_SECRET\"\n") + b.WriteString("# # session_ttl = \"1h\"\n") + b.WriteString("# # max_sessions = 50\n") + b.WriteString("# # group_permission = \"read-only\" # read-only | interactive | bypass\n") + b.WriteString("# # dm_permission = \"interactive\"\n") + b.WriteString("# # show_tool_progress = false # true = inline tool markers; false = summary at end\n") + } + return b.String() } diff --git a/internal/larkbot/adapter/adapter.go b/internal/larkbot/adapter/adapter.go new file mode 100644 index 000000000..020257070 --- /dev/null +++ b/internal/larkbot/adapter/adapter.go @@ -0,0 +1,386 @@ +package adapter + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "reasonix/internal/event" + "reasonix/internal/provider" + + channeltypes "github.com/larksuite/oapi-sdk-go/v3/channel/types" +) + +const ( + flushInterval = 200 * time.Millisecond +) + +type Options struct { + ChatID string + MessageID string + ShowReasoning bool + ShowToolProgress bool + MaxResponseLength int +} + +type toolRecord struct { + Name string + Output string + Err string + Truncated bool +} + +type EventAdapter struct { + ch channeltypes.Channel + opts Options + + streamCtrl channeltypes.StreamController + totalChars int + started bool + + buf strings.Builder + bufMu sync.Mutex + flushCh chan struct{} + done chan struct{} + + toolLog []toolRecord + promptTokens int + outputTokens int +} + +func New(ch channeltypes.Channel, opts Options) *EventAdapter { + if opts.MaxResponseLength <= 0 { + opts.MaxResponseLength = 8000 + } + return &EventAdapter{ + ch: ch, + opts: opts, + flushCh: make(chan struct{}, 1), + done: make(chan struct{}), + } +} + +func (a *EventAdapter) Flush(ctx context.Context) error { + a.bufMu.Lock() + text := a.buf.String() + a.buf.Reset() + a.bufMu.Unlock() + + if text != "" && a.streamCtrl != nil { + return a.streamCtrl.Append(ctx, text) + } + if a.streamCtrl != nil { + return a.streamCtrl.Flush(ctx) + } + return nil +} + +func (a *EventAdapter) ProcessEvents(ctx context.Context, events []event.Event) error { + for _, ev := range events { + switch ev.Kind { + case event.TurnStarted: + a.handleTurnStarted(ctx) + case event.Reasoning: + a.handleReasoning(ctx, ev.Text) + case event.Text: + a.handleText(ctx, ev.Text) + case event.ToolDispatch: + a.handleToolDispatch(ctx, ev.Tool) + case event.ToolProgress: + a.handleToolProgress(ctx, ev.Text) + case event.ToolResult: + a.handleToolResult(ctx, ev.Tool) + case event.Phase: + a.handlePhase(ctx, ev.Text) + case event.Notice: + a.handleNotice(ctx, ev) + case event.ApprovalRequest: + a.handleApprovalRequest(ctx, ev) + case event.Usage: + a.handleUsage(ev.Usage) + case event.TurnDone: + a.handleTurnDone(ctx, ev) + case event.CompactionStarted: + a.appendBuf("\n> πŸ—œοΈ *εŽ‹ηΌ©δΈŠδΈ‹ζ–‡δΈ­...*\n") + case event.CompactionDone: + a.appendBuf("\n> βœ… *δΈŠδΈ‹ζ–‡ε·²εŽ‹ηΌ©*\n") + } + } + return nil +} + +func (a *EventAdapter) startStream(ctx context.Context) error { + if a.started { + return nil + } + ctrl, err := a.ch.Stream(ctx, &channeltypes.SendInput{ + ChatID: a.opts.ChatID, + ReplyMessageID: a.opts.MessageID, + }) + if err != nil { + return err + } + a.streamCtrl = ctrl + a.started = true + a.totalChars = 0 + a.done = make(chan struct{}) + + go a.flushLoop(ctx) + + return nil +} + +func (a *EventAdapter) CloseAndRestart(ctx context.Context, replyMessageID string) error { + a.flushBuffer(ctx) + + if a.streamCtrl != nil { + _ = a.streamCtrl.Close(ctx) + a.streamCtrl = nil + } + if a.started { + a.started = false + close(a.done) + } + + if replyMessageID != "" { + a.opts.MessageID = replyMessageID + return a.startStream(ctx) + } + return nil +} + +func (a *EventAdapter) closeStream(ctx context.Context) { + if a.streamCtrl != nil { + _ = a.streamCtrl.Flush(ctx) + _ = a.streamCtrl.Close(ctx) + a.streamCtrl = nil + } + if a.started { + a.started = false + close(a.done) + } +} + +func (a *EventAdapter) flushLoop(ctx context.Context) { + ticker := time.NewTicker(flushInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + a.flushBuffer(ctx) + case <-a.done: + return + case <-ctx.Done(): + return + } + } +} + +func (a *EventAdapter) flushBuffer(ctx context.Context) { + a.bufMu.Lock() + text := a.buf.String() + a.buf.Reset() + a.bufMu.Unlock() + + if text == "" || a.streamCtrl == nil { + return + } + _ = a.streamCtrl.Append(ctx, text) +} + +func (a *EventAdapter) handleTurnStarted(ctx context.Context) { + a.streamCtrl = nil + a.started = false + a.totalChars = 0 + a.toolLog = nil + a.promptTokens = 0 + a.outputTokens = 0 + a.buf.Reset() + _ = a.startStream(ctx) +} + +func (a *EventAdapter) handleReasoning(ctx context.Context, text string) { + if !a.opts.ShowReasoning { + return + } + a.appendBuf(text) +} + +func (a *EventAdapter) handleText(ctx context.Context, text string) { + a.appendBuf(text) +} + +func (a *EventAdapter) handleToolDispatch(ctx context.Context, tool event.Tool) { + if a.opts.ShowToolProgress { + a.appendBuf("\n> ⏳ **" + tool.Name + "**\n") + return + } + a.toolLog = append(a.toolLog, toolRecord{Name: tool.Name}) +} + +func (a *EventAdapter) handleToolProgress(ctx context.Context, text string) { + if !a.opts.ShowToolProgress { + return + } + a.appendBuf(text) +} + +func (a *EventAdapter) handleToolResult(ctx context.Context, tool event.Tool) { + if a.opts.ShowToolProgress { + if tool.Err != "" { + a.appendBuf("\n> ❌ **" + tool.Name + "** β€” " + tool.Err + "\n") + } else { + a.appendBuf("\n> βœ… **" + tool.Name + "**\n") + } + return + } + if len(a.toolLog) > 0 { + last := &a.toolLog[len(a.toolLog)-1] + preview := tool.Output + if len(preview) > 500 { + preview = preview[:497] + "..." + } + last.Output = preview + last.Err = tool.Err + last.Truncated = tool.Truncated + } +} + +func (a *EventAdapter) handlePhase(ctx context.Context, label string) { + if !a.opts.ShowToolProgress { + return + } + a.appendBuf("\n> πŸ“‹ *" + label + "*\n") +} + +func (a *EventAdapter) handleNotice(ctx context.Context, ev event.Event) { + if ev.Level == event.LevelWarn { + _, _ = a.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: a.opts.ChatID, + Text: ev.Text, + }) + } else { + a.appendBuf(ev.Text) + } +} + +func (a *EventAdapter) handleUsage(usage *provider.Usage) { + if usage == nil { + return + } + a.promptTokens = usage.PromptTokens + a.outputTokens = usage.CompletionTokens +} + +func (a *EventAdapter) handleApprovalRequest(ctx context.Context, ev event.Event) { + a.flushBuffer(ctx) + if a.streamCtrl != nil { + _ = a.streamCtrl.Flush(ctx) + } +} + +func (a *EventAdapter) handleTurnDone(ctx context.Context, ev event.Event) { + a.flushBuffer(ctx) + + if ev.Err != nil { + a.closeStream(ctx) + _, _ = a.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: a.opts.ChatID, + Markdown: "> ⚠️ **ι”™θ――**: " + ev.Err.Error(), + }) + return + } + + a.closeStream(ctx) + + var parts []string + if !a.opts.ShowToolProgress && len(a.toolLog) > 0 { + parts = append(parts, formatToolSummary(a.toolLog)) + } + if a.promptTokens > 0 || a.outputTokens > 0 { + parts = append(parts, formatUsageFooter(a.promptTokens, a.outputTokens)) + } + if a.totalChars >= a.opts.MaxResponseLength { + parts = append(parts, "> response truncated") + } + if len(parts) > 0 { + _ = a.sendMarkdown(ctx, strings.Join(parts, "\n")) + } +} + +func (a *EventAdapter) sendMarkdown(ctx context.Context, md string) error { + _, err := a.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: a.opts.ChatID, + Markdown: md, + }) + return err +} + +func (a *EventAdapter) appendBuf(text string) { + a.bufMu.Lock() + defer a.bufMu.Unlock() + + textLen := len(text) + if a.totalChars+textLen > a.opts.MaxResponseLength { + remaining := a.opts.MaxResponseLength - a.totalChars + if remaining > 0 { + a.buf.WriteString(text[:remaining]) + a.totalChars += remaining + } + return + } + a.buf.WriteString(text) + a.totalChars += textLen +} + +func (a *EventAdapter) appendBufDirect(ctx context.Context, text string) { + if a.streamCtrl == nil { + return + } + textLen := len(text) + if a.totalChars+textLen > a.opts.MaxResponseLength { + remaining := a.opts.MaxResponseLength - a.totalChars + if remaining > 0 { + _ = a.streamCtrl.Append(ctx, text[:remaining]) + a.totalChars += remaining + } + return + } + _ = a.streamCtrl.Append(ctx, text) + a.totalChars += textLen +} + +func formatToolSummary(log []toolRecord) string { + var b strings.Builder + b.WriteString("---\n") + b.WriteString(fmt.Sprintf("**πŸ”§ ε·₯具调用 (%d)**\n", len(log))) + for i, t := range log { + status := "βœ…" + if t.Err != "" { + status = "❌" + } + b.WriteString(fmt.Sprintf("%d. %s `%s`", i+1, status, t.Name)) + if t.Err != "" { + b.WriteString(" β€” " + truncateForSummary(t.Err, 80)) + } else if t.Output != "" { + b.WriteString(": " + truncateForSummary(t.Output, 60)) + } + b.WriteString("\n") + } + return b.String() +} + +func formatUsageFooter(prompt, output int) string { + total := prompt + output + return fmt.Sprintf("---\nπŸ“Š **Token 用量**: %d (θΎ“ε…₯ %d + θΎ“ε‡Ί %d)", total, prompt, output) +} + +func truncateForSummary(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} diff --git a/internal/larkbot/approval/approval.go b/internal/larkbot/approval/approval.go new file mode 100644 index 000000000..9993cd749 --- /dev/null +++ b/internal/larkbot/approval/approval.go @@ -0,0 +1,348 @@ +package approval + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + channeltypes "github.com/larksuite/oapi-sdk-go/v3/channel/types" + + "reasonix/internal/control" + "reasonix/internal/event" +) + +type CardAction struct { + Action string `json:"action"` + ApprovalID string `json:"approval_id"` +} + +type pendingApproval struct { + replyCh chan approvalResult + chatID string + ctrl *control.Controller + timeout time.Duration + cardMessageID string +} + +type pendingAsk struct { + replyCh chan []event.AskAnswer + chatID string + ctrl *control.Controller + timeout time.Duration + answers map[string][]string + total int + cardMessageID string +} + +type approvalResult struct { + allow bool + session bool + persist bool +} + +type Handler struct { + mu sync.Mutex + pending map[string]*pendingApproval + pendingAsks map[string]*pendingAsk + ch channeltypes.Channel + ctrl *control.Controller + chatID string +} + +func NewHandler(ch channeltypes.Channel) *Handler { + return &Handler{ + ch: ch, + pending: map[string]*pendingApproval{}, + pendingAsks: map[string]*pendingAsk{}, + } +} + +func (h *Handler) HandleApproval(ctx context.Context, ctrl *control.Controller, chatID string, ev event.Event, timeout time.Duration) (string, error) { + approvalID := ev.Approval.ID + + h.mu.Lock() + replyCh := make(chan approvalResult, 1) + h.pending[approvalID] = &pendingApproval{ + replyCh: replyCh, + chatID: chatID, + ctrl: ctrl, + timeout: timeout, + } + h.mu.Unlock() + + card := buildApprovalCard(approvalID, ev.Approval.Tool, ev.Approval.Subject) + result, err := h.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: chatID, + Card: card, + }) + if err != nil { + h.mu.Lock() + delete(h.pending, approvalID) + h.mu.Unlock() + return "", fmt.Errorf("send approval card: %w", err) + } + cardMsgID := result.MessageID + + h.mu.Lock() + if pa, ok := h.pending[approvalID]; ok { + pa.cardMessageID = cardMsgID + } + h.mu.Unlock() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case result := <-replyCh: + ctrl.Approve(approvalID, result.allow, result.session, result.persist) + h.updateCard(ctx, chatID, approvalID, result) + return cardMsgID, nil + case <-timer.C: + h.mu.Lock() + delete(h.pending, approvalID) + h.mu.Unlock() + ctrl.Approve(approvalID, false, false, false) + h.updateCardWithMessage(ctx, chatID, "Timed out β€” denied", "yellow") + return cardMsgID, nil + case <-ctx.Done(): + h.mu.Lock() + delete(h.pending, approvalID) + h.mu.Unlock() + return cardMsgID, ctx.Err() + } +} + +func (h *Handler) HandleAsk(ctx context.Context, ctrl *control.Controller, chatID string, ev event.Event, timeout time.Duration) (string, error) { + askID := ev.Ask.ID + questions := ev.Ask.Questions + + h.mu.Lock() + replyCh := make(chan []event.AskAnswer, 1) + h.pendingAsks[askID] = &pendingAsk{ + replyCh: replyCh, + chatID: chatID, + ctrl: ctrl, + timeout: timeout, + answers: map[string][]string{}, + total: len(questions), + } + h.mu.Unlock() + + var cardMsgID string + for _, q := range questions { + card := buildAskCard(askID, q) + result, err := h.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: chatID, + Card: card, + }) + if err != nil { + h.mu.Lock() + delete(h.pendingAsks, askID) + h.mu.Unlock() + return "", fmt.Errorf("send ask card: %w", err) + } + cardMsgID = result.MessageID + } + + h.mu.Lock() + if pa, ok := h.pendingAsks[askID]; ok { + pa.cardMessageID = cardMsgID + } + h.mu.Unlock() + + timer := time.NewTimer(timeout) + defer timer.Stop() + + select { + case answers := <-replyCh: + ctrl.AnswerQuestion(askID, answers) + return cardMsgID, nil + case <-timer.C: + h.mu.Lock() + delete(h.pendingAsks, askID) + h.mu.Unlock() + return cardMsgID, nil + case <-ctx.Done(): + h.mu.Lock() + delete(h.pendingAsks, askID) + h.mu.Unlock() + return cardMsgID, ctx.Err() + } +} + +func (h *Handler) OnCardAction(ctx context.Context, ev *channeltypes.CardActionEvent) error { + action, _ := ev.Action.Value["action"].(string) + slog.Info("lark card action received", "action", action, "value", ev.Action.Value) + + if approvalID, _ := ev.Action.Value["approval_id"].(string); approvalID != "" { + slog.Info("lark approval card action", "approval_id", approvalID, "action", action) + h.mu.Lock() + pa, ok := h.pending[approvalID] + if !ok { + h.mu.Unlock() + slog.Warn("lark approval card action: no pending approval", "approval_id", approvalID) + return nil + } + delete(h.pending, approvalID) + h.mu.Unlock() + + var result approvalResult + switch action { + case "allow": + result = approvalResult{allow: true, session: false, persist: false} + case "deny": + result = approvalResult{allow: false, session: false, persist: false} + case "always_allow": + result = approvalResult{allow: true, session: true, persist: true} + default: + return nil + } + select { + case pa.replyCh <- result: + default: + } + return nil + } + + if askID, _ := ev.Action.Value["ask_id"].(string); askID != "" { + questionID, _ := ev.Action.Value["question_id"].(string) + if questionID == "" || action == "" { + return nil + } + + h.mu.Lock() + pa, ok := h.pendingAsks[askID] + if !ok { + h.mu.Unlock() + return nil + } + pa.answers[questionID] = append(pa.answers[questionID], action) + answered := len(pa.answers) + total := pa.total + h.mu.Unlock() + + if answered >= total { + h.mu.Lock() + if pa, ok := h.pendingAsks[askID]; ok { + delete(h.pendingAsks, askID) + var answers []event.AskAnswer + for qID, selected := range pa.answers { + answers = append(answers, event.AskAnswer{QuestionID: qID, Selected: selected}) + } + h.mu.Unlock() + select { + case pa.replyCh <- answers: + default: + } + return nil + } + h.mu.Unlock() + } + return nil + } + + return nil +} + +func (h *Handler) updateCard(ctx context.Context, chatID, approvalID string, result approvalResult) { + var msg string + var template string + if result.allow { + if result.persist { + msg = "Always allowed" + template = "green" + } else { + msg = "Approved" + template = "green" + } + } else { + msg = "Denied" + template = "red" + } + h.updateCardWithMessage(ctx, chatID, msg, template) +} + +func (h *Handler) updateCardWithMessage(ctx context.Context, chatID, msg, template string) { + card := fmt.Sprintf(`{"header":{"title":{"tag":"plain_text","content":"Tool Approval"},"template":"%s"},"elements":[{"tag":"div","text":{"tag":"plain_text","content":"%s"}}]}`, template, msg) + _, _ = h.ch.Send(ctx, &channeltypes.SendInput{ + ChatID: chatID, + Card: card, + }) +} + +func buildAskCard(askID string, q event.AskQuestion) string { + var sb strings.Builder + sb.WriteString(`{"header":{"title":{"tag":"plain_text","content":"`) + sb.WriteString(escapeJSON(q.Header)) + sb.WriteString(`"},"template":"blue"},"elements":[`) + sb.WriteString(`{"tag":"div","text":{"tag":"lark_md","content":"`) + sb.WriteString(escapeJSON(q.Prompt)) + sb.WriteString(`"}},{"tag":"action","actions":[`) + + for _, opt := range q.Options { + label := opt.Label + if opt.Description != "" { + label = label + " - " + opt.Description + } + sb.WriteString(`{"tag":"button","text":{"tag":"plain_text","content":"`) + sb.WriteString(escapeJSON(label)) + sb.WriteString(`"},"type":"default","value":{"action":"`) + sb.WriteString(escapeJSON(opt.Label)) + sb.WriteString(`","ask_id":"`) + sb.WriteString(askID) + sb.WriteString(`","question_id":"`) + sb.WriteString(q.ID) + sb.WriteString(`"}},`) + } + + s := sb.String() + s = s[:len(s)-1] + return s + `]}]}` +} + +func buildApprovalCard(approvalID, toolName, subject string) string { + var sb strings.Builder + sb.WriteString(`{"header":{"title":{"tag":"plain_text","content":"Tool Approval"},"template":"blue"},"elements":[`) + sb.WriteString(`{"tag":"div","text":{"tag":"lark_md","content":"**`) + sb.WriteString(escapeJSON(toolName)) + sb.WriteString(`**\n`) + sb.WriteString(escapeJSON(truncateSubject(subject, 200))) + sb.WriteString(`"}},{"tag":"action","actions":[`) + sb.WriteString(`{"tag":"button","text":{"tag":"plain_text","content":"Allow"},"type":"primary","value":{"action":"allow","approval_id":"`) + sb.WriteString(approvalID) + sb.WriteString(`"}},`) + sb.WriteString(`{"tag":"button","text":{"tag":"plain_text","content":"Deny"},"type":"danger","value":{"action":"deny","approval_id":"`) + sb.WriteString(approvalID) + sb.WriteString(`"}},`) + sb.WriteString(`{"tag":"button","text":{"tag":"plain_text","content":"Always Allow"},"type":"default","value":{"action":"always_allow","approval_id":"`) + sb.WriteString(approvalID) + sb.WriteString(`"}}`) + sb.WriteString(`]}]}`) + return sb.String() +} + +func escapeJSON(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + s = strings.ReplaceAll(s, "\r", `\r`) + s = strings.ReplaceAll(s, "\t", `\t`) + return s +} + +func truncateSubject(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max-3] + "..." +} + +func (h *Handler) HasPending(approvalID string) bool { + h.mu.Lock() + defer h.mu.Unlock() + _, ok := h.pending[approvalID] + return ok +} diff --git a/internal/larkbot/bot.go b/internal/larkbot/bot.go new file mode 100644 index 000000000..545bf5dbb --- /dev/null +++ b/internal/larkbot/bot.go @@ -0,0 +1,327 @@ +package larkbot + +import ( + "context" + "log/slog" + "strings" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkchannel "github.com/larksuite/oapi-sdk-go/v3/channel" + channeltypes "github.com/larksuite/oapi-sdk-go/v3/channel/types" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + dispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" + + "reasonix/internal/config" + "reasonix/internal/control" + "reasonix/internal/event" + "reasonix/internal/larkbot/adapter" + "reasonix/internal/larkbot/approval" + "reasonix/internal/larkbot/session" +) + +type Options struct { + AppID string + AppSecret string + LogLevel larkcore.LogLevel + Cfg *config.LarkConfig +} + +type Bot struct { + appID string + appSecret string + logLevel larkcore.LogLevel + cfg *config.LarkConfig + + client *lark.Client + wsClient *larkws.Client + ch channeltypes.Channel + approvalHandler *approval.Handler + + router *session.Router + cancelSweep context.CancelFunc +} + +func New(opts Options) (*Bot, error) { + if opts.LogLevel == 0 { + opts.LogLevel = larkcore.LogLevelInfo + } + + client := lark.NewClient(opts.AppID, opts.AppSecret, lark.WithLogLevel(opts.LogLevel)) + wsClient := larkws.NewClient(opts.AppID, opts.AppSecret, + larkws.WithLogLevel(opts.LogLevel), + larkws.WithEventHandler(dispatcher.NewEventDispatcher("", "")), + ) + ch := larkchannel.NewChannel(client, wsClient) + approvalHandler := approval.NewHandler(ch) + + sessionTTL, err := time.ParseDuration(opts.Cfg.ResolvedSessionTTL()) + if err != nil { + sessionTTL = 1 * time.Hour + } + + router := session.NewRouter(session.Options{ + GroupPermission: session.PermissionMode(opts.Cfg.ResolvedGroupPermission()), + DMPermission: session.PermissionMode(opts.Cfg.ResolvedDMPermission()), + SessionTTL: sessionTTL, + MaxSessions: opts.Cfg.ResolvedMaxSessions(), + }) + + b := &Bot{ + appID: opts.AppID, + appSecret: opts.AppSecret, + logLevel: opts.LogLevel, + cfg: opts.Cfg, + client: client, + wsClient: wsClient, + ch: ch, + approvalHandler: approvalHandler, + router: router, + } + + b.wireLifecycle() + b.applyPolicy() + b.wireMessageHandler() + b.wireCardActionHandler() + b.wireRejectHandler() + + return b, nil +} + +func (b *Bot) wireLifecycle() { + b.ch.OnReady(func() { + slog.Info("lark bot ready") + }) + + b.ch.OnError(func(err error) { + slog.Error("lark bot error", "err", err) + }) + + b.ch.OnReconnecting(func() { + slog.Info("lark bot reconnecting") + }) + + b.ch.OnReconnected(func() { + slog.Info("lark bot reconnected") + }) + + b.ch.OnDisconnected(func() { + slog.Info("lark bot disconnected") + }) +} + +func (b *Bot) applyPolicy() { + requireMention := b.cfg.RequireMention + respondToMentionAll := b.cfg.RespondToMentionAll + + b.ch.UpdatePolicy(channeltypes.PolicyConfig{ + GroupAllowlist: b.cfg.AllowGroups, + DMAllowlist: b.cfg.AllowDMs, + RequireMention: &requireMention, + RespondToMentionAll: &respondToMentionAll, + }) +} + +func (b *Bot) wireCardActionHandler() { + b.ch.OnCardAction(func(ctx context.Context, ev *channeltypes.CardActionEvent) error { + return b.approvalHandler.OnCardAction(ctx, ev) + }) +} + +func (b *Bot) wireRejectHandler() { + b.ch.OnReject(func(ctx context.Context, event *channeltypes.RejectEvent) error { + slog.Warn("lark message rejected", "chat_id", event.ChatID, "reason", event.Reason, "sender_id", event.SenderID) + return nil + }) +} + +func (b *Bot) wireMessageHandler() { + b.ch.OnMessage(func(ctx context.Context, msg *channeltypes.NormalizedMessage) error { + content := strings.TrimSpace(msg.Content) + slog.Info("lark message received", "chat_id", msg.ChatID, "chat_type", msg.ChatType, "user_id", msg.UserID) + if content == "" { + return nil + } + + if strings.HasPrefix(content, "/new") { + b.router.RemoveAndClose(msg.ChatID) + _ = b.sendText(msg.ChatID, msg.MessageID, "Session reset. Starting fresh conversation.") + return nil + } + + if strings.HasPrefix(content, "/model ") { + modelRef := strings.TrimSpace(strings.TrimPrefix(content, "/model")) + if modelRef == "" { + _ = b.sendText(msg.ChatID, msg.MessageID, "Usage: /model ") + return nil + } + if err := b.router.SwitchModel(ctx, msg.ChatID, modelRef); err != nil { + _ = b.sendText(msg.ChatID, msg.MessageID, "Failed to switch model: "+err.Error()) + return nil + } + _ = b.sendText(msg.ChatID, msg.MessageID, "Switched to "+modelRef) + return nil + } + + ctrl, sink, err := b.router.GetOrCreate(ctx, msg.ChatID, msg.ChatType) + if err != nil { + slog.Error("lark session create", "err", err, "chat_id", msg.ChatID) + _ = b.sendText(msg.ChatID, msg.MessageID, "Failed to create session: "+err.Error()) + return nil + } + + ctrl.Send(content) + go b.pumpEvents(ctrl, sink, msg.ChatID, msg.MessageID) + + return nil + }) +} + +func (b *Bot) pumpEvents(ctrl *control.Controller, sink *session.SinkAdapter, chatID, messageID string) { + adp := adapter.New(b.ch, adapter.Options{ + ChatID: chatID, + MessageID: messageID, + ShowReasoning: b.cfg.ShowReasoning, + ShowToolProgress: b.cfg.ShowToolProgress, + MaxResponseLength: b.cfg.ResolvedMaxResponseLength(), + }) + + timeout, err := time.ParseDuration(b.cfg.ResolvedApprovalTimeout()) + if err != nil { + timeout = 5 * time.Minute + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + for { + events, err := sink.WaitForEvent(ctx) + if err != nil { + return + } + + approvalIdx := -1 + askIdx := -1 + for i, ev := range events { + if ev.Kind == event.ApprovalRequest { + if ctrl.Bypass() { + ctrl.Approve(ev.Approval.ID, true, false, false) + continue + } + approvalIdx = i + break + } + if ev.Kind == event.AskRequest { + askIdx = i + break + } + if ev.Kind == event.TurnDone { + adp.ProcessEvents(ctx, events[:i+1]) + return + } + } + + if approvalIdx >= 0 { + adp.ProcessEvents(ctx, events[:approvalIdx]) + adp.CloseAndRestart(ctx, "") + + approvalCtx, approvalCancel := context.WithTimeout(ctx, timeout) + cardMsgID, err := b.approvalHandler.HandleApproval(approvalCtx, ctrl, chatID, events[approvalIdx], timeout) + approvalCancel() + + if cardMsgID != "" { + adp.CloseAndRestart(ctx, cardMsgID) + } + + if err != nil { + slog.Error("lark approval", "err", err) + } + + adp.ProcessEvents(ctx, events[approvalIdx+1:]) + continue + } + + if askIdx >= 0 { + adp.ProcessEvents(ctx, events[:askIdx]) + adp.CloseAndRestart(ctx, "") + + askCtx, askCancel := context.WithTimeout(ctx, timeout) + cardMsgID, err := b.approvalHandler.HandleAsk(askCtx, ctrl, chatID, events[askIdx], timeout) + askCancel() + + if cardMsgID != "" { + adp.CloseAndRestart(ctx, cardMsgID) + } + + if err != nil { + slog.Error("lark ask", "err", err) + } + + adp.ProcessEvents(ctx, events[askIdx+1:]) + continue + } + + adp.ProcessEvents(ctx, events) + + for _, ev := range events { + if ev.Kind == event.TurnDone { + return + } + } + } + }() + + <-done +} + +func (b *Bot) sendText(chatID, messageID, text string) error { + _, err := b.ch.Send(context.Background(), &channeltypes.SendInput{ + ChatID: chatID, + ReplyMessageID: messageID, + Text: text, + }) + return err +} + +func (b *Bot) Run(ctx context.Context) error { + sweepCtx, cancelSweep := context.WithCancel(ctx) + b.cancelSweep = cancelSweep + go b.sweepLoop(sweepCtx) + + if err := b.ch.Start(ctx); err != nil { + cancelSweep() + return err + } + + <-ctx.Done() + cancelSweep() + + if err := b.ch.Stop(context.Background()); err != nil { + slog.Warn("lark bot stop", "err", err) + } + return nil +} + +func (b *Bot) sweepLoop(ctx context.Context) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + b.router.SweepExpired(ctx) + case <-ctx.Done(): + return + } + } +} + +func (b *Bot) Close() { + b.router.CloseAll() + if b.cancelSweep != nil { + b.cancelSweep() + } +} diff --git a/internal/larkbot/session/session.go b/internal/larkbot/session/session.go new file mode 100644 index 000000000..5f3f8d347 --- /dev/null +++ b/internal/larkbot/session/session.go @@ -0,0 +1,313 @@ +package session + +import ( + "context" + "fmt" + "io" + "log/slog" + "sync" + "time" + + "reasonix/internal/agent" + "reasonix/internal/boot" + "reasonix/internal/control" + "reasonix/internal/event" +) + +type PermissionMode string + +const ( + PermissionReadOnly PermissionMode = "read-only" + PermissionInteractive PermissionMode = "interactive" + PermissionBypass PermissionMode = "bypass" +) + +type sessionHandle struct { + ctrl *control.Controller + sink *SinkAdapter + chatID string + chatType string + lastUsed time.Time +} + +type Options struct { + GroupPermission PermissionMode + DMPermission PermissionMode + SessionTTL time.Duration + MaxSessions int +} + +type Router struct { + mu sync.RWMutex + sessions map[string]*sessionHandle + opts Options +} + +func NewRouter(opts Options) *Router { + if opts.SessionTTL <= 0 { + opts.SessionTTL = 1 * time.Hour + } + return &Router{ + sessions: map[string]*sessionHandle{}, + opts: opts, + } +} + +func (r *Router) GetOrCreate(ctx context.Context, chatID, chatType string) (*control.Controller, *SinkAdapter, error) { + r.mu.RLock() + sh, ok := r.sessions[chatID] + r.mu.RUnlock() + + if ok { + sh.lastUsed = time.Now() + return sh.ctrl, sh.sink, nil + } + + r.mu.Lock() + defer r.mu.Unlock() + + sh, ok = r.sessions[chatID] + if ok { + sh.lastUsed = time.Now() + return sh.ctrl, sh.sink, nil + } + + r.evictIfNeededLocked() + + sink := &SinkAdapter{} + bo := boot.Options{ + RequireKey: false, + Sink: sink, + Stderr: io.Discard, + } + + ctrl, err := boot.Build(ctx, bo) + if err != nil { + return nil, nil, fmt.Errorf("build controller: %w", err) + } + + perm := r.resolvePermission(chatType) + r.applyPermission(ctrl, perm) + + ctrl.EnableInteractiveApproval() + + sh = &sessionHandle{ + ctrl: ctrl, + sink: sink, + chatID: chatID, + chatType: chatType, + lastUsed: time.Now(), + } + r.sessions[chatID] = sh + + slog.Info("lark session created", "chat_id", chatID, "chat_type", chatType, "permission", perm) + return sh.ctrl, sh.sink, nil +} + +func (r *Router) resolvePermission(chatType string) PermissionMode { + switch chatType { + case "group": + return r.opts.GroupPermission + case "p2p": + return r.opts.DMPermission + default: + return r.opts.DMPermission + } +} + +func (r *Router) applyPermission(ctrl *control.Controller, perm PermissionMode) { + switch perm { + case PermissionReadOnly: + ctrl.SetPlanMode(true) + case PermissionInteractive: + ctrl.SetPlanMode(false) + case PermissionBypass: + ctrl.SetPlanMode(false) + ctrl.SetBypass(true) + } +} + +func (r *Router) Get(chatID string) (*control.Controller, *SinkAdapter) { + r.mu.RLock() + defer r.mu.RUnlock() + sh, ok := r.sessions[chatID] + if !ok { + return nil, nil + } + sh.lastUsed = time.Now() + return sh.ctrl, sh.sink +} + +func (r *Router) RemoveAndClose(chatID string) { + r.mu.Lock() + defer r.mu.Unlock() + sh, ok := r.sessions[chatID] + if !ok { + return + } + if sh.ctrl != nil { + sh.ctrl.Close() + } + delete(r.sessions, chatID) + slog.Info("lark session removed", "chat_id", chatID) +} + +func (r *Router) SweepExpired(ctx context.Context) { + r.mu.Lock() + defer r.mu.Unlock() + + cutoff := time.Now().Add(-r.opts.SessionTTL) + var expired []string + for id, sh := range r.sessions { + if sh.lastUsed.Before(cutoff) { + expired = append(expired, id) + } + } + for _, id := range expired { + if sh := r.sessions[id]; sh != nil && sh.ctrl != nil { + sh.ctrl.Close() + } + delete(r.sessions, id) + slog.Info("lark session expired", "chat_id", id) + } +} + +func (r *Router) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.sessions) +} + +func (r *Router) evictIfNeededLocked() { + if r.opts.MaxSessions <= 0 { + return + } + if len(r.sessions) < r.opts.MaxSessions { + return + } + + var oldestID string + var oldestTime time.Time + for id, sh := range r.sessions { + if oldestID == "" || sh.lastUsed.Before(oldestTime) { + oldestID = id + oldestTime = sh.lastUsed + } + } + if oldestID != "" { + if sh := r.sessions[oldestID]; sh != nil && sh.ctrl != nil { + sh.ctrl.Close() + } + delete(r.sessions, oldestID) + slog.Info("lark session evicted (limit reached)", "chat_id", oldestID) + } +} + +func (r *Router) SwitchModel(ctx context.Context, chatID, modelRef string) error { + r.mu.Lock() + sh, ok := r.sessions[chatID] + r.mu.Unlock() + + if !ok { + return fmt.Errorf("no active session for this chat") + } + if sh.ctrl.Running() { + return fmt.Errorf("cannot switch model while a turn is running") + } + + chatType := sh.chatType + prevPath := sh.ctrl.SessionPath() + _ = sh.ctrl.Snapshot() + carried := sh.ctrl.History() + + sink := &SinkAdapter{} + bo := boot.Options{ + Model: modelRef, + RequireKey: false, + Sink: sink, + Stderr: io.Discard, + } + + newCtrl, err := boot.Build(ctx, bo) + if err != nil { + return fmt.Errorf("switch model: %w", err) + } + + perm := r.resolvePermission(chatType) + r.applyPermission(newCtrl, perm) + newCtrl.EnableInteractiveApproval() + + newPath := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) + if len(carried) > 0 { + newCtrl.Resume(&agent.Session{Messages: carried}, newPath) + } else if newPath != "" { + newCtrl.SetSessionPath(newPath) + } + + r.mu.Lock() + sh.ctrl.Close() + sh.ctrl = newCtrl + sh.sink = sink + r.mu.Unlock() + + slog.Info("lark session model switched", "chat_id", chatID, "model", modelRef) + return nil +} + +func (r *Router) CloseAll() { + r.mu.Lock() + defer r.mu.Unlock() + for id, sh := range r.sessions { + if sh.ctrl != nil { + sh.ctrl.Close() + } + delete(r.sessions, id) + } +} + +type SinkAdapter struct { + mu sync.Mutex + queue []event.Event + waitCh chan struct{} +} + +func (s *SinkAdapter) Emit(ev event.Event) { + s.mu.Lock() + defer s.mu.Unlock() + s.queue = append(s.queue, ev) + if s.waitCh != nil { + close(s.waitCh) + s.waitCh = nil + } +} + +func (s *SinkAdapter) Drain() []event.Event { + s.mu.Lock() + defer s.mu.Unlock() + out := s.queue + s.queue = nil + return out +} + +func (s *SinkAdapter) WaitForEvent(ctx context.Context) ([]event.Event, error) { + s.mu.Lock() + if len(s.queue) > 0 { + out := s.queue + s.queue = nil + s.mu.Unlock() + return out, nil + } + ch := make(chan struct{}) + s.waitCh = ch + s.mu.Unlock() + + select { + case <-ch: + return s.Drain(), nil + case <-ctx.Done(): + s.mu.Lock() + s.waitCh = nil + s.mu.Unlock() + return nil, ctx.Err() + } +} diff --git a/internal/larkbot/session/session_test.go b/internal/larkbot/session/session_test.go new file mode 100644 index 000000000..e96a2cb01 --- /dev/null +++ b/internal/larkbot/session/session_test.go @@ -0,0 +1,159 @@ +package session + +import ( + "context" + "testing" + "time" + + "reasonix/internal/event" +) + +func TestNewRouterDefaults(t *testing.T) { + r := NewRouter(Options{}) + if r.opts.SessionTTL != 1*time.Hour { + t.Errorf("default TTL = %v, want 1h", r.opts.SessionTTL) + } + if r.Count() != 0 { + t.Errorf("new router should have 0 sessions") + } +} + +func TestRouterCount(t *testing.T) { + r := NewRouter(Options{}) + + r.mu.Lock() + r.sessions["a"] = &sessionHandle{chatID: "a", chatType: "p2p", lastUsed: time.Now()} + r.sessions["b"] = &sessionHandle{chatID: "b", chatType: "group", lastUsed: time.Now()} + r.mu.Unlock() + + if r.Count() != 2 { + t.Errorf("count = %d, want 2", r.Count()) + } +} + +func TestRouterGet(t *testing.T) { + r := NewRouter(Options{}) + + ctrl, sink := r.Get("nonexistent") + if ctrl != nil || sink != nil { + t.Error("Get on nonexistent should return nil") + } +} + +func TestRouterSweepExpired(t *testing.T) { + r := NewRouter(Options{SessionTTL: 10 * time.Minute}) + + r.mu.Lock() + r.sessions["fresh"] = &sessionHandle{ + chatID: "fresh", + chatType: "p2p", + lastUsed: time.Now(), + } + r.sessions["stale"] = &sessionHandle{ + chatID: "stale", + chatType: "group", + lastUsed: time.Now().Add(-1 * time.Hour), + } + r.mu.Unlock() + + r.SweepExpired(context.Background()) + + if r.Count() != 1 { + t.Errorf("after sweep: %d sessions, want 1 (fresh only)", r.Count()) + } + _, sink := r.Get("stale") + if sink != nil { + t.Error("stale session should return nil") + } +} + +func TestRouterEviction(t *testing.T) { + r := NewRouter(Options{MaxSessions: 2}) + + r.mu.Lock() + r.sessions["oldest"] = &sessionHandle{ + chatID: "oldest", + chatType: "p2p", + lastUsed: time.Now().Add(-2 * time.Hour), + } + r.sessions["middle"] = &sessionHandle{ + chatID: "middle", + chatType: "group", + lastUsed: time.Now().Add(-1 * time.Hour), + } + r.mu.Unlock() + + r.mu.Lock() + r.evictIfNeededLocked() + r.mu.Unlock() + + if r.Count() != 1 { + t.Errorf("after eviction: %d sessions, want 1", r.Count()) + } +} + +func TestRouterNoEvictionUnderLimit(t *testing.T) { + r := NewRouter(Options{MaxSessions: 5}) + + r.mu.Lock() + r.sessions["a"] = &sessionHandle{chatID: "a", chatType: "p2p", lastUsed: time.Now()} + r.sessions["b"] = &sessionHandle{chatID: "b", chatType: "group", lastUsed: time.Now()} + r.mu.Unlock() + + r.mu.Lock() + r.evictIfNeededLocked() + r.mu.Unlock() + + if r.Count() != 2 { + t.Error("no eviction should happen when under limit") + } +} + +func TestRouterPermissionResolution(t *testing.T) { + r := NewRouter(Options{ + GroupPermission: PermissionReadOnly, + DMPermission: PermissionInteractive, + }) + + if got := r.resolvePermission("group"); got != PermissionReadOnly { + t.Errorf("group = %q, want %q", got, PermissionReadOnly) + } + if got := r.resolvePermission("p2p"); got != PermissionInteractive { + t.Errorf("p2p = %q, want %q", got, PermissionInteractive) + } + if got := r.resolvePermission("unknown"); got != PermissionInteractive { + t.Errorf("unknown = %q, want %q (fallback to DM)", got, PermissionInteractive) + } +} + +func TestSinkAdapterEmitDrain(t *testing.T) { + sink := &SinkAdapter{} + sink.Emit(event.Event{Kind: event.TurnStarted}) + sink.Emit(event.Event{Kind: event.Text, Text: "hello"}) + + events := sink.Drain() + if len(events) != 2 { + t.Fatalf("drain: %d events, want 2", len(events)) + } + if events[0].Kind != event.TurnStarted { + t.Error("first event should be TurnStarted") + } + + events = sink.Drain() + if len(events) != 0 { + t.Errorf("second drain: %d events, want 0", len(events)) + } +} + +func TestSinkAdapterWaitForEventPreloaded(t *testing.T) { + sink := &SinkAdapter{} + sink.Emit(event.Event{Kind: event.Text, Text: "pre"}) + + events, err := sink.WaitForEvent(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(events) != 1 { + t.Fatalf("got %d events, want 1", len(events)) + } +} diff --git a/reasonix.example.toml b/reasonix.example.toml index ca14191f6..023192112 100644 --- a/reasonix.example.toml +++ b/reasonix.example.toml @@ -148,3 +148,34 @@ enabled = [] # empty = all built-in tools # # the model uses them, then handshake on-demand. Default. # # background β€” placeholder + spawn at boot in a goroutine so an idle # # session warms up without blocking the user. + +# [lark] +# # Lark (飞书) bot frontend: operate Reasonix through chat. +# # Credentials follow the provider pattern: app_id_env names the env var; +# # app_id is a direct fallback (supports ${VAR} expansion). Never put secrets here. +# app_id_env = "LARK_APP_ID" +# app_secret_env = "LARK_APP_SECRET" +# +# # Or set directly (with env expansion): +# # app_id = "cli_xxx" +# # app_secret = "${LARK_APP_SECRET}" +# +# # Session management +# # session_ttl = "1h" # idle sessions expire after this duration +# # max_sessions = 50 # concurrent chat sessions, 0 = unlimited +# +# # Permission mode per context +# # group_permission = "read-only" # read-only | interactive | bypass +# # dm_permission = "interactive" # read-only | interactive | bypass +# +# # SDK Channel policy +# # require_mention = true +# # respond_to_mention_all = false +# # allow_groups = [] # empty = all allowed +# # allow_dms = [] # empty = all allowed +# +# # Response formatting +# # show_tool_progress = false # true = inline tool markers; false = summary at end +# # show_reasoning = false +# # max_response_length = 8000 +# # approval_timeout = "5m" From ce4a2eefaa204e2c52e7723464bb1ea94d4ef7c4 Mon Sep 17 00:00:00 2001 From: 0x0101010 <0x0101010.plus@gmail.com> Date: Thu, 4 Jun 2026 07:40:24 +0800 Subject: [PATCH 2/3] fix(config): preserve unrecognized TOML sections on save via toml.Marshal RenderTOML only serializes sections it knows about, so new sections (e.g. [lark]) were silently dropped when SaveTo overwrote the file. Switch SaveTo to use toml.Marshal for existing files, which auto- serializes all struct fields. First-run scaffolding still uses RenderTOML for commented house style. --- internal/config/edit.go | 24 +++++++++-- internal/config/merge_save_test.go | 64 ++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 internal/config/merge_save_test.go diff --git a/internal/config/edit.go b/internal/config/edit.go index ff4730665..2a60cb4ee 100644 --- a/internal/config/edit.go +++ b/internal/config/edit.go @@ -7,6 +7,8 @@ import ( "runtime" "strings" + "github.com/BurntSushi/toml" + "reasonix/internal/fileutil" "reasonix/internal/mcpdiag" "reasonix/internal/netclient" @@ -501,10 +503,12 @@ func validatePlugin(e PluginEntry) error { return nil } -// SaveTo writes the configuration to path as annotated TOML, atomically: it -// writes a sibling temp file then renames, so a crash mid-write can't leave a -// half-written reasonix.toml that fails to parse on next load. Parent directories -// are created as needed. +// SaveTo writes the configuration to path as TOML, atomically: it writes a +// sibling temp file then renames, so a crash mid-write can't leave a +// half-written reasonix.toml that fails to parse on next load. When the file +// already exists, the in-memory config is marshalled via toml.Marshal to +// preserve all sections (including those not handled by RenderTOML). First-run +// scaffolding uses RenderTOML for the commented house style. func (c *Config) SaveTo(path string) error { return c.SaveToScope(path, renderScopeForPath(path)) } @@ -542,6 +546,18 @@ func writeConfigFile(path, body string) error { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("save: create dir: %w", err) } + + var body string + if _, err := os.Stat(path); err == nil { + b, err := toml.Marshal(c) + if err != nil { + return fmt.Errorf("save: marshal: %w", err) + } + body = string(append([]byte("# Reasonix configuration.\n"), b...)) + } else { + body = RenderTOML(c) + } + tmp, err := os.CreateTemp(dir, ".reasonix.*.toml.tmp") if err != nil { return fmt.Errorf("save: create temp: %w", err) diff --git a/internal/config/merge_save_test.go b/internal/config/merge_save_test.go new file mode 100644 index 000000000..4023a8087 --- /dev/null +++ b/internal/config/merge_save_test.go @@ -0,0 +1,64 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestSaveToPreservesUnrecognizedSection(t *testing.T) { + dir := t.TempDir() + path := dir + "/config.toml" + + original := `default_model = "deepseek-flash" +[lark] +app_id_env = "LARK_APP_ID" +app_secret_env = "LARK_APP_SECRET" +` + if err := os.WriteFile(path, []byte(original), 0644); err != nil { + t.Fatal(err) + } + + cfg := LoadForEdit(path) + cfg.DefaultModel = "deepseek-pro" + if err := cfg.SaveTo(path); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if !strings.Contains(content, "[lark]") { + t.Errorf("[lark] section lost after save:\n%s", content) + } + if !strings.Contains(content, "app_id_env") { + t.Errorf("app_id_env lost after save:\n%s", content) + } + if !strings.Contains(content, "deepseek-pro") { + t.Errorf("modified default_model not preserved:\n%s", content) + } +} + +func TestSaveToNewFileUsesRenderTOML(t *testing.T) { + dir := t.TempDir() + path := dir + "/config.toml" + + cfg := Default() + cfg.DefaultModel = "deepseek-flash" + if err := cfg.SaveTo(path); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if !strings.Contains(content, "# Reasonix configuration") { + t.Error("new file should use RenderTOML with comments") + } +} From 808e6f404cea6112d94a1ac74fbe7caca35b4c7b Mon Sep 17 00:00:00 2001 From: 0x0101010 <0x0101010.plus@gmail.com> Date: Fri, 5 Jun 2026 20:36:03 +0800 Subject: [PATCH 3/3] docs(lark): add Lark bot configuration guide --- docs/lark-bot.md | 214 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/lark-bot.md diff --git a/docs/lark-bot.md b/docs/lark-bot.md new file mode 100644 index 000000000..b7ec5d258 --- /dev/null +++ b/docs/lark-bot.md @@ -0,0 +1,214 @@ +# Lark (飞书) Bot + +Reasonix can operate as a Lark bot, letting you interact with it through Lark +chat β€” group conversations or direct messages. Responses stream progressively, +tool approvals use interactive card buttons, and each chat maintains an isolated +session. + +## Prerequisites + +- A **self-built** Lark/Feishu app with bot capability enabled +- App credentials: `App ID` and `App Secret` from the [Feishu Developer Console](https://open.feishu.cn/app) + +## App Setup + +1. **Create a self-built app** at [open.feishu.cn](https://open.feishu.cn/app) +2. Go to **Features β†’ Bot** and enable the bot capability +3. Go to **Permissions** and add: + - `im:message` (read messages) + - `im:message:send_as_bot` (send messages as bot) +4. Go to **Events & Callbacks β†’ Event Subscription**: + - Subscription mode: **Receive events through persistent connection** (δ½Ώη”¨ι•ΏθΏžζŽ₯ζŽ₯ζ”ΆδΊ‹δ»Ά) + - Add event: `im.message.receive_v1` (receive messages) +5. Go to **Version Management β†’ Create version** and publish (requires admin approval) + +## Quick Start + +### 1. Set up credentials + +Add your credentials to the Reasonix credentials file at +`~/Library/Application Support/reasonix/credentials` (macOS) or +`~/.config/reasonix/credentials` (Linux): + +``` +LARK_APP_ID=cli_xxxx +LARK_APP_SECRET=your_app_secret +DEEPSEEK_API_KEY=sk-xxxx +``` + +Or export them as environment variables in your shell profile (`~/.zshrc`, `~/.bashrc`): + +```bash +export LARK_APP_ID="cli_xxxx" +export LARK_APP_SECRET="your_app_secret" +``` + +### 2. Configure `reasonix.toml` + +Add a `[lark]` section to your Reasonix config. The minimal config uses the +Provider-style env var pattern: + +```toml +# ~/Library/Application Support/reasonix/config.toml (macOS) +# or ~/.config/reasonix/config.toml (Linux) + +[lark] +app_id_env = "LARK_APP_ID" +app_secret_env = "LARK_APP_SECRET" +``` + +Alternatively, use `${VAR}` expansion: + +```toml +[lark] +app_id = "${LARK_APP_ID}" +app_secret = "${LARK_APP_SECRET}" +``` + +### 3. Start the bot + +```bash +reasonix lark +``` + +You should see: + +``` +reasonix lark β€” connected to Lark bot +lark bot ready +``` + +Now send a message to the bot in Lark β€” either in a group where the bot is a +member, or via direct message by searching for the app name. + +## Configuration Reference + +All fields are optional except credentials. Defaults are shown in comments. + +```toml +[lark] +# ── Credentials (required) ────────────────────────────────────── +# Env var style (recommended β€” matches Provider api_key_env pattern): +app_id_env = "LARK_APP_ID" +app_secret_env = "LARK_APP_SECRET" + +# Direct style (supports ${VAR} expansion): +# app_id = "${LARK_APP_ID}" +# app_secret = "${LARK_APP_SECRET}" + +# ── Session Management ────────────────────────────────────────── +# session_ttl = "1h" # idle sessions expire after this duration +# max_sessions = 50 # concurrent chat sessions, 0 = unlimited + +# ── Permissions ───────────────────────────────────────────────── +# Per-context mode: "read-only" | "interactive" | "bypass" +# group_permission = "read-only" # groups: plan mode, no writes +# dm_permission = "interactive" # DMs: prompt before writes + +# ── Message Policy ────────────────────────────────────────────── +# require_mention = false # when true, only respond to @bot in groups +# respond_to_mention_all = false # when true, respond to @all mentions +# allow_groups = [] # group allowlist (empty = all) +# allow_dms = [] # DM user allowlist (empty = all) + +# ── Output Formatting ─────────────────────────────────────────── +# show_tool_progress = false # true = inline tool markers; false = summary at end +# show_reasoning = false # include thinking/reasoning in output +# max_response_length = 8000 # truncate responses exceeding this (chars) +# approval_timeout = "5m" # auto-deny pending approvals after this +``` + +### Permission Modes + +| Mode | Behavior | +|------|----------| +| `read-only` | Bot runs in plan mode β€” it can read files and research, but cannot write or execute side-effecting commands | +| `interactive` | Bot prompts for approval via interactive cards before executing write operations | +| `bypass` | Bot auto-approves all tool calls without prompting (use with caution) | + +## Slash Commands + +In Lark chat, the bot responds to these slash commands: + +| Command | Description | +|---------|-------------| +| `/new` | Reset the session β€” starts a fresh conversation | +| `/model ` | Switch to a different configured model (e.g., `/model deepseek-pro`) | + +## How It Works + +``` +User sends message in Lark + β”‚ + β–Ό +Lark pushes event via WebSocket + β”‚ + β–Ό +SDK Channel.OnMessage β†’ session router + β”‚ + β–Ό +control.Controller processes the turn + β”‚ + β–Ό +Events stream back via ch.Stream() + β”‚ + β–Ό +Model text β†’ progressive Lark message +Tool calls β†’ accumulated silently + β”‚ + β–Ό +TurnDone β†’ tool summary + token count + sent as a markdown message +``` + +### Message Format + +Each turn produces up to two messages: + +1. **Streamed reply** β€” model text only, progressive +2. **Tool summary** (markdown) β€” only if tools were used: + + ``` + **3 tool calls** + `read_file` βœ…: main.go (2.1KB) + `grep` βœ…: 15 matches + `bash` βœ…: tests passed + + 11,750 tokens + ``` + +### Approval Flow + +When the model needs to run a write operation: + +1. Current stream is closed +2. An interactive card is sent with `[Allow]` `[Deny]` `[Always Allow]` buttons +3. User clicks β†’ approval handler resolves β†’ new stream opens +4. Clicking "Always Allow" persists the rule to your Reasonix config + +## Troubleshooting + +### Bot doesn't respond to messages + +1. Make sure the app is **published** (not just in development mode) +2. Verify the bot is a member of the chat (group) or you're in a DM with the bot +3. Check that `im.message.receive_v1` event subscription is added and event mode is "persistent connection" +4. Check the terminal logs for any errors + +### Authentication fails + +1. Verify `DEEPSEEK_API_KEY` is set in `~/.env` or `credentials` file +2. Run `reasonix setup` to reconfigure the model provider +3. Check that your API key is not expired + +### Session feels slow on first message + +The first message in a chat creates a new controller via `boot.Build`, which +loads config, tools, plugins, memory, and skills. This takes 2-5 seconds. +Subsequent messages reuse the session and respond quickly. + +### Config section lost after `reasonix setup` + +Reasonix v1.0+ uses merge-on-save for existing config files. If your `[lark]` +section disappears, make sure your Reasonix binary is up to date +(`reasonix --version`).