├── .gitignore ├── choochoobot.png ├── testaccttweet.py ├── choochoopost.py ├── README.md ├── LICENSE └── choochoogen.py /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | authtwit.py 3 | __pycache__/* 4 | *~ 5 | -------------------------------------------------------------------------------- /choochoobot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thisisparker/choochoobot/HEAD/choochoobot.png -------------------------------------------------------------------------------- /testaccttweet.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import yaml, os 4 | import choochoogen 5 | from twython import Twython 6 | 7 | fullpath = os.path.dirname(os.path.realpath(__file__)) 8 | 9 | config = yaml.load(open(fullpath + "/config.yaml")) 10 | 11 | twitter_app_key = config['TEST_twitter_app_key'] 12 | twitter_app_secret = config['TEST_twitter_app_secret'] 13 | twitter_oauth_token = config['TEST_twitter_oauth_token'] 14 | twitter_oauth_token_secret = config['TEST_twitter_oauth_token_secret'] 15 | 16 | twitter = Twython(twitter_app_key, twitter_app_secret, twitter_oauth_token, twitter_oauth_token_secret) 17 | 18 | tweet = choochoogen.maketrain() 19 | 20 | twitter.update_status(status=tweet) 21 | -------------------------------------------------------------------------------- /choochoopost.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import yaml, os 4 | import choochoogen 5 | import time 6 | import random 7 | import sys 8 | from mastodon import Mastodon 9 | from atproto import Client 10 | 11 | if '-n' not in sys.argv: 12 | start_delay = random.randint(0,3600) 13 | time.sleep(start_delay) 14 | 15 | fullpath = os.path.dirname(os.path.realpath(__file__)) 16 | 17 | config = yaml.safe_load(open(fullpath + "/config.yaml")) 18 | 19 | mc = Mastodon(access_token=config['mastodon_token'], 20 | api_base_url=config['mastodon_url']) 21 | 22 | bsky = Client() 23 | bsky.login(config['bsky_username'], config['bsky_password']) 24 | 25 | post = choochoogen.maketrain() 26 | 27 | mc.toot(post) 28 | bsky.send_post(post) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Choochoobot 2 | 3 | These scripts power the social media choochoobot, the trains-botting friend we all know and love, on [Bluesky](https://bsky.app/profile/choochoo.xor.blue) and [Mastodon](https://tech.intersects.art/@choochoo/). Trains are created with real-time weather and moon phase support, using [observations from a weather station in Central Park](https://parkerhiggins.net/2017/09/pulling-free-and-open-weather-data-in-python/) and the Python library [Astral](https://astral.readthedocs.io/en/latest/), respectively. 4 | 5 | ![A sample tweet from the @choochoobot account](https://github.com/thisisparker/choochoobot/blob/main/choochoobot.png) 6 | 7 | This code is released under a free software license and you are welcome to fork it and generate trains to your heart's content. I'm well aware that it's a goofy little thing, but because this is a personal creative project I will probabl not accept pull requests or issues about anything other than its technical operation. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Parker Higgins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /choochoogen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import random 4 | from datetime import datetime, timedelta 5 | import pytz 6 | import astral 7 | import xml.etree.ElementTree as ET 8 | import requests 9 | 10 | from astral import moon as amoon 11 | from astral.sun import sun 12 | 13 | ENGINES = ["🚂"] 14 | CARS = ["🚃"] 15 | 16 | SUN = "☀" 17 | MOONS = ["🌑","🌒","🌔","🌕","🌖","🌘"] 18 | DESERT_TILES = ["🌵","🌵","🌴","🌴","🐪","🐢","🐎"] 19 | FOREST_TILES = ["🌲","🌲","🌲","🌲","🐇","🌳","🌳"] 20 | BEACH_TILES = ["🌴","🌴","🍍","🐢","🗿","🐚"] 21 | FIELD_TILES = ["🌾","🌾","🌾","🌻","🐍","🐈"] 22 | WILDFLOWERS_TILES = ["🌼","🌺","🏵️","🌷","🌷","🐝","🦋"] 23 | SEA_TILES =["🐬","🐳","🐙"] 24 | 25 | HELL_TILES = ["🔥","👻","😈","💀"] 26 | HEAVEN_TILES = ["📯👼","✨","🐕","👼"] 27 | SPACE_TILES = ["👾","👽","💫","🚀","🛰"] 28 | UNDERSEA_TILES = ["🐟","🐙","🐬","🐋"] 29 | 30 | class Scene(): 31 | def __init__(self, mode, height = 4, item_rarity = 10, top_border = None, bottom_border = None): 32 | self.mode = mode 33 | self.height = height 34 | self.item_rarity = item_rarity 35 | 36 | self.top_border = top_border 37 | self.bottom_border = bottom_border 38 | 39 | self.sky = "" 40 | self.landscape = [] 41 | 42 | self.train = self.pick_engine() + self.pick_body() 43 | 44 | def pick_engine(self): 45 | leading_spaces = random.randint(0,9) 46 | self.engine = "" 47 | for _ in range(leading_spaces): 48 | self.engine += " " 49 | self.engine += random.choice(ENGINES) 50 | return self.engine 51 | 52 | def pick_body(self): 53 | self.body = "" 54 | cars = random.randint(3,8) 55 | for _ in range(cars): 56 | self.body += random.choice(CARS) 57 | return self.body 58 | 59 | def add_clouds(self): 60 | self.sky = self.sky.replace(SUN, "⛅ ") 61 | for _ in range(len(self.sky) - 1): 62 | if self.sky[_] == "\u2800" and random.randint(1,5) == 1: 63 | self.sky = self.sky[:_] + "☁️" + self.sky[_ + 1:] 64 | 65 | def get_weather(self): 66 | cloud_terms = ["Mostly Cloudy", "Mostly Cloudy with Haze", "Mostly Cloudy and Breezy", "A Few Clouds", "A Few Clouds with Haze", "A Few Clouds and Breezy", "Partly Cloudy", "Partly Cloudy with Haze", "Partly Cloudy and Breezy", "Overcast", "Overcast with Haze", "Overcast and Breezy", "Fog/Mist", "Fog", "Freezing Fog", "Shallow Fog", "Partial Fog", "Patches of Fog", "Fog in Vicinity", "Freezing Fog in Vicinity", "Shallow Fog in Vicinity", "Partial Fog in Vicinity", "Patches of Fog in Vicinity", "Showers in Vicinity Fog", "Light Freezing Fog", "Heavy Freezing Fog"] 67 | rain_terms = ["Rain Showers", "Light Rain Showers", "Light Rain and Breezy", "Heavy Rain Showers", "Rain Showers in Vicinity", "Light Showers Rain", "Heavy Showers Rain", "Showers Rain", "Showers Rain in Vicinity", "Rain Showers Fog/Mist", "Light Rain Showers Fog/Mist", "Heavy Rain Showers Fog/Mist", "Rain Showers in Vicinity Fog/Mist", "Light Showers Rain Fog/Mist", "Heavy Showers Rain Fog/Mist", "Showers Rain Fog/Mist", "Showers Rain in Vicinity Fog/Mist", "Light Rain", "Drizzle", "Light Drizzle", "Heavy Drizzle", "Light Rain Fog/Mist", "Drizzle Fog/Mist", "Light Drizzle Fog/Mist", "Heavy Drizzle Fog/Mist", "Light Rain Fog", "Drizzle Fog", "Light Drizzle Fog", "Heavy Drizzle Fog Rain", "Heavy Rain", "Rain Fog/Mist", "Heavy Rain Fog/Mist", "Rain Fog", "Heavy Rain Fog"] 68 | 69 | try: 70 | res = requests.get("https://forecast.weather.gov/xml/current_obs/KNYC.xml") 71 | xml_tree = ET.fromstring(res.text) 72 | weather = xml_tree.find('weather').text.strip() 73 | 74 | if weather in cloud_terms: 75 | self.add_clouds() 76 | return self.sky 77 | elif weather in rain_terms: 78 | self.sky = self.fill_row(tileset = ["🌧️","🌧️","☁️"], item_rarity = 5) 79 | return self.sky 80 | elif "Thunderstorm" in weather: 81 | self.sky = self.fill_row(tileset = ["🌧️","⛈️","⛈️"], item_rarity = 5) 82 | return self.sky 83 | elif "Snow" in weather: 84 | self.sky = self.fill_row(tileset = ["🌨️","❄️"], item_rarity = 5) 85 | return self.sky 86 | else: 87 | return None 88 | except: 89 | return None 90 | 91 | def make_daysky(self): 92 | day_length = self.sun['sunset'] - self.sun['sunrise'] 93 | day_so_far = self.dt - self.sun['sunrise'] 94 | 95 | sun_placement = 14 - int((day_so_far.seconds/day_length.seconds) * 15) 96 | 97 | for _ in range(15): 98 | if _ == sun_placement: 99 | self.sky += SUN + "\uFE0F" 100 | self.sky += "\u2800" 101 | 102 | def make_nightsky(self): 103 | tomorrow = self.dt + timedelta(days = 1) 104 | yesterday = self.dt - timedelta(days = 1) 105 | 106 | if self.dt > self.sun['sunset']: 107 | moon_phase = int(amoon.phase(self.dt.date())) 108 | night_length = (sun(self.loc.observer, tomorrow)['sunrise'] 109 | - sun(self.loc.observer)['sunset']) 110 | night_so_far = self.dt - self.sun['sunset'] 111 | elif self.dt < self.sun['sunrise']: 112 | moon_phase = int(amoon.phase(yesterday.date())) 113 | night_length = (self.sun['sunrise'] 114 | - sun(self.loc.observer, yesterday)['sunset']) 115 | night_so_far = self.dt - sun(self.loc.observer, yesterday)['sunset'] 116 | 117 | if moon_phase == 0: 118 | moon = MOONS[0] 119 | elif moon_phase <= 7: 120 | moon = MOONS[1] 121 | elif moon_phase < 14: 122 | moon = MOONS[2] 123 | elif moon_phase == 14: 124 | moon = MOONS[3] 125 | elif moon_phase <= 21: 126 | moon = MOONS[4] 127 | else: 128 | moon = MOONS[5] 129 | 130 | moon_placement = 14 - int((night_so_far.seconds/night_length.seconds) * 15) 131 | 132 | for _ in range(moon_placement): 133 | self.sky += "\u2800" 134 | self.sky += moon + "\uFE0F" 135 | 136 | def make_sky(self): 137 | self.sky = "" 138 | 139 | self.dt = pytz.timezone('America/New_York').localize(datetime.now()) 140 | 141 | self.loc = astral.LocationInfo(name='New York', 142 | region='USA', 143 | timezone='US/Eastern', 144 | latitude=40.71666666666667, 145 | longitude=-74.0) 146 | 147 | self.sun = sun(self.loc.observer, 148 | date=self.dt, 149 | tzinfo=self.loc.timezone) 150 | 151 | if self.dt >= self.sun["sunrise"] and self.dt <= self.sun["sunset"]: 152 | self.make_daysky() 153 | self.get_weather() 154 | else: 155 | self.make_nightsky() 156 | 157 | return self.sky 158 | 159 | def make_sea(self): 160 | return self.fill_row(tileset = SEA_TILES, space_char = "🌊", length = 12) 161 | 162 | def fill_row(self, tileset = None, item_rarity = None, space_char = " ", length = 20): 163 | row = "" 164 | 165 | if not tileset: 166 | tileset = self.tileset 167 | 168 | if not item_rarity: 169 | item_rarity = self.item_rarity 170 | 171 | for spot in range(length): 172 | tile = random.randint(1, item_rarity) 173 | if tile == 1: 174 | row += random.choice(tileset) 175 | else: 176 | row += space_char 177 | return row 178 | 179 | def generate(self): 180 | self.landscape = [] 181 | 182 | if self.top_border: 183 | self.landscape.append(self.top_border) 184 | else: 185 | self.make_sky() 186 | self.landscape.append(self.fill_row()) 187 | 188 | self.landscape.extend([self.fill_row(), self.fill_row()]) 189 | 190 | if self.bottom_border: 191 | self.landscape.append(self.bottom_border) 192 | else: 193 | self.landscape.append(self.fill_row()) 194 | 195 | tweet = "" 196 | if self.sky: 197 | tweet += self.sky + "\n" 198 | 199 | tweet += self.landscape[0] + "\n" + \ 200 | self.landscape[1] + "\n" + \ 201 | self.train + "\n" + \ 202 | self.landscape[2] + "\n" + \ 203 | self.landscape[3] 204 | 205 | return tweet 206 | 207 | class Desert(Scene): 208 | def __init__(self): 209 | super(Desert, self).__init__("desert") 210 | self.tileset = DESERT_TILES 211 | 212 | class Forest(Scene): 213 | def __init__(self): 214 | super(Forest, self).__init__("forest") 215 | self.tileset = FOREST_TILES 216 | 217 | class Field(Scene): 218 | def __init__(self): 219 | super(Field, self).__init__("field") 220 | self.tileset = FIELD_TILES 221 | 222 | class Wildflowers(Scene): 223 | def __init__(self): 224 | super(Wildflowers, self).__init__("wildflowers") 225 | self.tileset = WILDFLOWERS_TILES 226 | 227 | class Beach(Scene): 228 | def __init__(self): 229 | super(Beach, self).__init__("beach") 230 | self.tileset = BEACH_TILES 231 | self.bottom_border = self.make_sea() 232 | 233 | class Space(Scene): 234 | def __init__(self): 235 | super(Space, self).__init__("space") 236 | self.top_border = "⭐🌟⭐🌟⭐🌟⭐🌟⭐🌟⭐🌟" 237 | self.bottom_border = "⭐🌟⭐🌟⭐🌟⭐🌟⭐🌟⭐🌟" 238 | self.tileset = SPACE_TILES 239 | 240 | class Hell(Scene): 241 | def __init__(self): 242 | super(Hell, self).__init__("hell") 243 | self.top_border = "🔥👹🔥👹🔥👹🔥👹🔥👹🔥👹" 244 | self.bottom_border = "🔥👹🔥👹🔥👹🔥👹🔥👹🔥👹" 245 | self.tileset = HELL_TILES 246 | 247 | class Heaven(Scene): 248 | def __init__(self): 249 | super(Heaven, self).__init__("heaven") 250 | self.top_border = "☁👼☁👼☁👼☁👼☁👼☁👼" 251 | self.bottom_border = "☁👼☁👼☁👼☁👼☁👼☁👼" 252 | self.tileset = HEAVEN_TILES 253 | 254 | class Undersea(Scene): 255 | def __init__(self): 256 | super(Undersea, self).__init__("undersea") 257 | self.top_border = "🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊" 258 | self.bottom_border = "🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊🌊" 259 | self.tileset = UNDERSEA_TILES 260 | 261 | def maketrain(): 262 | standard_scenes = [Desert, Beach, Forest, Field, Wildflowers] 263 | special_scenes = [Space, Undersea, Heaven, Hell] 264 | 265 | if random.randint(1,20) == 20: 266 | scene = random.choice(special_scenes)() 267 | else: 268 | scene = random.choice(standard_scenes)() 269 | 270 | return scene.generate() 271 | 272 | if __name__ == "__main__": 273 | maketrain() 274 | --------------------------------------------------------------------------------