From ac5b9f3955229edf7cc38bbccffb094c18527d57 Mon Sep 17 00:00:00 2001 From: Victor Westerlund Date: Thu, 14 Nov 2024 10:02:03 +0000 Subject: [PATCH] Add random user generator (#1) This PR adds scripts for generating random users, and generating random relationships between these users. This PR also refactors config file loading and writing into a class. Reviewed-on: https://codeberg.org/vlw/misskey-microblogger/pulls/1 Co-authored-by: Victor Westerlund Co-committed-by: Victor Westerlund --- .gitignore | 2 +- data/users_template.json | 23 +++--- generate.py | 46 +++++++++++ run.py | 18 ++--- src/Config.py | 28 +++++++ src/Dictionary/DictionaryParser.py | 12 +-- src/Enums.py | 8 +- src/Generate/GenerateRelationships.py | 101 ++++++++++++++++++++++++ src/Generate/GenerateUser.py | 107 ++++++++++++++++++++++++++ src/Generate/__init__.py | 0 src/Misskey.py | 12 ++- src/Poster.py | 8 +- src/User/User.py | 10 +++ 13 files changed, 328 insertions(+), 47 deletions(-) create mode 100644 generate.py create mode 100644 src/Config.py create mode 100644 src/Generate/GenerateRelationships.py create mode 100644 src/Generate/GenerateUser.py create mode 100644 src/Generate/__init__.py diff --git a/.gitignore b/.gitignore index cd7c677..2d8254b 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Config data/users -config.json +.config.json # Bootstrapping __pycache__ \ No newline at end of file diff --git a/data/users_template.json b/data/users_template.json index 9965c0c..6170fd9 100644 --- a/data/users_template.json +++ b/data/users_template.json @@ -1,24 +1,13 @@ { "key": "API_KEY", "online": { - "intervals": [ - { - "start": { - "from": 0.00, - "to": 0.00 - }, - "end": { - "from": 24.00, - "to": 24.00 - } - } - ] + "intervals": [] }, "actions": { "posts": { "public": { "percent": 0, - "cooldown": 86400 + "cooldown": 0 }, "specified": { "percent": { @@ -27,7 +16,7 @@ "neutral": 0, "enemies": 0 }, - "cooldown": 86400 + "cooldown": 0 } }, "replies": { @@ -85,6 +74,12 @@ } } }, + "note": { + "text_length": { + "flux": 0, + "average": 0 + } + }, "relationships": { "friends": [], "enemies": [], diff --git a/generate.py b/generate.py new file mode 100644 index 0000000..2d02343 --- /dev/null +++ b/generate.py @@ -0,0 +1,46 @@ +import sys +import json +import typing +from os import system + +from src.Config import Config +from src.User.User import User +from src.Generate.GenerateUser import GenerateUser +from src.Generate.GenerateRelationships import GenerateRelationships + +DEFAULT_GENERATE_USER_COUNT = 10 + +# Generate a user +def generate_user() -> User: + while True: + user = GenerateUser() + print(f"1. Create username: {user.username}") + # We need the human's help here since I haven't found a way to get API keys from Misskey automatically + user.set_api_key(input("2. Paste API key:")) + + yield user.autorun() + +def main(): + config = Config() + users = [] + + # Generate n amount of users + for i in (range(int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_GENERATE_USER_COUNT)): + system("clear") + print(f"Generating user: {i + 1}") + users.append(next(generate_user())) + i += 1 + + # Create random relationships for generated users + print("Generating random user relationships") + GenerateRelationships(users).autorun() + + print("Activating users") + # Add generated users to active users in config + for user in users: + config.add_active_user(user.username) + + config.save_config() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/run.py b/run.py index 3356b4a..d7580ff 100644 --- a/run.py +++ b/run.py @@ -2,26 +2,18 @@ import typing import random import json +from src.Config import Config from src.Poster import Poster -from src.User.User import User -from src.Misskey import Misskey def main(): - with open("config.json", "r") as f: - config = json.load(f) + config = Config() # Don't do ANYTHING this time if the roll against the global activity percentage failed - if (random.randint(0, 100) >= config["global"]["activity"]): + if (not config.get_global_activity_roll()): return False - for username in config["active_users"]: - user = User(username) - - # Don't do anything for this user if they're not active right now - if (not user.is_online()): - continue - - Poster(user.username, Misskey(config["server"]["url"], user.config["key"])).autorun() + for username in config.get_active_users(): + Poster(username).autorun() if __name__ == "__main__": main() diff --git a/src/Config.py b/src/Config.py new file mode 100644 index 0000000..299aa4a --- /dev/null +++ b/src/Config.py @@ -0,0 +1,28 @@ +import json +import random +import typing +from pathlib import Path + +CONFIG_FILEPATH = Path.cwd() / ".config.json" + +class Config(): + def __init__(self): + with open(CONFIG_FILEPATH, "r") as f: + self.config = json.load(f) + + # Overwrite modified config + def save_config(self) -> bool: + with open(CONFIG_FILEPATH, "w") as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + + def add_active_user(self, username: str) -> None: + self.config["active_users"].append(username) + + def get_global_activity_roll(self) -> bool: + return random.randint(0, 100) < self.config["global"]["activity"] + + def get_misskey_server(self) -> str: + return self.config["server"]["url"] + + def get_active_users(self) -> list: + return self.config["active_users"] \ No newline at end of file diff --git a/src/Dictionary/DictionaryParser.py b/src/Dictionary/DictionaryParser.py index e16cf69..3cc7701 100644 --- a/src/Dictionary/DictionaryParser.py +++ b/src/Dictionary/DictionaryParser.py @@ -16,25 +16,25 @@ class DictionaryParser(Dictionary): tokens = [token.lower() for token in note["text"].split(" ")] return set(tokens) - def get_tone(self) -> NoteTones: + def get_tone(self) -> NoteTones | None: words = self.get_words(Dictionaries.CONTROL)["tone"] if (self.tokens & set(words[NoteTones.INFORMAL.value])): return NoteTones.INFORMAL elif (self.tokens & set(words[NoteTones.FORMAL.value])): return NoteTones.FORMAL - return NoteTones.UNKNOWN + return None - def get_mood(self) -> NoteMoods: + def get_mood(self) -> NoteMoods | None: words = self.get_words(Dictionaries.CONTROL)["mood"] if (self.tokens & set(words[NoteMoods.FUNNY.value])): return NoteMoods.FUNNY elif (self.tokens & set(words[NoteMoods.DECENT.value])): return NoteMoods.DECENT elif (self.tokens & set(words[NoteMoods.ANNOYED.value])): return NoteMoods.ANNOYED - return NoteMoods.UNKNOWN + return None - def get_type(self) -> NoteTypes: + def get_type(self) -> NoteTypes | None: words = self.get_words(Dictionaries.CONTROL)["type"] if (self.tokens & set(words[NoteTypes.QUESTION.value])): return NoteTypes.QUESTION elif (self.tokens & set(words[NoteTypes.EXAGGERATED.value])): return NoteTypes.EXAGGERATED elif (self.tokens & set(words[NoteTypes.STATEMENT.value])): return NoteTypes.STATEMENT - return NoteTypes.UNKNOWN \ No newline at end of file + return None \ No newline at end of file diff --git a/src/Enums.py b/src/Enums.py index 9df50e9..deee485 100644 --- a/src/Enums.py +++ b/src/Enums.py @@ -1,8 +1,5 @@ from enum import Enum -class Traits(Enum): - UNKNOWN = None - class Errors(Enum): WORD_LIST_TOO_LONG = 0 WORD_LIST_HAS_DUPLICATES = 1 @@ -43,16 +40,13 @@ class ControlWords(Enum): class NoteTones(Enum): FORMAL = "formal" INFORMAL = "informal" - UNKNOWN = Traits.UNKNOWN.value class NoteMoods(Enum): FUNNY = "funny" DECENT = "decent" ANNOYED = "annoyed" - UNKNOWN = Traits.UNKNOWN.value class NoteTypes(Enum): QUESTION = "question" EXAGGERATED = "exaggerated" - STATEMENT = "statement" - UNKNOWN = Traits.UNKNOWN.value \ No newline at end of file + STATEMENT = "statement" \ No newline at end of file diff --git a/src/Generate/GenerateRelationships.py b/src/Generate/GenerateRelationships.py new file mode 100644 index 0000000..b930e6d --- /dev/null +++ b/src/Generate/GenerateRelationships.py @@ -0,0 +1,101 @@ +import typing +import random + +from ..Misskey import Misskey +from ..Enums import RelationshipType +from ..User.User import USER_CONFIG_DIR, User + +CHANCE_FRIEND = 70 +CHANCE_PARTNER = 30 + +MIN_RELATIONSHIPS = 3 +MAX_RELATIONSHIPS = 7 + +RECURSE_LIMIT = 10 + +class GenerateRelationships(): + def __init__(self, users: list = None): + self.users = users + + self.partners = [] + + # Compute diff between a provided target list of users against list of all users + def users_diff(self, target: list) -> list: + return list(set(self.users) - set(target)) + + def pick_random_user_from_list(self, target: list, ignore: User) -> User | None: + available = self.users_diff(target) + available.remove(ignore) + + return random.choice(available) if available else None + + # Pick a random partner from available partners + def set_random_partner(self, user: User, i: int = 0) -> None: + # Pick a random user + partner = self.pick_random_user_from_list(self.partners, user) + + # Give up trying to find a partner if none found or we've reached the recurse depth limit + if (not partner or i >= RECURSE_LIMIT): + return None + + # Don't partner up with this user if they're already a friend or enemy + if (user.get_relationship_with_user(partner.username) != RelationshipType.NEUTRAL): + return self.set_random_partner(user, i + 1) + + # Set partner's usernames in each other's configs + user.config["relationships"][RelationshipType.PARTNER.value] = partner.username + partner.config["relationships"][RelationshipType.PARTNER.value] = user.username + + # Mark users as partners + self.partners.extend((user, partner)) + + # Set a another random user as friend or foe + def set_random_user_relationship(self, user: User, i: int = 0) -> None: + # Roll if ranomd user should be a friend or enemy + relationship = RelationshipType.FRIEND if random.randint(0, 100) < CHANCE_FRIEND else RelationshipType.ENEMY + + # Get available friends (not already a friend, enemy, or self) + target = self.pick_random_user_from_list(user.get_friends() + user.get_enemies(), user) + + # Give up trying to find a friend if none found or if we've reached the recurse depth limit + if (not target or i >= RECURSE_LIMIT): + return None + + # Try again if target user is not neutral with user + if (user.get_relationship_with_user(target.username) != RelationshipType.NEUTRAL): + return self.set_random_user_relationship(user, i + 1) + + # Set relationship on both users + user.config["relationships"][relationship.value].append(target.username) + target.config["relationships"][relationship.value].append(user.username) + + return None + + # Save all modified user configs and follow users with new relationships + def save_all(self) -> None: + for user in self.users: + # Save modified config + user.save_config() + + # Create a Misskey instance for user + mk = Misskey(user.get_api_key()) + + # Place partner string in a list if set + partner = [user.get_partner()] if user.get_partner() else [] + + # Follow all users on Misskey we've added relationships for. It's required for the home timeline + for username in user.get_friends() + user.get_enemies() + partner: + mk.follow_user(username) + + def autorun(self) -> None: + for user in self.users: + # Find a random partner for user if they don't have one already and if roll is in bounds + if (random.randint(0, 100) < CHANCE_PARTNER and not user.get_partner()): + self.set_random_partner(user) + + # Set random relationships for user + for i in range(random.randint(MIN_RELATIONSHIPS, MAX_RELATIONSHIPS)): + self.set_random_user_relationship(user) + + return self.save_all() + diff --git a/src/Generate/GenerateUser.py b/src/Generate/GenerateUser.py new file mode 100644 index 0000000..dca56a4 --- /dev/null +++ b/src/Generate/GenerateUser.py @@ -0,0 +1,107 @@ +import math +import json +import typing +import random +from pathlib import Path + +from ..User.User import USER_CONFIG_DIR, User +from ..Enums import RelationshipType, NoteTones, NoteMoods, NoteTypes + +from misskey.enum import NoteVisibility +from random_username.generate import generate_username + +# Use this file as a template for generated users +USER_TEMPLATE_FILE = Path.cwd() / "data" / "users_template.json" + +# Minimum and maximum cooldown time for posting new notes +CONFIG_POST_MIN_COOLDOWN = 300 # 10 minutes +CONFIG_POST_MAX_COOLDOWN = 259200 # 48 hours + +# Available preferred reactions +REACTIONS = [ + "❤", + "👍", + "😆" +] + +class GenerateUser(): + def __init__(self): + self.username = self.gen_username() + + # Load user template file + with open(USER_TEMPLATE_FILE, "r") as f: + self.config = json.load(f) + + # Return a random unique username + @staticmethod + def gen_username() -> str: + username = generate_username(1)[0] + return username if not (USER_CONFIG_DIR / f"{username}.json").exists() else GenerateUser.gen_username() + + # Generate a random time interval + @staticmethod + def gen_interval() -> dict: + def to_time(h: int) -> float: + return float(f"{h}.{random.randint(0, 59)}") + + start_hour_from = random.randint(0, 22) + start_hour_end = start_hour_from + 1 + + end_hour_from = random.randint(min(start_hour_end + 1, 22), 23) + end_hour_to = min(end_hour_from + 1, 23) + + return { + "start": { + "from": to_time(start_hour_from), + "to": to_time(start_hour_end) + }, + "end": { + "from": to_time(end_hour_from), + "to": to_time(end_hour_to) + } + } + + def set_api_key(self, key: str) -> None: + self.config["key"] = key + + def save_config(self) -> bool: + USER_CONFIG_DIR.mkdir(exist_ok=True) + + with open(USER_CONFIG_DIR / f"{self.username}.json", "w") as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + + def autorun(self) -> User: + # Generate a random online interval + self.config["online"]["intervals"].append(self.gen_interval()) + + # Set a random percent for user to post new notes + self.config["actions"]["posts"]["public"]["percent"] = random.randint(0, 100) + self.config["actions"]["posts"]["public"]["cooldown"] = random.randint(CONFIG_POST_MIN_COOLDOWN, CONFIG_POST_MAX_COOLDOWN) + + # Set random reply chance for each relationship + for visiblity in [NoteVisibility.PUBLIC, NoteVisibility.SPECIFIED]: + for relationship in RelationshipType: + self.config["actions"]["replies"][visiblity.value]["percent"][relationship.value] = random.randint(0, 100) + + # Set random react chances for each relationship + for relationship in RelationshipType: + self.config["actions"]["reacts"]["percent"][relationship.value] = random.randint(0, 100) + + # Set preferred reactions for each relationship + for relationship in RelationshipType: + self.config["actions"]["reacts"]["prefrerred_reaction"][relationship.value] = random.choice(REACTIONS) + + # Set random personality tone + for x in NoteTones: + self.config["personality"]["tone"]["probability"][x.value] = random.randint(0, 100) + + # Set random personality mood + for x in NoteMoods: + self.config["personality"]["mood"]["probability"][x.value] = random.randint(0, 100) + + # Set random personality type + for x in NoteTypes: + self.config["personality"]["type"]["probability"][x.value] = random.randint(0, 100) + + self.save_config() + return User(self.username) \ No newline at end of file diff --git a/src/Generate/__init__.py b/src/Generate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Misskey.py b/src/Misskey.py index 9ad626c..ae3038c 100644 --- a/src/Misskey.py +++ b/src/Misskey.py @@ -1,6 +1,8 @@ import typing from datetime import datetime, timedelta +from .Config import Config + from misskey import Misskey as lib_Misskey from misskey.enum import NotificationsType, NoteVisibility @@ -12,8 +14,8 @@ TIMELINE_FETCH_LIMIT = 5 TIMELINE_THROTTLE_SECONDS = 300 class Misskey(lib_Misskey): - def __init__(self, server: str, key: str): - super().__init__(server, i=key) + def __init__(self, key: str): + super().__init__(Config().get_misskey_server(), i=key) # Reverse lists returned by Misskey.py because for some stupid reason they're ordered oldest-to-newest @staticmethod @@ -81,4 +83,10 @@ class Misskey(lib_Misskey): return self.notes_reactions_create( note_id=note["id"], reaction=reaction + ) + + # Send a follow request to username + def follow_user(self, username: str) -> dict: + return self.following_create( + user_id=self.resolve_user_id(username) ) \ No newline at end of file diff --git a/src/Poster.py b/src/Poster.py index 98641b0..808a1b7 100644 --- a/src/Poster.py +++ b/src/Poster.py @@ -2,17 +2,17 @@ import typing from datetime import datetime from .Note import Note -from .Enums import Intent from .Misskey import Misskey from .User.UserIntent import UserIntent from misskey.enum import NoteVisibility class Poster(): - def __init__(self, username: str, mk: Misskey): - self.mk = mk - self.note = Note(username) + def __init__(self, username: str): self.user = UserIntent(username) + self.note = Note(username) + + self.mk = Misskey(self.user.get_api_key()) def note_is_older_than_cooldown(self, note: dict) -> bool: date_now = datetime.now() diff --git a/src/User/User.py b/src/User/User.py index b9db22e..83e282f 100644 --- a/src/User/User.py +++ b/src/User/User.py @@ -19,6 +19,13 @@ class User(): with open(USER_CONFIG_DIR / f"{self.username}.json", "r") as f: self.config = json.load(f) + # Overwrite current config to user config file + def save_config(self) -> None: + USER_CONFIG_DIR.mkdir(exist_ok=True) + + with open(USER_CONFIG_DIR / f"{self.username}.json", "w") as f: + json.dump(self.config, f, indent=4, ensure_ascii=False) + # Check if the user is currently online given their time intervals def is_online(self) -> bool: # Find the first time interval that is within the current time @@ -38,6 +45,9 @@ class User(): # Current time was not in range of any configured intervals return False + def get_api_key(self) -> str: + return self.config["key"] + def get_post_cooldown(self, visibility: NoteVisibility = NoteVisibility.PUBLIC) -> int: return self.config["actions"][Intent.POST.value][visibility.value]["cooldown"]