mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
improve(qa): align Telegram coverage with startup behavior (#113527)
* test(telegram): align coverage with startup behavior * fix(qa): register Slack MPIM coverage
This commit is contained in:
@@ -195,6 +195,13 @@ describe("crabline transport", () => {
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
});
|
||||
await transport.sendInbound({
|
||||
conversation: { id: "alice", kind: "direct" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "/status",
|
||||
nativeCommand: { name: "status" },
|
||||
});
|
||||
|
||||
const config = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" });
|
||||
const telegram = config.channels?.telegram as
|
||||
@@ -211,6 +218,12 @@ describe("crabline transport", () => {
|
||||
text: "/stop",
|
||||
},
|
||||
},
|
||||
{
|
||||
message: {
|
||||
entities: [{ length: 7, offset: 0, type: "bot_command" }],
|
||||
text: "/status",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("Telegram QA transport adapter", () => {
|
||||
getMeCalls += 1;
|
||||
return getMeCalls === 1
|
||||
? { id: 1, is_bot: true, first_name: "driver", username: "driver_bot" }
|
||||
: { id: 2, is_bot: true, first_name: "sut", username: "sut_bot" };
|
||||
: { id: 2, is_bot: true, first_name: "sut", username: "openclaw_qa_bot" };
|
||||
}
|
||||
if (method === "sendMessage") {
|
||||
sendMessageCalls += 1;
|
||||
@@ -111,7 +111,21 @@ describe("Telegram QA transport adapter", () => {
|
||||
"sendMessage",
|
||||
expect.objectContaining({
|
||||
chat_id: "-100123",
|
||||
text: "@sut_bot reply exactly: QA-MARKER",
|
||||
text: "@openclaw_qa_bot reply exactly: QA-MARKER",
|
||||
}),
|
||||
);
|
||||
await adapter.sendInbound?.({
|
||||
conversation: { id: "logical-room", kind: "group" },
|
||||
senderId: "driver",
|
||||
text: "/status",
|
||||
nativeCommand: { name: "status" },
|
||||
});
|
||||
expect(mocks.callTelegramApi).toHaveBeenCalledWith(
|
||||
"placeholder",
|
||||
"sendMessage",
|
||||
expect.objectContaining({
|
||||
chat_id: "-100123",
|
||||
text: "/status@openclaw_qa_bot",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -122,7 +136,7 @@ describe("Telegram QA transport adapter", () => {
|
||||
message_id: 11,
|
||||
date: 100,
|
||||
chat: { id: -100123 },
|
||||
from: { id: 2, is_bot: true, username: "sut_bot" },
|
||||
from: { id: 2, is_bot: true, username: "openclaw_qa_bot" },
|
||||
text: "preview",
|
||||
reply_to_message: { message_id: 10 },
|
||||
},
|
||||
@@ -161,7 +175,7 @@ describe("Telegram QA transport adapter", () => {
|
||||
message_id: 11,
|
||||
date: 101,
|
||||
chat: { id: -100123 },
|
||||
from: { id: 2, is_bot: true, username: "sut_bot" },
|
||||
from: { id: 2, is_bot: true, username: "openclaw_qa_bot" },
|
||||
text: "final",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,21 @@ type FactoryContext = Parameters<AdapterFactory["create"]>[0];
|
||||
type AdapterDefinition = Awaited<ReturnType<AdapterFactory["create"]>> & {
|
||||
cleanupAfterGatewayStop?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function renderTelegramQaInboundText(
|
||||
input: { text: string; nativeCommand?: { name: string } },
|
||||
botUsername: string,
|
||||
) {
|
||||
const commandName = input.nativeCommand?.name.trim().toLowerCase();
|
||||
const renderedText = input.text.replaceAll("@openclaw", `@${botUsername}`);
|
||||
const commandToken = renderedText.match(/^\S+/u)?.[0];
|
||||
// Scenarios declare command semantics once; the live adapter owns Telegram's
|
||||
// bot-username targeting while local drivers may encode the same metadata differently.
|
||||
return commandName && commandToken?.toLowerCase() === `/${commandName}`
|
||||
? `/${commandName}@${botUsername}${renderedText.slice(commandToken.length)}`
|
||||
: renderedText;
|
||||
}
|
||||
|
||||
export async function createTelegramQaTransportAdapter(
|
||||
context: FactoryContext,
|
||||
): Promise<AdapterDefinition> {
|
||||
@@ -58,6 +73,7 @@ export async function createTelegramQaTransportAdapter(
|
||||
const runtimeEnv = credentialLease.payload;
|
||||
let driverIdentity: TelegramBotIdentity;
|
||||
let sutIdentity: TelegramBotIdentity;
|
||||
let sutUsername: string;
|
||||
let offset: number;
|
||||
try {
|
||||
[driverIdentity, sutIdentity] = await Promise.all([
|
||||
@@ -73,6 +89,7 @@ export async function createTelegramQaTransportAdapter(
|
||||
if (!sutIdentity.username?.trim()) {
|
||||
throw new Error("Telegram QA requires the SUT bot to have a Telegram username.");
|
||||
}
|
||||
sutUsername = sutIdentity.username.trim();
|
||||
[offset] = await Promise.all([
|
||||
flushTelegramUpdates(runtimeEnv.driverToken),
|
||||
flushTelegramUpdates(runtimeEnv.sutToken),
|
||||
@@ -167,9 +184,7 @@ export async function createTelegramQaTransportAdapter(
|
||||
heartbeat.throwIfFailed();
|
||||
logicalConversationId = input.conversation.id;
|
||||
logicalConversationKind = input.conversation.kind;
|
||||
const text = sutIdentity.username
|
||||
? input.text.replaceAll("@openclaw", `@${sutIdentity.username}`)
|
||||
: input.text;
|
||||
const text = renderTelegramQaInboundText(input, sutUsername);
|
||||
const nativeReplyToId = input.replyToId ? nativeMessageIds.get(input.replyToId) : undefined;
|
||||
const sent = await callTelegramApi<{ message_id: number }>(
|
||||
runtimeEnv.driverToken,
|
||||
|
||||
@@ -285,6 +285,51 @@ describe("resolveTelegramToken", () => {
|
||||
expect(res.source).toBe("tokenFile");
|
||||
});
|
||||
|
||||
it("applies account file, account config, channel file, channel config, then env precedence", () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "env-token");
|
||||
const accountTokenFile = createTokenFile("account-token.txt", "account-file-token\n");
|
||||
const channelTokenFile = createTokenFile("channel-token.txt", "channel-file-token\n");
|
||||
const baseTelegramConfig = {
|
||||
botToken: "channel-config-token",
|
||||
tokenFile: channelTokenFile,
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "account-config-token",
|
||||
tokenFile: accountTokenFile,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolve = (telegram: Record<string, unknown>) =>
|
||||
resolveTelegramToken({ channels: { telegram } } as OpenClawConfig);
|
||||
|
||||
expect(resolve(baseTelegramConfig)).toEqual({
|
||||
token: "account-file-token",
|
||||
source: "tokenFile",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
...baseTelegramConfig,
|
||||
accounts: { default: { botToken: "account-config-token" } },
|
||||
}),
|
||||
).toEqual({ token: "account-config-token", source: "config" });
|
||||
expect(resolve({ ...baseTelegramConfig, accounts: { default: {} } })).toEqual({
|
||||
token: "channel-file-token",
|
||||
source: "tokenFile",
|
||||
});
|
||||
expect(
|
||||
resolve({
|
||||
...baseTelegramConfig,
|
||||
tokenFile: undefined,
|
||||
accounts: { default: {} },
|
||||
}),
|
||||
).toEqual({ token: "channel-config-token", source: "config" });
|
||||
expect(resolve({ accounts: { default: {} } })).toEqual({
|
||||
token: "env-token",
|
||||
source: "env",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to top-level tokenFile for non-default accounts", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -6,7 +6,6 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
objective: Verify Telegram renders the full native command catalog.
|
||||
successCriteria:
|
||||
@@ -34,8 +33,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: group }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/commands@openclaw' : '/commands'"
|
||||
text: /commands
|
||||
nativeCommand: { name: commands }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: group }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
objective: Verify Telegram context help reaches native command routing.
|
||||
successCriteria:
|
||||
- The context command returns the context list shortcut.
|
||||
@@ -33,8 +33,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/context@openclaw' : '/context'"
|
||||
text: /context
|
||||
nativeCommand: { name: context }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
objective: Verify the Telegram help command returns the concise command guide.
|
||||
successCriteria:
|
||||
- The help command is accepted in the Telegram group command path.
|
||||
@@ -37,7 +37,10 @@ flow:
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? `/${config.command}@openclaw` : `/${config.command}`"
|
||||
expr: "`/${config.command}`"
|
||||
nativeCommand:
|
||||
name:
|
||||
expr: config.command
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.other-bot-command-gating
|
||||
objective: Verify a command addressed to another Telegram bot does not wake OpenClaw.
|
||||
successCriteria:
|
||||
- The driver sends a native status command addressed to another bot.
|
||||
@@ -32,6 +32,7 @@ flow:
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text: /status@OpenClawQaOtherBot
|
||||
nativeCommand: { name: status }
|
||||
- waitForNoOutbound:
|
||||
quietMs: 8000
|
||||
sinceIndex: { ref: outboundStartIndex }
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.command-authorization-in-groups
|
||||
objective: Verify an allowlisted Telegram bot operator remains authorized across repeated native commands.
|
||||
successCriteria:
|
||||
- The same operator is blocked while removed from the group allowlist.
|
||||
@@ -25,12 +25,9 @@ flow:
|
||||
steps:
|
||||
- name: repeated commands reuse restored authorization
|
||||
actions:
|
||||
- assert:
|
||||
expr: "transport.id === 'telegram'"
|
||||
message: repeated authorization requires the live Telegram adapter
|
||||
- set: authorizationState
|
||||
value:
|
||||
expr: "({ allowFrom: [] })"
|
||||
expr: "({ allowFrom: [], hadGroupAllowFrom: false })"
|
||||
- try:
|
||||
actions:
|
||||
- call: env.gateway.restartAfterStateMutation
|
||||
@@ -38,7 +35,7 @@ flow:
|
||||
- lambda:
|
||||
async: true
|
||||
params: [ctx]
|
||||
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts[transport.accountId]; const group = account.groups[Object.keys(account.groups)[0]]; authorizationState.allowFrom = [...(group.allowFrom ?? [])]; group.allowFrom = []; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
|
||||
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts?.[transport.accountId] ?? cfg.channels.telegram; const group = account.groups[Object.keys(account.groups)[0]]; authorizationState.hadGroupAllowFrom = Array.isArray(group.allowFrom); authorizationState.allowFrom = [...(group.allowFrom ?? [])]; group.allowFrom = []; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
|
||||
- call: waitForTransportReady
|
||||
args: [{ ref: env }, 60000]
|
||||
- resetTransport: true
|
||||
@@ -46,7 +43,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text: /status@openclaw
|
||||
text: /status
|
||||
nativeCommand: { name: status }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: You are not authorized to use this command.
|
||||
@@ -57,7 +55,7 @@ flow:
|
||||
- lambda:
|
||||
async: true
|
||||
params: [ctx]
|
||||
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts[transport.accountId]; const group = account.groups[Object.keys(account.groups)[0]]; group.allowFrom = authorizationState.allowFrom; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
|
||||
expr: "fs.readFile(ctx.configPath, 'utf8').then((raw) => { const cfg = JSON.parse(raw); const account = cfg.channels.telegram.accounts?.[transport.accountId] ?? cfg.channels.telegram; const group = account.groups[Object.keys(account.groups)[0]]; if (authorizationState.hadGroupAllowFrom) group.allowFrom = authorizationState.allowFrom; else delete group.allowFrom; return fs.writeFile(ctx.configPath, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8'); })"
|
||||
- call: waitForTransportReady
|
||||
args: [{ ref: env }, 60000]
|
||||
- resetTransport: true
|
||||
@@ -65,7 +63,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text: /status@openclaw
|
||||
text: /status
|
||||
nativeCommand: { name: status }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: "Session:"
|
||||
@@ -74,7 +73,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text: /help@openclaw
|
||||
text: /help
|
||||
nativeCommand: { name: help }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: /commands for full list
|
||||
@@ -83,7 +83,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text: /commands@openclaw
|
||||
text: /commands
|
||||
nativeCommand: { name: commands }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: /stop
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
title: Telegram product-startup getMe live
|
||||
|
||||
scenario:
|
||||
id: telegram-startup-getme-live
|
||||
surface: channels
|
||||
category: channels.channel-setup
|
||||
coverage:
|
||||
primary:
|
||||
- telegram.startup-getme
|
||||
objective: Verify a leased Telegram bot reaches polling through the real Gateway product-startup getMe path.
|
||||
successCriteria:
|
||||
- The live producer launches an isolated real OpenClaw Gateway with the leased bot configured on a named Telegram account.
|
||||
- Product startAccount logs the getMe-derived bot username before the polling monitor starts.
|
||||
- Missing dedicated leased credentials produce blocked evidence and never pass startup coverage.
|
||||
docsRefs:
|
||||
- docs/channels/telegram.md
|
||||
- docs/gateway/config-channels.md
|
||||
codeRefs:
|
||||
- test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts
|
||||
- extensions/telegram/src/channel.ts
|
||||
- extensions/telegram/src/probe.ts
|
||||
- extensions/telegram/src/monitor.ts
|
||||
execution:
|
||||
kind: script
|
||||
channel: telegram
|
||||
path: test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts
|
||||
summary: Launch a real isolated Gateway and require product-startup getMe before Telegram polling begins.
|
||||
timeoutMs: 240000
|
||||
args:
|
||||
- --output-dir
|
||||
- ${outputDir}
|
||||
@@ -0,0 +1,26 @@
|
||||
title: Telegram product-startup getMe outcomes
|
||||
|
||||
scenario:
|
||||
id: telegram-startup-getme-outcomes
|
||||
surface: channels
|
||||
category: channels.channel-setup
|
||||
coverage:
|
||||
primary:
|
||||
- telegram.startup-getme
|
||||
objective: Verify the Telegram gateway owner handles startup getMe success, authentication failure, cache fallback, and probe throttling before monitor startup.
|
||||
successCriteria:
|
||||
- A successful product-startup probe passes botInfo into the polling monitor and refreshes the cache.
|
||||
- A 401 rejects startup before the monitor, while a non-authentication failure may reuse matching cached botInfo.
|
||||
- Concurrent account probes respect the shared startup limit and an aborted queued account never probes.
|
||||
docsRefs:
|
||||
- docs/channels/telegram.md
|
||||
- docs/gateway/config-channels.md
|
||||
codeRefs:
|
||||
- extensions/telegram/src/channel.ts
|
||||
- extensions/telegram/src/channel.gateway.test.ts
|
||||
- extensions/telegram/src/bot-info-cache.ts
|
||||
- extensions/telegram/src/startup-probe-limiter.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: extensions/telegram/src/channel.gateway.test.ts
|
||||
summary: Run the Telegram gateway startAccount outcome, cache, and cross-account probe-limiter assertions.
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
regressionRefs:
|
||||
- openclaw/openclaw#74698
|
||||
objective: Verify Telegram status returns model, session, and activation details.
|
||||
@@ -35,8 +35,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/status@openclaw' : '/status'"
|
||||
text: /status
|
||||
nativeCommand: { name: status }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
title: Telegram token-source precedence
|
||||
|
||||
scenario:
|
||||
id: telegram-token-source-precedence
|
||||
surface: channels
|
||||
category: channels.channel-setup
|
||||
coverage:
|
||||
primary:
|
||||
- telegram.telegram-bot-token
|
||||
objective: Verify Telegram resolves account and channel token sources in the documented product precedence order.
|
||||
successCriteria:
|
||||
- Account tokenFile wins over account botToken and all channel-level sources.
|
||||
- Account botToken wins over channel tokenFile, channel botToken, and TELEGRAM_BOT_TOKEN.
|
||||
- Channel tokenFile wins over channel botToken and TELEGRAM_BOT_TOKEN, while channel botToken wins over TELEGRAM_BOT_TOKEN.
|
||||
docsRefs:
|
||||
- docs/channels/telegram.md
|
||||
- docs/gateway/config-channels.md
|
||||
codeRefs:
|
||||
- extensions/telegram/src/token.ts
|
||||
- extensions/telegram/src/token.test.ts
|
||||
execution:
|
||||
kind: vitest
|
||||
path: extensions/telegram/src/token.test.ts
|
||||
summary: Run the Telegram token resolver assertions, including the complete account-to-environment precedence chain.
|
||||
@@ -8,7 +8,7 @@ scenario:
|
||||
primary:
|
||||
- observability.model-usage
|
||||
secondary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
regressionRefs:
|
||||
- openclaw/openclaw#87392
|
||||
objective: Verify Telegram usage mode decorates a message-tool-only visible reply.
|
||||
@@ -34,8 +34,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/usage@openclaw tokens' : '/usage tokens'"
|
||||
text: /usage tokens
|
||||
nativeCommand: { name: usage }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: tokens
|
||||
@@ -70,8 +70,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/usage@openclaw off' : '/usage off'"
|
||||
text: /usage off
|
||||
nativeCommand: { name: usage }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
textIncludes: "Usage footer: off"
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
objective: Verify Telegram renders the compact model-tool inventory.
|
||||
successCriteria:
|
||||
- The compact tools command reaches native command dispatch.
|
||||
@@ -33,8 +33,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/tools@openclaw compact' : '/tools compact'"
|
||||
text: /tools compact
|
||||
nativeCommand: { name: tools }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
@@ -6,7 +6,7 @@ scenario:
|
||||
category: channels.channel-actions-commands-and-approvals
|
||||
coverage:
|
||||
primary:
|
||||
- channels.channel-native-commands
|
||||
- telegram.built-in-commands
|
||||
objective: Verify Telegram command identity includes the active channel context.
|
||||
successCriteria:
|
||||
- The whoami command returns an identity block.
|
||||
@@ -31,8 +31,8 @@ flow:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
senderId: qa-command-operator
|
||||
senderName: QA Command Operator
|
||||
text:
|
||||
expr: "transport.id === 'telegram' ? '/whoami@openclaw' : '/whoami'"
|
||||
text: /whoami
|
||||
nativeCommand: { name: whoami }
|
||||
- waitForOutbound:
|
||||
conversation: { id: telegram-command-room, kind: channel }
|
||||
sinceIndex: { ref: startIndex }
|
||||
|
||||
+16
-4
@@ -7955,15 +7955,15 @@ surfaces:
|
||||
- name: BotFather token creation
|
||||
coverageIds: [telegram.botfather-token-creation]
|
||||
description: BotFather token creation and first gateway start
|
||||
- name: TELEGRAM_BOT_TOKEN
|
||||
- name: Token-source precedence
|
||||
coverageIds: [telegram.telegram-bot-token]
|
||||
description: TELEGRAM_BOT_TOKEN, botToken, tokenFile, and account-scoped token
|
||||
description: Account tokenFile, account botToken, channel tokenFile, channel botToken, then TELEGRAM_BOT_TOKEN precedence.
|
||||
- name: Setup wizard credential capture
|
||||
coverageIds: [telegram.setup-wizard-credential-capture]
|
||||
description: Setup wizard credential capture, allowlist prompts, and DM policy defaults
|
||||
- name: Startup getMe
|
||||
coverageIds: [telegram.startup-getme]
|
||||
description: Startup getMe, bot-info cache, account throttling, and multi-account default
|
||||
description: Product-startup getMe success and authentication failure, bot-info cache fallback, and account probe throttling.
|
||||
- name: Doctor/status surfacing
|
||||
coverageIds: [telegram.doctor-status-surfacing]
|
||||
description: Doctor/status surfacing for invalid tokens, missing defaults, and read-only
|
||||
@@ -8094,7 +8094,13 @@ surfaces:
|
||||
description: Built-in commands such as /help, /commands, /whoami, /status, and related command UI.
|
||||
- name: Command authorization in DMs
|
||||
coverageIds: [telegram.command-authorization-in-dms]
|
||||
description: Command authorization in DMs, groups, and commands addressed to other bots
|
||||
description: Command authorization for Telegram direct messages.
|
||||
- name: Command authorization in groups
|
||||
coverageIds: [telegram.command-authorization-in-groups]
|
||||
description: Group allowlist authorization for Telegram commands.
|
||||
- name: Other-bot command gating
|
||||
coverageIds: [telegram.other-bot-command-gating]
|
||||
description: Commands addressed to another Telegram bot remain ignored.
|
||||
- name: Model buttons
|
||||
coverageIds: [telegram.model-buttons]
|
||||
description: Model buttons and command UI helpers
|
||||
@@ -8362,6 +8368,12 @@ surfaces:
|
||||
- name: Sender Authorization
|
||||
coverageIds: [slack.sender-authorization]
|
||||
description: Covers Sender Authorization across Slack DM routing, `dmPolicy`, `allowFrom`, pairing approvals, group DMs/MPIMs, account-level allowlist inheritance, command authorization in DMs, and sender identity normalization.
|
||||
- name: MPIM routing
|
||||
coverageIds: [slack.mpim]
|
||||
description: Covers group direct-message routing and app-mention dispatch in Slack MPIM conversations.
|
||||
- name: Delivery dedupe
|
||||
coverageIds: [slack.delivery-dedupe]
|
||||
description: Covers single-delivery enforcement when one Slack event arrives through overlapping message and app-mention paths.
|
||||
docs:
|
||||
- docs/channels/slack.md
|
||||
- docs/channels/bot-loop-protection.md
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runTelegramBotTokenRuntime, testing } from "./telegram-bot-token-runtime.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -30,14 +30,107 @@ describe("telegram bot token runtime evidence", () => {
|
||||
);
|
||||
|
||||
expect(evidence.entries[0]?.result.status).toBe("blocked");
|
||||
const log = await fs.readFile(path.join(artifactBase, "telegram-bot-token.log"), "utf8");
|
||||
const log = await fs.readFile(
|
||||
path.join(artifactBase, "telegram-startup-getme-live.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(log).toContain("blocked");
|
||||
expect(log).not.toContain("generic-token");
|
||||
});
|
||||
|
||||
it("bounds monitor shutdown", async () => {
|
||||
await expect(testing.waitForMonitorShutdown(new Promise(() => {}), 10)).rejects.toThrow(
|
||||
"Telegram runtime shutdown timed out",
|
||||
it("launches the real Gateway shape and redacts the leased token from evidence", async () => {
|
||||
const artifactBase = await fs.mkdtemp(path.join(os.tmpdir(), "telegram-startup-getme-"));
|
||||
tempDirs.push(artifactBase);
|
||||
const leasedToken = "123456:leased-secret";
|
||||
const cleanup = vi.fn(async () => undefined);
|
||||
const startGateway = vi.fn(async () => undefined);
|
||||
let instanceOptions: Record<string, unknown> | undefined;
|
||||
|
||||
const evidence = await runTelegramBotTokenRuntime(
|
||||
{ artifactBase, repoRoot: process.cwd(), startupTimeoutMs: 100 },
|
||||
{ OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: leasedToken },
|
||||
{
|
||||
createInstance: async (options) => {
|
||||
instanceOptions = options as unknown as Record<string, unknown>;
|
||||
return {
|
||||
cleanup,
|
||||
startGateway,
|
||||
logs: () =>
|
||||
`[qa-live] starting provider (@qa_test_bot) token=${leasedToken}\n` +
|
||||
"[telegram][diag] polling cycle started\n",
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(evidence.entries[0]?.result.status).toBe("pass");
|
||||
expect(startGateway).toHaveBeenCalledOnce();
|
||||
expect(cleanup).toHaveBeenCalledOnce();
|
||||
expect(instanceOptions).toMatchObject({
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultAccount: "qa-live",
|
||||
accounts: { "qa-live": { botToken: leasedToken } },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_SKIP_CHANNELS: undefined,
|
||||
OPENCLAW_SKIP_PROVIDERS: undefined,
|
||||
},
|
||||
});
|
||||
const log = await fs.readFile(
|
||||
path.join(artifactBase, "telegram-startup-getme-live.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(log).toContain("product startAccount resolved getMe bot identity before polling");
|
||||
expect(log).toContain("[REDAC");
|
||||
expect(log).not.toContain(leasedToken);
|
||||
});
|
||||
|
||||
it("requires product startup to precede polling and bounds the wait", async () => {
|
||||
await expect(
|
||||
testing.waitForProductStartup(
|
||||
{
|
||||
cleanup: async () => undefined,
|
||||
startGateway: async () => undefined,
|
||||
logs: () =>
|
||||
"[telegram][diag] polling cycle started\n" +
|
||||
"[qa-live] starting provider (@qa_test_bot)\n",
|
||||
},
|
||||
10,
|
||||
),
|
||||
).rejects.toThrow("product startup getMe timed out");
|
||||
});
|
||||
|
||||
it("redacts leased tokens from startup failure evidence", async () => {
|
||||
const artifactBase = await fs.mkdtemp(path.join(os.tmpdir(), "telegram-startup-failure-"));
|
||||
tempDirs.push(artifactBase);
|
||||
const leasedToken = "123456:leased-secret";
|
||||
|
||||
const evidence = await runTelegramBotTokenRuntime(
|
||||
{ artifactBase, repoRoot: process.cwd(), startupTimeoutMs: 100 },
|
||||
{ OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: leasedToken },
|
||||
{
|
||||
createInstance: async () => ({
|
||||
cleanup: async () => undefined,
|
||||
logs: () => "",
|
||||
startGateway: async () => {
|
||||
throw new Error(
|
||||
`Telegram startup failed for https://api.telegram.org/bot${leasedToken}/getMe`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(evidence.entries[0]?.result.status).toBe("fail");
|
||||
expect(JSON.stringify(evidence)).not.toContain(leasedToken);
|
||||
const log = await fs.readFile(
|
||||
path.join(artifactBase, "telegram-startup-getme-live.log"),
|
||||
"utf8",
|
||||
);
|
||||
expect(log).not.toContain(leasedToken);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
// Telegram bot-token runtime evidence starts the real monitor through getMe.
|
||||
// Telegram startup evidence launches the real Gateway product path through getMe.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { monitorTelegramProvider } from "../../../../extensions/telegram/runtime-api.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import {
|
||||
createOpenClawTestInstance,
|
||||
type OpenClawTestInstance,
|
||||
} from "../../../helpers/openclaw-test-instance.js";
|
||||
import { createQaScriptEvidenceWriter } from "./script-evidence.js";
|
||||
|
||||
const STARTUP_TIMEOUT_MS = 30_000;
|
||||
const LIVE_ACCOUNT_ID = "qa-live";
|
||||
const PRODUCT_STARTUP_LOG = `[${LIVE_ACCOUNT_ID}] starting provider (@`;
|
||||
const POLLING_STARTUP_LOGS = ["isolated polling ingress started", "polling cycle started"] as const;
|
||||
const TOKEN_ENV_KEYS = [
|
||||
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
|
||||
"TELEGRAM_E2E_SUT_BOT_TOKEN",
|
||||
@@ -20,18 +24,62 @@ type TelegramRuntimeOptions = {
|
||||
startupTimeoutMs: number;
|
||||
};
|
||||
|
||||
async function waitForMonitorShutdown(monitorPromise: Promise<void>, timeoutMs: number) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("Telegram runtime shutdown timed out")), timeoutMs);
|
||||
type TelegramProductStartupInstance = Pick<
|
||||
OpenClawTestInstance,
|
||||
"child" | "cleanup" | "logs" | "startGateway"
|
||||
>;
|
||||
|
||||
type TelegramRuntimeDependencies = {
|
||||
createInstance: (
|
||||
options: Parameters<typeof createOpenClawTestInstance>[0],
|
||||
) => Promise<TelegramProductStartupInstance>;
|
||||
};
|
||||
|
||||
const defaultDependencies: TelegramRuntimeDependencies = {
|
||||
createInstance: createOpenClawTestInstance,
|
||||
};
|
||||
|
||||
const wait = (durationMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, durationMs);
|
||||
});
|
||||
try {
|
||||
await Promise.race([monitorPromise.catch(() => undefined), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
function sanitizeRuntimeLogs(logs: string, token: string) {
|
||||
if (!token) {
|
||||
return logs;
|
||||
}
|
||||
return logs
|
||||
.replaceAll(token, "[REDACTED_TELEGRAM_TOKEN]")
|
||||
.replaceAll(encodeURIComponent(token), "[REDACTED_TELEGRAM_TOKEN]");
|
||||
}
|
||||
|
||||
async function waitForProductStartup(instance: TelegramProductStartupInstance, timeoutMs: number) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const logs = instance.logs();
|
||||
const productStartupIndex = logs.indexOf(PRODUCT_STARTUP_LOG);
|
||||
const pollingStartupIndex = Math.min(
|
||||
...POLLING_STARTUP_LOGS.map((marker) => {
|
||||
const index = logs.indexOf(marker);
|
||||
return index < 0 ? Number.POSITIVE_INFINITY : index;
|
||||
}),
|
||||
);
|
||||
if (
|
||||
productStartupIndex >= 0 &&
|
||||
Number.isFinite(pollingStartupIndex) &&
|
||||
pollingStartupIndex > productStartupIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
instance.child &&
|
||||
(instance.child.exitCode !== null || instance.child.signalCode !== null)
|
||||
) {
|
||||
throw new Error("Telegram Gateway stopped before product startup completed");
|
||||
}
|
||||
await wait(50);
|
||||
}
|
||||
throw new Error("Telegram product startup getMe timed out before polling began");
|
||||
}
|
||||
|
||||
function parseOptions(argv: string[], repoRoot = process.cwd()): TelegramRuntimeOptions {
|
||||
@@ -71,64 +119,38 @@ function resolveLeasedToken(env: NodeJS.ProcessEnv = process.env) {
|
||||
function createWriter(options: TelegramRuntimeOptions) {
|
||||
return createQaScriptEvidenceWriter({
|
||||
artifactBase: options.artifactBase,
|
||||
logFileName: "telegram-bot-token.log",
|
||||
logFileName: "telegram-startup-getme-live.log",
|
||||
primaryModel: "telegram/bot-api",
|
||||
providerMode: "live-frontier",
|
||||
repoRoot: options.repoRoot,
|
||||
target: {
|
||||
id: "telegram-bot-token",
|
||||
title: "Telegram bot token runtime startup",
|
||||
id: "telegram-startup-getme-live",
|
||||
title: "Telegram product-startup getMe live",
|
||||
sourcePath: "test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts",
|
||||
docsRefs: ["docs/channels/telegram.md"],
|
||||
codeRefs: [
|
||||
"test/e2e/qa-lab/runtime/telegram-bot-token-runtime.ts",
|
||||
"extensions/telegram/src/channel.ts",
|
||||
"extensions/telegram/src/probe.ts",
|
||||
"extensions/telegram/src/monitor.ts",
|
||||
"extensions/telegram/src/polling-session.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForStartup(params: {
|
||||
abortController: AbortController;
|
||||
monitorPromise: Promise<void>;
|
||||
startupPromise: Promise<void>;
|
||||
timeoutMs: number;
|
||||
}) {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(
|
||||
() => reject(new Error("Telegram runtime startup timed out")),
|
||||
params.timeoutMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
params.startupPromise,
|
||||
params.monitorPromise.then(() => {
|
||||
throw new Error("Telegram runtime stopped before polling startup");
|
||||
}),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
params.abortController.abort();
|
||||
await waitForMonitorShutdown(params.monitorPromise, params.timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTelegramBotTokenRuntime(
|
||||
options: TelegramRuntimeOptions,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
dependencies: TelegramRuntimeDependencies = defaultDependencies,
|
||||
) {
|
||||
await fs.mkdir(options.artifactBase, { recursive: true });
|
||||
const writer = createWriter(options);
|
||||
const startedAt = Date.now();
|
||||
const credential = resolveLeasedToken(env);
|
||||
if (!credential) {
|
||||
writer.appendLog(`telegram-bot-token: blocked; none of ${TOKEN_ENV_KEYS.join(", ")} is set\n`);
|
||||
writer.appendLog(
|
||||
`telegram-startup-getme: blocked; none of ${TOKEN_ENV_KEYS.join(", ")} is set\n`,
|
||||
);
|
||||
return await writer.write({
|
||||
details: "Telegram runtime proof requires a leased bot token",
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
@@ -136,81 +158,80 @@ export async function runTelegramBotTokenRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
writer.appendLog(`telegram-bot-token: using leased credential from ${credential.key}\n`);
|
||||
const abortController = new AbortController();
|
||||
let markStarted: (() => void) | undefined;
|
||||
const startupPromise = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const runtime: RuntimeEnv = {
|
||||
log: (...args) => {
|
||||
const line = args.map(String).join(" ");
|
||||
writer.appendLog(`${line}\n`);
|
||||
if (
|
||||
line.includes("isolated polling ingress started") ||
|
||||
line.includes("polling cycle started")
|
||||
) {
|
||||
markStarted?.();
|
||||
}
|
||||
},
|
||||
error: (...args) => writer.appendLog(`${args.map(String).join(" ")}\n`),
|
||||
exit: (code) => {
|
||||
throw new Error(`Telegram runtime requested exit ${code}`);
|
||||
},
|
||||
};
|
||||
const config: OpenClawConfig = {
|
||||
channels: { telegram: { enabled: true } },
|
||||
};
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(options.artifactBase, "state");
|
||||
writer.appendLog(`telegram-startup-getme: using leased credential from ${credential.key}\n`);
|
||||
let instance: TelegramProductStartupInstance | undefined;
|
||||
try {
|
||||
const monitorPromise = monitorTelegramProvider({
|
||||
abortSignal: abortController.signal,
|
||||
config,
|
||||
isolatedIngress: { enabled: true },
|
||||
runtime,
|
||||
token: credential.token,
|
||||
instance = await dependencies.createInstance({
|
||||
name: "qa-telegram-startup-getme",
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
defaultAccount: LIVE_ACCOUNT_ID,
|
||||
dmPolicy: "disabled",
|
||||
groupPolicy: "disabled",
|
||||
commands: { native: false, nativeSkills: false },
|
||||
accounts: {
|
||||
[LIVE_ACCOUNT_ID]: {
|
||||
enabled: true,
|
||||
botToken: credential.token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
OPENCLAW_SKIP_CHANNELS: undefined,
|
||||
OPENCLAW_SKIP_PROVIDERS: undefined,
|
||||
TELEGRAM_BOT_TOKEN: "qa-invalid-precedence-decoy",
|
||||
},
|
||||
startTimeoutMs: options.startupTimeoutMs,
|
||||
});
|
||||
await waitForStartup({
|
||||
abortController,
|
||||
monitorPromise,
|
||||
startupPromise,
|
||||
timeoutMs: options.startupTimeoutMs,
|
||||
});
|
||||
writer.appendLog("telegram-bot-token: runtime started after Telegram getMe\n");
|
||||
await instance.startGateway();
|
||||
await waitForProductStartup(instance, options.startupTimeoutMs);
|
||||
writer.appendLog(sanitizeRuntimeLogs(instance.logs(), credential.token));
|
||||
writer.appendLog(
|
||||
"telegram-startup-getme: product startAccount resolved getMe bot identity before polling\n",
|
||||
);
|
||||
await instance.cleanup();
|
||||
instance = undefined;
|
||||
return await writer.write({
|
||||
details: `Telegram runtime startup completed with ${credential.key}`,
|
||||
details: `Telegram product-startup getMe completed with ${credential.key}`,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "pass",
|
||||
});
|
||||
} catch (error) {
|
||||
const details = formatErrorMessage(error);
|
||||
writer.appendLog(`telegram-bot-token: ${details}\n`);
|
||||
const details = sanitizeRuntimeLogs(formatErrorMessage(error), credential.token);
|
||||
if (instance) {
|
||||
writer.appendLog(sanitizeRuntimeLogs(instance.logs(), credential.token));
|
||||
}
|
||||
writer.appendLog(`telegram-startup-getme: ${details}\n`);
|
||||
return await writer.write({
|
||||
details,
|
||||
durationMs: Math.max(1, Date.now() - startedAt),
|
||||
status: "fail",
|
||||
});
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await instance?.cleanup().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const testing = { parseOptions, resolveLeasedToken, waitForMonitorShutdown };
|
||||
export const testing = {
|
||||
parseOptions,
|
||||
resolveLeasedToken,
|
||||
sanitizeRuntimeLogs,
|
||||
waitForProductStartup,
|
||||
};
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
runTelegramBotTokenRuntime(parseOptions(process.argv.slice(2)))
|
||||
.then((evidence) => {
|
||||
const status = evidence.entries[0]?.result.status;
|
||||
process.stdout.write(`telegram-bot-token: ${status}\n`);
|
||||
process.stdout.write(`telegram-startup-getme: ${status}\n`);
|
||||
process.exitCode = status === "fail" ? 1 : 0;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`telegram-bot-token: ${formatErrorMessage(error)}\n`);
|
||||
process.stderr.write(`telegram-startup-getme: ${formatErrorMessage(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user