Compare commits
	
		
			58 Commits
		
	
	
		
			3298c7a244
			...
			feat/cicd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 36d1306180 | |||
| 69bde313b5 | |||
| 7af3d87048 | |||
| 73741230b1 | |||
| 4f6d4f646a | |||
| 6169649261 | |||
| 0560c4620c | |||
| be3ee5e493 | |||
| 251e6ae3d6 | |||
| 2edd0312dc | |||
| e52e845851 | |||
| 61544feaba | |||
| 1966640239 | |||
| fa9998e92c | |||
| c1a449bafe | |||
| d5d82043f0 | |||
| 51ebf2e939 | |||
| f314b2f355 | |||
| a4d7c57d10 | |||
| 2802afa7d5 | |||
| 3a5ea5d4ff | |||
| 45d87275bf | |||
| 31e440434e | |||
| 3d70b56eb7 | |||
| 5b98c9bf2f | |||
| 9da8f47784 | |||
| e8c58d5ff8 | |||
| f2b5ee502f | |||
| 749e1c89ab | |||
| 0d5799796a | |||
| b7986d276b | |||
| 8540381834 | |||
| 7e67d1fed9 | |||
| 0cb19ba8f1 | |||
| 5dcf766593 | |||
| 808bdd033e | |||
| 33f031d333 | |||
| 40d9523e21 | |||
| 26e74a62c1 | |||
| c0f91aad79 | |||
| 79ffde5f34 | |||
| 911b9e4884 | |||
| 31a9e0eb28 | |||
| bcf788293e | |||
| 934b6dfead | |||
| cd0c8c0017 | |||
| 83f803d0e7 | |||
| 2cb652aee6 | |||
| 034d14eb15 | |||
| c8bfc47ddf | |||
| b67982ed38 | |||
| e3144fc402 | |||
| 1970f4b0cb | |||
| 8ac4f568a0 | |||
| 09f4efc96c | |||
| 6e0c3b8ef6 | |||
| 8ee36f7510 | |||
| 1593e126eb | 
| @ -22,6 +22,6 @@ jobs: | ||||
|       - name: Log in to the Container registry | ||||
|         run: docker login -u ${{ env.USER }} -p ${{ secrets.TOKEN }} ${{ env.REGISTRY }} | ||||
|       - name: Build Container | ||||
|         run: docker build -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ node -p "require('./package.json').version" }}". | ||||
|         run: docker build -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" . | ||||
|       - name: Push Container | ||||
|         run: docker push --all-tags "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | ||||
|  | ||||
							
								
								
									
										19
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|   "name": "node-jellyfin-discord-bot", | ||||
|   "version": "1.0.4", | ||||
|   "version": "1.1.3", | ||||
|   "lockfileVersion": 2, | ||||
|   "requires": true, | ||||
|   "packages": { | ||||
|     "": { | ||||
|       "name": "node-jellyfin-discord-bot", | ||||
|       "version": "1.0.4", | ||||
|       "version": "1.1.3", | ||||
|       "license": "MIT", | ||||
|       "dependencies": { | ||||
|         "@discordjs/rest": "^1.7.0", | ||||
| @ -17,6 +17,7 @@ | ||||
|         "@types/uuid": "^9.0.1", | ||||
|         "axios": "^1.3.5", | ||||
|         "date-fns": "^2.29.3", | ||||
|         "date-fns-tz": "^2.0.0", | ||||
|         "discord-api-types": "^0.37.38", | ||||
|         "discord.js": "^14.9.0", | ||||
|         "dotenv": "^16.0.3", | ||||
| @ -2626,6 +2627,14 @@ | ||||
|         "url": "https://opencollective.com/date-fns" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/date-fns-tz": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", | ||||
|       "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", | ||||
|       "peerDependencies": { | ||||
|         "date-fns": ">=2.0.0" | ||||
|       } | ||||
|     }, | ||||
|     "node_modules/debug": { | ||||
|       "version": "4.3.4", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
| @ -8905,6 +8914,12 @@ | ||||
|       "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", | ||||
|       "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" | ||||
|     }, | ||||
|     "date-fns-tz": { | ||||
|       "version": "2.0.0", | ||||
|       "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz", | ||||
|       "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==", | ||||
|       "requires": {} | ||||
|     }, | ||||
|     "debug": { | ||||
|       "version": "4.3.4", | ||||
|       "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "node-jellyfin-discord-bot", | ||||
|   "version": "1.0.4", | ||||
|   "version": "1.1.3", | ||||
|   "description": "A discord bot to sync jellyfin accounts with discord roles", | ||||
|   "main": "index.js", | ||||
|   "license": "MIT", | ||||
| @ -13,6 +13,7 @@ | ||||
|     "@types/uuid": "^9.0.1", | ||||
|     "axios": "^1.3.5", | ||||
|     "date-fns": "^2.29.3", | ||||
|     "date-fns-tz": "^2.0.0", | ||||
|     "discord-api-types": "^0.37.38", | ||||
|     "discord.js": "^14.9.0", | ||||
|     "dotenv": "^16.0.3", | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { Guild, GuildScheduledEvent, GuildScheduledEventEditOptions, GuildSchedu | ||||
| import { v4 as uuid } from 'uuid' | ||||
| import { client } from '../..' | ||||
| import { config } from '../configuration' | ||||
| import { Emotes } from '../events/guildScheduledEventCreate' | ||||
| import { Emotes } from '../events/autoCreateVoteByWPEvent' | ||||
| import { Maybe } from '../interfaces' | ||||
| import { logger } from '../logger' | ||||
| import { Command } from '../structures/command' | ||||
| @ -75,7 +75,7 @@ export async function closePoll(guild: Guild, requestId: string) { | ||||
| async function sendVoteClosedMessage(event: GuildScheduledEvent, movie: string, guildId: string, requestId: string) { | ||||
|     const date = event.scheduledStartAt ? format(event.scheduledStartAt, "dd.MM") : "Fehler, event hatte kein Datum" | ||||
|     const time = event.scheduledStartAt ? format(event.scheduledStartAt, "HH:mm") : "Fehler, event hatte kein Datum" | ||||
|     const body = `[Abstimmung beendet] <@&${config.bot.announcement_role}> Wir gucken  ${movie} am ${date} um ${time}` | ||||
|     const body = `[Abstimmung beendet] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Wir gucken  ${movie} am ${date} um ${time}` | ||||
|     const options: MessageCreateOptions = { | ||||
|         content: body, | ||||
|         allowedMentions: { parse: ["roles"] } | ||||
|  | ||||
							
								
								
									
										48
									
								
								server/events/announceManualWatchparty.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								server/events/announceManualWatchparty.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| import { GuildScheduledEvent, TextChannel } from "discord.js"; | ||||
| import { v4 as uuid } from "uuid"; | ||||
| import { client } from "../.."; | ||||
| import { config } from "../configuration"; | ||||
| import { createDateStringFromEvent } from "../helper/dateHelper"; | ||||
| import { Maybe } from "../interfaces"; | ||||
| import { logger } from "../logger"; | ||||
|  | ||||
|  | ||||
| export const name = 'guildScheduledEventCreate' | ||||
|  | ||||
| export async function execute(event: GuildScheduledEvent) { | ||||
|     const guildId = event.guildId | ||||
|     const requestId = uuid() | ||||
|     try { | ||||
|         if (!event.description) { | ||||
|             logger.debug("Got GuildScheduledEventCreate event. But has no description. Aborting.") | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         if (event.description.includes("!wp")) { | ||||
|             logger.info("Got manual create event of watchparty event!", { guildId, requestId }) | ||||
|             if(event.description.includes("!private")) { | ||||
|                 logger.info("Event description contains \"!private\". Won't announce.", { guildId, requestId }) | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const channel: Maybe<TextChannel> = client.getAnnouncementChannelForGuild(guildId) | ||||
|  | ||||
|             if (!channel) { | ||||
|                 logger.error("Could not obtain announcement channel. Aborting announcement.", { guildId, requestId }) | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const message = `[Watchparty] https://discord.com/events/${event.guildId}/${event.id} \nHey <@&${config.bot.announcement_role}>, wir gucken ${event.name} ${createDateStringFromEvent(event, guildId, requestId)}` | ||||
|  | ||||
|             channel.send(message) | ||||
|         } else { | ||||
|             logger.debug("Got GuildScheduledEventCreate event but no !wp in description. Not creating manual wp announcement.", { guildId, requestId }) | ||||
|         } | ||||
|  | ||||
|     } catch (error) { | ||||
|         // sendFailureDM(error) | ||||
|         logger.error(<string>error, { guildId, requestId }) | ||||
|     } | ||||
|  | ||||
|  | ||||
| } | ||||
| @ -1,12 +1,11 @@ | ||||
| import { format } from "date-fns"; | ||||
| import { GuildScheduledEvent, Message, MessageCreateOptions, TextChannel } from "discord.js"; | ||||
| import { ScheduledTask } from "node-cron"; | ||||
| import { v4 as uuid } from "uuid"; | ||||
| import { client, yavinJellyfinHandler } from "../.."; | ||||
| import { config } from "../configuration"; | ||||
| import { createDateStringFromEvent } from "../helper/dateHelper"; | ||||
| import { Maybe } from "../interfaces"; | ||||
| import { logger } from "../logger"; | ||||
| import { SemanticClassificationFormat } from "typescript"; | ||||
| 
 | ||||
| 
 | ||||
| export const name = 'guildScheduledEventCreate' | ||||
| @ -18,7 +17,6 @@ export let task: ScheduledTask | undefined | ||||
| 
 | ||||
| export async function execute(event: GuildScheduledEvent) { | ||||
|     const requestId = uuid() | ||||
|     logger.debug(`New event created: ${JSON.stringify(event, null, 2)}`, { guildId: event.guildId, requestId }) | ||||
|      | ||||
|     if (event.name.toLowerCase().includes("!nextwp")) { | ||||
|         logger.info("Event was a placeholder event to start a new watchparty and voting. Creating vote!", { guildId: event.guildId, requestId }) | ||||
| @ -40,9 +38,7 @@ export async function execute(event: GuildScheduledEvent) { | ||||
|             logger.info("EVENT DOES NOT HAVE STARTDATE; CANCELLING", {guildId: event.guildId, requestId}) | ||||
|             return | ||||
|         } | ||||
|         const date = format(event.scheduledStartAt, "dd.MM") | ||||
|         const time = format(event.scheduledStartAt, "HH:mm") | ||||
|         let message = `[Abstimmung]\n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty am ${date} um ${time}! Stimme hierunter für den nächsten Film ab!\n` | ||||
|         let message = `[Abstimmung] für https://discord.com/events/${event.guildId}/${event.id}\n<@&${config.bot.announcement_role}> Es gibt eine neue Abstimmung für die nächste Watchparty ${createDateStringFromEvent(event, event.guildId, requestId)}! Stimme hierunter für den nächsten Film ab!\n` | ||||
| 
 | ||||
|         for (let i = 0; i < movies.length; i++) { | ||||
|             message = message.concat(Emotes[i]).concat(": ").concat(movies[i]).concat("\n") | ||||
| @ -51,7 +47,7 @@ export async function execute(event: GuildScheduledEvent) { | ||||
| 
 | ||||
|         const options: MessageCreateOptions = { | ||||
|             allowedMentions: { parse: ["roles"]}, | ||||
|             content: message | ||||
|             content: message, | ||||
|         } | ||||
| 
 | ||||
|         const sentMessage: Message<true> = await (await announcementChannel.fetch()).send(options) | ||||
							
								
								
									
										52
									
								
								server/events/deleteAnnouncementsWhenWPEnds.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								server/events/deleteAnnouncementsWhenWPEnds.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,52 @@ | ||||
| import { Collection, GuildScheduledEvent, GuildScheduledEventStatus, Message } from "discord.js"; | ||||
| import { v4 as uuid } from "uuid"; | ||||
| import { client } from "../.."; | ||||
| import { logger } from "../logger"; | ||||
|  | ||||
|  | ||||
| export const name = 'guildScheduledEventUpdate' | ||||
|  | ||||
| export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildScheduledEvent) { | ||||
|     const requestId = uuid() | ||||
|     try { | ||||
|         if (!newEvent.guild) { | ||||
|             logger.error("Event has no guild, aborting.", { guildId: newEvent.guildId, requestId }) | ||||
|             return | ||||
|         } | ||||
|         const guildId = newEvent.guildId | ||||
|  | ||||
|         if (newEvent.description?.toLowerCase().includes("!wp") && newEvent.status === GuildScheduledEventStatus.Completed) { | ||||
|             logger.info("A watchparty ended. Cleaning up announcements!", { guildId, requestId }) | ||||
|             const announcementChannel = client.getAnnouncementChannelForGuild(newEvent.guild.id) | ||||
|             if (!announcementChannel) { | ||||
|                 logger.error("Could not find announcement channel. Aborting", { guildId: newEvent.guild.id, requestId }) | ||||
|                 return | ||||
|             } | ||||
|  | ||||
|             const events = await newEvent.guild.scheduledEvents.fetch() | ||||
|  | ||||
|             const wpAnnouncements = (await announcementChannel.messages.fetch()).filter(message => !message.cleanContent.includes("[initial]")) | ||||
|             const announcementsWithoutEvent = filterAnnouncementsByPendingWPs(wpAnnouncements, events) | ||||
|             logger.info(`Deleting ${announcementsWithoutEvent.length} announcements.`, {guildId, requestId}) | ||||
|             announcementsWithoutEvent.forEach(message => message.delete()) | ||||
|         } | ||||
|     } catch (error) { | ||||
|         logger.error(<string>error, { guildId: newEvent.guildId, requestId }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| function filterAnnouncementsByPendingWPs(messages: Collection<string, Message<true>>, events: Collection<string, GuildScheduledEvent<GuildScheduledEventStatus>>): Message<true>[] { | ||||
|     const filteredMessages: Message<true>[] = [] | ||||
|     for (const message of messages.values()) { | ||||
|         let foundEventForMessage = false | ||||
|         for (const event of events.values()) { | ||||
|             if (message.cleanContent.includes(event.id)) { //announcement always has eventid because of eventbox | ||||
|                 foundEventForMessage = true | ||||
|             } | ||||
|         } | ||||
|         if(!foundEventForMessage){ | ||||
|             filteredMessages.push(message) | ||||
|         } | ||||
|     } | ||||
|     return filteredMessages | ||||
| } | ||||
| @ -1,6 +1,6 @@ | ||||
| import { GuildMember, GuildScheduledEvent, GuildScheduledEventStatus } from "discord.js"; | ||||
| import { v4 as uuid } from "uuid"; | ||||
| import { client, jellyfinHandler } from "../.."; | ||||
| import { jellyfinHandler } from "../.."; | ||||
| import { getGuildSpecificTriggerRoleId } from "../helper/roleFilter"; | ||||
| import { logger } from "../logger"; | ||||
| 
 | ||||
| @ -10,7 +10,7 @@ export const name = 'guildScheduledEventUpdate' | ||||
| export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildScheduledEvent) { | ||||
|     try { | ||||
|         const requestId = uuid() | ||||
|         logger.debug(`Got scheduledEvent update. New Event: ${JSON.stringify(newEvent, null, 2)}`, { guildId: newEvent.guildId, requestId }) | ||||
|         // logger.debug(`Got scheduledEvent update. New Event: ${JSON.stringify(newEvent, null, 2)}`, { guildId: newEvent.guildId, requestId })
 | ||||
|         if (!newEvent.guild) { | ||||
|             logger.error("Event has no guild, aborting.", { guildId: newEvent.guildId, requestId }) | ||||
|             return | ||||
| @ -32,13 +32,7 @@ export async function execute(oldEvent: GuildScheduledEvent, newEvent: GuildSche | ||||
|             if (newEvent.status === GuildScheduledEventStatus.Active) | ||||
|                 createJFUsers(members, newEvent.name, requestId) | ||||
|             else { | ||||
|                 const announcementChannel = await client.getAnnouncementChannelForGuild(newEvent.guild.id) | ||||
|                 if(!announcementChannel) { | ||||
|                     logger.error("Could not find announcement channel. Aborting", { guildId: newEvent.guild.id, requestId }) | ||||
|                     return | ||||
|                 } | ||||
|                 const announcements = (await announcementChannel.messages.fetch()).filter(message => !message.pinned) | ||||
|                 announcements.forEach(message => message.delete()) | ||||
| 
 | ||||
|                 members.forEach(member => { | ||||
|                     member.createDM().then(channel => channel.send(`Die Watchparty ist vorbei, dein Account wurde wieder gelöscht. Wenn du einen permanenten Account haben möchtest, melde dich bei Samantha oder Marukus.`)) | ||||
|                 }) | ||||
							
								
								
									
										23
									
								
								server/helper/dateHelper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								server/helper/dateHelper.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| import { format, isToday, toDate } from "date-fns"; | ||||
| import {utcToZonedTime} from "date-fns-tz" | ||||
| import { GuildScheduledEvent } from "discord.js"; | ||||
| import { logger } from "../logger"; | ||||
| import de from "date-fns/locale/de"; | ||||
|  | ||||
| export function createDateStringFromEvent(event: GuildScheduledEvent, requestId: string, guildId?: string): string { | ||||
|     if(!event.scheduledStartAt) { | ||||
|         logger.error("Event has no start. Cannot create dateString.", {guildId, requestId}) | ||||
|         return `"habe keinen Startzeitpunkt ermitteln können"` | ||||
|     } | ||||
|  | ||||
|     const timeZone = 'Europe/Berlin' | ||||
|     const zonedDateTime = utcToZonedTime(event.scheduledStartAt, timeZone) | ||||
|     const time = format(zonedDateTime, "HH:mm", {locale: de}) | ||||
|      | ||||
|     if(isToday(zonedDateTime)) { | ||||
|         return `heute um ${time}` | ||||
|     } | ||||
|  | ||||
|     const date = format(zonedDateTime, "eeee dd.MM", {locale: de}) | ||||
|     return `am ${date} um ${time}` | ||||
| } | ||||
| @ -2,7 +2,7 @@ import { GuildMember } from "discord.js"; | ||||
| import { JellyfinConfig, Maybe, PermissionLevel } from "../interfaces"; | ||||
| import { logger } from "../logger"; | ||||
| import { CreateUserByNameOperationRequest, DeleteUserRequest, GetItemsRequest, ItemsApi, SystemApi, UpdateUserPasswordOperationRequest, UpdateUserPolicyOperationRequest, UserApi } from "./apis"; | ||||
| import { BaseItemDto, UpdateUserPasswordRequest } from "./models"; | ||||
| import { BaseItemDto, UpdateUserPasswordRequest, UpdateUserPolicyRequest } from "./models"; | ||||
| import { UserDto } from "./models/UserDto"; | ||||
| import { Configuration, ConfigurationParameters } from "./runtime"; | ||||
|  | ||||
| @ -52,24 +52,46 @@ export class JellyfinHandler { | ||||
|     return (Math.random() * 10000 + 10000).toFixed(0) | ||||
|   } | ||||
|  | ||||
|   public async createUserAccountForDiscordUser(discordUser: GuildMember, level: PermissionLevel, guildId?: string, requestId?: string): Promise<UserDto> { | ||||
|   public async createUserAccountForDiscordUser(discordUser: GuildMember, level: PermissionLevel, requestId: string, guildId?: string): Promise<UserDto> { | ||||
|     const newUserName = this.generateJFUserName(discordUser, level) | ||||
|     logger.info(`New Username for ${discordUser.displayName}: ${newUserName}`, { guildId, requestId }) | ||||
|     const req: CreateUserByNameOperationRequest = { | ||||
|       createUserByNameRequest: { | ||||
|         name: newUserName, | ||||
|         password: this.generatePasswordForUser(), | ||||
|         password: this.generatePasswordForUser() | ||||
|       } | ||||
|     } | ||||
|     logger.debug(JSON.stringify(req), { requestId, guildId }) | ||||
|     const createResult = await this.userApi.createUserByName(req) | ||||
|     if (createResult) { | ||||
|       if(createResult.policy) { | ||||
|         this.setUserPermissions(createResult, requestId, guildId) | ||||
|       } | ||||
|       (await discordUser.createDM()).send(`Ich hab dir mal nen Account angelegt :)\nDein Username ist ${createResult.name}, dein Password ist "${req.createUserByNameRequest.password}"!`) | ||||
|       return createResult | ||||
|     } | ||||
|     else throw new Error('Could not create User in Jellyfin') | ||||
|   } | ||||
|  | ||||
|   public async setUserPermissions(user: UserDto, requestId: string, guildId?: string) { | ||||
|     if(!user.policy || !user.id) { | ||||
|       logger.error(`Cannot update user policy. User ${user.name} has no policy to modify`, {guildId, requestId})  | ||||
|       return | ||||
|     } | ||||
|     user.policy.enableVideoPlaybackTranscoding = false | ||||
|  | ||||
|     const operation: UpdateUserPolicyRequest = { | ||||
|       ...user.policy, | ||||
|       enableVideoPlaybackTranscoding: false | ||||
|     } | ||||
|  | ||||
|     const request: UpdateUserPolicyOperationRequest = { | ||||
|       userId: user.id, | ||||
|       updateUserPolicyRequest: operation | ||||
|     } | ||||
|     this.userApi.updateUserPolicy(request) | ||||
|   } | ||||
|  | ||||
|   public async isUserAlreadyPresent(discordUser: GuildMember, requestId?: string): Promise<boolean> { | ||||
|     const jfuser = await this.getUser(discordUser, requestId) | ||||
|     logger.debug(`Presence for DiscordUser ${discordUser.id}:${jfuser !== undefined}`, { guildId: discordUser.guild.id, requestId }) | ||||
|  | ||||
		Reference in New Issue
	
	Block a user