From 2a7f2d126fbd3d4d88fa8df56be1ca3bd2f07deb Mon Sep 17 00:00:00 2001 From: Masato Hoshino Date: Wed, 16 Sep 2026 10:27:08 +0900 Subject: [PATCH] fix(cli): honor Teams message search targets and limits (#149353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Problem This Solves Teams CLI search required Discord's guild flag, then silently returned 25 matches when 30 were requested. Fixes #149352. ## Why This Change Was Made Each channel validates its identifiers. Teams now uses the existing positive-integer reader. The search result state has one writer: the existing Graph loop. Production delta: registration +3 lines; Teams adapter +1 line. Growth accepted under the owner's standing acceptance (2026-09-15). ## User Impact Teams search works without a guild and honors the requested bounded limit. ## Evidence `openclaw message search --channel msteams --channel-id / --query planning --limit 30 --json` Real command registration → messageCommand → Teams → loopback HTTP: the base rejects a missing guild. With registration alone fixed, it returns 25 matches with truncated=true. Both fixes return 30 matches with truncated=false. Requesting 60 returns 50 with truncated=true. The adapter rejects malformed limits before token acquisition or HTTP; separate CLI helper tests cover the full command wrapper's earlier flag validation. Live tenant authentication was not tested. ## Compatibility Discord still requires a resolved guild and retains its exact adapter error when resolution fails. The CLI can now use the existing channel-to-guild resolution. Delegated Teams access refusals pass on base and candidate. Direct operators retain their existing explicit-conversation access policy. No configuration or schema change. ## Consumers CLI search, Teams adapter, and CLI documentation. ## Invalidation No cache or persistent state changes. ## Tests 457 tests passed on the merged candidate: Teams CLI/Graph/actions, Discord actions, and CLI helpers/registration. Base authorization and Discord controls: 265 passed. Co-authored-by: Ayaan Zaidi --- docs/cli/message.md | 2 +- extensions/msteams/src/channel.ts | 3 +- .../message/register.permissions-search.ts | 11 ++- test/msteams-read-target.integration.test.ts | 88 +++++++++++++------ 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/docs/cli/message.md b/docs/cli/message.md index 5b1fbe70cb93..5137a1bac304 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -113,7 +113,7 @@ unresolved SecretRef on the selected channel/account fails the action closed. | `pin` / `unpin` | Discord, Matrix, Microsoft Teams, Slack | `--message-id`, `--target` | `unpin` also accepts `--pinned-message-id` (Microsoft Teams: the pin/list-pins resource id, not the chat message id). | | `pins` (list) | Discord, Matrix, Microsoft Teams, Slack | `--target` | `--limit`. | | `permissions` | Discord, Matrix | `--target` | Matrix: available only when encryption is enabled and verification actions are allowed. | -| `search` | Discord | `--guild-id`, `--query` | `--channel-id`, `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`. | +| `search` | Discord, Microsoft Teams | `--query` | `--guild-id` (Discord; resolved from `--channel-id` when omitted), `--channel-id` (required for Microsoft Teams as Graph `/`), `--channel-ids` (repeat), `--author-id`, `--author-ids` (repeat), `--limit`. | | `member info` | Discord, Matrix, Microsoft Teams, Slack | `--user-id` | `--channel-id` (required for Matrix and Microsoft Teams), `--guild-id` (Discord). | Reaction listings show labels, counts, and available users as plain terminal text. diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 40c721446d44..091e08003463 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,5 +1,6 @@ // Msteams plugin module implements channel behavior. import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime"; +import { readPositiveIntegerParam } from "openclaw/plugin-sdk/channel-actions"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -891,7 +892,7 @@ export const msteamsPlugin: ChannelPlugin helpers.runMessageAction("permissions", opts)); } -/** Register Discord message search command and repeatable filters. */ +/** Register the channel message search command and repeatable filters. */ export function registerMessageSearchCommand(message: Command, helpers: MessageCliHelpers) { helpers - .withMessageBase(message.command("search").description("Search Discord messages")) - .requiredOption("--guild-id ", "Guild id") + .withMessageBase(message.command("search").description("Search messages")) .requiredOption("--query ", "Search query") - .option("--channel-id ", "Channel id") + .option("--guild-id ", "Guild id (Discord)") + .option( + "--channel-id ", + "Channel id (Discord) or Graph team-id/channel-id (Microsoft Teams)", + ) .option("--channel-ids ", "Channel id (repeat)", collectOption, [] as string[]) .option("--author-id ", "Author id") .option("--author-ids ", "Author id (repeat)", collectOption, [] as string[]) diff --git a/test/msteams-read-target.integration.test.ts b/test/msteams-read-target.integration.test.ts index 4cdf0ceff66c..4e08cc8546ff 100644 --- a/test/msteams-read-target.integration.test.ts +++ b/test/msteams-read-target.integration.test.ts @@ -10,6 +10,7 @@ import type { ChannelThreadingToolContext } from "../src/channels/plugins/types. import { createDefaultDeps } from "../src/cli/deps.js"; import { createMessageCliHelpers } from "../src/cli/program/message/helpers.js"; import { registerMessageDiscordAdminCommands } from "../src/cli/program/message/register.discord-admin.js"; +import { registerMessageSearchCommand } from "../src/cli/program/message/register.permissions-search.js"; import { messageCommand } from "../src/commands/message.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../src/config/config.js"; import type { OpenClawConfig } from "../src/config/types.js"; @@ -118,6 +119,7 @@ afterEach(async () => { async function createFixture( currentContext: "channel" | "chat" | "none" = "channel", origin: "bundled" | "global" = "bundled", + searchMatches = 1, ) { const cfg: OpenClawConfig = { channels: { @@ -236,12 +238,10 @@ async function createFixture( path === `/v1.0/teams/${destination.teamId}/channels/${destination.channelId}/messages` ) { body = { - value: [ - { - id: destination.messageId, - body: { content: destination.text, contentType: "text" }, - }, - ], + value: Array.from({ length: searchMatches }, (_, index) => ({ + id: index === 0 ? destination.messageId : `${destination.messageId}-${index}`, + body: { content: destination.text, contentType: "text" }, + })), }; } else { response.statusCode = 404; @@ -399,45 +399,77 @@ function expectGraphRequests(requests: GraphRequest[], action: Action, destinati ); } -describe("Teams member info CLI", () => { - it("reads a selected channel member without current conversation context", async () => { - const fixture = await createFixture("none"); +describe("Teams message CLI", () => { + async function runCli( + register: typeof registerMessageDiscordAdminCommands, + args: string[], + action: Action, + ) { const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const command = new Command().name("message").exitOverride(); - registerMessageDiscordAdminCommands(command, { + register(command, { ...createMessageCliHelpers("msteams"), - runMessageAction: async (action, opts) => { - await messageCommand({ ...opts, action }, createDefaultDeps(), runtime); + runMessageAction: async (name, opts) => { + await messageCommand({ ...opts, action: name }, createDefaultDeps(), runtime); }, }); - await command.parseAsync( - [ - "member", - "info", - "--channel", - "msteams", - "--user-id", - memberId, - "--channel-id", - otherTarget, - "--json", - ], - { from: "user" }, - ); + await command.parseAsync([...args, "--channel", "msteams", "--json"], { from: "user" }); expect(runtime.log).toHaveBeenCalledTimes(1); expect(runtime.error).not.toHaveBeenCalled(); const result = JSON.parse(String(runtime.log.mock.calls[0]?.[0])); expect(result).toMatchObject({ - action: "member-info", + action, channel: "msteams", dryRun: false, handledBy: "plugin", }); - expectReadResult(result.payload, "member-info", other); + return result.payload; + } + + it("reads a selected channel member without current conversation context", async () => { + const fixture = await createFixture("none"); + const payload = await runCli( + registerMessageDiscordAdminCommands, + ["member", "info", "--user-id", memberId, "--channel-id", otherTarget], + "member-info", + ); + expectReadResult(payload, "member-info", other); expectGraphRequests(fixture.requests, "member-info", other); }); + + it.each([ + { limit: 30, count: 30, truncated: false }, + { limit: 60, count: 50, truncated: true }, + ])( + "searches a selected channel with limit $limit and no guild", + async ({ limit, count, truncated }) => { + const fixture = await createFixture("none", "bundled", limit); + const payload = await runCli( + registerMessageSearchCommand, + ["search", "--channel-id", otherTarget, "--query", "planning", "--limit", String(limit)], + "search", + ); + expect(payload).toMatchObject({ ok: true, channel: "msteams", action: "search" }); + expect(payload.messages).toHaveLength(count); + expect(payload.truncated).toBe(truncated); + expectGraphRequests(fixture.requests, "search", other); + }, + ); + + it("rejects a malformed limit in the registered CLI search adapter", async () => { + const fixture = await createFixture("none"); + await expect( + runCli( + registerMessageSearchCommand, + ["search", "--channel-id", otherTarget, "--query", "planning", "--limit", "abc"], + "search", + ), + ).rejects.toThrow("limit must be a positive integer"); + expect(fixture.requests).toEqual([]); + expect(graph.acquireToken).not.toHaveBeenCalled(); + }); }); describe.each(["tool", "gateway"] as const)("Teams %s read target selection", (route) => {