feat: show connection state, bot, message counters and last activity in the omp status line
This commit is contained in:
1 parent
60254b288b
commit
ca05b58f38
7 files changed
+554
-269
No files matched your search
@@ -43,6 +43,7 @@ import * as TelegramApi from "./lib/telegram-api.ts";
|
||||
import * as TextGroups from "./lib/text-groups.ts";
|
||||
import * as ThreadReconciler from "./lib/thread-reconciler.ts";
|
||||
import * as TimeInjection from "./lib/time-injection.ts";
|
||||
import * as Traffic from "./lib/traffic.ts";
|
||||
import * as Updates from "./lib/updates.ts";
|
||||
import * as Voice from "./lib/voice.ts";
|
||||
|
||||
@@ -274,11 +275,13 @@ export default function (pi: Pi.ExtensionAPI) {
|
||||
getFollowerThreadName: telegramBusFollowerRegistrationState.getThreadName,
|
||||
getCurrentIdentity: getCurrentInstanceThreadIdentity,
|
||||
});
|
||||
const trafficCounters = Traffic.createTelegramTrafficCounters();
|
||||
const statusRuntime = Status.createTelegramBridgeStatusRuntime<
|
||||
Pi.ExtensionContext,
|
||||
Queue.TelegramQueueItem<Pi.ExtensionContext>
|
||||
>({
|
||||
getConfig: configStore.get,
|
||||
getTraffic: trafficCounters.snapshot,
|
||||
getActiveProfileName: configStore.getActiveProfileName,
|
||||
getDiagnosticPaths: Paths.getTelegramDiagnosticsDisplayPaths,
|
||||
isPollingActive: Polling.createTelegramPollingActivityReader(
|
||||
@@ -323,6 +326,12 @@ export default function (pi: Pi.ExtensionAPI) {
|
||||
getCwd: Pi.getExtensionContextCwd,
|
||||
recordRuntimeEvent,
|
||||
});
|
||||
const trafficBridge = Traffic.createTelegramTrafficStatusBridge({
|
||||
counters: trafficCounters,
|
||||
getContext: telegramSessionContextStore.get,
|
||||
updateStatus,
|
||||
recordRuntimeEvent,
|
||||
});
|
||||
|
||||
// --- Telegram API ---
|
||||
|
||||
@@ -330,6 +339,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
||||
TelegramApi.createDefaultTelegramBridgeApiRuntime({
|
||||
getBotToken: configStore.getBotToken,
|
||||
recordRuntimeEvent,
|
||||
onCallSucceeded: trafficBridge.onCallSucceeded,
|
||||
});
|
||||
const telegramBusFollowerClients =
|
||||
BusFollower.createTelegramBusFollowerClientRuntime<
|
||||
@@ -645,6 +655,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
||||
const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime({
|
||||
configStore,
|
||||
callApi: callTelegramApi,
|
||||
onAuthorizedMessage: trafficBridge.onAuthorizedMessage,
|
||||
getCurrentInstanceId() {
|
||||
return telegramInstanceId;
|
||||
},
|
||||
|
||||
@@ -554,6 +554,8 @@ export interface TelegramInboundRouteRuntimeDeps<
|
||||
method: string,
|
||||
body: Record<string, unknown>,
|
||||
) => Promise<TResponse>;
|
||||
/** Called for every message from the paired owner, before it is dispatched. */
|
||||
onAuthorizedMessage?: (ctx: TContext) => void;
|
||||
getCurrentInstanceId?: () => string | undefined;
|
||||
getMessageOwnership?: Updates.TelegramMessageOwnershipLookup;
|
||||
getTargetOwnership?: Updates.TelegramTargetOwnershipLookup;
|
||||
@@ -2086,6 +2088,7 @@ export function createTelegramInboundRouteRuntime<
|
||||
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
||||
sendTextReply: deps.sendTextReply,
|
||||
handleAuthorizedTelegramMessage: async (message, ctx) => {
|
||||
deps.onAuthorizedMessage?.(ctx);
|
||||
if (typeof message.message_thread_id === "number") {
|
||||
await deps.handleTelegramThreadTargetObserved?.(
|
||||
{
|
||||
|
||||
+125
-17
@@ -4,6 +4,13 @@
|
||||
* Builds usage, cost, and context summaries for the interactive Telegram status view
|
||||
*/
|
||||
|
||||
/** Message counters the status shows; structurally satisfied by the traffic domain. */
|
||||
export interface TelegramStatusTraffic {
|
||||
received: number;
|
||||
sent: number;
|
||||
lastAtMs?: number;
|
||||
}
|
||||
|
||||
export type TelegramStatusQueueLane = "control" | "priority" | "default";
|
||||
|
||||
export interface TelegramUsageStats {
|
||||
@@ -191,6 +198,7 @@ export interface TelegramBridgeStatusLineState {
|
||||
instanceSlot?: string;
|
||||
instanceThreadName?: string;
|
||||
lockState?: string;
|
||||
traffic?: TelegramStatusTraffic;
|
||||
pollingActive: boolean;
|
||||
lastUpdateId?: number;
|
||||
activeSourceMessageIds?: number[];
|
||||
@@ -215,12 +223,28 @@ export interface TelegramStatusBarTheme {
|
||||
token: "accent" | "error" | "muted" | "warning" | "success",
|
||||
text: string,
|
||||
) => string;
|
||||
/**
|
||||
* omp symbol presets (unicode, nerd font, ascii) so the status follows the
|
||||
* user's glyph set. Hosts that do not expose them get the unicode fallbacks.
|
||||
*/
|
||||
status?: {
|
||||
enabled?: string;
|
||||
shadowed?: string;
|
||||
running?: string;
|
||||
warning?: string;
|
||||
error?: string;
|
||||
disabled?: string;
|
||||
};
|
||||
sep?: { dot?: string };
|
||||
icon?: { input?: string; output?: string; time?: string };
|
||||
}
|
||||
|
||||
export interface TelegramStatusBarState {
|
||||
hasBotToken: boolean;
|
||||
pollingActive: boolean;
|
||||
paired: boolean;
|
||||
botUsername?: string;
|
||||
traffic?: TelegramStatusTraffic;
|
||||
busRole?: TelegramBridgeBusRole;
|
||||
busLifecyclePhase?: TelegramBridgeBusLifecyclePhase;
|
||||
instanceSlot?: string;
|
||||
@@ -298,6 +322,7 @@ export interface TelegramBridgeStatusRuntimeDeps<
|
||||
getInstanceSlot?: () => string | undefined;
|
||||
getInstanceThreadName?: () => string | undefined;
|
||||
getNowMs?: () => number;
|
||||
getTraffic?: () => TelegramStatusTraffic;
|
||||
}
|
||||
|
||||
export interface TelegramBridgeStatusLineOptions {
|
||||
@@ -599,6 +624,8 @@ export function createTelegramBridgeStatusRuntime<
|
||||
hasBotToken: !!config.botToken,
|
||||
pollingActive: deps.isPollingActive(),
|
||||
paired: !!config.allowedUserId,
|
||||
botUsername: config.botUsername,
|
||||
traffic: deps.getTraffic?.(),
|
||||
busRole: deps.getBusRole?.(),
|
||||
busLifecyclePhase: deps.getBusLifecyclePhase?.(),
|
||||
instanceSlot: deps.getInstanceSlot?.(),
|
||||
@@ -639,6 +666,7 @@ export function createTelegramBridgeStatusRuntime<
|
||||
instanceSlot: deps.getInstanceSlot?.(),
|
||||
instanceThreadName: deps.getInstanceThreadName?.(),
|
||||
lockState: deps.getRuntimeLockState?.(),
|
||||
traffic: deps.getTraffic?.(),
|
||||
pollingActive: deps.isPollingActive(),
|
||||
lastUpdateId: config.lastUpdateId,
|
||||
activeSourceMessageIds: deps.getActiveSourceMessageIds(),
|
||||
@@ -761,38 +789,116 @@ function getTelegramStatusBarLabel(state: TelegramStatusBarState): string {
|
||||
return threadName;
|
||||
}
|
||||
|
||||
interface TelegramStatusBarGlyphs {
|
||||
enabled: string;
|
||||
shadowed: string;
|
||||
running: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
disabled: string;
|
||||
dot: string;
|
||||
input: string;
|
||||
output: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
function resolveTelegramStatusBarGlyphs(
|
||||
theme: TelegramStatusBarTheme,
|
||||
): TelegramStatusBarGlyphs {
|
||||
return {
|
||||
enabled: theme.status?.enabled ?? "●",
|
||||
shadowed: theme.status?.shadowed ?? "○",
|
||||
running: theme.status?.running ?? "⟳",
|
||||
warning: theme.status?.warning ?? "⚠",
|
||||
error: theme.status?.error ?? "✘",
|
||||
disabled: theme.status?.disabled ?? "⦸",
|
||||
dot: theme.sep?.dot ?? " · ",
|
||||
input: theme.icon?.input ?? "↓",
|
||||
output: theme.icon?.output ?? "↑",
|
||||
time: theme.icon?.time ?? "⏱",
|
||||
};
|
||||
}
|
||||
|
||||
function formatTelegramStatusBarClock(timestampMs: number): string {
|
||||
const date = new Date(timestampMs);
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/** Bot name, message counters and last activity, each behind a muted dot. */
|
||||
function buildTelegramStatusBarDetails(
|
||||
theme: TelegramStatusBarTheme,
|
||||
glyphs: TelegramStatusBarGlyphs,
|
||||
state: TelegramStatusBarState,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (state.botUsername) parts.push(`@${state.botUsername}`);
|
||||
if (state.traffic) {
|
||||
parts.push(
|
||||
`${glyphs.input}${state.traffic.received} ${glyphs.output}${state.traffic.sent}`,
|
||||
);
|
||||
if (state.traffic.lastAtMs !== undefined) {
|
||||
parts.push(
|
||||
`${glyphs.time} ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return parts
|
||||
.map((part) => theme.fg("muted", `${glyphs.dot}${part}`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildTelegramTrafficLines(
|
||||
state: Pick<TelegramBridgeStatusLineState, "traffic">,
|
||||
): string[] {
|
||||
if (!state.traffic) return [];
|
||||
const last =
|
||||
state.traffic.lastAtMs === undefined
|
||||
? ""
|
||||
: `, last ${formatTelegramStatusBarClock(state.traffic.lastAtMs)}`;
|
||||
return [
|
||||
`- messages: ${state.traffic.received} received, ${state.traffic.sent} sent${last}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildTelegramStatusBarText(
|
||||
theme: TelegramStatusBarTheme,
|
||||
state: TelegramStatusBarState,
|
||||
): string {
|
||||
const glyphs = resolveTelegramStatusBarGlyphs(theme);
|
||||
const label = theme.fg("accent", getTelegramStatusBarLabel(state));
|
||||
if (state.error) {
|
||||
return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
|
||||
}
|
||||
const hint = (text: string) => theme.fg("muted", `${glyphs.dot}${text}`);
|
||||
const queued = state.queuedStatus
|
||||
? theme.fg("success", state.queuedStatus)
|
||||
: "";
|
||||
if (!state.hasBotToken)
|
||||
return `${label} ${theme.fg("muted", "not configured")}${queued}`;
|
||||
if (!state.paired)
|
||||
return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
|
||||
if (state.busLifecyclePhase === "electing")
|
||||
return `${label} ${theme.fg("warning", "electing")}${queued}`;
|
||||
if (!state.pollingActive && state.busRole !== "follower")
|
||||
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", "disconnected")}${queued}`;
|
||||
if (state.error) {
|
||||
return `${label} ${theme.fg("error", `${glyphs.error} error`)} ${theme.fg("muted", state.error)}`;
|
||||
}
|
||||
if (!state.hasBotToken) {
|
||||
return `${label} ${theme.fg("muted", `${glyphs.disabled} not configured`)}${queued}${hint("/telegram-setup")}`;
|
||||
}
|
||||
if (!state.paired) {
|
||||
const bot = state.botUsername ? `@${state.botUsername}` : "the bot";
|
||||
return `${label} ${theme.fg("warning", `${glyphs.warning} awaiting pairing`)}${queued}${hint(`send /start to ${bot}`)}`;
|
||||
}
|
||||
if (state.busLifecyclePhase === "electing") {
|
||||
return `${label} ${theme.fg("warning", `${glyphs.running} electing`)}${queued}`;
|
||||
}
|
||||
if (!state.pollingActive && state.busRole !== "follower") {
|
||||
return `${theme.fg("accent", "telegram")} ${theme.fg("muted", `${glyphs.shadowed} disconnected`)}${queued}${hint("/telegram-connect")}`;
|
||||
}
|
||||
const details = buildTelegramStatusBarDetails(theme, glyphs, state);
|
||||
if (state.processing) {
|
||||
const processingStatus = state.queuedStatus
|
||||
? "active"
|
||||
: (state.processingStatus ?? "processing");
|
||||
const processingToken =
|
||||
processingStatus === "active" ? "warning" : "accent";
|
||||
return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
|
||||
return `${label} ${theme.fg(processingToken, `${glyphs.running} ${processingStatus}`)}${queued}${details}`;
|
||||
}
|
||||
if (state.busRole === "follower")
|
||||
return `${label} ${theme.fg("success", "follower")}${queued}`;
|
||||
if (state.busRole === "leader")
|
||||
return `${label} ${theme.fg("success", "leader")}`;
|
||||
return `${label} ${theme.fg("success", "connected")}`;
|
||||
const role = state.busRole ?? "connected";
|
||||
return `${label} ${theme.fg("success", `${glyphs.enabled} ${role}`)}${queued}${details}`;
|
||||
}
|
||||
|
||||
function formatTelegramBridgeBotStatus(
|
||||
@@ -1079,6 +1185,7 @@ function buildTelegramBridgeCompactStatusLines(
|
||||
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
||||
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
||||
`- user: ${state.allowedUserId ?? "not paired"}`,
|
||||
...buildTelegramTrafficLines(state),
|
||||
...(state.botThreadMode ? [`- thread mode: ${state.botThreadMode}`] : []),
|
||||
...(state.busRole ? [`- role: ${state.busRole}`] : []),
|
||||
...(state.busLifecyclePhase
|
||||
@@ -1134,6 +1241,7 @@ export function buildTelegramBridgeDiagnosticStatusLines(
|
||||
`- bot: ${formatTelegramBridgeBotStatus(state)}`,
|
||||
...(state.activeProfileName ? [`- profile: ${state.activeProfileName}`] : []),
|
||||
`- allowed user: ${state.allowedUserId ?? "not paired"}`,
|
||||
...buildTelegramTrafficLines(state),
|
||||
...(state.botThreadMode
|
||||
? [
|
||||
`- thread mode: ${state.botThreadMode}${state.botThreadModeAction ? ` reconcile=${state.botThreadModeAction}` : ""}`,
|
||||
|
||||
+20
-10
@@ -375,6 +375,8 @@ export interface TelegramBridgeApiRuntimeDeps {
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => void;
|
||||
/** Called after a Bot API call or multipart upload succeeded. */
|
||||
onCallSucceeded?: (method: string) => void;
|
||||
}
|
||||
|
||||
export interface TelegramBridgeApiRuntime {
|
||||
@@ -1246,6 +1248,7 @@ export function createTelegramAssistantDraftSender(deps: {
|
||||
export function createDefaultTelegramBridgeApiRuntime(deps: {
|
||||
getBotToken: () => string | undefined;
|
||||
recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
|
||||
onCallSucceeded?: TelegramBridgeApiRuntimeDeps["onCallSucceeded"];
|
||||
}): TelegramBridgeApiRuntime {
|
||||
return createTelegramBridgeApiRuntime({
|
||||
client: createTelegramApiClient(deps.getBotToken, {
|
||||
@@ -1255,6 +1258,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
|
||||
maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
|
||||
tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
|
||||
recordRuntimeEvent: deps.recordRuntimeEvent,
|
||||
onCallSucceeded: deps.onCallSucceeded,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1266,8 +1270,9 @@ export function createTelegramBridgeApiRuntime(
|
||||
body: Record<string, unknown>,
|
||||
options?: TelegramApiCallOptions,
|
||||
): Promise<TResponse> => {
|
||||
let response: TResponse;
|
||||
try {
|
||||
return await deps.client.call<TResponse>(method, body, options);
|
||||
response = await deps.client.call<TResponse>(method, body, options);
|
||||
} catch (error) {
|
||||
deps.recordRuntimeEvent(
|
||||
"api",
|
||||
@@ -1276,6 +1281,8 @@ export function createTelegramBridgeApiRuntime(
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
deps.onCallSucceeded?.(method);
|
||||
return response;
|
||||
};
|
||||
return {
|
||||
call: callRecorded,
|
||||
@@ -1285,16 +1292,17 @@ export function createTelegramBridgeApiRuntime(
|
||||
* photos, documents, animations, etc.).
|
||||
* Errors are recorded under the "multipart" category for diagnostics.
|
||||
*/
|
||||
callMultipart: async (
|
||||
method,
|
||||
fields,
|
||||
fileField,
|
||||
filePath,
|
||||
fileName,
|
||||
options,
|
||||
) => {
|
||||
callMultipart: async <TResponse>(
|
||||
method: string,
|
||||
fields: Record<string, string>,
|
||||
fileField: string,
|
||||
filePath: string,
|
||||
fileName: string,
|
||||
options?: TelegramApiCallOptions,
|
||||
): Promise<TResponse> => {
|
||||
let response: TResponse;
|
||||
try {
|
||||
return await deps.client.callMultipart(
|
||||
response = await deps.client.callMultipart<TResponse>(
|
||||
method,
|
||||
fields,
|
||||
fileField,
|
||||
@@ -1310,6 +1318,8 @@ export function createTelegramBridgeApiRuntime(
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
deps.onCallSucceeded?.(method);
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Telegram bridge traffic counters
|
||||
* Zones: telegram transport, status
|
||||
* Owns in-memory received/sent message counts and last-activity time for the status bar
|
||||
*
|
||||
* This domain is pure: it counts, it does not read config, call Telegram, or import other domains.
|
||||
*/
|
||||
|
||||
export interface TelegramTrafficSnapshot {
|
||||
/** Authorized messages received from the paired owner since the bridge loaded. */
|
||||
received: number;
|
||||
/** Messages the bridge delivered to Telegram since the bridge loaded. */
|
||||
sent: number;
|
||||
/** Time of the latest received or sent message, when there was one. */
|
||||
lastAtMs?: number;
|
||||
}
|
||||
|
||||
export interface TelegramTrafficCounters {
|
||||
recordReceived: () => void;
|
||||
recordSent: () => void;
|
||||
snapshot: () => TelegramTrafficSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot API methods that deliver a message the owner can read. Typing actions,
|
||||
* drafts, edits, reactions and callback answers are not messages.
|
||||
*/
|
||||
const TELEGRAM_OUTBOUND_MESSAGE_METHODS: Readonly<Record<string, true>> = {
|
||||
sendMessage: true,
|
||||
sendRichMessage: true,
|
||||
sendPhoto: true,
|
||||
sendDocument: true,
|
||||
sendAudio: true,
|
||||
sendVideo: true,
|
||||
sendVoice: true,
|
||||
sendAnimation: true,
|
||||
sendVideoNote: true,
|
||||
sendMediaGroup: true,
|
||||
sendSticker: true,
|
||||
};
|
||||
|
||||
export function isTelegramOutboundMessageMethod(method: string): boolean {
|
||||
return Object.hasOwn(TELEGRAM_OUTBOUND_MESSAGE_METHODS, method);
|
||||
}
|
||||
|
||||
export function createTelegramTrafficCounters(
|
||||
now: () => number = Date.now,
|
||||
): TelegramTrafficCounters {
|
||||
let received = 0;
|
||||
let sent = 0;
|
||||
let lastAtMs: number | undefined;
|
||||
return {
|
||||
recordReceived() {
|
||||
received += 1;
|
||||
lastAtMs = now();
|
||||
},
|
||||
recordSent() {
|
||||
sent += 1;
|
||||
lastAtMs = now();
|
||||
},
|
||||
snapshot: () => ({
|
||||
received,
|
||||
sent,
|
||||
...(lastAtMs === undefined ? {} : { lastAtMs }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface TelegramTrafficStatusBridgeDeps<TContext> {
|
||||
counters: TelegramTrafficCounters;
|
||||
/** Current session context, when the bridge has one. */
|
||||
getContext: () => TContext | undefined;
|
||||
updateStatus: (ctx: TContext) => void;
|
||||
recordRuntimeEvent: (
|
||||
category: string,
|
||||
error: unknown,
|
||||
details?: Record<string, unknown>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface TelegramTrafficStatusBridge<TContext> {
|
||||
/** Observer for successful Bot API calls; counts delivered messages. */
|
||||
onCallSucceeded: (method: string) => void;
|
||||
/** Observer for messages accepted from the paired owner. */
|
||||
onAuthorizedMessage: (ctx: TContext) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts traffic and refreshes the status bar so the counters stay current. A
|
||||
* status refresh failure (for example a stale session context) is recorded and
|
||||
* never propagates into the send or receive path that triggered it.
|
||||
*/
|
||||
export function createTelegramTrafficStatusBridge<TContext>(
|
||||
deps: TelegramTrafficStatusBridgeDeps<TContext>,
|
||||
): TelegramTrafficStatusBridge<TContext> {
|
||||
const refreshStatus = (ctx: TContext): void => {
|
||||
try {
|
||||
deps.updateStatus(ctx);
|
||||
} catch (error) {
|
||||
deps.recordRuntimeEvent("status", error, { phase: "traffic-refresh" });
|
||||
}
|
||||
};
|
||||
return {
|
||||
onCallSucceeded(method) {
|
||||
if (!isTelegramOutboundMessageMethod(method)) return;
|
||||
deps.counters.recordSent();
|
||||
const ctx = deps.getContext();
|
||||
if (ctx !== undefined) refreshStatus(ctx);
|
||||
},
|
||||
onAuthorizedMessage(ctx) {
|
||||
deps.counters.recordReceived();
|
||||
refreshStatus(ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
+159
-242
@@ -111,248 +111,165 @@ test("Status runtime diagnostics scheduler coalesces snapshot persists", async (
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test("Status bar text renders bridge connection and queue states", () => {
|
||||
const theme = {
|
||||
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
|
||||
const statusBarTheme = {
|
||||
fg: (token: string, text: string) => `<${token}>${text}</${token}>`,
|
||||
};
|
||||
|
||||
function plainStatusText(text: string): string {
|
||||
return text.replace(/<\/?[a-z]+>/g, "");
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
test("Status bar text keeps the label, state color and queue for every connection state", () => {
|
||||
const base = {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
};
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: false,
|
||||
pollingActive: false,
|
||||
paired: false,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <muted>not configured</muted>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
queuedStatus: " +1",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
processingStatus: "dispatching",
|
||||
queuedStatus: " +1",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
processingStatus: "active",
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>active</warning>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: true,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <success>connected</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: true,
|
||||
processing: true,
|
||||
processingStatus: "active",
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>active</warning>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
instanceThreadName: "Aurora",
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
processingStatus: "queued",
|
||||
queuedStatus: " +2",
|
||||
}),
|
||||
"<accent>telegram</accent> <muted>disconnected</muted><success> +2</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <success>follower</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
instanceThreadName: "Amber",
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
processingStatus: "active",
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>Amber</accent> <warning>active</warning>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
busLifecyclePhase: "electing",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>electing</warning>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busLifecyclePhase: "electing",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>electing</warning>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
instanceThreadName: "Follower",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <success>follower</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
instanceThreadName: "Lname",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>Lname</accent> <success>follower</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
instanceSlot: "O",
|
||||
instanceThreadName: "extensions Follower",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>extensions Follower</accent> <success>follower</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
busRole: "follower",
|
||||
instanceSlot: "O",
|
||||
instanceThreadName: "Oname",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>Oname</accent> <success>follower</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
busRole: "leader",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>telegram</accent> <success>leader</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
busRole: "leader",
|
||||
instanceThreadName: "🌙 A-identity",
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
}),
|
||||
"<accent>🌙 A-identity</accent> <success>leader</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: false,
|
||||
compactionInProgress: false,
|
||||
processing: true,
|
||||
processingStatus: "queued",
|
||||
queuedStatus: " +1",
|
||||
}),
|
||||
"<accent>telegram</accent> <warning>awaiting pairing</warning><success> +1</success>",
|
||||
);
|
||||
assert.equal(
|
||||
buildTelegramStatusBarText(theme, {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
const cases: Array<{
|
||||
state: Partial<Parameters<typeof buildTelegramStatusBarText>[1]>;
|
||||
label: string;
|
||||
token: string;
|
||||
word: string;
|
||||
queued?: string;
|
||||
}> = [
|
||||
{ state: { hasBotToken: false, pollingActive: false, paired: false }, label: "telegram", token: "muted", word: "not configured" },
|
||||
{ state: { processing: true, queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
|
||||
{ state: { processing: true, processingStatus: "dispatching", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "active", queued: " +1" },
|
||||
{ state: { processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
|
||||
{ state: { compactionInProgress: true }, label: "telegram", token: "success", word: "connected" },
|
||||
{ state: { compactionInProgress: true, processing: true, processingStatus: "active" }, label: "telegram", token: "warning", word: "active" },
|
||||
{ state: { pollingActive: false, instanceThreadName: "Aurora", processing: true, processingStatus: "queued", queuedStatus: " +2" }, label: "telegram", token: "muted", word: "disconnected", queued: " +2" },
|
||||
{ state: { pollingActive: false, busRole: "follower" }, label: "telegram", token: "success", word: "follower" },
|
||||
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Amber", processing: true, processingStatus: "active" }, label: "Amber", token: "warning", word: "active" },
|
||||
{ state: { pollingActive: false, busRole: "follower", busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
|
||||
{ state: { pollingActive: false, busLifecyclePhase: "electing" }, label: "telegram", token: "warning", word: "electing" },
|
||||
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Follower" }, label: "telegram", token: "success", word: "follower" },
|
||||
{ state: { pollingActive: false, busRole: "follower", instanceThreadName: "Lname" }, label: "Lname", token: "success", word: "follower" },
|
||||
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "extensions Follower" }, label: "extensions Follower", token: "success", word: "follower" },
|
||||
{ state: { pollingActive: false, busRole: "follower", instanceSlot: "O", instanceThreadName: "Oname" }, label: "Oname", token: "success", word: "follower" },
|
||||
{ state: { busRole: "leader" }, label: "telegram", token: "success", word: "leader" },
|
||||
{ state: { busRole: "leader", instanceThreadName: "🌙 A-identity" }, label: "🌙 A-identity", token: "success", word: "leader" },
|
||||
{ state: { paired: false, processing: true, processingStatus: "queued", queuedStatus: " +1" }, label: "telegram", token: "warning", word: "awaiting pairing", queued: " +1" },
|
||||
];
|
||||
for (const { state, label, token, word, queued } of cases) {
|
||||
const text = buildTelegramStatusBarText(statusBarTheme, { ...base, ...state });
|
||||
const expected = new RegExp(
|
||||
`^<accent>${escapeRegExp(label)}</accent> <${token}>\\S+ ${word}</${token}>${queued ? `<success>${escapeRegExp(queued)}</success>` : ""}`,
|
||||
);
|
||||
assert.match(text, expected, JSON.stringify(state));
|
||||
}
|
||||
assert.match(
|
||||
buildTelegramStatusBarText(statusBarTheme, {
|
||||
...base,
|
||||
error: "typing failed",
|
||||
}),
|
||||
"<accent>telegram</accent> <error>error</error> <muted>typing failed</muted>",
|
||||
/^<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>typing failed<\/muted>$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Status bar shows bot, message counters and last activity only while the bridge is up", () => {
|
||||
const lastAtMs = new Date(2026, 9, 1, 14, 32).getTime();
|
||||
const connected = {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
botUsername: "demo_bot",
|
||||
traffic: { received: 12, sent: 11, lastAtMs },
|
||||
};
|
||||
const up = plainStatusText(buildTelegramStatusBarText(statusBarTheme, connected));
|
||||
assert.match(up, /connected/);
|
||||
assert.match(up, /@demo_bot/);
|
||||
assert.match(up, /12 \S*11/);
|
||||
assert.match(up, /14:32/);
|
||||
|
||||
const withoutActivity = plainStatusText(
|
||||
buildTelegramStatusBarText(statusBarTheme, {
|
||||
...connected,
|
||||
traffic: { received: 0, sent: 0 },
|
||||
}),
|
||||
);
|
||||
assert.match(withoutActivity, /0 \S*0/);
|
||||
assert.doesNotMatch(withoutActivity, /\d\d:\d\d/);
|
||||
|
||||
const working = plainStatusText(
|
||||
buildTelegramStatusBarText(statusBarTheme, {
|
||||
...connected,
|
||||
processing: true,
|
||||
processingStatus: "active",
|
||||
}),
|
||||
);
|
||||
assert.match(working, /active/);
|
||||
assert.match(working, /@demo_bot/);
|
||||
assert.match(working, /12 \S*11/);
|
||||
|
||||
const offline = plainStatusText(
|
||||
buildTelegramStatusBarText(statusBarTheme, { ...connected, pollingActive: false }),
|
||||
);
|
||||
assert.doesNotMatch(offline, /@demo_bot|14:32/);
|
||||
assert.match(offline, /disconnected/);
|
||||
});
|
||||
|
||||
test("Status bar tells the operator how to leave a non-working state", () => {
|
||||
const base = {
|
||||
hasBotToken: true,
|
||||
pollingActive: false,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
};
|
||||
assert.match(
|
||||
plainStatusText(buildTelegramStatusBarText(statusBarTheme, { ...base, hasBotToken: false, paired: false })),
|
||||
/\/telegram-setup/,
|
||||
);
|
||||
const pairing = plainStatusText(
|
||||
buildTelegramStatusBarText(statusBarTheme, { ...base, paired: false, botUsername: "demo_bot" }),
|
||||
);
|
||||
assert.match(pairing, /\/start/);
|
||||
assert.match(pairing, /@demo_bot/);
|
||||
assert.match(
|
||||
plainStatusText(buildTelegramStatusBarText(statusBarTheme, base)),
|
||||
/\/telegram-connect/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Status bar follows the host symbol preset and falls back without one", () => {
|
||||
const state = {
|
||||
hasBotToken: true,
|
||||
pollingActive: true,
|
||||
paired: true,
|
||||
compactionInProgress: false,
|
||||
processing: false,
|
||||
queuedStatus: "",
|
||||
botUsername: "demo_bot",
|
||||
traffic: { received: 3, sent: 2, lastAtMs: new Date(2026, 9, 1, 9, 5).getTime() },
|
||||
};
|
||||
const ascii = plainStatusText(
|
||||
buildTelegramStatusBarText(
|
||||
{
|
||||
...statusBarTheme,
|
||||
status: { enabled: "[x]" },
|
||||
sep: { dot: " - " },
|
||||
icon: { input: "in:", output: "out:", time: "t:" },
|
||||
},
|
||||
state,
|
||||
),
|
||||
);
|
||||
assert.equal(ascii, "telegram [x] connected - @demo_bot - in:3 out:2 - t: 09:05");
|
||||
const fallback = plainStatusText(buildTelegramStatusBarText(statusBarTheme, state));
|
||||
assert.match(fallback, /^telegram \S+ connected · @demo_bot · \S*3 \S*2 · \S+ 09:05$/u);
|
||||
});
|
||||
|
||||
test("Status runtime updates the status bar and exposes bridge lines", () => {
|
||||
const events: string[] = [];
|
||||
const ctx = {
|
||||
@@ -390,9 +307,9 @@ test("Status runtime updates the status bar and exposes bridge lines", () => {
|
||||
}),
|
||||
});
|
||||
runtime.updateStatus(ctx, "demo error");
|
||||
assert.equal(
|
||||
assert.match(
|
||||
events[0],
|
||||
"telegram:<accent>telegram</accent> <error>error</error> <muted>demo error</muted>",
|
||||
/^telegram:<accent>telegram<\/accent> <error>\S+ error<\/error> <muted>demo error<\/muted>$/,
|
||||
);
|
||||
assert.deepEqual(runtime.getStatusLines().slice(0, 3), [
|
||||
"connection:",
|
||||
@@ -550,9 +467,9 @@ test("Bridge status runtime stays active while tools run after queue changes", (
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
assert.match(
|
||||
events[0],
|
||||
"telegram:<accent>telegram</accent> <warning>active</warning>",
|
||||
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning>/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -589,9 +506,9 @@ test("Bridge status runtime builds status state from live ports", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
assert.match(
|
||||
events[0],
|
||||
"telegram:<accent>telegram</accent> <warning>active</warning><success> +1</success>",
|
||||
/^telegram:<accent>telegram<\/accent> <warning>\S+ active<\/warning><success> \+1<\/success>/,
|
||||
);
|
||||
assert.deepEqual(runtime.getStatusLines(), [
|
||||
"connection:",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Regression tests for the Telegram traffic counters
|
||||
* Covers what counts as a message, last-activity tracking, and the /telegram-status line.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildTelegramBridgeStatusLines } from "../lib/status.ts";
|
||||
import {
|
||||
createTelegramTrafficCounters,
|
||||
createTelegramTrafficStatusBridge,
|
||||
isTelegramOutboundMessageMethod,
|
||||
} from "../lib/traffic.ts";
|
||||
|
||||
test("Traffic counters count both directions and remember the latest activity", () => {
|
||||
let nowMs = 1_000;
|
||||
const counters = createTelegramTrafficCounters(() => nowMs);
|
||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 });
|
||||
counters.recordReceived();
|
||||
nowMs = 2_000;
|
||||
counters.recordSent();
|
||||
counters.recordSent();
|
||||
assert.deepEqual(counters.snapshot(), {
|
||||
received: 1,
|
||||
sent: 2,
|
||||
lastAtMs: 2_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("Only calls that deliver a readable message count as sent", () => {
|
||||
for (const method of ["sendMessage", "sendRichMessage", "sendPhoto", "sendDocument", "sendVoice"]) {
|
||||
assert.equal(isTelegramOutboundMessageMethod(method), true, method);
|
||||
}
|
||||
for (const method of [
|
||||
"sendChatAction",
|
||||
"sendMessageDraft",
|
||||
"editMessageText",
|
||||
"setMessageReaction",
|
||||
"answerCallbackQuery",
|
||||
"getUpdates",
|
||||
"toString",
|
||||
]) {
|
||||
assert.equal(isTelegramOutboundMessageMethod(method), false, method);
|
||||
}
|
||||
});
|
||||
|
||||
test("Status lines report the message counters and the time of the last one", () => {
|
||||
const baseState = {
|
||||
botUsername: "demo_bot",
|
||||
allowedUserId: 7,
|
||||
pollingActive: true,
|
||||
pendingDispatch: false,
|
||||
compactionInProgress: false,
|
||||
activeToolExecutions: 0,
|
||||
pendingModelSwitch: false,
|
||||
queuedItems: [],
|
||||
recentRuntimeEvents: [],
|
||||
};
|
||||
const withTraffic = buildTelegramBridgeStatusLines({
|
||||
...baseState,
|
||||
traffic: { received: 12, sent: 11, lastAtMs: new Date(2026, 9, 1, 14, 32).getTime() },
|
||||
});
|
||||
assert.ok(
|
||||
withTraffic.includes("- messages: 12 received, 11 sent, last 14:32"),
|
||||
withTraffic.join("\n"),
|
||||
);
|
||||
const withoutTraffic = buildTelegramBridgeStatusLines(baseState);
|
||||
assert.equal(withoutTraffic.some((line) => line.startsWith("- messages:")), false);
|
||||
});
|
||||
|
||||
test("Traffic bridge counts delivered messages and refreshes the status with the current context", () => {
|
||||
const counters = createTelegramTrafficCounters();
|
||||
const refreshed: string[] = [];
|
||||
let context: string | undefined = "session-a";
|
||||
const bridge = createTelegramTrafficStatusBridge({
|
||||
counters,
|
||||
getContext: () => context,
|
||||
updateStatus: (ctx: string) => {
|
||||
refreshed.push(ctx);
|
||||
},
|
||||
recordRuntimeEvent: () => {},
|
||||
});
|
||||
bridge.onCallSucceeded("sendChatAction");
|
||||
bridge.onCallSucceeded("editMessageText");
|
||||
assert.deepEqual(counters.snapshot(), { received: 0, sent: 0 });
|
||||
assert.deepEqual(refreshed, []);
|
||||
bridge.onCallSucceeded("sendRichMessage");
|
||||
assert.equal(counters.snapshot().sent, 1);
|
||||
assert.deepEqual(refreshed, ["session-a"]);
|
||||
context = undefined;
|
||||
bridge.onCallSucceeded("sendMessage");
|
||||
assert.equal(counters.snapshot().sent, 2);
|
||||
assert.deepEqual(refreshed, ["session-a"]);
|
||||
bridge.onAuthorizedMessage("session-b");
|
||||
assert.equal(counters.snapshot().received, 1);
|
||||
assert.deepEqual(refreshed, ["session-a", "session-b"]);
|
||||
});
|
||||
|
||||
test("Traffic bridge records a failed status refresh instead of breaking the message path", () => {
|
||||
const counters = createTelegramTrafficCounters();
|
||||
const recorded: string[] = [];
|
||||
const bridge = createTelegramTrafficStatusBridge({
|
||||
counters,
|
||||
getContext: () => "session",
|
||||
updateStatus: () => {
|
||||
throw new Error("stale ctx");
|
||||
},
|
||||
recordRuntimeEvent: (category, error, details) => {
|
||||
recorded.push(`${category}:${(error as Error).message}:${details?.phase}`);
|
||||
},
|
||||
});
|
||||
bridge.onCallSucceeded("sendMessage");
|
||||
bridge.onAuthorizedMessage("session");
|
||||
assert.deepEqual(counters.snapshot().received, 1);
|
||||
assert.deepEqual(counters.snapshot().sent, 1);
|
||||
assert.deepEqual(recorded, [
|
||||
"status:stale ctx:traffic-refresh",
|
||||
"status:stale ctx:traffic-refresh",
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user