Merge b05c075c06 into fa9ec6e854
This commit is contained in:
commit
dc7000104a
@ -2,7 +2,9 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js";
|
||||||
import {
|
import {
|
||||||
|
createForumTopicTelegram,
|
||||||
deleteMessageTelegram,
|
deleteMessageTelegram,
|
||||||
|
editForumTopicTelegram,
|
||||||
editMessageTelegram,
|
editMessageTelegram,
|
||||||
reactMessageTelegram,
|
reactMessageTelegram,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
@ -318,5 +320,71 @@ export async function handleTelegramAction(
|
|||||||
return jsonResult({ ok: true, ...stats });
|
return jsonResult({ ok: true, ...stats });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "createForumTopic") {
|
||||||
|
if (!isActionEnabled("createForumTopic", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram createForumTopic is disabled. Set channels.telegram.actions.createForumTopic to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const name = readStringParam(params, "name", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const iconColor = readNumberParam(params, "iconColor", { integer: true });
|
||||||
|
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||||
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await createForumTopicTelegram(chatId ?? "", name, {
|
||||||
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
iconColor: iconColor ?? undefined,
|
||||||
|
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||||
|
});
|
||||||
|
return jsonResult({
|
||||||
|
ok: true,
|
||||||
|
messageThreadId: result.messageThreadId,
|
||||||
|
name: result.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "editForumTopic") {
|
||||||
|
if (!isActionEnabled("editForumTopic", false)) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram editForumTopic is disabled. Set channels.telegram.actions.editForumTopic to true.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chatId = readStringOrNumberParam(params, "chatId", {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const messageThreadId = readNumberParam(params, "messageThreadId", {
|
||||||
|
required: true,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const name = readStringParam(params, "name");
|
||||||
|
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||||
|
if (!name && !iconCustomEmojiId) {
|
||||||
|
throw new Error("At least one of name or iconCustomEmojiId is required");
|
||||||
|
}
|
||||||
|
const token = resolveTelegramToken(cfg, { accountId }).token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(
|
||||||
|
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await editForumTopicTelegram(chatId ?? "", messageThreadId ?? 0, {
|
||||||
|
token,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
name: name ?? undefined,
|
||||||
|
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||||
|
});
|
||||||
|
return jsonResult({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Unsupported Telegram action: ${action}`);
|
throw new Error(`Unsupported Telegram action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,12 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
actions.add("sticker");
|
actions.add("sticker");
|
||||||
actions.add("sticker-search");
|
actions.add("sticker-search");
|
||||||
}
|
}
|
||||||
|
if (gate("createForumTopic", false)) {
|
||||||
|
actions.add("thread-create");
|
||||||
|
}
|
||||||
|
if (gate("editForumTopic", false)) {
|
||||||
|
actions.add("thread-edit");
|
||||||
|
}
|
||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
supportsButtons: ({ cfg }) => {
|
supportsButtons: ({ cfg }) => {
|
||||||
@ -185,6 +191,49 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "thread-create") {
|
||||||
|
const chatId =
|
||||||
|
readStringOrNumberParam(params, "chatId") ??
|
||||||
|
readStringOrNumberParam(params, "channelId") ??
|
||||||
|
readStringParam(params, "target") ??
|
||||||
|
readStringParam(params, "to", { required: true });
|
||||||
|
const name = readStringParam(params, "threadName", { required: true });
|
||||||
|
return await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "createForumTopic",
|
||||||
|
chatId,
|
||||||
|
name,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "thread-edit") {
|
||||||
|
const chatId =
|
||||||
|
readStringOrNumberParam(params, "chatId") ??
|
||||||
|
readStringOrNumberParam(params, "channelId") ??
|
||||||
|
readStringParam(params, "target") ??
|
||||||
|
readStringParam(params, "to", { required: true });
|
||||||
|
const messageThreadId = readNumberParam(params, "threadId", {
|
||||||
|
required: true,
|
||||||
|
integer: true,
|
||||||
|
});
|
||||||
|
const name = readStringParam(params, "threadName");
|
||||||
|
const iconCustomEmojiId = readStringParam(params, "iconCustomEmojiId");
|
||||||
|
return await handleTelegramAction(
|
||||||
|
{
|
||||||
|
action: "editForumTopic",
|
||||||
|
chatId,
|
||||||
|
messageThreadId,
|
||||||
|
name: name ?? undefined,
|
||||||
|
iconCustomEmojiId: iconCustomEmojiId ?? undefined,
|
||||||
|
accountId: accountId ?? undefined,
|
||||||
|
},
|
||||||
|
cfg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
|||||||
"list-pins",
|
"list-pins",
|
||||||
"permissions",
|
"permissions",
|
||||||
"thread-create",
|
"thread-create",
|
||||||
|
"thread-edit",
|
||||||
"thread-list",
|
"thread-list",
|
||||||
"thread-reply",
|
"thread-reply",
|
||||||
"search",
|
"search",
|
||||||
|
|||||||
@ -18,6 +18,10 @@ export type TelegramActionConfig = {
|
|||||||
editMessage?: boolean;
|
editMessage?: boolean;
|
||||||
/** Enable sticker actions (send and search). */
|
/** Enable sticker actions (send and search). */
|
||||||
sticker?: boolean;
|
sticker?: boolean;
|
||||||
|
/** Enable creating forum topics in supergroups. */
|
||||||
|
createForumTopic?: boolean;
|
||||||
|
/** Enable editing forum topics in supergroups. */
|
||||||
|
editForumTopic?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramNetworkConfig = {
|
export type TelegramNetworkConfig = {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
"list-pins": "to",
|
"list-pins": "to",
|
||||||
permissions: "to",
|
permissions: "to",
|
||||||
"thread-create": "to",
|
"thread-create": "to",
|
||||||
|
"thread-edit": "to",
|
||||||
"thread-list": "none",
|
"thread-list": "none",
|
||||||
"thread-reply": "to",
|
"thread-reply": "to",
|
||||||
search: "none",
|
search: "none",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { Bot } from "grammy";
|
import type { Bot } from "grammy";
|
||||||
|
|
||||||
import { resolveAckReaction } from "../agents/identity.js";
|
import { resolveAckReaction } from "../agents/identity.js";
|
||||||
|
import { getPinnedContextString } from "./pinned-context.js";
|
||||||
import {
|
import {
|
||||||
findModelInCatalog,
|
findModelInCatalog,
|
||||||
loadModelCatalog,
|
loadModelCatalog,
|
||||||
@ -542,6 +543,18 @@ export const buildTelegramMessageContext = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject pinned message context for groups
|
||||||
|
if (isGroup) {
|
||||||
|
try {
|
||||||
|
const pinnedContext = await getPinnedContextString(bot, chatId, resolvedThreadId);
|
||||||
|
if (pinnedContext) {
|
||||||
|
combinedBody = `${pinnedContext}\n\n${combinedBody}`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`[telegram] Failed to fetch pinned context: ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
||||||
const systemPromptParts = [
|
const systemPromptParts = [
|
||||||
groupConfig?.systemPrompt?.trim() || null,
|
groupConfig?.systemPrompt?.trim() || null,
|
||||||
|
|||||||
101
src/telegram/pinned-context.ts
Normal file
101
src/telegram/pinned-context.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import type { Bot } from "grammy";
|
||||||
|
import { logVerbose } from "../globals.js";
|
||||||
|
|
||||||
|
type PinnedMessageCache = {
|
||||||
|
text: string;
|
||||||
|
from?: string;
|
||||||
|
date?: number;
|
||||||
|
fetchedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache pinned messages per chat/topic
|
||||||
|
// Key format: "chatId" or "chatId:topicId"
|
||||||
|
const pinnedCache = new Map<string, PinnedMessageCache | null>();
|
||||||
|
|
||||||
|
// Cache TTL: 5 minutes
|
||||||
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function buildCacheKey(chatId: string | number, topicId?: number): string {
|
||||||
|
const base = String(chatId);
|
||||||
|
return topicId != null && topicId !== 1 ? `${base}:${topicId}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePinnedCache(chatId: string | number, topicId?: number): void {
|
||||||
|
const key = buildCacheKey(chatId, topicId);
|
||||||
|
pinnedCache.delete(key);
|
||||||
|
logVerbose(`[pinned-context] Invalidated cache for ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPinnedMessage(
|
||||||
|
bot: Bot,
|
||||||
|
chatId: string | number,
|
||||||
|
topicId?: number,
|
||||||
|
): Promise<PinnedMessageCache | null> {
|
||||||
|
const key = buildCacheKey(chatId, topicId);
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
const cached = pinnedCache.get(key);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
const age = Date.now() - (cached?.fetchedAt ?? 0);
|
||||||
|
if (age < CACHE_TTL_MS) {
|
||||||
|
logVerbose(`[pinned-context] Cache hit for ${key}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const chat = await bot.api.getChat(chatId);
|
||||||
|
const pinned = chat.pinned_message;
|
||||||
|
|
||||||
|
if (!pinned) {
|
||||||
|
pinnedCache.set(key, null);
|
||||||
|
logVerbose(`[pinned-context] No pinned message for ${key}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract text content
|
||||||
|
const text = pinned.text ?? pinned.caption ?? "";
|
||||||
|
if (!text.trim()) {
|
||||||
|
pinnedCache.set(key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: PinnedMessageCache = {
|
||||||
|
text: text.trim(),
|
||||||
|
from: pinned.from?.first_name ?? pinned.from?.username,
|
||||||
|
date: pinned.date,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
pinnedCache.set(key, entry);
|
||||||
|
logVerbose(`[pinned-context] Fetched pinned message for ${key}: "${text.slice(0, 50)}..."`);
|
||||||
|
return entry;
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`[pinned-context] Failed to fetch pinned for ${key}: ${String(err)}`);
|
||||||
|
// Cache the failure briefly to avoid hammering the API
|
||||||
|
pinnedCache.set(key, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPinnedContext(pinned: PinnedMessageCache): string {
|
||||||
|
const datePart = pinned.date
|
||||||
|
? ` (pinned ${new Date(pinned.date * 1000).toLocaleDateString()})`
|
||||||
|
: "";
|
||||||
|
const fromPart = pinned.from ? ` by ${pinned.from}` : "";
|
||||||
|
return `[📌 Pinned${fromPart}${datePart}]\n${pinned.text}\n[/Pinned]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted pinned message context for injection.
|
||||||
|
* Returns empty string if no pinned message.
|
||||||
|
*/
|
||||||
|
export async function getPinnedContextString(
|
||||||
|
bot: Bot,
|
||||||
|
chatId: string | number,
|
||||||
|
topicId?: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const pinned = await fetchPinnedMessage(bot, chatId, topicId);
|
||||||
|
if (!pinned) return "";
|
||||||
|
return formatPinnedContext(pinned);
|
||||||
|
}
|
||||||
@ -617,6 +617,169 @@ export async function editMessageTelegram(
|
|||||||
return { ok: true, messageId: String(messageId), chatId };
|
return { ok: true, messageId: String(messageId), chatId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CreateForumTopicOpts = {
|
||||||
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
api?: Bot["api"];
|
||||||
|
retry?: RetryConfig;
|
||||||
|
/** Color of the topic icon in RGB format (one of Telegram's allowed colors). */
|
||||||
|
iconColor?: number;
|
||||||
|
/** Custom emoji ID for the topic icon. */
|
||||||
|
iconCustomEmojiId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateForumTopicResult = {
|
||||||
|
ok: boolean;
|
||||||
|
messageThreadId?: number;
|
||||||
|
name?: string;
|
||||||
|
iconColor?: number;
|
||||||
|
iconCustomEmojiId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditForumTopicOpts = {
|
||||||
|
token?: string;
|
||||||
|
accountId?: string;
|
||||||
|
verbose?: boolean;
|
||||||
|
api?: Bot["api"];
|
||||||
|
retry?: RetryConfig;
|
||||||
|
/** New name for the topic (1-128 characters). */
|
||||||
|
name?: string;
|
||||||
|
/** Custom emoji ID for the topic icon. */
|
||||||
|
iconCustomEmojiId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditForumTopicResult = {
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit an existing forum topic in a Telegram supergroup.
|
||||||
|
* @param chatIdInput - Chat ID of the supergroup
|
||||||
|
* @param messageThreadId - Thread ID of the topic to edit
|
||||||
|
* @param opts - Configuration including new name and/or icon
|
||||||
|
*/
|
||||||
|
export async function editForumTopicTelegram(
|
||||||
|
chatIdInput: string | number,
|
||||||
|
messageThreadId: number,
|
||||||
|
opts: EditForumTopicOpts = {},
|
||||||
|
): Promise<EditForumTopicResult> {
|
||||||
|
if (!opts.name && !opts.iconCustomEmojiId) {
|
||||||
|
throw new Error("At least one of name or iconCustomEmojiId is required to edit a forum topic");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const account = resolveTelegramAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const token = resolveToken(opts.token, account);
|
||||||
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
|
const client = resolveTelegramClientOptions(account);
|
||||||
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
|
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: account.config.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
|
withTelegramApiErrorLogging({
|
||||||
|
operation: label ?? "request",
|
||||||
|
fn: () => request(fn, label),
|
||||||
|
}).catch((err) => {
|
||||||
|
logHttpError(label ?? "request", err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (opts.name) {
|
||||||
|
params.name = opts.name;
|
||||||
|
}
|
||||||
|
if (opts.iconCustomEmojiId) {
|
||||||
|
params.icon_custom_emoji_id = opts.iconCustomEmojiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestWithDiag(
|
||||||
|
() => api.editForumTopic(chatId, messageThreadId, params),
|
||||||
|
"editForumTopic",
|
||||||
|
);
|
||||||
|
|
||||||
|
logVerbose(`[telegram] Edited forum topic ${messageThreadId} in chat ${chatId}`);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new forum topic in a Telegram supergroup with topics enabled.
|
||||||
|
* @param chatIdInput - Chat ID of the supergroup
|
||||||
|
* @param name - Name of the topic (1-128 characters)
|
||||||
|
* @param opts - Optional configuration
|
||||||
|
*/
|
||||||
|
export async function createForumTopicTelegram(
|
||||||
|
chatIdInput: string | number,
|
||||||
|
name: string,
|
||||||
|
opts: CreateForumTopicOpts = {},
|
||||||
|
): Promise<CreateForumTopicResult> {
|
||||||
|
if (!name?.trim()) {
|
||||||
|
throw new Error("Forum topic name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const account = resolveTelegramAccount({
|
||||||
|
cfg,
|
||||||
|
accountId: opts.accountId,
|
||||||
|
});
|
||||||
|
const token = resolveToken(opts.token, account);
|
||||||
|
const chatId = normalizeChatId(String(chatIdInput));
|
||||||
|
const client = resolveTelegramClientOptions(account);
|
||||||
|
const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
|
||||||
|
|
||||||
|
const request = createTelegramRetryRunner({
|
||||||
|
retry: opts.retry,
|
||||||
|
configRetry: account.config.retry,
|
||||||
|
verbose: opts.verbose,
|
||||||
|
});
|
||||||
|
const logHttpError = createTelegramHttpLogger(cfg);
|
||||||
|
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
|
||||||
|
withTelegramApiErrorLogging({
|
||||||
|
operation: label ?? "request",
|
||||||
|
fn: () => request(fn, label),
|
||||||
|
}).catch((err) => {
|
||||||
|
logHttpError(label ?? "request", err);
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (opts.iconColor != null) {
|
||||||
|
params.icon_color = opts.iconColor;
|
||||||
|
}
|
||||||
|
if (opts.iconCustomEmojiId) {
|
||||||
|
params.icon_custom_emoji_id = opts.iconCustomEmojiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await requestWithDiag(
|
||||||
|
() =>
|
||||||
|
Object.keys(params).length > 0
|
||||||
|
? api.createForumTopic(chatId, name, params)
|
||||||
|
: api.createForumTopic(chatId, name),
|
||||||
|
"createForumTopic",
|
||||||
|
);
|
||||||
|
|
||||||
|
logVerbose(
|
||||||
|
`[telegram] Created forum topic "${name}" in chat ${chatId}, thread_id=${res?.message_thread_id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
messageThreadId: res?.message_thread_id,
|
||||||
|
name: res?.name,
|
||||||
|
iconColor: res?.icon_color,
|
||||||
|
iconCustomEmojiId: res?.icon_custom_emoji_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user