Limnoria/plugins/Infobot.py

655 lines
23 KiB
Python
Raw Normal View History

2004-05-06 17:54:26 +02:00
#!/usr/bin/python
###
2004-05-06 17:54:26 +02:00
# Copyright (c) 2004, Jeremiah Fincher
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
# * Redistributions of source code must retain the above copyright notice,
# this list of conditions, and the following disclaimer.
# * Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions, and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# * Neither the name of the author of this software nor the name of
# contributors to this software may be used to endorse or promote products
# derived from this software without specific prior written consent.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
###
"""
2004-05-06 17:54:26 +02:00
Infobot compatibility, for the parts that we don't support already.
"""
2003-11-25 09:23:47 +01:00
__revision__ = "$Id$"
2004-05-06 17:54:26 +02:00
__author__ = 'Jeremy Fincher (jemfinch) <jemfinch@users.sf.net>'
2003-11-25 09:23:47 +01:00
2004-07-24 07:18:26 +02:00
import supybot.plugins as plugins
2004-05-06 17:54:26 +02:00
import os
2003-04-01 10:36:13 +02:00
import re
import random
2004-05-06 17:54:26 +02:00
import cPickle as pickle
import supybot.dbi as dbi
2004-07-24 07:18:26 +02:00
import supybot.conf as conf
import supybot.utils as utils
import supybot.ircmsgs as ircmsgs
import supybot.ircutils as ircutils
import supybot.privmsgs as privmsgs
import supybot.registry as registry
import supybot.callbacks as callbacks
try:
import sqlite
except ImportError:
raise callbacks.Error, 'You need to have PySQLite installed to use this ' \
'plugin. Download it at <http://pysqlite.sf.net/>'
2004-05-06 17:54:26 +02:00
conf.registerPlugin('Infobot')
conf.registerGlobalValue(conf.supybot.plugins.Infobot, 'personality',
2004-07-23 23:27:16 +02:00
registry.Boolean(True, """Determines whether the bot will respond with
personable (Infobot-like) responses rather than its standard messages."""))
conf.registerGlobalValue(conf.supybot.plugins.Infobot, 'boringDunno',
registry.String('Dunno.', """Determines what boring dunno should be given
if supybot.plugins.Infobot.personality is False."""))
conf.registerGlobalValue(conf.supybot.plugins.Infobot,
'snarfUnaddressedDefinitions', registry.Boolean(True, """Determines whether
the bot will snarf definitions given in the channel that weren't directly
addressed to it. Of course, no confirmation will be given if the bot isn't
directly addressed."""))
conf.registerGlobalValue(conf.supybot.plugins.Infobot,
'answerUnaddressedQuestions', registry.Boolean(True, """Determines whether
the bot will answer questions that weren't directly addressed to it. Of
course, if it doesn't have an answer, it will remain silent."""))
2004-05-06 17:54:26 +02:00
def configure(advanced):
# This will be called by setup.py to configure this module. Advanced is
# a bool that specifies whether the user identified himself as an advanced
# user or not. You should effect your configuration by manipulating the
# registry as appropriate.
2004-07-25 20:24:51 +02:00
from supybot.questions import expect, anything, something, yn
2004-05-06 17:54:26 +02:00
conf.registerPlugin('Infobot', True)
filename = conf.supybot.directories.data.dirize('Infobot.db')
ends = ['!',
'.',
', $who.',]
dunnos = ['Dunno',
'No idea',
'I don\'t know',
'I have no idea',
'I don\'t have a clue',]
confirms = ['10-4',
'Okay',
'Got it',
'Gotcha',
'I hear ya']
class PickleInfobotDB(object):
def __init__(self):
2004-05-06 17:54:26 +02:00
try:
fd = file(filename)
except EnvironmentError:
self._is = utils.InsensitivePreservingDict()
self._are = utils.InsensitivePreservingDict()
else:
try:
(self._is, self._are) = pickle.load(fd)
except cPickle.UnpicklingError, e:
raise dbi.InvalidDBError, str(e)
2004-05-06 17:54:26 +02:00
self._changes = 0
self._responses = 0
def flush(self):
fd = utils.transactionalFile(filename, 'wb')
2004-05-06 17:54:26 +02:00
pickle.dump((self._is, self._are), fd)
fd.close()
def close(self):
self.flush()
2004-08-06 04:42:20 +02:00
def changeIs(self, factoid, replacer):
try:
old = self._is[factoid]
except KeyError:
raise dbi.NoRecordError
2004-08-06 04:42:20 +02:00
if replacer is not None:
self._is[factoid] = replacer(old)
self.flush()
self._changes += 1
2004-08-06 04:42:20 +02:00
2004-05-06 17:54:26 +02:00
def getIs(self, factoid):
ret = self._is[factoid]
self._responses += 1
return ret
def setIs(self, fact, oid):
self._is[fact] = oid
self.flush()
self._changes += 1
2004-05-06 17:54:26 +02:00
def delIs(self, factoid):
try:
del self._is[factoid]
except KeyError:
raise dbi.NoRecordError
2004-05-06 17:54:26 +02:00
self.flush()
self._changes += 1
2004-05-06 17:54:26 +02:00
def hasIs(self, factoid):
return factoid in self._is
2004-08-06 04:42:20 +02:00
def changeAre(self, factoid, replacer):
try:
old = self._are[factoid]
except KeyError:
raise dbi.NoRecordError
2004-08-06 04:42:20 +02:00
if replacer is not None:
self._are[factoid] = replacer(old)
self._changes += 1
self.flush()
2004-05-06 17:54:26 +02:00
def getAre(self, factoid):
ret = self._are[factoid]
self._responses += 1
return ret
def hasAre(self, factoid):
return factoid in self._are
def setAre(self, fact, oid):
self._are[fact] = oid
self.flush()
self._changes += 1
2004-05-06 17:54:26 +02:00
def delAre(self, factoid):
try:
del self._are[factoid]
except KeyError:
raise dbi.NoRecordError
2004-05-06 17:54:26 +02:00
self.flush()
self._changes += 1
def getDunno(self):
return random.choice(dunnos) + random.choice(ends)
def getConfirm(self):
return random.choice(confirms) + random.choice(ends)
2004-07-21 21:36:35 +02:00
2004-05-06 17:54:26 +02:00
def getChangeCount(self):
return self._changes
def getResponseCount(self):
return self._responses
class SqliteInfobotDB(object):
def __init__(self):
self._changes = 0
self._responses = 0
def _getDb(self):
try:
if os.path.exists(filename):
return sqlite.connect(filename)
#else:
db = sqlite.connect(filename)
cursor = db.cursor()
cursor.execute("""CREATE TABLE isFacts (
key TEXT UNIQUE ON CONFLICT REPLACE,
value TEXT
);""")
cursor.execute("""CREATE TABLE areFacts (
key TEXT UNIQUE ON CONFLICT REPLACE,
value TEXT
);""")
db.commit()
return db
except sqlite.DatabaseError, e:
raise dbi.InvalidDBError, str(e)
def close(self):
pass
def changeIs(self, factoid, replacer):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT value FROM isFacts WHERE key=%s""", factoid)
if cursor.rowcount == 0:
raise dbi.NoRecordError
old = cursor.fetchone()[0]
if replacer is not None:
cursor.execute("""UPDATE isFacts SET value=%s WHERE key=%s""",
replacer(old), factoid)
db.commit()
self._changes += 1
def getIs(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT value FROM isFacts WHERE key=%s""", factoid)
ret = cursor.fetchone()[0]
self._responses += 1
return ret
def setIs(self, fact, oid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""INSERT INTO isFacts VALUES (%s, %s)""", fact, oid)
db.commit()
self._changes += 1
def delIs(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""DELETE FROM isFacts WHERE key=%s""", factoid)
if cursor.rowcount == 0:
raise dbi.NoRecordError
db.commit()
self._changes += 1
def hasIs(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT * FROM isFacts WHERE key=%s""", factoid)
return cursor.rowcount == 1
def changeAre(self, factoid, replacer):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT value FROM areFacts WHERE key=%s""", factoid)
if cursor.rowcount == 0:
raise dbi.NoRecordError
old = cursor.fetchone()[0]
if replacer is not None:
cursor.execute("""UPDATE areFacts SET value=%s WHERE key=%s""",
replacer(old), factoid)
db.commit()
self._changes += 1
def getAre(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT value FROM areFacts WHERE key=%s""", factoid)
ret = cursor.fetchone()[0]
self._responses += 1
return ret
def setAre(self, fact, oid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""INSERT INTO areFacts VALUES (%s, %s)""", fact, oid)
db.commit()
self._changes += 1
def delAre(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""DELETE FROM areFacts WHERE key=%s""", factoid)
if cursor.rowcount == 0:
raise dbi.NoRecordError
db.commit()
self._changes += 1
def hasAre(self, factoid):
db = self._getDb()
cursor = db.cursor()
cursor.execute("""SELECT * FROM areFacts WHERE key=%s""", factoid)
return cursor.rowcount == 1
def getDunno(self):
return random.choice(dunnos) + random.choice(ends)
def getConfirm(self):
return random.choice(confirms) + random.choice(ends)
def getChangeCount(self):
return self._changes
def getResponseCount(self):
return self._responses
def InfobotDB():
return SqliteInfobotDB()
2004-07-31 08:46:19 +02:00
class Dunno(Exception):
pass
2004-05-06 17:54:26 +02:00
class Infobot(callbacks.PrivmsgCommandAndRegexp):
2004-08-06 04:42:20 +02:00
regexps = ['doForget', 'doChange', 'doFactoid', 'doUnknown']
2004-05-06 17:54:26 +02:00
def __init__(self):
callbacks.PrivmsgCommandAndRegexp.__init__(self)
try:
self.db = InfobotDB()
except Exception:
self.log.exception('Error loading %s:', filename)
raise # So it doesn't get loaded without its database.
2004-05-06 17:54:26 +02:00
self.irc = None
self.msg = None
self.force = False
2004-08-10 08:14:31 +02:00
self.replied = True
self.badForce = False
2004-05-06 17:54:26 +02:00
self.addressed = False
2003-08-14 14:11:45 +02:00
def die(self):
self.db.close()
def _error(self, s):
if self.addressed:
self.irc.error(s)
else:
self.log.warning(s)
def reply(self, s, irc=None, msg=None, action=False):
2004-05-06 17:54:26 +02:00
if self.replied:
self.log.debug('Already replied, not replying again.')
2003-08-14 14:11:45 +02:00
return
2004-05-06 17:54:26 +02:00
if irc is None:
assert self.irc is not None
irc = self.irc
if msg is None:
assert self.msg is not None
msg = self.msg
self.replied = True
2004-07-31 08:46:19 +02:00
irc.reply(plugins.standardSubstitute(irc, msg, s),
prefixName=False, action=action, msg=msg)
2004-07-21 21:36:35 +02:00
2004-05-06 17:54:26 +02:00
def confirm(self, irc=None, msg=None):
if self.registryValue('personality'):
self.reply(self.db.getConfirm(), irc=irc, msg=msg)
else:
assert self.irc is not None
self.irc.replySuccess()
2004-05-06 17:54:26 +02:00
def dunno(self, irc=None, msg=None):
if self.registryValue('personality'):
self.reply(self.db.getDunno(), irc=irc, msg=msg)
else:
self.reply(self.registryValue('boringDunno'), irc=irc, msg=msg)
2004-05-06 17:54:26 +02:00
def factoid(self, key, irc=None, msg=None, dunno=True, prepend=''):
2004-05-06 17:54:26 +02:00
if irc is None:
assert self.irc is not None
irc = self.irc
if msg is None:
assert self.msg is not None
msg = self.msg
isAre = None
try:
if self.db.hasIs(key):
isAre = 'is'
value = self.db.getIs(key)
elif self.db.hasAre(key):
isAre = 'are'
value = self.db.getAre(key)
except dbi.InvalidDBError:
self._error('Unable to access db: %s' % e)
return
2004-05-06 17:54:26 +02:00
if isAre is None:
if self.addressed:
2004-07-31 08:46:19 +02:00
if dunno:
self.dunno(irc=irc, msg=msg)
else:
raise Dunno
2003-08-14 14:11:45 +02:00
else:
value = random.choice(value.split('|'))
if value.startswith('<reply>'):
value = value[7:].strip()
if value:
self.reply(value, irc=irc, msg=msg)
else:
self.log.debug('Not sending empty factoid.')
elif value.startswith('<action>'):
2004-07-31 08:46:19 +02:00
self.reply(value[8:].strip(),
irc=irc, msg=msg, action=True)
else:
s = '%s %s %s, $who' % (key, isAre, value)
s = prepend + s
self.reply(s, irc=irc, msg=msg)
2003-08-20 18:26:23 +02:00
2004-05-06 17:54:26 +02:00
def normalize(self, s):
s = ircutils.stripFormatting(s)
s = s.strip() # After stripFormatting for formatted spaces.
s = utils.normalizeWhitespace(s)
contractions = [('what\'s', 'what is'), ('where\'s', 'where is'),
('who\'s', 'who is'), ('wtf\'s', 'wtf is'),]
2004-05-06 17:54:26 +02:00
for (contraction, replacement) in contractions:
if s.startswith(contraction):
s = replacement + s[len(contraction):]
return s
2004-07-21 21:36:35 +02:00
2004-05-06 22:09:16 +02:00
_forceRe = re.compile(r'^no[,: -]+', re.I)
_karmaRe = re.compile(r'^\S+(?:\+\+|--)(?:\s+)?$')
2004-05-06 17:54:26 +02:00
def doPrivmsg(self, irc, msg):
2003-04-01 11:07:14 +02:00
try:
if ircmsgs.isCtcp(msg):
return
# probably not necessary, but we'll see what the debug logs show
if getattr(irc, 'finished', False):
self.log.debug('Received a finished irc object. Bailing.')
return
maybeAddressed = callbacks.addressed(irc.nick, msg,
whenAddressedByNick=True)
if maybeAddressed:
self.addressed = True
payload = maybeAddressed
else:
payload = msg.args[1]
if self._karmaRe.search(payload):
self.log.debug('Not snarfing a karma adjustment.')
return
payload = self.normalize(payload)
maybeForced = self._forceRe.sub('', payload)
if maybeForced != payload:
self.force = True
# Infobot requires that forces have the form "no, botname, ..."
# We think that's stupid to require the bot name if the bot is
# being directly addressed. The following makes sure both
# "botname: no, botname, ..." and "botname: no, ..." work the
# same and non-addressed forms require the bots nick.
nick = irc.nick.lower()
if not self.addressed:
if not maybeForced.lower().startswith(nick):
self.badForce = True
self.force = False
if maybeForced.lower().startswith(nick):
maybeForced = maybeForced[len(nick):].lstrip(', ')
payload = maybeForced
# Let's make sure we dump out of Infobot if the privmsg is an
# actual command otherwise we could get multiple responses.
if self.addressed:
try:
tokens = callbacks.tokenize(payload)
if callbacks.findCallbackForCommand(irc, tokens[0]):
return
2004-08-06 04:42:20 +02:00
elif '=~' not in payload:
payload += '?'
2004-08-06 04:42:20 +02:00
else: # Looks like we have a doChange expression
pass
except SyntaxError:
pass
if payload.endswith(irc.nick):
self.addressed = True
payload = payload[:-len(irc.nick)]
payload = payload.strip(', ') # Strip punctuation separating nick.
payload += '?' # So doUnknown gets called.
if not payload.strip():
self.log.debug('Bailing since we received an empty msg.')
return
2004-05-06 17:54:26 +02:00
msg = ircmsgs.privmsg(msg.args[0], payload, prefix=msg.prefix)
callbacks.PrivmsgCommandAndRegexp.doPrivmsg(self, irc, msg)
finally:
self.force = False
self.replied = False
self.badForce = False
2004-05-06 17:54:26 +02:00
self.addressed = False
2003-04-01 10:36:13 +02:00
2004-05-06 17:54:26 +02:00
def callCommand(self, f, irc, msg, *L, **kwargs):
try:
self.irc = irc
self.msg = msg
callbacks.PrivmsgCommandAndRegexp.callCommand(self, f, irc, msg,
*L, **kwargs)
finally:
self.irc = None
self.msg = None
2003-04-01 10:36:13 +02:00
2004-05-06 17:54:26 +02:00
def doForget(self, irc, msg, match):
r"^forget\s+(.+?)[?!. ]*$"
fact = match.group(1)
2004-07-23 23:27:16 +02:00
deleted = False
2004-05-06 17:54:26 +02:00
for method in [self.db.delIs, self.db.delAre]:
try:
method(fact)
2004-07-23 23:27:16 +02:00
deleted = True
except dbi.NoRecordError:
2004-05-06 17:54:26 +02:00
pass
2004-07-23 23:27:16 +02:00
if deleted:
self.confirm()
else:
# XXX: Should this be genericified?
irc.reply('I\'ve never heard of %s, %s!' % (fact, msg.nick))
2003-08-20 18:26:23 +02:00
2004-08-06 04:42:20 +02:00
def doChange(self, irc, msg, match):
r"^(.+)\s+=~\s+(.+)$"
(fact, regexp) = match.groups()
changed = False
try:
r = utils.perlReToReplacer(regexp)
except ValueError, e:
if self.addressed:
irc.error('Invalid regexp: %s' % regexp)
return
else:
2004-08-10 08:14:31 +02:00
self.log.debug('Invalid regexp: %s' % regexp)
2004-08-06 04:42:20 +02:00
return
for method in [self.db.changeIs, self.db.changeAre]:
try:
method(fact, r)
changed = True
except dbi.NoRecordError:
2004-08-06 04:42:20 +02:00
pass
if changed:
self.confirm()
else:
# XXX: Should this be genericified?
irc.reply('I\'ve never heard of %s, %s!' % (fact, msg.nick))
2004-05-06 17:54:26 +02:00
def doUnknown(self, irc, msg, match):
r"^(.+?)\s*\?[?!. ]*$"
2004-05-06 17:54:26 +02:00
key = match.group(1)
if self.addressed or self.registryValue('answerUnaddressedQuestions'):
self.factoid(key) # Does the dunno'ing for us itself.
def invalidCommand(self, irc, msg, tokens):
2004-08-06 04:42:20 +02:00
irc.finished = True
2003-08-20 18:26:23 +02:00
2004-05-06 17:54:26 +02:00
def doFactoid(self, irc, msg, match):
r"^(.+?)\s+(?<!\\)(was|is|am|were|are)\s+(also\s+)?(.+?)[?!. ]*$"
2004-07-23 23:27:16 +02:00
(key, isAre, also, value) = match.groups()
key = key.replace('\\', '')
if key.lower() in ('where', 'what', 'who', 'wtf'):
2004-05-06 17:54:26 +02:00
# It's a question.
if self.addressed or \
self.registryValue('answerUnaddressedQuestions'):
self.factoid(value)
return
if not self.addressed and \
not self.registryValue('snarfUnaddressedDefinitions'):
2004-05-06 17:54:26 +02:00
return
isAre = isAre.lower()
if isAre in ('was', 'is', 'am'):
if self.db.hasIs(key):
2004-07-23 23:27:16 +02:00
if also:
2004-08-10 08:14:31 +02:00
self.log.debug('Adding %r to %r.', key, value)
2004-07-23 23:27:16 +02:00
value = '%s or %s' % (self.db.getIs(key), value)
elif self.force:
2004-08-10 08:14:31 +02:00
self.log.debug('Forcing %r to %r.', key, value)
elif self.badForce:
value = self.db.getIs(key)
self.reply('... but %s is %s, %s ...' % (key, value,
msg.nick))
return
2004-07-23 23:27:16 +02:00
elif self.addressed:
2004-05-06 17:54:26 +02:00
value = self.db.getIs(key)
2004-07-23 23:27:16 +02:00
self.reply('But %s is %s, %s.' % (key, value, msg.nick))
2004-05-06 17:54:26 +02:00
return
else:
2004-08-10 08:14:31 +02:00
self.log.debug('Already have a %r key.', key)
return
2004-05-06 17:54:26 +02:00
self.db.setIs(key, value)
else:
if self.db.hasAre(key):
2004-07-23 23:27:16 +02:00
if also:
2004-08-10 08:14:31 +02:00
self.log.debug('Adding %r to %r.', key, value)
2004-07-23 23:27:16 +02:00
value = '%s or %s' % (self.db.getAre(key), value)
elif self.force:
2004-08-10 08:14:31 +02:00
self.log.debug('Forcing %r to %r.', key, value)
elif self.badForce:
value = self.db.getAre(key)
self.reply('... but %s are %s, %s ...' % (key, value,
msg.nick))
return
2004-07-23 23:27:16 +02:00
elif self.addressed:
2004-05-06 17:54:26 +02:00
value = self.db.getAre(key)
2004-07-23 23:27:16 +02:00
self.reply('But %s are %s, %s.' % (key, value, msg.nick))
2004-05-06 17:54:26 +02:00
return
else:
2004-08-10 08:14:31 +02:00
self.log.debug('Already have a %r key.', key)
return
2004-05-06 17:54:26 +02:00
self.db.setAre(key, value)
2004-07-23 23:27:16 +02:00
if self.addressed or self.force or also:
2004-05-06 17:54:26 +02:00
self.confirm()
2004-05-06 17:54:26 +02:00
def stats(self, irc, msg, args):
"""takes no arguments
Returns the number of changes and requests made to the Infobot database
since the plugin was loaded.
"""
irc.reply('There have been %s answered and %s made '
'to the database since this plugin was loaded.' %
2004-08-19 19:57:55 +02:00
(utils.nItems('request', self.db.getResponseCount()),
utils.nItems('change', self.db.getChangeCount())))
2004-07-21 21:36:35 +02:00
2004-07-31 08:46:19 +02:00
def tell(self, irc, msg, args):
"""<nick> [about] <factoid>
Tells <nick> about <factoid>.
"""
if len(args) < 2:
raise callbacks.ArgumentError
if args[1] == 'about':
del args[1]
(nick, factoid) = privmsgs.getArgs(args, required=2)
try:
hostmask = irc.state.nickToHostmask(nick)
except KeyError:
irc.error('I haven\'t seen %s, I\'ll let you do the telling.')
return
newmsg = ircmsgs.privmsg(irc.nick, factoid+'?', prefix=hostmask)
try:
prepend = '%s wants you to know that ' % msg.nick
self.factoid(factoid, msg=newmsg, prepend=prepend)
2004-07-31 08:46:19 +02:00
except Dunno:
self.dunno()
2003-08-20 18:26:23 +02:00
2004-05-06 17:54:26 +02:00
Class = Infobot
2003-08-20 18:26:23 +02:00
# vim:set shiftwidth=4 tabstop=8 expandtab textwidth=78: