Reworked comment & progress bar
This commit is contained in:
211
pointsbot/bot.py
211
pointsbot/bot.py
@@ -1,136 +1,169 @@
|
||||
import configparser
|
||||
import re
|
||||
|
||||
import praw
|
||||
|
||||
from . import comment, database
|
||||
|
||||
### Globals ###
|
||||
|
||||
CONFIGPATH = 'pointsbot.ini'
|
||||
from . import config, database, level, reply
|
||||
|
||||
### Main Function ###
|
||||
|
||||
|
||||
def run():
|
||||
config = configparser.ConfigParser()
|
||||
config.read(CONFIGPATH)
|
||||
|
||||
# Get the user flair levels in ascending order by point value
|
||||
# TODO Make levels a dict instead
|
||||
# TODO Could also make a Level class or namedtuple that contains more info, e.g.
|
||||
# flair css or template id
|
||||
|
||||
# levels = [(o, config.getint('Levels', o)) for o in config.options('Levels')]
|
||||
# levels.sort(key=lambda pair: pair[1])
|
||||
|
||||
levels = []
|
||||
for opt in config.options('Levels'):
|
||||
levels.append((opt.title(), config.getint('Levels', opt)))
|
||||
levels.sort(key=lambda pair: pair[1])
|
||||
cfg = config.load()
|
||||
levels = cfg.levels
|
||||
|
||||
# Connect to Reddit
|
||||
reddit = praw.Reddit(site_name=config['Core']['praw_site_name'])
|
||||
subreddit = reddit.subreddit(config['Core']['subreddit_name'])
|
||||
reddit = praw.Reddit(site_name=cfg.praw_site_name)
|
||||
subreddit = reddit.subreddit(cfg.subreddit_name)
|
||||
|
||||
print(f'Connected to Reddit as {reddit.user.me()}')
|
||||
print(f'Read-only? {reddit.read_only}')
|
||||
print(f'Watching subreddit {subreddit.title}')
|
||||
print(f'Is mod? {bool(subreddit.moderator(redditor=reddit.user.me()))}')
|
||||
print_level(0, f'Connected to Reddit as {reddit.user.me()}')
|
||||
print_level(1, f'Read-only? {reddit.read_only}')
|
||||
print_level(0, f'Watching subreddit {subreddit.title}')
|
||||
is_mod = bool(subreddit.moderator(redditor=reddit.user.me()))
|
||||
print_level(1, f'Is mod? {is_mod}')
|
||||
|
||||
# TODO pass database path instead of setting global variable
|
||||
db = database.Database(config['Core']['database_name'])
|
||||
testpoints = [1, 3, 5, 10, 15, 30, 45, 75] + list(range(100, 551, 50))
|
||||
|
||||
# The pattern to look for in comments when determining whether to award a point
|
||||
# solved_pat = re.compile('!solved', re.IGNORECASE)
|
||||
for sub in subreddit.new():
|
||||
if sub.title == 'Testing comment scenarios':
|
||||
redditor = sub.author
|
||||
for points in testpoints:
|
||||
body = f'Solver: {redditor}\n\nTotal points after solving: {points}'
|
||||
print_level(0, body)
|
||||
comm = sub.reply(body)
|
||||
if comm:
|
||||
level_info = level.user_level_info(points, levels)
|
||||
body = reply.make(redditor, points, level_info)
|
||||
comm.reply(body)
|
||||
else:
|
||||
print_level(1, 'ERROR: Unable to comment')
|
||||
break
|
||||
|
||||
|
||||
def real_run():
|
||||
cfg = config.load()
|
||||
levels = cfg.levels
|
||||
|
||||
# Connect to Reddit
|
||||
reddit = praw.Reddit(site_name=cfg.praw_site_name)
|
||||
subreddit = reddit.subreddit(cfg.subreddit_name)
|
||||
|
||||
print_level(0, f'Connected to Reddit as {reddit.user.me()}')
|
||||
print_level(1, f'Read-only? {reddit.read_only}')
|
||||
print_level(0, f'Watching subreddit {subreddit.title}')
|
||||
is_mod = bool(subreddit.moderator(redditor=reddit.user.me()))
|
||||
print_level(1, f'Is mod? {is_mod}')
|
||||
|
||||
db = database.Database(cfg.database_path)
|
||||
|
||||
# The pattern that determines whether a post is marked as solved
|
||||
# Could also just use re.IGNORECASE flag
|
||||
solved_pat = re.compile('![Ss]olved')
|
||||
|
||||
# Monitor new comments for confirmed solutions
|
||||
for comm in subreddit.stream.comments(skip_existing=True):
|
||||
print('Found comment')
|
||||
print(f'Comment text: "{comm.body}"')
|
||||
# Passing pause_after=0 will bypass the internal exponential delay, but have
|
||||
# to check if any comments are returned with each query
|
||||
for comm in subreddit.stream.comments(skip_existing=True, pause_after=0):
|
||||
if comm is None:
|
||||
continue
|
||||
|
||||
print_level(0, '\nFound comment')
|
||||
print_level(1, f'Comment text: "{comm.body}"')
|
||||
|
||||
if marks_as_solved(comm, solved_pat):
|
||||
# Ensure that this is the first "!solved" comment in the thread
|
||||
submission = comm.submission
|
||||
# Retrieve any comments hidden by "more comments"
|
||||
submission.comments.replace_more(limit=0)
|
||||
|
||||
# Search the flattened comments tree
|
||||
is_first_solution = True
|
||||
for subcomm in submission.comments.list():
|
||||
if (subcomm.id != comm.id
|
||||
and marks_as_solved(subcomm, solved_pat)
|
||||
and subcomm.created_utc < comm.created_utc):
|
||||
# There is an earlier comment for the same submission
|
||||
# already marked as a solution by the OP
|
||||
is_first_solution = False
|
||||
break
|
||||
|
||||
if not is_first_solution:
|
||||
if not is_first_solution(comm, solved_pat):
|
||||
# Skip this "!solved" comment and wait for the next
|
||||
print('This is not the first solution')
|
||||
print_level(1, 'Not the first solution')
|
||||
continue
|
||||
|
||||
print('This is the first solution')
|
||||
print_level(1, 'This is the first solution')
|
||||
print_solution_info(comm)
|
||||
|
||||
solution = comm.parent()
|
||||
solver = solution.author
|
||||
print(f'Adding point for {solver.name}')
|
||||
solver = comm.parent().author
|
||||
print_level(1, f'Adding point for {solver.name}')
|
||||
db.add_point(solver)
|
||||
points = db.get_points(solver)
|
||||
print(f'Points for {solver.name}: {points}')
|
||||
print_level(1, f'Points for {solver.name}: {points}')
|
||||
|
||||
# Reply to the comment containing the solution
|
||||
reply_body = comment.make(solver, points, levels)
|
||||
print(f'Replying with: "{reply_body}"')
|
||||
solution.reply(reply_body)
|
||||
level_info = level.user_level_info(points, levels)
|
||||
|
||||
# TODO move comment to the end and use some things, e.g. whether
|
||||
# flair is set, when building comment (to avoid duplicate logic)
|
||||
|
||||
# Reply to the comment marking the submission as solved
|
||||
reply_body = reply.make(solver, points, level_info)
|
||||
# reply_body = reply.make(solver, points, levels)
|
||||
print_level(1, f'Replying with: "{reply_body}"')
|
||||
comm.reply(reply_body)
|
||||
|
||||
# Check if (non-mod) user flair should be updated to new level
|
||||
for levelname, levelpoints in levels:
|
||||
# If the redditor's points total is equal to one of the levels,
|
||||
# that means they just reached that level
|
||||
if points == levelpoints:
|
||||
print('User reached new level')
|
||||
if not subreddit.moderator(redditor=solver):
|
||||
# TODO can also use the keyword arg css_class *or*
|
||||
# flair_template_id to style the flair
|
||||
print(f'Setting flair text to {levelname}')
|
||||
subreddit.flair.set(solver, text=levelname)
|
||||
else:
|
||||
print('Don\'t update flair b/c user is mod')
|
||||
|
||||
# Don't need to check the rest of the levels
|
||||
break
|
||||
if level_info.current and level_info.current.points == points:
|
||||
print_level(1, f'User reached level: {level_info.current.name}')
|
||||
if not subreddit.moderator(redditor=solver):
|
||||
print_level(2, 'Setting flair')
|
||||
print_level(3, f'Flair text: {level_info.current.name}')
|
||||
# TODO
|
||||
# print_level(3, f'Flair template ID: {}')
|
||||
subreddit.flair.set(solver, text=levelname)
|
||||
else:
|
||||
print_level(2, 'Solver is mod; don\'t alter flair')
|
||||
else:
|
||||
print('Not a "!solved" comment')
|
||||
print_level(1, 'Not a "!solved" comment')
|
||||
|
||||
|
||||
### Auxiliary Functions ###
|
||||
### Reddit Comment Functions ###
|
||||
|
||||
|
||||
def marks_as_solved(comment, solved_pattern):
|
||||
'''Return True if not top-level comment, from OP, contains "!Solved"; False
|
||||
otherwise.
|
||||
'''Return True if the comment meets the criteria for marking the submission
|
||||
as solved, False otherwise.
|
||||
'''
|
||||
#comment.refresh() # probably not needed, but the docs are a tad unclear
|
||||
return (not comment.is_root
|
||||
and comment.is_submitter
|
||||
and not comment.parent().is_submitter
|
||||
and solved_pattern.search(comment.body))
|
||||
|
||||
|
||||
### Debugging ###
|
||||
def is_first_solution(solved_comment, solved_pattern):
|
||||
# Retrieve any comments hidden by "more comments"
|
||||
# Passing limit=0 will replace all "more comments"
|
||||
submission = solved_comment.submission
|
||||
submission.comments.replace_more(limit=0)
|
||||
|
||||
# Search the flattened comments tree
|
||||
for comment in submission.comments.list():
|
||||
if (comment.id != solved_comment.id
|
||||
and marks_as_solved(comment, solved_pattern)
|
||||
and comment.created_utc < solved_comment.created_utc):
|
||||
# There is an earlier comment for the same submission
|
||||
# already marked as a solution by the OP
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def find_solver(comment):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
def make_flair(points, levels):
|
||||
# TODO
|
||||
pass
|
||||
|
||||
|
||||
### Debugging & Logging ###
|
||||
|
||||
|
||||
def print_level(num_levels, string):
|
||||
print('\t' * num_levels + string)
|
||||
|
||||
|
||||
def print_solution_info(comm):
|
||||
print('Submission solved')
|
||||
print('\tSolution comment:')
|
||||
print(f'\t\tAuthor: {comm.parent().author.name}')
|
||||
print(f'\t\tBody: {comm.parent().body}')
|
||||
print('\t"Solved" comment:')
|
||||
print(f'\t\tAuthor: {comm.author.name}')
|
||||
print(f'\t\tBody: {comm.body}')
|
||||
print_level(1, 'Submission solved')
|
||||
print_level(2, 'Solution comment:')
|
||||
print_level(3, f'Author: {comm.parent().author.name}')
|
||||
print_level(3, f'Body: {comm.parent().body}')
|
||||
print_level(2, '"Solved" comment:')
|
||||
print_level(3, f'Author: {comm.author.name}')
|
||||
print_level(3, f'Body: {comm.body}')
|
||||
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
### Globals ###
|
||||
|
||||
FILLED_SYMBOL = '\u25AE' # A small filled box character
|
||||
EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character
|
||||
DIV_SYMBOL = '|'
|
||||
|
||||
### Main Functions ###
|
||||
|
||||
|
||||
def make(redditor, points, levels):
|
||||
body = (first_point(redditor) + '\n\n') if points == 1 else ''
|
||||
body += points_status(redditor, points, levels)
|
||||
return body
|
||||
|
||||
|
||||
### Auxiliary Functions ###
|
||||
|
||||
|
||||
def first_point(redditor):
|
||||
msg = (f'Congrats, u/{redditor.name}; you have received a point! Points '
|
||||
'help you "level up" to the next user flair!')
|
||||
return msg
|
||||
|
||||
|
||||
def points_status(redditor, points, levels):
|
||||
'''Levels is an iterable of (level_name, points) pairs, sorted in ascending
|
||||
order by points.
|
||||
'''
|
||||
for next_level_name, next_level_points in levels:
|
||||
if next_level_points > points:
|
||||
break
|
||||
|
||||
pointstext = 'points' if points > 1 else 'point'
|
||||
|
||||
if points < next_level_points:
|
||||
lines = [
|
||||
f'Next level: "{next_level_name}"',
|
||||
f'You have {points} {pointstext}',
|
||||
f'You need {next_level_points} points',
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
'MAXIMUM LEVEL ACHIEVED!!!',
|
||||
f'You have {points} {pointstext}',
|
||||
]
|
||||
|
||||
# 2 spaces are appended to each line to force a line break but not a
|
||||
# paragraph break
|
||||
# lines = [line + ' ' for line in lines]
|
||||
lines = list(map(lambda line: line + ' ', lines))
|
||||
|
||||
"""
|
||||
if points < next_level_points:
|
||||
lines = [
|
||||
f'Next level: "{next_level_name}" ',
|
||||
f'You need {next_level_points} points ',
|
||||
]
|
||||
else:
|
||||
lines = ['MAXIMUM LEVEL ACHIEVED!!! ']
|
||||
|
||||
# TODO hacky and bad :(
|
||||
lines.insert(1, f'You have {points} points ')
|
||||
"""
|
||||
|
||||
lines.append(progress_bar(points, levels))
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def progress_bar(points, levels):
|
||||
'''Assumes levels is sorted in ascending order.'''
|
||||
progbar = [FILLED_SYMBOL] * points
|
||||
ndx_shift = 0
|
||||
for levelndx, level in enumerate(levels):
|
||||
next_level_name, next_level_points = level
|
||||
if next_level_points > points:
|
||||
break
|
||||
ndx = next_level_points + ndx_shift
|
||||
progbar.insert(ndx, DIV_SYMBOL)
|
||||
ndx_shift += 1
|
||||
|
||||
if next_level_points <= points:
|
||||
# If just reached max level, then an extra DIV_SYMBOL was appended
|
||||
progbar.pop()
|
||||
else:
|
||||
# Not max level, so fill slots left until next level with EMPTY_SYMBOL
|
||||
remaining = next_level_points - points
|
||||
progbar.extend([EMPTY_SYMBOL] * remaining)
|
||||
|
||||
return '[' + ''.join(progbar) + ']'
|
||||
|
||||
|
||||
55
pointsbot/config.py
Normal file
55
pointsbot/config.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import configparser
|
||||
import os.path
|
||||
# from os.path import abspath, dirname, join
|
||||
|
||||
from .level import Level
|
||||
|
||||
### Globals ###
|
||||
|
||||
ROOTPATH = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
||||
CONFIGPATH = os.path.join(ROOTPATH, 'pointsbot.ini')
|
||||
# CONFIGPATH = abspath(join(dirname(__file__), '..', 'pointsbot.ini'))
|
||||
|
||||
# TODO add default config values to pass to configparser
|
||||
|
||||
### Classes ###
|
||||
|
||||
|
||||
class Config:
|
||||
|
||||
def __init__(self, praw_site_name='', subreddit_name='', database_path='',
|
||||
levels=None):
|
||||
self.praw_site_name = praw_site_name
|
||||
self.subreddit_name = subreddit_name
|
||||
self.database_path = database_path
|
||||
self.levels = levels if levels is not None else []
|
||||
|
||||
@classmethod
|
||||
def from_configparser(cls, config):
|
||||
database_path = os.path.join(ROOTPATH, config['Core']['database_name'])
|
||||
|
||||
# Get the user flair levels in ascending order by point value
|
||||
# TODO Make levels a dict instead
|
||||
levels = []
|
||||
for opt in config.options('Levels'):
|
||||
name, points = opt.title(), config.getint('Levels', opt)
|
||||
levels.append(Level(name, points))
|
||||
levels.sort(key=lambda lvl: lvl.points)
|
||||
|
||||
return cls(
|
||||
praw_site_name=config['Core']['praw_site_name'],
|
||||
subreddit_name=config['Core']['subreddit_name'],
|
||||
database_path=database_path,
|
||||
levels=levels,
|
||||
)
|
||||
|
||||
|
||||
### Functions ###
|
||||
|
||||
|
||||
def load():
|
||||
config = configparser.ConfigParser()
|
||||
config.read(CONFIGPATH)
|
||||
return Config.from_configparser(config)
|
||||
|
||||
|
||||
66
pointsbot/level.py
Normal file
66
pointsbot/level.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from collections import namedtuple
|
||||
|
||||
### Data Structures ###
|
||||
|
||||
# A (string, int) tuple
|
||||
Level = namedtuple('Level', 'name points')
|
||||
|
||||
# A ([Level], Level, Level) tuple;
|
||||
# previous can be empty, and exactly one of current and next can be None
|
||||
LevelInfo = namedtuple('LevelInfo', 'previous current next')
|
||||
# LevelInfo = namedtuple('LevelInfo', 'prev cur next')
|
||||
|
||||
### Functions ###
|
||||
|
||||
|
||||
"""
|
||||
def get_levels(config):
|
||||
levels = []
|
||||
for opt in config.options('Levels'):
|
||||
name, points = opt.title(), config.getint('Levels', opt)
|
||||
levels.append(Level(name, points))
|
||||
# levels.append((opt.title(), config.getint('Levels', opt)))
|
||||
# levels.sort(key=lambda pair: pair[1])
|
||||
levels.sort(key=lambda lvl: lvl.points)
|
||||
return levels
|
||||
"""
|
||||
|
||||
|
||||
def user_level_info(points, levels):
|
||||
'''Return a tuple the user's previous (plural), current, and next levels.
|
||||
|
||||
If the user has yet to reach the first level, return ([], None, <first
|
||||
level>).
|
||||
If the user has reached the max level, return ([previous], <max level>,
|
||||
None).
|
||||
|
||||
Assume levels is sorted in ascending order by points.
|
||||
'''
|
||||
past_levels, cur_level, next_level = [], None, None
|
||||
|
||||
for level in levels:
|
||||
lvlname, lvlpoints = level
|
||||
if points < lvlpoints:
|
||||
next_level = level
|
||||
break
|
||||
if cur_level:
|
||||
past_levels.append(cur_level)
|
||||
cur_level = level
|
||||
else:
|
||||
next_level = None
|
||||
|
||||
return LevelInfo(past_levels, cur_level, next_level)
|
||||
|
||||
|
||||
def is_max_level(level_info):
|
||||
return not level_info.next
|
||||
|
||||
|
||||
"""
|
||||
def is_max_level(points, levels):
|
||||
'''Assume levels is sorted in ascending order by points.'''
|
||||
# return points >= levels[-1][1]
|
||||
return points >= levels[-1].points
|
||||
"""
|
||||
|
||||
|
||||
124
pointsbot/reply.py
Normal file
124
pointsbot/reply.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from . import level
|
||||
|
||||
### Globals ###
|
||||
|
||||
EMPTY_SYMBOL = '\u25AF' # A same-sized empty box character
|
||||
FILLED_SYMBOL = '\u25AE' # A small filled box character
|
||||
EXCESS_SYMBOL = '\u2B51' # A star character
|
||||
DIV_SYMBOL = '|'
|
||||
|
||||
# Number of "excess" points should be greater than max level points
|
||||
EXCESS_POINTS = 100 # TODO move this to level?
|
||||
|
||||
### Main Functions ###
|
||||
|
||||
|
||||
def make(redditor, points, level_info):
|
||||
paras = [header()]
|
||||
if points == 1:
|
||||
paras.append(first_greeting(redditor))
|
||||
if level_info.current and points == level_info.current.points:
|
||||
paras.append(level_up(redditor,
|
||||
level_info.current.name,
|
||||
tag_user=False))
|
||||
elif points > 1:
|
||||
if level_info.current and points == level_info.current.points:
|
||||
paras.append(level_up(redditor, level_info.current.name))
|
||||
elif not level_info.next and points > 0 and points % EXCESS_POINTS == 0:
|
||||
first_star = (points == EXCESS_POINTS)
|
||||
paras.append(new_star(redditor, first_star))
|
||||
else:
|
||||
paras.append(normal_greeting(redditor))
|
||||
|
||||
paras.append(points_status(redditor, points, level_info))
|
||||
paras.append(footer())
|
||||
return '\n\n'.join(paras)
|
||||
|
||||
|
||||
### Comment Section Functions ###
|
||||
|
||||
|
||||
def header():
|
||||
return 'Thanks! Post marked as Solved!'
|
||||
|
||||
|
||||
def first_greeting(redditor):
|
||||
msg = (f'Congrats, u/{redditor.name}, you have received a point! Points '
|
||||
'help you "level up" to the next user flair!')
|
||||
return msg
|
||||
|
||||
|
||||
def normal_greeting(redditor):
|
||||
return f'u/{redditor.name}, here is your points status:'
|
||||
|
||||
|
||||
def level_up(redditor, level_name, tag_user=True):
|
||||
start = f'Congrats u/{redditor.name}, y' if tag_user else 'Y'
|
||||
return (f'{start}ou have leveled up to "{level_name}"! Your flair has been '
|
||||
'updated accordingly.')
|
||||
# return (f'Congrats u/{redditor.name}, y
|
||||
# return (f'Congrats u/{redditor.name}, you have leveled up to "{level_name}"! '
|
||||
# 'Your flair has been updated accordingly.')
|
||||
|
||||
|
||||
def new_star(redditor, first_star):
|
||||
num_stars_msg = '' if first_star else 'another '
|
||||
return (f'Congrats u/{redditor.name} on getting '
|
||||
'{num_stars_msg}{EXCESS_POINTS} points! They are shown as a star '
|
||||
'in your progress bar.')
|
||||
|
||||
|
||||
def points_status(redditor, points, level_info):
|
||||
pointstext = 'points' if points > 1 else 'point'
|
||||
|
||||
if level_info.next:
|
||||
lines = [
|
||||
f'Next level: "{level_info.next.name}"',
|
||||
f'You have {points} {pointstext}',
|
||||
f'You need {level_info.next.points} points',
|
||||
]
|
||||
else:
|
||||
lines = [
|
||||
'MAXIMUM LEVEL ACHIEVED!!!',
|
||||
f'You have {points} {pointstext}',
|
||||
]
|
||||
|
||||
# 2 spaces are appended to each line to force a Markdown line break
|
||||
lines = [line + ' ' for line in lines]
|
||||
lines.append(progress_bar(points, level_info))
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def progress_bar(points, level_info):
|
||||
if points < EXCESS_POINTS:
|
||||
past, cur, nxt = level_info
|
||||
allpoints = [lvl.points for lvl in [*past, cur]]
|
||||
diffs = [a - b for a, b in zip(allpoints, [0] + allpoints)]
|
||||
bar = [FILLED_SYMBOL * diff for diff in diffs]
|
||||
|
||||
if nxt:
|
||||
have = points if not cur else points - cur.points
|
||||
need = nxt.points - points
|
||||
bar.append((FILLED_SYMBOL * have) + (EMPTY_SYMBOL * need))
|
||||
|
||||
bar = DIV_SYMBOL.join(bar)
|
||||
else:
|
||||
num_excess, num_leftover = divmod(points, EXCESS_POINTS)
|
||||
bar = [DIV_SYMBOL.join(EXCESS_SYMBOL * num_excess)]
|
||||
if num_leftover > 0:
|
||||
bar.append(DIV_SYMBOL)
|
||||
bar.append(FILLED_SYMBOL * num_leftover)
|
||||
bar = ''.join(bar)
|
||||
|
||||
return f'[{bar}]'
|
||||
|
||||
|
||||
def footer():
|
||||
return ('^(This bot is written and maintained by GlipGlorp7 '
|
||||
'| Learn more and view the source code on '
|
||||
'[Github](https://github.com/cur33/PointsBot))')
|
||||
# ^(This bot is written and maintained by u/GlipGlorp7 | Learn more and view the source code on [Github](https://github.com/cur33/PointsBot))
|
||||
# ^([Learn more]() | [View source code](https://github.com/cur33/PointsBot) | [Contacts mods]())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user