diff --git a/tools/events-automation/.gitignore b/tools/events-automation/.gitignore new file mode 100644 index 000000000..2eea525d8 --- /dev/null +++ b/tools/events-automation/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/tools/events-automation/generate_events_meetup.py b/tools/events-automation/generate_events_meetup.py new file mode 100644 index 000000000..630a857bf --- /dev/null +++ b/tools/events-automation/generate_events_meetup.py @@ -0,0 +1,283 @@ +import requests +import datetime +import concurrent.futures +import pandas as pd + +from jwt_auth import generate_signed_jwt +from urllib.parse import urlsplit +from geopy.geocoders import Nominatim +from event import Event + +def authenticate(): + """ + Handles the OAuth 2.0 authentication process. + Returns obtaining access and refresh tokens from the Meetup API + """ + # API Configuration: + URL = "https://secure.meetup.com/oauth2/access" + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } + body = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": generate_signed_jwt() + } + + # Make a request for access and refresh tokens + response = requests.post(url=URL, headers=headers, data=body) + if response.status_code == 200: + access_token = response.json().get("access_token") + refresh_token = response.json().get("refresh_token") + return access_token, refresh_token + else: + print("Failed to obtain access token") + print("Status Code:", response.status_code) + print("Response:", response.text) + return None, None + +# Initialize variables for querying and formatting data: +ACCESS_TOKEN, REFRESH_TOKEN = authenticate() +# initialize Nominatim API +GEOLOCATOR = Nominatim(user_agent="TWiR") + +def fetch_groups(endCursor=""): + """ + Returns the response from the API call, which includes data on groups matching the criteria specified in the GraphQL query. + :type endCursor: An optional string parameter used for pagination, indicating the starting point of the query for fetching subsequent pages of results + :rtype: requests.Response + """ + + # API Configuration: + # Sets the API endpoint and constructs headers using an access token for authentication. + URL = "https://api.meetup.com/gql" + access_token, refresh_token = ACCESS_TOKEN, REFRESH_TOKEN + + if not access_token: + print("Authentication failed, cannot proceed to fetch events.") + return + + # Sets the content type to application/json for the request body. + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + # GraphQL Query: + # Below is a GraphQL query that requests information about groups such as ID, name, link, URL name, latitude, and longitude. + data = { + "query": """ + query ( + $searchGroupInput: ConnectionInput!, + $searchGroupFilter: SearchConnectionFilter!, + $sortOrder: KeywordSort! + ) { + keywordSearch( + input: $searchGroupInput, + filter: $searchGroupFilter, + sort: $sortOrder + ) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + result { + ... on Group { + id + name + link + urlname + latitude + longitude + } + } + } + } + } + } + """, + # The query filters results based on the keyword "Rust" and sorts them by relevance + "variables": { + "searchGroupFilter": { + "query": "Rust", + "lat": 0.0, + "lon": 0.0, + "radius": 20000, + "source": "GROUPS" + }, + "searchGroupInput": { + "first": 200, + "after": endCursor + }, + "sortOrder":{ + "sortField": "RELEVANCE" + } + } + } + return requests.post(url=URL, headers=headers, json=data) + +def get_rush_groups() -> dict: + """ + Returns a dictionary where each key represents the unique ID of a group, and the corresponding value is another dictionary containing details about the group such as name, link, URL name, latitude, and longitude + :rtype: dict + """ + endCursor = None + groups = dict() + while True: + response = fetch_groups(endCursor).json() + data = response['data'] + edges = data['keywordSearch']['edges'] + pageInfo = data['keywordSearch']['pageInfo'] + for node in edges: + group = node["node"]["result"] + if not (group["id"] in groups): + groups[group["id"]] = group + if pageInfo['hasNextPage']: + endCursor = pageInfo['endCursor'] + else: + break + return groups + +def get_known_rush_groups(fileName="rust_meetup_groups.csv") -> dict: + """ + Returns a dictionary represents all groups from a specified CSV file + :type fileName: Name or Path of the CSV file that contains the URLs and locations of the groups. + """ + + # Reads the CSV file, specifically extracting data from the 'url' and 'location' columns + groups = dict() # main dictionary that stores all information of different groups + df = pd.read_csv(fileName, header=0, usecols=['url', 'location']) + + # Extracting the url name of known Rust groups + # Format of extracting the URL: + # [source](https://stackoverflow.com/questions/35616434/how-can-i-get-the-base-of-a-url-in-python) + # https://www.meetup.com/seattle-rust-user-group/ + # split_url.scheme "http" + # split_url.netloc "www.meetup.com" + # split_url.path "/seattle-rust-user-group/" + for index, row in df.iterrows(): + group = {} + group["link"] = row["url"] + split_url = urlsplit(group["link"]) + group["urlname"] = (split_url.path).replace("/", "") + group["location"] = row["location"] + groups[index] = group + return groups + +def get_20_events(groups) -> list[Event]: + """ + Returns a list where each element is an instance of the Event class, representing event data from the Meetup API + :type groups: A dictionary of groups where each entry contains the group's URL name to make an API request + :rtype: dict + """ + events = [] # main list to store data about each fetched event. + + # API Configuration: + URL = "https://api.meetup.com/gql" + access_token, refresh_token = ACCESS_TOKEN, REFRESH_TOKEN + + if not access_token: + print("Authentication failed, cannot proceed to fetch events.") + return + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + # Constructs and sends a GraphQL query for each group to fetch up to 20 upcoming events from the Meetup API using the group's URL name + data = {} + for group in groups.values(): + urlName = group["urlname"] + data = { + "query": """ + query ($urlName: String!, $searchEventInput: ConnectionInput!) { + groupByUrlname(urlname: $urlName) { + upcomingEvents(input: $searchEventInput, sortOrder: ASC) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + title + dateTime + eventUrl + venue { + venueType + lat + lng + } + } + } + } + } + } + """, + "variables": { + "urlName": urlName, + "searchEventInput": { + "first": 20 + } + } + } + response = requests.post(url=URL, headers=headers, json=data) + data = response.json()["data"] + + # Constructs Event with attributes such as title, location, date, URL, and organizer details + if data: + searchGroupByUrlname = data["groupByUrlname"] + if searchGroupByUrlname: + edges = searchGroupByUrlname["upcomingEvents"]["edges"] + if edges: + for edge in edges: + node = edge["node"] + if node: + venue = node["venue"] + # TODO: Handle events don't have venue: + # 1. Flagging the events and they will have to be check manually, + # 2. Putting them in separate list to check + # (for now ignore those events) + if venue: + name = node["title"] + virtual = True + if venue["venueType"] != "online": + virtual = False + + # Convert obtained latitude and longitude of an event to formatted location + address = (GEOLOCATOR.reverse(str(venue["lat"]) +","+ str(venue["lng"]))).raw["address"] + location = format_location(address) + date = datetime.datetime.fromisoformat(node["dateTime"]).date() + url = node["eventUrl"] + organizerName = group.get("name", urlName) + organizerUrl = group["link"] + events.append(Event(name, location, date, url, virtual, organizerName, organizerUrl)) + return events + +def format_location(address) -> str: + """ + Helper method to format address of events with required components for a location + :rtype: string + """ + if not address: + return "No location" + + # All components for a location + components = ['road', 'city', 'state', 'postcode', 'country'] + + # Get available components, otherwise replace missing component with an empty string + location = [address.get(component, "") for component in components] + + + return ','.join(location) if location else "No location" + +def get_events() -> list[Event]: + """ + Returns a list of Event objects querying from known, and Meetup API Rust groups + :rtype: list[Event] + """ + # TODO: once the handling events without venue successful, get events_meetup_groups = get_20_events(get_rush_groups()) + events_known_groups = get_20_events(get_known_rush_groups()) + return events_known_groups \ No newline at end of file diff --git a/tools/events-automation/jwt_auth.py b/tools/events-automation/jwt_auth.py new file mode 100644 index 000000000..76542e15b --- /dev/null +++ b/tools/events-automation/jwt_auth.py @@ -0,0 +1,85 @@ +import jwt +import os +import datetime + +from dotenv import load_dotenv +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.backends import default_backend + +# Automate loading environment variables in Python script, make them accessible to the project +load_dotenv() + +def get_PEM_private_key(): + """ + Loads the PRIVATE_KEY in string from .env file. + Returns it in PEM-formatted bytes + """ + pem_bytes = (os.getenv('PRIVATE_KEY', "")).encode() + return pem_bytes + +def get_RSA_private_key(): + """ + Deserializes and sign the private key in PEM-formatted in bytes to an RSA private key object using cryptographic operations. + Returns the RSA private key object + """ + private_key = serialization.load_pem_private_key( + get_PEM_private_key(), password=None, backend=default_backend() + ) + return private_key + +def get_RSA_public_key(): + """ + Returns the corresponding RSA public key object from RSA private_key + """ + public_key = get_RSA_private_key().public_key() + return public_key + +def get_PEM_public_key(): + """ + Returns the public key in in PEM-formatted in bytes using RSA public key, to verify digital signatures + """ + pem_bytes = (get_RSA_public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + )).decode() + return pem_bytes + +# This function is essential for authorize step when calling Meetup API +def generate_signed_jwt(): + """ + Generates a JWT: + Encodes and signs the payload using RS256 and the private RSA key, forming a base64-url encoded header, payload, and signature. + Then returns it. + """ + AUTHORIZED_MEMBER_ID = os.getenv('AUTHORIZED_MEMBER_ID', "") # the member id that owns the OAuth Client + CLIENT_KEY = os.getenv('CLIENT_KEY', "") + private_key = get_RSA_private_key() + payload = { + "sub": AUTHORIZED_MEMBER_ID, + "iss": CLIENT_KEY, + "aud": "api.meetup.com", + "exp": (datetime.datetime.utcnow() + datetime.timedelta(hours=24)).timestamp() + } + return jwt.encode( + payload=payload, + key=private_key, + algorithm="RS256" + ) + +def decode_and_validate_jwt(): + """ + Checks/Validates the signed jwt. + Returns a decoded jwt payload/claim + """ + token = generate_signed_jwt() + pem_public_key = get_PEM_public_key() + try: + payload = jwt.decode( + token, + key=pem_public_key, + algorithms="RS256", + audience="api.meetup.com" + ) + return payload + except ExpiredSignatureError as error: + print(f'Unable to decode the token, error: {error}') \ No newline at end of file diff --git a/tools/events-automation/main.py b/tools/events-automation/main.py index c0674f6df..6f5c84295 100644 --- a/tools/events-automation/main.py +++ b/tools/events-automation/main.py @@ -6,12 +6,13 @@ from test_events import get_test_events from datetime import date, timedelta from country_code_to_continent import country_code_to_continent +from generate_events_meetup import get_events as get_meetup_events # TODO: Flagged events list handling. def main(): # Get Events list from Event Sources. - event_list = get_test_events() + event_list = get_meetup_events() # Format date and location data. format_data(event_list) diff --git a/tools/events-automation/meetup_events.md b/tools/events-automation/meetup_events.md new file mode 100644 index 000000000..0247c0a74 --- /dev/null +++ b/tools/events-automation/meetup_events.md @@ -0,0 +1,75 @@ +## Guide to Generating Events Using Meetup APIs + +### Introduction +This guide provides step-by-step instructions on how to use Meetup APIs to fetch event data related to Rust programming groups. It outlines the installation of necessary packages, setting up environment variables, and executing the script to obtain event data. + +### Prerequisites +Before you start, ensure you have the following: +- Python installed on your system (Python 3.8 or later recommended). +- `pip` for managing Python packages. +- Access to terminal or command line. + +### Tips for Managing Python Packages + +- **Virtual Environment**: Create a virtual environment using `venv` (built into Python 3) or `virtualenv`. Here's how to activate a virtual environment: + ```bash + # Creating a virtual environment (for Linux/macOS) + python3 -m venv myenv + # Activating the virtual environment (for Linux/macOS) + source myenv/bin/activate + + or + + # Creating a virtual environment (for Windows) + python -m venv myenv + # Activating the virtual environment (for Windows) + myenv\Scripts\activate + ``` +- **Upgrade `pip`**: Ensure your `pip` is up-to-date to avoid installation issues with newer packages: + ```bash + pip install --upgrade pip + ``` + +### Installation +1. **Open Terminal or Command Prompt**: + Navigate to the directory `.../tools/events-automation` where `requirements.txt` file is located. + +2. **Run the Installation Command**: + Execute the following command to install all the packages listed in `requirements.txt`: + ```bash + pip install -r requirements.txt + ``` + +3. **Set Up Environment Variables**: + - Create a `.env` file for project directory. + - Add the following environment variables with your actual values: + ``` + AUTHORIZED_MEMBER_ID= + CLIENT_KEY= + PRIVATE_KEY= + ``` + These values are used for authentication with the Meetup API and to generate JWT tokens securely. + +### Running the Script +To fetch events, run the following command from the project directory `.../tools/events-automation`: +```bash +python3 main.py +``` +This script performs the following operations: +- Authenticates with the Meetup API using JWT. +- Fetches data for known Rust groups from `.../tools/events-automation/rust_meetup_groups.csv` file and Meetup API. +- Filters and formats the event data into a standardized structure. +- Outputs the details of upcoming events. + +### Example Output Format +The script outputs event details in the following format: +``` +* 2025-05-08T19:00+02:00 | Virtual (,,Tuvalu) | [rust-noris](TODO: ORGANISER URL HERE) + *[**Rust Nürnberg online**](https://www.meetup.com/rust-noris/events/gmkpctyhchblb) +``` +This format includes the date and time of the event, the location (virtual in this case), and links to both the organizer's profile and the event itself. + +### Challenges and Considerations +- **Data Accuracy**: The script uses "Rust" as a keyword for searching groups and events. This can sometimes pull in events that merely mention Rust but aren't directly related to Rust programming in the event descriptions. +- **Event Data Completeness**: Not all events have complete venue information, especially for virtual events. The script currently ignores handling missing data, but the ideal is flagging such events for manual review or excluding them from the final output. +- **API Limitations**: The Meetup API has rate limits and other constraints that may affect how frequently you can fetch data. \ No newline at end of file diff --git a/tools/events-automation/requirements.txt b/tools/events-automation/requirements.txt index ac07547e8..cdfed0a83 100644 --- a/tools/events-automation/requirements.txt +++ b/tools/events-automation/requirements.txt @@ -1 +1,58 @@ -geopy +aiohttp==3.8.6 +aiosignal==1.3.1 +async-timeout==4.0.3 +attrs==23.1.0 +beautifulsoup4==4.12.3 +blinker==1.6.3 +cachetools==5.3.3 +certifi==2024.2.2 +cffi==1.16.0 +charset-normalizer==3.3.1 +click==8.1.7 +cryptography==42.0.5 +distlib==0.3.8 +filelock==3.13.1 +Flask==3.0.0 +Flask-Cors==4.0.0 +frozenlist==1.4.0 +geographiclib==2.0 +geopy==2.4.0 +google-api-core==2.18.0 +google-api-python-client==2.123.0 +google-auth==2.29.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==1.2.0 +googleapis-common-protos==1.63.0 +httplib2==0.22.0 +idna==3.4 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.3 +multidict==6.0.4 +numpy==1.26.0 +oauthlib==3.2.2 +openai==0.28.1 +pandas==2.1.1 +platformdirs==4.2.0 +proto-plus==1.23.0 +protobuf==4.25.3 +pyasn1==0.5.1 +pyasn1-modules==0.3.0 +pycparser==2.22 +PyJWT==2.8.0 +pyparsing==3.1.2 +python-dateutil==2.8.2 +python-dotenv==1.0.0 +pytz==2023.3.post1 +requests==2.31.0 +requests-oauthlib==1.4.0 +rsa==4.9 +six==1.16.0 +soupsieve==2.5 +tqdm==4.66.1 +tzdata==2023.3 +uritemplate==4.1.1 +urllib3==2.0.7 +virtualenv==20.25.1 +Werkzeug==3.0.0 +yarl==1.9.2 diff --git a/tools/events-automation/rust_meetup_groups.csv b/tools/events-automation/rust_meetup_groups.csv new file mode 100644 index 000000000..d6785b2a7 --- /dev/null +++ b/tools/events-automation/rust_meetup_groups.csv @@ -0,0 +1,118 @@ +name,url,location,non-meetup.com? +Rust London User Group,https://www.meetup.com/rust-london-user-group/,"London, 17, United Kingdom",no. +Rust NYC,https://www.meetup.com/rust-nyc/,"New York, NY, USA",no. +Rust Bay Area,https://www.meetup.com/Rust-Bay-Area/,"San Francisco, CA, USA",no. +Rust India,https://www.meetup.com/rustox/,"Bangalore, India",no. +Rust Berlin,https://www.meetup.com/rust-berlin/,"Berlin, Germany",no. +Seattle Rust User Group,https://www.meetup.com/seattle-rust-user-group/,"Seattle, WA, USA",no. +Rust Moscow,https://www.meetup.com/Rust-%D0%B2-%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B5/,"Moscow, Russia",no. +Rust Paris,https://www.meetup.com/Rust-Paris/,"Paris, France",no. +Rust Nederland,https://www.meetup.com/rust-nederland/,"Amsterdam, Netherlands",no. +Rust Munich,https://www.meetup.com/rust-munich/,"München, Germany",no. +Vancouver Rust,https://www.meetup.com/vancouver-rust/,"Vancouver, BC, Canada",no. +Rust Chennai,https://www.meetup.com/mad-rs/,"Chennai, India",no. +Rust Linz,https://www.meetup.com/de-DE/rust-linz/,"Linz, Austria",no. +Boston Rust Meetup,https://www.meetup.com/bostonrust/,"Boston, MA, USA",no. +Rust São Paulo Meetup,https://www.meetup.com/Rust-Sao-Paulo-Meetup/,"São Paulo, Brazil",no. +Rust Language Hyderabad,https://www.meetup.com/Rust-Hyderabad/,"Hyderabad, India",no. +Rust Dublin,https://www.meetup.com/rust-dublin/,"Dublin, Ireland",no. +Rust Zurich,https://www.meetup.com/rust-zurich/,"Zürich, Switzerland",no. +Rust Melbourne,https://www.meetup.com/Rust-Melbourne/,"Melbourne, Australia",no. +Rust Argentina,https://www.meetup.com/rust-argentina/,"Buenos Aires, Argentina",no. +Rust TLV,https://www.meetup.com/rust-tlv/,"Tel Aviv-Yafo, Israel",no. +Rust and C++ Cardiff,https://www.meetup.com/rust-and-c-plus-plus-in-cardiff/,"Cardiff, X5, United Kingdom",no. +Rust MX,https://www.meetup.com/Rust-MX/,"México City, Mexico",no. +Rust Denver,https://www.meetup.com/Rust-Boulder-Denver/,"Denver, CO, USA",no. +Rust Delhi,https://www.meetup.com/rustdelhi/,"Delhi, India",no. +Rust DC,https://www.meetup.com/RustDC/,"Washington, DC, USA",no. +Rust Sydney,https://www.meetup.com/Rust-Sydney/,"Sydney, Australia",no. +Rust Oslo,https://www.meetup.com/Rust-Oslo/,"Oslo, Norway",no. +Dallas Rust User Meetup,https://www.meetup.com/dallasrust/,"Dallas, TX, USA",no. +Rust Cologne,https://www.meetup.com/rustcologne/,"Köln, Germany",no. +Stockholm Rust,https://www.meetup.com/stockholm-rust/,"Espoo, Finland",no. +PDXRust,https://www.meetup.com/PDXRust/,"Portland, OR, USA",no. +Tokyo Rust Meetup,https://www.meetup.com/tokyo-rust-meetup/,"Tokyo, Japan",no. +Rust Sthlm,https://www.meetup.com/ruststhlm/,"Stockholm, Sweden",no. +BcnRust,https://www.meetup.com/bcnrust/,"Barcelona, Spain",no. +Rust Toronto,https://www.meetup.com/Rust-Toronto/,"Toronto, ON, Canada",no. +Rust Language Milano,https://www.meetup.com/Rust-lang-Milano/,"Milano, MI, Italy",no. +Rust Meetup Hamburg,https://www.meetup.com/Rust-Meetup-Hamburg/,"Hamburg, Germany",no. +Rust AKL,https://www.meetup.com/rust-akl/,"Auckland, New Zealand",no. +Columbus Rust Society,https://www.meetup.com/columbus-rs/,"Columbus, OH, USA",no. +Finland Rust-lang Group,https://www.meetup.com/Finland-Rust-Meetup/,"Helsinki, Finland",no. +Rust ATX,https://www.meetup.com/rust-atx/,"Austin, TX, USA",no. +Rust Warsaw,https://www.meetup.com/Rust-Warsaw/,"Warsaw, Poland",no. +Rust Nuremberg,https://www.meetup.com/rust-noris/,"Nürnberg, Germany",no. +Utah Rust,https://www.meetup.com/utah-rust/,"Lehi, UT, USA",no. +Rust Los Angeles,https://www.meetup.com/Rust-Los-Angeles/,"Los Angeles, CA, USA",no. +Copenhagen Rust Community,https://www.meetup.com/copenhagen-rust-community/,"Copenhagen, Denmark",no. +MadRust,https://www.meetup.com/MadRust/,"Madrid, Spain",no. +Rust Vienna,https://www.meetup.com/rust-vienna/,"Vienna, Austria",no. +San Diego Rust,https://www.meetup.com/San-Diego-Rust/,"San Diego, CA, USA",no. +Minneapolis Rust Meetup,https://www.meetup.com/minneapolis-rust-meetup/,"Minneapolis, MN, USA",no. +Rust-Saar,https://www.meetup.com/Rust-Saar/,"Saarbrücken, Germany",no. +San Francisco Rust Study Group,https://www.meetup.com/san-francisco-rust-study-group/,"San Francisco, CA, USA",no. +Charlottesville Rust Meetup,https://www.meetup.com/charlottesville-rust-meetup/,"Charlottesville, VA, USA",no. +Chicago Rust Meetup,https://www.meetup.com/Chicago-Rust-Meetup/,"Chicago, IL, USA",no. +Rust Brisbane,https://www.meetup.com/Rust-Brisbane/,"Brisbane, Australia",no. +Buffalo Rust Meetup,https://www.meetup.com/buffalo-rust-meetup/,"Buffalo, NY, USA",no. +Rust Roma,https://www.meetup.com/it-IT/Rust-Roma/,"Roma, RM, Italia",no. +Rust Meetup Uruguay,https://www.meetup.com/Rust-Uruguay/,"Montevideo, Uruguay",no. +Indy Rust,https://www.meetup.com/indyrs/,"Indianapolis, IN, USA",no. +Cambridge Rust meetup,https://www.meetup.com/Cambridge-Rust-Meetup/,"Cambridge, C3, United Kingdom",no. +Rust Atlanta,https://www.meetup.com/Rust-ATL/,"Atlanta, GA, USA",no. +Montpellier Rust Meetup,https://www.meetup.com/Montpellier-Rust-Meetup/,"Montpellier, France",no. +Rust KW,https://www.meetup.com/Rust-KW/,"Kitchener, ON, Canada",no. +Rust Wellington,https://www.meetup.com/Wellington-Rust-Meetup/,"Wellington, New Zealand",no. +Desert Rust,https://www.meetup.com/Desert-Rustaceans/,"Phoenix, AZ, USA",no. +Rust Prague,https://www.meetup.com/rust-prague/,"Prague, Czech Republic",no. +Rust Aarhus,https://www.meetup.com/rust-aarhus/,"Aarhus, Denmark",no. +Rust Nigeria,https://www.meetup.com/rust-meetup-group/,"Lagos, Nigeria",no. +Rust Lille,https://www.meetup.com/meetup-group-zgphbyet/,"Lille, France",no. +Rust Montréal,https://www.meetup.com/Rust-Montreal/,"Montréal, QC, Canada",no. +Rust Vilnius,https://www.meetup.com/Rust-in-Vilnius/,"Vilnius, Lithuania",no. +Rust Medellin,https://www.meetup.com/rust-medellin/,"Medellin, Colombia",no. +Rust Rhein-Main,https://www.meetup.com/rust-rhein-main/,"Darmstadt, Germany",no. +Belgium Rust user group,https://www.meetup.com/belgium-rust-user-group/,"Brussels, Belgium",no. +Rust Basel,https://www.meetup.com/rust-basel/,"Basel, Switzerland",no. +impl Zagreb for Rust,https://www.meetup.com/zagreb-rust-meetup/,"Zagreb, Croatia",no. +Cap Hill Rust Coding/Hacking/Learning,https://www.meetup.com/cap-hill-rust/,"Seattle, WA, USA",no. +Oxford Rust Meetup Group,https://www.meetup.com/oxford-rust-meetup-group/,"Oxford, K2, United Kingdom",no. +Mountain View Rust Meetup,https://www.meetup.com/mv-rust-meetup/,"Mountain View, CA, USA",no. +Rust Franken,https://www.meetup.com/rust-nerf/,"Erlangen, Germany",no. +Rust Bern,https://www.meetup.com/rust-bern/,"Bern, Switzerland",no. +Rust Hack & Learn Karlsruhe,https://www.meetup.com/Rust-Hack-Learn-Karlsruhe/,"Karlsruhe, Germany",no. +Deep Dish Rust,https://www.meetup.com/deep-dish-rust/,"Chicago, IL, USA",no. +Vilnius Rust and Go Meetup Group,https://www.meetup.com/vilnius-rust-go-meetup-group/,"Vilnius, Lithuania",no. +Rust Czech Republic,https://www.meetup.com/rust-czech-republic/,"Prague, Czech Republic",no. +Ottawa Rust Language Meetup,https://www.meetup.com/Ottawa-Rust-Language-Meetup/,"Ottawa, ON, Canada",no. +Rust Belfast Meetup,https://www.meetup.com/Rust-Belfast-Meetup/,"Belfast, R3, United Kingdom",no. +Pasadena Thursday Go / Rust,https://www.meetup.com/thursday-go/,"Pasadena, CA, USA",no. +Hack-away with Rust,https://www.meetup.com/hack-away-with-rust/,"Espoo, Finland",no. +Rust Colombia,https://www.meetup.com/rust-colombia/,"Medellín, Colombia",no. +Rust Gdansk,https://www.meetup.com/rust-gdansk/,"Gdansk, Poland",no. +RustSchool Rotterdam,https://www.meetup.com/RustSchool-Rotterdam/,"Rotterdam, Netherlands",no. +Rust Rio,https://www.meetup.com/Rust-Rio/,"Rio de Janeiro, Brazil",no. +Rust Lang Comunidad Mexico,https://www.meetup.com/rustlangmx/,"Guadalajara, Mexico",no. +Rust - Modern Systems Programming in Leipzig,https://www.meetup.com/rust-modern-systems-programming-in-leipzig/,"Leipzig, Germany",no. +Rust Perth Meetup Group,https://www.meetup.com/perth-rust-meetup-group/,"Perth, Australia",no. +Rust Trondheim,https://www.meetup.com/rust-trondheim/,"Trondheim, Norway",no. +Rust Tbilisi,https://www.meetup.com/tbilisi-rustaceans/,"Tbilisi, Georgia",no. +Rust Halifax,https://www.meetup.com/rust-tell-halifax/,"Halifax, NS, Canada",no. +Rust Seoul,https://www.meetup.com/Rust-Seoul/,"Seoul, South Korea",no. +Canberra Rust User Group,https://www.meetup.com/rust-canberra/,"Canberra, Australia",no. +Budapest Rust Meetup Group,https://www.meetup.com/budapest-rust-meetup-group/,"Budapest, Hungary",no. +Paessler Rust Camp 2024,https://www.meetup.com/paessler-rust-camp-2024/,"Nürnbert, Germany",no. +Rust Taiwan Community,https://github.com/rust-tw/meetup,"Taipei, Taiwan",x +Rust Bordeaux,https://www.meetup.com/bordeaux-rust/,"Bordeaux, France",no. +Rust Girona,https://www.meetup.com/rust-girona/,"Girona, Spain",no. +Rust Circle Kampala,https://www.eventbrite.com/o/rust-circle-kampala-65249289033,Online,x +Rust Würzburg Meetup Group,https://www.meetup.com/rust-wurzburg-meetup-group/,"Würzburg, Germany",no. +Spokane,https://www.meetup.com/spokane-rust/,"Spokane, WA, USA",no. +Calgary Rust,https://www.eventbrite.ca/o/rust-calgary-63449860593,"Calgary, AB, Canada",x +Rust Indonesia,https://github.com/rustid/meetup,Indonesia,no. +Chengdu,https://github.com/RPG-Alex/rust-chengdu,"Chengdu, China",no. +Rust Lyon,https://www.meetup.com/fr-FR/rust-lyon/,"Lyon, France",no. +Rust Meetup Augsburg,https://www.meetup.com/de-DE/rust-meetup-augsburg/,"Augsburg, Germany",no. + Bratislava Rust Meetup Group,https://www.meetup.com/bratislava-rust-meetup-group/,"Bratislava, Slovakia",no. +Music City Rust Developers,https://www.meetup.com/music-city-rust-developers/,"Nashville, TN, USA",no. \ No newline at end of file