Supybot-plugins/Twitter/plugin.py

680 lines
26 KiB
Python
Raw Normal View History

###
# Copyright (c) 2011, Valentin Lorentz
# 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.
###
2012-08-15 07:26:22 -07:00
from __future__ import division
import re
import sys
2012-07-28 13:22:58 -07:00
import time
2013-03-25 11:12:08 -07:00
import json
2012-07-28 13:22:58 -07:00
import threading
import supybot.log as log
2012-07-30 14:50:26 -07:00
import supybot.conf as conf
import supybot.utils as utils
import supybot.world as world
from supybot.commands import *
import supybot.ircmsgs as ircmsgs
import supybot.plugins as plugins
import supybot.ircutils as ircutils
2012-07-30 14:50:26 -07:00
import supybot.registry as registry
import supybot.callbacks as callbacks
2013-03-25 11:12:08 -07:00
if sys.version_info[0] < 3:
import htmlentitydefs
else:
import html.entities as htmlentitydefs
from imp import reload
try:
from supybot.i18n import PluginInternationalization
from supybot.i18n import internationalizeDocstring
_ = PluginInternationalization('Twitter')
except:
# This are useless functions that's allow to run the plugin on a bot
# without the i18n plugin
_ = lambda x:x
internationalizeDocstring = lambda x:x
try:
import twitter
except ImportError:
2013-03-25 11:12:08 -07:00
raise callbacks.Error('You need the python-twitter library.')
reload(twitter)
2013-03-25 11:12:08 -07:00
if not hasattr(twitter, '__version__') or \
twitter.__version__.split('.') < ['0', '8', '0']:
raise ImportError('You current version of python-twitter is to old, '
'you need at least version 0.8.0, because older '
'versions do not support OAuth authentication.')
class ExtendedApi(twitter.Api):
"""Api with retweet support."""
def PostRetweet(self, id):
'''Retweet a tweet with the Retweet API
The twitter.Api instance must be authenticated.
Args:
id: The numerical ID of the tweet you are retweeting
Returns:
A twitter.Status instance representing the retweet posted
'''
if not self._oauth_consumer:
raise TwitterError("The twitter.Api instance must be authenticated.")
try:
if int(id) <= 0:
raise TwitterError("'id' must be a positive number")
except ValueError:
raise TwitterError("'id' must be an integer")
url = 'http://api.twitter.com/1/statuses/retweet/%s.json' % id
json = self._FetchUrl(url, post_data={'dummy': None})
2013-03-25 11:12:08 -07:00
data = json.loads(json)
self._CheckForTwitterError(data)
return twitter.Status.NewFromJsonDict(data)
2013-03-25 11:12:08 -07:00
_tco_link_re = re.compile('http://t.co/[a-zA-Z0-9]+')
def expandLinks(tweet):
if 'Untiny.plugin' in sys.modules:
def repl(link):
return sys.modules['Untiny.plugin'].Untiny(None) \
._untiny(None, link.group(0))
return _tco_link_re.sub(repl, tweet)
else:
return tweet
2011-02-19 06:05:36 -08:00
@internationalizeDocstring
class Twitter(callbacks.Plugin):
"""Add the help for "@plugin help Twitter" here
This should describe *how* to use this plugin."""
threaded = True
def __init__(self, irc):
self.__parent = super(Twitter, self)
callbacks.Plugin.__init__(self, irc)
self._apis = {}
2012-07-28 13:22:58 -07:00
self._died = False
if world.starting:
try:
self._getApi().PostUpdate(_('I just woke up. :)'))
except:
pass
2012-07-28 13:22:58 -07:00
self._runningAnnounces = []
2012-07-30 14:50:26 -07:00
try:
conf.supybot.plugins.Twitter.consumer.key.addCallback(
self._dropApiObjects)
conf.supybot.plugins.Twitter.consumer.secret.addCallback(
self._dropApiObjects)
conf.supybot.plugins.Twitter.accounts.channel.key.addCallback(
self._dropApiObjects)
conf.supybot.plugins.Twitter.accounts.channel.secret.addCallback(
self._dropApiObjects)
conf.supybot.plugins.Twitter.accounts.channel.api.addCallback(
self._dropApiObjects)
2012-07-30 14:50:26 -07:00
except registry.NonExistentRegistryEntry:
log.error('Your version of Supybot is not compatible with '
'configuration hooks. So, Twitter won\'t be able '
'to apply changes to the consumer key/secret '
'and token key/secret unless you reload it.')
2012-08-15 07:26:22 -07:00
self._shortids = {}
self._current_shortid = 0
2012-07-30 14:50:26 -07:00
def _dropApiObjects(self, name=None):
2012-07-30 14:50:26 -07:00
self._apis = {}
2012-07-28 13:22:58 -07:00
def _getApi(self, channel):
if channel in self._apis:
2012-07-28 13:22:58 -07:00
# TODO: handle configuration changes (using Limnoria's config hooks)
return self._apis[channel]
if channel is None:
key = self.registryValue('accounts.bot.key')
secret = self.registryValue('accounts.bot.secret')
url = self.registryValue('accounts.bot.api')
else:
key = self.registryValue('accounts.channel.key', channel)
secret = self.registryValue('accounts.channel.secret', channel)
url = self.registryValue('accounts.channel.api')
if key == '' or secret == '':
2012-08-15 07:26:22 -07:00
return ExtendedApi(base_url=url)
2012-06-24 03:22:23 -07:00
api = ExtendedApi(consumer_key=self.registryValue('consumer.key'),
consumer_secret=self.registryValue('consumer.secret'),
access_token_key=key,
access_token_secret=secret,
base_url=url)
self._apis[channel] = api
return api
2012-08-15 07:26:22 -07:00
def _get_shortid(self, longid):
characters = '0123456789abcdefghijklmnopwrstuvwyz'
id_ = self._current_shortid + 1
id_ %= (36**4)
self._current_shortid = id_
shortid = ''
while len(shortid) < 3:
quotient, remainder = divmod(id_, 36)
shortid = characters[remainder] + shortid
2012-08-16 05:39:56 -07:00
id_ = quotient
2012-08-15 07:26:22 -07:00
self._shortids[shortid] = longid
return shortid
def _unescape(self, text):
"""Created by Fredrik Lundh (http://effbot.org/zone/re-sub.htm#unescape-html)"""
text = text.replace("\n", " ")
def fixup(m):
text = m.group(0)
if text[:2] == "&#":
# character reference
try:
if text[:3] == "&#x":
return unichr(int(text[3:-1], 16))
else:
return unichr(int(text[2:-1]))
except (ValueError, OverflowError):
pass
else:
# named entity
try:
text = unichr(htmlentitydefs.name2codepoint[text[1:-1]])
except KeyError:
pass
return text # leave as is
return re.sub("&#?\w+;", fixup, text)
2012-08-15 07:26:22 -07:00
2012-07-28 13:22:58 -07:00
def __call__(self, irc, msg):
super(Twitter, self).__call__(irc, msg)
irc = callbacks.SimpleProxy(irc, msg)
for channel in irc.state.channels:
if self.registryValue('announce.interval', channel) != 0 and \
channel not in self._runningAnnounces:
threading.Thread(target=self._fetchTimeline,
2012-12-30 09:59:26 -08:00
args=(irc, channel),
name='Twitter timeline for %s' % channel).start()
2012-07-28 13:22:58 -07:00
def _fetchTimeline(self, irc, channel):
2012-07-30 14:50:26 -07:00
if channel in self._runningAnnounces:
# Prevent race conditions
return
2012-07-28 13:22:58 -07:00
lastRun = time.time()
maxId = None
self._runningAnnounces.append(channel)
try:
while not irc.zombie and not self._died and \
self.registryValue('announce.interval', channel) != 0:
while lastRun is not None and \
lastRun+self.registryValue('announce.interval', channel)>time.time():
time.sleep(5)
2012-07-28 13:22:58 -07:00
lastRun = time.time()
self.log.debug(_('Fetching tweets for channel %s') % channel)
api = self._getApi(channel) # Reload it from conf everytime
if not api._oauth_consumer:
2012-07-28 13:22:58 -07:00
return
retweets = self.registryValue('announce.retweets', channel)
try:
if maxId is None:
timeline = api.GetFriendsTimeline(retweets=retweets)
else:
timeline = api.GetFriendsTimeline(retweets=retweets,
since_id=maxId)
except twitter.TwitterError as e:
self.log.error('Could not fetch timeline: %s' % e)
continue
2012-07-28 13:22:58 -07:00
if timeline is None or timeline == []:
continue
timeline.reverse()
if maxId is None:
maxId = timeline[-1].id
continue
else:
maxId = timeline[-1].id
2012-08-15 07:26:22 -07:00
format_ = '@%(user)s> %(msg)s'
2012-07-28 13:22:58 -07:00
if self.registryValue('announce.withid', channel):
2012-08-15 07:26:22 -07:00
format_ = '[%(longid)s] ' + format_
if self.registryValue('announce.withshortid', channel):
format_ = '(%(shortid)s) ' + format_
replies = [format_ % {'longid': x.id,
'shortid': self._get_shortid(x.id),
'user': x.user.screen_name,
'msg': x.text
} for x in timeline]
2012-07-28 13:22:58 -07:00
replies = map(self._unescape, replies)
replies = map(expandLinks, replies)
2012-07-28 13:22:58 -07:00
if self.registryValue('announce.oneline', channel):
irc.replies(replies, prefixNick=False, joiner=' | ',
to=channel)
2012-07-28 13:22:58 -07:00
else:
for reply in replies:
irc.reply(reply, prefixNick=False, to=channel)
2012-07-28 13:22:58 -07:00
finally:
assert channel in self._runningAnnounces
self._runningAnnounces.remove(channel)
@internationalizeDocstring
2011-08-08 09:11:21 -07:00
def following(self, irc, msg, args, channel, user):
"""[<channel>] [<user>]
2011-08-08 09:11:21 -07:00
Replies with the people this <user> follows. If <user> is not given, it
defaults to the <channel>'s account. If <channel> is not given, it
defaults to the current channel."""
api = self._getApi(channel)
if not api._oauth_consumer and user is None:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel, or provide '
'a user name.'))
return
2011-08-08 09:11:21 -07:00
following = api.GetFriends(user) # If user is not given, it defaults
# to None, and giving None to
# GetFriends() has the expected
# behaviour.
2011-08-08 09:11:21 -07:00
reply = utils.str.format("%L", ['%s (%s)' % (x.name, x.screen_name)
for x in following])
reply = self._unescape(reply)
irc.reply(reply)
2011-08-08 09:11:21 -07:00
following = wrap(following, ['channel',
optional('somethingWithoutSpaces')])
2011-08-08 09:21:03 -07:00
@internationalizeDocstring
def followers(self, irc, msg, args, channel):
"""[<channel>]
Replies with the people that follow this account. If <channel> is not
given, it defaults to the current channel."""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel, or provide '
'a user name.'))
return
followers = api.GetFollowers()
reply = utils.str.format("%L", ['%s (%s)' % (x.name, x.screen_name)
for x in followers])
reply = self._unescape(reply)
2011-08-08 09:21:03 -07:00
irc.reply(reply)
followers = wrap(followers, ['channel'])
2011-08-08 09:36:28 -07:00
@internationalizeDocstring
def dm(self, irc, msg, args, user, channel, recipient, message):
"""[<channel>] <recipient> <message>
Sends a <message> to <recipient> from the account associated with the
given <channel>. If <channel> is not given, it defaults to the current
channel."""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op or try with another channel.'))
return
if len(message) > 140:
irc.error(_('Sorry, your message exceeds 140 characters (%i)') %
len(message))
else:
api.PostDirectMessage(recipient, message)
irc.replySuccess()
2012-06-14 05:00:28 -07:00
dm = wrap(dm, ['user', ('checkChannelCapability', 'twitteradmin'),
2011-08-08 09:36:28 -07:00
'somethingWithoutSpaces', 'text'])
2011-02-19 01:38:25 -08:00
@internationalizeDocstring
def post(self, irc, msg, args, user, channel, message):
"""[<channel>] <message>
Updates the status of the account associated with the given <channel>
to the <message>. If <channel> is not given, it defaults to the
current channel."""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op or try with another channel.'))
2011-02-19 01:38:25 -08:00
return
2011-08-08 09:11:38 -07:00
tweet = message
if self.registryValue('prefixusername', channel):
tweet = '[%s] %s' % (user.name, tweet)
2011-07-12 05:21:42 -07:00
if len(tweet) > 140:
irc.error(_('Sorry, your tweet exceeds 140 characters (%i)') %
len(tweet))
else:
api.PostUpdate(tweet)
irc.replySuccess()
2012-06-14 05:00:28 -07:00
post = wrap(post, ['user', ('checkChannelCapability', 'twitterpost'), 'text'])
2011-02-19 01:38:25 -08:00
@internationalizeDocstring
def retweet(self, irc, msg, args, user, channel, id_):
"""[<channel>] <id>
Retweets the message with the given ID."""
api = self._getApi(channel)
try:
2012-08-15 07:26:22 -07:00
if len(id_) <= 3:
try:
id_ = self._shortids[id_]
except KeyError:
irc.error(_('This is not a valid ID.'))
return
else:
try:
id_ = int(id_)
except ValueError:
irc.error(_('This is not a valid ID.'))
return
api.PostRetweet(id_)
irc.replySuccess()
except twitter.TwitterError as e:
irc.error(e.args[0])
retweet = wrap(retweet, ['user', ('checkChannelCapability', 'twitterpost'),
2012-08-15 07:26:22 -07:00
'somethingWithoutSpaces'])
2011-02-19 06:05:36 -08:00
@internationalizeDocstring
2012-06-13 11:10:46 -07:00
def timeline(self, irc, msg, args, channel, tupleOptlist, user):
"""[<channel>] [--since <oldest>] [--max <newest>] [--count <number>] \
[--noretweet] [--with-id] [<user>]
2011-02-19 06:05:36 -08:00
Replies with the timeline of the <user>.
If <user> is not given, it defaults to the account associated with the
<channel>.
If <channel> is not given, it defaults to the current channel.
If given, --since and --max take tweet IDs, used as boundaries.
If given, --count takes an integer, that stands for the number of
tweets to display.
If --noretweet is given, only native user's tweet will be displayed.
"""
optlist = {}
for key, value in tupleOptlist:
optlist.update({key: value})
for key in ('since', 'max', 'count'):
if key not in optlist:
optlist[key] = None
optlist['noretweet'] = 'noretweet' in optlist
2011-08-09 05:25:39 -07:00
optlist['with-id'] = 'with-id' in optlist
2011-02-19 06:05:36 -08:00
api = self._getApi(channel)
if not api._oauth_consumer and user is None:
2011-02-19 06:05:36 -08:00
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel.'))
return
try:
2012-12-22 02:29:51 -08:00
timeline = api.GetUserTimeline(screen_name=user,
since_id=optlist['since'],
max_id=optlist['max'],
count=optlist['count'],
include_rts=not optlist['noretweet'])
except twitter.TwitterError:
irc.error(_('This user protects his tweets; you need to fetch '
'them from a channel whose associated account can '
'fetch this timeline.'))
return
2011-08-09 05:25:39 -07:00
if optlist['with-id']:
reply = ' | '.join(['[%s] %s' % (x.id, expandLinks(x.text))
for x in timeline])
2011-08-09 05:25:39 -07:00
else:
reply = ' | '.join([expandLinks(x.text) for x in timeline])
2011-02-19 06:05:36 -08:00
reply = self._unescape(reply)
2011-02-19 06:05:36 -08:00
irc.reply(reply)
timeline = wrap(timeline, ['channel',
getopts({'since': 'int',
'max': 'int',
'count': 'int',
2011-08-09 05:25:39 -07:00
'noretweet': '',
2012-06-13 11:10:46 -07:00
'with-id': ''}),
optional('somethingWithoutSpaces')])
2011-02-19 06:05:36 -08:00
@internationalizeDocstring
def public(self, irc, msg, args, channel, tupleOptlist):
"""[<channel>] [--since <oldest>]
Replies with the public timeline.
If <channel> is not given, it defaults to the current channel.
If given, --since takes a tweet ID, used as a boundary
"""
optlist = {}
for key, value in tupleOptlist:
optlist.update({key: value})
if 'since' not in optlist:
optlist['since'] = None
api = self._getApi(channel)
try:
public = api.GetPublicTimeline(since_id=optlist['since'])
except twitter.TwitterError:
irc.error(_('No tweets'))
return
reply = ' | '.join([expandLinks(x.text) for x in public])
reply = self._unescape(reply)
irc.reply(reply)
public = wrap(public, ['channel', getopts({'since': 'int'})])
@internationalizeDocstring
def replies(self, irc, msg, args, channel, tupleOptlist):
"""[<channel>] [--since <oldest>]
Replies with the replies timeline.
If <channel> is not given, it defaults to the current channel.
If given, --since takes a tweet ID, used as a boundary
"""
2012-08-15 07:51:39 -07:00
optlist = {}
for key, value in tupleOptlist:
optlist.update({key: value})
if 'since' not in optlist:
optlist['since'] = None
id_ = optlist['since'] or '0000'
2012-08-15 07:51:39 -07:00
2012-08-15 07:26:22 -07:00
if len(id_) <= 3:
try:
id_ = self._shortids[id_]
except KeyError:
irc.error(_('This is not a valid ID.'))
return
else:
try:
id_ = int(id_)
except ValueError:
irc.error(_('This is not a valid ID.'))
return
api = self._getApi(channel)
try:
2012-08-15 07:51:39 -07:00
replies = api.GetReplies(since_id=id_)
except twitter.TwitterError:
irc.error(_('No tweets'))
return
reply = ' | '.join(["%s: %s" % (x.user.screen_name, expandLinks(x.text))
for x in replies])
reply = self._unescape(reply)
irc.reply(reply)
2012-08-15 07:26:22 -07:00
replies = wrap(replies, ['channel',
getopts({'since': 'somethingWithoutSpaces'})])
2011-08-12 01:18:56 -07:00
@internationalizeDocstring
def trends(self, irc, msg, args, channel):
"""[<channel>]
Current trending topics
If <channel> is not given, it defaults to the current channel.
"""
api = self._getApi(channel)
try:
trends = api.GetTrendsCurrent()
except twitter.TwitterError:
irc.error(_('No tweets'))
return
reply = self._unescape(reply)
2011-08-12 01:18:56 -07:00
irc.reply(reply)
trends = wrap(trends, ['channel'])
@internationalizeDocstring
def follow(self, irc, msg, args, channel, user):
"""[<channel>] <user>
Follow a specified <user>
If <channel> is not given, it defaults to the current channel.
"""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel.'))
return
try:
follow = api.CreateFriendship(user)
except twitter.TwitterError:
irc.error(_('An error occurred'))
return
irc.replySuccess()
follow = wrap(follow, ['channel', ('checkChannelCapability', 'twitteradmin'),
'somethingWithoutSpaces'])
@internationalizeDocstring
def unfollow(self, irc, msg, args, channel, user):
"""[<channel>] <user>
Unfollow a specified <user>
If <channel> is not given, it defaults to the current channel.
"""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel.'))
return
try:
unfollow = api.DestroyFriendship(user)
except twitter.TwitterError:
irc.error(_('An error occurred'))
return
irc.replySuccess()
unfollow = wrap(unfollow, ['channel',
('checkChannelCapability', 'twitteradmin'),
'somethingWithoutSpaces'])
2011-08-07 03:54:23 -07:00
@internationalizeDocstring
2012-08-15 07:51:39 -07:00
def delete(self, irc, msg, args, channel, id_):
2011-08-07 03:54:23 -07:00
"""[<channel>] <id>
Delete a specified status with id <id>
If <channel> is not given, it defaults to the current channel.
"""
2012-08-15 07:26:22 -07:00
if len(id_) <= 3:
try:
id_ = self._shortids[id_]
except KeyError:
irc.error(_('This is not a valid ID.'))
return
else:
try:
id_ = int(id_)
except ValueError:
irc.error(_('This is not a valid ID.'))
return
2011-08-07 03:54:23 -07:00
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel.'))
return
try:
2012-08-15 07:51:39 -07:00
delete = api.DestroyStatus(id_)
2011-08-07 03:54:23 -07:00
except twitter.TwitterError:
irc.error(_('An error occurred'))
return
irc.replySuccess()
delete = wrap(delete, ['channel',
('checkChannelCapability', 'twitteradmin'),
'somethingWithoutSpaces'])
2011-08-08 11:07:09 -07:00
@internationalizeDocstring
def stats(self, irc, msg, args, channel):
"""[<channel>]
Print some stats
If <channel> is not given, it defaults to the current channel.
"""
api = self._getApi(channel)
try:
reply = {}
reply['followers'] = len(api.GetFollowers())
reply['following'] = len(api.GetFriends(None))
except twitter.TwitterError:
irc.error(_('An error occurred'))
return
reply = "I am following %d people and have %d followers" % (reply['following'], reply['followers'])
irc.reply(reply)
stats = wrap(stats, ['channel'])
2011-08-13 12:10:07 -07:00
@internationalizeDocstring
def profile(self, irc, msg, args, channel, user=None):
"""[<channel>] [<user>]
2011-08-13 12:10:07 -07:00
Return profile image for a specified <user>
If <channel> is not given, it defaults to the current channel.
"""
api = self._getApi(channel)
if not api._oauth_consumer:
irc.error(_('No account is associated with this channel. Ask '
'an op, try with another channel.'))
return
try:
if user:
profile = api.GetUser(user)
else:
profile = api.VerifyCredentials()
2011-08-13 12:10:07 -07:00
except twitter.TwitterError:
irc.error(_('An error occurred'))
return
irc.reply(('Name: @%s (%s). Profile picture: %s. Biography: %s') %
(profile.screen_name,
profile.name,
profile.GetProfileImageUrl().replace('_normal', ''),
profile.description))
profile = wrap(profile, ['channel', optional('somethingWithoutSpaces')])
2011-08-13 12:10:07 -07:00
def die(self):
self.__parent.die()
2012-07-28 13:22:58 -07:00
self._died = True
Class = Twitter
# vim:set shiftwidth=4 softtabstop=4 expandtab textwidth=79:
2012-12-30 09:59:26 -08:00