Files
openhuman/app/test/e2e/helpers/telegram.ts
T

377 lines
13 KiB
TypeScript

/**
* Telegram channel E2E helpers.
*
* Wraps `callOpenhumanRpc` (core RPC) and the Telegram mock admin endpoints so
* specs can drive the full Telegram channel lifecycle without knowing the raw
* RPC method names or admin path strings.
*
* Design principles:
* - All helpers are pure async functions — no hidden state.
* - Admin HTTP helpers call mock server endpoints that are already wired by
* WS-A (see `app/test/e2e/mock-server.ts` and `scripts/mock-api/routes/telegram.mjs`).
* - RPC helpers forward to `callOpenhumanRpc` using the exact field names from
* `src/openhuman/channels/controllers/schemas.rs` (camelCase for the wire
* format; the Rust serde layer translates).
*
* Key RPC shapes (verified from schemas.rs / ops.rs):
* channels_connect -> { channel, authMode, credentials: { bot_token, allowed_users?, mention_only? } }
* channels_disconnect -> { channel, authMode }
* channels_status -> { channel? } -> entries: ChannelStatusEntry[]
*/
import {
getTelegramSentMessages as adminGetSentMessages,
injectTelegramUpdate as adminInjectUpdate,
resetTelegramMock as adminReset,
} from '../mock-server';
import { callOpenhumanRpc } from './core-rpc';
const LOG_PREFIX = '[TelegramChannel]';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TelegramConnectOptions {
/** Bot token issued by BotFather. */
botToken: string;
/**
* Optional allowlist of Telegram usernames (without `@`) or numeric user
* IDs. When provided only those identities can trigger the bot. When
* omitted the bot uses the pairing-code flow.
*/
allowedUsers?: string[];
/**
* When true the bot only responds to messages that mention it by
* `@username` in group chats.
*/
mentionOnly?: boolean;
}
export interface TelegramConnectResult {
ok: boolean;
status?: string;
restartRequired?: boolean;
message?: string;
error?: string;
}
export interface TelegramStatusEntry {
channelId: string;
authMode: string;
connected: boolean;
hasCredentials: boolean;
}
export interface TelegramUpdate {
update_id: number;
message: {
message_id: number;
from: { id: number; is_bot: boolean; first_name: string; username?: string };
chat: {
id: number;
type: 'private' | 'group' | 'supergroup' | 'channel';
title?: string;
username?: string;
first_name?: string;
};
date: number;
text: string;
};
}
export interface SentMessage {
method: string;
chat_id: string | number;
text?: string;
[key: string]: unknown;
}
// ---------------------------------------------------------------------------
// RPC wrappers
// ---------------------------------------------------------------------------
/**
* Connect a Telegram bot via the `channels_connect` RPC.
*
* Maps to `openhuman.channels_connect` with `authMode: "bot_token"`.
* The connect call writes TOML config and sets `restart_required: true` —
* it does NOT start the live polling loop immediately.
*
* Returns the raw RPC result so callers can assert on specific fields.
*/
export async function connectTelegramBot(
opts: TelegramConnectOptions
): Promise<TelegramConnectResult> {
const credentials: Record<string, unknown> = { bot_token: opts.botToken };
if (opts.allowedUsers !== undefined) {
credentials.allowed_users = opts.allowedUsers;
}
if (opts.mentionOnly !== undefined) {
credentials.mention_only = opts.mentionOnly;
}
console.log(
`${LOG_PREFIX} connectTelegramBot: token=***${opts.botToken.slice(-4)} ` +
`allowedUsers=${JSON.stringify(opts.allowedUsers ?? [])} ` +
`mentionOnly=${opts.mentionOnly ?? false}`
);
const out = await callOpenhumanRpc('openhuman.channels_connect', {
channel: 'telegram',
authMode: 'bot_token',
credentials,
});
if (!out.ok) {
console.warn(`${LOG_PREFIX} connectTelegramBot: RPC failed — ${JSON.stringify(out)}`);
return { ok: false, error: String(out.error ?? 'unknown error') };
}
// The result shape from ops.rs is { status, restart_required, message? }.
// It is wrapped by RpcOutcome which the Node RPC client unwraps one level.
const result = (out.result as Record<string, unknown> | null) ?? {};
const inner =
typeof result.result === 'object' && result.result !== null
? (result.result as Record<string, unknown>)
: result;
console.log(`${LOG_PREFIX} connectTelegramBot: ok — ${JSON.stringify(inner)}`);
return {
ok: true,
status: inner.status as string | undefined,
restartRequired: inner.restart_required as boolean | undefined,
message: inner.message as string | undefined,
};
}
/**
* Disconnect the Telegram bot via `channels_disconnect` RPC.
*
* Removes stored credentials and clears TOML config. Returns true on success.
*/
export async function disconnectTelegramBot(): Promise<boolean> {
console.log(`${LOG_PREFIX} disconnectTelegramBot: calling channels_disconnect`);
const out = await callOpenhumanRpc('openhuman.channels_disconnect', {
channel: 'telegram',
authMode: 'bot_token',
});
if (!out.ok) {
console.warn(`${LOG_PREFIX} disconnectTelegramBot: RPC failed — ${JSON.stringify(out)}`);
return false;
}
console.log(`${LOG_PREFIX} disconnectTelegramBot: ok`);
return true;
}
/**
* Fetch the channel status for Telegram.
*
* Calls `channels_status` with `channel: "telegram"` and returns the first
* matching entry (the `bot_token` mode entry). Returns `null` if no entry is
* found or the RPC fails.
*/
export async function getTelegramChannelStatus(): Promise<TelegramStatusEntry | null> {
console.log(`${LOG_PREFIX} getTelegramChannelStatus: calling channels_status`);
const out = await callOpenhumanRpc('openhuman.channels_status', { channel: 'telegram' });
if (!out.ok) {
console.warn(`${LOG_PREFIX} getTelegramChannelStatus: RPC failed — ${JSON.stringify(out)}`);
return null;
}
// channels_status returns entries: ChannelStatusEntry[].
// The core wraps with RpcOutcome so the Node client may unwrap one level.
const result = (out.result as Record<string, unknown> | null) ?? {};
const entries: TelegramStatusEntry[] = Array.isArray(result)
? result
: Array.isArray((result as Record<string, unknown>).entries)
? ((result as Record<string, unknown>).entries as TelegramStatusEntry[])
: Array.isArray((result as Record<string, unknown>).result)
? ((result as Record<string, unknown>).result as TelegramStatusEntry[])
: [];
const raw = entries.find(
(e: TelegramStatusEntry) =>
(e.channelId === 'telegram' || (e as Record<string, unknown>).channel_id === 'telegram') &&
(e.authMode === 'bot_token' || (e as Record<string, unknown>).auth_mode === 'bot_token')
) as (TelegramStatusEntry & Record<string, unknown>) | undefined;
// Normalise snake_case fields that the Rust core serialises.
const match: TelegramStatusEntry | undefined = raw
? {
channelId: (raw.channelId ?? raw.channel_id) as string,
authMode: (raw.authMode ?? raw.auth_mode) as string,
connected: raw.connected,
hasCredentials: (raw.hasCredentials ?? raw.has_credentials ?? false) as boolean,
}
: undefined;
console.log(`${LOG_PREFIX} getTelegramChannelStatus: ${JSON.stringify(match ?? null)}`);
return match ?? null;
}
// ---------------------------------------------------------------------------
// Mock admin helpers (relay to WS-A admin endpoints)
// ---------------------------------------------------------------------------
/**
* Build a realistic Telegram Update JSON for a private or group message.
*
* @param opts.updateId — Telegram update_id (must increase monotonically).
* @param opts.chatId — Chat numeric ID.
* @param opts.userId — Sender numeric user ID.
* @param opts.username — Sender Telegram username (without `@`).
* @param opts.text — Message text.
* @param opts.isGroup — When true, emits a group chat type.
* @param opts.botUsername — When provided AND isGroup, includes the mention
* in the text so mention-only filtering fires.
*/
export function buildTelegramUpdate(opts: {
updateId: number;
chatId: number;
userId: number;
username: string;
text: string;
isGroup?: boolean;
botUsername?: string;
}): TelegramUpdate {
const chatType = opts.isGroup ? 'group' : 'private';
return {
update_id: opts.updateId,
message: {
message_id: opts.updateId * 10, // stable across retries
from: { id: opts.userId, is_bot: false, first_name: opts.username, username: opts.username },
chat: {
id: opts.chatId,
type: chatType,
...(opts.isGroup ? { title: `e2e-group-${opts.chatId}` } : { first_name: opts.username }),
},
date: Math.floor(Date.now() / 1000),
text: opts.text,
},
};
}
/**
* Inject a Telegram Update into the mock server's pending queue.
* The Telegram provider's `getUpdates` poll will drain this on the next call.
*/
export async function injectTelegramUpdate(update: TelegramUpdate): Promise<void> {
console.log(
`${LOG_PREFIX} injectTelegramUpdate: update_id=${update.update_id} ` +
`chat_id=${update.message.chat.id} text="${update.message.text.slice(0, 60)}"`
);
await adminInjectUpdate(update);
}
/**
* Poll the mock's sent-messages log until a `sendMessage` entry appears that
* matches the given `chatId` and optional `contains` predicate.
*
* Returns the matching entry, or throws after `timeoutMs`.
*/
export async function waitForTelegramReply(opts: {
chatId: number;
contains?: string;
timeoutMs?: number;
}): Promise<SentMessage> {
const { chatId, contains, timeoutMs = 20_000 } = opts;
console.log(
`${LOG_PREFIX} waitForTelegramReply: chatId=${chatId} ` +
`contains="${contains ?? '*'}" timeout=${timeoutMs}ms`
);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const sent = (await adminGetSentMessages()) as SentMessage[];
const match = sent.find(entry => {
// method might be 'sendMessage', 'sendText', etc.; filter by chat_id first.
const matchesChat =
String(entry.chat_id) === String(chatId) ||
// Some mock implementations nest the chat_id inside a request body JSON.
String((entry as Record<string, unknown>).body_chat_id ?? '') === String(chatId);
if (!matchesChat) return false;
if (!contains) return true;
const text = String(entry.text ?? entry.message ?? '');
return text.includes(contains);
});
if (match) {
console.log(`${LOG_PREFIX} waitForTelegramReply: found match — ${JSON.stringify(match)}`);
return match;
}
await browser.pause(300);
}
const allSent = (await adminGetSentMessages()) as SentMessage[];
throw new Error(
`${LOG_PREFIX} waitForTelegramReply: TIMEOUT — no reply to chatId=${chatId}` +
(contains ? ` containing "${contains}"` : '') +
` after ${timeoutMs}ms. Sent log (${allSent.length} entries): ` +
JSON.stringify(allSent.slice(-5))
);
}
/**
* Poll the mock's sent-messages log and assert that NO reply to `chatId`
* appears within `timeoutMs`. Returns `true` if the window passes cleanly,
* `false` if a matching message is observed.
*/
export async function assertNoTelegramReply(opts: {
chatId: number;
contains?: string;
timeoutMs?: number;
}): Promise<boolean> {
const { chatId, contains, timeoutMs = 5_000 } = opts;
console.log(
`${LOG_PREFIX} assertNoTelegramReply: chatId=${chatId} ` +
`contains="${contains ?? '*'}" window=${timeoutMs}ms`
);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const sent = (await adminGetSentMessages()) as SentMessage[];
const match = sent.find(entry => {
const matchesChat = String(entry.chat_id) === String(chatId);
if (!matchesChat) return false;
if (!contains) return true;
const text = String(entry.text ?? entry.message ?? '');
return text.includes(contains);
});
if (match) {
console.warn(
`${LOG_PREFIX} assertNoTelegramReply: UNEXPECTED reply to chatId=${chatId} — ` +
JSON.stringify(match)
);
return false;
}
await browser.pause(300);
}
console.log(`${LOG_PREFIX} assertNoTelegramReply: clean — no reply in ${timeoutMs}ms window`);
return true;
}
/**
* Reset the Telegram mock state (pending update queue + sent log + counter).
* Delegates to the WS-A admin endpoint via `mock-server.ts`.
*/
export async function resetTelegramMock(): Promise<void> {
await adminReset();
console.log(`${LOG_PREFIX} resetTelegramMock: done`);
}