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]

Reply via email to