refactor: extract mattermost channel plugin to extension
Move mattermost channel implementation from core to extensions/mattermost plugin. Extract config schema, group mentions, normalize utilities, and all mattermost-specific logic (accounts, client, monitor, probe, send) into the extension. Update imports to use plugin SDK and local modules. Add channel metadata directly in plugin definition instead of using getChatChannelMeta. Update package.json with channel and install configuration.
This commit is contained in:
@@ -6,6 +6,20 @@
|
|||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
],
|
||||||
|
"channel": {
|
||||||
|
"id": "mattermost",
|
||||||
|
"label": "Mattermost",
|
||||||
|
"selectionLabel": "Mattermost (plugin)",
|
||||||
|
"docsPath": "/channels/mattermost",
|
||||||
|
"docsLabel": "mattermost",
|
||||||
|
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
|
||||||
|
"order": 65
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"npmSpec": "@clawdbot/mattermost",
|
||||||
|
"localPath": "extensions/mattermost",
|
||||||
|
"defaultChoice": "npm"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,42 @@ import {
|
|||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
getChatChannelMeta,
|
|
||||||
listMattermostAccountIds,
|
|
||||||
looksLikeMattermostTargetId,
|
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
normalizeMattermostBaseUrl,
|
|
||||||
normalizeMattermostMessagingTarget,
|
|
||||||
resolveDefaultMattermostAccountId,
|
|
||||||
resolveMattermostAccount,
|
|
||||||
resolveMattermostGroupRequireMention,
|
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
mattermostOnboardingAdapter,
|
|
||||||
MattermostConfigSchema,
|
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type ResolvedMattermostAccount,
|
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { MattermostConfigSchema } from "./config-schema.js";
|
||||||
|
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||||
|
import {
|
||||||
|
looksLikeMattermostTargetId,
|
||||||
|
normalizeMattermostMessagingTarget,
|
||||||
|
} from "./normalize.js";
|
||||||
|
import { mattermostOnboardingAdapter } from "./onboarding.js";
|
||||||
|
import {
|
||||||
|
listMattermostAccountIds,
|
||||||
|
resolveDefaultMattermostAccountId,
|
||||||
|
resolveMattermostAccount,
|
||||||
|
type ResolvedMattermostAccount,
|
||||||
|
} from "./mattermost/accounts.js";
|
||||||
|
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||||
|
import { monitorMattermostProvider } from "./mattermost/monitor.js";
|
||||||
|
import { probeMattermost } from "./mattermost/probe.js";
|
||||||
|
import { sendMessageMattermost } from "./mattermost/send.js";
|
||||||
import { getMattermostRuntime } from "./runtime.js";
|
import { getMattermostRuntime } from "./runtime.js";
|
||||||
|
|
||||||
const meta = getChatChannelMeta("mattermost");
|
const meta = {
|
||||||
|
id: "mattermost",
|
||||||
|
label: "Mattermost",
|
||||||
|
selectionLabel: "Mattermost (plugin)",
|
||||||
|
detailLabel: "Mattermost Bot",
|
||||||
|
docsPath: "/channels/mattermost",
|
||||||
|
docsLabel: "mattermost",
|
||||||
|
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
|
||||||
|
systemImage: "bubble.left.and.bubble.right",
|
||||||
|
order: 65,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
||||||
id: "mattermost",
|
id: "mattermost",
|
||||||
@@ -96,8 +112,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||||
const send =
|
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
||||||
deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
|
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToId: replyToId ?? undefined,
|
replyToId: replyToId ?? undefined,
|
||||||
@@ -105,8 +120,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
return { channel: "mattermost", ...result };
|
return { channel: "mattermost", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||||
const send =
|
const send = deps?.sendMattermost ?? sendMessageMattermost;
|
||||||
deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
|
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -144,11 +158,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
if (!token || !baseUrl) {
|
if (!token || !baseUrl) {
|
||||||
return { ok: false, error: "bot token or baseUrl missing" };
|
return { ok: false, error: "bot token or baseUrl missing" };
|
||||||
}
|
}
|
||||||
return await getMattermostRuntime().channel.mattermost.probeMattermost(
|
return await probeMattermost(baseUrl, token, timeoutMs);
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -256,7 +266,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
botTokenSource: account.botTokenSource,
|
botTokenSource: account.botTokenSource,
|
||||||
});
|
});
|
||||||
ctx.log?.info(`[${account.accountId}] starting channel`);
|
ctx.log?.info(`[${account.accountId}] starting channel`);
|
||||||
return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
|
return monitorMattermostProvider({
|
||||||
botToken: account.botToken ?? undefined,
|
botToken: account.botToken ?? undefined,
|
||||||
baseUrl: account.baseUrl ?? undefined,
|
baseUrl: account.baseUrl ?? undefined,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
|
|||||||
24
extensions/mattermost/src/config-schema.ts
Normal file
24
extensions/mattermost/src/config-schema.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
const MattermostAccountSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
capabilities: z.array(z.string()).optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
configWrites: z.boolean().optional(),
|
||||||
|
botToken: z.string().optional(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
|
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||||
|
oncharPrefixes: z.array(z.string()).optional(),
|
||||||
|
requireMention: z.boolean().optional(),
|
||||||
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
blockStreaming: z.boolean().optional(),
|
||||||
|
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const MattermostConfigSchema = MattermostAccountSchema.extend({
|
||||||
|
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
|
||||||
|
});
|
||||||
14
extensions/mattermost/src/group-mentions.ts
Normal file
14
extensions/mattermost/src/group-mentions.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||||
|
|
||||||
|
export function resolveMattermostGroupRequireMention(
|
||||||
|
params: ChannelGroupContext,
|
||||||
|
): boolean | undefined {
|
||||||
|
const account = resolveMattermostAccount({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
if (typeof account.requireMention === "boolean") return account.requireMention;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js";
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
|
||||||
|
import type { MattermostAccountConfig, MattermostChatMode } 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";
|
||||||
150
extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
150
extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
|
||||||
|
import type WebSocket from "ws";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
export type ResponsePrefixContext = {
|
||||||
|
model?: string;
|
||||||
|
modelFull?: string;
|
||||||
|
provider?: string;
|
||||||
|
thinkingLevel?: string;
|
||||||
|
identityName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractShortModelName(fullModel: string): string {
|
||||||
|
const slash = fullModel.lastIndexOf("/");
|
||||||
|
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||||
|
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatInboundFromLabel(params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
groupLabel?: string;
|
||||||
|
groupId?: string;
|
||||||
|
directLabel: string;
|
||||||
|
directId?: string;
|
||||||
|
groupFallback?: string;
|
||||||
|
}): string {
|
||||||
|
if (params.isGroup) {
|
||||||
|
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
|
||||||
|
const id = params.groupId?.trim();
|
||||||
|
return id ? `${label} id:${id}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directLabel = params.directLabel.trim();
|
||||||
|
const directId = params.directId?.trim();
|
||||||
|
if (!directId || directId === directLabel) return directLabel;
|
||||||
|
return `${directLabel} id:${directId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DedupeCache = {
|
||||||
|
check: (key: string | undefined | null, now?: number) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
|
||||||
|
const ttlMs = Math.max(0, options.ttlMs);
|
||||||
|
const maxSize = Math.max(0, Math.floor(options.maxSize));
|
||||||
|
const cache = new Map<string, number>();
|
||||||
|
|
||||||
|
const touch = (key: string, now: number) => {
|
||||||
|
cache.delete(key);
|
||||||
|
cache.set(key, now);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prune = (now: number) => {
|
||||||
|
const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
|
||||||
|
if (cutoff !== undefined) {
|
||||||
|
for (const [entryKey, entryTs] of cache) {
|
||||||
|
if (entryTs < cutoff) {
|
||||||
|
cache.delete(entryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxSize <= 0) {
|
||||||
|
cache.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (cache.size > maxSize) {
|
||||||
|
const oldestKey = cache.keys().next().value as string | undefined;
|
||||||
|
if (!oldestKey) break;
|
||||||
|
cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
check: (key, now = Date.now()) => {
|
||||||
|
if (!key) return false;
|
||||||
|
const existing = cache.get(key);
|
||||||
|
if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
|
||||||
|
touch(key, now);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
touch(key, now);
|
||||||
|
prune(now);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rawDataToString(
|
||||||
|
data: WebSocket.RawData,
|
||||||
|
encoding: BufferEncoding = "utf8",
|
||||||
|
): string {
|
||||||
|
if (typeof data === "string") return data;
|
||||||
|
if (Buffer.isBuffer(data)) return data.toString(encoding);
|
||||||
|
if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return Buffer.from(data).toString(encoding);
|
||||||
|
}
|
||||||
|
return Buffer.from(String(data)).toString(encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
|
const trimmed = (value ?? "").trim();
|
||||||
|
if (!trimmed) return "main";
|
||||||
|
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
|
||||||
|
return (
|
||||||
|
trimmed
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+/, "")
|
||||||
|
.replace(/-+$/, "")
|
||||||
|
.slice(0, 64) || "main"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentEntry = NonNullable<NonNullable<ClawdbotConfig["agents"]>["list"]>[number];
|
||||||
|
|
||||||
|
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
|
||||||
|
const list = cfg.agents?.list;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
|
||||||
|
const id = normalizeAgentId(agentId);
|
||||||
|
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
|
||||||
|
const entry = resolveAgentEntry(cfg, agentId);
|
||||||
|
return entry?.identity?.name?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThreadSessionKeys(params: {
|
||||||
|
baseSessionKey: string;
|
||||||
|
threadId?: string | null;
|
||||||
|
parentSessionKey?: string;
|
||||||
|
useSuffix?: boolean;
|
||||||
|
}): { sessionKey: string; parentSessionKey?: string } {
|
||||||
|
const threadId = (params.threadId ?? "").trim();
|
||||||
|
if (!threadId) {
|
||||||
|
return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
|
||||||
|
}
|
||||||
|
const useSuffix = params.useSuffix ?? true;
|
||||||
|
const sessionKey = useSuffix
|
||||||
|
? `${params.baseSessionKey}:thread:${threadId}`
|
||||||
|
: params.baseSessionKey;
|
||||||
|
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
||||||
|
}
|
||||||
@@ -1,52 +1,21 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
resolveEffectiveMessagesConfig,
|
ChannelAccountSnapshot,
|
||||||
resolveHumanDelayConfig,
|
ClawdbotConfig,
|
||||||
resolveIdentityName,
|
ReplyPayload,
|
||||||
} from "../agents/identity.js";
|
RuntimeEnv,
|
||||||
import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
|
||||||
import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js";
|
|
||||||
import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js";
|
|
||||||
import {
|
|
||||||
createInboundDebouncer,
|
|
||||||
resolveInboundDebounceMs,
|
|
||||||
} from "../auto-reply/inbound-debounce.js";
|
|
||||||
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
|
|
||||||
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
|
|
||||||
import {
|
import {
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
recordPendingHistoryEntry,
|
recordPendingHistoryEntry,
|
||||||
|
resolveChannelMediaMaxBytes,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
} from "../auto-reply/reply/history.js";
|
} from "clawdbot/plugin-sdk";
|
||||||
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
|
||||||
import {
|
import { getMattermostRuntime } from "../runtime.js";
|
||||||
extractShortModelName,
|
|
||||||
type ResponsePrefixContext,
|
|
||||||
} from "../auto-reply/reply/response-prefix-template.js";
|
|
||||||
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
|
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
|
|
||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
|
||||||
import { createDedupeCache } from "../infra/dedupe.js";
|
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
|
||||||
import { getChildLogger } from "../logging.js";
|
|
||||||
import { mediaKindFromMime, type MediaKind } from "../media/constants.js";
|
|
||||||
import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js";
|
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
|
||||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
|
|
||||||
import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
|
|
||||||
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
|
|
||||||
import { resolveMattermostAccount } from "./accounts.js";
|
import { resolveMattermostAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createMattermostClient,
|
createMattermostClient,
|
||||||
@@ -59,6 +28,15 @@ import {
|
|||||||
type MattermostPost,
|
type MattermostPost,
|
||||||
type MattermostUser,
|
type MattermostUser,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
|
import {
|
||||||
|
createDedupeCache,
|
||||||
|
extractShortModelName,
|
||||||
|
formatInboundFromLabel,
|
||||||
|
rawDataToString,
|
||||||
|
resolveIdentityName,
|
||||||
|
resolveThreadSessionKeys,
|
||||||
|
type ResponsePrefixContext,
|
||||||
|
} from "./monitor-helpers.js";
|
||||||
import { sendMessageMattermost } from "./send.js";
|
import { sendMessageMattermost } from "./send.js";
|
||||||
|
|
||||||
export type MonitorMattermostOpts = {
|
export type MonitorMattermostOpts = {
|
||||||
@@ -71,6 +49,9 @@ export type MonitorMattermostOpts = {
|
|||||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FetchLike = typeof fetch;
|
||||||
|
type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||||
|
|
||||||
type MattermostEventPayload = {
|
type MattermostEventPayload = {
|
||||||
event?: string;
|
event?: string;
|
||||||
data?: {
|
data?: {
|
||||||
@@ -208,8 +189,9 @@ function buildMattermostWsUrl(baseUrl: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
|
||||||
|
const core = getMattermostRuntime();
|
||||||
const runtime = resolveRuntime(opts);
|
const runtime = resolveRuntime(opts);
|
||||||
const cfg = opts.config ?? loadConfig();
|
const cfg = opts.config ?? core.config.loadConfig();
|
||||||
const account = resolveMattermostAccount({
|
const account = resolveMattermostAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
@@ -235,7 +217,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||||
const logger = getChildLogger({ module: "mattermost" });
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
|
const logVerboseMessage = (message: string) => {
|
||||||
|
if (!core.logging.shouldLogVerbose()) return;
|
||||||
|
logger.debug?.(message);
|
||||||
|
};
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
resolveChannelMediaMaxBytes({
|
resolveChannelMediaMaxBytes({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -262,13 +248,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const out: MattermostMediaInfo[] = [];
|
const out: MattermostMediaInfo[] = [];
|
||||||
for (const fileId of ids) {
|
for (const fileId of ids) {
|
||||||
try {
|
try {
|
||||||
const fetched = await fetchRemoteMedia({
|
const fetched = await core.channel.media.fetchRemoteMedia({
|
||||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||||
fetchImpl: fetchWithAuth,
|
fetchImpl: fetchWithAuth,
|
||||||
filePathHint: fileId,
|
filePathHint: fileId,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
});
|
});
|
||||||
const saved = await saveMediaBuffer(
|
const saved = await core.channel.media.saveMediaBuffer(
|
||||||
fetched.buffer,
|
fetched.buffer,
|
||||||
fetched.contentType ?? undefined,
|
fetched.contentType ?? undefined,
|
||||||
"inbound",
|
"inbound",
|
||||||
@@ -278,7 +264,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
out.push({
|
out.push({
|
||||||
path: saved.path,
|
path: saved.path,
|
||||||
contentType,
|
contentType,
|
||||||
kind: mediaKindFromMime(contentType),
|
kind: core.media.mediaKindFromMime(contentType),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||||
@@ -366,7 +352,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
|
||||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = core.channel.routing.resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -387,12 +373,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const sessionKey = threadKeys.sessionKey;
|
const sessionKey = threadKeys.sessionKey;
|
||||||
const historyKey = kind === "dm" ? null : sessionKey;
|
const historyKey = kind === "dm" ? null : sessionKey;
|
||||||
|
|
||||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
|
||||||
const rawText = post.message?.trim() || "";
|
const rawText = post.message?.trim() || "";
|
||||||
const wasMentioned =
|
const wasMentioned =
|
||||||
kind !== "dm" &&
|
kind !== "dm" &&
|
||||||
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
|
||||||
matchesMentionPatterns(rawText, mentionRegexes));
|
core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
|
||||||
const pendingBody =
|
const pendingBody =
|
||||||
rawText ||
|
rawText ||
|
||||||
(post.file_ids?.length
|
(post.file_ids?.length
|
||||||
@@ -416,11 +402,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowTextCommands = shouldHandleTextCommands({
|
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||||
cfg,
|
cfg,
|
||||||
surface: "mattermost",
|
surface: "mattermost",
|
||||||
});
|
});
|
||||||
const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
|
const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
|
||||||
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
|
||||||
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
|
||||||
const oncharResult = oncharEnabled
|
const oncharResult = oncharEnabled
|
||||||
@@ -456,7 +442,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
const bodyText = normalizeMention(baseText, botUsername);
|
const bodyText = normalizeMention(baseText, botUsername);
|
||||||
if (!bodyText) return;
|
if (!bodyText) return;
|
||||||
|
|
||||||
recordChannelActivity({
|
core.channel.activity.record({
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
@@ -476,13 +462,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
kind === "dm"
|
kind === "dm"
|
||||||
? `Mattermost DM from ${senderName}`
|
? `Mattermost DM from ${senderName}`
|
||||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||||
const body = formatInboundEnvelope({
|
const body = core.channel.reply.formatInboundEnvelope({
|
||||||
channel: "Mattermost",
|
channel: "Mattermost",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
|
||||||
@@ -498,7 +484,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
limit: historyLimit,
|
limit: historyLimit,
|
||||||
currentMessage: combinedBody,
|
currentMessage: combinedBody,
|
||||||
formatEntry: (entry) =>
|
formatEntry: (entry) =>
|
||||||
formatInboundEnvelope({
|
core.channel.reply.formatInboundEnvelope({
|
||||||
channel: "Mattermost",
|
channel: "Mattermost",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
@@ -513,11 +499,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
|
||||||
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
const mediaPayload = buildMattermostMediaPayload(mediaList);
|
||||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||||
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
|
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
|
||||||
authorizers: [],
|
authorizers: [],
|
||||||
});
|
});
|
||||||
const ctxPayload = finalizeInboundContext({
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||||
Body: combinedBody,
|
Body: combinedBody,
|
||||||
RawBody: bodyText,
|
RawBody: bodyText,
|
||||||
CommandBody: bodyText,
|
CommandBody: bodyText,
|
||||||
@@ -557,10 +543,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
|
|
||||||
if (kind === "dm") {
|
if (kind === "dm") {
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
const storePath = resolveStorePath(sessionCfg?.store, {
|
const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
});
|
||||||
await updateLastRoute({
|
await core.channel.session.updateLastRoute({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: route.mainSessionKey,
|
sessionKey: route.mainSessionKey,
|
||||||
deliveryContext: {
|
deliveryContext: {
|
||||||
@@ -571,14 +557,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldLogVerbose()) {
|
|
||||||
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||||
logVerbose(
|
logVerboseMessage(
|
||||||
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -586,15 +570,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
identityName: resolveIdentityName(cfg, route.agentId),
|
identityName: resolveIdentityName(cfg, route.agentId),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
|
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
|
||||||
|
.responsePrefix,
|
||||||
responsePrefixContextProvider: () => prefixContext,
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||||
deliver: async (payload: ReplyPayload) => {
|
deliver: async (payload: ReplyPayload) => {
|
||||||
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
if (mediaUrls.length === 0) {
|
if (mediaUrls.length === 0) {
|
||||||
const chunks = chunkMarkdownText(text, textLimit);
|
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
|
||||||
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
for (const chunk of chunks.length > 0 ? chunks : [text]) {
|
||||||
if (!chunk) continue;
|
if (!chunk) continue;
|
||||||
await sendMessageMattermost(to, chunk, {
|
await sendMessageMattermost(to, chunk, {
|
||||||
@@ -617,12 +603,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
runtime.log?.(`delivered reply to ${to}`);
|
runtime.log?.(`delivered reply to ${to}`);
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`));
|
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
|
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
|
||||||
});
|
});
|
||||||
|
|
||||||
await dispatchReplyFromConfig({
|
await core.channel.reply.dispatchReplyFromConfig({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -644,8 +630,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
|
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||||
const debouncer = createInboundDebouncer<{
|
cfg,
|
||||||
|
channel: "mattermost",
|
||||||
|
});
|
||||||
|
const debouncer = core.channel.debounce.createInboundDebouncer<{
|
||||||
post: MattermostPost;
|
post: MattermostPost;
|
||||||
payload: MattermostEventPayload;
|
payload: MattermostEventPayload;
|
||||||
}>({
|
}>({
|
||||||
@@ -664,7 +653,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
|
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
|
||||||
const text = entry.post.message?.trim() ?? "";
|
const text = entry.post.message?.trim() ?? "";
|
||||||
if (!text) return false;
|
if (!text) return false;
|
||||||
return !hasControlCommand(text, cfg);
|
return !core.channel.text.hasControlCommand(text, cfg);
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
const last = entries.at(-1);
|
const last = entries.at(-1);
|
||||||
@@ -686,7 +675,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
|
runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -739,7 +728,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
try {
|
try {
|
||||||
await debouncer.enqueue({ post, payload });
|
await debouncer.enqueue({ post, payload });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
|
runtime.error?.(`mattermost handler failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -758,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("error", (err) => {
|
ws.on("error", (err) => {
|
||||||
runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
|
runtime.error?.(`mattermost websocket error: ${String(err)}`);
|
||||||
opts.statusSink?.({
|
opts.statusSink?.({
|
||||||
lastError: String(err),
|
lastError: String(err),
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { getMattermostRuntime } from "../runtime.js";
|
||||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
|
||||||
import { loadWebMedia } from "../web/media.js";
|
|
||||||
import { resolveMattermostAccount } from "./accounts.js";
|
import { resolveMattermostAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createMattermostClient,
|
createMattermostClient,
|
||||||
@@ -34,6 +31,8 @@ type MattermostTarget =
|
|||||||
const botUserCache = new Map<string, MattermostUser>();
|
const botUserCache = new Map<string, MattermostUser>();
|
||||||
const userByNameCache = new Map<string, MattermostUser>();
|
const userByNameCache = new Map<string, MattermostUser>();
|
||||||
|
|
||||||
|
const getCore = () => getMattermostRuntime();
|
||||||
|
|
||||||
function cacheKey(baseUrl: string, token: string): string {
|
function cacheKey(baseUrl: string, token: string): string {
|
||||||
return `${baseUrl}::${token}`;
|
return `${baseUrl}::${token}`;
|
||||||
}
|
}
|
||||||
@@ -129,7 +128,9 @@ export async function sendMessageMattermost(
|
|||||||
text: string,
|
text: string,
|
||||||
opts: MattermostSendOpts = {},
|
opts: MattermostSendOpts = {},
|
||||||
): Promise<MattermostSendResult> {
|
): Promise<MattermostSendResult> {
|
||||||
const cfg = loadConfig();
|
const core = getCore();
|
||||||
|
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
|
const cfg = core.config.loadConfig();
|
||||||
const account = resolveMattermostAccount({
|
const account = resolveMattermostAccount({
|
||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
@@ -161,7 +162,7 @@ export async function sendMessageMattermost(
|
|||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
if (mediaUrl) {
|
if (mediaUrl) {
|
||||||
try {
|
try {
|
||||||
const media = await loadWebMedia(mediaUrl);
|
const media = await core.media.loadWebMedia(mediaUrl);
|
||||||
const fileInfo = await uploadMattermostFile(client, {
|
const fileInfo = await uploadMattermostFile(client, {
|
||||||
channelId,
|
channelId,
|
||||||
buffer: media.buffer,
|
buffer: media.buffer,
|
||||||
@@ -171,8 +172,8 @@ export async function sendMessageMattermost(
|
|||||||
fileIds = [fileInfo.id];
|
fileIds = [fileInfo.id];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||||
if (shouldLogVerbose()) {
|
if (core.logging.shouldLogVerbose()) {
|
||||||
logVerbose(
|
logger.debug?.(
|
||||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -194,7 +195,7 @@ export async function sendMessageMattermost(
|
|||||||
fileIds,
|
fileIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
recordChannelActivity({
|
core.channel.activity.record({
|
||||||
channel: "mattermost",
|
channel: "mattermost",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
42
extensions/mattermost/src/onboarding-helpers.ts
Normal file
42
extensions/mattermost/src/onboarding-helpers.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
type PromptAccountIdParams = {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
label: string;
|
||||||
|
currentId?: string;
|
||||||
|
listAccountIds: (cfg: ClawdbotConfig) => string[];
|
||||||
|
defaultAccountId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function promptAccountId(params: PromptAccountIdParams): Promise<string> {
|
||||||
|
const existingIds = params.listAccountIds(params.cfg);
|
||||||
|
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
|
||||||
|
const choice = (await params.prompter.select({
|
||||||
|
message: `${params.label} account`,
|
||||||
|
options: [
|
||||||
|
...existingIds.map((id) => ({
|
||||||
|
value: id,
|
||||||
|
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
|
||||||
|
})),
|
||||||
|
{ value: "__new__", label: "Add a new account" },
|
||||||
|
],
|
||||||
|
initialValue: initial,
|
||||||
|
})) as string;
|
||||||
|
|
||||||
|
if (choice !== "__new__") return normalizeAccountId(choice);
|
||||||
|
|
||||||
|
const entered = await params.prompter.text({
|
||||||
|
message: `New ${params.label} account id`,
|
||||||
|
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
const normalized = normalizeAccountId(String(entered));
|
||||||
|
if (String(entered).trim() !== normalized) {
|
||||||
|
await params.prompter.note(
|
||||||
|
`Normalized account id to "${normalized}".`,
|
||||||
|
`${params.label} account`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
listMattermostAccountIds,
|
listMattermostAccountIds,
|
||||||
resolveDefaultMattermostAccountId,
|
resolveDefaultMattermostAccountId,
|
||||||
resolveMattermostAccount,
|
resolveMattermostAccount,
|
||||||
} from "../../../mattermost/accounts.js";
|
} from "./mattermost/accounts.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
|
import { promptAccountId } from "./onboarding-helpers.js";
|
||||||
import { formatDocsLink } from "../../../terminal/links.js";
|
|
||||||
import type { WizardPrompter } from "../../../wizard/prompts.js";
|
|
||||||
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
|
|
||||||
import { promptAccountId } from "./helpers.js";
|
|
||||||
|
|
||||||
const channel = "mattermost" as const;
|
const channel = "mattermost" as const;
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
|
|||||||
"2) Create a bot + copy its token",
|
"2) Create a bot + copy its token",
|
||||||
"3) Use your server base URL (e.g., https://chat.example.com)",
|
"3) Use your server base URL (e.g., https://chat.example.com)",
|
||||||
"Tip: the bot must be a member of any channel you want it to monitor.",
|
"Tip: the bot must be a member of any channel you want it to monitor.",
|
||||||
`Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`,
|
"Docs: https://docs.clawd.bot/channels/mattermost",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"Mattermost bot token",
|
"Mattermost bot token",
|
||||||
);
|
);
|
||||||
40
extensions/mattermost/src/types.ts
Normal file
40
extensions/mattermost/src/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
|
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
|
||||||
|
|
||||||
|
export type MattermostAccountConfig = {
|
||||||
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
|
name?: string;
|
||||||
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
|
capabilities?: string[];
|
||||||
|
/** Allow channel-initiated config writes (default: true). */
|
||||||
|
configWrites?: boolean;
|
||||||
|
/** If false, do not start this Mattermost account. Default: true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Bot token for Mattermost. */
|
||||||
|
botToken?: string;
|
||||||
|
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
|
||||||
|
baseUrl?: string;
|
||||||
|
/**
|
||||||
|
* Controls when channel messages trigger replies.
|
||||||
|
* - "oncall": only respond when mentioned
|
||||||
|
* - "onmessage": respond to every channel message
|
||||||
|
* - "onchar": respond when a trigger character prefixes the message
|
||||||
|
*/
|
||||||
|
chatmode?: MattermostChatMode;
|
||||||
|
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
|
||||||
|
oncharPrefixes?: string[];
|
||||||
|
/** Require @mention to respond in channels. Default: true. */
|
||||||
|
requireMention?: boolean;
|
||||||
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
|
textChunkLimit?: number;
|
||||||
|
/** Disable block streaming for this account. */
|
||||||
|
blockStreaming?: boolean;
|
||||||
|
/** Merge streamed block replies before sending. */
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MattermostConfig = {
|
||||||
|
/** Optional per-account Mattermost configuration (multi-account). */
|
||||||
|
accounts?: Record<string, MattermostAccountConfig>;
|
||||||
|
} & MattermostAccountConfig;
|
||||||
@@ -159,8 +159,7 @@ export async function runPreparedReply(
|
|||||||
const isGroupChat = sessionCtx.ChatType === "group";
|
const isGroupChat = sessionCtx.ChatType === "group";
|
||||||
const originatingChannel =
|
const originatingChannel =
|
||||||
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
|
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
|
||||||
const wasMentioned =
|
const wasMentioned = ctx.WasMentioned === true;
|
||||||
ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
|
|
||||||
const isHeartbeat = opts?.isHeartbeat === true;
|
const isHeartbeat = opts?.isHeartbeat === true;
|
||||||
const typingMode = resolveTypingMode({
|
const typingMode = resolveTypingMode({
|
||||||
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
|||||||
import {
|
import {
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
resolveIMessageGroupRequireMention,
|
resolveIMessageGroupRequireMention,
|
||||||
resolveMattermostGroupRequireMention,
|
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
resolveTelegramGroupRequireMention,
|
resolveTelegramGroupRequireMention,
|
||||||
resolveWhatsAppGroupRequireMention,
|
resolveWhatsAppGroupRequireMention,
|
||||||
@@ -231,30 +230,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
buildToolContext: (params) => buildSlackThreadingToolContext(params),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mattermost: {
|
|
||||||
id: "mattermost",
|
|
||||||
capabilities: {
|
|
||||||
chatTypes: ["direct", "channel", "group", "thread"],
|
|
||||||
media: true,
|
|
||||||
threads: true,
|
|
||||||
},
|
|
||||||
outbound: { textChunkLimit: 4000 },
|
|
||||||
streaming: {
|
|
||||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
|
||||||
},
|
|
||||||
groups: {
|
|
||||||
resolveRequireMention: resolveMattermostGroupRequireMention,
|
|
||||||
},
|
|
||||||
threading: {
|
|
||||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
||||||
currentChannelId: context.To?.startsWith("channel:")
|
|
||||||
? context.To.slice("channel:".length)
|
|
||||||
: undefined,
|
|
||||||
currentThreadTs: context.ReplyToId,
|
|
||||||
hasRepliedRef,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
signal: {
|
signal: {
|
||||||
id: "signal",
|
id: "signal",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
|
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
|
||||||
import type { DiscordConfig } from "../../config/types.js";
|
import type { DiscordConfig } from "../../config/types.js";
|
||||||
import { resolveMattermostAccount } from "../../mattermost/accounts.js";
|
|
||||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
|
|
||||||
type GroupMentionParams = {
|
type GroupMentionParams = {
|
||||||
@@ -185,15 +184,6 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean {
|
|
||||||
const account = resolveMattermostAccount({
|
|
||||||
cfg: params.cfg,
|
|
||||||
accountId: params.accountId,
|
|
||||||
});
|
|
||||||
if (typeof account.requireMention === "boolean") return account.requireMention;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
|
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
|
||||||
return resolveChannelGroupRequireMention({
|
return resolveChannelGroupRequireMention({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export const CHAT_CHANNEL_ORDER = [
|
|||||||
"whatsapp",
|
"whatsapp",
|
||||||
"discord",
|
"discord",
|
||||||
"slack",
|
"slack",
|
||||||
"mattermost",
|
|
||||||
"signal",
|
"signal",
|
||||||
"imessage",
|
"imessage",
|
||||||
] as const;
|
] as const;
|
||||||
@@ -68,16 +67,6 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
|||||||
blurb: "supported (Socket Mode).",
|
blurb: "supported (Socket Mode).",
|
||||||
systemImage: "number",
|
systemImage: "number",
|
||||||
},
|
},
|
||||||
mattermost: {
|
|
||||||
id: "mattermost",
|
|
||||||
label: "Mattermost",
|
|
||||||
selectionLabel: "Mattermost (Bot Token)",
|
|
||||||
detailLabel: "Mattermost Bot",
|
|
||||||
docsPath: "/channels/mattermost",
|
|
||||||
docsLabel: "mattermost",
|
|
||||||
blurb: "self-hosted Slack-style chat (bot token + URL).",
|
|
||||||
systemImage: "bubble.left.and.bubble.right",
|
|
||||||
},
|
|
||||||
signal: {
|
signal: {
|
||||||
id: "signal",
|
id: "signal",
|
||||||
label: "Signal",
|
label: "Signal",
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import type { sendMessageDiscord } from "../../discord/send.js";
|
import type { sendMessageDiscord } from "../../discord/send.js";
|
||||||
import type { sendMessageIMessage } from "../../imessage/send.js";
|
import type { sendMessageIMessage } from "../../imessage/send.js";
|
||||||
import type { sendMessageMattermost } from "../../mattermost/send.js";
|
|
||||||
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
|
||||||
import { sendMessageSignal } from "../../signal/send.js";
|
import { sendMessageSignal } from "../../signal/send.js";
|
||||||
import type { sendMessageSlack } from "../../slack/send.js";
|
import type { sendMessageSlack } from "../../slack/send.js";
|
||||||
@@ -29,12 +28,18 @@ type SendMatrixMessage = (
|
|||||||
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
|
||||||
) => Promise<{ messageId: string; roomId: string }>;
|
) => Promise<{ messageId: string; roomId: string }>;
|
||||||
|
|
||||||
|
type SendMattermostMessage = (
|
||||||
|
to: string,
|
||||||
|
text: string,
|
||||||
|
opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
|
||||||
|
) => Promise<{ messageId: string; channelId: string }>;
|
||||||
|
|
||||||
export type OutboundSendDeps = {
|
export type OutboundSendDeps = {
|
||||||
sendWhatsApp?: typeof sendMessageWhatsApp;
|
sendWhatsApp?: typeof sendMessageWhatsApp;
|
||||||
sendTelegram?: typeof sendMessageTelegram;
|
sendTelegram?: typeof sendMessageTelegram;
|
||||||
sendDiscord?: typeof sendMessageDiscord;
|
sendDiscord?: typeof sendMessageDiscord;
|
||||||
sendSlack?: typeof sendMessageSlack;
|
sendSlack?: typeof sendMessageSlack;
|
||||||
sendMattermost?: typeof sendMessageMattermost;
|
sendMattermost?: SendMattermostMessage;
|
||||||
sendSignal?: typeof sendMessageSignal;
|
sendSignal?: typeof sendMessageSignal;
|
||||||
sendIMessage?: typeof sendMessageIMessage;
|
sendIMessage?: typeof sendMessageIMessage;
|
||||||
sendMatrix?: SendMatrixMessage;
|
sendMatrix?: SendMatrixMessage;
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export type {
|
|||||||
export {
|
export {
|
||||||
DiscordConfigSchema,
|
DiscordConfigSchema,
|
||||||
IMessageConfigSchema,
|
IMessageConfigSchema,
|
||||||
MattermostConfigSchema,
|
|
||||||
MSTeamsConfigSchema,
|
MSTeamsConfigSchema,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -121,7 +120,6 @@ export {
|
|||||||
resolveBlueBubblesGroupRequireMention,
|
resolveBlueBubblesGroupRequireMention,
|
||||||
resolveDiscordGroupRequireMention,
|
resolveDiscordGroupRequireMention,
|
||||||
resolveIMessageGroupRequireMention,
|
resolveIMessageGroupRequireMention,
|
||||||
resolveMattermostGroupRequireMention,
|
|
||||||
resolveSlackGroupRequireMention,
|
resolveSlackGroupRequireMention,
|
||||||
resolveTelegramGroupRequireMention,
|
resolveTelegramGroupRequireMention,
|
||||||
resolveWhatsAppGroupRequireMention,
|
resolveWhatsAppGroupRequireMention,
|
||||||
@@ -241,21 +239,6 @@ export {
|
|||||||
} from "../channels/plugins/normalize/slack.js";
|
} from "../channels/plugins/normalize/slack.js";
|
||||||
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
|
||||||
|
|
||||||
// Channel: Mattermost
|
|
||||||
export {
|
|
||||||
listEnabledMattermostAccounts,
|
|
||||||
listMattermostAccountIds,
|
|
||||||
resolveDefaultMattermostAccountId,
|
|
||||||
resolveMattermostAccount,
|
|
||||||
type ResolvedMattermostAccount,
|
|
||||||
} from "../mattermost/accounts.js";
|
|
||||||
export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
|
|
||||||
export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
|
|
||||||
export {
|
|
||||||
looksLikeMattermostTargetId,
|
|
||||||
normalizeMattermostMessagingTarget,
|
|
||||||
} from "../channels/plugins/normalize/mattermost.js";
|
|
||||||
|
|
||||||
// Channel: Telegram
|
// Channel: Telegram
|
||||||
export {
|
export {
|
||||||
listTelegramAccountIds,
|
listTelegramAccountIds,
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
|
|||||||
import { monitorIMessageProvider } from "../../imessage/monitor.js";
|
import { monitorIMessageProvider } from "../../imessage/monitor.js";
|
||||||
import { probeIMessage } from "../../imessage/probe.js";
|
import { probeIMessage } from "../../imessage/probe.js";
|
||||||
import { sendMessageIMessage } from "../../imessage/send.js";
|
import { sendMessageIMessage } from "../../imessage/send.js";
|
||||||
import { monitorMattermostProvider } from "../../mattermost/monitor.js";
|
|
||||||
import { probeMattermost } from "../../mattermost/probe.js";
|
|
||||||
import { sendMessageMattermost } from "../../mattermost/send.js";
|
|
||||||
import { shouldLogVerbose } from "../../globals.js";
|
import { shouldLogVerbose } from "../../globals.js";
|
||||||
import { getChildLogger } from "../../logging.js";
|
import { getChildLogger } from "../../logging.js";
|
||||||
import { normalizeLogLevel } from "../../logging/levels.js";
|
import { normalizeLogLevel } from "../../logging/levels.js";
|
||||||
@@ -233,11 +230,6 @@ export function createPluginRuntime(): PluginRuntime {
|
|||||||
monitorSlackProvider,
|
monitorSlackProvider,
|
||||||
handleSlackAction,
|
handleSlackAction,
|
||||||
},
|
},
|
||||||
mattermost: {
|
|
||||||
probeMattermost,
|
|
||||||
sendMessageMattermost,
|
|
||||||
monitorMattermostProvider,
|
|
||||||
},
|
|
||||||
telegram: {
|
telegram: {
|
||||||
auditGroupMembership: auditTelegramGroupMembership,
|
auditGroupMembership: auditTelegramGroupMembership,
|
||||||
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
|
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
|
||||||
|
|||||||
@@ -98,10 +98,6 @@ type ResolveSlackUserAllowlist =
|
|||||||
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
|
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
|
||||||
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
|
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
|
||||||
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
|
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
|
||||||
type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost;
|
|
||||||
type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost;
|
|
||||||
type MonitorMattermostProvider =
|
|
||||||
typeof import("../../mattermost/monitor.js").monitorMattermostProvider;
|
|
||||||
type AuditTelegramGroupMembership =
|
type AuditTelegramGroupMembership =
|
||||||
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
|
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
|
||||||
type CollectTelegramUnmentionedGroupIds =
|
type CollectTelegramUnmentionedGroupIds =
|
||||||
@@ -246,11 +242,6 @@ export type PluginRuntime = {
|
|||||||
monitorSlackProvider: MonitorSlackProvider;
|
monitorSlackProvider: MonitorSlackProvider;
|
||||||
handleSlackAction: HandleSlackAction;
|
handleSlackAction: HandleSlackAction;
|
||||||
};
|
};
|
||||||
mattermost: {
|
|
||||||
probeMattermost: ProbeMattermost;
|
|
||||||
sendMessageMattermost: SendMessageMattermost;
|
|
||||||
monitorMattermostProvider: MonitorMattermostProvider;
|
|
||||||
};
|
|
||||||
telegram: {
|
telegram: {
|
||||||
auditGroupMembership: AuditTelegramGroupMembership;
|
auditGroupMembership: AuditTelegramGroupMembership;
|
||||||
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
|
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
|
|||||||
if (snapshot?.channelOrder?.length) {
|
if (snapshot?.channelOrder?.length) {
|
||||||
return snapshot.channelOrder;
|
return snapshot.channelOrder;
|
||||||
}
|
}
|
||||||
return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
|
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChannel(
|
function renderChannel(
|
||||||
|
|||||||
Reference in New Issue
Block a user