This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 423d215 feat(channels): reach an agent from Telegram and WeChat
423d215 is described below
commit 423d2152d6af96bf1b73b8bfacb6d273104dffef
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 17:42:08 2026 +0800
feat(channels): reach an agent from Telegram and WeChat
---
controllers/im_channel.go | 139 ++++++++++
imbridge/bridge.go | 64 +++++
imbridge/command.go | 226 ++++++++++++++++
imbridge/manager.go | 166 ++++++++++++
imbridge/render.go | 89 +++++++
imbridge/router.go | 203 +++++++++++++++
imbridge/telegram.go | 242 ++++++++++++++++++
imbridge/util.go | 79 ++++++
imbridge/weixin.go | 256 +++++++++++++++++++
imbridge/weixin_login.go | 112 ++++++++
main.go | 5 +
object/im_channel.go | 207 +++++++++++++++
object/ormer.go | 6 +
routers/router.go | 8 +
web/package.json | 1 +
web/src/App.tsx | 2 +
web/src/backend/ImChannelBackend.ts | 42 +++
web/src/components/shared/brand-options.tsx | 6 +
web/src/locales/en/data.json | 25 ++
web/src/locales/zh/data.json | 25 ++
web/src/nav.ts | 4 +
web/src/pages/ChannelsPage.tsx | 382 ++++++++++++++++++++++++++++
web/src/types.ts | 34 +++
web/yarn.lock | 5 +
24 files changed, 2328 insertions(+)
diff --git a/controllers/im_channel.go b/controllers/im_channel.go
new file mode 100644
index 0000000..27db0cf
--- /dev/null
+++ b/controllers/im_channel.go
@@ -0,0 +1,139 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controllers
+
+import (
+ "encoding/json"
+
+ "github.com/apache/casbin-gateway/imbridge"
+ "github.com/apache/casbin-gateway/object"
+)
+
+// imChannelView is one stored channel and what its listener is doing.
+type imChannelView struct {
+ *object.ImChannel
+ Status imbridge.Status `json:"status"`
+}
+
+// GetImChannels lists the chat platforms Gateway listens on. Every credential
is
+// masked: a token stored here never goes back to the browser.
+func (c *ApiController) GetImChannels() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ channels, err := object.GetImChannels()
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ statuses := map[string]imbridge.Status{}
+ for _, status := range imbridge.Statuses() {
+ statuses[status.Name] = status
+ }
+
+ result := []*imChannelView{}
+ for _, channel := range channels {
+ result = append(result, &imChannelView{ImChannel:
channel.Masked(), Status: statuses[channel.Name]})
+ }
+ c.ResponseOk(result)
+}
+
+// UpdateImChannel writes one channel and starts or stops its listener to
match.
+func (c *ApiController) UpdateImChannel() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ channel := object.ImChannel{}
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &channel); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+ if err := object.SaveImChannel(&channel); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(channel.Masked())
+}
+
+func (c *ApiController) DeleteImChannel() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request); err != nil
{
+ c.ResponseError(err.Error())
+ return
+ }
+ if err := object.DeleteImChannel(request.Name); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk()
+}
+
+// StartWeixinLogin asks WeChat for a code to be scanned by the account that
will
+// carry the bot.
+func (c *ApiController) StartWeixinLogin() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ qrcode, err := imbridge.StartWeixinLogin(c.Ctx.Request.Context())
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ c.ResponseOk(qrcode)
+}
+
+// GetWeixinLoginStatus reports whether the code has been scanned. The token
that
+// comes back is written straight into the named channel rather than handed to
+// the browser: it is the credential, and it has no business making that trip.
+func (c *ApiController) GetWeixinLoginStatus() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ status, err := imbridge.PollWeixinLogin(c.Ctx.Request.Context(),
c.Input().Get("qrcode"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ if status.Token != "" {
+ channel, err := object.GetImChannel(c.Input().Get("channel"))
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if channel == nil {
+ c.ResponseError("no channel is waiting for this
sign-in")
+ return
+ }
+ channel.Token = status.Token
+ if err := object.SaveImChannel(channel); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ status.Token = ""
+ }
+ c.ResponseOk(status)
+}
diff --git a/imbridge/bridge.go b/imbridge/bridge.go
new file mode 100644
index 0000000..0759925
--- /dev/null
+++ b/imbridge/bridge.go
@@ -0,0 +1,64 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package imbridge carries a conversation between a chat platform and an agent
+// on this machine. The platforms differ only in how a message arrives and how
a
+// reply goes back; everything between - which session a chat is bound to,
what a
+// slash command means, how an answer is written out - is the same for all of
+// them and lives here.
+package imbridge
+
+import "context"
+
+// Message is one thing somebody said to the bot.
+type Message struct {
+ Platform string
+ // Channel is the stored channel this arrived on, which is what binds
the
+ // conversation to an agent and a working directory.
+ Channel string
+ // ChatId is where a reply goes.
+ ChatId string
+ UserId string
+ UserName string
+ Text string
+ // ReplyToken is what a platform demands back on the reply for it to
land in
+ // the right conversation. WeChat's context_token is one; Telegram
needs none.
+ ReplyToken string
+}
+
+// Reply is one thing the bot says. An empty Edit posts a new message;
otherwise
+// it replaces the one already posted under that id.
+type Reply struct {
+ Text string
+ Edit string
+}
+
+// Platform is one chat service. An implementation owns its own connection and
+// its own idea of an id, and nothing else about it reaches the rest of
Gateway.
+type Platform interface {
+ Name() string
+ // Receive delivers messages to handle until ctx is done. It is
expected to
+ // keep going through the ordinary network failures, and to return only
when
+ // the channel is stopped or the credential is refused.
+ Receive(ctx context.Context, handle func(Message)) error
+ // Send posts a reply and reports the id of the message it created,
empty on
+ // a platform that has no way to name one.
+ Send(message Message, reply Reply) (string, error)
+ // Typing shows that the agent is working, where the platform has a way
to.
+ Typing(message Message)
+ // CanEdit reports whether Send can replace a message it already posted,
+ // which is what decides between an answer that grows in place and one
that
+ // arrives when it is finished.
+ CanEdit() bool
+}
diff --git a/imbridge/command.go b/imbridge/command.go
new file mode 100644
index 0000000..8be7303
--- /dev/null
+++ b/imbridge/command.go
@@ -0,0 +1,226 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/agentsession"
+)
+
+const helpText = `/new — start a fresh conversation
+/status — which agent, where, how many turns
+/agents — the agents on this machine that can be driven
+/agent <id> — talk to a different one, from the next message
+/dir <path> — work somewhere else, from the next message
+/model <name> — pick a model, empty to use the agent's own
+/stop — stop what the agent is doing now
+/help — this`
+
+// command runs one slash command and reports what to say back. Changing where
a
+// conversation happens - the agent, the directory, the model - ends it and
opens
+// another: an agent is told its working directory when it starts, and
carrying a
+// half-finished conversation into a different project would be a lie.
+func (r *router) command(message Message, text string) string {
+ name, argument, _ := strings.Cut(strings.TrimPrefix(text, "/"), " ")
+ argument = strings.TrimSpace(argument)
+
+ switch strings.ToLower(name) {
+ case "help", "start":
+ return helpText
+
+ case "new":
+ r.closeSession(message)
+ return "Started fresh. The next message opens a new
conversation."
+
+ case "status":
+ return r.status(message)
+
+ case "agents":
+ return r.agents()
+
+ case "agent":
+ return r.rebind(message, func(spec *agentsession.Spec) string {
+ installation, err := findDrivable(argument)
+ if err != nil {
+ return err.Error()
+ }
+ spec.AgentId = installation.AgentId
+ spec.AgentPath = installation.Path
+ spec.Owner = installation.Owner
+ return ""
+ }, "Talking to "+argument+" from the next message.")
+
+ case "dir", "cd":
+ return r.rebind(message, func(spec *agentsession.Spec) string {
+ spec.WorkDir = argument
+ return ""
+ }, "Working in "+displayDir(argument)+" from the next message.")
+
+ case "model":
+ return r.rebind(message, func(spec *agentsession.Spec) string {
+ spec.Model = argument
+ return ""
+ }, modelReply(argument))
+
+ case "stop":
+ session, found := r.current(message)
+ if !found {
+ return "Nothing is running."
+ }
+ agentsession.Interrupt(session.Id)
+ return "Stopped."
+ }
+
+ return "No such command. /help lists them."
+}
+
+func (r *router) status(message Message) string {
+ session, found := r.current(message)
+ if !found {
+ return fmt.Sprintf("No conversation yet. The next message
starts one with %s in %s.",
+ nameOr(r.channel.AgentId, "no agent"),
displayDir(r.channel.WorkDir))
+ }
+
+ lines := []string{
+ "Agent: " + nameOr(session.AgentId, "none"),
+ "Directory: " + displayDir(session.WorkDir),
+ fmt.Sprintf("Turns: %d", session.Turns),
+ "State: " + string(session.State),
+ }
+ if session.Model != "" {
+ lines = append(lines, "Model: "+session.Model)
+ }
+ if !session.Resumable {
+ lines = append(lines, "This agent keeps nothing between
messages.")
+ }
+ if session.LastError != "" {
+ lines = append(lines, "Last error: "+session.LastError)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func (r *router) agents() string {
+ installations, err := agent.Scan(false)
+ if err != nil {
+ return err.Error()
+ }
+
+ lines := []string{}
+ for _, installation := range installations {
+ if agent.HeadlessOf(installation.AgentId) == nil {
+ continue
+ }
+ lines = append(lines, "/agent "+installation.AgentId+" —
"+installation.Name)
+ }
+ if len(lines) == 0 {
+ return "No agent on this machine publishes a mode Gateway can
drive."
+ }
+ return strings.Join(lines, "\n")
+}
+
+// rebind ends the conversation and opens another with one thing about it
+// changed. Returns what to say back.
+func (r *router) rebind(message Message, change func(*agentsession.Spec)
string, done string) string {
+ spec := agentsession.Spec{
+ AgentId: r.channel.AgentId,
+ AgentPath: r.channel.AgentPath,
+ Owner: r.channel.AgentUser,
+ WorkDir: r.channel.WorkDir,
+ Model: r.channel.Model,
+ Source: sourceOf(message),
+ }
+ if session, found := r.current(message); found {
+ spec.AgentId = session.AgentId
+ spec.AgentPath = session.AgentPath
+ spec.Owner = session.Owner
+ spec.WorkDir = session.WorkDir
+ spec.Model = session.Model
+ }
+
+ if failure := change(&spec); failure != "" {
+ return failure
+ }
+
+ r.closeSession(message)
+ if _, err := agentsession.Open(spec); err != nil {
+ return err.Error()
+ }
+ return done
+}
+
+func (r *router) current(message Message) (agentsession.Session, bool) {
+ source := sourceOf(message)
+ for _, candidate := range agentsession.List() {
+ if candidate.Source == source {
+ return candidate, true
+ }
+ }
+ return agentsession.Session{}, false
+}
+
+func (r *router) closeSession(message Message) {
+ if session, found := r.current(message); found {
+ agentsession.Close(session.Id)
+ forgetSession(session.Id)
+ }
+}
+
+// findDrivable resolves an agent id to an installation that can be driven.
+func findDrivable(agentId string) (agent.Installation, error) {
+ if agentId == "" {
+ return agent.Installation{}, fmt.Errorf("name an agent: /agents
lists them")
+ }
+ if agent.HeadlessOf(agentId) == nil {
+ return agent.Installation{}, fmt.Errorf("%s publishes no mode
Gateway can drive", agentId)
+ }
+
+ installations, err := agent.Scan(false)
+ if err != nil {
+ return agent.Installation{}, err
+ }
+ for _, installation := range installations {
+ if installation.AgentId == agentId {
+ return installation, nil
+ }
+ }
+ return agent.Installation{}, fmt.Errorf("%s is not installed here",
agentId)
+}
+
+func displayDir(workDir string) string {
+ if workDir == "" {
+ return "the home directory"
+ }
+ return workDir
+}
+
+func nameOr(agentId, fallback string) string {
+ if name := agent.DisplayNameOf(agentId); name != "" {
+ return name
+ }
+ if agentId != "" {
+ return agentId
+ }
+ return fallback
+}
+
+func modelReply(model string) string {
+ if model == "" {
+ return "Using the agent's own model from the next message."
+ }
+ return "Using " + model + " from the next message."
+}
diff --git a/imbridge/manager.go b/imbridge/manager.go
new file mode 100644
index 0000000..805d517
--- /dev/null
+++ b/imbridge/manager.go
@@ -0,0 +1,166 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "sync"
+ "time"
+)
+
+// forgetSession removes a stored session, so a conversation ended from a chat
+// does not come back after a restart. Storage lives outside this package, so
it
+// is handed in the same way the driver's own is.
+var forgetSession = func(string) {}
+
+// SetSessionForgetter installs how a closed conversation is removed from
store.
+func SetSessionForgetter(forget func(string)) {
+ forgetSession = forget
+}
+
+// Status is what one channel is doing, for the page that lists them.
+type Status struct {
+ Name string `json:"name"`
+ // Running says the poll loop is up. It is not a promise that the
credential
+ // works: that only shows once the platform answers, in Error.
+ Running bool `json:"running"`
+ Error string `json:"error,omitempty"`
+ StartTime string `json:"startTime,omitempty"`
+}
+
+type listener struct {
+ channel Channel
+ cancel context.CancelFunc
+ status Status
+}
+
+var manager = struct {
+ sync.Mutex
+ listeners map[string]*listener
+}{listeners: map[string]*listener{}}
+
+// Reload brings the running channels in line with the stored ones: what is
gone
+// is stopped, what changed is restarted, and the rest is left alone so an open
+// poll is not dropped for nothing.
+func Reload(channels []Channel) {
+ manager.Lock()
+ defer manager.Unlock()
+
+ wanted := map[string]Channel{}
+ for _, channel := range channels {
+ wanted[channel.Name] = channel
+ }
+
+ for name, running := range manager.listeners {
+ channel, keep := wanted[name]
+ if keep && sameChannel(channel, running.channel) {
+ continue
+ }
+ running.cancel()
+ delete(manager.listeners, name)
+ }
+
+ for name, channel := range wanted {
+ if _, running := manager.listeners[name]; running {
+ continue
+ }
+ start(name, channel)
+ }
+}
+
+// Stop ends every channel, for a shutdown.
+func Stop() {
+ manager.Lock()
+ defer manager.Unlock()
+
+ for name, running := range manager.listeners {
+ running.cancel()
+ delete(manager.listeners, name)
+ }
+}
+
+// Statuses is what every channel is doing.
+func Statuses() []Status {
+ manager.Lock()
+ defer manager.Unlock()
+
+ result := []Status{}
+ for _, running := range manager.listeners {
+ result = append(result, running.status)
+ }
+ return result
+}
+
+// start runs one channel. The caller holds the lock.
+func start(name string, channel Channel) {
+ platform, err := platformOf(channel)
+ if err != nil {
+ manager.listeners[name] = &listener{
+ channel: channel,
+ cancel: func() {},
+ status: Status{Name: name, Error: err.Error()},
+ }
+ return
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ entry := &listener{
+ channel: channel,
+ cancel: cancel,
+ status: Status{Name: name, Running: true, StartTime:
time.Now().Format(time.RFC3339)},
+ }
+ manager.listeners[name] = entry
+
+ router := newRouter(channel, platform)
+ go func() {
+ err := platform.Receive(ctx, router.handle)
+ manager.Lock()
+ defer manager.Unlock()
+ // A channel restarted while this one was winding down has
already taken
+ // the slot, and its status is not this one's to overwrite.
+ if manager.listeners[name] != entry {
+ return
+ }
+ entry.status.Running = false
+ if err != nil && ctx.Err() == nil {
+ entry.status.Error = err.Error()
+ }
+ }()
+}
+
+// sameChannel reports whether nothing a listener depends on has changed. A
+// Channel carries a slice, so it cannot simply be compared.
+func sameChannel(left, right Channel) bool {
+ return left.Platform == right.Platform && left.Token == right.Token &&
+ left.AgentId == right.AgentId && left.AgentPath ==
right.AgentPath &&
+ left.AgentUser == right.AgentUser && left.WorkDir ==
right.WorkDir &&
+ left.Model == right.Model && slices.Equal(left.AllowedUsers,
right.AllowedUsers)
+}
+
+func platformOf(channel Channel) (Platform, error) {
+ if channel.Token == "" {
+ return nil, fmt.Errorf("the %s channel has no credential yet",
channel.Name)
+ }
+
+ switch channel.Platform {
+ case PlatformTelegram:
+ return NewTelegram(channel.Name, channel.Token), nil
+ case PlatformWeixin:
+ return NewWeixin(channel.Name, channel.Token), nil
+ }
+ return nil, fmt.Errorf("no chat platform named %q", channel.Platform)
+}
diff --git a/imbridge/render.go b/imbridge/render.go
new file mode 100644
index 0000000..7d1d200
--- /dev/null
+++ b/imbridge/render.go
@@ -0,0 +1,89 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "strings"
+
+ "github.com/apache/casbin-gateway/agentsession"
+)
+
+// answerBuffer gathers one turn into the single message a chat shows. Tool
calls
+// are kept in the order they happened, between the prose around them, because
+// what an agent did is most of what somebody watching wants to see.
+type answerBuffer struct {
+ parts []string
+ // dirty says something has arrived since the last time this was
written out.
+ dirty bool
+}
+
+func (b *answerBuffer) add(event agentsession.Event) {
+ switch event.Type {
+ case agentsession.EventText:
+ if event.Text == "" {
+ return
+ }
+ // Prose arrives in fragments, so it is joined onto the piece
before it
+ // rather than starting a new one.
+ if len(b.parts) > 0 &&
!strings.HasPrefix(b.parts[len(b.parts)-1], toolMarker) {
+ b.parts[len(b.parts)-1] += event.Text
+ } else {
+ b.parts = append(b.parts, event.Text)
+ }
+ case agentsession.EventToolUse:
+ b.parts = append(b.parts, toolMarker+toolLine(event))
+ case agentsession.EventError:
+ if event.Text != "" {
+ b.parts = append(b.parts, "\n⚠ "+event.Text)
+ }
+ default:
+ return
+ }
+ b.dirty = true
+}
+
+// toolMarker starts the pieces that are a tool rather than prose, so a
fragment
+// of prose is not appended onto one.
+const toolMarker = "· "
+
+// toolInputLimit keeps a command in the message without the message becoming
the
+// command.
+const toolInputLimit = 120
+
+func toolLine(event agentsession.Event) string {
+ name := event.ToolName
+ if name == "" {
+ name = "tool"
+ }
+ input := strings.TrimSpace(strings.ReplaceAll(event.ToolInput, "\n", "
"))
+ if runes := []rune(input); len(runes) > toolInputLimit {
+ input = string(runes[:toolInputLimit]) + "..."
+ }
+ if input == "" {
+ return name
+ }
+ return name + " " + input
+}
+
+// changed reports whether anything new has arrived, and clears the flag.
+func (b *answerBuffer) changed() bool {
+ was := b.dirty
+ b.dirty = false
+ return was
+}
+
+func (b *answerBuffer) text() string {
+ return strings.TrimSpace(strings.Join(b.parts, "\n"))
+}
diff --git a/imbridge/router.go b/imbridge/router.go
new file mode 100644
index 0000000..b5e3a73
--- /dev/null
+++ b/imbridge/router.go
@@ -0,0 +1,203 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "fmt"
+ "math"
+ "slices"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/agentsession"
+)
+
+// Channel is one chat platform Gateway is listening on, and what a
conversation
+// there is bound to. It is filled in from storage, which lives outside this
+// package so that carrying a chat does not depend on the database.
+type Channel struct {
+ Name string
+ Platform string
+ Token string
+
+ AgentId string
+ AgentPath string
+ AgentUser string
+ WorkDir string
+ Model string
+
+ // AllowedUsers are the platform ids that may drive the agent. An empty
list
+ // lets anybody who finds the bot drive it, which is only right while
the bot
+ // is not published anywhere.
+ AllowedUsers []string
+}
+
+func (channel Channel) allows(userId string) bool {
+ return len(channel.AllowedUsers) == 0 ||
slices.Contains(channel.AllowedUsers, userId)
+}
+
+// sourceOf names one chat in the way a session records who opened it. It is
also
+// how a session is found again after a restart, so no second table is needed
to
+// remember which conversation belongs to which chat.
+func sourceOf(message Message) string {
+ return fmt.Sprintf("im:%s:%s:%s", message.Platform, message.Channel,
message.ChatId)
+}
+
+const (
+ // editInterval is how often a growing answer is rewritten on a
platform that
+ // can edit. Often enough to look live, rarely enough not to be rate
limited.
+ editInterval = 2 * time.Second
+ // typingInterval is how often a platform is reminded that the agent is
still
+ // working, for one that cannot show a partial answer.
+ typingInterval = 4 * time.Second
+)
+
+// router carries the conversations of one channel.
+type router struct {
+ channel Channel
+ platform Platform
+
+ // busy keeps one chat from starting a second turn while the first is
still
+ // running, which every one of these agents would refuse anyway.
+ mu sync.Mutex
+ busy map[string]bool
+}
+
+func newRouter(channel Channel, platform Platform) *router {
+ return &router{channel: channel, platform: platform, busy:
map[string]bool{}}
+}
+
+func (r *router) handle(message Message) {
+ if !r.channel.allows(message.UserId) {
+ return
+ }
+
+ text := strings.TrimSpace(message.Text)
+ if strings.HasPrefix(text, "/") {
+ r.reply(message, r.command(message, text))
+ return
+ }
+ if text == "" {
+ return
+ }
+
+ r.mu.Lock()
+ if r.busy[message.ChatId] {
+ r.mu.Unlock()
+ r.reply(message, "The agent is still working on the last one.
/stop ends it.")
+ return
+ }
+ r.busy[message.ChatId] = true
+ r.mu.Unlock()
+
+ go func() {
+ defer func() {
+ r.mu.Lock()
+ delete(r.busy, message.ChatId)
+ r.mu.Unlock()
+ }()
+ r.ask(message, text)
+ }()
+}
+
+// ask hands one message to the agent and writes the answer back as it arrives.
+func (r *router) ask(message Message, prompt string) {
+ session, err := r.session(message)
+ if err != nil {
+ r.reply(message, "This chat has no agent to talk to:
"+err.Error())
+ return
+ }
+
+ // Everything already on the feed belongs to turns that were written out
+ // long ago, so this one starts from now rather than replaying them.
+ subscriberId, feed, _, err := agentsession.Subscribe(session.Id,
math.MaxInt64)
+ if err != nil {
+ r.reply(message, err.Error())
+ return
+ }
+ defer agentsession.Unsubscribe(session.Id, subscriberId)
+
+ if _, err := agentsession.Send(session.Id, prompt); err != nil {
+ r.reply(message, err.Error())
+ return
+ }
+ r.platform.Typing(message)
+
+ answer := &answerBuffer{}
+ posted := ""
+ ticker := time.NewTicker(editInterval)
+ defer ticker.Stop()
+ typing := time.NewTicker(typingInterval)
+ defer typing.Stop()
+
+ for {
+ select {
+ case event, ok := <-feed:
+ if !ok {
+ return
+ }
+ answer.add(event)
+ if event.Type == agentsession.EventDone {
+ if final := answer.text(); final != "" {
+ posted, _ = r.platform.Send(message,
Reply{Text: final, Edit: posted})
+ }
+ return
+ }
+ case <-ticker.C:
+ // A platform that cannot edit says nothing until the
end: sending
+ // every few seconds would be a wall of half-written
answers.
+ if !r.platform.CanEdit() || !answer.changed() {
+ continue
+ }
+ if partial := answer.text(); partial != "" {
+ posted, _ = r.platform.Send(message,
Reply{Text: partial, Edit: posted})
+ }
+ case <-typing.C:
+ if !r.platform.CanEdit() {
+ r.platform.Typing(message)
+ }
+ }
+ }
+}
+
+// session is the conversation this chat is bound to, opening one the first
time.
+func (r *router) session(message Message) (agentsession.Session, error) {
+ source := sourceOf(message)
+ for _, candidate := range agentsession.List() {
+ if candidate.Source == source {
+ return candidate, nil
+ }
+ }
+
+ if r.channel.AgentId == "" {
+ return agentsession.Session{}, fmt.Errorf("no agent is bound to
the %s channel", r.channel.Name)
+ }
+ return agentsession.Open(agentsession.Spec{
+ AgentId: r.channel.AgentId,
+ AgentPath: r.channel.AgentPath,
+ Owner: r.channel.AgentUser,
+ WorkDir: r.channel.WorkDir,
+ Model: r.channel.Model,
+ Source: source,
+ })
+}
+
+func (r *router) reply(message Message, text string) {
+ if text == "" {
+ return
+ }
+ r.platform.Send(message, Reply{Text: text})
+}
diff --git a/imbridge/telegram.go b/imbridge/telegram.go
new file mode 100644
index 0000000..d06dcc4
--- /dev/null
+++ b/imbridge/telegram.go
@@ -0,0 +1,242 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/apache/casbin-gateway/proxy"
+)
+
+// PlatformTelegram is the id this platform is stored under.
+const PlatformTelegram = "telegram"
+
+const (
+ telegramApi = "https://api.telegram.org"
+ // telegramPollSeconds is how long the server holds a poll open with
nothing
+ // to say. It is well inside the client timeout below.
+ telegramPollSeconds = 50
+ // telegramMessageLimit is the longest message Telegram accepts.
+ telegramMessageLimit = 4096
+)
+
+// Telegram reaches the Bot API by long polling, which needs no public address:
+// Gateway calls out and the answer is held open until something is said.
+type Telegram struct {
+ channel string
+ token string
+ client *http.Client
+ // offset is the update after the last one handled. Telegram keeps an
update
+ // until it is acknowledged this way, so it survives a restart.
+ offset int64
+}
+
+func NewTelegram(channel, token string) *Telegram {
+ return &Telegram{
+ channel: channel,
+ token: token,
+ client: &http.Client{Transport: proxy.Transport(), Timeout:
(telegramPollSeconds + 30) * time.Second},
+ }
+}
+
+func (t *Telegram) Name() string { return PlatformTelegram }
+
+func (t *Telegram) CanEdit() bool { return true }
+
+type telegramUpdate struct {
+ UpdateId int64 `json:"update_id"`
+ Message *struct {
+ MessageId int64 `json:"message_id"`
+ Text string `json:"text"`
+ From struct {
+ Id int64 `json:"id"`
+ FirstName string `json:"first_name"`
+ Username string `json:"username"`
+ } `json:"from"`
+ Chat struct {
+ Id int64 `json:"id"`
+ } `json:"chat"`
+ } `json:"message"`
+}
+
+func (t *Telegram) Receive(ctx context.Context, handle func(Message)) error {
+ // Whatever piled up while Gateway was down is dropped: answering a
question
+ // from hours ago, in a directory that has moved on, is worse than
silence.
+ if err := t.skipBacklog(ctx); err != nil {
+ if !isWebhookConflict(err) {
+ return err
+ }
+ // Telegram hands updates to a webhook or to a poll, never to
both, and
+ // refuses the poll while one is set. The webhook is taken down
so this
+ // channel can listen; nothing else about the bot is touched.
+ if err := t.dropWebhook(ctx); err != nil {
+ return err
+ }
+ if err := t.skipBacklog(ctx); err != nil {
+ return err
+ }
+ }
+
+ for ctx.Err() == nil {
+ updates := []telegramUpdate{}
+ body := map[string]any{"offset": t.offset, "timeout":
telegramPollSeconds}
+ if err := t.call(ctx, "getUpdates", body, &updates); err != nil
{
+ if ctx.Err() != nil {
+ return nil
+ }
+ // A refused credential is worth stopping for; anything
else is the
+ // network, and the next poll is the retry.
+ if errors.Is(err, errUnauthorized) {
+ return err
+ }
+ // A webhook set on the bot after this started takes
the updates
+ // away again, so it is taken down the same way.
+ if isWebhookConflict(err) {
+ if err := t.dropWebhook(ctx); err != nil {
+ return err
+ }
+ continue
+ }
+ waitBeforeRetry(ctx)
+ continue
+ }
+
+ for _, update := range updates {
+ t.offset = update.UpdateId + 1
+ if update.Message == nil || update.Message.Text == "" {
+ continue
+ }
+ handle(Message{
+ Platform: PlatformTelegram,
+ Channel: t.channel,
+ ChatId:
strconv.FormatInt(update.Message.Chat.Id, 10),
+ UserId:
strconv.FormatInt(update.Message.From.Id, 10),
+ UserName:
firstNonEmpty(update.Message.From.Username, update.Message.From.FirstName),
+ Text: update.Message.Text,
+ })
+ }
+ }
+ return nil
+}
+
+// skipBacklog moves the cursor past everything already waiting.
+func (t *Telegram) skipBacklog(ctx context.Context) error {
+ updates := []telegramUpdate{}
+ if err := t.call(ctx, "getUpdates", map[string]any{"offset": -1,
"timeout": 0}, &updates); err != nil {
+ return err
+ }
+ for _, update := range updates {
+ t.offset = update.UpdateId + 1
+ }
+ return nil
+}
+
+func (t *Telegram) Send(message Message, reply Reply) (string, error) {
+ text := reply.Text
+ if text == "" {
+ return "", nil
+ }
+
+ // Telegram refuses anything over its limit outright, so a long answer
is cut
+ // into messages and only the last one stays editable.
+ chunks := splitForChat(text, telegramMessageLimit)
+ last := ""
+ for index, chunk := range chunks {
+ result := struct {
+ MessageId int64 `json:"message_id"`
+ }{}
+
+ method, body := "sendMessage", map[string]any{"chat_id":
message.ChatId, "text": chunk}
+ // Only the first chunk can replace what is already there; the
rest are
+ // new messages under it.
+ if reply.Edit != "" && index == 0 {
+ method = "editMessageText"
+ body["message_id"] = reply.Edit
+ }
+ if err := t.call(context.Background(), method, body, &result);
err != nil {
+ return last, err
+ }
+ last = strconv.FormatInt(result.MessageId, 10)
+ }
+ return last, nil
+}
+
+func (t *Telegram) Typing(message Message) {
+ body := map[string]any{"chat_id": message.ChatId, "action": "typing"}
+ t.call(context.Background(), "sendChatAction", body, nil)
+}
+
+var errUnauthorized = errors.New("the bot token was refused")
+
+// isWebhookConflict recognises the one failure that is fixed rather than
waited
+// out. Telegram reports it in words rather than in a code of its own.
+func isWebhookConflict(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "webhook is active")
+}
+
+// dropWebhook takes the bot's webhook down. Updates already waiting are kept:
+// they are dropped a moment later by the backlog skip, which is the one place
+// that decides what is too old to answer.
+func (t *Telegram) dropWebhook(ctx context.Context) error {
+ return t.call(ctx, "deleteWebhook",
map[string]any{"drop_pending_updates": false}, nil)
+}
+
+// call posts one Bot API method and unpacks its "result" into out.
+func (t *Telegram) call(ctx context.Context, method string, body
map[string]any, out any) error {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+
+ url := fmt.Sprintf("%s/bot%s/%s", telegramApi, t.token, method)
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("Content-Type", "application/json")
+
+ response, err := t.client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ envelope := struct {
+ Ok bool `json:"ok"`
+ Description string `json:"description"`
+ Result json.RawMessage `json:"result"`
+ }{}
+ if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
+ return err
+ }
+ if !envelope.Ok {
+ if response.StatusCode == http.StatusUnauthorized {
+ return fmt.Errorf("%w: %s", errUnauthorized,
envelope.Description)
+ }
+ return errors.New(envelope.Description)
+ }
+ if out == nil {
+ return nil
+ }
+ return json.Unmarshal(envelope.Result, out)
+}
diff --git a/imbridge/util.go b/imbridge/util.go
new file mode 100644
index 0000000..52b5bc6
--- /dev/null
+++ b/imbridge/util.go
@@ -0,0 +1,79 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "context"
+ "io"
+ "strings"
+ "time"
+)
+
+// retryDelay is how long a platform waits after a failed poll. It is short
+// because a chat that has stopped answering is what somebody notices first.
+const retryDelay = 5 * time.Second
+
+// maxBody caps what is read from one API response.
+const maxBody = 8 * 1024 * 1024
+
+func waitBeforeRetry(ctx context.Context) {
+ timer := time.NewTimer(retryDelay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ case <-ctx.Done():
+ }
+}
+
+func readAll(reader io.Reader) ([]byte, error) {
+ return io.ReadAll(io.LimitReader(reader, maxBody))
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+// splitForChat cuts one answer into messages a platform will accept, breaking
at
+// a line end where there is one nearby so a paragraph is not sliced mid-word.
+func splitForChat(text string, limit int) []string {
+ runes := []rune(text)
+ if len(runes) <= limit {
+ return []string{text}
+ }
+
+ chunks := []string{}
+ for len(runes) > 0 {
+ if len(runes) <= limit {
+ chunks = append(chunks, string(runes))
+ break
+ }
+ cut := limit
+ // Look back over the last fifth of the chunk for a line end.
Further
+ // back than that and the messages come out lopsided.
+ if index := strings.LastIndex(string(runes[:limit]), "\n");
index > 0 {
+ if candidate :=
len([]rune(string(runes[:limit])[:index])); candidate > limit*4/5 {
+ cut = candidate
+ }
+ }
+ chunks = append(chunks, strings.TrimRight(string(runes[:cut]),
"\n"))
+ runes = runes[cut:]
+ }
+ return chunks
+}
diff --git a/imbridge/weixin.go b/imbridge/weixin.go
new file mode 100644
index 0000000..6714045
--- /dev/null
+++ b/imbridge/weixin.go
@@ -0,0 +1,256 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/rand"
+ "net/http"
+ "strconv"
+ "time"
+
+ "github.com/apache/casbin-gateway/proxy"
+)
+
+// PlatformWeixin is the id this platform is stored under.
+const PlatformWeixin = "weixin"
+
+const (
+ // weixinApi is Tencent's own endpoint for the ClawBot pipeline. The
protocol
+ // is called iLink and it is an official one: a personal WeChat account
can
+ // carry a bot without any third-party client standing in for it.
+ weixinApi = "https://ilinkai.weixin.qq.com"
+ // weixinChannelVersion is what the protocol expects to be told about
the
+ // client. It is sent on every poll.
+ weixinChannelVersion = "1.0.2"
+ // weixinMessageLimit keeps one reply to a length a chat bubble can
show.
+ // WeChat has no editing, so a long answer arrives as several messages.
+ weixinMessageLimit = 2000
+)
+
+// Weixin reaches WeChat over iLink, which is long polling: Gateway calls out
and
+// the answer is held open, so no public address is needed here either.
+type Weixin struct {
+ channel string
+ token string
+ client *http.Client
+ // cursor is the protocol's own "get_updates_buf". It must be handed
back on
+ // the next poll or the same messages arrive again.
+ cursor string
+ // typingTicket is asked for once and reused, since it is what a typing
+ // notice has to carry.
+ typingTicket string
+}
+
+func NewWeixin(channel, token string) *Weixin {
+ return &Weixin{
+ channel: channel,
+ token: token,
+ client: &http.Client{Transport: proxy.Transport(), Timeout: 70
* time.Second},
+ }
+}
+
+func (w *Weixin) Name() string { return PlatformWeixin }
+
+// CanEdit is false: iLink has no way to replace a message already sent, so an
+// answer arrives when it is finished rather than growing in place.
+func (w *Weixin) CanEdit() bool { return false }
+
+// weixinItem is one piece of a message. Only text is read; a voice message
+// arrives with its own transcription, which is text by the time it is here.
+type weixinItem struct {
+ Type int `json:"type"`
+ TextItem *struct {
+ Text string `json:"text"`
+ } `json:"text_item,omitempty"`
+ VoiceItem *struct {
+ Text string `json:"text"`
+ } `json:"voice_item,omitempty"`
+}
+
+type weixinMessage struct {
+ FromUserId string `json:"from_user_id"`
+ ToUserId string `json:"to_user_id"`
+ MessageType int `json:"message_type"`
+ MessageState int `json:"message_state"`
+ ContextToken string `json:"context_token"`
+ ItemList []weixinItem `json:"item_list"`
+}
+
+func (w *Weixin) Receive(ctx context.Context, handle func(Message)) error {
+ for ctx.Err() == nil {
+ result := struct {
+ Ret int `json:"ret"`
+ Msgs []weixinMessage `json:"msgs"`
+ Cursor string `json:"get_updates_buf"`
+ }{}
+ body := map[string]any{
+ "get_updates_buf": w.cursor,
+ "base_info": map[string]any{"channel_version":
weixinChannelVersion},
+ }
+ if err := w.call(ctx, "/ilink/bot/getupdates", body, &result);
err != nil {
+ if ctx.Err() != nil {
+ return nil
+ }
+ if errors.Is(err, errUnauthorized) {
+ return err
+ }
+ waitBeforeRetry(ctx)
+ continue
+ }
+ // Losing the cursor is what makes the same messages arrive
twice, so it
+ // is taken before anything is handled.
+ w.cursor = result.Cursor
+
+ for _, msg := range result.Msgs {
+ // message_type 1 is somebody talking; message_state 2
is a message
+ // that is finished rather than still being dictated.
+ if msg.MessageType != 1 || msg.MessageState != 2 {
+ continue
+ }
+ text := weixinText(msg)
+ if text == "" {
+ continue
+ }
+ handle(Message{
+ Platform: PlatformWeixin,
+ Channel: w.channel,
+ ChatId: msg.FromUserId,
+ UserId: msg.FromUserId,
+ // iLink carries no display name, so the
account id is the name.
+ UserName: msg.FromUserId,
+ Text: text,
+ ReplyToken: msg.ContextToken,
+ })
+ }
+ }
+ return nil
+}
+
+func weixinText(msg weixinMessage) string {
+ for _, item := range msg.ItemList {
+ if item.TextItem != nil && item.TextItem.Text != "" {
+ return item.TextItem.Text
+ }
+ if item.VoiceItem != nil && item.VoiceItem.Text != "" {
+ return item.VoiceItem.Text
+ }
+ }
+ return ""
+}
+
+func (w *Weixin) Send(message Message, reply Reply) (string, error) {
+ if reply.Text == "" {
+ return "", nil
+ }
+
+ for _, chunk := range splitForChat(reply.Text, weixinMessageLimit) {
+ body := map[string]any{
+ "msg": map[string]any{
+ "to_user_id": message.ChatId,
+ "message_type": 2,
+ "message_state": 2,
+ // The reply carries the token of the message
it answers. Without
+ // it the protocol has nowhere to put the reply.
+ "context_token": message.ReplyToken,
+ "item_list": []any{map[string]any{"type":
1, "text_item": map[string]any{"text": chunk}}},
+ },
+ }
+ if err := w.call(context.Background(),
"/ilink/bot/sendmessage", body, nil); err != nil {
+ return "", err
+ }
+ }
+ return "", nil
+}
+
+func (w *Weixin) Typing(message Message) {
+ if w.typingTicket == "" {
+ config := struct {
+ TypingTicket string `json:"typing_ticket"`
+ }{}
+ if err := w.call(context.Background(), "/ilink/bot/getconfig",
map[string]any{}, &config); err != nil {
+ return
+ }
+ w.typingTicket = config.TypingTicket
+ }
+
+ body := map[string]any{
+ "to_user_id": message.ChatId,
+ "context_token": message.ReplyToken,
+ "typing_ticket": w.typingTicket,
+ }
+ w.call(context.Background(), "/ilink/bot/sendtyping", body, nil)
+}
+
+// call posts one iLink method. A non-zero "ret" is the protocol's own way of
+// reporting a failure, and comes back with 200.
+func (w *Weixin) call(ctx context.Context, path string, body map[string]any,
out any) error {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost,
weixinApi+path, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ weixinHeaders(request, w.token)
+
+ response, err := w.client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ if response.StatusCode == http.StatusUnauthorized ||
response.StatusCode == http.StatusForbidden {
+ return fmt.Errorf("%w: HTTP %d", errUnauthorized,
response.StatusCode)
+ }
+
+ raw, err := readAll(response.Body)
+ if err != nil {
+ return err
+ }
+ envelope := struct {
+ Ret int `json:"ret"`
+ ErrMsg string `json:"errmsg"`
+ }{}
+ if err := json.Unmarshal(raw, &envelope); err != nil {
+ return err
+ }
+ if envelope.Ret != 0 {
+ return fmt.Errorf("weixin returned %d: %s", envelope.Ret,
envelope.ErrMsg)
+ }
+ if out == nil {
+ return nil
+ }
+ return json.Unmarshal(raw, out)
+}
+
+// weixinHeaders are what every iLink request carries. X-WECHAT-UIN is a fresh
+// random number each time, which is the protocol's guard against a request
being
+// replayed.
+func weixinHeaders(request *http.Request, token string) {
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("AuthorizationType", "ilink_bot_token")
+ request.Header.Set("X-WECHAT-UIN",
base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(rand.Uint32()),
10))))
+ if token != "" {
+ request.Header.Set("Authorization", "Bearer "+token)
+ }
+}
diff --git a/imbridge/weixin_login.go b/imbridge/weixin_login.go
new file mode 100644
index 0000000..897539b
--- /dev/null
+++ b/imbridge/weixin_login.go
@@ -0,0 +1,112 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package imbridge
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/apache/casbin-gateway/proxy"
+)
+
+// weixinBotType is the kind of bot the QR code signs in: the personal-account
+// pipeline, which is the only one this connects.
+const weixinBotType = "3"
+
+// WeixinQrcode is a sign-in waiting to be scanned. WeChat hands back a link
+// rather than a picture, so the code drawn on screen is one the page makes out
+// of Url; Qrcode is what the status is then asked about.
+type WeixinQrcode struct {
+ Qrcode string `json:"qrcode"`
+ Url string `json:"url"`
+}
+
+// WeixinStatus is where a sign-in has got to. Token is filled in only once the
+// account has confirmed, and is what the channel is stored with.
+type WeixinStatus struct {
+ Status string `json:"status"`
+ Token string `json:"token"`
+}
+
+func weixinLoginClient() *http.Client {
+ return &http.Client{Transport: proxy.Transport(), Timeout: 70 *
time.Second}
+}
+
+// StartWeixinLogin asks for a QR code to be scanned by the WeChat account that
+// will carry the bot.
+func StartWeixinLogin(ctx context.Context) (*WeixinQrcode, error) {
+ result := struct {
+ Qrcode string `json:"qrcode"`
+ // The endpoint calls this "img_content", but what it holds is
the link
+ // the WeChat scanner opens.
+ QrcodeImgContent string `json:"qrcode_img_content"`
+ Ret int `json:"ret"`
+ ErrMsg string `json:"errmsg"`
+ }{}
+ query := url.Values{"bot_type": {weixinBotType}}
+ if err := weixinGet(ctx, "/ilink/bot/get_bot_qrcode", query, "",
&result); err != nil {
+ return nil, err
+ }
+
+ if result.Ret != 0 {
+ return nil, fmt.Errorf("weixin returned %d: %s", result.Ret,
result.ErrMsg)
+ }
+ return &WeixinQrcode{Qrcode: result.Qrcode, Url:
result.QrcodeImgContent}, nil
+}
+
+// PollWeixinLogin reports whether the code has been scanned yet. It answers
+// straight away with "wait" while nothing has happened, so the caller has to
+// pace itself rather than calling again the moment this returns.
+func PollWeixinLogin(ctx context.Context, qrcode string) (*WeixinStatus,
error) {
+ result := struct {
+ Status string `json:"status"`
+ BotToken string `json:"bot_token"`
+ BaseUrl string `json:"baseurl"`
+ Ret int `json:"ret"`
+ ErrMsg string `json:"errmsg"`
+ }{}
+ query := url.Values{"qrcode": {qrcode}}
+ if err := weixinGet(ctx, "/ilink/bot/get_qrcode_status", query, "",
&result); err != nil {
+ return nil, err
+ }
+ if result.Ret != 0 {
+ return nil, fmt.Errorf("weixin returned %d: %s", result.Ret,
result.ErrMsg)
+ }
+ return &WeixinStatus{Status: result.Status, Token: result.BotToken}, nil
+}
+
+func weixinGet(ctx context.Context, path string, query url.Values, token
string, out any) error {
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet,
weixinApi+path+"?"+query.Encode(), nil)
+ if err != nil {
+ return err
+ }
+ weixinHeaders(request, token)
+
+ response, err := weixinLoginClient().Do(request)
+ if err != nil {
+ return err
+ }
+ defer response.Body.Close()
+
+ raw, err := readAll(response.Body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(raw, out)
+}
diff --git a/main.go b/main.go
index e8827af..c5b3c11 100644
--- a/main.go
+++ b/main.go
@@ -27,6 +27,7 @@ import (
"github.com/apache/casbin-gateway/casdoor"
"github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/controllers"
+ "github.com/apache/casbin-gateway/imbridge"
"github.com/apache/casbin-gateway/mcpproxy"
"github.com/apache/casbin-gateway/mcpserver"
"github.com/apache/casbin-gateway/object"
@@ -131,6 +132,10 @@ func main() {
// A driven agent answers through the provider bound to it, so a session
// needs no sign-in of the agent's own.
service.InitAgentSessionEnv()
+ // The chat platforms those conversations arrive on. Each one polls out
from
+ // here, so none of them needs a public address.
+ object.InitImChannels()
+ defer imbridge.Stop()
// Monitoring is on by default, so the agents already on this host are
// patched without anyone opening the UI. The scan walks the disk,
hence the
diff --git a/object/im_channel.go b/object/im_channel.go
new file mode 100644
index 0000000..d150e85
--- /dev/null
+++ b/object/im_channel.go
@@ -0,0 +1,207 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+
+ "github.com/apache/casbin-gateway/imbridge"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/xorm-io/core"
+)
+
+// ImChannel is one chat platform Gateway listens on, and what a conversation
+// there is bound to. The credential is held here rather than in a file, the
same
+// way a connection's is, and never leaves the process in the clear.
+type ImChannel struct {
+ Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+ // Name is what the operator called this channel. One platform can be
+ // listened to more than once - two bots, two projects - so the name
rather
+ // than the platform is the key.
+ Name string `xorm:"varchar(100) notnull pk" json:"name"`
+ CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+ UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+ Platform string `xorm:"varchar(50)" json:"platform"`
+ Enabled bool `json:"enabled"`
+ // Token is the bot credential: Telegram's bot token, or the token a
WeChat
+ // account handed over when it scanned the code. Ciphertext at rest when
+ // "apiKeyEncryptionKey" is set, and masked on the way out.
+ Token string `xorm:"varchar(500)" json:"token"`
+
+ // The installation a conversation on this channel talks to.
+ AgentId string `xorm:"varchar(100)" json:"agentId"`
+ AgentPath string `xorm:"varchar(500)" json:"agentPath"`
+ AgentUser string `xorm:"varchar(100)" json:"agentUser"`
+ WorkDir string `xorm:"varchar(500)" json:"workDir"`
+ Model string `xorm:"varchar(200)" json:"model"`
+
+ // AllowedUsers are the platform ids that may drive the agent. Empty
lets
+ // anybody who finds the bot drive it.
+ AllowedUsers []string `xorm:"mediumtext json" json:"allowedUsers"`
+}
+
+func (channel *ImChannel) GetId() string {
+ return fmt.Sprintf("%s/%s", channel.Owner, channel.Name)
+}
+
+// imChannelAad binds the ciphertext to its own row, so a token copied into
+// another channel no longer decrypts.
+func imChannelAad(channel *ImChannel) string {
+ return "im-channel/" + channel.GetId()
+}
+
+func (channel *ImChannel) decrypted() *ImChannel {
+ copied := *channel
+ if copied.Token == "" {
+ return &copied
+ }
+ // A token that will not decrypt is left as it is, matching how a
provider's
+ // key behaves when the encryption key changed: the row is still
listed, and
+ // using it is what reports the problem.
+ if plain, err := util.DecryptWithKey(apiKeyEncryptionSecret(),
copied.Token, imChannelAad(&copied)); err == nil {
+ copied.Token = plain
+ }
+ return &copied
+}
+
+// Masked is a copy safe to send to the browser.
+func (channel *ImChannel) Masked() *ImChannel {
+ masked := *channel
+ if masked.Token != "" {
+ masked.Token = ApiKeyMask
+ }
+ return &masked
+}
+
+func GetImChannels() ([]*ImChannel, error) {
+ channels := []*ImChannel{}
+ if err := ormer.Engine.Where("owner = ?", AgentOwner).Find(&channels);
err != nil {
+ return nil, err
+ }
+
+ sort.Slice(channels, func(i, j int) bool { return channels[i].Name <
channels[j].Name })
+ return channels, nil
+}
+
+func GetImChannel(name string) (*ImChannel, error) {
+ channel := &ImChannel{Owner: AgentOwner, Name: name}
+ existed, err := ormer.Engine.Get(channel)
+ if err != nil {
+ return nil, err
+ }
+ if !existed {
+ return nil, nil
+ }
+ return channel, nil
+}
+
+// SaveImChannel writes one channel, inserting it the first time. A masked
token
+// means the browser is sending back what it was shown, so the stored one is
+// kept rather than overwritten with the mask.
+func SaveImChannel(channel *ImChannel) error {
+ if channel.Name == "" {
+ return errors.New("the channel needs a name")
+ }
+ if channel.Platform != imbridge.PlatformTelegram && channel.Platform !=
imbridge.PlatformWeixin {
+ return fmt.Errorf("no chat platform named %q", channel.Platform)
+ }
+
+ channel.Owner = AgentOwner
+ channel.UpdatedTime = util.GetCurrentTime()
+
+ existing, err := GetImChannel(channel.Name)
+ if err != nil {
+ return err
+ }
+ if existing == nil {
+ channel.CreatedTime = channel.UpdatedTime
+ } else {
+ channel.CreatedTime = existing.CreatedTime
+ if channel.Token == "" || channel.Token == ApiKeyMask {
+ channel.Token = existing.Token
+ }
+ }
+
+ if channel.Token != "" && channel.Token != ApiKeyMask &&
!util.IsEncrypted(channel.Token) {
+ encrypted, err := util.EncryptWithKey(apiKeyEncryptionSecret(),
channel.Token, imChannelAad(channel))
+ if err != nil {
+ return err
+ }
+ channel.Token = encrypted
+ }
+
+ if existing == nil {
+ _, err = ormer.Engine.Insert(channel)
+ } else {
+ _, err = ormer.Engine.ID(core.PK{channel.Owner,
channel.Name}).AllCols().Update(channel)
+ }
+ if err != nil {
+ return err
+ }
+ return ReloadImChannels()
+}
+
+func DeleteImChannel(name string) error {
+ if _, err := ormer.Engine.ID(core.PK{AgentOwner,
name}).Delete(&ImChannel{}); err != nil {
+ return err
+ }
+ return ReloadImChannels()
+}
+
+// ReloadImChannels brings the listeners in line with what is stored. It is
+// called on every change, so a channel switched on starts listening without a
+// restart.
+func ReloadImChannels() error {
+ channels, err := GetImChannels()
+ if err != nil {
+ return err
+ }
+
+ wanted := []imbridge.Channel{}
+ for _, channel := range channels {
+ if !channel.Enabled {
+ continue
+ }
+ plain := channel.decrypted()
+ wanted = append(wanted, imbridge.Channel{
+ Name: plain.Name,
+ Platform: plain.Platform,
+ Token: plain.Token,
+ AgentId: plain.AgentId,
+ AgentPath: plain.AgentPath,
+ AgentUser: plain.AgentUser,
+ WorkDir: plain.WorkDir,
+ Model: plain.Model,
+ AllowedUsers: plain.AllowedUsers,
+ })
+ }
+
+ imbridge.Reload(wanted)
+ return nil
+}
+
+// InitImChannels starts listening on the stored channels and tells the bridge
+// how to forget a conversation ended from a chat.
+func InitImChannels() {
+ imbridge.SetSessionForgetter(func(id string) {
+ DeleteAgentSession(id)
+ })
+ if err := ReloadImChannels(); err != nil {
+ panic(err)
+ }
+}
diff --git a/object/ormer.go b/object/ormer.go
index 3e58bce..5626c7a 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -344,4 +344,10 @@ func (a *Ormer) createTable() {
if err != nil {
panic(err)
}
+
+ // The chat platforms Gateway listens on, and the bot credential for
each.
+ err = a.Engine.Sync2(new(ImChannel))
+ if err != nil {
+ panic(err)
+ }
}
diff --git a/routers/router.go b/routers/router.go
index b1fcef4..052ccb5 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -97,6 +97,14 @@ func initAPI() {
beego.Router("/api/interrupt-driven-session",
&controllers.ApiController{}, "POST:InterruptDrivenSession")
beego.Router("/api/close-driven-session", &controllers.ApiController{},
"POST:CloseDrivenSession")
beego.Router("/api/stream-driven-session",
&controllers.ApiController{}, "GET:StreamDrivenSession")
+
+ // The chat platforms a conversation can arrive on.
+ beego.Router("/api/get-im-channels", &controllers.ApiController{},
"GET:GetImChannels")
+ beego.Router("/api/update-im-channel", &controllers.ApiController{},
"POST:UpdateImChannel")
+ beego.Router("/api/delete-im-channel", &controllers.ApiController{},
"POST:DeleteImChannel")
+ beego.Router("/api/start-weixin-login", &controllers.ApiController{},
"POST:StartWeixinLogin")
+ beego.Router("/api/get-weixin-login-status",
&controllers.ApiController{}, "GET:GetWeixinLoginStatus")
+
beego.Router("/api/get-agent-accounts", &controllers.ApiController{},
"GET:GetAgentAccounts")
beego.Router("/api/save-agent-account", &controllers.ApiController{},
"POST:SaveAgentAccount")
beego.Router("/api/add-agent-account", &controllers.ApiController{},
"POST:AddAgentAccount")
diff --git a/web/package.json b/web/package.json
index 7b247b0..ccbd9ff 100644
--- a/web/package.json
+++ b/web/package.json
@@ -34,6 +34,7 @@
"file-saver": "^2.0.5",
"i18next": "^23.16.8",
"lucide-react": "^0.475.0",
+ "qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.4.0",
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5c338ae..ba4474c 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -38,6 +38,7 @@ import AgentVersionsPage from "@/pages/AgentVersionsPage";
import AgentsPage from "@/pages/AgentsPage";
import AuthCallback from "@/pages/AuthCallback";
import AuthenticityPage from "@/pages/AuthenticityPage";
+import ChannelsPage from "@/pages/ChannelsPage";
import ChatPage from "@/pages/ChatPage";
import ConnectionsPage from "@/pages/ConnectionsPage";
import HomePage from "@/pages/HomePage";
@@ -288,6 +289,7 @@ export default function App() {
/>
<Route path="/account" element={requireSignin(user =>
<AccountPage account={user} />)} />
<Route path="/chat" element={requireSignin(user => <ChatPage
account={user} />)} />
+ <Route path="/channels" element={requireSignin(user =>
<ChannelsPage account={user} />)} />
<Route path="/agents" element={requireSignin(user =>
<AgentsPage account={user} />)} />
<Route
path="/agent-versions"
diff --git a/web/src/backend/ImChannelBackend.ts
b/web/src/backend/ImChannelBackend.ts
new file mode 100644
index 0000000..f1dccda
--- /dev/null
+++ b/web/src/backend/ImChannelBackend.ts
@@ -0,0 +1,42 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {query, request} from "@/backend/request";
+import type {ImChannel, WeixinQrcode} from "@/types";
+
+export function getImChannels() {
+ return request<ImChannel[]>("/api/get-im-channels");
+}
+
+export function updateImChannel(channel: ImChannel) {
+ return request<ImChannel>("/api/update-im-channel", "POST", channel);
+}
+
+export function deleteImChannel(name: string) {
+ return request<boolean>("/api/delete-im-channel", "POST", {name: name});
+}
+
+/** Asks WeChat for a code the account that will carry the bot scans. */
+export function startWeixinLogin() {
+ return request<WeixinQrcode>("/api/start-weixin-login", "POST", {});
+}
+
+/**
+ * Whether the code has been scanned. The endpoint holds the request open while
+ * nothing has happened, so this is called again as soon as it answers. The
token
+ * is written into the channel on the server; it never comes back here.
+ */
+export function getWeixinLoginStatus(qrcode: string, channel: string) {
+ return request<{status:
string}>(`/api/get-weixin-login-status${query({qrcode: qrcode, channel:
channel})}`);
+}
diff --git a/web/src/components/shared/brand-options.tsx
b/web/src/components/shared/brand-options.tsx
index 3b94a29..3130d86 100644
--- a/web/src/components/shared/brand-options.tsx
+++ b/web/src/components/shared/brand-options.tsx
@@ -48,6 +48,12 @@ export function vendorOption(value: string, label: string,
site: string): Select
return brandOption(value, label, <ProviderIcon icon={site} size={16} />);
}
+/** The chat platforms a conversation with an agent can arrive on. */
+export const chatPlatformOptions: SelectOption[] = [
+ vendorOption("telegram", "Telegram", "telegram.org"),
+ vendorOption("weixin", "WeChat", "weixin.qq.com"),
+];
+
/** The wire format a provider serves, which is a vendor's API either way. */
export const protocolOptions: SelectOption[] = [
vendorOption("openai", "OpenAI", "openai.com"),
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 7648423..ed64372 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -832,6 +832,31 @@
"What was sent": "What was sent",
"Wrong": "Wrong"
},
+ "channel": {
+ "A channel polls out from this machine, so no public address is needed":
"A channel polls out from this machine, so no public address is needed",
+ "Add channel": "Add channel",
+ "Agent": "Agent",
+ "Allowed users": "Allowed users",
+ "Asking WeChat for a code": "Asking WeChat for a code",
+ "Bot token": "Bot token",
+ "Chat channels": "Chat channels",
+ "Edit channel": "Edit channel",
+ "Empty lets anybody who finds the bot drive the agent": "Empty lets
anybody who finds the bot drive the agent",
+ "From @BotFather. Leave empty to keep the stored one": "From @BotFather.
Leave empty to keep the stored one",
+ "Listening": "Listening",
+ "Name": "Name",
+ "No agent bound": "No agent bound",
+ "No channel yet": "No channel yet",
+ "Not listening": "Not listening",
+ "Off": "Off",
+ "Platform": "Platform",
+ "Remove this channel?": "Remove this channel?",
+ "Scan this with the WeChat account that will carry the bot": "Scan this
with the WeChat account that will carry the bot",
+ "Show the code": "Show the code",
+ "Signed in": "Signed in",
+ "Talk to an agent on this machine from a chat app": "Talk to an agent on
this machine from a chat app",
+ "WeChat sign-in": "WeChat sign-in"
+ },
"chat": {
"Ask an agent on this machine something, and watch it work": "Ask an agent
on this machine something, and watch it work",
"Ask the agent something": "Ask the agent something",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 70d1242..674936b 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -904,6 +904,31 @@
"case.vendor-headers.method":
"这一条只向厂商自己的域名发问,比对的也是该厂商文档里写明的响应头:能说OpenAI兼容的API,不等于自称是OpenAI。真货前面的转售商通常会透传一部分,而从没连过厂商的后端一个也拿不出来;但把响应头抹掉也可能只是干净,所以这一条单独不会判定为异常。响应头列表留空就用该厂商自己的,也可以在这里指定要检查哪些响应头。",
"case.vendor-headers.question": "厂商自己API的那些响应头回来了吗?"
},
+ "channel": {
+ "A channel polls out from this machine, so no public address is needed":
"渠道是从这台机器主动往外轮询的,不需要公网地址",
+ "Add channel": "添加渠道",
+ "Agent": "Agent",
+ "Allowed users": "允许的用户",
+ "Asking WeChat for a code": "正在向微信申请二维码",
+ "Bot token": "Bot令牌",
+ "Chat channels": "聊天渠道",
+ "Edit channel": "编辑渠道",
+ "Empty lets anybody who finds the bot drive the agent":
"留空表示任何找到这个机器人的人都能驱动Agent",
+ "From @BotFather. Leave empty to keep the stored one":
"从@BotFather获取。留空则沿用已保存的",
+ "Listening": "监听中",
+ "Name": "名称",
+ "No agent bound": "没有绑定Agent",
+ "No channel yet": "还没有渠道",
+ "Not listening": "未监听",
+ "Off": "已关闭",
+ "Platform": "平台",
+ "Remove this channel?": "删除这个渠道?",
+ "Scan this with the WeChat account that will carry the bot":
"用要承载这个机器人的微信账号扫码",
+ "Show the code": "显示二维码",
+ "Signed in": "已登录",
+ "Talk to an agent on this machine from a chat app": "在聊天软件里跟这台机器上的Agent对话",
+ "WeChat sign-in": "微信登录"
+ },
"chat": {
"Ask an agent on this machine something, and watch it work":
"让这台机器上的Agent做事,并看着它做",
"Ask the agent something": "问这个Agent点什么",
diff --git a/web/src/nav.ts b/web/src/nav.ts
index 396797f..9482e63 100644
--- a/web/src/nav.ts
+++ b/web/src/nav.ts
@@ -26,6 +26,7 @@ import {
Plug,
Route,
SendHorizontal,
+ Smartphone,
Settings,
ShieldCheck,
ShieldHalf,
@@ -73,6 +74,9 @@ export const navGroups: NavGroup[] = [
// Driving an agent is what somebody does with one, so it sits beside the
// list of them rather than down among the pages that only report.
{key: "/chat", label: "chat:Chat", icon: SendHorizontal, path: "/chat",
adminOnly: true},
+ // Under Chat, because a channel is the same conversation arriving from a
+ // phone instead of from this page.
+ {key: "/channels", label: "channel:Chat channels", icon: Smartphone, path:
"/channels", adminOnly: true},
{
key: "/agent-versions",
label: "agent:Agent versions",
diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx
new file mode 100644
index 0000000..b10f06a
--- /dev/null
+++ b/web/src/pages/ChannelsPage.tsx
@@ -0,0 +1,382 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {MessagesSquare, Pencil, Plus, QrCode, Trash2} from "lucide-react";
+import i18next from "i18next";
+import {QRCodeSVG} from "qrcode.react";
+
+import * as DriveBackend from "@/backend/DriveBackend";
+import * as ImChannelBackend from "@/backend/ImChannelBackend";
+import * as Setting from "@/Setting";
+import {ConfirmDialog} from "@/components/shared/confirm-dialog";
+import {EmptyState} from "@/components/shared/empty-state";
+import {Field, FormDialog} from "@/components/shared/form-dialog";
+import {PageContainer, PageHeader} from "@/components/shared/page-header";
+import {SimpleSelect} from "@/components/shared/simple-select";
+import {UnauthorizedResult} from "@/components/shared/misc";
+import {chatPlatformOptions} from "@/components/shared/brand-options";
+import {agentOption} from "@/components/shared/brand-options";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import {Switch} from "@/components/ui/switch";
+import {TagsInput} from "@/components/ui/tags-input";
+import type {Account, DrivableAgent, ImChannel, WeixinQrcode} from "@/types";
+
+// scanPollInterval paces the wait for a scan.
+const scanPollInterval = 1500;
+
+const PLATFORM_TELEGRAM = "telegram";
+const PLATFORM_WEIXIN = "weixin";
+
+function emptyChannel(): ImChannel {
+ return {
+ name: "",
+ platform: PLATFORM_TELEGRAM,
+ enabled: true,
+ token: "",
+ agentId: "",
+ agentPath: "",
+ agentUser: "",
+ workDir: "",
+ model: "",
+ allowedUsers: [],
+ };
+}
+
+/** The one line that says whether a channel is actually listening. */
+function StatusBadge({channel}: {channel: ImChannel}) {
+ if (!channel.enabled) {
+ return <Badge variant="outline">{i18next.t("channel:Off")}</Badge>;
+ }
+ if (channel.status?.error) {
+ return <Badge variant="destructive">{channel.status.error}</Badge>;
+ }
+ if (channel.status?.running) {
+ return <Badge variant="secondary">{i18next.t("channel:Listening")}</Badge>;
+ }
+ return <Badge variant="outline">{i18next.t("channel:Not listening")}</Badge>;
+}
+
+/**
+ * The chat platforms Gateway listens on. Each one polls out from this machine,
+ * so a bot works from a laptop behind any network without a public address.
+ */
+export default function ChannelsPage({account}: {account: Account}) {
+ const isAdmin = Setting.isAdminUser(account);
+ const [channels, setChannels] = React.useState<ImChannel[]>([]);
+ const [agents, setAgents] = React.useState<DrivableAgent[]>([]);
+ const [editing, setEditing] = React.useState<ImChannel | null>(null);
+ const [isNew, setIsNew] = React.useState(false);
+ const [qrcode, setQrcode] = React.useState<WeixinQrcode | null>(null);
+ const [scanState, setScanState] = React.useState("");
+ const [error, setError] = React.useState("");
+ const [loading, setLoading] = React.useState(true);
+ const [saving, setSaving] = React.useState(false);
+ // The code being waited on. Clearing it is what stops the wait, so closing
+ // the form does not leave a poll running against WeChat forever.
+ const waitingFor = React.useRef("");
+
+ const load = React.useCallback(() => {
+ return ImChannelBackend.getImChannels()
+ .then(res => {
+ if (res.status === "ok") {
+ setChannels(res.data ?? []);
+ setError("");
+ } else {
+ setError(res.msg || i18next.t("general:Failed to get data"));
+ }
+ })
+ .catch(failure => setError(failure.message || String(failure)));
+ }, []);
+
+ React.useEffect(() => {
+ if (!isAdmin) {
+ setLoading(false);
+ return;
+ }
+ Promise.all([
+ load(),
+ DriveBackend.getDrivableAgents().then(res => setAgents(res.status ===
"ok" ? (res.data ?? []) : [])),
+ ]).then(() => setLoading(false));
+ }, [isAdmin, load]);
+
+ // A listener that has just been switched on reports what the platform said
+ // only once it has answered, so the list is read again shortly after a save.
+ React.useEffect(() => {
+ if (!isAdmin) {
+ return;
+ }
+ const timer = window.setInterval(load, 5000);
+ return () => window.clearInterval(timer);
+ }, [isAdmin, load]);
+
+ const closeForm = () => {
+ waitingFor.current = "";
+ setEditing(null);
+ setQrcode(null);
+ setScanState("");
+ };
+
+ const save = () => {
+ if (!editing) {
+ return;
+ }
+ setSaving(true);
+ ImChannelBackend.updateImChannel(editing)
+ .then(res => {
+ if (res.status === "ok") {
+ closeForm();
+ return load();
+ }
+ setError(res.msg || i18next.t("general:Failed to save"));
+ })
+ .catch(failure => setError(failure.message || String(failure)))
+ .then(() => setSaving(false));
+ };
+
+ // Signing WeChat in needs the channel to exist first: the token it hands
back
+ // is written straight into that row rather than carried through the browser.
+ const scan = () => {
+ if (!editing) {
+ return;
+ }
+ setScanState(i18next.t("channel:Asking WeChat for a code"));
+ ImChannelBackend.updateImChannel(editing)
+ .then(() => ImChannelBackend.startWeixinLogin())
+ .then(res => {
+ if (res.status !== "ok" || !res.data) {
+ setScanState(res.msg || i18next.t("general:Failed to get data"));
+ return;
+ }
+ setQrcode(res.data);
+ waitingFor.current = res.data.qrcode;
+ setScanState(i18next.t("channel:Scan this with the WeChat account that
will carry the bot"));
+ poll(res.data.qrcode, editing.name);
+ })
+ .catch(failure => setScanState(failure.message || String(failure)));
+ };
+
+ const poll = (code: string, channel: string) => {
+ if (waitingFor.current !== code) {
+ return;
+ }
+ ImChannelBackend.getWeixinLoginStatus(code, channel)
+ .then(res => {
+ if (waitingFor.current !== code) {
+ return;
+ }
+ if (res.status !== "ok") {
+ setScanState(res.msg || "");
+ return;
+ }
+ if (res.data?.status === "confirmed") {
+ waitingFor.current = "";
+ setQrcode(null);
+ setScanState(i18next.t("channel:Signed in"));
+ load();
+ return;
+ }
+ // The endpoint answers "wait" immediately rather than holding the
+ // request open, so asking again is on a timer.
+ window.setTimeout(() => poll(code, channel), scanPollInterval);
+ })
+ .catch(failure => setScanState(failure.message || String(failure)));
+ };
+
+ const remove = (name: string) => {
+ return ImChannelBackend.deleteImChannel(name)
+ .then(() => load())
+ .catch(failure => setError(failure.message || String(failure)));
+ };
+
+ if (!isAdmin) {
+ return <UnauthorizedResult />;
+ }
+
+ const agentValue = editing ? editing.agentId + "\n" + editing.agentPath : "";
+
+ return (
+ <PageContainer>
+ <PageHeader
+ title={i18next.t("channel:Chat channels")}
+ description={i18next.t("channel:Talk to an agent on this machine from
a chat app")}
+ actions={
+ <Button
+ onClick={() => {
+ setEditing(emptyChannel());
+ setIsNew(true);
+ setQrcode(null);
+ setScanState("");
+ }}
+ >
+ <Plus className="size-4" />
+ {i18next.t("channel:Add channel")}
+ </Button>
+ }
+ />
+
+ {error ? <MessageAlert variant="destructive" title={error} /> : null}
+
+ {channels.length === 0 ? (
+ <EmptyState
+ icon={MessagesSquare}
+ title={loading ? i18next.t("general:Loading") :
i18next.t("channel:No channel yet")}
+ description={i18next.t("channel:A channel polls out from this
machine, so no public address is needed")}
+ />
+ ) : (
+ <div className="flex flex-col gap-2">
+ {channels.map(channel => (
+ <div key={channel.name} className="flex flex-wrap items-center
gap-3 rounded-lg border px-3 py-2.5">
+ <span className="font-medium">{channel.name}</span>
+ <Badge variant="outline">{channel.platform === PLATFORM_WEIXIN ?
"WeChat" : "Telegram"}</Badge>
+ <StatusBadge channel={channel} />
+ <span className="text-muted-foreground min-w-0 flex-1 truncate
text-xs">
+ {channel.agentId || i18next.t("channel:No agent bound")}
+ {channel.workDir ? " · " + channel.workDir : ""}
+ </span>
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ setEditing({...channel, token: ""});
+ setIsNew(false);
+ setQrcode(null);
+ setScanState("");
+ }}
+ >
+ <Pencil className="size-4" />
+ </Button>
+ <ConfirmDialog
+ title={i18next.t("channel:Remove this channel?")}
+ description={channel.name}
+ onConfirm={() => remove(channel.name)}
+ >
+ <Button size="sm" variant="ghost">
+ <Trash2 className="size-4" />
+ </Button>
+ </ConfirmDialog>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <FormDialog
+ open={editing !== null}
+ onOpenChange={open => (open ? null : closeForm())}
+ title={isNew ? i18next.t("channel:Add channel") :
i18next.t("channel:Edit channel")}
+ onSubmit={save}
+ submitting={saving}
+ submitDisabled={!editing?.name}
+ size="lg"
+ >
+ {editing ? (
+ <>
+ <Field label={i18next.t("channel:Name")} required>
+ <Input
+ value={editing.name}
+ disabled={!isNew}
+ onChange={event => setEditing({...editing, name:
event.target.value})}
+ />
+ </Field>
+
+ <Field label={i18next.t("channel:Platform")}>
+ <SimpleSelect
+ value={editing.platform}
+ onChange={value => setEditing({...editing, platform: value})}
+ options={chatPlatformOptions}
+ disabled={!isNew}
+ />
+ </Field>
+
+ {editing.platform === PLATFORM_TELEGRAM ? (
+ <Field
+ label={i18next.t("channel:Bot token")}
+ hint={i18next.t("channel:From @BotFather. Leave empty to keep
the stored one")}
+ >
+ <Input
+ value={editing.token}
+ placeholder={isNew ? "" : "***"}
+ onChange={event => setEditing({...editing, token:
event.target.value})}
+ />
+ </Field>
+ ) : (
+ <Field label={i18next.t("channel:WeChat sign-in")}
hint={scanState}>
+ {qrcode?.url ? (
+ // WeChat hands back the link its scanner opens rather than a
+ // picture, so the code itself is drawn here.
+ <div className="w-fit rounded border bg-white p-3">
+ <QRCodeSVG value={qrcode.url} size={168} />
+ </div>
+ ) : (
+ <Button type="button" variant="outline" onClick={scan}
disabled={!editing.name}>
+ <QrCode className="size-4" />
+ {i18next.t("channel:Show the code")}
+ </Button>
+ )}
+ </Field>
+ )}
+
+ <Field label={i18next.t("channel:Agent")}>
+ <SimpleSelect
+ value={agentValue}
+ onChange={value => {
+ const [agentId, path] = value.split("\n");
+ const found = agents.find(candidate => candidate.agentId ===
agentId && candidate.path === path);
+ setEditing({
+ ...editing,
+ agentId: agentId ?? "",
+ agentPath: path ?? "",
+ agentUser: found?.owner ?? "",
+ });
+ }}
+ placeholder={i18next.t("chat:Pick an agent")}
+ options={agents.map(candidate => ({
+ ...agentOption(candidate.agentId, candidate.name),
+ value: candidate.agentId + "\n" + candidate.path,
+ }))}
+ />
+ </Field>
+
+ <Field label={i18next.t("chat:Working directory")}>
+ <Input
+ value={editing.workDir}
+ onChange={event => setEditing({...editing, workDir:
event.target.value})}
+ />
+ </Field>
+
+ <Field
+ label={i18next.t("channel:Allowed users")}
+ hint={i18next.t("channel:Empty lets anybody who finds the bot
drive the agent")}
+ >
+ <TagsInput
+ value={editing.allowedUsers ?? []}
+ onChange={value => setEditing({...editing, allowedUsers:
value})}
+ />
+ </Field>
+
+ <Field label={i18next.t("channel:Listening")}>
+ <Switch
+ checked={editing.enabled}
+ onCheckedChange={value => setEditing({...editing, enabled:
value})}
+ />
+ </Field>
+ </>
+ ) : null}
+ </FormDialog>
+ </PageContainer>
+ );
+}
diff --git a/web/src/types.ts b/web/src/types.ts
index b5d138b..978122c 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -568,6 +568,40 @@ export interface DrivenEvent {
durationMs?: number;
}
+/** What one chat-platform listener is doing. */
+export interface ImChannelStatus {
+ name?: string;
+ running?: boolean;
+ error?: string;
+ startTime?: string;
+}
+
+/** One chat platform Gateway listens on, and what a conversation there talks
to. */
+export interface ImChannel {
+ owner?: string;
+ name: string;
+ createdTime?: string;
+ updatedTime?: string;
+ platform: string;
+ enabled: boolean;
+ /** Masked on the way out: a stored token never comes back to the browser. */
+ token: string;
+ agentId: string;
+ agentPath: string;
+ agentUser: string;
+ workDir: string;
+ model: string;
+ allowedUsers: string[];
+ status?: ImChannelStatus;
+}
+
+/** A WeChat sign-in waiting to be scanned. */
+export interface WeixinQrcode {
+ qrcode: string;
+ /** The link WeChat's scanner opens; the page draws the code out of it. */
+ url: string;
+}
+
/** One part of a transcript message: prose, thinking, a tool call or its
result. */
export interface AgentMessageBlock {
kind: "text" | "thinking" | "toolUse" | "toolResult" | "image";
diff --git a/web/yarn.lock b/web/yarn.lock
index 1160dee..f872fa3 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -3780,6 +3780,11 @@ punycode@^2.1.0:
resolved
"https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity
sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+qrcode.react@^4.2.0:
+ version "4.2.0"
+ resolved
"https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-4.2.0.tgz#1bce8363f348197d145c0da640929a24c83cbca3"
+ integrity
sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==
+
queue-microtask@^1.2.2:
version "1.2.3"
resolved
"https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]