diff --git a/.gitignore b/.gitignore
index 7bdb8a2..adaea74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
*.puz
-__pycache__/*
+*/__pycache__/*
dist/*
build/*
+*.egg-info
+.vscode
+.DS_Store
diff --git a/README.md b/README.md
index 109c9f8..d306250 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,12 @@
* a pausable puzzle timer
* a puzzle completeness notification
-It is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use.
+`cursewords` also includes features for solving puzzles on a Twitch stream with audience interactivity:
+
+* animated rewards for when a user guesses a word
+* a chatbot command to request the clue of a given word be posted to the chat
+
+`cursewords` is currently under active development, and should not be considered fully "released." That said, it is stable and suitable for everyday use.
## Installation
@@ -34,3 +39,79 @@ If you've used a program to solve crossword puzzles, navigation should be pretty
If you need some help, `ctrl+c` will check the current square, word, or entire puzzle for errors, and `ctrl+r` will reveal answers (subject to the same scoping options). To clear all entries on the puzzle, use `ctrl+x`, and to reset the puzzle to its original state (resetting the timer and removing any stored information about hints and corrections, use `ctrl+z`.
To open a puzzle in `downs-only` mode, where only the down clues are visible, use the `--downs-only` flag when opening the file on the command line.
+
+
+## Configuration
+
+You can configure the behavior of `cursewords` using command-line options or a configuration file. The configuration file must be named `cursewords.toml`, and can reside in either the directory where you run the `cursewords` command or in `$HOME/.config/`. See the `cursewords.toml` file included with this distribution for example settings.
+
+Settings settable by the configuration file can be overridden on the command line, like so:
+
+```
+cursewords --log-to-file --twitch.channel=PuzzleCity todaysnyt.puz
+```
+
+True/false settings, like `log-to-file`, are set as flags. `--log-to-file` sets the `log_to_file` option to `true`. To override a flag to `false`, prepend the name with `no-`, as in `--no-log-to-file`.
+
+Settings in TOML sections use the section name followed by a dot, as in `--twitch.channel=...`.
+
+
+## Twitch integration
+
+`cursewords` includes features for live-streaming puzzle solving on Twitch with audience interactivity. You can connect `cursewords` to the channel's chat room using a bot account or your own account. Features include:
+
+* **Guessing**
+ * When someone posts a message to the chat containing a solution to an unsolved clue, the board will highlight the squares. This signals to the solver that someone has made a correct guess, and rewards viewers for guessing.
+ * Any word or phrase in a chat message counts as a guess.
+* **Clues**
+ * Anyone can request one of the clues from the board with the `!clue` command, like so: `!clue 22d` The requested clue is posted to the room.
+
+These features can be enabled or disabled separately with configuration.
+
+To enable Twitch integration:
+
+1. Create a Twitch.tv account for the bot, or use an existing account that you control.
+2. In your browser, visit https://twitchapps.com/tmi/ and sign in with the bot's account and password. This generates an OAuth token, a string of letters and numbers that begins `oauth:...` Copy the OAuth token to your clipboard.
+3. Copy the `cursewords.toml` file from this distribution to the desired location, and edit it to set the parameters under `[twitch]`:
+ * `enable = true` : enables the Twitch features
+ * `nickname = "..."` : the bot's Twitch account name
+ * `channel = "..."` : the name of the Twitch channel (without the `#`)
+ * `oauth_token = "oauth:..."` : the OAuth token
+ * `enable_guessing = true` : enables the guessing feature
+ * `enable_clue = true` : enables the clue command
+
+When you run `cursewords`, it will attempt to connect to the Twitch chat room. You should see the bot account arrive in the room and announce its presence.
+
+### About OAuth tokens
+
+The OAuth token is a temporary password that `cursewords` uses to connect to Twitch with the given account. It is *not* the account password. The OAuth token allows you to grant access to `cursewords` without telling it the real account password.
+
+Keep the OAuth token a secret! It behaves like a real password for accessing the Twitch API. If you no longer want an app to be able to connect using the token, you can revoke access. Sign in to Twitch.tv with the bot account, go to **Settings**, then select the **Connections** tab. Locate the "Twitch Developer" connection then click **Disconnect**.
+
+You can generate a new OAuth token at any time by visiting: https://twitchapps.com/tmi/
+
+(There are nicer ways to [authenticate a bot on Twitch](https://dev.twitch.tv/docs/authentication), but `cursewords` does not yet support them. Contributions welcome!)
+
+### Troubleshooting
+
+**The bot account does not join the room when I run `cursewords`.**
+Verify that configuration parameters are set correctly, and that the configuration file is named `cursewords.toml` and is in either the current working directory or in: `$HOME/.config/`
+
+Occasionally `cursewords` will simply fail to connect to Twitch. Quit (Ctrl-Q) and re-run `cursewords` to try again.
+
+**The bot does not animate a correct guess posted by a user.**
+Check that the `enable_guessing` configuration parameter is set to `true` (`--twitch.enable-guessing` on the command line). The bot will invite users to post guesses to the chat when it joins if this feature is enabled correctly.
+
+Check that the word is not already solved on the board. The guessing feature will only animate squares of a correct guess if the word is unsolved, incompletely solved, or solved incorrectly.
+
+A guess can be run together (`DOUBLERAINBOW`) or separated by spaces (`DOUBLE RAINBOW`), and case doesn't matter (`double raIN bOW`). It must start and end at a "word boundary:" `corporeous`, `oreodontoid`, or `choreo` will not match `oreo`. Punctuation cannot appear in the middle of the guess: `o-r-e-o` will not match.
+
+**The bot never replies to the `!clue` command.**
+Check that the `enable_clue` configuration parameter is set to `true` (`--twitch.enable-clue` on the command line). The bot will invite users to use the `!clue` command when it joins if this feature is enabled correctly.
+
+Check that `!clue` (an exclamation point followed by the word "clue") is the first word in the chat message.
+
+**The bot replies to the `!clue` command sometimes, but not always.**
+To prevent a user from cluttering the chat with clues, each user is restricted to one `!clue` per period of time. This period is configurable using the `clue_cooldown_per_person` parameter (`--twitch.clue-cooldown-per-person` on the command line). The bot will ignore clue commands from a user within this time.
+
+If a single user requests the same clue twice in succession and the bot hasn't spoken since the previous time, Twitch will prevent the bot from posting the same message twice in a row.
diff --git a/cursewords.toml b/cursewords.toml
new file mode 100644
index 0000000..508cce2
--- /dev/null
+++ b/cursewords.toml
@@ -0,0 +1,45 @@
+# This is the configuration file for Cursewords.
+#
+# This file is expected to be named "cursewords.toml". It can be kept in the
+# current working directory where Cursewords is run or in $HOME/.config/.
+#
+# Most options can be overridden on the command line, like so:
+# cursewords --twitch.nickname=mybot --twitch.enable-clue=false puzfile.puz
+
+# Log messages to a file named cursewords.log.
+log_to_file = false
+
+[twitch]
+# Set this to "true" to enable Twitch support.
+enable = false
+
+# Viewers can guess words in the puzzle and cause things to happen.
+enable_guessing = true
+
+# Viewers can type "!clue 22d" to have the bot post a clue to the chat room.
+enable_clue = true
+
+# The Twitch account that Cursewords should use to connect.
+nickname = "..."
+
+# The Twitch channel that Cursewords should join.
+channel = "..."
+
+# The OAuth token is a temporary password that Cursewords will use to connect
+# to Twitch. This is *not* the Twitch password for the account.
+#
+# To get the OAuth token, sign in to this website with the bot's Twitch
+# account and password:
+# https://twitchapps.com/tmi/
+#
+# The OAuth token is a string that begins "oauth:" followed by letters and
+# numbers. Keep the OAuth token secret.
+#
+# To revoke access for this token, sign in to Twitch.tv with the bot account,
+# go to Settings, then select the Connections tab. Locate the "Twitch
+# Developer" connection then click Disconnect.
+oauth_token = "oauth:..."
+
+# If enable_clue is true, the number of seconds a given user must wait between
+# clue requests.
+clue_cooldown_per_person = 10
diff --git a/cursewords/config.py b/cursewords/config.py
new file mode 100644
index 0000000..c34355b
--- /dev/null
+++ b/cursewords/config.py
@@ -0,0 +1,251 @@
+"""A simple configuration manager.
+
+A configuration file is in the TOML format. It lives either in the current
+working directory or in ~/.config/, with the former overriding the latter.
+Configuration files do not merge: it uses the first one it finds.
+
+For example, this config file might be named "$HOME/.config/myapp.toml":
+
+ name = "Mr. Chips"
+ timeout_seconds = 60
+
+ [network]
+ ip_addr = "10.0.0.1"
+
+Config allows for overriding configuration parameters on the command line.
+To enable this, register expected parameters with ConfigParser, then request
+the ArgumentParser to set non-config parameters:
+
+ from . import config
+
+ cfgparser = config.ConfigParser('myapp.toml')
+ cfgparser.add_parameter('name', type=str, --help='...')
+ cfgparser.add_parameter('timeout-seconds', type=int, --help='...')
+ cfgparser.add_parameter('network.ip-addr', type=str, --help='...')
+ cfgparser.add_parameter('verbose-log', type=bool, --help='...')
+
+ # Set non-config arguments on the ArgumentParser. Technically Config will
+ # see these arguments too, so avoid using a config parameter whose name
+ # matches an optional argument's metavar.
+ argparser = cfgparser.get_argument_parser()
+ argparser.add_argument('filename', --help='...')
+
+ cfg = cfgparser.parse_cfg()
+
+ # This is either the "--name" argument if specified, or the top-level
+ # "name" parameter in the config file.
+ name = cfg.name
+
+ # Argparse converts hyphens in argument names to underscores. This must
+ # appear with an underscore in the config file, e.g. "timeout_seconds".
+ timeout_secs = cfg.timeout_seconds
+
+ # Command line arguments can override arguments in TOML sections using
+ # a dot-delimited path.
+ ip_address = cfg.network.ip_addr
+
+ # If type=bool, the parameter supports TOML true and false values. On the
+ # command line, the parameter can be set or overriden using flag syntax,
+ # e.g. --verbose-log or --no-verbose-log.
+ do_verbose_logging = cfg.verbose_log
+
+ # Use the argparse Namespace directly to access positional command line
+ # arguments.
+ args = argparser.parse_args()
+ filename = args.filename
+
+Config can access all values in the TOML file, not just those registered with
+add_parameter(). Only items with add_parameter() can be overriden on the
+command line. Only simple types are supported on the command line. Structures
+like lists are supported in TOML, but not supported on the command line.
+
+For more information on the TOML file format:
+ https://en.wikipedia.org/wiki/TOML
+"""
+
+import argparse
+import collections
+import os.path
+import toml
+
+
+# The search path for configuration files, as an ordered list of directories.
+CONFIG_DIRS = ['.', '~/.config']
+
+
+class ConfigNamespace:
+ """Helper class to represent a sub-tree of config values.
+
+ You won't use this directly. Access values with attribute paths of the
+ Config instance.
+ """
+
+ def __init__(self):
+ # A key is a str. A value is either a raw value or a ConfigNamespace
+ # instance.
+ self._dict = {}
+
+ def _set(self, path, value):
+ # If the key is a dot path, drill down the path.
+ dot_i = path.find('.')
+ if dot_i != -1:
+ k = path[:dot_i]
+ rest = path[dot_i + 1:]
+ if k not in self._dict:
+ self._dict[k] = ConfigNamespace()
+ self._dict[k]._set(rest, value)
+ return
+
+ # If the value is a mapping, merge values into a child namespace.
+ if isinstance(value, collections.abc.Mapping):
+ if path not in self._dict:
+ self._dict[path] = ConfigNamespace()
+ for k in value:
+ self._dict[path]._set(k, value[k])
+ return
+
+ # For a simple key and value, set the value.
+ self._dict[path] = value
+
+ def _merge(self, mapping_value):
+ for k in mapping_value:
+ self._set(k, mapping_value[k])
+
+ def __getattr__(self, name):
+ return self._dict.get(name)
+
+ def __repr__(self):
+ return '[ConfigNamespace: ' + repr(self._dict) + ']'
+
+
+class ConfigParameter:
+ def __init__(self, name, is_flag=False, default=None):
+ self.name = name
+ self.is_flag = is_flag
+ self.default = default
+
+
+class ConfigParser:
+ def __init__(
+ self,
+ config_fname,
+ config_dirs=CONFIG_DIRS,
+ *args,
+ **kwargs):
+ """Initializes the configuration manager.
+
+ Args:
+ config_fname: The TOML filename of the config file.
+ config_dirs: The config file search path, as a list of directory
+ paths. Default is current working directory (.), then
+ ~/.config.
+ *args: Remaining positional arguments are passed to
+ argparse.ArgumentParser.
+ **kwargs: Remaining keyword arguments are passed to
+ argparse.ArgumentParser.
+ """
+ self.config_fname = config_fname
+ self.config_dirs = config_dirs
+
+ self._argparser = argparse.ArgumentParser(*args, **kwargs)
+ self._params = []
+
+ def add_parameter(
+ self,
+ name,
+ default=None,
+ type=str,
+ help=None):
+ """Registers a config parameter as overrideable on the command line.
+
+ Args:
+ name: The TOML path name of the parameter. Hyphens will be
+ treated as hyphens for the command line and as underscores
+ for the TOML file. Dots indicate TOML sections.
+ default: The default value if not specified on either the command
+ line or in the TOML file.
+ type: The type of the value: str, int, or bool.
+ help: A description of the option for argparse's help messages.
+ """
+ if type == bool:
+ self._params.append(
+ ConfigParameter(
+ name,
+ is_flag=True,
+ default=default))
+ self._argparser.add_argument(
+ '--' + name,
+ action='store_true',
+ required=False,
+ help=help)
+ self._argparser.add_argument(
+ '--no-' + name,
+ action='store_true',
+ required=False,
+ help=help)
+ else:
+ self._params.append(ConfigParameter(name, default=default))
+ self._argparser.add_argument(
+ '--' + name,
+ type=type,
+ required=False,
+ help=help)
+
+ def get_argument_parser(self):
+ return self._argparser
+
+ def parse_cfg(self, args=None):
+ """Parses the config file and command line arguments.
+
+ Args:
+ args: A list of strings to parse as command line arguments.
+ The default is taken from sys.argv.
+
+ Returns:
+ The results object.
+ """
+ namespace = ConfigNamespace()
+
+ for dpath in self.config_dirs:
+ cfgpath = os.path.normpath(
+ os.path.expanduser(
+ os.path.join(dpath, self.config_fname)))
+ if not os.path.isfile(cfgpath):
+ continue
+ with open(cfgpath) as infh:
+ namespace._merge(toml.loads(infh.read()))
+
+ # It wouldn't be difficult to merge multiple config files, but
+ # this isn't typically expected behavior. Use only the first
+ # file on the lookup path.
+ break
+
+ args = self._argparser.parse_args(args=args)
+ if args is not None:
+ args_dict = dict([
+ i for i in vars(args).items()
+ if i[1] is not None])
+
+ for param in self._params:
+ if param.is_flag:
+ flag_attr_name = param.name.replace('-', '_')
+ inverse_flag_attr_name = 'no_' + flag_attr_name
+
+ if args_dict[inverse_flag_attr_name]:
+ # If --no-xxx, set value to False.
+ args_dict[flag_attr_name] = False
+ elif not args_dict[flag_attr_name]:
+ # If neither --no-xxx nor --xxx, delete the default
+ # False value.
+ del args_dict[flag_attr_name]
+ # Delete --no-xxx regardless.
+ del args_dict[inverse_flag_attr_name]
+
+ namespace._merge(args_dict)
+
+ for param in self._params:
+ if (getattr(namespace, param.name) is None and
+ param.default is not None):
+ namespace._set(param.name, param.default)
+
+ return namespace
diff --git a/cursewords/config_test.py b/cursewords/config_test.py
new file mode 100644
index 0000000..a5f92b1
--- /dev/null
+++ b/cursewords/config_test.py
@@ -0,0 +1,123 @@
+import os
+
+from . import config
+
+
+CONFIG_FNAME = 'myapp.toml'
+
+CONFIG_TEXT = """
+name = "Mr. Chips"
+timeout_seconds = 60
+verbose_log = false
+enable_feature = true
+
+[network]
+ip_addr = "10.0.0.1"
+"""
+
+CONFIG_TEXT_ALT = """
+name = "Scooter Computer"
+timeout_seconds = 20
+"""
+
+
+def make_temp_dirs(tmpdir):
+ dirs = [os.path.join(tmpdir, x) for x in ['cwd', 'home']]
+ for dpath in dirs:
+ os.makedirs(dpath)
+ return dirs
+
+
+def make_config(tmpdir, in_cwd=True):
+ dirs = make_temp_dirs(tmpdir)
+ cfgpath = os.path.join(dirs[0 if in_cwd else 1], CONFIG_FNAME)
+ with open(cfgpath, 'w') as outfh:
+ outfh.write(CONFIG_TEXT)
+ return dirs
+
+
+def make_config_parser(config_dirs, config_fname=CONFIG_FNAME):
+ cfgparser = config.ConfigParser(
+ config_fname=config_fname,
+ config_dirs=config_dirs)
+ cfgparser.add_parameter('name', type=str)
+ cfgparser.add_parameter('timeout-seconds', type=int)
+ cfgparser.add_parameter('network.ip-addr', type=str)
+ cfgparser.add_parameter('verbose-log', type=bool)
+ cfgparser.add_parameter('enable-feature', type=bool)
+ return cfgparser
+
+
+def test_config_uses_args(tmpdir):
+ cfgparser = make_config_parser(make_temp_dirs(tmpdir))
+ cfg = cfgparser.parse_cfg(['--timeout-seconds=999'])
+ assert cfg.timeout_seconds == 999
+
+
+def test_config_uses_file(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg([])
+ assert cfg.timeout_seconds == 60
+
+
+def test_arg_overrides_file(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg(['--timeout-seconds=999'])
+ assert cfg.timeout_seconds == 999
+
+
+def test_config_honors_lookup_path(tmpdir):
+ config_dirs = make_config(tmpdir, in_cwd=False)
+ cfgparser = make_config_parser(config_dirs)
+ with open(os.path.join(config_dirs[0], CONFIG_FNAME), 'w') as outfh:
+ outfh.write(CONFIG_TEXT_ALT)
+ cfg = cfgparser.parse_cfg([])
+ assert cfg.timeout_seconds == 20
+
+
+def test_lookup_path_supports_cwd(tmpdir):
+ config_dirs = make_config(tmpdir)
+ os.chdir(config_dirs[0])
+ config_dirs[0] = '.'
+ cfgparser = make_config_parser(config_dirs)
+ cfg = cfgparser.parse_cfg([])
+ assert cfg.timeout_seconds == 60
+
+
+def test_dotpath_from_arg(tmpdir):
+ cfgparser = make_config_parser(make_temp_dirs(tmpdir))
+ cfg = cfgparser.parse_cfg(['--network.ip-addr=192.168.0.1'])
+ assert cfg.network.ip_addr == '192.168.0.1'
+
+
+def test_dotpath_from_file(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg([])
+ assert cfg.network.ip_addr == '10.0.0.1'
+
+
+def test_dotpath_args_overrides_file(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg(['--network.ip-addr=192.168.0.1'])
+ assert cfg.network.ip_addr == '192.168.0.1'
+
+
+def test_bool_from_file(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg([])
+ assert not cfg.verbose_log
+ assert cfg.enable_feature
+
+
+def test_flag_positive(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg(['--verbose-log'])
+ assert cfg.verbose_log
+ assert cfg.enable_feature
+
+
+def test_flag_negative(tmpdir):
+ cfgparser = make_config_parser(make_config(tmpdir))
+ cfg = cfgparser.parse_cfg(['--no-enable-feature'])
+ assert not cfg.verbose_log
+ assert not cfg.enable_feature
diff --git a/cursewords/cursewords.py b/cursewords/cursewords.py
index 42cb01e..ed0086f 100755
--- a/cursewords/cursewords.py
+++ b/cursewords/cursewords.py
@@ -1,7 +1,7 @@
#! /usr/bin/env python3
-import argparse
import itertools
+import logging
import os
import sys
import time
@@ -13,6 +13,12 @@
from blessed import Terminal
from . import chars
+from . import config
+from . import twitch
+
+
+CONFIG_FNAME = 'cursewords.toml'
+LOG_FNAME = 'cursewords.log'
class Cell:
@@ -56,11 +62,13 @@ def __init__(self, grid_x, grid_y, term):
self.grid_y = grid_y
self.term = term
- self.notification_area = (term.height-2, self.grid_x)
+ self.notification_area = (term.height - 2, self.grid_x)
+ self.twinkle_delay = 0.1
def load(self, puzfile):
self.puzfile = puzfile
self.cells = {}
+ self.twinkles = {}
self.row_count = puzfile.height
self.column_count = puzfile.width
@@ -72,8 +80,8 @@ def load(self, puzfile):
idx = i * self.column_count + j
entry = self.puzfile.fill[idx]
self.cells[(j, i)] = Cell(
- self.puzfile.solution[idx],
- entry)
+ self.puzfile.solution[idx],
+ entry)
self.across_words = []
for i in range(self.row_count):
@@ -103,8 +111,19 @@ def load(self, puzfile):
if len(current_word) > 1:
self.down_words.append(current_word)
- self.down_words_grouped = sorted(self.down_words,
- key=lambda word: (word[0][1], word[0][0]))
+ self.down_words_grouped = sorted(
+ self.down_words,
+ key=lambda word: (word[0][1], word[0][0]))
+
+ self.word_index = {}
+ for word_coords in (self.across_words + self.down_words):
+ word_text = ''.join(
+ self.cells[pos].solution for pos in word_coords).lower()
+ if word_text not in self.word_index:
+ self.word_index[word_text] = []
+ # word_index maps word text to a list of coordinate sets. This
+ # supports the edge case where a puzzle contains dupes.
+ self.word_index[word_text].append(word_coords)
num = self.puzfile.clue_numbering()
self.across_clues = [word['clue'] for word in num.across]
@@ -129,7 +148,8 @@ def load(self, puzfile):
timer_bytes = self.puzfile.extensions.get(puz.Extensions.Timer, None)
if timer_bytes:
- self.start_time, self.timer_active = timer_bytes.decode().split(',')
+ self.start_time, self.timer_active = \
+ timer_bytes.decode().split(',')
else:
self.start_time, self.timer_active = 0, 1
@@ -140,11 +160,11 @@ def draw(self):
divider_row = self.get_divider_row()
print(self.term.move(self.grid_y, self.grid_x)
- + self.term.dim(top_row))
+ + self.term.dim(top_row))
for index, y_val in enumerate(
- range(self.grid_y + 1,
- self.grid_y + self.row_count * 2),
- 1):
+ range(self.grid_y + 1,
+ self.grid_y + self.row_count * 2),
+ 1):
if index % 2 == 0:
print(self.term.move(y_val, self.grid_x) +
self.term.dim(divider_row))
@@ -176,7 +196,7 @@ def fill(self):
self.draw_cell(position)
elif cell.is_block():
print(self.term.move(y_coord, x_coord - 1) +
- self.term.dim(chars.squareblock))
+ self.term.dim(chars.squareblock))
if cell.number:
small = str(cell.number).translate(chars.small_nums)
@@ -186,19 +206,21 @@ def fill(self):
def confirm_quit(self, modified_since_save):
if modified_since_save:
confirmation = self.get_notification_input(
- "Quit without saving? (y/n)",
- chars=1, blocking=True, timeout=5)
+ "Quit without saving? (y/n)",
+ chars=1, blocking=True, timeout=5)
return confirmation.lower() == 'y'
return True
def confirm_clear(self):
- confirmation = self.get_notification_input("Clear puzzle? (y/n)",
- chars=1, blocking=True, timeout=5)
+ confirmation = self.get_notification_input(
+ "Clear puzzle? (y/n)",
+ chars=1, blocking=True, timeout=5)
return confirmation.lower() == 'y'
def confirm_reset(self):
- confirmation = self.get_notification_input("Reset puzzle? (y/n)",
- chars=1, blocking=True, timeout=5)
+ confirmation = self.get_notification_input(
+ "Reset puzzle? (y/n)",
+ chars=1, blocking=True, timeout=5)
return confirmation.lower() == 'y'
def save(self, filename):
@@ -263,7 +285,6 @@ def to_term(self, position):
term_y = self.grid_y + (2 * point_y) + 1
return (term_y, term_x)
-
def make_row(self, leftmost, middle, divider, rightmost):
chars = ''
for col in range(1, self.column_count * 4):
@@ -271,16 +292,19 @@ def make_row(self, leftmost, middle, divider, rightmost):
return leftmost + chars + rightmost
def get_top_row(self):
- return self.make_row(chars.ulcorner, chars.hline, chars.ttee, chars.urcorner)
+ return self.make_row(chars.ulcorner, chars.hline,
+ chars.ttee, chars.urcorner)
def get_bottom_row(self):
- return self.make_row(chars.llcorner, chars.hline, chars.btee, chars.lrcorner)
+ return self.make_row(chars.llcorner, chars.hline,
+ chars.btee, chars.lrcorner)
def get_middle_row(self):
return self.make_row(chars.vline, " ", chars.vline, chars.vline)
def get_divider_row(self):
- return self.make_row(chars.ltee, chars.hline, chars.bigplus, chars.rtee)
+ return self.make_row(chars.ltee, chars.hline,
+ chars.bigplus, chars.rtee)
def compile_cell(self, position):
cell = self.cells.get(position)
@@ -319,19 +343,19 @@ def draw_cursor_cell(self, position):
print(self.term.move(*self.to_term(position)) + value)
def get_notification_input(self, message, timeout=5, chars=3,
- input_condition=str.isalnum, blocking=False):
+ input_condition=str.isalnum, blocking=False):
# If there's already a notification timer running, stop it.
try:
self.notification_timer.cancel()
- except:
+ except BaseException:
pass
input_phrase = message + " "
key_input_place = len(input_phrase)
print(self.term.move(*self.notification_area)
- + self.term.reverse(input_phrase)
- + self.term.clear_eol)
+ + self.term.reverse(input_phrase)
+ + self.term.clear_eol)
user_input = ''
keypress = None
@@ -339,14 +363,16 @@ def get_notification_input(self, message, timeout=5, chars=3,
keypress = self.term.inkey(timeout)
if input_condition(keypress):
user_input += keypress
- print(self.term.move(self.notification_area[0],
- self.notification_area[1] + key_input_place),
- user_input)
+ print(self.term.move(
+ self.notification_area[0],
+ self.notification_area[1] + key_input_place),
+ user_input)
elif keypress.name in ['KEY_DELETE']:
user_input = user_input[:-1]
- print(self.term.move(self.notification_area[0],
- self.notification_area[1] + key_input_place),
- user_input + self.term.clear_eol)
+ print(self.term.move(
+ self.notification_area[0],
+ self.notification_area[1] + key_input_place),
+ user_input + self.term.clear_eol)
elif blocking and keypress.name not in ['KEY_ENTER', 'KEY_ESCAPE']:
continue
else:
@@ -356,15 +382,88 @@ def get_notification_input(self, message, timeout=5, chars=3,
def send_notification(self, message, timeout=5):
self.notification_timer = threading.Timer(timeout,
- self.clear_notification_area)
+ self.clear_notification_area)
self.notification_timer.daemon = True
print(self.term.move(*self.notification_area)
- + self.term.reverse(message) + self.term.clear_eol)
+ + self.term.reverse(message) + self.term.clear_eol)
self.notification_timer.start()
def clear_notification_area(self):
print(self.term.move(*self.notification_area) + self.term.clear_eol)
+ def draw_twinkle(self, pos, frame):
+ # 0th char is vline, will always reset square sides in last frame
+ twinkle_chars = chars.vline + '/-\\'
+ twinkle_c = twinkle_chars[frame % len(twinkle_chars)]
+ term_y, term_x = self.to_term(pos)
+ print(self.term.move(term_y, term_x - 2) + twinkle_c
+ + self.term.move(term_y, term_x + 2) + twinkle_c)
+
+ def set_twinkle_timer(self, force=False):
+ if force or getattr(self, 'twinkle_timer', None) is None:
+ self.twinkle_timer = threading.Timer(
+ self.twinkle_delay, self.animate_twinkles)
+ self.twinkle_timer.daemon = True
+ self.twinkle_timer.start()
+
+ def start_twinkle(self, pos, duration=None):
+ if duration is None:
+ duration = int(5 / self.twinkle_delay)
+ self.twinkles[pos] = duration
+ self.set_twinkle_timer()
+
+ def animate_twinkles(self):
+ dead_twinkles = []
+ for pos in self.twinkles:
+ self.twinkles[pos] -= 1
+ self.draw_twinkle(pos, self.twinkles[pos])
+ if self.twinkles[pos] == 0:
+ dead_twinkles.append(pos)
+ for pos in dead_twinkles:
+ del self.twinkles[pos]
+
+ if self.twinkles:
+ self.set_twinkle_timer(force=True)
+ else:
+ self.twinkle_timer = None
+
+ def clear_twinkles(self):
+ for pos in self.twinkles:
+ self.draw_twinkle(pos, 0)
+ self.twinkles = {}
+
+ def twinkle_unsolved_word(self, txt, duration=None):
+ """Twinkle an unsolved word via its lowercase text, if any.
+
+ In the edge case where a word appears more than once in the puzzle
+ unsolved, only one unsolved instance is twinkled.
+
+ Returns:
+ True if a matching unsolved word was found and twinkled.
+ """
+ if txt in self.word_index:
+ for word_coords in self.word_index[txt]:
+ if all(self.cells[pos].is_correct() for pos in word_coords):
+ continue
+ for pos in word_coords:
+ self.start_twinkle(pos, duration=duration)
+ return True
+ return False
+
+ def get_clue_by_number(self, num, is_across=True):
+ if is_across:
+ word_list = self.across_words
+ clue_list = self.across_clues
+ else:
+ word_list = self.down_words_grouped
+ clue_list = self.down_clues
+
+ for i, word in enumerate(word_list):
+ if self.cells[word[0]].number == num:
+ return clue_list[i]
+
+ return None
+
class Cursor:
def __init__(self, position, direction, grid):
@@ -419,7 +518,7 @@ def move_within_word(self, overwrite_mode=False, wrap_mode=False):
if not overwrite_mode:
ordered_spaces = [pos for pos in ordered_spaces
- if self.grid.cells.get(pos).is_blankish()]
+ if self.grid.cells.get(pos).is_blankish()]
return next(iter(ordered_spaces), None)
@@ -458,7 +557,7 @@ def advance_to_next_word(self, blank_placement=False):
# the blank_placement setting
if (blank_placement and
not any(self.grid.cells.get(pos).is_blankish() for
- pos in itertools.chain(*self.grid.across_words))):
+ pos in itertools.chain(*self.grid.across_words))):
blank_placement = False
# Otherwise, if blank_placement is on, put the
@@ -494,7 +593,7 @@ def retreat_to_previous_word(self,
# the blank_placement setting
if (blank_placement and
not any(self.grid.cells.get(pos).is_blankish() for
- pos in itertools.chain(*self.grid.across_words))):
+ pos in itertools.chain(*self.grid.across_words))):
blank_placement = False
if blank_placement and self.earliest_blank_in_word():
@@ -504,7 +603,7 @@ def retreat_to_previous_word(self,
def earliest_blank_in_word(self):
blanks = (pos for pos in self.current_word()
- if self.grid.cells.get(pos).is_blankish())
+ if self.grid.cells.get(pos).is_blankish())
return next(blanks, None)
def move_right(self):
@@ -554,11 +653,11 @@ def go_to_numbered_square(self):
if num:
pos = next((pos for pos in self.grid.cells
if self.grid.cells.get(pos).number == int(num)),
- None)
+ None)
if pos:
self.position = pos
self.grid.send_notification(
- "Moved cursor to square {}.".format(num))
+ "Moved cursor to square {}.".format(num))
else:
self.grid.send_notification("Not a valid number.")
else:
@@ -584,7 +683,7 @@ def run(self):
while self.active:
if self.is_running:
self.time_passed = (self.starting_seconds
- + int(time.time() - self.start_time))
+ + int(time.time() - self.start_time))
self.show_time()
time.sleep(0.5)
@@ -594,7 +693,7 @@ def show_time(self):
x_coord = self.grid.grid_x + self.grid.column_count * 4 - 7
print(self.grid.term.move(y_coord, x_coord)
- + self.display_format())
+ + self.display_format())
def display_format(self):
time_amount = self.time_passed
@@ -611,7 +710,7 @@ def save_format(self):
time_amount = self.time_passed
save_string = '{t},{r}'.format(
- t=int(time_amount), r=int(self.active))
+ t=int(time_amount), r=int(self.active))
save_bytes = save_string.encode(puz.ENCODING)
@@ -632,23 +731,67 @@ def main():
with open(version_file) as f:
version = f.read().strip()
- parser = argparse.ArgumentParser(
- prog='cursewords',
- description="""A terminal-based crossword puzzle solving interface.""")
-
- parser.add_argument('filename', metavar='PUZfile',
- help="""path of puzzle file in the AcrossLite .puz format""")
- parser.add_argument('--downs-only', action='store_true',
- help="""displays only the down clues""")
- parser.add_argument('--version', action='version', version=version)
-
+ cfgparser = config.ConfigParser(
+ CONFIG_FNAME,
+ prog='cursewords',
+ description='A terminal-based crossword puzzle solving interface.')
+ cfgparser.add_parameter(
+ 'twitch.enable',
+ type=bool,
+ help='Enables Twitch integration')
+ cfgparser.add_parameter(
+ 'twitch.nickname',
+ help='The Twitch bot account name')
+ cfgparser.add_parameter(
+ 'twitch.channel',
+ help='The Twitch channel to join')
+ cfgparser.add_parameter(
+ 'twitch.oauth_token',
+ help='The OAuth token to use to connect with this account')
+ cfgparser.add_parameter(
+ 'twitch.enable_guessing',
+ type=bool,
+ help='Enables reacting to guesses in the Twitch chat')
+ cfgparser.add_parameter(
+ 'twitch.enable_clue',
+ type=bool,
+ help='Enables the !clue command in the Twitch chat')
+ cfgparser.add_parameter(
+ 'twitch.clue_cooldown_per_person',
+ default=10,
+ type=int,
+ help='Seconds a Twitch chat user must wait between !clue commands')
+ cfgparser.add_parameter(
+ 'log_to_file',
+ type=bool,
+ help='Log messages to a file named cursewords.log')
+
+ parser = cfgparser.get_argument_parser()
+ parser.add_argument(
+ 'filename', metavar='PUZfile',
+ help='Path of puzzle file in the AcrossLite .puz format')
+ parser.add_argument(
+ '--downs-only', action='store_true',
+ help='Displays only the down clues')
+ parser.add_argument(
+ '--version', action='version', version=version)
+
+ cfg = cfgparser.parse_cfg()
args = parser.parse_args()
filename = args.filename
downs_only = args.downs_only
+ if cfg.log_to_file:
+ logging.basicConfig(
+ filename=LOG_FNAME,
+ encoding='utf-8',
+ level=logging.INFO)
+ else:
+ logging.basicConfig(stream=os.devnull)
+
try:
puzfile = puz.read(filename)
- except:
+ except BaseException:
sys.exit("Unable to parse {} as a .puz file.".format(filename))
term = Terminal()
@@ -663,15 +806,15 @@ def main():
puzzle_height = 2 * grid.row_count
min_width = (puzzle_width
- + grid_x
- + 2) # a little breathing room
+ + grid_x
+ + 2) # a little breathing room
min_height = (puzzle_height
- + grid_y # includes the top bar + timer
- + 2 # padding above clues
- + 3 # clue area
- + 2 # toolbar
- + 2) # again, just some breathing room
+ + grid_y # includes the top bar + timer
+ + 2 # padding above clues
+ + 3 # clue area
+ + 2 # toolbar
+ + 2) # again, just some breathing room
necessary_resize = []
if term.width < min_width:
@@ -682,7 +825,7 @@ def main():
if necessary_resize:
exit_text = textwrap.dedent("""\
This puzzle is {} columns wide and {} rows tall.
- The terminal window must be {} to properly display
+ The terminal window must be {} to properly display
it.""".format(
grid.column_count, grid.row_count,
' and '.join(necessary_resize)))
@@ -710,8 +853,8 @@ def main():
puzzle_info = "{}…".format(puzzle_info[:pz_width - 1])
headline = " {:<{pz_w}}{:>{sw_w}} ".format(
- puzzle_info, software_info,
- pz_w=pz_width, sw_w=sw_width)
+ puzzle_info, software_info,
+ pz_w=pz_width, sw_w=sw_width)
with term.location(x=0, y=0):
print(term.dim(term.reverse(headline)))
@@ -724,7 +867,7 @@ def main():
("^R", "reveal"),
("^G", "go to"),
("^X", "clear"),
- ("^Z", "reset"),]
+ ("^Z", "reset"), ]
if term.width >= 15 * len(commands):
for shortcut, action in commands:
@@ -735,7 +878,7 @@ def main():
print(toolbar, end='')
else:
grid.notification_area = (grid.notification_area[0] - 1, grid_x)
- command_split = int(len(commands)/2) - 1
+ command_split = int(len(commands) / 2) - 1
for idx, (shortcut, action) in enumerate(commands):
shortcut = term.reverse(shortcut)
toolbar += "{:<25}".format(' '.join([shortcut, action]))
@@ -750,9 +893,9 @@ def main():
term.width - 2 - grid_x)
clue_wrapper = textwrap.TextWrapper(
- width=clue_width,
- max_lines=3,
- subsequent_indent=grid_x * ' ')
+ width=clue_width,
+ max_lines=3,
+ subsequent_indent=grid_x * ' ')
start_pos = grid.across_words[0][0]
cursor = Cursor(start_pos, "across", grid)
@@ -769,6 +912,17 @@ def main():
is_running=True, active=bool(int(grid.timer_active)))
timer.start()
+ twitchbot = twitch.TwitchBot(
+ grid,
+ enable=cfg.twitch.enable,
+ nickname=cfg.twitch.nickname,
+ channel=cfg.twitch.channel,
+ oauth_token=cfg.twitch.oauth_token,
+ enable_guessing=cfg.twitch.enable_guessing,
+ enable_clue=cfg.twitch.enable_clue,
+ clue_cooldown_per_person=cfg.twitch.clue_cooldown_per_person)
+ twitchbot.start()
+
info_location = {'x': grid_x, 'y': grid_y + 2 * grid.row_count + 2}
with term.raw(), term.hidden_cursor():
@@ -782,34 +936,34 @@ def main():
for pos in cursor.current_word():
grid.draw_highlighted_cell(pos)
- # Draw the clue for the new word:
+ # Draw the clue for the new word:
if cursor.direction == "across":
num_index = grid.across_words.index(
- cursor.current_word())
+ cursor.current_word())
clue = grid.across_clues[num_index]
if downs_only:
clue = "—"
elif cursor.direction == "down":
num_index = grid.down_words_grouped.index(
- cursor.current_word())
+ cursor.current_word())
clue = grid.down_clues[num_index]
num = str(grid.cells.get(cursor.current_word()[0]).number)
compiled_clue = (num + " " + cursor.direction.upper()
- + ": " + clue)
+ + ": " + clue)
wrapped_clue = clue_wrapper.wrap(compiled_clue)
wrapped_clue += [''] * (3 - len(wrapped_clue))
wrapped_clue = [line + term.clear_eol for line in wrapped_clue]
# This is fun: since we're in raw mode, \n isn't sufficient to
- # return the printing location to the first column. If you
+ # return the printing location to the first column. If you
# don't also have \r,
# it
# prints
# like
# this after each newline
print(term.move(info_location['y'], info_location['x'])
- + '\r\n'.join(wrapped_clue))
+ + '\r\n'.join(wrapped_clue))
# Otherwise, just draw the old square now that it's not under
# the cursor
@@ -817,16 +971,15 @@ def main():
grid.draw_highlighted_cell(old_position)
current_cell = grid.cells.get(cursor.position)
- value = current_cell.entry
grid.draw_cursor_cell(cursor.position)
# Check if the puzzle is complete!
if not puzzle_complete and all(grid.cells.get(pos).is_correct()
- for pos in grid.cells):
+ for pos in grid.cells):
puzzle_complete = True
with term.location(x=grid_x, y=2):
print(term.reverse("You've completed the puzzle!"),
- term.clear_eol)
+ term.clear_eol)
timer.show_time()
timer.active = False
@@ -847,7 +1000,8 @@ def main():
# ctrl-s
elif keypress == chr(19):
- grid.puzfile.extensions[puz.Extensions.Timer] = timer.save_format()
+ grid.puzfile.extensions[puz.Extensions.Timer] = \
+ timer.save_format()
grid.save(filename)
modified_since_save = False
@@ -856,6 +1010,7 @@ def main():
if timer.is_running:
timer.pause()
grid.draw()
+ grid.clear_twinkles()
with term.location(**info_location):
print('\r\n'.join(['PUZZLE PAUSED' + term.clear_eol,
@@ -899,8 +1054,8 @@ def main():
# ctrl-c
elif keypress == chr(3):
group = grid.get_notification_input(
- "Check (l)etter, (w)ord, or (p)uzzle?",
- chars=1)
+ "Check (l)etter, (w)ord, or (p)uzzle?",
+ chars=1)
scope = ''
if group.lower() == 'l':
scope = 'letter'
@@ -914,7 +1069,7 @@ def main():
if scope:
grid.send_notification("Checked {scope} for errors.".
- format(scope=scope))
+ format(scope=scope))
else:
grid.send_notification("No valid input entered.")
@@ -939,12 +1094,11 @@ def main():
else:
grid.send_notification("Clear command canceled.")
-
# ctrl-r
elif keypress == chr(18):
group = grid.get_notification_input(
- "Reveal (l)etter, (w)ord, or (p)uzzle?",
- chars=1)
+ "Reveal (l)etter, (w)ord, or (p)uzzle?",
+ chars=1)
scope = ''
if group.lower() == 'l':
scope = 'letter'
@@ -958,7 +1112,7 @@ def main():
if scope:
grid.send_notification("Revealed answers for {scope}.".
- format(scope=scope))
+ format(scope=scope))
else:
grid.send_notification("No valid input entered.")
@@ -984,10 +1138,12 @@ def main():
cursor.retreat_within_word(end_placement=True)
# Navigation
- elif keypress.name in ['KEY_TAB'] and current_cell.is_blankish():
+ elif (keypress.name in ['KEY_TAB'] and
+ current_cell.is_blankish()):
cursor.advance_to_next_word(blank_placement=True)
- elif keypress.name in ['KEY_TAB'] and not current_cell.is_blankish():
+ elif (keypress.name in ['KEY_TAB'] and
+ not current_cell.is_blankish()):
cursor.advance_within_word(overwrite_mode=False)
elif keypress.name in ['KEY_PGDOWN']:
@@ -1010,14 +1166,14 @@ def main():
cursor.switch_direction()
elif ((cursor.direction == "across" and
- keypress.name == 'KEY_RIGHT') or
+ keypress.name == 'KEY_RIGHT') or
(cursor.direction == "down" and
keypress.name == 'KEY_DOWN')):
cursor.advance()
elif ((cursor.direction == "across" and
- keypress.name == 'KEY_LEFT') or
+ keypress.name == 'KEY_LEFT') or
(cursor.direction == "down" and
keypress.name == 'KEY_UP')):
@@ -1036,6 +1192,9 @@ def main():
cursor.retreat_perpendicular()
print(term.exit_fullscreen())
+ timer.pause()
+ twitchbot.running = False
+ twitchbot.join()
if __name__ == '__main__':
diff --git a/cursewords/twitch.py b/cursewords/twitch.py
new file mode 100644
index 0000000..36411cd
--- /dev/null
+++ b/cursewords/twitch.py
@@ -0,0 +1,253 @@
+import asyncio
+import logging
+import random
+import re
+import threading
+import time
+import websockets
+
+
+# Twitch bot secure web socket URL
+TWITCH_URI = 'wss://irc-ws.chat.twitch.tv:443'
+
+# Minimum number of seconds between posts to the chat, to avoid Twitch
+# dropping messages
+MESSAGE_COOLDOWN_SECS = 1
+
+HELLOS = [
+ 'Hello!', 'Bonjour!', '¡Hola!', 'Nǐ hǎo.', 'Konnichiwa!', 'Hallo!',
+ 'Namaste!', 'Shalom!', 'Greetings, puzzle people!'
+]
+
+GOODBYES = [
+ 'gg!', 'Well done!', 'And that\'s the game!', 'Let\'s do another one!',
+ 'Fantastic work!', 'Oreos for all!', 'Mazel tov!'
+]
+
+
+class TwitchBot(threading.Thread):
+ def __init__(
+ self,
+ grid,
+ enable,
+ nickname,
+ channel,
+ oauth_token,
+ enable_guessing,
+ enable_clue,
+ clue_cooldown_per_person):
+ self.grid = grid
+ self.enable = enable
+ self.nickname = nickname
+ self.channel = channel
+ self.oauth_token = oauth_token
+ self.enable_guessing = enable_guessing
+ self.enable_clue = enable_clue
+ self.clue_cooldown_per_person = clue_cooldown_per_person
+
+ super().__init__(daemon=True)
+
+ self.websocket = None
+ self.running = None
+
+ self.message_handlers = [
+ (re.compile(r'PING (.*)\r\n'), self.handle_ping),
+ (re.compile(r'.*:(\w+)!\S* PRIVMSG #\S+ :(.*)\r\n'),
+ self.handle_chat_msg),
+ (re.compile(r'.* WHISPER (\S+) :(.*)\r\n'), self.handle_whisper),
+ (re.compile(r'.* JOIN #(.*)\r\n'), self.handle_join),
+ (re.compile(r'.* NOTICE #(\S*) (.*)\r\n'), self.handle_notice)
+ ]
+
+ self._outgoing_message_queue = []
+ self._outgoing_message_last_time = time.monotonic()
+
+ self.successful_guessing_users = set()
+
+ self.last_clue_timestamp_per_user = {}
+
+ def run(self):
+ # If no Twitch features are enabled, don't bother connecting to Twitch.
+ if (not self.enable or
+ (not self.enable_guessing and
+ not self.enable_clue)):
+ return
+
+ self.running = True
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.run_until_complete(self.connect())
+
+ async def join_channel(self):
+ assert self.websocket
+ await self.websocket.send(f'PASS {self.oauth_token}\n')
+ await self.websocket.send(f'NICK {self.nickname}\n')
+ await self.websocket.send(f'JOIN #{self.channel}\n')
+ await self.websocket.send(
+ 'CAP REQ :twitch.tv/tags twitch.tv/commands\n')
+
+ async def _post_next_message(self):
+ assert self.websocket
+ txt = self._outgoing_message_queue.pop()
+ await self.websocket.send(f'PRIVMSG #{self.channel} :{txt}\n')
+ self._outgoing_message_last_time = time.monotonic()
+
+ async def post_message(self, txt):
+ self._outgoing_message_queue.insert(0, txt)
+
+ async def send_whisper(self, user, msg):
+ assert self.websocket
+ # Note: Untested! This probably requires that a bot be verified.
+ # Without a verified bot account, this returns an error.
+ await self.post_message(f'.w {user} {msg}')
+
+ async def connect(self):
+ async with websockets.connect(TWITCH_URI, ssl=True) as websocket:
+ logging.info(
+ f'TwitchBot connected to #{self.channel} as {self.nickname}')
+ self.websocket = websocket
+ await self.join_channel()
+ await self.event_loop()
+
+ async def event_loop(self):
+ try:
+ await self.startup()
+
+ while self.running:
+ # Listen for incoming messages, timing out once a second to
+ # process outgoing messages.
+ try:
+ resp = await asyncio.wait_for(
+ self.websocket.recv(), timeout=1)
+ for (pat, func) in self.message_handlers:
+ m = pat.search(resp)
+ if m:
+ logging.info('Matched message: ' + resp)
+ await func(*m.groups())
+ except asyncio.exceptions.TimeoutError:
+ pass
+
+ # Post an outgoing message at most every MESSAGE_COOLDOWN_SECS.
+ # (Twitch does its own throttling of bots that drops messages.)
+ if (self._outgoing_message_queue and
+ (time.monotonic() - self._outgoing_message_last_time >
+ MESSAGE_COOLDOWN_SECS)):
+ await self._post_next_message()
+
+ await self.shutdown()
+
+ except websockets.exceptions.WebSocketException:
+ self.grid.send_notification(
+ 'Twitch connection lost, sorry')
+
+ async def handle_ping(self, unused_domain):
+ await self.websocket.send('PONG :tmi.twitch.tv\n')
+
+ async def startup(self):
+ hello = random.choice(HELLOS)
+ if self.enable_clue:
+ clue_msg = (
+ ' To request a clue, say !clue followed by a clue name, '
+ 'like: !clue 22d')
+ else:
+ clue_msg = ''
+ if self.enable_guessing:
+ guessing_msg = ' Post your guesses to the chat!'
+ else:
+ guessing_msg = ''
+ await self.post_message(f'{hello}{guessing_msg}{clue_msg}')
+
+ async def handle_join(self, unused_channel_name):
+ self.grid.send_notification(
+ f'Connected to Twitch #{self.channel} as {self.nickname}')
+
+ async def handle_notice(self, unused_channel_name, msg):
+ logging.info(f'Twitch NOTICE: {msg}')
+
+ async def handle_chat_msg(self, user, msg):
+ cmd_m = re.match(r'\s*!(\w+)(\s+.*)?', msg)
+ cmd = (None, None)
+ if cmd_m:
+ cmd = (cmd_m.group(1).lower(), cmd_m.group(2))
+
+ if self.enable_clue and cmd[0] == 'clue':
+ await self.do_clue(user, cmd[1])
+
+ if self.enable_guessing and cmd[0] is None:
+ await self.do_guesses(user, msg)
+
+ async def do_clue(self, user, msg):
+ if (self.clue_cooldown_per_person and
+ user in self.last_clue_timestamp_per_user and
+ (time.monotonic() - self.last_clue_timestamp_per_user[user]
+ < self.clue_cooldown_per_person)):
+ return
+
+ self.last_clue_timestamp_per_user[user] = time.monotonic()
+
+ m = re.match(r'\s*(?P\d+)\s*(?P[aAdD])', msg)
+ if not m:
+ m = re.match(r'\s*(?P[aAdD])\D*\s*(?P\d+)', msg)
+ if m:
+ num = m.group('num')
+ cluedir = m.group('dir').upper()
+ clue = self.grid.get_clue_by_number(
+ int(num), is_across=(cluedir == 'A'))
+ if clue:
+ await self.post_message(f'{user} {num}{cluedir}: {clue}')
+ else:
+ await self.post_message(f'{user} No clue for {num}{cluedir}')
+ # Allow an immediate repost after an error
+ self.last_clue_timestamp_per_user[user] -= \
+ self.clue_cooldown_per_person
+ else:
+ await self.post_message(
+ f'{user} I didn\'t understand. Try something like: !clue 22d')
+
+ def _itemize_guesses(self, msg):
+ # Search a chat message for guesses.
+ #
+ # A guess is one or more words that may or may not be separated by
+ # spaces, case ignored. A guess begins and ends at a word boundary, and
+ # does not span across punctuation.
+ #
+ # For example, if someone posts:
+ # DOUBLE RAINBOW, maybe?
+ #
+ # These words are considered guesses:
+ # double
+ # doublerainbow
+ # rainbow
+ # maybe
+ phrases = re.split(r'[^\w\s]+', msg)
+ for phrase in phrases:
+ words = re.split(r'\W+', phrase.strip())
+ for start_i in range(len(words)):
+ for end_i in range(start_i + 1, len(words) + 1):
+ guess = (''.join(words[start_i:end_i])
+ .lower())
+ if guess:
+ yield guess
+
+ async def do_guesses(self, user, msg):
+ for guess in self._itemize_guesses(msg):
+ if guess in self.grid.word_index:
+ result = self.grid.twinkle_unsolved_word(guess)
+ if result:
+ self.successful_guessing_users.add(user)
+
+ async def handle_whisper(self, user, msg):
+ # We have no whisper-based features.
+ pass
+
+ async def shutdown(self):
+ goodbye = random.choice(GOODBYES)
+ self._outgoing_message_queue = []
+ await self.post_message(f'{goodbye}')
+ await self._post_next_message()
+
+ if self.enable_guessing and self.successful_guessing_users:
+ print('\n### Thanks to these successful solvers:\n')
+ for user in sorted(self.successful_guessing_users):
+ print(' ' + user)
+ print('\n')
diff --git a/requirements.txt b/requirements.txt
index 7baf74c..56331d5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,4 @@
blessed==1.15.0
puzpy==0.2.4
+pytest
+websockets
\ No newline at end of file