fix(cli): honor Teams message search targets and limits (#149353)

## 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 <team-id>/<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 <hi@obviy.us>
This commit is contained in:
Masato Hoshino 2026-09-16 10:27:08 +09:00 • committed by GitHub
parent e9a73c210d
commit 2a7f2d126f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 70 additions and 34 deletions

View file

@ -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 `<team-id>/<channel-id>`), `--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.

View file

@ -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<ResolvedMSTeamsAccount, ProbeMSTeamsRe
if (!query) {
return actionError("Search requires a target (to) and query.");
}
const limit = typeof ctx.params.limit === "number" ? ctx.params.limit : undefined;
const limit = readPositiveIntegerParam(ctx.params, "limit");
const from =
typeof ctx.params.from === "string" ? ctx.params.from.trim() : undefined;
const { searchMessagesMSTeams } = await loadMSTeamsChannelRuntime();

View file

@ -14,13 +14,16 @@ export function registerMessagePermissionsCommand(message: Command, helpers: Mes
.action((opts) => 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 <id>", "Guild id")
.withMessageBase(message.command("search").description("Search messages"))
.requiredOption("--query <text>", "Search query")
.option("--channel-id <id>", "Channel id")
.option("--guild-id <id>", "Guild id (Discord)")
.option(
"--channel-id <id>",
"Channel id (Discord) or Graph team-id/channel-id (Microsoft Teams)",
)
.option("--channel-ids <id>", "Channel id (repeat)", collectOption, [] as string[])
.option("--author-id <id>", "Author id")
.option("--author-ids <id>", "Author id (repeat)", collectOption, [] as string[])

View file

@ -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) => {