From 2eeb9e14b1c678b9b1273745a7dca6908f0d0742 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:51:29 +0000 Subject: [PATCH] feat(channels): first message claims the owner A channel enabled with an empty allowlist used to let nobody in, so the user had to look up their own platform sender id and run `redrob channels allow`. Now `redrob channels enable` opens a 15-minute claim window (`claimUntil` in channels.json); the first person who messages the bot inside it is written into the allowlist as the owner, greeted, and answered. After the window a stranger is told to turn the channel off and on again; once owned, the old "not allowed" reply stays. The claim is applied in the running allowlist and the manager rewrites its config snapshot, so a later /channels/reload does not restart the transport. GET /channels/status gains `owner` and `claimOpen`; the client types are regenerated. CLI: `enable` prints the waiting line, `list` shows owner, `status` shows "waiting for its owner". Docs and transport READMEs replace the allow step with the claim; `allow` remains the way to add more people. --- docs/CHANNELS.md | 47 ++++++++++++++----- packages/channels/core/src/allowlist.ts | 60 +++++++++++++++++++----- packages/channels/core/src/config.ts | 53 ++++++++++++++++++++- packages/channels/core/src/index.ts | 50 +++++++++++++++++--- packages/channels/discord/README.md | 5 +- packages/channels/slack/README.md | 3 +- packages/channels/telegram/README.md | 6 ++- packages/channels/whatsapp/README.md | 4 +- packages/client/src/generated/types.ts | 2 + packages/protocol/src/groups/channels.ts | 7 ++- packages/redrob/src/cli/cmd/channels.ts | 45 ++++++++++++++---- packages/server/src/channels.ts | 4 ++ 12 files changed, 237 insertions(+), 49 deletions(-) diff --git a/docs/CHANNELS.md b/docs/CHANNELS.md index 67fb1f1c69..e48536bb34 100644 --- a/docs/CHANNELS.md +++ b/docs/CHANNELS.md @@ -34,7 +34,7 @@ daemon record. "maxChunk": 3900, "options": {} }, - "telegram": { "enabled": false, "allowlist": [] } + "telegram": { "enabled": true, "allowlist": [], "claimUntil": 1760000000000 } } } ``` @@ -42,12 +42,25 @@ daemon record. - `directory` is the project directory new sessions are created in (the engine's `x-redrob-directory`). The file-level value is the default; a channel may override it. When neither is set, the user's home. -- `allowlist` is the list of platform sender ids allowed to talk. **Empty means nobody.** A denied - sender gets one "You are not allowed to use this bot." reply per daemon start and a warning in - the daemon log; nothing reaches the engine. +- `allowlist` is the list of platform sender ids allowed to talk. The first entry is the + **owner**. **Empty means nobody** -- except while the channel is waiting for its owner (below). + Once somebody owns it, a denied sender gets one "You are not allowed to use this bot." reply per + daemon start and a warning in the daemon log; nothing reaches the engine. +- `claimUntil` (epoch ms) is the **owner claim window**. `redrob channels enable ` and the + launcher set it to now + 15 minutes when the allowlist is empty. While it is in the future, the + first person who messages the bot is written into the allowlist, the field is removed, and the + bot answers "You are now the owner of this bot. Only you can talk to it. Send /new to start a + fresh session." and then handles that message normally. After the window has passed, a sender + gets (once) "Nobody owns this bot yet. Turn the channel off and on again in Redrob Launcher, + then send a message within 15 minutes." The claim takes effect in the running daemon at once; no + restart and no reconnect. - `maxChunk` caps a reply chunk for this channel (default: the transport's own limit, else 3900). - `options` is free-form, non-secret, read by the transport (Stage 2 documents its keys). +Nobody has to look up their own Telegram id, Slack member id, Discord user id or phone number: +turn the channel on and send it a message. `redrob channels allow ` is for adding +**more** people after that (it also closes any open claim window). + Unknown channel names and malformed entries are dropped on load. The CLI edits this file; editing it by hand and running `redrob channels enable ` (or `POST /channels/reload`) is fine too. @@ -137,28 +150,34 @@ Both are under the daemon's Basic auth (`redrob:` from `server.json`) an location header. `GET /channels/status` is a status poll and does not count as daemon activity. ``` -GET /channels/status -> { "channels": [ { "name", "enabled", "connected", "available", "lastError"? } ] } +GET /channels/status -> { "channels": [ { "name", "enabled", "connected", "available", "owner"?, "claimOpen", "lastError"? } ] } POST /channels/reload -> { "running": ["slack"] } ``` +`owner` is the first allowlist entry (absent while nobody is allowed); `claimOpen` is true while +the channel is waiting for its owner's first message, which is what the launcher shows as +"waiting for your first message". + `reload` re-reads `channels.json`: newly enabled channels start, disabled ones stop, a channel whose entry changed is restarted. An embedded server (no `--register`) answers with empty lists. ## CLI ``` -redrob channels list # configured channels, allowlists, secret key names +redrob channels list # configured channels, owner, secret key names redrob channels status [--json] # what the running daemon has connected (exit 1 when none) -redrob channels enable +redrob channels enable # opens a 15-minute owner claim window when nobody is allowed redrob channels disable -redrob channels allow [--remove] +redrob channels allow [--remove] # add MORE people after the owner redrob channels set-secret [--value v] # value from stdin when --value is omitted redrob channels slack-manifest [--url] ``` `` is one of `slack`, `telegram`, `discord`, `whatsapp`. `enable`, `disable`, `allow` and `set-secret` write the file and then `POST /channels/reload` on the running registered daemon; -with no daemon the change waits for the next start. +with no daemon the change waits for the next start. `enable` with an empty allowlist prints +`Waiting for its owner: send any message to the bot within 15 minutes.`; `list` shows +`owner: ` or `owner: (waiting)`. `slack-manifest` prints the Slack app manifest: bot named **Redrob**, Socket Mode on, bot scopes `app_mentions:read chat:write im:history im:read im:write channels:history groups:history @@ -172,8 +191,9 @@ Setting up Slack: redrob channels slack-manifest --url # open it, create the app, install to the workspace redrob channels set-secret slack botToken # paste xoxb-… , Ctrl-D redrob channels set-secret slack appToken # paste xapp-… (Socket Mode, connections:write), Ctrl-D -redrob channels allow slack U0123ABCDEF # your Slack member id redrob channels enable slack +# then DM the bot within 15 minutes: the first message makes you its owner +# (redrob channels allow slack adds more people later) ``` ## Transports @@ -206,8 +226,9 @@ needed. # OAuth2 URL Generator: scope bot, permissions View Channels, Send Messages, # Send Messages in Threads, Read Message History; add the bot to the server. redrob channels set-secret discord botToken # paste the token, Ctrl-D -redrob channels allow discord 123456789012345678 # your Discord user id (Developer Mode -> Copy User ID) redrob channels enable discord +# then DM the bot (or mention it) within 15 minutes: the first message makes you its owner +# (redrob channels allow discord adds more people later) ``` ### WhatsApp (`@redrob-code/channels-whatsapp`) @@ -216,8 +237,10 @@ An **unofficial** client over Baileys -- WhatsApp may ban accounts that use it; see the package README. ```sh -redrob channels allow whatsapp 821012345678 # the sender's phone number, digits only redrob channels enable whatsapp +# scan the QR code, then send the linked number a message within 15 minutes (from the paired +# phone itself or another one): the first sender becomes the owner +# (redrob channels allow whatsapp adds more people later) ``` No secret is set. While unpaired the channel starts anyway and writes the QR code to diff --git a/packages/channels/core/src/allowlist.ts b/packages/channels/core/src/allowlist.ts index d8e08b684e..c0e517d17c 100644 --- a/packages/channels/core/src/allowlist.ts +++ b/packages/channels/core/src/allowlist.ts @@ -1,24 +1,39 @@ /** * Who may talk to the daemon through a channel: an explicit list of sender IDs per channel. * - * Deny by default. An empty list lets nobody in, so enabling a channel without allowing anyone is - * safe: the bot connects, answers "not allowed" once per denied sender per start, and logs it. + * Deny by default. An empty list lets nobody in -- except while the channel is waiting for its + * owner: `redrob channels enable` (and the launcher) open a claim window (`claimUntil`) when the + * allowlist is empty, and the FIRST person who messages the bot inside that window becomes the + * owner. Nobody has to look up their own platform id. Once the window has passed, strangers are + * told to turn the channel off and on again; once somebody owns it, they are told "not allowed". + * Either reply goes out once per denied sender per start, and every denial is logged. */ import type { ChannelConfig } from "./config" import type { Inbound, Logger } from "./transport" export const DENIED_REPLY = "You are not allowed to use this bot." +export const CLAIMED_REPLY = + "You are now the owner of this bot. Only you can talk to it. Send /new to start a fresh session." +export const UNCLAIMED_REPLY = + "Nobody owns this bot yet. Turn the channel off and on again in Redrob Launcher, then send a message within 15 minutes." + +export type Verdict = + /** Proceed. `claimed` is set when this very message made the sender the owner. */ + | { allowed: true; claimed?: string } + | { allowed: false; reply: string | undefined } export class Allowlist { private readonly ids: Set private readonly warned = new Set() + private claimUntil: number | undefined - constructor(ids: readonly string[]) { + constructor(ids: readonly string[], claimUntil?: number) { this.ids = new Set(ids.map((id) => id.trim()).filter((id) => id.length > 0)) + this.claimUntil = claimUntil } static fromConfig(config: ChannelConfig): Allowlist { - return new Allowlist(config.allowlist) + return new Allowlist(config.allowlist, config.claimUntil) } allows(senderId: string): boolean { @@ -29,18 +44,37 @@ export class Allowlist { return this.ids.size } + /** The owner: the first allowed sender. */ + get owner(): string | undefined { + return this.ids.values().next().value + } + + /** True while nobody is allowed and the first sender may still claim the channel. */ + claimOpen(now: number = Date.now()): boolean { + return this.ids.size === 0 && this.claimUntil !== undefined && now < this.claimUntil + } + /** - * Decide about one message. Returns the reply to send when the sender is denied (only the - * first time per sender, so a chatty stranger does not get a reply per line), or undefined when - * the message may proceed. + * Decide about one message. Allowed senders proceed. When nobody is allowed and the claim + * window is open, the sender is recorded as the owner in memory (the caller persists it with + * `Config.claim`) and proceeds with `claimed` set so the caller can greet them first. Otherwise + * the verdict carries the reply to send, only the first time per sender, so a chatty stranger + * does not get a reply per line. */ - check(message: Inbound, log: Logger): { allowed: true } | { allowed: false; reply: string | undefined } { - if (this.allows(message.senderId)) return { allowed: true } + check(message: Inbound, log: Logger, now: number = Date.now()): Verdict { + const senderId = message.senderId.trim() + if (this.ids.has(senderId)) return { allowed: true } + const who = `${message.channel} sender ${message.senderId}${message.senderName ? ` (${message.senderName})` : ""} in ${message.conversationId}` + if (this.claimOpen(now) && senderId.length > 0) { + this.ids.add(senderId) + this.claimUntil = undefined + log.info(`claimed ${message.channel} owner ${senderId}`) + return { allowed: true, claimed: senderId } + } const first = !this.warned.has(message.senderId) this.warned.add(message.senderId) - log.warn( - `denied ${message.channel} sender ${message.senderId}${message.senderName ? ` (${message.senderName})` : ""} in ${message.conversationId}`, - ) - return { allowed: false, reply: first ? DENIED_REPLY : undefined } + log.warn(`denied ${who}`) + const reply = this.ids.size === 0 ? UNCLAIMED_REPLY : DENIED_REPLY + return { allowed: false, reply: first ? reply : undefined } } } diff --git a/packages/channels/core/src/config.ts b/packages/channels/core/src/config.ts index 8f0d674489..1297e6f96e 100644 --- a/packages/channels/core/src/config.ts +++ b/packages/channels/core/src/config.ts @@ -10,10 +10,13 @@ * "directory": "/path/the/sessions/work/in", * "channels": { * "slack": { "enabled": true, "allowlist": ["U0123ABC"] }, - * "telegram": { "enabled": false, "allowlist": [] } + * "telegram": { "enabled": true, "allowlist": [], "claimUntil": 1760000000000 } * } * } * ``` + * + * `claimUntil` is the owner claim window: while it is in the future and the allowlist is empty, + * the first person who messages the bot is written into the allowlist and the field is removed. */ import * as Paths from "./paths" @@ -26,8 +29,16 @@ export function isChannelName(value: string): value is ChannelName { export type ChannelConfig = { readonly enabled: boolean - /** Sender IDs allowed to talk to the daemon. Empty means nobody. */ + /** + * Sender IDs allowed to talk to the daemon. Empty means nobody -- unless a claim window is open, + * in which case the first sender becomes the owner (`claimUntil`). + */ readonly allowlist: readonly string[] + /** + * Epoch ms until which an empty allowlist may be claimed by the first person who messages the + * bot. Set by `openClaim` when a channel is enabled with nobody allowed; cleared by the claim. + */ + readonly claimUntil?: number /** Project directory the channel's sessions work in; falls back to the file-level `directory`. */ readonly directory?: string /** Longest reply chunk this channel accepts; the renderer splits at this many characters. */ @@ -61,6 +72,7 @@ function channel(value: unknown): ChannelConfig | undefined { return { enabled: value.enabled === true, allowlist, + ...(typeof value.claimUntil === "number" && Number.isFinite(value.claimUntil) ? { claimUntil: value.claimUntil } : {}), ...(typeof value.directory === "string" ? { directory: value.directory } : {}), ...(typeof value.maxChunk === "number" && value.maxChunk > 0 ? { maxChunk: value.maxChunk } : {}), ...(isObject(value.options) ? { options: value.options } : {}), @@ -108,6 +120,43 @@ export function setChannel(config: ChannelsConfig, name: ChannelName, patch: Par return { ...config, channels: { ...config.channels, [name]: { ...current, ...patch } } } } +/** How long after enabling a channel with nobody allowed the first sender may claim it. */ +export const CLAIM_WINDOW_MS = 15 * 60_000 + +function withoutClaim(config: ChannelConfig): ChannelConfig { + const { claimUntil: _dropped, ...rest } = config + return rest +} + +/** + * Turn a channel on. With an empty allowlist this also opens the owner claim window: the first + * person to message the bot within `CLAIM_WINDOW_MS` becomes the only allowed sender. With an + * allowlist already set, any stale window is dropped. + */ +export function openClaim(config: ChannelsConfig, name: ChannelName, now: number = Date.now()): ChannelsConfig { + const current = withoutClaim(config.channels[name] ?? defaultChannel()) + const next: ChannelConfig = + current.allowlist.length === 0 ? { ...current, enabled: true, claimUntil: now + CLAIM_WINDOW_MS } : { ...current, enabled: true } + return { ...config, channels: { ...config.channels, [name]: next } } +} + +/** True while nobody is allowed and the claim window has not passed. */ +export function claimOpen(config: ChannelConfig | undefined, now: number = Date.now()): boolean { + return !!config && config.allowlist.length === 0 && typeof config.claimUntil === "number" && now < config.claimUntil +} + +/** The owner: the first allowed sender, or undefined while nobody is. */ +export function owner(config: ChannelConfig | undefined): string | undefined { + return config?.allowlist[0] +} + +/** Record `senderId` as the owner: first in the allowlist, and the claim window closes. */ +export function claim(config: ChannelsConfig, name: ChannelName, senderId: string): ChannelsConfig { + const current = withoutClaim(config.channels[name] ?? defaultChannel()) + const allowlist = [...new Set([senderId, ...current.allowlist])] + return { ...config, channels: { ...config.channels, [name]: { ...current, allowlist } } } +} + export function enabledChannels(config: ChannelsConfig): ChannelName[] { return CHANNEL_NAMES.filter((name) => config.channels[name]?.enabled === true) } diff --git a/packages/channels/core/src/index.ts b/packages/channels/core/src/index.ts index 59df3d128f..163ed37b6b 100644 --- a/packages/channels/core/src/index.ts +++ b/packages/channels/core/src/index.ts @@ -3,7 +3,9 @@ * * `startEnabledChannels(factories)` reads `channels.json`, starts a transport for every enabled * channel that has a factory, attaches each to the daemon as `channel:` so it is counted as - * a client, and wires inbound messages through the allowlist to the dispatcher. `reload()` + * a client, and wires inbound messages through the allowlist to the dispatcher. A channel enabled + * with nobody allowed waits for its owner: the first sender inside the claim window is written + * into `channels.json` and answered. `reload()` * re-reads the file and starts, stops or restarts transports to match. `stopAll()` tears down. * * Plain promises, no Effect: this runs inside `redrob serve --register` next to the server, and @@ -11,7 +13,7 @@ */ import os from "node:os" import { attach as attachToDaemon, currentDaemon, type Attachment } from "@redrob-labs/sdk/daemon" -import { Allowlist } from "./allowlist" +import { Allowlist, CLAIMED_REPLY } from "./allowlist" import * as Config from "./config" import { Dispatcher, SessionMap } from "./dispatch" import { FileSecretStore, scoped, type SecretStore } from "./secrets" @@ -20,7 +22,7 @@ import { consoleLogger, type DaemonConnection, type Inbound, type Logger, type T export * as Config from "./config" export * as Paths from "./paths" export * as Renderer from "./renderer" -export { Allowlist, DENIED_REPLY } from "./allowlist" +export { Allowlist, CLAIMED_REPLY, DENIED_REPLY, UNCLAIMED_REPLY, type Verdict } from "./allowlist" export { Dispatcher, SessionMap, conversationKey, HELP, type Binding, type DispatcherOptions } from "./dispatch" export { FileSecretStore, MemorySecretStore, scoped, type SecretStore, type ChannelSecrets } from "./secrets" export { @@ -41,6 +43,10 @@ export type ChannelStatus = { readonly lastError?: string /** Whether a transport for this channel is compiled in at all. */ readonly available: boolean + /** The owner: the first allowed sender. Undefined while nobody is allowed. */ + readonly owner?: string + /** True while the channel is waiting for its owner's first message. */ + readonly claimOpen: boolean } export type ManagerOptions = { @@ -55,7 +61,8 @@ export type ManagerOptions = { type Running = { readonly name: Config.ChannelName - readonly config: Config.ChannelConfig + /** The entry the transport was started from; rewritten in place by a claim so a later reload does not restart it. */ + config: Config.ChannelConfig readonly transport: Transport readonly dispatcher: Dispatcher readonly allowlist: Allowlist @@ -138,11 +145,15 @@ export class ChannelManager { const entry = this.config.channels[name] const run = this.running.get(name) const lastError = run?.lastError ?? this.errors.get(name) + const owner = run ? run.allowlist.owner : Config.owner(entry) + const claimOpen = run ? run.allowlist.claimOpen() : Config.claimOpen(entry) return { name, enabled: entry?.enabled === true, connected: run?.connected === true, available: this.factories[name] !== undefined, + claimOpen, + ...(owner !== undefined ? { owner } : {}), ...(lastError ? { lastError } : {}), } }) @@ -197,7 +208,15 @@ export class ChannelManager { if (verdict.reply) void transport.send(message.conversationId, [verdict.reply], message.replyTo).catch(() => undefined) return } - void dispatcher.handle(message) + if (verdict.claimed === undefined) { + void dispatcher.handle(message) + return + } + // The owner's first message: write them into channels.json, greet them, then answer the + // message itself. The in-memory allowlist already has them, so nothing restarts. + void this.claimed(run, verdict.claimed, log) + .then(() => transport.send(message.conversationId, [CLAIMED_REPLY], message.replyTo).catch(() => undefined)) + .then(() => dispatcher.handle(message)) } try { await transport.start({ config, secrets: scoped(this.secrets, name), daemon, log, onMessage }) @@ -215,7 +234,10 @@ export class ChannelManager { // exit off while channels run anyway. log.warn(`could not attach to the daemon as a client: ${error instanceof Error ? error.message : String(error)}`) } - if (allowlist.size === 0) log.warn("allowlist is empty: every sender will be denied") + if (allowlist.size === 0) { + if (allowlist.claimOpen()) log.info("waiting for its owner: the first sender to message the bot is allowed") + else log.warn("allowlist is empty and the claim window is over: every sender will be denied") + } log.info(`started (directory ${directory})`) } catch (error) { run.lastError = error instanceof Error ? error.message : String(error) @@ -227,6 +249,22 @@ export class ChannelManager { this.running.set(name, run) } + /** + * Persist a claim: `channels.json` gets the owner and loses `claimUntil`. The running entry and + * the manager's config snapshot are rewritten to match, so a `reload()` sees no change and keeps + * the connection. A write failure is logged; the owner is still allowed for this process. + */ + private async claimed(run: Running, senderId: string, log: Logger): Promise { + try { + const next = await Config.update((current) => Config.claim(current, run.name, senderId), this.env) + this.config = next + const entry = next.channels[run.name] + if (entry) run.config = entry + } catch (error) { + log.error(`could not save the owner of ${run.name}: ${error instanceof Error ? error.message : String(error)}`) + } + } + private async stopOne(name: Config.ChannelName): Promise { const run = this.running.get(name) if (!run) return diff --git a/packages/channels/discord/README.md b/packages/channels/discord/README.md index 2a46c15e9d..ac2fdb4e62 100644 --- a/packages/channels/discord/README.md +++ b/packages/channels/discord/README.md @@ -49,11 +49,12 @@ No non-secret `options` are read. bots need Discord's approval.) *Server Members* and *Presence* are not needed. 4. **OAuth2** -> URL Generator: scope `bot`, permissions *View Channels*, *Send Messages*, *Send Messages in Threads*, *Read Message History*. Open the URL and add the bot to your server. -5. Allow yourself and switch the channel on: +5. Switch the channel on and claim it: ```sh -redrob channels allow discord 123456789012345678 # your Discord user id (Settings -> Advanced -> Developer Mode, then right-click -> Copy User ID) redrob channels enable discord +# DM the bot (or mention it) within 15 minutes: the first message makes you its owner +# (redrob channels allow discord adds more people later; Developer Mode -> Copy User ID) ``` DM the bot, or mention it in a channel it can read. `/new`, `/status` and `/help` work as in every diff --git a/packages/channels/slack/README.md b/packages/channels/slack/README.md index a0f6506372..6860d56220 100644 --- a/packages/channels/slack/README.md +++ b/packages/channels/slack/README.md @@ -16,8 +16,9 @@ Read at `start` from the channel secret store, never from the environment: redrob channels slack-manifest --url # create the app from the manifest, install it redrob channels set-secret slack botToken redrob channels set-secret slack appToken -redrob channels allow slack U0123ABCDEF # Slack member id redrob channels enable slack +# DM the bot within 15 minutes: the first message makes you its owner +# (redrob channels allow slack adds more people later) ``` No non-secret `options` are read yet. diff --git a/packages/channels/telegram/README.md b/packages/channels/telegram/README.md index 6dae26abff..c17ce3e4e7 100644 --- a/packages/channels/telegram/README.md +++ b/packages/channels/telegram/README.md @@ -6,7 +6,8 @@ Telegram transport for the Redrob Code chat channels (`docs/CHANNELS.md`). Built ## Behaviour - One Telegram chat is one conversation: `conversationId` is the chat id, `senderId` is the - Telegram user id (what `redrob channels allow telegram ` takes). + Telegram user id (what the allowlist holds; the first sender claims it, `redrob channels allow + telegram ` adds more). - Private chats: every text message is forwarded to the core. - Groups and supergroups: only messages that mention the bot (`@YourBot`, `/new@YourBot`) or reply to one of its messages are forwarded; the mention is stripped before the core sees the text. @@ -34,8 +35,9 @@ Telegram transport for the Redrob Code chat channels (`docs/CHANNELS.md`). Built # @BotFather: /newbot, copy the token. For groups, /setprivacy -> Disable is NOT required: # mentions and replies to the bot are delivered with privacy mode on. redrob channels set-secret telegram botToken # paste the token, Ctrl-D -redrob channels allow telegram 123456789 # your Telegram user id (@userinfobot tells you) redrob channels enable telegram +# message the bot within 15 minutes: the first message makes you its owner +# (redrob channels allow telegram adds more people later; @userinfobot tells you an id) ``` ## Exports diff --git a/packages/channels/whatsapp/README.md b/packages/channels/whatsapp/README.md index 0347af0419..77a9a459ae 100644 --- a/packages/channels/whatsapp/README.md +++ b/packages/channels/whatsapp/README.md @@ -15,8 +15,10 @@ There is no bot token and nothing to put in `secrets.json`. The daemon is linked account as a *linked device* by scanning a QR code once: ```sh -redrob channels allow whatsapp 821012345678 # the phone number that may talk, digits only redrob channels enable whatsapp +# scan the QR code, then send the linked number a message within 15 minutes (from the paired +# phone itself or another one): the first sender becomes the owner +# (redrob channels allow whatsapp adds more people later) ``` While the channel is not paired, `start()` still resolves and the transport exposes the QR code diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index c9a21957d7..efd5444aad 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -170,6 +170,8 @@ export type ServerChannelsStatusOutput = { readonly connected: boolean readonly available: boolean readonly lastError?: string | undefined + readonly owner?: string | undefined + readonly claimOpen: boolean }> } diff --git a/packages/protocol/src/groups/channels.ts b/packages/protocol/src/groups/channels.ts index d9398cf6ed..e34edbe177 100644 --- a/packages/protocol/src/groups/channels.ts +++ b/packages/protocol/src/groups/channels.ts @@ -14,6 +14,10 @@ export const ChannelStatus = Schema.Struct({ /** Whether this build has a transport for the channel at all. */ available: Schema.Boolean, lastError: Schema.optional(Schema.String), + /** The owner: the first allowed sender id. Absent while nobody is allowed. */ + owner: Schema.optional(Schema.String), + /** True while the channel is waiting for its owner's first message (claim window open). */ + claimOpen: Schema.Boolean, }).annotate({ identifier: "ChannelStatus" }) export const ChannelStatusList = Schema.Struct({ @@ -31,7 +35,8 @@ export const ChannelsGroup = HttpApiGroup.make("server.channels") OpenApi.annotations({ identifier: "v2.channels.status", summary: "Chat channel status", - description: "Per channel: enabled in channels.json, connected right now, and the last error if any.", + description: + "Per channel: enabled in channels.json, connected right now, who owns it, whether it is waiting for its owner's first message, and the last error if any.", }), ), ) diff --git a/packages/redrob/src/cli/cmd/channels.ts b/packages/redrob/src/cli/cmd/channels.ts index 58cba587ce..d97979b30a 100644 --- a/packages/redrob/src/cli/cmd/channels.ts +++ b/packages/redrob/src/cli/cmd/channels.ts @@ -18,7 +18,17 @@ function channelName(value: string): ChannelName { throw new Error(`Unknown channel "${value}"; one of: ${NAMES.join(", ")}`) } -type Status = { name: string; enabled: boolean; connected: boolean; available: boolean; lastError?: string } +type Status = { + name: string + enabled: boolean + connected: boolean + available: boolean + lastError?: string + owner?: string + claimOpen?: boolean +} + +const WAITING_FOR_OWNER = "Waiting for its owner: send any message to the bot within 15 minutes." /** The running daemon's view, or undefined when none is registered. */ async function daemonStatus(): Promise { @@ -138,7 +148,14 @@ export const ChannelsCommand = cmd({ const rows = [] for (const name of NAMES) { const entry = config.channels[name] ?? Config.defaultChannel() - rows.push({ name, enabled: entry.enabled, allowlist: [...entry.allowlist], secrets: await secrets.keys(name) }) + rows.push({ + name, + enabled: entry.enabled, + owner: Config.owner(entry), + claimOpen: Config.claimOpen(entry), + allowlist: [...entry.allowlist], + secrets: await secrets.keys(name), + }) } if (args.json) { process.stdout.write(JSON.stringify({ directory: config.directory, channels: rows }) + "\n") @@ -148,8 +165,10 @@ export const ChannelsCommand = cmd({ const state = row.enabled ? `${UI.Style.TEXT_SUCCESS_BOLD}enabled${UI.Style.TEXT_NORMAL}` : `${UI.Style.TEXT_DIM}disabled${UI.Style.TEXT_NORMAL}` + const owner = row.owner ?? "(waiting)" + const others = row.allowlist.slice(1) UI.println( - `${row.name.padEnd(9)} ${state} allow: ${row.allowlist.length ? row.allowlist.join(", ") : "(nobody)"} secrets: ${row.secrets.length ? row.secrets.join(", ") : "(none)"}`, + `${row.name.padEnd(9)} ${state} owner: ${owner}${others.length ? ` also: ${others.join(", ")}` : ""} secrets: ${row.secrets.length ? row.secrets.join(", ") : "(none)"}`, ) } }, @@ -180,7 +199,14 @@ export const ChannelsCommand = cmd({ : !row.available ? `${UI.Style.TEXT_WARNING}no transport in this build${UI.Style.TEXT_NORMAL}` : `${UI.Style.TEXT_DANGER}not connected${UI.Style.TEXT_NORMAL}` - UI.println(`${row.name.padEnd(9)} ${state}${row.lastError ? ` ${UI.Style.TEXT_DIM}${row.lastError}${UI.Style.TEXT_NORMAL}` : ""}`) + const owner = row.claimOpen + ? ` ${UI.Style.TEXT_WARNING}waiting for its owner's first message${UI.Style.TEXT_NORMAL}` + : row.owner + ? ` ${UI.Style.TEXT_DIM}owner: ${row.owner}${UI.Style.TEXT_NORMAL}` + : "" + UI.println( + `${row.name.padEnd(9)} ${state}${owner}${row.lastError ? ` ${UI.Style.TEXT_DIM}${row.lastError}${UI.Style.TEXT_NORMAL}` : ""}`, + ) } }, }), @@ -192,9 +218,9 @@ export const ChannelsCommand = cmd({ builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true, choices: NAMES }), handler: async (args) => { const name = channelName(args.name) - const config = await Config.update((c) => Config.setChannel(c, name, { enabled: true })) - if ((config.channels[name]?.allowlist.length ?? 0) === 0) { - UI.println(`${UI.Style.TEXT_WARNING}allowlist is empty: nobody can talk to it yet (redrob channels allow ${name} )${UI.Style.TEXT_NORMAL}`) + const config = await Config.update((c) => Config.openClaim(c, name)) + if (Config.claimOpen(config.channels[name])) { + UI.println(`${UI.Style.TEXT_WARNING}${WAITING_FOR_OWNER}${UI.Style.TEXT_NORMAL}`) } after(await reload()) await syncAutostart(config) @@ -217,7 +243,7 @@ export const ChannelsCommand = cmd({ .command( cmd({ command: "allow ", - describe: "let a platform user id talk to the engine through a channel", + describe: "let one more platform user id talk to the engine through a channel (the owner is claimed by the first message)", builder: (yargs) => yargs .positional("name", { type: "string", demandOption: true, choices: NAMES }) @@ -232,7 +258,8 @@ export const ChannelsCommand = cmd({ const allowlist = args.remove ? current.allowlist.filter((x) => x !== id) : [...new Set([...current.allowlist, id])] - return Config.setChannel(c, name, { allowlist }) + // An explicit allowlist edit closes any open claim window (parse drops the undefined). + return Config.setChannel(c, name, { allowlist, claimUntil: undefined }) }) after(await reload()) }, diff --git a/packages/server/src/channels.ts b/packages/server/src/channels.ts index ce610a5cd7..e0e8ad7b52 100644 --- a/packages/server/src/channels.ts +++ b/packages/server/src/channels.ts @@ -12,6 +12,10 @@ export type ChannelStatus = { readonly connected: boolean readonly available: boolean readonly lastError?: string + /** First allowed sender; undefined while nobody is allowed. */ + readonly owner?: string + /** Waiting for the owner's first message. */ + readonly claimOpen: boolean } export type Controller = {