Backport part of v4 Bot class
This commit is contained in:
parent
a8fe4c5159
commit
8d1919a36b
222
bot.py
222
bot.py
@ -20,12 +20,16 @@ from datetime import datetime
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from queue import Queue
|
from queue import Queue
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import re
|
import uuid
|
||||||
|
|
||||||
import consumer
|
__version__ = '3.4.dev0'
|
||||||
import event
|
__author__ = 'nemunaire'
|
||||||
|
|
||||||
|
from consumer import Consumer, EventConsumer, MessageConsumer
|
||||||
|
from event import ModuleEvent
|
||||||
import hooks
|
import hooks
|
||||||
from networkbot import NetworkBot
|
from networkbot import NetworkBot
|
||||||
from server.IRC import IRCServer
|
from server.IRC import IRCServer
|
||||||
@ -34,18 +38,29 @@ import response
|
|||||||
|
|
||||||
logger = logging.getLogger("nemubot.bot")
|
logger = logging.getLogger("nemubot.bot")
|
||||||
|
|
||||||
ID_letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
|
|
||||||
class Bot:
|
class Bot:
|
||||||
def __init__(self, ip, realname, mp=list()):
|
|
||||||
# Bot general informations
|
"""Class containing the bot context and ensuring key goals"""
|
||||||
self.version = 3.4
|
|
||||||
self.version_txt = "3.4-dev"
|
def __init__(self, ip="127.0.0.1", modules_paths=list(), data_path="./datas/"):
|
||||||
logger.info("Initiate nemubot v%s", self.version_txt)
|
"""Initialize the bot context
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
ip -- The external IP of the bot (default: 127.0.0.1)
|
||||||
|
modules_paths -- Paths to all directories where looking for module
|
||||||
|
data_path -- Path to directory where store bot context data
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info("Initiate nemubot v%s", __version__)
|
||||||
|
|
||||||
|
# External IP for accessing this bot
|
||||||
|
self.ip = ip
|
||||||
|
|
||||||
|
# Context paths
|
||||||
|
self.modules_paths = modules_paths
|
||||||
|
self.data_path = data_path
|
||||||
|
|
||||||
# Save various informations
|
# Save various informations
|
||||||
self.ip = ip
|
|
||||||
self.realname = realname
|
|
||||||
self.ctcp_capabilities = dict()
|
self.ctcp_capabilities = dict()
|
||||||
self.init_ctcp_capabilities()
|
self.init_ctcp_capabilities()
|
||||||
|
|
||||||
@ -54,10 +69,6 @@ class Bot:
|
|||||||
self.modules = dict()
|
self.modules = dict()
|
||||||
self.modules_configuration = dict()
|
self.modules_configuration = dict()
|
||||||
|
|
||||||
# Context paths
|
|
||||||
self.modules_path = mp
|
|
||||||
self.datas_path = './datas/'
|
|
||||||
|
|
||||||
# Events
|
# Events
|
||||||
self.events = list()
|
self.events = list()
|
||||||
self.event_timer = None
|
self.event_timer = None
|
||||||
@ -85,13 +96,13 @@ class Bot:
|
|||||||
self.ctcp_capabilities["CLIENTINFO"] = self._ctcp_clientinfo
|
self.ctcp_capabilities["CLIENTINFO"] = self._ctcp_clientinfo
|
||||||
self.ctcp_capabilities["DCC"] = self._ctcp_dcc
|
self.ctcp_capabilities["DCC"] = self._ctcp_dcc
|
||||||
self.ctcp_capabilities["NEMUBOT"] = lambda srv, msg: _ctcp_response(
|
self.ctcp_capabilities["NEMUBOT"] = lambda srv, msg: _ctcp_response(
|
||||||
msg.sender, "NEMUBOT %f" % self.version)
|
msg.sender, "NEMUBOT %s" % __version__)
|
||||||
self.ctcp_capabilities["TIME"] = lambda srv, msg: _ctcp_response(
|
self.ctcp_capabilities["TIME"] = lambda srv, msg: _ctcp_response(
|
||||||
msg.sender, "TIME %s" % (datetime.now()))
|
msg.sender, "TIME %s" % (datetime.now()))
|
||||||
self.ctcp_capabilities["USERINFO"] = lambda srv, msg: _ctcp_response(
|
self.ctcp_capabilities["USERINFO"] = lambda srv, msg: _ctcp_response(
|
||||||
msg.sender, "USERINFO %s" % self.realname)
|
msg.sender, "USERINFO %s" % srv.realname)
|
||||||
self.ctcp_capabilities["VERSION"] = lambda srv, msg: _ctcp_response(
|
self.ctcp_capabilities["VERSION"] = lambda srv, msg: _ctcp_response(
|
||||||
msg.sender, "VERSION nemubot v%s" % self.version_txt)
|
msg.sender, "VERSION nemubot v%s" % __version__)
|
||||||
logger.debug("CTCP capabilities setup: %s", ", ".join(self.ctcp_capabilities))
|
logger.debug("CTCP capabilities setup: %s", ", ".join(self.ctcp_capabilities))
|
||||||
|
|
||||||
def _ctcp_clientinfo(self, srv, msg):
|
def _ctcp_clientinfo(self, srv, msg):
|
||||||
@ -110,43 +121,85 @@ class Bot:
|
|||||||
logger.error("DCC: unable to connect to %s:%s", ip, msg.cmds[4])
|
logger.error("DCC: unable to connect to %s:%s", ip, msg.cmds[4])
|
||||||
|
|
||||||
|
|
||||||
|
# Events methods
|
||||||
|
|
||||||
def add_event(self, evt, eid=None, module_src=None):
|
def add_event(self, evt, eid=None, module_src=None):
|
||||||
"""Register an event and return its identifiant for futur update"""
|
"""Register an event and return its identifiant for futur update
|
||||||
|
|
||||||
|
Return:
|
||||||
|
None if the event is not in the queue (eg. if it has been executed during the call) or
|
||||||
|
returns the event ID.
|
||||||
|
|
||||||
|
Argument:
|
||||||
|
evt -- The event object to add
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
eid -- The desired event ID (object or string UUID)
|
||||||
|
module_src -- The module to which the event is attached to
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Generate the event id if no given
|
||||||
if eid is None:
|
if eid is None:
|
||||||
# Find an ID
|
eid = uuid.uuid1()
|
||||||
now = datetime.now()
|
|
||||||
evt.id = "%d%c%d%d%c%d%d%c%d" % (now.year, ID_letters[now.microsecond % 52],
|
# Fill the id field of the event
|
||||||
now.month, now.day, ID_letters[now.microsecond % 42],
|
if type(eid) is uuid.UUID:
|
||||||
now.hour, now.minute, ID_letters[now.microsecond % 32],
|
evt.id = str(eid)
|
||||||
now.second)
|
|
||||||
else:
|
else:
|
||||||
|
# Ok, this is quite useless...
|
||||||
|
try:
|
||||||
|
evt.id = str(uuid.UUID(eid))
|
||||||
|
except ValueError:
|
||||||
evt.id = eid
|
evt.id = eid
|
||||||
|
|
||||||
# Add the event in place
|
# Add the event in its place
|
||||||
t = evt.current
|
t = evt.current
|
||||||
i = -1
|
i = 0 # sentinel
|
||||||
for i in range(0, len(self.events)):
|
for i in range(0, len(self.events)):
|
||||||
if self.events[i].current > t:
|
if self.events[i].current > t:
|
||||||
i -= 1
|
|
||||||
break
|
break
|
||||||
self.events.insert(i + 1, evt)
|
self.events.insert(i, evt)
|
||||||
if i == -1:
|
|
||||||
self.update_timer()
|
if i == 0:
|
||||||
if len(self.events) <= 0 or self.events[i+1] != evt:
|
# First event changed, reset timer
|
||||||
|
self._update_event_timer()
|
||||||
|
if len(self.events) <= 0 or self.events[i] != evt:
|
||||||
|
# Our event has been executed and removed from queue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Register the event in the source module
|
||||||
if module_src is not None:
|
if module_src is not None:
|
||||||
module_src.REGISTERED_EVENTS.append(evt.id)
|
module_src.REGISTERED_EVENTS.append(evt.id)
|
||||||
|
evt.module_src = module_src
|
||||||
|
|
||||||
logger.info("New event registered: %s -> %s", evt.id, evt)
|
logger.info("New event registered: %s -> %s", evt.id, evt)
|
||||||
return evt.id
|
return evt.id
|
||||||
|
|
||||||
def del_event(self, id, module_src=None):
|
|
||||||
"""Find and remove an event from list"""
|
def del_event(self, evt, module_src=None):
|
||||||
logger.info("Removing event: %s from %s", id, module_src)
|
"""Find and remove an event from list
|
||||||
|
|
||||||
|
Return:
|
||||||
|
True if the event has been found and removed, False else
|
||||||
|
|
||||||
|
Argument:
|
||||||
|
evt -- The ModuleEvent object to remove or just the event identifier
|
||||||
|
|
||||||
|
Keyword arguments:
|
||||||
|
module_src -- The module to which the event is attached to (ignored if evt is a ModuleEvent)
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info("Removing event: %s from %s", evt, module_src)
|
||||||
|
|
||||||
|
if type(evt) is ModuleEvent:
|
||||||
|
id = evt.id
|
||||||
|
module_src = evt.module_src
|
||||||
|
else:
|
||||||
|
id = evt
|
||||||
|
|
||||||
if len(self.events) > 0 and id == self.events[0].id:
|
if len(self.events) > 0 and id == self.events[0].id:
|
||||||
self.events.remove(self.events[0])
|
self.events.remove(self.events[0])
|
||||||
self.update_timer()
|
self._update_event_timer()
|
||||||
if module_src is not None:
|
if module_src is not None:
|
||||||
module_src.REGISTERED_EVENTS.remove(evt.id)
|
module_src.REGISTERED_EVENTS.remove(evt.id)
|
||||||
return True
|
return True
|
||||||
@ -160,35 +213,52 @@ class Bot:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_timer(self):
|
|
||||||
"""Relaunch the timer to end with the closest event"""
|
def _update_event_timer(self):
|
||||||
|
"""(Re)launch the timer to end with the closest event"""
|
||||||
|
|
||||||
# Reset the timer if this is the first item
|
# Reset the timer if this is the first item
|
||||||
if self.event_timer is not None:
|
if self.event_timer is not None:
|
||||||
self.event_timer.cancel()
|
self.event_timer.cancel()
|
||||||
|
|
||||||
if len(self.events) > 0:
|
if len(self.events) > 0:
|
||||||
logger.debug("Update timer: next event in %d seconds",
|
logger.debug("Update timer: next event in %d seconds",
|
||||||
self.events[0].time_left.seconds)
|
self.events[0].time_left.seconds)
|
||||||
if datetime.now() + timedelta(seconds=5) >= self.events[0].current:
|
if datetime.now() + timedelta(seconds=5) >= self.events[0].current:
|
||||||
while datetime.now() < self.events[0].current:
|
while datetime.now() < self.events[0].current:
|
||||||
time.sleep(0.6)
|
time.sleep(0.6)
|
||||||
self.end_timer()
|
self._end_event_timer()
|
||||||
else:
|
else:
|
||||||
self.event_timer = threading.Timer(
|
self.event_timer = threading.Timer(
|
||||||
self.events[0].time_left.seconds + 1, self.end_timer)
|
self.events[0].time_left.seconds + 1, self._end_event_timer)
|
||||||
self.event_timer.start()
|
self.event_timer.start()
|
||||||
else:
|
else:
|
||||||
logger.debug("Update timer: no timer left")
|
logger.debug("Update timer: no timer left")
|
||||||
|
|
||||||
def end_timer(self):
|
|
||||||
"""Function called at the end of the timer"""
|
|
||||||
#print ("end timer")
|
|
||||||
while len(self.events) > 0 and datetime.now() >= self.events[0].current:
|
|
||||||
#print ("end timer: while")
|
|
||||||
evt = self.events.pop(0)
|
|
||||||
self.cnsr_queue.put_nowait(consumer.EventConsumer(evt))
|
|
||||||
self.update_consumers()
|
|
||||||
|
|
||||||
self.update_timer()
|
def _end_event_timer(self):
|
||||||
|
"""Function called at the end of the event timer"""
|
||||||
|
|
||||||
|
while len(self.events) > 0 and datetime.now() >= self.events[0].current:
|
||||||
|
evt = self.events.pop(0)
|
||||||
|
self.cnsr_queue.put_nowait(EventConsumer(evt))
|
||||||
|
self._launch_consumers()
|
||||||
|
|
||||||
|
self._update_event_timer()
|
||||||
|
|
||||||
|
|
||||||
|
# Consumers methods
|
||||||
|
|
||||||
|
def _launch_consumers(self):
|
||||||
|
"""Launch new consumer threads if necessary"""
|
||||||
|
|
||||||
|
while self.cnsr_queue.qsize() > self.cnsr_thrd_size:
|
||||||
|
# Next launch if two more items in queue
|
||||||
|
self.cnsr_thrd_size += 2
|
||||||
|
|
||||||
|
c = Consumer(self)
|
||||||
|
self.cnsr_thrd.append(c)
|
||||||
|
c.start()
|
||||||
|
|
||||||
|
|
||||||
def add_server(self, node, nick, owner, realname, ssl=False):
|
def add_server(self, node, nick, owner, realname, ssl=False):
|
||||||
@ -207,6 +277,21 @@ class Bot:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Modules methods
|
||||||
|
|
||||||
|
def add_modules_path(self, path):
|
||||||
|
"""Add a path to the modules_path array, used by module loader"""
|
||||||
|
# The path must end by / char
|
||||||
|
if path[len(path)-1] != "/":
|
||||||
|
path = path + "/"
|
||||||
|
|
||||||
|
if path not in self.modules_paths:
|
||||||
|
self.modules_paths.append(path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def add_module(self, module):
|
def add_module(self, module):
|
||||||
"""Add a module to the context, if already exists, unload the
|
"""Add a module to the context, if already exists, unload the
|
||||||
old one before"""
|
old one before"""
|
||||||
@ -220,19 +305,6 @@ class Bot:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def add_modules_path(self, path):
|
|
||||||
"""Add a path to the modules_path array, used by module loader"""
|
|
||||||
# The path must end by / char
|
|
||||||
if path[len(path)-1] != "/":
|
|
||||||
path = path + "/"
|
|
||||||
|
|
||||||
if path not in self.modules_path:
|
|
||||||
self.modules_path.append(path)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def unload_module(self, name):
|
def unload_module(self, name):
|
||||||
"""Unload a module"""
|
"""Unload a module"""
|
||||||
if name in self.modules:
|
if name in self.modules:
|
||||||
@ -252,22 +324,14 @@ class Bot:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def update_consumers(self):
|
|
||||||
"""Launch new consumer thread if necessary"""
|
|
||||||
if self.cnsr_queue.qsize() > self.cnsr_thrd_size:
|
|
||||||
c = consumer.Consumer(self)
|
|
||||||
self.cnsr_thrd.append(c)
|
|
||||||
c.start()
|
|
||||||
self.cnsr_thrd_size += 2
|
|
||||||
|
|
||||||
|
|
||||||
def receive_message(self, srv, raw_msg, private=False, data=None):
|
def receive_message(self, srv, raw_msg, private=False, data=None):
|
||||||
"""Queued the message for treatment"""
|
"""Queued the message for treatment"""
|
||||||
#print (raw_msg)
|
#print (raw_msg)
|
||||||
self.cnsr_queue.put_nowait(consumer.MessageConsumer(srv, raw_msg, datetime.now(), private, data))
|
self.cnsr_queue.put_nowait(MessageConsumer(srv, raw_msg, datetime.now(), private, data))
|
||||||
|
|
||||||
# Launch a new thread if necessary
|
# Launch a new thread if necessary
|
||||||
self.update_consumers()
|
self._launch_consumers()
|
||||||
|
|
||||||
|
|
||||||
def add_networkbot(self, srv, dest, dcc=None):
|
def add_networkbot(self, srv, dest, dcc=None):
|
||||||
@ -416,6 +480,8 @@ class Bot:
|
|||||||
return self.treat_ask(msg, srv)
|
return self.treat_ask(msg, srv)
|
||||||
|
|
||||||
def treat_prvmsg(self, msg, srv):
|
def treat_prvmsg(self, msg, srv):
|
||||||
|
msg.is_owner = msg.nick == srv.owner
|
||||||
|
|
||||||
# First, treat CTCP
|
# First, treat CTCP
|
||||||
if msg.ctcp:
|
if msg.ctcp:
|
||||||
if msg.cmds[0] in self.ctcp_capabilities:
|
if msg.cmds[0] in self.ctcp_capabilities:
|
||||||
@ -625,7 +691,15 @@ def _help_msg(sndr, modules, cmd):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
def hotswap(bak):
|
def hotswap(bak):
|
||||||
return Bot(bak.servers, bak.modules, bak.modules_path)
|
new = Bot(str(bak.ip), bak.modules_paths, bak.data_path)
|
||||||
|
new.ctcp_capabilities = bak.ctcp_capabilities
|
||||||
|
new.servers = bak.servers
|
||||||
|
new.modules = bak.modules
|
||||||
|
new.modules_configuration = bak.modules_configuration
|
||||||
|
new.events = bak.events
|
||||||
|
new.hooks = bak.hooks
|
||||||
|
new.network = bak.network
|
||||||
|
return new
|
||||||
|
|
||||||
def reload():
|
def reload():
|
||||||
import imp
|
import imp
|
||||||
|
12
importer.py
12
importer.py
@ -16,6 +16,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from distutils.version import LooseVersion
|
||||||
from importlib.abc import Finder
|
from importlib.abc import Finder
|
||||||
from importlib.abc import SourceLoader
|
from importlib.abc import SourceLoader
|
||||||
import imp
|
import imp
|
||||||
@ -23,6 +24,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from bot import __version__
|
||||||
import event
|
import event
|
||||||
import exception
|
import exception
|
||||||
import hooks
|
import hooks
|
||||||
@ -40,7 +42,7 @@ class ModuleFinder(Finder):
|
|||||||
#print ("looking for", fullname, "in", path)
|
#print ("looking for", fullname, "in", path)
|
||||||
# Search only for new nemubot modules (packages init)
|
# Search only for new nemubot modules (packages init)
|
||||||
if path is None:
|
if path is None:
|
||||||
for mpath in self.context.modules_path:
|
for mpath in self.context.modules_paths:
|
||||||
#print ("looking for", fullname, "in", mpath)
|
#print ("looking for", fullname, "in", mpath)
|
||||||
if (os.path.isfile(mpath + fullname + ".py") or
|
if (os.path.isfile(mpath + fullname + ".py") or
|
||||||
os.path.isfile(mpath + fullname + "/__init__.py")):
|
os.path.isfile(mpath + fullname + "/__init__.py")):
|
||||||
@ -134,11 +136,11 @@ class ModuleLoader(SourceLoader):
|
|||||||
if not hasattr(module, "nemubotversion"):
|
if not hasattr(module, "nemubotversion"):
|
||||||
raise ImportError("Module `%s' is not a nemubot module."%self.name)
|
raise ImportError("Module `%s' is not a nemubot module."%self.name)
|
||||||
# Check module version
|
# Check module version
|
||||||
if module.nemubotversion != self.context.version:
|
if LooseVersion(__version__) < LooseVersion(str(module.nemubotversion)):
|
||||||
raise ImportError("Module `%s' is not compatible with this "
|
raise ImportError("Module `%s' is not compatible with this "
|
||||||
"version." % self.name)
|
"version." % self.name)
|
||||||
|
|
||||||
# Set module common functions and datas
|
# Set module common functions and data
|
||||||
module.__LOADED__ = True
|
module.__LOADED__ = True
|
||||||
module.logger = logging.getLogger("nemubot.module." + fullname)
|
module.logger = logging.getLogger("nemubot.module." + fullname)
|
||||||
|
|
||||||
@ -151,7 +153,7 @@ class ModuleLoader(SourceLoader):
|
|||||||
module.logger.debug(*args)
|
module.logger.debug(*args)
|
||||||
|
|
||||||
def mod_save():
|
def mod_save():
|
||||||
fpath = self.context.datas_path + "/" + module.name + ".xml"
|
fpath = self.context.data_path + "/" + module.name + ".xml"
|
||||||
module.print_debug("Saving DATAS to " + fpath)
|
module.print_debug("Saving DATAS to " + fpath)
|
||||||
module.DATAS.save(fpath)
|
module.DATAS.save(fpath)
|
||||||
|
|
||||||
@ -189,7 +191,7 @@ class ModuleLoader(SourceLoader):
|
|||||||
module.del_event = del_event
|
module.del_event = del_event
|
||||||
|
|
||||||
if not hasattr(module, "NODATA"):
|
if not hasattr(module, "NODATA"):
|
||||||
module.DATAS = xmlparser.parse_file(self.context.datas_path
|
module.DATAS = xmlparser.parse_file(self.context.data_path
|
||||||
+ module.name + ".xml")
|
+ module.name + ".xml")
|
||||||
module.save = mod_save
|
module.save = mod_save
|
||||||
else:
|
else:
|
||||||
|
@ -45,7 +45,7 @@ if __name__ == "__main__":
|
|||||||
logger.addHandler(fh)
|
logger.addHandler(fh)
|
||||||
|
|
||||||
# Create bot context
|
# Create bot context
|
||||||
context = bot.Bot(0, "FIXME")
|
context = bot.Bot()
|
||||||
|
|
||||||
# Load the prompt
|
# Load the prompt
|
||||||
prmpt = prompt.Prompt()
|
prmpt = prompt.Prompt()
|
||||||
@ -66,7 +66,7 @@ if __name__ == "__main__":
|
|||||||
else:
|
else:
|
||||||
load_file(arg, context)
|
load_file(arg, context)
|
||||||
|
|
||||||
print ("Nemubot v%s ready, my PID is %i!" % (context.version_txt,
|
print ("Nemubot v%s ready, my PID is %i!" % (bot.__version__,
|
||||||
os.getpid()))
|
os.getpid()))
|
||||||
while prmpt.run(context):
|
while prmpt.run(context):
|
||||||
try:
|
try:
|
||||||
@ -79,7 +79,7 @@ if __name__ == "__main__":
|
|||||||
# Reload all other modules
|
# Reload all other modules
|
||||||
bot.reload()
|
bot.reload()
|
||||||
print("\033[1;32mContext reloaded\033[0m, now in Nemubot %s" %
|
print("\033[1;32mContext reloaded\033[0m, now in Nemubot %s" %
|
||||||
context.version_txt)
|
bot.__version__)
|
||||||
except:
|
except:
|
||||||
logger.exception("\033[1;31mUnable to reload the prompt due to errors.\033[0"
|
logger.exception("\033[1;31mUnable to reload the prompt due to errors.\033[0"
|
||||||
"m Fix them before trying to reload the prompt.")
|
"m Fix them before trying to reload the prompt.")
|
||||||
|
Loading…
Reference in New Issue
Block a user