mirror of
https://github.com/caronc/apprise.git
synced 2024-11-25 01:24:03 +01:00
Apprise HTML/MARKDOWN/TEXT Translation Handling Refactoring (#575)
This commit is contained in:
parent
344b2153e0
commit
4a87d45879
@ -43,6 +43,7 @@ from .AppriseLocale import AppriseLocale
|
|||||||
from .config.ConfigBase import ConfigBase
|
from .config.ConfigBase import ConfigBase
|
||||||
from .plugins.NotifyBase import NotifyBase
|
from .plugins.NotifyBase import NotifyBase
|
||||||
|
|
||||||
|
|
||||||
from . import plugins
|
from . import plugins
|
||||||
from . import __version__
|
from . import __version__
|
||||||
|
|
||||||
@ -542,11 +543,23 @@ class Apprise(object):
|
|||||||
# determined we need to notify the service it's associated with
|
# determined we need to notify the service it's associated with
|
||||||
if server.notify_format not in conversion_body_map:
|
if server.notify_format not in conversion_body_map:
|
||||||
# Perform Conversion
|
# Perform Conversion
|
||||||
(conversion_title_map[server.notify_format],
|
conversion_body_map[server.notify_format] = \
|
||||||
conversion_body_map[server.notify_format]) = \
|
|
||||||
convert_between(
|
convert_between(
|
||||||
body_format, server.notify_format, body=body,
|
body_format, server.notify_format, content=body)
|
||||||
title=title, title_format=server.title_format)
|
|
||||||
|
# Prepare our title
|
||||||
|
conversion_title_map[server.notify_format] = \
|
||||||
|
'' if not title else title
|
||||||
|
|
||||||
|
# Tidy Title IF required (hence it will become part of the
|
||||||
|
# body)
|
||||||
|
if server.title_maxlen <= 0 and \
|
||||||
|
conversion_title_map[server.notify_format]:
|
||||||
|
|
||||||
|
conversion_title_map[server.notify_format] = \
|
||||||
|
convert_between(
|
||||||
|
body_format, server.notify_format,
|
||||||
|
content=conversion_title_map[server.notify_format])
|
||||||
|
|
||||||
if interpret_escapes:
|
if interpret_escapes:
|
||||||
#
|
#
|
||||||
@ -587,14 +600,6 @@ class Apprise(object):
|
|||||||
if six.PY2:
|
if six.PY2:
|
||||||
# Python 2.7 strings must be encoded as utf-8 for
|
# Python 2.7 strings must be encoded as utf-8 for
|
||||||
# consistency across all platforms
|
# consistency across all platforms
|
||||||
if conversion_title_map[server.notify_format] and \
|
|
||||||
isinstance(
|
|
||||||
conversion_title_map[server.notify_format],
|
|
||||||
unicode): # noqa: F821
|
|
||||||
conversion_title_map[server.notify_format] = \
|
|
||||||
conversion_title_map[server.notify_format]\
|
|
||||||
.encode('utf-8')
|
|
||||||
|
|
||||||
if conversion_body_map[server.notify_format] and \
|
if conversion_body_map[server.notify_format] and \
|
||||||
isinstance(
|
isinstance(
|
||||||
conversion_body_map[server.notify_format],
|
conversion_body_map[server.notify_format],
|
||||||
@ -603,12 +608,21 @@ class Apprise(object):
|
|||||||
conversion_body_map[server.notify_format]\
|
conversion_body_map[server.notify_format]\
|
||||||
.encode('utf-8')
|
.encode('utf-8')
|
||||||
|
|
||||||
|
if conversion_title_map[server.notify_format] and \
|
||||||
|
isinstance(
|
||||||
|
conversion_title_map[server.notify_format],
|
||||||
|
unicode): # noqa: F821
|
||||||
|
conversion_title_map[server.notify_format] = \
|
||||||
|
conversion_title_map[server.notify_format]\
|
||||||
|
.encode('utf-8')
|
||||||
|
|
||||||
yield handler(
|
yield handler(
|
||||||
server,
|
server,
|
||||||
body=conversion_body_map[server.notify_format],
|
body=conversion_body_map[server.notify_format],
|
||||||
title=conversion_title_map[server.notify_format],
|
title=conversion_title_map[server.notify_format],
|
||||||
notify_type=notify_type,
|
notify_type=notify_type,
|
||||||
attach=attach
|
attach=attach,
|
||||||
|
body_format=body_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
def details(self, lang=None, show_requirements=False, show_disabled=False):
|
def details(self, lang=None, show_requirements=False, show_disabled=False):
|
||||||
|
@ -28,6 +28,7 @@ import re
|
|||||||
import six
|
import six
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
from .common import NotifyFormat
|
from .common import NotifyFormat
|
||||||
|
from .URLBase import URLBase
|
||||||
|
|
||||||
if six.PY2:
|
if six.PY2:
|
||||||
from HTMLParser import HTMLParser
|
from HTMLParser import HTMLParser
|
||||||
@ -36,13 +37,12 @@ else:
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
def convert_between(from_format, to_format, body, title=None,
|
def convert_between(from_format, to_format, content):
|
||||||
title_format=NotifyFormat.TEXT):
|
|
||||||
"""
|
"""
|
||||||
Converts between different notification formats. If no conversion exists,
|
Converts between different suported formats. If no conversion exists,
|
||||||
or the selected one fails, the original text will be returned.
|
or the selected one fails, the original text will be returned.
|
||||||
|
|
||||||
This function returns a tuple as (title, body)
|
This function returns the content translated (if required)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
converters = {
|
converters = {
|
||||||
@ -53,106 +53,39 @@ def convert_between(from_format, to_format, body, title=None,
|
|||||||
(NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text,
|
(NotifyFormat.HTML, NotifyFormat.MARKDOWN): html_to_text,
|
||||||
}
|
}
|
||||||
|
|
||||||
if NotifyFormat.MARKDOWN in (from_format, to_format):
|
|
||||||
# Tidy any exising pre-formating configuration
|
|
||||||
title = '' if not title else title.lstrip('\r\n \t\v\b*#-')
|
|
||||||
|
|
||||||
else:
|
|
||||||
title = '' if not title else title
|
|
||||||
|
|
||||||
convert = converters.get((from_format, to_format))
|
convert = converters.get((from_format, to_format))
|
||||||
title, body = convert(title=title, body=body, title_format=title_format) \
|
return convert(content) if convert else content
|
||||||
if convert is not None else (title, body)
|
|
||||||
|
|
||||||
return (title, body)
|
|
||||||
|
|
||||||
|
|
||||||
def markdown_to_html(body, title=None, title_format=None):
|
def markdown_to_html(content):
|
||||||
"""
|
"""
|
||||||
Handle Markdown conversions
|
Converts specified content from markdown to HTML.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if title_format == NotifyFormat.HTML and title:
|
return markdown(content)
|
||||||
# perform conversion if otherwise told to do so
|
|
||||||
title = markdown(title)
|
|
||||||
|
|
||||||
return (
|
|
||||||
# Title
|
|
||||||
'' if not title else title,
|
|
||||||
|
|
||||||
# Body
|
|
||||||
markdown(body),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def text_to_html(body, title=None, title_format=None):
|
def text_to_html(content):
|
||||||
"""
|
"""
|
||||||
Converts a notification body from plain text to HTML.
|
Converts specified content from plain text to HTML.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Basic TEXT to HTML format map; supports keys only
|
return URLBase.escape_html(content)
|
||||||
re_map = {
|
|
||||||
# Support Ampersand
|
|
||||||
r'&': '&',
|
|
||||||
|
|
||||||
# Spaces to for formatting purposes since
|
|
||||||
# multiple spaces are treated as one an this may
|
|
||||||
# not be the callers intention
|
|
||||||
r' ': ' ',
|
|
||||||
|
|
||||||
# Tab support
|
|
||||||
r'\t': ' ',
|
|
||||||
|
|
||||||
# Greater than and Less than Characters
|
|
||||||
r'>': '>',
|
|
||||||
r'<': '<',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Compile our map
|
|
||||||
re_table = re.compile(
|
|
||||||
r'(' + '|'.join(
|
|
||||||
map(re.escape, re_map.keys())) + r')',
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Execute our map against our body in addition to
|
|
||||||
# swapping out new lines and replacing them with <br/>
|
|
||||||
return (
|
|
||||||
# Title; swap whitespace with space
|
|
||||||
'' if not title else re.sub(
|
|
||||||
r'[\r\n]+', ' ', re_table.sub(
|
|
||||||
lambda x: re_map[x.group()], title)),
|
|
||||||
|
|
||||||
# Body Formatting
|
|
||||||
re.sub(
|
|
||||||
r'\r*\n', '<br/>\n', re_table.sub(
|
|
||||||
lambda x: re_map[x.group()], body)))
|
|
||||||
|
|
||||||
|
|
||||||
def html_to_text(body, title=None, title_format=None):
|
def html_to_text(content):
|
||||||
"""
|
"""
|
||||||
Converts a notification body from HTML to plain text.
|
Converts a content from HTML to plain text.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
parser = HTMLConverter()
|
parser = HTMLConverter()
|
||||||
if six.PY2:
|
if six.PY2:
|
||||||
# Python 2.7 requires an additional parsing to un-escape characters
|
# Python 2.7 requires an additional parsing to un-escape characters
|
||||||
body = parser.unescape(body)
|
content = parser.unescape(content)
|
||||||
|
|
||||||
if title:
|
parser.feed(content)
|
||||||
if six.PY2:
|
|
||||||
# Python 2.7 requires an additional parsing to un-escape characters
|
|
||||||
title = parser.unescape(title)
|
|
||||||
|
|
||||||
parser.feed(title)
|
|
||||||
parser.close()
|
parser.close()
|
||||||
title = parser.converted
|
return parser.converted
|
||||||
|
|
||||||
parser.feed(body)
|
|
||||||
parser.close()
|
|
||||||
body = parser.converted
|
|
||||||
|
|
||||||
return ('' if not title else title, body)
|
|
||||||
|
|
||||||
|
|
||||||
class HTMLConverter(HTMLParser, object):
|
class HTMLConverter(HTMLParser, object):
|
||||||
|
@ -121,13 +121,6 @@ class NotifyBase(BASE_OBJECT):
|
|||||||
# automatically placed into the body
|
# automatically placed into the body
|
||||||
title_maxlen = 250
|
title_maxlen = 250
|
||||||
|
|
||||||
# Set this to HTML for services that support the conversion of HTML in
|
|
||||||
# the title. For example; services like Telegram support HTML in the
|
|
||||||
# title, however services like Email (where this goes in the Subject line)
|
|
||||||
# do not (but the body does). By default we do not convert titles but
|
|
||||||
# allow those who wish to over-ride this to do so.
|
|
||||||
title_format = NotifyFormat.TEXT
|
|
||||||
|
|
||||||
# Set the maximum line count; if this is set to anything larger then zero
|
# Set the maximum line count; if this is set to anything larger then zero
|
||||||
# the message (prior to it being sent) will be truncated to this number
|
# the message (prior to it being sent) will be truncated to this number
|
||||||
# of lines. Setting this to zero disables this feature.
|
# of lines. Setting this to zero disables this feature.
|
||||||
@ -272,7 +265,7 @@ class NotifyBase(BASE_OBJECT):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def notify(self, body, title=None, notify_type=NotifyType.INFO,
|
def notify(self, body, title=None, notify_type=NotifyType.INFO,
|
||||||
overflow=None, attach=None, **kwargs):
|
overflow=None, attach=None, body_format=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Performs notification
|
Performs notification
|
||||||
|
|
||||||
@ -298,18 +291,22 @@ class NotifyBase(BASE_OBJECT):
|
|||||||
title = '' if not title else title
|
title = '' if not title else title
|
||||||
|
|
||||||
# Apply our overflow (if defined)
|
# Apply our overflow (if defined)
|
||||||
for chunk in self._apply_overflow(body=body, title=title,
|
for chunk in self._apply_overflow(
|
||||||
overflow=overflow):
|
body=body, title=title, overflow=overflow,
|
||||||
|
body_format=body_format):
|
||||||
|
|
||||||
# Send notification
|
# Send notification
|
||||||
if not self.send(body=chunk['body'], title=chunk['title'],
|
if not self.send(body=chunk['body'], title=chunk['title'],
|
||||||
notify_type=notify_type, attach=attach):
|
notify_type=notify_type, attach=attach,
|
||||||
|
body_format=body_format):
|
||||||
|
|
||||||
# Toggle our return status flag
|
# Toggle our return status flag
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _apply_overflow(self, body, title=None, overflow=None):
|
def _apply_overflow(self, body, title=None, overflow=None,
|
||||||
|
body_format=None):
|
||||||
"""
|
"""
|
||||||
Takes the message body and title as input. This function then
|
Takes the message body and title as input. This function then
|
||||||
applies any defined overflow restrictions associated with the
|
applies any defined overflow restrictions associated with the
|
||||||
@ -341,18 +338,24 @@ class NotifyBase(BASE_OBJECT):
|
|||||||
overflow = self.overflow_mode
|
overflow = self.overflow_mode
|
||||||
|
|
||||||
if self.title_maxlen <= 0 and len(title) > 0:
|
if self.title_maxlen <= 0 and len(title) > 0:
|
||||||
if self.notify_format == NotifyFormat.MARKDOWN:
|
|
||||||
# Content is appended to body as markdown
|
|
||||||
body = '**{}**\r\n{}'.format(title, body)
|
|
||||||
|
|
||||||
elif self.notify_format == NotifyFormat.HTML:
|
if self.notify_format == NotifyFormat.HTML:
|
||||||
# Content is appended to body as html
|
# Content is appended to body as html
|
||||||
body = '<{open_tag}>{title}</{close_tag}>' \
|
body = '<{open_tag}>{title}</{close_tag}>' \
|
||||||
'<br />\r\n{body}'.format(
|
'<br />\r\n{body}'.format(
|
||||||
open_tag=self.default_html_tag_id,
|
open_tag=self.default_html_tag_id,
|
||||||
title=self.escape_html(title),
|
title=title,
|
||||||
close_tag=self.default_html_tag_id,
|
close_tag=self.default_html_tag_id,
|
||||||
body=body)
|
body=body)
|
||||||
|
|
||||||
|
elif self.notify_format == NotifyFormat.MARKDOWN and \
|
||||||
|
body_format == NotifyFormat.TEXT:
|
||||||
|
# Content is appended to body as markdown
|
||||||
|
title = title.lstrip('\r\n \t\v\f#-')
|
||||||
|
if title:
|
||||||
|
# Content is appended to body as text
|
||||||
|
body = '# {}\r\n{}'.format(title, body)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Content is appended to body as text
|
# Content is appended to body as text
|
||||||
body = '{}\r\n{}'.format(title, body)
|
body = '{}\r\n{}'.format(title, body)
|
||||||
|
@ -105,8 +105,8 @@ class NotifyTelegram(NotifyBase):
|
|||||||
# The maximum allowable characters allowed in the body per message
|
# The maximum allowable characters allowed in the body per message
|
||||||
body_maxlen = 4096
|
body_maxlen = 4096
|
||||||
|
|
||||||
# Allow the title to support HTML character sets
|
# Title is to be part of body
|
||||||
title_format = NotifyFormat.HTML
|
title_maxlen = 0
|
||||||
|
|
||||||
# Telegram is limited to sending a maximum of 100 requests per second.
|
# Telegram is limited to sending a maximum of 100 requests per second.
|
||||||
request_rate_per_sec = 0.001
|
request_rate_per_sec = 0.001
|
||||||
@ -173,6 +173,49 @@ class NotifyTelegram(NotifyBase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Telegram's HTML support doesn't like having HTML escaped
|
||||||
|
# characters passed into it. to handle this situation, we need to
|
||||||
|
# search the body for these sequences and convert them to the
|
||||||
|
# output the user expected
|
||||||
|
__telegram_escape_html_dict = {
|
||||||
|
# New Lines
|
||||||
|
re.compile(r'<\s*/?br\s*/?>\r*\n?', re.I): '\r\n',
|
||||||
|
re.compile(r'<\s*/(br|p|div|li)[^>]*>\r*\n?', re.I): '\r\n',
|
||||||
|
|
||||||
|
# The following characters can be altered to become supported
|
||||||
|
re.compile(r'<\s*pre[^>]*>', re.I): '<code>',
|
||||||
|
re.compile(r'<\s*/pre[^>]*>', re.I): '</code>',
|
||||||
|
|
||||||
|
# the following tags are not supported
|
||||||
|
re.compile(
|
||||||
|
r'<\s*(br|p|div|span|body|script|meta|html|font'
|
||||||
|
r'|label|iframe|li|ol|ul|source|script)[^>]*>', re.I): '',
|
||||||
|
|
||||||
|
re.compile(
|
||||||
|
r'<\s*/(span|body|script|meta|html|font'
|
||||||
|
r'|label|iframe|ol|ul|source|script)[^>]*>', re.I): '',
|
||||||
|
|
||||||
|
# Italic
|
||||||
|
re.compile(r'<\s*(caption|em)[^>]*>', re.I): '<i>',
|
||||||
|
re.compile(r'<\s*/(caption|em)[^>]*>', re.I): '</i>',
|
||||||
|
|
||||||
|
# Bold
|
||||||
|
re.compile(r'<\s*(h[1-6]|title|strong)[^>]*>', re.I): '<b>',
|
||||||
|
re.compile(r'<\s*/(h[1-6]|title|strong)[^>]*>', re.I): '</b>',
|
||||||
|
|
||||||
|
# HTML Spaces ( ) and tabs ( ) aren't supported
|
||||||
|
# See https://core.telegram.org/bots/api#html-style
|
||||||
|
re.compile(r'\ ?', re.I): ' ',
|
||||||
|
|
||||||
|
# Tabs become 3 spaces
|
||||||
|
re.compile(r'\ ?', re.I): ' ',
|
||||||
|
|
||||||
|
# Some characters get re-escaped by the Telegram upstream
|
||||||
|
# service so we need to convert these back,
|
||||||
|
re.compile(r'\'?', re.I): '\'',
|
||||||
|
re.compile(r'\"?', re.I): '"',
|
||||||
|
}
|
||||||
|
|
||||||
# Define our template tokens
|
# Define our template tokens
|
||||||
template_tokens = dict(NotifyBase.template_tokens, **{
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
||||||
'bot_token': {
|
'bot_token': {
|
||||||
@ -505,7 +548,7 @@ class NotifyTelegram(NotifyBase):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
||||||
**kwargs):
|
body_format=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Perform Telegram Notification
|
Perform Telegram Notification
|
||||||
"""
|
"""
|
||||||
@ -548,93 +591,43 @@ class NotifyTelegram(NotifyBase):
|
|||||||
if self.notify_format == NotifyFormat.MARKDOWN:
|
if self.notify_format == NotifyFormat.MARKDOWN:
|
||||||
payload['parse_mode'] = 'MARKDOWN'
|
payload['parse_mode'] = 'MARKDOWN'
|
||||||
|
|
||||||
payload['text'] = '{}{}'.format(
|
payload['text'] = body
|
||||||
'# {}\r\n'.format(title) if title else '',
|
|
||||||
body,
|
else: # HTML
|
||||||
)
|
|
||||||
|
|
||||||
elif self.notify_format == NotifyFormat.HTML:
|
|
||||||
# Use Telegram's HTML mode
|
# Use Telegram's HTML mode
|
||||||
payload['parse_mode'] = 'HTML'
|
payload['parse_mode'] = 'HTML'
|
||||||
|
for r, v in self.__telegram_escape_html_dict.items():
|
||||||
|
body = r.sub(v, body, re.I)
|
||||||
|
|
||||||
# Telegram's HTML support doesn't like having HTML escaped
|
# Prepare our payload based on HTML or TEXT
|
||||||
# characters passed into it. to handle this situation, we need to
|
payload['text'] = body
|
||||||
# search the body for these sequences and convert them to the
|
|
||||||
# output the user expected
|
|
||||||
telegram_escape_html_dict = {
|
|
||||||
# HTML Spaces ( ) and tabs ( ) aren't supported
|
|
||||||
# See https://core.telegram.org/bots/api#html-style
|
|
||||||
r'\ ?': ' ',
|
|
||||||
|
|
||||||
# Tabs become 3 spaces
|
# else: # self.notify_format == NotifyFormat.TEXT:
|
||||||
r'\ ?': ' ',
|
# # Use Telegram's HTML mode
|
||||||
|
# payload['parse_mode'] = 'HTML'
|
||||||
|
|
||||||
# Some characters get re-escaped by the Telegram upstream
|
# # Further html escaping required...
|
||||||
# service so we need to convert these back,
|
# telegram_escape_text_dict = {
|
||||||
r'\'?': '\'',
|
# # We need to escape characters that conflict with html
|
||||||
r'\"?': '"',
|
# # entity blocks (< and >) when displaying text
|
||||||
|
# r'>': '>',
|
||||||
|
# r'<': '<',
|
||||||
|
# r'\&': '&',
|
||||||
|
# }
|
||||||
|
|
||||||
# the following tags are not supported
|
# # Create a regular expression from the dictionary keys
|
||||||
r'<[ \t]*/?(br|p|div|span|body|script|meta|html|font'
|
# text_regex = re.compile("(%s)" % "|".join(
|
||||||
r'|label|iframe|li|ol|ul)[^>]*>': '',
|
# map(re.escape, telegram_escape_text_dict.keys())).lower(),
|
||||||
|
# re.I)
|
||||||
|
|
||||||
# The following characters can be altered to become supported
|
# # For each match, look-up corresponding value in dictionary
|
||||||
r'<[ \t]*pre[^>]*>': '<code>',
|
# body = text_regex.sub( # pragma: no branch
|
||||||
r'<[ \t]*/pre[^>]*>': '</code>',
|
# lambda mo: telegram_escape_text_dict[
|
||||||
|
# mo.string[mo.start():mo.end()]], body)
|
||||||
|
|
||||||
# Bold
|
# # prepare our payload based on HTML or TEXT
|
||||||
r'<[ \t]*(h[0-9]+|title|strong)[^>]*>': '<b>',
|
# payload['text'] = body
|
||||||
r'<[ \t]*/(h[0-9]+|title|strong)[^>]*>': '</b>',
|
|
||||||
|
|
||||||
# Italic
|
|
||||||
r'<[ \t]*(caption|em)[^>]*>': '<i>',
|
|
||||||
r'<[ \t]*/(caption|em)[^>]*>': '</i>',
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v in telegram_escape_html_dict.items():
|
|
||||||
body = re.sub(k, v, body, re.I)
|
|
||||||
if title:
|
|
||||||
title = re.sub(k, v, title, re.I)
|
|
||||||
|
|
||||||
# prepare our payload based on HTML or TEXT
|
|
||||||
payload['text'] = '{}{}'.format(
|
|
||||||
'<b>{}</b>\r\n'.format(title) if title else '',
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
|
|
||||||
else: # self.notify_format == NotifyFormat.TEXT:
|
|
||||||
# Use Telegram's HTML mode
|
|
||||||
payload['parse_mode'] = 'HTML'
|
|
||||||
|
|
||||||
# Further html escaping required...
|
|
||||||
telegram_escape_text_dict = {
|
|
||||||
# We need to escape characters that conflict with html
|
|
||||||
# entity blocks (< and >) when displaying text
|
|
||||||
r'>': '>',
|
|
||||||
r'<': '<',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create a regular expression from the dictionary keys
|
|
||||||
text_regex = re.compile("(%s)" % "|".join(
|
|
||||||
map(re.escape, telegram_escape_text_dict.keys())).lower(),
|
|
||||||
re.I)
|
|
||||||
|
|
||||||
# For each match, look-up corresponding value in dictionary
|
|
||||||
body = text_regex.sub( # pragma: no branch
|
|
||||||
lambda mo: telegram_escape_text_dict[
|
|
||||||
mo.string[mo.start():mo.end()]], body)
|
|
||||||
|
|
||||||
if title:
|
|
||||||
# For each match, look-up corresponding value in dictionary
|
|
||||||
title = text_regex.sub( # pragma: no branch
|
|
||||||
lambda mo: telegram_escape_text_dict[
|
|
||||||
mo.string[mo.start():mo.end()]], title)
|
|
||||||
|
|
||||||
# prepare our payload based on HTML or TEXT
|
|
||||||
payload['text'] = '{}{}'.format(
|
|
||||||
'<b>{}</b>\r\n'.format(title) if title else '',
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a copy of the chat_ids list
|
# Create a copy of the chat_ids list
|
||||||
targets = list(self.targets)
|
targets = list(self.targets)
|
||||||
|
@ -702,7 +702,7 @@ def parse_url(url, default_schema='http', verify_host=True, strict_port=False):
|
|||||||
|
|
||||||
# Port Parsing
|
# Port Parsing
|
||||||
pmatch = re.search(
|
pmatch = re.search(
|
||||||
r'^(?P<host>([[0-9a-f:]+]|[^:]+)):(?P<port>[^:]*)$',
|
r'^(?P<host>(\[[0-9a-f:]+\]|[^:]+)):(?P<port>[^:]*)$',
|
||||||
result['host'])
|
result['host'])
|
||||||
|
|
||||||
if pmatch:
|
if pmatch:
|
||||||
|
@ -32,7 +32,7 @@ import logging
|
|||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
def test_html_to_text():
|
def test_conversion_html_to_text():
|
||||||
"""conversion: Test HTML to plain text
|
"""conversion: Test HTML to plain text
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ def test_html_to_text():
|
|||||||
"""
|
"""
|
||||||
A function to simply html conversion tests
|
A function to simply html conversion tests
|
||||||
"""
|
"""
|
||||||
return convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body)[1]
|
return convert_between(NotifyFormat.HTML, NotifyFormat.TEXT, body)
|
||||||
|
|
||||||
assert to_html("No HTML code here.") == "No HTML code here."
|
assert to_html("No HTML code here.") == "No HTML code here."
|
||||||
|
|
||||||
@ -134,3 +134,16 @@ def test_html_to_text():
|
|||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
# Invalid input
|
# Invalid input
|
||||||
assert to_html(object)
|
assert to_html(object)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conversion_text_to():
|
||||||
|
"""conversion: Test Text to all types
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = convert_between(
|
||||||
|
NotifyFormat.TEXT, NotifyFormat.HTML,
|
||||||
|
"<title>Test Message</title><body>Body</body>")
|
||||||
|
|
||||||
|
assert response == \
|
||||||
|
'<title>Test Message</title><body>Body<'\
|
||||||
|
'/body>'
|
||||||
|
@ -311,10 +311,6 @@ def test_plugin_telegram_general(mock_post):
|
|||||||
# ensures our plugin inheritance is working properly
|
# ensures our plugin inheritance is working properly
|
||||||
assert obj.body_maxlen == plugins.NotifyTelegram.body_maxlen
|
assert obj.body_maxlen == plugins.NotifyTelegram.body_maxlen
|
||||||
|
|
||||||
# We don't override the title maxlen so we should be set to the same
|
|
||||||
# as our parent class in this case
|
|
||||||
assert obj.title_maxlen == plugins.NotifyBase.title_maxlen
|
|
||||||
|
|
||||||
# This tests erroneous messages involving multiple chat ids
|
# This tests erroneous messages involving multiple chat ids
|
||||||
assert obj.notify(
|
assert obj.notify(
|
||||||
body='body', title='title', notify_type=NotifyType.INFO) is False
|
body='body', title='title', notify_type=NotifyType.INFO) is False
|
||||||
@ -407,7 +403,7 @@ def test_plugin_telegram_general(mock_post):
|
|||||||
|
|
||||||
# Test our payload
|
# Test our payload
|
||||||
assert payload['text'] == \
|
assert payload['text'] == \
|
||||||
'<b>special characters</b>\r\n\'"This can\'t\t\r\nfail us"\''
|
'<b>special characters</b>\r\n\'"This can\'t\t\r\nfail us"\'\r\n'
|
||||||
|
|
||||||
# Test sending attachments
|
# Test sending attachments
|
||||||
attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif'))
|
attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif'))
|
||||||
@ -629,10 +625,11 @@ def test_plugin_telegram_formating_py3(mock_post):
|
|||||||
|
|
||||||
# Test that everything is escaped properly in a TEXT mode
|
# Test that everything is escaped properly in a TEXT mode
|
||||||
assert payload['text'] == \
|
assert payload['text'] == \
|
||||||
'<b>🚨 Change detected for <i>Apprise Test' \
|
'<b>🚨 Change detected for <i>Apprise ' \
|
||||||
' Title</i></b>\r\n<a href="http://localhost">' \
|
'Test Title</i></b>\r\n<a href=' \
|
||||||
'<i>Apprise Body Title</i></a> had' \
|
'"http://localhost"><i>Apprise Body Title<' \
|
||||||
' <a href="http://127.0.0.1">a change</a>'
|
'/i></a> had <a href="http://' \
|
||||||
|
'127.0.0.1">a change</a>'
|
||||||
|
|
||||||
# Reset our values
|
# Reset our values
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
@ -699,7 +696,12 @@ def test_plugin_telegram_formating_py3(mock_post):
|
|||||||
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=html')
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=html')
|
||||||
assert len(aobj) == 1
|
assert len(aobj) == 1
|
||||||
|
|
||||||
# HTML forced by the command line, but MARKDOWN spacified as
|
# Now test our MARKDOWN Handling
|
||||||
|
title = '# 🚨 Another Change detected for _Apprise Test Title_'
|
||||||
|
body = '_[Apprise Body Title](http://localhost)_' \
|
||||||
|
' had [a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# HTML forced by the command line, but MARKDOWN specified as
|
||||||
# upstream mode
|
# upstream mode
|
||||||
assert aobj.notify(
|
assert aobj.notify(
|
||||||
title=title, body=body, body_format=NotifyFormat.MARKDOWN)
|
title=title, body=body, body_format=NotifyFormat.MARKDOWN)
|
||||||
@ -716,9 +718,79 @@ def test_plugin_telegram_formating_py3(mock_post):
|
|||||||
|
|
||||||
# Test that everything is escaped properly in a HTML mode
|
# Test that everything is escaped properly in a HTML mode
|
||||||
assert payload['text'] == \
|
assert payload['text'] == \
|
||||||
'<b>🚨 Change detected for <i>Apprise Test Title</i></b>\r\n<i>' \
|
'<b><b>🚨 Another Change detected for <i>Apprise Test Title</i>' \
|
||||||
'<a href="http://localhost">Apprise Body Title</a></i> ' \
|
'</b></b>\r\n<i><a href="http://localhost">Apprise Body Title</a>' \
|
||||||
'had <a href="http://127.0.0.1">a change</a>'
|
'</i> had <a href="http://127.0.0.2">a change</a>\r\n'
|
||||||
|
|
||||||
|
# Now we'll test an edge case where a title was defined, but after
|
||||||
|
# processing it, it was determiend there really wasn't anything there
|
||||||
|
# at all at the end of the day.
|
||||||
|
|
||||||
|
# Reset our values
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Upstream to use HTML but input specified as Markdown
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=markdown')
|
||||||
|
assert len(aobj) == 1
|
||||||
|
|
||||||
|
# Now test our MARKDOWN Handling (no title defined... not really anyway)
|
||||||
|
title = '# '
|
||||||
|
body = '_[Apprise Body Title](http://localhost)_' \
|
||||||
|
' had [a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# MARKDOWN forced by the command line, but TEXT specified as
|
||||||
|
# upstream mode
|
||||||
|
assert aobj.notify(
|
||||||
|
title=title, body=body, body_format=NotifyFormat.TEXT)
|
||||||
|
|
||||||
|
# Test our calls
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage'
|
||||||
|
|
||||||
|
payload = loads(mock_post.call_args_list[1][1]['data'])
|
||||||
|
|
||||||
|
# Test that everything is escaped properly in a HTML mode
|
||||||
|
assert payload['text'] == \
|
||||||
|
'_[Apprise Body Title](http://localhost)_ had ' \
|
||||||
|
'[a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# Reset our values
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Upstream to use HTML but input specified as Markdown
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=markdown')
|
||||||
|
assert len(aobj) == 1
|
||||||
|
|
||||||
|
# Set an actual title this time
|
||||||
|
title = '# A Great Title'
|
||||||
|
body = '_[Apprise Body Title](http://localhost)_' \
|
||||||
|
' had [a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# MARKDOWN forced by the command line, but TEXT specified as
|
||||||
|
# upstream mode
|
||||||
|
assert aobj.notify(
|
||||||
|
title=title, body=body, body_format=NotifyFormat.TEXT)
|
||||||
|
|
||||||
|
# Test our calls
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage'
|
||||||
|
|
||||||
|
payload = loads(mock_post.call_args_list[1][1]['data'])
|
||||||
|
|
||||||
|
# Test that everything is escaped properly in a HTML mode
|
||||||
|
assert payload['text'] == \
|
||||||
|
'# A Great Title\r\n_[Apprise Body Title](http://localhost)_ had ' \
|
||||||
|
'[a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(sys.version_info.major >= 3, reason="Requires Python 2.x+")
|
@pytest.mark.skipif(sys.version_info.major >= 3, reason="Requires Python 2.x+")
|
||||||
@ -809,11 +881,11 @@ def test_plugin_telegram_formating_py2(mock_post):
|
|||||||
|
|
||||||
# Test that everything is escaped properly in a TEXT mode
|
# Test that everything is escaped properly in a TEXT mode
|
||||||
assert payload['text'].encode('utf-8') == \
|
assert payload['text'].encode('utf-8') == \
|
||||||
'<b>\xf0\x9f\x9a\xa8 Change detected for ' \
|
'<b>\xf0\x9f\x9a\xa8 Change detected for <i>' \
|
||||||
'<i>Apprise Test Title</i></b>\r\n' \
|
'Apprise Test Title</i></b>\r\n<a ' \
|
||||||
'<a href="http://localhost"><i>Apprise Body ' \
|
'href="http://localhost"><i>Apprise Body ' \
|
||||||
'Title</i></a> had <a ' \
|
'Title</i></a> had <a href="' \
|
||||||
'href="http://127.0.0.1">a change</a>'
|
'http://127.0.0.1">a change</a>'
|
||||||
|
|
||||||
# Reset our values
|
# Reset our values
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
@ -880,7 +952,7 @@ def test_plugin_telegram_formating_py2(mock_post):
|
|||||||
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=html')
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=html')
|
||||||
assert len(aobj) == 1
|
assert len(aobj) == 1
|
||||||
|
|
||||||
# HTML forced by the command line, but MARKDOWN spacified as
|
# HTML forced by the command line, but MARKDOWN specified as
|
||||||
# upstream mode
|
# upstream mode
|
||||||
assert aobj.notify(
|
assert aobj.notify(
|
||||||
title=title, body=body, body_format=NotifyFormat.MARKDOWN)
|
title=title, body=body, body_format=NotifyFormat.MARKDOWN)
|
||||||
@ -897,9 +969,10 @@ def test_plugin_telegram_formating_py2(mock_post):
|
|||||||
|
|
||||||
# Test that everything is escaped properly in a HTML mode
|
# Test that everything is escaped properly in a HTML mode
|
||||||
assert payload['text'].encode('utf-8') == \
|
assert payload['text'].encode('utf-8') == \
|
||||||
'<b>\xf0\x9f\x9a\xa8 Change detected for <i>Apprise Test Title</i>' \
|
'<b><b>\xf0\x9f\x9a\xa8 Change detected for ' \
|
||||||
'</b>\r\n<i><a href="http://localhost">Apprise Body Title</a></i> ' \
|
'<i>Apprise Test Title</i></b></b>\r\n<i>' \
|
||||||
'had <a href="http://127.0.0.1">a change</a>'
|
'<a href="http://localhost">Apprise Body Title</a>'\
|
||||||
|
'</i> had <a href="http://127.0.0.1">a change</a>\r\n'
|
||||||
|
|
||||||
# Reset our values
|
# Reset our values
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
@ -951,6 +1024,76 @@ def test_plugin_telegram_formating_py2(mock_post):
|
|||||||
'\xd7\xa0\xd7\xa4\xd7\x9c\xd7\x90\xd7\x94</b>\r\n[_[\xd7\x96\xd7\x95 '\
|
'\xd7\xa0\xd7\xa4\xd7\x9c\xd7\x90\xd7\x94</b>\r\n[_[\xd7\x96\xd7\x95 '\
|
||||||
'\xd7\x94\xd7\x95\xd7\x93\xd7\xa2\xd7\x94](http://localhost)_'
|
'\xd7\x94\xd7\x95\xd7\x93\xd7\xa2\xd7\x94](http://localhost)_'
|
||||||
|
|
||||||
|
# Now we'll test an edge case where a title was defined, but after
|
||||||
|
# processing it, it was determiend there really wasn't anything there
|
||||||
|
# at all at the end of the day.
|
||||||
|
|
||||||
|
# Reset our values
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Upstream to use HTML but input specified as Markdown
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=markdown')
|
||||||
|
assert len(aobj) == 1
|
||||||
|
|
||||||
|
# Now test our MARKDOWN Handling (no title defined... not really anyway)
|
||||||
|
title = '# '
|
||||||
|
body = '_[Apprise Body Title](http://localhost)_' \
|
||||||
|
' had [a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# MARKDOWN forced by the command line, but TEXT specified as
|
||||||
|
# upstream mode
|
||||||
|
assert aobj.notify(
|
||||||
|
title=title, body=body, body_format=NotifyFormat.TEXT)
|
||||||
|
|
||||||
|
# Test our calls
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage'
|
||||||
|
|
||||||
|
payload = loads(mock_post.call_args_list[1][1]['data'])
|
||||||
|
|
||||||
|
# Test that everything is escaped properly in a HTML mode
|
||||||
|
assert payload['text'] == \
|
||||||
|
'_[Apprise Body Title](http://localhost)_ had ' \
|
||||||
|
'[a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# Reset our values
|
||||||
|
mock_post.reset_mock()
|
||||||
|
|
||||||
|
# Upstream to use HTML but input specified as Markdown
|
||||||
|
aobj = Apprise()
|
||||||
|
aobj.add('tgram://987654321:abcdefg_hijklmnop/?format=markdown')
|
||||||
|
assert len(aobj) == 1
|
||||||
|
|
||||||
|
# Set an actual title this time
|
||||||
|
title = '# A Great Title'
|
||||||
|
body = '_[Apprise Body Title](http://localhost)_' \
|
||||||
|
' had [a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
# MARKDOWN forced by the command line, but TEXT specified as
|
||||||
|
# upstream mode
|
||||||
|
assert aobj.notify(
|
||||||
|
title=title, body=body, body_format=NotifyFormat.TEXT)
|
||||||
|
|
||||||
|
# Test our calls
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
assert mock_post.call_args_list[0][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/getUpdates'
|
||||||
|
assert mock_post.call_args_list[1][0][0] == \
|
||||||
|
'https://api.telegram.org/bot987654321:abcdefg_hijklmnop/sendMessage'
|
||||||
|
|
||||||
|
payload = loads(mock_post.call_args_list[1][1]['data'])
|
||||||
|
|
||||||
|
# Test that everything is escaped properly in a HTML mode
|
||||||
|
assert payload['text'] == \
|
||||||
|
'# A Great Title\r\n_[Apprise Body Title](http://localhost)_ had ' \
|
||||||
|
'[a change](http://127.0.0.2)'
|
||||||
|
|
||||||
|
|
||||||
@mock.patch('requests.post')
|
@mock.patch('requests.post')
|
||||||
def test_plugin_telegram_html_formatting(mock_post):
|
def test_plugin_telegram_html_formatting(mock_post):
|
||||||
@ -1020,8 +1163,8 @@ def test_plugin_telegram_html_formatting(mock_post):
|
|||||||
|
|
||||||
# Test that everything is escaped properly in a HTML mode
|
# Test that everything is escaped properly in a HTML mode
|
||||||
assert payload['text'] == \
|
assert payload['text'] == \
|
||||||
'<b><b>\'information\'</b></b>\r\n<i>"This is in Italic"</i>' \
|
'<b><b>\'information\'</b></b>\r\n<i>"This is in Italic"' \
|
||||||
'<b> Headings are dropped and converted to bold</b>'
|
'</i>\r\n<b> Headings are dropped and converted to bold</b>'
|
||||||
|
|
||||||
mock_post.reset_mock()
|
mock_post.reset_mock()
|
||||||
|
|
||||||
@ -1034,6 +1177,7 @@ def test_plugin_telegram_html_formatting(mock_post):
|
|||||||
|
|
||||||
assert payload['text'] == \
|
assert payload['text'] == \
|
||||||
'<b><title>&apos;information&apos</title></b>' \
|
'<b><title>&apos;information&apos</title></b>' \
|
||||||
'\r\n<em>&quot;This is in Italic&quot</em>' \
|
'\r\n<em>&quot;This is in Italic&quot</em' \
|
||||||
'<br/><h5>&emsp;&emspHeadings&nbsp;are' \
|
'><br/><h5>&emsp;&emspHeadings&nbsp;' \
|
||||||
' dropped and&nbspconverted to bold</h5>'
|
'are dropped and&nbspconverted to bold<' \
|
||||||
|
'/h5>'
|
||||||
|
@ -386,3 +386,57 @@ def test_notify_overflow_split():
|
|||||||
_body = chunk.get('body')
|
_body = chunk.get('body')
|
||||||
assert bulk[offset: len(_body) + offset] == _body
|
assert bulk[offset: len(_body) + offset] == _body
|
||||||
offset += len(_body)
|
offset += len(_body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_overflow_general():
|
||||||
|
"""
|
||||||
|
API: Overflow General Testing
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# A little preparation
|
||||||
|
#
|
||||||
|
|
||||||
|
# Disable Throttling to speed testing
|
||||||
|
plugins.NotifyBase.request_rate_per_sec = 0
|
||||||
|
|
||||||
|
#
|
||||||
|
# First Test: Truncated Title
|
||||||
|
#
|
||||||
|
class TestMarkdownNotification(NotifyBase):
|
||||||
|
|
||||||
|
# Force our title to wrap
|
||||||
|
title_maxlen = 0
|
||||||
|
|
||||||
|
# Default Notify Format
|
||||||
|
notify_format = NotifyFormat.MARKDOWN
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(TestMarkdownNotification, self).__init__(**kwargs)
|
||||||
|
|
||||||
|
def notify(self, *args, **kwargs):
|
||||||
|
# Pretend everything is okay
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Load our object
|
||||||
|
obj = TestMarkdownNotification()
|
||||||
|
assert obj is not None
|
||||||
|
|
||||||
|
# A bad header
|
||||||
|
title = " # "
|
||||||
|
body = "**Test Body**"
|
||||||
|
|
||||||
|
chunks = obj._apply_overflow(body=body, title=title)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
# whitspace is trimmed
|
||||||
|
assert '#\r\n**Test Body**' == chunks[0].get('body')
|
||||||
|
assert chunks[0].get('title') == ""
|
||||||
|
|
||||||
|
# If we know our input is text however, we perform manipulation
|
||||||
|
chunks = obj._apply_overflow(
|
||||||
|
body=body, title=title, body_format=NotifyFormat.TEXT)
|
||||||
|
assert len(chunks) == 1
|
||||||
|
# Our title get's stripped off since it's not of valid markdown
|
||||||
|
assert body == chunks[0].get('body')
|
||||||
|
assert chunks[0].get('title') == ""
|
||||||
|
Loading…
Reference in New Issue
Block a user