schedules, and turn loop. Its prompt limits work to its area, asks for
only reports that need the parent or the user, and permits an empty
reply. Children have no WhatsApp tools and draft replies instead of
-sending them. Clients and WhatsApp still address only the root. A
-child's saved schedules and subagents can resume at startup. The ready
-log line lists the child names.
+sending them. Clients address only the root. A child's saved schedules
+and subagents can resume at startup. The ready log line lists the child
+names.
A message from a parent to its own child enters the child's log as
``user`` and wakes it. A child's final reply reaches the parent as
``/whatsapp status``
Shows the link: not linked, linked and connected, or linked and
- disconnected, with the times of the connection.
+ disconnected, with the times of the connection and the WhatsApp
+ routes.
``/whatsapp unlink``
Ends the link and deletes the session. When WhatsApp is disconnected,
Each batch of a group goes to the chat, also with no turn, and the
memory tree summarizes it with model calls, which use the quota.
+The root manages source routes with ``route(source, agent)``,
+``unroute(source)``, and ``routes()``, only after the user agrees. A
+source is ``whatsapp:CHAT-ID``, with the ID from ``whatsapp_find`` or a
+``[WhatsApp]`` message, and the agent must be its child. The root keeps
+the map in ``agent/routes.json`` with mode 0600. Children and subagents
+have no route tools. There is no command to change routes.
+
When the session holds a linked device, the daemon connects at its
start. If the first connection fails, it tries again after 2 seconds,
then doubles the wait up to 5 minutes. The inbox and commands stay
}
// Tools returns the tools shared by the turns and compactions of this agent. A
-// child has neither WhatsApp tools nor child creation.
+// child has no WhatsApp, child creation, or route tools.
func (a *Agent) Tools() []ModelTool {
tools := promptTools()
if a.config.Parent != "" {
tools = slices.DeleteFunc(tools, func(tool ModelTool) bool {
- return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "agent_create"
+ return strings.HasPrefix(tool.Name, "whatsapp_") || tool.Name == "agent_create" || tool.Name == "route" || tool.Name == "unroute" || tool.Name == "routes"
})
}
// messages before the loops start. A queue entry stays until its recipient
// finishes the turn that takes it, so a stop can interrupt delivery or a turn.
func openDaemonMessages(loops []*TurnLoop) (*DaemonMessages, error) {
+ routes, err := openRoutes(filepath.Join(loops[0].agent.dir, "routes.json"), loops[0].logger)
+ if err != nil {
+ return nil, fmt.Errorf("open agent routes: %w", err)
+ }
+
m := &DaemonMessages{
+ routes: routes,
path: filepath.Join(loops[0].agent.dir, "messages.json"),
ordered: append([]*TurnLoop(nil), loops...),
loops: make(map[string]*TurnLoop),
loop.tools.messages = m
if loop.agent.config.Parent == "" {
loop.logger = loop.logger.With("name", loop.agent.config.Name)
+ loop.tools.routes = routes
+ if loop.tools.whatsapp != nil {
+ loop.tools.whatsapp.mutex.Lock()
+
+ loop.tools.whatsapp.routes = routes
+ loop.tools.whatsapp.mutex.Unlock()
+ }
}
}
ordered []*TurnLoop
state DaemonMessageFile
failure error
+ routes *Routes
}
// deliver records one queued message and wakes its recipient. The caller holds
--- /dev/null
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// DaemonMessageTest owns test agents and the loops selected by Start.
+type DaemonMessageTest struct {
+ agents DaemonAgents
+ loops []*TurnLoop
+ events []*TurnEventRecorder
+ messages *DaemonMessages
+ logs *SyncBuffer
+ cancel context.CancelFunc
+ done []chan error
+}
+
+func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
+ t.Helper()
+
+ ctx, cancel := context.WithCancel(t.Context())
+ r.cancel = cancel
+ for _, index := range indices {
+ done := make(chan error, 1)
+ r.done = append(r.done, done)
+ go func() {
+ done <- r.loops[index].Run(ctx)
+ }()
+ }
+}
+
+func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
+ t.Helper()
+
+ data, err := os.ReadFile(r.messages.path)
+ require.NoError(t, err)
+
+ var file DaemonMessageFile
+
+ require.NoError(t, json.Unmarshal(data, &file))
+
+ return file
+}
+
+func (r *DaemonMessageTest) Close(t *testing.T) {
+ t.Helper()
+
+ if r.cancel != nil {
+ r.cancel()
+
+ for _, done := range r.done {
+ require.ErrorIs(t, <-done, context.Canceled)
+ }
+
+ r.cancel = nil
+ r.done = nil
+ }
+
+ require.NoError(t, r.agents.Close())
+
+ r.agents = nil
+}
+
+func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
+ t.Helper()
+
+ _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
+ if errors.Is(err, os.ErrNotExist) {
+ daemonChildConfig(t, dir, "Garden")
+ daemonChildConfig(t, dir, "Library")
+ } else {
+ require.NoError(t, err)
+ }
+
+ test := &DaemonMessageTest{
+ logs: &SyncBuffer{},
+ }
+ test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ test.Close(t)
+ })
+
+ for _, agent := range test.agents {
+ events := &TurnEventRecorder{}
+ loop, err := agent.Loop(nil, events.Add)
+ require.NoError(t, err)
+
+ test.events = append(test.events, events)
+ test.loops = append(test.loops, loop)
+ }
+
+ test.messages, err = openDaemonMessages(test.loops)
+ require.NoError(t, err)
+
+ return test
+}
import (
"context"
- "encoding/json"
"errors"
"net/http"
"os"
require.Contains(t, test.events[1].LastEvent("work").Text, "Check the plants.")
require.Equal(t, scheduleSource, test.events[1].LastEvent("work").Source)
}
-
-// DaemonMessageTest owns test agents and the loops selected by Start.
-type DaemonMessageTest struct {
- agents DaemonAgents
- loops []*TurnLoop
- events []*TurnEventRecorder
- messages *DaemonMessages
- logs *SyncBuffer
- cancel context.CancelFunc
- done []chan error
-}
-
-func (r *DaemonMessageTest) Start(t *testing.T, indices ...int) {
- t.Helper()
-
- ctx, cancel := context.WithCancel(t.Context())
- r.cancel = cancel
- for _, index := range indices {
- done := make(chan error, 1)
- r.done = append(r.done, done)
- go func() {
- done <- r.loops[index].Run(ctx)
- }()
- }
-}
-
-func (r *DaemonMessageTest) Queue(t *testing.T) DaemonMessageFile {
- t.Helper()
-
- data, err := os.ReadFile(r.messages.path)
- require.NoError(t, err)
-
- var file DaemonMessageFile
-
- require.NoError(t, json.Unmarshal(data, &file))
-
- return file
-}
-
-func (r *DaemonMessageTest) Close(t *testing.T) {
- t.Helper()
-
- if r.cancel != nil {
- r.cancel()
-
- for _, done := range r.done {
- require.ErrorIs(t, <-done, context.Canceled)
- }
-
- r.cancel = nil
- r.done = nil
- }
-
- require.NoError(t, r.agents.Close())
-
- r.agents = nil
-}
-
-func newDaemonMessageTest(t *testing.T, dir string, server *ModelFake) *DaemonMessageTest {
- t.Helper()
-
- _, err := os.Stat(filepath.Join(dir, daemonAgentDirName, "children"))
- if errors.Is(err, os.ErrNotExist) {
- daemonChildConfig(t, dir, "Garden")
- daemonChildConfig(t, dir, "Library")
- } else {
- require.NoError(t, err)
- }
-
- test := &DaemonMessageTest{
- logs: &SyncBuffer{},
- }
- test.agents, err = openDaemonAgents(t.Context(), dir, server.URL, ModelAPIKey(modelTestKey), defaultConfig().Compactor, newTestClock().Now, daemonTestLogger(test.logs))
- require.NoError(t, err)
- t.Cleanup(func() {
- test.Close(t)
- })
-
- for _, agent := range test.agents {
- events := &TurnEventRecorder{}
- loop, err := agent.Loop(nil, events.Add)
- require.NoError(t, err)
-
- test.events = append(test.events, events)
- test.loops = append(test.loops, loop)
- }
-
- test.messages, err = openDaemonMessages(test.loops)
- require.NoError(t, err)
-
- return test
-}
Use tell for children and subagents, and zoom("Name") for their memory.
Pass area messages from mixed chats to the right child with sender, date, and
words. Never send a WhatsApp reply drafted by a child without the user's
-agreement.`
+agreement.
+Propose routing a source that talks only about one area to its child.
+Set or remove a route only after the user agrees.`
// promptSubagentText is the system prompt of a subagent, from the subagent
// prompt of the reference design: its head, then the doc of the view, without
// promptTools returns the function tools of every call of the agent: the tools
// of promptSubagentTools, then spawn, agent_create, tell, the WhatsApp tools,
-// and the schedule tools. A compaction sends them too and never calls them,
-// because a change of the tools ends the shared prompt cache.
+// the route tools, and the schedule tools. A compaction sends them too and
+// never calls them, because a change of the tools ends the shared prompt cache.
func promptTools() []ModelTool {
return append(promptSubagentTools(), ModelTool{
Type: "function",
"text": promptStringSchema("The text of the message."),
}),
Strict: true,
+ }, ModelTool{
+ Type: "function",
+ Name: "route",
+ Description: "Route a source to your child, only after the user agrees. Use whatsapp:CHAT-ID with the ID from whatsapp_find or a [WhatsApp] message.",
+ Parameters: promptObjectSchema(map[string]any{
+ "source": promptStringSchema("The source key, whatsapp:CHAT-ID."),
+ "agent": promptStringSchema("The name of your child."),
+ }),
+ Strict: true,
+ }, ModelTool{
+ Type: "function",
+ Name: "unroute",
+ Description: "Remove a source route, only after the user agrees. New inputs go to you.",
+ Parameters: promptObjectSchema(map[string]any{
+ "source": promptStringSchema("The source key, whatsapp:CHAT-ID."),
+ }),
+ Strict: true,
+ }, ModelTool{
+ Type: "function",
+ Name: "routes",
+ Description: "List source routes to your children.",
+ Parameters: promptObjectSchema(map[string]any{}),
+ Strict: true,
}, ModelTool{
Type: "function",
Name: "schedule_set",
func TestAgentToolsAreStrictAndStable(t *testing.T) {
tools := promptTools()
- require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "agent_create", "tell", "whatsapp_find", "whatsapp_send", "schedule_set", "schedule_list", "schedule_remove"}, promptToolNames(tools))
+ require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit", "spawn", "agent_create", "tell", "whatsapp_find", "whatsapp_send", "route", "unroute", "routes", "schedule_set", "schedule_list", "schedule_remove"}, promptToolNames(tools))
require.Equal(t, []string{"zoom", "date", "shell", "read", "write", "edit"}, promptToolNames(promptSubagentTools()))
for _, tool := range tools {
require.Contains(t, text, `Use tell for children and subagents, and zoom("Name") for their memory.`)
require.Contains(t, text, "Pass area messages from mixed chats to the right child with sender, date, and\nwords.")
require.Contains(t, text, "Never send a WhatsApp reply drafted by a child without the user's\nagreement.")
+ require.Contains(t, text, "Set or remove a route only after the user agrees.")
require.Contains(t, text, "You act again when a message or your own schedule wakes you.")
require.True(t, strings.HasSuffix(text, "Keep replies short."))
--- /dev/null
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "slices"
+ "strings"
+ "sync"
+
+ "go.mau.fi/whatsmeow/types"
+)
+
+// openRoutes reads the source-to-child map. A missing file has no routes.
+func openRoutes(path string, logger *slog.Logger) (*Routes, error) {
+ r := &Routes{
+ path: path,
+ logger: logger,
+ targets: make(map[string]string),
+ }
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return r, nil
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("read routes: %w", err)
+ }
+
+ err = json.Unmarshal(data, &r.targets)
+ if err != nil {
+ return nil, fmt.Errorf("decode routes: %w", err)
+ }
+
+ for source, target := range r.targets {
+ err = routeSource(source)
+ if err != nil {
+ return nil, fmt.Errorf("validate route: %w", err)
+ }
+
+ if target == "" {
+ return nil, errors.New("route has an empty agent name")
+ }
+ }
+
+ if r.targets == nil {
+ r.targets = make(map[string]string)
+ }
+
+ return r, nil
+}
+
+// Routes owns the root's source map. Writes replace the private file before
+// changing the map in memory. The mutex protects reads and writes from tools
+// and status commands. Logs omit source IDs, which can hold phone numbers.
+type Routes struct {
+ mutex sync.Mutex
+ path string
+ logger *slog.Logger
+ targets map[string]string
+}
+
+// Set maps source to a child name, or removes it when target is empty. The
+// caller checks that a nonempty target is a child of the root.
+func (r *Routes) Set(source, target string) error {
+ err := routeSource(source)
+ if err != nil {
+ return fmt.Errorf("validate source: %w", err)
+ }
+
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ targets := make(map[string]string, len(r.targets)+1)
+ for key, value := range r.targets {
+ targets[key] = value
+ }
+
+ if target == "" {
+ delete(targets, source)
+ } else {
+ targets[source] = target
+ }
+
+ data, err := json.Marshal(targets)
+ if err != nil {
+ return fmt.Errorf("encode routes: %w", err)
+ }
+
+ err = writeFileAtomic(r.path, append(data, '\n'))
+ if err != nil {
+ return fmt.Errorf("save routes: %w", err)
+ }
+
+ r.targets = targets
+ if target == "" {
+ r.logger.Info("route removed", "kind", whatsappSource)
+ } else {
+ r.logger.Info("route set", "kind", whatsappSource, "name", target)
+ }
+
+ return nil
+}
+
+// Target returns the configured name, or an empty string for an unrouted
+// source.
+func (r *Routes) Target(source string) string {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ return r.targets[source]
+}
+
+// List returns the routes in source order, or a short empty result.
+func (r *Routes) List() string {
+ r.mutex.Lock()
+ defer r.mutex.Unlock()
+
+ var sources []string
+
+ for source := range r.targets {
+ sources = append(sources, source)
+ }
+
+ slices.Sort(sources)
+
+ var lines []string
+
+ for _, source := range sources {
+ lines = append(lines, source+" -> "+r.targets[source])
+ }
+
+ if len(lines) == 0 {
+ return "No routes."
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// routeSource accepts the supported source kind and a canonical private or
+// group chat ID. The key keeps its kind so later sources can use the same map.
+func routeSource(source string) error {
+ kind, id, _ := strings.Cut(source, ":")
+ if kind != whatsappSource {
+ return errors.New("unknown source kind: use whatsapp:CHAT-ID")
+ }
+
+ jid, err := types.ParseJID(id)
+ if err != nil || jid.User == "" || jid.String() != id || jid != jid.ToNonAD() || (jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer && jid.Server != types.GroupServer) || strings.ContainsAny(id, " \t\r\n") {
+ return errors.New("invalid WhatsApp source: use whatsapp:CHAT-ID from whatsapp_find or a [WhatsApp] message")
+ }
+
+ return nil
+}
--- /dev/null
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRootRouteToolsPersistAndRemoveRoutes(t *testing.T) {
+ dir := t.TempDir()
+ server := newModelFake(t)
+ test := newDaemonMessageTest(t, dir, server)
+ root := test.loops[0]
+ source := "whatsapp:" + whatsappTestFamily.String()
+ arguments := `{"source":"` + source + `","agent":"Garden"}`
+ require.Equal(t, "Route set.", root.tools.run(t.Context(), "route", arguments))
+ require.Equal(t, source+" -> Garden", root.tools.run(t.Context(), "routes", `{}`))
+
+ info, err := os.Stat(filepath.Join(root.agent.dir, "routes.json"))
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+ require.Contains(t, test.logs.String(), "route set kind=whatsapp name=Garden")
+ require.NotContains(t, test.logs.String(), whatsappTestFamily.String())
+ test.Close(t)
+
+ restored := newDaemonMessageTest(t, dir, server)
+ require.Equal(t, source+" -> Garden", restored.loops[0].tools.run(t.Context(), "routes", `{}`))
+ require.Equal(t, "Route removed.", restored.loops[0].tools.run(t.Context(), "unroute", `{"source":"`+source+`"}`))
+ require.Equal(t, "No routes.", restored.loops[0].tools.run(t.Context(), "routes", `{}`))
+ require.Contains(t, restored.logs.String(), "route removed kind=whatsapp")
+ restored.Close(t)
+
+ empty := newDaemonMessageTest(t, dir, server)
+ require.Equal(t, "No routes.", empty.loops[0].tools.run(t.Context(), "routes", `{}`))
+ require.Zero(t, server.Calls())
+}
+
+func TestRoutesRefuseUnknownSourcesAndNonchildren(t *testing.T) {
+ test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+ testCases := []struct {
+ name string
+ source string
+ agent string
+ cause string
+ }{
+ {
+ name: "unknown kind",
+ source: "mail:inbox",
+ agent: "Garden",
+ cause: "unknown source kind",
+ },
+ {
+ name: "missing kind",
+ source: whatsappTestFamily.String(),
+ agent: "Garden",
+ cause: "unknown source kind",
+ },
+ {
+ name: "empty chat",
+ source: "whatsapp:",
+ agent: "Garden",
+ cause: "invalid WhatsApp source",
+ },
+ {
+ name: "unknown chat kind",
+ source: "whatsapp:example@unknown",
+ agent: "Garden",
+ cause: "invalid WhatsApp source",
+ },
+ {
+ name: "root is not a child",
+ source: "whatsapp:" + whatsappTestFamily.String(),
+ agent: "OneChat",
+ cause: "must be a child",
+ },
+ {
+ name: "unknown child",
+ source: "whatsapp:" + whatsappTestFamily.String(),
+ agent: "Other",
+ cause: "must be a child",
+ },
+ {
+ name: "empty child",
+ source: "whatsapp:" + whatsappTestFamily.String(),
+ agent: "",
+ cause: "must be a child",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ arguments := `{"source":"` + tc.source + `","agent":"` + tc.agent + `"}`
+ require.Contains(t, test.loops[0].tools.run(t.Context(), "route", arguments), tc.cause)
+ })
+ }
+
+ require.Contains(t, test.loops[0].tools.run(t.Context(), "unroute", `{"source":"mail:inbox"}`), "unknown source kind")
+ require.Equal(t, "No routes.", test.messages.routes.List())
+}
+
+func TestRouteToolsBelongOnlyToRoot(t *testing.T) {
+ test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+ for _, name := range []string{"route", "unroute", "routes"} {
+ require.Contains(t, promptToolNames(test.agents[0].agent.Tools()), name)
+ require.NotContains(t, promptToolNames(test.agents[1].agent.Tools()), name)
+ require.NotContains(t, promptToolNames(promptSubagentTools()), name)
+ require.Contains(t, test.loops[1].tools.run(t.Context(), name, `{}`), "unknown tool")
+ }
+
+ require.NotContains(t, promptChild("Garden", "OneChat", "Keep plans.", "/CHILD/instructions.md", ""), "Propose routing a source")
+}
+
+func TestWhatsAppStatusListsRoutes(t *testing.T) {
+ whatsapp, _, _ := whatsappStartTest(t, t.TempDir(), newTestClock())
+ test := newDaemonMessageTest(t, t.TempDir(), newModelFake(t))
+ whatsapp.routes = test.messages.routes
+ require.NoError(t, test.messages.routes.Set("whatsapp:"+whatsappTestFamily.String(), "Garden"))
+
+ var answer string
+
+ whatsapp.Command("status", func(text string) {
+ answer = text
+ })
+ require.Contains(t, answer, "WhatsApp is not linked.")
+ require.Contains(t, answer, "WhatsApp routes:\nwhatsapp:"+whatsappTestFamily.String()+" -> Garden")
+}
+
+func TestInvalidRouteFileStopsStartup(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ }{
+ {
+ name: "invalid JSON",
+ text: "{",
+ },
+ {
+ name: "unknown source",
+ text: `{"mail:inbox":"Garden"}`,
+ },
+ {
+ name: "empty destination",
+ text: `{"whatsapp:example@lid":""}`,
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "routes.json")
+ require.NoError(t, os.WriteFile(path, []byte(tc.text), 0o600))
+
+ _, err := openRoutes(path, discardLog())
+ require.Error(t, err)
+ })
+ }
+}
// subagent's log. The toolbox of the agent has the runs of its subagents for
// spawn and tell, the WhatsApp link for the WhatsApp tools, and the schedules
// for the schedule tools. Each permanent agent has the parent-child queue. Only
-// the root has child creation. A child has no WhatsApp link or child creation.
-// The toolbox of a subagent has none of them, so these are unknown tools there.
+// the root has child creation and source routes. A child has neither of them
+// nor a WhatsApp link. The toolbox of a subagent has none of them, so these are
+// unknown tools there.
type Toolbox struct {
log ToolLog
chat ToolChat
agentName string
messages *DaemonMessages
children *DaemonChildren
+ routes *Routes
}
// Call logs the call of the tool name with its JSON arguments, makes the log
case err == nil:
output, err = b.children.Create(args.Name, args.Responsibility)
}
+ case "route", "unroute", "routes":
+ var args ToolRouteArguments
+
+ switch name {
+ case "routes":
+ err = toolDecodeArguments(arguments, &struct{}{})
+ case "unroute":
+ var source ToolRouteSourceArguments
+
+ err = toolDecodeArguments(arguments, &source)
+ args.Source = source.Source
+ default:
+ err = toolDecodeArguments(arguments, &args)
+ }
+
+ switch {
+ case b.routes == nil:
+ err = fmt.Errorf("unknown tool %q", name)
+ case err != nil:
+ case name == "routes":
+ output = b.routes.List()
+ case name == "route" && (b.messages == nil || b.messages.Child(b.agentName, args.Agent) == nil):
+ err = errors.New("the agent must be a child of the root")
+ default:
+ err = b.routes.Set(args.Source, args.Agent)
+ output = "Route removed."
+ if name == "route" {
+ output = "Route set."
+ }
+ }
case "tell":
var args ToolTellArguments
Responsibility string `json:"responsibility"`
}
+// ToolRouteArguments are the arguments of route.
+type ToolRouteArguments struct {
+ Source string `json:"source"`
+ Agent string `json:"agent"`
+}
+
+// ToolRouteSourceArguments are the arguments of unroute.
+type ToolRouteSourceArguments struct {
+ Source string `json:"source"`
+}
+
// ToolTellArguments are the arguments of tell.
type ToolTellArguments struct {
Name string `json:"name"`
state *WhatsAppState
groups *WhatsAppGroups
inbox *WhatsAppInbox
+ routes *Routes
sessionMutex sync.Mutex
mutex sync.Mutex
}
// Status returns the state of the link: not linked, linked and connected, or
-// linked and disconnected, with the times of the connection. It never holds the
-// phone number.
+// linked and disconnected, with the times of the connection and the routes.
+// Route source IDs can hold the phone numbers of other chats.
func (w *WhatsApp) Status() string {
w.mutex.Lock()
defer w.mutex.Unlock()
+ routes := ""
+ if w.routes != nil {
+ listed := w.routes.List()
+ if listed != "No routes." {
+ routes = "\nWhatsApp routes:\n" + listed
+ }
+ }
+
switch {
case !w.linked:
- return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it."
+ return "WhatsApp is not linked. Run /whatsapp pair PHONE to link it." + routes
case w.connected:
- return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "."
+ return "WhatsApp is linked and connected since " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
case w.disconnectedAt.IsZero():
- return "WhatsApp is linked and not connected yet."
+ return "WhatsApp is linked and not connected yet." + routes
case w.connectedAt.IsZero():
- return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "."
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + "." + routes
default:
- return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "."
+ return "WhatsApp is linked and disconnected since " + w.disconnectedAt.Format(whatsappTimeLayout) + ". The last connection started at " + w.connectedAt.Format(whatsappTimeLayout) + "." + routes
}
}