mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 05:41:37 +00:00
feat(ui): manage DM pairing requests in Channels (#112401)
* feat(ui): manage DM pairing requests * fix(ui): clear pairing data across auth changes * test(ui): tighten pairing page fixture type * fix(gateway): complete pairing protocol contracts * fix(ui): guard pairing mutations across epochs * fix(ui): restore chat teardown gates * fix(ui): isolate channel auth lifecycles * fix(ui): remove stale chat view export
This commit is contained in:
committed by
GitHub
parent
0d7e870a2b
commit
0c99a4e362
@@ -414,6 +414,9 @@ enum class GatewayMethod(
|
||||
TerminalInput("terminal.input"),
|
||||
TerminalResize("terminal.resize"),
|
||||
TerminalClose("terminal.close"),
|
||||
ChannelsPairingList("channels.pairing.list"),
|
||||
ChannelsPairingApprove("channels.pairing.approve"),
|
||||
ChannelsPairingDismiss("channels.pairing.dismiss"),
|
||||
AssistantMediaGet("assistant.media.get"),
|
||||
SessionsGet("sessions.get"),
|
||||
SessionsResolve("sessions.resolve"),
|
||||
|
||||
@@ -9898,6 +9898,146 @@ public struct ChannelsStatusResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingListParams: Codable, Sendable {
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
|
||||
public init(
|
||||
channel: String? = nil,
|
||||
accountid: String? = nil)
|
||||
{
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingListResult: Codable, Sendable {
|
||||
public let accounts: [[String: AnyCodable]]
|
||||
public let requests: [[String: AnyCodable]]
|
||||
public let commandownerconfigured: Bool
|
||||
public let limits: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
accounts: [[String: AnyCodable]],
|
||||
requests: [[String: AnyCodable]],
|
||||
commandownerconfigured: Bool,
|
||||
limits: [String: AnyCodable])
|
||||
{
|
||||
self.accounts = accounts
|
||||
self.requests = requests
|
||||
self.commandownerconfigured = commandownerconfigured
|
||||
self.limits = limits
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case accounts
|
||||
case requests
|
||||
case commandownerconfigured = "commandOwnerConfigured"
|
||||
case limits
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingApproveParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String
|
||||
public let requestid: String
|
||||
public let notify: Bool?
|
||||
public let bootstrapcommandowner: Bool?
|
||||
|
||||
public init(
|
||||
channel: String,
|
||||
accountid: String,
|
||||
requestid: String,
|
||||
notify: Bool? = nil,
|
||||
bootstrapcommandowner: Bool? = nil)
|
||||
{
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.requestid = requestid
|
||||
self.notify = notify
|
||||
self.bootstrapcommandowner = bootstrapcommandowner
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case requestid = "requestId"
|
||||
case notify
|
||||
case bootstrapcommandowner = "bootstrapCommandOwner"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingApproveResult: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let senderid: String
|
||||
public let notification: String
|
||||
public let commandownerbootstrap: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
senderid: String,
|
||||
notification: String,
|
||||
commandownerbootstrap: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.senderid = senderid
|
||||
self.notification = notification
|
||||
self.commandownerbootstrap = commandownerbootstrap
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case senderid = "senderId"
|
||||
case notification
|
||||
case commandownerbootstrap = "commandOwnerBootstrap"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingDismissParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String
|
||||
public let requestid: String
|
||||
|
||||
public init(
|
||||
channel: String,
|
||||
accountid: String,
|
||||
requestid: String)
|
||||
{
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
self.requestid = requestid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
case requestid = "requestId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsPairingDismissResult: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let senderid: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
senderid: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.senderid = senderid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case senderid = "senderId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStartParams: Codable, Sendable {
|
||||
public let channel: String
|
||||
public let accountid: String?
|
||||
|
||||
@@ -32,20 +32,43 @@ Pairing codes:
|
||||
- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender).
|
||||
- Pending DM pairing requests are capped at **3 per channel account**; additional requests are ignored until one expires or is approved.
|
||||
|
||||
### Approve a sender
|
||||
### Approve from the Control UI
|
||||
|
||||
Open **Settings → Channels → DM access requests**. The queue combines pending
|
||||
requests from every configured channel account whose DM policy is `pairing`.
|
||||
Filter by channel or account, review the sender ID and metadata, then choose
|
||||
**Approve**.
|
||||
|
||||
Approval grants direct-message access only. It does not grant group access. The
|
||||
approval dialog also offers these explicit options when supported:
|
||||
|
||||
- **Notify the requester after approval**
|
||||
- **Also make this sender the first command owner**, shown only when no command
|
||||
owner exists and the Control UI session has `operator.admin`
|
||||
|
||||
Choose **Dismiss** to remove a pending request without approving it. Dismissal is
|
||||
not a permanent block; the sender can request access again later.
|
||||
|
||||
### Approve from the CLI
|
||||
|
||||
```bash
|
||||
openclaw pairing list telegram
|
||||
openclaw pairing approve telegram <CODE>
|
||||
```
|
||||
|
||||
Add `--notify` to the approve command to tell the requester on the same channel. Multi-account channels take `--account <id>`.
|
||||
Add `--notify` to tell the requester on the same channel. Multi-account channels
|
||||
take `--account <id>`.
|
||||
|
||||
If no command owner is configured yet, approving a DM pairing code also bootstraps
|
||||
`commands.ownerAllowFrom` to the approved sender, such as `telegram:123456789`.
|
||||
That gives first-time setups an explicit owner for privileged commands and exec
|
||||
approval prompts. After an owner exists, later pairing approvals only grant DM
|
||||
access; they do not add more owners.
|
||||
Unlike the Control UI's explicit checkbox, the CLI automatically bootstraps
|
||||
`commands.ownerAllowFrom` when no command owner is configured, using an entry
|
||||
such as `telegram:123456789`. This gives first-time setups an explicit owner for
|
||||
privileged commands and exec approval prompts. After an owner exists, later
|
||||
pairing approvals only grant DM access; they do not add more owners.
|
||||
|
||||
<Note>
|
||||
WhatsApp's login QR links a WhatsApp account to OpenClaw. DM access requests
|
||||
approve people who message that account. These are separate flows.
|
||||
</Note>
|
||||
|
||||
Supported channels (any installed channel plugin that declares pairing; external plugins such as `openclaw-weixin` can add more): `discord`, `feishu`, `googlechat`, `imessage`, `irc`, `line`, `matrix`, `mattermost`, `msteams`, `nextcloud-talk`, `nostr`, `signal`, `slack`, `sms`, `synology-chat`, `telegram`, `twitch`, `whatsapp`, `zalo`, `zalouser`.
|
||||
|
||||
|
||||
@@ -80,14 +80,19 @@ openclaw gateway
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Approve the first pairing request (pairing mode)">
|
||||
<Step title="Approve the first DM access request (pairing mode)">
|
||||
|
||||
Open **Settings → Channels → DM access requests**, find the WhatsApp account,
|
||||
and approve the sender. If you prefer the CLI:
|
||||
|
||||
```bash
|
||||
openclaw pairing list whatsapp
|
||||
openclaw pairing approve whatsapp <CODE>
|
||||
```
|
||||
|
||||
Pairing requests expire after 1 hour; pending requests are capped at 3 per account.
|
||||
DM access requests expire after 1 hour; pending requests are capped at 3 per
|
||||
account. This approval is separate from the WhatsApp login QR used to link the
|
||||
account itself.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -11,6 +11,11 @@ Approve or inspect DM pairing requests for channels that support pairing (chat D
|
||||
|
||||
Related: [Pairing flow](/channels/pairing)
|
||||
|
||||
The same pending requests can be reviewed in the Control UI under **Settings →
|
||||
Channels → DM access requests**. The Control UI supports approve, optional
|
||||
requester notification, and dismiss. Dismiss removes the current request but does
|
||||
not permanently block the sender.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
@@ -50,7 +55,7 @@ Options: `--channel <channel>`, `--account <accountId>`, `--notify` (send a conf
|
||||
|
||||
### Owner bootstrap
|
||||
|
||||
If `commands.ownerAllowFrom` is empty when you approve a pairing code, OpenClaw also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. This only bootstraps the first owner - later pairing approvals never replace or expand `commands.ownerAllowFrom`.
|
||||
If `commands.ownerAllowFrom` is empty when you approve a pairing code, the CLI also records the approved sender as the command owner, using a channel-scoped entry such as `telegram:123456789`. This only bootstraps the first owner - later pairing approvals never replace or expand `commands.ownerAllowFrom`. The Control UI presents this elevation as a separate `operator.admin`-protected checkbox instead of applying it automatically.
|
||||
|
||||
The command owner is the human operator account allowed to run owner-only commands and approve dangerous actions such as `/diagnostics`, `/export-session`, `/export-trajectory`, `/config`, and exec approvals. Pairing only lets a sender talk to the agent; it does not by itself grant owner privileges beyond this one-time bootstrap.
|
||||
|
||||
|
||||
@@ -784,7 +784,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- Route: /channels/pairing
|
||||
- Headings:
|
||||
- H2: 1) DM pairing (inbound chat access)
|
||||
- H3: Approve a sender
|
||||
- H3: Approve from the Control UI
|
||||
- H3: Approve from the CLI
|
||||
- H3: Reusable sender groups
|
||||
- H3: Where the state lives
|
||||
- H2: 2) Node device pairing (iOS/Android/macOS/headless nodes)
|
||||
|
||||
@@ -103,6 +103,12 @@ import {
|
||||
ConversationTurnParamsSchema,
|
||||
ConversationTurnReplySchema,
|
||||
ConversationTurnResultSchema,
|
||||
ChannelsPairingApproveParamsSchema,
|
||||
ChannelsPairingApproveResultSchema,
|
||||
ChannelsPairingDismissParamsSchema,
|
||||
ChannelsPairingDismissResultSchema,
|
||||
ChannelsPairingListParamsSchema,
|
||||
ChannelsPairingListResultSchema,
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsStopParamsSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -877,6 +883,9 @@ export const validateTalkSessionCloseParams = lazyCompile(TalkSessionCloseParams
|
||||
export const validateTalkSpeakParams = lazyCompile(TalkSpeakParamsSchema);
|
||||
export const validateTtsSpeakParams = lazyCompile(TtsSpeakParamsSchema);
|
||||
export const validateChannelsStatusParams = lazyCompile(ChannelsStatusParamsSchema);
|
||||
export const validateChannelsPairingListParams = lazyCompile(ChannelsPairingListParamsSchema);
|
||||
export const validateChannelsPairingApproveParams = lazyCompile(ChannelsPairingApproveParamsSchema);
|
||||
export const validateChannelsPairingDismissParams = lazyCompile(ChannelsPairingDismissParamsSchema);
|
||||
export const validateChannelsStartParams = lazyCompile(ChannelsStartParamsSchema);
|
||||
export const validateChannelsStopParams = lazyCompile(ChannelsStopParamsSchema);
|
||||
export const validateChannelsLogoutParams = lazyCompile(ChannelsLogoutParamsSchema);
|
||||
@@ -1302,6 +1311,12 @@ export {
|
||||
TtsSpeakResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
ChannelsPairingListParamsSchema,
|
||||
ChannelsPairingListResultSchema,
|
||||
ChannelsPairingApproveParamsSchema,
|
||||
ChannelsPairingApproveResultSchema,
|
||||
ChannelsPairingDismissParamsSchema,
|
||||
ChannelsPairingDismissResultSchema,
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsStopParamsSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
@@ -1646,6 +1661,14 @@ export type {
|
||||
TalkModeParams,
|
||||
ChannelsStatusParams,
|
||||
ChannelsStatusResult,
|
||||
ChannelsPairingListParams,
|
||||
ChannelsPairingListResult,
|
||||
ChannelsPairingApproveParams,
|
||||
ChannelsPairingApproveResult,
|
||||
ChannelsPairingDismissParams,
|
||||
ChannelsPairingDismissResult,
|
||||
ChannelsPairingAccount,
|
||||
ChannelsPairingRequest,
|
||||
ChannelsStartParams,
|
||||
ChannelsStopParams,
|
||||
ChannelsLogoutParams,
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from "./schema/audit.js";
|
||||
export * from "./schema/board.js";
|
||||
export * from "./schema/users.js";
|
||||
export * from "./schema/channels.js";
|
||||
export * from "./schema/channel-pairing.js";
|
||||
export * from "./schema/talk-marks.js";
|
||||
export * from "./schema/commands.js";
|
||||
export * from "./schema/config.js";
|
||||
|
||||
83
packages/gateway-protocol/src/schema/channel-pairing.ts
Normal file
83
packages/gateway-protocol/src/schema/channel-pairing.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Gateway Protocol schemas for DM sender access requests.
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
const ChannelPairingAccountSchema = closedObject({
|
||||
channel: NonEmptyString,
|
||||
channelLabel: NonEmptyString,
|
||||
accountId: NonEmptyString,
|
||||
accountLabel: Type.Optional(NonEmptyString),
|
||||
notifySupported: Type.Boolean(),
|
||||
});
|
||||
|
||||
const ChannelPairingRequestSchema = closedObject({
|
||||
requestId: NonEmptyString,
|
||||
channel: NonEmptyString,
|
||||
channelLabel: NonEmptyString,
|
||||
accountId: NonEmptyString,
|
||||
accountLabel: Type.Optional(NonEmptyString),
|
||||
senderId: NonEmptyString,
|
||||
senderLabel: NonEmptyString,
|
||||
metadata: Type.Optional(Type.Record(NonEmptyString, Type.String())),
|
||||
createdAt: NonEmptyString,
|
||||
lastSeenAt: NonEmptyString,
|
||||
expiresAt: NonEmptyString,
|
||||
notifySupported: Type.Boolean(),
|
||||
});
|
||||
|
||||
/** Lists pending DM sender access requests for pairing-policy channel accounts. */
|
||||
export const ChannelsPairingListParamsSchema = closedObject({
|
||||
channel: Type.Optional(NonEmptyString),
|
||||
accountId: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
export const ChannelsPairingListResultSchema = closedObject({
|
||||
accounts: Type.Array(ChannelPairingAccountSchema),
|
||||
requests: Type.Array(ChannelPairingRequestSchema),
|
||||
commandOwnerConfigured: Type.Boolean(),
|
||||
limits: closedObject({
|
||||
pendingPerAccount: Type.Integer({ minimum: 0 }),
|
||||
ttlMs: Type.Integer({ minimum: 0 }),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Approves one pending DM sender request. */
|
||||
export const ChannelsPairingApproveParamsSchema = closedObject({
|
||||
channel: NonEmptyString,
|
||||
accountId: NonEmptyString,
|
||||
requestId: NonEmptyString,
|
||||
notify: Type.Optional(Type.Boolean()),
|
||||
bootstrapCommandOwner: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
export const ChannelsPairingApproveResultSchema = closedObject({
|
||||
requestId: NonEmptyString,
|
||||
senderId: NonEmptyString,
|
||||
notification: Type.String({ enum: ["not-requested", "sent", "unsupported", "failed"] }),
|
||||
commandOwnerBootstrap: Type.String({
|
||||
enum: ["not-requested", "configured", "already-configured", "unavailable"],
|
||||
}),
|
||||
});
|
||||
|
||||
/** Dismisses one pending request without permanently blocking the sender. */
|
||||
export const ChannelsPairingDismissParamsSchema = closedObject({
|
||||
channel: NonEmptyString,
|
||||
accountId: NonEmptyString,
|
||||
requestId: NonEmptyString,
|
||||
});
|
||||
|
||||
export const ChannelsPairingDismissResultSchema = closedObject({
|
||||
requestId: NonEmptyString,
|
||||
senderId: NonEmptyString,
|
||||
});
|
||||
|
||||
export type ChannelsPairingListParams = Static<typeof ChannelsPairingListParamsSchema>;
|
||||
export type ChannelsPairingListResult = Static<typeof ChannelsPairingListResultSchema>;
|
||||
export type ChannelsPairingApproveParams = Static<typeof ChannelsPairingApproveParamsSchema>;
|
||||
export type ChannelsPairingApproveResult = Static<typeof ChannelsPairingApproveResultSchema>;
|
||||
export type ChannelsPairingDismissParams = Static<typeof ChannelsPairingDismissParamsSchema>;
|
||||
export type ChannelsPairingDismissResult = Static<typeof ChannelsPairingDismissResultSchema>;
|
||||
export type ChannelsPairingAccount = Static<typeof ChannelPairingAccountSchema>;
|
||||
export type ChannelsPairingRequest = Static<typeof ChannelPairingRequestSchema>;
|
||||
@@ -191,6 +191,14 @@ import {
|
||||
BoardWidgetDeclaredSchema,
|
||||
BoardWidgetSchema,
|
||||
} from "./board.js";
|
||||
import {
|
||||
ChannelsPairingApproveParamsSchema,
|
||||
ChannelsPairingApproveResultSchema,
|
||||
ChannelsPairingDismissParamsSchema,
|
||||
ChannelsPairingDismissResultSchema,
|
||||
ChannelsPairingListParamsSchema,
|
||||
ChannelsPairingListResultSchema,
|
||||
} from "./channel-pairing.js";
|
||||
import {
|
||||
ChannelsStartParamsSchema,
|
||||
ChannelsStopParamsSchema,
|
||||
@@ -973,6 +981,12 @@ export const ProtocolSchemas = {
|
||||
TtsSpeakResult: TtsSpeakResultSchema,
|
||||
ChannelsStatusParams: ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResult: ChannelsStatusResultSchema,
|
||||
ChannelsPairingListParams: ChannelsPairingListParamsSchema,
|
||||
ChannelsPairingListResult: ChannelsPairingListResultSchema,
|
||||
ChannelsPairingApproveParams: ChannelsPairingApproveParamsSchema,
|
||||
ChannelsPairingApproveResult: ChannelsPairingApproveResultSchema,
|
||||
ChannelsPairingDismissParams: ChannelsPairingDismissParamsSchema,
|
||||
ChannelsPairingDismissResult: ChannelsPairingDismissResultSchema,
|
||||
ChannelsStartParams: ChannelsStartParamsSchema,
|
||||
ChannelsStopParams: ChannelsStopParamsSchema,
|
||||
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
|
||||
|
||||
@@ -9,15 +9,8 @@ import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js";
|
||||
import {
|
||||
formatCommandOwnerFromChannelSender,
|
||||
hasConfiguredCommandOwners,
|
||||
} from "../commands/doctor-command-owner.js";
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
readConfigFileSnapshotForWrite,
|
||||
replaceConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { bootstrapCommandOwnerFromPairing } from "../pairing/command-owner.js";
|
||||
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||
import { approveChannelPairingCode, listChannelPairingRequests } from "../pairing/pairing-store.js";
|
||||
import type { PairingChannel } from "../pairing/pairing-store.types.js";
|
||||
@@ -57,35 +50,6 @@ async function notifyApproved(channel: PairingChannel, id: string, accountId?: s
|
||||
await notifyPairingApproved({ channelId: channel, id, cfg, ...(accountId ? { accountId } : {}) });
|
||||
}
|
||||
|
||||
async function maybeBootstrapCommandOwnerFromPairing(params: {
|
||||
channel: PairingChannel;
|
||||
id: string;
|
||||
}): Promise<{ ownerEntry: string | null; bootstrapped: boolean }> {
|
||||
// First approved pairing can seed ownerAllowFrom so command access is not left open-ended.
|
||||
const ownerEntry = formatCommandOwnerFromChannelSender(params);
|
||||
if (!ownerEntry) {
|
||||
return { ownerEntry: null, bootstrapped: false };
|
||||
}
|
||||
|
||||
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
|
||||
if (hasConfiguredCommandOwners(snapshot.sourceConfig)) {
|
||||
return { ownerEntry, bootstrapped: false };
|
||||
}
|
||||
|
||||
const nextConfig = structuredClone(snapshot.sourceConfig);
|
||||
nextConfig.commands = {
|
||||
...nextConfig.commands,
|
||||
ownerAllowFrom: [ownerEntry],
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
snapshot,
|
||||
writeOptions,
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
return { ownerEntry, bootstrapped: true };
|
||||
}
|
||||
|
||||
export function registerPairingCli(program: Command) {
|
||||
const channels = listPairingChannels();
|
||||
// Avoid rendering a bare "()" enum when no channels are configured.
|
||||
@@ -208,11 +172,11 @@ export function registerPairingCli(program: Command) {
|
||||
defaultRuntime.log(
|
||||
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
|
||||
);
|
||||
const ownerBootstrap = await maybeBootstrapCommandOwnerFromPairing({
|
||||
const ownerBootstrap = await bootstrapCommandOwnerFromPairing({
|
||||
channel,
|
||||
id: approved.id,
|
||||
});
|
||||
if (ownerBootstrap.bootstrapped && ownerBootstrap.ownerEntry) {
|
||||
if (ownerBootstrap.status === "configured" && ownerBootstrap.ownerEntry) {
|
||||
defaultRuntime.log(
|
||||
`${theme.success("Command owner configured")} ${theme.command(ownerBootstrap.ownerEntry)} ${theme.muted("(commands.ownerAllowFrom was empty).")}`,
|
||||
);
|
||||
|
||||
@@ -180,6 +180,29 @@ describe("method scope resolution", () => {
|
||||
).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("requires admin only when DM pairing approval bootstraps a command owner", () => {
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {})).toEqual([
|
||||
"operator.pairing",
|
||||
]);
|
||||
expect(
|
||||
resolveLeastPrivilegeOperatorScopesForMethod("channels.pairing.approve", {
|
||||
bootstrapCommandOwner: true,
|
||||
}),
|
||||
).toEqual(["operator.pairing", "operator.admin"]);
|
||||
expect(
|
||||
authorizeOperatorScopesForMethod("channels.pairing.approve", ["operator.pairing"], {
|
||||
bootstrapCommandOwner: true,
|
||||
}),
|
||||
).toEqual({ allowed: false, missingScope: "operator.admin" });
|
||||
expect(
|
||||
authorizeOperatorScopesForMethod(
|
||||
"channels.pairing.approve",
|
||||
["operator.pairing", "operator.admin"],
|
||||
{ bootstrapCommandOwner: true },
|
||||
),
|
||||
).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("classifies plugin session actions with a CLI-safe default operator scope", () => {
|
||||
expect(resolveLeastPrivilegeOperatorScopesForMethod("plugins.sessionAction")).toEqual([
|
||||
"operator.write",
|
||||
|
||||
@@ -192,6 +192,13 @@ function resolveDynamicLeastPrivilegeOperatorScopesForMethod(
|
||||
: undefined;
|
||||
return includeSecrets === true ? [READ_SCOPE, TALK_SECRETS_SCOPE] : [READ_SCOPE];
|
||||
}
|
||||
if (method === "channels.pairing.approve") {
|
||||
const bootstrapCommandOwner =
|
||||
params && typeof params === "object" && !Array.isArray(params)
|
||||
? (params as { bootstrapCommandOwner?: unknown }).bootstrapCommandOwner
|
||||
: undefined;
|
||||
return bootstrapCommandOwner === true ? [PAIRING_SCOPE, ADMIN_SCOPE] : [PAIRING_SCOPE];
|
||||
}
|
||||
if (method === "sessions.patch") {
|
||||
return resolveSessionsPatchRequiredScopes(params);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"device.pair.rename",
|
||||
"sessions.observer.ask",
|
||||
"sessions.observer.visibility",
|
||||
"channels.pairing.list",
|
||||
"channels.pairing.approve",
|
||||
"channels.pairing.dismiss",
|
||||
] as const;
|
||||
|
||||
describe("core gateway method release trains", () => {
|
||||
|
||||
@@ -314,6 +314,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "terminal.input", scope: "operator.admin", since: "2026.7" },
|
||||
{ name: "terminal.resize", scope: "operator.admin", since: "2026.7" },
|
||||
{ name: "terminal.close", scope: "operator.admin", since: "2026.7" },
|
||||
// DM pairing is additive to the advertised method list. Keep it appended so
|
||||
// older clients retain every pre-existing advertised method index.
|
||||
{ name: "channels.pairing.list", scope: "operator.pairing", since: "2026.7" },
|
||||
{ name: "channels.pairing.approve", scope: "dynamic", since: "2026.7" },
|
||||
{ name: "channels.pairing.dismiss", scope: "operator.pairing", since: "2026.7" },
|
||||
{ name: "assistant.media.get", scope: "operator.read", since: "<=2026.7", advertise: false },
|
||||
{ name: "sessions.get", scope: "operator.read", since: "<=2026.7", advertise: false },
|
||||
{ name: "sessions.resolve", scope: "operator.read", since: "<=2026.7", advertise: false },
|
||||
|
||||
@@ -81,6 +81,10 @@ const loadChannelsHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/channels.js"),
|
||||
(module) => module.channelsHandlers,
|
||||
);
|
||||
const loadChannelPairingHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/channel-pairing.js"),
|
||||
(module) => module.channelPairingHandlers,
|
||||
);
|
||||
const loadChatHandlers = lazyHandlerModule(
|
||||
() => import("./server-methods/chat.js"),
|
||||
(module) => module.chatHandlers,
|
||||
@@ -426,6 +430,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
methods: ["channels.status", "channels.start", "channels.stop", "channels.logout"],
|
||||
loadHandlers: loadChannelsHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: ["channels.pairing.list", "channels.pairing.approve", "channels.pairing.dismiss"],
|
||||
loadHandlers: loadChannelPairingHandlers,
|
||||
}),
|
||||
...createLazyCoreHandlers({
|
||||
methods: [
|
||||
"chat.history",
|
||||
|
||||
251
src/gateway/server-methods/channel-pairing.test.ts
Normal file
251
src/gateway/server-methods/channel-pairing.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
approve: vi.fn(),
|
||||
bootstrapOwner: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
hasOwners: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listRequests: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
listChannelPlugins: mocks.listPlugins,
|
||||
}));
|
||||
vi.mock("../../channels/plugins/pairing.js", () => ({
|
||||
notifyPairingApproved: mocks.notify,
|
||||
}));
|
||||
vi.mock("../../commands/doctor-command-owner.js", () => ({
|
||||
hasConfiguredCommandOwners: mocks.hasOwners,
|
||||
}));
|
||||
vi.mock("../../pairing/command-owner.js", () => ({
|
||||
bootstrapCommandOwnerFromPairing: mocks.bootstrapOwner,
|
||||
}));
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
approveChannelPairingRequest: mocks.approve,
|
||||
CHANNEL_PAIRING_PENDING_MAX: 3,
|
||||
CHANNEL_PAIRING_PENDING_TTL_MS: 3_600_000,
|
||||
dismissChannelPairingRequest: mocks.dismiss,
|
||||
listChannelPairingRequests: mocks.listRequests,
|
||||
resolveChannelPairingRequestId: vi.fn(() => "opaque-request-id"),
|
||||
}));
|
||||
vi.mock("../runtime-plugin-config.js", () => ({
|
||||
resolveGatewayPluginConfig: ({ config }: { config: unknown }) => config,
|
||||
}));
|
||||
|
||||
import { channelPairingHandlers } from "./channel-pairing.js";
|
||||
|
||||
const notifyApproval = vi.fn(async () => undefined);
|
||||
const pairingPlugin = {
|
||||
id: "whatsapp",
|
||||
meta: { label: "WhatsApp" },
|
||||
pairing: { idLabel: "Phone number", notifyApproval },
|
||||
config: {
|
||||
listAccountIds: () => ["personal", "public", "unconfigured"],
|
||||
resolveAccount: (_cfg: unknown, accountId: string) => ({
|
||||
configured: accountId !== "unconfigured",
|
||||
dmPolicy: accountId === "public" ? "open" : "pairing",
|
||||
name: accountId === "personal" ? "Personal" : accountId,
|
||||
}),
|
||||
isConfigured: (account: { configured: boolean }) => account.configured,
|
||||
describeAccount: (account: { name: string }) => ({
|
||||
accountId: account.name,
|
||||
name: account.name,
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ account }: { account: { dmPolicy: string } }) => ({
|
||||
policy: account.dmPolicy,
|
||||
allowFromPath: "channels.whatsapp.allowFrom",
|
||||
approveHint: "approve",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
getRuntimeConfig: () => ({}),
|
||||
logGateway: { warn: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
async function invoke(
|
||||
method: keyof typeof channelPairingHandlers,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
const handler = expectDefined(channelPairingHandlers[method], `${method} test invariant`);
|
||||
await handler({
|
||||
params,
|
||||
respond,
|
||||
context: createContext(),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
return respond;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.listPlugins.mockReturnValue([pairingPlugin]);
|
||||
mocks.hasOwners.mockReturnValue(false);
|
||||
mocks.listRequests.mockResolvedValue([]);
|
||||
mocks.bootstrapOwner.mockResolvedValue({ ownerEntry: "whatsapp:+1555", status: "configured" });
|
||||
});
|
||||
|
||||
describe("channel DM pairing gateway handlers", () => {
|
||||
it("lists only pairing-policy accounts without exposing the human code", async () => {
|
||||
mocks.listRequests.mockResolvedValue([
|
||||
{
|
||||
id: "+15551234567",
|
||||
code: "SECRET12",
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:05:00.000Z",
|
||||
meta: { accountId: "personal", name: "Alice" },
|
||||
},
|
||||
]);
|
||||
|
||||
const respond = await invoke("channels.pairing.list", {});
|
||||
|
||||
expect(mocks.listRequests).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listRequests).toHaveBeenCalledWith("whatsapp", process.env, "personal");
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
accounts: [
|
||||
{
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
accountLabel: "Personal",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
requestId: "opaque-request-id",
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
accountLabel: "Personal",
|
||||
senderId: "+15551234567",
|
||||
senderLabel: "Phone number",
|
||||
metadata: { name: "Alice" },
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:05:00.000Z",
|
||||
expiresAt: "2026-07-20T11:00:00.000Z",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
commandOwnerConfigured: false,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(JSON.stringify(respond.mock.calls)).not.toContain("SECRET12");
|
||||
});
|
||||
|
||||
it("approves access even when the optional notification fails", async () => {
|
||||
mocks.approve.mockResolvedValue({
|
||||
id: "+15551234567",
|
||||
entry: {
|
||||
id: "+15551234567",
|
||||
code: "SECRET12",
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:00:00.000Z",
|
||||
meta: { accountId: "personal" },
|
||||
},
|
||||
});
|
||||
mocks.notify.mockRejectedValue(new Error("offline"));
|
||||
|
||||
const respond = await invoke("channels.pairing.approve", {
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "opaque-request-id",
|
||||
notify: true,
|
||||
bootstrapCommandOwner: true,
|
||||
});
|
||||
|
||||
expect(mocks.approve).toHaveBeenCalledWith({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "opaque-request-id",
|
||||
pairingAdapter: pairingPlugin.pairing,
|
||||
});
|
||||
expect(mocks.bootstrapOwner).toHaveBeenCalledWith({
|
||||
channel: "whatsapp",
|
||||
id: "+15551234567",
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
requestId: "opaque-request-id",
|
||||
senderId: "+15551234567",
|
||||
notification: "failed",
|
||||
commandOwnerBootstrap: "configured",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports command-owner setup failure without rolling back DM approval", async () => {
|
||||
mocks.approve.mockResolvedValue({
|
||||
id: "+15551234567",
|
||||
entry: {
|
||||
id: "+15551234567",
|
||||
code: "SECRET12",
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:00:00.000Z",
|
||||
},
|
||||
});
|
||||
mocks.bootstrapOwner.mockRejectedValue(new Error("config write failed"));
|
||||
|
||||
const respond = await invoke("channels.pairing.approve", {
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "opaque-request-id",
|
||||
bootstrapCommandOwner: true,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{
|
||||
requestId: "opaque-request-id",
|
||||
senderId: "+15551234567",
|
||||
notification: "not-requested",
|
||||
commandOwnerBootstrap: "unavailable",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a request without approving the sender", async () => {
|
||||
mocks.dismiss.mockResolvedValue({
|
||||
id: "+15551234567",
|
||||
entry: {
|
||||
id: "+15551234567",
|
||||
code: "SECRET12",
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const respond = await invoke("channels.pairing.dismiss", {
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "opaque-request-id",
|
||||
});
|
||||
|
||||
expect(mocks.dismiss).toHaveBeenCalledWith({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "opaque-request-id",
|
||||
});
|
||||
expect(mocks.approve).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ requestId: "opaque-request-id", senderId: "+15551234567" },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
396
src/gateway/server-methods/channel-pairing.ts
Normal file
396
src/gateway/server-methods/channel-pairing.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
// Gateway RPC handlers for DM sender access requests on pairing-policy channels.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type ChannelsPairingApproveParams,
|
||||
type ChannelsPairingApproveResult,
|
||||
type ChannelsPairingDismissParams,
|
||||
type ChannelsPairingListParams,
|
||||
type ChannelsPairingRequest,
|
||||
validateChannelsPairingApproveParams,
|
||||
validateChannelsPairingDismissParams,
|
||||
validateChannelsPairingListParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveChannelDmPolicy } from "../../channels/plugins/dm-access.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { notifyPairingApproved } from "../../channels/plugins/pairing.js";
|
||||
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
|
||||
import { hasConfiguredCommandOwners } from "../../commands/doctor-command-owner.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { bootstrapCommandOwnerFromPairing } from "../../pairing/command-owner.js";
|
||||
import {
|
||||
approveChannelPairingRequest,
|
||||
CHANNEL_PAIRING_PENDING_MAX,
|
||||
CHANNEL_PAIRING_PENDING_TTL_MS,
|
||||
dismissChannelPairingRequest,
|
||||
listChannelPairingRequests,
|
||||
resolveChannelPairingRequestId,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
type PairingAccount = {
|
||||
plugin: ChannelPlugin;
|
||||
accountId: string;
|
||||
accountLabel?: string;
|
||||
};
|
||||
|
||||
class InvalidPairingTargetError extends Error {}
|
||||
|
||||
function normalizeFilter(value: string | undefined): string | undefined {
|
||||
return normalizeOptionalString(value)?.toLowerCase();
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolvePairingPolicy(params: {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
account: unknown;
|
||||
}): string | undefined {
|
||||
const securityPolicy = params.plugin.security?.resolveDmPolicy?.({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
account: params.account,
|
||||
})?.policy;
|
||||
if (securityPolicy) {
|
||||
return securityPolicy;
|
||||
}
|
||||
const account = asRecord(params.account);
|
||||
return resolveChannelDmPolicy({
|
||||
account,
|
||||
parent: asRecord(account?.config),
|
||||
defaultPolicy: "pairing",
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePairingAccountLabel(plugin: ChannelPlugin, account: unknown, cfg: OpenClawConfig) {
|
||||
const described = plugin.config.describeAccount?.(account, cfg);
|
||||
return (
|
||||
normalizeOptionalString(described?.name) ?? normalizeOptionalString(asRecord(account)?.name)
|
||||
);
|
||||
}
|
||||
|
||||
async function listPairingAccounts(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
}): Promise<PairingAccount[]> {
|
||||
const requestedChannel = normalizeFilter(params.channel);
|
||||
const requestedAccount = normalizeFilter(params.accountId);
|
||||
const pairingPlugins = listChannelPlugins().filter((plugin) => plugin.pairing);
|
||||
if (requestedChannel && !pairingPlugins.some((plugin) => plugin.id === requestedChannel)) {
|
||||
throw new InvalidPairingTargetError(`unknown pairing channel: ${params.channel}`);
|
||||
}
|
||||
|
||||
const accounts: PairingAccount[] = [];
|
||||
for (const plugin of pairingPlugins) {
|
||||
if (requestedChannel && plugin.id !== requestedChannel) {
|
||||
continue;
|
||||
}
|
||||
for (const accountId of plugin.config.listAccountIds(params.cfg)) {
|
||||
if (requestedAccount && accountId.toLowerCase() !== requestedAccount) {
|
||||
continue;
|
||||
}
|
||||
const account = plugin.config.resolveAccount(params.cfg, accountId);
|
||||
const configured = plugin.config.isConfigured
|
||||
? await plugin.config.isConfigured(account, params.cfg)
|
||||
: asRecord(account)?.configured !== false;
|
||||
if (
|
||||
!configured ||
|
||||
resolvePairingPolicy({ plugin, cfg: params.cfg, accountId, account }) !== "pairing"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const accountLabel = resolvePairingAccountLabel(plugin, account, params.cfg);
|
||||
accounts.push({
|
||||
plugin,
|
||||
accountId,
|
||||
...(accountLabel ? { accountLabel } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
}
|
||||
|
||||
async function resolvePairingAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
}): Promise<PairingAccount | null> {
|
||||
const accounts = await listPairingAccounts(params);
|
||||
return accounts.length === 1 ? (accounts[0] ?? null) : null;
|
||||
}
|
||||
|
||||
function publicAccount(account: PairingAccount) {
|
||||
const adapter = account.plugin.pairing;
|
||||
if (!adapter) {
|
||||
throw new Error(`Channel ${account.plugin.id} does not support pairing`);
|
||||
}
|
||||
return {
|
||||
channel: account.plugin.id,
|
||||
channelLabel: account.plugin.meta.label,
|
||||
accountId: account.accountId,
|
||||
...(account.accountLabel ? { accountLabel: account.accountLabel } : {}),
|
||||
notifySupported: Boolean(adapter.notifyApproval),
|
||||
};
|
||||
}
|
||||
|
||||
function publicRequest(params: {
|
||||
account: PairingAccount;
|
||||
request: Awaited<ReturnType<typeof listChannelPairingRequests>>[number];
|
||||
}): ChannelsPairingRequest {
|
||||
const adapter = params.account.plugin.pairing;
|
||||
if (!adapter) {
|
||||
throw new Error(`Channel ${params.account.plugin.id} does not support pairing`);
|
||||
}
|
||||
const metadata = params.request.meta
|
||||
? Object.fromEntries(
|
||||
Object.entries(params.request.meta).filter(([key, value]) => key !== "accountId" && value),
|
||||
)
|
||||
: undefined;
|
||||
const createdAtMs = Date.parse(params.request.createdAt);
|
||||
return {
|
||||
requestId: resolveChannelPairingRequestId(params.account.plugin.id, params.request),
|
||||
channel: params.account.plugin.id,
|
||||
channelLabel: params.account.plugin.meta.label,
|
||||
accountId: params.account.accountId,
|
||||
...(params.account.accountLabel ? { accountLabel: params.account.accountLabel } : {}),
|
||||
senderId: params.request.id,
|
||||
senderLabel: adapter.idLabel,
|
||||
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
|
||||
createdAt: params.request.createdAt,
|
||||
lastSeenAt: params.request.lastSeenAt,
|
||||
expiresAt: new Date(createdAtMs + CHANNEL_PAIRING_PENDING_TTL_MS).toISOString(),
|
||||
notifySupported: Boolean(adapter.notifyApproval),
|
||||
};
|
||||
}
|
||||
|
||||
function invalidPairingAccount(respond: RespondFn, channel: string, accountId: string): void {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`channel account does not use DM pairing: ${channel}:${accountId}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function respondPairingFailure(respond: RespondFn, error: unknown): void {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
error instanceof InvalidPairingTargetError
|
||||
? ErrorCodes.INVALID_REQUEST
|
||||
: ErrorCodes.UNAVAILABLE,
|
||||
formatForLog(error),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const channelPairingHandlers: GatewayRequestHandlers = {
|
||||
"channels.pairing.list": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateChannelsPairingListParams,
|
||||
"channels.pairing.list",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = params as ChannelsPairingListParams;
|
||||
const cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
|
||||
const accounts = await listPairingAccounts({
|
||||
cfg,
|
||||
...(parsed.channel ? { channel: parsed.channel } : {}),
|
||||
...(parsed.accountId ? { accountId: parsed.accountId } : {}),
|
||||
});
|
||||
const requests: ChannelsPairingRequest[] = [];
|
||||
for (const account of accounts) {
|
||||
const pending = await listChannelPairingRequests(
|
||||
account.plugin.id,
|
||||
process.env,
|
||||
account.accountId,
|
||||
);
|
||||
requests.push(...pending.map((request) => publicRequest({ account, request })));
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
accounts: accounts.map(publicAccount),
|
||||
requests,
|
||||
commandOwnerConfigured: hasConfiguredCommandOwners(cfg),
|
||||
limits: {
|
||||
pendingPerAccount: CHANNEL_PAIRING_PENDING_MAX,
|
||||
ttlMs: CHANNEL_PAIRING_PENDING_TTL_MS,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
respondPairingFailure(respond, error);
|
||||
}
|
||||
},
|
||||
|
||||
"channels.pairing.approve": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateChannelsPairingApproveParams,
|
||||
"channels.pairing.approve",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parsed = params as ChannelsPairingApproveParams;
|
||||
let cfg: OpenClawConfig;
|
||||
let account: PairingAccount | null;
|
||||
try {
|
||||
cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
|
||||
account = await resolvePairingAccount({
|
||||
cfg,
|
||||
channel: parsed.channel,
|
||||
accountId: parsed.accountId,
|
||||
});
|
||||
} catch (error) {
|
||||
respondPairingFailure(respond, error);
|
||||
return;
|
||||
}
|
||||
if (!account?.plugin.pairing) {
|
||||
invalidPairingAccount(respond, parsed.channel, parsed.accountId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const approved = await approveChannelPairingRequest({
|
||||
channel: account.plugin.id,
|
||||
accountId: account.accountId,
|
||||
requestId: parsed.requestId,
|
||||
pairingAdapter: account.plugin.pairing,
|
||||
});
|
||||
if (!approved) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "pending DM access request no longer exists"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let commandOwnerBootstrap: ChannelsPairingApproveResult["commandOwnerBootstrap"] =
|
||||
"not-requested";
|
||||
if (parsed.bootstrapCommandOwner === true) {
|
||||
try {
|
||||
commandOwnerBootstrap = (
|
||||
await bootstrapCommandOwnerFromPairing({
|
||||
channel: account.plugin.id,
|
||||
id: approved.id,
|
||||
})
|
||||
).status;
|
||||
} catch (error) {
|
||||
context.logGateway.warn(
|
||||
`DM pairing command-owner bootstrap failed channel=${account.plugin.id} account=${account.accountId}: ${formatForLog(error)}`,
|
||||
);
|
||||
commandOwnerBootstrap = "unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
let notification: "not-requested" | "sent" | "unsupported" | "failed" = "not-requested";
|
||||
if (parsed.notify === true) {
|
||||
if (!account.plugin.pairing.notifyApproval) {
|
||||
notification = "unsupported";
|
||||
} else {
|
||||
try {
|
||||
await notifyPairingApproved({
|
||||
channelId: account.plugin.id,
|
||||
accountId: account.accountId,
|
||||
id: approved.id,
|
||||
cfg,
|
||||
pairingAdapter: account.plugin.pairing,
|
||||
});
|
||||
notification = "sent";
|
||||
} catch (error) {
|
||||
context.logGateway.warn(
|
||||
`DM pairing approval notification failed channel=${account.plugin.id} account=${account.accountId}: ${formatForLog(error)}`,
|
||||
);
|
||||
notification = "failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
requestId: parsed.requestId,
|
||||
senderId: approved.id,
|
||||
notification,
|
||||
commandOwnerBootstrap,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
|
||||
}
|
||||
},
|
||||
|
||||
"channels.pairing.dismiss": async ({ params, respond, context }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateChannelsPairingDismissParams,
|
||||
"channels.pairing.dismiss",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const parsed = params as ChannelsPairingDismissParams;
|
||||
let account: PairingAccount | null;
|
||||
try {
|
||||
const cfg = resolveGatewayPluginConfig({ config: context.getRuntimeConfig() });
|
||||
account = await resolvePairingAccount({
|
||||
cfg,
|
||||
channel: parsed.channel,
|
||||
accountId: parsed.accountId,
|
||||
});
|
||||
} catch (error) {
|
||||
respondPairingFailure(respond, error);
|
||||
return;
|
||||
}
|
||||
if (!account) {
|
||||
invalidPairingAccount(respond, parsed.channel, parsed.accountId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dismissed = await dismissChannelPairingRequest({
|
||||
channel: account.plugin.id,
|
||||
accountId: account.accountId,
|
||||
requestId: parsed.requestId,
|
||||
});
|
||||
if (!dismissed) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "pending DM access request no longer exists"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(true, { requestId: parsed.requestId, senderId: dismissed.id }, undefined);
|
||||
} catch (error) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
|
||||
}
|
||||
},
|
||||
};
|
||||
41
src/pairing/command-owner.ts
Normal file
41
src/pairing/command-owner.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Shared first-owner bootstrap for DM pairing approval surfaces.
|
||||
import {
|
||||
formatCommandOwnerFromChannelSender,
|
||||
hasConfiguredCommandOwners,
|
||||
} from "../commands/doctor-command-owner.js";
|
||||
import { readConfigFileSnapshotForWrite, replaceConfigFile } from "../config/config.js";
|
||||
import type { PairingChannel } from "./pairing-store.types.js";
|
||||
|
||||
type PairingCommandOwnerBootstrapResult = {
|
||||
ownerEntry: string | null;
|
||||
status: "configured" | "already-configured" | "unavailable";
|
||||
};
|
||||
|
||||
/** Adds the approved sender as command owner only when no owner exists yet. */
|
||||
export async function bootstrapCommandOwnerFromPairing(params: {
|
||||
channel: PairingChannel;
|
||||
id: string;
|
||||
}): Promise<PairingCommandOwnerBootstrapResult> {
|
||||
const ownerEntry = formatCommandOwnerFromChannelSender(params);
|
||||
if (!ownerEntry) {
|
||||
return { ownerEntry: null, status: "unavailable" };
|
||||
}
|
||||
|
||||
const { snapshot, writeOptions } = await readConfigFileSnapshotForWrite();
|
||||
if (hasConfiguredCommandOwners(snapshot.sourceConfig)) {
|
||||
return { ownerEntry, status: "already-configured" };
|
||||
}
|
||||
|
||||
const nextConfig = structuredClone(snapshot.sourceConfig);
|
||||
nextConfig.commands = {
|
||||
...nextConfig.commands,
|
||||
ownerAllowFrom: [ownerEntry],
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig,
|
||||
snapshot,
|
||||
writeOptions,
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
return { ownerEntry, status: "configured" };
|
||||
}
|
||||
@@ -29,10 +29,13 @@ import {
|
||||
import {
|
||||
addChannelAllowFromStoreEntry,
|
||||
approveChannelPairingCode,
|
||||
approveChannelPairingRequest,
|
||||
dismissChannelPairingRequest,
|
||||
listChannelPairingRequests,
|
||||
readChannelAllowFromStore,
|
||||
readChannelAllowFromStoreSync,
|
||||
removeChannelAllowFromStoreEntry,
|
||||
resolveChannelPairingRequestId,
|
||||
upsertChannelPairingRequest,
|
||||
} from "./pairing-store.js";
|
||||
|
||||
@@ -272,6 +275,63 @@ describe("pairing store", () => {
|
||||
await expect(readChannelAllowFromStore("demo-a", env)).resolves.toEqual(["fixture-entry"]);
|
||||
});
|
||||
|
||||
it("approves and dismisses account-scoped requests by opaque id", async () => {
|
||||
const { env } = createTestEnv();
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
accountId: "alpha",
|
||||
id: "shared-sender",
|
||||
env,
|
||||
});
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
accountId: "beta",
|
||||
id: "shared-sender",
|
||||
env,
|
||||
});
|
||||
const alphaRequest = requireFirstPairingRequest(
|
||||
await listChannelPairingRequests("telegram", env, "alpha"),
|
||||
);
|
||||
const betaRequest = requireFirstPairingRequest(
|
||||
await listChannelPairingRequests("telegram", env, "beta"),
|
||||
);
|
||||
const alphaRequestId = resolveChannelPairingRequestId("telegram", alphaRequest);
|
||||
const betaRequestId = resolveChannelPairingRequestId("telegram", betaRequest);
|
||||
|
||||
expect(alphaRequestId).not.toBe(betaRequestId);
|
||||
expect(alphaRequestId).not.toContain(alphaRequest.code);
|
||||
await expect(
|
||||
approveChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
accountId: "alpha",
|
||||
requestId: alphaRequestId,
|
||||
env,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: "shared-sender" });
|
||||
await expect(readChannelAllowFromStore("telegram", env, "alpha")).resolves.toEqual([
|
||||
"shared-sender",
|
||||
]);
|
||||
await expect(
|
||||
approveChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
accountId: "beta",
|
||||
requestId: alphaRequestId,
|
||||
env,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await expect(
|
||||
dismissChannelPairingRequest({
|
||||
channel: "telegram",
|
||||
accountId: "beta",
|
||||
requestId: betaRequestId,
|
||||
env,
|
||||
}),
|
||||
).resolves.toMatchObject({ id: "shared-sender" });
|
||||
await expect(readChannelAllowFromStore("telegram", env, "beta")).resolves.toEqual([]);
|
||||
await expect(listChannelPairingRequests("telegram", env)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("regenerates colliding codes and reports exhaustion without leaking codes", async () => {
|
||||
const { env } = createTestEnv();
|
||||
await withMockRandomInt({
|
||||
|
||||
@@ -40,8 +40,8 @@ export function resolveChannelAllowFromPath(
|
||||
const PAIRING_CODE_LENGTH = 8;
|
||||
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const PAIRING_CODE_MAX_ATTEMPTS = 500;
|
||||
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||
const PAIRING_PENDING_MAX = 3;
|
||||
export const CHANNEL_PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
|
||||
export const CHANNEL_PAIRING_PENDING_MAX = 3;
|
||||
|
||||
export type PairingRequest = {
|
||||
id: string;
|
||||
@@ -51,6 +51,19 @@ export type PairingRequest = {
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
|
||||
/** Stable opaque id for approving a request without exposing its human pairing code. */
|
||||
export function resolveChannelPairingRequestId(
|
||||
channel: PairingChannel,
|
||||
request: PairingRequest,
|
||||
): string {
|
||||
const accountId = resolvePairingRequestAccountId(request);
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(`${channel}\0${accountId}\0${request.id}\0${request.createdAt}`)
|
||||
.digest("base64url")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
@@ -61,7 +74,7 @@ function parseTimestamp(value: string | undefined): number | null {
|
||||
|
||||
function isExpired(entry: PairingRequest, nowMs: number): boolean {
|
||||
const createdAt = parseTimestamp(entry.createdAt);
|
||||
return createdAt === null || nowMs - createdAt > PAIRING_PENDING_TTL_MS;
|
||||
return createdAt === null || nowMs - createdAt > CHANNEL_PAIRING_PENDING_TTL_MS;
|
||||
}
|
||||
|
||||
function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
|
||||
@@ -263,7 +276,7 @@ export async function listChannelPairingRequests(
|
||||
return runOpenClawStateWriteTransaction((database) => {
|
||||
const state = readChannelPairingStateFromDatabase(database, channel);
|
||||
const expired = pruneExpiredRequests(state.requests, Date.now());
|
||||
const capped = pruneExcessRequestsByAccount(expired.requests, PAIRING_PENDING_MAX);
|
||||
const capped = pruneExcessRequestsByAccount(expired.requests, CHANNEL_PAIRING_PENDING_MAX);
|
||||
if (expired.removed || capped.removed) {
|
||||
state.requests = capped.requests;
|
||||
writeChannelPairingStateToDatabase(database, channel, state);
|
||||
@@ -326,17 +339,17 @@ export async function upsertChannelPairingRequest(params: {
|
||||
lastSeenAt: now,
|
||||
meta,
|
||||
};
|
||||
state.requests = pruneExcessRequestsByAccount(requests, PAIRING_PENDING_MAX).requests;
|
||||
state.requests = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX).requests;
|
||||
writeChannelPairingStateToDatabase(database, params.channel, state);
|
||||
return { code, created: false };
|
||||
}
|
||||
|
||||
const capped = pruneExcessRequestsByAccount(requests, PAIRING_PENDING_MAX);
|
||||
const capped = pruneExcessRequestsByAccount(requests, CHANNEL_PAIRING_PENDING_MAX);
|
||||
requests = capped.requests;
|
||||
const accountRequestCount = requests.filter((request) =>
|
||||
requestMatchesAccountId(request, accountId),
|
||||
).length;
|
||||
if (PAIRING_PENDING_MAX > 0 && accountRequestCount >= PAIRING_PENDING_MAX) {
|
||||
if (CHANNEL_PAIRING_PENDING_MAX > 0 && accountRequestCount >= CHANNEL_PAIRING_PENDING_MAX) {
|
||||
if (expired.removed || capped.removed) {
|
||||
state.requests = requests;
|
||||
writeChannelPairingStateToDatabase(database, params.channel, state);
|
||||
@@ -351,26 +364,25 @@ export async function upsertChannelPairingRequest(params: {
|
||||
}, sqliteOptionsForEnv(env));
|
||||
}
|
||||
|
||||
export async function approveChannelPairingCode(params: {
|
||||
type ResolvePairingRequestParams = {
|
||||
channel: PairingChannel;
|
||||
code: string;
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pairingAdapter?: ChannelPairingAdapter;
|
||||
}): Promise<{ id: string; entry?: PairingRequest } | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const code = (normalizeNullableString(params.code) ?? "").toUpperCase();
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
matches: (request: PairingRequest) => boolean;
|
||||
approve: boolean;
|
||||
};
|
||||
|
||||
async function resolveChannelPairingRequest(
|
||||
params: ResolvePairingRequestParams,
|
||||
): Promise<{ id: string; entry: PairingRequest } | null> {
|
||||
const env = params.env ?? process.env;
|
||||
return runOpenClawStateWriteTransaction((database) => {
|
||||
const state = readChannelPairingStateFromDatabase(database, params.channel);
|
||||
const pruned = pruneExpiredRequests(state.requests, Date.now());
|
||||
const accountId = normalizePairingAccountId(params.accountId);
|
||||
const index = pruned.requests.findIndex(
|
||||
(request) =>
|
||||
request.code.toUpperCase() === code && requestMatchesAccountId(request, accountId),
|
||||
(request) => requestMatchesAccountId(request, accountId) && params.matches(request),
|
||||
);
|
||||
if (index < 0) {
|
||||
if (pruned.removed) {
|
||||
@@ -385,26 +397,87 @@ export async function approveChannelPairingCode(params: {
|
||||
}
|
||||
pruned.requests.splice(index, 1);
|
||||
state.requests = pruned.requests;
|
||||
const allowAccountId = resolveAllowFromAccountId(
|
||||
normalizeOptionalString(params.accountId) ?? normalizeOptionalString(entry.meta?.accountId),
|
||||
);
|
||||
const currentAllow = state.allowFrom?.[allowAccountId] ?? [];
|
||||
const adapter = resolvePairingAdapter(params.channel, params.pairingAdapter);
|
||||
// Channels with key-bound handoffs can persist an opaque approval token
|
||||
// derived from request metadata instead of a durable sender allowlist id.
|
||||
const approvalEntry = adapter?.resolveApprovalStoreEntry
|
||||
? adapter.resolveApprovalStoreEntry({
|
||||
id: entry.id,
|
||||
...(entry.meta ? { meta: entry.meta } : {}),
|
||||
})
|
||||
: entry.id;
|
||||
const normalizedAllow =
|
||||
approvalEntry == null ? "" : normalizeAllowFromInput(params.channel, approvalEntry, adapter);
|
||||
if (normalizedAllow && !currentAllow.includes(normalizedAllow)) {
|
||||
state.allowFrom ??= {};
|
||||
state.allowFrom[allowAccountId] = [...currentAllow, normalizedAllow];
|
||||
|
||||
if (params.approve) {
|
||||
const allowAccountId = resolveAllowFromAccountId(
|
||||
normalizeOptionalString(params.accountId) ?? normalizeOptionalString(entry.meta?.accountId),
|
||||
);
|
||||
const currentAllow = state.allowFrom?.[allowAccountId] ?? [];
|
||||
const adapter = resolvePairingAdapter(params.channel, params.pairingAdapter);
|
||||
// Channels with key-bound handoffs can persist an opaque approval token
|
||||
// derived from request metadata instead of a durable sender allowlist id.
|
||||
const approvalEntry = adapter?.resolveApprovalStoreEntry
|
||||
? adapter.resolveApprovalStoreEntry({
|
||||
id: entry.id,
|
||||
...(entry.meta ? { meta: entry.meta } : {}),
|
||||
})
|
||||
: entry.id;
|
||||
const normalizedAllow =
|
||||
approvalEntry == null
|
||||
? ""
|
||||
: normalizeAllowFromInput(params.channel, approvalEntry, adapter);
|
||||
if (normalizedAllow && !currentAllow.includes(normalizedAllow)) {
|
||||
state.allowFrom ??= {};
|
||||
state.allowFrom[allowAccountId] = [...currentAllow, normalizedAllow];
|
||||
}
|
||||
}
|
||||
|
||||
writeChannelPairingStateToDatabase(database, params.channel, state);
|
||||
return { id: entry.id, entry };
|
||||
}, sqliteOptionsForEnv(env));
|
||||
}
|
||||
|
||||
export async function approveChannelPairingCode(params: {
|
||||
channel: PairingChannel;
|
||||
code: string;
|
||||
accountId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pairingAdapter?: ChannelPairingAdapter;
|
||||
}): Promise<{ id: string; entry: PairingRequest } | null> {
|
||||
const code = (normalizeNullableString(params.code) ?? "").toUpperCase();
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
return resolveChannelPairingRequest({
|
||||
...params,
|
||||
matches: (request) => request.code.toUpperCase() === code,
|
||||
approve: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Approves a pending request by opaque id without exposing its pairing code. */
|
||||
export async function approveChannelPairingRequest(params: {
|
||||
channel: PairingChannel;
|
||||
requestId: string;
|
||||
accountId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pairingAdapter?: ChannelPairingAdapter;
|
||||
}): Promise<{ id: string; entry: PairingRequest } | null> {
|
||||
const requestId = normalizeOptionalString(params.requestId);
|
||||
if (!requestId) {
|
||||
return null;
|
||||
}
|
||||
return resolveChannelPairingRequest({
|
||||
...params,
|
||||
matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId,
|
||||
approve: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Dismisses a pending request without blocking the sender from requesting again. */
|
||||
export async function dismissChannelPairingRequest(params: {
|
||||
channel: PairingChannel;
|
||||
requestId: string;
|
||||
accountId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ id: string; entry: PairingRequest } | null> {
|
||||
const requestId = normalizeOptionalString(params.requestId);
|
||||
if (!requestId) {
|
||||
return null;
|
||||
}
|
||||
return resolveChannelPairingRequest({
|
||||
...params,
|
||||
matches: (request) => resolveChannelPairingRequestId(params.channel, request) === requestId,
|
||||
approve: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ import type {
|
||||
export type { ConfigUiHint, ConfigUiHints } from "../../../src/shared/config-ui-hints-types.js";
|
||||
export type { SessionGoal } from "../../../src/config/sessions/types.js";
|
||||
export type { FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
export type ChannelsPairingAccount =
|
||||
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingAccount;
|
||||
export type ChannelsPairingApproveResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingApproveResult;
|
||||
export type ChannelsPairingListResult =
|
||||
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingListResult;
|
||||
export type ChannelsPairingRequest =
|
||||
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingRequest;
|
||||
export type ChannelsStatusSnapshot = {
|
||||
ts: number;
|
||||
channelOrder: string[];
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasOperatorApprovalsAccess } from "./operator-access.ts";
|
||||
import { hasOperatorApprovalsAccess, hasOperatorPairingAccess } from "./operator-access.ts";
|
||||
|
||||
describe("hasOperatorPairingAccess", () => {
|
||||
it("requires pairing scope while keeping admin and legacy auth compatible", () => {
|
||||
expect(hasOperatorPairingAccess(null)).toBe(false);
|
||||
expect(hasOperatorPairingAccess({ role: "operator" })).toBe(true);
|
||||
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.read"] })).toBe(false);
|
||||
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.pairing"] })).toBe(true);
|
||||
expect(hasOperatorPairingAccess({ role: "operator", scopes: ["operator.admin"] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasOperatorApprovalsAccess", () => {
|
||||
it("requires the approval scope when the gateway advertises scopes", () => {
|
||||
|
||||
@@ -27,6 +27,22 @@ export function hasOperatorAdminAccess(
|
||||
});
|
||||
}
|
||||
|
||||
export function hasOperatorPairingAccess(
|
||||
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||
): boolean {
|
||||
if (!auth) {
|
||||
return false;
|
||||
}
|
||||
if (!auth.scopes) {
|
||||
return true;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: auth.role ?? "operator",
|
||||
requestedScopes: ["operator.pairing"],
|
||||
allowedScopes: auth.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
export function hasOperatorApprovalsAccess(
|
||||
auth: { role?: string; scopes?: readonly string[] } | null,
|
||||
): boolean {
|
||||
|
||||
@@ -159,6 +159,54 @@ export const en: TranslationMap = {
|
||||
saveBeforeSetup:
|
||||
"You have unsaved channel config changes. Save or reload them before running guided setup.",
|
||||
},
|
||||
pairing: {
|
||||
title: "DM access requests",
|
||||
subtitle: "Review people waiting to send direct messages to pairing-protected channels.",
|
||||
channelFilter: "Channel",
|
||||
accountFilter: "Account",
|
||||
allChannels: "All channels",
|
||||
allAccounts: "All accounts",
|
||||
approve: "Approve",
|
||||
dismiss: "Dismiss",
|
||||
approveAria: "Approve {sender} for {channel}, account {account}",
|
||||
dismissAria: "Dismiss {sender} for {channel}, account {account}",
|
||||
requested: "Requested {ago}",
|
||||
expires: "Expires {ago}",
|
||||
senderDetails: "Sender details",
|
||||
missingPermission:
|
||||
"This connection does not have operator.pairing access, so DM requests cannot be reviewed.",
|
||||
noAccounts: "No configured channel accounts use DM sender pairing.",
|
||||
noRequests: "No pending DM access requests.",
|
||||
noFilteredRequests: "No pending requests match these filters.",
|
||||
limits:
|
||||
"Requests expire after {minutes} minutes. Each channel account can hold up to {count} pending requests.",
|
||||
detailTitle: "DM access",
|
||||
detailSubtitle: "People must be approved before their direct messages reach the agent.",
|
||||
pendingCount: "{count} pending",
|
||||
noPending: "No pending requests",
|
||||
review: "Review requests",
|
||||
approveDialogTitle: "Approve DM access",
|
||||
dismissDialogTitle: "Dismiss DM access request",
|
||||
approveExplanation:
|
||||
"This lets the sender talk to the agent in direct messages. It does not grant group access.",
|
||||
dismissExplanation:
|
||||
"This removes the current request but does not block the sender. They can request access again later.",
|
||||
notifyRequester: "Notify the requester after approval",
|
||||
makeCommandOwner: "Also make this sender the first command owner",
|
||||
commandOwnerHelp:
|
||||
"Command owners can run privileged commands and approve dangerous actions. This option is only available while no owner is configured.",
|
||||
commandOwnerNeedsAdmin:
|
||||
"No command owner is configured. This connection needs operator.admin to assign the first owner.",
|
||||
approvedNotice: "DM access approved.",
|
||||
approvedOwnerNotice: "DM access approved and the first command owner was configured.",
|
||||
approvedNotificationFailedNotice:
|
||||
"DM access approved, but the requester notification could not be delivered.",
|
||||
approvedOwnerFailedNotice:
|
||||
"DM access approved, but the first command owner could not be configured.",
|
||||
approvedFollowupsFailedNotice:
|
||||
"DM access approved, but requester notification and command-owner setup both failed.",
|
||||
dismissedNotice: "DM access request dismissed. The sender can request access again.",
|
||||
},
|
||||
setup: {
|
||||
dialogLabel: "Set up {channel}",
|
||||
title: "Set up {channel}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Channels domain tests.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelsStatusSnapshot } from "../../api/types.ts";
|
||||
import type { ChannelsPairingListResult, ChannelsStatusSnapshot } from "../../api/types.ts";
|
||||
import { createChannelCapability } from "./index.ts";
|
||||
|
||||
function createDeferred<T>() {
|
||||
@@ -98,6 +98,106 @@ describe("channels controller WhatsApp wait", () => {
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("keeps an active login wait across pairing-scope changes", async () => {
|
||||
const pending = createDeferred<{
|
||||
message: string;
|
||||
connected: boolean;
|
||||
qrDataUrl: string;
|
||||
}>();
|
||||
const request = vi.fn((method: string) =>
|
||||
method === "web.login.wait"
|
||||
? pending.promise
|
||||
: Promise.resolve(createChannelsSnapshot("refreshed")),
|
||||
);
|
||||
const client = { request };
|
||||
let snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
|
||||
};
|
||||
const listeners = new Set<(next: typeof snapshot) => void>();
|
||||
const channels = createChannelCapability({
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: typeof snapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
} as never);
|
||||
|
||||
const wait = channels.waitWhatsApp();
|
||||
await vi.waitFor(() => expect(channels.state.whatsappBusy).toBe(true));
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] } },
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
expect(channels.state.whatsappBusy).toBe(true);
|
||||
|
||||
pending.resolve({
|
||||
message: "login survived scope change",
|
||||
connected: false,
|
||||
qrDataUrl: "data:image/png;base64,scope-change",
|
||||
});
|
||||
await wait;
|
||||
|
||||
expect(channels.state.whatsappLoginMessage).toBe("login survived scope change");
|
||||
expect(channels.state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,scope-change");
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("rejects an active login result when admin access is revoked", async () => {
|
||||
const pending = createDeferred<{
|
||||
message: string;
|
||||
connected: boolean;
|
||||
qrDataUrl: string;
|
||||
}>();
|
||||
const request = vi.fn(() => pending.promise);
|
||||
const client = { request };
|
||||
let snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.admin", "operator.pairing"] } },
|
||||
};
|
||||
const listeners = new Set<(next: typeof snapshot) => void>();
|
||||
const channels = createChannelCapability({
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: typeof snapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
} as never);
|
||||
channels.state.whatsappLoginQrDataUrl = "data:image/png;base64,existing";
|
||||
|
||||
const wait = channels.waitWhatsApp();
|
||||
await vi.waitFor(() => expect(channels.state.whatsappBusy).toBe(true));
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
expect(channels.state.whatsappBusy).toBe(false);
|
||||
expect(channels.state.whatsappLoginQrDataUrl).toBeNull();
|
||||
|
||||
pending.resolve({
|
||||
message: "stale login",
|
||||
connected: true,
|
||||
qrDataUrl: "data:image/png;base64,stale",
|
||||
});
|
||||
await wait;
|
||||
|
||||
expect(channels.state.whatsappLoginMessage).toBeNull();
|
||||
expect(channels.state.whatsappLoginQrDataUrl).toBeNull();
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("does not apply or refresh a login result after its capability is disposed", async () => {
|
||||
const pending = createDeferred<{
|
||||
message: string;
|
||||
@@ -209,7 +309,330 @@ describe("channels controller WhatsApp logout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("channels controller DM pairing", () => {
|
||||
const emptyPairing: ChannelsPairingListResult = {
|
||||
accounts: [],
|
||||
requests: [],
|
||||
commandOwnerConfigured: true,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
};
|
||||
const pendingPairing: ChannelsPairingListResult = {
|
||||
...emptyPairing,
|
||||
accounts: [
|
||||
{
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
requestId: "request-1",
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
senderId: "+1555",
|
||||
senderLabel: "Phone number",
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:00:00.000Z",
|
||||
expiresAt: "2026-07-20T11:00:00.000Z",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("loads pending requests and refreshes after approval", async () => {
|
||||
let listCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "channels.pairing.list") {
|
||||
listCount += 1;
|
||||
return listCount === 1 ? pendingPairing : emptyPairing;
|
||||
}
|
||||
if (method === "channels.pairing.approve") {
|
||||
return {
|
||||
requestId: "request-1",
|
||||
senderId: "+1555",
|
||||
notification: "sent",
|
||||
commandOwnerBootstrap: "not-requested",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const channels = createChannelCapability({
|
||||
snapshot: { client: { request }, connected: true },
|
||||
subscribe: () => () => undefined,
|
||||
} as never);
|
||||
|
||||
await channels.refreshPairing();
|
||||
expect(channels.state.pairingSnapshot?.requests).toHaveLength(1);
|
||||
|
||||
const result = await channels.approvePairing({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "request-1",
|
||||
notify: true,
|
||||
bootstrapCommandOwner: false,
|
||||
});
|
||||
|
||||
expect(result?.notification).toBe("sent");
|
||||
expect(request).toHaveBeenCalledWith("channels.pairing.approve", {
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "request-1",
|
||||
notify: true,
|
||||
bootstrapCommandOwner: false,
|
||||
});
|
||||
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
|
||||
expect(channels.state.pairingBusyRequestId).toBeNull();
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("keeps the row resolved when refresh fails and blocks polling during mutation", async () => {
|
||||
const approvalResult = createDeferred<{
|
||||
requestId: string;
|
||||
senderId: string;
|
||||
notification: "not-requested";
|
||||
commandOwnerBootstrap: "not-requested";
|
||||
}>();
|
||||
let listCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "channels.pairing.list") {
|
||||
listCount += 1;
|
||||
if (listCount === 1) {
|
||||
return pendingPairing;
|
||||
}
|
||||
throw new Error("refresh unavailable");
|
||||
}
|
||||
return approvalResult.promise;
|
||||
});
|
||||
const channels = createChannelCapability({
|
||||
snapshot: { client: { request }, connected: true },
|
||||
subscribe: () => () => undefined,
|
||||
} as never);
|
||||
await channels.refreshPairing();
|
||||
|
||||
const approval = channels.approvePairing({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "request-1",
|
||||
notify: false,
|
||||
bootstrapCommandOwner: false,
|
||||
});
|
||||
await vi.waitFor(() => expect(channels.state.pairingBusyRequestId).toBe("request-1"));
|
||||
await channels.refreshPairing();
|
||||
expect(listCount).toBe(1);
|
||||
|
||||
approvalResult.resolve({
|
||||
requestId: "request-1",
|
||||
senderId: "+1555",
|
||||
notification: "not-requested",
|
||||
commandOwnerBootstrap: "not-requested",
|
||||
});
|
||||
await approval;
|
||||
|
||||
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
|
||||
expect(channels.state.pairingError).toBe("Error: refresh unavailable");
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("does not let a pre-approval list restore the resolved request", async () => {
|
||||
const staleList = createDeferred<ChannelsPairingListResult>();
|
||||
const freshList = createDeferred<ChannelsPairingListResult>();
|
||||
let listCount = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "channels.pairing.list") {
|
||||
listCount += 1;
|
||||
return listCount === 1 ? staleList.promise : freshList.promise;
|
||||
}
|
||||
return {
|
||||
requestId: "request-1",
|
||||
senderId: "+1555",
|
||||
notification: "not-requested",
|
||||
commandOwnerBootstrap: "not-requested",
|
||||
};
|
||||
});
|
||||
const channels = createChannelCapability({
|
||||
snapshot: { client: { request }, connected: true },
|
||||
subscribe: () => () => undefined,
|
||||
} as never);
|
||||
|
||||
const staleRefresh = channels.refreshPairing();
|
||||
await vi.waitFor(() => expect(listCount).toBe(1));
|
||||
const approval = channels.approvePairing({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "request-1",
|
||||
notify: false,
|
||||
bootstrapCommandOwner: false,
|
||||
});
|
||||
await vi.waitFor(() => expect(listCount).toBe(2));
|
||||
freshList.resolve(emptyPairing);
|
||||
await approval;
|
||||
|
||||
staleList.resolve(pendingPairing);
|
||||
await staleRefresh;
|
||||
|
||||
expect(channels.state.pairingSnapshot?.requests).toEqual([]);
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("clears sender metadata on disconnect and pairing-scope changes", () => {
|
||||
const client = { request: vi.fn() };
|
||||
let snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
|
||||
};
|
||||
const listeners = new Set<(next: typeof snapshot) => void>();
|
||||
const channels = createChannelCapability({
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: typeof snapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
} as never);
|
||||
channels.state.pairingSnapshot = pendingPairing;
|
||||
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.read"] } },
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
expect(channels.state.pairingSnapshot).toBeNull();
|
||||
|
||||
channels.state.pairingSnapshot = pendingPairing;
|
||||
snapshot = { ...snapshot, connected: false };
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
expect(channels.state.pairingSnapshot).toBeNull();
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("ignores a pairing mutation result from an earlier authorization epoch", async () => {
|
||||
const approval = createDeferred<{
|
||||
requestId: string;
|
||||
senderId: string;
|
||||
notification: "not-requested";
|
||||
commandOwnerBootstrap: "not-requested";
|
||||
}>();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "channels.pairing.approve") {
|
||||
return approval.promise;
|
||||
}
|
||||
return emptyPairing;
|
||||
});
|
||||
const client = { request };
|
||||
let snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
|
||||
};
|
||||
const listeners = new Set<(next: typeof snapshot) => void>();
|
||||
const channels = createChannelCapability({
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: typeof snapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
} as never);
|
||||
channels.state.pairingSnapshot = pendingPairing;
|
||||
|
||||
const pendingApproval = channels.approvePairing({
|
||||
channel: "whatsapp",
|
||||
accountId: "personal",
|
||||
requestId: "request-1",
|
||||
notify: false,
|
||||
bootstrapCommandOwner: false,
|
||||
});
|
||||
await vi.waitFor(() => expect(channels.state.pairingBusyRequestId).toBe("request-1"));
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] } },
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
channels.state.pairingSnapshot = pendingPairing;
|
||||
|
||||
approval.resolve({
|
||||
requestId: "request-1",
|
||||
senderId: "+1555",
|
||||
notification: "not-requested",
|
||||
commandOwnerBootstrap: "not-requested",
|
||||
});
|
||||
|
||||
await expect(pendingApproval).resolves.toBeNull();
|
||||
expect(channels.state.pairingSnapshot).toBe(pendingPairing);
|
||||
expect(request.mock.calls.filter(([method]) => method === "channels.pairing.list")).toEqual([]);
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("keeps the last request snapshot visible when refresh fails", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("gateway unavailable");
|
||||
});
|
||||
const channels = createChannelCapability({
|
||||
snapshot: { client: { request }, connected: true },
|
||||
subscribe: () => () => undefined,
|
||||
} as never);
|
||||
channels.state.pairingSnapshot = emptyPairing;
|
||||
|
||||
await channels.refreshPairing();
|
||||
|
||||
expect(channels.state.pairingSnapshot).toBe(emptyPairing);
|
||||
expect(channels.state.pairingError).toBe("Error: gateway unavailable");
|
||||
channels.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel refresh sequencing", () => {
|
||||
it("rejects an in-flight channel snapshot after read access is revoked", async () => {
|
||||
const pending = createDeferred<ChannelsStatusSnapshot | null>();
|
||||
const request = vi.fn(() => pending.promise);
|
||||
const client = { request };
|
||||
let snapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.read"] } },
|
||||
};
|
||||
const listeners = new Set<(next: typeof snapshot) => void>();
|
||||
const channels = createChannelCapability({
|
||||
get snapshot() {
|
||||
return snapshot;
|
||||
},
|
||||
subscribe(listener: (next: typeof snapshot) => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
} as never);
|
||||
|
||||
const refresh = channels.refresh();
|
||||
await vi.waitFor(() => expect(channels.state.channelsLoading).toBe(true));
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
hello: { auth: { role: "operator", scopes: ["operator.pairing"] } },
|
||||
};
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
expect(channels.state.channelsLoading).toBe(false);
|
||||
expect(channels.state.channelsSnapshot).toBeNull();
|
||||
|
||||
pending.resolve(createChannelsSnapshot("stale"));
|
||||
await refresh;
|
||||
|
||||
expect(channels.state.channelsSnapshot).toBeNull();
|
||||
channels.dispose();
|
||||
});
|
||||
|
||||
it("keeps a stale slow probe from replacing a newer runtime snapshot", async () => {
|
||||
const slowProbe = createDeferred<ChannelsStatusSnapshot | null>();
|
||||
const fastRuntime = createDeferred<ChannelsStatusSnapshot | null>();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ChannelsStatusSnapshot } from "../../api/types.ts";
|
||||
import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts";
|
||||
import type {
|
||||
ChannelsPairingApproveResult,
|
||||
ChannelsPairingListResult,
|
||||
ChannelsStatusSnapshot,
|
||||
} from "../../api/types.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import {
|
||||
formatMissingOperatorReadScopeMessage,
|
||||
@@ -16,6 +21,9 @@ type ChannelLogoutResult = {
|
||||
type ChannelGatewaySnapshot = {
|
||||
client: ChannelGatewayClient | null;
|
||||
connected: boolean;
|
||||
hello?: {
|
||||
auth?: { role?: string; scopes?: readonly string[] } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ChannelGateway = {
|
||||
@@ -32,6 +40,12 @@ type ChannelsState = {
|
||||
channelsSnapshot: ChannelsStatusSnapshot | null;
|
||||
channelsError: string | null;
|
||||
channelsLastSuccess: number | null;
|
||||
pairingLoading: boolean;
|
||||
pairingRefreshSeq: number;
|
||||
pairingSnapshot: ChannelsPairingListResult | null;
|
||||
pairingError: string | null;
|
||||
pairingLastSuccess: number | null;
|
||||
pairingBusyRequestId: string | null;
|
||||
whatsappLoginMessage: string | null;
|
||||
whatsappLoginQrDataUrl: string | null;
|
||||
whatsappLoginConnected: boolean | null;
|
||||
@@ -45,6 +59,19 @@ type LoadChannelsOptions = {
|
||||
export type ChannelCapability = {
|
||||
readonly state: ChannelsState;
|
||||
refresh: (probe?: boolean, options?: LoadChannelsOptions) => Promise<void>;
|
||||
refreshPairing: () => Promise<void>;
|
||||
approvePairing: (params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
requestId: string;
|
||||
notify: boolean;
|
||||
bootstrapCommandOwner: boolean;
|
||||
}) => Promise<ChannelsPairingApproveResult | null>;
|
||||
dismissPairing: (params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
requestId: string;
|
||||
}) => Promise<boolean>;
|
||||
startWhatsApp: (force: boolean, accountId?: string) => Promise<void>;
|
||||
waitWhatsApp: (accountId?: string) => Promise<void>;
|
||||
logoutWhatsApp: (accountId?: string) => Promise<void>;
|
||||
@@ -52,6 +79,31 @@ export type ChannelCapability = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export function resolveChannelPairingAuthSignature(
|
||||
snapshot: Partial<ChannelGatewaySnapshot>,
|
||||
): string {
|
||||
const auth = snapshot.hello?.auth;
|
||||
return JSON.stringify({
|
||||
role: auth?.role ?? null,
|
||||
scopes: auth?.scopes ? [...auth.scopes].toSorted() : null,
|
||||
});
|
||||
}
|
||||
|
||||
function channelSnapshotAllowsScope(
|
||||
snapshot: Partial<ChannelGatewaySnapshot>,
|
||||
scope: string,
|
||||
): boolean {
|
||||
const auth = snapshot.hello?.auth;
|
||||
if (!auth?.scopes) {
|
||||
return true;
|
||||
}
|
||||
return roleScopesAllow({
|
||||
role: auth.role ?? "operator",
|
||||
requestedScopes: [scope],
|
||||
allowedScopes: auth.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
function createInitialChannelsState(snapshot: Partial<ChannelGatewaySnapshot> = {}): ChannelsState {
|
||||
return {
|
||||
client: snapshot.client ?? null,
|
||||
@@ -62,6 +114,12 @@ function createInitialChannelsState(snapshot: Partial<ChannelGatewaySnapshot> =
|
||||
channelsSnapshot: null,
|
||||
channelsError: null,
|
||||
channelsLastSuccess: null,
|
||||
pairingLoading: false,
|
||||
pairingRefreshSeq: 0,
|
||||
pairingSnapshot: null,
|
||||
pairingError: null,
|
||||
pairingLastSuccess: null,
|
||||
pairingBusyRequestId: null,
|
||||
whatsappLoginMessage: null,
|
||||
whatsappLoginQrDataUrl: null,
|
||||
whatsappLoginConnected: null,
|
||||
@@ -140,14 +198,174 @@ async function loadChannels(
|
||||
await refresh;
|
||||
}
|
||||
|
||||
function isCurrentPairingRefresh(
|
||||
state: ChannelsState,
|
||||
client: ChannelGatewayClient,
|
||||
refreshSeq: number,
|
||||
): boolean {
|
||||
return state.connected && state.client === client && state.pairingRefreshSeq === refreshSeq;
|
||||
}
|
||||
|
||||
function invalidatePairingRefresh(state: ChannelsState): void {
|
||||
// A mutation must supersede any list that started before it; otherwise that
|
||||
// stale list can put the resolved request back until the next poll.
|
||||
state.pairingRefreshSeq += 1;
|
||||
state.pairingLoading = false;
|
||||
}
|
||||
|
||||
async function loadChannelPairing(
|
||||
state: ChannelsState,
|
||||
options: { duringMutation?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const client = state.client;
|
||||
if (
|
||||
!client ||
|
||||
!state.connected ||
|
||||
state.pairingLoading ||
|
||||
(state.pairingBusyRequestId && !options.duringMutation)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const refreshSeq = state.pairingRefreshSeq + 1;
|
||||
state.pairingRefreshSeq = refreshSeq;
|
||||
state.pairingLoading = true;
|
||||
state.pairingError = null;
|
||||
try {
|
||||
const snapshot = await client.request<ChannelsPairingListResult>("channels.pairing.list", {});
|
||||
if (!isCurrentPairingRefresh(state, client, refreshSeq)) {
|
||||
return;
|
||||
}
|
||||
state.pairingSnapshot = snapshot;
|
||||
state.pairingLastSuccess = Date.now();
|
||||
} catch (error) {
|
||||
if (isCurrentPairingRefresh(state, client, refreshSeq)) {
|
||||
state.pairingError = String(error);
|
||||
}
|
||||
} finally {
|
||||
if (isCurrentPairingRefresh(state, client, refreshSeq)) {
|
||||
state.pairingLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type PairingMutation = {
|
||||
client: ChannelGatewayClient;
|
||||
pairingEpoch: number;
|
||||
requestId: string;
|
||||
};
|
||||
|
||||
function isCurrentPairingMutation(state: ChannelsState, mutation: PairingMutation): boolean {
|
||||
return (
|
||||
state.connected &&
|
||||
state.client === mutation.client &&
|
||||
getChannelsLifecycle(state).pairingEpoch === mutation.pairingEpoch &&
|
||||
state.pairingBusyRequestId === mutation.requestId
|
||||
);
|
||||
}
|
||||
|
||||
function removePairingRequestFromSnapshot(state: ChannelsState, requestId: string): void {
|
||||
const snapshot = state.pairingSnapshot;
|
||||
if (!snapshot || !snapshot.requests.some((request) => request.requestId === requestId)) {
|
||||
return;
|
||||
}
|
||||
state.pairingSnapshot = {
|
||||
...snapshot,
|
||||
requests: snapshot.requests.filter((request) => request.requestId !== requestId),
|
||||
};
|
||||
}
|
||||
|
||||
async function approveChannelPairing(
|
||||
state: ChannelsState,
|
||||
params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
requestId: string;
|
||||
notify: boolean;
|
||||
bootstrapCommandOwner: boolean;
|
||||
},
|
||||
): Promise<ChannelsPairingApproveResult | null> {
|
||||
const client = state.client;
|
||||
if (!client || !state.connected || state.pairingBusyRequestId) {
|
||||
return null;
|
||||
}
|
||||
const mutation: PairingMutation = {
|
||||
client,
|
||||
pairingEpoch: getChannelsLifecycle(state).pairingEpoch,
|
||||
requestId: params.requestId,
|
||||
};
|
||||
invalidatePairingRefresh(state);
|
||||
state.pairingBusyRequestId = params.requestId;
|
||||
state.pairingError = null;
|
||||
try {
|
||||
const result = await client.request<ChannelsPairingApproveResult>(
|
||||
"channels.pairing.approve",
|
||||
params,
|
||||
);
|
||||
if (!isCurrentPairingMutation(state, mutation)) {
|
||||
return null;
|
||||
}
|
||||
removePairingRequestFromSnapshot(state, params.requestId);
|
||||
invalidatePairingRefresh(state);
|
||||
await loadChannelPairing(state, { duringMutation: true });
|
||||
return isCurrentPairingMutation(state, mutation) ? result : null;
|
||||
} catch (error) {
|
||||
if (isCurrentPairingMutation(state, mutation)) {
|
||||
state.pairingError = String(error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (isCurrentPairingMutation(state, mutation)) {
|
||||
state.pairingBusyRequestId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissChannelPairing(
|
||||
state: ChannelsState,
|
||||
params: { channel: string; accountId: string; requestId: string },
|
||||
): Promise<boolean> {
|
||||
const client = state.client;
|
||||
if (!client || !state.connected || state.pairingBusyRequestId) {
|
||||
return false;
|
||||
}
|
||||
const mutation: PairingMutation = {
|
||||
client,
|
||||
pairingEpoch: getChannelsLifecycle(state).pairingEpoch,
|
||||
requestId: params.requestId,
|
||||
};
|
||||
invalidatePairingRefresh(state);
|
||||
state.pairingBusyRequestId = params.requestId;
|
||||
state.pairingError = null;
|
||||
try {
|
||||
await client.request("channels.pairing.dismiss", params);
|
||||
if (!isCurrentPairingMutation(state, mutation)) {
|
||||
return false;
|
||||
}
|
||||
removePairingRequestFromSnapshot(state, params.requestId);
|
||||
invalidatePairingRefresh(state);
|
||||
await loadChannelPairing(state, { duringMutation: true });
|
||||
return isCurrentPairingMutation(state, mutation);
|
||||
} catch (error) {
|
||||
if (isCurrentPairingMutation(state, mutation)) {
|
||||
state.pairingError = String(error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (isCurrentPairingMutation(state, mutation)) {
|
||||
state.pairingBusyRequestId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WhatsAppOperation = {
|
||||
client: ChannelGatewayClient;
|
||||
gatewayEpoch: number;
|
||||
whatsappEpoch: number;
|
||||
operationSeq: number;
|
||||
};
|
||||
|
||||
type ChannelsLifecycle = {
|
||||
gatewayEpoch: number;
|
||||
whatsappEpoch: number;
|
||||
pairingEpoch: number;
|
||||
whatsappOperationSeq: number;
|
||||
};
|
||||
|
||||
@@ -158,7 +376,7 @@ function getChannelsLifecycle(state: ChannelsState): ChannelsLifecycle {
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created = { gatewayEpoch: 0, whatsappOperationSeq: 0 };
|
||||
const created = { whatsappEpoch: 0, pairingEpoch: 0, whatsappOperationSeq: 0 };
|
||||
channelsLifecycles.set(state, created);
|
||||
return created;
|
||||
}
|
||||
@@ -172,7 +390,7 @@ function beginWhatsAppOperation(state: ChannelsState): WhatsAppOperation | null
|
||||
const operationSeq = lifecycle.whatsappOperationSeq + 1;
|
||||
lifecycle.whatsappOperationSeq = operationSeq;
|
||||
state.whatsappBusy = true;
|
||||
return { client, gatewayEpoch: lifecycle.gatewayEpoch, operationSeq };
|
||||
return { client, whatsappEpoch: lifecycle.whatsappEpoch, operationSeq };
|
||||
}
|
||||
|
||||
function isCurrentWhatsAppOperation(state: ChannelsState, operation: WhatsAppOperation): boolean {
|
||||
@@ -180,7 +398,7 @@ function isCurrentWhatsAppOperation(state: ChannelsState, operation: WhatsAppOpe
|
||||
return (
|
||||
state.connected &&
|
||||
state.client === operation.client &&
|
||||
lifecycle.gatewayEpoch === operation.gatewayEpoch &&
|
||||
lifecycle.whatsappEpoch === operation.whatsappEpoch &&
|
||||
lifecycle.whatsappOperationSeq === operation.operationSeq
|
||||
);
|
||||
}
|
||||
@@ -351,6 +569,9 @@ export function resolveChannelExtras(params: {
|
||||
export function createChannelCapability(gateway: ChannelGateway): ChannelCapability {
|
||||
const state = createInitialChannelsState(gateway.snapshot);
|
||||
const listeners = new Set<(state: ChannelsState) => void>();
|
||||
let currentChannelReadAccess = channelSnapshotAllowsScope(gateway.snapshot, "operator.read");
|
||||
let currentPairingAuthSignature = resolveChannelPairingAuthSignature(gateway.snapshot);
|
||||
let currentWhatsAppAdminAccess = channelSnapshotAllowsScope(gateway.snapshot, "operator.admin");
|
||||
let disposed = false;
|
||||
|
||||
const publish = () => {
|
||||
@@ -376,18 +597,48 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
|
||||
const stopGateway = gateway.subscribe((snapshot) => {
|
||||
const clientChanged = state.client !== snapshot.client;
|
||||
const connectionChanged = state.connected !== snapshot.connected;
|
||||
const nextChannelReadAccess = channelSnapshotAllowsScope(snapshot, "operator.read");
|
||||
const channelReadAccessChanged = currentChannelReadAccess !== nextChannelReadAccess;
|
||||
currentChannelReadAccess = nextChannelReadAccess;
|
||||
const nextPairingAuthSignature = resolveChannelPairingAuthSignature(snapshot);
|
||||
const pairingAuthChanged = currentPairingAuthSignature !== nextPairingAuthSignature;
|
||||
currentPairingAuthSignature = nextPairingAuthSignature;
|
||||
const nextWhatsAppAdminAccess = channelSnapshotAllowsScope(snapshot, "operator.admin");
|
||||
const whatsappAdminAccessChanged = currentWhatsAppAdminAccess !== nextWhatsAppAdminAccess;
|
||||
currentWhatsAppAdminAccess = nextWhatsAppAdminAccess;
|
||||
state.client = snapshot.client;
|
||||
state.connected = snapshot.connected;
|
||||
if (clientChanged || connectionChanged) {
|
||||
// Every transport epoch invalidates both channel loads and login work.
|
||||
// A reconnect may reuse the same client object, so identity alone is insufficient.
|
||||
const lifecycle = getChannelsLifecycle(state);
|
||||
lifecycle.gatewayEpoch += 1;
|
||||
lifecycle.whatsappOperationSeq += 1;
|
||||
const lifecycle = getChannelsLifecycle(state);
|
||||
if (clientChanged || connectionChanged || channelReadAccessChanged) {
|
||||
state.channelsLoading = false;
|
||||
state.channelsLoadingProbe = null;
|
||||
state.whatsappBusy = false;
|
||||
state.channelsRefreshSeq = (state.channelsRefreshSeq ?? 0) + 1;
|
||||
if (!nextChannelReadAccess) {
|
||||
state.channelsSnapshot = null;
|
||||
state.channelsError = null;
|
||||
state.channelsLastSuccess = null;
|
||||
}
|
||||
}
|
||||
if (clientChanged || connectionChanged || whatsappAdminAccessChanged) {
|
||||
lifecycle.whatsappEpoch += 1;
|
||||
lifecycle.whatsappOperationSeq += 1;
|
||||
state.whatsappBusy = false;
|
||||
if (!nextWhatsAppAdminAccess) {
|
||||
state.whatsappLoginMessage = null;
|
||||
state.whatsappLoginQrDataUrl = null;
|
||||
state.whatsappLoginConnected = null;
|
||||
}
|
||||
}
|
||||
if (clientChanged || connectionChanged || pairingAuthChanged) {
|
||||
// Pairing authorization has its own epoch so scope changes cannot cancel
|
||||
// unrelated channel login/logout operations on the same connection.
|
||||
lifecycle.pairingEpoch += 1;
|
||||
state.pairingSnapshot = null;
|
||||
state.pairingError = null;
|
||||
state.pairingLastSuccess = null;
|
||||
state.pairingLoading = false;
|
||||
state.pairingBusyRequestId = null;
|
||||
state.pairingRefreshSeq += 1;
|
||||
}
|
||||
publish();
|
||||
});
|
||||
@@ -397,6 +648,21 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
|
||||
return state;
|
||||
},
|
||||
refresh: (probe, options) => run(() => loadChannels(state, probe ?? false, options)),
|
||||
refreshPairing: () => run(() => loadChannelPairing(state)),
|
||||
approvePairing: async (params) => {
|
||||
let result: ChannelsPairingApproveResult | null = null;
|
||||
await run(async () => {
|
||||
result = await approveChannelPairing(state, params);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
dismissPairing: async (params) => {
|
||||
let dismissed = false;
|
||||
await run(async () => {
|
||||
dismissed = await dismissChannelPairing(state, params);
|
||||
});
|
||||
return dismissed;
|
||||
},
|
||||
startWhatsApp: (force, accountId) =>
|
||||
run(async () => {
|
||||
if (await startWhatsAppLogin(state, force, accountId)) {
|
||||
@@ -425,8 +691,11 @@ export function createChannelCapability(gateway: ChannelGateway): ChannelCapabil
|
||||
}
|
||||
disposed = true;
|
||||
const lifecycle = getChannelsLifecycle(state);
|
||||
lifecycle.gatewayEpoch += 1;
|
||||
lifecycle.whatsappEpoch += 1;
|
||||
lifecycle.pairingEpoch += 1;
|
||||
lifecycle.whatsappOperationSeq += 1;
|
||||
state.pairingRefreshSeq += 1;
|
||||
state.pairingBusyRequestId = null;
|
||||
state.whatsappBusy = false;
|
||||
stopGateway();
|
||||
listeners.clear();
|
||||
|
||||
@@ -14,6 +14,12 @@ type ChannelsPageTestElement = HTMLElement & {
|
||||
requestUpdate: () => void;
|
||||
};
|
||||
|
||||
type PairingTestPage = ChannelsPageTestElement & {
|
||||
pairingAccountFilter: string | null;
|
||||
pairingChannelFilter: string | null;
|
||||
pairingPrompt: object | null;
|
||||
};
|
||||
|
||||
type NostrTestPage = ChannelsPageTestElement & {
|
||||
nostrProfileFormState: {
|
||||
values: NostrProfile;
|
||||
@@ -58,7 +64,18 @@ function stubHangingFetch() {
|
||||
}
|
||||
|
||||
function createGateway(): TestGateway {
|
||||
const client = { request: vi.fn(async () => ({})) } as unknown as GatewayBrowserClient;
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) =>
|
||||
method === "channels.pairing.list"
|
||||
? {
|
||||
accounts: [],
|
||||
requests: [],
|
||||
commandOwnerConfigured: true,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
}
|
||||
: {},
|
||||
),
|
||||
} as unknown as GatewayBrowserClient;
|
||||
const snapshot: ApplicationGatewaySnapshot = {
|
||||
client,
|
||||
connected: true,
|
||||
@@ -141,6 +158,44 @@ describe("ChannelsPage lifecycle", () => {
|
||||
second.channels.dispose();
|
||||
});
|
||||
|
||||
it("refreshes pairing data when the authorized scope set changes", async () => {
|
||||
const gateway = createGateway();
|
||||
gateway.emit({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.pairing"] },
|
||||
} as unknown as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
const source = createContext(gateway);
|
||||
source.channels.state.pairingSnapshot = {
|
||||
accounts: [],
|
||||
requests: [],
|
||||
commandOwnerConfigured: true,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
};
|
||||
const refreshPairing = vi.spyOn(source.channels, "refreshPairing").mockResolvedValue();
|
||||
const page = document.createElement("openclaw-channels-page") as PairingTestPage;
|
||||
page.context = source.context;
|
||||
document.body.append(page);
|
||||
await page.updateComplete;
|
||||
refreshPairing.mockClear();
|
||||
page.pairingPrompt = {};
|
||||
page.pairingChannelFilter = "whatsapp";
|
||||
page.pairingAccountFilter = "personal";
|
||||
|
||||
gateway.emit({
|
||||
hello: {
|
||||
auth: { role: "operator", scopes: ["operator.pairing", "operator.read"] },
|
||||
} as unknown as ApplicationGatewaySnapshot["hello"],
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(refreshPairing).toHaveBeenCalled());
|
||||
expect(page.pairingPrompt).toBeNull();
|
||||
expect(page.pairingChannelFilter).toBeNull();
|
||||
expect(page.pairingAccountFilter).toBeNull();
|
||||
source.runtimeConfig.dispose();
|
||||
source.channels.dispose();
|
||||
});
|
||||
|
||||
it("drops a profile save when the channel source is replaced", async () => {
|
||||
const gateway = createGateway();
|
||||
const first = createContext(gateway);
|
||||
|
||||
@@ -2,20 +2,31 @@ import { consume } from "@lit/context";
|
||||
import { html } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import type { NostrProfile } from "../../api/types.ts";
|
||||
import type {
|
||||
ChannelsPairingListResult,
|
||||
ChannelsPairingRequest,
|
||||
NostrProfile,
|
||||
} from "../../api/types.ts";
|
||||
import { titleForRoute } from "../../app-navigation.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { resolveControlUiAuthHeader } from "../../app/control-ui-auth.ts";
|
||||
import { hasOperatorAdminAccess, hasOperatorPairingAccess } from "../../app/operator-access.ts";
|
||||
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { resolveChannelPairingAuthSignature } from "../../lib/channels/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { PollController } from "../../lit/poll-controller.ts";
|
||||
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
|
||||
import { importNostrProfile, parseValidationErrors, putNostrProfile } from "./nostr-profile-ops.ts";
|
||||
import { createNostrProfileFormState } from "./view.nostr-profile-form.ts";
|
||||
import { renderChannels } from "./view.ts";
|
||||
import type { ChannelPairingPrompt } from "./view.types.ts";
|
||||
import { ChannelWizardHost } from "./wizard-host.ts";
|
||||
|
||||
type NostrProfileFormState = ReturnType<typeof createNostrProfileFormState> | null;
|
||||
|
||||
const CHANNEL_PAIRING_POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
const NOSTR_PROFILE_TIMEOUT_ERROR =
|
||||
"Request timed out after 30 seconds; the server may still have applied the change — check the profile before retrying.";
|
||||
|
||||
@@ -48,6 +59,18 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
@state()
|
||||
private selectedChannel: string | null = null;
|
||||
|
||||
@state()
|
||||
private pairingChannelFilter: string | null = null;
|
||||
|
||||
@state()
|
||||
private pairingAccountFilter: string | null = null;
|
||||
|
||||
@state()
|
||||
private pairingPrompt: ChannelPairingPrompt | null = null;
|
||||
|
||||
@state()
|
||||
private pairingNotice: string | null = null;
|
||||
|
||||
private readonly wizardHost = new ChannelWizardHost({
|
||||
getContext: () => this.context,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
@@ -61,8 +84,20 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
private channelsSource?: ApplicationContext["channels"];
|
||||
private gatewayClient: GatewayBrowserClient | null = null;
|
||||
private gatewayConnected = false;
|
||||
private gatewayPairingAuthSignature: string | null = null;
|
||||
private hasGatewaySnapshot = false;
|
||||
private nostrOperationGeneration = 0;
|
||||
private readonly pairingPolling = new PollController(
|
||||
this,
|
||||
CHANNEL_PAIRING_POLL_INTERVAL_MS,
|
||||
() => {
|
||||
const gateway = this.context?.gateway.snapshot;
|
||||
if (gateway?.connected && hasOperatorPairingAccess(gateway.hello?.auth ?? null)) {
|
||||
void this.context.channels.refreshPairing();
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
private readonly subscriptions = new SubscriptionsController(this)
|
||||
.effect(
|
||||
@@ -75,6 +110,7 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
}
|
||||
const handleChange = () => {
|
||||
if (this.channelsSource === channels) {
|
||||
this.reconcilePairingFilter(channels.state.pairingSnapshot);
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
@@ -123,22 +159,58 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
const clientChanged = this.hasGatewaySnapshot && this.gatewayClient !== snapshot.client;
|
||||
const connectionChanged =
|
||||
this.hasGatewaySnapshot && this.gatewayConnected !== snapshot.connected;
|
||||
const pairingAccess = hasOperatorPairingAccess(snapshot.hello?.auth ?? null);
|
||||
const pairingAuthSignature = resolveChannelPairingAuthSignature(snapshot);
|
||||
const pairingAuthChanged =
|
||||
this.hasGatewaySnapshot && this.gatewayPairingAuthSignature !== pairingAuthSignature;
|
||||
if (!this.hasGatewaySnapshot || sourceChanged || clientChanged || connectionChanged) {
|
||||
this.nostrOperationGeneration += 1;
|
||||
}
|
||||
if (sourceChanged || clientChanged || !snapshot.connected) {
|
||||
this.clearNostrForm();
|
||||
}
|
||||
if (
|
||||
sourceChanged ||
|
||||
clientChanged ||
|
||||
pairingAuthChanged ||
|
||||
!snapshot.connected ||
|
||||
!pairingAccess
|
||||
) {
|
||||
this.pairingPrompt = null;
|
||||
this.pairingChannelFilter = null;
|
||||
this.pairingAccountFilter = null;
|
||||
this.pairingNotice = null;
|
||||
}
|
||||
this.hasGatewaySnapshot = true;
|
||||
this.gatewayClient = snapshot.client;
|
||||
this.gatewayConnected = snapshot.connected;
|
||||
this.gatewayPairingAuthSignature = pairingAuthSignature;
|
||||
this.syncPairingPolling(snapshot);
|
||||
if (snapshot.connected && snapshot.client) {
|
||||
this.ensureInitialData();
|
||||
if (
|
||||
(sourceChanged || clientChanged || connectionChanged || pairingAuthChanged) &&
|
||||
pairingAccess
|
||||
) {
|
||||
void this.context.channels.refreshPairing();
|
||||
}
|
||||
} else {
|
||||
this.schemaLoadStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
private syncPairingPolling(snapshot: ApplicationContext["gateway"]["snapshot"]) {
|
||||
if (
|
||||
snapshot.connected &&
|
||||
snapshot.client &&
|
||||
hasOperatorPairingAccess(snapshot.hello?.auth ?? null)
|
||||
) {
|
||||
this.pairingPolling.start();
|
||||
return;
|
||||
}
|
||||
this.pairingPolling.stop();
|
||||
}
|
||||
|
||||
private ensureInitialData() {
|
||||
const context = this.context;
|
||||
const gateway = context.gateway.snapshot;
|
||||
@@ -152,6 +224,13 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
if (!channels.channelsSnapshot && !channels.channelsLoading) {
|
||||
void context.channels.refresh(false);
|
||||
}
|
||||
if (
|
||||
hasOperatorPairingAccess(gateway.hello?.auth ?? null) &&
|
||||
!channels.pairingSnapshot &&
|
||||
!channels.pairingLoading
|
||||
) {
|
||||
void context.channels.refreshPairing();
|
||||
}
|
||||
if (!config.configSnapshot && !config.configLoading) {
|
||||
void context.runtimeConfig.ensureLoaded();
|
||||
}
|
||||
@@ -168,7 +247,13 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
this.channelsSource = undefined;
|
||||
this.gatewayClient = null;
|
||||
this.gatewayConnected = false;
|
||||
this.gatewayPairingAuthSignature = null;
|
||||
this.hasGatewaySnapshot = false;
|
||||
this.pairingPrompt = null;
|
||||
this.pairingChannelFilter = null;
|
||||
this.pairingAccountFilter = null;
|
||||
this.pairingNotice = null;
|
||||
this.pairingPolling.stop();
|
||||
this.invalidateNostrForm();
|
||||
this.subscriptions.clear();
|
||||
this.schemaLoadStarted = false;
|
||||
@@ -436,10 +521,113 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private reconcilePairingFilter(snapshot: ChannelsPairingListResult | null) {
|
||||
if (!snapshot || !this.pairingChannelFilter) {
|
||||
return;
|
||||
}
|
||||
const channelAccounts = snapshot.accounts.filter(
|
||||
(account) => account.channel === this.pairingChannelFilter,
|
||||
);
|
||||
if (channelAccounts.length === 0) {
|
||||
this.pairingChannelFilter = null;
|
||||
this.pairingAccountFilter = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.pairingAccountFilter &&
|
||||
!channelAccounts.some((account) => account.accountId === this.pairingAccountFilter)
|
||||
) {
|
||||
this.pairingAccountFilter = null;
|
||||
}
|
||||
}
|
||||
|
||||
private setPairingFilter(channel: string | null, accountId: string | null) {
|
||||
this.pairingChannelFilter = channel;
|
||||
this.pairingAccountFilter = channel ? accountId : null;
|
||||
}
|
||||
|
||||
private reviewPairingAccount(channel: string, accountId: string) {
|
||||
this.selectedChannel = null;
|
||||
this.setPairingFilter(channel, accountId);
|
||||
void this.updateComplete.then(() => {
|
||||
this.renderRoot.querySelector("#channels-pairing-requests")?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private openPairingPrompt(kind: ChannelPairingPrompt["kind"], request: ChannelsPairingRequest) {
|
||||
if (this.context.channels.state.pairingBusyRequestId) {
|
||||
return;
|
||||
}
|
||||
this.pairingNotice = null;
|
||||
this.pairingPrompt = {
|
||||
kind,
|
||||
request,
|
||||
notify: false,
|
||||
bootstrapCommandOwner: false,
|
||||
};
|
||||
}
|
||||
|
||||
private patchPairingPrompt(
|
||||
patch: Partial<Pick<ChannelPairingPrompt, "notify" | "bootstrapCommandOwner">>,
|
||||
) {
|
||||
if (!this.pairingPrompt) {
|
||||
return;
|
||||
}
|
||||
this.pairingPrompt = { ...this.pairingPrompt, ...patch };
|
||||
}
|
||||
|
||||
private async confirmPairingPrompt() {
|
||||
const prompt = this.pairingPrompt;
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
if (prompt.kind === "dismiss") {
|
||||
const dismissed = await this.context.channels.dismissPairing({
|
||||
channel: prompt.request.channel,
|
||||
accountId: prompt.request.accountId,
|
||||
requestId: prompt.request.requestId,
|
||||
});
|
||||
if (dismissed && this.pairingPrompt === prompt) {
|
||||
this.pairingPrompt = null;
|
||||
this.pairingNotice = t("channels.pairing.dismissedNotice");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.context.channels.approvePairing({
|
||||
channel: prompt.request.channel,
|
||||
accountId: prompt.request.accountId,
|
||||
requestId: prompt.request.requestId,
|
||||
notify: prompt.notify,
|
||||
bootstrapCommandOwner: prompt.bootstrapCommandOwner,
|
||||
});
|
||||
if (!result || this.pairingPrompt !== prompt) {
|
||||
return;
|
||||
}
|
||||
this.pairingPrompt = null;
|
||||
if (result.notification === "failed" && result.commandOwnerBootstrap === "unavailable") {
|
||||
this.pairingNotice = t("channels.pairing.approvedFollowupsFailedNotice");
|
||||
} else if (result.commandOwnerBootstrap === "unavailable") {
|
||||
this.pairingNotice = t("channels.pairing.approvedOwnerFailedNotice");
|
||||
} else if (result.notification === "failed") {
|
||||
this.pairingNotice = t("channels.pairing.approvedNotificationFailedNotice");
|
||||
} else if (result.commandOwnerBootstrap === "configured") {
|
||||
this.pairingNotice = t("channels.pairing.approvedOwnerNotice");
|
||||
} else {
|
||||
this.pairingNotice = t("channels.pairing.approvedNotice");
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const context = this.context;
|
||||
const channels = context.channels.state;
|
||||
const config = context.runtimeConfig.state;
|
||||
const auth = context.gateway.snapshot.hello?.auth ?? null;
|
||||
const canManagePairing = hasOperatorPairingAccess(auth);
|
||||
const canAdmin = hasOperatorAdminAccess(auth);
|
||||
return html`
|
||||
<section class="content-header">
|
||||
<div>
|
||||
@@ -453,6 +641,17 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
snapshot: channels.channelsSnapshot,
|
||||
lastError: channels.channelsError,
|
||||
lastSuccessAt: channels.channelsLastSuccess,
|
||||
pairingLoading: channels.pairingLoading,
|
||||
pairingSnapshot: channels.pairingSnapshot,
|
||||
pairingError: channels.pairingError,
|
||||
pairingLastSuccessAt: channels.pairingLastSuccess,
|
||||
pairingBusyRequestId: channels.pairingBusyRequestId,
|
||||
pairingChannelFilter: this.pairingChannelFilter,
|
||||
pairingAccountFilter: this.pairingAccountFilter,
|
||||
pairingPrompt: this.pairingPrompt,
|
||||
pairingNotice: this.pairingNotice,
|
||||
canManagePairing,
|
||||
canAdmin,
|
||||
whatsappMessage: channels.whatsappLoginMessage,
|
||||
whatsappQrDataUrl: channels.whatsappLoginQrDataUrl,
|
||||
whatsappConnected: channels.whatsappLoginConnected,
|
||||
@@ -480,6 +679,17 @@ class ChannelsPage extends OpenClawLightDomElement {
|
||||
onWizardToggleMultiselect: (value) => this.wizardHost.toggleMultiselect(value),
|
||||
onWizardClose: () => this.wizardHost.close(),
|
||||
onRefresh: (probe) => void context.channels.refresh(probe),
|
||||
onPairingRefresh: () => void context.channels.refreshPairing(),
|
||||
onPairingFilterChange: (channel, accountId) => this.setPairingFilter(channel, accountId),
|
||||
onPairingReviewAccount: (channel, accountId) =>
|
||||
this.reviewPairingAccount(channel, accountId),
|
||||
onPairingApprove: (request) => this.openPairingPrompt("approve", request),
|
||||
onPairingDismiss: (request) => this.openPairingPrompt("dismiss", request),
|
||||
onPairingPromptChange: (patch) => this.patchPairingPrompt(patch),
|
||||
onPairingPromptCancel: () => {
|
||||
this.pairingPrompt = null;
|
||||
},
|
||||
onPairingPromptConfirm: () => void this.confirmPairingPrompt(),
|
||||
onWhatsAppStart: (force) =>
|
||||
void context.channels.startWhatsApp(force, this.wizardHost.whatsappAccountId),
|
||||
onWhatsAppWait: () =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { renderDiscordCard } from "./view.discord.ts";
|
||||
import { renderGoogleChatCard } from "./view.googlechat.ts";
|
||||
import { renderIMessageCard } from "./view.imessage.ts";
|
||||
import { renderNostrCard } from "./view.nostr.ts";
|
||||
import { renderChannelPairingDetail } from "./view.pairing.ts";
|
||||
import {
|
||||
boolStatusKind,
|
||||
formatNullableBoolean,
|
||||
@@ -195,7 +196,7 @@ export function renderChannelDetail(params: {
|
||||
${params.props.setupBlockedByDirtyConfig && params.props.configFormDirty
|
||||
? html`<div class="callout warn">${t("channels.hub.saveBeforeSetup")}</div>`
|
||||
: nothing}
|
||||
${body}
|
||||
${renderChannelPairingDetail(params.channelId, params.props)} ${body}
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
|
||||
195
ui/src/pages/channels/view.pairing.test.ts
Normal file
195
ui/src/pages/channels/view.pairing.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
renderChannelPairingDetail,
|
||||
renderChannelPairingPrompt,
|
||||
renderChannelPairingQueue,
|
||||
} from "./view.pairing.ts";
|
||||
import type { ChannelsProps } from "./view.types.ts";
|
||||
|
||||
const request = {
|
||||
requestId: "opaque-request-id",
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
accountLabel: "Personal",
|
||||
senderId: "+15551234567",
|
||||
senderLabel: "Phone number",
|
||||
metadata: { name: "Alice" },
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
lastSeenAt: "2026-07-20T10:05:00.000Z",
|
||||
expiresAt: "2026-07-20T11:00:00.000Z",
|
||||
notifySupported: true,
|
||||
} as const;
|
||||
|
||||
function createProps(overrides: Partial<ChannelsProps> = {}): ChannelsProps {
|
||||
return {
|
||||
connected: true,
|
||||
loading: false,
|
||||
snapshot: null,
|
||||
lastError: null,
|
||||
lastSuccessAt: null,
|
||||
pairingLoading: false,
|
||||
pairingSnapshot: {
|
||||
accounts: [
|
||||
{
|
||||
channel: "whatsapp",
|
||||
channelLabel: "WhatsApp",
|
||||
accountId: "personal",
|
||||
accountLabel: "Personal",
|
||||
notifySupported: true,
|
||||
},
|
||||
],
|
||||
requests: [request],
|
||||
commandOwnerConfigured: false,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
},
|
||||
pairingError: null,
|
||||
pairingLastSuccessAt: null,
|
||||
pairingBusyRequestId: null,
|
||||
pairingChannelFilter: null,
|
||||
pairingAccountFilter: null,
|
||||
pairingPrompt: null,
|
||||
pairingNotice: null,
|
||||
canManagePairing: true,
|
||||
canAdmin: true,
|
||||
whatsappMessage: null,
|
||||
whatsappQrDataUrl: null,
|
||||
whatsappConnected: null,
|
||||
whatsappBusy: false,
|
||||
configSchema: null,
|
||||
configSchemaLoading: false,
|
||||
configForm: null,
|
||||
configUiHints: {},
|
||||
configSaving: false,
|
||||
configFormDirty: false,
|
||||
nostrProfileFormState: null,
|
||||
nostrProfileAccountId: null,
|
||||
selectedChannel: null,
|
||||
wizard: { phase: "idle" },
|
||||
wizardMultiselect: [],
|
||||
setupBlockedByDirtyConfig: false,
|
||||
onShowDetail: () => undefined,
|
||||
onCloseDetail: () => undefined,
|
||||
onStartSetup: () => undefined,
|
||||
onWizardAnswer: () => undefined,
|
||||
onWizardToggleMultiselect: () => undefined,
|
||||
onWizardClose: () => undefined,
|
||||
onRefresh: () => undefined,
|
||||
onPairingRefresh: () => undefined,
|
||||
onPairingFilterChange: () => undefined,
|
||||
onPairingReviewAccount: () => undefined,
|
||||
onPairingApprove: () => undefined,
|
||||
onPairingDismiss: () => undefined,
|
||||
onPairingPromptChange: () => undefined,
|
||||
onPairingPromptCancel: () => undefined,
|
||||
onPairingPromptConfirm: () => undefined,
|
||||
onWhatsAppStart: () => undefined,
|
||||
onWhatsAppWait: () => undefined,
|
||||
onWhatsAppLogout: () => undefined,
|
||||
onConfigPatch: () => undefined,
|
||||
onConfigSave: () => undefined,
|
||||
onConfigReload: () => undefined,
|
||||
onNostrProfileEdit: () => undefined,
|
||||
onNostrProfileCancel: () => undefined,
|
||||
onNostrProfileFieldChange: () => undefined,
|
||||
onNostrProfileSave: () => undefined,
|
||||
onNostrProfileImport: () => undefined,
|
||||
onNostrProfileToggleAdvanced: () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderInto(template: unknown): HTMLDivElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(template as never, container);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe("channel DM access request views", () => {
|
||||
it("renders pending senders without exposing the pairing code", () => {
|
||||
const onApprove = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const container = renderInto(
|
||||
renderChannelPairingQueue(
|
||||
createProps({ onPairingApprove: onApprove, onPairingDismiss: onDismiss }),
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("+15551234567");
|
||||
expect(container.textContent).toContain("Alice");
|
||||
expect(container.textContent).not.toContain("SECRET12");
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
buttons.find((button) => button.textContent?.trim() === "Approve")?.click();
|
||||
buttons.find((button) => button.textContent?.trim() === "Dismiss")?.click();
|
||||
expect(onApprove).toHaveBeenCalledWith(request);
|
||||
expect(onDismiss).toHaveBeenCalledWith(request);
|
||||
});
|
||||
|
||||
it("hides cached sender data without pairing access", () => {
|
||||
const container = renderInto(
|
||||
renderChannelPairingQueue(createProps({ canManagePairing: false })),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("operator.pairing access");
|
||||
expect(container.textContent).not.toContain("+15551234567");
|
||||
expect(container.textContent).not.toContain("Alice");
|
||||
});
|
||||
|
||||
it("disables every request action while one mutation is active", () => {
|
||||
const secondRequest = {
|
||||
...request,
|
||||
requestId: "other-request",
|
||||
senderId: "987654321",
|
||||
};
|
||||
const props = createProps({
|
||||
pairingBusyRequestId: request.requestId,
|
||||
pairingSnapshot: {
|
||||
...createProps().pairingSnapshot!,
|
||||
requests: [request, secondRequest],
|
||||
},
|
||||
});
|
||||
const container = renderInto(renderChannelPairingQueue(props));
|
||||
const actionButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).filter((button) => /^(Approve|Dismiss) /u.test(button.getAttribute("aria-label") ?? ""));
|
||||
|
||||
expect(actionButtons).toHaveLength(4);
|
||||
expect(actionButtons.every((button) => button.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows explicit notification and first-owner choices for an admin", () => {
|
||||
const container = renderInto(
|
||||
renderChannelPairingPrompt(
|
||||
createProps({
|
||||
pairingPrompt: {
|
||||
kind: "approve",
|
||||
request,
|
||||
notify: false,
|
||||
bootstrapCommandOwner: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("Notify the requester after approval");
|
||||
expect(container.textContent).toContain("Also make this sender the first command owner");
|
||||
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("links a channel account detail back to the filtered request queue", () => {
|
||||
const review = vi.fn();
|
||||
const container = renderInto(
|
||||
renderChannelPairingDetail("whatsapp", createProps({ onPairingReviewAccount: review })),
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain("1 pending");
|
||||
const button = Array.from(container.querySelectorAll("button")).find(
|
||||
(candidate) => candidate.textContent?.trim() === "Review requests",
|
||||
);
|
||||
button?.click();
|
||||
expect(review).toHaveBeenCalledWith("whatsapp", "personal");
|
||||
});
|
||||
});
|
||||
352
ui/src/pages/channels/view.pairing.ts
Normal file
352
ui/src/pages/channels/view.pairing.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
// DM sender access request queue shared by the Channels hub and detail panels.
|
||||
import { html, nothing } from "lit";
|
||||
import type { ChannelsPairingAccount, ChannelsPairingRequest } from "../../api/types.ts";
|
||||
import "../../components/modal-dialog.ts";
|
||||
import {
|
||||
renderSettingsEmpty,
|
||||
renderSettingsSection,
|
||||
renderSettingsStatus,
|
||||
} from "../../components/settings-ui.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import type { ChannelsProps } from "./view.types.ts";
|
||||
|
||||
function accountName(account: ChannelsPairingAccount): string {
|
||||
return account.accountLabel || account.accountId;
|
||||
}
|
||||
|
||||
function requestAccountName(request: ChannelsPairingRequest): string {
|
||||
return request.accountLabel || request.accountId;
|
||||
}
|
||||
|
||||
function formatRequestTime(value: string): string {
|
||||
const time = Date.parse(value);
|
||||
return Number.isFinite(time) ? formatRelativeTimestamp(time) : value;
|
||||
}
|
||||
|
||||
function selectValue(event: Event): string | null {
|
||||
const value = event.currentTarget instanceof HTMLSelectElement ? event.currentTarget.value : "";
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function filteredAccounts(props: ChannelsProps): ChannelsPairingAccount[] {
|
||||
const accounts = props.pairingSnapshot?.accounts ?? [];
|
||||
return props.pairingChannelFilter
|
||||
? accounts.filter((account) => account.channel === props.pairingChannelFilter)
|
||||
: accounts;
|
||||
}
|
||||
|
||||
function filteredRequests(props: ChannelsProps): ChannelsPairingRequest[] {
|
||||
return (props.pairingSnapshot?.requests ?? []).filter((request) => {
|
||||
if (props.pairingChannelFilter && request.channel !== props.pairingChannelFilter) {
|
||||
return false;
|
||||
}
|
||||
if (props.pairingAccountFilter && request.accountId !== props.pairingAccountFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderFilters(props: ChannelsProps) {
|
||||
const accounts = props.pairingSnapshot?.accounts ?? [];
|
||||
const channels = Array.from(
|
||||
new Map(accounts.map((account) => [account.channel, account.channelLabel])).entries(),
|
||||
).toSorted((left, right) => left[1].localeCompare(right[1]));
|
||||
const accountsForChannel = filteredAccounts(props);
|
||||
return html`
|
||||
<div class="channels-pairing-filters">
|
||||
<label>
|
||||
<span>${t("channels.pairing.channelFilter")}</span>
|
||||
<select
|
||||
.value=${props.pairingChannelFilter ?? ""}
|
||||
@change=${(event: Event) => props.onPairingFilterChange(selectValue(event), null)}
|
||||
>
|
||||
<option value="">${t("channels.pairing.allChannels")}</option>
|
||||
${channels.map(([channel, label]) => html`<option value=${channel}>${label}</option>`)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>${t("channels.pairing.accountFilter")}</span>
|
||||
<select
|
||||
.value=${props.pairingAccountFilter ?? ""}
|
||||
?disabled=${!props.pairingChannelFilter}
|
||||
@change=${(event: Event) =>
|
||||
props.onPairingFilterChange(props.pairingChannelFilter, selectValue(event))}
|
||||
>
|
||||
<option value="">${t("channels.pairing.allAccounts")}</option>
|
||||
${accountsForChannel.map(
|
||||
(account) => html`<option value=${account.accountId}>${accountName(account)}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRequest(request: ChannelsPairingRequest, props: ChannelsProps) {
|
||||
const busy = Boolean(props.pairingBusyRequestId);
|
||||
const thisRequestBusy = props.pairingBusyRequestId === request.requestId;
|
||||
const metadata = Object.entries(request.metadata ?? {});
|
||||
return html`
|
||||
<div class="settings-row settings-row--stacked channels-pairing-request">
|
||||
<div class="channels-pairing-request__main">
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">${request.senderId}</span>
|
||||
<span class="settings-row__desc">
|
||||
${request.senderLabel} · ${request.channelLabel} · ${requestAccountName(request)}
|
||||
(${request.accountId})
|
||||
</span>
|
||||
<span class="settings-row__desc">
|
||||
${t("channels.pairing.requested", { ago: formatRequestTime(request.createdAt) })} ·
|
||||
${t("channels.pairing.expires", { ago: formatRequestTime(request.expiresAt) })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="settings-row__control channels-pairing-request__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${busy || !props.canManagePairing}
|
||||
aria-label=${t("channels.pairing.approveAria", {
|
||||
sender: request.senderId,
|
||||
channel: request.channelLabel,
|
||||
account: requestAccountName(request),
|
||||
})}
|
||||
@click=${() => props.onPairingApprove(request)}
|
||||
>
|
||||
${thisRequestBusy ? t("common.loading") : t("channels.pairing.approve")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
?disabled=${busy || !props.canManagePairing}
|
||||
aria-label=${t("channels.pairing.dismissAria", {
|
||||
sender: request.senderId,
|
||||
channel: request.channelLabel,
|
||||
account: requestAccountName(request),
|
||||
})}
|
||||
@click=${() => props.onPairingDismiss(request)}
|
||||
>
|
||||
${t("channels.pairing.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${metadata.length > 0
|
||||
? html`
|
||||
<details class="channels-pairing-request__details">
|
||||
<summary>${t("channels.pairing.senderDetails")}</summary>
|
||||
<dl class="settings-kv">
|
||||
${metadata.map(
|
||||
([key, value]) =>
|
||||
html`<dt>${key}</dt>
|
||||
<dd>${value}</dd>`,
|
||||
)}
|
||||
</dl>
|
||||
</details>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChannelPairingQueue(props: ChannelsProps) {
|
||||
const snapshot = props.canManagePairing ? props.pairingSnapshot : null;
|
||||
const accounts = snapshot?.accounts ?? [];
|
||||
const requests = props.canManagePairing ? filteredRequests(props) : [];
|
||||
const hasFilter = Boolean(props.pairingChannelFilter || props.pairingAccountFilter);
|
||||
const count = snapshot?.requests.length ?? 0;
|
||||
return html`
|
||||
<div id="channels-pairing-requests">
|
||||
${renderSettingsSection(
|
||||
{
|
||||
title: t("channels.pairing.title"),
|
||||
description: t("channels.pairing.subtitle"),
|
||||
...(count > 0 ? { count } : {}),
|
||||
actions: html`
|
||||
<span class="settings-row__value">
|
||||
${props.canManagePairing && props.pairingLastSuccessAt
|
||||
? t("channels.hub.updatedAgo", {
|
||||
ago: formatRelativeTimestamp(props.pairingLastSuccessAt),
|
||||
})
|
||||
: t("common.na")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
?disabled=${props.pairingLoading || !props.canManagePairing}
|
||||
@click=${props.onPairingRefresh}
|
||||
>
|
||||
${t("common.refresh")}
|
||||
</button>
|
||||
`,
|
||||
},
|
||||
!props.canManagePairing
|
||||
? html`<div class="callout warn">${t("channels.pairing.missingPermission")}</div>`
|
||||
: html`
|
||||
${props.pairingError
|
||||
? html`<div class="callout danger">${props.pairingError}</div>`
|
||||
: nothing}
|
||||
${props.pairingNotice
|
||||
? html`<div class="callout info" role="status">${props.pairingNotice}</div>`
|
||||
: nothing}
|
||||
${snapshot ? renderFilters(props) : nothing}
|
||||
${props.pairingLoading && !snapshot
|
||||
? html`<div class="settings-row">${t("common.loading")}</div>`
|
||||
: accounts.length === 0
|
||||
? renderSettingsEmpty(t("channels.pairing.noAccounts"))
|
||||
: requests.length === 0
|
||||
? renderSettingsEmpty(
|
||||
hasFilter
|
||||
? t("channels.pairing.noFilteredRequests")
|
||||
: t("channels.pairing.noRequests"),
|
||||
)
|
||||
: requests.map((request) => renderRequest(request, props))}
|
||||
${snapshot
|
||||
? html`
|
||||
<div class="channels-pairing-help">
|
||||
${t("channels.pairing.limits", {
|
||||
count: String(snapshot.limits.pendingPerAccount),
|
||||
minutes: String(Math.round(snapshot.limits.ttlMs / 60_000)),
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChannelPairingDetail(channelId: string, props: ChannelsProps) {
|
||||
if (!props.canManagePairing) {
|
||||
return nothing;
|
||||
}
|
||||
const accounts = (props.pairingSnapshot?.accounts ?? []).filter(
|
||||
(account) => account.channel === channelId,
|
||||
);
|
||||
if (accounts.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
const requests = props.pairingSnapshot?.requests ?? [];
|
||||
return renderSettingsSection(
|
||||
{
|
||||
title: t("channels.pairing.detailTitle"),
|
||||
description: t("channels.pairing.detailSubtitle"),
|
||||
},
|
||||
accounts.map((account) => {
|
||||
const pending = requests.filter(
|
||||
(request) => request.channel === account.channel && request.accountId === account.accountId,
|
||||
).length;
|
||||
return html`
|
||||
<div class="settings-row">
|
||||
<div class="settings-row__text">
|
||||
<span class="settings-row__title">${accountName(account)}</span>
|
||||
<span class="settings-row__desc">${account.accountId}</span>
|
||||
</div>
|
||||
<div class="settings-row__control">
|
||||
${renderSettingsStatus({
|
||||
kind: pending > 0 ? "warn" : "muted",
|
||||
label:
|
||||
pending > 0
|
||||
? t("channels.pairing.pendingCount", { count: String(pending) })
|
||||
: t("channels.pairing.noPending"),
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm"
|
||||
@click=${() => props.onPairingReviewAccount(account.channel, account.accountId)}
|
||||
>
|
||||
${t("channels.pairing.review")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function renderChannelPairingPrompt(props: ChannelsProps) {
|
||||
const prompt = props.pairingPrompt;
|
||||
if (!prompt || !props.canManagePairing) {
|
||||
return nothing;
|
||||
}
|
||||
const request = prompt.request;
|
||||
const busy = props.pairingBusyRequestId === request.requestId;
|
||||
const approving = prompt.kind === "approve";
|
||||
const ownerMissing = props.pairingSnapshot?.commandOwnerConfigured === false;
|
||||
const dialogTitle = approving
|
||||
? t("channels.pairing.approveDialogTitle")
|
||||
: t("channels.pairing.dismissDialogTitle");
|
||||
return html`
|
||||
<openclaw-modal-dialog label=${dialogTitle} @modal-cancel=${props.onPairingPromptCancel}>
|
||||
<div class="channels-pairing-dialog">
|
||||
<div class="settings-row__title">${dialogTitle}</div>
|
||||
<div class="settings-row__desc">
|
||||
${request.senderId} · ${request.channelLabel} · ${requestAccountName(request)}
|
||||
(${request.accountId})
|
||||
</div>
|
||||
<div class="callout ${approving ? "info" : "warn"}">
|
||||
${approving
|
||||
? t("channels.pairing.approveExplanation")
|
||||
: t("channels.pairing.dismissExplanation")}
|
||||
</div>
|
||||
${props.pairingError
|
||||
? html`<div class="callout danger" role="alert">${props.pairingError}</div>`
|
||||
: nothing}
|
||||
${approving && request.notifySupported
|
||||
? html`
|
||||
<label class="channels-pairing-dialog__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${prompt.notify}
|
||||
@change=${(event: Event) =>
|
||||
props.onPairingPromptChange({
|
||||
notify:
|
||||
event.currentTarget instanceof HTMLInputElement
|
||||
? event.currentTarget.checked
|
||||
: false,
|
||||
})}
|
||||
/>
|
||||
<span>${t("channels.pairing.notifyRequester")}</span>
|
||||
</label>
|
||||
`
|
||||
: nothing}
|
||||
${approving && ownerMissing && props.canAdmin
|
||||
? html`
|
||||
<label class="channels-pairing-dialog__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${prompt.bootstrapCommandOwner}
|
||||
@change=${(event: Event) =>
|
||||
props.onPairingPromptChange({
|
||||
bootstrapCommandOwner:
|
||||
event.currentTarget instanceof HTMLInputElement
|
||||
? event.currentTarget.checked
|
||||
: false,
|
||||
})}
|
||||
/>
|
||||
<span>${t("channels.pairing.makeCommandOwner")}</span>
|
||||
</label>
|
||||
<div class="settings-row__desc">${t("channels.pairing.commandOwnerHelp")}</div>
|
||||
`
|
||||
: nothing}
|
||||
${approving && ownerMissing && !props.canAdmin
|
||||
? html`<div class="callout warn">${t("channels.pairing.commandOwnerNeedsAdmin")}</div>`
|
||||
: nothing}
|
||||
<div class="channels-pairing-dialog__actions">
|
||||
<button
|
||||
type="button"
|
||||
class=${approving ? "btn primary" : "btn danger"}
|
||||
?disabled=${busy}
|
||||
@click=${props.onPairingPromptConfirm}
|
||||
>
|
||||
${approving ? t("channels.pairing.approve") : t("channels.pairing.dismiss")}
|
||||
</button>
|
||||
<button type="button" class="btn" ?disabled=${busy} @click=${props.onPairingPromptCancel}>
|
||||
${t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
`;
|
||||
}
|
||||
@@ -17,6 +17,22 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
|
||||
snapshot,
|
||||
lastError: null,
|
||||
lastSuccessAt: null,
|
||||
pairingLoading: false,
|
||||
pairingSnapshot: {
|
||||
accounts: [],
|
||||
requests: [],
|
||||
commandOwnerConfigured: true,
|
||||
limits: { pendingPerAccount: 3, ttlMs: 3_600_000 },
|
||||
},
|
||||
pairingError: null,
|
||||
pairingLastSuccessAt: null,
|
||||
pairingBusyRequestId: null,
|
||||
pairingChannelFilter: null,
|
||||
pairingAccountFilter: null,
|
||||
pairingPrompt: null,
|
||||
pairingNotice: null,
|
||||
canManagePairing: true,
|
||||
canAdmin: true,
|
||||
whatsappMessage: null,
|
||||
whatsappQrDataUrl: null,
|
||||
whatsappConnected: null,
|
||||
@@ -40,6 +56,14 @@ function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps {
|
||||
onWizardToggleMultiselect: () => {},
|
||||
onWizardClose: () => {},
|
||||
onRefresh: () => {},
|
||||
onPairingRefresh: () => {},
|
||||
onPairingFilterChange: () => {},
|
||||
onPairingReviewAccount: () => {},
|
||||
onPairingApprove: () => {},
|
||||
onPairingDismiss: () => {},
|
||||
onPairingPromptChange: () => {},
|
||||
onPairingPromptCancel: () => {},
|
||||
onPairingPromptConfirm: () => {},
|
||||
onWhatsAppStart: () => {},
|
||||
onWhatsAppWait: () => {},
|
||||
onWhatsAppLogout: () => {},
|
||||
|
||||
@@ -29,6 +29,7 @@ import { t } from "../../i18n/index.ts";
|
||||
import { formatRelativeTimestamp } from "../../lib/format.ts";
|
||||
import { renderChannelArt } from "./hub-meta.ts";
|
||||
import { renderChannelDetail } from "./view.detail.ts";
|
||||
import { renderChannelPairingPrompt, renderChannelPairingQueue } from "./view.pairing.ts";
|
||||
import { channelEnabled, resolveChannelDisplayState } from "./view.shared.ts";
|
||||
import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts";
|
||||
import { renderChannelWizard } from "./wizard-view.ts";
|
||||
@@ -61,6 +62,7 @@ export function renderChannels(props: ChannelsProps) {
|
||||
${props.setupBlockedByDirtyConfig && props.configFormDirty
|
||||
? html`<div class="callout warn">${t("channels.hub.saveBeforeSetup")}</div>`
|
||||
: nothing}
|
||||
${renderChannelPairingQueue(props)}
|
||||
${renderSettingsSection(
|
||||
{
|
||||
title: t("channels.hub.connectedTitle"),
|
||||
@@ -141,6 +143,7 @@ ${props.snapshot
|
||||
onWhatsAppStart: props.onWhatsAppStart,
|
||||
onWhatsAppWait: props.onWhatsAppWait,
|
||||
})}
|
||||
${renderChannelPairingPrompt(props)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Channels page view contracts.
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelsPairingListResult,
|
||||
ChannelsPairingRequest,
|
||||
ChannelsStatusSnapshot,
|
||||
ConfigUiHints,
|
||||
DiscordStatus,
|
||||
@@ -18,12 +20,30 @@ import type { ChannelWizardState } from "./wizard-controller.ts";
|
||||
|
||||
export type ChannelKey = string;
|
||||
|
||||
export type ChannelPairingPrompt = {
|
||||
kind: "approve" | "dismiss";
|
||||
request: ChannelsPairingRequest;
|
||||
notify: boolean;
|
||||
bootstrapCommandOwner: boolean;
|
||||
};
|
||||
|
||||
export type ChannelsProps = {
|
||||
connected: boolean;
|
||||
loading: boolean;
|
||||
snapshot: ChannelsStatusSnapshot | null;
|
||||
lastError: string | null;
|
||||
lastSuccessAt: number | null;
|
||||
pairingLoading: boolean;
|
||||
pairingSnapshot: ChannelsPairingListResult | null;
|
||||
pairingError: string | null;
|
||||
pairingLastSuccessAt: number | null;
|
||||
pairingBusyRequestId: string | null;
|
||||
pairingChannelFilter: string | null;
|
||||
pairingAccountFilter: string | null;
|
||||
pairingPrompt: ChannelPairingPrompt | null;
|
||||
pairingNotice: string | null;
|
||||
canManagePairing: boolean;
|
||||
canAdmin: boolean;
|
||||
whatsappMessage: string | null;
|
||||
whatsappQrDataUrl: string | null;
|
||||
whatsappConnected: boolean | null;
|
||||
@@ -47,6 +67,16 @@ export type ChannelsProps = {
|
||||
onWizardToggleMultiselect: (value: unknown) => void;
|
||||
onWizardClose: () => void;
|
||||
onRefresh: (probe: boolean) => void;
|
||||
onPairingRefresh: () => void;
|
||||
onPairingFilterChange: (channel: string | null, accountId: string | null) => void;
|
||||
onPairingReviewAccount: (channel: string, accountId: string) => void;
|
||||
onPairingApprove: (request: ChannelsPairingRequest) => void;
|
||||
onPairingDismiss: (request: ChannelsPairingRequest) => void;
|
||||
onPairingPromptChange: (
|
||||
patch: Partial<Pick<ChannelPairingPrompt, "notify" | "bootstrapCommandOwner">>,
|
||||
) => void;
|
||||
onPairingPromptCancel: () => void;
|
||||
onPairingPromptConfirm: () => void;
|
||||
onWhatsAppStart: (force: boolean) => void;
|
||||
onWhatsAppWait: () => void;
|
||||
onWhatsAppLogout: () => void;
|
||||
|
||||
@@ -114,31 +114,32 @@ describe("chat pane presentation teardown", () => {
|
||||
window.localStorage.removeItem(SKIP_REWIND_CONFIRM_PREFERENCE);
|
||||
const owner = createConfirmationOwner();
|
||||
|
||||
for (const callback of frameCallbacks.splice(0)) {
|
||||
callback(0);
|
||||
}
|
||||
const captureClickListener = addDocumentListener.mock.calls.find(
|
||||
([type, listener, options]) => type === "click" && options === true && listener,
|
||||
)?.[1];
|
||||
const captureKeydownListener = addWindowListener.mock.calls.find(
|
||||
([type, listener, options]) => type === "keydown" && options === true && listener,
|
||||
)?.[1];
|
||||
expect(captureClickListener).toBeDefined();
|
||||
expect(captureKeydownListener).toBeDefined();
|
||||
pane.appendChild(owner);
|
||||
try {
|
||||
for (const callback of frameCallbacks.splice(0)) {
|
||||
callback(0);
|
||||
}
|
||||
const captureClickListener = addDocumentListener.mock.calls.find(
|
||||
([type, listener, options]) => type === "click" && options === true && listener,
|
||||
)?.[1];
|
||||
const captureKeydownListener = addWindowListener.mock.calls.find(
|
||||
([type, listener, options]) => type === "keydown" && options === true && listener,
|
||||
)?.[1];
|
||||
expect(captureClickListener).toBeDefined();
|
||||
expect(captureKeydownListener).toBeDefined();
|
||||
pane.appendChild(owner);
|
||||
|
||||
const resetPresentation = chatThread.resetChatThreadPresentationState;
|
||||
const stopAfterReset = new Error("stop after thread presentation reset");
|
||||
vi.spyOn(chatThread, "resetChatThreadPresentationState").mockImplementation(
|
||||
(paneId, presentationOwner) => {
|
||||
resetPresentation(paneId, presentationOwner);
|
||||
const stopAfterReset = new Error("stop after thread presentation reset");
|
||||
vi.spyOn(pane, "cancelHeaderRename").mockImplementation(() => {
|
||||
throw stopAfterReset;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(() => pane.switchPaneSession("agent:main:next")).toThrow(stopAfterReset);
|
||||
expect(owner.querySelector(".chat-delete-confirm")).toBeNull();
|
||||
expect(removeDocumentListener).toHaveBeenCalledWith("click", captureClickListener, true);
|
||||
expect(removeWindowListener).toHaveBeenCalledWith("keydown", captureKeydownListener, true);
|
||||
expect(() => pane.switchPaneSession("agent:main:next")).toThrow(stopAfterReset);
|
||||
expect(owner.querySelector(".chat-delete-confirm")).toBeNull();
|
||||
expect(removeDocumentListener).toHaveBeenCalledWith("click", captureClickListener, true);
|
||||
expect(removeWindowListener).toHaveBeenCalledWith("keydown", captureKeydownListener, true);
|
||||
} finally {
|
||||
dismissConfirmedActionPopovers(owner);
|
||||
owner.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,12 +181,8 @@ import {
|
||||
saveRouteSessionSettings,
|
||||
type ChatPageHost,
|
||||
} from "./chat-state.ts";
|
||||
import {
|
||||
renderChat,
|
||||
renderChatResizableDivider,
|
||||
resetChatViewState,
|
||||
type ChatProps,
|
||||
} from "./chat-view.ts";
|
||||
import { resetChatViewState } from "./chat-view-state.ts";
|
||||
import { renderChat, renderChatResizableDivider, type ChatProps } from "./chat-view.ts";
|
||||
import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts";
|
||||
import { chatAttachmentFromDataUrl } from "./components/chat-attachments.ts";
|
||||
import {
|
||||
@@ -196,6 +192,7 @@ import {
|
||||
} from "./components/chat-background-tasks.ts";
|
||||
import { isChatRunWorking } from "./components/chat-composer.ts";
|
||||
import { renderChatControls } from "./components/chat-controls.ts";
|
||||
import { dismissConfirmedActionPopovers } from "./components/chat-message.ts";
|
||||
import {
|
||||
canRevealSessionWorkspace,
|
||||
renderChatPaneHeader,
|
||||
@@ -930,9 +927,10 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
// Close old-session portals and listener-owning popovers before the next
|
||||
// render detaches their DOM and makes owner-scoped cleanup impossible.
|
||||
resetChatThreadPresentationState(this.paneId, this);
|
||||
// Close old-session listener owners before the next render detaches their
|
||||
// DOM; thread-global portals and caches are reset separately.
|
||||
dismissConfirmedActionPopovers(this);
|
||||
resetChatThreadPresentationState(this.paneId);
|
||||
this.sessionDiscussionOpenUrls.clear();
|
||||
const previousSessionKey = state.sessionKey;
|
||||
// An in-progress title edit belongs to the previous session; committing
|
||||
@@ -2490,7 +2488,8 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
this.headerWorktreePaths.clear();
|
||||
this.headerBranches.clear();
|
||||
this.announceCommandPaletteTarget(null);
|
||||
resetChatViewState(this.paneId, this);
|
||||
dismissConfirmedActionPopovers(this);
|
||||
resetChatViewState(this.paneId);
|
||||
this.state = undefined;
|
||||
this.connectedClient = null;
|
||||
disposeQuestionPromptState(this.questionPromptState);
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
} from "./attachment-payload-store.ts";
|
||||
import { switchChatFastMode, switchChatModel, switchChatThinkingLevel } from "./chat-session.ts";
|
||||
import * as chatThread from "./chat-thread.ts";
|
||||
import { renderChat, resetChatViewState } from "./chat-view.ts";
|
||||
import { resetChatViewState } from "./chat-view-state.ts";
|
||||
import { renderChat } from "./chat-view.ts";
|
||||
import { resetChatComposerState } from "./components/chat-composer.ts";
|
||||
import * as chatMessage from "./components/chat-message.ts";
|
||||
import {
|
||||
|
||||
@@ -73,8 +73,6 @@ import type { CompactionStatus, FallbackStatus, PlanStatus } from "./tool-stream
|
||||
import type { WorkspaceResultConflict } from "./workspace-conflict.ts";
|
||||
import "../../components/resizable-divider.ts";
|
||||
|
||||
export { resetChatViewState } from "./chat-view-state.ts";
|
||||
|
||||
type ChatReplyTarget = {
|
||||
messageId: string;
|
||||
text: string;
|
||||
|
||||
@@ -117,6 +117,107 @@
|
||||
padding: var(--space-4) var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
/* ---------- DM sender access requests ---------- */
|
||||
|
||||
.channels-pairing-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.channels-pairing-filters label {
|
||||
display: grid;
|
||||
flex: 1 1 180px;
|
||||
gap: var(--space-1);
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
}
|
||||
|
||||
.channels-pairing-filters select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channels-pairing-request {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.channels-pairing-request__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channels-pairing-request__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.channels-pairing-request__details {
|
||||
width: 100%;
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-sm);
|
||||
}
|
||||
|
||||
.channels-pairing-request__details summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channels-pairing-request__details .settings-kv {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.channels-pairing-help {
|
||||
padding: var(--space-3);
|
||||
color: var(--muted);
|
||||
font-size: var(--control-ui-text-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.channels-pairing-dialog {
|
||||
display: grid;
|
||||
width: min(520px, calc(100vw - 48px));
|
||||
gap: var(--space-3);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-4);
|
||||
background: var(--popover);
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.channels-pairing-dialog__option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channels-pairing-dialog__option input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.channels-pairing-dialog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.channels-pairing-request__main {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.channels-pairing-request__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- setup wizard dialog ---------- */
|
||||
|
||||
.channels-wizard {
|
||||
|
||||
Reference in New Issue
Block a user