mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 13:34:49 +00:00
refactor: centralize channel ingress access
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
f303fc2fea17115736f9b7565ab2b6cfa07cfb46e5b3c804c55a945922f749db plugin-sdk-api-baseline.json
|
||||
90a4a9538691ee1af4d1fcf10b3b0c1d161e835c03e87b09dc0ff0020d1ded7a plugin-sdk-api-baseline.jsonl
|
||||
d0bbcee28603940444071492f65e293e708d2b437bdc6f0fb492fc41a0920760 plugin-sdk-api-baseline.json
|
||||
f6a73cf469f279671ab00ff5a0e95eb0ff89d8a8b9652cf1ff9cd983aa69093d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -59,6 +59,22 @@
|
||||
"source": "Channel message API",
|
||||
"target": "频道消息 API"
|
||||
},
|
||||
{
|
||||
"source": "Channel ingress API",
|
||||
"target": "频道入口 API"
|
||||
},
|
||||
{
|
||||
"source": "Channel access cleanup",
|
||||
"target": "频道访问清理"
|
||||
},
|
||||
{
|
||||
"source": "Ingress core shrink plan",
|
||||
"target": "入口核心精简计划"
|
||||
},
|
||||
{
|
||||
"source": "Ingress core shrink",
|
||||
"target": "入口核心精简"
|
||||
},
|
||||
{
|
||||
"source": "Talk mode",
|
||||
"target": "Talk 模式"
|
||||
|
||||
@@ -125,7 +125,26 @@ Access groups are available in shared message-channel authorization paths, inclu
|
||||
- channel-specific per-room sender allowlists that use the same sender matching rules
|
||||
- command authorization paths that reuse message-channel sender allowlists
|
||||
|
||||
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes Discord, Google Chat, Nostr, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion.
|
||||
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes BlueBubbles, Discord, Feishu, Google Chat, iMessage, LINE, Mattermost, Microsoft Teams, Nextcloud Talk, Nostr, QQBot, Signal, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are designed to be channel-agnostic, so new message channels should support them by using the shared plugin SDK helpers instead of custom allowlist expansion.
|
||||
|
||||
## Plugin diagnostics
|
||||
|
||||
Plugin authors can inspect structured access-group state without expanding it back into a flat allowlist:
|
||||
|
||||
```typescript
|
||||
import { resolveAccessGroupAllowFromState } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
const state = await resolveAccessGroupAllowFromState({
|
||||
accessGroups: cfg.accessGroups,
|
||||
allowFrom: channelConfig.allowFrom,
|
||||
channel: "my-channel",
|
||||
accountId: "default",
|
||||
senderId,
|
||||
isSenderAllowed,
|
||||
});
|
||||
```
|
||||
|
||||
The result reports referenced, matched, missing, unsupported, and failed groups. Use this when you need diagnostics or conformance tests. Use `expandAllowFromWithAccessGroups(...)` only for compatibility paths that still expect a flat `allowFrom` array.
|
||||
|
||||
## Discord channel audiences
|
||||
|
||||
|
||||
@@ -451,8 +451,8 @@ Example:
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="DM access groups">
|
||||
Discord DMs can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
|
||||
<Tab title="Access groups">
|
||||
Discord DMs and text command authorization can use dynamic `accessGroup:<name>` entries in `channels.discord.allowFrom`.
|
||||
|
||||
Access group names are shared across message channels. Use `type: "message.senders"` for a static group whose members are expressed in each channel's normal `allowFrom` syntax, or `type: "discord.channelAudience"` when a Discord channel's current `ViewChannel` audience should define membership dynamically. Shared access-group behavior is documented here: [Access groups](/channels/access-groups).
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
Allowlist field: `channels.imessage.allowFrom`.
|
||||
|
||||
Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
|
||||
Allowlist entries can be handles, static sender access groups (`accessGroup:<name>`), or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -230,6 +230,8 @@ If SIP-disabled isn't acceptable for your threat model:
|
||||
|
||||
Group sender allowlist: `channels.imessage.groupAllowFrom`.
|
||||
|
||||
`groupAllowFrom` entries can also reference static sender access groups (`accessGroup:<name>`).
|
||||
|
||||
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
|
||||
Runtime note: if `channels.imessage` is completely missing, runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ Allowlists and policies:
|
||||
- `channels.line.groupPolicy`: `allowlist | open | disabled`
|
||||
- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups
|
||||
- Per-group overrides: `channels.line.groups.<groupId>.allowFrom`
|
||||
- Static sender access groups can be referenced from `allowFrom`, `groupAllowFrom`, and per-group `allowFrom` with `accessGroup:<name>`.
|
||||
- Runtime note: if `channels.line` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
LINE IDs are case-sensitive. Valid IDs look like:
|
||||
|
||||
@@ -189,11 +189,13 @@ Notes:
|
||||
- `openclaw pairing list mattermost`
|
||||
- `openclaw pairing approve mattermost <CODE>`
|
||||
- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
|
||||
- `channels.mattermost.allowFrom` accepts `accessGroup:<name>` entries. See [Access groups](/channels/access-groups).
|
||||
|
||||
## Channels (groups)
|
||||
|
||||
- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs recommended).
|
||||
- `channels.mattermost.groupAllowFrom` accepts `accessGroup:<name>` entries. See [Access groups](/channels/access-groups).
|
||||
- Per-channel mention overrides live under `channels.mattermost.groups.<channelId>.requireMention` or `channels.mattermost.groups["*"].requireMention` for a default.
|
||||
- `@username` matching is mutable and only enabled when `channels.mattermost.dangerouslyAllowNameMatching: true`.
|
||||
- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
|
||||
|
||||
@@ -146,14 +146,14 @@ Disable with:
|
||||
**DM access**
|
||||
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` should use stable AAD object IDs.
|
||||
- `channels.msteams.allowFrom` should use stable AAD object IDs or static sender access groups such as `accessGroup:core-team`.
|
||||
- Do not rely on UPN/display-name matching for allowlists - they can change. OpenClaw disables direct name matching by default; opt in explicitly with `channels.msteams.dangerouslyAllowNameMatching: true`.
|
||||
- The wizard can resolve names to IDs via Microsoft Graph when credentials allow.
|
||||
|
||||
**Group access**
|
||||
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||
- `channels.msteams.groupAllowFrom` controls which senders or static sender access groups can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||
- Set `groupPolicy: "open"` to allow any member (still mention-gated by default).
|
||||
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
||||
|
||||
@@ -164,7 +164,7 @@ Example:
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["user@org.com"],
|
||||
groupAllowFrom: ["00000000-0000-0000-0000-000000000000", "accessGroup:core-team"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ Provider options:
|
||||
- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`.
|
||||
- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs).
|
||||
- `channels.nextcloud-talk.rooms`: per-room settings and allowlist.
|
||||
- Static sender access groups can be referenced from `allowFrom` and `groupAllowFrom` with `accessGroup:<name>`.
|
||||
- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
|
||||
- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit).
|
||||
|
||||
@@ -44,7 +44,14 @@ Account keys:
|
||||
- `botUserId` - Matrix-style bot user id used in target grammar.
|
||||
- `botDisplayName` - display name for outbound messages.
|
||||
- `pollTimeoutMs` - long-poll wait window. Integer between 100 and 30000.
|
||||
- `allowFrom` - sender allowlist (user ids or `"*"`).
|
||||
- `allowFrom` - sender allowlist (user ids or `"*"`). Direct messages and
|
||||
allowlisted group policy both use these synthetic sender ids.
|
||||
- `groupPolicy` - shared-room policy: `"open"` (default), `"allowlist"`, or
|
||||
`"disabled"`.
|
||||
- `groupAllowFrom` - optional shared-room sender allowlist. When omitted under
|
||||
`"allowlist"`, QA Channel falls back to `allowFrom`.
|
||||
- `groups.<room>.requireMention` - require a bot mention before replying in a
|
||||
specific group/channel room. `groups."*"` sets the default.
|
||||
- `defaultTo` - fallback target when none is supplied.
|
||||
- `actions.messages` / `actions.reactions` / `actions.search` / `actions.threads` - per-action tool gating.
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ openclaw directory groups list --channel zalouser --query "work"
|
||||
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
|
||||
`channels.zalouser.allowFrom` should use stable Zalo user IDs. During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup.
|
||||
`channels.zalouser.allowFrom` should use stable Zalo user IDs. It can also reference static sender access groups (`accessGroup:<name>`). During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup.
|
||||
|
||||
If a raw name remains in config, startup resolves it only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. Without that opt-in, runtime sender checks are ID-only and raw names are ignored for authorization.
|
||||
|
||||
@@ -96,7 +96,7 @@ Approve via:
|
||||
- Restrict to an allowlist with:
|
||||
- `channels.zalouser.groupPolicy = "allowlist"`
|
||||
- `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled)
|
||||
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
|
||||
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot; static sender access groups can be referenced with `accessGroup:<name>`)
|
||||
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
||||
- The configure wizard can prompt for group allowlists.
|
||||
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping only when `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled.
|
||||
|
||||
137
docs/plugins/sdk-channel-ingress.md
Normal file
137
docs/plugins/sdk-channel-ingress.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
summary: "Experimental channel ingress API for inbound message authorization"
|
||||
read_when:
|
||||
- Building or migrating a messaging channel plugin
|
||||
- Changing DM or group allowlists, route gates, command auth, event auth, or mention activation
|
||||
- Reviewing channel ingress redaction or SDK compatibility boundaries
|
||||
title: "Channel ingress API"
|
||||
sidebarTitle: "Channel Ingress"
|
||||
---
|
||||
|
||||
# Channel ingress API
|
||||
|
||||
Channel ingress is the experimental access-control boundary for inbound channel
|
||||
events. Use `openclaw/plugin-sdk/channel-ingress-runtime` for receive paths.
|
||||
The older `openclaw/plugin-sdk/channel-ingress` subpath stays exported as a
|
||||
deprecated compatibility facade for third-party plugins.
|
||||
|
||||
Plugins own platform facts and side effects. Core owns generic policy: DM/group
|
||||
allowlists, pairing-store DM entries, route gates, command gates, event auth,
|
||||
mention activation, redacted diagnostics, and admission.
|
||||
|
||||
## Runtime Resolver
|
||||
|
||||
```ts
|
||||
import {
|
||||
defineStableChannelIngressIdentity,
|
||||
resolveChannelMessageIngress,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
|
||||
const identity = defineStableChannelIngressIdentity({
|
||||
key: "platform-user-id",
|
||||
normalize: normalizePlatformUserId,
|
||||
sensitivity: "pii",
|
||||
});
|
||||
|
||||
const result = await resolveChannelMessageIngress({
|
||||
channelId: "my-channel",
|
||||
accountId,
|
||||
identity,
|
||||
subject: { stableId: platformUserId },
|
||||
conversation: { kind: isGroup ? "group" : "direct", id: conversationId },
|
||||
event: { kind: "message", authMode: "inbound", mayPair: !isGroup },
|
||||
policy: {
|
||||
dmPolicy: config.dmPolicy,
|
||||
groupPolicy: config.groupPolicy,
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
},
|
||||
allowFrom: config.allowFrom,
|
||||
groupAllowFrom: config.groupAllowFrom,
|
||||
accessGroups: cfg.accessGroups,
|
||||
route,
|
||||
readStoreAllowFrom,
|
||||
command: hasControlCommand ? { allowTextCommands: true, hasControlCommand } : undefined,
|
||||
});
|
||||
```
|
||||
|
||||
Do not precompute effective allowlists, command owners, or command groups. The
|
||||
resolver derives them from raw allowlists, store callbacks, route descriptors,
|
||||
access groups, policy, and conversation kind.
|
||||
|
||||
## Result
|
||||
|
||||
Bundled plugins should consume modern projections directly:
|
||||
|
||||
- `ingress`: ordered gate decision and admission
|
||||
- `senderAccess`: sender/conversation authorization only
|
||||
- `routeAccess`: route and route-sender projection
|
||||
- `commandAccess`: command authorization; false when no command gate ran
|
||||
- `activationAccess`: mention/activation result
|
||||
|
||||
Event authorization remains available on the ordered `ingress.graph` and the
|
||||
decisive `ingress.reasonCode`; no separate event projection is emitted.
|
||||
|
||||
Deprecated third-party SDK helpers may rebuild older shapes internally. New
|
||||
bundled receive paths should not translate modern results back into local DTOs.
|
||||
|
||||
## Access Groups
|
||||
|
||||
`accessGroup:<name>` entries stay redacted. Core resolves static
|
||||
`message.senders` groups itself and calls `resolveAccessGroupMembership` only
|
||||
for dynamic groups that require a platform lookup. Missing, unsupported, and
|
||||
failed groups fail closed.
|
||||
|
||||
## Event Modes
|
||||
|
||||
| `authMode` | Meaning |
|
||||
| ---------------- | ------------------------------------------------ |
|
||||
| `inbound` | normal inbound sender gates |
|
||||
| `command` | command gates for callbacks or scoped buttons |
|
||||
| `origin-subject` | actor must match the original message subject |
|
||||
| `route-only` | route gates only for route-scoped trusted events |
|
||||
| `none` | plugin-owned internal events bypass shared auth |
|
||||
|
||||
Use `mayPair: false` for reactions, buttons, callbacks, and native commands.
|
||||
|
||||
## Routes And Activation
|
||||
|
||||
Use route descriptors for room, topic, guild, thread, or nested route policy:
|
||||
|
||||
```ts
|
||||
route: {
|
||||
id: "room",
|
||||
allowed: roomAllowed,
|
||||
enabled: roomEnabled,
|
||||
senderPolicy: "replace",
|
||||
senderAllowFrom: roomAllowFrom,
|
||||
blockReason: "room_sender_not_allowlisted",
|
||||
}
|
||||
```
|
||||
|
||||
Use `channelIngressRoutes(...)` when a plugin has several optional route
|
||||
descriptors; it filters disabled branches while keeping route facts generic and
|
||||
ordered by each descriptor's `precedence`.
|
||||
|
||||
Mention gating is an activation gate. A mention miss returns
|
||||
`admission: "skip"` so the turn kernel does not process an observe-only turn.
|
||||
Most channels should leave activation after sender and command gates. Public
|
||||
chat surfaces that must quiet non-mentioned traffic before sender allowlist
|
||||
noise can opt into `activation.order: "before-sender"` when text-command
|
||||
bypass is disabled. Channels with implicit activation, such as replies in bot
|
||||
threads, can pass `activation.allowedImplicitMentionKinds`; the projected
|
||||
`activationAccess.shouldBypassMention` then reports when command or implicit
|
||||
activation bypassed an explicit mention.
|
||||
|
||||
## Redaction
|
||||
|
||||
Raw sender values and raw allowlist entries are resolver input only. They must
|
||||
not appear in resolved state, decisions, diagnostics, snapshots, or
|
||||
compatibility facts. Use opaque subject ids, entry ids, route ids, and
|
||||
diagnostic ids.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts
|
||||
pnpm plugin-sdk:api:check
|
||||
```
|
||||
@@ -74,6 +74,16 @@ remain available for compatibility dispatchers. Do not use those names for new
|
||||
channel code; new plugins should start with the `message` adapter, receipts, and
|
||||
receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`.
|
||||
|
||||
Channels migrating inbound authorization can use the experimental
|
||||
`openclaw/plugin-sdk/channel-ingress-runtime` subpath from runtime receive
|
||||
paths. The subpath keeps platform lookup and side effects in the plugin, while
|
||||
sharing allowlist state resolution, route/sender/command/event/activation
|
||||
decisions, redacted diagnostics, and turn-admission mapping. Keep plugin
|
||||
identity normalization in the descriptor you pass to the resolver; do not
|
||||
serialize raw match values from the resolved state or decision. See
|
||||
[Channel ingress API](/plugins/sdk-channel-ingress) for the API design,
|
||||
ownership boundary, and test expectations.
|
||||
|
||||
If your channel supports typing indicators outside inbound replies, expose
|
||||
`heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the
|
||||
resolved heartbeat delivery target before the heartbeat model run starts and
|
||||
|
||||
@@ -64,6 +64,7 @@ The runtime exposes three preferred entry points so adapters can opt in at the l
|
||||
|
||||
```typescript
|
||||
runtime.channel.turn.run(...) // adapter-driven full pipeline
|
||||
runtime.channel.turn.runAssembled(...) // already-built context + delivery adapter
|
||||
runtime.channel.turn.runPrepared(...) // channel owns dispatch; kernel runs record + finalize
|
||||
runtime.channel.turn.buildContext(...) // pure facts to FinalizedMsgContext mapping
|
||||
```
|
||||
@@ -72,7 +73,7 @@ Two older runtime helpers remain available for Plugin SDK compatibility:
|
||||
|
||||
```typescript
|
||||
runtime.channel.turn.runResolved(...) // deprecated compatibility alias; prefer run
|
||||
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer run or runPrepared
|
||||
runtime.channel.turn.dispatchAssembled(...) // deprecated compatibility alias; prefer runAssembled
|
||||
```
|
||||
|
||||
### run
|
||||
@@ -114,6 +115,41 @@ await runtime.channel.turn.run({
|
||||
|
||||
`run` is the right shape when the channel has small adapter logic and benefits from owning the lifecycle through hooks.
|
||||
|
||||
### runAssembled
|
||||
|
||||
Use when the channel has already resolved routing, built a `FinalizedMsgContext`,
|
||||
and only needs the shared record, reply-pipeline, dispatch, and finalize
|
||||
ordering. This is the preferred shape for simple bundled inbound paths that
|
||||
would otherwise repeat `createChannelMessageReplyPipeline(...)` and
|
||||
`runPrepared(...)` boilerplate.
|
||||
|
||||
```typescript
|
||||
await runtime.channel.turn.runAssembled({
|
||||
cfg,
|
||||
channel: "irc",
|
||||
accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: runtime.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
await sendPlatformReply(payload);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`reply ${info.kind} failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Choose `runAssembled` over `runPrepared` when the only channel-owned dispatch
|
||||
behavior is final payload delivery plus optional typing, reply options, durable
|
||||
delivery, or error logging.
|
||||
|
||||
### runPrepared
|
||||
|
||||
Use when the channel has a complex local dispatcher with previews, retries, edits, or thread bootstrap that must stay channel-owned. The kernel still records the inbound session before dispatch and surfaces a uniform `DispatchedChannelTurnResult`.
|
||||
|
||||
@@ -56,6 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/account-id` | `DEFAULT_ACCOUNT_ID`, account-id normalization helpers |
|
||||
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
|
||||
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
|
||||
| `plugin-sdk/access-groups` | Access-group allowlist parsing and redacted group diagnostics helpers |
|
||||
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
|
||||
| `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. |
|
||||
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
|
||||
@@ -65,6 +66,8 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
|
||||
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
|
||||
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
|
||||
| `plugin-sdk/channel-ingress` | Deprecated low-level channel ingress compatibility facade. New receive paths should use `plugin-sdk/channel-ingress-runtime`. |
|
||||
| `plugin-sdk/channel-ingress-runtime` | Experimental high-level channel ingress runtime resolver and route fact builders for migrated channel receive paths. Prefer this over assembling effective allowlists, command allowlists, and legacy projections in each plugin. See [Channel ingress API](/plugins/sdk-channel-ingress). |
|
||||
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. |
|
||||
| `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createChannelMessageReplyPipeline`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy reply-dispatch facades are deprecated compatibility only. |
|
||||
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, and `withDurableMessageSendContext`. Deprecated reply-dispatch bridges remain importable for compatibility dispatchers only. Use from monitor/send runtime modules, not hot plugin bootstrap files. |
|
||||
|
||||
9
docs/refactor/access.md
Normal file
9
docs/refactor/access.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
summary: "Redirect to /refactor/ingress-core"
|
||||
read_when:
|
||||
- Changing channel ingress, sender access, command authorization, or access-group handling
|
||||
title: "Channel access cleanup"
|
||||
sidebarTitle: "Channel access cleanup"
|
||||
---
|
||||
|
||||
The channel access cleanup plan now lives in [Ingress core deletion plan](/refactor/ingress-core).
|
||||
341
docs/refactor/ingress-core.md
Normal file
341
docs/refactor/ingress-core.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
summary: "Deletion-first plan for moving repeated channel ingress glue into core."
|
||||
read_when:
|
||||
- Auditing why the channel ingress refactor added too much code
|
||||
- Moving route, command, event, activation, or access-group policy from bundled plugins into core
|
||||
- Reviewing whether a channel ingress helper actually deletes bundled plugin code
|
||||
title: "Ingress core deletion plan"
|
||||
sidebarTitle: "Ingress core deletion"
|
||||
---
|
||||
|
||||
# Ingress core deletion plan
|
||||
|
||||
The ingress refactor is not healthy while it adds thousands of net lines. Core
|
||||
centralization only counts when bundled plugin production code gets smaller and
|
||||
old third-party SDK compatibility is quarantined to SDK/core shims.
|
||||
|
||||
Desired runtime shape:
|
||||
|
||||
```text
|
||||
bundled plugin event
|
||||
-> extract platform facts locally
|
||||
-> resolve shared ingress once when facts are available
|
||||
-> branch on generic ingress projections/outcomes
|
||||
-> perform platform side effects locally
|
||||
|
||||
old third-party helper
|
||||
-> SDK compatibility shim
|
||||
-> shared ingress-compatible projection where possible
|
||||
-> old return shape preserved
|
||||
```
|
||||
|
||||
Bundled plugins should not translate ingress back into local `AccessResult`,
|
||||
`GroupAccessDecision`, `CommandAuthDecision`, `DmCommandAccess`, or
|
||||
`{ allowed, reasonCode }` shapes unless that type is public plugin API.
|
||||
|
||||
## Budget
|
||||
|
||||
Measured against the PR merge-base with `origin/main`, including untracked
|
||||
files.
|
||||
|
||||
```text
|
||||
merge-base 1671e7532adb
|
||||
|
||||
current:
|
||||
core production +3,922 / -546 = +3,376
|
||||
docs +601 / -17 = +584
|
||||
other +145 / -2 = +143
|
||||
plugin production +4,148 / -5,388 = -1,240
|
||||
tests +2,326 / -2,414 = -88
|
||||
total +11,142 / -8,367 = +2,775
|
||||
|
||||
required:
|
||||
plugin production <= -1,500
|
||||
core production <= +1,500, or paid for by larger plugin deletion
|
||||
tests <= +1,000
|
||||
total <= +2,000
|
||||
|
||||
stretch:
|
||||
plugin production <= -2,500
|
||||
core production <= +1,200
|
||||
total <= 0
|
||||
```
|
||||
|
||||
Minimum remaining cleanup:
|
||||
|
||||
```text
|
||||
plugin production needs 260 more net deleted lines
|
||||
total needs 775 more net deleted lines
|
||||
core production still +1,876 over standalone budget, unless paid down by plugin deletion
|
||||
```
|
||||
|
||||
Comment-only deletion does not count as cleanup. The previous budget pass was
|
||||
too generous because it included restored QQBot explanatory comments; this
|
||||
document tracks executable/docs/test code movement only.
|
||||
|
||||
Re-measure after each cleanup wave:
|
||||
|
||||
```sh
|
||||
base=$(git merge-base HEAD origin/main)
|
||||
git diff --shortstat "$base"
|
||||
git diff --numstat "$base" -- src/channels/message-access src/plugin-sdk extensions | sort -nr -k1 | head -n 80
|
||||
pnpm lint:extensions:no-deprecated-channel-access
|
||||
```
|
||||
|
||||
## Diagnosis
|
||||
|
||||
The first pass added the shared ingress kernel, then left too much plugin-local
|
||||
authorization beside it:
|
||||
|
||||
```text
|
||||
platform facts
|
||||
-> shared ingress state and decision
|
||||
-> plugin-local DTO or legacy projection
|
||||
-> plugin-local if/else ladder
|
||||
```
|
||||
|
||||
That duplicates the model. Core production grew by about 3,376 lines, while
|
||||
bundled plugin production is 1,240 lines smaller. That is better than the first
|
||||
pass, but it is not inside the minimum budget. The fix remains deletion-first:
|
||||
|
||||
- delete plugin DTOs that only rename ingress fields
|
||||
- delete tests that only assert wrapper shape
|
||||
- add core helpers only when the same patch deletes bundled plugin code
|
||||
- keep old SDK compatibility in SDK/core shims only
|
||||
- repack core after wrapper deletion exposes the stable shape
|
||||
|
||||
## Hotspots
|
||||
|
||||
Positive bundled production files that still need to shrink:
|
||||
|
||||
```text
|
||||
extensions/telegram/src/ingress.ts +126
|
||||
extensions/discord/src/monitor/dm-command-auth.ts +101
|
||||
extensions/signal/src/monitor/access-policy.ts +92
|
||||
extensions/feishu/src/policy.ts +85
|
||||
extensions/slack/src/monitor/auth.ts +64
|
||||
extensions/googlechat/src/monitor-access.ts +59
|
||||
extensions/nextcloud-talk/src/inbound.ts +51
|
||||
extensions/matrix/src/matrix/monitor/access-state.ts +49
|
||||
extensions/irc/src/inbound.ts +44
|
||||
extensions/imessage/src/monitor/inbound-processing.ts +36
|
||||
extensions/qa-channel/src/inbound.ts +34
|
||||
extensions/qqbot/src/bridge/sdk-adapter.ts +33
|
||||
extensions/tlon/src/monitor/utils.ts +30
|
||||
extensions/twitch/src/access-control.ts +22
|
||||
extensions/qqbot/src/engine/commands/slash-command-handler.ts +20
|
||||
extensions/telegram/src/bot-handlers.runtime.ts +19
|
||||
```
|
||||
|
||||
The branch is not inside the minimum budget yet. The remaining review-relevant
|
||||
work should delete repeated authorization flow, turn scaffolding, or wrapper
|
||||
tests before adding another core abstraction.
|
||||
|
||||
## Current Code Read
|
||||
|
||||
The healthy core seam already exists in `src/channels/message-access/runtime.ts`:
|
||||
it owns identity adapters, effective allowlists, pairing-store reads, route
|
||||
descriptors, command/event presets, access groups, and the final resolved
|
||||
`ResolvedChannelMessageIngress` projection.
|
||||
|
||||
The remaining growth is mostly plugin glue layered on top of that seam:
|
||||
|
||||
- `extensions/telegram/src/ingress.ts` wraps core decisions in Telegram-specific
|
||||
command/event helpers, then call sites still pass precomputed normalized
|
||||
allowlists and owner lists.
|
||||
- `extensions/discord/src/monitor/dm-command-auth.ts`,
|
||||
`extensions/feishu/src/policy.ts`, `extensions/googlechat/src/monitor-access.ts`,
|
||||
and `extensions/matrix/src/matrix/monitor/access-state.ts` still keep
|
||||
local policy DTOs or legacy decision names beside ingress.
|
||||
- `extensions/signal/src/monitor/access-policy.ts` correctly keeps Signal
|
||||
identity normalization and pairing replies local, but still has a wrapper
|
||||
seam that should collapse into direct ingress consumption.
|
||||
- `extensions/nextcloud-talk/src/inbound.ts`, `extensions/irc/src/inbound.ts`,
|
||||
`extensions/qa-channel/src/inbound.ts`, `extensions/zalo/src/monitor.ts`, and
|
||||
`extensions/zalouser/src/monitor.ts` still repeat route/envelope/turn
|
||||
assembly that can move to shared turn helpers outside the ingress kernel.
|
||||
|
||||
Conclusion: moving more code into core is only useful if it deletes these
|
||||
plugin wrapper layers in the same patch. Adding another abstraction while
|
||||
leaving wrapper returns in place repeats the mistake.
|
||||
|
||||
## Boundary
|
||||
|
||||
Core owns generic policy:
|
||||
|
||||
- allowlist normalization and matching
|
||||
- access-group expansion and diagnostics
|
||||
- pairing-store DM allowlist reads
|
||||
- route, sender, command, event, and activation gates
|
||||
- admission mapping: dispatch, drop, skip, observe, pairing
|
||||
- redacted state, decisions, diagnostics, and SDK compatibility projections
|
||||
- reusable generic descriptors for identity, route, command, event, activation,
|
||||
and outcomes
|
||||
|
||||
Plugins own transport facts and side effects:
|
||||
|
||||
- webhook/socket/request authenticity
|
||||
- platform identity extraction and API lookups
|
||||
- channel-specific policy defaults
|
||||
- pairing challenge delivery, replies, acks, reactions, typing, media, history,
|
||||
setup, doctor, status, logs, and user-facing copy
|
||||
|
||||
Core must stay channel-agnostic: no Discord, Slack, Telegram, Matrix, room,
|
||||
guild, space, API client, or plugin-specific default in
|
||||
`src/channels/message-access`.
|
||||
|
||||
## Acceptance Rule
|
||||
|
||||
Every new core helper must delete bundled plugin production code immediately.
|
||||
|
||||
```text
|
||||
one bundled caller reject; keep plugin-local
|
||||
two bundled callers accept only if plugin production LOC drops
|
||||
three or more callers plugin deletion must be at least 2x new core LOC
|
||||
compatibility-only helper SDK/core shim only; never bundled hot paths
|
||||
```
|
||||
|
||||
Stop and redesign if:
|
||||
|
||||
- plugin production LOC increases
|
||||
- tests grow faster than production shrinks
|
||||
- a bundled hot path returns a DTO that only renames `ResolvedChannelMessageIngress`
|
||||
- a core helper needs a channel id, platform object, API client, or
|
||||
channel-specific default
|
||||
|
||||
## Work Packages
|
||||
|
||||
1. Freeze the budget.
|
||||
Put LOC in the PR, keep deprecated-ingress lint green, and include before/after
|
||||
LOC in cleanup commits.
|
||||
|
||||
2. Delete thin DTO seams.
|
||||
Replace plugin-local wrapper returns with `ResolvedChannelMessageIngress`,
|
||||
`senderAccess`, `commandAccess`, `routeAccess`, or `ingress` directly. Start
|
||||
with QQBot, Telegram, Slack, Discord, Signal, Feishu, Matrix, iMessage, and
|
||||
Tlon. Delete wrapper-shape tests; keep behavior tests.
|
||||
|
||||
3. Add outcome classification only with deletions.
|
||||
A generic classifier may expose `dispatch`, `pairing-required`,
|
||||
`skip-activation`, `drop-command`, `drop-route`, `drop-sender`, and
|
||||
`drop-ingress`. It must derive from the decision graph, not reason strings,
|
||||
and migrate at least three plugins in the same patch.
|
||||
|
||||
4. Add route descriptor builders only with deletions.
|
||||
Generic route target and route sender helpers are acceptable only if they
|
||||
immediately shrink route-heavy plugins: Google Chat, IRC, Microsoft Teams,
|
||||
Nextcloud Talk, Mattermost, Slack, Zalo, and Zalo Personal.
|
||||
|
||||
5. Add command/event presets only with deletions.
|
||||
Centralize text-command, native-command, callback, and origin-subject shapes.
|
||||
Command consumers must default to unauthorized when no command gate ran;
|
||||
events must not start pairing.
|
||||
|
||||
6. Add identity presets only where they remove boilerplate.
|
||||
Stable-id, stable-id-plus-aliases, phone/e164, and multi-identifier helpers
|
||||
are allowed when raw values enter only adapter input and redacted state keeps
|
||||
opaque ids/counts.
|
||||
|
||||
7. Share authorized turn assembly.
|
||||
Outside the ingress kernel, remove repeated route/envelope/context/reply
|
||||
scaffolding from QA Channel, IRC, Nextcloud Talk, Zalo, and Zalo Personal.
|
||||
Core may own route/session/envelope/dispatch sequencing; plugins keep
|
||||
delivery and channel-specific context.
|
||||
|
||||
8. Quarantine compatibility.
|
||||
Deprecated SDK helpers stay source-compatible, but bundled hot paths must not
|
||||
import deprecated ingress or command-auth facades. Compatibility tests should
|
||||
use fake third-party plugins, not bundled-plugin internals.
|
||||
|
||||
9. Repack core.
|
||||
After wrapper deletion, collapse one-use modules, remove unused exports, move
|
||||
compatibility projection out of hot paths, and keep focused tests for identity,
|
||||
route, command/event, activation, access groups, and compatibility shims.
|
||||
|
||||
## Deletion Waves
|
||||
|
||||
Run these in order. Each wave must lower bundled production LOC.
|
||||
|
||||
1. Wrapper collapse, expected plugin delta: -400 to -600.
|
||||
Replace plugin-local `resolveXAccess`, `resolveXCommandAccess`, and
|
||||
`accessFromIngress` result types with direct reads from
|
||||
`ResolvedChannelMessageIngress`. First targets: Discord DM command auth,
|
||||
Feishu policy, Matrix access state, Telegram ingress, Signal access policy,
|
||||
QQBot SDK adapter.
|
||||
|
||||
2. Shared outcome helpers, expected plugin delta: -200 to -350.
|
||||
Add one generic classifier only if it deletes repeated
|
||||
`shouldBlockControlCommand`, pairing, activation skip, route block, and sender
|
||||
block ladders across at least three plugins.
|
||||
|
||||
3. Route descriptor builders, expected plugin delta: -200 to -350.
|
||||
Move repeated route target and route sender descriptor assembly into core
|
||||
helpers. First targets: Google Chat, IRC, Microsoft Teams, Nextcloud Talk,
|
||||
Mattermost, Slack, Zalo, Zalo Personal.
|
||||
|
||||
4. Turn assembly sharing, expected plugin delta: -250 to -450.
|
||||
Use common route/session/envelope/dispatch sequencing for simple inbound
|
||||
plugins. First targets: QA Channel, IRC, Nextcloud Talk, Zalo, Zalo Personal.
|
||||
|
||||
5. Core repack, expected core delta: -300 to -700.
|
||||
After plugins consume runtime projections directly, delete one-use modules,
|
||||
merge tiny files back into `runtime.ts` or focused siblings, and keep SDK
|
||||
compatibility files separate from bundled hot paths.
|
||||
|
||||
6. Test pruning, expected test delta: -300 to -600.
|
||||
Delete tests that only assert removed wrapper shapes. Keep behavior tests for
|
||||
command denial, group fallback, origin-subject matching, activation skip,
|
||||
access groups, pairing, and redaction.
|
||||
|
||||
Expected minimum landing shape after these waves:
|
||||
|
||||
```text
|
||||
plugin production <= -1,500
|
||||
core production about +1,800 to +2,200 before final repack
|
||||
tests <= +500
|
||||
total <= +2,000
|
||||
```
|
||||
|
||||
## Do Not Move
|
||||
|
||||
Do not move platform config defaults, setup UX, doctor/fix copy, API lookups,
|
||||
Slack owner-presence checks, Matrix alias/verification handling, Telegram
|
||||
callback parsing, command syntax parsing, native command registration, reaction
|
||||
payload parsing, pairing replies, command replies, acks, typing, media, history,
|
||||
or logs.
|
||||
|
||||
## Verification
|
||||
|
||||
Targeted local loop:
|
||||
|
||||
```sh
|
||||
pnpm lint:extensions:no-deprecated-channel-access
|
||||
pnpm test src/channels/message-access/message-access.test.ts src/plugin-sdk/channel-ingress-runtime.test.ts src/plugin-sdk/access-groups.test.ts
|
||||
pnpm test extensions/<changed-plugin>/src/...
|
||||
pnpm plugin-sdk:api:check
|
||||
pnpm config:docs:check
|
||||
pnpm check:docs
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Use Testbox for broad changed gates/full-suite proof once the LOC trend is
|
||||
inside budget.
|
||||
|
||||
Each work package records:
|
||||
|
||||
- before/after LOC by category
|
||||
- deleted plugin wrappers
|
||||
- new core helper LOC, if any
|
||||
- targeted tests run
|
||||
- remaining hotspot list
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- bundled production imports no deprecated channel-access or command-auth facades
|
||||
- compatibility code is isolated to SDK/core seams
|
||||
- bundled plugins consume ingress projections or generic outcomes directly
|
||||
- plugin production LOC is at least 1,500 net negative against `origin/main`
|
||||
- core production LOC is <= +1,500, or any excess is paid for while total stays
|
||||
<= +2,000
|
||||
- representative tests cover redaction, route, command/event, activation,
|
||||
access-group, and channel-specific fallback behavior
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
resolveAccessGroupAllowFromMatches,
|
||||
type AccessGroupMembershipResolver,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RequestClient } from "../internal/discord.js";
|
||||
import { canViewDiscordGuildChannel } from "../send.permissions.js";
|
||||
|
||||
export function createDiscordAccessGroupMembershipResolver(params: {
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
}): AccessGroupMembershipResolver {
|
||||
return async ({ cfg, name, group, accountId, senderId }) => {
|
||||
if (group.type !== "discord.channelAudience") {
|
||||
return false;
|
||||
}
|
||||
const membership = group.membership ?? "canViewChannel";
|
||||
if (membership !== "canViewChannel") {
|
||||
return false;
|
||||
}
|
||||
return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, {
|
||||
cfg,
|
||||
accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}).catch((err) => {
|
||||
logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`);
|
||||
return false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDiscordDmAccessGroupEntries(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
allowFrom: string[];
|
||||
sender: { id: string };
|
||||
accountId: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
isSenderAllowed?: (senderId: string, allowFrom: string[]) => boolean;
|
||||
}): Promise<string[]> {
|
||||
return await resolveAccessGroupAllowFromMatches({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.allowFrom,
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
senderId: params.sender.id,
|
||||
isSenderAllowed: params.isSenderAllowed,
|
||||
resolveMembership: createDiscordAccessGroupMembershipResolver({
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveDiscordDmAccessGroupEntries } from "./access-groups.js";
|
||||
import {
|
||||
resolveComponentInteractionContext,
|
||||
resolveDiscordChannelContext,
|
||||
} from "./agent-components-context.js";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
readChannelIngressStoreAllowFromForDmPolicy,
|
||||
upsertChannelPairingRequest,
|
||||
} from "./agent-components-helpers.runtime.js";
|
||||
import { replySilently } from "./agent-components-reply.js";
|
||||
@@ -16,11 +15,8 @@ import type {
|
||||
AgentComponentInteraction,
|
||||
DiscordUser,
|
||||
} from "./agent-components.types.js";
|
||||
import {
|
||||
normalizeDiscordAllowList,
|
||||
resolveDiscordAllowListMatch,
|
||||
resolveGroupDmAllow,
|
||||
} from "./allow-list.js";
|
||||
import { resolveGroupDmAllow } from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
async function ensureDmComponentAuthorized(params: {
|
||||
@@ -31,50 +27,36 @@ async function ensureDmComponentAuthorized(params: {
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
}) {
|
||||
const { ctx, interaction, user, componentLabel, replyOpts } = params;
|
||||
const allowFromPrefixes = ["discord:", "user:", "pk:"];
|
||||
const resolveAllowMatch = (entries: string[]) => {
|
||||
const allowList = normalizeDiscordAllowList(entries, allowFromPrefixes);
|
||||
return allowList
|
||||
? resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: formatDiscordUserTag(user),
|
||||
},
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
|
||||
})
|
||||
: { allowed: false };
|
||||
};
|
||||
const resolveAllowMatchWithAccessGroups = async (entries: string[]) => {
|
||||
const staticMatch = resolveAllowMatch(entries);
|
||||
if (staticMatch.allowed) {
|
||||
return staticMatch;
|
||||
}
|
||||
const matchedGroups = await resolveDiscordDmAccessGroupEntries({
|
||||
cfg: ctx.cfg,
|
||||
allowFrom: entries,
|
||||
sender: { id: user.id },
|
||||
accountId: ctx.accountId,
|
||||
token: ctx.token,
|
||||
isSenderAllowed: (senderId, allowFrom) =>
|
||||
resolveAllowMatch(allowFrom).allowed || allowFrom.includes(senderId),
|
||||
});
|
||||
return matchedGroups.length > 0
|
||||
? resolveAllowMatch([...entries, `discord:${user.id}`])
|
||||
: staticMatch;
|
||||
};
|
||||
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
||||
await replySilently(interaction, { content: "DM interactions are disabled.", ...replyOpts });
|
||||
return false;
|
||||
}
|
||||
if (dmPolicy === "allowlist") {
|
||||
const allowMatch = await resolveAllowMatchWithAccessGroups(ctx.allowFrom ?? []);
|
||||
if (allowMatch.allowed) {
|
||||
return true;
|
||||
}
|
||||
const access = await resolveDiscordDmCommandAccess({
|
||||
accountId: ctx.accountId,
|
||||
dmPolicy,
|
||||
configuredAllowFrom: ctx.allowFrom ?? [],
|
||||
sender: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: formatDiscordUserTag(user),
|
||||
},
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
|
||||
cfg: ctx.cfg,
|
||||
token: ctx.token,
|
||||
readStoreAllowFrom: async ({ accountId, dmPolicy }) =>
|
||||
await readChannelIngressStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
accountId,
|
||||
dmPolicy,
|
||||
}),
|
||||
eventKind: "button",
|
||||
});
|
||||
if (access.senderAccess.decision === "allow") {
|
||||
return true;
|
||||
}
|
||||
if (access.senderAccess.decision !== "pairing") {
|
||||
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
|
||||
await replySilently(interaction, {
|
||||
content: `You are not authorized to use this ${componentLabel}.`,
|
||||
@@ -82,62 +64,36 @@ async function ensureDmComponentAuthorized(params: {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const storeAllowFrom =
|
||||
dmPolicy === "open"
|
||||
? []
|
||||
: await readStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
accountId: ctx.accountId,
|
||||
dmPolicy,
|
||||
});
|
||||
const allowMatch = resolveAllowMatch([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
|
||||
const dynamicAllowMatch = allowMatch.allowed
|
||||
? allowMatch
|
||||
: await resolveAllowMatchWithAccessGroups([...(ctx.allowFrom ?? []), ...storeAllowFrom]);
|
||||
if (dynamicAllowMatch.allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dmPolicy === "pairing") {
|
||||
const pairingResult = await createChannelPairingChallengeIssuer({
|
||||
channel: "discord",
|
||||
upsertPairingRequest: async ({ id, meta }) => {
|
||||
return await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id,
|
||||
accountId: ctx.accountId,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
})({
|
||||
senderId: user.id,
|
||||
senderIdLine: `Your Discord user id: ${user.id}`,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(user),
|
||||
name: user.username,
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await interaction.reply({
|
||||
content: text,
|
||||
...replyOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!pairingResult.created) {
|
||||
await replySilently(interaction, {
|
||||
content: "Pairing already requested. Ask the bot owner to approve your code.",
|
||||
const pairingResult = await createChannelPairingChallengeIssuer({
|
||||
channel: "discord",
|
||||
upsertPairingRequest: async ({ id, meta }) => {
|
||||
return await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id,
|
||||
accountId: ctx.accountId,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
})({
|
||||
senderId: user.id,
|
||||
senderIdLine: `Your Discord user id: ${user.id}`,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(user),
|
||||
name: user.username,
|
||||
},
|
||||
sendPairingReply: async (text) => {
|
||||
await interaction.reply({
|
||||
content: text,
|
||||
...replyOpts,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
|
||||
await replySilently(interaction, {
|
||||
content: `You are not authorized to use this ${componentLabel}.`,
|
||||
...replyOpts,
|
||||
},
|
||||
});
|
||||
if (!pairingResult.created) {
|
||||
await replySilently(interaction, {
|
||||
content: "Pairing already requested. Ask the bot owner to approve your code.",
|
||||
...replyOpts,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ export async function resolveAuthorizedComponentInteraction(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
const commandAuthorized = await resolveComponentCommandAuthorized({
|
||||
ctx: params.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
@@ -273,7 +273,7 @@ export async function resolveAuthorizedComponentInteraction(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveComponentCommandAuthorized(params: {
|
||||
export async function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
export { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
|
||||
export { readChannelIngressStoreAllowFromForDmPolicy } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
export { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
|
||||
@@ -36,9 +36,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
|
||||
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
|
||||
let replyPipelineRuntimePromise:
|
||||
| Promise<typeof import("openclaw/plugin-sdk/channel-message")>
|
||||
| undefined;
|
||||
let typingRuntimePromise: Promise<typeof import("./typing.js")> | undefined;
|
||||
|
||||
async function loadConversationRuntime() {
|
||||
@@ -46,11 +43,6 @@ async function loadConversationRuntime() {
|
||||
return await conversationRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadReplyPipelineRuntime() {
|
||||
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-message");
|
||||
return await replyPipelineRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadTypingRuntime() {
|
||||
typingRuntimePromise ??= import("./typing.js");
|
||||
return await typingRuntimePromise;
|
||||
@@ -163,7 +155,7 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
const commandAuthorized = await resolveComponentCommandAuthorized({
|
||||
ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
@@ -241,13 +233,6 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
|
||||
const deliverTarget = `channel:${interactionCtx.channelId}`;
|
||||
const typingChannelId = interactionCtx.channelId;
|
||||
const { createChannelMessageReplyPipeline } = await loadReplyPipelineRuntime();
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg: ctx.cfg,
|
||||
agentId,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg: ctx.cfg,
|
||||
channel: "discord",
|
||||
@@ -283,12 +268,15 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
raw: interaction,
|
||||
}),
|
||||
resolveTurn: () => ({
|
||||
cfg: ctx.cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
agentId,
|
||||
routeSessionKey: sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
record: {
|
||||
updateLastRoute: interactionCtx.isDirectMessage
|
||||
? {
|
||||
@@ -315,51 +303,47 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
runDispatch: () =>
|
||||
dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: ctx.cfg,
|
||||
replyOptions: { onModelSelected },
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
||||
deliver: async (payload) => {
|
||||
const replyToId = replyReference.use();
|
||||
await deliverDiscordReply({
|
||||
cfg: ctx.cfg,
|
||||
replies: [payload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
rest: interaction.client.rest,
|
||||
runtime,
|
||||
replyToId,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId,
|
||||
}),
|
||||
tableMode,
|
||||
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
||||
mediaLocalRoots,
|
||||
});
|
||||
replyReference.markSent();
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
try {
|
||||
const { sendTyping } = await loadTypingRuntime();
|
||||
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
|
||||
} catch (err) {
|
||||
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
logError(`discord component dispatch failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
}),
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
const replyToId = replyReference.use();
|
||||
await deliverDiscordReply({
|
||||
cfg: ctx.cfg,
|
||||
replies: [payload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
rest: interaction.client.rest,
|
||||
runtime,
|
||||
replyToId,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
||||
cfg: ctx.cfg,
|
||||
discordConfig: ctx.discordConfig,
|
||||
accountId,
|
||||
}),
|
||||
tableMode,
|
||||
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
||||
mediaLocalRoots,
|
||||
});
|
||||
replyReference.markSent();
|
||||
},
|
||||
onError: (err) => {
|
||||
logError(`discord component dispatch failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
dispatcherOptions: {
|
||||
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
||||
onReplyStart: async () => {
|
||||
try {
|
||||
const { sendTyping } = await loadTypingRuntime();
|
||||
await sendTyping({ rest: feedbackRest, channelId: typingChannelId });
|
||||
} catch (err) {
|
||||
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
resolveChannelMatchConfig,
|
||||
type ChannelMatchSource,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
@@ -536,15 +535,14 @@ export function isDiscordGroupAllowedByPolicy(params: {
|
||||
if (params.groupPolicy === "allowlist" && !params.guildAllowlisted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return evaluateGroupRouteAccessForPolicy({
|
||||
groupPolicy:
|
||||
params.groupPolicy === "allowlist" && !params.channelAllowlistConfigured
|
||||
? "open"
|
||||
: params.groupPolicy,
|
||||
routeAllowlistConfigured: params.channelAllowlistConfigured,
|
||||
routeMatched: params.channelAllowed,
|
||||
}).allowed;
|
||||
if (params.groupPolicy === "disabled") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
params.groupPolicy !== "allowlist" ||
|
||||
!params.channelAllowlistConfigured ||
|
||||
params.channelAllowed
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelPolicyCommandAuthorizer(params: {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import {
|
||||
resolveDiscordDmCommandAccess,
|
||||
resolveDiscordTextCommandAccess,
|
||||
} from "./dm-command-auth.js";
|
||||
|
||||
const canViewDiscordGuildChannelMock = vi.hoisted(() => vi.fn());
|
||||
type DiscordDmIngressAccess = Awaited<ReturnType<typeof resolveDiscordDmCommandAccess>>;
|
||||
|
||||
vi.mock("../send.permissions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.permissions.js")>();
|
||||
@@ -11,6 +15,91 @@ vi.mock("../send.permissions.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
function dmCommandAuthorized(result: DiscordDmIngressAccess): boolean {
|
||||
return result.senderAccess.allowed ? result.commandAccess.authorized : false;
|
||||
}
|
||||
|
||||
describe("resolveDiscordTextCommandAccess", () => {
|
||||
const sender = {
|
||||
id: "123",
|
||||
name: "alice",
|
||||
tag: "alice#0001",
|
||||
};
|
||||
|
||||
it("authorizes guild text commands from owner allowlists", async () => {
|
||||
await expect(
|
||||
resolveDiscordTextCommandAccess({
|
||||
accountId: "default",
|
||||
sender,
|
||||
ownerAllowFrom: ["discord:123"],
|
||||
memberAccessConfigured: false,
|
||||
memberAllowed: false,
|
||||
allowNameMatching: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes guild text commands from member access facts", async () => {
|
||||
await expect(
|
||||
resolveDiscordTextCommandAccess({
|
||||
accountId: "default",
|
||||
sender,
|
||||
ownerAllowFrom: [],
|
||||
memberAccessConfigured: true,
|
||||
memberAllowed: true,
|
||||
allowNameMatching: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks unauthorized guild text control commands", async () => {
|
||||
await expect(
|
||||
resolveDiscordTextCommandAccess({
|
||||
accountId: "default",
|
||||
sender,
|
||||
ownerAllowFrom: ["discord:999"],
|
||||
memberAccessConfigured: true,
|
||||
memberAllowed: false,
|
||||
allowNameMatching: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
authorized: false,
|
||||
shouldBlockControlCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves configured mode when access groups are disabled", async () => {
|
||||
await expect(
|
||||
resolveDiscordTextCommandAccess({
|
||||
accountId: "default",
|
||||
sender,
|
||||
ownerAllowFrom: [],
|
||||
memberAccessConfigured: false,
|
||||
memberAllowed: false,
|
||||
allowNameMatching: false,
|
||||
cfg: { commands: { useAccessGroups: false } },
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordDmCommandAccess", () => {
|
||||
const sender = {
|
||||
id: "123",
|
||||
@@ -29,7 +118,6 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom,
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
}
|
||||
@@ -37,15 +125,15 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
it("blocks open DMs without allowlist wildcard entries", async () => {
|
||||
const result = await resolveOpenDmAccess([]);
|
||||
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.senderAccess.decision).toBe("block");
|
||||
expect(dmCommandAuthorized(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks command auth true when sender is allowlisted", async () => {
|
||||
const result = await resolveOpenDmAccess(["discord:123"]);
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
expect(result.senderAccess.decision).toBe("allow");
|
||||
expect(dmCommandAuthorized(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks open DMs when configured allowlist does not match", async () => {
|
||||
@@ -55,13 +143,12 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: ["discord:999"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.allowMatch.allowed).toBe(false);
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.senderAccess.decision).toBe("block");
|
||||
expect(result.senderAccess.reasonCode).toBe("dm_policy_not_allowlisted");
|
||||
expect(dmCommandAuthorized(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns pairing decision and unauthorized command auth for unknown senders", async () => {
|
||||
@@ -71,12 +158,11 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: ["discord:456"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
|
||||
expect(result.decision).toBe("pairing");
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.senderAccess.decision).toBe("pairing");
|
||||
expect(dmCommandAuthorized(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("authorizes sender from pairing-store allowlist entries", async () => {
|
||||
@@ -86,12 +172,11 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: [],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
readStoreAllowFrom: async () => ["discord:123"],
|
||||
});
|
||||
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
expect(result.senderAccess.decision).toBe("allow");
|
||||
expect(dmCommandAuthorized(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes allowlist DMs from a Discord channel audience access group", async () => {
|
||||
@@ -103,7 +188,6 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: ["accessGroup:maintainers"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
cfg: {
|
||||
accessGroups: {
|
||||
maintainers: {
|
||||
@@ -123,8 +207,8 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
"123",
|
||||
expect.objectContaining({ accountId: "default", token: "token" }),
|
||||
);
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
expect(result.senderAccess.decision).toBe("allow");
|
||||
expect(dmCommandAuthorized(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("authorizes allowlist DMs from a generic message sender access group", async () => {
|
||||
@@ -134,7 +218,6 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: ["accessGroup:owners"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
cfg: {
|
||||
accessGroups: {
|
||||
owners: {
|
||||
@@ -150,8 +233,8 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
});
|
||||
|
||||
expect(canViewDiscordGuildChannelMock).not.toHaveBeenCalled();
|
||||
expect(result.decision).toBe("allow");
|
||||
expect(result.commandAuthorized).toBe(true);
|
||||
expect(result.senderAccess.decision).toBe("allow");
|
||||
expect(dmCommandAuthorized(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed when a Discord channel audience access group lookup rejects", async () => {
|
||||
@@ -163,7 +246,6 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: ["accessGroup:maintainers"],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: true,
|
||||
cfg: {
|
||||
accessGroups: {
|
||||
maintainers: {
|
||||
@@ -176,8 +258,8 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.senderAccess.decision).toBe("block");
|
||||
expect(dmCommandAuthorized(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps open DM blocked without wildcard even when access groups are disabled", async () => {
|
||||
@@ -187,11 +269,11 @@ describe("resolveDiscordDmCommandAccess", () => {
|
||||
configuredAllowFrom: [],
|
||||
sender,
|
||||
allowNameMatching: false,
|
||||
useAccessGroups: false,
|
||||
cfg: { commands: { useAccessGroups: false } },
|
||||
readStoreAllowFrom: async () => [],
|
||||
});
|
||||
|
||||
expect(result.decision).toBe("block");
|
||||
expect(result.commandAuthorized).toBe(false);
|
||||
expect(result.senderAccess.decision).toBe("block");
|
||||
expect(dmCommandAuthorized(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,158 +1,259 @@
|
||||
import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
type DmGroupAccessDecision,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
type AccessGroupMembershipFact,
|
||||
type ChannelIngressEventInput,
|
||||
type ChannelIngressIdentifierKind,
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ChannelIngressIdentitySubjectInput,
|
||||
type ResolveChannelMessageIngressParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RequestClient } from "../internal/discord.js";
|
||||
import { createDiscordAccessGroupMembershipResolver } from "./access-groups.js";
|
||||
import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js";
|
||||
import { canViewDiscordGuildChannel } from "../send.permissions.js";
|
||||
import { normalizeDiscordAllowList } from "./allow-list.js";
|
||||
|
||||
const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"];
|
||||
const DISCORD_CHANNEL_ID = "discord";
|
||||
const DISCORD_USER_ID_KIND = "stable-id" satisfies ChannelIngressIdentifierKind;
|
||||
const DISCORD_USER_NAME_KIND = "username" satisfies ChannelIngressIdentifierKind;
|
||||
|
||||
export type DiscordDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
|
||||
export type DiscordDmCommandAccess = {
|
||||
decision: DmGroupAccessDecision;
|
||||
reason: string;
|
||||
commandAuthorized: boolean;
|
||||
allowMatch: ReturnType<typeof resolveDiscordAllowListMatch> | { allowed: false };
|
||||
};
|
||||
|
||||
function resolveSenderAllowMatch(params: {
|
||||
allowEntries: string[];
|
||||
sender: { id: string; name?: string; tag?: string };
|
||||
allowNameMatching: boolean;
|
||||
}) {
|
||||
const allowList = normalizeDiscordAllowList(params.allowEntries, DISCORD_ALLOW_LIST_PREFIXES);
|
||||
return allowList
|
||||
? resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: params.sender,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
})
|
||||
: ({ allowed: false } as const);
|
||||
function normalizeDiscordIdEntry(entry: string): string | null {
|
||||
const text = entry.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const maybeId = text.replace(/^<@!?/, "").replace(/>$/, "");
|
||||
if (/^\d+$/.test(maybeId)) {
|
||||
return maybeId;
|
||||
}
|
||||
const prefix = DISCORD_ALLOW_LIST_PREFIXES.find((entryPrefix) => text.startsWith(entryPrefix));
|
||||
if (prefix) {
|
||||
const candidate = text.slice(prefix.length).trim();
|
||||
return candidate || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDmPolicyCommandAuthorization(params: {
|
||||
decision: DmGroupAccessDecision;
|
||||
commandAuthorized: boolean;
|
||||
}) {
|
||||
return params.commandAuthorized;
|
||||
function normalizeDiscordNameEntry(entry: string): string | null {
|
||||
const text = entry.trim();
|
||||
if (!text || text === "*" || normalizeDiscordIdEntry(text)) {
|
||||
return null;
|
||||
}
|
||||
const nameSlug = normalizeDiscordAllowList([text], DISCORD_ALLOW_LIST_PREFIXES)
|
||||
?.names.values()
|
||||
.next().value;
|
||||
return typeof nameSlug === "string" && nameSlug ? nameSlug : null;
|
||||
}
|
||||
|
||||
async function expandAllowFromWithDiscordAccessGroups(params: {
|
||||
function normalizeDiscordNameSubject(value: string): string | null {
|
||||
const nameSlug = normalizeDiscordAllowList([value], DISCORD_ALLOW_LIST_PREFIXES)
|
||||
?.names.values()
|
||||
.next().value;
|
||||
return typeof nameSlug === "string" && nameSlug ? nameSlug : null;
|
||||
}
|
||||
|
||||
const discordIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "discordUserId",
|
||||
kind: DISCORD_USER_ID_KIND,
|
||||
normalizeEntry: normalizeDiscordIdEntry,
|
||||
normalizeSubject: (value) => value.trim() || null,
|
||||
sensitivity: "pii",
|
||||
aliases: (
|
||||
[
|
||||
["discordUserName", normalizeDiscordNameEntry],
|
||||
["discordUserTag", () => null],
|
||||
] as const
|
||||
).map(([key, normalizeEntry]) => ({
|
||||
key,
|
||||
kind: DISCORD_USER_NAME_KIND,
|
||||
normalizeEntry,
|
||||
normalizeSubject: normalizeDiscordNameSubject,
|
||||
dangerous: true,
|
||||
sensitivity: "pii",
|
||||
})),
|
||||
});
|
||||
|
||||
function createDiscordDmIngressSubject(sender: {
|
||||
id: string;
|
||||
name?: string;
|
||||
tag?: string;
|
||||
}): ChannelIngressIdentitySubjectInput {
|
||||
return {
|
||||
stableId: sender.id,
|
||||
aliases: {
|
||||
discordUserName: sender.name,
|
||||
discordUserTag: sender.tag,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordDynamicAccessGroupResolver(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
allowFrom: string[];
|
||||
sender: { id: string };
|
||||
accountId: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
}): ResolveChannelMessageIngressParams["resolveAccessGroupMembership"] {
|
||||
if (!params.cfg) {
|
||||
return undefined;
|
||||
}
|
||||
const cfg = params.cfg;
|
||||
return async ({ name, group, accountId, subject }) => {
|
||||
if (group.type !== "discord.channelAudience") {
|
||||
return false;
|
||||
}
|
||||
const senderId = String(subject.stableId ?? "").trim();
|
||||
if (!senderId) {
|
||||
return false;
|
||||
}
|
||||
const membership = group.membership ?? "canViewChannel";
|
||||
if (membership !== "canViewChannel") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return await canViewDiscordGuildChannel(group.guildId, group.channelId, senderId, {
|
||||
cfg,
|
||||
accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`discord: accessGroup:${name} lookup failed for user ${senderId}: ${String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordIngressResolver(params: {
|
||||
accountId: string;
|
||||
cfg?: OpenClawConfig;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
readStoreAllowFrom?: ResolveChannelMessageIngressParams["readStoreAllowFrom"];
|
||||
useDefaultPairingStore?: boolean;
|
||||
}) {
|
||||
return await expandAllowFromWithAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.allowFrom,
|
||||
channel: "discord",
|
||||
return createChannelIngressResolver({
|
||||
channelId: DISCORD_CHANNEL_ID,
|
||||
accountId: params.accountId,
|
||||
senderId: params.sender.id,
|
||||
senderAllowEntry: `discord:${params.sender.id}`,
|
||||
isSenderAllowed: (senderId, allowFrom) =>
|
||||
resolveSenderAllowMatch({
|
||||
allowEntries: allowFrom,
|
||||
sender: { id: senderId },
|
||||
allowNameMatching: false,
|
||||
}).allowed,
|
||||
resolveMembership: createDiscordAccessGroupMembershipResolver({
|
||||
identity: discordIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
resolveAccessGroupMembership: createDiscordDynamicAccessGroupResolver({
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
...(params.readStoreAllowFrom ? { readStoreAllowFrom: params.readStoreAllowFrom } : {}),
|
||||
...(params.useDefaultPairingStore !== undefined
|
||||
? { useDefaultPairingStore: params.useDefaultPairingStore }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function syntheticAccessGroupMembership(
|
||||
groupName: string,
|
||||
allowed: boolean,
|
||||
): AccessGroupMembershipFact {
|
||||
return allowed
|
||||
? {
|
||||
kind: "matched",
|
||||
groupName,
|
||||
source: "dynamic",
|
||||
matchedEntryIds: [groupName],
|
||||
}
|
||||
: {
|
||||
kind: "not-matched",
|
||||
groupName,
|
||||
source: "dynamic",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDiscordDmCommandAccess(params: {
|
||||
accountId: string;
|
||||
dmPolicy: DiscordDmPolicy;
|
||||
configuredAllowFrom: string[];
|
||||
sender: { id: string; name?: string; tag?: string };
|
||||
allowNameMatching: boolean;
|
||||
useAccessGroups: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
readStoreAllowFrom?: () => Promise<string[]>;
|
||||
}): Promise<DiscordDmCommandAccess> {
|
||||
const storeAllowFrom = params.readStoreAllowFrom
|
||||
? params.dmPolicy === "open"
|
||||
? []
|
||||
: await params.readStoreAllowFrom().catch(() => [])
|
||||
: await readStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
accountId: params.accountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
shouldRead: params.dmPolicy !== "open",
|
||||
});
|
||||
const [configuredAllowFrom, effectiveStoreAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithDiscordAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: params.configuredAllowFrom,
|
||||
sender: params.sender,
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
expandAllowFromWithDiscordAccessGroups({
|
||||
cfg: params.cfg,
|
||||
allowFrom: storeAllowFrom,
|
||||
sender: params.sender,
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}),
|
||||
]);
|
||||
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: false,
|
||||
readStoreAllowFrom?: ResolveChannelMessageIngressParams["readStoreAllowFrom"];
|
||||
eventKind?: ChannelIngressEventInput["kind"];
|
||||
}) {
|
||||
return await createDiscordIngressResolver({
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
readStoreAllowFrom: params.readStoreAllowFrom,
|
||||
useDefaultPairingStore: params.readStoreAllowFrom == null,
|
||||
}).message({
|
||||
subject: createDiscordDmIngressSubject(params.sender),
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: params.sender.id,
|
||||
},
|
||||
event: {
|
||||
kind: params.eventKind ?? "native-command",
|
||||
authMode: "inbound",
|
||||
mayPair: true,
|
||||
},
|
||||
dmPolicy: params.dmPolicy,
|
||||
allowFrom: configuredAllowFrom,
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: effectiveStoreAllowFrom,
|
||||
isSenderAllowed: (allowEntries) =>
|
||||
resolveSenderAllowMatch({
|
||||
allowEntries,
|
||||
sender: params.sender,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed,
|
||||
groupPolicy: "disabled",
|
||||
policy: {
|
||||
mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled",
|
||||
},
|
||||
allowFrom: params.configuredAllowFrom,
|
||||
command: {
|
||||
hasControlCommand: false,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
},
|
||||
});
|
||||
|
||||
const allowMatch = resolveSenderAllowMatch({
|
||||
allowEntries: access.effectiveAllowFrom,
|
||||
sender: params.sender,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
authorizers: [
|
||||
{
|
||||
configured: access.effectiveAllowFrom.length > 0,
|
||||
allowed: allowMatch.allowed,
|
||||
},
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
|
||||
return {
|
||||
decision: access.decision,
|
||||
reason: access.reason,
|
||||
commandAuthorized:
|
||||
access.decision === "allow"
|
||||
? resolveDmPolicyCommandAuthorization({
|
||||
decision: access.decision,
|
||||
commandAuthorized,
|
||||
})
|
||||
: false,
|
||||
allowMatch,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveDiscordTextCommandAccess(params: {
|
||||
accountId: string;
|
||||
sender: { id: string; name?: string; tag?: string };
|
||||
ownerAllowFrom?: string[];
|
||||
memberAccessConfigured: boolean;
|
||||
memberAllowed: boolean;
|
||||
allowNameMatching: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
}) {
|
||||
const ownerAllowFrom = (params.ownerAllowFrom ?? []).filter((entry) => entry.trim() !== "*");
|
||||
const memberAccessGroup = "discord-member-access";
|
||||
const commandGroup = params.memberAccessConfigured ? [`accessGroup:${memberAccessGroup}`] : [];
|
||||
const accessGroupMembership = params.memberAccessConfigured
|
||||
? [syntheticAccessGroupMembership(memberAccessGroup, params.memberAllowed)]
|
||||
: [];
|
||||
const result = await createDiscordIngressResolver({
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
}).command({
|
||||
subject: createDiscordDmIngressSubject(params.sender),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: "discord-command",
|
||||
},
|
||||
accessGroupMembership,
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
policy: {
|
||||
mutableIdentifierMatching: params.allowNameMatching ? "enabled" : "disabled",
|
||||
},
|
||||
allowFrom: ownerAllowFrom,
|
||||
groupAllowFrom: commandGroup,
|
||||
command: {
|
||||
allowTextCommands: params.allowTextCommands,
|
||||
hasControlCommand: params.hasControlCommand,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
},
|
||||
});
|
||||
return result.commandAccess;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
|
||||
function buildDmAccess(overrides: Partial<DiscordDmCommandAccess>): DiscordDmCommandAccess {
|
||||
function buildSenderAccess(
|
||||
overrides: Pick<Partial<ResolvedChannelMessageIngress["senderAccess"]>, "decision">,
|
||||
): Pick<ResolvedChannelMessageIngress["senderAccess"], "decision"> {
|
||||
return {
|
||||
decision: "allow",
|
||||
reason: "ok",
|
||||
commandAuthorized: true,
|
||||
allowMatch: { allowed: true, matchKey: "123", matchSource: "id" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -28,10 +27,8 @@ function createDmDecisionHarness(params?: { pairingCreated?: boolean }) {
|
||||
async function runPairingDecision(params?: { pairingCreated?: boolean }) {
|
||||
const harness = createDmDecisionHarness({ pairingCreated: params?.pairingCreated });
|
||||
const allowed = await handleDiscordDmCommandDecision({
|
||||
dmAccess: buildDmAccess({
|
||||
senderAccess: buildSenderAccess({
|
||||
decision: "pairing",
|
||||
commandAuthorized: false,
|
||||
allowMatch: { allowed: false },
|
||||
}),
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
sender: TEST_SENDER,
|
||||
@@ -47,7 +44,7 @@ describe("handleDiscordDmCommandDecision", () => {
|
||||
const { onPairingCreated, onUnauthorized, upsertPairingRequest } = createDmDecisionHarness();
|
||||
|
||||
const allowed = await handleDiscordDmCommandDecision({
|
||||
dmAccess: buildDmAccess({ decision: "allow" }),
|
||||
senderAccess: buildSenderAccess({ decision: "allow" }),
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
sender: TEST_SENDER,
|
||||
onPairingCreated,
|
||||
@@ -93,10 +90,8 @@ describe("handleDiscordDmCommandDecision", () => {
|
||||
const { onPairingCreated, onUnauthorized, upsertPairingRequest } = createDmDecisionHarness();
|
||||
|
||||
const allowed = await handleDiscordDmCommandDecision({
|
||||
dmAccess: buildDmAccess({
|
||||
senderAccess: buildSenderAccess({
|
||||
decision: "block",
|
||||
commandAuthorized: false,
|
||||
allowMatch: { allowed: false },
|
||||
}),
|
||||
accountId: TEST_ACCOUNT_ID,
|
||||
sender: TEST_SENDER,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ResolvedChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { DiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
|
||||
export async function handleDiscordDmCommandDecision(params: {
|
||||
dmAccess: DiscordDmCommandAccess;
|
||||
senderAccess: Pick<ResolvedChannelMessageIngress["senderAccess"], "decision">;
|
||||
accountId: string;
|
||||
sender: {
|
||||
id: string;
|
||||
@@ -14,11 +14,11 @@ export async function handleDiscordDmCommandDecision(params: {
|
||||
onUnauthorized: () => Promise<void>;
|
||||
upsertPairingRequest?: typeof upsertChannelPairingRequest;
|
||||
}): Promise<boolean> {
|
||||
if (params.dmAccess.decision === "allow") {
|
||||
if (params.senderAccess.decision === "allow") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.dmAccess.decision === "pairing") {
|
||||
if (params.senderAccess.decision === "pairing") {
|
||||
const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest;
|
||||
const result = await createChannelPairingChallengeIssuer({
|
||||
channel: "discord",
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
|
||||
import {
|
||||
ChannelType,
|
||||
@@ -15,15 +11,14 @@ import {
|
||||
} from "../internal/discord.js";
|
||||
import {
|
||||
isDiscordGroupAllowedByPolicy,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordAllowListMatch,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordMemberAccessState,
|
||||
resolveGroupDmAllow,
|
||||
shouldEmitDiscordReactionNotification,
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
|
||||
import { runDiscordListenerWithSlowLog, type DiscordListenerLogger } from "./listeners.queue.js";
|
||||
import { resolveFetchedDiscordThreadLikeChannelContext } from "./thread-channel-context.js";
|
||||
@@ -128,6 +123,7 @@ async function runDiscordReactionHandler(params: {
|
||||
}
|
||||
|
||||
type DiscordReactionIngressAuthorizationParams = {
|
||||
cfg: LoadedConfig;
|
||||
accountId: string;
|
||||
user: User;
|
||||
memberRoleIds: string[];
|
||||
@@ -158,36 +154,21 @@ async function authorizeDiscordReactionIngress(
|
||||
return { allowed: false, reason: "group-dm-disabled" };
|
||||
}
|
||||
if (params.isDirectMessage) {
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
const access = await resolveDiscordDmCommandAccess({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: false,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowEntries) => {
|
||||
const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]);
|
||||
const allowMatch = allowList
|
||||
? resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: params.user.id,
|
||||
name: params.user.username,
|
||||
tag: formatDiscordUserTag(params.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
})
|
||||
: { allowed: false };
|
||||
return allowMatch.allowed;
|
||||
configuredAllowFrom: params.allowFrom,
|
||||
sender: {
|
||||
id: params.user.id,
|
||||
name: params.user.username,
|
||||
tag: formatDiscordUserTag(params.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
eventKind: "reaction",
|
||||
});
|
||||
if (access.decision !== "allow") {
|
||||
return { allowed: false, reason: access.reason };
|
||||
if (access.senderAccess.decision !== "allow") {
|
||||
return { allowed: false, reason: access.senderAccess.reasonCode };
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -452,6 +433,7 @@ async function handleDiscordReactionEvent(
|
||||
const isGroupDm = channelType === ChannelType.GroupDM;
|
||||
const isThreadChannel = channelContext.isThreadChannel;
|
||||
const reactionIngressBase: Omit<DiscordReactionIngressAuthorizationParams, "channelConfig"> = {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
user,
|
||||
memberRoleIds,
|
||||
@@ -519,6 +501,7 @@ async function handleDiscordReactionEvent(
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey,
|
||||
trusted: false,
|
||||
});
|
||||
};
|
||||
const shouldNotifyReaction = (options: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
|
||||
import type { User } from "../internal/discord.js";
|
||||
@@ -32,7 +31,6 @@ export async function resolveDiscordDmPreflightAccess(params: {
|
||||
dmPolicy: DiscordDmPolicy;
|
||||
resolvedAccountId: string;
|
||||
allowNameMatching: boolean;
|
||||
useAccessGroups: boolean;
|
||||
}): Promise<{ commandAuthorized: boolean } | null> {
|
||||
if (params.dmPolicy === "disabled") {
|
||||
logVerbose("discord: drop dm (dmPolicy: disabled)");
|
||||
@@ -61,13 +59,14 @@ export async function resolveDiscordDmPreflightAccess(params: {
|
||||
tag: params.sender.tag,
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
cfg: params.preflight.cfg,
|
||||
token: params.preflight.token,
|
||||
rest: params.preflight.client.rest,
|
||||
});
|
||||
const commandAuthorized = dmAccess.commandAuthorized || directBindingRecord != null;
|
||||
if (dmAccess.decision === "allow") {
|
||||
const commandAuthorized =
|
||||
(dmAccess.senderAccess.allowed && dmAccess.commandAccess.authorized) ||
|
||||
directBindingRecord != null;
|
||||
if (dmAccess.senderAccess.decision === "allow") {
|
||||
return { commandAuthorized };
|
||||
}
|
||||
if (directBindingRecord) {
|
||||
@@ -77,11 +76,8 @@ export async function resolveDiscordDmPreflightAccess(params: {
|
||||
return { commandAuthorized };
|
||||
}
|
||||
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(
|
||||
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
|
||||
);
|
||||
await handleDiscordDmCommandDecision({
|
||||
dmAccess,
|
||||
senderAccess: dmAccess.senderAccess,
|
||||
accountId: params.resolvedAccountId,
|
||||
sender: {
|
||||
id: params.author.id,
|
||||
@@ -90,7 +86,7 @@ export async function resolveDiscordDmPreflightAccess(params: {
|
||||
},
|
||||
onPairingCreated: async (code) => {
|
||||
logVerbose(
|
||||
`discord pairing request sender=${params.author.id} tag=${formatDiscordUserTag(params.author)} (${allowMatchMeta})`,
|
||||
`discord pairing request sender=${params.author.id} tag=${formatDiscordUserTag(params.author)} reason=${dmAccess.senderAccess.reasonCode}`,
|
||||
);
|
||||
try {
|
||||
const conversationRuntime = await loadConversationRuntime();
|
||||
@@ -115,7 +111,7 @@ export async function resolveDiscordDmPreflightAccess(params: {
|
||||
},
|
||||
onUnauthorized: async () => {
|
||||
logVerbose(
|
||||
`Blocked unauthorized discord sender ${params.sender.id} (dmPolicy=${params.dmPolicy}, ${allowMatchMeta})`,
|
||||
`Blocked unauthorized discord sender ${params.sender.id} (dmPolicy=${params.dmPolicy}, reason=${dmAccess.senderAccess.reasonCode})`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@ vi.mock("../pluralkit.js", () => ({
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: transcribeFirstAudioMock,
|
||||
}));
|
||||
vi.mock("./dm-command-auth.js", () => ({
|
||||
vi.mock("./dm-command-auth.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./dm-command-auth.js")>()),
|
||||
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
|
||||
}));
|
||||
vi.mock("./dm-command-decision.js", () => ({
|
||||
@@ -317,9 +318,14 @@ describe("preflightDiscordMessage", () => {
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockResolvedValue({
|
||||
commandAuthorized: true,
|
||||
decision: "allow",
|
||||
allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" },
|
||||
senderAccess: {
|
||||
allowed: true,
|
||||
decision: "allow",
|
||||
reasonCode: "dm_policy_allowlisted",
|
||||
},
|
||||
commandAccess: {
|
||||
authorized: true,
|
||||
},
|
||||
});
|
||||
handleDiscordDmCommandDecisionMock.mockReset();
|
||||
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
logInboundDrop,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
||||
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
@@ -18,10 +17,10 @@ import { ChannelType, MessageType, type User } from "../internal/discord.js";
|
||||
import {
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordMemberAccessState,
|
||||
resolveDiscordOwnerAccess,
|
||||
resolveDiscordShouldRequireMention,
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordChannelInfoSafe, resolveDiscordChannelNameSafe } from "./channel-access.js";
|
||||
import { resolveDiscordTextCommandAccess } from "./dm-command-auth.js";
|
||||
import { resolveDiscordSystemLocation } from "./format.js";
|
||||
import { resolveDiscordDmPreflightAccess } from "./message-handler.dm-preflight.js";
|
||||
import { hydrateDiscordMessageIfNeeded } from "./message-handler.hydration.js";
|
||||
@@ -202,7 +201,6 @@ export async function preflightDiscordMessage(
|
||||
}
|
||||
|
||||
const dmPolicy = params.dmPolicy;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
||||
let commandAuthorized = true;
|
||||
@@ -214,7 +212,6 @@ export async function preflightDiscordMessage(
|
||||
dmPolicy,
|
||||
resolvedAccountId,
|
||||
allowNameMatching,
|
||||
useAccessGroups,
|
||||
});
|
||||
if (isPreflightAborted(params.abortSignal)) {
|
||||
return null;
|
||||
@@ -480,28 +477,24 @@ export async function preflightDiscordMessage(
|
||||
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
|
||||
|
||||
if (!isDirectMessage) {
|
||||
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||
allowFrom: params.allowFrom,
|
||||
const commandAccess = await resolveDiscordTextCommandAccess({
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
ownerAllowFrom: params.allowFrom,
|
||||
sender: {
|
||||
id: sender.id,
|
||||
name: sender.name,
|
||||
tag: sender.tag,
|
||||
},
|
||||
memberAccessConfigured: hasAccessRestrictions,
|
||||
memberAllowed,
|
||||
allowNameMatching,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
||||
],
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
commandAuthorized = commandGate.commandAuthorized;
|
||||
commandAuthorized = commandAccess.authorized;
|
||||
|
||||
if (commandGate.shouldBlock) {
|
||||
if (commandAccess.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: logVerbose,
|
||||
channel: "discord",
|
||||
@@ -597,6 +590,7 @@ export async function preflightDiscordMessage(
|
||||
enqueueSystemEvent(systemText, {
|
||||
sessionKey: effectiveRoute.sessionKey,
|
||||
contextKey: `discord:system:${messageChannelId}:${message.id}`,
|
||||
trusted: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1659,7 +1659,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n🛠️ Exec\n• done");
|
||||
});
|
||||
|
||||
it("keeps Discord progress labels as rolling lines", async () => {
|
||||
it("keeps Discord progress lines below the configured label", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
|
||||
@@ -308,6 +308,16 @@ describe("discord component interactions", () => {
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchReplyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dispatcherOptions: expect.objectContaining({
|
||||
responsePrefixContextProvider: expect.any(Function),
|
||||
}),
|
||||
replyOptions: expect.objectContaining({
|
||||
onModelSelected: expect.any(Function),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type CommandArgDefinition,
|
||||
type CommandArgValues,
|
||||
type CommandArgs,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { chunkItems } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
Button,
|
||||
|
||||
@@ -63,8 +63,9 @@ export function resolveDiscordNativeCommandAllowlistAccess(params: {
|
||||
return { configured: true, allowed: match.allowed } as const;
|
||||
}
|
||||
|
||||
export function resolveDiscordGuildNativeCommandAuthorized(params: {
|
||||
export async function resolveDiscordGuildNativeCommandAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
discordConfig: DiscordConfig;
|
||||
useAccessGroups: boolean;
|
||||
commandsAllowFromAccess: ReturnType<typeof resolveDiscordNativeCommandAllowlistAccess>;
|
||||
@@ -270,11 +271,10 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: {
|
||||
tag: sender.tag,
|
||||
},
|
||||
allowNameMatching,
|
||||
useAccessGroups,
|
||||
cfg,
|
||||
rest: interaction.client.rest,
|
||||
});
|
||||
if (dmAccess.decision !== "allow") {
|
||||
if (dmAccess.senderAccess.decision !== "allow") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -292,6 +292,7 @@ export async function resolveDiscordNativeAutocompleteAuthorized(params: {
|
||||
if (!isDirectMessage) {
|
||||
return resolveDiscordGuildNativeCommandAuthorized({
|
||||
cfg,
|
||||
accountId,
|
||||
discordConfig,
|
||||
useAccessGroups,
|
||||
commandsAllowFromAccess,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CommandArgs } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
|
||||
import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import type {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
listChatCommands,
|
||||
type ChatCommandDefinition,
|
||||
type CommandArgs,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
Button,
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
serializeCommandArgs,
|
||||
type ChatCommandDefinition,
|
||||
type CommandArgs,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
|
||||
@@ -435,14 +435,13 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
tag: sender.tag,
|
||||
},
|
||||
allowNameMatching,
|
||||
useAccessGroups,
|
||||
cfg,
|
||||
rest: interaction.client.rest,
|
||||
});
|
||||
commandAuthorized = dmAccess.commandAuthorized;
|
||||
if (dmAccess.decision !== "allow") {
|
||||
commandAuthorized = dmAccess.senderAccess.allowed ? dmAccess.commandAccess.authorized : false;
|
||||
if (dmAccess.senderAccess.decision !== "allow") {
|
||||
await handleDiscordDmCommandDecision({
|
||||
dmAccess,
|
||||
senderAccess: dmAccess.senderAccess,
|
||||
accountId,
|
||||
sender: {
|
||||
id: user.id,
|
||||
@@ -483,8 +482,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
return { accepted: false };
|
||||
}
|
||||
if (!isDirectMessage) {
|
||||
commandAuthorized = resolveDiscordGuildNativeCommandAuthorized({
|
||||
commandAuthorized = await resolveDiscordGuildNativeCommandAuthorized({
|
||||
cfg,
|
||||
accountId,
|
||||
discordConfig,
|
||||
useAccessGroups,
|
||||
commandsAllowFromAccess,
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
listNativeCommandSpecsForConfig,
|
||||
listSkillCommandsForAgents,
|
||||
type NativeCommandSpec,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { danger, warn, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
|
||||
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { isDiscordExecApprovalClientEnabled } from "../exec-approvals.js";
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract
|
||||
import {
|
||||
listNativeCommandSpecsForConfig,
|
||||
listSkillCommandsForAgents,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types";
|
||||
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import {
|
||||
|
||||
@@ -53,13 +53,17 @@ const resolvePluginConversationBindingApprovalMock: AsyncUnknownMock =
|
||||
const buildPluginBindingResolvedTextMock: UnknownMock =
|
||||
runtimeMocks.buildPluginBindingResolvedTextMock;
|
||||
|
||||
async function readStoreAllowFromForDmPolicy(params: {
|
||||
async function readChannelIngressStoreAllowFromForDmPolicy(params: {
|
||||
provider: string;
|
||||
accountId: string;
|
||||
dmPolicy?: string | null;
|
||||
shouldRead?: boolean | null;
|
||||
}) {
|
||||
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
|
||||
if (
|
||||
params.shouldRead === false ||
|
||||
params.dmPolicy === "allowlist" ||
|
||||
params.dmPolicy === "open"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return await readAllowFromStoreMock(params.provider, params.accountId);
|
||||
@@ -67,7 +71,7 @@ async function readStoreAllowFromForDmPolicy(params: {
|
||||
|
||||
vi.mock("../monitor/agent-components-helpers.runtime.js", () => {
|
||||
return {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
readChannelIngressStoreAllowFromForDmPolicy,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
export async function authorizeDiscordVoiceIngress(params: {
|
||||
cfg: OpenClawConfig;
|
||||
discordConfig: DiscordAccountConfig;
|
||||
accountId?: string;
|
||||
groupPolicy?: "open" | "disabled" | "allowlist";
|
||||
useAccessGroups?: boolean;
|
||||
guild?: Guild<true> | Guild | null;
|
||||
@@ -114,11 +115,12 @@ export async function authorizeDiscordVoiceIngress(params: {
|
||||
]
|
||||
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
|
||||
|
||||
return resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
})
|
||||
});
|
||||
return commandAuthorized
|
||||
? { ok: true, channelConfig }
|
||||
: { ok: false, message: "You are not authorized to use this command." };
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ async function authorizeVoiceCommand(
|
||||
const access = await authorizeDiscordVoiceIngress({
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
groupPolicy: params.groupPolicy,
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
guild: interaction.guild,
|
||||
|
||||
@@ -1178,6 +1178,7 @@ describe("DiscordVoiceManager", () => {
|
||||
const bridgeParams = createRealtimeVoiceBridgeSessionMock.mock.calls.at(-1)?.[0] as
|
||||
| {
|
||||
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
|
||||
onEvent?: (event: { direction: "server"; type: string }) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -1424,6 +1425,7 @@ describe("DiscordVoiceManager", () => {
|
||||
session: typeof realtimeSessionMock,
|
||||
) => void;
|
||||
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
|
||||
onEvent?: (event: { direction: "server"; type: string }) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -1499,6 +1501,7 @@ describe("DiscordVoiceManager", () => {
|
||||
session: typeof realtimeSessionMock,
|
||||
) => void;
|
||||
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
|
||||
onEvent?: (event: { direction: "server"; type: string }) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -1573,6 +1576,7 @@ describe("DiscordVoiceManager", () => {
|
||||
session: typeof realtimeSessionMock,
|
||||
) => void;
|
||||
onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
|
||||
onEvent?: (event: { direction: "server"; type: string }) => void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
|
||||
@@ -1080,10 +1080,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: false, allowed: false }],
|
||||
});
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ChatType: "group",
|
||||
@@ -1164,10 +1161,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
useAccessGroups: true,
|
||||
authorizers: [{ configured: true, allowed: true }],
|
||||
});
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ChatType: "group",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
import {
|
||||
buildAgentMediaPayload,
|
||||
evaluateSupplementalContextVisibility,
|
||||
filterSupplementalContextItems,
|
||||
normalizeAgentId,
|
||||
resolveChannelContextVisibilityMode,
|
||||
} from "./bot-runtime-api.js";
|
||||
@@ -47,9 +45,10 @@ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
||||
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
||||
import {
|
||||
hasExplicitFeishuGroupConfig,
|
||||
isFeishuGroupAllowed,
|
||||
resolveFeishuAllowlistMatch,
|
||||
resolveFeishuDmIngressAccess,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuGroupConversationIngressAccess,
|
||||
resolveFeishuGroupSenderActivationIngressAccess,
|
||||
resolveFeishuReplyPolicy,
|
||||
} from "./policy.js";
|
||||
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
|
||||
@@ -353,44 +352,33 @@ export function buildFeishuAgentBody(params: {
|
||||
return messageBody;
|
||||
}
|
||||
|
||||
function isFetchedGroupContextSenderAllowed(params: {
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
senderId?: string;
|
||||
senderType?: string;
|
||||
}): boolean {
|
||||
if (!params.isGroup || params.allowFrom.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (params.senderType === "app") {
|
||||
return true;
|
||||
}
|
||||
const senderId = params.senderId?.trim();
|
||||
const senderAllowed =
|
||||
!!senderId &&
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: params.allowFrom,
|
||||
senderId,
|
||||
senderName: undefined,
|
||||
});
|
||||
return senderAllowed;
|
||||
}
|
||||
|
||||
function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
async function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
mode: "all" | "allowlist" | "allowlist_quote";
|
||||
kind: "quote" | "thread" | "history";
|
||||
senderId?: string;
|
||||
senderType?: string;
|
||||
}): boolean {
|
||||
const senderAllowed = isFetchedGroupContextSenderAllowed({
|
||||
isGroup: params.isGroup,
|
||||
allowFrom: params.allowFrom,
|
||||
senderId: params.senderId,
|
||||
senderType: params.senderType,
|
||||
});
|
||||
}): Promise<boolean> {
|
||||
let senderAllowed =
|
||||
!params.isGroup || params.allowFrom.length === 0 || params.senderType === "app";
|
||||
const senderId = params.senderId?.trim();
|
||||
if (!senderAllowed && senderId) {
|
||||
const access = await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
allowFrom: params.allowFrom,
|
||||
senderOpenId: senderId,
|
||||
senderUserId: senderId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
});
|
||||
senderAllowed = access.senderAccess.decision === "allow";
|
||||
}
|
||||
return evaluateSupplementalContextVisibility({
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
@@ -398,29 +386,38 @@ function shouldIncludeFetchedGroupContextMessage(params: {
|
||||
}).include;
|
||||
}
|
||||
|
||||
function filterFetchedGroupContextMessages<
|
||||
async function filterFetchedGroupContextMessages<
|
||||
T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
|
||||
>(
|
||||
messages: readonly T[],
|
||||
params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
allowFrom: Array<string | number>;
|
||||
mode: "all" | "allowlist" | "allowlist_quote";
|
||||
kind: "quote" | "thread" | "history";
|
||||
},
|
||||
): T[] {
|
||||
return filterSupplementalContextItems({
|
||||
items: messages,
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
isSenderAllowed: (message) =>
|
||||
isFetchedGroupContextSenderAllowed({
|
||||
): Promise<T[]> {
|
||||
const results: Array<T | undefined> = await Promise.all(
|
||||
messages.map(async (message) =>
|
||||
(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
isGroup: params.isGroup,
|
||||
allowFrom: params.allowFrom,
|
||||
mode: params.mode,
|
||||
kind: params.kind,
|
||||
senderId: message.senderId,
|
||||
senderType: message.senderType,
|
||||
}),
|
||||
}).items;
|
||||
}))
|
||||
? message
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
return results.filter((message): message is T => message !== undefined);
|
||||
}
|
||||
|
||||
export async function handleFeishuMessage(params: {
|
||||
@@ -595,7 +592,6 @@ export async function handleFeishuMessage(params: {
|
||||
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
|
||||
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
|
||||
const broadcastAgents = rawBroadcastAgents
|
||||
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
||||
@@ -639,39 +635,22 @@ export async function handleFeishuMessage(params: {
|
||||
groupId: ctx.chatId,
|
||||
});
|
||||
|
||||
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
||||
const groupAllowed =
|
||||
groupPolicy !== "disabled" &&
|
||||
(groupExplicitlyConfigured ||
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy,
|
||||
allowFrom: groupAllowFrom,
|
||||
senderId: ctx.chatId, // Check group ID, not sender ID
|
||||
senderName: undefined,
|
||||
}));
|
||||
const groupIngress = await resolveFeishuGroupConversationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
groupExplicitlyConfigured,
|
||||
});
|
||||
|
||||
if (!groupAllowed) {
|
||||
if (groupIngress.ingress.admission !== "dispatch") {
|
||||
log(
|
||||
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
||||
if (effectiveGroupSenderAllowFrom.length > 0) {
|
||||
const senderAllowed = isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
({ requireMention } = resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg,
|
||||
@@ -680,7 +659,21 @@ export async function handleFeishuMessage(params: {
|
||||
groupPolicy,
|
||||
}));
|
||||
|
||||
if (requireMention && !ctx.mentionedBot) {
|
||||
const groupSenderActivationIngress = await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
requireMention,
|
||||
mentionedBot: ctx.mentionedBot,
|
||||
});
|
||||
if (groupSenderActivationIngress.senderAccess.decision !== "allow") {
|
||||
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
||||
return;
|
||||
}
|
||||
if (groupSenderActivationIngress.ingress.admission !== "dispatch") {
|
||||
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
|
||||
// Record to pending history for non-broadcast groups only. For broadcast groups,
|
||||
// the mentioned handler's broadcast dispatch writes the turn directly into all
|
||||
@@ -715,34 +708,22 @@ export async function handleFeishuMessage(params: {
|
||||
commandProbeBody,
|
||||
cfg,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const dmAllowed = resolveFeishuAllowlistMatch({
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed;
|
||||
|
||||
const dmAccessAllowed =
|
||||
dmPolicy === "open"
|
||||
? resolveOpenDmAllowlistAccess({
|
||||
effectiveAllowFrom: effectiveDmAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed,
|
||||
}).decision === "allow"
|
||||
: dmAllowed;
|
||||
|
||||
if (isDirect && !dmAccessAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const dmIngress = isDirect
|
||||
? await resolveFeishuDmIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
conversationId: ctx.senderOpenId,
|
||||
mayPair: true,
|
||||
...(shouldComputeCommandAuthorized ? { command: { hasControlCommand: true } } : {}),
|
||||
})
|
||||
: null;
|
||||
if (isDirect && dmIngress?.ingress.admission !== "dispatch") {
|
||||
if (dmIngress?.ingress.admission === "pairing-required") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
||||
@@ -774,13 +755,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
const commandAllowFrom = isGroup
|
||||
? (groupConfig?.allowFrom ?? configAllowFrom)
|
||||
: effectiveDmAllowFrom;
|
||||
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
|
||||
allowFrom: commandAllowFrom,
|
||||
senderId: ctx.senderOpenId,
|
||||
senderIds: [senderUserId],
|
||||
senderName: ctx.senderName,
|
||||
}).allowed;
|
||||
: (dmIngress?.senderAccess.effectiveAllowFrom ?? configAllowFrom);
|
||||
|
||||
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
||||
// Using a group-scoped From causes the agent to treat different users as the same person.
|
||||
@@ -982,12 +957,36 @@ export async function handleFeishuMessage(params: {
|
||||
? shouldComputeCommandAuthorized
|
||||
: core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
|
||||
const commandAuthorized = shouldComputeEffectiveCommandAuthorized
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
? isDirect && audioTranscript === undefined && dmIngress
|
||||
? dmIngress.commandAccess.authorized
|
||||
: isGroup
|
||||
? (
|
||||
await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
allowFrom: commandAllowFrom,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
command: { hasControlCommand: true },
|
||||
})
|
||||
).commandAccess.authorized
|
||||
: (
|
||||
await resolveFeishuDmIngressAccess({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
senderUserId,
|
||||
conversationId: ctx.senderOpenId,
|
||||
mayPair: false,
|
||||
command: { hasControlCommand: true },
|
||||
})
|
||||
).commandAccess.authorized
|
||||
: undefined;
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
@@ -1002,14 +1001,17 @@ export async function handleFeishuMessage(params: {
|
||||
});
|
||||
if (
|
||||
quotedMessageInfo &&
|
||||
shouldIncludeFetchedGroupContextMessage({
|
||||
(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
kind: "quote",
|
||||
senderId: quotedMessageInfo.senderId,
|
||||
senderType: quotedMessageInfo.senderType,
|
||||
})
|
||||
}))
|
||||
) {
|
||||
quotedContent = quotedMessageInfo.content;
|
||||
log(
|
||||
@@ -1115,14 +1117,17 @@ export async function handleFeishuMessage(params: {
|
||||
rootMessageThreadId = rootMessageInfo?.threadId;
|
||||
if (
|
||||
rootMessageInfo &&
|
||||
!shouldIncludeFetchedGroupContextMessage({
|
||||
!(await shouldIncludeFetchedGroupContextMessage({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
kind: "thread",
|
||||
senderId: rootMessageInfo.senderId,
|
||||
senderType: rootMessageInfo.senderType,
|
||||
})
|
||||
}))
|
||||
) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
|
||||
@@ -1208,7 +1213,10 @@ export async function handleFeishuMessage(params: {
|
||||
.map((id) => id?.trim())
|
||||
.filter((id): id is string => id !== undefined && id.length > 0),
|
||||
);
|
||||
const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, {
|
||||
const allowlistedMessages = await filterFetchedGroupContextMessages(threadMessages, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
chatId: ctx.chatId,
|
||||
isGroup,
|
||||
allowFrom: effectiveGroupSenderAllowFrom,
|
||||
mode: contextVisibilityMode,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
resolveDriveCommentEventTurn,
|
||||
type FeishuDriveCommentNoticeEvent,
|
||||
} from "./monitor.comment.js";
|
||||
import { resolveFeishuAllowlistMatch } from "./policy.js";
|
||||
import { resolveFeishuDmIngressAccess } from "./policy.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
@@ -88,30 +87,19 @@ export async function handleFeishuCommentEvent(
|
||||
channel: "feishu",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const storeAllowFrom =
|
||||
dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const senderAllowed = resolveFeishuAllowlistMatch({
|
||||
allowFrom: effectiveDmAllowFrom,
|
||||
senderId: turn.senderId,
|
||||
senderIds: [turn.senderUserId],
|
||||
}).allowed;
|
||||
const dmAccessAllowed =
|
||||
dmPolicy === "open"
|
||||
? resolveOpenDmAllowlistAccess({
|
||||
effectiveAllowFrom: effectiveDmAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId: turn.senderId,
|
||||
senderIds: [turn.senderUserId],
|
||||
}).allowed,
|
||||
}).decision === "allow"
|
||||
: senderAllowed;
|
||||
if (!dmAccessAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const dmIngress = await resolveFeishuDmIngressAccess({
|
||||
cfg: params.cfg,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
readAllowFromStore: pairing.readAllowFromStore,
|
||||
senderOpenId: turn.senderId,
|
||||
senderUserId: turn.senderUserId,
|
||||
conversationId: turn.senderId,
|
||||
mayPair: true,
|
||||
});
|
||||
if (dmIngress.ingress.admission !== "dispatch") {
|
||||
if (dmIngress.ingress.admission === "pairing-required") {
|
||||
const client = createFeishuClient(account);
|
||||
await pairing.issueChallenge({
|
||||
senderId: turn.senderId,
|
||||
|
||||
@@ -3,9 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
hasExplicitFeishuGroupConfig,
|
||||
isFeishuGroupAllowed,
|
||||
resolveFeishuAllowlistMatch,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuGroupSenderActivationIngressAccess,
|
||||
resolveFeishuReplyPolicy,
|
||||
} from "./policy.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
@@ -165,170 +164,60 @@ describe("hasExplicitFeishuGroupConfig", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "ou-attacker",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
});
|
||||
describe("resolveFeishuGroupSenderActivationIngressAccess", () => {
|
||||
async function senderDecision(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string;
|
||||
}) {
|
||||
return (
|
||||
await resolveFeishuGroupSenderActivationIngressAccess({
|
||||
cfg: createCfg({}),
|
||||
accountId: "default",
|
||||
chatId: "oc_group",
|
||||
allowFrom: params.allowFrom,
|
||||
senderOpenId: params.senderOpenId,
|
||||
senderUserId: params.senderUserId,
|
||||
requireMention: false,
|
||||
mentionedBot: true,
|
||||
})
|
||||
).senderAccess.decision;
|
||||
}
|
||||
|
||||
it("allows provider-prefixed wildcard entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("allows provider-prefixed wildcard entries", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["feishu:*", "lark:*"],
|
||||
senderId: "ou_anyone",
|
||||
senderOpenId: "ou_anyone",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
|
||||
it("treats typed wildcard aliases as bare wildcards", () => {
|
||||
for (const wildcard of [
|
||||
"chat:*",
|
||||
"group:*",
|
||||
"channel:*",
|
||||
"user:*",
|
||||
"dm:*",
|
||||
"open_id:*",
|
||||
"feishu:user:*",
|
||||
]) {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [wildcard],
|
||||
senderId: "ou_anyone",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches normalized ID entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["feishu:user:ou_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("accepts repeated provider prefixes for legacy allowlist entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("matches normalized immutable user ID entries", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["feishu:feishu:user:ou_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
senderOpenId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
|
||||
it("does not fold opaque IDs to lowercase", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["user:OU_ALLOWED"],
|
||||
senderId: "ou_ALLOWED",
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("keeps user and chat allowlist namespaces distinct", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("keeps user and chat allowlist namespaces distinct", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["user:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
senderOpenId: "oc_group_123",
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
).resolves.toBe("block");
|
||||
});
|
||||
|
||||
it("supports user_id as an additional immutable sender candidate", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
it("supports user_id as an additional immutable sender candidate", async () => {
|
||||
await expect(
|
||||
senderDecision({
|
||||
allowFrom: ["on_user_123"],
|
||||
senderId: "ou_other",
|
||||
senderIds: ["on_user_123"],
|
||||
senderOpenId: "ou_other",
|
||||
senderUserId: "on_user_123",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:on_user_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("auto-detects bare open_id entries as user allowlist matches", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["ou_BARE"],
|
||||
senderId: "ou_BARE",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "user:ou_BARE", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("auto-detects bare chat_id entries as chat allowlist matches", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "chat:oc_group_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("does not authorize based on display-name collision", () => {
|
||||
const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
|
||||
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [victimOpenId],
|
||||
senderId: "ou_attacker_real_open_id",
|
||||
senderIds: ["on_attacker_user_id"],
|
||||
senderName: victimOpenId,
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFeishuGroupAllowed", () => {
|
||||
it("matches group IDs with chat: prefix", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["chat:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows group when groupPolicy is 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats 'allowall' as equivalent to 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowall",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'disabled'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["oc_group_999"],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
).resolves.toBe("allow");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,38 +2,42 @@ import {
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ChannelIngressIdentitySubjectInput,
|
||||
type ResolveChannelMessageIngressParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { AllowlistMatch, ChannelGroupContext } from "../runtime-api.js";
|
||||
import type { ChannelGroupContext } from "../runtime-api.js";
|
||||
import { detectIdType } from "./targets.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">;
|
||||
type FeishuDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type FeishuGroupPolicy = "open" | "allowlist" | "disabled" | "allowall";
|
||||
type NormalizedFeishuGroupPolicy = Exclude<FeishuGroupPolicy, "allowall">;
|
||||
|
||||
const FEISHU_PROVIDER_PREFIX_RE = /^(feishu|lark):/i;
|
||||
|
||||
function stripRepeatedFeishuProviderPrefixes(raw: string): string {
|
||||
let normalized = raw.trim();
|
||||
while (FEISHU_PROVIDER_PREFIX_RE.test(normalized)) {
|
||||
normalized = normalized.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function canonicalizeFeishuAllowlistKey(params: { kind: "chat" | "user"; value: string }): string {
|
||||
const value = params.value.trim();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
// A typed wildcard (`chat:*`, `user:*`, `open_id:*`, `dm:*`, `group:*`,
|
||||
// `channel:*`) collapses to the bare wildcard so it keeps matching across
|
||||
// both kinds, preserving the prior `normalizeFeishuTarget`-based behavior.
|
||||
if (value === "*") {
|
||||
return "*";
|
||||
}
|
||||
return `${params.kind}:${value}`;
|
||||
}
|
||||
const FEISHU_TYPED_PREFIX_RE = /^(chat|group|channel|user|dm|open_id):/i;
|
||||
const FEISHU_ID_KIND = "plugin:feishu-id" as const;
|
||||
const feishuIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "feishu-id",
|
||||
kind: FEISHU_ID_KIND,
|
||||
normalize: normalizeFeishuAllowEntry,
|
||||
sensitivity: "pii",
|
||||
aliases: [
|
||||
{
|
||||
key: "feishu-alt-id",
|
||||
kind: FEISHU_ID_KIND,
|
||||
normalizeEntry: () => null,
|
||||
normalizeSubject: normalizeFeishuAllowEntry,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeFeishuAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex }) => `feishu-entry-${entryIndex + 1}`,
|
||||
});
|
||||
|
||||
function normalizeFeishuAllowEntry(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
@@ -44,7 +48,10 @@ function normalizeFeishuAllowEntry(raw: string): string {
|
||||
return "*";
|
||||
}
|
||||
|
||||
const withoutProviderPrefix = stripRepeatedFeishuProviderPrefixes(trimmed);
|
||||
let withoutProviderPrefix = trimmed;
|
||||
while (FEISHU_PROVIDER_PREFIX_RE.test(withoutProviderPrefix)) {
|
||||
withoutProviderPrefix = withoutProviderPrefix.replace(FEISHU_PROVIDER_PREFIX_RE, "").trim();
|
||||
}
|
||||
if (withoutProviderPrefix === "*") {
|
||||
return "*";
|
||||
}
|
||||
@@ -52,77 +59,170 @@ function normalizeFeishuAllowEntry(raw: string): string {
|
||||
if (!lowered) {
|
||||
return "";
|
||||
}
|
||||
// Lowercase for prefix detection only; preserve the original ID casing in the
|
||||
// canonicalized key. Sender candidates pass through this same path so allowlist
|
||||
// entries and runtime IDs stay normalized symmetrically.
|
||||
if (
|
||||
lowered.startsWith("chat:") ||
|
||||
lowered.startsWith("group:") ||
|
||||
lowered.startsWith("channel:")
|
||||
) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "chat",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
}
|
||||
if (lowered.startsWith("user:") || lowered.startsWith("dm:")) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
}
|
||||
if (lowered.startsWith("open_id:")) {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix.slice(withoutProviderPrefix.indexOf(":") + 1),
|
||||
});
|
||||
const prefixed = lowered.match(FEISHU_TYPED_PREFIX_RE);
|
||||
if (prefixed?.[1]) {
|
||||
const kind = ["chat", "group", "channel"].includes(prefixed[1]) ? "chat" : "user";
|
||||
const value = withoutProviderPrefix.slice(prefixed[0].length).trim();
|
||||
return value === "*" ? "*" : value ? `${kind}:${value}` : "";
|
||||
}
|
||||
|
||||
const detectedType = detectIdType(withoutProviderPrefix);
|
||||
if (detectedType === "chat_id") {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "chat",
|
||||
value: withoutProviderPrefix,
|
||||
});
|
||||
return `chat:${withoutProviderPrefix}`;
|
||||
}
|
||||
if (detectedType === "open_id" || detectedType === "user_id") {
|
||||
return canonicalizeFeishuAllowlistKey({
|
||||
kind: "user",
|
||||
value: withoutProviderPrefix,
|
||||
});
|
||||
return `user:${withoutProviderPrefix}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function resolveFeishuAllowlistMatch(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderIds?: Array<string | null | undefined>;
|
||||
senderName?: string | null;
|
||||
}): FeishuAllowlistMatch {
|
||||
const allowFrom = params.allowFrom
|
||||
.map((entry) => normalizeFeishuAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
if (allowFrom.length === 0) {
|
||||
return { allowed: false };
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
||||
}
|
||||
function normalizeFeishuDmPolicy(policy: string | null | undefined): FeishuDmPolicy {
|
||||
return policy === "open" ||
|
||||
policy === "pairing" ||
|
||||
policy === "allowlist" ||
|
||||
policy === "disabled"
|
||||
? policy
|
||||
: "pairing";
|
||||
}
|
||||
|
||||
// Feishu allowlists are ID-based; mutable display names must never grant access.
|
||||
const senderCandidates = [params.senderId, ...(params.senderIds ?? [])]
|
||||
.map((entry) => normalizeFeishuAllowEntry(entry ?? ""))
|
||||
.filter(Boolean);
|
||||
function normalizeFeishuGroupPolicy(policy: FeishuGroupPolicy): NormalizedFeishuGroupPolicy {
|
||||
return policy === "allowall" ? "open" : policy;
|
||||
}
|
||||
|
||||
for (const senderId of senderCandidates) {
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
}
|
||||
function createFeishuIngressSubject(params: {
|
||||
primaryId?: string | null;
|
||||
alternateIds?: Array<string | null | undefined>;
|
||||
}): ChannelIngressIdentitySubjectInput {
|
||||
const ids = [params.primaryId, ...(params.alternateIds ?? [])]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
return {
|
||||
stableId: ids[0],
|
||||
aliases: {
|
||||
"feishu-alt-id": ids[1],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: false };
|
||||
function createFeishuIngressResolver(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
readAllowFromStore?: ResolveChannelMessageIngressParams["readStoreAllowFrom"];
|
||||
}) {
|
||||
return createChannelIngressResolver({
|
||||
channelId: "feishu",
|
||||
accountId: normalizeAccountId(params.accountId) ?? "default",
|
||||
identity: feishuIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
...(params.readAllowFromStore ? { readStoreAllowFrom: params.readAllowFromStore } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuDmIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
dmPolicy?: string | null;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
readAllowFromStore?: () => Promise<Array<string | number>>;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string | null;
|
||||
conversationId: string;
|
||||
mayPair: boolean;
|
||||
command?: { hasControlCommand: boolean };
|
||||
}) {
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
readAllowFromStore: params.readAllowFromStore,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.senderOpenId,
|
||||
alternateIds: [params.senderUserId],
|
||||
}),
|
||||
conversation: {
|
||||
kind: "direct",
|
||||
id: params.conversationId,
|
||||
},
|
||||
event: {
|
||||
mayPair: params.mayPair,
|
||||
},
|
||||
dmPolicy: normalizeFeishuDmPolicy(params.dmPolicy),
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: params.allowFrom ?? [],
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuGroupConversationIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
chatId: string;
|
||||
groupPolicy: FeishuGroupPolicy;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
groupExplicitlyConfigured?: boolean;
|
||||
}) {
|
||||
const groupPolicy = normalizeFeishuGroupPolicy(params.groupPolicy);
|
||||
const groupAllowFrom =
|
||||
groupPolicy === "allowlist" && params.groupExplicitlyConfigured
|
||||
? [...(params.groupAllowFrom ?? []), params.chatId]
|
||||
: (params.groupAllowFrom ?? []);
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.chatId,
|
||||
}),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: params.chatId,
|
||||
},
|
||||
dmPolicy: "disabled",
|
||||
groupPolicy,
|
||||
groupAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFeishuGroupSenderActivationIngressAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
chatId: string;
|
||||
allowFrom?: Array<string | number> | null;
|
||||
senderOpenId: string;
|
||||
senderUserId?: string | null;
|
||||
requireMention: boolean;
|
||||
mentionedBot: boolean;
|
||||
command?: { hasControlCommand: boolean };
|
||||
}) {
|
||||
const groupAllowFrom = params.allowFrom ?? [];
|
||||
return await createFeishuIngressResolver({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
}).message({
|
||||
subject: createFeishuIngressSubject({
|
||||
primaryId: params.senderOpenId,
|
||||
alternateIds: [params.senderUserId],
|
||||
}),
|
||||
conversation: {
|
||||
kind: "group",
|
||||
id: params.chatId,
|
||||
},
|
||||
dmPolicy: "disabled",
|
||||
groupPolicy: groupAllowFrom.length > 0 ? "allowlist" : "open",
|
||||
groupAllowFrom,
|
||||
mentionFacts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.mentionedBot,
|
||||
},
|
||||
policy: {
|
||||
activation: {
|
||||
requireMention: params.requireMention,
|
||||
allowTextCommands: false,
|
||||
},
|
||||
},
|
||||
...(params.command ? { command: params.command } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveFeishuGroupConfig(params: { cfg?: FeishuConfig; groupId?: string | null }) {
|
||||
@@ -181,21 +281,6 @@ export function resolveFeishuGroupToolPolicy(params: ChannelGroupContext) {
|
||||
return groupConfig?.tools;
|
||||
}
|
||||
|
||||
export function isFeishuGroupAllowed(params: {
|
||||
groupPolicy: "open" | "allowlist" | "disabled" | "allowall";
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderIds?: Array<string | null | undefined>;
|
||||
senderName?: string | null;
|
||||
}): boolean {
|
||||
return evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: params.groupPolicy === "allowall" ? "open" : params.groupPolicy,
|
||||
groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: () => resolveFeishuAllowlistMatch(params).allowed,
|
||||
}).allowed;
|
||||
}
|
||||
|
||||
export function resolveFeishuReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
@@ -22,11 +22,6 @@ export {
|
||||
} from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const createChannelPairingController = vi.hoisted(() => vi.fn());
|
||||
const evaluateGroupRouteAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistProviderRuntimeGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const resolveDmGroupAccessWithLists = vi.hoisted(() => vi.fn());
|
||||
const resolveInboundMentionDecision = vi.hoisted(() => vi.fn());
|
||||
const resolveSenderScopedGroupPolicy = vi.hoisted(() => vi.fn());
|
||||
const warnMissingProviderGroupPolicyFallbackOnce = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
|
||||
resolveInboundMentionDecision,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
GROUP_POLICY_BLOCKED_LABEL: { space: "space" },
|
||||
createChannelPairingController,
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
}));
|
||||
|
||||
@@ -54,10 +43,6 @@ function primeCommonDefaults() {
|
||||
groupPolicy: "allowlist",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
resolveSenderScopedGroupPolicy.mockImplementation(({ groupPolicy }) => groupPolicy);
|
||||
evaluateGroupRouteAccessForPolicy.mockReturnValue({
|
||||
allowed: true,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce.mockReturnValue(undefined);
|
||||
}
|
||||
|
||||
@@ -74,23 +59,11 @@ const defaultSender = {
|
||||
|
||||
let applyGoogleChatInboundAccessPolicy: typeof import("./monitor-access.js").applyGoogleChatInboundAccessPolicy;
|
||||
|
||||
function allowInboundGroupTraffic(options?: {
|
||||
effectiveGroupAllowFrom?: string[];
|
||||
effectiveWasMentioned?: boolean;
|
||||
}) {
|
||||
function allowInboundGroupTraffic() {
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: options?.effectiveGroupAllowFrom ?? ["users/alice"],
|
||||
});
|
||||
resolveInboundMentionDecision.mockReturnValue({
|
||||
shouldSkip: false,
|
||||
effectiveWasMentioned: options?.effectiveWasMentioned ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyInboundAccessPolicy(
|
||||
@@ -119,12 +92,66 @@ describe("googlechat inbound access policy", () => {
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/channel-inbound");
|
||||
vi.doUnmock("../runtime-api.js");
|
||||
vi.doUnmock("./api.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks raw email entries when dangerous name matching is disabled",
|
||||
allowNameMatching: false,
|
||||
allowFrom: ["jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "matches raw email entries when dangerous name matching is enabled",
|
||||
allowNameMatching: true,
|
||||
allowFrom: ["jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "does not treat users/<email> entries as email allowlist entries",
|
||||
allowNameMatching: true,
|
||||
allowFrom: ["users/jane@example.com"],
|
||||
senderId: "users/123",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "matches user id entries",
|
||||
allowNameMatching: false,
|
||||
allowFrom: ["users/abc"],
|
||||
senderId: "users/abc",
|
||||
ok: true,
|
||||
},
|
||||
])("$name", async ({ allowNameMatching, allowFrom, senderId, ok }) => {
|
||||
primeCommonDefaults();
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(allowNameMatching);
|
||||
createChannelPairingController.mockReturnValue({
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
isGroup: false,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
senderId,
|
||||
senderEmail: "Jane@Example.com",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok });
|
||||
});
|
||||
|
||||
it("issues a pairing challenge for unauthorized DMs in pairing mode", async () => {
|
||||
primeCommonDefaults();
|
||||
const now = new Date("2026-05-09T06:35:00.000Z").getTime();
|
||||
@@ -136,12 +163,6 @@ describe("googlechat inbound access policy", () => {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
issueChallenge,
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "pairing",
|
||||
reason: "pairing_required",
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
sendGoogleChatMessage.mockResolvedValue({ ok: true });
|
||||
|
||||
const statusSink = vi.fn();
|
||||
@@ -269,11 +290,6 @@ describe("googlechat inbound access policy", () => {
|
||||
readAllowFromStore,
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveDmGroupAccessWithLists.mockReturnValue({
|
||||
decision: "allow",
|
||||
effectiveAllowFrom: ["accessGroup:operators", "users/alice"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
@@ -303,29 +319,23 @@ describe("googlechat inbound access policy", () => {
|
||||
ok: true,
|
||||
});
|
||||
|
||||
expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowFrom: ["accessGroup:operators", "users/alice"],
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves allowlist group policy when a routed space has no sender allowlist", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic({
|
||||
effectiveGroupAllowFrom: [],
|
||||
effectiveWasMentioned: false,
|
||||
});
|
||||
resolveSenderScopedGroupPolicy.mockReturnValue("open");
|
||||
resolveSenderScopedGroupPolicy.mockClear();
|
||||
resolveDmGroupAccessWithLists.mockClear();
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: ["users/alice"],
|
||||
},
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
enabled: true,
|
||||
@@ -333,38 +343,70 @@ describe("googlechat inbound access policy", () => {
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
commandAuthorized: undefined,
|
||||
effectiveWasMentioned: false,
|
||||
groupSystemPrompt: undefined,
|
||||
});
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(resolveSenderScopedGroupPolicy).not.toHaveBeenCalled();
|
||||
expect(resolveDmGroupAccessWithLists).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
}),
|
||||
expect(logVerbose).toHaveBeenCalledWith(
|
||||
"drop group message (sender policy blocked, reason=groupPolicy=allowlist (empty allowlist), space=spaces/AAA)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps configured space users sender-scoped when group policy is open", async () => {
|
||||
primeCommonDefaults();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
allowInboundGroupTraffic();
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
users: ["users/bob"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
logVerbose,
|
||||
}),
|
||||
).resolves.toEqual({ ok: false });
|
||||
|
||||
expect(logVerbose).toHaveBeenCalledWith("drop group message (sender not allowed, users/alice)");
|
||||
});
|
||||
|
||||
it("drops unauthorized group control commands", async () => {
|
||||
primeCommonDefaults();
|
||||
allowInboundGroupTraffic({
|
||||
effectiveGroupAllowFrom: [],
|
||||
effectiveWasMentioned: false,
|
||||
allowInboundGroupTraffic();
|
||||
resolveAllowlistProviderRuntimeGroupPolicy.mockReturnValue({
|
||||
groupPolicy: "open",
|
||||
providerMissingFallbackApplied: false,
|
||||
});
|
||||
const core = createCore();
|
||||
core.channel.commands.shouldComputeCommandAuthorized.mockReturnValue(true);
|
||||
core.channel.commands.resolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
||||
core.channel.commands.isControlCommandMessage.mockReturnValue(true);
|
||||
const logVerbose = vi.fn();
|
||||
|
||||
await expect(
|
||||
applyInboundAccessPolicy({
|
||||
core: core as never,
|
||||
account: {
|
||||
accountId: "default",
|
||||
config: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
rawBody: "/admin",
|
||||
logVerbose,
|
||||
}),
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { expandAllowFromWithAccessGroups } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createChannelPairingController,
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage } from "./api.js";
|
||||
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
@@ -30,7 +30,57 @@ function normalizeUserId(raw?: string | null): string {
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
export { isSenderAllowed } from "./sender-allow.js";
|
||||
type GoogleChatDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
|
||||
type GoogleChatGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
const GOOGLECHAT_EMAIL_KIND = "plugin:googlechat-email" as const;
|
||||
|
||||
function normalizeEntryValue(raw?: string | null): string {
|
||||
return normalizeLowercaseStringOrEmpty(raw ?? "");
|
||||
}
|
||||
|
||||
function normalizeGoogleChatStableEntry(entry: string): string | null {
|
||||
const withoutProvider = normalizeEntryValue(entry).replace(
|
||||
/^(googlechat|google-chat|gchat):/i,
|
||||
"",
|
||||
);
|
||||
if (!withoutProvider) {
|
||||
return null;
|
||||
}
|
||||
return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider;
|
||||
}
|
||||
|
||||
function normalizeGoogleChatEmailEntry(entry: string): string | null {
|
||||
const withoutProvider = normalizeEntryValue(entry).replace(
|
||||
/^(googlechat|google-chat|gchat):/i,
|
||||
"",
|
||||
);
|
||||
if (withoutProvider.startsWith("users/")) {
|
||||
return null;
|
||||
}
|
||||
const stable = normalizeGoogleChatStableEntry(entry);
|
||||
return stable?.includes("@") ? stable : null;
|
||||
}
|
||||
|
||||
const googleChatIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "sender-id",
|
||||
normalizeEntry: normalizeGoogleChatStableEntry,
|
||||
normalizeSubject: normalizeUserId,
|
||||
aliases: [
|
||||
{
|
||||
key: "email",
|
||||
kind: GOOGLECHAT_EMAIL_KIND,
|
||||
normalizeEntry: normalizeGoogleChatEmailEntry,
|
||||
normalizeSubject: normalizeEntryValue,
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeEntryValue(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
fieldKey === "stableId"
|
||||
? `entry-${entryIndex + 1}:user`
|
||||
: `entry-${entryIndex + 1}:${fieldKey}`,
|
||||
});
|
||||
|
||||
type GoogleChatGroupEntry = {
|
||||
requireMention?: boolean;
|
||||
@@ -205,151 +255,160 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
});
|
||||
const groupEntry = groupConfigResolved.entry;
|
||||
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
|
||||
const isGoogleChatSenderAllowed = (_senderId: string, allowFrom: string[]) =>
|
||||
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching);
|
||||
const expandedGroupUsers = await expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: groupUsers,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
});
|
||||
let effectiveWasMentioned: boolean | undefined;
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const rawConfigAllowFrom = normalizeStringEntries(account.config.dm?.allowFrom);
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const groupActivation = (() => {
|
||||
if (!isGroup) {
|
||||
return undefined;
|
||||
}
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const mentionInfo = extractMentionInfo(message.annotations ?? [], account.config.botUser);
|
||||
return {
|
||||
requireMention,
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "googlechat",
|
||||
}),
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
wasMentioned: mentionInfo.wasMentioned,
|
||||
hasAnyMention: mentionInfo.hasAnyMention,
|
||||
};
|
||||
})();
|
||||
const command = {
|
||||
hasControlCommand: groupActivation?.hasControlCommand ?? shouldComputeAuth,
|
||||
groupOwnerAllowFrom: "none" as const,
|
||||
};
|
||||
const groupAllowFrom = normalizeStringEntries(groupUsers);
|
||||
const senderGroupPolicy =
|
||||
groupConfigResolved.allowlistConfigured && groupAllowFrom.length === 0
|
||||
? groupPolicy
|
||||
: groupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: groupAllowFrom.length > 0
|
||||
? "allowlist"
|
||||
: "open";
|
||||
const route = channelIngressRoutes(
|
||||
isGroup &&
|
||||
groupPolicy !== "disabled" &&
|
||||
groupEntry?.enabled === false && {
|
||||
id: "googlechat:space",
|
||||
enabled: false,
|
||||
matched: true,
|
||||
matchId: "googlechat-space",
|
||||
blockReason: "route_disabled",
|
||||
},
|
||||
isGroup &&
|
||||
groupPolicy === "allowlist" &&
|
||||
groupEntry?.enabled !== false &&
|
||||
!groupConfigResolved.allowlistConfigured && {
|
||||
id: "googlechat:space",
|
||||
allowed: false,
|
||||
blockReason: "empty_allowlist",
|
||||
},
|
||||
isGroup &&
|
||||
groupPolicy === "allowlist" &&
|
||||
groupEntry?.enabled !== false &&
|
||||
groupConfigResolved.allowlistConfigured && {
|
||||
id: "googlechat:space",
|
||||
senderPolicy: "deny-when-empty" as const,
|
||||
...(groupEntry ? { senderAllowFromSource: "effective-group" as const } : {}),
|
||||
allowed: Boolean(groupEntry),
|
||||
matchId: "googlechat-space",
|
||||
blockReason: groupEntry ? "sender_empty_allowlist" : "route_not_allowlisted",
|
||||
},
|
||||
);
|
||||
const resolvedAccess = await createChannelIngressResolver({
|
||||
channelId: "googlechat",
|
||||
accountId: account.accountId,
|
||||
identity: googleChatIngressIdentity,
|
||||
cfg: config,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
}).message({
|
||||
subject: {
|
||||
stableId: senderId,
|
||||
aliases: { email: senderEmail },
|
||||
},
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: spaceId,
|
||||
},
|
||||
route,
|
||||
allowFrom: rawConfigAllowFrom,
|
||||
groupAllowFrom,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
...(groupActivation
|
||||
? {
|
||||
activation: {
|
||||
requireMention: groupActivation.requireMention,
|
||||
allowTextCommands: groupActivation.allowTextCommands,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(groupActivation == null
|
||||
? {}
|
||||
: {
|
||||
mentionFacts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: groupActivation.wasMentioned,
|
||||
hasAnyMention: groupActivation.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
}),
|
||||
command,
|
||||
});
|
||||
const senderAccess = resolvedAccess.senderAccess;
|
||||
const commandAuthorized = resolvedAccess.commandAccess.requested
|
||||
? resolvedAccess.commandAccess.authorized
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfigResolved.deprecatedNameMatch) {
|
||||
logVerbose(`drop group message (deprecated mutable group key matched, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
|
||||
const routeAccess = evaluateGroupRouteAccessForPolicy({
|
||||
groupPolicy,
|
||||
routeAllowlistConfigured: groupAllowlistConfigured,
|
||||
routeMatched: Boolean(groupEntry),
|
||||
routeEnabled: groupEntry?.enabled !== false,
|
||||
});
|
||||
if (!routeAccess.allowed) {
|
||||
if (routeAccess.reason === "disabled") {
|
||||
logVerbose(`drop group message (groupPolicy=disabled, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "empty_allowlist") {
|
||||
const routeBlockReason = resolvedAccess.routeAccess.reason;
|
||||
if (routeBlockReason && routeBlockReason !== "sender_empty_allowlist") {
|
||||
if (routeBlockReason === "empty_allowlist") {
|
||||
logVerbose(`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "route_not_allowlisted") {
|
||||
} else if (routeBlockReason === "route_not_allowlisted") {
|
||||
logVerbose(`drop group message (not allowlisted, space=${spaceId})`);
|
||||
} else if (routeAccess.reason === "route_disabled") {
|
||||
} else if (routeBlockReason === "route_disabled") {
|
||||
logVerbose(`drop group message (space disabled, space=${spaceId})`);
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (expandedGroupUsers.length > 0) {
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, expandedGroupUsers);
|
||||
const ok = isSenderAllowed(senderId, senderEmail, expandedGroupUsers, allowNameMatching);
|
||||
if (!ok) {
|
||||
logVerbose(`drop group message (sender not allowed, ${senderId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
if (senderAccess.effectiveGroupAllowFrom.length > 0 && senderAccess.decision !== "allow") {
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, senderAccess.effectiveGroupAllowFrom);
|
||||
logVerbose(`drop group message (sender not allowed, ${senderId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dm?.policy ?? "pairing";
|
||||
const rawConfigAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
|
||||
const normalizedGroupUsers = expandedGroupUsers;
|
||||
const senderGroupPolicy =
|
||||
groupConfigResolved.allowlistConfigured && normalizedGroupUsers.length === 0
|
||||
? groupPolicy
|
||||
: resolveSenderScopedGroupPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: normalizedGroupUsers,
|
||||
});
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
||||
const storeAllowFrom =
|
||||
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
||||
? await pairing.readAllowFromStore().catch(() => [])
|
||||
: [];
|
||||
const [configAllowFrom, effectiveStoreAllowFrom] = await Promise.all([
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: rawConfigAllowFrom,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
}),
|
||||
expandAllowFromWithAccessGroups({
|
||||
cfg: config,
|
||||
allowFrom: storeAllowFrom,
|
||||
channel: "googlechat",
|
||||
accountId: account.accountId,
|
||||
senderId,
|
||||
isSenderAllowed: isGoogleChatSenderAllowed,
|
||||
}),
|
||||
]);
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: normalizedGroupUsers,
|
||||
storeAllowFrom: effectiveStoreAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isSenderAllowed(senderId, senderEmail, allowFrom, allowNameMatching),
|
||||
});
|
||||
const effectiveAllowFrom = access.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
|
||||
const effectiveAllowFrom = senderAccess.effectiveAllowFrom;
|
||||
warnDeprecatedUsersEmailEntries(logVerbose, effectiveAllowFrom);
|
||||
const commandAllowFrom = isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom;
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(
|
||||
senderId,
|
||||
senderEmail,
|
||||
commandAllowFrom,
|
||||
allowNameMatching,
|
||||
);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (isGroup) {
|
||||
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
|
||||
const annotations = message.annotations ?? [];
|
||||
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "googlechat",
|
||||
});
|
||||
const mentionDecision = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: mentionInfo.wasMentioned,
|
||||
hasAnyMention: mentionInfo.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
policy: {
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
},
|
||||
});
|
||||
effectiveWasMentioned = mentionDecision.effectiveWasMentioned;
|
||||
if (mentionDecision.shouldSkip) {
|
||||
if (isGroup && resolvedAccess.activationAccess.ran) {
|
||||
effectiveWasMentioned = resolvedAccess.activationAccess.effectiveWasMentioned;
|
||||
if (resolvedAccess.activationAccess.shouldSkip) {
|
||||
logVerbose(`drop group message (mention required, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
if (isGroup && access.decision !== "allow") {
|
||||
logVerbose(
|
||||
`drop group message (sender policy blocked, reason=${access.reason}, space=${spaceId})`,
|
||||
);
|
||||
if (isGroup && senderAccess.decision !== "allow") {
|
||||
const reason =
|
||||
resolvedAccess.ingress.reasonCode === "route_sender_empty"
|
||||
? "groupPolicy=allowlist (empty allowlist)"
|
||||
: senderAccess.reasonCode;
|
||||
logVerbose(`drop group message (sender policy blocked, reason=${reason}, space=${spaceId})`);
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
@@ -359,8 +418,8 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
if (access.decision !== "allow") {
|
||||
if (access.decision === "pairing") {
|
||||
if (senderAccess.decision !== "allow") {
|
||||
if (senderAccess.decision === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId,
|
||||
senderIdLine: `Your Google Chat user id: ${senderId}`,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
function isEmailLike(value: string): boolean {
|
||||
// Keep this intentionally loose; allowlists are user-provided config.
|
||||
return value.includes("@");
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSenderId = normalizeUserId(senderId);
|
||||
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(entry);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
|
||||
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
if (withoutPrefix.startsWith("users/")) {
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
@@ -158,29 +157,6 @@ describe("googlechat group policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches user id entries", () => {
|
||||
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
authTesting.resetGoogleChatAuthForTests();
|
||||
|
||||
@@ -29,7 +29,7 @@ function baseCfg(): OpenClawConfig {
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
function resolve(params: {
|
||||
async function resolve(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
message: IMessagePayload;
|
||||
storeAllowFrom?: string[];
|
||||
@@ -53,7 +53,7 @@ function resolve(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDispatchDecision(params: {
|
||||
async function resolveDispatchDecision(params: {
|
||||
cfg: OpenClawConfig;
|
||||
message: IMessagePayload;
|
||||
groupHistories?: Parameters<typeof resolveIMessageInboundDecision>[0]["groupHistories"];
|
||||
@@ -63,7 +63,7 @@ function resolveDispatchDecision(params: {
|
||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
}) {
|
||||
const groupHistories = params.groupHistories ?? new Map();
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg: params.cfg,
|
||||
accountId: "default",
|
||||
message: params.message,
|
||||
@@ -85,9 +85,12 @@ function resolveDispatchDecision(params: {
|
||||
return { decision, groupHistories };
|
||||
}
|
||||
|
||||
function buildDispatchContextPayload(params: { cfg: OpenClawConfig; message: IMessagePayload }) {
|
||||
async function buildDispatchContextPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
message: IMessagePayload;
|
||||
}) {
|
||||
const { cfg, message } = params;
|
||||
const { decision, groupHistories } = resolveDispatchDecision({ cfg, message });
|
||||
const { decision, groupHistories } = await resolveDispatchDecision({ cfg, message });
|
||||
|
||||
const { ctxPayload } = buildIMessageInboundContext({
|
||||
cfg,
|
||||
@@ -125,8 +128,8 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops group messages without mention by default", () => {
|
||||
const decision = resolve({
|
||||
it("drops group messages without mention by default", async () => {
|
||||
const decision = await resolve({
|
||||
message: {
|
||||
id: 1,
|
||||
chat_id: 99,
|
||||
@@ -143,7 +146,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(decision.reason).toBe("no mention");
|
||||
});
|
||||
|
||||
it("dispatches group messages with mention and builds a group envelope", () => {
|
||||
it("dispatches group messages with mention and builds a group envelope", async () => {
|
||||
const cfg = baseCfg();
|
||||
const message: IMessagePayload = {
|
||||
id: 3,
|
||||
@@ -155,7 +158,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
chat_name: "Lobster Squad",
|
||||
participants: ["+1555", "+1556"],
|
||||
};
|
||||
const ctxPayload = buildDispatchContextPayload({ cfg, message });
|
||||
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
|
||||
|
||||
expect(ctxPayload.ChatType).toBe("group");
|
||||
expect(ctxPayload.SessionKey).toBe("agent:main:imessage:group:42");
|
||||
@@ -164,7 +167,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.To).toBe("chat_id:42");
|
||||
});
|
||||
|
||||
it("uses short message ids in context and keeps the full guid for actions", () => {
|
||||
it("uses short message ids in context and keeps the full guid for actions", async () => {
|
||||
const cfg = baseCfg();
|
||||
const message: IMessagePayload = {
|
||||
id: 3,
|
||||
@@ -179,13 +182,13 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
chat_name: "Lobster Squad",
|
||||
participants: ["+1555", "+1556"],
|
||||
};
|
||||
const ctxPayload = buildDispatchContextPayload({ cfg, message });
|
||||
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
|
||||
|
||||
expect(ctxPayload.MessageSid).toBe("1");
|
||||
expect(ctxPayload.MessageSidFull).toBe("full-message-guid");
|
||||
});
|
||||
|
||||
it("includes reply-to context fields + suffix", () => {
|
||||
it("includes reply-to context fields + suffix", async () => {
|
||||
const cfg = baseCfg();
|
||||
const message: IMessagePayload = {
|
||||
id: 5,
|
||||
@@ -198,7 +201,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
reply_to_text: "original message",
|
||||
reply_to_sender: "+15559998888",
|
||||
};
|
||||
const ctxPayload = buildDispatchContextPayload({ cfg, message });
|
||||
const ctxPayload = await buildDispatchContextPayload({ cfg, message });
|
||||
|
||||
expect(ctxPayload.ReplyToId).toBe("9001");
|
||||
expect(ctxPayload.ReplyToBody).toBe("original message");
|
||||
@@ -207,7 +210,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.Body ?? "").toContain("original message");
|
||||
});
|
||||
|
||||
it("drops group reply context from non-allowlisted senders in allowlist mode", () => {
|
||||
it("drops group reply context from non-allowlisted senders in allowlist mode", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
@@ -225,7 +228,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
reply_to_text: "blocked quote",
|
||||
reply_to_sender: "+15559998888",
|
||||
};
|
||||
const { decision, groupHistories } = resolveDispatchDecision({
|
||||
const { decision, groupHistories } = await resolveDispatchDecision({
|
||||
cfg,
|
||||
message,
|
||||
allowFrom: ["*"],
|
||||
@@ -246,7 +249,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.Body ?? "").not.toContain("[Replying to");
|
||||
});
|
||||
|
||||
it("keeps group reply context in allowlist_quote mode", () => {
|
||||
it("keeps group reply context in allowlist_quote mode", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
@@ -264,7 +267,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
reply_to_text: "quoted context",
|
||||
reply_to_sender: "+15559998888",
|
||||
};
|
||||
const { decision, groupHistories } = resolveDispatchDecision({
|
||||
const { decision, groupHistories } = await resolveDispatchDecision({
|
||||
cfg,
|
||||
message,
|
||||
allowFrom: ["*"],
|
||||
@@ -285,7 +288,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(ctxPayload.Body ?? "").toContain("[Replying to +15559998888 id:9001]");
|
||||
});
|
||||
|
||||
it("treats configured chat_id as a group session even when is_group is false", () => {
|
||||
it("treats configured chat_id as a group session even when is_group is false", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
@@ -300,19 +303,19 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
text: "hello",
|
||||
is_group: false,
|
||||
};
|
||||
const { decision } = resolveDispatchDecision({ cfg, message, groupHistories });
|
||||
const { decision } = await resolveDispatchDecision({ cfg, message, groupHistories });
|
||||
expect(decision.isGroup).toBe(true);
|
||||
expect(decision.route.sessionKey).toBe("agent:main:imessage:group:2");
|
||||
});
|
||||
|
||||
it("allows group messages when requireMention is true but no mentionPatterns exist", () => {
|
||||
it("allows group messages when requireMention is true but no mentionPatterns exist", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.messages ??= {};
|
||||
cfg.messages.groupChat ??= {};
|
||||
cfg.messages.groupChat.mentionPatterns = [];
|
||||
|
||||
const groupHistories = new Map();
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
@@ -337,14 +340,14 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("blocks group messages when imessage.groups is set without a wildcard", () => {
|
||||
it("blocks group messages when imessage.groups is set without a wildcard", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groups = { "99": { requireMention: false } };
|
||||
|
||||
const groupHistories = new Map();
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
@@ -369,14 +372,14 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(decision.kind).toBe("drop");
|
||||
});
|
||||
|
||||
it("honors group allowlist and ignores pairing-store senders in groups", () => {
|
||||
it("honors group allowlist and ignores pairing-store senders in groups", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "allowlist";
|
||||
|
||||
const groupHistories = new Map();
|
||||
const denied = resolveIMessageInboundDecision({
|
||||
const denied = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
@@ -400,7 +403,7 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
});
|
||||
expect(denied.kind).toBe("drop");
|
||||
|
||||
const allowed = resolveIMessageInboundDecision({
|
||||
const allowed = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
@@ -425,14 +428,14 @@ describe("imessage monitor gating + envelope builders", () => {
|
||||
expect(allowed.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("blocks group messages when groupPolicy is disabled", () => {
|
||||
it("blocks group messages when groupPolicy is disabled", async () => {
|
||||
const cfg = baseCfg();
|
||||
cfg.channels ??= {};
|
||||
cfg.channels.imessage ??= {};
|
||||
cfg.channels.imessage.groupPolicy = "disabled";
|
||||
|
||||
const groupHistories = new Map();
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
message: {
|
||||
|
||||
@@ -52,8 +52,8 @@ function buildDecisionParams(overrides: Partial<DecisionParams> = {}): DecisionP
|
||||
}
|
||||
|
||||
describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
it("captures the per-chat_id systemPrompt on group dispatch decisions", () => {
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
it("captures the per-chat_id systemPrompt on group dispatch decisions", async () => {
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"7": { systemPrompt: "Keep responses under 3 sentences." },
|
||||
@@ -67,8 +67,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBe("Keep responses under 3 sentences.");
|
||||
});
|
||||
|
||||
it("falls back to the groups['*'] wildcard systemPrompt", () => {
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
it("falls back to the groups['*'] wildcard systemPrompt", async () => {
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"*": { systemPrompt: "Default group voice." },
|
||||
@@ -82,8 +82,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBe("Default group voice.");
|
||||
});
|
||||
|
||||
it("prefers the per-chat_id systemPrompt over the wildcard when both are set", () => {
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
it("prefers the per-chat_id systemPrompt over the wildcard when both are set", async () => {
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"*": { systemPrompt: "Default group voice." },
|
||||
@@ -98,11 +98,11 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBe("Specific group voice.");
|
||||
});
|
||||
|
||||
it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", () => {
|
||||
it("treats whitespace-only per-chat_id systemPrompt as suppression of the wildcard", async () => {
|
||||
// Mirrors WhatsApp semantic: defining the systemPrompt key on a specific
|
||||
// group entry (even as whitespace) means "this group has no prompt" and
|
||||
// suppresses the groups["*"] fallback.
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"*": { systemPrompt: "Wildcard." },
|
||||
@@ -117,8 +117,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", () => {
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
it("treats explicit empty-string per-chat_id systemPrompt as suppression of the wildcard", async () => {
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"*": { systemPrompt: "Wildcard." },
|
||||
@@ -133,8 +133,8 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", () => {
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
it("falls back to the wildcard when the per-chat_id entry has no systemPrompt key at all", async () => {
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"*": { systemPrompt: "Wildcard." },
|
||||
@@ -149,12 +149,12 @@ describe("resolveIMessageInboundDecision per-group systemPrompt", () => {
|
||||
expect(decision.groupSystemPrompt).toBe("Wildcard.");
|
||||
});
|
||||
|
||||
it("does not set groupSystemPrompt on true DM decisions", () => {
|
||||
it("does not set groupSystemPrompt on true DM decisions", async () => {
|
||||
// Use a chat_id that does NOT match any configured group entry, and
|
||||
// route through the DM-shaped message (is_group=false, no chat_id key
|
||||
// in groups). Without a groupConfig match the path stays a DM and the
|
||||
// group prompt must not bleed into the ctx.
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
buildDecisionParams({
|
||||
cfg: buildCfgWithGroups({
|
||||
"999": { systemPrompt: "Other group." },
|
||||
|
||||
@@ -64,12 +64,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
return resolveIMessageInboundDecision(createInboundDecisionParams(overrides));
|
||||
}
|
||||
|
||||
it("drops inbound messages when outbound message id matches echo cache", () => {
|
||||
it("drops inbound messages when outbound message id matches echo cache", async () => {
|
||||
const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return lookup.messageId === "42";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 42,
|
||||
text: "Reasoning:\n_step_",
|
||||
@@ -86,12 +86,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(echoHas).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("matches attachment-only echoes by bodyText placeholder", () => {
|
||||
it("matches attachment-only echoes by bodyText placeholder", async () => {
|
||||
const echoHas = vi.fn((_scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return lookup.text === "<media:image>" && lookup.messageId === "42";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 42,
|
||||
text: "",
|
||||
@@ -116,12 +116,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("drops reflected self-chat duplicates after seeing the from-me copy", () => {
|
||||
it("drops reflected self-chat duplicates after seeing the from-me copy", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9641,
|
||||
sender: "+15555550123",
|
||||
@@ -138,7 +138,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
).toMatchObject({ kind: "dispatch" });
|
||||
|
||||
expect(
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9642,
|
||||
sender: "+15555550123",
|
||||
@@ -153,10 +153,10 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("does not drop same-text messages when created_at differs", () => {
|
||||
it("does not drop same-text messages when created_at differs", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9641,
|
||||
text: "ok",
|
||||
@@ -166,7 +166,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
selfChatCache,
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9642,
|
||||
text: "ok",
|
||||
@@ -178,7 +178,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("keeps self-chat cache scoped to configured group threads", () => {
|
||||
it("keeps self-chat cache scoped to configured group threads", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const groupedCfg = {
|
||||
channels: {
|
||||
@@ -193,7 +193,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
cfg: groupedCfg,
|
||||
message: {
|
||||
id: 9701,
|
||||
@@ -206,7 +206,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "from me" });
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
cfg: groupedCfg,
|
||||
message: {
|
||||
id: 9702,
|
||||
@@ -220,12 +220,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("does not drop other participants in the same group thread", () => {
|
||||
it("does not drop other participants in the same group thread", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
|
||||
expect(
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9751,
|
||||
chat_id: 123,
|
||||
@@ -238,7 +238,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
}),
|
||||
).toEqual({ kind: "drop", reason: "from me" });
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9752,
|
||||
chat_id: 123,
|
||||
@@ -253,7 +253,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_guid scope", () => {
|
||||
it("drops group echoes persisted under chat_guid scope", async () => {
|
||||
// Outbound `send` to a group keyed by chat_guid persists the echo scope
|
||||
// as `${accountId}:chat_guid:${chatGuid}` (see send.ts:resolveOutboundEchoScope).
|
||||
// The inbound side has chat_id, chat_guid, and chat_identifier all
|
||||
@@ -264,7 +264,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
return scope === "default:chat_guid:iMessage;+;chat0000" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
@@ -285,12 +285,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(calls).toContain("default:chat_guid:iMessage;+;chat0000");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_identifier scope", () => {
|
||||
it("drops group echoes persisted under chat_identifier scope", async () => {
|
||||
const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return scope === "default:chat_identifier:chat0000" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
@@ -310,12 +310,12 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(calls).toContain("default:chat_identifier:chat0000");
|
||||
});
|
||||
|
||||
it("drops group echoes persisted under chat_id scope (baseline)", () => {
|
||||
it("drops group echoes persisted under chat_id scope (baseline)", async () => {
|
||||
const echoHas = vi.fn((scope: string, lookup: { text?: string; messageId?: string }) => {
|
||||
return scope === "default:chat_id:42" && lookup.messageId === "9001";
|
||||
});
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
@@ -335,13 +335,13 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(calls).toContain("default:chat_id:42");
|
||||
});
|
||||
|
||||
it("does not drop a group inbound when echo cache holds an unrelated chat_guid", () => {
|
||||
it("does not drop a group inbound when echo cache holds an unrelated chat_guid", async () => {
|
||||
const echoHas = vi.fn(
|
||||
(scope: string, lookup: { text?: string; messageId?: string }) =>
|
||||
scope === "default:chat_guid:iMessage;+;OTHER" && lookup.messageId === "9001",
|
||||
);
|
||||
|
||||
const decision = resolveDecision({
|
||||
const decision = await resolveDecision({
|
||||
message: {
|
||||
id: 9001,
|
||||
chat_id: 42,
|
||||
@@ -359,13 +359,13 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("sanitizes reflected duplicate previews before logging", () => {
|
||||
it("sanitizes reflected duplicate previews before logging", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const logVerbose = vi.fn();
|
||||
const createdAt = "2026-03-02T20:58:10.649Z";
|
||||
const bodyText = "line-1\nline-2\t\u001b[31mred";
|
||||
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9801,
|
||||
sender: "+15555550123",
|
||||
@@ -381,7 +381,7 @@ describe("resolveIMessageInboundDecision echo detection", () => {
|
||||
logVerbose,
|
||||
});
|
||||
|
||||
resolveDecision({
|
||||
await resolveDecision({
|
||||
message: {
|
||||
id: 9802,
|
||||
sender: "+15555550123",
|
||||
@@ -413,8 +413,8 @@ describe("describeIMessageEchoDropLog", () => {
|
||||
});
|
||||
|
||||
describe("buildIMessageInboundContext", () => {
|
||||
it("keeps numeric row id and provider GUID separately for action tooling", () => {
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
it("keeps numeric row id and provider GUID separately for action tooling", async () => {
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
message: {
|
||||
@@ -496,8 +496,8 @@ describe("resolveIMessageInboundDecision command auth", () => {
|
||||
logVerbose: undefined,
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", () => {
|
||||
const decision = resolveDmCommandDecision({
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
|
||||
const decision = await resolveDmCommandDecision({
|
||||
messageId: 100,
|
||||
storeAllowFrom: [],
|
||||
});
|
||||
@@ -505,8 +505,8 @@ describe("resolveIMessageInboundDecision command auth", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "dmPolicy blocked" });
|
||||
});
|
||||
|
||||
it("authorizes DM commands for senders in pairing-mode store allowlist", () => {
|
||||
const decision = resolveDmCommandDecision({
|
||||
it("authorizes DM commands for senders in pairing-mode store allowlist", async () => {
|
||||
const decision = await resolveDmCommandDecision({
|
||||
messageId: 101,
|
||||
dmPolicy: "pairing",
|
||||
storeAllowFrom: ["+15555550123"],
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
resolveEnvelopeFormatOptions,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import {
|
||||
resolveChannelGroupPolicy,
|
||||
resolveChannelGroupRequireMention,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { DmPolicy, GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
import {
|
||||
buildPendingHistoryContextFromMap,
|
||||
@@ -23,11 +26,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/reply-history";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
resolveDmGroupAccessWithLists,
|
||||
evaluateSupplementalContextVisibility,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { sanitizeTerminalText } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveIMessageConversationRoute } from "../conversation-route.js";
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
formatIMessageChatTarget,
|
||||
isAllowedIMessageSender,
|
||||
normalizeIMessageHandle,
|
||||
parseIMessageAllowTarget,
|
||||
} from "../targets.js";
|
||||
import { detectReflectedContent } from "./reflection-guard.js";
|
||||
import type { SelfChatCache } from "./self-chat-cache.js";
|
||||
@@ -47,6 +47,61 @@ type IMessageReplyContext = {
|
||||
sender?: string;
|
||||
};
|
||||
|
||||
const normalizeNonEmpty = (value: string) => value.trim() || null;
|
||||
|
||||
const imessageIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "imessage-sender",
|
||||
normalizeEntry: normalizeIMessageHandleEntry,
|
||||
normalizeSubject: normalizeIMessageHandle,
|
||||
sensitivity: "pii",
|
||||
aliases: (
|
||||
[
|
||||
["imessage-chat-id", "plugin:imessage-chat-id", normalizeIMessageChatIdEntry],
|
||||
["imessage-chat-guid", "plugin:imessage-chat-guid", normalizeIMessageChatGuidEntry],
|
||||
[
|
||||
"imessage-chat-identifier",
|
||||
"plugin:imessage-chat-identifier",
|
||||
normalizeIMessageChatIdentifierEntry,
|
||||
],
|
||||
] as const
|
||||
).map(([key, kind, normalizeEntry]) => ({
|
||||
key,
|
||||
kind,
|
||||
normalizeEntry,
|
||||
normalizeSubject: normalizeNonEmpty,
|
||||
sensitivity: "pii",
|
||||
})),
|
||||
resolveEntryId: ({ entryIndex }) => `imessage-entry-${entryIndex + 1}`,
|
||||
});
|
||||
|
||||
function normalizeIMessageHandleEntry(entry: string): string | null {
|
||||
const parsed = parseIMessageAllowTarget(entry.trim());
|
||||
return parsed.kind === "handle" ? normalizeIMessageHandle(parsed.handle) : null;
|
||||
}
|
||||
|
||||
function normalizeIMessageChatIdEntry(entry: string): string | null {
|
||||
const parsed = parseIMessageAllowTarget(entry.trim());
|
||||
return parsed.kind === "chat_id" ? String(parsed.chatId) : null;
|
||||
}
|
||||
|
||||
function normalizeIMessageChatGuidEntry(entry: string): string | null {
|
||||
const parsed = parseIMessageAllowTarget(entry.trim());
|
||||
return parsed.kind === "chat_guid" ? parsed.chatGuid.trim() || null : null;
|
||||
}
|
||||
|
||||
function normalizeIMessageChatIdentifierEntry(entry: string): string | null {
|
||||
const parsed = parseIMessageAllowTarget(entry.trim());
|
||||
return parsed.kind === "chat_identifier" ? parsed.chatIdentifier.trim() || null : null;
|
||||
}
|
||||
|
||||
function normalizeDmPolicy(policy: string): DmPolicy {
|
||||
return policy === "open" || policy === "allowlist" || policy === "disabled" ? policy : "pairing";
|
||||
}
|
||||
|
||||
function normalizeGroupPolicy(policy: string): GroupPolicy {
|
||||
return policy === "open" || policy === "disabled" ? policy : "allowlist";
|
||||
}
|
||||
|
||||
function normalizeReplyField(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
@@ -172,9 +227,6 @@ type IMessageInboundDispatchDecision = {
|
||||
replyContext: IMessageReplyContext | null;
|
||||
effectiveWasMentioned: boolean;
|
||||
commandAuthorized: boolean;
|
||||
// Used for allowlist checks for control commands.
|
||||
effectiveDmAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
// Forwarded as ctxPayload.GroupSystemPrompt for group messages. Resolved
|
||||
// from `channels.imessage.groups.<chat_id>.systemPrompt` (or the `"*"`
|
||||
// wildcard) at gate time. Always undefined for DMs.
|
||||
@@ -186,7 +238,7 @@ type IMessageInboundDecision =
|
||||
| { kind: "pairing"; senderId: string }
|
||||
| IMessageInboundDispatchDecision;
|
||||
|
||||
export function resolveIMessageInboundDecision(params: {
|
||||
export async function resolveIMessageInboundDecision(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
message: IMessagePayload;
|
||||
@@ -209,7 +261,7 @@ export function resolveIMessageInboundDecision(params: {
|
||||
};
|
||||
selfChatCache?: SelfChatCache;
|
||||
logVerbose?: (msg: string) => void;
|
||||
}): IMessageInboundDecision {
|
||||
}): Promise<IMessageInboundDecision> {
|
||||
const senderRaw = params.message.sender ?? "";
|
||||
const sender = senderRaw.trim();
|
||||
if (!sender) {
|
||||
@@ -313,49 +365,65 @@ export function resolveIMessageInboundDecision(params: {
|
||||
}
|
||||
|
||||
const groupId = isGroup ? groupIdCandidate : undefined;
|
||||
const accessDecision = resolveDmGroupAccessWithLists({
|
||||
isGroup,
|
||||
dmPolicy: params.dmPolicy,
|
||||
groupPolicy: params.groupPolicy,
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
||||
const accessDecision = await createChannelIngressResolver({
|
||||
channelId: "imessage",
|
||||
accountId: params.accountId,
|
||||
identity: imessageIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
readStoreAllowFrom: async () => params.storeAllowFrom,
|
||||
}).message({
|
||||
subject: {
|
||||
stableId: sender,
|
||||
aliases: {
|
||||
...(chatId != null ? { "imessage-chat-id": String(chatId) } : {}),
|
||||
...(chatGuid ? { "imessage-chat-guid": chatGuid } : {}),
|
||||
...(chatIdentifier ? { "imessage-chat-identifier": chatIdentifier } : {}),
|
||||
},
|
||||
},
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup
|
||||
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
|
||||
: normalizeIMessageHandle(sender),
|
||||
},
|
||||
dmPolicy: normalizeDmPolicy(params.dmPolicy),
|
||||
groupPolicy: normalizeGroupPolicy(params.groupPolicy),
|
||||
policy: { groupAllowFromFallbackToAllowFrom: false },
|
||||
allowFrom: params.allowFrom,
|
||||
groupAllowFrom: params.groupAllowFrom,
|
||||
storeAllowFrom: params.storeAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isAllowedIMessageSender({
|
||||
allowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
}),
|
||||
command: {
|
||||
allowTextCommands: isGroup,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
directGroupAllowFrom: "effective",
|
||||
},
|
||||
});
|
||||
const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
|
||||
const { commandAccess, senderAccess } = accessDecision;
|
||||
const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom;
|
||||
|
||||
if (accessDecision.decision !== "allow") {
|
||||
if (senderAccess.decision !== "allow") {
|
||||
if (isGroup) {
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
if (senderAccess.reasonCode === "group_policy_disabled") {
|
||||
params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)");
|
||||
return { kind: "drop", reason: "groupPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
if (senderAccess.reasonCode === "group_policy_empty_allowlist") {
|
||||
params.logVerbose?.(
|
||||
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
|
||||
);
|
||||
return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
|
||||
if (senderAccess.reasonCode === "group_policy_not_allowlisted") {
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`);
|
||||
return { kind: "drop", reason: "not in groupAllowFrom" };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`);
|
||||
return { kind: "drop", reason: accessDecision.reason };
|
||||
params.logVerbose?.(`Blocked iMessage group message (${senderAccess.reasonCode})`);
|
||||
return { kind: "drop", reason: senderAccess.reasonCode };
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
|
||||
if (senderAccess.reasonCode === "dm_policy_disabled") {
|
||||
return { kind: "drop", reason: "dmPolicy disabled" };
|
||||
}
|
||||
if (accessDecision.decision === "pairing") {
|
||||
if (senderAccess.decision === "pairing") {
|
||||
return { kind: "pairing", senderId: senderNormalized };
|
||||
}
|
||||
params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`);
|
||||
@@ -478,38 +546,8 @@ export function resolveIMessageInboundDecision(params: {
|
||||
});
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = isGroup ? params.allowFrom : effectiveDmAllowFrom;
|
||||
const ownerAllowedForCommands =
|
||||
commandDmAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: commandDmAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const groupAllowedForCommands =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? isAllowedIMessageSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
})
|
||||
: false;
|
||||
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
||||
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
||||
useAccessGroups,
|
||||
primaryConfigured: commandDmAllowFrom.length > 0,
|
||||
primaryAllowed: ownerAllowedForCommands,
|
||||
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
secondaryAllowed: groupAllowedForCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
if (isGroup && shouldBlock) {
|
||||
const commandAuthorized = commandAccess.authorized;
|
||||
if (commandAccess.shouldBlockControlCommand) {
|
||||
if (params.logVerbose) {
|
||||
logInboundDrop({
|
||||
log: params.logVerbose,
|
||||
@@ -583,8 +621,6 @@ export function resolveIMessageInboundDecision(params: {
|
||||
replyContext: filteredReplyContext,
|
||||
effectiveWasMentioned,
|
||||
commandAuthorized,
|
||||
effectiveDmAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
groupSystemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,34 +73,21 @@ import { sanitizeIMessageWatchErrorPayload } from "./watch-error-log.js";
|
||||
const WATCH_SUBSCRIBE_MAX_ATTEMPTS = 3;
|
||||
const WATCH_SUBSCRIBE_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Try to detect remote host from an SSH wrapper script like:
|
||||
* exec ssh -T openclaw@192.168.64.3 /opt/homebrew/bin/imsg "$@"
|
||||
* exec ssh -T mac-mini imsg "$@"
|
||||
* Returns the user@host or host portion if found, undefined otherwise.
|
||||
*/
|
||||
async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
// Expand ~ to home directory
|
||||
const expanded = cliPath.startsWith("~")
|
||||
? cliPath.replace(/^~/, process.env.HOME ?? "")
|
||||
: cliPath;
|
||||
const content = await fs.readFile(expanded, "utf8");
|
||||
|
||||
// Match user@host pattern first (e.g., openclaw@192.168.64.3)
|
||||
const userHostMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+)/);
|
||||
if (userHostMatch) {
|
||||
return userHostMatch[1];
|
||||
}
|
||||
|
||||
// Fallback: match host-only before imsg command (e.g., ssh -T mac-mini imsg)
|
||||
const hostOnlyMatch = content.match(/\bssh\b[^\n]*?\s+([a-zA-Z][a-zA-Z0-9._-]*)\s+\S*\bimsg\b/);
|
||||
return hostOnlyMatch?.[1];
|
||||
} catch (err) {
|
||||
// ENOENT / ENOTDIR are expected for non-script cliPaths (just an
|
||||
// executable on disk). Anything else (EACCES, broken symlink) is
|
||||
// worth flagging — silent failure means attachments will fail to find
|
||||
// remote media because remoteHost stays undefined.
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
||||
logVerbose(
|
||||
@@ -111,7 +98,6 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | un
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot warning when typing/read are gated off due to old imsg build. */
|
||||
const warnIfImsgUpgradeNeeded = (() => {
|
||||
let fired = false;
|
||||
return {
|
||||
@@ -385,7 +371,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
||||
process.env,
|
||||
accountInfo.accountId,
|
||||
).catch(() => []);
|
||||
const decision = resolveIMessageInboundDecision({
|
||||
const decision = await resolveIMessageInboundDecision({
|
||||
cfg,
|
||||
accountId: accountInfo.accountId,
|
||||
message,
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("echo cache — message ID type canary (#47830)", () => {
|
||||
// Tests the implicit contract that outbound GUIDs (e.g. "p:0/abc-def-123")
|
||||
// never match inbound SQLite row IDs (e.g. "200"). If iMessage ever changes
|
||||
// ID schemes, this test should break loudly.
|
||||
it("outbound GUID format and inbound SQLite row ID format never collide", () => {
|
||||
it("outbound GUID format and inbound SQLite row ID format never collide", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const scope = "default:imessage:+15555550123";
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("echo cache — message ID type canary (#47830)", () => {
|
||||
expect(echoCache.has(scope, { text: "different", messageId: "p:0/abc-def-123" })).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to text when outbound messageId was junk ("ok")', () => {
|
||||
it('falls back to text when outbound messageId was junk ("ok")', async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const scope = "default:imessage:+15555550123";
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("echo cache — message ID type canary (#47830)", () => {
|
||||
expect(echoCache.has(scope, { text: "text-only fallback", messageId: "200" })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps ID short-circuit when scope has real outbound GUID IDs", () => {
|
||||
it("keeps ID short-circuit when scope has real outbound GUID IDs", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const scope = "default:imessage:+15555550123";
|
||||
|
||||
@@ -110,7 +110,7 @@ describe("echo cache — backward compat for channels without messageId", () =>
|
||||
// Proves text-fallback echo detection still works when no messageId is present
|
||||
// on either side. Critical for backward compat with channels that don't
|
||||
// populate messageId.
|
||||
it("text-only remember/has works within TTL", () => {
|
||||
it("text-only remember/has works within TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("echo cache — backward compat for channels without messageId", () =>
|
||||
expect(echoCache.has(scope, { text: "no id message" })).toBe(true);
|
||||
});
|
||||
|
||||
it("text-only has returns false after TTL expiry", () => {
|
||||
it("text-only has returns false after TTL expiry", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("echo cache — backward compat for channels without messageId", () =>
|
||||
expect(echoCache.has(scope, { text: "no id message" })).toBe(false);
|
||||
});
|
||||
|
||||
it("text-only has returns false for different text", () => {
|
||||
it("text-only has returns false for different text", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", () => {
|
||||
it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -169,7 +169,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
// 2 seconds later, user sends "Hello" to themselves (different message id)
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 200,
|
||||
@@ -191,7 +191,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("DOES drop genuine agent echo (same message id reflected back)", () => {
|
||||
it("DOES drop genuine agent echo (same message id reflected back)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
// 1 second later, iMessage reflects it back with same message id
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: "agent-msg-1" as unknown as number,
|
||||
@@ -221,7 +221,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
||||
});
|
||||
|
||||
it("does NOT drop different-text messages even within TTL", () => {
|
||||
it("does NOT drop different-text messages even within TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -233,7 +233,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 201,
|
||||
@@ -250,7 +250,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", () => {
|
||||
it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -265,7 +265,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// User sends "Part two" (matches chunk 2 text, but different message id)
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 300,
|
||||
@@ -283,7 +283,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops echo after text TTL expiry (4s TTL: expired at 5s)", () => {
|
||||
it("drops echo after text TTL expiry (4s TTL: expired at 5s)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
});
|
||||
|
||||
// Safe failure mode: TTL expiry causes duplicate delivery (noisy), never message loss (lossy)
|
||||
it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", () => {
|
||||
it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -321,7 +321,7 @@ describe("self-chat dedupe — #47830", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("still drops text echo within 4s TTL window", () => {
|
||||
it("still drops text echo within 4s TTL window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -343,11 +343,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
|
||||
it("processes real user self-chat message (is_from_me=true, no echo cache match)", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123703,
|
||||
@@ -368,11 +368,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops is_from_me outbound when destination_caller_id is blank and sender matches chat_identifier (#63980)", () => {
|
||||
it("drops is_from_me outbound when destination_caller_id is blank and sender matches chat_identifier (#63980)", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123704,
|
||||
@@ -393,11 +393,11 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("drops DM false positives even when participant lists include the local handle", () => {
|
||||
it("drops DM false positives even when participant lists include the local handle", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123705,
|
||||
@@ -419,7 +419,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
|
||||
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
// with a SQLite row ID (never matches the GUID)
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123706,
|
||||
@@ -457,7 +457,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
||||
});
|
||||
|
||||
it("drops attachment-only agent echo in self-chat via bodyText placeholder", () => {
|
||||
it("drops attachment-only agent echo in self-chat via bodyText placeholder", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -469,7 +469,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123707,
|
||||
@@ -491,7 +491,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
||||
});
|
||||
|
||||
it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => {
|
||||
it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -503,7 +503,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123709,
|
||||
@@ -525,7 +525,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
||||
});
|
||||
|
||||
it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => {
|
||||
it("does not drop a real self-chat image just because a recent agent image used the same placeholder", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -537,7 +537,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123708,
|
||||
@@ -559,7 +559,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision.kind).toBe("dispatch");
|
||||
});
|
||||
|
||||
it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", () => {
|
||||
it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -567,7 +567,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
const createdAt = "2026-03-24T12:00:00.000Z";
|
||||
|
||||
// Step 1: is_from_me=true copy arrives (real user message) → processed, selfChatCache populated
|
||||
const first = resolveIMessageInboundDecision(
|
||||
const first = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123703,
|
||||
@@ -588,7 +588,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
|
||||
// Step 2: is_from_me=false reflection arrives 2s later with same text+createdAt
|
||||
vi.advanceTimersByTime(2200);
|
||||
const second = resolveIMessageInboundDecision(
|
||||
const second = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 123704,
|
||||
@@ -608,10 +608,10 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(second).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("drops outbound DM when sender matches chat_identifier but destination_caller_id is absent (#63980)", () => {
|
||||
it("drops outbound DM when sender matches chat_identifier but destination_caller_id is absent (#63980)", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 10003,
|
||||
@@ -630,14 +630,14 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("drops reflected inbound when destination_caller_id is absent (#63980)", () => {
|
||||
it("drops reflected inbound when destination_caller_id is absent (#63980)", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
const selfChatCache = createSelfChatCache();
|
||||
const createdAt = "2026-03-24T12:00:00.000Z";
|
||||
|
||||
const outbound = resolveIMessageInboundDecision(
|
||||
const outbound = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 10003,
|
||||
@@ -657,7 +657,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
|
||||
vi.advanceTimersByTime(2200);
|
||||
|
||||
const reflection = resolveIMessageInboundDecision(
|
||||
const reflection = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 10004,
|
||||
@@ -677,12 +677,12 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(reflection).toEqual({ kind: "drop", reason: "self-chat echo" });
|
||||
});
|
||||
|
||||
it("normal DM is_from_me=true is still dropped (regression test)", () => {
|
||||
it("normal DM is_from_me=true is still dropped (regression test)", async () => {
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
// Normal DM with is_from_me=true: sender may be the local handle and
|
||||
// chat_identifier the other party (they differ), so this is NOT self-chat.
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 9999,
|
||||
@@ -701,7 +701,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("uses destination_caller_id to avoid DM self-chat false positives", () => {
|
||||
it("uses destination_caller_id to avoid DM self-chat false positives", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const selfChatCache = createSelfChatCache();
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
messageId: "p:0/GUID-outbound",
|
||||
});
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: 10001,
|
||||
@@ -731,7 +731,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
||||
});
|
||||
|
||||
it("echo cache text matching works with skipIdShortCircuit=true", () => {
|
||||
it("echo cache text matching works with skipIdShortCircuit=true", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -751,7 +751,7 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
||||
});
|
||||
|
||||
describe("echo cache — text fallback for null-id inbound messages", () => {
|
||||
it("still identifies echo via text when inbound message has id: null", () => {
|
||||
it("still identifies echo via text when inbound message has id: null", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
||||
|
||||
@@ -765,7 +765,7 @@ describe("echo cache — text fallback for null-id inbound messages", () => {
|
||||
// 1 second later, inbound reflection arrives with id: null
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
const decision = resolveIMessageInboundDecision(
|
||||
const decision = await resolveIMessageInboundDecision(
|
||||
createParams({
|
||||
message: {
|
||||
id: null as unknown as number,
|
||||
@@ -787,7 +787,7 @@ describe("echo cache — text fallback for null-id inbound messages", () => {
|
||||
});
|
||||
|
||||
describe("echo cache — mixed GUID and text-only scopes", () => {
|
||||
it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", () => {
|
||||
it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const scope = "default:imessage:+15555550123";
|
||||
|
||||
@@ -797,7 +797,7 @@ describe("echo cache — mixed GUID and text-only scopes", () => {
|
||||
expect(echoCache.has(scope, { text: "latest text-only", messageId: "200" })).toBe(true);
|
||||
});
|
||||
|
||||
it("still short-circuits when the latest copy of a text was GUID-backed", () => {
|
||||
it("still short-circuits when the latest copy of a text was GUID-backed", async () => {
|
||||
const echoCache = createSentMessageCache();
|
||||
const scope = "default:imessage:+15555550123";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { handleIrcInbound } from "./inbound.js";
|
||||
@@ -163,4 +164,30 @@ describe("irc inbound behavior", () => {
|
||||
"irc: drop control command (unauthorized) target=alice!ident@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the shared reply pipeline for dispatched replies", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
setIrcRuntime(coreRuntime as never);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage(),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
sendReply: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
expect(coreRuntime.channel.turn.runAssembled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyPipeline: {},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./inbound.js";
|
||||
|
||||
describe("irc inbound policy", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: ["group-owner"],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when groupAllowFrom is empty", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
deliverFormattedTextWithAttachments,
|
||||
type OutboundReplyPayload,
|
||||
@@ -21,42 +21,122 @@ import {
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
import {
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "irc" as const;
|
||||
const IRC_NICK_KIND = "plugin:irc-nick" as const;
|
||||
type IrcGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
const ircIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "irc-id",
|
||||
normalizeEntry: normalizeIrcStableEntry,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
sensitivity: "pii",
|
||||
aliases: [
|
||||
...["irc-id-nick-user", "irc-id-nick-host"].map((key) => ({
|
||||
key,
|
||||
kind: "stable-id" as const,
|
||||
normalizeEntry: () => null,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
sensitivity: "pii" as const,
|
||||
})),
|
||||
{
|
||||
key: "irc-nick",
|
||||
kind: IRC_NICK_KIND,
|
||||
normalizeEntry: normalizeIrcNickEntry,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
dangerous: true,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
`irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`,
|
||||
});
|
||||
|
||||
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
function resolveIrcEffectiveAllowlists(params: {
|
||||
configAllowFrom: string[];
|
||||
configGroupAllowFrom: string[];
|
||||
storeAllowList: string[];
|
||||
dmPolicy: string;
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
|
||||
allowFrom: params.configAllowFrom,
|
||||
groupAllowFrom: params.configGroupAllowFrom,
|
||||
storeAllowFrom: params.storeAllowList,
|
||||
dmPolicy: params.dmPolicy,
|
||||
// IRC intentionally requires explicit groupAllowFrom; do not fallback to allowFrom.
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
});
|
||||
return { effectiveAllowFrom, effectiveGroupAllowFrom };
|
||||
function isBareNick(value: string): boolean {
|
||||
return !value.includes("!") && !value.includes("@");
|
||||
}
|
||||
|
||||
function normalizeIrcStableEntry(value: string): string | null {
|
||||
const normalized = normalizeIrcAllowEntry(value);
|
||||
if (!normalized || normalized === "*" || isBareNick(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeIrcNickEntry(value: string): string | null {
|
||||
const normalized = normalizeIrcAllowEntry(value);
|
||||
if (!normalized || normalized === "*" || !isBareNick(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hasEntries(entries: Array<string | number> | undefined): boolean {
|
||||
return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
|
||||
}
|
||||
|
||||
function createIrcIngressSubject(message: IrcInboundMessage) {
|
||||
const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true });
|
||||
const stableCandidates = candidates.filter((candidate) => !isBareNick(candidate));
|
||||
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
|
||||
return {
|
||||
stableId: stableCandidates[stableCandidates.length - 1] ?? nick,
|
||||
aliases: {
|
||||
"irc-id-nick-user": stableCandidates.find(
|
||||
(candidate) => candidate.includes("!") && !candidate.includes("@"),
|
||||
),
|
||||
"irc-id-nick-host": stableCandidates.find(
|
||||
(candidate) => !candidate.includes("!") && candidate.includes("@"),
|
||||
),
|
||||
"irc-nick": nick,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeDescriptorsForIrcGroup(params: {
|
||||
isGroup: boolean;
|
||||
groupPolicy: IrcGroupPolicy;
|
||||
groupAllowed: boolean;
|
||||
hasConfiguredGroups: boolean;
|
||||
groupEnabled: boolean;
|
||||
routeGroupAllowFrom: string[];
|
||||
}) {
|
||||
if (!params.isGroup) {
|
||||
return [];
|
||||
}
|
||||
return channelIngressRoutes(
|
||||
params.groupPolicy === "allowlist" && {
|
||||
id: "irc:channel",
|
||||
allowed: params.hasConfiguredGroups && params.groupAllowed,
|
||||
precedence: 0,
|
||||
matchId: "irc-channel",
|
||||
blockReason: "channel_not_allowlisted",
|
||||
},
|
||||
!params.groupEnabled && {
|
||||
id: "irc:channel-enabled",
|
||||
enabled: false,
|
||||
precedence: 10,
|
||||
blockReason: "channel_disabled",
|
||||
},
|
||||
hasEntries(params.routeGroupAllowFrom) && {
|
||||
id: "irc:channel-sender",
|
||||
precedence: 20,
|
||||
senderPolicy: "replace",
|
||||
senderAllowFrom: params.routeGroupAllowFrom,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
@@ -67,7 +147,7 @@ async function deliverIrcReply(params: {
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
const delivered = await deliverFormattedTextWithAttachments({
|
||||
await deliverFormattedTextWithAttachments({
|
||||
payload: params.payload,
|
||||
send: async ({ text, replyToId }) => {
|
||||
if (params.sendReply) {
|
||||
@@ -82,9 +162,6 @@ async function deliverIrcReply(params: {
|
||||
params.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
});
|
||||
if (!delivered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleIrcInbound(params: {
|
||||
@@ -132,124 +209,16 @@ export async function handleIrcInbound(params: {
|
||||
log: (message) => runtime.log?.(message),
|
||||
});
|
||||
|
||||
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
|
||||
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
|
||||
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: account.config.groups,
|
||||
target: message.target,
|
||||
});
|
||||
|
||||
if (message.isGroup) {
|
||||
const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch });
|
||||
if (!groupAccess.allowed) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom);
|
||||
const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom);
|
||||
const groupAllowFrom =
|
||||
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
|
||||
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom,
|
||||
configGroupAllowFrom,
|
||||
storeAllowList,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{
|
||||
configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
|
||||
allowed: senderAllowedForCommands,
|
||||
},
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
|
||||
if (message.isGroup) {
|
||||
const senderAllowed = resolveIrcGroupSenderAllowed({
|
||||
groupPolicy,
|
||||
message,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: groupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
const dmAllowed = resolveIrcAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
|
||||
senderIdLine: `Your IRC id: ${senderDisplay}`,
|
||||
meta: { name: message.senderNick || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await deliverIrcReply({
|
||||
payload: { text },
|
||||
cfg: config,
|
||||
target: message.senderNick,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.isGroup && commandGate.shouldBlock) {
|
||||
const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound");
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
||||
const mentionNick = connectedNick?.trim() || account.nick;
|
||||
const explicitMentionRegex = mentionNick
|
||||
@@ -258,29 +227,126 @@ export async function handleIrcInbound(params: {
|
||||
const wasMentioned =
|
||||
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
||||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
|
||||
|
||||
const requireMention = message.isGroup
|
||||
? resolveIrcRequireMention({
|
||||
groupConfig: groupMatch.groupConfig,
|
||||
wildcardConfig: groupMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
|
||||
const mentionGate = resolveIrcMentionGate({
|
||||
isGroup: message.isGroup,
|
||||
requireMention,
|
||||
wasMentioned,
|
||||
hasControlCommand,
|
||||
allowTextCommands,
|
||||
commandAuthorized,
|
||||
const routeGroupAllowFrom = normalizeStringEntries(
|
||||
groupMatch.groupConfig?.allowFrom?.length
|
||||
? groupMatch.groupConfig.allowFrom
|
||||
: groupMatch.wildcardConfig?.allowFrom,
|
||||
);
|
||||
const accessGroupPolicy: IrcGroupPolicy =
|
||||
groupPolicy === "open" &&
|
||||
(hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom))
|
||||
? "allowlist"
|
||||
: groupPolicy;
|
||||
const access = await createChannelIngressResolver({
|
||||
channelId: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
identity: ircIngressIdentity,
|
||||
cfg: config as OpenClawConfig,
|
||||
readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
|
||||
}).message({
|
||||
subject: createIrcIngressSubject(message),
|
||||
conversation: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: message.target,
|
||||
},
|
||||
route: routeDescriptorsForIrcGroup({
|
||||
isGroup: message.isGroup,
|
||||
groupPolicy,
|
||||
groupAllowed: groupMatch.allowed,
|
||||
hasConfiguredGroups: groupMatch.hasConfiguredGroups,
|
||||
groupEnabled:
|
||||
groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false,
|
||||
routeGroupAllowFrom,
|
||||
}),
|
||||
mentionFacts: message.isGroup
|
||||
? {
|
||||
canDetectMention: true,
|
||||
wasMentioned,
|
||||
hasAnyMention: wasMentioned,
|
||||
}
|
||||
: undefined,
|
||||
dmPolicy,
|
||||
groupPolicy: accessGroupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
activation: {
|
||||
requireMention: message.isGroup && requireMention,
|
||||
allowTextCommands,
|
||||
},
|
||||
},
|
||||
allowFrom: account.config.allowFrom,
|
||||
groupAllowFrom: account.config.groupAllowFrom,
|
||||
command: {
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
},
|
||||
});
|
||||
if (mentionGate.shouldSkip) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`);
|
||||
const commandAuthorized = access.commandAccess.authorized;
|
||||
|
||||
if (access.ingress.admission === "pairing-required") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
|
||||
senderIdLine: `Your IRC id: ${senderDisplay}`,
|
||||
meta: { name: message.senderNick || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await deliverIrcReply({
|
||||
payload: { text },
|
||||
cfg: config,
|
||||
target: message.senderNick,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (access.ingress.admission === "skip") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
|
||||
return;
|
||||
}
|
||||
if (access.ingress.admission !== "dispatch") {
|
||||
if (
|
||||
message.isGroup &&
|
||||
access.ingress.decisiveGateId === "command" &&
|
||||
access.commandAccess.shouldBlockControlCommand
|
||||
) {
|
||||
const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound");
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.isGroup) {
|
||||
if (access.routeAccess.reason === "channel_not_allowlisted") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
|
||||
} else if (access.routeAccess.reason === "channel_disabled") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
|
||||
} else {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
}
|
||||
} else {
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = message.isGroup ? message.target : message.senderNick;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
@@ -288,23 +354,15 @@ export async function handleIrcInbound(params: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "IRC",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
@@ -334,49 +392,40 @@ export async function handleIrcInbound(params: {
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
await core.channel.turn.runAssembled({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.turn.runPrepared({
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
runDispatch: async () =>
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as OpenClawConfig,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
cfg: config,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
cfg: config,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
replyOptions: {
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
||||
@@ -384,7 +433,3 @@ export async function handleIrcInbound(params: {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveIrcEffectiveAllowlists,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
|
||||
describe("irc policy", () => {
|
||||
it("matches direct and wildcard group entries", () => {
|
||||
@@ -29,94 +23,6 @@ describe("irc policy", () => {
|
||||
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces allowlist by default in groups", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#ops",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice!ident@example.org"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unconfigured channels when groupPolicy is "open"', () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: undefined,
|
||||
target: "#random",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(true);
|
||||
expect(gate.reason).toBe("open");
|
||||
});
|
||||
|
||||
it("honors explicit group disable even in open mode", () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { enabled: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(false);
|
||||
expect(gate.reason).toBe("disabled");
|
||||
});
|
||||
|
||||
it("allows authorized control commands without mention", () => {
|
||||
const gate = resolveIrcMentionGate({
|
||||
isGroup: true,
|
||||
requireMention: true,
|
||||
wasMentioned: false,
|
||||
hasControlCommand: true,
|
||||
allowTextCommands: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(gate.shouldSkip).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
|
||||
const groups = {
|
||||
"#Ops": { requireMention: false },
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
import type { IrcAccountConfig, IrcChannelConfig } from "./types.js";
|
||||
import type { IrcInboundMessage } from "./types.js";
|
||||
import type { IrcChannelConfig } from "./types.js";
|
||||
|
||||
type IrcGroupMatch = {
|
||||
export type IrcGroupMatch = {
|
||||
allowed: boolean;
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
hasConfiguredGroups: boolean;
|
||||
};
|
||||
|
||||
type IrcGroupAccessGate = {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function resolveIrcGroupMatch(params: {
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
target: string;
|
||||
@@ -29,7 +22,7 @@ export function resolveIrcGroupMatch(params: {
|
||||
if (direct) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
groupConfig: direct,
|
||||
wildcardConfig: groups["*"],
|
||||
@@ -46,7 +39,7 @@ export function resolveIrcGroupMatch(params: {
|
||||
if (matched) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
groupConfig: matched,
|
||||
wildcardConfig: groups["*"],
|
||||
@@ -59,7 +52,7 @@ export function resolveIrcGroupMatch(params: {
|
||||
if (wildcard) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
wildcardConfig: wildcard,
|
||||
hasConfiguredGroups,
|
||||
@@ -71,39 +64,6 @@ export function resolveIrcGroupMatch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIrcGroupAccessGate(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
groupMatch: IrcGroupMatch;
|
||||
}): IrcGroupAccessGate {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
if (policy === "disabled") {
|
||||
return { allowed: false, reason: "groupPolicy=disabled" };
|
||||
}
|
||||
|
||||
// In open mode, unconfigured channels are allowed (mention-gated) but explicit
|
||||
// per-channel/wildcard disables still apply.
|
||||
if (policy === "allowlist") {
|
||||
if (!params.groupMatch.hasConfiguredGroups) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "groupPolicy=allowlist and no groups configured",
|
||||
};
|
||||
}
|
||||
if (!params.groupMatch.allowed) {
|
||||
return { allowed: false, reason: "not allowlisted" };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.groupMatch.groupConfig?.enabled === false ||
|
||||
params.groupMatch.wildcardConfig?.enabled === false
|
||||
) {
|
||||
return { allowed: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
|
||||
}
|
||||
|
||||
export function resolveIrcRequireMention(params: {
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
@@ -116,54 +76,3 @@ export function resolveIrcRequireMention(params: {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveIrcMentionGate(params: {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
hasControlCommand: boolean;
|
||||
allowTextCommands: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}): { shouldSkip: boolean; reason: string } {
|
||||
if (!params.isGroup) {
|
||||
return { shouldSkip: false, reason: "direct" };
|
||||
}
|
||||
if (!params.requireMention) {
|
||||
return { shouldSkip: false, reason: "mention-not-required" };
|
||||
}
|
||||
if (params.wasMentioned) {
|
||||
return { shouldSkip: false, reason: "mentioned" };
|
||||
}
|
||||
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
|
||||
return { shouldSkip: false, reason: "authorized-command" };
|
||||
}
|
||||
return { shouldSkip: true, reason: "missing-mention" };
|
||||
}
|
||||
|
||||
export function resolveIrcGroupSenderAllowed(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
message: IrcInboundMessage;
|
||||
outerAllowFrom: string[];
|
||||
innerAllowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||
|
||||
if (inner.length > 0) {
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: inner,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
if (outer.length > 0) {
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: outer,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
return policy === "open";
|
||||
}
|
||||
|
||||
@@ -24,11 +24,7 @@ export {
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
|
||||
@@ -27,12 +27,7 @@ export {
|
||||
splitSetupEntries,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export { setLineRuntime } from "./src/runtime.js";
|
||||
export {
|
||||
firstDefined,
|
||||
isSenderAllowed,
|
||||
normalizeAllowFrom,
|
||||
normalizeDmAllowFromWithStore,
|
||||
} from "./src/bot-access.js";
|
||||
export { firstDefined, normalizeAllowFrom } from "./src/bot-access.js";
|
||||
export { downloadLineMedia } from "./src/download.js";
|
||||
export { probeLineBot } from "./src/probe.js";
|
||||
export { buildTemplateMessageFromPayload } from "./src/template-messages.js";
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderIdAllowed,
|
||||
mergeDmAllowFromSources,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
import { firstDefined } from "openclaw/plugin-sdk/allow-from";
|
||||
|
||||
export type NormalizedAllowFrom = {
|
||||
entries: string[];
|
||||
@@ -10,7 +6,7 @@ export type NormalizedAllowFrom = {
|
||||
hasEntries: boolean;
|
||||
};
|
||||
|
||||
function normalizeAllowEntry(value: string | number): string {
|
||||
export function normalizeLineAllowEntry(value: string | number): string {
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
@@ -22,7 +18,7 @@ function normalizeAllowEntry(value: string | number): string {
|
||||
}
|
||||
|
||||
export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAllowFrom => {
|
||||
const entries = (list ?? []).map((value) => normalizeAllowEntry(value)).filter(Boolean);
|
||||
const entries = (list ?? []).map((value) => normalizeLineAllowEntry(value)).filter(Boolean);
|
||||
const hasWildcard = entries.includes("*");
|
||||
return {
|
||||
entries,
|
||||
@@ -31,18 +27,4 @@ export const normalizeAllowFrom = (list?: Array<string | number>): NormalizedAll
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeDmAllowFromWithStore = (params: {
|
||||
allowFrom?: Array<string | number>;
|
||||
storeAllowFrom?: string[];
|
||||
dmPolicy?: string;
|
||||
}): NormalizedAllowFrom => normalizeAllowFrom(mergeDmAllowFromSources(params));
|
||||
|
||||
export const isSenderAllowed = (params: {
|
||||
allow: NormalizedAllowFrom;
|
||||
senderId?: string;
|
||||
}): boolean => {
|
||||
const { allow, senderId } = params;
|
||||
return isSenderIdAllowed(allow, senderId, false);
|
||||
};
|
||||
|
||||
export { firstDefined };
|
||||
|
||||
@@ -11,76 +11,6 @@ type PostbackEvent = webhook.PostbackEvent;
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", () => ({
|
||||
buildMentionRegexes: () => [],
|
||||
matchesMentionPatterns: () => false,
|
||||
resolveInboundMentionDecision: (params: {
|
||||
facts?: {
|
||||
canDetectMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
};
|
||||
policy?: {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
};
|
||||
isGroup?: boolean;
|
||||
requireMention?: boolean;
|
||||
canDetectMention?: boolean;
|
||||
wasMentioned?: boolean;
|
||||
hasAnyMention?: boolean;
|
||||
allowTextCommands?: boolean;
|
||||
hasControlCommand?: boolean;
|
||||
commandAuthorized?: boolean;
|
||||
}) => {
|
||||
const facts =
|
||||
"facts" in params && params.facts
|
||||
? params.facts
|
||||
: {
|
||||
canDetectMention: Boolean(params.canDetectMention),
|
||||
wasMentioned: Boolean(params.wasMentioned),
|
||||
hasAnyMention: params.hasAnyMention,
|
||||
};
|
||||
const policy =
|
||||
"policy" in params && params.policy
|
||||
? params.policy
|
||||
: {
|
||||
isGroup: Boolean(params.isGroup),
|
||||
requireMention: Boolean(params.requireMention),
|
||||
allowTextCommands: Boolean(params.allowTextCommands),
|
||||
hasControlCommand: Boolean(params.hasControlCommand),
|
||||
commandAuthorized: Boolean(params.commandAuthorized),
|
||||
};
|
||||
return {
|
||||
effectiveWasMentioned:
|
||||
facts.wasMentioned ||
|
||||
(policy.allowTextCommands &&
|
||||
policy.hasControlCommand &&
|
||||
policy.commandAuthorized &&
|
||||
!facts.hasAnyMention),
|
||||
shouldSkip:
|
||||
policy.isGroup &&
|
||||
policy.requireMention &&
|
||||
facts.canDetectMention &&
|
||||
!facts.wasMentioned &&
|
||||
!(
|
||||
policy.allowTextCommands &&
|
||||
policy.hasControlCommand &&
|
||||
policy.commandAuthorized &&
|
||||
!facts.hasAnyMention
|
||||
),
|
||||
shouldBypassMention:
|
||||
policy.isGroup &&
|
||||
policy.requireMention &&
|
||||
!facts.wasMentioned &&
|
||||
!facts.hasAnyMention &&
|
||||
policy.allowTextCommands &&
|
||||
policy.hasControlCommand &&
|
||||
policy.commandAuthorized,
|
||||
implicitMention: false,
|
||||
matchedImplicitMentionKinds: [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/channel-pairing", () => ({
|
||||
createChannelPairingChallengeIssuer:
|
||||
@@ -122,36 +52,6 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
danger: (text: string) => text,
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/group-access", () => ({
|
||||
evaluateMatchedGroupAccessForPolicy: ({
|
||||
groupPolicy,
|
||||
hasMatchInput,
|
||||
allowlistConfigured,
|
||||
allowlistMatched,
|
||||
}: {
|
||||
groupPolicy: string;
|
||||
hasMatchInput: boolean;
|
||||
allowlistConfigured: boolean;
|
||||
allowlistMatched: boolean;
|
||||
}) => {
|
||||
if (groupPolicy === "disabled") {
|
||||
return { allowed: false, reason: "disabled" };
|
||||
}
|
||||
if (groupPolicy !== "allowlist") {
|
||||
return { allowed: true, reason: null };
|
||||
}
|
||||
if (!hasMatchInput) {
|
||||
return { allowed: false, reason: "missing_match_input" };
|
||||
}
|
||||
if (!allowlistConfigured) {
|
||||
return { allowed: false, reason: "empty_allowlist" };
|
||||
}
|
||||
if (!allowlistMatched) {
|
||||
return { allowed: false, reason: "not_allowlisted" };
|
||||
}
|
||||
return { allowed: true, reason: null };
|
||||
},
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/reply-history", () => ({
|
||||
DEFAULT_GROUP_HISTORY_LIMIT: 20,
|
||||
clearHistoryEntriesIfEnabled: ({
|
||||
@@ -285,17 +185,25 @@ function createLineWebhookTestContext(params: {
|
||||
processMessage: LineWebhookContext["processMessage"];
|
||||
groupPolicy?: LineAccountConfig["groupPolicy"];
|
||||
dmPolicy?: LineAccountConfig["dmPolicy"];
|
||||
allowFrom?: LineAccountConfig["allowFrom"];
|
||||
groupAllowFrom?: LineAccountConfig["groupAllowFrom"];
|
||||
requireMention?: boolean;
|
||||
groupHistories?: Map<string, HistoryEntry[]>;
|
||||
replayCache?: ReturnType<typeof createLineWebhookReplayCache>;
|
||||
accessGroups?: Record<string, { type: "message.senders"; members: Record<string, string[]> }>;
|
||||
}): Parameters<typeof handleLineWebhookEvents>[1] {
|
||||
const allowFrom = params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
|
||||
const lineConfig = {
|
||||
...(params.groupPolicy ? { groupPolicy: params.groupPolicy } : {}),
|
||||
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
|
||||
...(params.dmPolicy === "open" ? { allowFrom: ["*"] } : {}),
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
...(params.groupAllowFrom ? { groupAllowFrom: params.groupAllowFrom } : {}),
|
||||
};
|
||||
return {
|
||||
cfg: { channels: { line: lineConfig } },
|
||||
cfg: {
|
||||
...(params.accessGroups ? { accessGroups: params.accessGroups } : {}),
|
||||
channels: { line: lineConfig },
|
||||
},
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
@@ -379,7 +287,6 @@ describe("handleLineWebhookEvents", () => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/command-auth");
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-group-policy");
|
||||
vi.doUnmock("openclaw/plugin-sdk/runtime-env");
|
||||
vi.doUnmock("openclaw/plugin-sdk/group-access");
|
||||
vi.doUnmock("openclaw/plugin-sdk/reply-history");
|
||||
vi.doUnmock("openclaw/plugin-sdk/routing");
|
||||
vi.doUnmock("openclaw/plugin-sdk/conversation-runtime");
|
||||
@@ -491,8 +398,56 @@ describe("handleLineWebhookEvents", () => {
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks group sender not in groupAllowFrom even when sender is paired in DM store", async () => {
|
||||
readAllowFromStoreMock.mockResolvedValueOnce(["user-store"]);
|
||||
it("authorizes group control commands through shared access groups", async () => {
|
||||
const processMessage = vi.fn();
|
||||
await handleLineWebhookEvents(
|
||||
[
|
||||
createTestMessageEvent({
|
||||
message: { id: "m3a", type: "text", text: "!status", quoteToken: "quote-token" },
|
||||
source: { type: "group", groupId: "group-1", userId: "user-ag" },
|
||||
webhookEventId: "evt-3a",
|
||||
}),
|
||||
],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:line-operators"],
|
||||
requireMention: true,
|
||||
accessGroups: {
|
||||
"line-operators": {
|
||||
type: "message.senders",
|
||||
members: { line: ["user-ag"] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buildLineMessageContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks unauthorized group control commands even when an open group sender is allowed", async () => {
|
||||
const processMessage = vi.fn();
|
||||
await handleLineWebhookEvents(
|
||||
[
|
||||
createTestMessageEvent({
|
||||
message: { id: "m3b", type: "text", text: "!status", quoteToken: "quote-token" },
|
||||
source: { type: "group", groupId: "group-1", userId: "user-open" },
|
||||
webhookEventId: "evt-3b",
|
||||
}),
|
||||
],
|
||||
createLineWebhookTestContext({
|
||||
processMessage,
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group sender not in groupAllowFrom without consulting the DM pairing store", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
type: "message",
|
||||
@@ -524,7 +479,7 @@ describe("handleLineWebhookEvents", () => {
|
||||
|
||||
expect(processMessage).not.toHaveBeenCalled();
|
||||
expect(buildLineMessageContextMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("line", undefined, "default");
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group messages without sender id when groupPolicy is allowlist", async () => {
|
||||
@@ -562,7 +517,6 @@ describe("handleLineWebhookEvents", () => {
|
||||
});
|
||||
|
||||
it("does not authorize group messages from DM pairing-store entries when group allowlist is empty", async () => {
|
||||
readAllowFromStoreMock.mockResolvedValueOnce(["user-5"]);
|
||||
const processMessage = vi.fn();
|
||||
await expectGroupMessageBlocked({
|
||||
processMessage,
|
||||
@@ -591,6 +545,7 @@ describe("handleLineWebhookEvents", () => {
|
||||
processMessage,
|
||||
},
|
||||
});
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group messages when wildcard group config disables groups", async () => {
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import type { webhook } from "@line/bot-sdk";
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
matchesMentionPatterns,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { hasControlCommand, resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import {
|
||||
readChannelAllowFromStore,
|
||||
resolvePairingIdLabel,
|
||||
upsertChannelPairingRequest,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
@@ -28,13 +24,8 @@ import {
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
firstDefined,
|
||||
isSenderAllowed,
|
||||
normalizeAllowFrom,
|
||||
normalizeDmAllowFromWithStore,
|
||||
type NormalizedAllowFrom,
|
||||
} from "./bot-access.js";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { firstDefined, normalizeLineAllowEntry } from "./bot-access.js";
|
||||
import {
|
||||
buildLineMessageContext,
|
||||
buildLinePostbackContext,
|
||||
@@ -87,6 +78,10 @@ const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
||||
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
|
||||
export type LineWebhookReplayCache = ClaimableDedupe;
|
||||
|
||||
function normalizeLineIngressEntry(value: string): string | null {
|
||||
return normalizeLineAllowEntry(value) || null;
|
||||
}
|
||||
|
||||
export class LineRetryableWebhookError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
@@ -234,41 +229,100 @@ async function sendLinePairingReply(params: {
|
||||
async function shouldProcessLineEvent(
|
||||
event: MessageEvent | PostbackEvent,
|
||||
context: LineHandlerContext,
|
||||
): Promise<{ allowed: boolean; commandAuthorized: boolean }> {
|
||||
const denied = { allowed: false, commandAuthorized: false };
|
||||
) {
|
||||
const { cfg, account } = context;
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
|
||||
const senderId = userId ?? "";
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
|
||||
const storeAllowFrom = await readChannelAllowFromStore(
|
||||
"line",
|
||||
undefined,
|
||||
account.accountId,
|
||||
).catch(() => []);
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: account.config.allowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
||||
const groupAllowOverride = groupConfig?.allowFrom;
|
||||
const fallbackGroupAllowFrom = account.config.allowFrom?.length
|
||||
? account.config.allowFrom
|
||||
: undefined;
|
||||
const groupAllowFrom = firstDefined(
|
||||
groupAllowOverride,
|
||||
account.config.groupAllowFrom,
|
||||
fallbackGroupAllowFrom,
|
||||
);
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
const rawText = resolveEventRawText(event);
|
||||
const requireMention = isGroup ? groupConfig?.requireMention !== false : false;
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const { groupPolicy: runtimeGroupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.line !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
defaultGroupPolicy: resolveDefaultGroupPolicy(cfg),
|
||||
});
|
||||
const groupPolicy: GroupPolicy =
|
||||
runtimeGroupPolicy === "disabled"
|
||||
? "disabled"
|
||||
: groupConfig?.allowFrom !== undefined
|
||||
? "allowlist"
|
||||
: runtimeGroupPolicy;
|
||||
const groupAllowFrom = normalizeStringEntries(
|
||||
firstDefined(
|
||||
groupConfig?.allowFrom,
|
||||
account.config.groupAllowFrom,
|
||||
account.config.allowFrom?.length ? account.config.allowFrom : undefined,
|
||||
),
|
||||
);
|
||||
const mentionFacts = (() => {
|
||||
if (!isGroup || event.type !== "message") {
|
||||
return { canDetectMention: false, wasMentioned: false, hasAnyMention: false };
|
||||
}
|
||||
const peerId = groupId ?? roomId ?? userId ?? "unknown";
|
||||
const { agentId } = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "line",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "group", id: peerId },
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
const wasMentionedByNative = isLineBotMentioned(event.message);
|
||||
const wasMentionedByPattern =
|
||||
event.message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
|
||||
return {
|
||||
canDetectMention: event.message.type === "text",
|
||||
wasMentioned: wasMentionedByNative || wasMentionedByPattern,
|
||||
hasAnyMention: hasAnyLineMention(event.message),
|
||||
};
|
||||
})();
|
||||
const access = await resolveStableChannelMessageIngress({
|
||||
channelId: "line",
|
||||
accountId: account.accountId,
|
||||
identity: {
|
||||
key: "line-user-id",
|
||||
normalize: normalizeLineIngressEntry,
|
||||
sensitivity: "pii",
|
||||
entryIdPrefix: "line-entry",
|
||||
},
|
||||
cfg,
|
||||
readStoreAllowFrom: async () =>
|
||||
await readChannelAllowFromStore("line", undefined, account.accountId),
|
||||
subject: { stableId: senderId },
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: (groupId ?? roomId ?? senderId) || "unknown",
|
||||
},
|
||||
...(isGroup && groupConfig?.enabled === false
|
||||
? { route: { id: "line:group-config", enabled: false } }
|
||||
: {}),
|
||||
mentionFacts:
|
||||
isGroup && event.type === "message"
|
||||
? {
|
||||
canDetectMention: mentionFacts.canDetectMention,
|
||||
wasMentioned: mentionFacts.wasMentioned,
|
||||
hasAnyMention: mentionFacts.hasAnyMention,
|
||||
implicitMentionKinds: [],
|
||||
}
|
||||
: undefined,
|
||||
event: { kind: event.type === "postback" ? "postback" : "message" },
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
activation: {
|
||||
requireMention: isGroup && event.type === "message" && requireMention,
|
||||
allowTextCommands: true,
|
||||
},
|
||||
},
|
||||
allowFrom: normalizeStringEntries(account.config.allowFrom),
|
||||
groupAllowFrom,
|
||||
command: {
|
||||
hasControlCommand: shouldComputeCommandAuthorized(rawText, cfg),
|
||||
groupOwnerAllowFrom: "none",
|
||||
},
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "line",
|
||||
@@ -276,92 +330,71 @@ async function shouldProcessLineEvent(
|
||||
log: (message) => logVerbose(message),
|
||||
});
|
||||
|
||||
if (
|
||||
access.senderAccess.decision === "allow" &&
|
||||
(access.ingress.admission === "dispatch" ||
|
||||
access.ingress.admission === "observe" ||
|
||||
access.ingress.admission === "skip")
|
||||
) {
|
||||
return access;
|
||||
}
|
||||
|
||||
if (access.senderAccess.decision === "allow") {
|
||||
logVerbose(`Blocked line event (${access.ingress.reasonCode})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGroup) {
|
||||
if (groupConfig?.enabled === false) {
|
||||
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
|
||||
return denied;
|
||||
return null;
|
||||
}
|
||||
if (groupAllowOverride !== undefined) {
|
||||
if (groupConfig?.allowFrom !== undefined) {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
|
||||
return denied;
|
||||
return null;
|
||||
}
|
||||
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
|
||||
if (access.senderAccess.reasonCode !== "group_policy_allowed") {
|
||||
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
|
||||
return denied;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
requireMatchInput: true,
|
||||
hasMatchInput: Boolean(senderId),
|
||||
allowlistConfigured: effectiveGroupAllow.entries.length > 0,
|
||||
allowlistMatched:
|
||||
Boolean(senderId) &&
|
||||
isSenderAllowed({
|
||||
allow: effectiveGroupAllow,
|
||||
senderId,
|
||||
}),
|
||||
});
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
||||
if (access.senderAccess.reasonCode === "group_policy_disabled") {
|
||||
logVerbose("Blocked line group message (groupPolicy: disabled)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "missing_match_input") {
|
||||
} else if (!senderId && groupPolicy === "allowlist") {
|
||||
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
||||
} else if (access.senderAccess.reasonCode === "group_policy_empty_allowlist") {
|
||||
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
|
||||
return denied;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "not_allowlisted") {
|
||||
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
|
||||
return denied;
|
||||
}
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
senderId,
|
||||
allow: effectiveGroupAllow,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose("Blocked line sender (dmPolicy: disabled)");
|
||||
return denied;
|
||||
}
|
||||
|
||||
const dmAllowed = isSenderAllowed({ allow: effectiveDmAllow, senderId });
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
|
||||
return denied;
|
||||
}
|
||||
await sendLinePairingReply({
|
||||
senderId,
|
||||
replyToken: "replyToken" in event ? event.replyToken : undefined,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
|
||||
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
|
||||
}
|
||||
return denied;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
commandAuthorized: resolveLineCommandAuthorized({
|
||||
cfg,
|
||||
event,
|
||||
if (access.senderAccess.reasonCode === "dm_policy_disabled") {
|
||||
logVerbose("Blocked line sender (dmPolicy: disabled)");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (access.senderAccess.decision === "pairing") {
|
||||
if (!senderId) {
|
||||
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
|
||||
return null;
|
||||
}
|
||||
await sendLinePairingReply({
|
||||
senderId,
|
||||
allow: effectiveDmAllow,
|
||||
}),
|
||||
};
|
||||
replyToken: "replyToken" in event ? event.replyToken : undefined,
|
||||
context,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${
|
||||
account.config.dmPolicy ?? "pairing"
|
||||
})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLineMentionees(
|
||||
@@ -400,87 +433,35 @@ function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveLineCommandAuthorized(params: {
|
||||
cfg: OpenClawConfig;
|
||||
event: MessageEvent | PostbackEvent;
|
||||
senderId?: string;
|
||||
allow: NormalizedAllowFrom;
|
||||
}): boolean {
|
||||
const senderAllowedForCommands = isSenderAllowed({
|
||||
allow: params.allow,
|
||||
senderId: params.senderId,
|
||||
});
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const rawText = resolveEventRawText(params.event);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, params.cfg),
|
||||
});
|
||||
return commandGate.commandAuthorized;
|
||||
}
|
||||
|
||||
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
|
||||
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
|
||||
const message = event.message;
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision.allowed) {
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
|
||||
if (isGroup) {
|
||||
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
|
||||
const requireMention = groupConfig?.requireMention !== false;
|
||||
if (isGroup && decision.activationAccess.shouldSkip) {
|
||||
const rawText = message.type === "text" ? message.text : "";
|
||||
const sourceInfo = getLineSourceInfo(event.source);
|
||||
const peerId = groupId ?? roomId ?? sourceInfo.userId ?? "unknown";
|
||||
const { agentId } = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "line",
|
||||
accountId: account.accountId,
|
||||
peer: { kind: "group", id: peerId },
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(cfg, agentId);
|
||||
const wasMentionedByNative = isLineBotMentioned(message);
|
||||
const wasMentionedByPattern =
|
||||
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
|
||||
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
|
||||
const mentionDecision = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: message.type === "text",
|
||||
wasMentioned,
|
||||
hasAnyMention: hasAnyLineMention(message),
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
policy: {
|
||||
isGroup: true,
|
||||
requireMention,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCommand(rawText, cfg),
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
},
|
||||
});
|
||||
if (mentionDecision.shouldSkip) {
|
||||
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
|
||||
const historyKey = groupId ?? roomId;
|
||||
const senderId = sourceInfo.userId ?? "unknown";
|
||||
if (historyKey && context.groupHistories) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: context.groupHistories,
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
entry: {
|
||||
sender: `user:${senderId}`,
|
||||
body: rawText || `<${message.type}>`,
|
||||
timestamp: event.timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
|
||||
const historyKey = groupId ?? roomId;
|
||||
const senderId = sourceInfo.userId ?? "unknown";
|
||||
if (historyKey && context.groupHistories) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: context.groupHistories,
|
||||
historyKey,
|
||||
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
entry: {
|
||||
sender: `user:${senderId}`,
|
||||
body: rawText || `<${message.type}>`,
|
||||
timestamp: event.timestamp,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const allMedia: MediaRef[] = [];
|
||||
@@ -507,7 +488,7 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
|
||||
allMedia,
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
commandAuthorized: decision.commandAccess.authorized,
|
||||
groupHistories: context.groupHistories,
|
||||
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
});
|
||||
@@ -562,7 +543,7 @@ async function handlePostbackEvent(
|
||||
logVerbose(`line: received postback: ${data}`);
|
||||
|
||||
const decision = await shouldProcessLineEvent(event, context);
|
||||
if (!decision.allowed) {
|
||||
if (!decision) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -570,7 +551,7 @@ async function handlePostbackEvent(
|
||||
event,
|
||||
cfg: context.cfg,
|
||||
account: context.account,
|
||||
commandAuthorized: decision.commandAuthorized,
|
||||
commandAuthorized: decision.commandAccess.authorized,
|
||||
});
|
||||
if (!postbackContext) {
|
||||
return;
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixMonitorAccessState } from "./access-state.js";
|
||||
import {
|
||||
resolveMatrixMonitorAccessState,
|
||||
resolveMatrixMonitorCommandAccess,
|
||||
} from "./access-state.js";
|
||||
|
||||
describe("resolveMatrixMonitorAccessState", () => {
|
||||
it("normalizes effective allowlists once and exposes reusable matches", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
it("normalizes group allowlists and uses shared ingress matching", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: ["matrix:@Alice:Example.org"],
|
||||
storeAllowFrom: ["user:@bob:example.org"],
|
||||
groupAllowFrom: ["@Carol:Example.org"],
|
||||
roomUsers: ["user:@Dana:Example.org"],
|
||||
senderId: "@dana:example.org",
|
||||
isRoom: true,
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
|
||||
expect(state.effectiveAllowFrom).toEqual([
|
||||
"matrix:@alice:example.org",
|
||||
"user:@bob:example.org",
|
||||
]);
|
||||
expect(state.effectiveGroupAllowFrom).toEqual(["@carol:example.org"]);
|
||||
expect(state.effectiveRoomUsers).toEqual(["user:@dana:example.org"]);
|
||||
expect(state.directAllowMatch.allowed).toBe(false);
|
||||
expect(state.roomUserMatch?.allowed).toBe(true);
|
||||
expect(state.groupAllowMatch?.allowed).toBe(false);
|
||||
expect(state.commandAuthorizers).toEqual([
|
||||
{ configured: false, allowed: false },
|
||||
{ configured: true, allowed: true },
|
||||
{ configured: true, allowed: false },
|
||||
]);
|
||||
expect(state.messageIngress.ingress.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("does not let DM pairing-store entries authorize room control commands", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
it("does not let DM pairing-store entries authorize room control commands", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: ["@attacker:example.org"],
|
||||
groupAllowFrom: [],
|
||||
@@ -38,17 +31,20 @@ describe("resolveMatrixMonitorAccessState", () => {
|
||||
isRoom: true,
|
||||
});
|
||||
|
||||
expect(state.effectiveAllowFrom).toEqual(["@attacker:example.org"]);
|
||||
expect(state.directAllowMatch.allowed).toBe(true);
|
||||
expect(state.commandAuthorizers).toEqual([
|
||||
{ configured: false, allowed: false },
|
||||
{ configured: false, allowed: false },
|
||||
{ configured: false, allowed: false },
|
||||
]);
|
||||
expect(
|
||||
await resolveMatrixMonitorCommandAccess(state, {
|
||||
useAccessGroups: true,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
authorized: false,
|
||||
shouldBlockControlCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let pairing-store entries authorize open DMs without wildcard", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
it("does not let pairing-store entries authorize open DMs without wildcard", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: ["@alice:example.org"],
|
||||
dmPolicy: "open",
|
||||
@@ -58,12 +54,13 @@ describe("resolveMatrixMonitorAccessState", () => {
|
||||
isRoom: false,
|
||||
});
|
||||
|
||||
expect(state.effectiveAllowFrom).toStrictEqual([]);
|
||||
expect(state.directAllowMatch.allowed).toBe(false);
|
||||
expect(state.messageIngress.senderAccess.effectiveAllowFrom).toEqual([]);
|
||||
expect(state.messageIngress.senderAccess.decision).toBe("block");
|
||||
expect(state.messageIngress.ingress.reasonCode).toBe("dm_policy_not_allowlisted");
|
||||
});
|
||||
|
||||
it("does not let configured DM allowFrom authorize room control commands", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
it("does not let configured DM allowFrom authorize room control commands", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: ["@owner:example.org"],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: ["@admin:example.org"],
|
||||
@@ -72,16 +69,64 @@ describe("resolveMatrixMonitorAccessState", () => {
|
||||
isRoom: true,
|
||||
});
|
||||
|
||||
expect(state.directAllowMatch.allowed).toBe(true);
|
||||
expect(state.commandAuthorizers).toEqual([
|
||||
{ configured: false, allowed: false },
|
||||
{ configured: false, allowed: false },
|
||||
{ configured: true, allowed: false },
|
||||
]);
|
||||
expect(
|
||||
await resolveMatrixMonitorCommandAccess(state, {
|
||||
useAccessGroups: true,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
authorized: false,
|
||||
shouldBlockControlCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps room-user matching disabled for dm traffic", () => {
|
||||
const state = resolveMatrixMonitorAccessState({
|
||||
it("authorizes room control commands through the shared ingress command gate", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: ["@admin:example.org"],
|
||||
roomUsers: [],
|
||||
senderId: "@admin:example.org",
|
||||
isRoom: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixMonitorCommandAccess(state, {
|
||||
useAccessGroups: true,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps command allow mode when access groups are disabled", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
roomUsers: [],
|
||||
senderId: "@admin:example.org",
|
||||
isRoom: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
await resolveMatrixMonitorCommandAccess(state, {
|
||||
useAccessGroups: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
authorized: true,
|
||||
shouldBlockControlCommand: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps room-user allowlists out of dm traffic", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: ["@carol:example.org"],
|
||||
@@ -90,8 +135,55 @@ describe("resolveMatrixMonitorAccessState", () => {
|
||||
isRoom: false,
|
||||
});
|
||||
|
||||
expect(state.roomUserMatch).toBeNull();
|
||||
expect(state.commandAuthorizers[1]).toEqual({ configured: true, allowed: false });
|
||||
expect(state.commandAuthorizers[2]).toEqual({ configured: true, allowed: false });
|
||||
expect(state.messageIngress.senderAccess.decision).toBe("pairing");
|
||||
expect(
|
||||
await resolveMatrixMonitorCommandAccess(state, {
|
||||
useAccessGroups: true,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
authorized: false,
|
||||
shouldBlockControlCommand: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the shared ingress decision for room user sender gates", async () => {
|
||||
const blocked = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
roomUsers: ["@allowed:example.org"],
|
||||
senderId: "@blocked:example.org",
|
||||
isRoom: true,
|
||||
groupPolicy: "open",
|
||||
});
|
||||
const allowed = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
roomUsers: ["@allowed:example.org"],
|
||||
senderId: "@allowed:example.org",
|
||||
isRoom: true,
|
||||
groupPolicy: "open",
|
||||
});
|
||||
|
||||
expect(blocked.messageIngress.ingress.reasonCode).toBe("group_policy_not_allowlisted");
|
||||
expect(allowed.messageIngress.ingress.decision).toBe("allow");
|
||||
});
|
||||
|
||||
it("keeps route-only room allowlists open when no sender allowlist exists", async () => {
|
||||
const state = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
roomUsers: [],
|
||||
senderId: "@sender:example.org",
|
||||
isRoom: true,
|
||||
groupPolicy: "allowlist",
|
||||
});
|
||||
|
||||
expect(state.messageIngress.ingress.decision).toBe("allow");
|
||||
expect(state.messageIngress.ingress.reasonCode).toBe("activation_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +1,145 @@
|
||||
import { mergeDmAllowFromSources } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
type ResolvedChannelMessageIngress,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
type MatrixCommandAuthorizer = {
|
||||
configured: boolean;
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
type MatrixMonitorAllowListMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user";
|
||||
};
|
||||
|
||||
type MatrixMonitorAccessState = {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
effectiveRoomUsers: string[];
|
||||
groupAllowConfigured: boolean;
|
||||
directAllowMatch: MatrixMonitorAllowListMatch;
|
||||
roomUserMatch: MatrixMonitorAllowListMatch | null;
|
||||
groupAllowMatch: MatrixMonitorAllowListMatch | null;
|
||||
commandAuthorizers: [MatrixCommandAuthorizer, MatrixCommandAuthorizer, MatrixCommandAuthorizer];
|
||||
messageIngress: ResolvedChannelMessageIngress;
|
||||
accountId: string;
|
||||
senderId: string;
|
||||
isRoom: boolean;
|
||||
};
|
||||
|
||||
export function resolveMatrixMonitorAccessState(params: {
|
||||
function normalizeMatrixEntry(raw?: string | null): string | null {
|
||||
return normalizeMatrixAllowList([raw ?? ""])[0] ?? null;
|
||||
}
|
||||
|
||||
const matrixIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "sender-id",
|
||||
normalize: normalizeMatrixEntry,
|
||||
matchEntry({ subject, entry }) {
|
||||
const senderId = subject.identifiers[0]?.value;
|
||||
return (
|
||||
entry.value === "*" ||
|
||||
resolveMatrixAllowListMatch({
|
||||
allowList: [entry.value],
|
||||
userId: senderId ?? "",
|
||||
}).allowed
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
function resolveMatrixGroupIngress(params: {
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
effectiveGroupAllowFrom: string[];
|
||||
effectiveRoomUsers: string[];
|
||||
}): { groupPolicy: "open" | "allowlist" | "disabled"; groupAllowFrom: string[] } {
|
||||
if (params.groupPolicy === "disabled") {
|
||||
return { groupPolicy: "disabled", groupAllowFrom: [] };
|
||||
}
|
||||
if (params.effectiveRoomUsers.length > 0) {
|
||||
return { groupPolicy: "allowlist", groupAllowFrom: params.effectiveRoomUsers };
|
||||
}
|
||||
if (params.groupPolicy === "allowlist" && params.effectiveGroupAllowFrom.length > 0) {
|
||||
return { groupPolicy: "allowlist", groupAllowFrom: params.effectiveGroupAllowFrom };
|
||||
}
|
||||
return { groupPolicy: "open", groupAllowFrom: [] };
|
||||
}
|
||||
|
||||
export async function resolveMatrixMonitorAccessState(params: {
|
||||
allowFrom: Array<string | number>;
|
||||
storeAllowFrom: Array<string | number>;
|
||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groupAllowFrom: Array<string | number>;
|
||||
roomUsers: Array<string | number>;
|
||||
senderId: string;
|
||||
isRoom: boolean;
|
||||
}): MatrixMonitorAccessState {
|
||||
const configuredAllowFrom = normalizeMatrixAllowList(params.allowFrom);
|
||||
const effectiveAllowFrom = normalizeMatrixAllowList(
|
||||
mergeDmAllowFromSources({
|
||||
allowFrom: configuredAllowFrom,
|
||||
storeAllowFrom: params.storeAllowFrom,
|
||||
dmPolicy: params.dmPolicy,
|
||||
}),
|
||||
);
|
||||
accountId?: string;
|
||||
eventKind?: "message" | "reaction";
|
||||
}): Promise<MatrixMonitorAccessState> {
|
||||
const dmPolicy = params.dmPolicy ?? "pairing";
|
||||
const groupPolicy = params.groupPolicy ?? "open";
|
||||
const effectiveGroupAllowFrom = normalizeMatrixAllowList(params.groupAllowFrom);
|
||||
const effectiveRoomUsers = normalizeMatrixAllowList(params.roomUsers);
|
||||
const commandAllowFrom = params.isRoom ? [] : effectiveAllowFrom;
|
||||
|
||||
const directAllowMatch = resolveMatrixAllowListMatch({
|
||||
allowList: effectiveAllowFrom,
|
||||
userId: params.senderId,
|
||||
});
|
||||
const roomUserMatch =
|
||||
params.isRoom && effectiveRoomUsers.length > 0
|
||||
? resolveMatrixAllowListMatch({
|
||||
allowList: effectiveRoomUsers,
|
||||
userId: params.senderId,
|
||||
})
|
||||
: null;
|
||||
const groupAllowMatch =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? resolveMatrixAllowListMatch({
|
||||
allowList: effectiveGroupAllowFrom,
|
||||
userId: params.senderId,
|
||||
})
|
||||
: null;
|
||||
const commandAllowMatch =
|
||||
commandAllowFrom.length > 0
|
||||
? resolveMatrixAllowListMatch({
|
||||
allowList: commandAllowFrom,
|
||||
userId: params.senderId,
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
effectiveAllowFrom,
|
||||
const groupIngress = resolveMatrixGroupIngress({
|
||||
groupPolicy,
|
||||
effectiveGroupAllowFrom,
|
||||
effectiveRoomUsers,
|
||||
groupAllowConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
directAllowMatch,
|
||||
roomUserMatch,
|
||||
groupAllowMatch,
|
||||
commandAuthorizers: [
|
||||
{
|
||||
configured: commandAllowFrom.length > 0,
|
||||
allowed: commandAllowMatch?.allowed ?? false,
|
||||
},
|
||||
{
|
||||
configured: effectiveRoomUsers.length > 0,
|
||||
allowed: roomUserMatch?.allowed ?? false,
|
||||
},
|
||||
{
|
||||
configured: effectiveGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowMatch?.allowed ?? false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const accountId = params.accountId ?? "default";
|
||||
const eventKind = params.eventKind ?? "message";
|
||||
const ingress = createChannelIngressResolver({
|
||||
channelId: "matrix",
|
||||
accountId,
|
||||
identity: matrixIngressIdentity,
|
||||
readStoreAllowFrom: async () => params.storeAllowFrom,
|
||||
});
|
||||
const resolved = await ingress.message({
|
||||
subject: { stableId: params.senderId },
|
||||
conversation: {
|
||||
kind: params.isRoom ? "group" : "direct",
|
||||
id: params.isRoom ? "matrix-room" : "matrix-dm",
|
||||
},
|
||||
event: {
|
||||
kind: eventKind,
|
||||
authMode: "inbound" as const,
|
||||
mayPair: params.isRoom ? false : eventKind === "message",
|
||||
},
|
||||
dmPolicy,
|
||||
groupPolicy: params.isRoom ? groupIngress.groupPolicy : "disabled",
|
||||
policy: { groupAllowFromFallbackToAllowFrom: false },
|
||||
allowFrom: params.allowFrom,
|
||||
...(params.isRoom ? { groupAllowFrom: groupIngress.groupAllowFrom } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
effectiveGroupAllowFrom,
|
||||
effectiveRoomUsers,
|
||||
messageIngress: resolved,
|
||||
accountId,
|
||||
senderId: params.senderId,
|
||||
isRoom: params.isRoom,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMatrixMonitorCommandAccess(
|
||||
state: MatrixMonitorAccessState,
|
||||
params: {
|
||||
useAccessGroups: boolean;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
},
|
||||
) {
|
||||
const commandAllowFrom = state.isRoom ? [] : state.messageIngress.senderAccess.effectiveAllowFrom;
|
||||
const commandGroupAllowFrom =
|
||||
state.effectiveRoomUsers.length > 0 ? state.effectiveRoomUsers : state.effectiveGroupAllowFrom;
|
||||
const resolved = await createChannelIngressResolver({
|
||||
channelId: "matrix",
|
||||
accountId: state.accountId,
|
||||
identity: matrixIngressIdentity,
|
||||
}).command({
|
||||
subject: { stableId: state.senderId },
|
||||
conversation: {
|
||||
kind: state.isRoom ? "group" : "direct",
|
||||
id: state.isRoom ? "matrix-room" : "matrix-dm",
|
||||
},
|
||||
dmPolicy: "allowlist",
|
||||
groupPolicy: "allowlist",
|
||||
policy: { groupAllowFromFallbackToAllowFrom: false },
|
||||
allowFrom: commandAllowFrom,
|
||||
groupAllowFrom: commandGroupAllowFrom,
|
||||
command: {
|
||||
useAccessGroups: params.useAccessGroups,
|
||||
allowTextCommands: params.allowTextCommands,
|
||||
hasControlCommand: params.hasControlCommand,
|
||||
groupOwnerAllowFrom: "none",
|
||||
commandGroupAllowFromFallbackToAllowFrom: false,
|
||||
},
|
||||
});
|
||||
return resolved.commandAccess;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isChannelProgressDraftWorkToolName,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
||||
import {
|
||||
evaluateSupplementalContextVisibility,
|
||||
resolveChannelContextVisibilityMode,
|
||||
@@ -52,7 +51,10 @@ import {
|
||||
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
|
||||
import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
|
||||
import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
|
||||
import { resolveMatrixMonitorAccessState } from "./access-state.js";
|
||||
import {
|
||||
resolveMatrixMonitorAccessState,
|
||||
resolveMatrixMonitorCommandAccess,
|
||||
} from "./access-state.js";
|
||||
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
|
||||
import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
import {
|
||||
@@ -72,7 +74,6 @@ import { resolveMatrixInboundRoute } from "./route.js";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
formatAllowlistMatchMeta,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
@@ -730,34 +731,29 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
liveGroupAllowlistCache = next;
|
||||
},
|
||||
});
|
||||
const accessState = resolveMatrixMonitorAccessState({
|
||||
const accessState = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: liveDmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
groupAllowFrom: liveGroupAllowFrom,
|
||||
roomUsers,
|
||||
senderId,
|
||||
isRoom,
|
||||
accountId,
|
||||
eventKind: isReactionEvent ? "reaction" : "message",
|
||||
});
|
||||
const {
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
effectiveRoomUsers,
|
||||
groupAllowConfigured,
|
||||
directAllowMatch,
|
||||
roomUserMatch,
|
||||
groupAllowMatch,
|
||||
commandAuthorizers,
|
||||
} = accessState;
|
||||
const { effectiveGroupAllowFrom, effectiveRoomUsers, messageIngress } = accessState;
|
||||
const ingressDecision = messageIngress.ingress;
|
||||
|
||||
if (isDirectMessage) {
|
||||
if (!dmEnabled || dmPolicy === "disabled") {
|
||||
await commitInboundEventIfClaimed();
|
||||
return undefined;
|
||||
}
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(directAllowMatch);
|
||||
if (!directAllowMatch.allowed) {
|
||||
if (!isReactionEvent && dmPolicy === "pairing") {
|
||||
const senderReason = messageIngress.senderAccess.reasonCode;
|
||||
if (ingressDecision.decision !== "allow") {
|
||||
if (ingressDecision.admission === "pairing-required") {
|
||||
const senderName = await getSenderName();
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "matrix",
|
||||
@@ -773,8 +769,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
});
|
||||
logVerboseMessage(
|
||||
created
|
||||
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`
|
||||
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})`
|
||||
: `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})`,
|
||||
);
|
||||
try {
|
||||
const { sendMessageMatrix } = await loadMatrixSendModule();
|
||||
@@ -803,7 +799,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
if (isReactionEvent || dmPolicy !== "pairing") {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
`matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, reason=${senderReason})`,
|
||||
);
|
||||
await commitInboundEventIfClaimed();
|
||||
}
|
||||
@@ -811,27 +807,9 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
}
|
||||
|
||||
if (isRoom && roomUserMatch && !roomUserMatch.allowed) {
|
||||
if (isRoom && ingressDecision.decision !== "allow") {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
roomUserMatch,
|
||||
)})`,
|
||||
);
|
||||
await commitInboundEventIfClaimed();
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
isRoom &&
|
||||
groupPolicy === "allowlist" &&
|
||||
effectiveRoomUsers.length === 0 &&
|
||||
groupAllowConfigured &&
|
||||
groupAllowMatch &&
|
||||
!groupAllowMatch.allowed
|
||||
) {
|
||||
logVerboseMessage(
|
||||
`matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
groupAllowMatch,
|
||||
)})`,
|
||||
`matrix: blocked sender ${senderId} (ingress=${ingressDecision.reasonCode}, ${roomMatchMeta})`,
|
||||
);
|
||||
await commitInboundEventIfClaimed();
|
||||
return undefined;
|
||||
@@ -965,14 +943,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
commandCheckText,
|
||||
cfg,
|
||||
);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
const commandAccess = await resolveMatrixMonitorCommandAccess(accessState, {
|
||||
useAccessGroups,
|
||||
authorizers: commandAuthorizers,
|
||||
allowTextCommands,
|
||||
hasControlCommand: hasControlCommandInMessage,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
if (isRoom && commandGate.shouldBlock) {
|
||||
const commandAuthorized = commandAccess.authorized;
|
||||
if (isRoom && commandAccess.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "matrix",
|
||||
@@ -1149,7 +1126,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
triggerSnapshot,
|
||||
threadRootId,
|
||||
thread,
|
||||
effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
effectiveRoomUsers,
|
||||
};
|
||||
|
||||
@@ -362,18 +362,19 @@ async function isVerificationNoticeAuthorized(params: {
|
||||
params.dmPolicy !== "allowlist" && params.dmPolicy !== "open"
|
||||
? await params.readStoreAllowFrom()
|
||||
: [];
|
||||
const accessState = resolveMatrixMonitorAccessState({
|
||||
const accessState = await resolveMatrixMonitorAccessState({
|
||||
allowFrom: params.allowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy: params.dmPolicy,
|
||||
// Verification flows only exist in strict DMs, so room/group allowlists do
|
||||
// not participate in the authorization decision here.
|
||||
groupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
roomUsers: [],
|
||||
senderId: params.senderId,
|
||||
isRoom: false,
|
||||
});
|
||||
if (accessState.directAllowMatch.allowed) {
|
||||
if (accessState.messageIngress.senderAccess.decision === "allow") {
|
||||
return true;
|
||||
}
|
||||
params.logVerboseMessage(
|
||||
|
||||
@@ -98,10 +98,6 @@ export {
|
||||
buildChannelKeyCandidates,
|
||||
resolveChannelEntryMatch,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { buildTimeoutAbortSignal } from "./matrix/sdk/timeout-abort-signal.js";
|
||||
export { formatZonedTimestamp } from "openclaw/plugin-sdk/time-runtime";
|
||||
export type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
|
||||
@@ -16,7 +16,7 @@ export type {
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
|
||||
export type { ModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
@@ -34,11 +34,11 @@ export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export {
|
||||
buildModelsProviderData,
|
||||
listSkillCommandsForAgents,
|
||||
resolveControlCommandGate,
|
||||
resolveStoredModelOverride,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
@@ -50,13 +50,6 @@ export { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-
|
||||
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
|
||||
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/command-auth";
|
||||
import {
|
||||
resolveStoredModelOverride,
|
||||
type ModelsProviderData,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
|
||||
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
|
||||
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
@@ -19,7 +13,6 @@ describe("mattermost monitor auth", () => {
|
||||
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
|
||||
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
|
||||
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
|
||||
let resolveMattermostEffectiveAllowFromLists: typeof import("./monitor-auth.js").resolveMattermostEffectiveAllowFromLists;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -27,48 +20,23 @@ describe("mattermost monitor auth", () => {
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
} = await import("./monitor-auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
evaluateSenderGroupAccessForPolicy.mockReset();
|
||||
isDangerousNameMatchingEnabled.mockReset();
|
||||
resolveAllowlistMatchSimple.mockReset();
|
||||
resolveControlCommandGate.mockReset();
|
||||
resolveEffectiveAllowFromLists.mockReset();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries and resolves effective lists", () => {
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries", () => {
|
||||
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
|
||||
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
|
||||
expect(normalizeMattermostAllowEntry("accessGroup:Ops")).toBe("accessGroup:Ops");
|
||||
expect(normalizeMattermostAllowEntry("*")).toBe("*");
|
||||
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
|
||||
"alice",
|
||||
"*",
|
||||
]);
|
||||
expect(
|
||||
resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: [" Alice "],
|
||||
groupAllowFrom: [" Team "],
|
||||
storeAllowFrom: ["Store"],
|
||||
dmPolicy: "pairing",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
groupAllowFrom: ["team"],
|
||||
storeAllowFrom: ["store"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks sender allowlists against normalized ids and names", () => {
|
||||
@@ -89,24 +57,12 @@ describe("mattermost monitor auth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requires open direct messages to match the effective allowlist", () => {
|
||||
it("resolves direct command authorization from shared ingress", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
resolveControlCommandGate.mockReturnValue({
|
||||
commandAuthorized: false,
|
||||
shouldBlock: false,
|
||||
});
|
||||
evaluateSenderGroupAccessForPolicy.mockReturnValue({
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
});
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open" },
|
||||
} as never,
|
||||
@@ -115,8 +71,8 @@ describe("mattermost monitor auth", () => {
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
@@ -124,14 +80,10 @@ describe("mattermost monitor auth", () => {
|
||||
kind: "direct",
|
||||
});
|
||||
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: ["*"],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open", allowFrom: ["*"] },
|
||||
} as never,
|
||||
@@ -150,7 +102,7 @@ describe("mattermost monitor auth", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "disabled" },
|
||||
} as never,
|
||||
@@ -168,7 +120,7 @@ describe("mattermost monitor auth", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { groupPolicy: "allowlist" },
|
||||
} as never,
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups";
|
||||
import {
|
||||
type ChannelIngressDecision,
|
||||
type ChannelIngressEventInput,
|
||||
type ChannelIngressIdentifierKind,
|
||||
resolveStableChannelMessageIngress,
|
||||
type StableChannelIngressIdentityParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostChannel } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import {
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "./runtime-api.js";
|
||||
import { isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple } from "./runtime-api.js";
|
||||
|
||||
const MATTERMOST_USER_NAME_KIND =
|
||||
"plugin:mattermost-user-name" as const satisfies ChannelIngressIdentifierKind;
|
||||
const mattermostIngressIdentity = {
|
||||
key: "sender-id",
|
||||
normalize: normalizeMattermostAllowEntry,
|
||||
aliases: [
|
||||
{
|
||||
key: "sender-name",
|
||||
kind: MATTERMOST_USER_NAME_KIND,
|
||||
normalizeEntry: normalizeMattermostAllowEntry,
|
||||
normalizeSubject: normalizeMattermostAllowEntry,
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeMattermostAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
`mattermost-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "user"}`,
|
||||
} satisfies StableChannelIngressIdentityParams;
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
@@ -18,12 +39,15 @@ export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed
|
||||
const accessGroupName = parseAccessGroupAllowFromEntry(trimmed);
|
||||
if (accessGroupName) {
|
||||
return `accessGroup:${accessGroupName}`;
|
||||
}
|
||||
const normalized = trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim()
|
||||
? normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, "").replace(/^@/, ""))
|
||||
: "";
|
||||
.trim();
|
||||
return normalized ? normalizeLowercaseStringOrEmpty(normalized) : "";
|
||||
}
|
||||
|
||||
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
|
||||
@@ -33,23 +57,6 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
export function resolveMattermostEffectiveAllowFromLists(params: {
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
dmPolicy?: string | null;
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
return resolveEffectiveAllowFromLists({
|
||||
allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []),
|
||||
groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []),
|
||||
storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []),
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
@@ -109,7 +116,113 @@ export type MattermostCommandAuthDecision =
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
export function authorizeMattermostCommandInvocation(params: {
|
||||
type MattermostCommandDenyReason = Extract<
|
||||
MattermostCommandAuthDecision,
|
||||
{ ok: false }
|
||||
>["denyReason"];
|
||||
|
||||
export async function resolveMattermostMonitorInboundAccess(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
channelId: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
groupPolicy: "allowlist" | "open" | "disabled";
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
readStoreAllowFrom?: () => Promise<Array<string | number>>;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
eventKind?: ChannelIngressEventInput["kind"];
|
||||
mayPair?: boolean;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
storeAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
} = params;
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const configAllowFrom = account.config.allowFrom ?? [];
|
||||
const configGroupAllowFrom = account.config.groupAllowFrom ?? [];
|
||||
const readStoreAllowFrom =
|
||||
params.readStoreAllowFrom ??
|
||||
(storeAllowFrom != null ? async () => [...storeAllowFrom] : undefined);
|
||||
const ingress = await resolveStableChannelMessageIngress({
|
||||
channelId: "mattermost",
|
||||
accountId: account.accountId,
|
||||
identity: mattermostIngressIdentity,
|
||||
cfg,
|
||||
...(readStoreAllowFrom ? { readStoreAllowFrom } : {}),
|
||||
useDefaultPairingStore: params.readStoreAllowFrom === undefined && storeAllowFrom == null,
|
||||
subject: {
|
||||
stableId: senderId,
|
||||
aliases: { "sender-name": senderName },
|
||||
},
|
||||
conversation: {
|
||||
kind,
|
||||
id: channelId,
|
||||
},
|
||||
event: {
|
||||
kind: params.eventKind ?? "message",
|
||||
authMode: "inbound",
|
||||
mayPair: params.mayPair ?? true,
|
||||
},
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
},
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
command: {
|
||||
allowTextCommands,
|
||||
hasControlCommand: allowTextCommands && hasControlCommand,
|
||||
directGroupAllowFrom: kind === "direct" ? "effective" : "none",
|
||||
},
|
||||
});
|
||||
return ingress;
|
||||
}
|
||||
|
||||
function resolveMattermostCommandDenyReason(params: {
|
||||
decision: ChannelIngressDecision;
|
||||
kind: "direct" | "group" | "channel";
|
||||
dmPolicy: string;
|
||||
}): MattermostCommandDenyReason | null {
|
||||
if (params.decision.decision === "allow") {
|
||||
return null;
|
||||
}
|
||||
if (params.kind === "direct") {
|
||||
if (params.decision.reasonCode === "dm_policy_disabled") {
|
||||
return "dm-disabled";
|
||||
}
|
||||
if (
|
||||
params.dmPolicy === "pairing" &&
|
||||
(params.decision.admission === "pairing-required" ||
|
||||
params.decision.reasonCode === "dm_policy_pairing_required")
|
||||
) {
|
||||
return "dm-pairing";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
if (params.decision.reasonCode === "group_policy_disabled") {
|
||||
return "channels-disabled";
|
||||
}
|
||||
if (params.decision.reasonCode === "group_policy_empty_allowlist") {
|
||||
return "channel-no-allowlist";
|
||||
}
|
||||
return "unauthorized";
|
||||
}
|
||||
|
||||
export async function authorizeMattermostCommandInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
senderId: string;
|
||||
@@ -117,9 +230,10 @@ export function authorizeMattermostCommandInvocation(params: {
|
||||
channelId: string;
|
||||
channelInfo: MattermostChannel | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
readStoreAllowFrom?: () => Promise<Array<string | number>>;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
}): MattermostCommandAuthDecision {
|
||||
}): Promise<MattermostCommandAuthDecision> {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
@@ -128,6 +242,7 @@ export function authorizeMattermostCommandInvocation(params: {
|
||||
channelId,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
readStoreAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
} = params;
|
||||
@@ -152,160 +267,47 @@ export function authorizeMattermostCommandInvocation(params: {
|
||||
const channelDisplay = channelInfo.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
|
||||
const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []);
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
storeAllowFrom: normalizedStoreAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
|
||||
const commandGroupAllowFrom =
|
||||
kind === "direct"
|
||||
? effectiveGroupAllowFrom
|
||||
: configGroupAllowFrom.length > 0
|
||||
? configGroupAllowFrom
|
||||
: configAllowFrom;
|
||||
|
||||
const senderAllowedForCommands = isMattermostSenderAllowed({
|
||||
const ingress = await resolveMattermostMonitorInboundAccess({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandDmAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: commandGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
storeAllowFrom,
|
||||
readStoreAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand: allowTextCommands && hasControlCommand,
|
||||
hasControlCommand,
|
||||
eventKind: "native-command",
|
||||
mayPair: true,
|
||||
});
|
||||
const denyReason = resolveMattermostCommandDenyReason({
|
||||
decision: ingress.ingress,
|
||||
kind,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
});
|
||||
|
||||
const commandAuthorized =
|
||||
kind === "direct" ? senderAllowedForCommands : commandGate.commandAuthorized;
|
||||
|
||||
if (kind === "direct") {
|
||||
if (dmPolicy === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "dm-disabled",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!senderAllowedForCommands) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: (_senderId, allowFrom) =>
|
||||
isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom,
|
||||
allowNameMatching,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "channels-disabled",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "channel-no-allowlist",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (commandGate.shouldBlock) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
if (denyReason) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason,
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized,
|
||||
commandAuthorized: ingress.commandAccess.authorized,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveControlCommandGate } from "../../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
resolveMattermostMonitorInboundAccess,
|
||||
} from "./monitor-auth.js";
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
@@ -47,62 +46,103 @@ function authorizeGroupCommand(senderId: string) {
|
||||
}
|
||||
|
||||
describe("mattermost monitor authz", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
it("keeps DM allowlist merged with pairing-store entries", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
expect(resolved.senderAccess.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
});
|
||||
|
||||
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
it("uses explicit groupAllowFrom without pairing-store inheritance", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "group-owner",
|
||||
senderName: "Group Owner",
|
||||
channelId: "chan-1",
|
||||
kind: "channel",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not inherit pairing-store entries into group allowlist", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
it("falls group allowlist back to allowFrom without pairing-store entries", async () => {
|
||||
const resolved = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
allowFrom: ["@trusted-user"],
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "chan-1",
|
||||
kind: "channel",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
|
||||
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
|
||||
const access = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: [],
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups: true,
|
||||
authorizers: [
|
||||
{ configured: resolved.effectiveAllowFrom.length > 0, allowed: false },
|
||||
{ configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(commandGate.commandAuthorized).toBe(false);
|
||||
expect(access.ingress.decision).toBe("block");
|
||||
expect(access.commandAccess.authorized).toBe(false);
|
||||
});
|
||||
|
||||
it("denies group control commands when the sender is outside the allowlist", () => {
|
||||
const decision = authorizeGroupCommand("attacker");
|
||||
it("denies group control commands when the sender is outside the allowlist", async () => {
|
||||
const decision = await authorizeGroupCommand("attacker");
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
ok: false,
|
||||
@@ -111,8 +151,8 @@ describe("mattermost monitor authz", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes group control commands for allowlisted senders", () => {
|
||||
const decision = authorizeGroupCommand("trusted-user");
|
||||
it("authorizes group control commands for allowlisted senders", async () => {
|
||||
const decision = await authorizeGroupCommand("trusted-user");
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
ok: true,
|
||||
@@ -120,4 +160,72 @@ describe("mattermost monitor authz", () => {
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes group senders through static access groups", async () => {
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:oncall"],
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
accessGroups: {
|
||||
oncall: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
mattermost: ["mattermost:trusted-user"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
senderId: "trusted-user",
|
||||
senderName: "Trusted User",
|
||||
channelId: "chan-1",
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails direct reaction access without pairing admission", async () => {
|
||||
const access = await resolveMattermostMonitorInboundAccess({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
cfg: {},
|
||||
senderId: "new-user",
|
||||
senderName: "New User",
|
||||
channelId: "dm-1",
|
||||
kind: "direct",
|
||||
groupPolicy: "allowlist",
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
eventKind: "reaction",
|
||||
mayPair: false,
|
||||
});
|
||||
|
||||
expect(access.ingress.decision).toBe("block");
|
||||
expect(access.ingress.reasonCode).toBe("event_pairing_not_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,7 +132,6 @@ vi.mock("./runtime-api.js", async () => {
|
||||
onModelSelected: vi.fn(),
|
||||
typingCallbacks: {},
|
||||
})),
|
||||
readStoreAllowFromForDmPolicy: vi.fn(async () => []),
|
||||
registerPluginHttpRoute: mockState.registerPluginHttpRoute,
|
||||
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
|
||||
warnMissingProviderGroupPolicyFallbackOnce: vi.fn(),
|
||||
|
||||
@@ -45,9 +45,8 @@ import {
|
||||
} from "./model-picker.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
resolveMattermostMonitorInboundAccess,
|
||||
} from "./monitor-auth.js";
|
||||
import {
|
||||
evaluateMattermostMentionGate,
|
||||
@@ -83,18 +82,13 @@ import {
|
||||
createChannelPairingController,
|
||||
createChannelMessageReplyPipeline,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
isDangerousNameMatchingEnabled,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
registerPluginHttpRoute,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type HistoryEntry,
|
||||
} from "./runtime-api.js";
|
||||
@@ -480,7 +474,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const botToken =
|
||||
normalizeOptionalString(opts.botToken) ?? normalizeOptionalString(account.botToken);
|
||||
if (!botToken) {
|
||||
@@ -592,26 +585,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
handleInteraction: handleModelPickerInteraction,
|
||||
authorizeButtonClick: async ({ payload, post }) => {
|
||||
const channelInfo = await resolveChannelInfo(payload.channel_id);
|
||||
const isDirect = channelInfo?.type?.trim().toUpperCase() === "D";
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const decision = authorizeMattermostCommandInvocation({
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId: payload.user_id,
|
||||
senderName: payload.user_name ?? "",
|
||||
channelId: payload.channel_id,
|
||||
channelInfo,
|
||||
storeAllowFrom: isDirect
|
||||
? await readStoreAllowFromForDmPolicy({
|
||||
provider: "mattermost",
|
||||
accountId: account.accountId,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
})
|
||||
: undefined,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
allowTextCommands,
|
||||
hasControlCommand: false,
|
||||
});
|
||||
@@ -810,6 +795,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
);
|
||||
const channelHistories = new Map<string, HistoryEntry[]>();
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: cfg.channels?.mattermost !== undefined,
|
||||
@@ -1036,23 +1022,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
surface: "mattermost",
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(pickerCommandText, cfg);
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await readStoreAllowFromForDmPolicy({
|
||||
provider: "mattermost",
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
}),
|
||||
);
|
||||
const auth = authorizeMattermostCommandInvocation({
|
||||
const auth = await authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId: params.payload.user_id,
|
||||
senderName: params.userName,
|
||||
channelId: params.payload.channel_id,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
@@ -1268,77 +1245,35 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
normalizeOptionalString((await resolveUserInfo(senderId))?.username) ??
|
||||
senderId;
|
||||
const rawText = normalizeOptionalString(post.message) ?? "";
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const normalizedAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
|
||||
const normalizedGroupAllowFrom = normalizeMattermostAllowList(
|
||||
account.config.groupAllowFrom ?? [],
|
||||
);
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await readStoreAllowFromForDmPolicy({
|
||||
provider: "mattermost",
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
}),
|
||||
);
|
||||
const accessDecision = resolveDmGroupAccessWithLists({
|
||||
isGroup: kind !== "direct",
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
allowFrom: normalizedAllowFrom,
|
||||
groupAllowFrom: normalizedGroupAllowFrom,
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom,
|
||||
allowNameMatching,
|
||||
}),
|
||||
});
|
||||
const effectiveAllowFrom = accessDecision.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
|
||||
const isControlCommand = allowTextCommands && hasControlCommand;
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : normalizedAllowFrom;
|
||||
const senderAllowedForCommands = isMattermostSenderAllowed({
|
||||
const accessDecision = await resolveMattermostMonitorInboundAccess({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandDmAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: effectiveGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
eventKind: "message",
|
||||
mayPair: true,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
const commandAuthorized = accessDecision.commandAccess.authorized;
|
||||
|
||||
if (accessDecision.decision !== "allow") {
|
||||
if (accessDecision.ingress.decision !== "allow") {
|
||||
if (kind === "direct") {
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
|
||||
if (accessDecision.ingress.reasonCode === "dm_policy_disabled") {
|
||||
logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
|
||||
return;
|
||||
}
|
||||
if (accessDecision.decision === "pairing") {
|
||||
if (accessDecision.ingress.decision === "pairing") {
|
||||
const { code, created } = await pairing.upsertPairingRequest({
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
@@ -1369,25 +1304,25 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
||||
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {
|
||||
logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
|
||||
return;
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
||||
if (accessDecision.ingress.reasonCode === "group_policy_empty_allowlist") {
|
||||
logVerboseMessage("mattermost: drop group message (no group allowlist)");
|
||||
return;
|
||||
}
|
||||
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
|
||||
if (accessDecision.ingress.reasonCode === "group_policy_not_allowlisted") {
|
||||
logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`);
|
||||
return;
|
||||
}
|
||||
logVerboseMessage(
|
||||
`mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.reason})`,
|
||||
`mattermost: drop group message (groupPolicy=${groupPolicy} reason=${accessDecision.senderAccess.reasonCode})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind !== "direct" && commandGate.shouldBlock) {
|
||||
if (kind !== "direct" && accessDecision.commandAccess.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "mattermost",
|
||||
@@ -1968,39 +1903,29 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
}
|
||||
const kind = mapMattermostChannelTypeToChatType(channelInfo.type);
|
||||
|
||||
// Enforce DM/group policy and allowlist checks (same as normal messages)
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await readStoreAllowFromForDmPolicy({
|
||||
provider: "mattermost",
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
}),
|
||||
);
|
||||
const reactionAccess = resolveDmGroupAccessWithLists({
|
||||
isGroup: kind !== "direct",
|
||||
dmPolicy,
|
||||
// Enforce DM/group policy and allowlist checks (same as normal messages).
|
||||
const reactionAccess = await resolveMattermostMonitorInboundAccess({
|
||||
account,
|
||||
cfg,
|
||||
senderId: userId,
|
||||
senderName,
|
||||
channelId,
|
||||
kind,
|
||||
groupPolicy,
|
||||
allowFrom: normalizeMattermostAllowList(account.config.allowFrom ?? []),
|
||||
groupAllowFrom: normalizeMattermostAllowList(account.config.groupAllowFrom ?? []),
|
||||
storeAllowFrom,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
isMattermostSenderAllowed({
|
||||
senderId: userId,
|
||||
senderName,
|
||||
allowFrom,
|
||||
allowNameMatching,
|
||||
}),
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
eventKind: "reaction",
|
||||
mayPair: false,
|
||||
});
|
||||
if (reactionAccess.decision !== "allow") {
|
||||
if (reactionAccess.ingress.decision !== "allow") {
|
||||
if (kind === "direct") {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.reason})`,
|
||||
`mattermost: drop reaction (dmPolicy=${dmPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode})`,
|
||||
);
|
||||
} else {
|
||||
logVerboseMessage(
|
||||
`mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.reason} channel=${channelId})`,
|
||||
`mattermost: drop reaction (groupPolicy=${groupPolicy} sender=${userId} reason=${reactionAccess.senderAccess.reasonCode} channel=${channelId})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -13,26 +13,19 @@ export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload"
|
||||
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
buildModelsProviderData,
|
||||
listSkillCommandsForAgents,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export {
|
||||
|
||||
@@ -480,7 +480,7 @@ async function authorizeSlashInvocation(params: {
|
||||
})
|
||||
.catch(() => []),
|
||||
);
|
||||
const decision = authorizeMattermostCommandInvocation({
|
||||
const decision = await authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
|
||||
@@ -22,9 +22,7 @@ export {
|
||||
createDedupeCache,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
type DmPolicy,
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
formatInboundFromLabel,
|
||||
getAgentScopedMediaLocalRoots,
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
@@ -48,7 +46,6 @@ export {
|
||||
type PluginRuntime,
|
||||
rawDataToString,
|
||||
readRequestBodyWithLimit,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
registerPluginHttpRoute,
|
||||
type ReplyPayload,
|
||||
@@ -58,8 +55,6 @@ export {
|
||||
resolveClientIp,
|
||||
resolveControlCommandGate,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveStoredModelOverride,
|
||||
resolveStorePath,
|
||||
resolveThreadSessionKeys,
|
||||
|
||||
@@ -18,14 +18,7 @@ export type {
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-logging";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export {
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
resolveToolsBySender,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { resolveToolsBySender } from "openclaw/plugin-sdk/channel-policy";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
|
||||
@@ -14,6 +14,7 @@ type MSTeamsTestRuntimeOptions = {
|
||||
upsertPairingRequest?: ReturnType<typeof vi.fn>;
|
||||
recordInboundSession?: ReturnType<typeof vi.fn>;
|
||||
resolveAgentRoute?: (params: RuntimeRoutePeer) => unknown;
|
||||
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
|
||||
resolveTextChunkLimit?: () => number;
|
||||
resolveStorePath?: () => string;
|
||||
};
|
||||
@@ -79,7 +80,7 @@ export function installMSTeamsTestRuntime(options: MSTeamsTestRuntimeOptions = {
|
||||
upsertPairingRequest: options.upsertPairingRequest ?? vi.fn(async () => null),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
hasControlCommand: options.hasControlCommand ?? (() => false),
|
||||
resolveChunkMode: () => "length",
|
||||
resolveMarkdownTableMode: () => "code",
|
||||
...(options.resolveTextChunkLimit
|
||||
|
||||
@@ -74,7 +74,7 @@ async function isInvokeAuthorized(params: {
|
||||
|
||||
const maybeInvokeName = includeInvokeName ? { name: context.activity.name } : undefined;
|
||||
|
||||
if (isDirectMessage && resolved.access.decision !== "allow") {
|
||||
if (isDirectMessage && resolved.senderAccess.decision !== "allow") {
|
||||
deps.log.debug?.(deniedLogs.dm, {
|
||||
sender: senderId,
|
||||
conversationId,
|
||||
@@ -97,7 +97,7 @@ async function isInvokeAuthorized(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDirectMessage && !resolved.senderGroupAccess.allowed) {
|
||||
if (!isDirectMessage && !resolved.senderAccess.allowed) {
|
||||
deps.log.debug?.(deniedLogs.group, {
|
||||
sender: senderId,
|
||||
conversationId,
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
resolveStableChannelMessageIngress,
|
||||
type StableChannelIngressIdentityParams,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createChannelPairingController,
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveEffectiveAllowFromLists,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
type OpenClawConfig,
|
||||
} from "../../runtime-api.js";
|
||||
import { normalizeMSTeamsConversationId } from "../inbound.js";
|
||||
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsRouteConfig } from "../policy.js";
|
||||
import { resolveMSTeamsRouteConfig } from "../policy.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
|
||||
const MSTEAMS_SENDER_NAME_KIND = "plugin:msteams-sender-name" as const;
|
||||
const msteamsIngressIdentity = {
|
||||
key: "sender-id",
|
||||
normalize: normalizeIngressValue,
|
||||
aliases: [
|
||||
{
|
||||
key: "sender-name",
|
||||
kind: MSTEAMS_SENDER_NAME_KIND,
|
||||
normalizeEntry: normalizeIngressValue,
|
||||
normalizeSubject: normalizeIngressValue,
|
||||
dangerous: true,
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeIngressValue(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
`msteams-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "id"}`,
|
||||
} satisfies StableChannelIngressIdentityParams;
|
||||
|
||||
function normalizeIngressValue(value?: string | null): string | null {
|
||||
return normalizeOptionalLowercaseString(value) ?? null;
|
||||
}
|
||||
|
||||
export async function resolveMSTeamsSenderAccess(params: {
|
||||
cfg: OpenClawConfig;
|
||||
activity: MSTeamsTurnContext["activity"];
|
||||
hasControlCommand?: boolean;
|
||||
}) {
|
||||
const activity = params.activity;
|
||||
const msteamsCfg = params.cfg.channels?.msteams;
|
||||
@@ -35,26 +58,13 @@ export async function resolveMSTeamsSenderAccess(params: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
|
||||
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "msteams",
|
||||
accountId: pairing.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
const configuredDmAllowFrom = msteamsCfg?.allowFrom ?? [];
|
||||
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
|
||||
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
|
||||
allowFrom: configuredDmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(params.cfg);
|
||||
const groupPolicy =
|
||||
!isDirectMessage && msteamsCfg
|
||||
? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist")
|
||||
: "disabled";
|
||||
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
|
||||
const channelGate = resolveMSTeamsRouteConfig({
|
||||
cfg: msteamsCfg,
|
||||
@@ -65,60 +75,61 @@ export async function resolveMSTeamsSenderAccess(params: {
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
// When a route-level (team/channel) allowlist is configured but the sender allowlist is
|
||||
// empty, resolveSenderScopedGroupPolicy would otherwise downgrade the policy to "open",
|
||||
// allowing any sender. To close this bypass (GHSA-g7cr-9h7q-4qxq), treat an empty sender
|
||||
// allowlist as deny-all whenever the route allowlist is active.
|
||||
const senderGroupPolicy =
|
||||
channelGate.allowlistConfigured && effectiveGroupAllowFrom.length === 0
|
||||
? groupPolicy
|
||||
: resolveSenderScopedGroupPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
});
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: !isDirectMessage,
|
||||
const resolved = await resolveStableChannelMessageIngress({
|
||||
channelId: "msteams",
|
||||
accountId: pairing.accountId,
|
||||
identity: msteamsIngressIdentity,
|
||||
cfg: params.cfg,
|
||||
readStoreAllowFrom: pairing.readAllowFromStore,
|
||||
subject: {
|
||||
stableId: senderId,
|
||||
aliases: { "sender-name": senderName },
|
||||
},
|
||||
conversation: {
|
||||
kind: isDirectMessage ? "direct" : convType === "channel" ? "channel" : "group",
|
||||
id: conversationId,
|
||||
parentId: activity.channelData?.team?.id,
|
||||
},
|
||||
route: channelIngressRoutes(
|
||||
!isDirectMessage &&
|
||||
channelGate.allowlistConfigured && {
|
||||
id: "msteams:team-channel",
|
||||
kind: "nestedAllowlist",
|
||||
allowed: channelGate.allowed,
|
||||
precedence: 0,
|
||||
matchId: "msteams-route",
|
||||
...(channelGate.allowed && groupPolicy === "allowlist"
|
||||
? {
|
||||
senderPolicy: "deny-when-empty" as const,
|
||||
senderAllowFromSource: "effective-group" as const,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
dmPolicy,
|
||||
groupPolicy: senderGroupPolicy,
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
},
|
||||
allowFrom: configuredDmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
}).allowed,
|
||||
command: {
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: params.hasControlCommand === true,
|
||||
directGroupAllowFrom: isDirectMessage ? "effective" : "none",
|
||||
},
|
||||
});
|
||||
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: (_senderId, allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
}).allowed,
|
||||
});
|
||||
|
||||
return {
|
||||
msteamsCfg,
|
||||
...resolved,
|
||||
pairing,
|
||||
isDirectMessage,
|
||||
conversationId,
|
||||
senderId,
|
||||
senderName,
|
||||
msteamsCfg,
|
||||
dmPolicy,
|
||||
channelGate,
|
||||
access,
|
||||
senderGroupAccess,
|
||||
configuredDmAllowFrom,
|
||||
effectiveDmAllowFrom: access.effectiveAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
groupPolicy,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
|
||||
import type { GraphThreadMessage } from "../graph-thread.js";
|
||||
import { _resetThreadParentContextCachesForTest } from "../thread-parent-context.js";
|
||||
import "./message-handler-mock-support.test-support.js";
|
||||
@@ -80,7 +80,12 @@ vi.mock("../graph-thread.js", () => {
|
||||
});
|
||||
|
||||
describe("msteams monitor handler authz", () => {
|
||||
function createDeps(cfg: OpenClawConfig) {
|
||||
function createDeps(
|
||||
cfg: OpenClawConfig,
|
||||
options: {
|
||||
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
|
||||
} = {},
|
||||
) {
|
||||
const readAllowFromStore = vi.fn(async () => ["attacker-aad"]);
|
||||
const upsertPairingRequest = vi.fn(async () => null);
|
||||
const recordInboundSession = vi.fn(async () => undefined);
|
||||
@@ -94,6 +99,7 @@ describe("msteams monitor handler authz", () => {
|
||||
agentId: "default",
|
||||
accountId: "default",
|
||||
})),
|
||||
hasControlCommand: options.hasControlCommand,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,10 +287,7 @@ describe("msteams monitor handler authz", () => {
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler(createAttackerGroupActivity({ text: "" }));
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith({
|
||||
channel: "msteams",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
expect(conversationStore.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -560,6 +563,62 @@ describe("msteams monitor handler authz", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks unauthorized text control commands through shared ingress", async () => {
|
||||
resetThreadMocks();
|
||||
const hasControlCommand = vi.fn(() => true);
|
||||
const { conversationStore, deps } = createDeps(
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "open",
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{ hasControlCommand },
|
||||
);
|
||||
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler(createAttackerGroupActivity({ text: "/config set foo bar" }));
|
||||
|
||||
expect(hasControlCommand).toHaveBeenCalledWith("/config set foo bar", deps.cfg);
|
||||
expect(conversationStore.upsert).not.toHaveBeenCalled();
|
||||
expect(runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authorizes text control commands from static access groups", async () => {
|
||||
resetThreadMocks();
|
||||
const hasControlCommand = vi.fn(() => true);
|
||||
const { conversationStore, deps } = createDeps(
|
||||
{
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: { msteams: ["attacker-aad"] },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
{ hasControlCommand },
|
||||
);
|
||||
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler(createAttackerGroupActivity({ text: "/config set foo bar" }));
|
||||
|
||||
expect(conversationStore.upsert).toHaveBeenCalled();
|
||||
const dispatched =
|
||||
runtimeApiMockState.dispatchReplyFromConfigWithSettledDispatcher.mock.calls[0]?.[0];
|
||||
expect(dispatched?.ctxPayload).toMatchObject({
|
||||
CommandAuthorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("filters non-allowlisted thread messages out of BodyForAgent", async () => {
|
||||
mockThreadContext({
|
||||
parent: createThreadMessage({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
|
||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js";
|
||||
import { installMSTeamsTestRuntime } from "../monitor-handler.test-helpers.js";
|
||||
|
||||
@@ -11,6 +11,7 @@ type MessageHandlerDepsOptions = {
|
||||
upsertPairingRequest?: ReturnType<typeof vi.fn>;
|
||||
recordInboundSession?: ReturnType<typeof vi.fn>;
|
||||
resolveAgentRoute?: (params: { peer: { kind: string; id: string } }) => unknown;
|
||||
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
|
||||
};
|
||||
|
||||
export function createMessageHandlerDeps(
|
||||
@@ -39,6 +40,7 @@ export function createMessageHandlerDeps(
|
||||
upsertPairingRequest,
|
||||
recordInboundSession,
|
||||
resolveAgentRoute,
|
||||
hasControlCommand: options.hasControlCommand,
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
resolveStorePath: () => "/tmp/test-store",
|
||||
});
|
||||
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
logInboundDrop,
|
||||
resolveInboundSessionEnvelopeContext,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-gating";
|
||||
import {
|
||||
filterSupplementalContextItems,
|
||||
resolveChannelContextVisibilityMode,
|
||||
shouldIncludeSupplementalContext,
|
||||
} from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import {
|
||||
dispatchReplyFromConfigWithSettledDispatcher,
|
||||
hasFinalInboundReplyDispatch,
|
||||
@@ -88,12 +86,9 @@ function extractTextFromHtmlAttachments(attachments: MSTeamsAttachmentLike[]): s
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsAllowlistMatch,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
} from "../policy.js";
|
||||
import { resolveMSTeamsAllowlistMatch, resolveMSTeamsReplyPolicy } from "../policy.js";
|
||||
import { extractMSTeamsPollVote } from "../polls.js";
|
||||
import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
@@ -106,6 +101,38 @@ import { resolveMSTeamsSenderAccess } from "./access.js";
|
||||
import { resolveMSTeamsInboundMedia } from "./inbound-media.js";
|
||||
import { resolveMSTeamsRouteSessionKey } from "./thread-session.js";
|
||||
|
||||
function formatMSTeamsSenderReason(params: {
|
||||
reasonCode: string;
|
||||
dmPolicy?: string;
|
||||
groupPolicy?: string;
|
||||
}): string {
|
||||
switch (params.reasonCode) {
|
||||
case "dm_policy_open":
|
||||
return "dmPolicy=open";
|
||||
case "dm_policy_disabled":
|
||||
return "dmPolicy=disabled";
|
||||
case "dm_policy_pairing_required":
|
||||
return "dmPolicy=pairing (not allowlisted)";
|
||||
case "dm_policy_allowlisted":
|
||||
return `dmPolicy=${params.dmPolicy ?? "allowlist"} (allowlisted)`;
|
||||
case "dm_policy_not_allowlisted":
|
||||
return `dmPolicy=${params.dmPolicy ?? "allowlist"} (not allowlisted)`;
|
||||
case "group_policy_disabled":
|
||||
return "groupPolicy=disabled";
|
||||
case "group_policy_empty_allowlist":
|
||||
case "route_sender_empty":
|
||||
return "groupPolicy=allowlist (empty allowlist)";
|
||||
case "group_policy_not_allowlisted":
|
||||
return "groupPolicy=allowlist (not allowlisted)";
|
||||
case "group_policy_open":
|
||||
return "groupPolicy=open";
|
||||
case "group_policy_allowed":
|
||||
return `groupPolicy=${params.groupPolicy ?? "allowlist"}`;
|
||||
default:
|
||||
return params.reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
function buildStoredConversationReference(params: {
|
||||
activity: MSTeamsTurnContext["activity"];
|
||||
conversationId: string;
|
||||
@@ -262,21 +289,22 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
pairing,
|
||||
isDirectMessage,
|
||||
channelGate,
|
||||
access,
|
||||
configuredDmAllowFrom,
|
||||
effectiveDmAllowFrom,
|
||||
effectiveGroupAllowFrom,
|
||||
senderAccess,
|
||||
commandAccess,
|
||||
allowNameMatching,
|
||||
groupPolicy,
|
||||
} = await resolveMSTeamsSenderAccess({
|
||||
cfg,
|
||||
activity,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(text, cfg),
|
||||
});
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const commandAuthorized = commandAccess.requested ? commandAccess.authorized : undefined;
|
||||
const effectiveDmAllowFrom = senderAccess.effectiveAllowFrom;
|
||||
const effectiveGroupAllowFrom = senderAccess.effectiveGroupAllowFrom;
|
||||
const isChannel = conversationType === "channel";
|
||||
|
||||
if (isDirectMessage && msteamsCfg && access.decision !== "allow") {
|
||||
if (access.reason === "dmPolicy=disabled") {
|
||||
if (isDirectMessage && msteamsCfg && senderAccess.decision !== "allow") {
|
||||
if (senderAccess.reasonCode === "dm_policy_disabled") {
|
||||
log.info("dropping dm (dms disabled)", {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
@@ -290,7 +318,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (access.decision === "pairing") {
|
||||
if (senderAccess.decision === "pairing") {
|
||||
conversationStore.upsert(conversationId, conversationRef).catch((err) => {
|
||||
log.debug?.("failed to save conversation reference", {
|
||||
error: formatUnknownError(err),
|
||||
@@ -316,7 +344,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
sender: senderId,
|
||||
label: senderName,
|
||||
dmPolicy,
|
||||
reason: access.reason,
|
||||
reason: formatMSTeamsSenderReason({
|
||||
reasonCode: senderAccess.reasonCode,
|
||||
dmPolicy,
|
||||
groupPolicy,
|
||||
}),
|
||||
allowlistMatch: formatAllowlistMatchMeta(allowMatch),
|
||||
});
|
||||
return;
|
||||
@@ -340,20 +372,8 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: (_senderId, allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
}).allowed,
|
||||
});
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
||||
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_disabled") {
|
||||
log.info("dropping group message (groupPolicy: disabled)", {
|
||||
conversationId,
|
||||
});
|
||||
@@ -362,7 +382,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
||||
if (
|
||||
!senderAccess.allowed &&
|
||||
(senderAccess.reasonCode === "group_policy_empty_allowlist" ||
|
||||
senderAccess.reasonCode === "route_sender_empty")
|
||||
) {
|
||||
log.info("dropping group message (groupPolicy: allowlist, no allowlist)", {
|
||||
conversationId,
|
||||
});
|
||||
@@ -371,7 +395,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
|
||||
if (!senderAccess.allowed && senderAccess.reasonCode === "group_policy_not_allowlisted") {
|
||||
const allowMatch = resolveMSTeamsAllowlistMatch({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
@@ -392,30 +416,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const commandDmAllowFrom = isDirectMessage ? effectiveDmAllowFrom : configuredDmAllowFrom;
|
||||
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: commandDmAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching,
|
||||
});
|
||||
const { commandAuthorized, shouldBlock } = resolveDualTextControlCommandGate({
|
||||
useAccessGroups,
|
||||
primaryConfigured: commandDmAllowFrom.length > 0,
|
||||
primaryAllowed: ownerAllowedForCommands,
|
||||
secondaryConfigured: effectiveGroupAllowFrom.length > 0,
|
||||
secondaryAllowed: groupAllowedForCommands,
|
||||
hasControlCommand: core.channel.text.hasControlCommand(text, cfg),
|
||||
});
|
||||
if (shouldBlock) {
|
||||
if (commandAccess.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: logVerboseMessage,
|
||||
channel: "msteams",
|
||||
|
||||
@@ -263,5 +263,38 @@ describe("createMSTeamsReactionHandler", () => {
|
||||
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows reaction from static access group DM sender", async () => {
|
||||
const mockRuntime = buildMockRuntime();
|
||||
setMSTeamsRuntime(mockRuntime);
|
||||
const cfg: OpenClawConfig = {
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: { msteams: ["allowed-aad"] },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const handler = createMSTeamsReactionHandler(buildDeps(cfg, mockRuntime));
|
||||
const enqueue = mockRuntime.system.enqueueSystemEvent as ReturnType<typeof vi.fn>;
|
||||
|
||||
await invokeReactionEvent(
|
||||
handler,
|
||||
{
|
||||
reactionsAdded: [{ type: "like" }],
|
||||
from: { id: "good-user", aadObjectId: "allowed-aad", name: "Alice" },
|
||||
replyToId: "msg-7",
|
||||
},
|
||||
"added",
|
||||
);
|
||||
|
||||
expect(enqueue).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveEffectiveAllowFromLists,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveDefaultGroupPolicy,
|
||||
createChannelPairingController,
|
||||
} from "../../runtime-api.js";
|
||||
import { normalizeMSTeamsConversationId } from "../inbound.js";
|
||||
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsAllowlistMatch,
|
||||
resolveMSTeamsRouteConfig,
|
||||
} from "../policy.js";
|
||||
import { getMSTeamsRuntime } from "../runtime.js";
|
||||
import type { MSTeamsTurnContext } from "../sdk-types.js";
|
||||
import { resolveMSTeamsSenderAccess } from "./access.js";
|
||||
|
||||
/** Teams reaction type names → Unicode emoji. */
|
||||
const TEAMS_REACTION_EMOJI: Record<string, string> = {
|
||||
@@ -45,11 +32,6 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
const { cfg, log } = deps;
|
||||
const core = getMSTeamsRuntime();
|
||||
const msteamsCfg = cfg.channels?.msteams;
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
channel: "msteams",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
|
||||
return async function handleReaction(
|
||||
context: MSTeamsTurnContext,
|
||||
@@ -85,88 +67,18 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
|
||||
const senderId = from.aadObjectId ?? from.id;
|
||||
const senderName = from.name ?? from.id;
|
||||
const dmPolicy = msteamsCfg?.dmPolicy ?? "pairing";
|
||||
|
||||
// Simplified authorization: reuse the same allowlist/policy checks as the message handler.
|
||||
const storedAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "msteams",
|
||||
accountId: pairing.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
|
||||
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
|
||||
const groupAllowFrom = msteamsCfg?.groupAllowFrom;
|
||||
const resolvedAllowFromLists = resolveEffectiveAllowFromLists({
|
||||
allowFrom: dmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
// Enforce dmPolicy for DMs (open / disabled / allowlist / pairing).
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
||||
if (isDirectMessage && msteamsCfg) {
|
||||
const access = resolveDmGroupAccessWithLists({
|
||||
isGroup: false,
|
||||
dmPolicy,
|
||||
groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist",
|
||||
allowFrom: dmAllowFrom,
|
||||
groupAllowFrom,
|
||||
storeAllowFrom: storedAllowFrom,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
isSenderAllowed: (allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
allowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
}).allowed,
|
||||
});
|
||||
if (access.decision !== "allow") {
|
||||
log.debug?.("dropping reaction (dm access denied)", {
|
||||
if (msteamsCfg) {
|
||||
const senderAccess = await resolveMSTeamsSenderAccess({ cfg, activity });
|
||||
if (senderAccess.senderAccess.decision !== "allow") {
|
||||
log.debug?.("dropping reaction (access denied)", {
|
||||
sender: senderId,
|
||||
reason: access.reason,
|
||||
reason: senderAccess.senderAccess.reasonCode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For group/channel messages, check the route allowlist and sender allowlist.
|
||||
if (!isDirectMessage && msteamsCfg) {
|
||||
const teamId = (activity as unknown as { channelData?: { team?: { id?: string } } })
|
||||
.channelData?.team?.id;
|
||||
const teamName = (activity as unknown as { channelData?: { team?: { name?: string } } })
|
||||
.channelData?.team?.name;
|
||||
const channelName = (activity as unknown as { channelData?: { channel?: { name?: string } } })
|
||||
.channelData?.channel?.name;
|
||||
const channelGate = resolveMSTeamsRouteConfig({
|
||||
cfg: msteamsCfg,
|
||||
teamId,
|
||||
teamName,
|
||||
conversationId,
|
||||
channelName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
if (channelGate.allowlistConfigured && !channelGate.allowed) {
|
||||
log.debug?.("dropping reaction (not in team/channel allowlist)", { conversationId });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveGroupAllowFrom = resolvedAllowFromLists.effectiveGroupAllowFrom;
|
||||
const groupAllowed = isMSTeamsGroupAllowed({
|
||||
groupPolicy: msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist",
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
senderName,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(msteamsCfg),
|
||||
});
|
||||
if (!groupAllowed) {
|
||||
log.debug?.("dropping reaction (sender not in group allowlist)", { sender: senderId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the agent route for this conversation/sender.
|
||||
// Extract teamId for team-scoped routing bindings (channel/group reactions).
|
||||
const teamId = isDirectMessage
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MSTeamsConfig } from "../runtime-api.js";
|
||||
import {
|
||||
isMSTeamsGroupAllowed,
|
||||
resolveMSTeamsReplyPolicy,
|
||||
resolveMSTeamsRouteConfig,
|
||||
} from "./policy.js";
|
||||
import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js";
|
||||
|
||||
function resolveNamedTeamRouteConfig(allowNameMatching = false) {
|
||||
const cfg: MSTeamsConfig = {
|
||||
@@ -157,84 +153,4 @@ describe("msteams policy", () => {
|
||||
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMSTeamsGroupAllowed", () => {
|
||||
it("allows when policy is open", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks when policy is disabled", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["user-id"],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks allowlist when empty", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows allowlist when sender matches", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["User-Id"],
|
||||
senderId: "user-id",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks sender-name allowlist matches by default", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["user"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows sender-name allowlist matches when explicitly enabled", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["user"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist wildcard", () => {
|
||||
expect(
|
||||
isMSTeamsGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
senderId: "other",
|
||||
senderName: "User",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
normalizeChannelSlug,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveToolsBySender,
|
||||
@@ -245,18 +244,3 @@ export function resolveMSTeamsReplyPolicy(params: {
|
||||
|
||||
return { requireMention, replyStyle };
|
||||
}
|
||||
|
||||
export function isMSTeamsGroupAllowed(params: {
|
||||
groupPolicy: GroupPolicy;
|
||||
allowFrom: Array<string | number>;
|
||||
senderId: string;
|
||||
senderName?: string | null;
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
return evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy: params.groupPolicy,
|
||||
groupAllowFrom: params.allowFrom.map((entry) => String(entry)),
|
||||
senderId: params.senderId,
|
||||
isSenderAllowed: () => resolveMSTeamsAllowlistMatch(params).allowed,
|
||||
}).allowed;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user