fix(channels): honor configured read target policies (#99905)

* fix(channels): enforce configured read targets

* test(channels): align policy checks with boundaries

* fix: bind channel reads to trusted turn context

* test: satisfy gateway lint

* fix: narrow message action channel imports

* fix(feishu): authorize message reads before provider access

* fix(slack): await reaction clear authorization

* fix(channels): align provider action contracts

* fix(matrix): read direct-room account data before sync

* fix(channels): reject unsupported attachment actions early

* fix: restore trusted operator conversation reads

* fix(matrix): authorize pin actions before provider reads

* fix: preserve trusted channel read workflows

* fix(discord): resolve current channel ids consistently

* fix(agents): preserve message action turn capability

* fix(plugins): enforce host-owned read provenance

* fix(channels): harden Teams and Discord read policy

* fix(channels): preserve exact-current action compatibility

* fix(imessage): authorize trusted current chat aliases

* fix(channels): preserve normalized current aliases

* fix(channels): preserve external current target aliases

* fix: reconcile channel policy with current main

* fix(discord): isolate DM read policy

* fix(channels): enforce provider read gates

* fix(gateway): await serialized message action identity tokens

* fix(ci): refresh channel protocol contracts
This commit is contained in:
Josh Avant
2026-07-10 22:29:37 -05:00
committed by GitHub
parent ffc8051cac
commit fbd330b7aa
195 changed files with 15340 additions and 2534 deletions
@@ -870,6 +870,7 @@ public struct MessageActionParams: Codable, Sendable {
public let inboundturnkind: String?
public let agentid: String?
public let toolcontext: [String: AnyCodable]?
public let conversationreadorigin: String?
public let idempotencykey: String
public init(
@@ -885,6 +886,7 @@ public struct MessageActionParams: Codable, Sendable {
inboundturnkind: String? = nil,
agentid: String? = nil,
toolcontext: [String: AnyCodable]? = nil,
conversationreadorigin: String? = nil,
idempotencykey: String)
{
self.channel = channel
@@ -899,6 +901,7 @@ public struct MessageActionParams: Codable, Sendable {
self.inboundturnkind = inboundturnkind
self.agentid = agentid
self.toolcontext = toolcontext
self.conversationreadorigin = conversationreadorigin
self.idempotencykey = idempotencykey
}
@@ -915,6 +918,7 @@ public struct MessageActionParams: Codable, Sendable {
case inboundturnkind = "inboundTurnKind"
case agentid = "agentId"
case toolcontext = "toolContext"
case conversationreadorigin = "conversationReadOrigin"
case idempotencykey = "idempotencyKey"
}
}
@@ -6746,6 +6750,7 @@ public struct ToolsInvokeParams: Codable, Sendable {
public let agentid: String?
public let confirm: Bool?
public let idempotencykey: String?
public let conversationreadorigin: String?
public init(
name: String,
@@ -6753,7 +6758,8 @@ public struct ToolsInvokeParams: Codable, Sendable {
sessionkey: String? = nil,
agentid: String? = nil,
confirm: Bool? = nil,
idempotencykey: String? = nil)
idempotencykey: String? = nil,
conversationreadorigin: String? = nil)
{
self.name = name
self.args = args
@@ -6761,6 +6767,7 @@ public struct ToolsInvokeParams: Codable, Sendable {
self.agentid = agentid
self.confirm = confirm
self.idempotencykey = idempotencykey
self.conversationreadorigin = conversationreadorigin
}
private enum CodingKeys: String, CodingKey {
@@ -6770,6 +6777,7 @@ public struct ToolsInvokeParams: Codable, Sendable {
case agentid = "agentId"
case confirm
case idempotencykey = "idempotencyKey"
case conversationreadorigin = "conversationReadOrigin"
}
}
+2 -4
View File
@@ -179,7 +179,6 @@ Use these identifiers for delivery and allowlists:
systemPrompt: "Short answers only.",
},
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20,
},
@@ -193,9 +192,9 @@ Notes:
- Default webhook path is `/googlechat` when `webhookPath` is unset; `webhookUrl` can supply the path instead.
- Group keys must be stable space ids (`spaces/<spaceId>`). Display-name keys are deprecated and logged as such.
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries.
- Reactions are enabled by default and exposed through the `reactions` tool and `channels action`; disable with `actions.reactions: false`.
- Google Chat reaction actions are not exposed. The plugin uses service-account authentication, while Google Chat reaction endpoints require user authentication. Existing `actions.reactions` config is accepted for compatibility but has no effect.
- Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `dm.allowFrom` or `defaultTo` and must be stable numeric `users/<id>` values.
- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting (`threadId` / `replyTo`).
- Message actions expose text `send` only. Google Chat attachment upload requires user authentication, while this plugin uses service-account authentication, so outbound file upload is not exposed.
- `typingIndicator`: `message` (default) posts a `_<Bot> is typing..._` placeholder and edits it into the first reply; `none` disables it; `reaction` requires user OAuth and currently falls back to `message` with a logged error under service-account auth.
- Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20).
- Bot-authored messages are ignored by default. With `allowBots: true`, accepted bot messages use shared [bot loop protection](/channels/bot-loop-protection): configure `channels.defaults.botLoopProtection`, then override with `channels.googlechat.botLoopProtection` or `channels.googlechat.groups.<space>.botLoopProtection`.
@@ -257,5 +256,4 @@ openclaw channels status
- [Gateway configuration](/gateway/configuration)
- [Groups](/channels/groups) — group chat behavior and mention gating
- [Pairing](/channels/pairing) — DM authentication and pairing flow
- [Reactions](/tools/reactions)
- [Security](/gateway/security) — access model and hardening
+7 -4
View File
@@ -451,14 +451,17 @@ These auth-related config keys can be set via environment variables instead of `
## Member info action
OpenClaw exposes a Graph-backed `member-info` message action for Microsoft Teams so agents and automations can resolve channel member details (display name, email, job title, UPN, office location) directly from Microsoft Graph.
OpenClaw exposes a Graph-backed `member-info` action for Microsoft Teams so agents and automations can resolve verified roster details for a configured conversation.
Requirements:
- `Member.Read.Group` RSC permission (already in the recommended manifest).
- For cross-team lookups: `User.Read.All` Graph Application permission with admin consent.
- `ChannelSettings.Read.Group` and `TeamMember.Read.Group` RSC permissions (already in the recommended manifest).
The action runs whenever Graph credentials are configured; it fails with a Graph auth error when they are not. There is no separate `channels.msteams.actions.memberInfo` toggle.
The action is available whenever Graph credentials are configured; there is no separate `channels.msteams.actions.memberInfo` toggle.
Standard-channel lookups return the matching team-roster identity, display name, email, and roles.
In the current DM or group chat, the action can return the trusted sender's stable user ID.
Private/shared-channel and non-current chat member lookups require additional roster permissions
and are rejected by the default permission baseline.
## History context
+2 -2
View File
@@ -70,8 +70,8 @@ unresolved SecretRef on the selected channel/account fails the action closed.
| --------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `send` | Discord, Google Chat, iMessage, Matrix, Mattermost (plugin), Microsoft Teams, Signal, Slack, Telegram, WhatsApp | `--target`, plus one of `--message`/`--media`/`--presentation` | See [Send](#send) below. |
| `poll` | Discord, Matrix, Microsoft Teams, Telegram, WhatsApp | `--target`, `--poll-question`, `--poll-option` (repeat) | See [Poll](#poll) below. |
| `react` | Discord, Google Chat, Matrix, Nextcloud Talk, Signal, Slack, Telegram, WhatsApp | `--message-id`, `--target` | `--emoji`, `--remove` (needs `--emoji`; omit it to clear own reactions where supported, see [Reactions](/tools/reactions)). WhatsApp: `--participant`, `--from-me`. Signal group reactions require `--target-author` or `--target-author-uuid`. Nextcloud Talk only adds reactions; `--remove` errors. |
| `reactions` | Discord, Google Chat, Matrix, Microsoft Teams, Slack | `--message-id`, `--target` | `--limit`. |
| `react` | Discord, Matrix, Nextcloud Talk, Signal, Slack, Telegram, WhatsApp | `--message-id`, `--target` | `--emoji`, `--remove` (needs `--emoji`; omit it to clear own reactions where supported, see [Reactions](/tools/reactions)). WhatsApp: `--participant`, `--from-me`. Signal group reactions require `--target-author` or `--target-author-uuid`. Nextcloud Talk only adds reactions; `--remove` errors. |
| `reactions` | Discord, Matrix, Microsoft Teams, Slack | `--message-id`, `--target` | `--limit`. |
| `read` | Discord, Matrix, Microsoft Teams, Slack | `--target` | `--limit`, `--message-id`, `--before`, `--after`. Discord: `--around`, `--include-thread`. Slack: `--message-id` reads a specific timestamp, combine with `--thread-id` for an exact thread reply. |
| `edit` | Discord, Matrix, Microsoft Teams, Slack, Telegram | `--message-id`, `--message`, `--target` | Telegram forum threads use `--thread-id`. |
| `delete` | Discord, Matrix, Microsoft Teams, Slack, Telegram | `--message-id`, `--target` | |
+4
View File
@@ -236,6 +236,10 @@ Tools can be required or optional. Required tools are always available when the
plugin is enabled. Optional tools need explicit user opt-in before OpenClaw
loads the owning plugin runtime.
Tool factories receive trusted runtime context, including `deliveryContext`,
`nativeChannelId` for the active platform conversation when available, and
`requesterSenderId`.
```typescript
register(api) {
api.registerTool(
-6
View File
@@ -37,12 +37,6 @@ action. Behavior varies by channel.
</Accordion>
<Accordion title="Google Chat">
- Empty `emoji` (or `remove: true`) removes the bot's own reactions on the message, filtered to `emoji` when set.
- `remove: true` removes just the specified emoji.
</Accordion>
<Accordion title="Nextcloud Talk">
- Adding reactions only: `emoji` is required and must be non-empty.
- Reaction removal is not wired to a delete call yet; `remove: true` is rejected with an explicit error instead of silently no-oping.
@@ -274,23 +274,32 @@ describe("Codex app-server dynamic tool build", () => {
expect(webSearchAllowed).toBe(true);
});
it("forwards the originating client caps into coding tool assembly", async () => {
it("forwards client caps alongside channel authority context", async () => {
// Regression: capability-gated tools (requiredClientCaps) vanished on the
// Codex app-server path because this harness dropped params.clientCaps.
// Keep that fact composed with the operation-local message context.
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.clientCaps = ["tool-events", "inline-widgets"];
let receivedClientCaps: string[] | undefined;
params.chatId = "native-chat-123";
params.chatType = "direct";
params.messageActionTurnCapability = "turn-capability-1";
let receivedOptions: unknown;
setOpenClawCodingToolsFactoryForTests((options) => {
receivedClientCaps = (options as { clientCaps?: string[] }).clientCaps;
receivedOptions = options;
return [createRuntimeDynamicTool("message")];
});
await buildDynamicToolsForTest(params, workspaceDir);
expect(receivedClientCaps).toEqual(["tool-events", "inline-widgets"]);
expect(receivedOptions).toMatchObject({
clientCaps: ["tool-events", "inline-widgets"],
chatType: "direct",
nativeChannelId: "native-chat-123",
messageActionTurnCapability: "turn-capability-1",
});
});
it("shares the computer context epoch with dynamic tool assembly", async () => {
@@ -980,6 +989,7 @@ describe("Codex app-server dynamic tool build", () => {
} satisfies EmbeddedRunAttemptParams["authProfileStore"];
params.disableTools = false;
params.authProfileStore = authProfileStore;
params.messageActionTurnCapability = "turn-capability-1";
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
@@ -993,6 +1003,9 @@ describe("Codex app-server dynamic tool build", () => {
expect((factoryOptions[0] as { authProfileStore?: unknown }).authProfileStore).toBe(
authProfileStore,
);
expect(
(factoryOptions[0] as { messageActionTurnCapability?: unknown }).messageActionTurnCapability,
).toBe("turn-capability-1");
});
it("passes owner identity into Codex dynamic tool construction", async () => {
@@ -1018,6 +1031,8 @@ describe("Codex app-server dynamic tool build", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.chatId = "native-chat-123";
params.chatType = "direct";
params.currentChannelId = "D123";
params.currentMessagingTarget = "user:U123";
params.runtimePlan = createCodexRuntimePlanFixture();
@@ -1030,8 +1045,10 @@ describe("Codex app-server dynamic tool build", () => {
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
chatType: "direct",
currentChannelId: "D123",
currentMessagingTarget: "user:U123",
nativeChannelId: "native-chat-123",
});
});
@@ -250,9 +250,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
// Capability-gated tools (requiredClientCaps) need the originating client's
// declared caps in this sibling harness too, not only the embedded runner.
clientCaps: params.clientCaps,
chatType: params.chatType,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
nativeChannelId: params.chatId,
messageActionTurnCapability: params.messageActionTurnCapability,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
@@ -478,8 +478,11 @@ describe("runCodexAppServerSideQuestion", () => {
sideParams({
messageChannel: "discord",
messageProvider: "discord-voice",
chatId: "discord-native-room",
chatType: "channel",
sessionKey: "agent:main:conversation",
sandboxSessionKey: "agent:main:runtime-policy",
messageActionTurnCapability: "turn-capability-1",
currentChannelId: "voice-room",
agentAccountId: "account-1",
messageTo: "channel-1",
@@ -594,7 +597,9 @@ describe("runCodexAppServerSideQuestion", () => {
expect(toolOptions).toHaveProperty("modelId", "gpt-5.5");
expect(toolOptions).toHaveProperty("messageProvider", "discord");
expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice");
expect(toolOptions).toHaveProperty("chatType", "channel");
expect(toolOptions).toHaveProperty("currentChannelId", "voice-room");
expect(toolOptions).toHaveProperty("nativeChannelId", "discord-native-room");
expect(toolOptions).toMatchObject({
agentAccountId: "account-1",
sessionKey: "agent:main:runtime-policy",
@@ -610,6 +615,7 @@ describe("runCodexAppServerSideQuestion", () => {
senderUsername: "rosita",
senderE164: "+15550001",
senderIsOwner: true,
messageActionTurnCapability: "turn-capability-1",
});
expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true);
});
@@ -743,9 +743,14 @@ function buildSideRunAttemptParams(
agentId: params.agentId,
...(params.messageChannel ? { messageChannel: params.messageChannel } : {}),
...(params.messageProvider ? { messageProvider: params.messageProvider } : {}),
...(params.chatType ? { chatType: params.chatType } : {}),
...(params.agentAccountId ? { agentAccountId: params.agentAccountId } : {}),
...(params.messageTo ? { messageTo: params.messageTo } : {}),
...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}),
...(params.chatId ? { chatId: params.chatId } : {}),
...(params.messageActionTurnCapability
? { messageActionTurnCapability: params.messageActionTurnCapability }
: {}),
...(params.groupId !== undefined ? { groupId: params.groupId } : {}),
...(params.groupChannel !== undefined ? { groupChannel: params.groupChannel } : {}),
...(params.groupSpace !== undefined ? { groupSpace: params.groupSpace } : {}),
@@ -843,11 +848,16 @@ async function createCodexSideToolBridge(input: {
toolPolicyMessageProvider: input.params.messageProvider ?? input.params.messageChannel,
}
: {}),
...(input.params.chatType ? { chatType: input.params.chatType } : {}),
...(input.params.agentAccountId ? { agentAccountId: input.params.agentAccountId } : {}),
...(input.params.messageTo ? { messageTo: input.params.messageTo } : {}),
...(input.params.messageThreadId !== undefined
? { messageThreadId: input.params.messageThreadId }
: {}),
...(input.params.chatId ? { nativeChannelId: input.params.chatId } : {}),
...(input.params.messageActionTurnCapability
? { messageActionTurnCapability: input.params.messageActionTurnCapability }
: {}),
...(input.params.groupId !== undefined ? { groupId: input.params.groupId } : {}),
...(input.params.groupChannel !== undefined
? { groupChannel: input.params.groupChannel }
@@ -518,6 +518,7 @@ describe("createCopilotToolBridge", () => {
runId: "run-1",
config,
onToolOutcome,
messageActionTurnCapability: "turn-capability-1",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
@@ -530,6 +531,7 @@ describe("createCopilotToolBridge", () => {
expect(opts.runId).toBe("run-1");
expect(opts.config).toBe(config);
expect(opts.onToolOutcome).toBe(onToolOutcome);
expect(opts.messageActionTurnCapability).toBe("turn-capability-1");
});
it("prefers the unscoped toolAuthProfileStore when building OpenClaw tools", async () => {
@@ -695,6 +697,27 @@ describe("createCopilotToolBridge", () => {
expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]);
});
it("forwards the native conversation identity from attemptParams", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
chatId: "oc_native_chat",
chatType: "direct",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(getOpts()).toMatchObject({
chatType: "direct",
nativeChannelId: "oc_native_chat",
});
});
it("onYield routes to sessionRef.current.abort() and invokes onYieldDetected when the live session is bound", async () => {
const { createOpenClawCodingTools, getOpts } = captureCall();
const abort = vi.fn();
+3
View File
@@ -361,9 +361,12 @@ function buildOpenClawCodingToolsOptions(
elevated: a.bashElevated,
},
messageProvider: a.messageProvider ?? a.messageChannel,
chatType: a.chatType,
agentAccountId: a.agentAccountId,
messageTo: a.messageTo,
messageThreadId: a.messageThreadId,
nativeChannelId: a.chatId,
messageActionTurnCapability: a.messageActionTurnCapability,
groupId: a.groupId,
groupChannel: a.groupChannel,
groupSpace: a.groupSpace,
@@ -10,6 +10,7 @@ import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-co
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { handleDiscordAction } from "../../action-runtime-api.js";
import { isTrustedRequesterGuildAdminAction } from "../trusted-requester-actions.js";
import type { DiscordMessagingActionOptions } from "./runtime.messaging.shared.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
@@ -54,8 +55,9 @@ function senderParam(senderUserId: string | undefined) {
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
ctx: Ctx;
resolveChannelId: () => string;
readPolicyOptions?: DiscordMessagingActionOptions;
}): Promise<AgentToolResult<unknown> | undefined> {
const { ctx, resolveChannelId } = params;
const { ctx, resolveChannelId, readPolicyOptions } = params;
const { action, params: actionParams, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId");
const senderUserId = readDiscordRequesterSenderId(ctx);
@@ -68,6 +70,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "memberInfo", accountId: accountId ?? undefined, guildId, userId },
cfg,
readPolicyOptions,
);
}
@@ -78,6 +81,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "roleInfo", accountId: accountId ?? undefined, guildId },
cfg,
readPolicyOptions,
);
}
@@ -88,6 +92,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "emojiList", accountId: accountId ?? undefined, guildId },
cfg,
readPolicyOptions,
);
}
@@ -173,6 +178,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "channelInfo", accountId: accountId ?? undefined, channelId },
cfg,
readPolicyOptions,
);
}
@@ -183,6 +189,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "channelList", accountId: accountId ?? undefined, guildId },
cfg,
readPolicyOptions,
);
}
@@ -310,6 +317,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId },
cfg,
readPolicyOptions,
);
}
@@ -320,6 +328,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
return await handleDiscordAction(
{ action: "eventList", accountId: accountId ?? undefined, guildId },
cfg,
readPolicyOptions,
);
}
@@ -403,6 +412,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
limit,
},
cfg,
readPolicyOptions,
);
}
@@ -467,6 +477,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
limit: readPositiveIntegerParam(actionParams, "limit"),
},
cfg,
readPolicyOptions,
);
}
@@ -302,6 +302,81 @@ describe("handleDiscordMessageAction", () => {
});
});
it("forwards attested current-conversation context to Discord reads", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
action: "read",
params: {
channelId: "channel:123",
},
cfg,
accountId: "ops",
requesterAccountId: "ops",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expectDiscordActionCall({
payload: {
action: "readMessages",
accountId: "ops",
channelId: "123",
limit: undefined,
before: undefined,
after: undefined,
around: undefined,
},
cfg,
options: {
...defaultActionOptions(),
conversationReadOrigin: "delegated",
readContext: {
requesterAccountId: "ops",
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
},
});
});
it("forwards attested current-conversation context to Discord channel info", async () => {
const cfg = discordConfig({ channelInfo: true });
await handleDiscordMessageAction({
action: "channel-info",
params: {
channelId: "123",
},
cfg,
accountId: "ops",
requesterAccountId: "ops",
conversationReadOrigin: "delegated",
toolContext: {
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
});
expectDiscordActionCall({
payload: {
action: "channelInfo",
accountId: "ops",
channelId: "123",
},
cfg,
options: {
conversationReadOrigin: "delegated",
readContext: {
requesterAccountId: "ops",
currentChannelProvider: "discord",
currentChannelId: "channel:123",
},
},
});
});
it("forwards threadName on sends", async () => {
const cfg = discordConfig();
await handleDiscordMessageAction({
@@ -23,6 +23,7 @@ import {
} from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
import type { DiscordMessagingActionOptions } from "./runtime.messaging.shared.js";
const providerId = "discord";
@@ -44,6 +45,7 @@ export async function handleDiscordMessageAction(
| "params"
| "cfg"
| "accountId"
| "requesterAccountId"
| "requesterSenderId"
| "senderIsOwner"
| "toolContext"
@@ -52,14 +54,35 @@ export async function handleDiscordMessageAction(
| "mediaReadFile"
| "sessionKey"
| "inboundEventKind"
| "conversationReadOrigin"
>,
): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const readContext =
ctx.requesterAccountId &&
ctx.toolContext?.currentChannelProvider &&
ctx.toolContext.currentChannelId
? {
requesterAccountId: ctx.requesterAccountId,
currentChannelProvider: ctx.toolContext.currentChannelProvider,
currentChannelId: ctx.toolContext.currentChannelId,
}
: undefined;
const readPolicyOptions: DiscordMessagingActionOptions | undefined =
ctx.conversationReadOrigin || readContext
? {
...(ctx.conversationReadOrigin
? { conversationReadOrigin: ctx.conversationReadOrigin }
: {}),
...(readContext ? { readContext } : {}),
}
: undefined;
const actionOptions = {
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
...readPolicyOptions,
} as const;
const notifyVisibleOutbound = (to: string, fallbackSessionKey?: string) =>
notifyDiscordInboundEventOutboundSuccess({
@@ -397,6 +420,7 @@ export async function handleDiscordMessageAction(
const adminResult = await tryHandleDiscordMessageActionGuildAdmin({
ctx,
resolveChannelId,
readPolicyOptions,
});
if (adminResult !== undefined) {
if (action === "thread-reply") {
@@ -37,7 +37,10 @@ import {
uploadStickerDiscord,
resolveEventCoverImage,
} from "../send.js";
import { createDiscordMessagingActionContext } from "./runtime.messaging.shared.js";
import {
createDiscordMessagingActionContext,
type DiscordMessagingActionOptions,
} from "./runtime.messaging.shared.js";
import {
createDiscordActionOptions,
readDiscordChannelCreateParams,
@@ -357,7 +360,7 @@ export async function handleDiscordGuildAction(
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
cfg: OpenClawConfig,
options?: { mediaLocalRoots?: readonly string[] },
options?: DiscordMessagingActionOptions,
): Promise<AgentToolResult<unknown>> {
const accountId = readStringParam(params, "accountId");
if (!cfg) {
@@ -374,9 +377,13 @@ export async function handleDiscordGuildAction(
});
const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg, accountId, extra });
const assertGuildMetadataReadAllowed = async (guildId: string) => {
const assertGuildMetadataReadAllowed = async (
guildId: string,
readOptions?: { filteredResults?: boolean },
) => {
await readTargetGate.assertGuildReadTargetAllowed({
guildId,
filteredResults: readOptions?.filteredResults,
channelTargetRequiredMessage:
"Discord guild metadata reads require a wildcard channel allowlist for this guild.",
});
@@ -521,12 +528,13 @@ export async function handleDiscordGuildAction(
const guildId = readStringParam(params, "guildId", {
required: true,
});
await assertGuildMetadataReadAllowed(guildId);
await assertGuildMetadataReadAllowed(guildId, { filteredResults: true });
const channels = await discordGuildActionRuntime.listGuildChannelsDiscord(
guildId,
withOpts(),
);
return jsonResult({ ok: true, channels });
const visibleChannels = await readTargetGate.filterGuildChannelList({ guildId, channels });
return jsonResult({ ok: true, channels: visibleChannels });
}
case "voiceStatus": {
if (!isActionEnabled("voiceStatus")) {
@@ -126,6 +126,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
const content = readStringParam(ctx.params, "content", {
required: true,
});
await ctx.assertReadTargetAllowed({ channelId });
const message = await discordMessagingActionRuntime.editMessageDiscord(
channelId,
messageId,
@@ -142,6 +143,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await ctx.assertReadTargetAllowed({ channelId });
await discordMessagingActionRuntime.deleteMessageDiscord(
channelId,
messageId,
@@ -157,6 +159,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await ctx.assertReadTargetAllowed({ channelId });
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, ctx.withOpts());
return jsonResult({ ok: true });
}
@@ -168,6 +171,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
const messageId = readStringParam(ctx.params, "messageId", {
required: true,
});
await ctx.assertReadTargetAllowed({ channelId });
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, ctx.withOpts());
return jsonResult({ ok: true });
}
@@ -22,6 +22,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging
removeErrorMessage: "Emoji is required to remove a Discord reaction.",
});
if (remove) {
await ctx.assertReadTargetAllowed({ channelId });
await discordMessagingActionRuntime.removeReactionDiscord(
channelId,
messageId,
@@ -31,6 +32,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging
return jsonResult({ ok: true, removed: emoji });
}
if (isEmpty) {
await ctx.assertReadTargetAllowed({ channelId });
const removed = await discordMessagingActionRuntime.removeOwnReactionsDiscord(
channelId,
messageId,
@@ -38,6 +40,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging
);
return jsonResult({ ok: true, removed: removed.removed });
}
await ctx.assertReadTargetAllowed({ channelId });
await discordMessagingActionRuntime.reactMessageDiscord(
channelId,
messageId,
@@ -1,3 +1,6 @@
import { ChannelType } from "discord-api-types/v10";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
// Discord plugin module implements runtime.messaging.shared behavior.
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "../accounts.js";
@@ -5,6 +8,7 @@ import { createDiscordRuntimeAccountContext } from "../client.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordSlug,
resolveGroupDmAllow,
resolveDiscordChannelConfigWithFallback,
type DiscordGuildEntryResolved,
} from "../monitor/allow-list.js";
@@ -19,6 +23,10 @@ import type { DiscordReactOpts } from "../send.types.js";
import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js";
import { createDiscordActionOptions } from "./runtime.shared.js";
type ConversationReadInvocationOrigin = NonNullable<
ChannelMessageActionContext["conversationReadOrigin"]
>;
export type DiscordMessagingActionOptions = {
mediaAccess?: {
localRoots?: readonly string[];
@@ -27,6 +35,12 @@ export type DiscordMessagingActionOptions = {
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
conversationReadOrigin?: ConversationReadInvocationOrigin;
readContext?: {
requesterAccountId?: string | null;
currentChannelProvider?: string | null;
currentChannelId?: string | null;
};
};
export type DiscordMessagingActionContext = {
@@ -41,7 +55,9 @@ export type DiscordMessagingActionContext = {
assertGuildReadTargetAllowed: (params: {
guildId: string;
channelTargetRequiredMessage?: string;
filteredResults?: boolean;
}) => Promise<void>;
filterGuildChannelList: <T>(params: { guildId: string; channels: T[] }) => Promise<T[]>;
resolveReactionChannelId: () => Promise<string>;
withOpts: (extra?: Record<string, unknown>) => { cfg: OpenClawConfig; accountId?: string };
withReactionRuntimeOptions: <T extends Record<string, unknown> = Record<string, never>>(
@@ -103,15 +119,59 @@ function resolveDiscordActionGuildEntry(params: {
type DiscordReadTargetContext = {
channelId: string;
metadataKnown: boolean;
ancestryComplete: boolean;
channelType?: number;
guildId?: string;
channelName?: string;
channelSlug: string;
ancestors: DiscordReadAncestor[];
parentId?: string;
parentName?: string;
parentSlug?: string;
scope?: "channel" | "thread";
};
type DiscordReadAncestor = {
channelId: string;
channelName?: string;
channelSlug: string;
};
async function resolveDiscordReadAncestry(params: {
channelId: string;
parentId?: string;
loadChannel: (channelId: string) => Promise<unknown>;
}): Promise<{ ancestors: DiscordReadAncestor[]; complete: boolean }> {
const ancestors: DiscordReadAncestor[] = [];
const visited = new Set([params.channelId]);
let parentId = params.parentId;
// Discord hierarchy is bounded at thread -> channel -> category. Preserve
// that bound so malformed metadata cannot expand authorization-time I/O.
for (let depth = 0; parentId && depth < 2; depth++) {
if (visited.has(parentId)) {
return { ancestors, complete: false };
}
visited.add(parentId);
const parent = await params.loadChannel(parentId);
if (!parent) {
ancestors.push({
channelId: parentId,
channelSlug: normalizeDiscordSlug(parentId) || parentId,
});
return { ancestors, complete: false };
}
const parentName = readDiscordChannelStringField(parent, "name");
ancestors.push({
channelId: parentId,
...(parentName ? { channelName: parentName } : {}),
channelSlug: parentName ? normalizeDiscordSlug(parentName) : parentId,
});
parentId = readDiscordChannelStringField(parent, "parent_id", "parentId");
}
return { ancestors, complete: !parentId };
}
function readDiscordChannelStringField(value: unknown, ...keys: string[]): string | undefined {
if (!value || typeof value !== "object") {
return undefined;
@@ -139,11 +199,41 @@ function isDiscordThreadChannel(value: unknown): boolean {
return type === 10 || type === 11 || type === 12;
}
function isDiscordReadAncestryAllowed(params: {
guildInfo: DiscordGuildEntryResolved | null;
target: DiscordReadTargetContext;
}): boolean {
for (const ancestor of params.target.ancestors) {
const config = resolveDiscordChannelConfigWithFallback({
guildInfo: params.guildInfo,
channelId: ancestor.channelId,
channelName: ancestor.channelName,
channelSlug: ancestor.channelSlug,
});
if (config?.matchSource === "direct" && !config.allowed) {
return false;
}
}
return (
params.target.ancestryComplete ||
!hasExplicitlyDisabledDiscordChannels(params.guildInfo?.channels)
);
}
function isDiscordReadTargetAllowedInGuild(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildInfo: DiscordGuildEntryResolved | null;
target: DiscordReadTargetContext;
}): boolean {
if (!params.target.metadataKnown) {
if (hasExplicitlyDisabledDiscordChannels(params.guildInfo?.channels)) {
return false;
}
return isDiscordReadTargetExplicitlyAllowedById(params);
}
if (!isDiscordReadAncestryAllowed(params)) {
return false;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo: params.guildInfo,
channelId: params.target.channelId,
@@ -182,6 +272,20 @@ function isDiscordReadTargetExplicitlyAllowedById(params: {
});
}
function hasExplicitlyDisabledDiscordChannelConfig(
guilds: Record<string, DiscordGuildEntryResolved | undefined> | undefined,
): boolean {
return Object.values(guilds ?? {}).some((guild) =>
hasExplicitlyDisabledDiscordChannels(guild?.channels),
);
}
function hasExplicitlyDisabledDiscordChannels(
channels: DiscordGuildEntryResolved["channels"] | undefined,
): boolean {
return Object.values(channels ?? {}).some((channel) => channel.enabled === false);
}
export function createDiscordMessagingActionContext(params: {
action: string;
input: Record<string, unknown>;
@@ -196,15 +300,36 @@ export function createDiscordMessagingActionContext(params: {
accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const guilds = accountConfig.guilds as Record<string, DiscordGuildEntryResolved | undefined>;
const hasGuildEntries = Object.keys(guilds ?? {}).length > 0;
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.discord !== undefined,
groupPolicy: accountConfig.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const directOperator = params.options?.conversationReadOrigin === "direct-operator";
const currentReadContext = params.options?.readContext;
const directDmEnabled =
accountConfig.dm?.enabled !== false &&
(accountConfig.dmPolicy ?? accountConfig.dm?.policy ?? "pairing") !== "disabled";
const withOpts = (extra?: Record<string, unknown>) =>
createDiscordActionOptions({ cfg: params.cfg, accountId, extra });
const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg);
const isCurrentReadTarget = (channelId: string): boolean => {
const requesterAccountId = currentReadContext?.requesterAccountId?.trim();
const currentChannelId = currentReadContext?.currentChannelId?.trim();
if (
currentReadContext?.currentChannelProvider?.trim().toLowerCase() !== "discord" ||
!requesterAccountId ||
!currentChannelId ||
normalizeAccountId(requesterAccountId) !== normalizeAccountId(resolvedReactionAccountId)
) {
return false;
}
try {
return discordMessagingActionRuntime.resolveDiscordChannelId(currentChannelId) === channelId;
} catch {
return false;
}
};
const reactionRuntimeOptions = resolvedReactionAccountId
? createDiscordRuntimeAccountContext({
cfg: params.cfg,
@@ -256,6 +381,9 @@ export function createDiscordMessagingActionContext(params: {
const fallback: DiscordReadTargetContext = {
channelId,
channelSlug: normalizeDiscordSlug(channelId) || channelId,
metadataKnown: false,
ancestryComplete: false,
ancestors: [],
};
let channelInfo: unknown;
try {
@@ -270,7 +398,14 @@ export function createDiscordMessagingActionContext(params: {
const target: DiscordReadTargetContext = {
channelId,
channelSlug: channelName ? normalizeDiscordSlug(channelName) : fallback.channelSlug,
metadataKnown: true,
ancestryComplete: true,
ancestors: [],
};
const channelType = readDiscordChannelType(channelInfo);
if (channelType !== undefined) {
target.channelType = channelType;
}
const targetGuildId = readDiscordChannelStringField(channelInfo, "guild_id", "guildId");
if (targetGuildId) {
target.guildId = targetGuildId;
@@ -278,29 +413,84 @@ export function createDiscordMessagingActionContext(params: {
if (channelName) {
target.channelName = channelName;
}
if (!isDiscordThreadChannel(channelInfo)) {
if (isDiscordThreadChannel(channelInfo)) {
target.scope = "thread";
}
const ancestry = await resolveDiscordReadAncestry({
channelId,
parentId: readDiscordChannelStringField(channelInfo, "parent_id", "parentId"),
loadChannel: async (parentId) => {
try {
return await discordMessagingActionRuntime.fetchChannelInfoDiscord(parentId, withOpts());
} catch {
return undefined;
}
},
});
target.ancestors = ancestry.ancestors;
target.ancestryComplete = ancestry.complete;
const immediateParent = target.ancestors[0];
if (!immediateParent) {
return target;
}
target.scope = "thread";
target.parentId = readDiscordChannelStringField(channelInfo, "parent_id", "parentId");
if (!target.parentId) {
return target;
}
try {
const parentInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
target.parentId,
withOpts(),
);
const parentName = readDiscordChannelStringField(parentInfo, "name");
if (parentName) {
target.parentName = parentName;
target.parentSlug = normalizeDiscordSlug(parentName);
}
} catch {
// Parent id fallback is enough for allowlist checks when the parent fetch is unavailable.
target.parentId = immediateParent.channelId;
if (immediateParent.channelName) {
target.parentName = immediateParent.channelName;
}
target.parentSlug = immediateParent.channelSlug;
return target;
};
const isExpandedReadTargetEnabled = (
guildInfo: DiscordGuildEntryResolved | null,
target: DiscordReadTargetContext,
currentConversation: boolean,
): boolean => {
const groupDmEnabled =
accountConfig.dm?.groupEnabled === true &&
(currentConversation ||
resolveGroupDmAllow({
channels: accountConfig.dm?.groupChannels,
channelId: target.channelId,
channelName: target.channelName,
channelSlug: target.channelSlug,
}));
if (!target.metadataKnown) {
// Without provider metadata, the target might be a guild channel, DM, or
// group DM. Every plausible scope must allow it before provider content reads.
return (
groupPolicy !== "disabled" &&
directDmEnabled &&
groupDmEnabled &&
!hasExplicitlyDisabledDiscordChannelConfig(guilds)
);
}
if (!target.guildId) {
if (target.channelType === ChannelType.GroupDM) {
return groupDmEnabled;
}
if (target.channelType === ChannelType.DM) {
return directDmEnabled;
}
return directDmEnabled && groupDmEnabled;
}
if (groupPolicy === "disabled") {
return false;
}
if (!isDiscordReadAncestryAllowed({ guildInfo, target })) {
return false;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: target.channelId,
channelName: target.channelName,
channelSlug: target.channelSlug,
parentId: target.parentId,
parentName: target.parentName,
parentSlug: target.parentSlug,
scope: target.scope,
});
return !channelConfig?.matchSource || channelConfig.allowed;
};
return {
action: params.action,
params: params.input,
@@ -316,15 +506,19 @@ export function createDiscordMessagingActionContext(params: {
),
assertReadTargetAllowed: async ({ guildId, channelId }) => {
const targetChannelId = discordMessagingActionRuntime.resolveDiscordChannelId(channelId);
if (!hasGuildEntries && groupPolicy !== "disabled" && groupPolicy !== "allowlist") {
return;
}
const target = await resolveReadTargetContext(targetChannelId);
const currentConversation = isCurrentReadTarget(targetChannelId);
if (guildId) {
if (target.guildId && target.guildId !== guildId) {
if (target.metadataKnown && target.guildId !== guildId) {
throw new Error("Discord read target channel is not allowed.");
}
const guildInfo = await resolveReadGuildEntry(guildId);
if (
(directOperator && isExpandedReadTargetEnabled(guildInfo, target, false)) ||
(currentConversation && isExpandedReadTargetEnabled(guildInfo, target, true))
) {
return;
}
if (
!isDiscordReadTargetAllowedInGuild({
groupPolicy,
@@ -338,6 +532,12 @@ export function createDiscordMessagingActionContext(params: {
}
if (target.guildId) {
const guildInfo = await resolveReadGuildEntry(target.guildId);
if (
(directOperator && isExpandedReadTargetEnabled(guildInfo, target, false)) ||
(currentConversation && isExpandedReadTargetEnabled(guildInfo, target, true))
) {
return;
}
if (
!isDiscordReadTargetAllowedInGuild({
groupPolicy,
@@ -349,19 +549,41 @@ export function createDiscordMessagingActionContext(params: {
}
return;
}
const allowed = Object.values(guilds ?? {}).some((guildInfo) =>
isDiscordReadTargetExplicitlyAllowedById({
groupPolicy,
guildInfo: guildInfo ?? null,
target,
}),
);
// Known non-guild targets must never borrow a guild wildcard or channel
// allowlist. Unknown metadata may use only the helper's fail-closed,
// stable-ID path while every plausible non-guild scope remains enabled.
const allowed =
!target.metadataKnown &&
Object.values(guilds ?? {}).some((guildInfo) =>
isDiscordReadTargetAllowedInGuild({
groupPolicy,
guildInfo: guildInfo ?? null,
target,
}),
);
if (
(directOperator && isExpandedReadTargetEnabled(null, target, false)) ||
(currentConversation && isExpandedReadTargetEnabled(null, target, true))
) {
return;
}
if (!allowed) {
throw new Error("Discord read target channel is not allowed.");
}
},
assertGuildReadTargetAllowed: async ({ guildId, channelTargetRequiredMessage }) => {
assertGuildReadTargetAllowed: async ({
guildId,
channelTargetRequiredMessage,
filteredResults,
}) => {
const guildInfo = await resolveReadGuildEntry(guildId);
if (
directOperator &&
groupPolicy !== "disabled" &&
(filteredResults === true || !hasExplicitlyDisabledDiscordChannels(guildInfo?.channels))
) {
return;
}
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy,
@@ -382,6 +604,70 @@ export function createDiscordMessagingActionContext(params: {
);
}
},
filterGuildChannelList: async ({ guildId, channels }) => {
if (!directOperator) {
return channels;
}
const guildInfo = await resolveReadGuildEntry(guildId);
const channelById = new Map(
channels.flatMap((channel) => {
const channelId = readDiscordChannelStringField(channel, "id");
return channelId ? [[channelId, channel] as const] : [];
}),
);
const visibleChannels: typeof channels = [];
for (const channel of channels) {
const channelId = readDiscordChannelStringField(channel, "id");
if (!channelId) {
continue;
}
const channelName = readDiscordChannelStringField(channel, "name");
const channelType = readDiscordChannelType(channel);
const target: DiscordReadTargetContext = {
channelId,
channelSlug: channelName ? normalizeDiscordSlug(channelName) : channelId,
guildId,
metadataKnown: true,
ancestryComplete: true,
ancestors: [],
...(channelName ? { channelName } : {}),
...(channelType !== undefined ? { channelType } : {}),
...(isDiscordThreadChannel(channel) ? { scope: "thread" as const } : {}),
};
const ancestry = await resolveDiscordReadAncestry({
channelId,
parentId: readDiscordChannelStringField(channel, "parent_id", "parentId"),
loadChannel: async (parentId) => channelById.get(parentId),
});
target.ancestors = ancestry.ancestors;
target.ancestryComplete = ancestry.complete;
const immediateParent = target.ancestors[0];
if (immediateParent) {
target.parentId = immediateParent.channelId;
if (immediateParent.channelName) {
target.parentName = immediateParent.channelName;
}
target.parentSlug = immediateParent.channelSlug;
}
if (!isDiscordReadAncestryAllowed({ guildInfo, target })) {
continue;
}
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId,
channelName,
channelSlug: target.channelSlug,
parentId: target.parentId,
parentName: target.parentName,
parentSlug: target.parentSlug,
scope: target.scope,
});
if (!channelConfig?.matchSource || channelConfig.allowed) {
visibleChannels.push(channel);
}
}
return visibleChannels;
},
resolveReactionChannelId: async () => {
const target =
readStringParam(params.input, "channelId") ??
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,5 +1,6 @@
// Discord plugin module implements runtime behavior.
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { createDiscordActionGate } from "../accounts.js";
import { readStringParam, type OpenClawConfig } from "../runtime-api.js";
import { handleDiscordGuildAction } from "./runtime.guild.js";
@@ -7,6 +8,10 @@ import { handleDiscordMessagingAction } from "./runtime.messaging.js";
import { handleDiscordModerationAction } from "./runtime.moderation.js";
import { handleDiscordPresenceAction } from "./runtime.presence.js";
type ConversationReadInvocationOrigin = NonNullable<
ChannelMessageActionContext["conversationReadOrigin"]
>;
const messagingActions = new Set([
"react",
"reactions",
@@ -66,6 +71,12 @@ export async function handleDiscordAction(
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
conversationReadOrigin?: ConversationReadInvocationOrigin;
readContext?: {
requesterAccountId?: string | null;
currentChannelProvider?: string | null;
currentChannelId?: string | null;
};
},
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
@@ -517,12 +517,14 @@ describe("discordMessageActions", () => {
params: { to: "channel:123", message: "hello" },
cfg,
accountId: "ops",
requesterAccountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
conversationReadOrigin: "delegated",
});
expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({
@@ -530,12 +532,14 @@ describe("discordMessageActions", () => {
params: { to: "channel:123", message: "hello" },
cfg,
accountId: "ops",
requesterAccountId: "ops",
requesterSenderId: "user-1",
senderIsOwner: true,
toolContext,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
conversationReadOrigin: "delegated",
});
});
});
@@ -243,6 +243,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
params,
cfg,
accountId,
requesterAccountId,
requesterSenderId,
senderIsOwner,
toolContext,
@@ -251,6 +252,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
mediaReadFile,
sessionKey,
inboundEventKind,
conversationReadOrigin,
}) => {
return await (
await loadDiscordChannelActionsRuntime()
@@ -267,6 +269,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
mediaReadFile,
...(sessionKey ? { sessionKey } : {}),
...(inboundEventKind ? { inboundEventKind } : {}),
...(requesterAccountId ? { requesterAccountId } : {}),
...(conversationReadOrigin ? { conversationReadOrigin } : {}),
});
},
};
+84
View File
@@ -200,6 +200,33 @@ beforeAll(async () => {
});
describe("discordPlugin outbound", () => {
it("builds tool context with separate native and routable DM targets", () => {
const buildToolContext = discordPlugin.threading?.buildToolContext;
if (!buildToolContext) {
throw new Error("Expected discordPlugin.threading.buildToolContext to be defined");
}
const hasRepliedRef = { value: false };
expect(
buildToolContext({
cfg: {} as OpenClawConfig,
context: {
To: "user:123456789",
NativeChannelId: "987654321",
ChatType: "direct",
CurrentMessageId: "message-1",
},
hasRepliedRef,
}),
).toEqual({
currentChannelId: "987654321",
currentChatType: "direct",
currentMessagingTarget: "user:123456789",
currentMessageId: "message-1",
hasRepliedRef,
});
});
it("avoids local require calls for bundled-only sibling modules", async () => {
const source = await readFile(
resolve(process.cwd(), "extensions/discord/src/channel.ts"),
@@ -285,6 +312,63 @@ describe("discordPlugin outbound", () => {
});
});
it("preserves the normalized channel kind for bare current-channel ids", async () => {
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error(
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
);
}
await expect(
resolveTarget({
cfg: createCfg(),
accountId: "default",
input: "1470130713209602050",
normalized: "channel:1470130713209602050",
}),
).resolves.toEqual({
to: "channel:1470130713209602050",
kind: "channel",
display: "1470130713209602050",
source: "normalized",
});
});
it("keeps allowlisted bare Discord ids routable as DMs", async () => {
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error(
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
);
}
await expect(
resolveTarget({
cfg: {
channels: {
discord: {
accounts: {
default: {
token: "discord-token",
allowFrom: ["123456789"],
},
},
},
},
},
accountId: "default",
input: "123456789",
normalized: "channel:123456789",
}),
).resolves.toEqual({
to: "user:123456789",
kind: "user",
display: "123456789",
source: "directory",
});
});
it("honors per-account replyToMode overrides", () => {
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
+26 -9
View File
@@ -366,17 +366,17 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input, normalized, preferredKind }) => {
const defaultKind =
preferredKind === "user" || normalized.startsWith("user:")
? "user"
: preferredKind === "channel" ||
preferredKind === "group" ||
normalized.startsWith("channel:")
? "channel"
: undefined;
const resolved = await (
await loadDiscordTargetResolverModule()
).resolveDiscordTarget(
input,
{ cfg, accountId },
preferredKind === "user"
? { defaultKind: "user" }
: preferredKind === "channel" || preferredKind === "group"
? { defaultKind: "channel" }
: {},
);
).resolveDiscordTarget(input, { cfg, accountId }, defaultKind ? { defaultKind } : {});
if (!resolved) {
return null;
}
@@ -757,6 +757,23 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
resolveReplyToMode: (account) => account.config.replyToMode,
fallback: "off",
},
buildToolContext: ({ context, hasRepliedRef }) => {
const currentMessagingTarget = normalizeOptionalString(context.To);
const currentChatType =
context.ChatType === "direct" ||
context.ChatType === "group" ||
context.ChatType === "channel"
? context.ChatType
: undefined;
return {
currentChannelId:
normalizeOptionalString(context.NativeChannelId) ?? currentMessagingTarget,
currentChatType,
currentMessagingTarget,
currentMessageId: context.CurrentMessageId,
hasRepliedRef,
};
},
},
outbound: {
...discordOutbound,
@@ -4,6 +4,17 @@ import { buildDiscordMessageProcessContext } from "./message-handler.context.js"
import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js";
describe("discord buildDiscordMessageProcessContext sender bot status", () => {
it("preserves the native Discord channel id for tool authorization", async () => {
const ctx = await createBaseDiscordMessageContext();
const result = await buildDiscordMessageProcessContext({ ctx, text: "hi", mediaList: [] });
if (!result) {
throw new Error("expected a built Discord message context");
}
expect(result.ctxPayload.NativeChannelId).toBe(ctx.messageChannelId);
});
it("forwards bot author status to ctxPayload.SenderIsBot", async () => {
const ctx = await createBaseDiscordMessageContext({
author: { id: "U1", username: "alice", discriminator: "0", globalName: "Alice", bot: true },
@@ -343,6 +343,7 @@ export async function buildDiscordMessageProcessContext(params: {
conversation: {
kind: isDirectMessage ? "direct" : "channel",
id: messageChannelId,
nativeChannelId: messageChannelId,
label: fromLabel,
spaceId: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
parentId: threadChannel ? threadParentId : undefined,
+2
View File
@@ -2036,6 +2036,7 @@ describe("handleFeishuMessage command authorization", () => {
From?: string;
OriginatingChannel?: string;
OriginatingTo?: string;
NativeChannelId?: string;
SenderId?: string;
To?: string;
}>(mockFinalizeInboundContext, 0, 0);
@@ -2044,6 +2045,7 @@ describe("handleFeishuMessage command authorization", () => {
expect(finalized.To).toBe("chat:oc-group");
expect(finalized.OriginatingChannel).toBe("feishu");
expect(finalized.OriginatingTo).toBe("chat:oc-group");
expect(finalized.NativeChannelId).toBe("oc-group");
expect(finalized.SenderId).toBe("ou-allowed");
const groupSessionKey = resolveGroupSessionKey(finalized as never);
if (!groupSessionKey) {
+1
View File
@@ -1416,6 +1416,7 @@ export async function handleFeishuMessage(params: {
conversation: {
kind: isGroup ? "group" : "direct",
id: ctx.chatId,
nativeChannelId: ctx.chatId,
label: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
threadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
},
+5 -24
View File
@@ -15,6 +15,7 @@ import {
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js";
import { createFeishuClient } from "./client.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
@@ -140,9 +141,8 @@ function buildSyntheticMessageEvent(
// card-action-c-* IDs are temporary callback tokens, not valid Feishu message IDs.
// Using them as reply targets causes "Invalid ids" errors from the streaming reply API.
const isTemporaryCardActionId = replyTargetMessageId?.startsWith("card-action-c-");
const validReplyTargetId = replyTargetMessageId && !isTemporaryCardActionId
? replyTargetMessageId
: undefined;
const validReplyTargetId =
replyTargetMessageId && !isTemporaryCardActionId ? replyTargetMessageId : undefined;
return {
sender: {
sender_id: {
@@ -199,23 +199,6 @@ async function dispatchSyntheticCommand(params: {
});
}
// Feishu's im.chat.get returns two fields:
// chat_mode: conversation type — "p2p" | "group" | "topic"
// chat_type: privacy classification — "private" | "public"
// We check chat_mode first because it directly indicates conversation type.
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
// policy) — a private group chat misclassified as p2p is safer than the
// reverse. "topic" and "public" are treated as group semantics.
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
if (value === "group" || value === "topic" || value === "public") {
return "group";
}
if (value === "p2p" || value === "private") {
return "p2p";
}
return undefined;
}
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
@@ -273,7 +256,7 @@ async function resolveCardActionChatType(params: {
chatType?: "p2p" | "group";
log: (message: string) => void;
}): Promise<"p2p" | "group"> {
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
const explicitChatType = normalizeFeishuChatType(params.chatType);
if (explicitChatType) {
return explicitChatType;
}
@@ -300,9 +283,7 @@ async function resolveCardActionChatType(params: {
path: { chat_id: chatId },
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
if (response.code === 0) {
const resolvedChatType =
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
const resolvedChatType = resolveFeishuChatType(response.data ?? {});
if (resolvedChatType) {
cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now);
return resolvedChatType;
+4
View File
@@ -1,5 +1,7 @@
// Feishu plugin module implements channel behavior.
import {
assertFeishuChatMember as assertFeishuChatMemberImpl,
buildFeishuDirectChatMembers as buildFeishuDirectChatMembersImpl,
getChatInfo as getChatInfoImpl,
getChatMembers as getChatMembersImpl,
getFeishuMemberInfo as getFeishuMemberInfoImpl,
@@ -28,6 +30,8 @@ import {
} from "./send.js";
export const feishuChannelRuntime = {
assertFeishuChatMember: assertFeishuChatMemberImpl,
buildFeishuDirectChatMembers: buildFeishuDirectChatMembersImpl,
listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveImpl,
listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveImpl,
feishuOutbound: { ...feishuOutboundImpl },
+696 -12
View File
@@ -18,6 +18,24 @@ const listPinsFeishuMock = vi.hoisted(() => vi.fn());
const removePinFeishuMock = vi.hoisted(() => vi.fn());
const getChatInfoMock = vi.hoisted(() => vi.fn());
const getChatMembersMock = vi.hoisted(() => vi.fn());
const buildFeishuDirectChatMembersMock = vi.hoisted(() =>
vi.fn(
(authorization: { chatId: string; memberId: string; memberIdType: "open_id" | "user_id" }) => ({
chat_id: authorization.chatId,
has_more: false,
page_token: undefined,
members: [
{
member_id: authorization.memberId,
name: undefined,
tenant_key: undefined,
member_id_type: authorization.memberIdType,
},
],
}),
),
);
const assertFeishuChatMemberMock = vi.hoisted(() => vi.fn());
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
@@ -38,6 +56,8 @@ vi.mock("./channel.runtime.js", () => ({
editMessageFeishu: editMessageFeishuMock,
getChatInfo: getChatInfoMock,
getChatMembers: getChatMembersMock,
buildFeishuDirectChatMembers: buildFeishuDirectChatMembersMock,
assertFeishuChatMember: assertFeishuChatMemberMock,
getFeishuMemberInfo: getFeishuMemberInfoMock,
getMessageFeishu: getMessageFeishuMock,
listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock,
@@ -225,6 +245,9 @@ describe("feishuPlugin actions", () => {
actions: {
reactions: true,
},
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
} as OpenClawConfig;
@@ -232,6 +255,11 @@ describe("feishuPlugin actions", () => {
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReturnValue({ tag: "client" });
getChatInfoMock.mockResolvedValue({
chat_id: "oc_group_1",
chat_mode: "group",
chat_type: "private",
});
});
it("advertises the expanded Feishu action surface", () => {
@@ -928,13 +956,15 @@ describe("feishuPlugin actions", () => {
it("reads messages", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_1" },
params: { messageId: "om_1", chatId: "oc_group_1" },
cfg,
accountId: undefined,
} as never);
@@ -951,12 +981,177 @@ describe("feishuPlugin actions", () => {
expect(message.content).toBe("hello");
});
it("reads an explicit group target authorized only by groupAllowFrom", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_group_allow_from",
chatId: "oc_group_allow_from",
chatType: "group",
content: "hello",
contentType: "text",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "read",
params: {
messageId: "om_group_allow_from",
chatId: "oc_group_allow_from",
},
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "allowlist",
groupAllowFrom: ["oc_group_allow_from"],
},
},
} as OpenClawConfig,
} as never),
).resolves.toMatchObject({
details: {
ok: true,
action: "read",
},
});
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_allow_from");
expect(getMessageFeishuMock).toHaveBeenCalledTimes(1);
});
it.each([
{
name: "open group policy",
policy: { groupPolicy: "open" as const },
},
{
name: "wildcard group allowlist",
policy: {
groupPolicy: "allowlist" as const,
groupAllowFrom: ["*"],
},
},
])("classifies an explicit group target before reading under $name", async ({ policy }) => {
getChatInfoMock.mockResolvedValueOnce({
chat_id: "oc_open_group",
chat_mode: "group",
chat_type: "private",
});
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_open_group",
chatId: "oc_open_group",
chatType: "group",
content: "hello",
contentType: "text",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_open_group", chatId: "oc_open_group" },
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
dmPolicy: "pairing",
...policy,
},
},
} as OpenClawConfig,
} as never),
).resolves.toMatchObject({
details: {
ok: true,
action: "read",
},
});
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_open_group");
expect(getChatInfoMock.mock.invocationCallOrder[0]).toBeLessThan(
getMessageFeishuMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
);
});
it("resolves an omitted message chat type before authorizing a group read", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_group",
chatId: "oc_group_1",
content: "hello",
contentType: "text",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_group" },
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "open",
dmPolicy: "pairing",
},
},
} as OpenClawConfig,
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_group_1",
currentChatType: "group",
},
} as never),
).resolves.toMatchObject({
details: {
ok: true,
action: "read",
},
});
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
});
it("resolves private message visibility before applying read policy", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_private_group",
chatId: "oc_group_1",
chatType: "private",
content: "hidden",
contentType: "text",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_private_group" },
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "disabled",
dmPolicy: "open",
allowFrom: ["*"],
},
},
} as OpenClawConfig,
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_group_1",
currentChatType: "direct",
},
} as never),
).rejects.toThrow("Feishu read target is not allowed.");
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
});
it("returns an error result when message reads fail", async () => {
getMessageFeishuMock.mockResolvedValueOnce(null);
const result = await feishuPlugin.actions?.handleAction?.({
action: "read",
params: { messageId: "om_missing" },
params: { messageId: "om_missing", chatId: "oc_group_1" },
cfg,
accountId: undefined,
} as never);
@@ -968,6 +1163,13 @@ describe("feishuPlugin actions", () => {
});
it("edits messages", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_2",
chatId: "oc_group_1",
chatType: "group",
content: "before",
contentType: "text",
});
editMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_2", contentType: "post" });
const result = await feishuPlugin.actions?.handleAction?.({
@@ -975,6 +1177,7 @@ describe("feishuPlugin actions", () => {
params: { messageId: "om_2", text: "updated" },
cfg,
accountId: undefined,
conversationReadOrigin: "direct-operator",
} as never);
expect(editMessageFeishuMock).toHaveBeenCalledWith({
@@ -1157,6 +1360,13 @@ describe("feishuPlugin actions", () => {
});
it("creates pins", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_pin",
chatId: "oc_group_1",
chatType: "group",
content: "pin me",
contentType: "text",
});
createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
@@ -1164,6 +1374,7 @@ describe("feishuPlugin actions", () => {
params: { messageId: "om_pin" },
cfg,
accountId: undefined,
conversationReadOrigin: "direct-operator",
} as never);
expect(createPinFeishuMock).toHaveBeenCalledWith({
@@ -1208,11 +1419,19 @@ describe("feishuPlugin actions", () => {
});
it("removes pins", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_pin",
chatId: "oc_group_1",
chatType: "group",
content: "unpin me",
contentType: "text",
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "unpin",
params: { messageId: "om_pin" },
cfg,
accountId: undefined,
conversationReadOrigin: "direct-operator",
} as never);
expect(removePinFeishuMock).toHaveBeenCalledWith({
@@ -1280,13 +1499,19 @@ describe("feishuPlugin actions", () => {
const result = await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { memberId: "ou_1" },
params: { memberId: "ou_1", chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id");
expect(assertFeishuChatMemberMock).toHaveBeenCalledWith(
{ tag: "client" },
"oc_group_1",
"ou_1",
"open_id",
);
const details = resultDetails(result);
expect(details.ok).toBe(true);
const member = requireRecord(details.member, "member");
@@ -1294,12 +1519,96 @@ describe("feishuPlugin actions", () => {
expect(member.name).toBe("Alice");
});
it("uses the trusted sender identity for current direct-chat member info", async () => {
getChatInfoMock.mockResolvedValueOnce({
chat_id: "oc_direct",
chat_mode: "p2p",
chat_type: "private",
});
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "ou_sender", name: "Alice" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { memberId: "ou_sender", chatId: "oc_direct" },
cfg,
accountId: undefined,
requesterAccountId: "default",
requesterSenderId: "ou_sender",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_direct",
},
} as never);
expect(assertFeishuChatMemberMock).not.toHaveBeenCalled();
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_sender", "open_id");
expect(resultDetails(result).ok).toBe(true);
});
it("preserves a trusted user_id for current direct-chat member info", async () => {
getChatInfoMock.mockResolvedValueOnce({
chat_id: "oc_direct",
chat_mode: "p2p",
chat_type: "private",
});
getFeishuMemberInfoMock.mockResolvedValueOnce({
member_id: "u_mobile_only",
member_id_type: "user_id",
name: "Mobile User",
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { memberId: "u_mobile_only", chatId: "oc_direct" },
cfg,
accountId: undefined,
requesterAccountId: "default",
requesterSenderId: "u_mobile_only",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_direct",
},
} as never);
expect(assertFeishuChatMemberMock).not.toHaveBeenCalled();
expect(getFeishuMemberInfoMock).toHaveBeenCalledWith(
{ tag: "client" },
"u_mobile_only",
"user_id",
);
expect(resultDetails(result).ok).toBe(true);
});
it("rejects unrelated member lookups in current direct chats", async () => {
getChatInfoMock.mockResolvedValueOnce({
chat_id: "oc_direct",
chat_mode: "p2p",
chat_type: "private",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { memberId: "ou_other", chatId: "oc_direct" },
cfg,
accountId: undefined,
requesterAccountId: "default",
requesterSenderId: "ou_sender",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_direct",
},
} as never),
).rejects.toThrow("limited to the current sender");
expect(getFeishuMemberInfoMock).not.toHaveBeenCalled();
});
it("infers user_id lookups from the userId alias", async () => {
getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" });
await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { userId: "u_1" },
params: { userId: "u_1", chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
@@ -1313,7 +1622,7 @@ describe("feishuPlugin actions", () => {
await feishuPlugin.actions?.handleAction?.({
action: "member-info",
params: { userId: "u_1", memberIdType: "open_id" },
params: { userId: "u_1", memberIdType: "open_id", chatId: "oc_group_1" },
cfg,
accountId: undefined,
toolContext: {},
@@ -1337,15 +1646,16 @@ describe("feishuPlugin actions", () => {
cfg,
query: "eng",
limit: 5,
fallbackToStatic: false,
accountId: undefined,
fallbackToStatic: false,
filter: expect.any(Function),
});
expect(listFeishuDirectoryPeersLiveMock).toHaveBeenCalledWith({
cfg,
query: "eng",
limit: 5,
fallbackToStatic: false,
accountId: undefined,
fallbackToStatic: false,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
@@ -1369,8 +1679,9 @@ describe("feishuPlugin actions", () => {
cfg,
query: "eng",
limit: 5,
fallbackToStatic: false,
accountId: undefined,
fallbackToStatic: false,
filter: expect.any(Function),
});
});
@@ -1388,8 +1699,9 @@ describe("feishuPlugin actions", () => {
cfg,
query: "eng",
limit: undefined,
fallbackToStatic: false,
accountId: undefined,
fallbackToStatic: false,
filter: expect.any(Function),
});
});
@@ -1443,15 +1755,50 @@ describe("feishuPlugin actions", () => {
);
});
it("adds a reaction after authorizing the direct operator's ID-only target", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", emoji: "THUMBSUP" },
cfg,
accountId: undefined,
conversationReadOrigin: "direct-operator",
} as never);
expect(addReactionFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
emojiType: "THUMBSUP",
accountId: undefined,
});
expect(resultDetails(result)).toMatchObject({ ok: true, added: "THUMBSUP" });
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
{ reactionId: "r1", operatorType: "app", operatorId: "cli_main" },
{ reactionId: "r2", operatorType: "app", operatorId: "cli_main" },
{ reactionId: "r-other-app", operatorType: "app", operatorId: "cli_other" },
{ reactionId: "r-user", operatorType: "user", operatorId: "ou_user" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
params: { messageId: "om_msg1", chatId: "oc_group_1", clearAll: true },
cfg,
accountId: undefined,
} as never);
@@ -1462,11 +1809,324 @@ describe("feishuPlugin actions", () => {
accountId: undefined,
});
expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2);
expect(removeReactionFeishuMock).toHaveBeenNthCalledWith(1, {
cfg,
messageId: "om_msg1",
reactionId: "r1",
accountId: undefined,
});
expect(removeReactionFeishuMock).toHaveBeenNthCalledWith(2, {
cfg,
messageId: "om_msg1",
reactionId: "r2",
accountId: undefined,
});
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.removed).toBe(2);
});
it("removes an own reaction from an authorized Feishu message", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r-other", operatorType: "app", operatorId: "cli_other" },
{ reactionId: "r1", operatorType: "app", operatorId: "cli_main" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: {
messageId: "om_msg1",
chatId: "oc_group_1",
emoji: "THUMBSUP",
remove: true,
},
cfg,
accountId: undefined,
} as never);
expect(removeReactionFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
reactionId: "r1",
accountId: undefined,
});
expect(resultDetails(result)).toMatchObject({ ok: true, removed: "THUMBSUP" });
});
it("does not remove another app's matching reaction", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r-other", operatorType: "app", operatorId: "cli_other" },
{ reactionId: "r-user", operatorType: "user", operatorId: "ou_user" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: {
messageId: "om_msg1",
chatId: "oc_group_1",
emoji: "THUMBSUP",
remove: true,
},
cfg,
accountId: undefined,
} as never);
expect(removeReactionFeishuMock).not.toHaveBeenCalled();
expect(resultDetails(result)).toMatchObject({ ok: true, removed: null });
});
it("lists reactions from an authorized Feishu message", async () => {
const reactions = [{ reactionId: "r1", operatorType: "app", operatorId: "cli_main" }];
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
content: "hello",
contentType: "text",
});
listReactionsFeishuMock.mockResolvedValueOnce(reactions);
const result = await feishuPlugin.actions?.handleAction?.({
action: "reactions",
params: { messageId: "om_msg1", chatId: "oc_group_1" },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(resultDetails(result)).toMatchObject({ ok: true, reactions });
});
it("resolves an omitted message chat type before clearing group reactions", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_msg1",
chatId: "oc_group_1",
content: "hello",
contentType: "text",
});
listReactionsFeishuMock.mockResolvedValueOnce([]);
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "open",
dmPolicy: "pairing",
actions: { reactions: true },
},
},
} as OpenClawConfig,
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_group_1",
currentChatType: "group",
},
} as never),
).resolves.toMatchObject({
details: {
ok: true,
removed: 0,
},
});
expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1");
});
it.each([
{
name: "message reads",
action: "read",
params: { messageId: "om_blocked", chatId: "oc_blocked" },
},
{
name: "message edits",
action: "edit",
params: { messageId: "om_blocked", chatId: "oc_blocked", text: "blocked" },
},
{
name: "reaction addition",
action: "react",
params: { messageId: "om_blocked", chatId: "oc_blocked", emoji: "THUMBSUP" },
},
{
name: "reaction removal",
action: "react",
params: {
messageId: "om_blocked",
chatId: "oc_blocked",
emoji: "THUMBSUP",
remove: true,
},
},
{
name: "reaction clearing",
action: "react",
params: { messageId: "om_blocked", chatId: "oc_blocked", clearAll: true },
},
{
name: "reaction lookup",
action: "reactions",
params: { messageId: "om_blocked", chatId: "oc_blocked" },
},
{
name: "pin creation",
action: "pin",
params: { messageId: "om_blocked", chatId: "oc_blocked" },
},
{
name: "pin removal",
action: "unpin",
params: { messageId: "om_blocked", chatId: "oc_blocked" },
},
{
name: "pin lookup",
action: "list-pins",
params: { chatId: "oc_blocked" },
},
{
name: "channel info",
action: "channel-info",
params: { chatId: "oc_blocked" },
},
{
name: "member info",
action: "member-info",
params: { chatId: "oc_blocked", memberId: "ou_blocked" },
},
])("rejects blocked Feishu $name before provider content reads", async ({ action, params }) => {
await expect(
feishuPlugin.actions?.handleAction?.({
action,
params,
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "allowlist",
groups: { oc_allowed: {} },
actions: { reactions: true },
},
},
} as OpenClawConfig,
} as never),
).rejects.toThrow("Feishu read target is not allowed.");
expect(getChatInfoMock).not.toHaveBeenCalled();
expect(getMessageFeishuMock).not.toHaveBeenCalled();
expect(listReactionsFeishuMock).not.toHaveBeenCalled();
expect(addReactionFeishuMock).not.toHaveBeenCalled();
expect(removeReactionFeishuMock).not.toHaveBeenCalled();
expect(editMessageFeishuMock).not.toHaveBeenCalled();
expect(createPinFeishuMock).not.toHaveBeenCalled();
expect(removePinFeishuMock).not.toHaveBeenCalled();
});
it.each([
{
name: "message reads",
action: "read",
params: { messageId: "om_unknown", chatId: "oc_unknown" },
},
{
name: "pin lookup",
action: "list-pins",
params: { chatId: "oc_unknown" },
},
{
name: "channel info",
action: "channel-info",
params: { chatId: "oc_unknown" },
},
{
name: "member info",
action: "member-info",
params: { chatId: "oc_unknown", memberId: "ou_unknown" },
},
])(
"does not expose failed metadata lookup details for ambiguous Feishu $name",
async ({ action, params }) => {
getChatInfoMock.mockRejectedValueOnce(new Error("chat not found"));
await expect(
feishuPlugin.actions?.handleAction?.({
action,
params,
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "open",
dmPolicy: "pairing",
},
},
} as OpenClawConfig,
} as never),
).rejects.toThrow("Feishu read target is not allowed.");
expect(getChatInfoMock).toHaveBeenCalledOnce();
expect(getMessageFeishuMock).not.toHaveBeenCalled();
expect(listPinsFeishuMock).not.toHaveBeenCalled();
expect(getChatMembersMock).not.toHaveBeenCalled();
expect(assertFeishuChatMemberMock).not.toHaveBeenCalled();
expect(getFeishuMemberInfoMock).not.toHaveBeenCalled();
},
);
it("rejects a Feishu message returned from a different chat than the authorized target", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_other",
chatId: "oc_other",
chatType: "group",
content: "hidden",
contentType: "text",
});
await expect(
feishuPlugin.actions?.handleAction?.({
action: "reactions",
params: { messageId: "om_other", chatId: "oc_allowed" },
cfg: {
channels: {
feishu: {
appId: "cli_main",
appSecret: "secret_main",
groupPolicy: "allowlist",
groups: { oc_allowed: {} },
actions: { reactions: true },
},
},
} as OpenClawConfig,
} as never),
).rejects.toThrow("Feishu message target is not allowed.");
expect(getMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(listReactionsFeishuMock).not.toHaveBeenCalled();
});
it("fails for missing params on supported actions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
@@ -1597,6 +2257,30 @@ describe("feishuPlugin.messaging.resolveDeliveryTarget", () => {
});
});
describe("feishuPlugin.threading.buildToolContext", () => {
it("preserves the native chat id separately from the routable user target", () => {
const build = feishuPlugin.threading?.buildToolContext;
if (!build) {
throw new Error("Feishu threading.buildToolContext unavailable");
}
expect(
build({
cfg: {} as OpenClawConfig,
context: {
To: "user:ou_sender",
NativeChannelId: "oc_direct_chat",
ChatType: "direct",
},
}),
).toMatchObject({
currentChannelId: "oc_direct_chat",
currentChatType: "direct",
currentMessagingTarget: "user:ou_sender",
});
});
});
describe("looksLikeFeishuId", () => {
it("accepts provider-prefixed user targets", () => {
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
+451 -73
View File
@@ -1,6 +1,7 @@
// Feishu plugin module implements channel behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";
import {
adaptScopedAccountAccessor,
createHybridChannelConfigAdapter,
@@ -37,7 +38,10 @@ import {
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import type { PluginRuntime } from "../runtime-api.js";
import {
@@ -65,6 +69,7 @@ import {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "./channel-runtime-api.js";
import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js";
import { isRecord } from "./comment-shared.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
@@ -74,12 +79,26 @@ import {
parseFeishuDirectConversationId,
parseFeishuTargetId,
} from "./conversation-id.js";
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
import {
listAuthorizedFeishuDirectoryGroups,
listAuthorizedFeishuDirectoryPeers,
listFeishuDirectoryGroups,
listFeishuDirectoryPeers,
} from "./directory.static.js";
import { feishuDoctor } from "./doctor.js";
import { messageActionTargetAliases } from "./message-action-contract.js";
import { readNativeFeishuCardJson } from "./native-card.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { buildFeishuPresentationCard } from "./presentation-card.js";
import {
assertFeishuChatReadAllowed,
authorizeFeishuChatMemberRead,
canEnumerateAllFeishuGroups,
canEnumerateAllFeishuPeers,
isFeishuGroupReadAllowed,
isFeishuGroupReadEnabled,
resolveFeishuChatReadPreliminaryAuthorization,
} from "./read-policy.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { collectFeishuSecurityAuditFindings } from "./security-audit.js";
import { createFeishuSendReceipt } from "./send-result.js";
@@ -230,6 +249,32 @@ async function createFeishuActionClient(account: ResolvedFeishuAccount) {
return createFeishuClient(account);
}
async function resolveFeishuChatTypeById(params: {
account: ResolvedFeishuAccount;
chatId: string;
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>;
}) {
const client = await createFeishuActionClient(params.account);
const chat = await params.runtime.getChatInfo(client, params.chatId);
return resolveFeishuChatType(chat);
}
async function resolveFeishuMessageChatType(params: {
account: ResolvedFeishuAccount;
message: { chatId: string; chatType?: unknown };
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>;
}) {
const knownChatType = normalizeFeishuChatType(params.message.chatType);
if (knownChatType) {
return knownChatType;
}
return resolveFeishuChatTypeById({
account: params.account,
chatId: params.message.chatId,
runtime: params.runtime,
});
}
const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{
cfg: ClawdbotConfig;
accountId?: string | null;
@@ -655,6 +700,192 @@ function resolveFeishuMessageId(params: Record<string, unknown>): string | undef
return readFirstString(params, ["messageId", "message_id", "replyTo", "reply_to"]);
}
function resolveFeishuMessageReadTarget(ctx: {
params: Record<string, unknown>;
toolContext?: {
currentChannelId?: string;
currentChatType?: "direct" | "group" | "channel";
} | null;
}): { chatId: string; chatType?: "p2p" | "group" } | undefined {
const explicitChatId = resolveFeishuChatId({ params: ctx.params });
const currentChatId = resolveFeishuChatId({
params: {},
toolContext: ctx.toolContext,
});
const chatId = explicitChatId ?? currentChatId;
if (!chatId) {
return undefined;
}
const normalizedChatId = normalizeFeishuTarget(chatId) ?? chatId.trim();
const normalizedCurrentChatId = currentChatId
? (normalizeFeishuTarget(currentChatId) ?? currentChatId.trim())
: undefined;
if (normalizedChatId !== normalizedCurrentChatId) {
return { chatId: normalizedChatId };
}
const currentChatType =
ctx.toolContext?.currentChatType === "direct"
? "p2p"
: ctx.toolContext?.currentChatType === "group" ||
ctx.toolContext?.currentChatType === "channel"
? "group"
: undefined;
return { chatId: normalizedChatId, chatType: currentChatType };
}
function assertFeishuMessageMatchesReadTarget(params: {
authorizedChatId: string;
messageChatId: string;
}) {
const messageChatId = normalizeFeishuTarget(params.messageChatId) ?? params.messageChatId.trim();
if (messageChatId !== params.authorizedChatId) {
throw new ToolAuthorizationError("Feishu message target is not allowed.");
}
}
async function authorizeFeishuMessageReadTarget(params: {
ctx: ChannelMessageActionContext;
account: ResolvedFeishuAccount;
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>;
target: NonNullable<ReturnType<typeof resolveFeishuMessageReadTarget>>;
}) {
const authorize = (chatType?: "p2p" | "group") =>
assertFeishuChatReadAllowed({
cfg: params.ctx.cfg,
account: params.account,
chatId: params.target.chatId,
chatType,
ctx: params.ctx,
});
if (params.target.chatType) {
return authorize(params.target.chatType);
}
const preliminary = resolveFeishuChatReadPreliminaryAuthorization({
cfg: params.ctx.cfg,
account: params.account,
chatId: params.target.chatId,
ctx: params.ctx,
});
if (preliminary.decision === "allow") {
return preliminary.chatId;
}
if (preliminary.decision === "deny") {
throw new ToolAuthorizationError("Feishu read target is not allowed.");
}
// Static policy could not distinguish group from DM. Reuse the shared
// metadata gate so lookup failures cannot become a target-existence oracle.
await getAuthorizedFeishuChatInfo({
ctx: params.ctx,
account: params.account,
runtime: params.runtime,
chatId: params.target.chatId,
});
return preliminary.chatId;
}
async function getAuthorizedFeishuChatInfo(params: {
ctx: ChannelMessageActionContext;
account: ResolvedFeishuAccount;
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>;
chatId: string;
}) {
const preliminary = resolveFeishuChatReadPreliminaryAuthorization({
cfg: params.ctx.cfg,
account: params.account,
chatId: params.chatId,
ctx: params.ctx,
});
if (preliminary.decision === "deny") {
throw new ToolAuthorizationError("Feishu read target is not allowed.");
}
const client = await createFeishuActionClient(params.account);
let chat: Awaited<ReturnType<typeof params.runtime.getChatInfo>>;
try {
chat = await params.runtime.getChatInfo(client, preliminary.chatId);
} catch (error) {
if (preliminary.decision === "needs-metadata") {
assertFeishuChatReadAllowed({
cfg: params.ctx.cfg,
account: params.account,
chatId: preliminary.chatId,
ctx: params.ctx,
});
}
throw error;
}
assertFeishuChatReadAllowed({
cfg: params.ctx.cfg,
account: params.account,
chatId: preliminary.chatId,
chatType: resolveFeishuChatType(chat),
ctx: params.ctx,
});
return { chat, client };
}
async function getAuthorizedFeishuMessage(params: {
ctx: ChannelMessageActionContext;
account: ResolvedFeishuAccount;
runtime: Awaited<ReturnType<typeof loadFeishuChannelRuntime>>;
messageId: string;
}) {
// An opaque message id cannot authorize its own provider read. Gate an
// independent chat target first, then bind the provider response to it.
// Trusted direct operators may retain ID-only workflows because their
// provider read is not delegated; final account and disabled-scope policy
// still applies after the message resolves its chat.
const target = resolveFeishuMessageReadTarget(params.ctx);
if (!target && params.ctx.conversationReadOrigin !== "direct-operator") {
throw new ToolAuthorizationError(
"Feishu message reads require a chat target or current conversation.",
);
}
const authorizedChatId = target
? await authorizeFeishuMessageReadTarget({
ctx: params.ctx,
account: params.account,
runtime: params.runtime,
target,
})
: undefined;
const message = await params.runtime.getMessageFeishu({
cfg: params.ctx.cfg,
messageId: params.messageId,
accountId: params.ctx.accountId ?? undefined,
});
if (!message) {
return null;
}
if (authorizedChatId) {
assertFeishuMessageMatchesReadTarget({
authorizedChatId,
messageChatId: message.chatId,
});
}
assertFeishuChatReadAllowed({
cfg: params.ctx.cfg,
account: params.account,
chatId: message.chatId,
chatType: await resolveFeishuMessageChatType({
account: params.account,
message,
runtime: params.runtime,
}),
ctx: params.ctx,
});
return message;
}
async function requireAuthorizedFeishuMessage(
params: Parameters<typeof getAuthorizedFeishuMessage>[0],
) {
const message = await getAuthorizedFeishuMessage(params);
if (!message) {
throw new Error(`Feishu message not found: ${params.messageId}`);
}
return message;
}
function resolveFeishuMemberId(params: Record<string, unknown>): string | undefined {
return readFirstString(params, [
"memberId",
@@ -671,6 +902,12 @@ function resolveFeishuMemberId(params: Record<string, unknown>): string | undefi
function resolveFeishuMemberIdType(
params: Record<string, unknown>,
): "open_id" | "user_id" | "union_id" {
return resolveRequestedFeishuMemberIdType(params) ?? "open_id";
}
function resolveRequestedFeishuMemberIdType(
params: Record<string, unknown>,
): "open_id" | "user_id" | "union_id" | undefined {
const raw = readFirstString(params, [
"memberIdType",
"member_id_type",
@@ -692,7 +929,10 @@ function resolveFeishuMemberIdType(
) {
return "union_id";
}
return "open_id";
if (readFirstString(params, ["openId", "open_id"])) {
return "open_id";
}
return undefined;
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResult> =
@@ -908,11 +1148,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!messageId) {
throw new Error("Feishu read requires messageId.");
}
const { getMessageFeishu } = await loadFeishuChannelRuntime();
const message = await getMessageFeishu({
cfg: ctx.cfg,
const runtime = await loadFeishuChannelRuntime();
const message = await getAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
accountId: ctx.accountId ?? undefined,
});
if (!message) {
return {
@@ -941,8 +1182,14 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
ctx.params.card && typeof ctx.params.card === "object"
? (ctx.params.card as Record<string, unknown>)
: undefined;
const { editMessageFeishu } = await loadFeishuChannelRuntime();
const result = await editMessageFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
const result = await runtime.editMessageFeishu({
cfg: ctx.cfg,
messageId,
text,
@@ -962,8 +1209,14 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!messageId) {
throw new Error("Feishu pin requires messageId.");
}
const { createPinFeishu } = await loadFeishuChannelRuntime();
const pin = await createPinFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
const pin = await runtime.createPinFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
@@ -976,8 +1229,14 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!messageId) {
throw new Error("Feishu unpin requires messageId.");
}
const { removePinFeishu } = await loadFeishuChannelRuntime();
await removePinFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
await runtime.removePinFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
@@ -995,7 +1254,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!chatId) {
throw new Error("Feishu list-pins requires chatId or channelId.");
}
const { listPinsFeishu } = await loadFeishuChannelRuntime();
const runtime = await loadFeishuChannelRuntime();
await getAuthorizedFeishuChatInfo({ ctx, account, runtime, chatId });
const { listPinsFeishu } = runtime;
const result = await listPinsFeishu({
cfg: ctx.cfg,
chatId,
@@ -1019,8 +1280,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
throw new Error("Feishu channel-info requires chatId or channelId.");
}
const runtime = await loadFeishuChannelRuntime();
const client = await createFeishuActionClient(account);
const channel = await runtime.getChatInfo(client, chatId);
const { chat: channel, client } = await getAuthorizedFeishuChatInfo({
ctx,
account,
runtime,
chatId,
});
const chatType = resolveFeishuChatType(channel);
const includeMembers =
ctx.params.includeMembers === true || ctx.params.members === true;
if (!includeMembers) {
@@ -1031,13 +1297,25 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
channel,
});
}
const members = await runtime.getChatMembers(
client,
const requestedMemberIdType = resolveRequestedFeishuMemberIdType(ctx.params);
const authorization = authorizeFeishuChatMemberRead({
cfg: ctx.cfg,
account,
chatId,
readOptionalPositiveInteger(ctx.params, ["pageSize", "page_size"]),
readFirstString(ctx.params, ["pageToken", "page_token"]),
resolveFeishuMemberIdType(ctx.params),
);
chatType,
ctx,
memberIdType: requestedMemberIdType,
});
const members =
authorization.kind === "direct"
? runtime.buildFeishuDirectChatMembers(authorization)
: await runtime.getChatMembers(
client,
chatId,
readOptionalPositiveInteger(ctx.params, ["pageSize", "page_size"]),
readFirstString(ctx.params, ["pageToken", "page_token"]),
resolveFeishuMemberIdType(ctx.params),
);
return jsonActionResult({
ok: true,
provider: "feishu",
@@ -1049,13 +1327,45 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (ctx.action === "member-info") {
const runtime = await loadFeishuChannelRuntime();
const client = await createFeishuActionClient(account);
const memberId = resolveFeishuMemberId(ctx.params);
if (memberId) {
const chatId = resolveFeishuChatId(ctx);
if (!chatId) {
throw new Error(
"Feishu member-info requires chatId or channelId when memberId is provided.",
);
}
const { chat, client } = await getAuthorizedFeishuChatInfo({
ctx,
account,
runtime,
chatId,
});
const requestedMemberIdType = resolveRequestedFeishuMemberIdType(ctx.params);
const memberIdType = resolveFeishuMemberIdType(ctx.params);
const authorization = authorizeFeishuChatMemberRead({
cfg: ctx.cfg,
account,
chatId,
chatType: resolveFeishuChatType(chat),
ctx,
memberId,
memberIdType: requestedMemberIdType,
});
if (authorization.kind === "group") {
await runtime.assertFeishuChatMember(client, chatId, memberId, memberIdType);
const member = await runtime.getFeishuMemberInfo(client, memberId, memberIdType);
return jsonActionResult({
ok: true,
channel: "feishu",
action: "member-info",
member,
});
}
const member = await runtime.getFeishuMemberInfo(
client,
memberId,
resolveFeishuMemberIdType(ctx.params),
authorization.memberId,
authorization.memberIdType,
);
return jsonActionResult({
ok: true,
@@ -1068,13 +1378,31 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!chatId) {
throw new Error("Feishu member-info requires memberId or chatId/channelId.");
}
const members = await runtime.getChatMembers(
client,
const { chat, client } = await getAuthorizedFeishuChatInfo({
ctx,
account,
runtime,
chatId,
readOptionalPositiveInteger(ctx.params, ["pageSize", "page_size"]),
readFirstString(ctx.params, ["pageToken", "page_token"]),
resolveFeishuMemberIdType(ctx.params),
);
});
const requestedMemberIdType = resolveRequestedFeishuMemberIdType(ctx.params);
const authorization = authorizeFeishuChatMemberRead({
cfg: ctx.cfg,
account,
chatId,
chatType: resolveFeishuChatType(chat),
ctx,
memberIdType: requestedMemberIdType,
});
const members =
authorization.kind === "direct"
? runtime.buildFeishuDirectChatMembers(authorization)
: await runtime.getChatMembers(
client,
chatId,
readOptionalPositiveInteger(ctx.params, ["pageSize", "page_size"]),
readFirstString(ctx.params, ["pageToken", "page_token"]),
resolveFeishuMemberIdType(ctx.params),
);
return jsonActionResult({
ok: true,
channel: "feishu",
@@ -1088,19 +1416,38 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
const query = readFirstString(ctx.params, ["query"]);
const limit = readOptionalPositiveInteger(ctx.params, ["limit"]);
const scope = readFirstString(ctx.params, ["scope", "kind"]) ?? "all";
const directOperator = ctx.conversationReadOrigin === "direct-operator";
const listGroups =
directOperator || canEnumerateAllFeishuGroups(ctx.cfg, account)
? runtime.listFeishuDirectoryGroupsLive
: listAuthorizedFeishuDirectoryGroups;
const listPeers =
directOperator || canEnumerateAllFeishuPeers(account)
? runtime.listFeishuDirectoryPeersLive
: listAuthorizedFeishuDirectoryPeers;
const directoryParams = {
cfg: ctx.cfg,
query,
limit,
accountId: ctx.accountId ?? undefined,
fallbackToStatic: false,
};
const groupDirectoryParams = {
...directoryParams,
filter: directOperator
? (group: { id: string }) => isFeishuGroupReadEnabled(ctx.cfg, account, group.id)
: canEnumerateAllFeishuGroups(ctx.cfg, account)
? (group: { id: string }) =>
isFeishuGroupReadAllowed(ctx.cfg, account, group.id, false)
: undefined,
};
if (
scope === "groups" ||
scope === "group" ||
scope === "channels" ||
scope === "channel"
) {
const groups = await runtime.listFeishuDirectoryGroupsLive({
cfg: ctx.cfg,
query,
limit,
fallbackToStatic: false,
accountId: ctx.accountId ?? undefined,
});
const groups = await listGroups(groupDirectoryParams);
return jsonActionResult({
ok: true,
channel: "feishu",
@@ -1116,13 +1463,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
scope === "users" ||
scope === "user"
) {
const peers = await runtime.listFeishuDirectoryPeersLive({
cfg: ctx.cfg,
query,
limit,
fallbackToStatic: false,
accountId: ctx.accountId ?? undefined,
});
const peers = await listPeers(directoryParams);
return jsonActionResult({
ok: true,
channel: "feishu",
@@ -1131,20 +1472,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
});
}
const [groups, peers] = await Promise.all([
runtime.listFeishuDirectoryGroupsLive({
cfg: ctx.cfg,
query,
limit,
fallbackToStatic: false,
accountId: ctx.accountId ?? undefined,
}),
runtime.listFeishuDirectoryPeersLive({
cfg: ctx.cfg,
query,
limit,
fallbackToStatic: false,
accountId: ctx.accountId ?? undefined,
}),
listGroups(groupDirectoryParams),
listPeers(directoryParams),
]);
return jsonActionResult({
ok: true,
@@ -1167,19 +1496,29 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!emoji) {
throw new Error("Emoji is required to remove a Feishu reaction.");
}
const { listReactionsFeishu, removeReactionFeishu } =
await loadFeishuChannelRuntime();
const matches = await listReactionsFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
const matches = await runtime.listReactionsFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
const ownReaction = matches.find((entry) => entry.operatorType === "app");
const ownReaction = matches.find(
(entry) =>
entry.operatorType === "app" &&
Boolean(account.appId) &&
entry.operatorId === account.appId,
);
if (!ownReaction) {
return jsonActionResult({ ok: true, removed: null });
}
await removeReactionFeishu({
await runtime.removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: ownReaction.reactionId,
@@ -1193,16 +1532,27 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
}
const { listReactionsFeishu, removeReactionFeishu } =
await loadFeishuChannelRuntime();
const reactions = await listReactionsFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
const reactions = await runtime.listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
let removed = 0;
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
await removeReactionFeishu({
const ownReactions = reactions.filter(
(entry) =>
entry.operatorType === "app" &&
Boolean(account.appId) &&
entry.operatorId === account.appId,
);
for (const reaction of ownReactions) {
await runtime.removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: reaction.reactionId,
@@ -1212,8 +1562,14 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
}
return jsonActionResult({ ok: true, removed });
}
const { addReactionFeishu } = await loadFeishuChannelRuntime();
await addReactionFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
await runtime.addReactionFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
@@ -1227,8 +1583,14 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (!messageId) {
throw new Error("Feishu reactions lookup requires messageId.");
}
const { listReactionsFeishu } = await loadFeishuChannelRuntime();
const reactions = await listReactionsFeishu({
const runtime = await loadFeishuChannelRuntime();
await requireAuthorizedFeishuMessage({
ctx,
account,
runtime,
messageId,
});
const reactions = await runtime.listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
@@ -1422,6 +1784,22 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
},
},
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId:
normalizeOptionalString(context.NativeChannelId) ?? normalizeOptionalString(context.To),
currentChatType:
context.ChatType === "direct" ||
context.ChatType === "group" ||
context.ChatType === "channel"
? context.ChatType
: undefined,
currentMessagingTarget: normalizeOptionalString(context.To),
currentThreadTs:
context.MessageThreadId != null ? String(context.MessageThreadId) : undefined,
hasRepliedRef,
}),
},
outbound: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
+28
View File
@@ -0,0 +1,28 @@
export type ResolvedFeishuChatType = "p2p" | "group";
export function normalizeFeishuChatType(value: unknown): ResolvedFeishuChatType | undefined {
if (value === "group" || value === "topic_group") {
return "group";
}
if (value === "p2p") {
return "p2p";
}
return undefined;
}
export function normalizeFeishuChatMode(value: unknown): ResolvedFeishuChatType | undefined {
if (value === "group" || value === "topic" || value === "topic_group") {
return "group";
}
return value === "p2p" ? "p2p" : undefined;
}
export function resolveFeishuChatType(chat: {
chat_mode?: unknown;
chat_type?: unknown;
}): ResolvedFeishuChatType | undefined {
// im.chat.get uses chat_mode for conversation kind; chat_type is the
// public/private visibility classification. Older response shapes and test
// adapters may still expose p2p/group there; ignore privacy-only values.
return normalizeFeishuChatMode(chat.chat_mode) ?? normalizeFeishuChatType(chat.chat_type);
}
+454 -3
View File
@@ -19,6 +19,34 @@ function createFeishuToolRuntime(): PluginRuntime {
}
describe("registerFeishuChatTools", () => {
function resolveRegisteredTool(
registerTool: ReturnType<typeof vi.fn>,
context: {
agentAccountId?: string;
deliveryAccountId?: string;
deliveryTo?: string;
nativeChannelId?: string;
requesterSenderId?: string;
conversationReadOrigin?: "delegated" | "direct-operator";
} = {},
) {
const registered = registerTool.mock.calls[0]?.[0];
return typeof registered === "function"
? registered({
messageChannel: "feishu",
agentAccountId: context.agentAccountId ?? "default",
deliveryContext: {
channel: "feishu",
to: context.deliveryTo ?? "oc_1",
accountId: context.deliveryAccountId ?? context.agentAccountId ?? "default",
},
nativeChannelId: context.nativeChannelId,
requesterSenderId: context.requesterSenderId,
conversationReadOrigin: context.conversationReadOrigin,
})
: registered;
}
function createChatToolApi(params: {
config: OpenClawPluginApi["config"];
registerTool: OpenClawPluginApi["registerTool"];
@@ -45,6 +73,10 @@ describe("registerFeishuChatTools", () => {
beforeEach(() => {
vi.clearAllMocks();
chatGetMock.mockResolvedValue({
code: 0,
data: { chat_mode: "group", chat_type: "private" },
});
createFeishuClientMock.mockReturnValue({
im: {
chat: { get: chatGetMock },
@@ -67,6 +99,9 @@ describe("registerFeishuChatTools", () => {
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
},
},
},
@@ -75,7 +110,10 @@ describe("registerFeishuChatTools", () => {
);
expect(registerTool).toHaveBeenCalledTimes(1);
const tool = registerTool.mock.calls[0]?.[0];
expect(registerTool.mock.calls[0]?.[1]).toEqual({
name: "feishu_chat",
});
const tool = resolveRegisteredTool(registerTool);
expect(tool?.name).toBe("feishu_chat");
chatGetMock.mockResolvedValueOnce({
@@ -133,9 +171,17 @@ describe("registerFeishuChatTools", () => {
},
},
});
chatMembersGetMock.mockResolvedValueOnce({
code: 0,
data: {
has_more: false,
items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }],
},
});
const memberInfoResult = await tool.execute("tc_3", {
action: "member_info",
member_id: "ou_1",
chat_id: "oc_1",
});
expect(memberInfoResult.details).toEqual({
member_id: "ou_1",
@@ -168,6 +214,362 @@ describe("registerFeishuChatTools", () => {
});
});
it("allows current direct-chat reads under the default pairing policy", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
deliveryTo: "user:ou_sender",
nativeChannelId: "oc_direct_chat",
});
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { chat_mode: "p2p", chat_type: "private" },
});
const result = await tool.execute("tc_current_dm", {
action: "info",
chat_id: "oc_direct_chat",
});
expect(result.details).toMatchObject({
chat_id: "oc_direct_chat",
chat_mode: "p2p",
});
});
it("returns the trusted sender for current direct-chat member reads", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
deliveryTo: "user:ou_sender",
nativeChannelId: "oc_direct_chat",
requesterSenderId: "ou_sender",
});
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { chat_mode: "p2p", chat_type: "private" },
});
const result = await tool.execute("tc_current_dm_members", {
action: "members",
chat_id: "oc_direct_chat",
});
expect(result.details).toMatchObject({
chat_id: "oc_direct_chat",
has_more: false,
members: [{ member_id: "ou_sender", member_id_type: "open_id" }],
});
expect(chatMembersGetMock).not.toHaveBeenCalled();
});
it("preserves a trusted user_id for current direct-chat member reads", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
deliveryTo: "user:u_mobile_only",
nativeChannelId: "oc_direct_chat",
requesterSenderId: "u_mobile_only",
});
chatGetMock.mockResolvedValue({
code: 0,
data: { chat_mode: "p2p", chat_type: "private" },
});
contactUserGetMock.mockResolvedValueOnce({
code: 0,
data: { user: { user_id: "u_mobile_only", name: "Mobile User" } },
});
const members = await tool.execute("tc_current_dm_members_user_id", {
action: "members",
chat_id: "oc_direct_chat",
});
const profile = await tool.execute("tc_current_dm_profile_user_id", {
action: "member_info",
chat_id: "oc_direct_chat",
member_id: "u_mobile_only",
});
expect(members.details).toMatchObject({
members: [{ member_id: "u_mobile_only", member_id_type: "user_id" }],
});
expect(profile.details).toMatchObject({
member_id: "u_mobile_only",
member_id_type: "user_id",
});
expect(contactUserGetMock).toHaveBeenCalledWith({
path: { user_id: "u_mobile_only" },
params: {
user_id_type: "user_id",
department_id_type: "open_department_id",
},
});
});
it("rejects unrelated member profiles in current direct chats", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
deliveryTo: "user:ou_sender",
nativeChannelId: "oc_direct_chat",
requesterSenderId: "ou_sender",
});
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { chat_mode: "p2p", chat_type: "private" },
});
const result = await tool.execute("tc_current_dm_other_member", {
action: "member_info",
chat_id: "oc_direct_chat",
member_id: "ou_other",
});
expect(result.details.error).toContain("limited to the current sender");
expect(contactUserGetMock).not.toHaveBeenCalled();
});
it.each(["info", "members", "member_info"] as const)(
"rejects a blocked %s target before reading provider metadata",
async (action) => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
groups: { oc_allowed: {}, oc_blocked: { enabled: false } },
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool);
const input = {
action,
chat_id: "oc_blocked",
...(action === "member_info" ? { member_id: "ou_member" } : {}),
};
const result = await tool.execute(`tc_blocked_${action}`, input);
expect(result.details.error).toContain("Feishu read target is not allowed.");
expect(chatGetMock).not.toHaveBeenCalled();
expect(chatMembersGetMock).not.toHaveBeenCalled();
expect(contactUserGetMock).not.toHaveBeenCalled();
},
);
it.each([
{
name: "an existing blocked direct chat",
response: {
code: 0,
data: { chat_mode: "p2p", chat_type: "private" },
},
},
{
name: "a failed metadata lookup",
response: {
code: 230001,
msg: "chat not found",
},
},
])("does not expose whether an ambiguous target is $name", async ({ response }) => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "open",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
nativeChannelId: "oc_current",
});
chatGetMock.mockResolvedValueOnce(response);
const result = await tool.execute("tc_ambiguous_target", {
action: "info",
chat_id: "oc_other",
});
expect(result.details.error).toContain("Feishu read target is not allowed.");
expect(result.details.error).not.toContain("chat not found");
expect(chatGetMock).toHaveBeenCalledOnce();
expect(chatMembersGetMock).not.toHaveBeenCalled();
expect(contactUserGetMock).not.toHaveBeenCalled();
});
it("lets a direct operator read an unconfigured group", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
conversationReadOrigin: "direct-operator",
});
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { chat_mode: "group", name: "operator target" },
});
const result = await tool.execute("tc_direct_operator", {
action: "info",
chat_id: "oc_unconfigured",
});
expect(result.details).toMatchObject({
chat_id: "oc_unconfigured",
name: "operator target",
});
});
it("routes chat reads through the contextual Feishu account", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
defaultAccount: "a",
accounts: {
a: {
appId: "app_a",
appSecret: "secret_a", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
b: {
appId: "app_b",
appSecret: "secret_b", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "allowlist",
},
},
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool, {
agentAccountId: "b",
deliveryAccountId: "b",
nativeChannelId: "oc_1",
});
chatGetMock.mockResolvedValueOnce({
code: 0,
data: { name: "account b chat" },
});
const result = await tool.execute("tc_account_b", {
action: "info",
chat_id: "oc_1",
});
expect(result.details).toMatchObject({
chat_id: "oc_1",
name: "account b chat",
});
expect(createFeishuClientMock).toHaveBeenCalledWith(
expect.objectContaining({ accountId: "b" }),
);
});
it("advertises and validates member page_size as a positive integer", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
@@ -179,6 +581,7 @@ describe("registerFeishuChatTools", () => {
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "open",
},
},
},
@@ -186,7 +589,7 @@ describe("registerFeishuChatTools", () => {
}),
);
const tool = registerTool.mock.calls[0]?.[0];
const tool = resolveRegisteredTool(registerTool);
expect(tool?.parameters.properties.page_size).toMatchObject({
type: "integer",
minimum: 1,
@@ -253,6 +656,7 @@ describe("registerFeishuChatTools", () => {
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "open",
},
},
},
@@ -260,7 +664,14 @@ describe("registerFeishuChatTools", () => {
}),
);
const tool = registerTool.mock.calls[0]?.[0];
const tool = resolveRegisteredTool(registerTool);
chatMembersGetMock.mockResolvedValueOnce({
code: 0,
data: {
has_more: false,
items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }],
},
});
contactUserGetMock.mockRejectedValueOnce(
Object.assign(new Error("Request failed with status code 400"), {
response: {
@@ -280,6 +691,7 @@ describe("registerFeishuChatTools", () => {
const result = await tool.execute("tc_4", {
action: "member_info",
member_id: "ou_1",
chat_id: "oc_1",
});
expect(result.details.error).toContain('"http_status":400');
@@ -292,4 +704,43 @@ describe("registerFeishuChatTools", () => {
'"feishu_troubleshooter":"https://open.feishu.cn/search?log_id=20260429124800CHAT"',
);
});
it("rejects repeated member-list page tokens", async () => {
const registerTool = vi.fn();
registerFeishuChatTools(
createChatToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { chat: true },
groupPolicy: "open",
},
},
},
registerTool,
}),
);
const tool = resolveRegisteredTool(registerTool);
chatMembersGetMock.mockResolvedValue({
code: 0,
data: {
has_more: true,
page_token: "same-token",
items: [],
},
});
const result = await tool.execute("tc_repeated_page", {
action: "member_info",
member_id: "ou_missing",
chat_id: "oc_1",
});
expect(result.details.error).toContain("pagination repeated token");
expect(chatMembersGetMock).toHaveBeenCalledTimes(2);
});
});
+191 -13
View File
@@ -1,13 +1,21 @@
// Feishu plugin module implements chat behavior.
import type * as Lark from "@larksuiteoapi/node-sdk";
import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import { jsonResult as json } from "openclaw/plugin-sdk/tool-results";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
import { resolveFeishuChatType } from "./chat-type.js";
import { createFeishuClient } from "./client.js";
import { formatFeishuApiError } from "./comment-shared.js";
import { resolveToolsConfig } from "./tools-config.js";
import {
assertFeishuChatReadAllowed,
authorizeFeishuChatMemberRead,
resolveFeishuChatReadPreliminaryAuthorization,
type FeishuChatMemberReadAuthorization,
} from "./read-policy.js";
import { resolveAnyEnabledFeishuToolsConfig, resolveFeishuToolAccount } from "./tool-account.js";
function readChatPageSize(params: Record<string, unknown>): number | undefined {
return readPositiveIntegerParam(params, "page_size", {
@@ -16,6 +24,24 @@ function readChatPageSize(params: Record<string, unknown>): number | undefined {
});
}
export function buildFeishuDirectChatMembers(
authorization: Extract<FeishuChatMemberReadAuthorization, { kind: "direct" }>,
) {
return {
chat_id: authorization.chatId,
has_more: false,
page_token: undefined,
members: [
{
member_id: authorization.memberId,
name: undefined,
tenant_key: undefined,
member_id_type: authorization.memberIdType,
},
],
};
}
export async function getChatInfo(client: Lark.Client, chatId: string) {
const res = await client.im.chat.get({ path: { chat_id: chatId } });
if (res.code !== 0) {
@@ -40,6 +66,69 @@ export async function getChatInfo(client: Lark.Client, chatId: string) {
};
}
function authorizeFeishuChatInfo(params: {
cfg: NonNullable<OpenClawPluginApi["config"]>;
account: ReturnType<typeof resolveFeishuToolAccount>;
chatId: string;
chat: Awaited<ReturnType<typeof getChatInfo>>;
ctx: OpenClawPluginToolContext;
}): void {
assertFeishuChatReadAllowed({
cfg: params.cfg,
account: params.account,
chatId: params.chatId,
chatType: resolveFeishuChatType(params.chat),
ctx: params.ctx,
});
}
async function getAuthorizedFeishuChatInfo(params: {
client: Lark.Client;
cfg: NonNullable<OpenClawPluginApi["config"]>;
account: ReturnType<typeof resolveFeishuToolAccount>;
chatId: string;
ctx: OpenClawPluginToolContext;
}) {
const preliminary = resolveFeishuChatReadPreliminaryAuthorization({
cfg: params.cfg,
account: params.account,
chatId: params.chatId,
ctx: params.ctx,
});
if (preliminary.decision === "deny") {
assertFeishuChatReadAllowed({
cfg: params.cfg,
account: params.account,
chatId: preliminary.chatId,
ctx: params.ctx,
});
}
let chat: Awaited<ReturnType<typeof getChatInfo>>;
try {
// Only targets with at least one authorized conversation kind reach metadata.
// Hide lookup failures when type is needed so metadata cannot become an existence oracle.
chat = await getChatInfo(params.client, preliminary.chatId);
} catch (error) {
if (preliminary.decision === "needs-metadata") {
assertFeishuChatReadAllowed({
cfg: params.cfg,
account: params.account,
chatId: preliminary.chatId,
ctx: params.ctx,
});
}
throw error;
}
authorizeFeishuChatInfo({
cfg: params.cfg,
account: params.account,
chatId: preliminary.chatId,
chat,
ctx: params.ctx,
});
return chat;
}
export async function getChatMembers(
client: Lark.Client,
chatId: string,
@@ -75,6 +164,31 @@ export async function getChatMembers(
};
}
export async function assertFeishuChatMember(
client: Lark.Client,
chatId: string,
memberId: string,
memberIdType: "open_id" | "user_id" | "union_id" = "open_id",
): Promise<void> {
let pageToken: string | undefined;
const seenPageTokens = new Set<string>();
while (true) {
const members = await getChatMembers(client, chatId, 100, pageToken, memberIdType);
if (members.members.some((member) => member.member_id === memberId)) {
return;
}
if (!members.has_more || !members.page_token) {
break;
}
if (seenPageTokens.has(members.page_token)) {
throw new Error(`Feishu chat member pagination repeated token for chat ${chatId}`);
}
seenPageTokens.add(members.page_token);
pageToken = members.page_token;
}
throw new Error(`Member ${memberId} is not a member of chat ${chatId}`);
}
export async function getFeishuMemberInfo(
client: Lark.Client,
memberId: string,
@@ -128,22 +242,20 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
if (!api.config) {
return;
}
const cfg = api.config;
const accounts = listEnabledFeishuAccounts(api.config);
const accounts = listEnabledFeishuAccounts(cfg);
if (accounts.length === 0) {
return;
}
const firstAccount = accounts[0];
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
if (!toolsCfg.chat) {
return;
}
const getClient = () => createFeishuClient(firstAccount);
api.registerTool(
{
(toolContext: OpenClawPluginToolContext) => ({
name: "feishu_chat",
label: "Feishu Chat",
description: "Feishu chat operations. Actions: members, info, member_info",
@@ -152,12 +264,37 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
const rawParams = params as Record<string, unknown>;
const p = params as FeishuChatParams;
try {
const client = getClient();
const account = resolveFeishuToolAccount({
api,
defaultAccountId: toolContext.agentAccountId,
requiredTool: { family: "chat", label: "chat" },
});
const client = createFeishuClient(account);
switch (p.action) {
case "members":
if (!p.chat_id) {
return json({ error: "chat_id is required for action members" });
}
{
const chat = await getAuthorizedFeishuChatInfo({
client,
cfg,
account,
chatId: p.chat_id,
ctx: toolContext,
});
const authorization = authorizeFeishuChatMemberRead({
cfg,
account,
chatId: p.chat_id,
chatType: resolveFeishuChatType(chat),
ctx: toolContext,
memberIdType: p.member_id_type,
});
if (authorization.kind === "direct") {
return json(buildFeishuDirectChatMembers(authorization));
}
}
return json(
await getChatMembers(
client,
@@ -171,14 +308,53 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
if (!p.chat_id) {
return json({ error: "chat_id is required for action info" });
}
return json(await getChatInfo(client, p.chat_id));
{
const chat = await getAuthorizedFeishuChatInfo({
client,
cfg,
account,
chatId: p.chat_id,
ctx: toolContext,
});
return json(chat);
}
case "member_info":
if (!p.member_id) {
return json({ error: "member_id is required for action member_info" });
}
return json(
await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"),
);
if (!p.chat_id) {
return json({ error: "chat_id is required for action member_info" });
}
{
const chat = await getAuthorizedFeishuChatInfo({
client,
cfg,
account,
chatId: p.chat_id,
ctx: toolContext,
});
const authorization = authorizeFeishuChatMemberRead({
cfg,
account,
chatId: p.chat_id,
chatType: resolveFeishuChatType(chat),
ctx: toolContext,
memberId: p.member_id,
memberIdType: p.member_id_type,
});
if (authorization.kind === "group") {
const memberIdType = p.member_id_type ?? "open_id";
await assertFeishuChatMember(client, p.chat_id, p.member_id, memberIdType);
return json(await getFeishuMemberInfo(client, p.member_id, memberIdType));
}
return json(
await getFeishuMemberInfo(
client,
authorization.memberId,
authorization.memberIdType,
),
);
}
default:
return json({ error: `Unknown action: ${String(p.action)}` });
}
@@ -186,7 +362,9 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
return json({ error: formatFeishuApiError(err, { includeNestedErrorLogId: true }) });
}
},
}),
{
name: "feishu_chat",
},
{ name: "feishu_chat" },
);
}
+44
View File
@@ -1,10 +1,13 @@
// Feishu plugin module implements directory.static behavior.
import {
applyDirectoryQueryAndLimit,
listDirectoryGroupEntriesFromMapKeysAndAllowFrom,
listDirectoryUserEntriesFromAllowFrom,
listDirectoryUserEntriesFromAllowFromAndMapKeys,
} from "openclaw/plugin-sdk/directory-runtime";
import type { ClawdbotConfig } from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import { isFeishuGroupReadAllowed } from "./read-policy.js";
import { normalizeFeishuTarget } from "./targets.js";
export type FeishuDirectoryPeer = {
@@ -60,3 +63,44 @@ export async function listFeishuDirectoryGroups(params: {
});
return toFeishuDirectoryGroups(entries.map((entry) => entry.id));
}
export async function listAuthorizedFeishuDirectoryPeers(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryPeer[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const entries = listDirectoryUserEntriesFromAllowFrom({
allowFrom: account.config.allowFrom,
query: params.query,
limit: params.limit,
normalizeId: (entry) => normalizeFeishuTarget(entry) ?? entry,
});
return toFeishuDirectoryPeers(entries.map((entry) => entry.id));
}
export async function listAuthorizedFeishuDirectoryGroups(params: {
cfg: ClawdbotConfig;
query?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
const enabledGroups = Object.fromEntries(
Object.entries(account.config.groups ?? {}).filter(([, group]) => group?.enabled !== false),
);
const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({
groups: enabledGroups,
allowFrom: account.config.groupAllowFrom,
});
const authorizedEntries = entries.filter((entry) =>
isFeishuGroupReadAllowed(params.cfg, account, entry.id, false),
);
return toFeishuDirectoryGroups(
applyDirectoryQueryAndLimit(
authorizedEntries.map((entry) => entry.id),
params,
),
);
}
+122
View File
@@ -15,6 +15,11 @@ const { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } = await im
const { listFeishuDirectoryGroups, listFeishuDirectoryPeers } = await importFreshModule<
typeof import("./directory.static.js")
>(import.meta.url, "./directory.static.js?directory-test");
const { listAuthorizedFeishuDirectoryGroups, listAuthorizedFeishuDirectoryPeers } =
await importFreshModule<typeof import("./directory.static.js")>(
import.meta.url,
"./directory.static.js?authorized-directory-test",
);
function makeStaticCfg(): ClawdbotConfig {
return {
@@ -92,6 +97,63 @@ describe("feishu directory (config-backed)", () => {
]);
});
it("lists only read-authorized static peers and enabled groups", async () => {
const cfg = makeStaticCfg();
const feishu = cfg.channels?.feishu;
if (!feishu) {
throw new Error("Expected Feishu config");
}
feishu.groups = {
...feishu.groups,
"chat-disabled": { enabled: false },
};
await expect(listAuthorizedFeishuDirectoryPeers({ cfg })).resolves.toEqual([
{ kind: "user", id: "alice" },
{ kind: "user", id: "bob" },
]);
await expect(listAuthorizedFeishuDirectoryGroups({ cfg })).resolves.toEqual([
{ kind: "group", id: "chat-1" },
{ kind: "group", id: "chat-2" },
]);
});
it("keeps explicitly disabled groups out even when groupAllowFrom includes them", async () => {
const cfg = makeStaticCfg();
const feishu = cfg.channels?.feishu;
if (!feishu) {
throw new Error("Expected Feishu config");
}
feishu.groups = {
...feishu.groups,
"chat-disabled": { enabled: false },
};
feishu.groupAllowFrom = [...(feishu.groupAllowFrom ?? []), "chat-disabled"];
await expect(listAuthorizedFeishuDirectoryGroups({ cfg })).resolves.toEqual([
{ kind: "group", id: "chat-1" },
{ kind: "group", id: "chat-2" },
]);
});
it("applies the static group limit after authorization filtering", async () => {
const cfg = {
channels: {
feishu: {
groupPolicy: "allowlist",
groups: {
"chat-blocked": { enabled: false },
"chat-allowed": {},
},
},
},
} as ClawdbotConfig;
await expect(listAuthorizedFeishuDirectoryGroups({ cfg, limit: 1 })).resolves.toEqual([
{ kind: "group", id: "chat-allowed" },
]);
});
it("falls back to static peers on live lookup failure by default", async () => {
createFeishuClientMock.mockReturnValueOnce({
contact: {
@@ -110,6 +172,66 @@ describe("feishu directory (config-backed)", () => {
]);
});
it("paginates live groups until the filtered result limit is reached", async () => {
const list = vi
.fn()
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ chat_id: "chat-blocked", name: "Blocked" }],
has_more: true,
page_token: "page-2",
},
})
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ chat_id: "chat-allowed", name: "Allowed" }],
has_more: false,
},
});
createFeishuClientMock.mockReturnValueOnce({
im: { chat: { list } },
});
await expect(
listFeishuDirectoryGroupsLive({
cfg: makeConfiguredCfg(),
limit: 1,
filter: (group) => group.id !== "chat-blocked",
}),
).resolves.toEqual([{ kind: "group", id: "chat-allowed", name: "Allowed" }]);
expect(list).toHaveBeenNthCalledWith(2, {
params: {
page_size: 1,
page_token: "page-2",
},
});
});
it("rejects repeated live group directory page tokens", async () => {
const list = vi.fn().mockResolvedValue({
code: 0,
data: {
items: [{ chat_id: "chat-blocked", name: "Blocked" }],
has_more: true,
page_token: "repeat",
},
});
createFeishuClientMock.mockReturnValueOnce({
im: { chat: { list } },
});
await expect(
listFeishuDirectoryGroupsLive({
cfg: makeConfiguredCfg(),
filter: () => false,
fallbackToStatic: false,
}),
).rejects.toThrow("Feishu live group directory returned a repeated page token");
expect(list).toHaveBeenCalledTimes(2);
});
it("surfaces live peer lookup failures when fallback is disabled", async () => {
createFeishuClientMock.mockReturnValueOnce({
contact: {
+42 -23
View File
@@ -10,6 +10,8 @@ import {
type FeishuDirectoryPeer,
} from "./directory.static.js";
const MAX_FEISHU_DIRECTORY_PAGES = 100;
export async function listFeishuDirectoryPeersLive(params: {
cfg: ClawdbotConfig;
query?: string;
@@ -73,6 +75,7 @@ export async function listFeishuDirectoryGroupsLive(params: {
limit?: number;
accountId?: string;
fallbackToStatic?: boolean;
filter?: (group: FeishuDirectoryGroup) => boolean;
}): Promise<FeishuDirectoryGroup[]> {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
@@ -83,36 +86,52 @@ export async function listFeishuDirectoryGroupsLive(params: {
const client = createFeishuClient(account);
const groups: FeishuDirectoryGroup[] = [];
const limit = params.limit ?? 50;
const response = await client.im.chat.list({
params: {
page_size: Math.min(limit, 100),
},
});
if (response.code !== 0) {
throw new Error(response.msg || `code ${response.code}`);
}
const q = normalizeLowercaseStringOrEmpty(params.query);
for (const chat of response.data?.items ?? []) {
if (chat.chat_id) {
const name = chat.name || "";
if (
!q ||
normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) ||
normalizeLowercaseStringOrEmpty(name).includes(q)
) {
groups.push({
let pageToken: string | undefined;
let pages = 0;
const seenPageTokens = new Set<string>();
do {
const response = await client.im.chat.list({
params: {
page_size: Math.min(limit, 100),
page_token: pageToken,
},
});
if (response.code !== 0) {
throw new Error(response.msg || `code ${response.code}`);
}
for (const chat of response.data?.items ?? []) {
if (chat.chat_id) {
const name = chat.name || "";
const group = {
kind: "group",
id: chat.chat_id,
name: name || undefined,
});
} satisfies FeishuDirectoryGroup;
const matchesQuery =
!q ||
normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) ||
normalizeLowercaseStringOrEmpty(name).includes(q);
if (matchesQuery && (!params.filter || params.filter(group))) {
groups.push(group);
}
}
if (groups.length >= limit) {
break;
}
}
if (groups.length >= limit) {
break;
pages += 1;
const nextPageToken = response.data?.has_more ? response.data.page_token : undefined;
if (nextPageToken && seenPageTokens.has(nextPageToken)) {
throw new Error("Feishu live group directory returned a repeated page token");
}
if (nextPageToken) {
seenPageTokens.add(nextPageToken);
}
pageToken = nextPageToken;
} while (pageToken && groups.length < limit && pages < MAX_FEISHU_DIRECTORY_PAGES);
if (pageToken && pages >= MAX_FEISHU_DIRECTORY_PAGES) {
throw new Error("Feishu live group directory pagination limit exceeded");
}
return groups;
+112
View File
@@ -0,0 +1,112 @@
// Feishu tests cover reactions plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../runtime-api.js";
const listMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveFeishuRuntimeAccount: () => ({
accountId: "default",
configured: true,
appId: "cli_main",
appSecret: "secret",
domain: "feishu",
}),
}));
vi.mock("./client.js", () => ({
createFeishuClient: () => ({
im: {
messageReaction: {
list: listMock,
},
},
}),
}));
import { listReactionsFeishu } from "./reactions.js";
describe("listReactionsFeishu", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("reads the SDK's nested operator ownership fields", async () => {
listMock.mockResolvedValue({
code: 0,
data: {
items: [
{
reaction_id: "r-app",
reaction_type: { emoji_type: "THUMBSUP" },
operator: { operator_type: "app", operator_id: "cli_main" },
},
{
reaction_id: "r-user",
reaction_type: { emoji_type: "HEART" },
operator: { operator_type: "user", operator_id: "ou_user" },
},
],
},
});
await expect(
listReactionsFeishu({
cfg: {} as ClawdbotConfig,
messageId: "om_message",
}),
).resolves.toEqual([
{
reactionId: "r-app",
emojiType: "THUMBSUP",
operatorType: "app",
operatorId: "cli_main",
},
{
reactionId: "r-user",
emojiType: "HEART",
operatorType: "user",
operatorId: "ou_user",
},
]);
});
it("fails closed for missing or unrecognized operator metadata", async () => {
listMock.mockResolvedValue({
code: 0,
data: {
items: [
{
reaction_id: "r-missing",
reaction_type: { emoji_type: "THUMBSUP" },
},
{
reaction_id: "r-unknown",
reaction_type: { emoji_type: "HEART" },
operator: { operator_type: "tenant", operator_id: "tenant-1" },
},
],
},
});
const reactions = await listReactionsFeishu({
cfg: {} as ClawdbotConfig,
messageId: "om_message",
});
expect(reactions).toEqual([
{
reactionId: "r-missing",
emojiType: "THUMBSUP",
operatorType: "unknown",
operatorId: "",
},
{
reactionId: "r-unknown",
emojiType: "HEART",
operatorType: "unknown",
operatorId: "tenant-1",
},
]);
});
});
+12 -6
View File
@@ -6,7 +6,7 @@ import { createFeishuClient } from "./client.js";
type FeishuReaction = {
reactionId: string;
emojiType: string;
operatorType: "app" | "user";
operatorType: "app" | "user" | "unknown";
operatorId: string;
};
@@ -105,8 +105,10 @@ export async function listReactionsFeishu(params: {
items?: Array<{
reaction_id?: string;
reaction_type?: { emoji_type?: string };
operator_type?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
operator?: {
operator_type?: string;
operator_id?: string;
};
}>;
};
};
@@ -117,8 +119,12 @@ export async function listReactionsFeishu(params: {
return items.map((item) => ({
reactionId: item.reaction_id ?? "",
emojiType: item.reaction_type?.emoji_type ?? "",
operatorType: item.operator_type === "app" ? "app" : "user",
operatorId:
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
operatorType:
item.operator?.operator_type === "app"
? "app"
: item.operator?.operator_type === "user"
? "user"
: "unknown",
operatorId: item.operator?.operator_id ?? "",
}));
}
+369
View File
@@ -0,0 +1,369 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { describe, expect, it } from "vitest";
import { resolveFeishuAccount } from "./accounts.js";
import { resolveFeishuChatType } from "./chat-type.js";
import {
assertFeishuChatReadAllowed,
canEnumerateAllFeishuGroups,
canEnumerateAllFeishuPeers,
resolveFeishuChatReadPreliminaryAuthorization,
} from "./read-policy.js";
import type { ResolvedFeishuAccount } from "./types.js";
const cfg = { channels: { feishu: {} } } as OpenClawConfig;
function createAccount(): ResolvedFeishuAccount {
return {
accountId: "default",
selectionSource: "fallback",
enabled: true,
configured: true,
domain: "feishu",
config: {
groupPolicy: "allowlist",
dmPolicy: "pairing",
} as ResolvedFeishuAccount["config"],
};
}
describe("Feishu read policy", () => {
it("does not derive conversation kind from public/private visibility", () => {
expect(resolveFeishuChatType({ chat_type: "private" })).toBeUndefined();
expect(resolveFeishuChatType({ chat_type: "public" })).toBeUndefined();
expect(resolveFeishuChatType({ chat_type: "p2p" })).toBe("p2p");
expect(resolveFeishuChatType({ chat_type: "group" })).toBe("group");
expect(resolveFeishuChatType({ chat_mode: "group", chat_type: "private" })).toBe("group");
expect(resolveFeishuChatType({ chat_mode: "p2p", chat_type: "private" })).toBe("p2p");
});
it("allows only the trusted current chat when the target type is unknown", () => {
const account = createAccount();
const ctx = {
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_current",
},
};
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_current",
ctx,
}),
).toBe("oc_current");
expect(() =>
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_other",
ctx,
}),
).toThrow("Feishu read target is not allowed.");
});
it("does not treat public delivery routing as trusted current-chat identity", () => {
const account = createAccount();
expect(() =>
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_unconfigured",
chatType: "group",
ctx: {
agentAccountId: "default",
messageChannel: "feishu",
deliveryContext: {
channel: "feishu",
to: "oc_unconfigured",
accountId: "default",
},
},
}),
).toThrow("Feishu read target is not allowed.");
});
it("allows native Feishu ingress to identify the current chat", () => {
const account = createAccount();
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_current",
chatType: "group",
ctx: {
agentAccountId: "default",
messageChannel: "feishu",
nativeChannelId: "oc_current",
deliveryContext: {
channel: "feishu",
to: "oc_current",
accountId: "default",
},
},
}),
).toBe("oc_current");
});
it("does not treat wildcard group defaults as admission", () => {
const account = createAccount();
account.config = {
...account.config,
dmPolicy: "open",
groups: { "*": { requireMention: false } },
};
expect(() =>
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_unconfigured",
ctx: {},
}),
).toThrow("Feishu read target is not allowed.");
});
it("requires an effective wildcard before open policy allows non-current DMs", () => {
const mergedCfg = {
channels: {
feishu: {
appId: "cli_test",
appSecret: "secret_test",
dmPolicy: "allowlist",
allowFrom: ["ou_admin"],
accounts: {
sales: {
dmPolicy: "open",
},
},
},
},
} as OpenClawConfig;
const account = resolveFeishuAccount({ cfg: mergedCfg, accountId: "sales" });
expect(() =>
assertFeishuChatReadAllowed({
cfg: mergedCfg,
account,
chatId: "oc_other",
chatType: "p2p",
ctx: {},
}),
).toThrow("Feishu read target is not allowed.");
expect(canEnumerateAllFeishuPeers(account)).toBe(false);
account.config = {
...account.config,
allowFrom: ["ou_admin", "feishu:*"],
};
expect(
assertFeishuChatReadAllowed({
cfg: mergedCfg,
account,
chatId: "oc_other",
chatType: "p2p",
ctx: {},
}),
).toBe("oc_other");
expect(canEnumerateAllFeishuPeers(account)).toBe(true);
});
it.each(["allowlist", "pairing"] as const)(
"honors wildcard DM admission under %s policy",
(dmPolicy) => {
const account = createAccount();
account.config = {
...account.config,
dmPolicy,
allowFrom: ["feishu:*"],
};
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_any",
chatType: "p2p",
ctx: {},
}),
).toBe("oc_any");
expect(canEnumerateAllFeishuPeers(account)).toBe(true);
},
);
it("honors wildcard group admission entries", () => {
const account = createAccount();
account.config = {
...account.config,
groupAllowFrom: ["*"],
};
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_any",
chatType: "group",
ctx: {},
}),
).toBe("oc_any");
expect(canEnumerateAllFeishuGroups(cfg, account)).toBe(true);
});
it("uses filtered live enumeration for open groups with explicit denials", () => {
const account = createAccount();
account.config = {
...account.config,
groupPolicy: "open",
groups: {
oc_blocked: { enabled: false },
},
};
expect(canEnumerateAllFeishuGroups(cfg, account)).toBe(true);
});
it("uses the global group policy when the provider does not override it", () => {
const account = createAccount();
account.config = { dmPolicy: "pairing" } as ResolvedFeishuAccount["config"];
const globalOpenCfg = {
channels: {
defaults: { groupPolicy: "open" },
feishu: {},
},
} as OpenClawConfig;
expect(
assertFeishuChatReadAllowed({
cfg: globalOpenCfg,
account,
chatId: "oc_group",
chatType: "group",
ctx: {},
}),
).toBe("oc_group");
});
it("allows the trusted current DM when groups are disabled", () => {
const account = createAccount();
account.config = {
...account.config,
groupPolicy: "disabled",
dmPolicy: "pairing",
};
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_current",
chatType: "p2p",
ctx: {
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_current",
},
},
}),
).toBe("oc_current");
});
it("rejects an unclassified current target when group reads are disabled", () => {
const account = createAccount();
account.config = {
...account.config,
groupPolicy: "disabled",
dmPolicy: "pairing",
};
expect(() =>
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_current",
ctx: {
accountId: "default",
requesterAccountId: "default",
toolContext: {
currentChannelProvider: "feishu",
currentChannelId: "oc_current",
},
},
}),
).toThrow("Feishu read target is not allowed.");
});
it("lets a direct operator read an unconfigured group or DM", () => {
const account = createAccount();
const ctx = { conversationReadOrigin: "direct-operator" as const };
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_group",
chatType: "group",
ctx,
}),
).toBe("oc_group");
expect(
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_dm",
chatType: "p2p",
ctx,
}),
).toBe("oc_dm");
});
it("keeps disabled group targets blocked for direct operators", () => {
const account = createAccount();
account.config = {
...account.config,
groups: { oc_blocked: { enabled: false } },
};
expect(() =>
assertFeishuChatReadAllowed({
cfg,
account,
chatId: "oc_blocked",
chatType: "group",
ctx: { conversationReadOrigin: "direct-operator" },
}),
).toThrow("Feishu read target is not allowed.");
});
it("requires metadata only when an unknown target has mixed scope policy", () => {
const account = createAccount();
account.config = {
...account.config,
allowFrom: ["*"],
};
expect(
resolveFeishuChatReadPreliminaryAuthorization({
cfg,
account,
chatId: "oc_unknown",
ctx: {},
}),
).toEqual({ chatId: "oc_unknown", decision: "needs-metadata" });
expect(
resolveFeishuChatReadPreliminaryAuthorization({
cfg,
account,
chatId: "oc_unknown",
ctx: { conversationReadOrigin: "direct-operator" },
}),
).toEqual({ chatId: "oc_unknown", decision: "allow" });
});
});
+272
View File
@@ -0,0 +1,272 @@
import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
import {
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
} from "openclaw/plugin-sdk/runtime-group-policy";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeFeishuChatType } from "./chat-type.js";
import {
hasExplicitFeishuGroupConfig,
normalizeFeishuAllowEntry,
resolveFeishuGroupConfig,
} from "./policy.js";
import { detectIdType, normalizeFeishuTarget } from "./targets.js";
import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js";
type FeishuActionReadContext = Pick<
ChannelMessageActionContext,
| "accountId"
| "conversationReadOrigin"
| "requesterAccountId"
| "requesterSenderId"
| "toolContext"
>;
type FeishuReadContext = FeishuActionReadContext | OpenClawPluginToolContext;
function isActionContext(ctx: FeishuReadContext): ctx is FeishuActionReadContext {
return "toolContext" in ctx;
}
function normalizeChatId(raw?: string | null): string {
if (!raw) {
return "";
}
return normalizeFeishuTarget(raw) ?? raw.trim();
}
function readContextFields(ctx: FeishuReadContext): {
accountId?: string;
currentChannelId?: string;
currentProvider?: string;
requesterAccountId?: string;
requesterSenderId?: string;
directOperator: boolean;
} {
if (isActionContext(ctx)) {
return {
accountId: normalizeOptionalString(ctx.accountId),
currentChannelId: normalizeOptionalString(ctx.toolContext?.currentChannelId),
currentProvider: normalizeOptionalString(ctx.toolContext?.currentChannelProvider),
requesterAccountId: normalizeOptionalString(ctx.requesterAccountId),
requesterSenderId: normalizeOptionalString(ctx.requesterSenderId),
directOperator: ctx.conversationReadOrigin === "direct-operator",
};
}
return {
accountId: normalizeOptionalString(ctx.agentAccountId),
currentChannelId: normalizeOptionalString(ctx.nativeChannelId),
currentProvider: normalizeOptionalString(ctx.messageChannel ?? ctx.deliveryContext?.channel),
requesterAccountId: normalizeOptionalString(ctx.deliveryContext?.accountId),
requesterSenderId: normalizeOptionalString(ctx.requesterSenderId),
directOperator: ctx.conversationReadOrigin === "direct-operator",
};
}
function isCurrentChat(params: {
account: ResolvedFeishuAccount;
chatId: string;
ctx: FeishuReadContext;
}): boolean {
const context = readContextFields(params.ctx);
return (
context.currentProvider?.toLowerCase() === "feishu" &&
context.requesterAccountId === params.account.accountId &&
(context.accountId ?? params.account.accountId) === params.account.accountId &&
normalizeChatId(context.currentChannelId) === normalizeChatId(params.chatId)
);
}
function resolveFeishuReadGroupPolicy(cfg: OpenClawConfig, account: ResolvedFeishuAccount) {
return resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.feishu !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy: resolveDefaultGroupPolicy(cfg),
}).groupPolicy;
}
export function isFeishuGroupReadAllowed(
cfg: OpenClawConfig,
account: ResolvedFeishuAccount,
chatId: string,
current: boolean,
): boolean {
const policy = resolveFeishuReadGroupPolicy(cfg, account);
if (policy === "disabled") {
return false;
}
const group = resolveFeishuGroupConfig({ cfg: account.config, groupId: chatId });
if (group?.enabled === false) {
return false;
}
if (current) {
return true;
}
if (policy === "open") {
return true;
}
const explicitlyConfigured = hasExplicitFeishuGroupConfig({
cfg: account.config,
groupId: chatId,
});
const normalizedChatId = normalizeFeishuAllowEntry(chatId);
return (
explicitlyConfigured ||
(account.config.groupAllowFrom ?? []).some((entry) => {
const normalized = normalizeFeishuAllowEntry(String(entry));
return normalized === "*" || normalized === normalizedChatId;
})
);
}
export function isFeishuGroupReadEnabled(
cfg: OpenClawConfig,
account: ResolvedFeishuAccount,
chatId: string,
): boolean {
if (resolveFeishuReadGroupPolicy(cfg, account) === "disabled") {
return false;
}
return resolveFeishuGroupConfig({ cfg: account.config, groupId: chatId })?.enabled !== false;
}
function isDmUniversallyAllowed(account: ResolvedFeishuAccount): boolean {
// Feishu's canonical schema has no disabled DM mode; channel/account enabled owns shutdown.
// Account overrides merge field-by-field, so only an allowFrom wildcard proves
// universal non-current access under every supported ingress policy.
return (account.config.allowFrom ?? []).some(
(entry) => normalizeFeishuAllowEntry(String(entry)) === "*",
);
}
export function assertFeishuChatReadAllowed(params: {
cfg: OpenClawConfig;
account: ResolvedFeishuAccount;
chatId: string;
chatType?: FeishuChatType;
ctx: FeishuReadContext;
}): string {
const authorization = resolveFeishuChatReadPreliminaryAuthorization(params);
if (authorization.decision !== "allow") {
throw new ToolAuthorizationError("Feishu read target is not allowed.");
}
return authorization.chatId;
}
export type FeishuChatReadPreliminaryDecision = "allow" | "deny" | "needs-metadata";
export function resolveFeishuChatReadPreliminaryAuthorization(params: {
cfg: OpenClawConfig;
account: ResolvedFeishuAccount;
chatId: string;
chatType?: FeishuChatType;
ctx: FeishuReadContext;
}): {
chatId: string;
decision: FeishuChatReadPreliminaryDecision;
} {
const chatId = normalizeChatId(params.chatId);
const resolvedChatType = normalizeFeishuChatType(params.chatType);
const knownGroup =
resolvedChatType === "group" ||
(params.chatType === undefined &&
hasExplicitFeishuGroupConfig({
cfg: params.account.config,
groupId: chatId,
}));
const knownDm = resolvedChatType === "p2p";
const current = isCurrentChat({ account: params.account, chatId, ctx: params.ctx });
const directOperator = readContextFields(params.ctx).directOperator;
const groupAllowed = directOperator
? isFeishuGroupReadEnabled(params.cfg, params.account, chatId)
: isFeishuGroupReadAllowed(params.cfg, params.account, chatId, current);
const dmAllowed = directOperator || current || isDmUniversallyAllowed(params.account);
if (knownGroup) {
return { chatId, decision: groupAllowed ? "allow" : "deny" };
}
if (knownDm) {
return { chatId, decision: dmAllowed ? "allow" : "deny" };
}
if (groupAllowed === dmAllowed) {
return { chatId, decision: groupAllowed ? "allow" : "deny" };
}
return { chatId, decision: "needs-metadata" };
}
export type FeishuChatMemberReadAuthorization =
| { kind: "group"; chatId: string }
| {
kind: "direct";
chatId: string;
memberId: string;
memberIdType: "open_id" | "user_id";
};
export function authorizeFeishuChatMemberRead(params: {
cfg: OpenClawConfig;
account: ResolvedFeishuAccount;
chatId: string;
chatType?: FeishuChatType;
ctx: FeishuReadContext;
memberId?: string;
memberIdType?: "open_id" | "user_id" | "union_id";
}): FeishuChatMemberReadAuthorization {
const chatId = assertFeishuChatReadAllowed(params);
const chatType = normalizeFeishuChatType(params.chatType);
if (chatType === "group") {
return { kind: "group", chatId };
}
if (chatType !== "p2p") {
throw new ToolAuthorizationError("Feishu chat member reads require a known chat type.");
}
if (!isCurrentChat({ account: params.account, chatId, ctx: params.ctx })) {
throw new ToolAuthorizationError(
"Feishu direct-chat member reads require the current conversation.",
);
}
const requesterSenderId = normalizeChatId(readContextFields(params.ctx).requesterSenderId);
if (!requesterSenderId) {
throw new ToolAuthorizationError("Feishu direct-chat member identity is unavailable.");
}
const requesterSenderIdType = detectIdType(requesterSenderId);
if (requesterSenderIdType !== "open_id" && requesterSenderIdType !== "user_id") {
throw new ToolAuthorizationError("Feishu direct-chat member identity type is unavailable.");
}
if (params.memberIdType && params.memberIdType !== requesterSenderIdType) {
throw new ToolAuthorizationError(
"Feishu direct-chat member identifier type must match the current sender.",
);
}
if (params.memberId && normalizeChatId(params.memberId) !== requesterSenderId) {
throw new ToolAuthorizationError(
"Feishu direct-chat member reads are limited to the current sender.",
);
}
return {
kind: "direct",
chatId,
memberId: requesterSenderId,
memberIdType: requesterSenderIdType,
};
}
export function canEnumerateAllFeishuGroups(
cfg: OpenClawConfig,
account: ResolvedFeishuAccount,
): boolean {
const policy = resolveFeishuReadGroupPolicy(cfg, account);
return (
policy === "open" ||
(policy === "allowlist" &&
(account.config.groupAllowFrom ?? []).some(
(entry) => normalizeFeishuAllowEntry(String(entry)) === "*",
))
);
}
export function canEnumerateAllFeishuPeers(account: ResolvedFeishuAccount): boolean {
return isDmUniversallyAllowed(account);
}
-5
View File
@@ -33,11 +33,6 @@ export {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export {
readRemoteMediaBuffer,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/media-runtime";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
export type {
+86 -193
View File
@@ -1,16 +1,10 @@
// Googlechat tests cover actions plugin behavior.
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
const resolveGoogleChatAccount = vi.hoisted(() => vi.fn());
const createGoogleChatReaction = vi.hoisted(() => vi.fn());
const deleteGoogleChatReaction = vi.hoisted(() => vi.fn());
const listGoogleChatReactions = vi.hoisted(() => vi.fn());
const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
listEnabledGoogleChatAccounts,
@@ -18,15 +12,7 @@ vi.mock("./accounts.js", () => ({
}));
vi.mock("./api.js", () => ({
createGoogleChatReaction,
deleteGoogleChatReaction,
listGoogleChatReactions,
sendGoogleChatMessage,
uploadGoogleChatAttachment,
}));
vi.mock("./runtime.js", () => ({
getGoogleChatRuntime,
}));
vi.mock("./targets.js", () => ({
@@ -47,18 +33,25 @@ describe("googlechat message actions", () => {
afterAll(() => {
vi.doUnmock("./accounts.js");
vi.doUnmock("./api.js");
vi.doUnmock("./runtime.js");
vi.doUnmock("./targets.js");
vi.resetModules();
});
function buildAccount(overrides: Record<string, unknown> = {}) {
const overrideConfig =
overrides.config && typeof overrides.config === "object"
? (overrides.config as Record<string, unknown>)
: {};
return {
accountId: "default",
enabled: true,
credentialSource: "service-account",
config: {},
...overrides,
config: {
groupPolicy: "open",
dm: { policy: "open" },
...overrideConfig,
},
};
}
@@ -74,7 +67,7 @@ describe("googlechat message actions", () => {
});
}
it("describes send and reaction actions only when enabled accounts exist", () => {
it("describes only send actions when enabled accounts exist", () => {
listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
@@ -87,11 +80,13 @@ describe("googlechat message actions", () => {
]);
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
actions: ["send", "upload-file", "react", "reactions"],
actions: ["send"],
});
expect(googlechatMessageActions.supportsAction?.({ action: "send" })).toBe(true);
expect(googlechatMessageActions.supportsAction?.({ action: "upload-file" })).toBe(false);
});
it("honors account-scoped reaction gates during discovery", () => {
it("keeps the legacy reaction gate from changing account-scoped discovery", () => {
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
enabled: true,
credentialSource: "service-account",
@@ -100,39 +95,19 @@ describe("googlechat message actions", () => {
},
}));
expect(
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }),
).toEqual({
actions: ["send", "upload-file"],
});
expect(
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }),
).toEqual({
actions: ["send", "upload-file", "react", "reactions"],
});
for (const accountId of ["default", "work"]) {
expect(
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId }),
).toEqual({
actions: ["send"],
});
}
});
it("sends messages with uploaded media through the resolved space", async () => {
const account = buildAccount({
config: { mediaMaxMb: 5 },
});
it("sends text through the resolved space", async () => {
const account = buildAccount();
resolveGoogleChatAccount.mockReturnValue(account);
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
const readRemoteMediaBuffer = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
readRemoteMediaBuffer,
},
},
});
uploadGoogleChatAttachment.mockResolvedValue({
attachmentUploadToken: "token-1",
});
sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
threadName: "spaces/AAA/threads/thread-1",
@@ -146,7 +121,6 @@ describe("googlechat message actions", () => {
params: {
to: "spaces/AAA",
message: "caption",
media: "https://example.com/file.png",
threadId: "thread-1",
},
cfg: {},
@@ -157,23 +131,11 @@ describe("googlechat message actions", () => {
account,
target: "spaces/AAA",
});
expect(readRemoteMediaBuffer).toHaveBeenCalledWith({
url: "https://example.com/file.png",
maxBytes: 5 * 1024 * 1024,
});
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
account,
space: "spaces/AAA",
filename: "remote.png",
buffer: Buffer.from("remote-bytes"),
contentType: "image/png",
});
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
account,
space: "spaces/AAA",
text: "caption",
thread: "thread-1",
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
});
expectJsonResult(result, {
ok: true,
@@ -183,142 +145,73 @@ describe("googlechat message actions", () => {
});
});
it("routes upload-file through the same attachment upload path with filename override", async () => {
const account = buildAccount({
config: { mediaMaxMb: 5 },
});
resolveGoogleChatAccount.mockReturnValue(account);
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
const localRoot = "/tmp/googlechat-action-test";
const localPath = path.join(localRoot, "local.md");
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
getGoogleChatRuntime.mockReturnValue({
channel: {
media: {
readRemoteMediaBuffer: vi.fn(),
},
it.each([
{ action: "send", params: { to: "spaces/AAA", message: "caption", media: "remote.png" } },
{
action: "send",
params: { to: "spaces/AAA", message: "caption", mediaUrl: "remote.png" },
},
{
action: "send",
params: { to: "spaces/AAA", message: "caption", mediaUrls: ["remote.png"] },
},
{
action: "send",
params: { to: "spaces/AAA", message: "caption", fileUrl: "remote.png" },
},
{
action: "send",
params: {
to: "spaces/AAA",
message: "caption",
attachments: [{ url: "remote.png" }],
},
});
uploadGoogleChatAttachment.mockResolvedValue({
attachmentUploadToken: "token-2",
});
sendGoogleChatMessage.mockResolvedValue({
messageName: "spaces/BBB/messages/msg-2",
threadName: "spaces/BBB/threads/thread-2",
});
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
const result = await googlechatMessageActions.handleAction({
},
{
action: "upload-file",
params: {
to: "spaces/BBB",
path: localPath,
message: "notes",
filename: "renamed.txt",
},
cfg: {},
accountId: "default",
mediaLocalRoots: [localRoot],
mediaReadFile: readFile,
} as never);
params: { to: "spaces/AAA", message: "caption", path: "local.png" },
},
])(
"rejects outbound attachment action $action before provider access",
async ({ action, params }) => {
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
await expect(
googlechatMessageActions.handleAction({
action,
params,
cfg: {},
accountId: "default",
} as never),
).rejects.toThrow(
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.",
);
expect(readFile).toHaveBeenCalledWith(localPath);
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
account,
space: "spaces/BBB",
filename: "renamed.txt",
buffer: Buffer.from("local-bytes"),
contentType: "text/markdown",
});
expect(sendGoogleChatMessage).toHaveBeenCalledWith({
account,
space: "spaces/BBB",
text: "notes",
thread: undefined,
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
});
expectJsonResult(result, {
ok: true,
to: "spaces/BBB",
messageName: "spaces/BBB/messages/msg-2",
threadName: "spaces/BBB/threads/thread-2",
});
});
expect(resolveGoogleChatAccount).not.toHaveBeenCalled();
expect(resolveGoogleChatOutboundSpace).not.toHaveBeenCalled();
expect(sendGoogleChatMessage).not.toHaveBeenCalled();
},
);
it("removes only matching app reactions on react remove", async () => {
const account = buildAccount({
config: { botUser: "users/app-bot" },
});
resolveGoogleChatAccount.mockReturnValue(account);
listGoogleChatReactions.mockResolvedValue([
{
name: "reactions/1",
emoji: { unicode: "👍" },
user: { name: "users/app" },
},
{
name: "reactions/2",
emoji: { unicode: "👍" },
user: { name: "users/app-bot" },
},
{
name: "reactions/3",
emoji: { unicode: "👍" },
user: { name: "users/other" },
},
]);
it.each(["react", "reactions"])(
"rejects unsupported %s actions without provider access",
async (action) => {
resolveGoogleChatAccount.mockReturnValue(buildAccount());
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
const result = await googlechatMessageActions.handleAction({
action: "react",
params: {
messageId: "spaces/AAA/messages/msg-1",
emoji: "👍",
remove: true,
},
cfg: {},
accountId: "default",
} as never);
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
await expect(
googlechatMessageActions.handleAction({
action,
params: { messageId: "spaces/AAA/messages/msg-1", emoji: "👍" },
cfg: {},
accountId: "default",
} as never),
).rejects.toThrow(`Action ${action} is not supported for provider googlechat.`);
expect(listGoogleChatReactions).toHaveBeenCalledWith({
account,
messageName: "spaces/AAA/messages/msg-1",
});
expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2);
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, {
account,
reactionName: "reactions/1",
});
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, {
account,
reactionName: "reactions/2",
});
expectJsonResult(result, { ok: true, removed: 2 });
});
it("rejects fractional reaction limits before listing reactions", async () => {
const account = buildAccount();
resolveGoogleChatAccount.mockReturnValue(account);
if (!googlechatMessageActions.handleAction) {
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
}
await expect(
googlechatMessageActions.handleAction({
action: "reactions",
params: {
messageId: "spaces/AAA/messages/msg-1",
limit: 2.5,
},
cfg: {},
accountId: "default",
} as never),
).rejects.toThrow("limit must be a positive integer");
expect(listGoogleChatReactions).not.toHaveBeenCalled();
});
expect(sendGoogleChatMessage).not.toHaveBeenCalled();
},
);
});
+45 -169
View File
@@ -1,27 +1,14 @@
// Googlechat plugin module implements actions behavior.
import {
createActionGate,
jsonResult,
readPositiveIntegerParam,
readReactionParams,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-contract";
import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
import {
createGoogleChatReaction,
deleteGoogleChatReaction,
listGoogleChatReactions,
sendGoogleChatMessage,
uploadGoogleChatAttachment,
} from "./api.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { sendGoogleChatMessage } from "./api.js";
import { resolveGoogleChatOutboundSpace } from "./targets.js";
const providerId = "googlechat";
@@ -32,42 +19,28 @@ function listEnabledAccounts(cfg: OpenClawConfig) {
);
}
function isReactionsEnabled(accounts: Array<{ config: { actions?: unknown } }>) {
for (const account of accounts) {
const gate = createActionGate(account.config.actions as Record<string, boolean | undefined>);
if (gate("reactions")) {
return true;
}
const OUTBOUND_MEDIA_KEYS = ["media", "mediaUrl", "path", "filePath", "fileUrl"] as const;
const STRUCTURED_ATTACHMENT_MEDIA_KEYS = [...OUTBOUND_MEDIA_KEYS, "url"] as const;
function hasGoogleChatOutboundAttachment(params: Record<string, unknown>): boolean {
if (OUTBOUND_MEDIA_KEYS.some((key) => readStringParam(params, key) !== undefined)) {
return true;
}
return false;
}
function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
}
async function loadGoogleChatActionMedia(params: {
mediaUrl: string;
maxBytes: number;
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}) {
const runtime = getGoogleChatRuntime();
return /^https?:\/\//i.test(params.mediaUrl)
? await runtime.channel.media.readRemoteMediaBuffer({
url: params.mediaUrl,
maxBytes: params.maxBytes,
})
: await loadOutboundMediaFromUrl(params.mediaUrl, {
maxBytes: params.maxBytes,
mediaAccess: params.mediaAccess,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
});
if (readStringArrayParam(params, "mediaUrls") !== undefined) {
return true;
}
if (!Array.isArray(params.attachments)) {
return false;
}
return params.attachments.some((attachment) => {
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
return false;
}
const record = attachment as Record<string, unknown>;
return STRUCTURED_ATTACHMENT_MEDIA_KEYS.some(
(key) => readStringParam(record, key) !== undefined,
);
});
}
export const googlechatMessageActions: ChannelMessageActionAdapter = {
@@ -80,27 +53,26 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
if (accounts.length === 0) {
return null;
}
const actions = new Set<ChannelMessageActionName>([]);
actions.add("send");
actions.add("upload-file");
if (isReactionsEnabled(accounts)) {
actions.add("react");
actions.add("reactions");
}
return { actions: Array.from(actions) };
return { actions: ["send"] };
},
supportsAction: ({ action }) => action === "send",
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async ({
action,
params,
cfg,
accountId,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
}) => {
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "upload-file") {
throw new Error(
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.",
);
}
if (action === "send") {
if (hasGoogleChatOutboundAttachment(params)) {
throw new Error(
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.",
);
}
}
const account = resolveGoogleChatAccount({
cfg,
accountId,
@@ -109,66 +81,15 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
throw new Error("Google Chat credentials are missing.");
}
if (action === "send" || action === "upload-file") {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content =
readStringParam(params, "message", {
required: action === "send",
allowEmpty: true,
}) ??
readStringParam(params, "initialComment", {
allowEmpty: true,
}) ??
"";
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "filePath", { trim: false }) ??
readStringParam(params, "path", { trim: false });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
if (mediaUrl) {
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = await loadGoogleChatActionMedia({
mediaUrl,
maxBytes,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
const uploadFileName =
readStringParam(params, "filename") ??
readStringParam(params, "title") ??
loaded.fileName ??
"attachment";
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: uploadFileName,
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const sent = await sendGoogleChatMessage({
account,
space,
text: content,
thread: threadId ?? undefined,
attachments: upload.attachmentUploadToken
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: uploadFileName,
},
]
: undefined,
});
return jsonResult({ ok: true, to: space, ...sent });
}
if (action === "upload-file") {
throw new Error("upload-file requires media, filePath, or path");
}
const sent = await sendGoogleChatMessage({
account,
space,
@@ -178,51 +99,6 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
return jsonResult({ ok: true, to: space, ...sent });
}
if (action === "react") {
const messageName = readStringParam(params, "messageId", { required: true });
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
});
if (remove || isEmpty) {
const reactions = await listGoogleChatReactions({ account, messageName });
const appUsers = resolveAppUserNames(account);
const toRemove = reactions.filter((reaction) => {
const userName = reaction.user?.name?.trim();
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) {
return false;
}
if (emoji) {
return reaction.emoji?.unicode === emoji;
}
return true;
});
for (const reaction of toRemove) {
if (!reaction.name) {
continue;
}
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
}
return jsonResult({ ok: true, removed: toRemove.length });
}
const reaction = await createGoogleChatReaction({
account,
messageName,
emoji,
});
return jsonResult({ ok: true, reaction });
}
if (action === "reactions") {
const messageName = readStringParam(params, "messageId", { required: true });
const limit = readPositiveIntegerParam(params, "limit");
const reactions = await listGoogleChatReactions({
account,
messageName,
limit: limit ?? undefined,
});
return jsonResult({ ok: true, reactions });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
+2 -98
View File
@@ -1,5 +1,4 @@
// Googlechat API module exposes the plugin public contract.
import crypto from "node:crypto";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
parseMediaContentLength,
@@ -10,10 +9,9 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { shouldSuppressGoogleChatManualExecApprovalFollowupText } from "./approval-card-actions.js";
import { getGoogleChatAccessToken } from "./auth.js";
import type { GoogleChatCardV2, GoogleChatReaction, GoogleChatSpace } from "./types.js";
import type { GoogleChatCardV2, GoogleChatSpace } from "./types.js";
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
const GOOGLECHAT_API_TIMEOUT_MS = 30_000;
const GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS = 30_000;
const GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND = 256 * 1024;
@@ -184,13 +182,11 @@ export async function sendGoogleChatMessage(params: {
text?: string;
thread?: string;
cardsV2?: GoogleChatCardV2[];
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
}): Promise<{ messageName?: string; threadName?: string } | null> {
const { account, space, text, thread, cardsV2, attachments } = params;
const { account, space, text, thread, cardsV2 } = params;
if (
text &&
(!cardsV2 || cardsV2.length === 0) &&
(!attachments || attachments.length === 0) &&
shouldSuppressGoogleChatManualExecApprovalFollowupText(text)
) {
return null;
@@ -205,14 +201,6 @@ export async function sendGoogleChatMessage(params: {
if (thread) {
body.thread = { name: thread };
}
if (attachments && attachments.length > 0) {
body.attachment = attachments.map((item) =>
Object.assign(
{ attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken } },
item.contentName ? { contentName: item.contentName } : {},
),
);
}
const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`);
if (thread) {
urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
@@ -263,52 +251,6 @@ export async function deleteGoogleChatMessage(params: {
await fetchOk(account, url, { method: "DELETE" });
}
export async function uploadGoogleChatAttachment(params: {
account: ResolvedGoogleChatAccount;
space: string;
filename: string;
buffer: Buffer;
contentType?: string;
}): Promise<{ attachmentUploadToken?: string }> {
const { account, space, filename, buffer, contentType } = params;
const boundary = `openclaw-${crypto.randomUUID()}`;
const metadata = JSON.stringify({ filename });
const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`;
const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([
Buffer.from(header, "utf8"),
Buffer.from(mediaHeader, "utf8"),
buffer,
Buffer.from(footer, "utf8"),
]);
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
const payload = await withGoogleChatResponse<{
attachmentDataRef?: { attachmentUploadToken?: string };
}>({
account,
url,
init: {
method: "POST",
headers: {
"Content-Type": `multipart/related; boundary=${boundary}`,
},
body,
},
auditContext: "googlechat.upload",
errorPrefix: "Google Chat upload",
timeoutMs: resolveGoogleChatMediaTimeoutMs(body.length),
handleResponse: async (response) =>
await readGoogleChatJsonResponse<{
attachmentDataRef?: { attachmentUploadToken?: string };
}>(response, "Google Chat upload failed"),
});
return {
attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken,
};
}
export async function downloadGoogleChatMedia(params: {
account: ResolvedGoogleChatAccount;
resourceName: string;
@@ -319,44 +261,6 @@ export async function downloadGoogleChatMedia(params: {
return await fetchBuffer(account, url, undefined, { maxBytes });
}
export async function createGoogleChatReaction(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
emoji: string;
}): Promise<GoogleChatReaction> {
const { account, messageName, emoji } = params;
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
return await fetchJson<GoogleChatReaction>(account, url, {
method: "POST",
body: JSON.stringify({ emoji: { unicode: emoji } }),
});
}
export async function listGoogleChatReactions(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
limit?: number;
}): Promise<GoogleChatReaction[]> {
const { account, messageName, limit } = params;
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
if (limit && limit > 0) {
url.searchParams.set("pageSize", String(limit));
}
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
method: "GET",
});
return result.reactions ?? [];
}
export async function deleteGoogleChatReaction(params: {
account: ResolvedGoogleChatAccount;
reactionName: string;
}): Promise<void> {
const { account, reactionName } = params;
const url = `${CHAT_API_BASE}/${reactionName}`;
await fetchOk(account, url, { method: "DELETE" });
}
export async function findGoogleChatDirectMessage(params: {
account: ResolvedGoogleChatAccount;
userName: string;
+2 -1
View File
@@ -96,8 +96,9 @@ export function createGoogleChatPluginBase(
setupWizard: googlechatSetupWizard,
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
// Inbound attachment download remains supported even though service-account
// authentication cannot use Google Chat's user-auth-only upload endpoint.
media: true,
nativeCommands: false,
blockStreaming: true,
@@ -21,6 +21,25 @@ describe("googlechatPlugin config adapter", () => {
expect(googlechatSetupPlugin.capabilities?.chatTypes).toEqual(
googlechatPlugin.capabilities?.chatTypes,
);
expect(googlechatPlugin.capabilities?.media).toBe(true);
expect(googlechatPlugin.capabilities?.reactions).toBeUndefined();
});
it("does not advertise user-auth-only actions", () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: { client_email: "bot@example.com" },
actions: { reactions: true },
},
},
} as OpenClawConfig;
expect(googlechatPlugin.actions?.describeMessageTool?.({ cfg })).toEqual({
actions: ["send"],
});
expect(googlechatPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
expect(googlechatPlugin.actions?.supportsAction?.({ action: "upload-file" })).toBe(false);
});
it("registers an exec-capable native approval runtime", () => {
@@ -20,7 +20,6 @@ import {
listResolvedDirectoryUserEntriesFromAllowFrom,
} from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
@@ -29,13 +28,10 @@ import { formatGoogleChatAllowFromEntry } from "./channel-base.js";
import {
type ResolvedGoogleChatAccount,
chunkTextForOutbound,
readRemoteMediaBuffer,
isGoogleChatUserTarget,
loadOutboundMediaFromUrl,
missingTargetError,
normalizeGoogleChatTarget,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatAccount,
resolveGoogleChatOutboundSpace,
type OpenClawConfig,
@@ -260,92 +256,6 @@ export const googlechatOutboundAdapter = {
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }),
};
},
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
accountId,
replyToId,
threadId,
}: {
cfg: OpenClawConfig;
to: string;
text?: string;
mediaUrl?: string;
mediaAccess?: OutboundMediaLoadOptions["mediaAccess"];
mediaLocalRoots?: OutboundMediaLoadOptions["mediaLocalRoots"];
mediaReadFile?: OutboundMediaLoadOptions["mediaReadFile"];
accountId?: string | null;
replyToId?: string | null;
threadId?: string | number | null;
}) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
const account = resolveGoogleChatAccount({
cfg,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread =
typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined);
const maxBytes = resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: ({ cfg: cfgLocal, accountId: accountIdLocal }) =>
(
cfgLocal.channels?.googlechat as
| { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number }
| undefined
)?.accounts?.[accountIdLocal]?.mediaMaxMb ??
(cfgLocal.channels?.googlechat as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = /^https?:\/\//i.test(mediaUrl)
? await readRemoteMediaBuffer({
url: mediaUrl,
maxBytes: effectiveMaxBytes,
})
: await loadOutboundMediaFromUrl(mediaUrl, {
maxBytes: effectiveMaxBytes,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
await loadGoogleChatChannelRuntime();
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.fileName ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
attachments: upload.attachmentUploadToken
? [
{
attachmentUploadToken: upload.attachmentUploadToken,
contentName: loaded.fileName,
},
]
: undefined,
});
const messageId = result?.messageName ?? "";
return {
messageId,
chatId: space,
receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "media" }),
};
},
},
};
@@ -354,13 +264,11 @@ export const googlechatMessageAdapter = defineChannelMessageAdapter({
durableFinal: {
capabilities: {
text: true,
media: true,
thread: true,
messageSendingHooks: true,
},
},
send: {
text: googlechatOutboundAdapter.attachedResults.sendText,
media: googlechatOutboundAdapter.attachedResults.sendMedia,
},
});
@@ -3,14 +3,10 @@ export {
buildChannelConfigSchema,
chunkTextForOutbound,
DEFAULT_ACCOUNT_ID,
readRemoteMediaBuffer,
GoogleChatConfigSchema,
loadOutboundMediaFromUrl,
missingTargetError,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelStatusIssue,
type OpenClawConfig,
} from "../runtime-api.js";
@@ -2,7 +2,6 @@
import {
probeGoogleChat as probeGoogleChatImpl,
sendGoogleChatMessage as sendGoogleChatMessageImpl,
uploadGoogleChatAttachment as uploadGoogleChatAttachmentImpl,
} from "./api.js";
import {
resolveGoogleChatWebhookPath as resolveGoogleChatWebhookPathImpl,
@@ -12,7 +11,6 @@ import {
export const googleChatChannelRuntime = {
probeGoogleChat: probeGoogleChatImpl,
sendGoogleChatMessage: sendGoogleChatMessageImpl,
uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl,
resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl,
startGoogleChatMonitor: startGoogleChatMonitorImpl,
};
+3 -276
View File
@@ -15,12 +15,9 @@ import {
googlechatThreadingAdapter,
} from "./channel.adapters.js";
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn());
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
const probeGoogleChatMock = vi.hoisted(() => vi.fn());
const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn());
@@ -77,19 +74,6 @@ function mockGoogleChatOutboundSpaceResolution() {
});
}
function mockGoogleChatMediaLoaders() {
loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
buffer: Buffer.from("default-bytes"),
fileName: mediaUrl.split("/").pop() || "attachment",
contentType: "application/octet-stream",
}));
readRemoteMediaBufferMock.mockImplementation(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
}
vi.mock("./channel.runtime.js", () => {
return {
googleChatChannelRuntime: {
@@ -97,7 +81,6 @@ vi.mock("./channel.runtime.js", () => {
resolveGoogleChatWebhookPath: () => "/googlechat/webhook",
sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args),
startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args),
uploadGoogleChatAttachment: (...args: unknown[]) => uploadGoogleChatAttachmentMock(...args),
},
};
});
@@ -128,7 +111,6 @@ vi.mock("./channel.deps.runtime.js", () => {
return chunks;
},
createAccountStatusSink: () => () => {},
readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBufferMock(...args),
getChatChannelMeta: (id: string) => ({ id, name: id }),
isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"),
isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"),
@@ -136,25 +118,10 @@ vi.mock("./channel.deps.runtime.js", () => {
const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {});
return ids.length > 0 ? ids : ["default"];
},
loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args),
missingTargetError: (channel: string, hint: string) =>
new Error(`${channel} target is required (${hint})`),
normalizeGoogleChatTarget,
PAIRING_APPROVED_MESSAGE: "approved",
resolveChannelMediaMaxBytes: (params: {
cfg: OpenClawConfig;
resolveChannelLimitMb: (args: {
cfg: OpenClawConfig;
accountId?: string;
}) => number | undefined;
accountId?: string;
}) => {
const limitMb = params.resolveChannelLimitMb({
cfg: params.cfg,
accountId: params.accountId,
});
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
},
resolveDefaultGoogleChatAccountId: () => "default",
resolveGoogleChatAccount: (...args: Parameters<typeof resolveGoogleChatAccountImpl>) =>
resolveGoogleChatAccountMock(...args),
@@ -167,13 +134,11 @@ vi.mock("./channel.deps.runtime.js", () => {
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
mockGoogleChatOutboundSpaceResolution();
mockGoogleChatMediaLoaders();
afterEach(() => {
vi.clearAllMocks();
resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl);
mockGoogleChatOutboundSpaceResolution();
mockGoogleChatMediaLoaders();
});
afterAll(() => {
@@ -198,24 +163,6 @@ function createGoogleChatCfg(): OpenClawConfig {
};
}
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
const loadOutboundMediaFromUrl = vi.fn(async () => ({
buffer: Buffer.from(params.loadBytes),
fileName: params.loadFileName,
contentType: "image/png",
}));
const readRemoteMediaBuffer = vi.fn(async () => ({
buffer: Buffer.from("remote-bytes"),
fileName: "remote.png",
contentType: "image/png",
}));
loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl);
readRemoteMediaBufferMock.mockImplementation(readRemoteMediaBuffer);
return { loadOutboundMediaFromUrl, readRemoteMediaBuffer };
}
function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex = 0): unknown {
const call = mock.mock.calls[callIndex];
if (!call) {
@@ -224,22 +171,11 @@ function requireMockArg(mock: ReturnType<typeof vi.fn>, callIndex = 0, argIndex
return call[argIndex];
}
function requireMockArgs(mock: ReturnType<typeof vi.fn>, callIndex = 0): unknown[] {
const call = mock.mock.calls[callIndex];
if (!call) {
throw new Error(`expected mock call ${callIndex}`);
}
return call;
}
describe("googlechatPlugin outbound sendMedia", () => {
it("declares message adapter durable text, media, and thread with receipt proofs", async () => {
describe("googlechatPlugin outbound", () => {
it("declares durable text and thread delivery with receipt proofs", async () => {
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
});
const cfg = createGoogleChatCfg();
@@ -256,16 +192,6 @@ describe("googlechatPlugin outbound sendMedia", () => {
expect(result?.receipt.parts[0]?.kind).toBe("text");
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
},
media: async () => {
const result = await googlechatMessageAdapter.send?.media?.({
cfg,
to: "spaces/AAA",
text: "image",
mediaUrl: "https://example.com/img.png",
});
expect(result?.receipt.parts[0]?.kind).toBe("media");
expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]);
},
thread: async () => {
sendGoogleChatMessageMock.mockClear();
await googlechatMessageAdapter.send?.text?.({
@@ -288,7 +214,7 @@ describe("googlechatPlugin outbound sendMedia", () => {
});
expect(proofs).toStrictEqual([
{ capability: "text", status: "verified" },
{ capability: "media", status: "verified" },
{ capability: "media", status: "not_declared" },
{ capability: "poll", status: "not_declared" },
{ capability: "payload", status: "not_declared" },
{ capability: "silent", status: "not_declared" },
@@ -308,105 +234,6 @@ describe("googlechatPlugin outbound sendMedia", () => {
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
});
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "image.png",
loadBytes: "image-bytes",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-1",
});
const cfg = createGoogleChatCfg();
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
cfg,
to: "spaces/AAA",
text: "caption",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
});
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
string,
{ mediaLocalRoots?: string[] },
];
expect(mediaUrl).toBe("/tmp/workspace/image.png");
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
expect(readRemoteMediaBuffer).not.toHaveBeenCalled();
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
space?: string;
filename?: string;
contentType?: string;
};
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("image.png");
expect(uploadRequest.contentType).toBe("image/png");
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
text?: string;
};
expect(sendRequest.space).toBe("spaces/AAA");
expect(sendRequest.text).toBe("caption");
expect(result.messageId).toBe("spaces/AAA/messages/msg-1");
expect(result.chatId).toBe("spaces/AAA");
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1");
});
it("keeps remote URL media fetch on readRemoteMediaBuffer with maxBytes cap", async () => {
const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "unused.png",
loadBytes: "should-not-be-used",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-2",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-2",
});
const cfg = createGoogleChatCfg();
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
cfg,
to: "spaces/AAA",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
});
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
url?: string;
maxBytes?: number;
};
expect(remoteRequest.url).toBe("https://example.com/image.png");
expect(remoteRequest.maxBytes).toBe(20 * 1024 * 1024);
expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled();
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
space?: string;
filename?: string;
contentType?: string;
};
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("remote.png");
expect(uploadRequest.contentType).toBe("image/png");
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
space?: string;
text?: string;
};
expect(sendRequest.space).toBe("spaces/AAA");
expect(sendRequest.text).toBe("caption");
expect(result.messageId).toBe("spaces/AAA/messages/msg-2");
expect(result.chatId).toBe("spaces/AAA");
expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-2");
});
});
describe("googlechatPlugin threading", () => {
@@ -643,106 +470,6 @@ describe("googlechatPlugin outbound cfg threading", () => {
expect(request.space).toBe("spaces/AAA");
expect(request.text).toBe("hello");
});
it("threads resolved cfg into sendMedia account and media loading path", async () => {
const cfg = {
channels: {
googlechat: {
serviceAccount: {
type: "service_account",
},
mediaMaxMb: 8,
},
},
};
const account = {
accountId: "default",
config: { mediaMaxMb: 20 },
credentialSource: "inline" as const,
};
const { readRemoteMediaBuffer } = setupRuntimeMediaMocks({
loadFileName: "unused.png",
loadBytes: "should-not-be-used",
});
resolveGoogleChatAccountMock.mockReturnValue(account);
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-1",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-2",
});
await googlechatOutboundAdapter.attachedResults.sendMedia({
cfg: cfg as never,
to: "users/123",
text: "photo",
mediaUrl: "https://example.com/file.png",
accountId: "default",
});
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
cfg,
accountId: "default",
});
const remoteRequest = requireMockArg(readRemoteMediaBuffer) as {
url?: string;
maxBytes?: number;
};
expect(remoteRequest.url).toBe("https://example.com/file.png");
expect(remoteRequest.maxBytes).toBe(8 * 1024 * 1024);
const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as {
account?: unknown;
space?: string;
filename?: string;
};
expect(uploadRequest.account).toBe(account);
expect(uploadRequest.space).toBe("spaces/AAA");
expect(uploadRequest.filename).toBe("remote.png");
const sendRequest = requireMockArg(sendGoogleChatMessageMock) as {
account?: unknown;
attachments?: Array<{ attachmentUploadToken: string; contentName: string }>;
};
expect(sendRequest.account).toBe(account);
expect(sendRequest.attachments).toEqual([
{ attachmentUploadToken: "token-1", contentName: "remote.png" },
]);
});
it("sends media without requiring Google Chat runtime initialization", async () => {
const { loadOutboundMediaFromUrl } = setupRuntimeMediaMocks({
loadFileName: "image.png",
loadBytes: "image-bytes",
});
uploadGoogleChatAttachmentMock.mockResolvedValue({
attachmentUploadToken: "token-cold",
});
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/msg-cold",
});
const cfg = createGoogleChatCfg();
const result = await googlechatOutboundAdapter.attachedResults.sendMedia({
cfg,
to: "spaces/AAA",
text: "caption",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
});
expect(result.messageId).toBe("spaces/AAA/messages/msg-cold");
expect(result.chatId).toBe("spaces/AAA");
const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [
string,
{ mediaLocalRoots?: string[] },
];
expect(mediaUrl).toBe("/tmp/workspace/image.png");
expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]);
});
});
describe("googlechat directory", () => {
+2 -7
View File
@@ -1,5 +1,4 @@
// Googlechat plugin module implements channel behavior.
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
@@ -61,13 +60,9 @@ const googlechatActions: ChannelMessageActionAdapter = {
if (accounts.length === 0) {
return null;
}
const actions = new Set<ChannelMessageActionName>(["send", "upload-file"]);
if (accounts.some((account) => account.config.actions?.reactions !== false)) {
actions.add("react");
actions.add("reactions");
}
return { actions: Array.from(actions) };
return { actions: ["send"] };
},
supportsAction: ({ action }) => action === "send",
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async (ctx) => {
const { googlechatMessageActions } = await import("./actions.js");
+3 -3
View File
@@ -49,7 +49,7 @@ function normalizeGoogleChatStableEntry(entry: string): string | null {
return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider;
}
function normalizeGoogleChatEmailEntry(entry: string): string | null {
export function normalizeGoogleChatEmailEntry(entry: string): string | null {
const withoutProvider = normalizeEntryValue(entry).replace(
/^(googlechat|google-chat|gchat):/i,
"",
@@ -89,7 +89,7 @@ type GoogleChatGroupEntry = {
systemPrompt?: string;
};
function resolveGroupConfig(params: {
export function resolveGoogleChatGroupConfig(params: {
groupId: string;
groupName?: string | null;
groups?: Record<string, GoogleChatGroupEntry>;
@@ -249,7 +249,7 @@ export async function applyGoogleChatInboundAccessPolicy(params: {
log: logVerbose,
});
warnMutableGroupKeysConfigured(logVerbose, account.config.groups ?? undefined);
const groupConfigResolved = resolveGroupConfig({
const groupConfigResolved = resolveGoogleChatGroupConfig({
groupId: spaceId,
groupName: space.displayName ?? null,
groups: account.config.groups ?? undefined,
@@ -1,16 +1,8 @@
// Googlechat plugin module implements monitor reply delivery behavior.
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import type { OpenClawConfig } from "../runtime-api.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import {
deleteGoogleChatMessage,
sendGoogleChatMessage,
updateGoogleChatMessage,
uploadGoogleChatAttachment,
} from "./api.js";
import { deleteGoogleChatMessage, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
export async function deliverGoogleChatReply(params: {
@@ -33,40 +25,26 @@ export async function deliverGoogleChatReply(params: {
// text delivery can keep retrying a dead message and drop content.
let typingMessageName = params.typingMessageName;
const reply = resolveSendableOutboundReplyParts(payload);
const mediaCount = reply.mediaCount;
const hasMedia = reply.hasMedia;
const text = reply.text;
let firstTextChunk = true;
let suppressCaption = false;
if (hasMedia && typingMessageName) {
if (reply.hasMedia) {
runtime.error?.(
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel; sending text fallback only.",
);
}
if (reply.hasMedia && !reply.hasText) {
try {
await deleteGoogleChatMessage({
account,
messageName: typingMessageName,
});
typingMessageName = undefined;
if (typingMessageName) {
await deleteGoogleChatMessage({ account, messageName: typingMessageName });
}
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
if (typingMessageName) {
const fallbackText = reply.hasText
? text
: mediaCount > 1
? "Sent attachments."
: "Sent attachment.";
try {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: fallbackText,
});
suppressCaption = Boolean(text.trim());
} catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
typingMessageName = undefined;
}
}
}
throw new Error(
"Google Chat outbound attachments require user OAuth and no text fallback is available.",
);
}
const chunkLimit = account.config.textChunkLimit ?? 4000;
@@ -79,84 +57,36 @@ export async function deliverGoogleChatReply(params: {
thread: payload.replyToId,
});
};
await deliverTextOrMediaReply({
payload,
text: suppressCaption ? "" : reply.text,
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode),
sendText: async (chunk) => {
try {
if (firstTextChunk && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
for (const chunk of chunks) {
if (!chunk) {
continue;
}
try {
if (firstTextChunk && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendTextMessage(chunk);
}
firstTextChunk = false;
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
if (firstTextChunk && typingMessageName) {
typingMessageName = undefined;
try {
await sendTextMessage(chunk);
}
firstTextChunk = false;
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
if (firstTextChunk && typingMessageName) {
typingMessageName = undefined;
try {
await sendTextMessage(chunk);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (fallbackErr) {
runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`);
} finally {
firstTextChunk = false;
}
statusSink?.({ lastOutboundAt: Date.now() });
} catch (fallbackErr) {
runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`);
} finally {
firstTextChunk = false;
}
}
},
sendMedia: async ({ mediaUrl, caption }) => {
try {
const loaded = await core.channel.media.readRemoteMediaBuffer({
url: mediaUrl,
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
});
const upload = await uploadAttachmentForReply({
account,
spaceId,
buffer: loaded.buffer,
contentType: loaded.contentType,
filename: loaded.fileName ?? "attachment",
});
if (!upload.attachmentUploadToken) {
throw new Error("missing attachment upload token");
}
await sendGoogleChatMessage({
account,
space: spaceId,
text: caption,
thread: payload.replyToId,
attachments: [
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName },
],
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
}
},
});
}
async function uploadAttachmentForReply(params: {
account: ResolvedGoogleChatAccount;
spaceId: string;
buffer: Buffer;
contentType?: string;
filename: string;
}) {
const { account, spaceId, buffer, contentType, filename } = params;
return await uploadGoogleChatAttachment({
account,
space: spaceId,
filename,
buffer,
contentType,
});
}
}
}
@@ -8,14 +8,12 @@ const mocks = vi.hoisted(() => ({
deleteGoogleChatMessage: vi.fn(),
sendGoogleChatMessage: vi.fn(),
updateGoogleChatMessage: vi.fn(),
uploadGoogleChatAttachment: vi.fn(),
}));
vi.mock("./api.js", () => ({
deleteGoogleChatMessage: mocks.deleteGoogleChatMessage,
sendGoogleChatMessage: mocks.sendGoogleChatMessage,
updateGoogleChatMessage: mocks.updateGoogleChatMessage,
uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment,
}));
const account = {
@@ -106,14 +104,11 @@ describe("Google Chat reply delivery", () => {
);
});
it("does not update a deleted typing message before sending media with a caption", async () => {
it("uses text fallback without loading outbound media", async () => {
const core = createCore({
media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" },
});
const runtime = createRuntime();
mocks.deleteGoogleChatMessage.mockResolvedValue(undefined);
mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" });
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" });
await deliverGoogleChatReply({
payload: {
@@ -129,17 +124,46 @@ describe("Google Chat reply delivery", () => {
typingMessageName: "spaces/AAA/messages/typing",
});
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
account,
messageName: "spaces/AAA/messages/typing",
text: "caption",
});
expect(core.channel.media.readRemoteMediaBuffer).not.toHaveBeenCalled();
expect(mocks.deleteGoogleChatMessage).not.toHaveBeenCalled();
expect(mocks.sendGoogleChatMessage).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith(
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel; sending text fallback only.",
);
});
it("cleans up typing and rejects media-only replies without provider upload access", async () => {
const core = createCore();
const runtime = createRuntime();
await expect(
deliverGoogleChatReply({
payload: {
mediaUrl: "https://example.invalid/reply.png",
replyToId: "spaces/AAA/threads/root",
},
account,
spaceId: "spaces/AAA",
runtime,
core,
config,
typingMessageName: "spaces/AAA/messages/typing",
}),
).rejects.toThrow(
"Google Chat outbound attachments require user OAuth and no text fallback is available.",
);
expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({
account,
messageName: "spaces/AAA/messages/typing",
});
expect(core.channel.media.readRemoteMediaBuffer).not.toHaveBeenCalled();
expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled();
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({
account,
space: "spaces/AAA",
text: "caption",
thread: "spaces/AAA/threads/root",
attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }],
});
expect(mocks.sendGoogleChatMessage).not.toHaveBeenCalled();
});
});
+12 -18
View File
@@ -1,12 +1,7 @@
// Googlechat tests cover targets plugin behavior.
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import {
downloadGoogleChatMedia,
sendGoogleChatMessage,
updateGoogleChatMessage,
uploadGoogleChatAttachment,
} from "./api.js";
import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
import {
clearGoogleChatApprovalCardBindingsForTest,
registerGoogleChatManualApprovalFollowupSuppression,
@@ -365,7 +360,7 @@ describe("downloadGoogleChatMedia", () => {
});
});
describe("uploadGoogleChatAttachment", () => {
describe("supported Google Chat request bounds", () => {
afterEach(() => {
authTesting.resetGoogleChatAuthForTests();
mocks.fetchWithSsrFGuard.mockClear();
@@ -377,34 +372,33 @@ describe("uploadGoogleChatAttachment", () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ attachmentDataRef: { attachmentUploadToken: "token" } }), {
new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
),
);
await uploadGoogleChatAttachment({
await downloadGoogleChatMedia({
account,
space: "spaces/AAA",
filename: "recording.wav",
buffer: Buffer.alloc(1024 * 1024),
resourceName: "media/123",
maxBytes: 1024 * 1024,
});
expect(lastGuardedFetchOptions().timeoutMs).toBeGreaterThan(34_000);
expect(lastGuardedFetchOptions().timeoutMs).toBe(34_000);
});
it("cancels a stalled upload response body", async () => {
it("cancels a stalled JSON response body", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse()));
const result = expect(
uploadGoogleChatAttachment({
sendGoogleChatMessage({
account,
space: "spaces/AAA",
filename: "recording.wav",
buffer: Buffer.alloc(1024),
text: "hello",
}),
).rejects.toThrow("Google Chat upload failed: response body stalled after 30000ms");
).rejects.toThrow("Google Chat API request failed: response body stalled after 30000ms");
await vi.advanceTimersByTimeAsync(30_001);
await result;
});
-6
View File
@@ -91,12 +91,6 @@ export type GoogleChatEvent = {
};
};
export type GoogleChatReaction = {
name?: string;
user?: GoogleChatUser;
emoji?: { unicode?: string };
};
type GoogleChatTextParagraphWidget = {
textParagraph: {
text: string;
+29
View File
@@ -116,6 +116,35 @@ describe("imessage message actions", () => {
loggerMock.warn.mockReset();
});
it.each([
"react",
"edit",
"unsend",
"renameGroup",
"setGroupIcon",
"addParticipant",
"removeParticipant",
"leaveGroup",
] as const)("resolves %s chat aliases to the canonical delivery target", (action) => {
const aliasSpec = imessageMessageActions.messageActionTargetAliases?.[action];
expect(aliasSpec?.deliveryTargetAliases).toStrictEqual([
"chatGuid",
"chatIdentifier",
"chatId",
]);
if (action === "react") {
expect(aliasSpec?.aliases).toContain("messageId");
}
expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatGuid: "iMessage;+;chat0000" } })).toBe(
"chat_guid:iMessage;+;chat0000",
);
expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatIdentifier: "team-thread" } })).toBe(
"chat_identifier:team-thread",
);
expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatId: 42 } })).toBe("chat_id:42");
});
it("does not advertise private API actions when the bridge is known unavailable", () => {
probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({
available: false,
+49 -38
View File
@@ -24,6 +24,7 @@ import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js";
import { describeIMessageMessageTool } from "./message-tool-api.js";
import {
findLatestIMessageEntryForChat,
isIMessageCurrentMessageInChat,
rememberIMessageReplyCache,
type IMessageChatContext,
} from "./monitor-reply-cache.js";
@@ -71,6 +72,40 @@ function resolveIMessageDeliveryTarget(args: Record<string, unknown>): string |
return targets[0];
}
const IMESSAGE_DELIVERY_TARGET_ALIASES = ["chatGuid", "chatIdentifier", "chatId"];
function matchesIMessageCurrentConversation(params: {
args: Record<string, unknown>;
accountId: string;
toolContext: {
currentMessageId?: string | number;
};
}): boolean {
const currentMessageId = params.toolContext.currentMessageId;
if (currentMessageId === undefined) {
return false;
}
return isIMessageCurrentMessageInChat({
accountId: params.accountId,
currentMessageId,
chatContext: {
chatGuid: readStringParam(params.args, "chatGuid"),
chatIdentifier: readStringParam(params.args, "chatIdentifier"),
chatId: readPositiveIntegerParam(params.args, "chatId"),
},
});
}
function createIMessageTargetAliases(resourceAliases: string[] = []) {
return {
aliases: [...IMESSAGE_DELIVERY_TARGET_ALIASES, ...resourceAliases],
deliveryTargetAliases: [...IMESSAGE_DELIVERY_TARGET_ALIASES],
resolveDeliveryTarget: ({ args }: { args: Record<string, unknown> }) =>
resolveIMessageDeliveryTarget(args),
matchesCurrentConversation: matchesIMessageCurrentConversation,
};
}
function rememberOutboundBridgeMessage(params: {
accountId: string;
messageId?: string;
@@ -418,44 +453,20 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" &&
GROUP_MANAGEMENT_ACTIONS.has(action),
messageActionTargetAliases: {
react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] },
reply: {
aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
sendWithEffect: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
sendAttachment: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
poll: {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
"poll-vote": {
aliases: ["chatGuid", "chatIdentifier", "chatId", "pollId", "messageId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
"upload-file": {
aliases: ["chatGuid", "chatIdentifier", "chatId"],
deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"],
resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args),
},
renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] },
react: createIMessageTargetAliases(["messageId"]),
edit: createIMessageTargetAliases(["messageId"]),
unsend: createIMessageTargetAliases(["messageId"]),
reply: createIMessageTargetAliases(["messageId"]),
sendWithEffect: createIMessageTargetAliases(),
sendAttachment: createIMessageTargetAliases(),
poll: createIMessageTargetAliases(),
"poll-vote": createIMessageTargetAliases(["pollId", "messageId"]),
"upload-file": createIMessageTargetAliases(),
renameGroup: createIMessageTargetAliases(),
setGroupIcon: createIMessageTargetAliases(),
addParticipant: createIMessageTargetAliases(),
removeParticipant: createIMessageTargetAliases(),
leaveGroup: createIMessageTargetAliases(),
},
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
handleAction: async ({
@@ -1,10 +1,11 @@
// Imessage tests cover monitor reply cache plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetIMessageShortIdState,
findLatestIMessageEntryForChat,
isIMessageCurrentMessageInChat,
isKnownFromMeIMessageMessageId,
rememberIMessageReplyCache,
resetIMessageShortIdState,
resolveIMessageMessageId,
} from "./monitor-reply-cache.js";
import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js";
@@ -383,6 +384,70 @@ describe("hydrate-on-resolve (post-restart short-id persistence)", () => {
});
});
describe("current-message chat binding", () => {
it.each([{ chatGuid: "any;-;+12069106512" }, { chatIdentifier: "+12069106512" }, { chatId: 42 }])(
"matches a trusted current message through $chatGuid$chatIdentifier$chatId",
(chatContext) => {
const entry = rememberIMessageReplyCache({
accountId: "work",
messageId: "current-guid",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 42,
timestamp: Date.now(),
});
expect(
isIMessageCurrentMessageInChat({
accountId: "work",
currentMessageId: entry.shortId,
chatContext,
}),
).toBe(true);
expect(
isIMessageCurrentMessageInChat({
accountId: "work",
currentMessageId: "current-guid",
chatContext,
}),
).toBe(true);
},
);
it("fails closed for wrong accounts, chats, and unknown current messages", () => {
rememberIMessageReplyCache({
accountId: "work",
messageId: "current-guid",
chatGuid: "any;-;+12069106512",
chatIdentifier: "+12069106512",
chatId: 42,
timestamp: Date.now(),
});
expect(
isIMessageCurrentMessageInChat({
accountId: "other",
currentMessageId: "current-guid",
chatContext: { chatId: 42 },
}),
).toBe(false);
expect(
isIMessageCurrentMessageInChat({
accountId: "work",
currentMessageId: "current-guid",
chatContext: { chatId: 99 },
}),
).toBe(false);
expect(
isIMessageCurrentMessageInChat({
accountId: "work",
currentMessageId: "unknown-guid",
chatContext: { chatId: 42 },
}),
).toBe(false);
});
});
describe("hydrate counter advancement (rowid-collision protection)", () => {
it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => {
// Direct hydrate isn't easy to invoke without disk fixtures; instead
@@ -538,6 +538,34 @@ function isPositiveChatMatch(entry: IMessageReplyCacheEntry, ctx: IMessageChatCo
return false;
}
export function isIMessageCurrentMessageInChat(params: {
accountId: string;
currentMessageId: string | number;
chatContext: IMessageChatContext;
}): boolean {
if (!params.accountId || !hasChatScope(params.chatContext)) {
return false;
}
const currentMessageId = normalizeOptionalString(String(params.currentMessageId));
if (!currentMessageId) {
return false;
}
hydrateFromStoreOnce();
const fullMessageId = /^\d+$/.test(currentMessageId)
? imessageShortIdToUuid.get(currentMessageId)
: currentMessageId;
if (!fullMessageId) {
return false;
}
const entry = imessageReplyCacheByMessageId.get(fullMessageId);
return Boolean(
entry &&
entry.accountId === params.accountId &&
Date.now() - entry.timestamp <= REPLY_CACHE_TTL_MS &&
isPositiveChatMatch(entry, params.chatContext),
);
}
export function resetIMessageShortIdState(options: { clearPersistent?: boolean } = {}): void {
imessageReplyCacheByMessageId.clear();
imessageShortIdToUuid.clear();
@@ -73,7 +73,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.action).toBe("sendMessage");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("forwards accountId for permissions actions", async () => {
@@ -91,7 +91,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.action).toBe("verificationList");
expect(call.input.accountId).toBe("ops");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("forwards accountId for self-profile updates", async () => {
@@ -113,7 +113,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.displayName).toBe("Ops Bot");
expect(call.input.avatarUrl).toBe("mxc://example/avatar");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("rejects self-profile updates without sender owner context", async () => {
@@ -167,7 +167,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.accountId).toBe("ops");
expect(call.input.avatarPath).toBe("/tmp/avatar.jpg");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("forwards mediaLocalRoots for media sends", async () => {
@@ -189,7 +189,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.accountId).toBe("ops");
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] });
expect(call.options).toMatchObject({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] });
});
it("allows media-only sends without requiring a message body", async () => {
@@ -210,7 +210,7 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.content).toBeUndefined();
expect(call.input.mediaUrl).toBe("file:///tmp/photo.png");
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("accepts shared media aliases and forwards voice-send intent", async () => {
@@ -233,6 +233,35 @@ describe("matrixMessageActions account propagation", () => {
expect(call.input.mediaUrl).toBe("/tmp/clip.mp3");
expect(call.input.audioAsVoice).toBe(true);
expect(call.cfg).toBeTypeOf("object");
expect(call.options).toEqual({ mediaLocalRoots: undefined });
expect(call.options).toMatchObject({ mediaLocalRoots: undefined });
});
it("forwards trusted conversation context for read authorization", async () => {
await matrixMessageActions.handleAction?.(
createContext({
action: "reactions",
accountId: "ops",
requesterAccountId: "ops",
params: {
roomId: "!dm:example.org",
messageId: "$event",
},
toolContext: {
currentChannelId: "room:!dm:example.org",
currentChannelProvider: "matrix",
currentChatType: "direct",
},
}),
);
expect(matrixActionCall().options).toMatchObject({
readContext: {
accountId: "ops",
requesterAccountId: "ops",
currentChannelId: "room:!dm:example.org",
currentChannelProvider: "matrix",
currentChatType: "direct",
},
});
});
});
+12 -2
View File
@@ -160,7 +160,17 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
...(accountId ? { accountId } : {}),
},
cfg as CoreConfig,
{ mediaLocalRoots },
{
mediaLocalRoots,
readContext: {
accountId,
requesterAccountId: ctx.requesterAccountId,
currentChannelId: ctx.toolContext?.currentChannelId,
currentChannelProvider: ctx.toolContext?.currentChannelProvider,
currentChatType: ctx.toolContext?.currentChatType,
conversationReadOrigin: ctx.conversationReadOrigin,
},
},
);
const resolveRoomId = () =>
readStringParam(params, "roomId") ??
@@ -297,7 +307,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
return await dispatch({
action: "memberInfo",
userId,
roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"),
roomId: resolveRoomId(),
});
}
@@ -16,10 +16,7 @@ function createRoomClient() {
throw new Error(`unexpected state event ${eventType}`);
}
});
const getJoinedRoomMembers = vi.fn(async () => [
{ user_id: "@alice:example.org" },
{ user_id: "@bot:example.org" },
]);
const getJoinedRoomMembers = vi.fn(async () => ["@alice:example.org", "@bot:example.org"]);
const getUserProfile = vi.fn(async () => ({
displayname: "Alice",
avatar_url: "mxc://example.org/alice",
@@ -56,7 +53,7 @@ describe("matrix room actions", () => {
});
});
it("resolves optional room ids when looking up member info", async () => {
it("requires room membership when looking up member info", async () => {
const { client, getUserProfile } = createRoomClient();
const result = await getMatrixMemberInfo("@alice:example.org", {
@@ -77,4 +74,16 @@ describe("matrix room actions", () => {
roomId: "!ops:example.org",
});
});
it("rejects profiles for users outside the room", async () => {
const { client, getUserProfile } = createRoomClient();
await expect(
getMatrixMemberInfo("@mallory:example.org", {
client,
roomId: "room:!ops:example.org",
}),
).rejects.toThrow("User @mallory:example.org is not a member of room !ops:example.org");
expect(getUserProfile).not.toHaveBeenCalled();
});
});
+7 -3
View File
@@ -5,10 +5,14 @@ import { EventType, type MatrixActionClientOpts } from "./types.js";
export async function getMatrixMemberInfo(
userId: string,
opts: MatrixActionClientOpts & { roomId?: string } = {},
opts: MatrixActionClientOpts & { roomId: string },
) {
return await withResolvedActionClient(opts, async (client) => {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
const roomId = await resolveMatrixRoomId(client, opts.roomId);
const members = await client.getJoinedRoomMembers(roomId);
if (!members.includes(userId)) {
throw new Error(`User ${userId} is not a member of room ${roomId}`);
}
const profile = await client.getUserProfile(userId);
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
@@ -20,7 +24,7 @@ export async function getMatrixMemberInfo(
membership: null, // Would need separate room state query
powerLevel: null, // Would need separate power levels state query
displayName: profile?.displayname ?? null,
roomId: roomId ?? null,
roomId,
};
});
}
@@ -541,6 +541,47 @@ describe("Matrix auth/config live surfaces", () => {
).toThrow(/Matrix account "typo" is not configured/i);
});
it("rejects invalid explicit account ids instead of borrowing the default account", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
},
},
} as CoreConfig;
expect(() =>
resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "!!!" }),
).toThrow(/Matrix account id "!!!" is invalid/i);
});
it("rejects explicitly selected disabled accounts instead of borrowing another account", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://legacy.example.org",
accessToken: "legacy-token",
accounts: {
disabled: {
enabled: false,
homeserver: "https://disabled.example.org",
accessToken: "disabled-token",
},
},
},
},
} as CoreConfig;
expect(() =>
resolveMatrixAuthContext({
cfg,
env: {} as NodeJS.ProcessEnv,
accountId: "disabled",
}),
).toThrow(/Matrix account "disabled" is disabled/i);
});
it("allows explicit non-default account ids backed only by scoped env vars", () => {
const cfg = {
channels: {
@@ -540,7 +540,11 @@ export function resolveMatrixAuthContext(params: {
} {
const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig;
const env = params?.env ?? process.env;
const requestedAccountId = params?.accountId?.trim();
const explicitAccountId = normalizeOptionalAccountId(params?.accountId);
if (requestedAccountId && !explicitAccountId) {
throw new Error(`Matrix account id "${requestedAccountId}" is invalid.`);
}
const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env);
if (!effectiveAccountId) {
throw new Error(
@@ -557,6 +561,11 @@ export function resolveMatrixAuthContext(params: {
`Matrix account "${explicitAccountId}" is not configured. Add channels.matrix.accounts.${explicitAccountId} or define scoped ${getMatrixScopedEnvVarNames(explicitAccountId).accessToken.replace(/_ACCESS_TOKEN$/, "")}_* variables.`,
);
}
const matrix = resolveMatrixBaseConfig(cfg);
const account = findMatrixAccountConfig(cfg, effectiveAccountId);
if (matrix.enabled === false || account?.enabled === false) {
throw new Error(`Matrix account "${effectiveAccountId}" is disabled.`);
}
const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env);
return {
@@ -121,6 +121,7 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) {
};
return {
getRoomAliases,
getRoomInfo,
getMemberDisplayName,
};
@@ -0,0 +1,611 @@
import { describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { withAuthorizedMatrixReadTarget } from "./read-policy.js";
import type { MatrixClient } from "./sdk.js";
function createClient(
members: string[],
directFlag: boolean | null = null,
aliases: { canonicalAlias?: string; altAliases?: string[] } = {},
roomName?: string,
overrides: Partial<MatrixClient> = {},
): MatrixClient {
return {
dms: {
update: vi.fn(async () => false),
isDm: vi.fn(() => false),
},
getJoinedRoomMembers: vi.fn(async () => members),
getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => {
if (eventType === "m.room.canonical_alias") {
return { alias: aliases.canonicalAlias, alt_aliases: aliases.altAliases };
}
if (eventType === "m.room.name") {
return roomName ? { name: roomName } : {};
}
return directFlag === null ? {} : { is_direct: directFlag };
}),
getUserId: vi.fn(async () => "@bot:example.org"),
stop: vi.fn(),
...overrides,
} as unknown as MatrixClient;
}
describe("Matrix read policy", () => {
it("allows configured rooms and rejects other rooms before the read", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
const cfg = {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"!allowed:example.org": {},
},
},
},
} as CoreConfig;
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg,
roomId: "!allowed:example.org",
opts: { client },
run: read,
}),
).resolves.toBe("ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg,
roomId: "!blocked:example.org",
opts: { client },
run: read,
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(read).toHaveBeenCalledTimes(1);
});
it("authorizes direct rooms by their remote member", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org"], true);
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
dm: {
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
},
},
} as CoreConfig,
roomId: "!dm:example.org",
opts: { client },
run: read,
}),
).resolves.toBe("ok");
});
it("keeps a restrictive DM allowlist effective under open policy", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org"], true);
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
dm: {
policy: "open",
allowFrom: ["@bob:example.org"],
},
},
},
} as CoreConfig,
roomId: "!dm:example.org",
opts: { client },
run: read,
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(read).not.toHaveBeenCalled();
});
it("allows wildcard DM reads under any non-disabled policy", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org"], true);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
dm: {
policy: "pairing",
allowFrom: ["matrix:*"],
},
},
},
} as CoreConfig,
roomId: "!dm:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it("does not guess that an unmarked two-member room is a DM", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org"]);
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
dm: { policy: "allowlist", allowFrom: [] },
},
},
} as CoreConfig,
roomId: "!ambiguous:example.org",
opts: { client },
run: read,
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(read).not.toHaveBeenCalled();
});
it.each([
{
name: "member lookup fails",
overrides: {
getJoinedRoomMembers: vi.fn(async () => {
throw new Error("members unavailable");
}),
},
},
{
name: "self lookup fails",
overrides: {
getUserId: vi.fn(async () => {
throw new Error("whoami unavailable");
}),
},
},
])("fails closed when $name", async ({ overrides }) => {
const client = createClient(
["@bot:example.org", "@alice:example.org"],
true,
{},
undefined,
overrides,
);
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
dm: { policy: "disabled" },
},
},
} as CoreConfig,
roomId: "!unknown:example.org",
opts: { client },
run: read,
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(read).not.toHaveBeenCalled();
});
it("allows the trusted current room without broadening other targets", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
const read = vi.fn(async () => "ok");
const cfg = {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {},
},
},
} as CoreConfig;
await expect(
withAuthorizedMatrixReadTarget({
cfg,
roomId: "!current:example.org",
context: {
currentChannelProvider: "matrix",
currentChannelId: "!current:example.org",
requesterAccountId: "default",
},
opts: { client },
run: read,
}),
).resolves.toBe("ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg,
roomId: "!other:example.org",
context: {
currentChannelProvider: "matrix",
currentChannelId: "!current:example.org",
requesterAccountId: "default",
},
opts: { client },
run: read,
}),
).rejects.toThrow("Matrix read target is not allowed.");
});
it("preserves the trusted direct type for the current room", async () => {
const getJoinedRoomMembers = vi.fn(async () => ["@bot:example.org", "@alice:example.org"]);
const client = createClient([], null, {}, undefined, { getJoinedRoomMembers });
const read = vi.fn(async () => "ok");
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "disabled",
dm: { policy: "pairing", allowFrom: [] },
},
},
} as CoreConfig,
roomId: "!current-dm:example.org",
context: {
currentChannelProvider: "matrix",
currentChannelId: "room:!current-dm:example.org",
currentChatType: "direct",
requesterAccountId: "default",
},
opts: { client },
run: read,
}),
).resolves.toBe("ok");
expect(getJoinedRoomMembers).not.toHaveBeenCalled();
});
it("uses the global group policy when the account does not override it", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
defaults: { groupPolicy: "open" },
matrix: {},
},
} as CoreConfig,
roomId: "!global-open:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it("allows unmatched group rooms under an open group policy", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
groups: {
"!other:example.org": {},
},
},
},
} as CoreConfig,
roomId: "!unmatched:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it("matches configured room aliases before applying direct-message policy", async () => {
const client = createClient(
["@bot:example.org", "@alice:example.org", "@bob:example.org"],
null,
{
canonicalAlias: "#ops:example.org",
},
);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"#ops:example.org": {},
},
dm: { policy: "disabled" },
},
},
} as CoreConfig,
roomId: "!ops:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it("resolves aliases before applying a disabled wildcard room policy", async () => {
const resolveRoom = vi.fn(async () => "!ops:example.org");
const client = createClient(
["@bot:example.org", "@alice:example.org", "@bob:example.org"],
null,
{
canonicalAlias: "#ops:example.org",
},
undefined,
{ resolveRoom },
);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"!ops:example.org": {},
"*": { enabled: false },
},
},
},
} as CoreConfig,
roomId: "#ops:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
expect(resolveRoom).toHaveBeenCalledWith("#ops:example.org");
});
it("treats explicitly configured two-member rooms as groups like ingress", async () => {
const getJoinedRoomMembers = vi.fn(async () => ["@bot:example.org", "@alice:example.org"]);
const client = createClient(
[],
true,
{
canonicalAlias: "#ops:example.org",
},
undefined,
{ getJoinedRoomMembers },
);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"#ops:example.org": {},
},
dm: { policy: "disabled" },
},
},
} as CoreConfig,
roomId: "!ops:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
expect(getJoinedRoomMembers).not.toHaveBeenCalled();
});
it("does not let wildcard room config override direct-message policy", async () => {
const client = createClient(["@bot:example.org", "@alice:example.org"], true);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
groups: {
"*": {},
},
dm: { policy: "disabled" },
},
},
} as CoreConfig,
roomId: "!dm:example.org",
opts: { client },
run: async () => "ok",
}),
).rejects.toThrow("Matrix read target is not allowed.");
});
it("matches configured room names only when mutable matching is enabled", async () => {
const client = createClient(
["@bot:example.org", "@alice:example.org", "@bob:example.org"],
null,
{},
"General",
);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
dangerouslyAllowNameMatching: true,
groupPolicy: "allowlist",
groups: {
General: {},
},
},
},
} as CoreConfig,
roomId: "!general:example.org",
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it.each([
"!blocked:example.org",
"room:!blocked:example.org",
"matrix:room:!blocked:example.org",
"channel:!blocked:example.org",
])("rejects explicitly disabled room target %s before provider access", async (roomId) => {
const getRoomStateEvent = vi.fn(async () => ({}));
const getJoinedRoomMembers = vi.fn(async () => [
"@bot:example.org",
"@alice:example.org",
"@bob:example.org",
]);
const client = createClient([], null, {}, undefined, {
getRoomStateEvent,
getJoinedRoomMembers,
});
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
groups: {
"!blocked:example.org": { enabled: false },
},
},
},
} as CoreConfig,
roomId,
context: {
currentChannelProvider: "matrix",
currentChannelId: "!blocked:example.org",
requesterAccountId: "default",
},
opts: { client },
run: async () => "ok",
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(getRoomStateEvent).not.toHaveBeenCalled();
expect(getJoinedRoomMembers).not.toHaveBeenCalled();
});
it.each(["!other-account:example.org", "matrix:channel:!other-account:example.org"])(
"rejects wrong-account room target %s before provider access",
async (roomId) => {
const getRoomStateEvent = vi.fn(async () => ({}));
const getJoinedRoomMembers = vi.fn(async () => [
"@bot:example.org",
"@alice:example.org",
"@bob:example.org",
]);
const client = createClient([], null, {}, undefined, {
getRoomStateEvent,
getJoinedRoomMembers,
});
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "open",
groups: {
"!other-account:example.org": { account: "other" },
},
},
},
} as CoreConfig,
accountId: "default",
roomId,
opts: { client },
run: async () => "ok",
}),
).rejects.toThrow("Matrix read target is not allowed.");
expect(getRoomStateEvent).not.toHaveBeenCalled();
expect(getJoinedRoomMembers).not.toHaveBeenCalled();
},
);
it.each([
{
name: "group",
members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"],
directFlag: null,
},
{
name: "direct room",
members: ["@bot:example.org", "@alice:example.org"],
directFlag: true,
},
])("lets a direct operator read an unconfigured $name", async ({ members, directFlag }) => {
const client = createClient(members, directFlag);
await expect(
withAuthorizedMatrixReadTarget({
cfg: {
channels: {
matrix: {
groupPolicy: "allowlist",
dm: { policy: "pairing", allowFrom: [] },
},
},
} as CoreConfig,
roomId: "!operator:example.org",
context: { conversationReadOrigin: "direct-operator" },
opts: { client },
run: async () => "ok",
}),
).resolves.toBe("ok");
});
it.each([
{
name: "disabled room",
cfg: {
groupPolicy: "open",
groups: { "!blocked:example.org": { enabled: false } },
},
members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"],
},
{
name: "wrong-account room",
cfg: {
groupPolicy: "open",
groups: { "!blocked:example.org": { account: "other" } },
},
members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"],
},
{
name: "disabled direct-message scope",
cfg: {
groupPolicy: "open",
dm: { policy: "disabled" },
},
members: ["@bot:example.org", "@alice:example.org"],
directFlag: true,
},
])("keeps $name blocked for direct operators", async ({ cfg, members, directFlag }) => {
const client = createClient(members, directFlag ?? null);
await expect(
withAuthorizedMatrixReadTarget({
cfg: { channels: { matrix: cfg } } as CoreConfig,
accountId: "default",
roomId: "!blocked:example.org",
context: { conversationReadOrigin: "direct-operator" },
opts: { client },
run: async () => "ok",
}),
).rejects.toThrow("Matrix read target is not allowed.");
});
});
+251
View File
@@ -0,0 +1,251 @@
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
ToolAuthorizationError,
} from "../runtime-api.js";
import type { CoreConfig } from "../types.js";
import { resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixAccount } from "./accounts.js";
import { withResolvedActionClient } from "./actions/client.js";
import type { MatrixActionClientOpts } from "./actions/types.js";
import {
hasDirectMatrixMemberFlag,
isStrictDirectMembership,
readJoinedMatrixMembers,
} from "./direct-room.js";
import { createMatrixRoomInfoResolver } from "./monitor/room-info.js";
import { resolveMatrixRoomConfig } from "./monitor/rooms.js";
import type { MatrixClient } from "./sdk.js";
import { resolveMatrixRoomId } from "./send/targets.js";
import { normalizeMatrixResolvableTarget } from "./target-ids.js";
type ConversationReadInvocationOrigin = NonNullable<
ChannelMessageActionContext["conversationReadOrigin"]
>;
export type MatrixReadContext = {
accountId?: string | null;
currentChannelId?: string | null;
currentChannelProvider?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
requesterAccountId?: string | null;
conversationReadOrigin?: ConversationReadInvocationOrigin;
};
function normalizeRoomId(raw?: string | null): string {
return raw?.trim().replace(/^room:/i, "") ?? "";
}
function isCurrentRoom(params: {
accountId: string;
context?: MatrixReadContext;
roomId: string;
}): boolean {
return (
params.context?.currentChannelProvider?.trim().toLowerCase() === "matrix" &&
params.context.requesterAccountId?.trim() === params.accountId &&
normalizeRoomId(params.context.currentChannelId) === normalizeRoomId(params.roomId)
);
}
function includesEntry(entries: Array<string | number> | undefined, value: string): boolean {
const normalized = value.trim().toLowerCase();
return (entries ?? []).some((entry) => {
const candidate = String(entry)
.replace(/^matrix:/i, "")
.trim()
.toLowerCase();
return candidate === "*" || candidate === normalized;
});
}
function hasWildcardEntry(entries: Array<string | number> | undefined): boolean {
return (entries ?? []).some(
(entry) =>
String(entry)
.replace(/^matrix:/i, "")
.trim() === "*",
);
}
type MatrixRoomClassification =
| { kind: "direct"; remoteUserId: string }
| { kind: "group" }
| { kind: "unknown" };
function resolveMatrixReadRoomPolicy(params: {
account: ReturnType<typeof resolveMatrixAccount>;
baseConfig: ReturnType<typeof resolveMatrixBaseConfig>;
roomId: string;
aliases: string[];
}) {
const configuredRooms = params.account.config.groups ?? params.account.config.rooms;
const room = resolveMatrixRoomConfig({
rooms: configuredRooms,
roomId: params.roomId,
aliases: params.aliases,
});
const baseRoom = resolveMatrixRoomConfig({
rooms: params.baseConfig.groups ?? params.baseConfig.rooms,
roomId: params.roomId,
aliases: params.aliases,
});
const baseRoomAccount = baseRoom.config?.account;
const explicitlyScopedToAnotherAccount =
room.config === undefined &&
baseRoom.matchSource === "direct" &&
typeof baseRoomAccount === "string" &&
normalizeAccountId(baseRoomAccount) !== params.account.accountId;
const accountMatches = !room.config?.account || room.config.account === params.account.accountId;
const configuredRoomBlocked = room.config !== undefined && (!room.allowed || !accountMatches);
const blocked = explicitlyScopedToAnotherAccount || configuredRoomBlocked;
const blockedBeforeProviderAccess =
explicitlyScopedToAnotherAccount || (room.matchSource === "direct" && configuredRoomBlocked);
return { blocked, blockedBeforeProviderAccess, room };
}
async function classifyMatrixReadRoom(params: {
client: MatrixClient;
roomId: string;
}): Promise<MatrixRoomClassification> {
const members = await readJoinedMatrixMembers(params.client, params.roomId);
if (!members) {
return { kind: "unknown" };
}
if (members.length >= 3) {
return { kind: "group" };
}
if (members.length !== 2) {
return { kind: "unknown" };
}
const selfUserId = await params.client.getUserId().catch(() => null);
if (!selfUserId || !members.includes(selfUserId)) {
return { kind: "unknown" };
}
const remoteUserId = members.find((member) => member !== selfUserId);
if (
!isStrictDirectMembership({
selfUserId,
remoteUserId,
joinedMembers: members,
}) ||
!remoteUserId
) {
return { kind: "unknown" };
}
const memberStateFlag = await hasDirectMatrixMemberFlag(params.client, params.roomId, selfUserId);
await params.client.dms.update().catch(() => false);
if (memberStateFlag === true || params.client.dms.isDm(params.roomId)) {
return { kind: "direct", remoteUserId };
}
return memberStateFlag === false ? { kind: "group" } : { kind: "unknown" };
}
export async function withAuthorizedMatrixReadTarget<T>(params: {
cfg: CoreConfig;
accountId?: string | null;
roomId: string;
context?: MatrixReadContext;
opts: MatrixActionClientOpts;
run: (target: { client: MatrixClient; roomId: string }) => Promise<T>;
}): Promise<T> {
const account = resolveMatrixAccount({ cfg: params.cfg, accountId: params.accountId });
const baseConfig = resolveMatrixBaseConfig(params.cfg);
const preliminaryRoomId = normalizeMatrixResolvableTarget(params.roomId);
const preliminaryPolicy = resolveMatrixReadRoomPolicy({
account,
baseConfig,
roomId: preliminaryRoomId,
aliases: [],
});
if (preliminaryPolicy.blockedBeforeProviderAccess) {
throw new ToolAuthorizationError("Matrix read target is not allowed.");
}
return await withResolvedActionClient(params.opts, async (client) => {
const roomId = await resolveMatrixRoomId(client, params.roomId);
const inputAlias = params.roomId.trim().startsWith("#") ? params.roomId.trim() : undefined;
const { getRoomInfo } = createMatrixRoomInfoResolver(client);
const roomInfo = await getRoomInfo(roomId, { includeAliases: true });
const mutableRoomName =
account.config.dangerouslyAllowNameMatching === true ? roomInfo.name : undefined;
const aliases = [
inputAlias,
roomInfo.canonicalAlias,
...roomInfo.altAliases,
mutableRoomName,
].filter((value): value is string => Boolean(value));
const finalPolicy = resolveMatrixReadRoomPolicy({
account,
baseConfig,
roomId,
aliases,
});
const room = finalPolicy.room;
const current = isCurrentRoom({
accountId: account.accountId,
context: params.context,
roomId,
});
const currentChatType = params.context?.currentChatType?.trim().toLowerCase();
const trustedCurrentClassification =
currentChatType === "direct"
? ({ kind: "direct", remoteUserId: "" } as const)
: currentChatType === "group" || currentChatType === "channel"
? ({ kind: "group" } as const)
: null;
// Ingress treats an explicitly configured room or alias as a group before
// Matrix DM heuristics. Otherwise preserve its trusted type for the current room.
const classification =
room.matchSource === "direct"
? ({ kind: "group" } as const)
: current && trustedCurrentClassification
? trustedCurrentClassification
: await classifyMatrixReadRoom({ client, roomId });
const resolvedGroupPolicy = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.matrix !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg),
}).groupPolicy;
const groupPolicy =
account.config.allowlistOnly && resolvedGroupPolicy === "open"
? "allowlist"
: resolvedGroupPolicy;
const dmPolicy = account.config.allowlistOnly
? account.config.dm?.policy === "disabled"
? "disabled"
: "allowlist"
: (account.config.dm?.policy ?? "pairing");
const directOperator = params.context?.conversationReadOrigin === "direct-operator";
const allowed = finalPolicy.blocked
? false
: directOperator
? classification.kind === "direct"
? account.config.dm?.enabled !== false && dmPolicy !== "disabled"
: classification.kind === "group"
? groupPolicy !== "disabled"
: groupPolicy !== "disabled" &&
dmPolicy !== "disabled" &&
account.config.dm?.enabled !== false
: classification.kind === "direct"
? account.config.dm?.enabled !== false &&
dmPolicy !== "disabled" &&
(current || includesEntry(account.config.dm?.allowFrom, classification.remoteUserId))
: classification.kind === "group"
? groupPolicy !== "disabled" &&
(current || groupPolicy === "open" || room.config !== undefined)
: current
? groupPolicy !== "disabled" &&
dmPolicy !== "disabled" &&
account.config.dm?.enabled !== false
: groupPolicy === "open" &&
dmPolicy !== "disabled" &&
account.config.dm?.enabled !== false &&
hasWildcardEntry(account.config.dm?.allowFrom);
if (!allowed) {
throw new ToolAuthorizationError("Matrix read target is not allowed.");
}
return await params.run({ client, roomId });
});
}
+15
View File
@@ -190,6 +190,7 @@ type MatrixJsClientStub = {
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
getStateEvent: ReturnType<typeof vi.fn>;
getAccountData: ReturnType<typeof vi.fn>;
getAccountDataFromServer: ReturnType<typeof vi.fn>;
setAccountData: ReturnType<typeof vi.fn>;
getRoomIdForAlias: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
@@ -226,6 +227,7 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
client.getStateEvent = vi.fn(async () => ({}));
client.getAccountData = vi.fn(() => undefined);
client.getAccountDataFromServer = vi.fn(async () => null);
client.setAccountData = vi.fn(async () => {});
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
@@ -324,6 +326,19 @@ describe("MatrixClient request hardening", () => {
resetPluginStateStoreForTests();
});
it("reads account data through the server-aware SDK path before initial sync", async () => {
matrixJsClient.getAccountDataFromServer.mockResolvedValue({
"@alice:example.org": ["!dm:example.org"],
});
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.getAccountData("m.direct")).resolves.toEqual({
"@alice:example.org": ["!dm:example.org"],
});
expect(matrixJsClient.getAccountDataFromServer).toHaveBeenCalledWith("m.direct");
expect(matrixJsClient.getAccountData).not.toHaveBeenCalled();
});
it("blocks absolute endpoints unless explicitly allowed", async () => {
const fetchMock = vi.fn(async () => {
return new Response("{}", {
+6 -2
View File
@@ -902,8 +902,12 @@ export class MatrixClient {
}
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
const event = this.client.getAccountData(eventType as never);
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
return (
((await this.client.getAccountDataFromServer(eventType as never)) as Record<
string,
unknown
> | null) ?? undefined
);
}
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
+160
View File
@@ -6,20 +6,34 @@ import type { CoreConfig } from "./types.js";
const mocks = vi.hoisted(() => ({
voteMatrixPoll: vi.fn(),
reactMatrixMessage: vi.fn(),
editMatrixMessage: vi.fn(),
deleteMatrixMessage: vi.fn(),
listMatrixReactions: vi.fn(),
removeMatrixReactions: vi.fn(),
sendMatrixMessage: vi.fn(),
pinMatrixMessage: vi.fn(),
unpinMatrixMessage: vi.fn(),
listMatrixPins: vi.fn(),
getMatrixMemberInfo: vi.fn(),
getMatrixRoomInfo: vi.fn(),
applyMatrixProfileUpdate: vi.fn(),
matrixClient: { id: "matrix-client" },
withAuthorizedMatrixReadTarget: vi.fn(),
}));
vi.mock("./matrix/read-policy.js", () => ({
withAuthorizedMatrixReadTarget: mocks.withAuthorizedMatrixReadTarget,
}));
vi.mock("./matrix/actions.js", () => {
return {
deleteMatrixMessage: mocks.deleteMatrixMessage,
editMatrixMessage: mocks.editMatrixMessage,
getMatrixMemberInfo: mocks.getMatrixMemberInfo,
getMatrixRoomInfo: mocks.getMatrixRoomInfo,
listMatrixReactions: mocks.listMatrixReactions,
pinMatrixMessage: mocks.pinMatrixMessage,
unpinMatrixMessage: mocks.unpinMatrixMessage,
listMatrixPins: mocks.listMatrixPins,
removeMatrixReactions: mocks.removeMatrixReactions,
sendMatrixMessage: mocks.sendMatrixMessage,
@@ -40,6 +54,16 @@ vi.mock("./profile-update.js", () => ({
describe("handleMatrixAction pollVote", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.withAuthorizedMatrixReadTarget.mockImplementation(
async (params: {
roomId: string;
run: (target: { client: unknown; roomId: string }) => Promise<unknown>;
}) =>
await params.run({
client: mocks.matrixClient,
roomId: params.roomId.replace(/^room:/, ""),
}),
);
mocks.voteMatrixPoll.mockResolvedValue({
eventId: "evt-poll-vote",
roomId: "!room:example",
@@ -50,11 +74,14 @@ describe("handleMatrixAction pollVote", () => {
});
mocks.listMatrixReactions.mockResolvedValue([{ key: "👍", count: 1, users: ["@u:example"] }]);
mocks.listMatrixPins.mockResolvedValue({ pinned: ["$pin"], events: [] });
mocks.pinMatrixMessage.mockResolvedValue({ pinned: ["$existing", "$pin"] });
mocks.unpinMatrixMessage.mockResolvedValue({ pinned: ["$existing"] });
mocks.removeMatrixReactions.mockResolvedValue({ removed: 1 });
mocks.sendMatrixMessage.mockResolvedValue({
messageId: "$sent",
roomId: "!room:example",
});
mocks.editMatrixMessage.mockResolvedValue({ eventId: "$edited" });
mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" });
mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" });
mocks.applyMatrixProfileUpdate.mockResolvedValue({
@@ -91,6 +118,7 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", {
cfg,
accountId: "main",
client: mocks.matrixClient,
optionIds: ["a2", "a1"],
optionIndexes: [1, 2],
});
@@ -160,11 +188,32 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", {
cfg,
client: mocks.matrixClient,
optionIds: [],
optionIndexes: [1],
});
});
it("authorizes the room before reading the poll", async () => {
mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce(
new Error("Matrix read target is not allowed."),
);
await expect(
handleMatrixAction(
{
action: "pollVote",
roomId: "!blocked:example",
pollId: "$poll",
pollOptionIndex: 1,
},
{} as CoreConfig,
),
).rejects.toThrow("Matrix read target is not allowed.");
expect(mocks.voteMatrixPoll).not.toHaveBeenCalled();
});
it("passes account-scoped opts to add reactions", async () => {
const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig;
await handleMatrixAction(
@@ -181,9 +230,56 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.reactMatrixMessage).toHaveBeenCalledWith("!room:example", "$msg", "👍", {
cfg,
accountId: "ops",
client: mocks.matrixClient,
});
});
it.each([
{
action: "react",
params: { emoji: "👍" },
providerCall: mocks.reactMatrixMessage,
},
{
action: "editMessage",
params: { content: "updated" },
providerCall: mocks.editMatrixMessage,
},
{
action: "deleteMessage",
params: {},
providerCall: mocks.deleteMatrixMessage,
},
])("rejects blocked $action before mutating Matrix", async ({ action, params, providerCall }) => {
mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce(
new Error("Matrix read target is not allowed."),
);
const cfg = {
channels: {
matrix: {
actions: {
messages: true,
reactions: true,
},
},
},
} as CoreConfig;
await expect(
handleMatrixAction(
{
action,
roomId: "!blocked:example",
messageId: "$msg",
...params,
},
cfg,
),
).rejects.toThrow("Matrix read target is not allowed.");
expect(providerCall).not.toHaveBeenCalled();
});
it("passes account-scoped opts to remove reactions", async () => {
const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig;
await handleMatrixAction(
@@ -201,6 +297,7 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.removeMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", {
cfg,
accountId: "ops",
client: mocks.matrixClient,
emoji: "👍",
});
});
@@ -221,6 +318,7 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.listMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", {
cfg,
accountId: "ops",
client: mocks.matrixClient,
limit: 5,
});
expect(result.details).toEqual({
@@ -353,9 +451,69 @@ describe("handleMatrixAction pollVote", () => {
expect(mocks.listMatrixPins).toHaveBeenCalledWith("!room:example", {
cfg,
accountId: "ops",
client: mocks.matrixClient,
});
});
it.each([
{
action: "pinMessage",
expected: mocks.pinMatrixMessage,
expectedPinned: ["$existing", "$pin"],
},
{
action: "unpinMessage",
expected: mocks.unpinMatrixMessage,
expectedPinned: ["$existing"],
},
])(
"authorizes $action before reading pinned state",
async ({ action, expected, expectedPinned }) => {
const cfg = { channels: { matrix: { actions: { pins: true } } } } as CoreConfig;
const result = await handleMatrixAction(
{
action,
accountId: "ops",
roomId: "room:!room:example",
messageId: "$pin",
},
cfg,
);
expect(expected).toHaveBeenCalledWith("!room:example", "$pin", {
cfg,
accountId: "ops",
client: mocks.matrixClient,
});
expect(result.details).toEqual({ ok: true, pinned: expectedPinned });
},
);
it.each(["pinMessage", "unpinMessage"])(
"rejects blocked %s before reading or mutating pinned state",
async (action) => {
mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce(
new Error("Matrix read target is not allowed."),
);
const cfg = { channels: { matrix: { actions: { pins: true } } } } as CoreConfig;
await expect(
handleMatrixAction(
{
action,
roomId: "!blocked:example",
messageId: "$pin",
},
cfg,
),
).rejects.toThrow("Matrix read target is not allowed.");
expect(mocks.pinMatrixMessage).not.toHaveBeenCalled();
expect(mocks.unpinMatrixMessage).not.toHaveBeenCalled();
expect(mocks.listMatrixPins).not.toHaveBeenCalled();
},
);
it("passes account-scoped opts to member and room info actions", async () => {
const memberCfg = {
channels: { matrix: { actions: { memberInfo: true } } },
@@ -383,10 +541,12 @@ describe("handleMatrixAction pollVote", () => {
cfg: memberCfg,
accountId: "ops",
roomId: "!room:example",
client: mocks.matrixClient,
});
expect(mocks.getMatrixRoomInfo).toHaveBeenCalledWith("!room:example", {
cfg: roomCfg,
accountId: "ops",
client: mocks.matrixClient,
});
});
+102 -39
View File
@@ -33,6 +33,8 @@ import {
voteMatrixPoll,
verifyMatrixRecoveryKey,
} from "./matrix/actions.js";
import { withAuthorizedMatrixReadTarget, type MatrixReadContext } from "./matrix/read-policy.js";
import type { MatrixClient } from "./matrix/sdk.js";
import { reactMatrixMessage } from "./matrix/send.js";
import { applyMatrixProfileUpdate } from "./profile-update.js";
import {
@@ -147,7 +149,7 @@ function readPositiveIntegerArrayParam(params: Record<string, unknown>, key: str
export async function handleMatrixAction(
params: Record<string, unknown>,
cfg: CoreConfig,
opts: { mediaLocalRoots?: readonly string[] } = {},
opts: { mediaLocalRoots?: readonly string[]; readContext?: MatrixReadContext } = {},
): Promise<AgentToolResult<unknown>> {
const action = readStringParam(params, "action", { required: true });
const accountId = readStringParam(params, "accountId") ?? undefined;
@@ -156,6 +158,18 @@ export async function handleMatrixAction(
cfg,
...(accountId ? { accountId } : {}),
};
const withReadTarget = async <T>(
roomId: string,
run: (target: { roomId: string; client: MatrixClient }) => Promise<T>,
) =>
await withAuthorizedMatrixReadTarget({
cfg,
accountId,
roomId,
context: opts.readContext,
opts: clientOpts,
run,
});
if (reactionActions.has(action)) {
if (!isActionEnabled("reactions")) {
@@ -168,21 +182,32 @@ export async function handleMatrixAction(
removeErrorMessage: "Emoji is required to remove a Matrix reaction.",
});
if (remove || isEmpty) {
const result = await removeMatrixReactions(roomId, messageId, {
...clientOpts,
emoji: remove ? emoji : undefined,
const result = await withReadTarget(roomId, async (target) => {
return await removeMatrixReactions(target.roomId, messageId, {
...clientOpts,
client: target.client,
emoji: remove ? emoji : undefined,
});
});
return jsonResult({ ok: true, removed: result.removed });
}
await reactMatrixMessage(roomId, messageId, emoji, clientOpts);
await withReadTarget(roomId, async (target) => {
await reactMatrixMessage(target.roomId, messageId, emoji, {
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, added: emoji });
}
const limit = readPositiveIntegerParam(params, "limit", {
message: "limit must be a positive integer.",
});
const reactions = await listMatrixReactions(roomId, messageId, {
...clientOpts,
limit: limit ?? undefined,
const reactions = await withReadTarget(roomId, async (target) => {
return await listMatrixReactions(target.roomId, messageId, {
...clientOpts,
client: target.client,
limit: limit ?? undefined,
});
});
return jsonResult({ ok: true, reactions });
}
@@ -205,10 +230,13 @@ export async function handleMatrixAction(
...readPositiveIntegerArrayParam(params, "pollOptionIndexes"),
...(optionIndex !== undefined ? [optionIndex] : []),
];
const result = await voteMatrixPoll(roomId, pollId, {
...clientOpts,
optionIds,
optionIndexes,
const result = await withReadTarget(roomId, async (target) => {
return await voteMatrixPoll(target.roomId, pollId, {
...clientOpts,
client: target.client,
optionIds,
optionIndexes,
});
});
return jsonResult({ ok: true, result });
}
@@ -252,16 +280,24 @@ export async function handleMatrixAction(
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "content", { required: true });
const result = await editMatrixMessage(roomId, messageId, content, clientOpts);
const result = await withReadTarget(roomId, async (target) => {
return await editMatrixMessage(target.roomId, messageId, content, {
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, result });
}
case "deleteMessage": {
const roomId = readRoomId(params);
const messageId = readStringParam(params, "messageId", { required: true });
const reason = readStringParam(params, "reason");
await deleteMatrixMessage(roomId, messageId, {
reason: reason ?? undefined,
...clientOpts,
await withReadTarget(roomId, async (target) => {
await deleteMatrixMessage(target.roomId, messageId, {
reason: reason ?? undefined,
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, deleted: true });
}
@@ -273,12 +309,15 @@ export async function handleMatrixAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readMatrixMessages(roomId, {
limit: limit ?? undefined,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
...clientOpts,
const result = await withReadTarget(roomId, async (target) => {
return await readMatrixMessages(target.roomId, {
limit: limit ?? undefined,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, ...result });
}
@@ -292,18 +331,34 @@ export async function handleMatrixAction(
throw new Error("Matrix pins are disabled.");
}
const roomId = readRoomId(params);
if (action === "pinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await pinMatrixMessage(roomId, messageId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
if (action === "unpinMessage") {
const messageId = readStringParam(params, "messageId", { required: true });
const result = await unpinMatrixMessage(roomId, messageId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
const result = await listMatrixPins(roomId, clientOpts);
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
const request =
action === "pinMessage"
? {
kind: "pin" as const,
messageId: readStringParam(params, "messageId", { required: true }),
}
: action === "unpinMessage"
? {
kind: "unpin" as const,
messageId: readStringParam(params, "messageId", { required: true }),
}
: { kind: "list" as const };
return await withReadTarget(roomId, async (target) => {
const actionOpts = {
...clientOpts,
client: target.client,
};
if (request.kind === "pin") {
const result = await pinMatrixMessage(target.roomId, request.messageId, actionOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
if (request.kind === "unpin") {
const result = await unpinMatrixMessage(target.roomId, request.messageId, actionOpts);
return jsonResult({ ok: true, pinned: result.pinned });
}
const result = await listMatrixPins(target.roomId, actionOpts);
return jsonResult({ ok: true, pinned: result.pinned, events: result.events });
});
}
if (profileActions.has(action)) {
@@ -330,10 +385,13 @@ export async function handleMatrixAction(
throw new Error("Matrix member info is disabled.");
}
const userId = readStringParam(params, "userId", { required: true });
const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId");
const result = await getMatrixMemberInfo(userId, {
roomId: roomId ?? undefined,
...clientOpts,
const roomId = readRoomId(params);
const result = await withReadTarget(roomId, async (target) => {
return await getMatrixMemberInfo(userId, {
roomId: target.roomId,
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, member: result });
}
@@ -343,7 +401,12 @@ export async function handleMatrixAction(
throw new Error("Matrix room info is disabled.");
}
const roomId = readRoomId(params);
const result = await getMatrixRoomInfo(roomId, clientOpts);
const result = await withReadTarget(roomId, async (target) => {
return await getMatrixRoomInfo(target.roomId, {
...clientOpts,
client: target.client,
});
});
return jsonResult({ ok: true, room: result });
}
+487 -31
View File
@@ -38,7 +38,6 @@ const {
sendMessageMSTeamsMock: vi.fn(),
unpinMessageMSTeamsMock: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
msTeamsChannelRuntime: {
addParticipantMSTeams: addParticipantMSTeamsMock,
@@ -79,6 +78,9 @@ const actionMocks = [
unpinMessageMSTeamsMock,
];
const currentChannelId = "conversation:19:ctx@thread.tacv2";
const graphTeamId = "11111111-1111-1111-1111-111111111111";
const graphChannelId = "19:channel-1@thread.tacv2";
const graphChannelTarget = `${graphTeamId}/${graphChannelId}`;
const reactChannelId = "conversation:19:react@thread.tacv2";
const targetChannelId = "conversation:19:target@thread.tacv2";
const editedConversationId = "19:edited@thread.tacv2";
@@ -124,6 +126,8 @@ function requireMSTeamsHandleAction() {
async function runAction(params: {
action: string;
cfg?: Record<string, unknown>;
accountId?: string;
requesterAccountId?: string;
params?: Record<string, unknown>;
toolContext?: Record<string, unknown>;
mediaLocalRoots?: readonly string[];
@@ -137,6 +141,8 @@ async function runAction(params: {
channel: "msteams",
action: params.action,
cfg: params.cfg ?? {},
accountId: params.accountId,
requesterAccountId: params.requesterAccountId,
params: params.params ?? {},
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
@@ -187,9 +193,10 @@ function expectActionSuccess(
function expectActionRuntimeCall(
mockFn: ReturnType<typeof vi.fn>,
params: Record<string, unknown>,
cfg: Record<string, unknown> = {},
) {
expect(mockFn).toHaveBeenCalledWith({
cfg: {},
cfg,
...params,
});
}
@@ -198,6 +205,9 @@ async function expectSuccessfulAction(params: {
mockFn: ReturnType<typeof vi.fn>;
mockResult: unknown;
action: Parameters<typeof runAction>[0]["action"];
cfg?: Parameters<typeof runAction>[0]["cfg"];
accountId?: Parameters<typeof runAction>[0]["accountId"];
requesterAccountId?: Parameters<typeof runAction>[0]["requesterAccountId"];
actionParams?: Parameters<typeof runAction>[0]["params"];
toolContext?: Parameters<typeof runAction>[0]["toolContext"];
mediaLocalRoots?: Parameters<typeof runAction>[0]["mediaLocalRoots"];
@@ -212,6 +222,9 @@ async function expectSuccessfulAction(params: {
params.mockFn.mockResolvedValue(params.mockResult);
const result = await runAction({
action: params.action,
cfg: params.cfg,
accountId: params.accountId,
requesterAccountId: params.requesterAccountId,
params: params.actionParams,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
@@ -220,11 +233,19 @@ async function expectSuccessfulAction(params: {
senderIsOwner: params.senderIsOwner,
gatewayClientScopes: params.gatewayClientScopes,
});
expectActionRuntimeCall(params.mockFn, params.runtimeParams);
expectActionRuntimeCall(params.mockFn, params.runtimeParams, params.cfg);
expectActionSuccess(result, params.details, params.contentDetails);
}
describe("msteamsPlugin message actions", () => {
const unrestrictedReadCfg = {
channels: {
msteams: {
groupPolicy: "open",
dmPolicy: "open",
},
},
};
beforeEach(() => {
for (const mockFn of actionMocks) {
mockFn.mockReset();
@@ -241,7 +262,18 @@ describe("msteamsPlugin message actions", () => {
},
toolContext: {
currentChannelId: padded(currentChannelId),
currentChannelProvider: "msteams",
},
cfg: {
channels: {
msteams: {
groupPolicy: "allowlist",
dmPolicy: "pairing",
},
},
},
accountId: "default",
requesterAccountId: "default",
runtimeParams: {
to: currentChannelId,
messageId: "msg-1",
@@ -258,6 +290,287 @@ describe("msteamsPlugin message actions", () => {
});
});
it("allows the trusted current paired DM target", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
to: "user:aad-user-1",
messageId: "msg-1",
},
toolContext: {
currentChannelId: "user:aad-user-1",
currentChannelProvider: "msteams",
},
cfg: {
channels: {
msteams: {
groupPolicy: "allowlist",
dmPolicy: "pairing",
},
},
},
accountId: "default",
requesterAccountId: "default",
runtimeParams: {
to: "user:aad-user-1",
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("uses the global group policy when Teams does not override it", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
to: graphChannelTarget,
messageId: "msg-1",
},
cfg: {
channels: {
defaults: { groupPolicy: "open" },
msteams: {},
},
},
runtimeParams: {
to: graphChannelTarget,
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("allows the trusted current channel under allowlist policy", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
messageId: "msg-1",
},
toolContext: {
currentChannelProvider: "msteams",
currentMessagingTarget: "team-1/channel-1",
},
cfg: {
channels: {
msteams: {
groupPolicy: "allowlist",
groupAllowFrom: ["aad-user-1"],
},
},
},
accountId: "default",
requesterAccountId: "default",
runtimeParams: {
to: "team-1/channel-1",
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("does not route channel Graph actions through a Bot Framework conversation id", async () => {
await expectActionError(
{
action: "read",
params: { messageId: "msg-1" },
toolContext: {
currentChannelId: "conversation:19:channel@thread.tacv2",
currentChatType: "channel",
},
},
"Read requires a target (to) and messageId.",
);
expect(getMessageMSTeamsMock).not.toHaveBeenCalled();
});
it("allows the trusted current group chat when DMs are disabled", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
messageId: "msg-1",
},
toolContext: {
currentChannelProvider: "msteams",
currentChannelId: "conversation:19:group@thread.v2",
currentChatType: "group",
},
cfg: {
channels: {
msteams: {
groupPolicy: "open",
dmPolicy: "disabled",
},
},
},
accountId: "default",
requesterAccountId: "default",
runtimeParams: {
to: "conversation:19:group@thread.v2",
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("allows a bare trusted current group target when DMs are disabled", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
messageId: "msg-1",
},
toolContext: {
currentChannelProvider: "msteams",
currentChannelId: "19:group@thread.v2",
currentChatType: "group",
},
cfg: {
channels: {
msteams: {
groupPolicy: "open",
dmPolicy: "disabled",
},
},
},
accountId: "default",
requesterAccountId: "default",
runtimeParams: {
to: "19:group@thread.v2",
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("requires both scopes for a non-current opaque chat target", async () => {
getMessageMSTeamsMock.mockResolvedValue(readMessage);
await expect(
runAction({
action: "read",
params: {
to: "conversation:19:direct@thread.v2",
messageId: "msg-1",
},
cfg: {
channels: {
msteams: {
groupPolicy: "open",
dmPolicy: "pairing",
},
},
},
}),
).rejects.toThrow("Microsoft Teams read target is not allowed.");
expect(getMessageMSTeamsMock).not.toHaveBeenCalled();
});
it("allows a non-current opaque chat target when both scopes are open", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
to: "conversation:19:opaque@thread.v2",
messageId: "msg-1",
},
cfg: {
channels: {
msteams: {
groupPolicy: "open",
dmPolicy: "open",
},
},
},
runtimeParams: {
to: "conversation:19:opaque@thread.v2",
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("does not treat per-DM history config as read authorization", async () => {
getMessageMSTeamsMock.mockResolvedValue(readMessage);
await expect(
runAction({
action: "read",
params: {
to: "user:aad-user-1",
messageId: "msg-1",
},
cfg: {
channels: {
msteams: {
dmPolicy: "allowlist",
allowFrom: [],
dms: { "aad-user-1": { historyLimit: 5 } },
},
},
},
}),
).rejects.toThrow("Microsoft Teams read target is not allowed.");
expect(getMessageMSTeamsMock).not.toHaveBeenCalled();
});
it("advertises upload-file in the message tool surface", () => {
expect(
msteamsPlugin.actions?.describeMessageTool?.({
@@ -319,8 +632,48 @@ describe("msteamsPlugin message actions", () => {
mockFn: getMemberInfoMSTeamsMock,
mockResult: { member: { id: "user-1" } },
action: "member-info",
actionParams: { userId: " user-1 " },
runtimeParams: { userId: "user-1" },
cfg: unrestrictedReadCfg,
actionParams: { userId: " user-1 ", to: graphChannelTarget },
runtimeParams: {
to: graphChannelTarget,
userId: "user-1",
currentRequesterId: undefined,
},
details: okMSTeamsActionDetails("member-info", {
member: { id: "user-1" },
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "member-info",
member: { id: "user-1" },
},
});
});
it("passes the trusted requester only for current Teams chats", async () => {
await expectSuccessfulAction({
mockFn: getMemberInfoMSTeamsMock,
mockResult: { member: { id: "user-1" } },
action: "member-info",
cfg: unrestrictedReadCfg,
accountId: "default",
requesterAccountId: "default",
requesterSenderId: "user-1",
toolContext: {
currentChannelProvider: "msteams",
currentChannelId: "conversation:19:group@thread.v2",
currentChatType: "group",
},
actionParams: {
userId: "user-1",
to: "conversation:19:group@thread.v2",
},
runtimeParams: {
to: "conversation:19:group@thread.v2",
userId: "user-1",
currentRequesterId: "user-1",
},
details: okMSTeamsActionDetails("member-info", {
member: { id: "user-1" },
}),
@@ -338,8 +691,9 @@ describe("msteamsPlugin message actions", () => {
mockFn: listChannelsMSTeamsMock,
mockResult: { channels: [{ id: "channel-1" }] },
action: "channel-list",
actionParams: { teamId: " team-1 " },
runtimeParams: { teamId: "team-1" },
cfg: unrestrictedReadCfg,
actionParams: { teamId: ` ${graphTeamId} ` },
runtimeParams: { teamId: graphTeamId },
details: okMSTeamsActionDetails("channel-list", {
channels: [{ id: "channel-1" }],
}),
@@ -357,13 +711,14 @@ describe("msteamsPlugin message actions", () => {
mockFn: getChannelInfoMSTeamsMock,
mockResult: { channel: { id: "channel-1" } },
action: "channel-info",
cfg: unrestrictedReadCfg,
actionParams: {
teamId: " team-1 ",
channelId: " channel-1 ",
teamId: ` ${graphTeamId} `,
channelId: ` ${graphChannelId} `,
},
runtimeParams: {
teamId: "team-1",
channelId: "channel-1",
teamId: graphTeamId,
channelId: graphChannelId,
},
details: okMSTeamsActionDetails("channel-info", {
channelInfo: { id: "channel-1" },
@@ -523,6 +878,7 @@ describe("msteamsPlugin message actions", () => {
mockFn: pinMessageMSTeamsMock,
mockResult: { ok: true, pinnedMessageId: "pin-1" },
action: "pin",
cfg: unrestrictedReadCfg,
actionParams: {
target: padded(targetChannelId),
messageId: padded("msg-2"),
@@ -542,6 +898,7 @@ describe("msteamsPlugin message actions", () => {
mockFn: editMessageMSTeamsMock,
mockResult: { conversationId: editedConversationId },
action: "edit",
cfg: unrestrictedReadCfg,
actionParams: {
to: targetChannelId,
messageId: editedMessageId,
@@ -569,6 +926,7 @@ describe("msteamsPlugin message actions", () => {
mockFn: unpinMessageMSTeamsMock,
mockResult: { ok: true },
action: "unpin",
cfg: unrestrictedReadCfg,
actionParams: {
target: padded(targetChannelId),
messageId: padded("pin-2"),
@@ -586,6 +944,7 @@ describe("msteamsPlugin message actions", () => {
mockFn: unpinMessageMSTeamsMock,
mockResult: { ok: true },
action: "unpin",
cfg: unrestrictedReadCfg,
actionParams: {
target: padded(targetChannelId),
pinnedMessageId: padded("pinned-resource-99"),
@@ -634,6 +993,9 @@ describe("msteamsPlugin message actions", () => {
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
cfg: unrestrictedReadCfg,
accountId: "default",
requesterAccountId: "default",
actionParams: {
messageId: padded("msg-3"),
emoji: padded(reactionType),
@@ -786,36 +1148,116 @@ describe("msteamsPlugin message actions", () => {
});
it("requires a non-empty search query after trimming", async () => {
await expectActionParamError(
"search",
await expectActionError(
{
to: targetChannelId,
query: " ",
action: "search",
cfg: unrestrictedReadCfg,
params: {
to: targetChannelId,
query: " ",
},
},
searchMissingQueryError,
);
});
it("routes channel fallback targets via teamId/channelId for react actions", async () => {
// When an action is invoked in a Teams channel context and `target` is
// omitted, the action handler falls back to `toolContext.currentChannelId`.
// For channel turns, buildToolContext populates that field with the
// compound `teamId/channelId` form (see buildToolContext below), so the
// runtime call must receive that compound form — NOT a bare
// `conversation:<id>` — so Graph API routes through
// `/teams/{teamId}/channels/{channelId}` rather than `/chats/{id}`.
it("rejects reads outside configured Teams channels before calling Graph", async () => {
await expect(
runAction({
action: "read",
cfg: {
channels: {
msteams: {
groupPolicy: "allowlist",
teams: {
"team-1": {
channels: {
"channel-1": { enabled: true },
},
},
},
},
},
},
params: { to: "team-1/channel-2", messageId: "msg-1" },
}),
).rejects.toThrow("Microsoft Teams read target is not allowed.");
expect(getMessageMSTeamsMock).not.toHaveBeenCalled();
});
it.each([
{
action: "edit",
params: { to: targetChannelId, messageId: "msg-1", content: "updated" },
runtimeMock: editMessageMSTeamsMock,
},
{
action: "delete",
params: { to: targetChannelId, messageId: "msg-1" },
runtimeMock: deleteMessageMSTeamsMock,
},
{
action: "pin",
params: { to: targetChannelId, messageId: "msg-1" },
runtimeMock: pinMessageMSTeamsMock,
},
{
action: "unpin",
params: { to: targetChannelId, pinnedMessageId: "pin-1" },
runtimeMock: unpinMessageMSTeamsMock,
},
{
action: "react",
params: { to: targetChannelId, messageId: "msg-1", emoji: "like" },
runtimeMock: reactMessageMSTeamsMock,
},
])("rejects a blocked $action target before the provider operation", async (testCase) => {
await expect(
runAction({
action: testCase.action,
cfg: {
channels: {
msteams: {
groupPolicy: "allowlist",
dmPolicy: "pairing",
},
},
},
accountId: "default",
requesterAccountId: "default",
params: testCase.params,
toolContext: {
currentChannelProvider: "msteams",
currentChannelId,
currentChatType: "group",
},
}),
).rejects.toThrow("Microsoft Teams read target is not allowed.");
expect(testCase.runtimeMock).not.toHaveBeenCalled();
});
it("restores the Graph route from a core-materialized channel target", async () => {
// Core materializes an omitted target from currentChannelId before plugin
// dispatch. Teams must restore the prepared Graph target for channel turns.
const teamChannelTarget = "team-1/19:channel-abc@thread.tacv2";
const conversationTarget = "conversation:19:channel-abc@thread.tacv2";
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
cfg: unrestrictedReadCfg,
accountId: "default",
requesterAccountId: "default",
actionParams: {
target: conversationTarget,
messageId: "msg-channel-react",
emoji: reactionType,
},
toolContext: {
currentChannelId: "conversation:19:channel-abc@thread.tacv2",
currentGraphChannelId: teamChannelTarget,
currentChannelProvider: "msteams",
currentChannelId: conversationTarget,
currentChatType: "channel",
currentMessagingTarget: teamChannelTarget,
},
runtimeParams: {
to: teamChannelTarget,
@@ -837,12 +1279,13 @@ describe("msteamsPlugin message actions", () => {
it("preserves explicit teamId/channelId target over toolContext fallback", async () => {
// Even in a channel context with a compound currentChannelId, an
// explicit `target` param must take precedence.
const teamChannelTarget = "team-2/19:channel-def@thread.tacv2";
const explicitTarget = "team-explicit/19:other@thread.tacv2";
const teamChannelTarget = "22222222-2222-2222-2222-222222222222/19:channel-def@thread.tacv2";
const explicitTarget = "33333333-3333-3333-3333-333333333333/19:other@thread.tacv2";
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
cfg: unrestrictedReadCfg,
actionParams: {
target: explicitTarget,
messageId: "msg-explicit",
@@ -878,12 +1321,14 @@ describe("msteamsPlugin message actions", () => {
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
cfg: unrestrictedReadCfg,
actionParams: {
messageId: "msg-dm-react",
emoji: reactionType,
},
toolContext: {
currentChannelId: dmFallback,
currentChatType: "direct",
},
runtimeParams: {
to: dmFallback,
@@ -905,6 +1350,7 @@ describe("msteamsPlugin message actions", () => {
describe("msteamsPlugin.threading.buildToolContext", () => {
function callBuildToolContext(context: {
ChatType?: string;
To?: string;
NativeChannelId?: string;
ReplyToId?: string;
@@ -920,34 +1366,43 @@ describe("msteamsPlugin.threading.buildToolContext", () => {
});
}
it("uses NativeChannelId for channel turns so actions route via teamId/channelId", () => {
// Teams channel inbound messages carry the compound `teamId/channelId`
it("uses NativeChannelId for channel turns so actions route via Graph team/channel ids", () => {
// Teams channel inbound messages carry the compound Graph target
// on NativeChannelId. buildToolContext must prefer it over the bare
// `conversation:<id>` in To so action fallbacks route via
// `/teams/{teamId}/channels/{channelId}`.
const result = callBuildToolContext({
ChatType: "channel",
To: "conversation:19:channel-abc@thread.tacv2",
NativeChannelId: "team-1/19:channel-abc@thread.tacv2",
NativeChannelId: "graph-team-1/19:channel-abc@thread.tacv2",
ReplyToId: "reply-1",
});
expect(result?.currentChannelId).toBe("conversation:19:channel-abc@thread.tacv2");
expect(result?.currentGraphChannelId).toBe("team-1/19:channel-abc@thread.tacv2");
expect(result?.currentChatType).toBe("channel");
expect(result?.currentMessagingTarget).toBe("graph-team-1/19:channel-abc@thread.tacv2");
expect(result?.currentGraphChannelId).toBe("graph-team-1/19:channel-abc@thread.tacv2");
expect(result?.currentThreadTs).toBe("reply-1");
});
it("falls back to To for DM turns (no NativeChannelId)", () => {
const result = callBuildToolContext({
ChatType: "direct",
To: "user:aad-user-1",
});
expect(result?.currentChannelId).toBe("user:aad-user-1");
expect(result?.currentChatType).toBe("direct");
expect(result?.currentMessagingTarget).toBeUndefined();
expect(result?.currentGraphChannelId).toBeUndefined();
});
it("falls back to To for group chat turns (no NativeChannelId)", () => {
const result = callBuildToolContext({
ChatType: "group",
To: "conversation:19:groupchat@thread.v2",
});
expect(result?.currentChannelId).toBe("conversation:19:groupchat@thread.v2");
expect(result?.currentChatType).toBe("group");
expect(result?.currentMessagingTarget).toBeUndefined();
expect(result?.currentGraphChannelId).toBeUndefined();
});
@@ -960,6 +1415,7 @@ describe("msteamsPlugin.threading.buildToolContext", () => {
NativeChannelId: "19:chat@thread.v2",
});
expect(result?.currentChannelId).toBe("conversation:19:chat@thread.v2");
expect(result?.currentMessagingTarget).toBeUndefined();
expect(result?.currentGraphChannelId).toBeUndefined();
});
});
+163 -24
View File
@@ -48,6 +48,11 @@ import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import { buildMSTeamsPresentationCard, MSTEAMS_PRESENTATION_CAPABILITIES } from "./presentation.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
assertMSTeamsReadTargetAllowed,
assertMSTeamsTeamEnumerationAllowed,
isCurrentMSTeamsReadTarget,
} from "./read-policy.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
@@ -211,8 +216,38 @@ function resolveGraphActionTarget(
params: Record<string, unknown>,
currentChannelId?: string | null,
currentGraphChannelId?: string | null,
currentChatType?: "direct" | "group" | "channel" | null,
): string {
return resolveActionTarget(params, currentGraphChannelId ?? currentChannelId);
const explicitTarget = resolveActionTarget(params);
const currentChannelTarget = currentChannelId?.trim();
const currentGraphTarget = currentGraphChannelId?.trim();
if (explicitTarget) {
// Core materializes omitted action targets as currentChannelId before
// plugin dispatch. Restore the prepared Graph route for channel actions.
if (
currentChatType === "channel" &&
currentGraphTarget &&
currentChannelTarget &&
explicitTarget === currentChannelTarget
) {
return currentGraphTarget;
}
return explicitTarget;
}
if (currentGraphTarget) {
return currentGraphTarget;
}
return currentChatType === "channel" ? "" : (currentChannelTarget ?? "");
}
function resolveCurrentGraphActionTarget(toolContext?: {
currentGraphChannelId?: string;
currentMessagingTarget?: string;
}): string | undefined {
return (
normalizeOptionalString(toolContext?.currentGraphChannelId) ??
normalizeOptionalString(toolContext?.currentMessagingTarget)
);
}
function resolveActionMessageId(params: Record<string, unknown>): string {
@@ -265,6 +300,7 @@ function resolveRequiredActionTarget(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
}): string | ReturnType<typeof actionError> {
const to = params.graphOnly
@@ -272,6 +308,7 @@ function resolveRequiredActionTarget(params: {
params.toolParams,
params.currentChannelId,
params.currentGraphChannelId,
params.currentChatType,
)
: resolveActionTarget(params.toolParams, params.currentChannelId);
if (!to) {
@@ -285,6 +322,7 @@ function resolveRequiredActionMessageTarget(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
}): { to: string; messageId: string } | ReturnType<typeof actionError> {
const to = params.graphOnly
@@ -292,6 +330,7 @@ function resolveRequiredActionMessageTarget(params: {
params.toolParams,
params.currentChannelId,
params.currentGraphChannelId,
params.currentChatType,
)
: resolveActionTarget(params.toolParams, params.currentChannelId);
const messageId = resolveActionMessageId(params.toolParams);
@@ -306,6 +345,7 @@ function resolveRequiredActionPinnedMessageTarget(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
}): { to: string; pinnedMessageId: string } | ReturnType<typeof actionError> {
const to = params.graphOnly
@@ -313,6 +353,7 @@ function resolveRequiredActionPinnedMessageTarget(params: {
params.toolParams,
params.currentChannelId,
params.currentGraphChannelId,
params.currentChatType,
)
: resolveActionTarget(params.toolParams, params.currentChannelId);
const pinnedMessageId = resolveActionPinnedMessageId(params.toolParams);
@@ -327,6 +368,7 @@ async function runWithRequiredActionTarget<T>(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
run: (to: string) => Promise<T>;
}): Promise<T | ReturnType<typeof actionError>> {
@@ -335,6 +377,7 @@ async function runWithRequiredActionTarget<T>(params: {
toolParams: params.toolParams,
currentChannelId: params.currentChannelId,
currentGraphChannelId: params.currentGraphChannelId,
currentChatType: params.currentChatType,
graphOnly: params.graphOnly,
});
if (typeof to !== "string") {
@@ -348,6 +391,7 @@ async function runWithRequiredActionMessageTarget<T>(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
run: (target: { to: string; messageId: string }) => Promise<T>;
}): Promise<T | ReturnType<typeof actionError>> {
@@ -356,6 +400,7 @@ async function runWithRequiredActionMessageTarget<T>(params: {
toolParams: params.toolParams,
currentChannelId: params.currentChannelId,
currentGraphChannelId: params.currentGraphChannelId,
currentChatType: params.currentChatType,
graphOnly: params.graphOnly,
});
if ("isError" in target) {
@@ -369,6 +414,7 @@ async function runWithRequiredActionPinnedMessageTarget<T>(params: {
toolParams: Record<string, unknown>;
currentChannelId?: string | null;
currentGraphChannelId?: string | null;
currentChatType?: "direct" | "group" | "channel" | null;
graphOnly?: boolean;
run: (target: { to: string; pinnedMessageId: string }) => Promise<T>;
}): Promise<T | ReturnType<typeof actionError>> {
@@ -377,6 +423,7 @@ async function runWithRequiredActionPinnedMessageTarget<T>(params: {
toolParams: params.toolParams,
currentChannelId: params.currentChannelId,
currentGraphChannelId: params.currentGraphChannelId,
currentChatType: params.currentChatType,
graphOnly: params.graphOnly,
});
if ("isError" in target) {
@@ -800,10 +847,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { editMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await editMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
activityId: target.messageId,
text: content,
});
@@ -818,10 +870,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { deleteMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await deleteMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
activityId: target.messageId,
});
return jsonMSTeamsConversationResult(result.conversationId);
@@ -834,13 +891,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "Read",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { getMessageMSTeams } = await loadMSTeamsChannelRuntime();
const message = await getMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
messageId: target.messageId,
});
return jsonMSTeamsOkActionResult("read", { message });
@@ -853,13 +916,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "Pin",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { pinMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await pinMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
messageId: target.messageId,
});
return jsonMSTeamsActionResult("pin", result);
@@ -872,13 +941,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "Unpin",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { unpinMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await unpinMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
pinnedMessageId: target.pinnedMessageId,
});
return jsonMSTeamsActionResult("unpin", result);
@@ -891,11 +966,17 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "List-pins",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (to) => {
const allowedTarget = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: to,
});
const { listPinsMSTeams } = await loadMSTeamsChannelRuntime();
const result = await listPinsMSTeams({ cfg: ctx.cfg, to });
const result = await listPinsMSTeams({ cfg: ctx.cfg, to: allowedTarget });
return jsonMSTeamsOkActionResult("list-pins", result);
},
});
@@ -906,7 +987,8 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "React",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
@@ -926,11 +1008,16 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
},
};
}
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
if (remove) {
const { unreactMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await unreactMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
messageId: target.messageId,
reactionType: emoji,
});
@@ -943,7 +1030,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
const { reactMessageMSTeams } = await loadMSTeamsChannelRuntime();
const result = await reactMessageMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
messageId: target.messageId,
reactionType: emoji,
});
@@ -960,13 +1047,19 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "Reactions",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: target.to,
});
const { listReactionsMSTeams } = await loadMSTeamsChannelRuntime();
const result = await listReactionsMSTeams({
cfg: ctx.cfg,
to: target.to,
to,
messageId: target.messageId,
});
return jsonMSTeamsOkActionResult("reactions", result);
@@ -979,9 +1072,15 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
actionLabel: "Search",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: ctx.toolContext?.currentGraphChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (to) => {
const allowedTarget = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: to,
});
const query = resolveActionQuery(ctx.params);
if (!query) {
return actionError("Search requires a target (to) and query.");
@@ -992,7 +1091,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
const { searchMessagesMSTeams } = await loadMSTeamsChannelRuntime();
const result = await searchMessagesMSTeams({
cfg: ctx.cfg,
to,
to: allowedTarget,
query,
from: from || undefined,
limit,
@@ -1007,9 +1106,28 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
if (!userId) {
return actionError("member-info requires a userId.");
}
const { getMemberInfoMSTeams } = await loadMSTeamsChannelRuntime();
const result = await getMemberInfoMSTeams({ cfg: ctx.cfg, userId });
return jsonMSTeamsOkActionResult("member-info", result);
return await runWithRequiredActionTarget({
actionLabel: "member-info",
toolParams: ctx.params,
currentChannelId: ctx.toolContext?.currentChannelId,
currentGraphChannelId: resolveCurrentGraphActionTarget(ctx.toolContext),
currentChatType: ctx.toolContext?.currentChatType,
graphOnly: true,
run: async (target) => {
const to = await assertMSTeamsReadTargetAllowed({ cfg: ctx.cfg, ctx, target });
const currentRequesterId = isCurrentMSTeamsReadTarget({ ctx, target: to })
? ctx.requesterSenderId
: undefined;
const { getMemberInfoMSTeams } = await loadMSTeamsChannelRuntime();
const result = await getMemberInfoMSTeams({
cfg: ctx.cfg,
to,
userId,
currentRequesterId,
});
return jsonMSTeamsOkActionResult("member-info", result);
},
});
}
if (ctx.action === "channel-list") {
@@ -1017,8 +1135,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
if (!teamId) {
return actionError("channel-list requires a teamId.");
}
const graphTeamId = await assertMSTeamsTeamEnumerationAllowed({
cfg: ctx.cfg,
ctx,
teamId,
});
const { listChannelsMSTeams } = await loadMSTeamsChannelRuntime();
const result = await listChannelsMSTeams({ cfg: ctx.cfg, teamId });
const result = await listChannelsMSTeams({ cfg: ctx.cfg, teamId: graphTeamId });
return jsonMSTeamsOkActionResult("channel-list", result);
}
@@ -1028,11 +1151,20 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
if (!teamId || !channelId) {
return actionError("channel-info requires teamId and channelId.");
}
const graphTarget = await assertMSTeamsReadTargetAllowed({
cfg: ctx.cfg,
ctx,
target: `${teamId}/${channelId}`,
});
const [graphTeamId, graphChannelId] = graphTarget.split("/", 2);
if (!graphTeamId || !graphChannelId) {
throw new Error("Authorized Microsoft Teams channel target is invalid.");
}
const { getChannelInfoMSTeams } = await loadMSTeamsChannelRuntime();
const result = await getChannelInfoMSTeams({
cfg: ctx.cfg,
teamId,
channelId,
teamId: graphTeamId,
channelId: graphChannelId,
});
return jsonMSTeamsOkActionResult("channel-info", {
channelInfo: result.channel,
@@ -1195,6 +1327,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
const hasChannelRoute = Boolean(nativeChannelId && nativeChannelId.includes("/"));
return {
currentChannelId: normalizeOptionalString(context.To),
currentChatType:
context.ChatType === "direct" ||
context.ChatType === "group" ||
context.ChatType === "channel"
? context.ChatType
: undefined,
currentMessagingTarget: hasChannelRoute ? nativeChannelId : undefined,
currentGraphChannelId: hasChannelRoute ? nativeChannelId : undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
@@ -0,0 +1,56 @@
import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js";
import { fetchGraphJson } from "./graph.js";
export type MSTeamsConversationMember = {
id?: string;
userId?: string;
email?: string;
};
type GraphConversationMembersPage = {
value?: MSTeamsConversationMember[];
"@odata.nextLink"?: string;
};
const MAX_CONVERSATION_MEMBER_PAGES = 100;
export async function findMSTeamsConversationMember(params: {
includeIndirectChannelMembers?: boolean;
token: string;
to: string;
userId: string;
}): Promise<{
conversationId: string;
member: MSTeamsConversationMember | undefined;
}> {
const conversationId = await resolveGraphConversationId(params.to);
const conversation = resolveConversationPath(conversationId);
const collection =
conversation.kind === "channel" && params.includeIndirectChannelMembers
? "allMembers"
: "members";
let nextPath: string | undefined = `${conversation.basePath}/${collection}`;
let pages = 0;
let member: MSTeamsConversationMember | undefined;
while (nextPath && pages < MAX_CONVERSATION_MEMBER_PAGES && !member) {
const response: GraphConversationMembersPage =
await fetchGraphJson<GraphConversationMembersPage>({
token: params.token,
path: nextPath,
});
const userId = params.userId.trim().toLowerCase();
member = (response.value ?? []).find(
(candidate) =>
candidate.userId?.trim().toLowerCase() === userId ||
candidate.email?.trim().toLowerCase() === userId,
);
nextPath = response["@odata.nextLink"]?.replace("https://graph.microsoft.com/v1.0", "");
pages += 1;
}
if (nextPath && !member) {
throw new Error("MS Teams conversation member pagination limit exceeded");
}
return { conversationId, member };
}
@@ -56,7 +56,7 @@ describe("addParticipantMSTeams", () => {
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("adds member to a chat with default role", async () => {
it("maps the default chat member role to Graph owner", async () => {
mockState.postGraphJson.mockResolvedValue({});
const result = await addParticipantMSTeams({
@@ -71,7 +71,7 @@ describe("addParticipantMSTeams", () => {
path: `/chats/${encodeURIComponent(CHAT_ID)}/members`,
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["member"],
roles: ["owner"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-1')",
},
});
@@ -163,7 +163,7 @@ describe("addParticipantMSTeams", () => {
);
});
it("adds member to a channel", async () => {
it("maps the default channel member role to an empty Graph role list", async () => {
mockState.postGraphJson.mockResolvedValue({});
const result = await addParticipantMSTeams({
@@ -178,11 +178,32 @@ describe("addParticipantMSTeams", () => {
path: "/teams/team-id-1/channels/channel-id-1/members",
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["member"],
roles: [],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-3')",
},
});
});
it("preserves the owner role for a channel", async () => {
mockState.postGraphJson.mockResolvedValue({});
await addParticipantMSTeams({
cfg: {} as OpenClawConfig,
to: CHANNEL_TO,
userId: "user-aad-id-4",
role: "owner",
});
expect(mockState.postGraphJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/members",
body: {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: ["owner"],
"user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-4')",
},
});
});
});
describe("removeParticipantMSTeams", () => {
@@ -1,10 +1,10 @@
// Msteams plugin module implements graph group management behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { findMSTeamsConversationMember } from "./graph-conversation-members.js";
import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js";
import {
deleteGraphRequest,
escapeOData,
fetchGraphJson,
patchGraphJson,
postGraphJson,
resolveGraphToken,
@@ -38,6 +38,19 @@ function normalizeConversationMemberRole(role: string | undefined): Conversation
throw new Error('MS Teams participant role must be "member" or "owner".');
}
function resolveConversationMemberRoles(
role: string | undefined,
kind: "chat" | "channel",
): ConversationMemberRole[] {
const normalized = normalizeConversationMemberRole(role);
if (kind === "chat") {
// Graph accepts chat additions only as owners; "member" is the public
// convenience role and maps to the provider's required representation.
return ["owner"];
}
return normalized === "owner" ? ["owner"] : [];
}
/**
* Add a user to a chat or channel via Graph API.
*/
@@ -50,7 +63,7 @@ export async function addParticipantMSTeams(
const body = {
"@odata.type": "#microsoft.graph.aadUserConversationMember",
roles: [normalizeConversationMemberRole(params.role)],
roles: resolveConversationMemberRoles(params.role, conv.kind),
"user@odata.bind": `https://graph.microsoft.com/v1.0/users('${escapeOData(params.userId)}')`,
};
@@ -77,16 +90,6 @@ type RemoveParticipantMSTeamsResult = {
removed: { userId: string; chatId: string };
};
type GraphConversationMember = {
id?: string;
userId?: string;
};
type GraphConversationMemberResponse = {
value?: GraphConversationMember[];
"@odata.nextLink"?: string;
};
/**
* Remove a user from a chat or channel via Graph API.
* Lists members first to resolve the membership ID, then deletes.
@@ -95,35 +98,15 @@ export async function removeParticipantMSTeams(
params: RemoveParticipantMSTeamsParams,
): Promise<RemoveParticipantMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const conversationId = await resolveGraphConversationId(params.to);
const conv = resolveConversationPath(conversationId);
// List members to find the membership ID for the target user. Graph can
// paginate large chats/channels, so walk `@odata.nextLink` before concluding
// the user is missing.
const MAX_PAGES = 10;
let nextPath: string | undefined = `${conv.basePath}/members`;
let page = 0;
let member: GraphConversationMember | undefined;
while (nextPath && page < MAX_PAGES && !member) {
const membersRes: GraphConversationMemberResponse =
await fetchGraphJson<GraphConversationMemberResponse>({
token,
path: nextPath,
});
member = (membersRes.value ?? []).find(
(candidate: GraphConversationMember) => candidate.userId === params.userId,
);
if (member) {
break;
}
const nextLink: string | undefined = membersRes["@odata.nextLink"];
nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined;
page++;
}
const { conversationId, member } = await findMSTeamsConversationMember({
token,
to: params.to,
userId: params.userId,
});
if (!member?.id) {
throw new Error(`User ${params.userId} is not a member of this conversation`);
}
const conv = resolveConversationPath(conversationId);
await deleteGraphRequest({
token,
+158 -17
View File
@@ -23,18 +23,23 @@ describe("getMemberInfoMSTeams", () => {
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
});
it("fetches user profile and maps all fields", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "user-123",
displayName: "Alice Smith",
mail: "alice@contoso.com",
jobTitle: "Engineer",
userPrincipalName: "alice@contoso.com",
officeLocation: "Building 1",
});
it("returns verified standard-channel roster fields", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({ membershipType: "standard" })
.mockResolvedValueOnce({
value: [
{
userId: "user-123",
displayName: "Alice Smith",
email: "alice@contoso.com",
roles: ["owner"],
},
],
});
const result = await getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "graph-team-1/channel-1",
userId: "user-123",
});
@@ -43,25 +48,61 @@ describe("getMemberInfoMSTeams", () => {
id: "user-123",
displayName: "Alice Smith",
mail: "alice@contoso.com",
jobTitle: "Engineer",
jobTitle: undefined,
userPrincipalName: "alice@contoso.com",
officeLocation: "Building 1",
officeLocation: undefined,
roles: ["owner"],
},
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(1, {
token: TOKEN,
path: `/users/${encodeURIComponent("user-123")}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`,
path: "/teams/graph-team-1/channels/channel-1?$select=membershipType",
});
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(2, {
token: TOKEN,
path: "/teams/graph-team-1/members",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2);
});
it("keeps roster-backed fields for the current requester in a channel", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({ membershipType: "standard" })
.mockResolvedValueOnce({
value: [
{
userId: "user-123",
displayName: "Alice Smith",
email: "alice@contoso.com",
},
],
});
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "graph-team-1/channel-1",
userId: "user-123",
currentRequesterId: "user-123",
}),
).resolves.toMatchObject({
user: {
id: "user-123",
displayName: "Alice Smith",
mail: "alice@contoso.com",
},
});
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2);
});
it("handles sparse data with some fields undefined", async () => {
mockState.fetchGraphJson.mockResolvedValue({
id: "user-456",
displayName: "Bob",
});
mockState.fetchGraphJson
.mockResolvedValueOnce({ membershipType: "standard" })
.mockResolvedValueOnce({ value: [{ userId: "user-456", displayName: "Bob" }] });
const result = await getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "team-1/channel-1",
userId: "user-456",
});
@@ -73,18 +114,118 @@ describe("getMemberInfoMSTeams", () => {
jobTitle: undefined,
userPrincipalName: undefined,
officeLocation: undefined,
roles: [],
},
});
});
it("canonicalizes a user principal name before checking conversation membership", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({ membershipType: "standard" })
.mockResolvedValueOnce({
value: [
{
userId: "aad-user-123",
email: "alice@contoso.com",
},
],
});
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "team-1/channel-1",
userId: "alice@contoso.com",
}),
).resolves.toMatchObject({
user: {
id: "aad-user-123",
userPrincipalName: "alice@contoso.com",
},
});
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(1, {
token: TOKEN,
path: "/teams/team-1/channels/channel-1?$select=membershipType",
});
expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(2, {
token: TOKEN,
path: "/teams/team-1/members",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2);
});
it("propagates Graph API errors", async () => {
mockState.fetchGraphJson.mockRejectedValue(new Error("Graph API 404: user not found"));
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "team-1/channel-1",
userId: "nonexistent-user",
}),
).rejects.toThrow("Graph API 404: user not found");
});
it("does not return profiles for users outside the conversation", async () => {
mockState.fetchGraphJson
.mockResolvedValueOnce({ membershipType: "standard" })
.mockResolvedValueOnce({ value: [] });
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "team-1/channel-1",
userId: "user-789",
}),
).rejects.toThrow("User user-789 is not a member of this conversation");
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2);
});
it("rejects private channels when the baseline cannot prove channel membership", async () => {
mockState.fetchGraphJson.mockResolvedValueOnce({ membershipType: "private" });
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "team-1/channel-private",
userId: "user-123",
}),
).rejects.toThrow("requires a standard channel");
expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(1);
});
it("returns the trusted requester identity in the current chat without Graph reads", async () => {
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "user:user-123",
userId: "teams:user-123",
currentRequesterId: "user-123",
}),
).resolves.toMatchObject({
user: {
id: "user-123",
displayName: undefined,
mail: undefined,
jobTitle: undefined,
userPrincipalName: undefined,
officeLocation: undefined,
roles: [],
},
});
expect(mockState.resolveGraphToken).not.toHaveBeenCalled();
expect(mockState.fetchGraphJson).not.toHaveBeenCalled();
});
it("rejects unrelated profiles in chats before fetching a user", async () => {
await expect(
getMemberInfoMSTeams({
cfg: {} as OpenClawConfig,
to: "conversation:19:chat@thread.v2",
userId: "user-456",
currentRequesterId: "user-123",
}),
).rejects.toThrow("User user-456 is not a member of this conversation");
expect(mockState.fetchGraphJson).not.toHaveBeenCalled();
});
});
+108 -18
View File
@@ -1,19 +1,13 @@
// Msteams plugin module implements graph members behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js";
import { fetchGraphJson, resolveGraphToken } from "./graph.js";
type GraphUserProfile = {
id?: string;
displayName?: string;
mail?: string;
jobTitle?: string;
userPrincipalName?: string;
officeLocation?: string;
};
type GetMemberInfoMSTeamsParams = {
cfg: OpenClawConfig;
to: string;
userId: string;
currentRequesterId?: string | null;
};
type GetMemberInfoMSTeamsResult = {
@@ -24,26 +18,122 @@ type GetMemberInfoMSTeamsResult = {
jobTitle: string | undefined;
userPrincipalName: string | undefined;
officeLocation: string | undefined;
roles: string[];
};
};
type GraphConversationMember = {
displayName?: string;
userId?: string;
email?: string;
roles?: string[];
};
type GraphConversationMembersPage = {
value?: GraphConversationMember[];
"@odata.nextLink"?: string;
};
const MAX_TEAM_MEMBER_PAGES = 100;
function normalizeUserId(value?: string | null): string {
return (
value
?.replace(/^(msteams|teams|user):/i, "")
.trim()
.toLowerCase() ?? ""
);
}
async function findStandardChannelMember(params: {
token: string;
to: string;
userId: string;
}): Promise<GraphConversationMember | undefined> {
const conversationId = await resolveGraphConversationId(params.to);
const conversation = resolveConversationPath(conversationId);
if (conversation.kind !== "channel" || !conversation.teamId) {
return undefined;
}
const channel = await fetchGraphJson<{ membershipType?: string }>({
token: params.token,
path: `${conversation.basePath}?$select=membershipType`,
});
if (channel.membershipType !== "standard") {
throw new Error(
"Microsoft Teams member-info requires a standard channel when using the configured permission baseline.",
);
}
const requestedUserId = normalizeUserId(params.userId);
let nextPath: string | undefined = `/teams/${encodeURIComponent(conversation.teamId)}/members`;
let pages = 0;
while (nextPath && pages < MAX_TEAM_MEMBER_PAGES) {
const response: GraphConversationMembersPage =
await fetchGraphJson<GraphConversationMembersPage>({
token: params.token,
path: nextPath,
});
const member = (response.value ?? []).find(
(candidate) =>
normalizeUserId(candidate.userId) === requestedUserId ||
normalizeUserId(candidate.email) === requestedUserId,
);
if (member) {
return member;
}
nextPath = response["@odata.nextLink"]?.replace("https://graph.microsoft.com/v1.0", "");
pages += 1;
}
if (nextPath) {
throw new Error("Microsoft Teams team member pagination limit exceeded");
}
return undefined;
}
/**
* Fetch a user profile from Microsoft Graph by user ID.
*/
export async function getMemberInfoMSTeams(
params: GetMemberInfoMSTeamsParams,
): Promise<GetMemberInfoMSTeamsResult> {
const token = await resolveGraphToken(params.cfg);
const path = `/users/${encodeURIComponent(params.userId)}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`;
const user = await fetchGraphJson<GraphUserProfile>({ token, path });
const isCurrentRequester =
normalizeUserId(params.userId) === normalizeUserId(params.currentRequesterId);
if (isCurrentRequester && resolveConversationPath(params.to).kind === "chat") {
return {
user: {
id: params.currentRequesterId ?? undefined,
displayName: undefined,
mail: undefined,
jobTitle: undefined,
userPrincipalName: undefined,
officeLocation: undefined,
roles: [],
},
};
}
const conversationId = await resolveGraphConversationId(params.to);
const conversation = resolveConversationPath(conversationId);
const member =
conversation.kind === "channel"
? await findStandardChannelMember({
token: await resolveGraphToken(params.cfg),
to: params.to,
userId: params.userId,
})
: undefined;
if (!member?.userId) {
throw new Error(`User ${params.userId} is not a member of this conversation`);
}
return {
user: {
id: user.id,
displayName: user.displayName,
mail: user.mail,
jobTitle: user.jobTitle,
userPrincipalName: user.userPrincipalName,
officeLocation: user.officeLocation,
id: member.userId,
displayName: member.displayName,
mail: member.email,
jobTitle: undefined,
userPrincipalName: member.email,
officeLocation: undefined,
roles: member.roles ?? [],
},
};
}
@@ -133,7 +133,7 @@ describe("reactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
body: { reactionType: "like" },
body: { reactionType: "👍" },
});
});
@@ -151,11 +151,11 @@ describe("reactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction",
body: { reactionType: "heart" },
body: { reactionType: "❤️" },
});
});
it("normalizes reaction type to lowercase", async () => {
it("normalizes a case-insensitive reaction name to Unicode", async () => {
mockState.postGraphBetaJson.mockResolvedValue(undefined);
await reactMessageMSTeams({
@@ -168,14 +168,12 @@ describe("reactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
body: { reactionType: "laugh" },
body: { reactionType: "😆" },
});
});
it("passes through non-well-known reaction types (e.g. Unicode emoji)", async () => {
// Graph setReaction accepts arbitrary Unicode emoji plus the legacy
// well-known types; normalizeReactionType only lowercases the legacy set
// and lets any other non-empty value through unchanged.
// Graph setReaction accepts Unicode values outside the named convenience set.
mockState.postGraphBetaJson.mockResolvedValue(undefined);
await reactMessageMSTeams({
@@ -210,7 +208,7 @@ describe("reactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`,
body: { reactionType: "like" },
body: { reactionType: "👍" },
});
});
});
@@ -230,7 +228,7 @@ describe("unreactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`,
body: { reactionType: "sad" },
body: { reactionType: "😢" },
});
});
@@ -248,7 +246,7 @@ describe("unreactMessageMSTeams", () => {
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
token: TOKEN,
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction",
body: { reactionType: "angry" },
body: { reactionType: "😡" },
});
});
});
@@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../runtime-api.js";
import {
CHANNEL_TO,
CHAT_ID,
TOKEN,
type GraphMessagesTestModule,
getGraphMessagesMockState,
installGraphMessagesMockDefaults,
@@ -19,27 +20,27 @@ beforeAll(async () => {
});
function readFirstGraphPath(): string {
const [call] = mockState.fetchGraphJson.mock.calls;
if (!call) {
throw new Error("Expected Graph fetch call");
}
const [request] = call;
if (!request || typeof request !== "object" || typeof request.path !== "string") {
const request = mockState.fetchGraphJson.mock.calls[0]?.[0];
if (!request || typeof request.path !== "string") {
throw new Error("Expected Graph fetch request path");
}
return request.path;
}
describe("searchMessagesMSTeams", () => {
it("searches chat messages with query string", async () => {
it("filters chat messages locally and normalizes HTML content", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "msg-1",
body: { content: "Meeting notes from Monday" },
body: { content: "<p>Meeting <b>notes</b> from Monday</p>", contentType: "html" },
from: { user: { id: "u1", displayName: "Alice" } },
createdDateTime: "2026-03-25T10:00:00Z",
},
{
id: "msg-2",
body: { content: "Unrelated update", contentType: "text" },
},
],
});
@@ -49,32 +50,23 @@ describe("searchMessagesMSTeams", () => {
query: "meeting notes",
});
expect(result.messages).toEqual([
{
id: "msg-1",
text: "Meeting notes from Monday",
from: { user: { id: "u1", displayName: "Alice" } },
createdAt: "2026-03-25T10:00:00Z",
},
]);
const calledPath = readFirstGraphPath();
expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`);
expect(calledPath).toContain("$search=");
expect(calledPath).toContain("$top=25");
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain('$search="meeting notes"');
});
it("searches channel messages", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
expect(result).toEqual({
messages: [
{
id: "msg-2",
body: { content: "Sprint review" },
from: { user: { id: "u2", displayName: "Bob" } },
createdDateTime: "2026-03-25T11:00:00Z",
id: "msg-1",
text: "<p>Meeting <b>notes</b> from Monday</p>",
from: { user: { id: "u1", displayName: "Alice" } },
createdAt: "2026-03-25T10:00:00Z",
},
],
truncated: false,
});
expect(readFirstGraphPath()).toBe(`/chats/${encodeURIComponent(CHAT_ID)}/messages?$top=50`);
});
it("keeps channel search scoped to the selected channel", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [{ id: "msg-2", body: { content: "Sprint review" } }],
});
const result = await searchMessagesMSTeams({
@@ -84,129 +76,139 @@ describe("searchMessagesMSTeams", () => {
});
expect(result.messages).toHaveLength(1);
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?");
expect(readFirstGraphPath()).toBe("/teams/team-id-1/channels/channel-id-1/messages?$top=50");
});
it("applies limit parameter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 10,
it("follows target-scoped pagination and applies sender matching locally", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "wrong-sender",
body: { content: "budget update" },
from: { user: { id: "u1", displayName: "Bob" } },
},
],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/next-page",
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=10");
});
it("clamps limit to max 50", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 100,
mockState.fetchGraphAbsoluteUrl.mockResolvedValue({
value: [
{
id: "right-sender",
body: { content: "Budget update" },
from: { application: { id: "app-1", displayName: "Finance Bot" } },
},
],
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=50");
});
it("clamps limit to min 1", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
limit: 0,
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$top=1");
});
it("applies from filter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "budget",
from: "Alice",
});
const calledPath = readFirstGraphPath();
expect(calledPath).toContain("$filter=");
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain("from/user/displayName eq 'Alice'");
});
it("escapes single quotes in from filter", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
from: "O'Brien",
});
const calledPath = readFirstGraphPath();
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain("O''Brien");
});
it("strips double quotes from query to prevent injection", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: 'say "hello" world',
});
const calledPath = readFirstGraphPath();
const decoded = decodeURIComponent(calledPath);
expect(decoded).toContain('$search="say hello world"');
expect(decoded).not.toContain('""');
});
it("passes ConsistencyLevel: eventual header", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "test",
});
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
token: "test-graph-token",
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages?$search=${encodeURIComponent(
'"test"',
)}&$top=25`,
headers: { ConsistencyLevel: "eventual" },
});
});
it("returns empty array when no messages match", async () => {
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "nonexistent",
query: "BUDGET",
from: "finance bot",
});
expect(result.messages).toStrictEqual([]);
expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledWith({
token: TOKEN,
url: "https://graph.microsoft.com/v1.0/next-page",
});
expect(result).toEqual({
messages: [
{
id: "right-sender",
text: "Budget update",
from: { application: { id: "app-1", displayName: "Finance Bot" } },
createdAt: undefined,
},
],
truncated: false,
});
});
it("resolves user: target through conversation store", async () => {
it("matches the sender by stable ID", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{
id: "msg-1",
body: { content: "hello" },
from: { user: { id: "aad-user-1", displayName: "Alice" } },
},
],
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "hello",
from: "AAD-USER-1",
});
expect(result.messages).toHaveLength(1);
});
it("stops at the requested result limit and reports remaining pages", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [
{ id: "msg-1", body: { content: "match" } },
{ id: "msg-2", body: { content: "match" } },
],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/next-page",
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "match",
limit: 1,
});
expect(result.messages.map((message) => message.id)).toEqual(["msg-1"]);
expect(result.truncated).toBe(true);
expect(mockState.fetchGraphAbsoluteUrl).not.toHaveBeenCalled();
});
it("clamps a non-finite limit to the default", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: Array.from({ length: 30 }, (_, index) => ({
id: `msg-${index}`,
body: { content: "match" },
})),
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "match",
limit: Number.POSITIVE_INFINITY,
});
expect(result.messages).toHaveLength(25);
expect(result.truncated).toBe(true);
});
it("reports truncation after the bounded ten-page scan", async () => {
mockState.fetchGraphJson.mockResolvedValue({
value: [],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/page-2",
});
mockState.fetchGraphAbsoluteUrl.mockImplementation(async ({ url }: { url: string }) => {
const page = Number(url.match(/page-(\d+)/)?.[1] ?? "2");
return {
value: [],
"@odata.nextLink": `https://graph.microsoft.com/v1.0/page-${page + 1}`,
};
});
const result = await searchMessagesMSTeams({
cfg: {} as OpenClawConfig,
to: CHAT_ID,
query: "missing",
});
expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledTimes(9);
expect(result).toEqual({ messages: [], truncated: true });
});
it("resolves user targets before reading messages", async () => {
mockState.findPreferredDmByUserId.mockResolvedValue({
conversationId: "19:dm-chat@thread.tacv2",
reference: {},
@@ -220,9 +222,8 @@ describe("searchMessagesMSTeams", () => {
});
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
const calledPath = readFirstGraphPath();
expect(calledPath).toContain(
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`,
expect(readFirstGraphPath()).toBe(
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?$top=50`,
);
});
});
+69 -65
View File
@@ -1,16 +1,16 @@
// Msteams plugin module implements graph messages behavior.
import type { OpenClawConfig } from "../runtime-api.js";
import { createMSTeamsConversationStoreState } from "./conversation-store-state.js";
import { stripHtmlFromTeamsMessage } from "./graph-thread.js";
import {
type GraphResponse,
deleteGraphRequest,
escapeOData,
fetchGraphAbsoluteUrl,
fetchGraphJson,
postGraphBetaJson,
postGraphJson,
resolveGraphToken,
} from "./graph.js";
import { getMSTeamsReactionEmoji, resolveMSTeamsReactionEmoji } from "./reaction-types.js";
type GraphMessageBody = {
content?: string;
@@ -298,9 +298,6 @@ export async function listPinsMSTeams(
// Reactions
// ---------------------------------------------------------------------------
const TEAMS_REACTION_TYPES = ["like", "heart", "laugh", "surprised", "sad", "angry"] as const;
type TeamsReactionType = (typeof TEAMS_REACTION_TYPES)[number];
type GraphReaction = {
reactionType?: string;
user?: { id?: string; displayName?: string };
@@ -324,16 +321,6 @@ type ListReactionsMSTeamsParams = {
messageId: string;
};
/** Map well-known reaction type names to representative emoji for CLI display. */
const REACTION_TYPE_EMOJI: Record<string, string> = {
like: "\u{1F44D}",
heart: "\u2764\uFE0F",
laugh: "\u{1F606}",
surprised: "\u{1F62E}",
sad: "\u{1F622}",
angry: "\u{1F621}",
};
type ReactionSummary = {
reactionType: string;
/** Display name for the reaction (matches reactionType for known types). */
@@ -348,25 +335,6 @@ type ListReactionsMSTeamsResult = {
reactions: ReactionSummary[];
};
/**
* Normalize a reaction type string. Graph setReaction/unsetReaction accepts
* the well-known legacy names (like, heart, laugh, surprised, sad, angry)
* as well as Unicode emoji values so we pass unknown types through rather
* than rejecting them.
*/
function normalizeReactionType(raw: string): string {
const normalized = raw.trim();
if (!normalized) {
throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`);
}
// Lowercase only the well-known names; Unicode emoji should pass through as-is
const lowered = normalized.toLowerCase();
if (TEAMS_REACTION_TYPES.includes(lowered as TeamsReactionType)) {
return lowered;
}
return normalized;
}
/**
* Add an emoji reaction to a message via Graph API (beta).
*
@@ -378,7 +346,7 @@ function normalizeReactionType(raw: string): string {
export async function reactMessageMSTeams(
params: ReactMessageMSTeamsParams,
): Promise<{ ok: true }> {
const reactionType = normalizeReactionType(params.reactionType);
const reactionType = resolveMSTeamsReactionEmoji(params.reactionType);
const token = await resolveGraphToken(params.cfg, { preferDelegated: true });
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
@@ -396,7 +364,7 @@ export async function reactMessageMSTeams(
export async function unreactMessageMSTeams(
params: ReactMessageMSTeamsParams,
): Promise<{ ok: true }> {
const reactionType = normalizeReactionType(params.reactionType);
const reactionType = resolveMSTeamsReactionEmoji(params.reactionType);
const token = await resolveGraphToken(params.cfg, { preferDelegated: true });
const conversationId = await resolveGraphConversationId(params.to);
const { basePath } = resolveConversationPath(conversationId);
@@ -442,7 +410,7 @@ export async function listReactionsMSTeams(
const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, group]) => ({
reactionType: type,
name: type,
emoji: REACTION_TYPE_EMOJI[type],
emoji: getMSTeamsReactionEmoji(type),
count: group.count,
users: group.users,
}));
@@ -469,14 +437,41 @@ type SearchMessagesMSTeamsResult = {
from: GraphMessageFrom | undefined;
createdAt: string | undefined;
}>;
truncated: boolean;
};
const SEARCH_DEFAULT_LIMIT = 25;
const SEARCH_MAX_LIMIT = 50;
const SEARCH_PAGE_SIZE = 50;
const SEARCH_MAX_PAGES = 10;
type GraphMessagesPage = {
value?: GraphMessage[];
"@odata.nextLink"?: string;
};
function normalizeSearchText(message: GraphMessage): string {
const content = message.body?.content ?? "";
return message.body?.contentType?.toLowerCase() === "html"
? stripHtmlFromTeamsMessage(content)
: content.trim();
}
function matchesSearchSender(message: GraphMessage, from: string | undefined): boolean {
const normalized = from?.trim().toLowerCase();
if (!normalized) {
return true;
}
const sender = message.from?.user ?? message.from?.application;
return [sender?.id, sender?.displayName].some(
(value) => value?.trim().toLowerCase() === normalized,
);
}
/**
* Search messages in a chat or channel by content via Graph API.
* Uses `$search` for full-text body search and optional `$filter` for sender.
* Search messages within one already-authorized chat or channel.
* Graph does not support collection `$search` here, so filter bounded pages
* locally without widening the read to the account's global message index.
*/
export async function searchMessagesMSTeams(
params: SearchMessagesMSTeamsParams,
@@ -489,34 +484,43 @@ export async function searchMessagesMSTeams(
const top = Number.isFinite(rawLimit)
? Math.min(Math.max(Math.floor(rawLimit), 1), SEARCH_MAX_LIMIT)
: SEARCH_DEFAULT_LIMIT;
const query = params.query.trim().toLowerCase();
const messages: SearchMessagesMSTeamsResult["messages"] = [];
let nextUrl: string | undefined;
let truncated = false;
// Strip double quotes from the query to prevent OData $search injection
const sanitizedQuery = params.query.replace(/"/g, "");
for (let page = 0; page < SEARCH_MAX_PAGES; page++) {
const response: GraphMessagesPage = nextUrl
? await fetchGraphAbsoluteUrl<GraphMessagesPage>({ token, url: nextUrl })
: await fetchGraphJson<GraphMessagesPage>({
token,
path: `${basePath}/messages?$top=${SEARCH_PAGE_SIZE}`,
});
// Build query string manually (not URLSearchParams) to preserve literal $
// in OData parameter names, consistent with other Graph calls in this module.
const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`];
parts.push(`$top=${top}`);
if (params.from) {
parts.push(
`$filter=${encodeURIComponent(`from/user/displayName eq '${escapeOData(params.from)}'`)}`,
);
for (const message of response.value ?? []) {
const searchText = normalizeSearchText(message);
if (searchText.toLowerCase().includes(query) && matchesSearchSender(message, params.from)) {
if (messages.length >= top) {
return { messages, truncated: true };
}
messages.push({
id: message.id ?? "",
text: message.body?.content,
from: message.from,
createdAt: message.createdDateTime,
});
}
}
nextUrl = response["@odata.nextLink"];
if (messages.length >= top) {
return { messages, truncated: Boolean(nextUrl) };
}
if (!nextUrl) {
return { messages, truncated: false };
}
truncated = page === SEARCH_MAX_PAGES - 1;
}
const path = `${basePath}/messages?${parts.join("&")}`;
// ConsistencyLevel: eventual is required by Graph API for $search queries
const res = await fetchGraphJson<GraphResponse<GraphMessage>>({
token,
path,
headers: { ConsistencyLevel: "eventual" },
});
const messages = (res.value ?? []).map((msg) => ({
id: msg.id ?? "",
text: msg.body?.content,
from: msg.from,
createdAt: msg.createdDateTime,
}));
return { messages };
return { messages, truncated };
}
+26 -1
View File
@@ -1,5 +1,12 @@
// Msteams plugin module implements graph users behavior.
import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js";
import {
escapeOData,
fetchAllGraphPages,
fetchGraphJson,
type GraphResponse,
type GraphUser,
type PaginatedResult,
} from "./graph.js";
export async function searchGraphUsers(params: {
token: string;
@@ -28,3 +35,21 @@ export async function searchGraphUsers(params: {
});
return res.value ?? [];
}
export async function findGraphUsersByExactIdentity(params: {
token: string;
query: string;
}): Promise<PaginatedResult<GraphUser>> {
const query = params.query.trim();
if (!query) {
return { items: [], truncated: false };
}
const escaped = escapeOData(query);
const filter =
`(displayName eq '${escaped}' or mail eq '${escaped}' or ` +
`userPrincipalName eq '${escaped}')`;
const path =
`/users?$filter=${encodeURIComponent(filter)}` +
"&$select=id,displayName,mail,userPrincipalName";
return await fetchAllGraphPages<GraphUser>({ token: params.token, path });
}
+59 -1
View File
@@ -44,7 +44,7 @@ vi.mock("../runtime-api.js", async (importOriginal) => {
};
});
import { searchGraphUsers } from "./graph-users.js";
import { findGraphUsersByExactIdentity, searchGraphUsers } from "./graph-users.js";
import {
deleteGraphRequest,
escapeOData,
@@ -52,7 +52,9 @@ import {
fetchGraphAbsoluteUrl,
fetchGraphJson,
listChannelsForTeam,
listChannelsForTeamWithPageInfo,
listTeamsByName,
listTeamsByNameWithPageInfo,
normalizeQuery,
postGraphBetaJson,
postGraphJson,
@@ -222,6 +224,17 @@ describe("msteams graph helpers", () => {
expect(escapeOData("alice.o'hara")).toBe("alice.o''hara");
});
it("lets the shared SSRF guard select the Graph transport", async () => {
mockGraphCollection();
await fetchGraphJson({
token: graphToken,
path: "/groups",
});
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl");
});
it("fetches Graph JSON and surfaces Graph errors with response text", async () => {
mockGraphCollection(groupOne);
@@ -426,6 +439,51 @@ describe("msteams graph helpers", () => {
expectFetchPathContains(1, "/teams/team%2Fops/channels?$select=id,displayName");
});
it("exposes pagination completeness for authorization lookups", async () => {
mockFetch(async (input) => {
const url = requestUrl(input);
if (url.includes("/groups?$skip=1")) {
return jsonResponse(graphCollection({ id: "team-2", displayName: "Ops" }));
}
if (url.includes("/groups?")) {
return jsonResponse({
value: [opsTeam],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/groups?$skip=1",
});
}
if (url.includes("/channels?$skip=1")) {
return jsonResponse(graphCollection({ id: "channel-2", displayName: "Incidents" }));
}
return jsonResponse({
value: [deploymentsChannel],
"@odata.nextLink": "https://graph.microsoft.com/v1.0/teams/team-1/channels?$skip=1",
});
});
await expect(listTeamsByNameWithPageInfo(graphToken, "Ops")).resolves.toEqual({
items: [opsTeam, { id: "team-2", displayName: "Ops" }],
truncated: false,
});
await expect(listChannelsForTeamWithPageInfo(graphToken, "team-1")).resolves.toEqual({
items: [deploymentsChannel, { id: "channel-2", displayName: "Incidents" }],
truncated: false,
});
});
it("builds an exact identity filter for authorization user lookup", async () => {
mockGraphCollection(userOne);
await expect(
findGraphUsersByExactIdentity({
token: graphToken,
query: "Alice O'Hara",
}),
).resolves.toEqual({ items: [userOne], truncated: false });
expect(fetchCallSearchParam(0, "$filter")).toBe(
"(displayName eq 'Alice O''Hara' or mail eq 'Alice O''Hara' or userPrincipalName eq 'Alice O''Hara')",
);
});
it("returns no graph users for blank queries", async () => {
mockJsonFetchResponse({});
await expectSearchGraphUsers(" ", [], { token: "token-1" });
+19 -9
View File
@@ -24,12 +24,12 @@ export type GraphUser = {
mail?: string;
};
type GraphGroup = {
export type GraphGroup = {
id?: string;
displayName?: string;
};
type GraphChannel = {
export type GraphChannel = {
id?: string;
displayName?: string;
};
@@ -56,10 +56,8 @@ async function requestGraph(params: {
}): Promise<Response> {
const hasBody = params.body !== undefined;
const url = `${params.root ?? GRAPH_ROOT}${params.path}`;
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: params.method,
headers: {
@@ -159,7 +157,7 @@ type GraphPagedResponse<T> = {
};
/** Result of a paginated Graph API fetch. */
type PaginatedResult<T> = {
export type PaginatedResult<T> = {
items: T[];
truncated: boolean;
found?: T;
@@ -254,11 +252,17 @@ export async function resolveGraphToken(
}
export async function listTeamsByName(token: string, query: string): Promise<GraphGroup[]> {
return (await listTeamsByNameWithPageInfo(token, query)).items;
}
export async function listTeamsByNameWithPageInfo(
token: string,
query: string,
): Promise<PaginatedResult<GraphGroup>> {
const escaped = escapeOData(query);
const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`;
const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`;
const { items } = await fetchAllGraphPages<GraphGroup>({ token, path, maxPages: 5 });
return items;
return await fetchAllGraphPages<GraphGroup>({ token, path });
}
export async function postGraphJson<T>(params: {
@@ -317,7 +321,13 @@ export async function patchGraphJson<T>(params: {
}
export async function listChannelsForTeam(token: string, teamId: string): Promise<GraphChannel[]> {
return (await listChannelsForTeamWithPageInfo(token, teamId)).items;
}
export async function listChannelsForTeamWithPageInfo(
token: string,
teamId: string,
): Promise<PaginatedResult<GraphChannel>> {
const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`;
const { items } = await fetchAllGraphPages<GraphChannel>({ token, path, maxPages: 10 });
return items;
return await fetchAllGraphPages<GraphChannel>({ token, path });
}
@@ -21,7 +21,9 @@ type TestAttachment = {
const runtimeApiMockState = getRuntimeApiMockState();
const graphThreadMockState = vi.hoisted(() => ({
resolveTeamGroupId: vi.fn(async () => "group-1"),
resolveTeamGroupId: vi.fn(
async (params: { aadGroupId?: string }) => params.aadGroupId?.trim() || "group-1",
),
fetchChannelMessage: vi.fn<
(
token: string,
@@ -267,8 +269,8 @@ describe("msteams monitor handler authz", () => {
conversationType: "channel",
},
channelData: {
team: { id: "team123", name: "Team 123" },
channel: { name: "General" },
team: { id: "team123", name: "Team 123", aadGroupId: "graph-team-123" },
channel: { id: "19:graph-channel@thread.tacv2", name: "General" },
},
extraActivity: { replyToId: "parent-msg" },
attachments: params?.attachments ?? [],
@@ -928,6 +930,7 @@ describe("msteams monitor handler authz", () => {
"[Thread history]\nAlice: Allowed context\n[/Thread history]\n\nCurrent message",
);
expect(ctxPayload.GroupSpace).toBe("team123");
expect(ctxPayload.NativeChannelId).toBe("graph-team-123/19:graph-channel@thread.tacv2");
expect(String((dispatched.ctxPayload as { BodyForAgent?: string }).BodyForAgent)).not.toContain(
"Mallory",
);
@@ -202,7 +202,7 @@ describe("msteams message handler Graph media recovery", () => {
});
});
it("uses the canonical AAD group ID for ordinary channel action context", async () => {
it("uses canonical Graph team and channel IDs for ordinary channel action context", async () => {
inboundMediaMockState.resolve.mockResolvedValue([]);
const { deps, getTeamDetails } = createMessageHandlerDeps(cfg);
const handler = createMSTeamsMessageHandler(deps);
@@ -222,7 +222,7 @@ describe("msteams message handler Graph media recovery", () => {
expect(getTeamDetails).toHaveBeenCalledWith("19:raw-team@thread.skype");
expect(firstDispatchedContext()).toMatchObject({
NativeChannelId: "team-aad-group/19:general@thread.tacv2",
NativeChannelId: "team-aad-group/19:channel@thread.tacv2",
});
expect(JSON.stringify(firstDispatchedContext())).not.toContain("19:raw-team@thread.skype/");
});
@@ -266,6 +266,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId);
const conversationType = conversation?.conversationType ?? "personal";
const teamId = activity.channelData?.team?.id;
const graphChannelId = activity.channelData?.channel?.id?.trim() || conversationId;
// For channel thread messages, resolve the thread root message ID so outbound
// replies land in the correct thread. The root ID comes from the `messageid=`
// portion of conversation.id (preferred) or from activity.replyToId.
@@ -699,11 +700,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
channelGroupId,
conversationId,
threadParentId,
(token, groupId, graphChannelId, messageId) =>
(token, groupId, requestedChannelId, messageId) =>
fetchChannelMessage(
token,
groupId,
graphChannelId,
requestedChannelId,
messageId,
preprocessingDeadline,
),
@@ -846,7 +847,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
// The bare conversation id (`19:...@thread.tacv2`) is insufficient on its
// own because channel Graph endpoints require the owning team id too.
const nativeChannelId =
isChannel && teamAadGroupId ? `${teamAadGroupId}/${conversationId}` : undefined;
isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined;
const ctxPayload = buildChannelInboundEventContext({
channel: "msteams",
finalize: core.channel.reply.finalizeInboundContext,
@@ -1,28 +1,11 @@
// Msteams plugin module implements reaction handler behavior.
import { normalizeMSTeamsConversationId } from "../inbound.js";
import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js";
import { resolveMSTeamsReactionEmoji } from "../reaction-types.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> = {
like: "👍",
heart: "❤️",
laugh: "😆",
surprised: "😮",
sad: "😢",
angry: "😡",
};
/**
* Map a Teams reaction type string to a Unicode emoji.
* Falls back to the raw type if not recognized.
*/
function mapReactionEmoji(reactionType: string): string {
return TEAMS_REACTION_EMOJI[reactionType] ?? reactionType;
}
type ReactionDirection = "added" | "removed";
/**
@@ -100,7 +83,7 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) {
for (const reaction of reactions) {
const reactionType = reaction.type ?? "unknown";
const emoji = mapReactionEmoji(reactionType);
const emoji = resolveMSTeamsReactionEmoji(reactionType);
const label =
direction === "added"
? `Teams reaction ${emoji} added by ${senderName} on message ${targetMessageId}`
+103 -29
View File
@@ -14,23 +14,21 @@ type FakeServer = EventEmitter & {
headersTimeout: number;
};
type MSTeamsChannelResolution = {
input: string;
resolved: boolean;
teamId?: string;
channelId?: string;
};
type MSTeamsUserResolution = {
input: string;
resolved: boolean;
id?: string;
};
type ResolveMSTeamsChannelAllowlistMock = (params: {
type ResolveMSTeamsTeamsConfigMock = (params: {
cfg: unknown;
entries: string[];
}) => Promise<MSTeamsChannelResolution[]>;
teamIdMode: "bot-framework" | "graph";
teams: Record<string, unknown>;
}) => Promise<{
teams: Record<string, unknown>;
mapping: string[];
unresolved: string[];
}>;
type ResolveMSTeamsUserAllowlistMock = (params: {
cfg: unknown;
@@ -171,12 +169,17 @@ vi.mock("./file-consent-invoke.js", () => ({
}));
const resolveAllowlistMocks = vi.hoisted(() => ({
resolveMSTeamsChannelAllowlist: vi.fn<ResolveMSTeamsChannelAllowlistMock>(async () => []),
resolveMSTeamsTeamsConfig: vi.fn<ResolveMSTeamsTeamsConfigMock>(async ({ teams }) => ({
teams,
mapping: [],
unresolved: [],
})),
resolveMSTeamsUserAllowlist: vi.fn<ResolveMSTeamsUserAllowlistMock>(async () => []),
}));
vi.mock("./resolve-allowlist.js", () => ({
resolveMSTeamsChannelAllowlist: resolveAllowlistMocks.resolveMSTeamsChannelAllowlist,
vi.mock("./resolve-allowlist.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./resolve-allowlist.js")>()),
resolveMSTeamsTeamsConfig: resolveAllowlistMocks.resolveMSTeamsTeamsConfig,
resolveMSTeamsUserAllowlist: resolveAllowlistMocks.resolveMSTeamsUserAllowlist,
}));
@@ -281,7 +284,9 @@ describe("monitorMSTeamsProvider lifecycle", () => {
expressControl.mode.value = "listening";
expressControl.apps.length = 0;
isDangerousNameMatchingEnabled.mockReset().mockReturnValue(false);
resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockReset().mockResolvedValue([]);
resolveAllowlistMocks.resolveMSTeamsTeamsConfig
.mockReset()
.mockImplementation(async ({ teams }) => ({ teams, mapping: [], unresolved: [] }));
resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockReset().mockResolvedValue([]);
isSigninInvokeAuthorized.mockReset().mockResolvedValue(true);
isCardActionInvokeAuthorized.mockReset().mockResolvedValue(true);
@@ -930,14 +935,17 @@ describe("monitorMSTeamsProvider lifecycle", () => {
},
},
});
resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockResolvedValueOnce([
{
input: "Product/Roadmap",
resolved: true,
teamId: "team-id",
channelId: "channel-id",
resolveAllowlistMocks.resolveMSTeamsTeamsConfig.mockResolvedValueOnce({
teams: {
"team-id": {
channels: {
"channel-id": {},
},
},
},
]);
mapping: ["Product/Roadmap→team-id/channel-id"],
unresolved: [],
});
const task = monitorMSTeamsProvider({
cfg,
@@ -952,22 +960,32 @@ describe("monitorMSTeamsProvider lifecycle", () => {
});
expect(resolveAllowlistMocks.resolveMSTeamsUserAllowlist).not.toHaveBeenCalled();
expect(resolveAllowlistMocks.resolveMSTeamsChannelAllowlist).toHaveBeenCalledWith({
expect(resolveAllowlistMocks.resolveMSTeamsTeamsConfig).toHaveBeenCalledWith({
cfg,
entries: ["Product/Roadmap"],
teamIdMode: "bot-framework",
teams: {
Product: {
channels: {
Roadmap: {},
},
},
},
});
const registeredCfg = requireRegisteredMSTeamsConfig();
expect(registeredCfg.channels?.msteams?.allowFrom).toEqual([
"Alice",
"user:40a1a0ed-4ff2-4164-a219-55518990c197",
"40a1a0ed-4ff2-4164-a219-55518990c197",
]);
expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual([
"Bob",
"msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198",
"50a1a0ed-4ff2-4164-a219-55518990c198",
]);
expect(registeredCfg.channels?.msteams?.teams).toEqual({
"team-id": {
channels: {
"channel-id": {},
},
},
});
abort.abort();
await task;
@@ -1009,8 +1027,64 @@ describe("monitorMSTeamsProvider lifecycle", () => {
});
const registeredCfg = requireRegisteredMSTeamsConfig();
expect(registeredCfg.channels?.msteams?.allowFrom).toEqual(["Alice", "alice-aad"]);
expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual(["Bob", "bob-aad"]);
expect(registeredCfg.channels?.msteams?.allowFrom).toEqual(["alice-aad"]);
expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual(["bob-aad"]);
abort.abort();
await task;
});
it("keeps only stable allowlist entries when Graph resolution fails", async () => {
isDangerousNameMatchingEnabled.mockReturnValue(true);
resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockRejectedValueOnce(
new Error("Graph unavailable"),
);
const runtime = createRuntime();
const abort = new AbortController();
const cfg = createConfig(0);
updateMSTeamsConfig(cfg, {
dangerouslyAllowNameMatching: true,
allowFrom: ["Alice", "accessGroup:operators", "user:40a1a0ed-4ff2-4164-a219-55518990c197"],
teams: {
Mutable: {
channels: {
Roadmap: {},
},
},
"19:stable-team@thread.tacv2": {
channels: {
"19:stable-channel@thread.tacv2": {},
},
},
},
});
const task = monitorMSTeamsProvider({
cfg,
runtime,
abortSignal: abort.signal,
conversationStore: createStores().conversationStore,
pollStore: createStores().pollStore,
});
await vi.waitFor(() => {
expect(registerMSTeamsHandlers).toHaveBeenCalled();
});
expect(requireRegisteredMSTeamsConfig().channels?.msteams?.allowFrom).toEqual([
"accessGroup:operators",
"40a1a0ed-4ff2-4164-a219-55518990c197",
]);
expect(requireRegisteredMSTeamsConfig().channels?.msteams?.teams).toEqual({
"19:stable-team@thread.tacv2": {
channels: {
"19:stable-channel@thread.tacv2": {},
},
},
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("mutable allowlist entries are disabled"),
);
abort.abort();
await task;
+24 -94
View File
@@ -29,7 +29,9 @@ import {
type MSTeamsPollStore,
} from "./polls.js";
import {
resolveMSTeamsChannelAllowlist,
projectStableMSTeamsUserAllowlist,
projectStableMSTeamsTeamsConfig,
resolveMSTeamsTeamsConfig,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
import { getMSTeamsRuntime } from "./runtime.js";
@@ -86,9 +88,11 @@ export async function monitorMSTeamsProvider(
},
};
let allowFrom = msteamsCfg.allowFrom;
let groupAllowFrom = msteamsCfg.groupAllowFrom;
let teamsConfig = msteamsCfg.teams;
const configuredAllowFrom = msteamsCfg.allowFrom;
const configuredGroupAllowFrom = msteamsCfg.groupAllowFrom;
let allowFrom = projectStableMSTeamsUserAllowlist(configuredAllowFrom);
let groupAllowFrom = projectStableMSTeamsUserAllowlist(configuredGroupAllowFrom);
let teamsConfig = projectStableMSTeamsTeamsConfig(msteamsCfg.teams);
const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg);
const cleanAllowEntry = (entry: string) =>
@@ -99,10 +103,8 @@ export async function monitorMSTeamsProvider(
const isStableUserId = (entry: string) => /^[0-9a-fA-F-]{16,}$/.test(entry);
const cleanAllowEntries = (entries?: string[]) =>
entries?.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*") ?? [];
const mergeStableUserIds = (entries?: string[]) => {
const additions = cleanAllowEntries(entries).filter((entry) => isStableUserId(entry));
return additions.length > 0 ? mergeAllowlist({ existing: entries, additions }) : entries;
};
const isMutableUserEntry = (entry: string) =>
!isStableUserId(entry) && !/^accessGroup:/i.test(entry);
const resolveAllowlistUsers = async (label: string, entries: string[]) => {
if (entries.length === 0) {
@@ -126,22 +128,15 @@ export async function monitorMSTeamsProvider(
};
try {
allowFrom = mergeStableUserIds(allowFrom);
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
groupAllowFrom = mergeStableUserIds(groupAllowFrom);
}
if (allowNameMatching) {
const allowEntries = cleanAllowEntries(allowFrom).filter((entry) => !isStableUserId(entry));
const allowEntries = cleanAllowEntries(configuredAllowFrom).filter(isMutableUserEntry);
if (allowEntries.length > 0) {
const { additions } = await resolveAllowlistUsers("msteams users", allowEntries);
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
}
if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) {
const groupEntries = cleanAllowEntries(groupAllowFrom).filter(
(entry) => !isStableUserId(entry),
);
if (Array.isArray(configuredGroupAllowFrom) && configuredGroupAllowFrom.length > 0) {
const groupEntries = cleanAllowEntries(configuredGroupAllowFrom).filter(isMutableUserEntry);
if (groupEntries.length > 0) {
const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries);
groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions });
@@ -149,85 +144,20 @@ export async function monitorMSTeamsProvider(
}
}
if (teamsConfig && Object.keys(teamsConfig).length > 0) {
const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = [];
for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) {
if (teamKey === "*") {
continue;
}
const channels = teamCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
entries.push({ input: teamKey, teamKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${teamKey}/${channelKey}`,
teamKey,
channelKey,
});
}
}
if (entries.length > 0) {
const resolved = await resolveMSTeamsChannelAllowlist({
cfg,
entries: entries.map((entry) => entry.input),
});
const mapping: string[] = [];
const unresolved: string[] = [];
const nextTeams = { ...teamsConfig };
resolved.forEach((entry, idx) => {
const source = entries[idx];
if (!source) {
return;
}
const sourceTeam = teamsConfig?.[source.teamKey] ?? {};
if (!entry.resolved || !entry.teamId) {
unresolved.push(entry.input);
return;
}
mapping.push(
entry.channelId
? `${entry.input}${entry.teamId}/${entry.channelId}`
: `${entry.input}${entry.teamId}`,
);
const existing = nextTeams[entry.teamId] ?? {};
const mergedChannels = {
...sourceTeam.channels,
...existing.channels,
};
const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels };
nextTeams[entry.teamId] = mergedTeam;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceTeam.channels?.[source.channelKey];
if (sourceChannel) {
nextTeams[entry.teamId] = {
...mergedTeam,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...mergedChannels?.[entry.channelId],
},
},
};
}
}
});
teamsConfig = nextTeams;
summarizeMapping("msteams channels", mapping, unresolved, runtime);
}
if (msteamsCfg.teams && Object.keys(msteamsCfg.teams).length > 0) {
const resolved = await resolveMSTeamsTeamsConfig({
cfg,
teamIdMode: "bot-framework",
teams: msteamsCfg.teams,
});
teamsConfig = resolved.teams;
summarizeMapping("msteams channels", resolved.mapping, resolved.unresolved, runtime);
}
} catch (err) {
// Allowlist Graph resolution is security-sensitive — surface failures at
// error level so operators notice the degraded state where Graph-resolved
// IDs are missing (#77674).
// Graph-resolved aliases are authorization inputs. Keep only the stable
// projection when resolution fails so mutable names never become active.
runtime.error?.(
`msteams resolve failed; falling back to raw config entries — allowlist members resolved via Graph may be missing. ${formatUnknownError(err)}`,
`msteams resolve failed; mutable allowlist entries are disabled. ${formatUnknownError(err)}`,
);
}
+20 -13
View File
@@ -2,20 +2,26 @@
import { createHash } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithSsrFGuardMock = vi.hoisted(() =>
vi.fn(
async (params: {
url: string;
init?: RequestInit;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const response = await fetchImpl(params.url, params.init);
return {
response,
finalUrl: params.url,
release: async () => {},
};
},
),
);
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: async (params: {
url: string;
init?: RequestInit;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
}) => {
const fetchImpl = params.fetchImpl ?? globalThis.fetch;
const response = await fetchImpl(params.url, params.init);
return {
response,
finalUrl: params.url,
release: async () => {},
};
},
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import {
@@ -209,6 +215,7 @@ describe("exchangeMSTeamsCodeForTokens", () => {
expect(body.get("code")).toBe("auth-code");
expect(body.get("code_verifier")).toBe("pkce-verifier");
expect(body.get("redirect_uri")).toBe(MSTEAMS_OAUTH_REDIRECT_URI);
expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl");
});
it("throws on a 400 error response", async () => {
-2
View File
@@ -75,10 +75,8 @@ async function fetchMSTeamsTokens(params: {
auditContext: string;
failureLabel: string;
}): Promise<MSTeamsTokenResponse> {
const currentFetch = globalThis.fetch;
const { response, release } = await fetchWithSsrFGuard({
url: params.tokenUrl,
fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit),
init: {
method: "POST",
headers: {
+45 -1
View File
@@ -1,7 +1,11 @@
// Msteams tests cover policy plugin behavior.
import { describe, expect, it } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js";
import {
resolveMSTeamsGroupToolPolicy,
resolveMSTeamsReplyPolicy,
resolveMSTeamsRouteConfig,
} from "./policy.js";
function resolveNamedTeamRouteConfig(allowNameMatching = false) {
const cfg: MSTeamsConfig = {
@@ -154,4 +158,44 @@ describe("msteams policy", () => {
expect(policy).toEqual({ requireMention: false, replyStyle: "thread" });
});
});
describe("resolveMSTeamsGroupToolPolicy", () => {
it("uses stable projected keys and never raw mutable names", () => {
const cfg = {
channels: {
msteams: {
dangerouslyAllowNameMatching: true,
teams: {
"Mutable Team": {
channels: {
"Mutable Channel": { tools: { allow: ["exec"] } },
},
},
"19:stable-team@thread.tacv2": {
channels: {
"19:stable-channel@thread.tacv2": { tools: { allow: ["read"] } },
},
},
},
},
},
};
expect(
resolveMSTeamsGroupToolPolicy({
cfg,
groupId: "19:unknown@thread.tacv2",
groupChannel: "Mutable Channel",
groupSpace: "Mutable Team",
}),
).toBeUndefined();
expect(
resolveMSTeamsGroupToolPolicy({
cfg,
groupId: "19:stable-channel@thread.tacv2",
groupSpace: "19:stable-team@thread.tacv2",
}),
).toEqual({ allow: ["read"] });
});
});
});
+1 -11
View File
@@ -15,7 +15,6 @@ import {
resolveToolsBySender,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
isDangerousNameMatchingEnabled,
} from "../runtime-api.js";
type MSTeamsResolvedRouteConfig = {
@@ -100,17 +99,12 @@ export function resolveMSTeamsGroupToolPolicy(
return undefined;
}
const groupId = params.groupId?.trim();
const groupChannel = params.groupChannel?.trim();
const groupSpace = params.groupSpace?.trim();
const allowNameMatching = isDangerousNameMatchingEnabled(cfg);
const resolved = resolveMSTeamsRouteConfig({
cfg,
teamId: groupSpace,
teamName: groupSpace,
conversationId: groupId,
channelName: groupChannel,
allowNameMatching,
});
if (resolved.channelConfig) {
@@ -159,11 +153,7 @@ export function resolveMSTeamsGroupToolPolicy(
return undefined;
}
const channelCandidates = buildChannelKeyCandidates(
groupId,
allowNameMatching ? groupChannel : undefined,
allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
);
const channelCandidates = buildChannelKeyCandidates(groupId, undefined, undefined);
for (const teamConfig of Object.values(cfg.teams ?? {})) {
const match = resolveChannelEntryMatchWithFallback({
entries: teamConfig?.channels ?? {},
+23
View File
@@ -0,0 +1,23 @@
// Msteams plugin module implements reaction type normalization.
const TEAMS_REACTION_EMOJI: Record<string, string> = {
like: "\u{1F44D}",
heart: "\u2764\uFE0F",
laugh: "\u{1F606}",
surprised: "\u{1F62E}",
sad: "\u{1F622}",
angry: "\u{1F621}",
};
export const TEAMS_REACTION_TYPES = Object.keys(TEAMS_REACTION_EMOJI);
export function getMSTeamsReactionEmoji(raw: string): string | undefined {
return TEAMS_REACTION_EMOJI[raw.trim().toLowerCase()];
}
export function resolveMSTeamsReactionEmoji(raw: string): string {
const normalized = raw.trim();
if (!normalized) {
throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`);
}
return getMSTeamsReactionEmoji(normalized) ?? normalized;
}

Some files were not shown because too many files have changed in this diff Show More