mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:11:36 +00:00
refactor: extract iMessage echo cache and unify suppression guards
This commit is contained in:
@@ -286,6 +286,14 @@ export function extractToolErrorMessage(result: unknown): string | undefined {
|
|||||||
return normalizeToolErrorText(text);
|
return normalizeToolErrorText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMessageToolTarget(args: Record<string, unknown>): string | undefined {
|
||||||
|
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||||
|
if (toRaw) {
|
||||||
|
return toRaw;
|
||||||
|
}
|
||||||
|
return typeof args.target === "string" ? args.target : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function extractMessagingToolSend(
|
export function extractMessagingToolSend(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
@@ -298,12 +306,7 @@ export function extractMessagingToolSend(
|
|||||||
if (action !== "send" && action !== "thread-reply") {
|
if (action !== "send" && action !== "thread-reply") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const toRaw =
|
const toRaw = resolveMessageToolTarget(args);
|
||||||
typeof args.to === "string"
|
|
||||||
? args.to
|
|
||||||
: typeof args.target === "string"
|
|
||||||
? args.target
|
|
||||||
: undefined;
|
|
||||||
if (!toRaw) {
|
if (!toRaw) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
|||||||
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
|
||||||
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||||
|
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
|
||||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||||
|
|
||||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||||
@@ -366,7 +367,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
// Suppress reasoning payloads — channels using this generic dispatch
|
// Suppress reasoning payloads — channels using this generic dispatch
|
||||||
// path (WhatsApp, web, etc.) do not have a dedicated reasoning lane.
|
// path (WhatsApp, web, etc.) do not have a dedicated reasoning lane.
|
||||||
// Telegram has its own dispatch path that handles reasoning splitting.
|
// Telegram has its own dispatch path that handles reasoning splitting.
|
||||||
if (payload.isReasoning) {
|
if (shouldSuppressReasoningPayload(payload)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Accumulate block text for TTS generation after streaming
|
// Accumulate block text for TTS generation after streaming
|
||||||
@@ -404,7 +405,7 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
for (const reply of replies) {
|
for (const reply of replies) {
|
||||||
// Suppress reasoning payloads from channel delivery — channels using this
|
// Suppress reasoning payloads from channel delivery — channels using this
|
||||||
// generic dispatch path do not have a dedicated reasoning lane.
|
// generic dispatch path do not have a dedicated reasoning lane.
|
||||||
if (reply.isReasoning) {
|
if (shouldSuppressReasoningPayload(reply)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ttsReply = await maybeApplyTtsToPayload({
|
const ttsReply = await maybeApplyTtsToPayload({
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean {
|
||||||
|
return payload.isReasoning === true;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyReplyThreading(params: {
|
export function applyReplyThreading(params: {
|
||||||
payloads: ReplyPayload[];
|
payloads: ReplyPayload[];
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/m
|
|||||||
import type { OriginatingChannelType } from "../templating.js";
|
import type { OriginatingChannelType } from "../templating.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||||
|
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
|
||||||
|
|
||||||
export type RouteReplyParams = {
|
export type RouteReplyParams = {
|
||||||
/** The reply payload to send. */
|
/** The reply payload to send. */
|
||||||
@@ -56,7 +57,7 @@ export type RouteReplyResult = {
|
|||||||
*/
|
*/
|
||||||
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
||||||
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
|
const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params;
|
||||||
if (payload.isReasoning) {
|
if (shouldSuppressReasoningPayload(payload)) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
const normalizedChannel = normalizeMessageChannel(channel);
|
const normalizedChannel = normalizeMessageChannel(channel);
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { convertMarkdownTables } from "../../markdown/tables.js";
|
|||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import type { createIMessageRpcClient } from "../client.js";
|
import type { createIMessageRpcClient } from "../client.js";
|
||||||
import { sendMessageIMessage } from "../send.js";
|
import { sendMessageIMessage } from "../send.js";
|
||||||
|
import type { SentMessageCache } from "./echo-cache.js";
|
||||||
type SentMessageCache = {
|
|
||||||
remember: (scope: string, lookup: { text?: string; messageId?: string }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function deliverReplies(params: {
|
export async function deliverReplies(params: {
|
||||||
replies: ReplyPayload[];
|
replies: ReplyPayload[];
|
||||||
@@ -19,7 +16,7 @@ export async function deliverReplies(params: {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
sentMessageCache?: SentMessageCache;
|
sentMessageCache?: Pick<SentMessageCache, "remember">;
|
||||||
}) {
|
}) {
|
||||||
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
const { replies, target, client, runtime, maxBytes, textLimit, accountId, sentMessageCache } =
|
||||||
params;
|
params;
|
||||||
|
|||||||
85
src/imessage/monitor/echo-cache.ts
Normal file
85
src/imessage/monitor/echo-cache.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export type SentMessageLookup = {
|
||||||
|
text?: string;
|
||||||
|
messageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SentMessageCache = {
|
||||||
|
remember: (scope: string, lookup: SentMessageLookup) => void;
|
||||||
|
has: (scope: string, lookup: SentMessageLookup) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SENT_MESSAGE_TEXT_TTL_MS = 5000;
|
||||||
|
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function normalizeEchoTextKey(text: string | undefined): string | null {
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
|
||||||
|
if (!messageId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = messageId.trim();
|
||||||
|
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultSentMessageCache implements SentMessageCache {
|
||||||
|
private textCache = new Map<string, number>();
|
||||||
|
private messageIdCache = new Map<string, number>();
|
||||||
|
|
||||||
|
remember(scope: string, lookup: SentMessageLookup): void {
|
||||||
|
const textKey = normalizeEchoTextKey(lookup.text);
|
||||||
|
if (textKey) {
|
||||||
|
this.textCache.set(`${scope}:${textKey}`, Date.now());
|
||||||
|
}
|
||||||
|
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||||
|
if (messageIdKey) {
|
||||||
|
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
|
||||||
|
}
|
||||||
|
this.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
has(scope: string, lookup: SentMessageLookup): boolean {
|
||||||
|
this.cleanup();
|
||||||
|
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
||||||
|
if (messageIdKey) {
|
||||||
|
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
|
||||||
|
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const textKey = normalizeEchoTextKey(lookup.text);
|
||||||
|
if (textKey) {
|
||||||
|
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
|
||||||
|
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of this.textCache.entries()) {
|
||||||
|
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
||||||
|
this.textCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, timestamp] of this.messageIdCache.entries()) {
|
||||||
|
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
|
||||||
|
this.messageIdCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSentMessageCache(): SentMessageCache {
|
||||||
|
return new DefaultSentMessageCache();
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { __testing } from "./monitor-provider.js";
|
import { createSentMessageCache } from "./echo-cache.js";
|
||||||
|
|
||||||
describe("iMessage sent-message echo cache", () => {
|
describe("iMessage sent-message echo cache", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -9,7 +9,7 @@ describe("iMessage sent-message echo cache", () => {
|
|||||||
it("matches recent text within the same scope", () => {
|
it("matches recent text within the same scope", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||||
const cache = __testing.createSentMessageCache();
|
const cache = createSentMessageCache();
|
||||||
|
|
||||||
cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " });
|
cache.remember("acct:imessage:+1555", { text: " Reasoning:\r\n_step_ " });
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ describe("iMessage sent-message echo cache", () => {
|
|||||||
it("matches by outbound message id and ignores placeholder ids", () => {
|
it("matches by outbound message id and ignores placeholder ids", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||||
const cache = __testing.createSentMessageCache();
|
const cache = createSentMessageCache();
|
||||||
|
|
||||||
cache.remember("acct:imessage:+1555", { messageId: "abc-123" });
|
cache.remember("acct:imessage:+1555", { messageId: "abc-123" });
|
||||||
cache.remember("acct:imessage:+1555", { messageId: "ok" });
|
cache.remember("acct:imessage:+1555", { messageId: "ok" });
|
||||||
@@ -32,7 +32,7 @@ describe("iMessage sent-message echo cache", () => {
|
|||||||
it("keeps message-id lookups longer than text fallback", () => {
|
it("keeps message-id lookups longer than text fallback", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));
|
||||||
const cache = __testing.createSentMessageCache();
|
const cache = createSentMessageCache();
|
||||||
|
|
||||||
cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" });
|
cache.remember("acct:imessage:+1555", { text: "hello", messageId: "m-1" });
|
||||||
vi.advanceTimersByTime(6000);
|
vi.advanceTimersByTime(6000);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { probeIMessage } from "../probe.js";
|
|||||||
import { sendMessageIMessage } from "../send.js";
|
import { sendMessageIMessage } from "../send.js";
|
||||||
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
import { attachIMessageMonitorAbortHandler } from "./abort-handler.js";
|
||||||
import { deliverReplies } from "./deliver.js";
|
import { deliverReplies } from "./deliver.js";
|
||||||
|
import { createSentMessageCache } from "./echo-cache.js";
|
||||||
import {
|
import {
|
||||||
buildIMessageInboundContext,
|
buildIMessageInboundContext,
|
||||||
resolveIMessageInboundDecision,
|
resolveIMessageInboundDecision,
|
||||||
@@ -80,88 +81,6 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Cache for recently sent messages, used for echo detection.
|
|
||||||
* Keys are scoped by conversation (accountId:target) so the same text in different chats is not conflated.
|
|
||||||
* Message IDs use a longer TTL than text fallback to improve resilience when inbound polling is delayed.
|
|
||||||
*/
|
|
||||||
const SENT_MESSAGE_TEXT_TTL_MS = 5000;
|
|
||||||
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
|
||||||
|
|
||||||
function normalizeEchoTextKey(text: string | undefined): string | null {
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
|
||||||
return normalized ? normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
|
|
||||||
if (!messageId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalized = messageId.trim();
|
|
||||||
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SentMessageLookup = {
|
|
||||||
text?: string;
|
|
||||||
messageId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class SentMessageCache {
|
|
||||||
private textCache = new Map<string, number>();
|
|
||||||
private messageIdCache = new Map<string, number>();
|
|
||||||
|
|
||||||
remember(scope: string, lookup: SentMessageLookup): void {
|
|
||||||
const textKey = normalizeEchoTextKey(lookup.text);
|
|
||||||
if (textKey) {
|
|
||||||
this.textCache.set(`${scope}:${textKey}`, Date.now());
|
|
||||||
}
|
|
||||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
|
||||||
if (messageIdKey) {
|
|
||||||
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
|
|
||||||
}
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
has(scope: string, lookup: SentMessageLookup): boolean {
|
|
||||||
this.cleanup();
|
|
||||||
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
|
||||||
if (messageIdKey) {
|
|
||||||
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
|
|
||||||
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const textKey = normalizeEchoTextKey(lookup.text);
|
|
||||||
if (textKey) {
|
|
||||||
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
|
|
||||||
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanup(): void {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, timestamp] of this.textCache.entries()) {
|
|
||||||
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
|
||||||
this.textCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [key, timestamp] of this.messageIdCache.entries()) {
|
|
||||||
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
|
|
||||||
this.messageIdCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
|
||||||
const runtime = resolveRuntime(opts);
|
const runtime = resolveRuntime(opts);
|
||||||
const cfg = opts.config ?? loadConfig();
|
const cfg = opts.config ?? loadConfig();
|
||||||
@@ -177,7 +96,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
);
|
);
|
||||||
const groupHistories = new Map<string, HistoryEntry[]>();
|
const groupHistories = new Map<string, HistoryEntry[]>();
|
||||||
const sentMessageCache = new SentMessageCache();
|
const sentMessageCache = createSentMessageCache();
|
||||||
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
const textLimit = resolveTextChunkLimit(cfg, "imessage", accountInfo.accountId);
|
||||||
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
const allowFrom = normalizeAllowList(opts.allowFrom ?? imessageCfg.allowFrom);
|
||||||
const groupAllowFrom = normalizeAllowList(
|
const groupAllowFrom = normalizeAllowList(
|
||||||
@@ -564,5 +483,4 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
|
|||||||
export const __testing = {
|
export const __testing = {
|
||||||
resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
resolveIMessageRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
createSentMessageCache: () => new SentMessageCache(),
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js";
|
||||||
import { isRenderablePayload } from "../../auto-reply/reply/reply-payloads.js";
|
import {
|
||||||
|
isRenderablePayload,
|
||||||
|
shouldSuppressReasoningPayload,
|
||||||
|
} from "../../auto-reply/reply/reply-payloads.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
|
|
||||||
export type NormalizedOutboundPayload = {
|
export type NormalizedOutboundPayload = {
|
||||||
@@ -41,7 +44,7 @@ export function normalizeReplyPayloadsForDelivery(
|
|||||||
payloads: readonly ReplyPayload[],
|
payloads: readonly ReplyPayload[],
|
||||||
): ReplyPayload[] {
|
): ReplyPayload[] {
|
||||||
return payloads.flatMap((payload) => {
|
return payloads.flatMap((payload) => {
|
||||||
if (payload.isReasoning) {
|
if (shouldSuppressReasoningPayload(payload)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const parsed = parseReplyDirectives(payload.text ?? "");
|
const parsed = parseReplyDirectives(payload.text ?? "");
|
||||||
|
|||||||
Reference in New Issue
Block a user