]> jfr.im git - irc/rizon/acid.git/blob - pyva/src/main/python/internets/cmd_user.py
.gitignore: Ignore all pyva logs
[irc/rizon/acid.git] / pyva / src / main / python / internets / cmd_user.py
1 import random
2 import re
3 from datetime import datetime, timedelta
4 from xml.parsers.expat import ExpatError
5
6 from pseudoclient.cmd_manager import *
7 from utils import *
8 from internets_utils import *
9 from api.feed import FeedError
10 from api.idlerpg import IrpgPlayer
11 from api.quotes import FmlException
12 from api.weather import WeatherException
13
14
15 def get_citystate_from_zipcode(self, zipcode):
16 """Return [city,state] for the given U.S. zip code (if database has been imported)"""
17 try:
18 self.dbp.execute("SELECT city, state FROM zipcode_citystate WHERE zipcode=%s", [int(zipcode)])
19 city, state = self.dbp.fetchone()
20 return city, state
21 except:
22 return None
23
24
25 def command_weather(self, manager, opts, arg, channel, sender, userinfo):
26 arg = self.get_location(opts, arg, channel, sender)
27 if not arg:
28 return
29 w_state = ''
30 if arg.isdigit():
31 location = get_citystate_from_zipcode(self, arg)
32 if location is None:
33 self.errormsg(channel, 'zip code not recognised.')
34 return False
35 city, state = location
36 location = '{city}, {state}, USA'.format(city=city, state=state)
37 w_state = state + u', '
38 else:
39 location = arg.strip()
40
41 try:
42 w = self.weather.get_conditions(location)
43 except WeatherException as exc:
44 if exc == 'this key is not valid':
45 self.elog.warning('WARNING: OpenWeatherMap API key is not correctly set (%s)' % exc)
46 self.errormsg(channel, 'weather data is temporarily unavailable. Try again later')
47 else:
48 self.errormsg(channel, exc)
49 return
50 except FeedError, e:
51 self.errormsg(channel, e.msg)
52 return
53
54 code = get_tempcolor(w['temp_c'])
55
56 self.msg(channel, format_weather(code, u"""@sep @b{w[city]}{w_state}{w[country]}@b @sep @bConditions@b {w[description]} @sep \
57 @bTemperature@b {tempcolor}{w[temp_c]}C / {w[temp_f]}F@o @sep \
58 @bPressure@b {w[pressure]}mb @sep @bHumidity@b {w[humidity]}% @sep \
59 @bRain@b {w[rain]} @sep \
60 Powered by OpenWeatherMap http://openweathermap.org/city/{w[id]} @sep""".format(w=w, tempcolor=code, w_state=w_state)))
61
62
63 def command_forecast(self, manager, opts, arg, channel, sender, userinfo):
64 arg = self.get_location(opts, arg, channel, sender)
65 if not arg:
66 return
67 w_state = ''
68 if arg.isdigit():
69 location = get_citystate_from_zipcode(self, arg)
70 if location is None:
71 self.errormsg(channel, 'zip code not recognised.')
72 return False
73 city, state = location
74 location = '{city}, {state}, USA'.format(city=city, state=state)
75 w_state = state + u', '
76 else:
77 location = arg.strip()
78
79 try:
80 w = self.weather.get_forecast(location)
81 except WeatherException as exc:
82 if exc == 'this key is not valid':
83 self.elog.warning('WARNING: OpenWeatherMap API key is not correctly set (%s)' % exc)
84 self.errormsg(channel, 'weather data is temporarily unavailable. Try again later')
85 else:
86 self.errormsg(channel, exc)
87 return
88 except FeedError, e:
89 self.errormsg(channel, e.msg)
90 return
91
92 fc = ' @sep '.join([u"""@b{day[name]}@b {day[description]} @c04{day[max_c]}C / {day[max_f]}F \
93 @c10{day[min_c]}C / {day[min_f]}F""".format(day=day) for day in w['days']])
94
95 self.msg(channel, u'@sep @b{w[city]}{w_state}{w[country]}@b @sep {fc} @sep'.format(w=w, fc=fc, w_state=w_state))
96
97
98 def command_register_location(self, manager, opts, arg, channel, sender, userinfo):
99 arg = arg.strip()
100 try:
101 w_state = ''
102 if arg.isdigit():
103 location = get_citystate_from_zipcode(self, arg)
104 if location is None:
105 self.errormsg(channel, 'zip code not recognised.')
106 return False
107 city, state = location
108 location = '{city}, {state}, USA'.format(city=city, state=state)
109 w_state = state + u', '
110 else:
111 location = arg.strip()
112
113 w = self.weather.get_conditions(location)
114 except WeatherException as exc:
115 if exc == 'this key is not valid':
116 self.elog.warning('WARNING: OpenWeatherMap API key is not correctly set (%s)' % exc)
117 self.errormsg(channel, 'weather data is temporarily unavailable. Try again later')
118 else:
119 self.errormsg(channel, exc)
120 return
121 except FeedError, e:
122 self.errormsg(channel, e.msg)
123 return
124
125 loc_name = u'{w[city]}{w_state}{w[country]}'.format(w=w, w_state=w_state)
126
127 self.users.set(sender, 'location', arg)
128 self.msg(channel, u'%s: registered location @b%s@b' % (sender, loc_name))
129
130 def command_bing_translate(self, manager, opts, arg, channel, sender, userinfo):
131 sp = arg.split(' ', 2)
132 try:
133 if len(sp) > 2:
134 source, target, text = sp
135 if source.lower() not in self.bing.languages or target.lower() not in self.bing.languages:
136 source = self.bing.detect_language(arg)
137 target = None
138 translation = self.bing.translate(arg)
139 else:
140 source = source.lower()
141 target = target.lower()
142 translation = self.bing.translate(text, source, target)
143 else:
144 source = self.bing.detect_language(arg)
145 target = None
146 translation = self.bing.translate(arg)
147 except FeedError, e:
148 self.elog.warning('WARNING: Bing translate failed: %s' % e)
149 self.errormsg(channel, e.msg)
150 return
151
152 self.msg(channel, '[t] [from %s] %s' % (source, translation))
153
154 def command_google_search(self, manager, opts, arg, channel, sender, userinfo):
155 try:
156 result = self.google.search(arg, userinfo['ip'] if userinfo['ip'] != '0' else '255.255.255.255')
157 except FeedError, e:
158 self.errormsg(channel, e.msg)
159 return
160
161 if result['responseStatus'] == 403:
162 self.elog.warning('WARNING: Google Search failed: %s' % result['responseDetails'] if 'responseDetails' in result else 'unknown error')
163 self.notice(sender, 'Google Search is temporarily unavailable. Try again later.')
164 return
165
166 result = result['responseData']['results']
167 if not result:
168 self.msg(channel, '[Google] No results found')
169 return
170
171 json = result[0]
172 self.msg(channel, '[Google] @b%(title)s@b <@u%(url)s@u>' % {
173 'title': unescape(json['titleNoFormatting']),
174 'url': json['unescapedUrl']})
175 self.msg(channel, '[Google] @bDescription@b: %s' % unescape(json['content']).replace('<b>', '@b').replace('</b>', '@b'))
176
177 def command_calc(self, manager, opts, arg, channel, sender, userinfo):
178 try: # local calculation using PyParsing
179 result = self.nsp.eval(arg)
180 self.msg(channel, '[calc] {} = {}'.format(arg, result))
181 except: # just throw it at W|A, hopefully they can get it
182 try:
183 result = self.wolfram.alpha(arg)
184 except FeedError as e:
185 self.errormsg(channel, e.msg)
186 return
187
188 if result is None:
189 self.msg(channel, '[W|A] Invalid input.')
190 else:
191 self.msg(channel, u'[W|A] {r[0]} = {r[1]}'.format(r=result))
192
193 def command_youtube_search(self, manager, opts, arg, channel, sender, userinfo):
194 try:
195 res = self.google.yt_search(arg)
196 except FeedError, e:
197 self.errormsg(channel, e.msg)
198 return
199
200 if not res:
201 self.msg(channel, '[YouTube] No results found')
202 return
203
204 self.msg(channel, """@sep @bYouTube@b %(title)s @sep @bURL@b %(url)s (%(duration)s) @sep @bViews@b %(views)s @sep \
205 @bRating@b %(rating)s/5 - %(votes)s votes @c3@b[+]@b %(liked)s likes @c4@b[-]@b %(disliked)s dislikes @sep""" % {
206 'title': res['title'],
207 'url': 'http://www.youtube.com/watch?v=' + res['id'],
208 'duration': '%s' % format_hms(res['duration']),
209 'views': format_thousand(res['view_count']),
210 'rating': round(res['rating'], 2) if res['rating'] else 0,
211 'votes': format_thousand(res['rate_count']) if res['rate_count'] else 0,
212 'liked': format_thousand(res['liked']) if res['liked'] else 0,
213 'disliked': format_thousand(res['disliked']) if res['disliked'] else 0
214 })
215
216 def command_dictionary(self, manager, opts, arg, channel, sender, userinfo):
217 try:
218 results = self.wordnik.definition(arg)
219 except FeedError, e:
220 self.errormsg(channel, e.msg)
221 return
222
223 if not results:
224 self.msg(channel, '[dictionary] Nothing found')
225 return
226
227 if 'all' in opts:
228 for n, res in enumerate(results, 1):
229 self.notice(sender, u'@sep [{num}/{tot}] @bDefinition@b {res.word} @sep {res.text} @sep'.format(
230 res=res, num=n, tot=len(results)))
231 elif 'number' in opts:
232 if opts['number'] - 1 < 0 or opts['number'] - 1 > len(results):
233 self.errormsg(channel, 'option -n out of range: only %d definitions found.' % len(results))
234 return
235
236 result = results[opts['number'] - 1]
237 self.msg(channel, u'@sep [{num}/{tot}] @bDefinition@b {res.word} @sep {res.text} @sep'.format(
238 res=result, num=opts['number'], tot=len(results)))
239 else:
240 for n, res in enumerate(results, 1):
241 self.msg(channel, u'@sep [{num}/{tot}] @bDefinition@b {res.word} @sep {res.text} @sep'.format(
242 res=res, num=n, tot=len(results)))
243 if n == 4:
244 self.notice(sender, 'To view all definitions: .dict {res.word} -a. To view the n-th definition: .dict {res.word} -n <number>'.format(res=res))
245 break
246
247 def command_urbandictionary(self, manager, opts, arg, channel, sender, userinfo):
248 expr, sep, def_id = arg.partition('/')
249 try:
250 res = self.urbandictionary.get_definitions(expr.strip())
251 except FeedError, e:
252 self.errormsg(channel, e.msg)
253 self.elog.warning('feed error in .urbandictionary: %s' % e)
254 return
255
256 if res['result_type'] == 'no_results' or res['result_type'] == 'fulltext':
257 self.errormsg(channel, 'no results found')
258 elif res['result_type'] == 'exact':
259 if def_id:
260 try:
261 def_id = int(def_id)
262 if def_id < 1:
263 self.errormsg(channel, 'invalid definition number')
264 return
265
266 entry = res['list'][def_id - 1]
267 definition = entry['definition'].replace('\r\n', ' / ').replace('\n', ' / ')
268 example = entry['example'].replace('\r\n', ' / ').replace('\n', ' / ')
269 self.msg(channel, u'@sep [{num}/{total}] {entry[word]} @sep {definition} @sep'.format(
270 num = def_id,
271 total = len(res['list']),
272 res = res,
273 definition = definition if len(definition) < 200 else definition[:200] + '...',
274 entry = entry))
275 self.msg(channel, u'@sep @bExample@b %s @sep' % (example if len(example) < 280 else example[:280] + '...'))
276 except ValueError:
277 self.errormsg(channel, 'invalid definition number')
278 except IndexError:
279 self.errormsg(channel, 'definition id out of range: only %d definitions available' % len(res['list']))
280 else:
281 for num, entry in enumerate(res['list'], 1):
282 if num == 4:
283 self.notice(sender, 'To view a single definition with a related example, type: @b.u %s /def_number@b. For more definitions, visit: %s' % (expr, res['list'][0]['permalink']))
284 break
285
286 definition = entry['definition'].replace('\r\n', ' / ').replace('\n', ' / ')
287 self.msg(channel, u'@sep [{num}/{total}] {entry[word]} @sep {definition} @sep'.format(
288 num = num,
289 total = len(res['list']),
290 res = res,
291 definition = definition if len(definition) < 200 else definition[:200] + '...',
292 entry = entry))
293 else:
294 self.msg(channel, 'An exception occurred and has been reported to the developers. If this error persists please do not use the faulty command until it has been fixed.')
295 self.elog.warning('unrecognized result type: %s' % res['result_type'])
296
297 def command_imdb(self, manager, opts, arg, channel, sender, userinfo):
298 try:
299 reply = self.imdb.get(arg)
300 except FeedError, e:
301 self.errormsg(channel, e.msg)
302 return
303 except ValueError:
304 self.errormsg(channel, 'movie not found')
305 return
306
307 if reply['Response'] != 'True':
308 self.msg(channel, '[imdb] Nothing found')
309 return
310
311 self.msg(channel, u"""@sep @b{r[Title]}@b [{r[Year]}] Rated {r[Rated]} @sep @bRating@b {r[imdbRating]}/10, {r[imdbVotes]} votes @sep \
312 @bGenre@b {r[Genre]} @sep @bDirector@b {r[Director]} @sep @bActors@b {r[Actors]} @sep @bRuntime@b {r[Runtime]} @sep""".format(r=reply))
313 self.msg(channel, u'@sep @bPlot@b {r[Plot]} @sep @uhttp://www.imdb.com/title/{r[imdbID]}/@u @sep'.format(r=reply))
314
315 def command_lastfm(self, manager, opts, arg, channel, sender, userinfo):
316 try:
317 user = self.lastfm.get_user(arg)
318 if 'error' in user:
319 self.errormsg(channel, user['message'])
320 return
321 latest = self.lastfm.get_recent_tracks(arg, 1)
322 except FeedError as e:
323 self.errormsg(channel, e.msg)
324 return
325
326 user = user['user']
327 userinfo = []
328 if user['realname']:
329 userinfo.append(user['realname'])
330 if user['age']:
331 userinfo.append(user['age'])
332 if user['country']:
333 userinfo.append(user['country'])
334
335 if userinfo:
336 userinfo = ' [%s]' % ', '.join(userinfo)
337 else:
338 userinfo = ''
339
340 if 'track' in latest['recenttracks']:
341 if isinstance(latest['recenttracks']['track'], list):
342 latest = latest['recenttracks']['track'][0]
343 else:
344 latest = latest['recenttracks']['track']
345 try:
346 latest['@attr']['nowplaying']
347 latest_str = u' @bNow playing@b {latest[artist][#text]} - {latest[name]} @sep'.format(latest=latest)
348 except KeyError:
349 latestdate = get_timespan(datetime.fromtimestamp(int(latest['date']['uts'])))
350 latest_str = u' @bLatest track@b {latest[artist][#text]} - {latest[name]} ({latestdate} ago) @sep'.format(
351 latest=latest, latestdate=latestdate)
352 else:
353 latest_str = ''
354
355 self.msg(channel, u'@sep @b{user[name]}@b{userinfo} @sep @bPlays@b {plays} since {regdate} @sep \
356 @bLink@b {user[url]} @sep{latest_track}'.format(
357 userinfo = userinfo,
358 plays = format_thousand(int(user['playcount'])),
359 regdate = user['registered']['#text'][:10],
360 user = user,
361 latest_track = latest_str))
362
363 def command_url_shorten(self, manager, opts, arg, channel, sender, userinfo):
364 if not arg.startswith('http://') and not arg.startswith('https://'):
365 self.errormsg(channel, 'a valid URL must start with http:// or https://')
366 return
367
368 try:
369 reply = self.urls.shorten(arg)
370 except FeedError, e:
371 self.errormsg(channel, e.msg)
372 return
373
374 if reply['status_code'] != 200:
375 self.errormsg(channel, 'an error occurred.')
376 self.elog.warning('[shorten] error: code %d, %s' % (reply['status_code'], reply['status_txt']))
377 else:
378 self.msg(channel, '@sep @bShort URL@b %s @sep' % reply['data']['url'])
379
380 def command_url_expand(self, manager, opts, arg, channel, sender, userinfo):
381 if not arg.startswith('http://') and not arg.startswith('https://'):
382 self.errormsg(channel, 'a valid URL must start with http:// or https://')
383 return
384
385 try:
386 reply = self.urls.expand(arg)
387 except FeedError, e:
388 self.errormsg(channel, e.msg)
389 return
390
391 if 'error' in reply:
392 self.errormsg(channel, reply['error'])
393 else:
394 self.msg(channel, '@sep @bLong URL@b {reply[long-url]} @sep @bContent-type@b {reply[content-type]} @sep'.format(reply=reply))
395
396 def command_idlerpg(self, manager, opts, arg, channel, sender, userinfo):
397 try:
398 player = IrpgPlayer(arg)
399 except FeedError, e:
400 self.errormsg(channel, e.msg)
401 return
402
403 if not player.name:
404 self.errormsg(channel, 'player not found. @bNote@b: nicks are case sensitive.')
405 return
406
407 self.msg(channel, """@sep @b{player.name}@b [{status}] @sep @bLevel@b {player.level} {player.classe} @sep @bNext level@b \
408 {nextlevel} @sep @bIdled@b {idled_for} @sep @bAlignment@b {player.alignment} @sep""".format(
409 player = player,
410 status = '@c3ON@c' if player.is_online else '@c4OFF@c',
411 nextlevel = timedelta(seconds=player.ttl),
412 idled_for = timedelta(seconds=player.idled_for)))
413
414 def command_ipinfo(self, manager, opts, arg, channel, sender, userinfo):
415 try:
416 reply = self.ipinfo.get_info(arg)
417 except FeedError, e:
418 self.errormsg(channel, e.msg)
419 return
420
421 self.msg(channel, """@sep @bIP/Host@b {arg} ({reply[ip_addr]}) @sep @bLocation@b {reply[city]}, {reply[region]}, \
422 {reply[country_name]} [{reply[country_code]}] @sep{map}""".format(
423 reply = reply,
424 arg = arg.lower(),
425 map = ' http://maps.google.com/maps?q=%s,%s @sep' % (reply['latitude'], reply['longitude']) if reply['latitude'] and reply['longitude'] else ''))
426
427 dice_regex = re.compile('^(?:(\d+)d)?(\d+)(?:([\+\-])(\d+))?$')
428 def command_dice(self, manager, opts, arg, channel, sender, userinfo):
429 r = dice_regex.search(arg)
430 if not r:
431 self.errormsg(channel, 'invalid format')
432 return
433
434 num, faces, type, modifier = r.groups()
435 if num:
436 num = int(num)
437 else:
438 num = 1
439 faces = int(faces)
440 if num < 1 or num > 32 or faces < 2 or faces > 65536:
441 self.errormsg(channel, 'parameter out of range')
442 return
443
444 total = 0
445 results = []
446 for n in xrange(int(num)):
447 randnum = random.randint(1, int(faces))
448 total += randnum
449 results.append(randnum)
450
451 if type == '-':
452 modifier = int(modifier)
453 total -= modifier
454 max = num * faces - modifier
455 elif type == '+':
456 modifier = int(modifier)
457 total += modifier
458 max = num * faces + modifier
459 else:
460 max = num * faces
461
462 self.msg(channel, '@sep @bTotal@b {total} / {max} [{percent}%] @sep @bResults@b {results} @sep'.format(
463 total = total,
464 max = max,
465 percent = 100 * total / max if max != 0 else '9001',
466 results = str(results)))
467
468 def command_qdb(self, manager, opts, arg, channel, sender, userinfo):
469 try:
470 if not arg:
471 quote = self.quotes.get_qdb_random()
472 else:
473 try:
474 quote_id = int(arg)
475 quote = self.quotes.get_qdb_id(quote_id)
476 if not quote:
477 self.errormsg(channel, 'quote @b%d@b not found' % quote_id)
478 return
479 except ValueError:
480 self.errormsg(channel, 'invalid quote ID')
481 return
482 except ExpatError: # qdb returns a malformed xml when the quote doesn't exist
483 self.errormsg(channel, 'quote @b%d@b not found' % quote_id)
484 return
485 except FeedError, e:
486 self.errormsg(channel, e.msg)
487 return
488
489 id = quote['id']
490 for line in quote['lines']:
491 self.msg(channel, u'[qdb {id}] {line}'.format(id=id, line=line.replace('\n', '')))
492
493 def command_fml(self, manager, opts, arg, channel, sender, userinfo):
494 try:
495 if not arg:
496 quote = self.quotes.get_fml()
497 else:
498 try:
499 quote_id = int(arg)
500 quote = self.quotes.get_fml(quote_id)
501 if not quote:
502 self.errormsg(channel, 'quote @b%d@b not found' % quote_id)
503 return
504 except (ValueError, IndexError):
505 self.errormsg(channel, 'invalid quote ID')
506 return
507 except (FeedError, FmlException) as e:
508 self.errormsg(channel, e.msg)
509 self.elog.warning('WARNING: .fml error: %s' % e.msg)
510 return
511
512 self.msg(channel, u'[fml #{quote[id]}] {quote[text]}'.format(quote=quote))
513
514 def command_internets_info(self, manager, opts, arg, channel, sender, userinfo):
515 self.notice(sender, '@sep @bRizon Internets Bot@b @sep @bDevelopers@b martin <martin@rizon.net> @sep @bHelp/feedback@b %(channel)s @sep' % {
516 'channel' : '#internets'})
517
518 def command_internets_help(self, manager, opts, arg, channel, sender, userinfo):
519 command = arg.lower()
520
521 if command == '':
522 message = ['internets: .help internets - for internets commands']
523 elif command == 'internets':
524 message = manager.get_help()
525 else:
526 message = manager.get_help(command)
527
528 if message == None:
529 message = ['%s is not a valid command.' % arg]
530
531 for line in message:
532 self.notice(sender, line)
533
534 class UserCommandManager(CommandManager):
535 def get_prefix(self):
536 return '.'
537
538 def get_commands(self):
539 return {
540 'cc': 'calc',
541 'calc': (command_calc, ARG_YES, 'Calculates an expression', [], 'expression'),
542
543 'dict': 'dictionary',
544 'dictionary': (command_dictionary, ARG_YES, 'Search for a dictionary definition', [
545 ('number', '-n', 'display the n-th result', {'type': '+integer'}, ARG_YES),
546 ('all', '-a', 'display all results (using /notice)', {'action': 'store_true'}, ARG_YES)], 'word'),
547
548 'u': 'urbandictionary',
549 'urbandictionary': (command_urbandictionary, ARG_YES, 'Search for a definition on Urban Dictionary', [], 'word'),
550
551 'g': 'google',
552 'google': (command_google_search, ARG_YES, 'Search for something on Google', [], 'google_search'),
553
554 't': 'translate',
555 'translate': (command_bing_translate, ARG_YES, 'Translate something from a language to another', [], 'from to text'),
556
557 'yt': 'youtube',
558 'youtube': (command_youtube_search, ARG_YES, 'Search for something on YouTube', [], 'youtube_search'),
559
560 'w': 'weather',
561 'weather': (command_weather, ARG_OPT, 'Displays current weather conditions for a location', [
562 ('nick', '-n', 'use the weather location linked to a nick', {'action': 'store_true'}, ARG_YES)]),
563
564 'f': 'forecast',
565 'forecast': (command_forecast, ARG_OPT, 'Displays 5-day forecast for a location', [
566 ('nick', '-n', 'use the weather location linked to a nick', {'action': 'store_true'}, ARG_YES)]),
567
568 'regloc': 'register_location',
569 'register_location': (command_register_location, ARG_YES, 'Links a location to your nick that will be used as default location in .w and .f', [], 'location'),
570
571 'imdb': (command_imdb, ARG_YES, 'Search for information on a movie on IMDB', [], 'movie_title'),
572
573 'lastfm': (command_lastfm, ARG_YES, 'Returns information on a Last.fm user', [], 'lastfm_user'),
574
575 'shorten': (command_url_shorten, ARG_YES, 'Shortens a URL using http://j.mp', [], 'long_url'),
576
577 'expand': (command_url_expand, ARG_YES, 'Expands a shortened URL using http://longurl.org', [], 'shortened_url'),
578
579 'irpg': 'idlerpg',
580 'idlerpg': (command_idlerpg, ARG_YES, 'Returns info on a player in Rizon IdleRPG (http://idlerpg.rizon.net/)', [], 'player_name'),
581
582 'ipinfo': (command_ipinfo, ARG_YES, 'Returns short info on a IP address/hostname', [], 'ip/host'),
583
584 'd': 'dice',
585 'dice': (command_dice, ARG_YES, 'Rolls X N-sided dice with an optional modifier A (XdN+A format)', [], 'dice_notation'),
586
587 'qdb': (command_qdb, ARG_OPT, 'Displays a quote from qdb.us', []),
588
589 'fml': (command_fml, ARG_OPT, 'Displays a quote from http://www.fmylife.com', []),
590
591 'info': (command_internets_info, ARG_NO|ARG_OFFLINE, 'Displays version and author information', []),
592 'help': (command_internets_help, ARG_OPT|ARG_OFFLINE, 'Displays available commands and their usage', []),
593 }