mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}.`);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -91,12 +91,6 @@ export type GoogleChatEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
export type GoogleChatReaction = {
|
||||
name?: string;
|
||||
user?: GoogleChatUser;
|
||||
emoji?: { unicode?: string };
|
||||
};
|
||||
|
||||
type GoogleChatTextParagraphWidget = {
|
||||
textParagraph: {
|
||||
text: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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("{}", {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user