Esempio n. 1
0
class StockLib(with_metaclass(abc.ABCMeta)):
    """Base class for getting stock information."""

    DEFAULT_PARAMS = params_lib.HypeParams({})

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()

    def ParseSymbols(self, symbols_str: Text) -> List[Text]:
        """Convert incoherent user input into a list of stock symbols."""
        symbols = re.split(r'[\s,;|+\\/]+', symbols_str.upper())
        return [s for s in symbols if s]

    @abc.abstractmethod
    def Quotes(self, symbols: List[Text]) -> Dict[Text, StockQuote]:
        """Fetches current quote data for each symbol.

    Stocks which aren't found, are not added to the return dict.

    Args:
      symbols: List of symbols to research.
    Returns:
      Dict keyed on symbol of quotes.
    """

    @abc.abstractmethod
    def History(self, symbols: List[Text],
                span: Text) -> Dict[Text, List[float]]:
        """Fetches daily historical data for each symbol.
Esempio n. 2
0
    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        if self._params.interface:
            self._params.interface.Override(
                {'name': self._params.name.lower()})
        self._params.Lock()

        # self.interface always maintains the connected interface that is listening
        # for messages. self._core.interface holds the interface desired for
        # outgoing communication. It may be swapped out on the fly, e.g., to handle
        # nested callbacks.
        self.interface = interface_factory.CreateFromParams(
            self._params.interface)
        self._InitCore()
        self.interface.RegisterHandlers(self.HandleMessage,
                                        self._core.user_tracker,
                                        self._core.user_prefs)

        # TODO: Factory built code change listener.

        self._commands = [
            command_factory.Create(name, params, self._core)
            for name, params in self._params.commands.AsDict().items()
            if params not in (None, False)
        ]
Esempio n. 3
0
    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        if self._params.interface:
            self._params.interface.Override(
                {'name': self._params.name.lower()})
        self._params.Lock()

        # self.interface always maintains the connected interface that is listening
        # for messages. self._core.interface holds the interface desired for
        # outgoing communication. It may be swapped out on the fly, e.g., to handle
        # nested callbacks.
        self.interface = interface_factory.CreateFromParams(
            self._params.interface)
        self._core = hypecore.Core(self._params, self.interface)

        self.interface.RegisterHandlers(self.HandleMessage,
                                        self._core.user_tracker)

        # TODO(someone): Factory built code change listener.

        # TODO(someone): Should betting on stocks be encapsulated into a
        # `command` so that it can be loaded on demand?
        self._stock_game = vegas_game_lib.StockGame(self._core.stocks)
        self._core.betting_games.append(self._stock_game)
        self._core.scheduler.DailyCallback(
            util_lib.ArrowTime(16, 0, 30, 'America/New_York'),
            self._StockCallback)

        self._commands = [
            command_factory.Create(name, params, self._core)
            for name, params in self._params.commands.AsDict().items()
            if params not in (None, False)
        ]
Esempio n. 4
0
 def __init__(self, params, core):
   self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
   self._params.Override(params)
   self._params.Lock()
   self.command_prefix = '%' if core.params.execution_mode.dev else '!'
   self._core = core
   self._parsers = []
   self._last_called = defaultdict(lambda: defaultdict(float))
   self._ratelimit_lock = Lock()
   self._spook_replies = util_lib.WeightedCollection(messages.SPOOKY_STRINGS)
Esempio n. 5
0
    def __init__(self, scheduler: schedule_lib.HypeScheduler,
                 store: storage_lib.HypeStore, bot_name: Text,
                 params: params_lib.HypeParams):
        self._scheduler = scheduler
        self._store = store
        self._bot_name = bot_name
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()

        self.badges = None
        self._LoadBadges()
        self._InitWeights()
        self._scheduler.DailyCallback(util_lib.ArrowTime(6),
                                      self._RestoreEnergy)
Esempio n. 6
0
    def CreateFromParams(self, params: params_lib.HypeParams, *args, **kwargs):
        """Creates an instance of the class based on the params.

    Creates a new instance of the registered subclass for params.type. Assumes
    the subclass accepts a HypeParams object as the first argument to the ctor.

    params.AsDict() is assumed to have the following structure:
    {
        # Name in registrar of class to create.
        'type': 'CAR',

        # Parameters shared between all subclasses.
        'owner': 'HypeBot',

        # Parameters specific to different registered names/subclass.
        'CAR': {
            'wheels': 4,
        },
        'BUS': {
            'passengers': 60,
        },
    }

    Args:
      params: Parameters.
      *args: Additional arguments passed to the subclass' ctor.
      **kwargs: Keyword arguments passed to the subclass' ctor.

    Returns:
      Newly constructed instance of subclass registered to params.name.
    """
        name = params.type
        subclass_params = params.get(name, {})
        # Remove keys that are registered names and the special name key.
        params = params.AsDict()
        for key in list(params.keys()):
            if key in self._registrar.keys() or key == 'type':
                del params[key]

        # Recreate params with the subclass specific params raised to the top level.
        params = params_lib.HypeParams(params)
        params.Override(subclass_params)
        return self.Create(name, params, *args, **kwargs)
Esempio n. 7
0
class NewsLib(with_metaclass(abc.ABCMeta)):
  """Base class for getting headlines."""

  DEFAULT_PARAMS = params_lib.HypeParams({})

  def __init__(self, params, proxy):
    self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
    self._params.Override(params)
    self._params.Lock()
    self._proxy = proxy

  @abc.abstractproperty
  def source(self) -> Text:
    """The human-readable name of this source.

    If the API provides news from multiple sources, this should be the "default"
    source. If no logical default exists, this can simply be the empty string.
    """

  @abc.abstractproperty
  def icon(self) -> 'message_pb2.Card.Image':
    """Returns an icon used to identify this news source."""

  @abc.abstractmethod
  def GetHeadlines(self,
                   query: Text,
                   max_results: int = 5) -> List[Dict[Text, Any]]:
    """Get headlines relating to query.

    Args:
      query: The query string to search for.
      max_results: The maximum number of headliens to return.

    Returns:
      List of dicts representing articles.
    """

  @abc.abstractmethod
  def GetTrending(self, max_results: int = 5) -> List[Dict[Text, Any]]:
    """Get the "front page" headlines at the current time.
Esempio n. 8
0
 def __init__(self, params):
     self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
     self._params.Override(params)
     self._params.Lock()
Esempio n. 9
0
class HypeStore(with_metaclass(abc.ABCMeta)):
    """Abstract base class for HypeBot storage."""

    DEFAULT_PARAMS = params_lib.HypeParams()

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()

    @abc.abstractproperty
    def engine(self):
        """The name of the backing engine. Should be all lowercase."""

    @abc.abstractmethod
    def GetValue(self,
                 key: HypeStr,
                 subkey: HypeStr,
                 tx: Optional[HypeTransaction] = None) -> Optional[HypeStr]:
        """Fetch the value for key and subkey.

    Args:
      key: The primary key used to find the entry.
      subkey: The subkey used to find the entry.
      tx: Optional transaction to run the underlying storage call within.

    Raises:
      If an error occurs during fetching the value from the store. This will
      abort any transaction GetValue is called with.

    Returns:
      The value found, or None if no value is found.
    """

    @abc.abstractmethod
    def SetValue(self,
                 key: HypeStr,
                 subkey: HypeStr,
                 value: Union[int, HypeStr],
                 tx: Optional[HypeTransaction] = None) -> None:
        """Replaces the current value (if any) for key and subkey with value.

    Args:
      key: The primary key used to find the entry.
      subkey: The subkey used to find the entry.
      value: The new value to overwrite any existing value with.
      tx: Optional transaction to run the underlying storage call within.

    Raises:
      If an error occurs during setting the value from the store. This will
      abort any transaction SetValue is called with.

    Returns:
      None.
    """

    def UpdateValue(self,
                    key: HypeStr,
                    subkey: HypeStr,
                    delta: int,
                    tx: Optional[HypeTransaction] = None) -> None:
        """Reads the current value for key/subkey and adds delta, atomically."""
        if tx:
            self._UpdateValue(key, subkey, delta, tx)
        else:
            self.RunInTransaction(self._UpdateValue,
                                  key,
                                  subkey,
                                  delta,
                                  tx_name='%s/%s += %s' % (key, subkey, delta))

    def _UpdateValue(self, key: HypeStr, subkey: HypeStr, delta: int,
                     tx: HypeTransaction) -> None:
        """Internal version of UpdateValue which requires a transaction."""
        cur_value = self.GetValue(key, subkey, tx) or 0
        try:
            cur_value = int(cur_value)
        except Exception as e:
            logging.error(
                'Can\'t call UpdateValue on (%s, %s), value %s isn\'t an int [%s]',
                key, subkey, cur_value, type(cur_value))
            raise e
        new_value = cur_value + delta
        self.SetValue(key, subkey, new_value, tx)

    @abc.abstractmethod
    def GetSubkey(
            self,
            subkey: HypeStr,
            tx: Optional[HypeTransaction] = None
    ) -> List[Tuple[HypeStr, HypeStr]]:
        """Returns (key, value) tuples for all keys with a value for subkey."""

    def GetJsonValue(
            self,
            key: HypeStr,
            subkey: HypeStr,
            tx: Optional[HypeTransaction] = None) -> (Optional[JsonType]):
        """Gets and deserializes the JSON object for key and subkey."""
        value = None
        try:
            serialized_value = self.GetValue(key, subkey, tx)
            if serialized_value:
                value = json.loads(serialized_value)
            return value
        except Exception as e:
            logging.error('Error fetching JSON value for %s/%s:', key, subkey)
            raise e

    def SetJsonValue(self,
                     key: HypeStr,
                     subkey: HypeStr,
                     json_value: JsonType,
                     tx: Optional[HypeTransaction] = None) -> None:
        """Serializes and stores json_value as a string."""
        try:
            value = json.dumps(json_value)
            self.SetValue(key, subkey, value, tx)
        except Exception as e:
            logging.error('Error storing JSON value for %s/%s.', key, subkey)
            # Re-raise so that transactions will be aborted.
            raise e

    def UpdateJson(self,
                   key: HypeStr,
                   subkey: HypeStr,
                   transform_fn: Callable[[JsonType], Any],
                   success_fn: Callable[[JsonType], bool],
                   is_set: bool = False,
                   tx: Optional[HypeTransaction] = None) -> bool:
        """Fetches a JSON object and stores it after applying transform_fn.

    Args:
      key: The storage key to operate on.
      subkey: The storage subkey to operate on.
      transform_fn: A function that accepts a deserialized JSON object (e.g.
        python dict or list) and modifies it in place. Return value is ignored.
      success_fn: A function that accepts a deserialized JSON object and returns
        a boolean, which is used as the final return value of UpdateJson. Note
        this function is applied to the deserialized object BEFORE transform_fn.
      is_set: If the JSON object should be treated as a set. This is required
        because JSON does not natively support sets, but a set can be easily
        represented by a list of members.
      tx: Optional transaction to include this update in. If no transaction is
        passed, a new transaction will be created to ensure the Update is
        atomic.

    Returns:
      The result of success_fn applied to the JSON object as it was when fetched
      from table.
    """
        if tx:
            return self._UpdateJson(key, subkey, transform_fn, success_fn,
                                    is_set, tx)
        tx_name = 'UpdateJson on %s/%s' % (key, subkey)
        return self.RunInTransaction(self._UpdateJson,
                                     key,
                                     subkey,
                                     transform_fn,
                                     success_fn,
                                     is_set,
                                     tx_name=tx_name)

    def _UpdateJson(self, key: HypeStr, subkey: HypeStr,
                    transform_fn: Callable[[JsonType], Any],
                    success_fn: Callable[[JsonType], bool], is_set: bool,
                    tx: HypeTransaction) -> bool:
        """Internal version of UpdateJson, requiring a transaction."""
        raw_structure = self.GetJsonValue(key, subkey, tx) or {}
        if is_set:
            raw_structure = set(raw_structure)
        success = success_fn(raw_structure)
        transform_fn(raw_structure)
        if is_set:
            raw_structure = list(raw_structure)
        self.SetJsonValue(key, subkey, raw_structure, tx)
        return success

    @abc.abstractmethod
    def GetHistoricalValues(
            self,
            key: HypeStr,
            subkey: HypeStr,
            num_past_values: int,
            tx: Optional[HypeTransaction] = None) -> List[JsonType]:
        """Like GetJsonValue, but allows for getting past values for key/subkey.

    Args:
      key: The primary key used to find the entry.
      subkey: The subkey used to find the entry.
      num_past_values: The maximum number of historical values to return. If
        fewer values are found (including 0 values), only that many values will
        be returned.
      tx: Optional transaction to run the underlying storage call within.

    Raises:
      If an error occurs during fetching the values from the store. This will
      abort any transaction GetHistoricalValues is called with.

    Returns:
      A list of the values found in reverse chronological order (newest values
      first). When no values are found, returns an empty list.
    """

    @abc.abstractmethod
    def PrependValue(self,
                     key: HypeStr,
                     subkey: HypeStr,
                     new_value: JsonType,
                     max_length: Optional[int] = None,
                     tx: Optional[HypeTransaction] = None) -> None:
        """Like SetJsonValue, but keeps past values upto an optional max_length.

    Args:
      key: The primary key used to find the entry.
      subkey: The subkey used to find the entry.
      new_value: The new value to add to the entry.
      max_length: The maximum number of past values to keep. Note that not all
        storage engines will respect this value, so it may be set to None.
      tx: Optional transaction to run the underlying storage call within.

    Raises:
      If an error occurs during setting the value from the store. This will
      abort any transaction PrependValue is called with.

    Returns:
      None.
    """

    @abc.abstractmethod
    def NewTransaction(self, tx_name: HypeStr) -> HypeTransaction:
        """Returns a new concrete transaction.

    Args:
      tx_name: Short description for this transaction, used in logging to
        clarify what operations are attempted within this transaction.

    Returns:
      An implementation-specific subclass of HypeTransaction.
    """

    @retrying.retry(stop_max_attempt_number=6, wait_exponential_multiplier=200)
    def RunInTransaction(self, fn: Callable, *args, **kwargs) -> Any:
        """Retriably attemps to execute fn within a single transaction.

    The normal use of this function is as follows:

    store.RunInTransaction(self._DoWorkThatNeedsToBeAtomic, arg_1, arg_2)
    ...
    def _DoWorkThatNeedsToBeAtomic(self, arg_1, arg_2, tx=None):
      a = store.GetValue(arg_1, arg_2)
      a += '-foo'
      store.SetValue(arg_1, arg_2, a)

    With the above, _DoWorkThatNeedsToBeAtomic will be done inside a transaction
    and retried if committing the transaction fails for a storage-engine defined
    reason which means that retrying to commit the transaction might be
    successful.

    Args:
      fn: The function which is executed within a transaction. It must be safe
        to run multiple times in the case of a transaction abort (e.g.
        contention on one of the key/subkey pairs), and must accept the kwarg
        "tx", which is a HypeTransaction.
      *args: Positional arguments to pass to fn.
      **kwargs: Keyword arguments to pass to fn. Will always include "tx".

    Raises:
      If tx.Commit raises any exception other than CommitAbortException,
      this function will not retry, and re-raise that exception.

    Returns:
      The return value of fn if fn does not raise an Exception. If fn does
      raise, the transaction is aborted, RunInTransaction is not retried, and
      returns False.
    """
        tx = kwargs.get('tx')
        if not tx:
            tx_name = kwargs.pop('tx_name',
                                 '%s: %s %s' % (fn.__name__, args, kwargs))
            tx = self.NewTransaction(tx_name)
            kwargs['tx'] = tx
        try:
            return_value = fn(*args, **kwargs)
        except Exception:
            logging.exception('Transactional function %s threw, aborting %s:',
                              fn.__name__, tx)
            return False

        try:
            tx.Commit()
        except CommitAbortException as e:
            logging.warning('%s Commit failed! Retrying', tx)
            raise e
        except Exception:
            logging.exception('Unknown error, aborting %s:', tx)
            return False

        return return_value
Esempio n. 10
0
 def setUp(self):
     super(HypeQueueTest, self).setUp()
     self._store = memstore_lib.MemStore(params_lib.HypeParams())
     self._scheduler = mock.MagicMock()
     self.queue = storage_lib.HypeQueue(self._store, 'test',
                                        self._scheduler, lambda _: True)
Esempio n. 11
0
class BaseChatInterface(with_metaclass(abc.ABCMeta)):
    """The interface base class.

  An `interface` allows hypebot to communicate with a chat application (e.g.,
  IRC, Discord, FireChat). This is an application-agnostic way of sending and
  receiving messages and information about users.
  """

    DEFAULT_PARAMS = params_lib.HypeParams({
        # Display name used when chatting.
        'name': 'chatbot',
    })

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()
        self._channels = set()

    def RegisterHandlers(self, on_message_fn, user_tracker, user_prefs):
        """Register handlers from the bot onto the interface.

    Allows the interface to communicate asynchronously to the bot when messages
    or user information comes.

    Args:
      on_message_fn: {callable(Channel, User, message)} Function that will be
        called in response to an incoming message.
      user_tracker: {UserTracker} Where to store results of Who/WhoAll requests.
      user_prefs: {SyncedDict} Persistent user preferences.
    """
        self._channels = set()
        self._on_message_fn = on_message_fn
        self._user_tracker = user_tracker
        self._user_prefs = user_prefs

    def Join(self, channel: hype_types.Channel):
        """Bring the power of hype to the desired channel.

    The base class only maintains a list of active channels. Subclasses are
    responsible for actually joining the channel.

    Args:
      channel: {Channel} channel name to join.
    """
        self._channels.add(channel.id)

    def Leave(self, channel: hype_types.Channel):
        """We do not condone this behavior.

    The base class only maintains a list of active channels. Subclasses are
    responsible for actually leaving the channel.

    Args:
      channel: {Channel} channel to leave.
    """
        if channel.id in self._channels:
            self._channels.remove(channel.id)
        else:
            logging.warning('Tried to leave channel that I never joined: %s',
                            channel)

    @abc.abstractmethod
    def Loop(self):
        """Listen to messages from the chat application indefinitely.

    Loop steals the current thread.
    """
        raise NotImplementedError()

    def FindUser(self, query: Text) -> Optional[user_pb2.User]:
        """Find user with the given name or user_id.

    Attempts to find a user proto for the given query. Some interfaces provide
    an annotation syntax to allow specifying a specific user. Since these aren't
    universal, the Interface will convert it into the user_id for the command.
    However, we would also like to support referring to a user by their display
    name directly. If specifying the display name, it is possible for it not to
    be unique.

    Args:
      query: Either user_id or display name of user.

    Returns:
      The full user proto of the desired user or None if no user exists or the
      query does not resolve to a unique user.
    """
        users = self._user_tracker.AllUsers()
        matches = []
        for user in users:
            if user.user_id == query:
                return user
            if user.display_name.lower() == query.lower():
                matches.append(user)
        if len(matches) == 1:
            return matches[0]
        return None

    @abc.abstractmethod
    def WhoAll(self):
        """Request that all users be added to the user tracker."""
        raise NotImplementedError()

    # TODO: Eliminate Optional from the message type.
    @abc.abstractmethod
    def SendMessage(self, channel: hype_types.Channel,
                    message: Optional[hype_types.Message]):
        """Send a message to the given channel.

    Args:
      channel: channel to receive message.
      message: message to send to the channel.
    """
        raise NotImplementedError()

    @abc.abstractmethod
    def SendDirectMessage(self, user: user_pb2.User,
                          message: hype_types.Message):
        raise NotImplementedError()

    # TODO: Eliminate Optional from the message type.
    @abc.abstractmethod
    def Notice(self, channel: hype_types.Channel, message: hype_types.Message):
        """Send a notice to the channel.

    Some applications (IRC) support a different type of message to a channel.
    This is used to broadcast a message not in response to a user input. E.g.,
    match start time or scheduled bet resolution.

    Args:
      channel: channel to send notice.
      message: notice to send to the channel.
    """
        raise NotImplementedError()

    @abc.abstractmethod
    def Topic(self, channel: hype_types.Channel, new_topic: Text):
        """Changes the "topic" of channel to new_topic.

    Args:
      channel: channel to change the topic of.
      new_topic: new topic to set.
    """
        raise NotImplementedError()
Esempio n. 12
0
class CoffeeLib:
    """Handles the management of coffee for plebs."""

    _SUBKEY = 'coffee'

    # For new users, we initialize their CoffeeData to a copy of this value.
    _DEFAULT_COFFEE_DATA = coffee_pb2.CoffeeData(energy=10)

    DEFAULT_PARAMS = params_lib.HypeParams({
        # Chance of finding a bean when searching with no other modifiers. [0,1)
        'bean_chance': 0.5,
        # Number of beans that can be stored before a user runs out of room.
        'bean_storage_limit': 10,
        # Filepath to load badge data from. Should be a textproto file containing
        # a coffee_pb2.CoffeeData proto with only `badges` set.
        'badge_data_path': 'hypebot/data/coffee_badges.textproto',
        # Weights used to calculate drop rates for bean regions. Data is actual
        # coffee production by country/region in 1000's of 60kg bags.
        #
        # Global data source:
        # http://www.ico.org/historical/1990%20onwards/PDF/1a-total-production.pdf
        # Hawaii data source:
        # https://www.nass.usda.gov/Statistics_by_State/Hawaii/Publications/Fruits_and_Nuts/201807FinalCoffee.pdf
        #
        # Retrieved 2020/04/07, regions with less than 0.1% of global production
        # have been removed.
        'region_weights': {
            'Brazil': 62925,
            'Colombia': 13858,
            'Cote d\'Ivoire': 2294,
            'Ethiopia': 7776,
            'Guatemala': 4007,
            'Honduras': 7328,
            'India': 5302,
            'Indonesia': 9418,
            'Mexico': 4351,
            'Nicaragua': 2510,
            'Peru': 4263,
            'Uganda': 4704,
            'Vietnam': 31174,
        },
        # Weights used to calculate drop rates for bean varities.
        'variety_weights': {
            'Arabica': 6,
            'Robusta': 3,
            'Liberica': 1,
        },
        # Weights used to calculate drop rates for bean rarities.
        'rarity_weights': {
            'common': 50,
            'uncommon': 28,
            'rare': 14,
            'precious': 7,
            'legendary': 1,
        }
    })

    def __init__(self, scheduler: schedule_lib.HypeScheduler,
                 store: storage_lib.HypeStore, bot_name: Text,
                 params: params_lib.HypeParams):
        self._scheduler = scheduler
        self._store = store
        self._bot_name = bot_name
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()

        self.badges = None
        self._LoadBadges()
        self._InitWeights()
        self._scheduler.DailyCallback(util_lib.ArrowTime(6),
                                      self._RestoreEnergy)

    def _LoadBadges(self):
        logging.info('Trying to load badges from %s',
                     self._params.badge_data_path)
        try:
            with open(self._params.badge_data_path, 'r') as f:
                self.badges = text_format.Parse(f.read(),
                                                coffee_pb2.BadgeList()).badges
        except FileNotFoundError:
            logging.exception(
                'Couldn\'t load badges, granting badges disabled.')

    def _InitWeights(self):
        """Initializes WeightedCollections for use in generating new beans."""
        # Actual production data ends up being a bit too skewed towards the largest
        # regions for gameplay purposes, so we smooth the distribution out by moving
        # all weights towards the median.
        region_weights = self._params.region_weights.AsDict()
        regions_by_weight = sorted(region_weights.items(), key=lambda x: x[1])
        region_value_median = regions_by_weight[len(region_weights) // 2][1]
        smoothed_region_weights = {
            k: v + (region_value_median - v) * 0.5
            for k, v in region_weights.items()
        }
        scale_factor = sum(region_weights.values()) / sum(
            smoothed_region_weights.values())
        smoothed_region_weights = {
            k: scale_factor * v
            for k, v in smoothed_region_weights.items()
        }

        self._weighted_regions = util_lib.WeightedCollection(
            smoothed_region_weights.keys(), smoothed_region_weights.values())
        self._weighted_rarities = util_lib.WeightedCollection(
            self._params.rarity_weights.AsDict().keys(),
            self._params.rarity_weights.AsDict().values())
        self._weighted_varieties = util_lib.WeightedCollection(
            self._params.variety_weights.AsDict().keys(),
            self._params.variety_weights.AsDict().values())

        self._weighted_regions.Freeze()
        self._weighted_rarities.Freeze()
        self._weighted_varieties.Freeze()

    def _RestoreEnergy(self) -> None:
        # No transaction for the whole function because we don't want to hold a tx
        # while we update every single user. This way gets us a snapshot of all
        # users, then updates each one atomically.
        user_list = self._store.GetSubkey(self._SUBKEY)
        c = 0
        for username, data in user_list:
            if data:
                c += 1
                self._store.RunInTransaction(self._RestoreUserEnergy, username)
        logging.info('Restored energy to %d pleb(s)', c)

    def _RestoreUserEnergy(
            self,
            username: Text,
            tx: Optional[storage_lib.HypeTransaction] = None) -> None:
        if not tx:
            return self._store.RunInTransaction(self._RestoreUserEnergy,
                                                username)
        user = user_pb2.User(user_id=username)
        user_data = self.GetCoffeeData(user, tx)
        # Min is set such that there is a ~1% chance that a user with no beans ends
        # up at 0 energy without finding at least one bean.
        min_energy = int(math.ceil(math.log(0.01, self._params.bean_chance)))
        max_energy = min_energy * 4
        # We allow users to go over the "max" energy, but they will stop
        # regenerating energy until they fall back below the max.
        if user_data.energy < max_energy:
            user_data.energy = max(min_energy, user_data.energy + 3)
            # Ensure we don't regen over the max.
            user_data.energy = min(max_energy, user_data.energy)
        self._SetCoffeeData(user, user_data, tx)

    def DrinkCoffee(
        self,
        user: user_pb2.User,
        bean_id: Optional[Text] = None,
        tx: Optional[storage_lib.HypeTransaction] = None
    ) -> Union[Dict[Text, Any], StatusCode]:
        """Lets user drink some of their nice coffee."""
        if not tx:
            return self._store.RunInTransaction(self.DrinkCoffee, user,
                                                bean_id)
        user_data = self.GetCoffeeData(user, tx)
        if not user_data.beans:
            return StatusCode.NOT_FOUND

        bean = None
        if bean_id:
            bean = GetBean(bean_id, user_data.beans, remove_bean=True)
            if not bean:
                return StatusCode.NOT_FOUND
        else:
            bean = user_data.beans.pop(
                random.randint(0,
                               len(user_data.beans) - 1))
        energy = random.randint(1, 6)
        user_data.energy += energy
        user_data.statistics.drink_count += 1
        self._UpdateGameData(user, user_data, bean, tx)
        return {'energy': energy, 'bean': bean}

    def FindBeans(
        self,
        user: user_pb2.User,
        energy: int,
        tx: Optional[storage_lib.HypeTransaction] = None
    ) -> Union[StatusCode, coffee_pb2.CoffeeData]:
        """Tries to scrounge up some beans for user."""
        if not tx:
            return self._store.RunInTransaction(self.FindBeans, user, energy)
        user_data = self.GetCoffeeData(user, tx)
        if user_data.energy < energy:
            return StatusCode.RESOURCE_EXHAUSTED
        if len(user_data.beans) >= self._params.bean_storage_limit:
            return StatusCode.OUT_OF_RANGE

        user_data.energy -= energy
        iterations = 1
        if energy > 1:
            iterations = int(min(math.ceil(energy * 1.5), energy + 7))
        bean = None
        for _ in range(iterations):
            r = random.random()
            if r < self._params.bean_chance:
                candidate_bean = coffee_pb2.Bean(
                    region=self._weighted_regions.GetItem(),
                    variety=self._weighted_varieties.GetItem(),
                    rarity=self._weighted_rarities.GetItem(),
                )
                if not bean or self.GetOccurrenceChance(
                        candidate_bean) < self.GetOccurrenceChance(bean):
                    bean = candidate_bean

        if bean:
            user_data.beans.append(bean)
            user_data.statistics.find_count += 1
        self._UpdateGameData(user, user_data, bean, tx)
        return bean or StatusCode.NOT_FOUND

    def _GrantBadges(
        self,
        user: user_pb2.User,
        user_data: coffee_pb2.CoffeeData,
        unused_bean: Optional[coffee_pb2.Bean] = None,
    ) -> Dict[Text, List[int]]:
        """Checks all possible badges and grants/revokes them as needed.

    Args:
      user: User to grant/revoke badges for.
      user_data: User's CoffeeData.
      unused_bean: Bean found/consumed immediately prior to this call. Currently
        unused.

    Returns:
      A dict with badges granted and revoked.
    """
        modifications = {'grant': [], 'revoke': []}
        if not self.badges:
            return modifications
        for badge in self.badges.values():
            if badge.id not in _BADGE_CODE_REGISTRY:
                logging.warning(
                    'Badge "%s" [id=%s] is not registered so can\'t be assiged to '
                    'users.', badge.name, badge.id)
                continue
            should_user_have_badge = _BADGE_CODE_REGISTRY[badge.id](user_data)
            if should_user_have_badge:
                if badge.id in user_data.badges:
                    continue
                logging.info('Granting badge %s to %s', badge.name,
                             user.user_id)
                user_data.badges.append(badge.id)
                modifications['grant'].append(badge.id)
            elif not badge.is_permanent:
                if badge.id in user_data.badges:
                    logging.info('Revoking badge %s from %s', badge.name,
                                 user.user_id)
                    user_data.badges.remove(badge.id)
                    modifications['revoke'].append(badge.id)
        return modifications

    def GetOccurrenceChance(self, bean: coffee_pb2.Bean) -> float:
        """Returns the probability of finding bean in the wild."""
        region_weight = self._weighted_regions.GetWeight(bean.region)
        variety_weight = self._weighted_varieties.GetWeight(bean.variety)
        rarity_weight = self._weighted_rarities.GetWeight(bean.rarity)
        return region_weight * variety_weight * rarity_weight

    def GetCoffeeData(
        self,
        user: user_pb2.User,
        tx: Optional[storage_lib.HypeTransaction] = None
    ) -> coffee_pb2.CoffeeData:
        """Returns user_data for user, or the default data if user is not found."""
        serialized_data = self._store.GetJsonValue(user.user_id, self._SUBKEY,
                                                   tx)
        if serialized_data:
            return json_format.Parse(serialized_data, coffee_pb2.CoffeeData())
        coffee_data = coffee_pb2.CoffeeData()
        coffee_data.CopyFrom(self._DEFAULT_COFFEE_DATA)
        return coffee_data

    def _SetCoffeeData(self,
                       user: user_pb2.User,
                       coffee_data: coffee_pb2.CoffeeData,
                       tx: Optional[storage_lib.HypeTransaction] = None):
        # We first generate bean_ids for any beans missing one.
        for b in [b for b in coffee_data.beans if not b.id]:
            b.id = _GetBeanId(b)
        serialized_data = json_format.MessageToJson(coffee_data, indent=0)
        self._store.SetJsonValue(user.user_id, self._SUBKEY, serialized_data,
                                 tx)

    def TransferBeans(
            self,
            owner: user_pb2.User,
            buyer: user_pb2.User,
            bean_id: Text,
            price: int,
            tx: Optional[storage_lib.HypeTransaction] = None) -> StatusCode:
        """Transfers bean_id from owner_id to target_id."""
        if not tx:
            return self._store.RunInTransaction(self.TransferBeans, owner,
                                                buyer, bean_id, price)
        owner_beans = self.GetCoffeeData(owner)
        bean = GetBean(bean_id, owner_beans.beans)
        if not bean:
            return StatusCode.NOT_FOUND

        buyer_beans = self.GetCoffeeData(buyer, tx)
        if len(buyer_beans.beans) >= self._params.bean_storage_limit:
            return StatusCode.OUT_OF_RANGE

        owner_beans.beans.remove(bean)
        owner_stats = owner_beans.statistics
        owner_stats.sell_count += 1
        owner_stats.sell_amount = str(
            util_lib.UnformatHypecoins(owner_stats.sell_amount or '0') + price)
        buyer_beans.beans.append(bean)
        buyer_stats = buyer_beans.statistics
        buyer_stats.buy_count += 1
        buyer_stats.buy_amount = str(
            util_lib.UnformatHypecoins(buyer_stats.buy_amount or '0') + price)
        self._UpdateGameData(owner, owner_beans, bean, tx)
        self._UpdateGameData(buyer, buyer_beans, bean, tx)
        return StatusCode.OK

    def _UpdateGameData(self,
                        user: user_pb2.User,
                        user_data: coffee_pb2.CoffeeData,
                        bean: Optional[coffee_pb2.Bean] = None,
                        tx: Optional[storage_lib.HypeTransaction] = None):
        self._GrantBadges(user, user_data, bean)
        self._SetCoffeeData(user, user_data, tx)
Esempio n. 13
0
class BaseBot(object):
    """Class for shitposting in IRC."""

    DEFAULT_PARAMS = params_lib.HypeParams({
        # Human readable name to call bot. Will be normalized before most uses.
        'name': 'BaseBot',
        # Chat application interface.
        'interface': {
            'type': 'DiscordInterface'
        },
        # The default channel for announcements and discussion.
        'default_channel': {
            'id': '418098011445395462',
            'name': '#dev'
        },
        # Default time zone for display.
        'time_zone': 'America/Los_Angeles',
        'news': {
            'type': 'NYTimesNews',
        },
        'coffee': {
            'badge_data_path': 'hypebot/data/coffee_badges.textproto',
        },
        'proxy': {
            'type': 'RequestsProxy'
        },
        'storage': {
            'type': 'RedisStore',
            'cached_type': 'ReadCacheRedisStore'
        },
        'stocks': {
            'type': 'IEXStock'
        },
        'weather': {
            'geocode_key': None,
            'darksky_key': None,
            'airnow_key': None,
        },
        'execution_mode': {
            # If this bot is being run for development. Points to non-prod data
            # and changes the command prefix.
            'dev': True,
            # If the bot is running on deployed architecture.
            'deployed': False,
        },
        # List of commands for the bot to create.
        'commands': {
            'AliasAddCommand': {},
            'AliasCloneCommand': {},
            'AliasListCommand': {},
            'AliasRemoveCommand': {},
            'AskFutureCommand': {},
            'AutoReplySnarkCommand': {},
            'BuyHypeStackCommand': {},
            'CoinFlipCommand': {},
            'CookieJarCommand': {},
            'DebugCommand': {},
            'DisappointCommand': {},
            'EchoCommand': {},
            'EnergyCommand': {},
            'GreetingPurchaseCommand': {},
            'GreetingsCommand': {},
            'GrepCommand': {},
            'HypeCommand': {},
            'HypeJackCommand': {},
            'HypeStackBalanceCommand': {},
            'InventoryList': {},
            'InventoryUse': {},
            'JackpotCommand': {},
            'KittiesSalesCommand': {},
            'MemeCommand': {},
            'MissingPingCommand': {},
            'NewsCommand': {},
            'OrRiotCommand': {},
            'PopulationCommand': {},
            'PreferencesCommand': {},
            'PrideAndAccomplishmentCommand': {},
            'RageCommand': {},
            'RatelimitCommand': {},
            'RaiseCommand': {},
            'ReloadCommand': {},
            'RipCommand': {},
            'SameCommand': {},
            'SayCommand': {},
            'ScrabbleCommand': {},
            'SetPreferenceCommand': {},
            'ShruggieCommand': {},
            'SticksCommand': {},
            'StocksCommand': {},
            'StoryCommand': {},
            'SubCommand': {},
            'VersionCommand': {},
            'VirusCommand': {},
            'WebsiteDevelopmentCommand': {},
            'WordCountCommand': {},
            # Hypecoins
            'HCBalanceCommand': {},
            'HCBetCommand': {},
            'HCBetsCommand': {},
            'HCCirculationCommand': {},
            'HCForbesCommand': {},
            'HCGiftCommand': {},
            'HCResetCommand': {},
            'HCRobCommand': {},
            'HCTransactionsCommand': {},
            # HypeCoffee
            'DrinkCoffeeCommand': {},
            'FindCoffeeCommand': {},
            'CoffeeBadgeCommand': {},
            'CoffeeStashCommand': {},
            # Deployment
            'BuildCommand': {},
            'DeployCommand': {},
            'PushCommand': {},
            'SetSchemaCommand': {},
            'TestCommand': {},
            # Interface
            'JoinCommand': {},
            'LeaveCommand': {},
        },
        'subscriptions': {
            'lottery': [{
                'id': '418098011445395462',
                'name': '#dev'
            }],
            'stocks': [{
                'id': '418098011445395462',
                'name': '#dev'
            }],
        },
        'version': '4.20.0',
    })

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        if self._params.interface:
            self._params.interface.Override(
                {'name': self._params.name.lower()})
        self._params.Lock()

        # self.interface always maintains the connected interface that is listening
        # for messages. self._core.interface holds the interface desired for
        # outgoing communication. It may be swapped out on the fly, e.g., to handle
        # nested callbacks.
        self.interface = interface_factory.CreateFromParams(
            self._params.interface)
        self._InitCore()
        self.interface.RegisterHandlers(self.HandleMessage,
                                        self._core.user_tracker,
                                        self._core.user_prefs)

        # TODO: Factory built code change listener.

        self._commands = [
            command_factory.Create(name, params, self._core)
            for name, params in self._params.commands.AsDict().items()
            if params not in (None, False)
        ]

    def _InitCore(self):
        """Initialize hypecore.

    Broken out from __init__ so that subclasses can ensure the core is
    initialized before dependent things (e.g., commands) are constructed.

    We define initialization as the instantiation of all objects attached to
    core. However, the objects don't need to be fully loaded.
    """
        self._core = hypecore.Core(self._params, self.interface)

    def HandleMessage(self, channel: channel_pb2.Channel, user: user_pb2.User,
                      msg: Text):
        """Handle an incoming message from the interface."""
        self._core.user_tracker.AddUser(user)
        msg = self._ProcessAliases(channel, user, msg)
        msg = self._ProcessNestedCalls(channel, user, msg)

        if channel.visibility == channel_pb2.Channel.PRIVATE:
            # See if someone is confirming/denying a pending request. This must happen
            # before command parsing so that we don't try to resolve a request created
            # in this message (e.g. !stack buy 1)
            if self._core.request_tracker.HasPendingRequest(user):
                self._core.request_tracker.ResolveRequest(user, msg)

        for command in self._commands:
            try:
                sync_reply = command.Handle(channel, user, msg)
                # Note that this does not track commands that result in only:
                #   * async replies
                #   * direct messages to users
                #   * rate limits
                #   * exceptions
                # TODO: Figure out how to do proper activity tracking.
                if sync_reply:
                    self._core.activity_tracker.RecordActivity(
                        channel, user, command.__class__.__name__)
                self._core.Reply(channel, sync_reply)
            except Exception:
                self._core.Reply(user,
                                 'Exception handling: %s' % msg,
                                 log=True,
                                 log_level=logging.ERROR)

        # This must come after message processing for paychecks to work properly.
        self._core.user_tracker.RecordActivity(user, channel)

    def _ProcessAliases(self, unused_channel, user: user_pb2.User, msg: Text):
        return alias_lib.ExpandAliases(self._core.cached_store, user, msg)

    NESTED_PATTERN = re.compile(
        r'\$\(([^\(\)]+'
        # TODO: Actually tokenize input
        # instead of relying on cheap hacks
        r'(?:[^\(\)]*".*?"[^\(\)]*)*)\)')

    def _ProcessNestedCalls(self, channel, user, msg):
        """Evaluate nested commands within $(...)."""
        m = self.NESTED_PATTERN.search(msg)
        while m:
            backup_interface = self._core.interface
            self._core.interface = interface_factory.Create(
                'CaptureInterface', {})

            # Pretend it's Private to avoid ratelimit.
            nested_channel = channel_pb2.Channel(
                id=channel.id,
                visibility=channel_pb2.Channel.PRIVATE,
                name=channel.name)
            self.HandleMessage(nested_channel, user, m.group(1))
            response = self._core.interface.MessageLog()

            msg = msg[:m.start()] + response + msg[m.end():]
            self._core.interface = backup_interface
            m = self.NESTED_PATTERN.search(msg)
        return msg
Esempio n. 14
0
class BaseBot(object):
    """Class for shitposting in IRC."""

    DEFAULT_PARAMS = params_lib.HypeParams({
        # Human readable name to call bot. Will be normalized before most uses.
        'name': 'BaseBot',
        # Chat application interface.
        'interface': {
            'type': 'DiscordInterface'
        },
        # Restrict some responses to only these channels.
        'main_channels': ['.*'],
        # The default channel for announcements and discussion.
        'default_channel': {
            'id': '418098011445395462',
            'name': '#dev',
        },
        # Default time zone for display.
        'time_zone': 'America/Los_Angeles',
        'storage': {
            'type': 'RedisStore'
        },
        'stocks': {
            'type': 'IEXStock'
        },
        'execution_mode': {
            # If this bot is being run for development. Points to non-prod data
            # and changes the command prefix.
            'dev': True,
        },
        # List of commands for the bot to create.
        'commands': {
            'AliasAddCommand': {},
            'AliasCloneCommand': {},
            'AliasListCommand': {},
            'AliasRemoveCommand': {},
            'AskFutureCommand': {},
            'BuyHypeStackCommand': {},
            'CookieJarCommand': {},
            'DisappointCommand': {},
            'EchoCommand': {},
            'EnergyCommand': {},
            'GreetingPurchaseCommand': {},
            'GreetingsCommand': {},
            'GrepCommand': {},
            'HypeCommand': {},
            'HypeStackBalanceCommand': {},
            'InventoryList': {},
            'InventoryUse': {},
            'JackpotCommand': {},
            'KittiesSalesCommand': {},
            'MainCommand': {},
            'MemeCommand': {},
            'MissingPingCommand': {},
            'OrRiotCommand': {},
            'RageCommand': {},
            'RatelimitCommand': {},
            'RaiseCommand': {},
            'ReloadCommand': {},
            'RipCommand': {},
            'SameCommand': {},
            'SayCommand': {},
            'ScrabbleCommand': {},
            'SticksCommand': {},
            'StocksCommand': {},
            'StoryCommand': {},
            'SubCommand': {},
            'VersionCommand': {},
            'WordCountCommand': {},
            # Hypecoins
            'HCBalanceCommand': {},
            'HCBetCommand': {},
            'HCBetsCommand': {},
            'HCCirculationCommand': {},
            'HCForbesCommand': {},
            'HCGiftCommand': {},
            'HCResetCommand': {},
            'HCRobCommand': {},
            'HCTransactionsCommand': {},
            # Deployment
            'BuildCommand': {},
            'DeployCommand': {},
            'PushCommand': {},
            'SetSchemaCommand': {},
            'TestCommand': {},
            # Interface
            'JoinCommand': {},
            'LeaveCommand': {},
        },
        'version': '4.20.0'
    })

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        if self._params.interface:
            self._params.interface.Override(
                {'name': self._params.name.lower()})
        self._params.Lock()

        # self.interface always maintains the connected interface that is listening
        # for messages. self._core.interface holds the interface desired for
        # outgoing communication. It may be swapped out on the fly, e.g., to handle
        # nested callbacks.
        self.interface = interface_factory.CreateFromParams(
            self._params.interface)
        self._core = hypecore.Core(self._params, self.interface)

        self.interface.RegisterHandlers(self.HandleMessage,
                                        self._core.user_tracker)

        # TODO(someone): Factory built code change listener.

        # TODO(someone): Should betting on stocks be encapsulated into a
        # `command` so that it can be loaded on demand?
        self._stock_game = vegas_game_lib.StockGame(self._core.stocks)
        self._core.betting_games.append(self._stock_game)
        self._core.scheduler.DailyCallback(
            util_lib.ArrowTime(16, 0, 30, 'America/New_York'),
            self._StockCallback)

        self._commands = [
            command_factory.Create(name, params, self._core)
            for name, params in self._params.commands.AsDict().items()
            if params not in (None, False)
        ]

    def HandleMessage(self, channel, user, msg):
        """Handle an incoming message from the interface."""
        msg = self._ProcessAliases(channel, user, msg)
        msg = self._ProcessNestedCalls(channel, user, msg)

        if channel.visibility == Channel.PRIVATE:
            # See if someone is confirming/denying a pending request. This must happen
            # before command parsing so that we don't try to resolve a request created
            # in this message (e.g. !stack buy 1)
            if self._core.request_tracker.HasPendingRequest(user):
                self._core.request_tracker.ResolveRequest(user, msg)

        for command in self._commands:
            try:
                command.Handle(channel, user, msg)
            except Exception:
                self._core.Reply(user,
                                 'Exception handling: %s' % msg,
                                 log=True,
                                 log_level=logging.ERROR)

        # This must come after message processing for paychecks to work properly.
        self._core.executor.submit(self._RecordUserActivity, channel, user)

    def _ProcessAliases(self, unused_channel, user, msg):
        return alias_lib.ExpandAliases(self._core.cached_store, user, msg)

    NESTED_PATTERN = re.compile(
        r'\$\(([^\(\)]+'
        # TODO(someone): Actually tokenize input
        # instead of relying on cheap hacks
        r'(?:[^\(\)]*".*?"[^\(\)]*)*)\)')

    def _ProcessNestedCalls(self, channel, user, msg):
        """Evaluate nested commands within $(...)."""
        m = self.NESTED_PATTERN.search(msg)
        while m:
            backup_interface = self._core.interface
            self._core.interface = interface_factory.Create(
                'CaptureInterface', {})

            # Pretend it's Private to avoid ratelimit.
            nested_channel = Channel(id=channel.id,
                                     visibility=Channel.PRIVATE,
                                     name=channel.name)
            self.HandleMessage(nested_channel, user, m.group(1))
            response = self._core.interface.MessageLog()

            msg = msg[:m.start()] + response + msg[m.end():]
            self._core.interface = backup_interface
            m = self.NESTED_PATTERN.search(msg)
        return msg

    def _RecordUserActivity(self, unused_channel, user):
        """TLA recording of users."""
        if not self._core.user_tracker.IsKnown(user):
            logging.info('Unknown user %s, not recording activity', user)
            self.interface.Who(user)
            return
        if self._core.user_tracker.IsBot(user) or not self._core.cached_store:
            return
        # Update user's activity. We only store at the 5 minute resolution to cut
        # down on the number of storage writes we need to do.
        utc_now = arrow.utcnow().timestamp // (5 * 60)
        try:
            self._core.cached_store.SetValue(user, 'lastseen', str(utc_now))
        except Exception as e:
            logging.error('Error recording user activity: %s', e)

    def _StockCallback(self):
        msg_fn = partial(self._core.Reply,
                         default_channel=self._core.default_channel)
        self._core.bets.SettleBets(self._stock_game, self._core.nick, msg_fn)
Esempio n. 15
0
class BaseCommand(object):
  """Base class for commands."""

  DEFAULT_PARAMS = params_lib.HypeParams({
      # See _Ratelimit for details.
      'ratelimit': {
          'enabled': True,
          # One of USER, GLOBAL, or CHANNEL. Calls from the same scope are
          # rate-limitted.
          'scope': 'USER',
          # Minimum number of seconds to pass between calls.
          'interval': 5,
          # Will only ratelimit if _Handle returns a value.
          'return_only': False,
      },
      # Which channels (rooms) should handle the message.
      #
      # This operates in addition to the parsers and is useful for surfacing
      # different commands for different communities. E.g., !who could be
      # handled by different commands for a LoL or Overwatch channel.
      #
      # Default allows all channels to utilize command.  Otherwise, supply a
      # list of channels where the specified id must be a prefix of the incoming
      # message's channel.id.
      'channels': [''],
      # Alternatively, which channels should not handle the message. This takes
      # precedence over 'channels'.
      #
      # Default allows all channels to handle the message.
      'avoid_channels': [],
      # By default, parsers may provide a `target_user` in their kwargs which
      # gets auto-converted into a User proto if the correponsing user exists.
      # If the user does not exist, the command will respond "Unknown user
      # $target_user" and not call the underlying `_Handle` method. If you
      # override this to `True`, it will create a fake user with user_id and
      # display_name set to target_user if the user does not exist.
      'target_any': False,
      # If the command should only be invoked in channels with PRIVATE
      # visibility (aka private messages, or DMs as the kids say).
      'private_channels_only': False,
  })

  # Used to ignore a level of scoping.
  _DEFAULT_SCOPE = 'all'

  def __init__(self, params, core):
    self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
    self._params.Override(params)
    self._params.Lock()
    self.command_prefix = '%' if core.params.execution_mode.dev else '!'
    self._core = core
    self._parsers = []
    self._last_called = defaultdict(lambda: defaultdict(float))
    self._ratelimit_lock = Lock()
    self._spook_replies = util_lib.WeightedCollection(messages.SPOOKY_STRINGS)

  def Handle(self, channel: channel_pb2.Channel, user: user_pb2.User,
             message: Text) -> hype_types.CommandResponse:
    """Attempt to handle the message.

    First we check if this command is available for this channel. Then, we
    compare message against all parsers. If one of them accepts the message,
    send the parsed command to the internal _Handle function.

    Args:
      channel: Channel from which the message was received.
      user: User who invoked the command.
      message: Raw message from application.

    Returns:
      Response message from command.
    """
    if not self._InScope(channel):
      return
    for parser in self._parsers:
      take, args, kwargs = parser(channel, user, message)
      if take:
        if 'target_user' in kwargs and kwargs['target_user'] is not None:
          target_user = self._ParseCommandTarget(
              user, kwargs['target_user'], message)
          if not target_user:
            return 'Unrecognized user %s' % kwargs['target_user']
          kwargs['target_user'] = target_user

        # Ensure we don't handle the same message twice.
        return self._Ratelimit(channel, user, *args, **kwargs)

  def _InScope(self, channel: channel_pb2.Channel):
    """Determine if channel is in scope."""
    # DMs and system internal commands are always allowed.
    if channel.visibility in [
        channel_pb2.Channel.PRIVATE, channel_pb2.Channel.SYSTEM
    ]:
      return True
    elif self._params.private_channels_only:
      return False
    # Channel scope
    if (not util_lib.MatchesAny(self._params.channels, channel) or
        util_lib.MatchesAny(self._params.avoid_channels, channel)):
      return False

    return True

  def _Ratelimit(self, channel: channel_pb2.Channel, user: user_pb2.User, *args,
                 **kwargs):
    """Ratelimits calls/responses from Handling the message.

    In general this, prevents the same user, channel, or global triggering a
    command in quick succession. This works by timing calls and verifying that
    future calls have exceeded the interval before invocation.

    Some commands like to handle every message, but only respond to a few. E.g.,
    MissingPing needs every message to record the most recent user, but only
    sends a response when someone types '?'. In this case, we always execute the
    command and only ratelimit the response. The restriction being, that it is
    safe to call on every invocation. E.g., do not transfer hypecoins.

    Args:
      channel: Passed through to _Handle.
      user: Passed through to _Handle.
      *args: Passed through to _Handle.
      **kwargs: Passed through to _Handle.

    Returns:
      Optional message(s) to reply to the channel.
    """
    if (not self._params.ratelimit.enabled or channel.visibility in [
        channel_pb2.Channel.PRIVATE, channel_pb2.Channel.SYSTEM
    ]):
      return self._Handle(channel, user, *args, **kwargs)

    scoped_channel = channel_pb2.Channel()
    scoped_channel.CopyFrom(channel)
    scoped_user_id = user.user_id
    if self._params.ratelimit.scope == 'GLOBAL':
      scoped_channel.id = self._DEFAULT_SCOPE
      scoped_user_id = self._DEFAULT_SCOPE
    elif self._params.ratelimit.scope == 'CHANNEL':
      scoped_user_id = self._DEFAULT_SCOPE

    with self._ratelimit_lock:
      t = time.time()
      delta_t = t - self._last_called[scoped_channel.id][scoped_user_id]
      response = None
      if self._params.ratelimit.return_only:
        response = self._Handle(channel, user, *args, **kwargs)
        if not response:
          return

      if delta_t < self._params.ratelimit.interval:
        logging.info('Call to %s._Handle ratelimited in %s for %s: %s < %s',
                     self.__class__.__name__, scoped_channel.id, scoped_user_id,
                     delta_t, self._params.ratelimit.interval)
        self._Reply(user, random.choice(messages.RATELIMIT_MEMES))
        return

      self._last_called[scoped_channel.id][scoped_user_id] = t
      return response or self._Handle(channel, user, *args, **kwargs)

  def _ParseCommandTarget(self, user: user_pb2.User, target_user: Text,
                          message: Text) -> Optional[user_pb2.User]:
    """Processes raw target_user into a User class, resolving 'me' to user."""
    # An empty target_user defaults to the calling user
    if target_user.strip() in ('', 'me'):
      self._core.last_command = partial(self.Handle, message=message)
      return user
    real_user = self._core.interface.FindUser(target_user)
    if self._params.target_any and not real_user:
      real_user = user_pb2.User(user_id=target_user, display_name=target_user)
    return real_user

  def _Reply(self, *args, **kwargs):
    return self._core.Reply(*args, **kwargs)

  def _Spook(self, user: user_pb2.User) -> None:
    """Creates a spooky encounter with user."""
    logging.info('Spooking %s', user)
    self._Reply(user, self._spook_replies.GetAndDownweightItem())

  def _Handle(self, channel: channel_pb2.Channel, user: user_pb2.User, *args,
              **kwargs):
    """Internal method that handles the command.

    *args and **kwargs are the logical arguments returned by the parsers.

    Args:
      channel: Where to send the reply.
      user: User who invoked the command.
      *args: defined by subclass
      **kwargs: defined by subclass

    Returns:
      Optional message(s) to reply to the channel.
    """
    pass
Esempio n. 16
0
class BaseChatInterface(with_metaclass(abc.ABCMeta)):
    """The interface base class.

  An `interface` allows hypebot to communicate with a chat application (e.g.,
  IRC, Discord, FireChat). This is an application-agnostic way of sending and
  receiving messages and information about users.
  """

    DEFAULT_PARAMS = params_lib.HypeParams({
        # Display name used when chatting.
        'name': 'chatbot',
    })

    def __init__(self, params):
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()
        self._channels = set()

    def RegisterHandlers(self, on_message_fn, user_tracker):
        """Register handlers from the bot onto the interface.

    Allows the interface to communicate asynchronously to the bot when messages
    or user information comes.

    Args:
      on_message_fn: {callable(Channel, user, message)} Function that will be
        called in response to an incoming message.
      user_tracker: {UserTracker} Where to store results of Who/WhoAll requests.
    """
        self._channels = set()
        self._on_message_fn = on_message_fn
        self._user_tracker = user_tracker

    def Join(self, channel: types.Channel):
        """Bring the power of hype to the desired channel.

    The base class only maintains a list of active channels. Subclasses are
    responsible for actually joining the channel.

    Args:
      channel: {Channel} channel name to join.
    """
        self._channels.add(channel.id)

    def Leave(self, channel: types.Channel):
        """We do not condone this behavior.

    The base class only maintains a list of active channels. Subclasses are
    responsible for actually leaving the channel.

    Args:
      channel: {Channel} channel to leave.
    """
        if channel.id in self._channels:
            self._channels.remove(channel.id)
        else:
            logging.warning('Tried to leave channel that I never joined: %s',
                            channel)

    @abc.abstractmethod
    def Loop(self):
        """Listen to messages from the chat application indefinitely.

    Loop steals the current thread.
    """
        raise NotImplementedError()

    @abc.abstractmethod
    def Who(self, user: types.User):
        """Request that `user` be added to the user tracker eventually.

    Subclasses are allowed (possibly encouraged) to implement this
    asynchronously. So take care not to depend on the result immediately.

    Args:
      user: {string} user name to query.
    """
        raise NotImplementedError()

    @abc.abstractmethod
    def WhoAll(self):
        """Request that all users be added to the user tracker."""
        raise NotImplementedError()

    # TODO(someone): Eliminate Optional from the message type.
    @abc.abstractmethod
    def SendMessage(self, channel: types.Channel,
                    message: Optional[types.Message]):
        """Send a message to the given channel.

    Args:
      channel: channel to receive message.
      message: message to send to the channel.
    """
        raise NotImplementedError()

    # TODO(someone): Eliminate Optional from the message type.
    @abc.abstractmethod
    def Notice(self, channel: types.Channel, message: Text):
        """Send a notice to the channel.

    Some applications (IRC) support a different type of message to a channel.
    This is used to broadcast a message not in response to a user input. E.g.,
    match start time or scheduled bet resolution.

    Args:
      channel: channel to send notice.
      message: notice to send to the channel.
    """
        raise NotImplementedError()

    @abc.abstractmethod
    def Topic(self, channel: types.Channel, new_topic: Text):
        """Changes the "topic" of channel to new_topic.

    Args:
      channel: channel to change the topic of.
      new_topic: new topic to set.
    """
        raise NotImplementedError()
Esempio n. 17
0
 def __init__(self, proxy: proxy_lib.Proxy, params: params_lib.HypeParams):
     self._proxy = proxy
     self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
     self._params.Override(params)
     self._params.Lock()
Esempio n. 18
0
class WeatherLib(object):
    """Is it raining, is it snowing, is a hurricane a-blowing?"""

    DEFAULT_PARAMS = params_lib.HypeParams({
        'geocode_key': None,
        'darksky_key': None,
        'airnow_key': None,
    })

    def __init__(self, proxy: proxy_lib.Proxy, params: params_lib.HypeParams):
        self._proxy = proxy
        self._params = params_lib.HypeParams(self.DEFAULT_PARAMS)
        self._params.Override(params)
        self._params.Lock()

    def _LocationToGPS(self, location: str) -> Optional[Dict[Any, Any]]:
        """Uses geocode API to convert human location into GPS coordinates.

    Args:
      location: Human readable location.

    Returns:
      Dictionary with location information. Important keys are
      `formatted_address` and `location` which is a dictionary of `lat`/`lng`.
      None if no results are found.

    Raises:
      WeatherException: If the API call failed.
    """
        # Override airport code from pacific.
        if location.lower() == 'mtv':
            location = 'mountain view, CA'

        response = self._proxy.FetchJson(_GEOCODE_URL,
                                         params={
                                             'q': location,
                                             'api_key':
                                             self._params.geocode_key,
                                             'limit': 1,
                                         },
                                         force_lookup=True)
        if not response:
            return None
        if not response['results']:
            return None
        return response['results'][0]

    def _CallForecast(self, gps: Dict[str, Any]):
        url = os.path.join(_DARKSKY_URL, self._params.darksky_key,
                           '{lat},{lng}'.format(**gps))
        return self._proxy.FetchJson(url, force_lookup=True)

    def GetForecast(self, location: str):
        """Get weather forecast for the location.

    Forecast consists of the current conditions and predictions for the future.

    Args:
      location: Location in human readable form.

    Returns:
      WeatherProto with condition and forecast filled in.
    """
        location = self._LocationToGPS(location)
        if not location:
            return None

        forecast = self._CallForecast(location['location'])
        if not forecast:
            return None

        weather = weather_pb2.Weather(
            location=location['formatted_address'],
            current=weather_pb2.Current(
                temp_f=forecast['currently']['temperature'],
                condition=forecast['currently']['summary'],
                icon=forecast['currently']['icon']))

        for day in forecast['daily']['data']:
            weather.forecast.add(min_temp_f=day['temperatureLow'],
                                 max_temp_f=day['temperatureHigh'],
                                 condition=day['summary'],
                                 icon=day['icon'])

        return weather

    def _CallAQI(self, zip_code: str):
        return self._proxy.FetchJson(os.path.join(
            _AIRNOW_URL, 'observation/zipCode/current'),
                                     params={
                                         'format': 'application/json',
                                         'zipCode': zip_code,
                                         'distance': 50,
                                         'API_KEY': self._params.airnow_key,
                                     },
                                     force_lookup=True)

    def GetAQI(self, location: str):
        """Get air quality index for the location.

    Args:
      location: Location in human readable form.

    Returns:
      AQI response from airnow.
    """
        location = self._LocationToGPS(location)
        if not location:
            return None
        zip_code = util_lib.Access(location, 'address_components.zip')
        if not zip_code:
            return None

        return self._CallAQI(zip_code)