mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 03:20:49 +00:00
feat(mattermost): add replyToMode support (off | first | all) (#29587)
Merged via squash.
Prepared head SHA: 4a67791f53
Co-authored-by: teconomix <6959299+teconomix@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
@@ -57,7 +57,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
||||
<<<<<<< HEAD
|
||||
- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
|
||||
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@@ -129,6 +129,35 @@ Notes:
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
|
||||
## Threading and sessions
|
||||
|
||||
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
|
||||
main channel or start a thread under the triggering post.
|
||||
|
||||
- `off` (default): only reply in a thread when the inbound post is already in one.
|
||||
- `first`: for top-level channel/group posts, start a thread under that post and route the
|
||||
conversation to a thread-scoped session.
|
||||
- `all`: same behavior as `first` for Mattermost today.
|
||||
- Direct messages ignore this setting and stay non-threaded.
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Thread-scoped sessions use the triggering post id as the thread root.
|
||||
- `first` and `all` are currently equivalent because once Mattermost has a thread root,
|
||||
follow-up chunks and media continue in that same thread.
|
||||
|
||||
## Access control (DMs)
|
||||
|
||||
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
||||
|
||||
@@ -65,6 +65,38 @@ describe("mattermostPlugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("threading", () => {
|
||||
it("uses replyToMode for channel messages and keeps direct messages off", () => {
|
||||
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageActions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
deleteAccountFromConfigSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
@@ -25,6 +27,7 @@ import {
|
||||
listMattermostAccountIds,
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
resolveMattermostReplyToMode,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||
@@ -271,13 +274,13 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId }) => {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) => {
|
||||
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
|
||||
const mode = account.config.replyToMode;
|
||||
if (mode === "off" || mode === "first") {
|
||||
return mode;
|
||||
}
|
||||
return "all";
|
||||
const kind =
|
||||
chatType === "direct" || chatType === "group" || chatType === "channel"
|
||||
? chatType
|
||||
: "channel";
|
||||
return resolveMattermostReplyToMode(account, kind);
|
||||
},
|
||||
},
|
||||
reload: { configPrefixes: ["channels.mattermost"] },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MattermostConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("MattermostConfigSchema SecretInput", () => {
|
||||
describe("MattermostConfigSchema", () => {
|
||||
it("accepts SecretRef botToken at top-level", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
|
||||
@@ -21,4 +21,29 @@ describe("MattermostConfigSchema SecretInput", () => {
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts replyToMode", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
replyToMode: "all",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsupported direct-message reply threading config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dm: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsupported per-chat-type reply threading config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
replyToModeByChatType: {
|
||||
direct: "all",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDefaultMattermostAccountId } from "./accounts.js";
|
||||
import {
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
resolveMattermostReplyToMode,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDefaultMattermostAccountId", () => {
|
||||
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
|
||||
@@ -50,3 +54,37 @@ describe("resolveDefaultMattermostAccountId", () => {
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyToMode", () => {
|
||||
it("uses the configured mode for channel and group messages", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
|
||||
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
|
||||
});
|
||||
|
||||
it("keeps direct messages off even when replyToMode is enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to off when replyToMode is unset", () => {
|
||||
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
||||
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
|
||||
import type {
|
||||
MattermostAccountConfig,
|
||||
MattermostChatMode,
|
||||
MattermostChatTypeKey,
|
||||
MattermostReplyToMode,
|
||||
} from "../types.js";
|
||||
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
@@ -130,6 +135,20 @@ export function resolveMattermostAccount(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective replyToMode for a given chat type.
|
||||
* Mattermost auto-threading only applies to channel and group messages.
|
||||
*/
|
||||
export function resolveMattermostReplyToMode(
|
||||
account: ResolvedMattermostAccount,
|
||||
kind: MattermostChatTypeKey,
|
||||
): MattermostReplyToMode {
|
||||
if (kind === "direct") {
|
||||
return "off";
|
||||
}
|
||||
return account.config.replyToMode ?? "off";
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import { setMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import type { MattermostClient, MattermostPost } from "./client.js";
|
||||
import {
|
||||
buildButtonAttachments,
|
||||
computeInteractionCallbackUrl,
|
||||
@@ -738,6 +738,70 @@ describe("createMattermostInteractionHandler", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards fetched post threading metadata to session and button callbacks", async () => {
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
setMattermostRuntime({
|
||||
system: {
|
||||
enqueueSystemEvent,
|
||||
},
|
||||
} as unknown as Parameters<typeof setMattermostRuntime>[0]);
|
||||
const context = { action_id: "approve", __openclaw_channel_id: "chan-1" };
|
||||
const token = generateInteractionToken(context, "acct");
|
||||
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const fetchedPost: MattermostPost = {
|
||||
id: "post-1",
|
||||
channel_id: "chan-1",
|
||||
root_id: "root-9",
|
||||
message: "Choose",
|
||||
props: {
|
||||
attachments: [{ actions: [{ id: "approve", name: "Approve" }] }],
|
||||
},
|
||||
};
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: {
|
||||
request: async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
|
||||
} as unknown as MattermostClient,
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
resolveSessionKey,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const req = createReq({
|
||||
body: {
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
channel_id: "chan-1",
|
||||
post_id: "post-1",
|
||||
context: { ...context, _token: token },
|
||||
},
|
||||
});
|
||||
const res = createRes();
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(resolveSessionKey).toHaveBeenCalledWith({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
post: fetchedPost,
|
||||
});
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Mattermost button click: action="approve"'),
|
||||
expect.objectContaining({ sessionKey: "session:thread:root-9" }),
|
||||
);
|
||||
expect(dispatchButtonClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
postId: "post-1",
|
||||
post: fetchedPost,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
|
||||
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
|
||||
const token = generateInteractionToken(context, "acct");
|
||||
@@ -751,6 +815,7 @@ describe("createMattermostInteractionHandler", () => {
|
||||
request: async (path: string, init?: { method?: string }) => {
|
||||
requestLog.push({ path, method: init?.method });
|
||||
return {
|
||||
id: "post-1",
|
||||
channel_id: "chan-1",
|
||||
message: "Choose",
|
||||
props: {
|
||||
@@ -790,6 +855,7 @@ describe("createMattermostInteractionHandler", () => {
|
||||
actionId: "mdlprov",
|
||||
actionName: "Browse providers",
|
||||
originalMessage: "Choose",
|
||||
post: expect.objectContaining({ id: "post-1" }),
|
||||
userName: "alice",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/mattermost";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { updateMattermostPost, type MattermostClient } from "./client.js";
|
||||
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
|
||||
|
||||
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||
@@ -390,7 +390,11 @@ export function createMattermostInteractionHandler(params: {
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
resolveSessionKey?: (channelId: string, userId: string) => Promise<string>;
|
||||
resolveSessionKey?: (params: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<string>;
|
||||
handleInteraction?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
userName: string;
|
||||
@@ -398,6 +402,7 @@ export function createMattermostInteractionHandler(params: {
|
||||
actionName: string;
|
||||
originalMessage: string;
|
||||
context: Record<string, unknown>;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionResponse | null>;
|
||||
dispatchButtonClick?: (opts: {
|
||||
channelId: string;
|
||||
@@ -406,6 +411,7 @@ export function createMattermostInteractionHandler(params: {
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
postId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<void>;
|
||||
log?: (message: string) => void;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
@@ -503,13 +509,10 @@ export function createMattermostInteractionHandler(params: {
|
||||
|
||||
const userName = payload.user_name ?? payload.user_id;
|
||||
let originalMessage = "";
|
||||
let originalPost: MattermostPost | null = null;
|
||||
let clickedButtonName: string | null = null;
|
||||
try {
|
||||
const originalPost = await client.request<{
|
||||
channel_id?: string | null;
|
||||
message?: string;
|
||||
props?: Record<string, unknown>;
|
||||
}>(`/posts/${payload.post_id}`);
|
||||
originalPost = await client.request<MattermostPost>(`/posts/${payload.post_id}`);
|
||||
const postChannelId = originalPost.channel_id?.trim();
|
||||
if (!postChannelId || postChannelId !== payload.channel_id) {
|
||||
log?.(
|
||||
@@ -550,6 +553,14 @@ export function createMattermostInteractionHandler(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!originalPost) {
|
||||
log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to load interaction post" }));
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(
|
||||
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||
@@ -564,6 +575,7 @@ export function createMattermostInteractionHandler(params: {
|
||||
actionName: clickedButtonName,
|
||||
originalMessage,
|
||||
context: contextWithoutToken,
|
||||
post: originalPost,
|
||||
});
|
||||
if (response !== null) {
|
||||
res.statusCode = 200;
|
||||
@@ -590,7 +602,11 @@ export function createMattermostInteractionHandler(params: {
|
||||
`in channel ${payload.channel_id}`;
|
||||
|
||||
const sessionKey = params.resolveSessionKey
|
||||
? await params.resolveSessionKey(payload.channel_id, payload.user_id)
|
||||
? await params.resolveSessionKey({
|
||||
channelId: payload.channel_id,
|
||||
userId: payload.user_id,
|
||||
post: originalPost,
|
||||
})
|
||||
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
|
||||
|
||||
core.system.enqueueSystemEvent(eventLabel, {
|
||||
@@ -632,6 +648,7 @@ export function createMattermostInteractionHandler(params: {
|
||||
actionId,
|
||||
actionName: clickedButtonName,
|
||||
postId: payload.post_id,
|
||||
post: originalPost,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
evaluateMattermostMentionGate,
|
||||
resolveMattermostEffectiveReplyToId,
|
||||
resolveMattermostReplyRootId,
|
||||
resolveMattermostThreadSessionContext,
|
||||
type MattermostMentionGateInput,
|
||||
type MattermostRequireMentionResolverInput,
|
||||
} from "./monitor.js";
|
||||
@@ -154,3 +156,94 @@ describe("resolveMattermostReplyRootId", () => {
|
||||
expect(resolveMattermostReplyRootId({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostEffectiveReplyToId", () => {
|
||||
it("keeps an existing thread root", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
threadRootId: "thread-root-456",
|
||||
}),
|
||||
).toBe("thread-root-456");
|
||||
});
|
||||
|
||||
it("starts a thread for top-level channel messages when replyToMode is all", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toBe("post-123");
|
||||
});
|
||||
|
||||
it("starts a thread for top-level group messages when replyToMode is first", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "group",
|
||||
postId: "post-123",
|
||||
replyToMode: "first",
|
||||
}),
|
||||
).toBe("post-123");
|
||||
});
|
||||
|
||||
it("keeps direct messages non-threaded", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "direct",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostThreadSessionContext", () => {
|
||||
it("forks channel sessions by top-level post when replyToMode is all", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: "post-123",
|
||||
sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123",
|
||||
parentSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing thread roots for threaded follow-ups", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
kind: "group",
|
||||
postId: "post-123",
|
||||
replyToMode: "first",
|
||||
threadRootId: "root-456",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: "root-456",
|
||||
sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456",
|
||||
parentSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct-message sessions linear", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:user-1",
|
||||
kind: "direct",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: undefined,
|
||||
sessionKey: "agent:main:mattermost:default:user-1",
|
||||
parentSessionKey: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/mattermost";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
fetchMattermostChannel,
|
||||
@@ -274,6 +274,51 @@ export function resolveMattermostReplyRootId(params: {
|
||||
}
|
||||
return params.replyToId?.trim() || undefined;
|
||||
}
|
||||
|
||||
export function resolveMattermostEffectiveReplyToId(params: {
|
||||
kind: ChatType;
|
||||
postId?: string | null;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadRootId?: string | null;
|
||||
}): string | undefined {
|
||||
const threadRootId = params.threadRootId?.trim();
|
||||
if (threadRootId) {
|
||||
return threadRootId;
|
||||
}
|
||||
if (params.kind === "direct") {
|
||||
return undefined;
|
||||
}
|
||||
const postId = params.postId?.trim();
|
||||
if (!postId) {
|
||||
return undefined;
|
||||
}
|
||||
return params.replyToMode === "all" || params.replyToMode === "first" ? postId : undefined;
|
||||
}
|
||||
|
||||
export function resolveMattermostThreadSessionContext(params: {
|
||||
baseSessionKey: string;
|
||||
kind: ChatType;
|
||||
postId?: string | null;
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadRootId?: string | null;
|
||||
}): { effectiveReplyToId?: string; sessionKey: string; parentSessionKey?: string } {
|
||||
const effectiveReplyToId = resolveMattermostEffectiveReplyToId({
|
||||
kind: params.kind,
|
||||
postId: params.postId,
|
||||
replyToMode: params.replyToMode,
|
||||
threadRootId: params.threadRootId,
|
||||
});
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: params.baseSessionKey,
|
||||
threadId: effectiveReplyToId,
|
||||
parentSessionKey: effectiveReplyToId ? params.baseSessionKey : undefined,
|
||||
});
|
||||
return {
|
||||
effectiveReplyToId,
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
parentSessionKey: threadKeys.parentSessionKey,
|
||||
};
|
||||
}
|
||||
type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
@@ -521,7 +566,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
trustedProxies: cfg.gateway?.trustedProxies,
|
||||
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
||||
handleInteraction: handleModelPickerInteraction,
|
||||
resolveSessionKey: async (channelId: string, userId: string) => {
|
||||
resolveSessionKey: async ({ channelId, userId, post }) => {
|
||||
const channelInfo = await resolveChannelInfo(channelId);
|
||||
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
||||
const teamId = channelInfo?.team_id ?? undefined;
|
||||
@@ -535,7 +580,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
id: kind === "direct" ? userId : channelId,
|
||||
},
|
||||
});
|
||||
return route.sessionKey;
|
||||
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
||||
return resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: route.sessionKey,
|
||||
kind,
|
||||
postId: post.id || undefined,
|
||||
replyToMode,
|
||||
threadRootId: post.root_id,
|
||||
}).sessionKey;
|
||||
},
|
||||
dispatchButtonClick: async (opts) => {
|
||||
const channelInfo = await resolveChannelInfo(opts.channelId);
|
||||
@@ -554,6 +606,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
id: kind === "direct" ? opts.userId : opts.channelId,
|
||||
},
|
||||
});
|
||||
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
||||
const threadContext = resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: route.sessionKey,
|
||||
kind,
|
||||
postId: opts.post.id || opts.postId,
|
||||
replyToMode,
|
||||
threadRootId: opts.post.root_id,
|
||||
});
|
||||
const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
|
||||
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
@@ -568,7 +628,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
? `mattermost:group:${opts.channelId}`
|
||||
: `mattermost:channel:${opts.channelId}`,
|
||||
To: to,
|
||||
SessionKey: route.sessionKey,
|
||||
SessionKey: threadContext.sessionKey,
|
||||
ParentSessionKey: threadContext.parentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: `mattermost:${opts.userName}`,
|
||||
@@ -580,6 +641,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
|
||||
ReplyToId: threadContext.effectiveReplyToId,
|
||||
MessageThreadId: threadContext.effectiveReplyToId,
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: false,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
@@ -604,7 +667,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingIndicator(opts.channelId),
|
||||
start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => logger.debug?.(message),
|
||||
@@ -636,6 +699,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
if (!chunk) continue;
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId: threadContext.effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -646,6 +713,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
await sendMessageMattermost(to, caption, {
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId: threadContext.effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -834,6 +905,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
commandText: string;
|
||||
commandAuthorized: boolean;
|
||||
route: ReturnType<typeof core.channel.routing.resolveAgentRoute>;
|
||||
sessionKey: string;
|
||||
parentSessionKey?: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
@@ -844,6 +917,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
roomLabel: string;
|
||||
teamId?: string;
|
||||
postId: string;
|
||||
effectiveReplyToId?: string;
|
||||
deliverReplies?: boolean;
|
||||
}): Promise<string> => {
|
||||
const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`;
|
||||
@@ -863,7 +937,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
? `mattermost:group:${params.channelId}`
|
||||
: `mattermost:channel:${params.channelId}`,
|
||||
To: to,
|
||||
SessionKey: params.route.sessionKey,
|
||||
SessionKey: params.sessionKey,
|
||||
ParentSessionKey: params.parentSessionKey,
|
||||
AccountId: params.route.accountId,
|
||||
ChatType: params.chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
@@ -876,6 +951,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: `interaction:${params.postId}:${Date.now()}`,
|
||||
ReplyToId: params.effectiveReplyToId,
|
||||
MessageThreadId: params.effectiveReplyToId,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: params.commandAuthorized,
|
||||
@@ -907,7 +984,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
const capturedTexts: string[] = [];
|
||||
const typingCallbacks = shouldDeliverReplies
|
||||
? createTypingCallbacks({
|
||||
start: () => sendTypingIndicator(params.channelId),
|
||||
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => logger.debug?.(message),
|
||||
@@ -948,6 +1025,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
}
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId: params.effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -960,6 +1041,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
await sendMessageMattermost(to, caption, {
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId: params.effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -1000,6 +1085,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
};
|
||||
userName: string;
|
||||
context: Record<string, unknown>;
|
||||
post: MattermostPost;
|
||||
}): Promise<MattermostInteractionResponse | null> {
|
||||
const pickerState = parseMattermostModelPickerContext(params.context);
|
||||
if (!pickerState) {
|
||||
@@ -1088,6 +1174,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
id: kind === "direct" ? params.payload.user_id : params.payload.channel_id,
|
||||
},
|
||||
});
|
||||
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
||||
const threadContext = resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: route.sessionKey,
|
||||
kind,
|
||||
postId: params.post.id || params.payload.post_id,
|
||||
replyToMode,
|
||||
threadRootId: params.post.root_id,
|
||||
});
|
||||
const modelSessionRoute = {
|
||||
agentId: route.agentId,
|
||||
sessionKey: threadContext.sessionKey,
|
||||
};
|
||||
|
||||
const data = await buildModelsProviderData(cfg, route.agentId);
|
||||
if (data.providers.length === 0) {
|
||||
@@ -1101,7 +1199,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
if (pickerState.action === "providers" || pickerState.action === "back") {
|
||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route,
|
||||
route: modelSessionRoute,
|
||||
data,
|
||||
});
|
||||
const view = renderMattermostProviderPickerView({
|
||||
@@ -1120,7 +1218,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
if (pickerState.action === "list") {
|
||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route,
|
||||
route: modelSessionRoute,
|
||||
data,
|
||||
});
|
||||
const view = renderMattermostModelsPickerView({
|
||||
@@ -1151,6 +1249,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
commandText: `/model ${targetModelRef}`,
|
||||
commandAuthorized: auth.commandAuthorized,
|
||||
route,
|
||||
sessionKey: threadContext.sessionKey,
|
||||
parentSessionKey: threadContext.parentSessionKey,
|
||||
channelId: params.payload.channel_id,
|
||||
senderId: params.payload.user_id,
|
||||
senderName: params.userName,
|
||||
@@ -1161,11 +1261,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
roomLabel,
|
||||
teamId,
|
||||
postId: params.payload.post_id,
|
||||
effectiveReplyToId: threadContext.effectiveReplyToId,
|
||||
deliverReplies: true,
|
||||
});
|
||||
const updatedModel = resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route,
|
||||
route: modelSessionRoute,
|
||||
data,
|
||||
skipCache: true,
|
||||
});
|
||||
@@ -1385,12 +1486,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const threadRootId = post.root_id?.trim() || undefined;
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
||||
const threadContext = resolveMattermostThreadSessionContext({
|
||||
baseSessionKey,
|
||||
threadId: threadRootId,
|
||||
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
||||
kind,
|
||||
postId: post.id,
|
||||
replyToMode,
|
||||
threadRootId,
|
||||
});
|
||||
const sessionKey = threadKeys.sessionKey;
|
||||
const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext;
|
||||
const historyKey = kind === "direct" ? null : sessionKey;
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||
@@ -1554,7 +1658,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: sessionKey,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ParentSessionKey: parentSessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
@@ -1570,8 +1674,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
||||
MessageSidLast:
|
||||
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
||||
ReplyToId: threadRootId,
|
||||
MessageThreadId: threadRootId,
|
||||
ReplyToId: effectiveReplyToId,
|
||||
MessageThreadId: effectiveReplyToId,
|
||||
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
@@ -1623,7 +1727,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
});
|
||||
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: () => sendTypingIndicator(channelId, threadRootId),
|
||||
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => logger.debug?.(message),
|
||||
@@ -1655,7 +1759,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
await sendMessageMattermost(to, chunk, {
|
||||
accountId: account.accountId,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId,
|
||||
threadRootId: effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
@@ -1669,7 +1773,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
accountId: account.accountId,
|
||||
mediaUrl,
|
||||
replyToId: resolveMattermostReplyRootId({
|
||||
threadRootId,
|
||||
threadRootId: effectiveReplyToId,
|
||||
replyToId: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
SecretInput,
|
||||
} from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
export type MattermostReplyToMode = "off" | "first" | "all";
|
||||
export type MattermostChatTypeKey = "direct" | "channel" | "group";
|
||||
|
||||
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||
|
||||
export type MattermostAccountConfig = {
|
||||
@@ -52,10 +55,16 @@ export type MattermostAccountConfig = {
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Control reply threading (off|first|all). Default: "all". */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
/**
|
||||
* Controls whether channel and group replies are sent as thread replies.
|
||||
* - "off" (default): only thread-reply when incoming message is already a thread reply
|
||||
* - "first": reply in a thread under the triggering message
|
||||
* - "all": always reply in a thread; uses existing thread root or starts a new thread under the message
|
||||
* Direct messages always behave as "off".
|
||||
*/
|
||||
replyToMode?: MattermostReplyToMode;
|
||||
/** Action toggles for this account. */
|
||||
actions?: {
|
||||
/** Enable message reaction actions. Default: true. */
|
||||
|
||||
Reference in New Issue
Block a user