Add support for groupchat and activation feature

This commit is contained in:
Schimon Jehudah 2023-11-26 15:23:52 +00:00
parent 31baf96430
commit 071bf78e1d
7 changed files with 296 additions and 131 deletions

View file

@ -113,6 +113,7 @@ if __name__ == '__main__':
xmpp.register_plugin('xep_0004') # Data Forms xmpp.register_plugin('xep_0004') # Data Forms
xmpp.register_plugin('xep_0030') # Service Discovery xmpp.register_plugin('xep_0030') # Service Discovery
xmpp.register_plugin('xep_0045') # Multi-User Chat xmpp.register_plugin('xep_0045') # Multi-User Chat
xmpp.register_plugin('xep_0048') # Bookmarks
xmpp.register_plugin('xep_0060') # PubSub xmpp.register_plugin('xep_0060') # PubSub
xmpp.register_plugin('xep_0199') # XMPP Ping xmpp.register_plugin('xep_0199') # XMPP Ping
xmpp.register_plugin('xep_0249') # Multi-User Chat xmpp.register_plugin('xep_0249') # Multi-User Chat

View file

@ -12,6 +12,7 @@ TODO
import os import os
import filehandler import filehandler
from random import randrange
async def get_value_default(key): async def get_value_default(key):
@ -42,8 +43,10 @@ async def get_value_default(key):
result = 3 result = 3
case "random": case "random":
result = 0 result = 0
case "read": case "masters":
result = "https://www.blacklistednews.com/rss.php" result = randrange(100000, 999999)
case "token":
result = "none"
return result return result

View file

@ -21,7 +21,7 @@ import feedparser
import sqlitehandler import sqlitehandler
import confighandler import confighandler
import datetimehandler import datetimehandler
import filterhandler import listhandler
from asyncio.exceptions import IncompleteReadError from asyncio.exceptions import IncompleteReadError
from http.client import IncompleteRead from http.client import IncompleteRead
@ -168,15 +168,15 @@ async def download_updates(db_file, url=None):
summary, summary,
pathname pathname
) )
allow_list = await filterhandler.is_listed( allow_list = await listhandler.is_listed(
db_file, db_file,
"allow", "filter-allow",
string string
) )
if not allow_list: if not allow_list:
reject_list = await filterhandler.is_listed( reject_list = await listhandler.is_listed(
db_file, db_file,
"deny", "filter-deny",
string string
) )
if reject_list: if reject_list:
@ -208,7 +208,7 @@ async def download_updates(db_file, url=None):
# NOTE Why (if result[0]) and (if result[1] == 200)? # NOTE Why (if result[0]) and (if result[1] == 200)?
async def view_feed(db_file, url): async def view_feed(url):
""" """
Check feeds for new entries. Check feeds for new entries.
@ -235,8 +235,7 @@ async def view_feed(db_file, url):
# "For more information, visit " # "For more information, visit "
# "https://pythonhosted.org/feedparser/bozo.html" # "https://pythonhosted.org/feedparser/bozo.html"
# ).format(url) # ).format(url)
# msg = await probe_page(view_feed, url, result[0]) msg = await probe_page(view_feed, url, result[0])
msg = await probe_page(view_feed, url, result[0], db_file)
return msg return msg
except ( except (
IncompleteReadError, IncompleteReadError,
@ -253,10 +252,7 @@ async def view_feed(db_file, url):
if result[1] == 200: if result[1] == 200:
title = await get_title(url, result[0]) title = await get_title(url, result[0])
entries = feed.entries entries = feed.entries
msg = "Extracted {} entries from {}:\n```\n".format( msg = "Preview of {}:\n```\n".format(title)
len(entries),
title
)
count = 0 count = 0
for entry in entries: for entry in entries:
count += 1 count += 1
@ -290,13 +286,11 @@ async def view_feed(db_file, url):
link, link,
count count
) )
if count > 4:
break
msg += ( msg += (
"```\n" "```\nSource: {}"
"Source: {}\n" ).format(url)
"Enter a number from 1 - {} using command `select` "
"to view a specific item from the list."
).format(url, count)
await sqlitehandler.set_settings_value(db_file, ["read", url])
else: else:
msg = ( msg = (
">{}\nFailed to load URL. Reason: {}" ">{}\nFailed to load URL. Reason: {}"
@ -304,14 +298,38 @@ async def view_feed(db_file, url):
return msg return msg
async def view_entry(db_file, num): # NOTE Why (if result[0]) and (if result[1] == 200)?
num = int(num) - 1 async def view_entry(url, num):
url = await sqlitehandler.get_settings_value(db_file, "read")
result = await download_feed(url) result = await download_feed(url)
if result[0]:
try:
feed = feedparser.parse(result[0])
if feed.bozo:
# msg = (
# ">{}\n"
# "WARNING: Bozo detected!\n"
# "For more information, visit "
# "https://pythonhosted.org/feedparser/bozo.html"
# ).format(url)
msg = await probe_page(view_entry, url, result[0], num)
return msg
except (
IncompleteReadError,
IncompleteRead,
error.URLError
) as e:
# print(e)
# TODO Print error to log
msg = (
"> {}\n"
"Error: {}"
).format(url, e)
breakpoint()
if result[1] == 200: if result[1] == 200:
feed = feedparser.parse(result[0]) feed = feedparser.parse(result[0])
title = await get_title(url, result[0]) title = await get_title(url, result[0])
entries = feed.entries entries = feed.entries
num = int(num) - 1
entry = entries[num] entry = entries[num]
if entry.has_key("title"): if entry.has_key("title"):
title = entry.title title = entry.title
@ -328,9 +346,9 @@ async def view_entry(db_file, num):
if entry.has_key("summary"): if entry.has_key("summary"):
summary = entry.summary summary = entry.summary
# Remove HTML tags # Remove HTML tags
# summary = BeautifulSoup(summary, "lxml").text summary = BeautifulSoup(summary, "lxml").text
# TODO Limit text length # TODO Limit text length
# summary = summary.replace("\n\n", "\n") summary = summary.replace("\n\n\n", "\n\n")
else: else:
summary = "*** No summary ***" summary = "*** No summary ***"
if entry.has_key("link"): if entry.has_key("link"):
@ -346,11 +364,8 @@ async def view_entry(db_file, num):
"\n" "\n"
"{}\n" "{}\n"
"\n" "\n"
"{}\n"
"\n"
).format( ).format(
title, title,
date,
summary, summary,
link link
) )
@ -453,7 +468,7 @@ async def add_feed(db_file, url):
# TODO callback for use with add_feed and view_feed # TODO callback for use with add_feed and view_feed
async def probe_page(callback, url, doc, db_file=None): async def probe_page(callback, url, doc, num=None, db_file=None):
msg = None msg = None
try: try:
# tree = etree.fromstring(res[0]) # etree is for xml # tree = etree.fromstring(res[0]) # etree is for xml
@ -483,6 +498,8 @@ async def probe_page(callback, url, doc, db_file=None):
url = msg[0] url = msg[0]
if db_file: if db_file:
return await callback(db_file, url) return await callback(db_file, url)
elif num:
return await callback(url, num)
else: else:
return await callback(url) return await callback(url)

View file

@ -17,9 +17,10 @@ TODO
import sqlitehandler import sqlitehandler
async def set_filter(newwords, keywords):
async def set_list(newwords, keywords):
""" """
Append new keywords to filter. Append new keywords to list.
Parameters Parameters
---------- ----------
@ -46,7 +47,8 @@ async def set_filter(newwords, keywords):
val = ",".join(keywords) val = ",".join(keywords)
return val return val
async def is_listed(db_file, type, string):
async def is_listed(db_file, key, string):
""" """
Check keyword match. Check keyword match.
@ -66,10 +68,9 @@ async def is_listed(db_file, type, string):
""" """
# async def reject(db_file, string): # async def reject(db_file, string):
# async def is_blacklisted(db_file, string): # async def is_blacklisted(db_file, string):
filter_type = "filter-" + type
list = await sqlitehandler.get_settings_value( list = await sqlitehandler.get_settings_value(
db_file, db_file,
filter_type key
) )
if list: if list:
list = list.split(",") list = list.split(",")

View file

@ -1059,7 +1059,7 @@ async def list_feeds(db_file):
"FROM feeds" "FROM feeds"
) )
results = cur.execute(sql) results = cur.execute(sql)
feeds_list = "\nList of subscriptions:\n```" feeds_list = "\nList of subscriptions:\n```\n"
counter = 0 counter = 0
for result in results: for result in results:
counter += 1 counter += 1
@ -1329,7 +1329,7 @@ async def set_settings_value(db_file, key_value):
key_value : list key_value : list
key : str key : str
enabled, filter-allow, filter-deny, enabled, filter-allow, filter-deny,
interval, master, quantum, random. interval, masters, quantum, random.
value : int value : int
Numeric value. Numeric value.
""" """

View file

@ -198,11 +198,15 @@ async def send_update(self, jid, num=None):
if new: if new:
# TODO Add while loop to assure delivery. # TODO Add while loop to assure delivery.
# print(await datetimehandler.current_time(), ">>> ACT send_message",jid) # print(await datetimehandler.current_time(), ">>> ACT send_message",jid)
if await xmpphandler.Slixfeed.is_muc(self, jid):
chat_type = "groupchat"
else:
chat_type = "chat"
xmpphandler.Slixfeed.send_message( xmpphandler.Slixfeed.send_message(
self, self,
mto=jid, mto=jid,
mbody=new, mbody=new,
mtype="chat" mtype=chat_type
) )
# TODO Do not refresh task before # TODO Do not refresh task before
# verifying that it was completed. # verifying that it was completed.

View file

@ -23,6 +23,15 @@ TODO
4) Do not send updates when busy or away. 4) Do not send updates when busy or away.
See https://slixmpp.readthedocs.io/en/latest/event_index.html#term-changed_status See https://slixmpp.readthedocs.io/en/latest/event_index.html#term-changed_status
5) XHTTML-IM
case _ if message_lowercase.startswith("html"):
message['html']="<h1>Parse me!</h1>"
self.send_message(
mto=jid,
mfrom=self.boundjid.bare,
mhtml=message
)
NOTE NOTE
1) Self presence 1) Self presence
@ -51,7 +60,7 @@ from slixmpp.plugins.xep_0363.http_upload import FileTooBig, HTTPError, UploadSe
import datahandler import datahandler
import datetimehandler import datetimehandler
import filehandler import filehandler
import filterhandler import listhandler
import sqlitehandler import sqlitehandler
import taskhandler import taskhandler
@ -105,8 +114,8 @@ class Slixfeed(slixmpp.ClientXMPP):
self.add_event_handler("message", self.message) self.add_event_handler("message", self.message)
self.add_event_handler("message", self.settle) self.add_event_handler("message", self.settle)
self.add_event_handler("groupchat_invite", self.accept_muc_invite) self.add_event_handler("groupchat_invite", self.process_muc_invite) # XEP_0045
self.add_event_handler("groupchat_direct_invite", self.accept_muc_invite) self.add_event_handler("groupchat_direct_invite", self.process_muc_invite) # XEP_0249
# self.add_event_handler("groupchat_message", self.message) # self.add_event_handler("groupchat_message", self.message)
# self.add_event_handler("disconnected", self.reconnect) # self.add_event_handler("disconnected", self.reconnect)
@ -190,27 +199,58 @@ class Slixfeed(slixmpp.ClientXMPP):
print("reactions") print("reactions")
print(message) print(message)
async def accept_muc_invite(self, message): # async def accept_muc_invite(self, message, ctr=None):
ctr = message["from"].bare # # if isinstance(message, str):
jid = message['groupchat_invite']['jid'] # if not ctr:
tkn = randrange(10000, 99999) # ctr = message["from"].bare
# jid = message['groupchat_invite']['jid']
# else:
# jid = message
async def process_muc_invite(self, message):
# operator muc_chat
inviter = message["from"].bare
muc_jid = message['groupchat_invite']['jid']
await self.join_muc(inviter, muc_jid)
async def join_muc(self, inviter, muc_jid):
token = await filehandler.initdb(
muc_jid,
sqlitehandler.get_settings_value,
"token"
)
if token != "accepted":
token = randrange(10000, 99999)
await filehandler.initdb(
muc_jid,
sqlitehandler.set_settings_value,
["token", token]
)
self.send_message(
mto=inviter,
mbody=(
"Send activation token {} to groupchat xmpp:{}?join."
).format(token, muc_jid)
)
self.plugin['xep_0045'].join_muc( self.plugin['xep_0045'].join_muc(
jid, muc_jid,
"Slixfeed (RSS News Bot)", "Slixfeed (RSS News Bot)",
# If a room password is needed, use: # If a room password is needed, use:
# password=the_room_password, # password=the_room_password,
) )
self.send_message(
mto=ctr,
mbody=(
"Send activation token {} to groupchat xmpp:{}?join."
).format(tkn, jid)
)
# self.add_event_handler( # self.add_event_handler(
# "muc::[room]::message", # "muc::[room]::message",
# self.message # self.message
# ) # )
# await self.get_bookmarks()
# bookmark = self.plugin['xep_0048'].instantiate_pep()
# print(bookmark)
# nick = "Slixfeed (RSS News Bot)"
# bookmark.add_bookmark(muc_jid, nick=nick)
# await self['xep_0048'].set_bookmarks(bookmark)
# print(bookmark)
async def on_session_end(self, event): async def on_session_end(self, event):
print(await datetimehandler.current_time(), "Session ended. Attempting to reconnect.") print(await datetimehandler.current_time(), "Session ended. Attempting to reconnect.")
@ -341,6 +381,31 @@ class Slixfeed(slixmpp.ClientXMPP):
# await taskhandler.select_file() # await taskhandler.select_file()
async def is_muc(self, jid):
"""
Check whether a JID is of MUC.
Parameters
----------
jid : str
Jabber ID.
Returns
-------
boolean
True or False.
"""
iqresult = await self["xep_0030"].get_info(jid=jid)
features = iqresult["disco_info"]["features"]
# identity = iqresult['disco_info']['identities']
# if 'account' in indentity:
# if 'conference' in indentity:
if 'http://jabber.org/protocol/muc' in features:
return True
else:
return False
async def settle(self, msg): async def settle(self, msg):
""" """
Add JID to roster and settle subscription. Add JID to roster and settle subscription.
@ -355,42 +420,46 @@ class Slixfeed(slixmpp.ClientXMPP):
None. None.
""" """
jid = msg["from"].bare jid = msg["from"].bare
await self.get_roster() if await self.is_muc(jid):
# Check whether JID is in roster; otherwise, add it. # Check whether JID is in bookmarks; otherwise, add it.
if jid not in self.client_roster.keys(): print(jid, "is muc")
self.send_presence_subscription( else:
pto=jid, await self.get_roster()
ptype="subscribe", # Check whether JID is in roster; otherwise, add it.
pnick="Slixfeed RSS News Bot" if jid not in self.client_roster.keys():
) self.send_presence_subscription(
self.update_roster( pto=jid,
jid, ptype="subscribe",
subscription="both" pnick="Slixfeed RSS News Bot"
) )
# Check whether JID is subscribed; otherwise, ask for presence. self.update_roster(
if not self.client_roster[jid]["to"]: jid,
self.send_presence_subscription( subscription="both"
pto=jid, )
pfrom=self.boundjid.bare, # Check whether JID is subscribed; otherwise, ask for presence.
ptype="subscribe", if not self.client_roster[jid]["to"]:
pnick="Slixfeed RSS News Bot" self.send_presence_subscription(
) pto=jid,
self.send_message( pfrom=self.boundjid.bare,
mto=jid, ptype="subscribe",
mtype="headline", pnick="Slixfeed RSS News Bot"
msubject="RSS News Bot", )
mbody=("Accept subscription request to receive updates."), self.send_message(
mfrom=self.boundjid.bare, mto=jid,
mnick="Slixfeed RSS News Bot" # mtype="headline",
) msubject="RSS News Bot",
self.send_presence( mbody="Accept subscription request to receive updates.",
pto=jid, mfrom=self.boundjid.bare,
pfrom=self.boundjid.bare, mnick="Slixfeed RSS News Bot"
# Accept symbol 🉑️ 👍️ ✍ )
pstatus="✒️ Accept subscription request to receive updates", self.send_presence(
# ptype="subscribe", pto=jid,
pnick="Slixfeed RSS News Bot" pfrom=self.boundjid.bare,
) # Accept symbol 🉑️ 👍️ ✍
pstatus="✒️ Accept subscription request to receive updates",
# ptype="subscribe",
pnick="Slixfeed RSS News Bot"
)
async def presence_unsubscribe(self, presence): async def presence_unsubscribe(self, presence):
@ -436,27 +505,36 @@ class Slixfeed(slixmpp.ClientXMPP):
action = 0 action = 0
jid = msg["from"].bare jid = msg["from"].bare
if msg["type"] == "groupchat": if msg["type"] == "groupchat":
ctr = await filehandler.initdb( # nick = msg["from"][msg["from"].index("/")+1:]
nick = str(msg["from"])
nick = nick[nick.index("/")+1:]
if (msg['muc']['nick'] == "Slixfeed (RSS News Bot)" or
not msg["body"].startswith("!")):
return
token = await filehandler.initdb(
jid, jid,
sqlitehandler.get_settings_value, sqlitehandler.get_settings_value,
"masters" "token"
) )
if (msg["from"][msg["from"].index("/")+1:] not in ctr if token == "accepted":
or not msg["body"].startswith("!")): operator = await filehandler.initdb(
return jid,
sqlitehandler.get_settings_value,
"masters"
)
if operator:
if nick not in operator:
return
# # Begin processing new JID # # Begin processing new JID
# # Deprecated in favour of event "presence_available" # # Deprecated in favour of event "presence_available"
# db_dir = filehandler.get_default_dbdir() # db_dir = filehandler.get_default_dbdir()
# os.chdir(db_dir) # os.chdir(db_dir)
# if jid + ".db" not in os.listdir(): # if jid + ".db" not in os.listdir():
# await taskhandler.task_jid(jid) # await taskhandler.task_jid(jid)
print(msg["body"])
print(msg["body"].split())
message = " ".join(msg["body"].split()) message = " ".join(msg["body"].split())
if msg["type"] == "groupchat": if msg["type"] == "groupchat":
message = message[1:] message = message[1:]
print(message)
message_lowercase = message.lower() message_lowercase = message.lower()
print(await datetimehandler.current_time(), "ACCOUNT: " + str(msg["from"])) print(await datetimehandler.current_time(), "ACCOUNT: " + str(msg["from"]))
@ -482,6 +560,33 @@ class Slixfeed(slixmpp.ClientXMPP):
print(self.client_roster) print(self.client_roster)
print("roster 2") print("roster 2")
print(self.client_roster.keys()) print(self.client_roster.keys())
print("jid")
print(jid)
case _ if message_lowercase.startswith("activate"):
if msg["type"] == "groupchat":
acode = message[9:]
token = await filehandler.initdb(
jid,
sqlitehandler.get_settings_value,
"token"
)
if int(acode) == token:
await filehandler.initdb(
jid,
sqlitehandler.set_settings_value,
["masters", nick]
)
await filehandler.initdb(
jid,
sqlitehandler.set_settings_value,
["token", "accepted"]
)
action = "{}, your are in command.".format(nick)
else:
action = "Activation code is not valid."
else:
action = "This command is valid for groupchat only."
case _ if message_lowercase.startswith("add"): case _ if message_lowercase.startswith("add"):
message = message[4:] message = message[4:]
url = message.split(" ")[0] url = message.split(" ")[0]
@ -510,7 +615,7 @@ class Slixfeed(slixmpp.ClientXMPP):
sqlitehandler.get_settings_value, sqlitehandler.get_settings_value,
key key
) )
val = await filterhandler.set_filter( val = await listhandler.set_list(
val, val,
keywords keywords
) )
@ -534,7 +639,7 @@ class Slixfeed(slixmpp.ClientXMPP):
sqlitehandler.get_settings_value, sqlitehandler.get_settings_value,
key key
) )
val = await filterhandler.set_filter( val = await listhandler.set_list(
val, val,
keywords keywords
) )
@ -629,6 +734,33 @@ class Slixfeed(slixmpp.ClientXMPP):
).format(val) ).format(val)
else: else:
action = "Missing value." action = "Missing value."
case _ if message_lowercase.startswith("join"):
muc = message[5:]
await self.join_muc(jid, muc)
case _ if message_lowercase.startswith("mastership"):
key = message[:7]
val = message[11:]
if val:
names = await filehandler.initdb(
jid,
sqlitehandler.get_settings_value,
key
)
val = await listhandler.set_list(
val,
names
)
await filehandler.initdb(
jid,
sqlitehandler.set_settings_value,
[key, val]
)
action = (
"Operators\n"
"```\n{}\n```"
).format(val)
else:
action = "Missing value."
case _ if message_lowercase.startswith("next"): case _ if message_lowercase.startswith("next"):
num = message[5:] num = message[5:]
await taskhandler.clean_tasks_xmpp( await taskhandler.clean_tasks_xmpp(
@ -675,16 +807,29 @@ class Slixfeed(slixmpp.ClientXMPP):
case "random": case "random":
action = "Updates will be sent randomly." action = "Updates will be sent randomly."
case _ if message_lowercase.startswith("read"): case _ if message_lowercase.startswith("read"):
url = message[5:] data = message[5:]
if url.startswith("http"): data = data.split()
# action = await datahandler.view_feed(url) url = data[0]
action = await filehandler.initdb( if url.startswith("feed:"):
jid, url = await datahandler.feed_to_http(url)
datahandler.view_feed, match len(data):
url case 1:
) if url.startswith("http"):
else: action = await datahandler.view_feed(url)
action = "Missing URL." else:
action = "Missing URL."
case 2:
num = data[1]
if url.startswith("http"):
action = await datahandler.view_entry(url, num)
else:
action = "Missing URL."
case _:
action = (
"Enter command as follows:\n"
"`read URL` or `read URL NUMBER`\n"
"URL must not contain white space."
)
case _ if message_lowercase.startswith("recent"): case _ if message_lowercase.startswith("recent"):
num = message[7:] num = message[7:]
if num: if num:
@ -759,16 +904,6 @@ class Slixfeed(slixmpp.ClientXMPP):
jid, jid,
sqlitehandler.statistics sqlitehandler.statistics
) )
case _ if message_lowercase.startswith("select"):
num = message[7:]
if num:
action = await filehandler.initdb(
jid,
datahandler.view_entry,
num
)
else:
action = "Missing number."
case _ if message_lowercase.startswith("status "): case _ if message_lowercase.startswith("status "):
ix = message[7:] ix = message[7:]
action = await filehandler.initdb( action = await filehandler.initdb(
@ -878,8 +1013,6 @@ def print_info():
" GNU General Public License for more details.\n" " GNU General Public License for more details.\n"
"\n" "\n"
"NOTE\n" "NOTE\n"
" Make Slixfeed your own.\n"
"\n"
" You can run Slixfeed on your own computer, server, and\n" " You can run Slixfeed on your own computer, server, and\n"
" even on a Linux phone (i.e. Droidian, Mobian NixOS,\n" " even on a Linux phone (i.e. Droidian, Mobian NixOS,\n"
" postmarketOS). You can also use Termux.\n" " postmarketOS). You can also use Termux.\n"
@ -919,30 +1052,34 @@ def print_help():
" For more information, visit https://xmpp.org/software/\n" " For more information, visit https://xmpp.org/software/\n"
"\n" "\n"
"BASIC USAGE\n" "BASIC USAGE\n"
" start\n"
" Enable bot and send updates.\n"
" stop\n"
" Disable bot and stop updates.\n"
" URL\n" " URL\n"
" Add URL to subscription list.\n" " Add URL to subscription list.\n"
" add URL TITLE\n" " add URL TITLE\n"
" Add URL to subscription list (without validity check).\n" " Add URL to subscription list (without validity check).\n"
" feeds\n" " join MUC\n"
" List subscriptions.\n" " Join specified groupchat.\n"
" read URL\n"
" Display most recent 20 titles of given URL.\n"
" read URL N\n"
" Display specified entry number from given URL.\n"
"\n"
"MESSAGE OPTIONS\n"
" start\n"
" Enable bot and send updates.\n"
" stop\n"
" Disable bot and stop updates.\n"
" interval N\n" " interval N\n"
" Set interval update to every N minutes.\n" " Set interval update to every N minutes.\n"
" next N\n" " next N\n"
" Send N next updates.\n" " Send N next updates.\n"
" quantum N\n" " quantum N\n"
" Set amount of updates for each interval.\n" " Set N amount of updates per interval.\n"
" read URL\n"
" Display most recent 20 titles of given URL.\n"
" read URL NUM\n"
" Display specified entry from given URL.\n"
"\n" "\n"
"GROUPCHAT OPTIONS\n" "GROUPCHAT OPTIONS\n"
" ! (command initiation)\n" " ! (command initiation)\n"
" Use exclamation mark to initiate an actionable command.\n" " Use exclamation mark to initiate an actionable command.\n"
" activate CODE\n"
" Activate and command bot.\n"
" demaster NICKNAME\n" " demaster NICKNAME\n"
" Remove master privilege.\n" " Remove master privilege.\n"
" mastership NICKNAME\n" " mastership NICKNAME\n"
@ -967,6 +1104,8 @@ def print_help():
" Toggle update status of feed.\n" " Toggle update status of feed.\n"
"\n" "\n"
"SEARCH OPTIONS\n" "SEARCH OPTIONS\n"
" feeds\n"
" List all subscriptions.\n"
" feeds TEXT\n" " feeds TEXT\n"
" Search subscriptions by given keywords.\n" " Search subscriptions by given keywords.\n"
" search TEXT\n" " search TEXT\n"