feat(mattermost): add replyToMode support (off | first | all) (#29587)
Merged via squash. Prepared head SHA: 4a67791f53b1109959082738429471b7a5bc93b8 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:
parent
8e0e4f736a
commit
171d2df9e0
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
@ -129,6 +129,35 @@ Notes:
|
|||||||
- `onchar` still responds to explicit @mentions.
|
- `onchar` still responds to explicit @mentions.
|
||||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
- `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)
|
## Access control (DMs)
|
||||||
|
|
||||||
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
- 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", () => {
|
describe("messageActions", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetMattermostReactionBotUserCacheForTests();
|
resetMattermostReactionBotUserCacheForTests();
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
|
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||||
|
resolveDefaultGroupPolicy,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
type ChannelMessageActionAdapter,
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
@ -25,6 +27,7 @@ import {
|
|||||||
listMattermostAccountIds,
|
listMattermostAccountIds,
|
||||||
resolveDefaultMattermostAccountId,
|
resolveDefaultMattermostAccountId,
|
||||||
resolveMattermostAccount,
|
resolveMattermostAccount,
|
||||||
|
resolveMattermostReplyToMode,
|
||||||
type ResolvedMattermostAccount,
|
type ResolvedMattermostAccount,
|
||||||
} from "./mattermost/accounts.js";
|
} from "./mattermost/accounts.js";
|
||||||
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||||
@ -271,13 +274,13 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||||
},
|
},
|
||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: ({ cfg, accountId }) => {
|
resolveReplyToMode: ({ cfg, accountId, chatType }) => {
|
||||||
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
|
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
|
||||||
const mode = account.config.replyToMode;
|
const kind =
|
||||||
if (mode === "off" || mode === "first") {
|
chatType === "direct" || chatType === "group" || chatType === "channel"
|
||||||
return mode;
|
? chatType
|
||||||
}
|
: "channel";
|
||||||
return "all";
|
return resolveMattermostReplyToMode(account, kind);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.mattermost"] },
|
reload: { configPrefixes: ["channels.mattermost"] },
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MattermostConfigSchema } from "./config-schema.js";
|
import { MattermostConfigSchema } from "./config-schema.js";
|
||||||
|
|
||||||
describe("MattermostConfigSchema SecretInput", () => {
|
describe("MattermostConfigSchema", () => {
|
||||||
it("accepts SecretRef botToken at top-level", () => {
|
it("accepts SecretRef botToken at top-level", () => {
|
||||||
const result = MattermostConfigSchema.safeParse({
|
const result = MattermostConfigSchema.safeParse({
|
||||||
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
|
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
|
||||||
@ -21,4 +21,29 @@ describe("MattermostConfigSchema SecretInput", () => {
|
|||||||
});
|
});
|
||||||
expect(result.success).toBe(true);
|
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 type { OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveDefaultMattermostAccountId } from "./accounts.js";
|
import {
|
||||||
|
resolveDefaultMattermostAccountId,
|
||||||
|
resolveMattermostAccount,
|
||||||
|
resolveMattermostReplyToMode,
|
||||||
|
} from "./accounts.js";
|
||||||
|
|
||||||
describe("resolveDefaultMattermostAccountId", () => {
|
describe("resolveDefaultMattermostAccountId", () => {
|
||||||
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
|
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
|
||||||
@ -50,3 +54,37 @@ describe("resolveDefaultMattermostAccountId", () => {
|
|||||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||||
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/mattermost";
|
||||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
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";
|
import { normalizeMattermostBaseUrl } from "./client.js";
|
||||||
|
|
||||||
export type MattermostTokenSource = "env" | "config" | "none";
|
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[] {
|
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
|
||||||
return listMattermostAccountIds(cfg)
|
return listMattermostAccountIds(cfg)
|
||||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
.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 { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { setMattermostRuntime } from "../runtime.js";
|
import { setMattermostRuntime } from "../runtime.js";
|
||||||
import { resolveMattermostAccount } from "./accounts.js";
|
import { resolveMattermostAccount } from "./accounts.js";
|
||||||
import type { MattermostClient } from "./client.js";
|
import type { MattermostClient, MattermostPost } from "./client.js";
|
||||||
import {
|
import {
|
||||||
buildButtonAttachments,
|
buildButtonAttachments,
|
||||||
computeInteractionCallbackUrl,
|
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 () => {
|
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
|
||||||
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
|
const context = { action_id: "mdlprov", __openclaw_channel_id: "chan-1" };
|
||||||
const token = generateInteractionToken(context, "acct");
|
const token = generateInteractionToken(context, "acct");
|
||||||
@ -751,6 +815,7 @@ describe("createMattermostInteractionHandler", () => {
|
|||||||
request: async (path: string, init?: { method?: string }) => {
|
request: async (path: string, init?: { method?: string }) => {
|
||||||
requestLog.push({ path, method: init?.method });
|
requestLog.push({ path, method: init?.method });
|
||||||
return {
|
return {
|
||||||
|
id: "post-1",
|
||||||
channel_id: "chan-1",
|
channel_id: "chan-1",
|
||||||
message: "Choose",
|
message: "Choose",
|
||||||
props: {
|
props: {
|
||||||
@ -790,6 +855,7 @@ describe("createMattermostInteractionHandler", () => {
|
|||||||
actionId: "mdlprov",
|
actionId: "mdlprov",
|
||||||
actionName: "Browse providers",
|
actionName: "Browse providers",
|
||||||
originalMessage: "Choose",
|
originalMessage: "Choose",
|
||||||
|
post: expect.objectContaining({ id: "post-1" }),
|
||||||
userName: "alice",
|
userName: "alice",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/mattermost";
|
} from "openclaw/plugin-sdk/mattermost";
|
||||||
import { getMattermostRuntime } from "../runtime.js";
|
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_MAX_BODY_BYTES = 64 * 1024;
|
||||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||||
@ -390,7 +390,11 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
allowedSourceIps?: string[];
|
allowedSourceIps?: string[];
|
||||||
trustedProxies?: string[];
|
trustedProxies?: string[];
|
||||||
allowRealIpFallback?: boolean;
|
allowRealIpFallback?: boolean;
|
||||||
resolveSessionKey?: (channelId: string, userId: string) => Promise<string>;
|
resolveSessionKey?: (params: {
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
post: MattermostPost;
|
||||||
|
}) => Promise<string>;
|
||||||
handleInteraction?: (opts: {
|
handleInteraction?: (opts: {
|
||||||
payload: MattermostInteractionPayload;
|
payload: MattermostInteractionPayload;
|
||||||
userName: string;
|
userName: string;
|
||||||
@ -398,6 +402,7 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
actionName: string;
|
actionName: string;
|
||||||
originalMessage: string;
|
originalMessage: string;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
post: MattermostPost;
|
||||||
}) => Promise<MattermostInteractionResponse | null>;
|
}) => Promise<MattermostInteractionResponse | null>;
|
||||||
dispatchButtonClick?: (opts: {
|
dispatchButtonClick?: (opts: {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
@ -406,6 +411,7 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
actionId: string;
|
actionId: string;
|
||||||
actionName: string;
|
actionName: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
|
post: MattermostPost;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
log?: (message: string) => void;
|
log?: (message: string) => void;
|
||||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||||
@ -503,13 +509,10 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
|
|
||||||
const userName = payload.user_name ?? payload.user_id;
|
const userName = payload.user_name ?? payload.user_id;
|
||||||
let originalMessage = "";
|
let originalMessage = "";
|
||||||
|
let originalPost: MattermostPost | null = null;
|
||||||
let clickedButtonName: string | null = null;
|
let clickedButtonName: string | null = null;
|
||||||
try {
|
try {
|
||||||
const originalPost = await client.request<{
|
originalPost = await client.request<MattermostPost>(`/posts/${payload.post_id}`);
|
||||||
channel_id?: string | null;
|
|
||||||
message?: string;
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
}>(`/posts/${payload.post_id}`);
|
|
||||||
const postChannelId = originalPost.channel_id?.trim();
|
const postChannelId = originalPost.channel_id?.trim();
|
||||||
if (!postChannelId || postChannelId !== payload.channel_id) {
|
if (!postChannelId || postChannelId !== payload.channel_id) {
|
||||||
log?.(
|
log?.(
|
||||||
@ -550,6 +553,14 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
return;
|
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?.(
|
log?.(
|
||||||
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||||
`post=${payload.post_id} channel=${payload.channel_id}`,
|
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||||
@ -564,6 +575,7 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
actionName: clickedButtonName,
|
actionName: clickedButtonName,
|
||||||
originalMessage,
|
originalMessage,
|
||||||
context: contextWithoutToken,
|
context: contextWithoutToken,
|
||||||
|
post: originalPost,
|
||||||
});
|
});
|
||||||
if (response !== null) {
|
if (response !== null) {
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
@ -590,7 +602,11 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
`in channel ${payload.channel_id}`;
|
`in channel ${payload.channel_id}`;
|
||||||
|
|
||||||
const sessionKey = params.resolveSessionKey
|
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}`;
|
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
|
||||||
|
|
||||||
core.system.enqueueSystemEvent(eventLabel, {
|
core.system.enqueueSystemEvent(eventLabel, {
|
||||||
@ -632,6 +648,7 @@ export function createMattermostInteractionHandler(params: {
|
|||||||
actionId,
|
actionId,
|
||||||
actionName: clickedButtonName,
|
actionName: clickedButtonName,
|
||||||
postId: payload.post_id,
|
postId: payload.post_id,
|
||||||
|
post: originalPost,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(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 { resolveMattermostAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
evaluateMattermostMentionGate,
|
evaluateMattermostMentionGate,
|
||||||
|
resolveMattermostEffectiveReplyToId,
|
||||||
resolveMattermostReplyRootId,
|
resolveMattermostReplyRootId,
|
||||||
|
resolveMattermostThreadSessionContext,
|
||||||
type MattermostMentionGateInput,
|
type MattermostMentionGateInput,
|
||||||
type MattermostRequireMentionResolverInput,
|
type MattermostRequireMentionResolverInput,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
@ -154,3 +156,94 @@ describe("resolveMattermostReplyRootId", () => {
|
|||||||
expect(resolveMattermostReplyRootId({})).toBeUndefined();
|
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,
|
type HistoryEntry,
|
||||||
} from "openclaw/plugin-sdk/mattermost";
|
} from "openclaw/plugin-sdk/mattermost";
|
||||||
import { getMattermostRuntime } from "../runtime.js";
|
import { getMattermostRuntime } from "../runtime.js";
|
||||||
import { resolveMattermostAccount } from "./accounts.js";
|
import { resolveMattermostAccount, resolveMattermostReplyToMode } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createMattermostClient,
|
createMattermostClient,
|
||||||
fetchMattermostChannel,
|
fetchMattermostChannel,
|
||||||
@ -274,6 +274,51 @@ export function resolveMattermostReplyRootId(params: {
|
|||||||
}
|
}
|
||||||
return params.replyToId?.trim() || undefined;
|
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 = {
|
type MattermostMediaInfo = {
|
||||||
path: string;
|
path: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
@ -521,7 +566,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
trustedProxies: cfg.gateway?.trustedProxies,
|
trustedProxies: cfg.gateway?.trustedProxies,
|
||||||
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
allowRealIpFallback: cfg.gateway?.allowRealIpFallback === true,
|
||||||
handleInteraction: handleModelPickerInteraction,
|
handleInteraction: handleModelPickerInteraction,
|
||||||
resolveSessionKey: async (channelId: string, userId: string) => {
|
resolveSessionKey: async ({ channelId, userId, post }) => {
|
||||||
const channelInfo = await resolveChannelInfo(channelId);
|
const channelInfo = await resolveChannelInfo(channelId);
|
||||||
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
const kind = mapMattermostChannelTypeToChatType(channelInfo?.type);
|
||||||
const teamId = channelInfo?.team_id ?? undefined;
|
const teamId = channelInfo?.team_id ?? undefined;
|
||||||
@ -535,7 +580,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
id: kind === "direct" ? userId : channelId,
|
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) => {
|
dispatchButtonClick: async (opts) => {
|
||||||
const channelInfo = await resolveChannelInfo(opts.channelId);
|
const channelInfo = await resolveChannelInfo(opts.channelId);
|
||||||
@ -554,6 +606,14 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
id: kind === "direct" ? opts.userId : opts.channelId,
|
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 to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
|
||||||
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
|
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
|
||||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
@ -568,7 +628,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
? `mattermost:group:${opts.channelId}`
|
? `mattermost:group:${opts.channelId}`
|
||||||
: `mattermost:channel:${opts.channelId}`,
|
: `mattermost:channel:${opts.channelId}`,
|
||||||
To: to,
|
To: to,
|
||||||
SessionKey: route.sessionKey,
|
SessionKey: threadContext.sessionKey,
|
||||||
|
ParentSessionKey: threadContext.parentSessionKey,
|
||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: chatType,
|
ChatType: chatType,
|
||||||
ConversationLabel: `mattermost:${opts.userName}`,
|
ConversationLabel: `mattermost:${opts.userName}`,
|
||||||
@ -580,6 +641,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
Provider: "mattermost" as const,
|
Provider: "mattermost" as const,
|
||||||
Surface: "mattermost" as const,
|
Surface: "mattermost" as const,
|
||||||
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
|
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
|
||||||
|
ReplyToId: threadContext.effectiveReplyToId,
|
||||||
|
MessageThreadId: threadContext.effectiveReplyToId,
|
||||||
WasMentioned: true,
|
WasMentioned: true,
|
||||||
CommandAuthorized: false,
|
CommandAuthorized: false,
|
||||||
OriginatingChannel: "mattermost" as const,
|
OriginatingChannel: "mattermost" as const,
|
||||||
@ -604,7 +667,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
const typingCallbacks = createTypingCallbacks({
|
const typingCallbacks = createTypingCallbacks({
|
||||||
start: () => sendTypingIndicator(opts.channelId),
|
start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
|
||||||
onStartError: (err) => {
|
onStartError: (err) => {
|
||||||
logTypingFailure({
|
logTypingFailure({
|
||||||
log: (message) => logger.debug?.(message),
|
log: (message) => logger.debug?.(message),
|
||||||
@ -636,6 +699,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
replyToId: resolveMattermostReplyRootId({
|
||||||
|
threadRootId: threadContext.effectiveReplyToId,
|
||||||
|
replyToId: payload.replyToId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -646,6 +713,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
await sendMessageMattermost(to, caption, {
|
await sendMessageMattermost(to, caption, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
replyToId: resolveMattermostReplyRootId({
|
||||||
|
threadRootId: threadContext.effectiveReplyToId,
|
||||||
|
replyToId: payload.replyToId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -834,6 +905,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
commandText: string;
|
commandText: string;
|
||||||
commandAuthorized: boolean;
|
commandAuthorized: boolean;
|
||||||
route: ReturnType<typeof core.channel.routing.resolveAgentRoute>;
|
route: ReturnType<typeof core.channel.routing.resolveAgentRoute>;
|
||||||
|
sessionKey: string;
|
||||||
|
parentSessionKey?: string;
|
||||||
channelId: string;
|
channelId: string;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
senderName: string;
|
senderName: string;
|
||||||
@ -844,6 +917,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
roomLabel: string;
|
roomLabel: string;
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
postId: string;
|
postId: string;
|
||||||
|
effectiveReplyToId?: string;
|
||||||
deliverReplies?: boolean;
|
deliverReplies?: boolean;
|
||||||
}): Promise<string> => {
|
}): Promise<string> => {
|
||||||
const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`;
|
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:group:${params.channelId}`
|
||||||
: `mattermost:channel:${params.channelId}`,
|
: `mattermost:channel:${params.channelId}`,
|
||||||
To: to,
|
To: to,
|
||||||
SessionKey: params.route.sessionKey,
|
SessionKey: params.sessionKey,
|
||||||
|
ParentSessionKey: params.parentSessionKey,
|
||||||
AccountId: params.route.accountId,
|
AccountId: params.route.accountId,
|
||||||
ChatType: params.chatType,
|
ChatType: params.chatType,
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
@ -876,6 +951,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
Provider: "mattermost" as const,
|
Provider: "mattermost" as const,
|
||||||
Surface: "mattermost" as const,
|
Surface: "mattermost" as const,
|
||||||
MessageSid: `interaction:${params.postId}:${Date.now()}`,
|
MessageSid: `interaction:${params.postId}:${Date.now()}`,
|
||||||
|
ReplyToId: params.effectiveReplyToId,
|
||||||
|
MessageThreadId: params.effectiveReplyToId,
|
||||||
Timestamp: Date.now(),
|
Timestamp: Date.now(),
|
||||||
WasMentioned: true,
|
WasMentioned: true,
|
||||||
CommandAuthorized: params.commandAuthorized,
|
CommandAuthorized: params.commandAuthorized,
|
||||||
@ -907,7 +984,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const capturedTexts: string[] = [];
|
const capturedTexts: string[] = [];
|
||||||
const typingCallbacks = shouldDeliverReplies
|
const typingCallbacks = shouldDeliverReplies
|
||||||
? createTypingCallbacks({
|
? createTypingCallbacks({
|
||||||
start: () => sendTypingIndicator(params.channelId),
|
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
|
||||||
onStartError: (err) => {
|
onStartError: (err) => {
|
||||||
logTypingFailure({
|
logTypingFailure({
|
||||||
log: (message) => logger.debug?.(message),
|
log: (message) => logger.debug?.(message),
|
||||||
@ -948,6 +1025,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
}
|
}
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
replyToId: resolveMattermostReplyRootId({
|
||||||
|
threadRootId: params.effectiveReplyToId,
|
||||||
|
replyToId: payload.replyToId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -960,6 +1041,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
await sendMessageMattermost(to, caption, {
|
await sendMessageMattermost(to, caption, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
|
replyToId: resolveMattermostReplyRootId({
|
||||||
|
threadRootId: params.effectiveReplyToId,
|
||||||
|
replyToId: payload.replyToId,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1000,6 +1085,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
};
|
};
|
||||||
userName: string;
|
userName: string;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
post: MattermostPost;
|
||||||
}): Promise<MattermostInteractionResponse | null> {
|
}): Promise<MattermostInteractionResponse | null> {
|
||||||
const pickerState = parseMattermostModelPickerContext(params.context);
|
const pickerState = parseMattermostModelPickerContext(params.context);
|
||||||
if (!pickerState) {
|
if (!pickerState) {
|
||||||
@ -1088,6 +1174,18 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
id: kind === "direct" ? params.payload.user_id : params.payload.channel_id,
|
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);
|
const data = await buildModelsProviderData(cfg, route.agentId);
|
||||||
if (data.providers.length === 0) {
|
if (data.providers.length === 0) {
|
||||||
@ -1101,7 +1199,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
if (pickerState.action === "providers" || pickerState.action === "back") {
|
if (pickerState.action === "providers" || pickerState.action === "back") {
|
||||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||||
cfg,
|
cfg,
|
||||||
route,
|
route: modelSessionRoute,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
const view = renderMattermostProviderPickerView({
|
const view = renderMattermostProviderPickerView({
|
||||||
@ -1120,7 +1218,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
if (pickerState.action === "list") {
|
if (pickerState.action === "list") {
|
||||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||||
cfg,
|
cfg,
|
||||||
route,
|
route: modelSessionRoute,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
const view = renderMattermostModelsPickerView({
|
const view = renderMattermostModelsPickerView({
|
||||||
@ -1151,6 +1249,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
commandText: `/model ${targetModelRef}`,
|
commandText: `/model ${targetModelRef}`,
|
||||||
commandAuthorized: auth.commandAuthorized,
|
commandAuthorized: auth.commandAuthorized,
|
||||||
route,
|
route,
|
||||||
|
sessionKey: threadContext.sessionKey,
|
||||||
|
parentSessionKey: threadContext.parentSessionKey,
|
||||||
channelId: params.payload.channel_id,
|
channelId: params.payload.channel_id,
|
||||||
senderId: params.payload.user_id,
|
senderId: params.payload.user_id,
|
||||||
senderName: params.userName,
|
senderName: params.userName,
|
||||||
@ -1161,11 +1261,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
roomLabel,
|
roomLabel,
|
||||||
teamId,
|
teamId,
|
||||||
postId: params.payload.post_id,
|
postId: params.payload.post_id,
|
||||||
|
effectiveReplyToId: threadContext.effectiveReplyToId,
|
||||||
deliverReplies: true,
|
deliverReplies: true,
|
||||||
});
|
});
|
||||||
const updatedModel = resolveMattermostModelPickerCurrentModel({
|
const updatedModel = resolveMattermostModelPickerCurrentModel({
|
||||||
cfg,
|
cfg,
|
||||||
route,
|
route: modelSessionRoute,
|
||||||
data,
|
data,
|
||||||
skipCache: true,
|
skipCache: true,
|
||||||
});
|
});
|
||||||
@ -1385,12 +1486,15 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
const threadRootId = post.root_id?.trim() || undefined;
|
const threadRootId = post.root_id?.trim() || undefined;
|
||||||
const threadKeys = resolveThreadSessionKeys({
|
const replyToMode = resolveMattermostReplyToMode(account, kind);
|
||||||
|
const threadContext = resolveMattermostThreadSessionContext({
|
||||||
baseSessionKey,
|
baseSessionKey,
|
||||||
threadId: threadRootId,
|
kind,
|
||||||
parentSessionKey: threadRootId ? baseSessionKey : undefined,
|
postId: post.id,
|
||||||
|
replyToMode,
|
||||||
|
threadRootId,
|
||||||
});
|
});
|
||||||
const sessionKey = threadKeys.sessionKey;
|
const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext;
|
||||||
const historyKey = kind === "direct" ? null : sessionKey;
|
const historyKey = kind === "direct" ? null : sessionKey;
|
||||||
|
|
||||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||||
@ -1554,7 +1658,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
: `mattermost:channel:${channelId}`,
|
: `mattermost:channel:${channelId}`,
|
||||||
To: to,
|
To: to,
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
ParentSessionKey: threadKeys.parentSessionKey,
|
ParentSessionKey: parentSessionKey,
|
||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: chatType,
|
ChatType: chatType,
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
@ -1570,8 +1674,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
|
||||||
MessageSidLast:
|
MessageSidLast:
|
||||||
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
|
||||||
ReplyToId: threadRootId,
|
ReplyToId: effectiveReplyToId,
|
||||||
MessageThreadId: threadRootId,
|
MessageThreadId: effectiveReplyToId,
|
||||||
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||||
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
|
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
|
||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
@ -1623,7 +1727,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
|
|
||||||
const typingCallbacks = createTypingCallbacks({
|
const typingCallbacks = createTypingCallbacks({
|
||||||
start: () => sendTypingIndicator(channelId, threadRootId),
|
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
|
||||||
onStartError: (err) => {
|
onStartError: (err) => {
|
||||||
logTypingFailure({
|
logTypingFailure({
|
||||||
log: (message) => logger.debug?.(message),
|
log: (message) => logger.debug?.(message),
|
||||||
@ -1655,7 +1759,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToId: resolveMattermostReplyRootId({
|
replyToId: resolveMattermostReplyRootId({
|
||||||
threadRootId,
|
threadRootId: effectiveReplyToId,
|
||||||
replyToId: payload.replyToId,
|
replyToId: payload.replyToId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -1669,7 +1773,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
replyToId: resolveMattermostReplyRootId({
|
replyToId: resolveMattermostReplyRootId({
|
||||||
threadRootId,
|
threadRootId: effectiveReplyToId,
|
||||||
replyToId: payload.replyToId,
|
replyToId: payload.replyToId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,9 @@ import type {
|
|||||||
SecretInput,
|
SecretInput,
|
||||||
} from "openclaw/plugin-sdk/mattermost";
|
} 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 MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||||
|
|
||||||
export type MattermostAccountConfig = {
|
export type MattermostAccountConfig = {
|
||||||
@ -52,10 +55,16 @@ export type MattermostAccountConfig = {
|
|||||||
blockStreaming?: boolean;
|
blockStreaming?: boolean;
|
||||||
/** Merge streamed block replies before sending. */
|
/** Merge streamed block replies before sending. */
|
||||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
/** Control reply threading (off|first|all). Default: "all". */
|
|
||||||
replyToMode?: "off" | "first" | "all";
|
|
||||||
/** Outbound response prefix override for this channel/account. */
|
/** Outbound response prefix override for this channel/account. */
|
||||||
responsePrefix?: string;
|
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. */
|
/** Action toggles for this account. */
|
||||||
actions?: {
|
actions?: {
|
||||||
/** Enable message reaction actions. Default: true. */
|
/** Enable message reaction actions. Default: true. */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user