From d574fdec73d1cd84b09764ffeb4a92d746702c37 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Sun, 20 Apr 2025 17:20:46 -0400 Subject: [PATCH] Move fun and example commands from csp-bot --- csp_bot_commands/__init__.py | 7 + csp_bot_commands/common.py | 706 ++++++++++++++++++++++++++ csp_bot_commands/delaytest.py | 72 +++ csp_bot_commands/fun.py | 69 +++ csp_bot_commands/mets.py | 144 ++++++ csp_bot_commands/tests/test_fun.py | 58 +++ csp_bot_commands/tests/test_mets.py | 165 ++++++ csp_bot_commands/tests/test_thanks.py | 38 ++ csp_bot_commands/tests/test_trout.py | 40 ++ csp_bot_commands/thanks.py | 50 ++ csp_bot_commands/trout.py | 57 +++ pyproject.toml | 19 +- 12 files changed, 1422 insertions(+), 3 deletions(-) create mode 100644 csp_bot_commands/common.py create mode 100644 csp_bot_commands/delaytest.py create mode 100644 csp_bot_commands/fun.py create mode 100644 csp_bot_commands/mets.py create mode 100644 csp_bot_commands/tests/test_fun.py create mode 100644 csp_bot_commands/tests/test_mets.py create mode 100644 csp_bot_commands/tests/test_thanks.py create mode 100644 csp_bot_commands/tests/test_trout.py create mode 100644 csp_bot_commands/thanks.py create mode 100644 csp_bot_commands/trout.py diff --git a/csp_bot_commands/__init__.py b/csp_bot_commands/__init__.py index 3dc1f76..48264da 100644 --- a/csp_bot_commands/__init__.py +++ b/csp_bot_commands/__init__.py @@ -1 +1,8 @@ __version__ = "0.1.0" + +from .common import * +from .delaytest import * +from .fun import * +from .mets import * +from .thanks import * +from .trout import * diff --git a/csp_bot_commands/common.py b/csp_bot_commands/common.py new file mode 100644 index 0000000..40dbb2f --- /dev/null +++ b/csp_bot_commands/common.py @@ -0,0 +1,706 @@ +__all__ = ( + "a_or_an", + "ADJECTIVES", + "COLORS", + "FISH", + "SHAKEPEREAN_MODIFIERS_ONE", + "SHAKEPEREAN_MODIFIERS_TWO", + "SHAKESPEREAN_NOUNS", + "RANDOM_SINGULAR_NOUNS", + "MONEY", + "ICELANDIC", + "GERMAN", + "DUNE", + "BUSH", + "DRINKING_ESTABLISHMENTS", + "COCKTAILS", + "BEER", +) + + +def a_or_an(next_word): + first_letter = next_word[0] + if first_letter.lower() in ("a", "e", "i", "o", "u"): + return "an" + return "a" + + +ADJECTIVES = () +COLORS = ( + "pink", + "light pink", + "dark pink", + "hot pink", + "violet", + "light violet", + "dark violet", + "purple", + "light purple", + "dark purple", + "magenta", + "fuchsia", + "plum", + "lavender", + "light red", + "dark red", + "red", + "crimson", + "salmon", + "tomato", + "orange", + "gold", + "khaki", + "yellow", + "light yellow", + "dark yellow", + "lemon yellow", + "maroon", + "brown", + "sienna", + "chocolate", + "tan", + "blue", + "light blue", + "dark blue", + "slate blue", + "indigo", + "midnight blue", + "navy", + "royal blue", + "sky blue", + "powder blue", + "teal", + "cyan", + "sea green", + "tuquoise", + "aquq", + "aquamarine", + "green", + "dark green", + "light green", + "olive green", + "forest green", + "sea green", + "lime green", + "chartreuse", + "white", + "beige", + "snow white", + "ivory", + "grey", + "light grey", + "dark grey", + "slate grey", + "silver", + "black", +) + +FISH = ( + "albacore", + "amberjack", + "anchovy", + "angelfish", + "anglerfish", + "arctic char", + "barracuda", + "barramundi", + "bass", + "blackfin tuna", + "blobfish", + "blowfish", + "bluefin tuna", + "bonefish", + "bonito", + "bream", + "carp", + "catfish", + "clownfish", + "cod", + "cowfish", + "dogfish", + "dorado", + "dory", + "eagle ray", + "eel", + "electric eel", + "fire goby", + "flathead catfish", + "flounder", + "flying fish", + "frogfish", + "goatfish", + "goby", + "goldfish", + "gourami", + "grouper", + "guppy", + "haddock", + "hagfish", + "halibut", + "herring", + "horsefish", + "jackfish", + "jawfish", + "jewelfish", + "koi", + "lamprey", + "largemouth bass", + "lionfish", + "loach", + "mackerel", + "mahi-mahi", + "manta ray", + "minnow", + "moray eel", + "mudfish", + "mudskipper", + "oarfish", + "parrotfish", + "perch", + "pollock", + "porgy", + "pufferfish", + "rainbow trout", + "ray", + "remora", + "salmon", + "sardine", + "sculpin", + "sea bass", + "sea bream", + "sea dragon", + "sea snail", + "seahorse", + "shark", + "skate", + "skipjack tuna", + "smelt", + "snakehead", + "sockeye salmon", + "sole", + "spearfish", + "sprat", + "stingray", + "striped bass", + "sturgeon", + "sunfish", + "swordfish", + "tarpon", + "tetra", + "tiger shark", + "tilapia", + "tilefish", + "toadfish", + "triggerfish", + "trout", + "tuna", + "wahoo", + "walleye", + "wrasse", + "yellowfin tuna", + "yellowtail", + "zebrafish", +) + +SHAKEPEREAN_MODIFIERS_ONE = ( + "artless", + "bawdy", + "beslubbering", + "bootless", + "churlish", + "cockered", + "clouted", + "craven", + "currish", + "dankish", + "dissembling", + "droning", + "errant", + "fawning", + "fobbing", + "froward", + "frothy", + "gleeking", + "goatish", + "gorbellied", + "impertinent", + "infectious", + "jarring", + "joggerheaded", + "lumpish", + "mammering", + "mangled", + "mewling", + "paunchy", + "pribbling", + "puking", + "puny", + "rank", + "reeky", + "roguish", + "ruftish", + "saucy", + "spleeny", + "spongy", + "surly", + "tottering", + "unmuzzled", + "vain", + "venomed", + "villainous", + "warped", + "wayward", + "weedy", + "yeasty", +) + +SHAKEPEREAN_MODIFIERS_TWO = ( + "base-court", + "bat-forling", + "beef-witted", + "beetle-headed", + "boil-brained", + "clapper-clawed", + "clay-brained", + "common-kissing", + "crook-pated", + "dismal-dreaming", + "dizzy-eyed", + "doghearted", + "dread-bolted", + "earth-vexing", + "elf-skinned", + "fat-kidneyed", + "fen-sucked", + "flap-mothed", + "fly-bitten", + "folly-fallen", + "fool-born", + "fill-gorged", + "guts-griping", + "half-faced", + "hasty-witted", + "hedge-born", + "hell-hated", + "idle-headed", + "ill-breeding", + "ill-nurtured", + "knotty-pated", + "milk-livered", + "motley-minded", + "onion-eyed", + "plume-plucked", + "pottle-deep", + "pox-marked", + "reeling-ripe", + "rough-hewn", + "rude-growing", + "rump-faced", + "shard-borne", + "sheep-biting", + "spur-galled", + "swag-bellied", + "tardy-gaited", + "tickle-brained", + "toad-spotted", + "unchin-snoted", + "weather-bitten", +) + +SHAKESPEREAN_NOUNS = ( + "apple-john", + "baggage", + "barnacle", + "bladder", + "boar-pig", + "bugbear", + "bum-bailey", + "canket-blossom", + "clack-dish", + "clotpole", + "coxcomb", + "codpiece", + "death-token", + "dewberry", + "flap-dragon", + "flax-wench", + "flirt-gill", + "foot-licker", + "futilarrian", + "giglet", + "gudgeon", + "haggard", + "harpy", + "hedge-pig", + "horn-beast", + "hugger-mugger", + "joithead", + "lewduster", + "lout", + "maggot-pie", + "malt-worm", + "mammet", + "measle", + "minnow", + "miscreant", + "moldwarp", + "mumble-news", + "nut-hook", + "pigeon-egg", + "pignut", + "puttock", + "pumbion", + "ratsbane", + "scut", + "skainsmate", + "strumpot", + "varlot", + "vassal", + "wheyface", + "wagtail", +) + +RANDOM_SINGULAR_NOUNS = ( + "CD", + "USB drive", + "VHS", + "air freshener", + "bag", + "bag of chalk", + "bag of packing peanuts", + "balloon", + "bed", + "blanket", + "blouse", + "book", + "bookmark", + "boom box", + "bottle", + "bottle cap", + "bouquet of flowers", + "bow", + "bowl", + "box", + "bracelet", + "buckle", + "camera", + "candle", + "candy wrapper", + "car", + "cell phone", + "chair", + "charger", + "checkbook", + "cinder block", + "clamp", + "clay pot", + "clock", + "computer", + "cookie jar", + "cork", + "couch", + "credit card", + "cup", + "desk", + "doll", + "drawer", + "drill press", + "eraser", + "flag", + "fork", + "fridge", + "fruit basket", + "glass", + "glow stick", + "greeting card", + "hair brush", + "hair tie", + "helmet", + "house", + "ice cube tray", + "iphone", + "ipod", + "key chain", + "keyboard", + "knife", + "lamp", + "lamp shade", + "magnet", + "mirror", + "model car", + "monitor", + "mop", + "mouse pad", + "mp3 player", + "nail file", + "newspaper", + "paint brush", + "pair of glasses", + "pair of headphones", + "pair of leg warmers", + "pair of nail clippers", + "pair of pants", + "pair of shoes", + "pair of socks", + "pair of speakers", + "pair of sun glasses", + "pen", + "pencil", + "perfume", + "phone", + "photo album", + "piano", + "picture frame", + "pillow", + "plastic fork", + "plate", + "playing card", + "pool stick", + "purse", + "radio", + "remote", + "ring", + "rubber band", + "rubber duck", + "rug", + "rusty nail", + "sailboat", + "sandal", + "scotch tape", + "set of keys", + "set of tweezers", + "sharpie", + "shawl", + "shirt", + "shovel", + "sketch pad", + "slipper", + "soda can", + "sofa", + "spindle of thread", + "sponge", + "spoon", + "spring", + "sticky note", + "stop sign", + "table", + "tablet", + "teacup", + "television", + "thermometer", + "thermostat", + "tissue box", + "toe ring", + "toilet", + "toothbrush", + "towel", + "toy truck", + "tree", + "tube of toothpaste", + "tv", + "vase", + "video game", + "wagon", + "wallet", + "washing machine", + "watch", + "water bottle", + "zipper", +) + +MONEY = ( + "$1 bills", + "pennies", + "nickels", + "dimes", + "loonies", + "stright cash money", + "gold bullion", + "onion futures", + "tulips", + "rare beanie babies", + "bitcoin", + "dogecoin", +) + +ICELANDIC = ( + "Af góðu upphafi vonast góður endir.", + "Af óskum eru allir eins ríkir", + "Allir vilja herrann vera, en enginn sekkinn bera", + "Aldrei er góð vísa of oft kveðin", + "Árinni kennir illur ræðari", + "Á misjöfnu þrífast börnin bes", + "Ber er hver að baki nema sér bróður eigi", + "Betra er að vera fátækr og heill, enn ríkr og vanheill", + "Betra er einn að vera, en illan stallbróður hafa", + "Bezt er að hætta hvörjum leik , meðan vel fer", + "Brennt barn forðast eldinn", + "Ef ábóti teninga a sér ber, oss munkum leyft ao tefla er.", + "Eftir því sem gamlir fuglar sungu, kvökuðu þeir ungu", + "Einki er so illt, táð er ikki gott firi okkurt", + "Engum flýgur sofanda steikt gæs i munn", + "Garðr er granna sættir", + "Guð hjálpar þeim sem hjálpa sér sjálfir", + "Góð orð finna góðan samastað", + "Vitur maður breytir hugum sínum, heimskingi vill aldrei", + "Hver er sinnar gæfu smiður", + "Kemst þó hægt fari", + "Kom ei of opt til vina þinna, svo þá væmi ekki við þér", + "Kornbarn, drukkin maðr og dárinn segja sannleikann", + "Láttu ekki happ ur hendi sleppa", + "Líkt á við líkt Líkt", + "Linur bartskeri gjörir fúin sár", + "Margr lækr smár gjörir stórar ár", + "Náttin er manns óvinur", + "Ofleyfingjarnir bregðask mér mest", + "Oft hafa fagrar hnetur fúinn kjarna", + "Þá mér klær, þarf ég að klóra mér", + "þar liggur hundurinn grafinn", + "þegar neyðin er hærst, er hjálpin nærst", + "þó hinn fátæki tali hyggiliga, vill það enginn heyra", + "Ragur maður fíflar aldrei fríða konu", + "Sá er fuglinn verstur, sem í sjálfs síns hreiður dritar", + "Sjaldan er ein báran stök", + "Sjaldan fellur eplið langt frá eikini", + "Spónasmiða börn eiga oft versta spæni", + "Sinn er siður í landi hverju", + "Sá vinnur sitt mál, sem þráastur er", + "Sá fær opt æruna virðinguna sem minnst is fær opt æruna sækist eptir henni", + "Sæll er sá, sem lætr sér annars víti að varnaði verða", + "Vitið kemr ei fyrir árin", +) + +GERMAN = ( + "Streichholzschächtelchen - Translation: Matchbox", + "Lebensabschnittsgefährte - Translation: Domestic partner", + "Freundschaftsbeziehungen - Translation: Friendship relationships", + "Unabhängigkeitserklärungen - Translation: Declarations of Independence", + "Nahrungsmittelunverträglichkeit - Translation: Food intolerance", + "Arbeiterunfallverischerungsgesetz - Translation: Workers' Compensation Act", + "Rechtsschutzversicherungsgesellschaften - Translation: Legal expenses insurance companies", + "Donaudampfschiffahrtsgesellschaftskapitän - Translation: Danube Steamship Company Captain", + "Betäubungsmittelverschreibungsverordnung - Translation: Narcotics Prescription Ordinance", + "Massenkommunikationsdienstleistungsunternehmen - Translation: Mass Communications Services Company", + "Neunhundertneunundneunzigtausendneunhundertneunundneunzig - Translation: Nine hundred and ninety nine thousand nine hundred and ninety nine", + "Rindfleischetikettierungsüberwachungsaufgabenübertragungsgesetz - Translation: Delegation transfer law for the labeling of beef in order to monitor task transfers", + "Grundstücksverkehrsgenehmigungszuständigkeitsübertragungsverordnung - Translation: Regulation on the delegation of authority concerning land conveyance permissions", + "Donaudampfschiffahrtselektrizitätenhauptbetriebswerkbauunterbeamtengesellschaft - Translation: Danube Steamship Electricity Main Plant Construction Suboffice Company", + "Rinderkennzeichnungsfleischetikettierungsüberwachungsaufgabenübertragungsgesetz - Translation: Delegation transfer law for cattle labeling and beef labeling supervision duties", +) + + +DUNE = ( + "Not the blood, sir. But all of a man’s water, ultimately, belongs to his people—to his tribe. It’s a necessity when you live near the Great Flat. All water’s precious there, and the human body is composed of some seventy percent water by weight. A dead man, surely, no longer requires that water.", + "The willow submits to the wind and prospers until one day it is many willows—a wall against the wind. This is the willow’s purpose.", + "Sir, I honor and respect the personal dignity of any man who respects my dignity. I am indeed indebted to you. And I always pay my debts. If it is your custom that this knife remain sheathed here, then it is so ordered—by me. And if there is any other way we may honor the man who died in our service, you have but to name it.", + "The concept of progress acts as a protective mechanism to shield us from the terrors of the future.", + "Deep in the human unconscious is a pervasive need for a logical universe that makes sense, But the real universe is always one step beyond logic.", + "Anything outside yourself, this you can see and apply your logic to it. But it’s a human trait that when we encounter personal problems, these things most deeply personal are the most difficult to bring out for our logic to scan. We tend to flounder around, blaming everything but the actual, deep-seated thing that’s really chewing on us.", + "The mystery of life isn’t a problem to solve, but a reality to experience.", + "Growth is limited by that necessity which is present in the least amount. And, naturally, the least favorable condition controls the growth rate.", + "Intelligence takes chance with limited data in an arena where mistakes are not only possible but also necessary.", + "When law and duty are one, united by religion, you never become fully conscious, fully aware of yourself. You are always a little less than an individual.", + "A process cannot be understood by stopping it. Understanding must move with the flow of the process, must join it and flow with it.", + "I must not fear. Fear is the mind-killer. Fear is the little-death that brings total obliteration. I will face my fear. I will permit it to pass over me and through me. And when it has gone past I will turn the inner eye to see its path. Where the fear has gone there will be nothing. Only I will remain.", +) + + +BUSH = ( + "Our enemies are innovative and resourceful, and so are we. They never stop thinking about new ways to harm our country and our people, and neither do we.", + "I know how hard it is for you to put food on your family.”—Greater Nashua, N.H., Chamber of Commerce, Jan. 27, 2000", + "Rarely is the question asked: Is our children learning?", + "You teach a child to read, and he or her will be able to pass a literacy test.", + "See, in my line of work you got to keep repeating things over and over and over again for the truth to sink in, to kind of catapult the propaganda.", + "And so, General, I want to thank you for your service. And I appreciate the fact that you really snatched defeat out of the jaws of those who are trying to defeat us in Iraq.", + "We ought to make the pie higher.", + "There’s an old saying in Tennessee—I know it’s in Texas, probably in Tennessee—that says, fool me once, shame on—shame on you. Fool me—you can’t get fooled again.", + "And there is distrust in Washington. I am surprised, frankly, at the amount of distrust that exists in this town. And I’m sorry it’s the case, and I’ll work hard to try to elevate it.", + "We’ll let our friends be the peacekeepers and the great country called America will be the pacemakers.", + "It’s important for us to explain to our nation that life is important. It’s not only life of babies, but it’s life of children living in, you know, the dark dungeons of the Internet.", + "One of the great things about books is sometimes there are some fantastic pictures", + "People say, ‘How can I help on this war against terror? How can I fight evil?’ You can do so by mentoring a child; by going into a shut-in’s house and say I love you.", + "Well, I think if you say you’re going to do something and don’t do it, that’s trustworthiness.", + "I’m looking forward to a good night’s sleep on the soil of a friend.", + "I think it’s really important for this great state of baseball to reach out to people of all walks of life to make sure that the sport is inclusive. The best way to do it is to convince little kids how to—the beauty of playing baseball.", + "Families is where our nation finds hope, where wings take dream.", + "There’s a huge trust. I see it all the time when people come up to me and say, ‘I don’t want you to let me down again.’", + "They misunderestimated me.", + "I’ll be long gone before some smart person ever figures out what happened inside this Oval Office.", + "I think we agree, the past is over.", + "I call upon all nations, to do everything they can, to stop these terrorist killers. Thank you...now watch this drive.", + "I know the human being and fish can coexist peacefully.", +) + +DRINKING_ESTABLISHMENTS = ( + "pub", + "bar", + "tavern", + "beer hall", + "izakaya", + "beer garden", + "speakeasy", + "cocktail bar", +) + +COCKTAILS = ( + "Old Fashioned", + "Negroni", + "Daiquiri", + "Dry Martini", + "Margarita", + "Espresso Martini", + "Whiskey Sour", + "Manhattan", + "Aperol Spritz", + "Mojito", + "Bloody Mary", + "Gimlet", + "Moscow Mule", + "Penicillin", + "Dark 'n Stormy", + "Corpse Reviver", + "Clover Club", + "Boulevardier", + "Mai Tai", + "Sazerac", + "French 75", + "Paloma", + "Pisco Sour", + "Vieux Carré", + "Americano", + "Amaretto Sour", + "Gin Fizz", + "Bramble", + "Brandy Crusta", + "Bellini", + "Piña Colada", + "Sidecar", + "Aviation", + "Irish Coffee", + "Last Word", + "Tommy's Margarita", + "Tom Collins", + "Caipirinha", + "Vodka Martini", + "Cosmopolitan", + "Gin Mule", + "Long Island Iced Tea", +) + + +BEER = ( + "ale", + "amber ale", + "brown ale", + "cream ale", + "hefeweizen", + "helles", + "IPA", + "lager", + "lambic", + "pale ale", + "pilsner", + "porter", + "saison", + "stout", +) diff --git a/csp_bot_commands/delaytest.py b/csp_bot_commands/delaytest.py new file mode 100644 index 0000000..739021c --- /dev/null +++ b/csp_bot_commands/delaytest.py @@ -0,0 +1,72 @@ +import logging +from datetime import datetime, timedelta +from multiprocessing.pool import ThreadPool +from random import randint +from time import sleep +from typing import Optional, Type, Union + +from csp_bot import BaseCommand, BaseCommandModel, BotCommand, Message, ReplyToOtherCommand + +log = logging.getLogger(__name__) + +__all__ = ( + "DelayTestCommandModel", + "DelayTestCommand", +) + + +def _delay_test(): + """A simple function to imitate performing some background work""" + sleep(16) + return randint(0, 100) + + +class DelayTestCommand(ReplyToOtherCommand): + def __init__(self, *args, **kwargs): + self._threadpool = ThreadPool() + self._resultmap = {} + + def command(self) -> str: + return "_delaytest" + + def name(self) -> str: + return "" + + def help(self) -> str: + return "" + + def preexecute(self, command: BotCommand) -> BotCommand: + """This isn't actually used, but is designed to test a feature of arbitrarily + delaying a command to execute in the background""" + log.critical(f"Testing async bot command: {command}") + self._resultmap[command.id] = self._threadpool.apply_async(_delay_test) + # set delay to +5s + command.delay = datetime.now() + timedelta(seconds=5) + return command + + def execute(self, command: BotCommand) -> Optional[Union[Message, "DelayTestCommand"]]: + log.critical(f"Delaytest command: {command}") + + if command.id in self._resultmap: + # it was a delay test, check if ready + if self._resultmap[command.id].ready(): + # return the random number as a symphony message + message = f"Delay test result: {self._resultmap[command.id].get()}" + self._resultmap.pop(command.id) + return Message( + msg=message, + channel=command.channel, + ) + else: + # reschedule for +5s + command.delay = datetime.now() + timedelta(seconds=5) + msg = Message( + msg="All bots are currently assisting other customers, please stay on the line...", + channel=command.channel, + backend=command.backend, + ) + return [command, msg] + + +class DelayTestCommandModel(BaseCommandModel): + command: Type[BaseCommand] = DelayTestCommand diff --git a/csp_bot_commands/fun.py b/csp_bot_commands/fun.py new file mode 100644 index 0000000..ec849a8 --- /dev/null +++ b/csp_bot_commands/fun.py @@ -0,0 +1,69 @@ +import logging +from random import choice +from typing import Optional, Type + +from csp_bot import BaseCommand, BaseCommandModel, BotCommand, Message, ReplyToOtherCommand, mention_user + +from .common import ( + BEER, + BUSH, + COCKTAILS, + DRINKING_ESTABLISHMENTS, + DUNE, + GERMAN, + ICELANDIC, + a_or_an, +) + +__all__ = ( + "FunCommandModel", + "FunCommand", +) +log = logging.getLogger(__name__) + + +class FunCommand(ReplyToOtherCommand): + def command(self) -> str: + return "_fun" + + def name(self) -> str: + return "" + + def help(self) -> str: + return "" + + def execute(self, command: BotCommand) -> Optional[Message]: + log.info(f"Fun command: {command}") + author = mention_user(command.source.id, command.backend) + target = [mention_user(user.id, command.backend) for user in command.targets] + if not target: + return + if "icelandic" in command.args: + message = f'{author} consoles {" ".join(target)} with an Icelandic folk saying: "{choice(ICELANDIC)}"' + elif "german" in command.args: + message = f"{author} teaches {' '.join(target)} some German: {choice(GERMAN)}. Prost!" + elif "cocktail" in command.args: + venue = choice(DRINKING_ESTABLISHMENTS) + cocktail = choice(COCKTAILS) + message = f'{author} calls {" ".join(target)} over to the {venue} for a cocktail: "How about {a_or_an(cocktail)} {cocktail}?"' + elif "beer" in command.args: + venue = choice(DRINKING_ESTABLISHMENTS) + beer = choice(BEER) + message = f'{author} calls {" ".join(target)} over to the {venue} for a beer: "How about {a_or_an(beer)} {beer}?"' + elif "dune" in command.args: + quote = choice(DUNE) + message = f'{author} scrapes wisdom for {" ".join(target)} off the sands of Arrakis: "{quote}"' + elif "bush" in command.args: + quote = choice(BUSH) + message = f'{author} impresses {" ".join(target)} with a quote from George W. Bush: "{quote}"' + else: + return None + return Message( + msg=message, + channel=command.channel, + backend=command.backend, + ) + + +class FunCommandModel(BaseCommandModel): + command: Type[BaseCommand] = FunCommand diff --git a/csp_bot_commands/mets.py b/csp_bot_commands/mets.py new file mode 100644 index 0000000..a6d1fb8 --- /dev/null +++ b/csp_bot_commands/mets.py @@ -0,0 +1,144 @@ +import logging +from typing import Optional, Type + +from csp_bot import BaseCommand, BaseCommandModel, BotCommand, Message, ReplyToOtherCommand + +try: + import lxml # noqa: F401 + import pandas + + # Required for pandas functions we use + import tabulate # noqa: F401 +except ModuleNotFoundError: + pandas = None + +__all__ = ( + "get_stats", + "get_roster", + "get_schedule", + "get_standings", + "MetsCommand", + "MetsCommandModel", +) +log = logging.getLogger(__name__) + + +def get_stats(): + dfs = pandas.read_html("https://www.espn.com/mlb/team/stats/_/name/nym/new-york-mets") + df = pandas.concat(dfs[:2], axis=1) + return df + + +def get_roster(): + dfs = pandas.read_html("https://www.espn.com/mlb/team/roster/_/name/nym/new-york-mets") + df = pandas.concat(dfs)[["Name", "POS", "BAT", "THW", "Age", "HT", "WT"]] + df["Name"] = df["Name"].str.replace("\\d+", "") + return df + + +def get_schedule(): + df = pandas.read_html("https://www.espn.com/mlb/team/schedule/_/name/nym")[0] + df = df.iloc[1:] + df.columns = ["Date", "Opponent", "Result", "W-L", "Win", "Loss", "Save", "Att"] + df = df[df["Date"] != "DATE"] + return df + + +def get_standings(): + dfs = pandas.read_html("https://www.espn.com/mlb/standings/_/group/overall") + teams = dfs[0].columns.tolist() + dfs[0].iloc[:, 0].tolist() + teams = [n.replace("e --", "") for n in teams] + team_names = [] + team_acronyms = [] + for team in teams: + # 2 letter + for _ in ("TB", "SF", "SD", "KC"): + if team.startswith(_): + team_acronyms.append(_) + team_names.append(team[2:]) + break + else: + team_acronyms.append(team[:3]) + team_names.append(team[3:]) + df = dfs[1] + df["Team"] = team_acronyms + df["Name"] = team_names + df = df[ + [ + "Team", + "Name", + "W", + "L", + "PCT", + "GB", + "HOME", + "AWAY", + "RS", + "RA", + "DIFF", + "STRK", + "L10", + ] + ] + return df + + +class MetsCommand(ReplyToOtherCommand): + def command(self) -> str: + return "mets" + + def name(self) -> str: + return "Mets Information" + + def help(self) -> str: + return "Information about the Mets. Syntax: /mets [stats roster schedule standings]" + + def execute(self, command: BotCommand) -> Optional[Message]: + log.info(f"Mets command: {command}") + + try: + if pandas is None: + raise ValueError("pandas not installed") + if "stats" in command.args: + message = get_stats() + kind = "Mets Statistics" + elif "roster" in command.args: + message = get_roster() + kind = "Mets Roster" + elif "schedule" in command.args: + message = get_schedule() + kind = "Mets Schedule" + else: + message = get_standings() + kind = "League Standings" + + if command.backend == "symphony": + message = message.to_html(index=False).replace('border="1"', "") + message = f'
{kind}
{message}
' + elif command.backend == "slack": + message = f"{kind}\n```\n{message.to_markdown(index=False)}\n```" + elif command.backend == "discord": + message = f"{kind}\n```\n{message.to_markdown(index=False)}\n```" + else: + raise NotImplementedError(f"Unsupported backend: {command.backend}") + + return Message( + msg=message, + channel=command.channel, + backend=command.backend, + ) + except ValueError: + # error pulling tables + log.exception("Error pulling Mets data") + message = "Mets data unavailable right now!" + if pandas is None: + message += " (pandas not installed)" + return Message( + msg=message, + channel=command.channel, + backend=command.backend, + ) + + +class MetsCommandModel(BaseCommandModel): + command: Type[BaseCommand] = MetsCommand diff --git a/csp_bot_commands/tests/test_fun.py b/csp_bot_commands/tests/test_fun.py new file mode 100644 index 0000000..5632087 --- /dev/null +++ b/csp_bot_commands/tests/test_fun.py @@ -0,0 +1,58 @@ +import pytest +from csp_bot import BotCommand, User + +from csp_bot_commands.fun import FunCommand + +cmd = FunCommand() + + +class TestFun: + def test_statics(self): + assert cmd.backends() == [] # All + assert cmd.command() == "_fun" + + # hidden + assert cmd.name() == "" + assert cmd.help() == "" + + @pytest.mark.parametrize( + "args,", + [ + ("icelandic",), + ("german",), + ("cocktail",), + ("beer",), + ("dune",), + ("bush",), + ], + ) + def test_execute(self, args): + msg = cmd.execute( + BotCommand( + backend="slack", + channel="test_channel", + source=User( + id="123", + ), + targets=(User(id="456"),), + args=args, + ) + ) + assert msg is not None + assert msg.backend == "slack" + assert msg.channel == "test_channel" + + if args[0] == "icelandic": + assert msg.msg.startswith("<@123> consoles <@456> with an Icelandic folk saying:") + elif args[0] == "german": + assert msg.msg.startswith("<@123> teaches <@456> some German:") + elif args[0] == "cocktail": + assert msg.msg.startswith("<@123> calls <@456> over to the") + elif args[0] == "beer": + assert msg.msg.startswith("<@123> calls <@456> over to the") + elif args[0] == "dune": + assert msg.msg.startswith("<@123> scrapes wisdom for <@456> off the sands of Arrakis:") + elif args[0] == "bush": + assert msg.msg.startswith("<@123> impresses <@456> with a quote from George W. Bush:") + else: + assert False diff --git a/csp_bot_commands/tests/test_mets.py b/csp_bot_commands/tests/test_mets.py new file mode 100644 index 0000000..5e8c342 --- /dev/null +++ b/csp_bot_commands/tests/test_mets.py @@ -0,0 +1,165 @@ +from unittest.mock import patch + +import pandas as pd +import pytest +from csp_bot import BotCommand, User + +from csp_bot_commands.mets import MetsCommand, get_roster, get_schedule, get_standings, get_stats + +cmd = MetsCommand() + + +class TestMets: + def test_statics(self): + assert cmd.backends() == [] # All + assert cmd.command() == "mets" + assert cmd.name() == "Mets Information" + assert cmd.help() == "Information about the Mets. Syntax: /mets [stats roster schedule standings]" + + def test_data_fetch(self): + assert get_roster() is not None + assert get_schedule() is not None + assert get_standings() is not None + assert get_stats() is not None + + @pytest.mark.parametrize( + "args,backend", + [ + (("stats",), "discord"), + (("stats",), "slack"), + (("stats",), "symphony"), + (("roster",), "discord"), + (("roster",), "slack"), + (("roster",), "symphony"), + (("schedule",), "discord"), + (("schedule",), "slack"), + (("schedule",), "symphony"), + (("standings",), "discord"), + (("standings",), "slack"), + (("standings",), "symphony"), + ], + ) + def test_execute(self, args, backend): + print(args) + with patch("csp_bot_commands.mets.pandas") as mock_pandas: + if args[0] == "stats": + mock_pandas.read_html.return_value = [ + pd.DataFrame([{"Name": "Francisco Lindor SS"}]), + pd.DataFrame( + [ + { + "GP": 21, + "AB": 85, + "R": 14, + "H": 23, + "2B": 4, + "3B": 0, + "HR": 3, + "RBI": 9, + "TB": 36, + "BB": 6, + "SO": 15, + "SB": 2, + "AVG": 0.271, + "OBP": 0.323, + "SLG": 0.424, + "OPS": 0.746, + "WAR": 0.1, + } + ] + ), + ] + elif args[0] == "roster": + mock_pandas.read_html.return_value = [ + pd.DataFrame( + [ + { + "Unnamed: 0": 0, + "Name": "Edwin Diaz39", + "POS": "RP", + "BAT": "R", + "THW": "R", + "Age": 31, + "HT": "6' 3\"", + "WT": "165 lbs", + "Birth Place": "Naguabo, Puerto Rico", + } + ] + ), + ] + elif args[0] == "schedule": + mock_pandas.read_html.return_value = [ + pd.DataFrame( + [ + { + 0: "Sun, Jul 13", + 1: "@ Kansas City", + 2: "2:10 PM", + 3: 0, + 4: 0, + 5: 0, + 6: "Tickets as low as $11", + 7: "Tickets as low as $11", + } + ] + ) + ] + elif args[0] == "standings": + mock_pandas.read_html.return_value = [ + pd.DataFrame([{"SDSan Diego Padres": "NYMNew York Mets"}]), + pd.DataFrame( + [ + { + "W": 3, + "L": 16, + "PCT": 0.158, + "GB": "11", + "HOME": "2-5", + "AWAY": "1-11", + "RS": 63, + "RA": 115, + "DIFF": -52, + "STRK": "L7", + "L10": "1-9", + }, + { + "W": 7, + "L": 15, + "PCT": 0.318, + "GB": "8.5", + "HOME": "4-5", + "AWAY": "3-10", + "RS": 75, + "RA": 95, + "DIFF": -20, + "STRK": "L3", + "L10": "3-7", + }, + ] + ), + ] + else: + mock_pandas.read_html.return_value = [pd.DataFrame()] + + msg = cmd.execute( + BotCommand( + backend=backend, + channel="test_channel", + source=User( + id="123", + ), + targets=(User(id="456"),), + args=args, + ) + ) + assert msg is not None + assert msg.backend == backend + assert msg.channel == "test_channel" + if args[0] == "stats": + assert "Mets Statistics" in msg.msg + elif args[0] == "roster": + assert "Mets Roster" in msg.msg + elif args[0] == "schedule": + assert "Mets Schedule" in msg.msg + elif args[0] == "standings": + assert "League Standings" in msg.msg diff --git a/csp_bot_commands/tests/test_thanks.py b/csp_bot_commands/tests/test_thanks.py new file mode 100644 index 0000000..9c8cef9 --- /dev/null +++ b/csp_bot_commands/tests/test_thanks.py @@ -0,0 +1,38 @@ +import pytest +from csp_bot import BotCommand, User + +from csp_bot_commands.thanks import ThanksCommand + +cmd = ThanksCommand() + + +class TestThanks: + def test_statics(self): + assert cmd.backends() == [] # All + assert cmd.command() == "thanks" + assert cmd.name() == "Thanks" + assert cmd.help() == "Thank someone. Syntax: /thanks [/channel ]" + + @pytest.mark.parametrize( + "args,", + [ + ("cash",), + ("other",), + ], + ) + def test_execute(self, args): + msg = cmd.execute( + BotCommand( + backend="slack", + channel="test_channel", + source=User( + id="123", + ), + targets=(User(id="456"),), + args=args, + ) + ) + assert msg is not None + assert msg.backend == "slack" + assert msg.channel == "test_channel" + assert msg.msg.startswith("<@123> thanks <@456> with") diff --git a/csp_bot_commands/tests/test_trout.py b/csp_bot_commands/tests/test_trout.py new file mode 100644 index 0000000..d822890 --- /dev/null +++ b/csp_bot_commands/tests/test_trout.py @@ -0,0 +1,40 @@ +import pytest +from csp_bot import BotCommand, User + +from csp_bot_commands.trout import TroutSlapCommand + +cmd = TroutSlapCommand() + + +class TestTroutSlap: + def test_statics(self): + assert cmd.backends() == [] # All + assert cmd.command() == "slap" + assert cmd.name() == "Slap" + assert cmd.help() == "Slap someone with a wet fish. Syntax: /slap [/channel ]" + + @pytest.mark.parametrize( + "args,", + [ + ("random",), + ("trout",), + ], + ) + def test_execute(self, args): + msg = cmd.execute( + BotCommand( + backend="slack", + channel="test_channel", + source=User( + id="123", + ), + targets=(User(id="456"),), + args=args, + ) + ) + assert msg is not None + assert msg.backend == "slack" + assert msg.channel == "test_channel" + assert msg.msg.startswith("<@123> slaps <@456> with") + if args[0] == "trout": + assert "trout" in msg.msg diff --git a/csp_bot_commands/thanks.py b/csp_bot_commands/thanks.py new file mode 100644 index 0000000..081e930 --- /dev/null +++ b/csp_bot_commands/thanks.py @@ -0,0 +1,50 @@ +import logging +from random import choice, randint +from typing import Optional, Type + +from csp_bot import BaseCommand, BaseCommandModel, BotCommand, Message, ReplyToOtherCommand, mention_user + +from .common import COLORS, MONEY, RANDOM_SINGULAR_NOUNS, a_or_an + +__all__ = ( + "ThanksCommandModel", + "ThanksCommand", +) + +log = logging.getLogger(__name__) + + +class ThanksCommand(ReplyToOtherCommand): + def command(self) -> str: + return "thanks" + + def name(self) -> str: + return "Thanks" + + def help(self) -> str: + return "Thank someone. Syntax: /thanks [/channel ]" + + def execute(self, command: BotCommand) -> Optional[Message]: + log.info(f"Thanks command: {command}") + author = mention_user(command.source.id, command.backend) + target = [mention_user(user.id, command.backend) for user in command.targets] + if not target: + return + + if "cash" in command.args: + amount = randint(1, 11) * choice((10, 100)) + payment_type = choice(MONEY) + message = f"{author} thanks {' '.join(target)} with ${amount} in {payment_type}" + else: + color = choice(COLORS) + gift = choice(RANDOM_SINGULAR_NOUNS) + message = f"{author} thanks {' '.join(target)} with {a_or_an(color)} {color} {gift}" + return Message( + msg=message, + channel=command.channel, + backend=command.backend, + ) + + +class ThanksCommandModel(BaseCommandModel): + command: Type[BaseCommand] = ThanksCommand diff --git a/csp_bot_commands/trout.py b/csp_bot_commands/trout.py new file mode 100644 index 0000000..06e3cfd --- /dev/null +++ b/csp_bot_commands/trout.py @@ -0,0 +1,57 @@ +import logging +from random import choice +from typing import Optional, Type + +from csp_bot import BaseCommand, BaseCommandModel, BotCommand, Message, ReplyToOtherCommand, mention_user + +from .common import ( + COLORS, + FISH, + SHAKEPEREAN_MODIFIERS_ONE, + SHAKEPEREAN_MODIFIERS_TWO, + SHAKESPEREAN_NOUNS, + a_or_an, +) + +__all__ = ( + "TroutSlapCommandModel", + "TroutSlapCommand", +) + + +log = logging.getLogger(__name__) + + +class TroutSlapCommand(ReplyToOtherCommand): + def command(self) -> str: + return "slap" + + def name(self) -> str: + return "Slap" + + def help(self) -> str: + return "Slap someone with a wet fish. Syntax: /slap [/channel ]" + + def execute(self, command: BotCommand) -> Optional[Message]: + log.info(f"Trout command: {command}") + author = mention_user(command.source.id, command.backend) + target = [mention_user(user.id, command.backend) for user in command.targets] + if not target: + return + color = choice(COLORS) + fish = choice(FISH) if "random" in command.args else "trout" + modifier_one = choice(SHAKEPEREAN_MODIFIERS_ONE) + modifier_two = choice(SHAKEPEREAN_MODIFIERS_TWO) + noun = choice(SHAKESPEREAN_NOUNS) + message = ( + f'{author} slaps {" ".join(target)} with {a_or_an(color)} {color} {fish} while yelling "Thou {modifier_one}, {modifier_two} {noun}!"' + ) + return Message( + msg=message, + channel=command.channel, + backend=command.backend, + ) + + +class TroutSlapCommandModel(BaseCommandModel): + command: Type[BaseCommand] = TroutSlapCommand diff --git a/pyproject.toml b/pyproject.toml index 1473415..e0fc6d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,13 @@ readme = "README.md" license = { text = "Apache-2.0" } version = "0.1.0" requires-python = ">=3.9" -keywords = [] +keywords = [ + "csp", + "stream-processing", + "slack", + "chat", + "chatbot", +] classifiers = [ "Development Status :: 3 - Alpha", @@ -25,7 +31,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] -dependencies = [] +dependencies = [ + "csp-bot>=1,<2", +] [project.optional-dependencies] develop = [ @@ -39,6 +47,11 @@ develop = [ "twine", "uv", "wheel", + # Command-specific dependencies + "lxml", # mets + "pandas", # mets + "tabulate", # mets + "dateparser", # delaytest ] [project.scripts] @@ -115,4 +128,4 @@ known-first-party = ["csp_bot_commands"] section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] [tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401"] +"__init__.py" = ["F401", "F403"]