Skip to content

Multi-chat support: one Orchestrator per authorized chat#93

Merged
macrosak merged 14 commits into
mainfrom
multi-chat-support
Apr 23, 2026
Merged

Multi-chat support: one Orchestrator per authorized chat#93
macrosak merged 14 commits into
mainfrom
multi-chat-support

Conversation

@macrosak

@macrosak macrosak commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Closes #91

Summary

  • sessions: session map keyed by chat name instead of a single mainSessionId (with legacy migration)
  • settings: rename chatIdadminChatId; AUTHORIZED_CHAT_ID env var still accepted as fallback
  • authorized-chats: new module (AuthorizedChats) to load/persist authorized chat list at runtime
  • app: one Orchestrator per chat in a Map<chatId, Orchestrator>; incoming messages routed by chatId
  • commands: /chats, /chatsadd <chatId> <name>, /chatsremove [name] — names use Telegram-valid characters (no hyphens). Behaviour:
    • /chats — admin only, lists authorized chats
    • /chatsadd — admin only, creates the Orchestrator and sessions slot
    • /chatsremove <name> — admin only, disposes the Orchestrator and clears sessions
    • /chatsremove (no arg, from admin) — shows a pick-list button for each authorized chat
    • /chatsremove (no arg, from an authorized non-admin chat) — self-removal
  • scheduler: per-job chat field; undefined → admin, "*" → broadcast to all orchestrators; App.handleCron is the public routing entrypoint (no test-only schedulerFactory)
  • prompt-builder: mandatory chatName constructor arg, rendered as chat="..." attribute on every <event> element. System prompt documents the multi-chat model and the chat attribute so the agent tailors responses per chat

Test plan

  • All tests pass with 100% line coverage (bun run check)
  • Smoke-test: add a secondary chat with /chatsadd, send a message, verify it gets a response
  • Admin /chatsremove with no arg shows chat-picker buttons
  • Button-based removal disposes the orchestrator and clears its session
  • Non-admin authorized chat: /chatsremove (no arg) self-removes
  • Cron job with chat: "*" broadcasts to all authorized chats
  • Existing admin chat continues working after upgrade (legacy session migration)

Refactor sessions.json from { mainSessionId } to
{ mainSessions: Record<string, string> } keyed by chat name, with
legacy migration that promotes any existing `mainSessionId` to
`mainSessions.admin` on load.

Introduce getMainSession/setMainSession/clearMainSession helpers so
future multi-chat code touches only one slot at a time. Orchestrator
still passes `"admin"` implicitly — behavior unchanged.

Preparation for issue #91.
Rename the settings field and its env var to make room for additional
authorized chats:

- settings.json:  chatId -> adminChatId
- env var:        AUTHORIZED_CHAT_ID -> ADMIN_CHAT_ID
- App config:     authorizedChatId -> adminChatId
- Setup wizard:   "Admin chat ID" prompt, notes /chats-add

Legacy fallbacks kept for one release:
- settings.json with `chatId` auto-migrates to `adminChatId` on load
- `AUTHORIZED_CHAT_ID` env var still honored when `ADMIN_CHAT_ID` is unset
  (logs a deprecation warning)

No behavior change — single-chat users keep working.

Preparation for issue #91.
Add src/authorized-chats.ts + tests. Loads, mutates, and persists
~/.macroclaw/authorized-chats.json. Stores additional chat IDs beyond
the admin chat, each with a human-readable name used for session
bookkeeping, cron routing, and memory tagging.

- AuthorizedChat: { chatId, name, addedAt }
- Name constraints: lowercase alphanumeric + dashes, unique,
  "admin" reserved
- add() / remove() / byName() / byChatId() / list()
- Typed errors: DuplicateChatError, InvalidChatNameError,
  UnknownChatError
- Missing or corrupt file → empty list with a warn log

Not wired into any consumer yet.

Preparation for issue #91.
Each authorized chat gets its own Orchestrator, keyed by chatId in a
Map. Admin orchestrator is always created first. Incoming messages
resolve to the right orchestrator by chatId, with unauthorized chats
silently ignored.
Admin-only commands to manage authorized chats at runtime.
/chats-add creates an Orchestrator for the new chat immediately.
/chats-remove disposes the Orchestrator and clears its session entry.
Jobs can now target a specific chat via the 'chat' field.
Default (omitted) routes to admin. '*' broadcasts to all orchestrators.
App routes cron jobs using #orchestratorByName; unknown chats are warned.
When chatName is provided (all production orchestrators), every prompt
is prepended with <chat>name</chat> so the agent knows which chat
context it is responding in.
Update architecture diagram, App layer description, and conventions to
reflect multi-orchestrator design with AuthorizedChats and per-job
chat routing in the Scheduler.
@macrosak macrosak force-pushed the multi-chat-support branch from 72f9569 to 87ac2db Compare April 21, 2026 08:24
Telegram bot commands cannot contain hyphens. Renamed /chats-add →
/chatsadd and /chats-remove → /chatsremove (matching the menu entries).

/chatsremove now supports three modes:
- with a name arg (admin only): remove that chat
- without arg from admin: pick-list buttons for each authorized chat
- without arg from a non-admin authorized chat: self-removal
chatName is now a required constructor arg, rendered as a chat="..."
attribute on every <event> element (not a separate <chat> sibling).
System prompt documents the multi-chat model and the chat attribute so
the agent tailors its response per chat.
@macrosak macrosak force-pushed the multi-chat-support branch 8 times, most recently from 0fd50ff to d3505d7 Compare April 21, 2026 11:36
The /chatsremove button-picker added in this PR exercises a code path
that triggers Bun's static ESM-binding check against the mocked
grammy module. Previous mocks only exposed Bot, which worked on main
because the button-picker path wasn't reached. With only Bot mocked,
telegram.ts's `import { InlineKeyboard, InputFile }` fails at load
time under the mock, aborting app.test.ts entirely (app tests silently
drop out of the suite, visible only as "Unhandled error between tests"
in the bun test log).

Mock both symbols so telegram.ts loads cleanly under mock.module.
@macrosak macrosak force-pushed the multi-chat-support branch from e5edcbe to c7b0492 Compare April 21, 2026 13:02
- /chatsadd rejects a chatId that matches the admin chat. Without this,
  adding such a chat overwrites the admin entry in the #orchestrators
  map, and removing it later deletes the admin mapping entirely.
- Setup wizard referenced /chats-add (hyphen); the real command is
  /chatsadd.
@macrosak macrosak force-pushed the multi-chat-support branch from bcf5994 to f65fe6f Compare April 21, 2026 15:06
- README: document /chats, /chatsadd, /chatsremove; rename chatId to
  adminChatId with legacy env-var note; add a Privacy Mode section
  explaining why bots appear silent in groups until it is disabled via
  BotFather.
- schedule-event skill: add the chat field to job examples and the
  fields table, with a Chat routing section explaining how to read the
  incoming event chat attribute and route the response back to the
  same chat (or * to broadcast).
… admin

memory-capture now runs per-chat (chat: "*") so each authorized chat's
independent session writes its own daily log. memory-consolidate stays
pinned to admin — it distills into MEMORY.md/USER.md, which are shared
workspace files and shouldn't be written from multiple chats.
@macrosak macrosak merged commit 4ab5e26 into main Apr 23, 2026
1 check passed
@macrosak macrosak deleted the multi-chat-support branch April 23, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support multiple authorized Telegram chats

1 participant