]> git.0d.be Git - botaradio.git/blob - mumbleBot.py
feat: add tags, remove tags, play tags, find tags #91
[botaradio.git] / mumbleBot.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
3
4 import threading
5 import time
6 import sys
7 import math
8 import signal
9 import configparser
10 import audioop
11 import subprocess as sp
12 import argparse
13 import os
14 import os.path
15 import pymumble.pymumble_py3 as pymumble
16 import variables as var
17 import hashlib
18 import youtube_dl
19 import logging
20 import logging.handlers
21 import traceback
22 from packaging import version
23
24 import util
25 import command
26 import constants
27 from database import SettingsDatabase, MusicDatabase
28 import media.url
29 import media.file
30 import media.radio
31 import media.system
32 from media.playlist import BasePlaylist
33 from media.library import MusicLibrary
34
35
36 class MumbleBot:
37     version = '5.2'
38
39     def __init__(self, args):
40         self.log = logging.getLogger("bot")
41         self.log.info("bot: botamusique version %s, starting..." % self.version)
42         signal.signal(signal.SIGINT, self.ctrl_caught)
43         self.cmd_handle = {}
44         self.volume_set = var.config.getfloat('bot', 'volume')
45         if var.db.has_option('bot', 'volume'):
46             self.volume_set = var.db.getfloat('bot', 'volume')
47
48         self.volume = self.volume_set
49
50         if args.channel:
51             self.channel = args.channel
52         else:
53             self.channel = var.config.get("server", "channel", fallback=None)
54
55         if args.verbose:
56             self.log.setLevel(logging.DEBUG)
57             self.log.debug("Starting in DEBUG loglevel")
58         elif args.quiet:
59             self.log.setLevel(logging.ERROR)
60             self.log.error("Starting in ERROR loglevel")
61
62         var.user = args.user
63         var.music_folder = util.solve_filepath(var.config.get('bot', 'music_folder'))
64         var.tmp_folder = util.solve_filepath(var.config.get('bot', 'tmp_folder'))
65         var.is_proxified = var.config.getboolean(
66             "webinterface", "is_web_proxified")
67         self.exit = False
68         self.nb_exit = 0
69         self.thread = None
70         self.thread_stderr = None
71         self.is_pause = False
72         self.pause_at_id = ""
73         self.playhead = -1
74         self.song_start_at = -1
75         #self.download_threads = []
76         self.wait_for_downloading = False # flag for the loop are waiting for download to complete in the other thread
77
78         if var.config.getboolean("webinterface", "enabled"):
79             wi_addr = var.config.get("webinterface", "listening_addr")
80             wi_port = var.config.getint("webinterface", "listening_port")
81             tt = threading.Thread(
82                 target=start_web_interface, name="WebThread", args=(wi_addr, wi_port))
83             tt.daemon = True
84             self.log.info('Starting web interface on {}:{}'.format(wi_addr, wi_port))
85             tt.start()
86
87         if var.config.getboolean("bot", "auto_check_update"):
88             th = threading.Thread(target=self.check_update, name="UpdateThread")
89             th.daemon = True
90             th.start()
91
92         if args.host:
93             host = args.host
94         else:
95             host = var.config.get("server", "host")
96
97         if args.port:
98             port = args.port
99         else:
100             port = var.config.getint("server", "port")
101
102         if args.password:
103             password = args.password
104         else:
105             password = var.config.get("server", "password")
106
107         if args.channel:
108             self.channel = args.channel
109         else:
110             self.channel = var.config.get("server", "channel")
111
112         if args.certificate:
113             certificate = args.certificate
114         else:
115             certificate = util.solve_filepath(var.config.get("server", "certificate"))
116
117         if args.tokens:
118             tokens = args.tokens
119         else:
120             tokens = var.config.get("server", "tokens")
121             tokens = tokens.split(',')
122
123         if args.user:
124             self.username = args.user
125         else:
126             self.username = var.config.get("bot", "username")
127
128         self.mumble = pymumble.Mumble(host, user=self.username, port=port, password=password, tokens=tokens,
129                                       debug=var.config.getboolean('debug', 'mumbleConnection'), certfile=certificate)
130         self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, self.message_received)
131
132         self.mumble.set_codec_profile("audio")
133         self.mumble.start()  # start the mumble thread
134         self.mumble.is_ready()  # wait for the connection
135         self.set_comment()
136         self.mumble.users.myself.unmute()  # by sure the user is not muted
137         if self.channel:
138             self.mumble.channels.find_by_name(self.channel).move_in()
139         self.mumble.set_bandwidth(200000)
140
141         self.is_ducking = False
142         self.on_ducking = False
143         self.ducking_release = time.time()
144
145         if not var.db.has_option("bot", "ducking") and var.config.getboolean("bot", "ducking", fallback=False)\
146                 or var.config.getboolean("bot", "ducking"):
147             self.is_ducking = True
148             self.ducking_volume = var.config.getfloat("bot", "ducking_volume", fallback=0.05)
149             self.ducking_volume = var.db.getfloat("bot", "ducking_volume", fallback=self.ducking_volume)
150             self.ducking_threshold = var.config.getfloat("bot", "ducking_threshold", fallback=5000)
151             self.ducking_threshold = var.db.getfloat("bot", "ducking_threshold", fallback=self.ducking_threshold)
152             self.mumble.callbacks.set_callback(pymumble.constants.PYMUMBLE_CLBK_SOUNDRECEIVED, \
153                                                self.ducking_sound_received)
154             self.mumble.set_receive_sound(True)
155
156         # Debug use
157         self._loop_status = 'Idle'
158         self._display_rms = False
159         self._max_rms = 0
160
161     # Set the CTRL+C shortcut
162     def ctrl_caught(self, signal, frame):
163
164         self.log.info(
165             "\nSIGINT caught, quitting, {} more to kill".format(2 - self.nb_exit))
166         self.exit = True
167         self.pause()
168         if self.nb_exit > 1:
169             self.log.info("Forced Quit")
170             sys.exit(0)
171         self.nb_exit += 1
172
173         if var.config.getboolean('bot', 'save_playlist', fallback=True) \
174                     and var.config.get("bot", "save_music_library", fallback=True):
175             self.log.info("bot: save playlist into database")
176             var.playlist.save()
177
178     def check_update(self):
179         self.log.debug("update: checking for updates...")
180         new_version = util.new_release_version()
181         if version.parse(new_version) > version.parse(self.version):
182             self.log.info("update: new version %s found, current installed version %s." % (new_version, self.version))
183             self.send_msg(constants.strings('new_version_found'))
184         else:
185             self.log.debug("update: no new version found.")
186
187     def register_command(self, cmd, handle, no_partial_match=False):
188         cmds = cmd.split(",")
189         for command in cmds:
190             command = command.strip()
191             if command:
192                 self.cmd_handle[command] = { 'handle': handle, 'partial_match': not no_partial_match}
193                 self.log.debug("bot: command added: " + command)
194
195     def set_comment(self):
196         self.mumble.users.myself.comment(var.config.get('bot', 'comment'))
197
198     # =======================
199     #         Message
200     # =======================
201
202     # All text send to the chat is analysed by this function
203     def message_received(self, text):
204         message = text.message.strip()
205         user = self.mumble.users[text.actor]['name']
206
207         if var.config.getboolean('commands', 'split_username_at_space'):
208             # in can you use https://github.com/Natenom/mumblemoderator-module-collection/tree/master/os-suffixes ,
209             # you want to split the username
210             user = user.split()[0]
211
212         if message[0] in var.config.get('commands', 'command_symbol'):
213             # remove the symbol from the message
214             message = message[1:].split(' ', 1)
215
216             # use the first word as a command, the others one as  parameters
217             if len(message) > 0:
218                 command = message[0]
219                 parameter = ''
220                 if len(message) > 1:
221                     parameter = message[1].rstrip()
222             else:
223                 return
224
225             self.log.info('bot: received command ' + command + ' - ' + parameter + ' by ' + user)
226
227             # Anti stupid guy function
228             if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_other_channel_message') \
229                     and self.mumble.users[text.actor]['channel_id'] != self.mumble.users.myself['channel_id']:
230                 self.mumble.users[text.actor].send_text_message(
231                     constants.strings('not_in_my_channel'))
232                 return
233
234             if not self.is_admin(user) and not var.config.getboolean('bot', 'allow_private_message') and text.session:
235                 self.mumble.users[text.actor].send_text_message(
236                     constants.strings('pm_not_allowed'))
237                 return
238
239             for i in var.db.items("user_ban"):
240                 if user.lower() == i[0]:
241                     self.mumble.users[text.actor].send_text_message(
242                         constants.strings('user_ban'))
243                     return
244
245             if parameter:
246                 for i in var.db.items("url_ban"):
247                     if util.get_url_from_input(parameter.lower()) == i[0]:
248                         self.mumble.users[text.actor].send_text_message(
249                             constants.strings('url_ban'))
250                         return
251
252
253             command_exc = ""
254             try:
255                 if command in self.cmd_handle:
256                     command_exc = command
257                     self.cmd_handle[command]['handle'](self, user, text, command, parameter)
258                 else:
259                     # try partial match
260                     cmds = self.cmd_handle.keys()
261                     matches = []
262                     for cmd in cmds:
263                         if cmd.startswith(command) and self.cmd_handle[cmd]['partial_match']:
264                             matches.append(cmd)
265
266                     if len(matches) == 1:
267                         self.log.info("bot: {:s} matches {:s}".format(command, matches[0]))
268                         command_exc = matches[0]
269                         self.cmd_handle[command_exc]['handle'](self, user, text, command_exc, parameter)
270                     elif len(matches) > 1:
271                         self.mumble.users[text.actor].send_text_message(
272                             constants.strings('which_command', commands="<br>".join(matches)))
273                     else:
274                         self.mumble.users[text.actor].send_text_message(
275                             constants.strings('bad_command', command=command))
276             except:
277                 error_traceback = traceback.format_exc()
278                 error = error_traceback.rstrip().split("\n")[-1]
279                 self.log.error("bot: command %s failed with error: %s\n" % (command_exc, error_traceback))
280                 self.send_msg(constants.strings('error_executing_command', command=command_exc, error=error), text)
281
282     def send_msg(self, msg, text=None):
283         msg = msg.encode('utf-8', 'ignore').decode('utf-8')
284         # text if the object message, contain information if direct message or channel message
285         if not text or not text.session:
286             own_channel = self.mumble.channels[self.mumble.users.myself['channel_id']]
287             own_channel.send_text_message(msg)
288         else:
289             self.mumble.users[text.actor].send_text_message(msg)
290
291     def is_admin(self, user):
292         list_admin = var.config.get('bot', 'admin').rstrip().split(';')
293         if user in list_admin:
294             return True
295         else:
296             return False
297
298     # =======================
299     #   Launch and Download
300     # =======================
301
302     def launch_music(self):
303         if var.playlist.is_empty():
304             return
305         assert self.wait_for_downloading == False
306
307         music_wrapper = var.playlist.current_item()
308         uri = music_wrapper.uri()
309
310         self.log.info("bot: play music " + music_wrapper.format_debug_string())
311
312         if var.config.getboolean('bot', 'announce_current_music'):
313             self.send_msg(music_wrapper.format_current_playing())
314
315         if var.config.getboolean('debug', 'ffmpeg'):
316             ffmpeg_debug = "debug"
317         else:
318             ffmpeg_debug = "warning"
319
320         command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-i',
321                    uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
322         self.log.debug("bot: execute ffmpeg command: " + " ".join(command))
323
324         # The ffmpeg process is a thread
325         # prepare pipe for catching stderr of ffmpeg
326         pipe_rd, pipe_wd = os.pipe()
327         util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
328         self.thread_stderr = os.fdopen(pipe_rd)
329         self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
330         self.is_pause = False
331         self.song_start_at = -1
332         self.playhead = 0
333         self.last_volume_cycle_time = time.time()
334
335     def async_download_next(self):
336         # Function start if the next music isn't ready
337         # Do nothing in case the next music is already downloaded
338         self.log.debug("bot: Async download next asked ")
339         while var.playlist.next_item() and var.playlist.next_item().type in ['url', 'url_from_playlist']:
340             # usually, all validation will be done when adding to the list.
341             # however, for performance consideration, youtube playlist won't be validate when added.
342             # the validation has to be done here.
343             next = var.playlist.next_item()
344             if next.validate():
345                 if not next.is_ready():
346                     next.async_prepare()
347                 break
348             else:
349                 var.playlist.remove_by_id(next.id)
350                 var.library.delete(next.id)
351
352
353     # =======================
354     #          Loop
355     # =======================
356
357     # Main loop of the Bot
358     def loop(self):
359         raw_music = ""
360         while not self.exit and self.mumble.is_alive():
361
362             while self.thread and self.mumble.sound_output.get_buffer_size() > 0.5 and not self.exit:
363                 # If the buffer isn't empty, I cannot send new music part, so I wait
364                 self._loop_status = 'Wait for buffer %.3f' % self.mumble.sound_output.get_buffer_size()
365                 time.sleep(0.01)
366
367             if self.thread:
368                 # I get raw from ffmpeg thread
369                 # move playhead forward
370                 self._loop_status = 'Reading raw'
371                 if self.song_start_at == -1:
372                     self.song_start_at = time.time() - self.playhead
373                 self.playhead = time.time() - self.song_start_at
374
375                 raw_music = self.thread.stdout.read(480)
376
377                 try:
378                     stderr_msg = self.thread_stderr.readline()
379                     if stderr_msg:
380                         self.log.debug("ffmpeg: " + stderr_msg.strip("\n"))
381                 except:
382                     pass
383
384                 if raw_music:
385                     # Adjust the volume and send it to mumble
386                     self.volume_cycle()
387                     self.mumble.sound_output.add_sound(
388                         audioop.mul(raw_music, 2, self.volume))
389                 else:
390                     time.sleep(0.1)
391             else:
392                 time.sleep(0.1)
393
394             if not self.is_pause and (self.thread is None or not raw_music):
395                 # ffmpeg thread has gone. indicate that last song has finished. move to the next song.
396                 if not self.wait_for_downloading:
397                     if var.playlist.next():
398                         current = var.playlist.current_item()
399                         if current.validate():
400                             if current.is_ready():
401                                 self.launch_music()
402                                 self.async_download_next()
403                             else:
404                                 self.log.info("bot: current music isn't ready, start downloading.")
405                                 self.wait_for_downloading = True
406                                 current.async_prepare()
407                                 self.send_msg(constants.strings('download_in_progress', item=current.format_short_string()))
408                         else:
409                             var.playlist.remove_by_id(current.id)
410                             var.library.delete(current.id)
411                     else:
412                         self._loop_status = 'Empty queue'
413                 else:
414                     current = var.playlist.current_item()
415                     if current:
416                         if current.is_ready():
417                             self.wait_for_downloading = False
418                             self.launch_music()
419                             self.async_download_next()
420                         elif current.is_failed():
421                             var.playlist.remove_by_id(current.id)
422                         else:
423                             self._loop_status = 'Wait for downloading'
424                     else:
425                         self.wait_for_downloading = False
426
427         while self.mumble.sound_output.get_buffer_size() > 0:
428             # Empty the buffer before exit
429             time.sleep(0.01)
430         time.sleep(0.5)
431
432         if self.exit:
433             self._loop_status = "exited"
434             if var.config.getboolean('bot', 'save_playlist', fallback=True) \
435                     and var.config.get("bot", "save_music_library", fallback=True):
436                 self.log.info("bot: save playlist into database")
437                 var.playlist.save()
438
439     def volume_cycle(self):
440         delta = time.time() - self.last_volume_cycle_time
441
442         if self.on_ducking and self.ducking_release < time.time():
443             self._clear_pymumble_soundqueue()
444             self.on_ducking = False
445             self._max_rms = 0
446
447         if delta > 0.001:
448             if self.is_ducking and self.on_ducking:
449                 self.volume = (self.volume - self.ducking_volume) * math.exp(- delta / 0.2) + self.ducking_volume
450             else:
451                 self.volume = self.volume_set - (self.volume_set - self.volume) * math.exp(- delta / 0.5)
452
453         self.last_volume_cycle_time = time.time()
454
455     def ducking_sound_received(self, user, sound):
456         rms = audioop.rms(sound.pcm, 2)
457         self._max_rms = max(rms, self._max_rms)
458         if self._display_rms:
459             if rms < self.ducking_threshold:
460                 print('%6d/%6d  ' % (rms, self._max_rms) + '-'*int(rms/200), end='\r')
461             else:
462                 print('%6d/%6d  ' % (rms, self._max_rms) + '-'*int(self.ducking_threshold/200) \
463                       + '+'*int((rms - self.ducking_threshold)/200), end='\r')
464
465         if rms > self.ducking_threshold:
466             if self.on_ducking is False:
467                 self.log.debug("bot: ducking triggered")
468                 self.on_ducking = True
469             self.ducking_release = time.time() + 1 # ducking release after 1s
470
471     # =======================
472     #      Play Control
473     # =======================
474
475     def clear(self):
476         # Kill the ffmpeg thread and empty the playlist
477         if self.thread:
478             self.thread.kill()
479             self.thread = None
480         var.playlist.clear()
481         self.log.info("bot: music stopped. playlist trashed.")
482
483     def stop(self):
484         self.interrupt()
485         self.is_pause = True
486         self.log.info("bot: music stopped.")
487
488     def interrupt(self):
489         # Kill the ffmpeg thread
490         if self.thread:
491             self.thread.kill()
492             self.thread = None
493         self.song_start_at = -1
494         self.playhead = 0
495
496     def pause(self):
497         # Kill the ffmpeg thread
498         if self.thread:
499             self.pause_at_id = var.playlist.current_item()
500             self.thread.kill()
501             self.thread = None
502         self.is_pause = True
503         self.song_start_at = -1
504         self.log.info("bot: music paused at %.2f seconds." % self.playhead)
505
506     def resume(self):
507         self.is_pause = False
508
509         if var.playlist.current_index == -1:
510             var.playlist.next()
511
512         music_wrapper = var.playlist.current_item()
513
514         if not music_wrapper or not music_wrapper.id == self.pause_at_id or not music_wrapper.is_ready():
515             self.playhead = 0
516             return
517
518         if var.config.getboolean('debug', 'ffmpeg'):
519             ffmpeg_debug = "debug"
520         else:
521             ffmpeg_debug = "warning"
522
523         self.log.info("bot: resume music at %.2f seconds" % self.playhead)
524
525         uri = music_wrapper.uri()
526
527         command = ("ffmpeg", '-v', ffmpeg_debug, '-nostdin', '-ss', "%f" % self.playhead, '-i',
528                    uri, '-ac', '1', '-f', 's16le', '-ar', '48000', '-')
529
530
531         if var.config.getboolean('bot', 'announce_current_music'):
532             self.send_msg(var.playlist.current_item().format_current_playing())
533
534         self.log.info("bot: execute ffmpeg command: " + " ".join(command))
535         # The ffmpeg process is a thread
536         # prepare pipe for catching stderr of ffmpeg
537         pipe_rd, pipe_wd = os.pipe()
538         util.pipe_no_wait(pipe_rd) # Let the pipe work in non-blocking mode
539         self.thread_stderr = os.fdopen(pipe_rd)
540         self.thread = sp.Popen(command, stdout=sp.PIPE, stderr=pipe_wd, bufsize=480)
541         self.last_volume_cycle_time = time.time()
542         self.pause_at_id = ""
543
544
545     # TODO: this is a temporary workaround for issue #44 of pymumble.
546     def _clear_pymumble_soundqueue(self):
547         for id, user in self.mumble.users.items():
548             user.sound.lock.acquire()
549             user.sound.queue.clear()
550             user.sound.lock.release()
551         self.log.debug("bot: pymumble soundqueue cleared.")
552
553
554
555 def start_web_interface(addr, port):
556     global formatter
557     import interface
558
559     # setup logger
560     werkzeug_logger = logging.getLogger('werkzeug')
561     logfile = util.solve_filepath(var.config.get('webinterface', 'web_logfile'))
562     handler = None
563     if logfile:
564         handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
565     else:
566         handler = logging.StreamHandler()
567
568     werkzeug_logger.addHandler(handler)
569
570     interface.init_proxy()
571     interface.web.env = 'development'
572     interface.web.run(port=port, host=addr)
573
574
575 if __name__ == '__main__':
576     parser = argparse.ArgumentParser(
577         description='Bot for playing music on Mumble')
578
579     # General arguments
580     parser.add_argument("--config", dest='config', type=str, default='configuration.ini',
581                         help='Load configuration from this file. Default: configuration.ini')
582     parser.add_argument("--db", dest='db', type=str,
583                         default=None, help='database file. Default: database.db')
584
585     parser.add_argument("-q", "--quiet", dest="quiet",
586                         action="store_true", help="Only Error logs")
587     parser.add_argument("-v", "--verbose", dest="verbose",
588                         action="store_true", help="Show debug log")
589
590     # Mumble arguments
591     parser.add_argument("-s", "--server", dest="host",
592                         type=str, help="Hostname of the Mumble server")
593     parser.add_argument("-u", "--user", dest="user",
594                         type=str, help="Username for the bot")
595     parser.add_argument("-P", "--password", dest="password",
596                         type=str, help="Server password, if required")
597     parser.add_argument("-T", "--tokens", dest="tokens",
598                         type=str, help="Server tokens, if required")
599     parser.add_argument("-p", "--port", dest="port",
600                         type=int, help="Port for the Mumble server")
601     parser.add_argument("-c", "--channel", dest="channel",
602                         type=str, help="Default channel for the bot")
603     parser.add_argument("-C", "--cert", dest="certificate",
604                         type=str, default=None, help="Certificate file")
605
606     args = parser.parse_args()
607
608     config = configparser.ConfigParser(interpolation=None, allow_no_value=True)
609     parsed_configs = config.read([util.solve_filepath('configuration.default.ini'), util.solve_filepath(args.config)],
610                                  encoding='utf-8')
611     var.dbfile = args.db if args.db is not None else util.solve_filepath(
612         config.get("bot", "database_path", fallback="database.db"))
613
614     if len(parsed_configs) == 0:
615         logging.error('Could not read configuration from file \"{}\"'.format(args.config))
616         sys.exit()
617
618     var.config = config
619     var.db = SettingsDatabase(var.dbfile)
620
621     # Setup logger
622     bot_logger = logging.getLogger("bot")
623     formatter = logging.Formatter('[%(asctime)s %(levelname)s %(threadName)s] %(message)s', "%b %d %H:%M:%S")
624     bot_logger.setLevel(logging.INFO)
625
626     logfile = util.solve_filepath(var.config.get('bot', 'logfile'))
627     handler = None
628     if logfile:
629         handler = logging.handlers.RotatingFileHandler(logfile, mode='a', maxBytes=10240) # Rotate after 10KB
630     else:
631         handler = logging.StreamHandler()
632
633     handler.setFormatter(formatter)
634     bot_logger.addHandler(handler)
635     var.bot_logger = bot_logger
636
637     if var.config.get("bot", "save_music_library", fallback=True):
638         var.music_db = MusicDatabase(var.dbfile)
639     else:
640         var.music_db = MusicDatabase(":memory:")
641
642     var.library = MusicLibrary(var.music_db)
643
644     # load playback mode
645     playback_mode = None
646     if var.db.has_option("playlist", "playback_mode"):
647         playback_mode = var.db.get('playlist', 'playback_mode')
648     else:
649         playback_mode = var.config.get('bot', 'playback_mode', fallback="one-shot")
650
651     if playback_mode in ["one-shot", "repeat", "random", "autoplay"]:
652         var.playlist = media.playlist.get_playlist(playback_mode)
653     else:
654         raise KeyError("Unknown playback mode '%s'" % playback_mode)
655
656     var.bot = MumbleBot(args)
657     command.register_all_commands(var.bot)
658
659     if var.config.get("bot", "refresh_cache_on_startup", fallback=True)\
660             or not var.db.has_option("dir_cache", "files"):
661         var.library.build_dir_cache(var.bot)
662     else:
663         var.library.load_dir_cache(var.bot)
664
665     # load playlist
666     if var.config.getboolean('bot', 'save_playlist', fallback=True):
667         var.bot_logger.info("bot: load playlist from previous session")
668         var.playlist.load()
669
670     # Start the main loop.
671     var.bot.loop()