Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions docs/CHANNELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,33 @@ daemon record.
"maxChunk": 3900,
"options": {}
},
"telegram": { "enabled": false, "allowlist": [] }
"telegram": { "enabled": true, "allowlist": [], "claimUntil": 1760000000000 }
}
}
```

- `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 <name>` 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 <name> <senderId>` 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 <name>` (or `POST /channels/reload`) is fine too.

Expand Down Expand Up @@ -137,28 +150,34 @@ Both are under the daemon's Basic auth (`redrob:<secret>` 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 <name>
redrob channels enable <name> # opens a 15-minute owner claim window when nobody is allowed
redrob channels disable <name>
redrob channels allow <name> <senderId> [--remove]
redrob channels allow <name> <senderId> [--remove] # add MORE people after the owner
redrob channels set-secret <name> <key> [--value v] # value from stdin when --value is omitted
redrob channels slack-manifest [--url]
```

`<name>` 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: <id>` 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
Expand All @@ -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 <memberId> adds more people later)
```

## Transports
Expand Down Expand Up @@ -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 <userId> adds more people later)
```

### WhatsApp (`@redrob-code/channels-whatsapp`)
Expand All @@ -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 <digits> adds more people later)
```

No secret is set. While unpaired the channel starts anyway and writes the QR code to
Expand Down
60 changes: 47 additions & 13 deletions packages/channels/core/src/allowlist.ts
Original file line number Diff line number Diff line change
@@ -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<string>
private readonly warned = new Set<string>()
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 {
Expand All @@ -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 }
}
}
53 changes: 51 additions & 2 deletions packages/channels/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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. */
Expand Down Expand Up @@ -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 } : {}),
Expand Down Expand Up @@ -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)
}
Loading
Loading