-
Notifications
You must be signed in to change notification settings - Fork 78
contributing the Mastodon toolkit #478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| files: ^.*/mastodon/.* | ||
| repos: | ||
| - repo: https://github.com/pre-commit/pre-commit-hooks | ||
| rev: "v4.4.0" | ||
| hooks: | ||
| - id: check-case-conflict | ||
| - id: check-merge-conflict | ||
| - id: check-toml | ||
| - id: check-yaml | ||
| - id: end-of-file-fixer | ||
| - id: trailing-whitespace | ||
|
|
||
| - repo: https://github.com/astral-sh/ruff-pre-commit | ||
| rev: v0.6.7 | ||
| hooks: | ||
| - id: ruff | ||
| args: [--fix] | ||
| - id: ruff-format |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| target-version = "py310" | ||
| line-length = 100 | ||
| fix = true | ||
|
|
||
| [lint] | ||
| select = [ | ||
| # flake8-2020 | ||
| "YTT", | ||
| # flake8-bandit | ||
| "S", | ||
| # flake8-bugbear | ||
| "B", | ||
| # flake8-builtins | ||
| "A", | ||
| # flake8-comprehensions | ||
| "C4", | ||
| # flake8-debugger | ||
| "T10", | ||
| # flake8-simplify | ||
| "SIM", | ||
| # isort | ||
| "I", | ||
| # mccabe | ||
| "C90", | ||
| # pycodestyle | ||
| "E", "W", | ||
| # pyflakes | ||
| "F", | ||
| # pygrep-hooks | ||
| "PGH", | ||
| # pyupgrade | ||
| "UP", | ||
| # ruff | ||
| "RUF", | ||
| # tryceratops | ||
| "TRY", | ||
| ] | ||
|
|
||
| [lint.per-file-ignores] | ||
| "**/tests/*" = ["S101"] | ||
|
|
||
| [format] | ||
| preview = true | ||
| skip-magic-trailing-comma = false |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| .PHONY: help | ||
|
|
||
| help: | ||
| @echo "🛠️ github Commands:\n" | ||
| @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' | ||
|
|
||
| .PHONY: install | ||
| install: ## Install the uv environment and install all packages with dependencies | ||
| @echo "🚀 Creating virtual environment and installing all packages using uv" | ||
| @uv sync --active --all-extras --no-sources | ||
| @if [ -f .pre-commit-config.yaml ]; then uv run --no-sources pre-commit install; fi | ||
| @echo "✅ All packages and dependencies installed via uv" | ||
|
|
||
| .PHONY: install-local | ||
| install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources | ||
| @echo "🚀 Creating virtual environment and installing all packages using uv" | ||
| @uv sync --active --all-extras | ||
| @if [ -f .pre-commit-config.yaml ]; then uv run pre-commit install; fi | ||
| @echo "✅ All packages and dependencies installed via uv" | ||
|
|
||
| .PHONY: build | ||
| build: clean-build ## Build wheel file using poetry | ||
| @echo "🚀 Creating wheel file" | ||
| uv build | ||
|
|
||
| .PHONY: clean-build | ||
| clean-build: ## clean build artifacts | ||
| @echo "🗑️ Cleaning dist directory" | ||
| rm -rf dist | ||
|
|
||
| .PHONY: test | ||
| test: ## Test the code with pytest | ||
| @echo "🚀 Testing code: Running pytest" | ||
| @uv run --no-sources pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml | ||
|
|
||
| .PHONY: coverage | ||
| coverage: ## Generate coverage report | ||
| @echo "coverage report" | ||
| @uv run --no-sources coverage report | ||
| @echo "Generating coverage report" | ||
| @uv run --no-sources coverage html | ||
|
|
||
| .PHONY: bump-version | ||
| bump-version: ## Bump the version in the pyproject.toml file by a patch version | ||
| @echo "🚀 Bumping version in pyproject.toml" | ||
| uv version --no-sources --bump patch | ||
|
|
||
| .PHONY: check | ||
| check: ## Run code quality tools. | ||
| @if [ -f .pre-commit-config.yaml ]; then\ | ||
| echo "🚀 Linting code: Running pre-commit";\ | ||
| uv run --no-sources pre-commit run -a;\ | ||
| fi | ||
| @echo "🚀 Static type checking: Running mypy" | ||
| @uv run --no-sources mypy --config-file=pyproject.toml |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <div style="display: flex; justify-content: center; align-items: center;"> | ||
| <img | ||
| src="https://docs.arcade.dev/images/logo/arcade-logo.png" | ||
| style="width: 250px;" | ||
| > | ||
| </div> | ||
|
|
||
| <div style="display: flex; justify-content: center; align-items: center; margin-bottom: 8px;"> | ||
| <img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="Python version" style="margin: 0 2px;"> | ||
| <img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License" style="margin: 0 2px;"> | ||
| <img src="https://img.shields.io/pypi/v/arcade_mastodon" alt="PyPI version" style="margin: 0 2px;"> | ||
| </div> | ||
|
|
||
|
|
||
| <br> | ||
| <br> | ||
|
|
||
| # Arcade mastodon Toolkit | ||
| Allow the agent to interact with a Mastodon server | ||
| ## Features | ||
|
|
||
| - The mastodon toolkit does not have any features yet. | ||
|
|
||
| ## Development | ||
|
|
||
| Read the docs on how to create a toolkit [here](https://docs.arcade.dev/home/build-tools/create-a-toolkit) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| from typing import Annotated | ||
|
|
||
| import httpx | ||
| from arcade_tdk import ToolContext, tool | ||
| from arcade_tdk.auth import OAuth2 | ||
| from arcade_tdk.errors import RetryableToolError, ToolExecutionError | ||
|
|
||
| from arcade_mastodon.tools.users import lookup_single_user_by_username | ||
| from arcade_mastodon.utils import get_headers, get_url, parse_status, parse_statuses | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["write:statuses"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def post_status( | ||
| context: ToolContext, | ||
| status: Annotated[str, "The status to post"], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion: might be worth mentioning that a 'status' is the text content of a user's post. Idk, worth a shot! Personally I thought status was 'online', 'offline', etc., so adding that context could be helpful. |
||
| ) -> Annotated[dict, "The status object from Mastodon"]: | ||
| """ | ||
| Post a status to Mastodon. | ||
| """ | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.post( | ||
| get_url(context=context, endpoint="statuses"), | ||
| headers=get_headers(context), | ||
| json={"status": status}, | ||
| ) | ||
| response.raise_for_status() | ||
| return parse_status(response.json()) | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["write:statuses"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def delete_status_by_id( | ||
| context: ToolContext, | ||
| status_id: Annotated[str, "The ID of the status to delete"], | ||
| ) -> Annotated[dict, "The status object from Mastodon that was deleted"]: | ||
| """ | ||
| Delete a Mastodon status by its ID. | ||
| """ | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.delete( | ||
| get_url(context=context, endpoint=f"statuses/{status_id}"), | ||
| headers=get_headers(context), | ||
| ) | ||
| response.raise_for_status() | ||
| return parse_status(response.json()) | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["read:statuses"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def lookup_status_by_id( | ||
| context: ToolContext, | ||
| status_id: Annotated[str, "The ID of the status to lookup"], | ||
| ) -> Annotated[dict, "The status object from Mastodon"]: | ||
| """ | ||
| Lookup a Mastodon status by its ID. | ||
| """ | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.get( | ||
| get_url(context=context, endpoint=f"statuses/{status_id}"), | ||
| headers=get_headers(context), | ||
| ) | ||
| response.raise_for_status() | ||
| return parse_status(response.json()) | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["read:statuses", "read:accounts"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def search_recent_statuses_by_username( | ||
| context: ToolContext, | ||
| username: Annotated[str, "The username of the Mastodon account to look up."], | ||
| limit: Annotated[ | ||
| int, "The maximum number of statuses to return. Default is 20, maximum is 40." | ||
| ] = 20, | ||
| ) -> Annotated[dict, "The statuses from Mastodon"]: | ||
| """ | ||
| Search for recent statuses by a username. | ||
| """ | ||
|
|
||
| account_info = await lookup_single_user_by_username(context, username) | ||
| if not account_info["account"]: | ||
| raise ToolExecutionError( | ||
| message=f"Account {username} not found.", | ||
| developer_message=f"Account {username} not found while searching for recent statuses.", | ||
| ) | ||
|
|
||
| account_id = account_info["account"]["id"] | ||
|
|
||
| limit = max(1, min(limit, 40)) | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.get( | ||
| get_url(context=context, endpoint=f"accounts/{account_id}/statuses"), | ||
| headers=get_headers(context), | ||
| params={"limit": limit}, | ||
| ) | ||
| response.raise_for_status() | ||
| return {"statuses": parse_statuses(response.json())} | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["read:statuses", "read:accounts"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def search_recent_statuses_by_keywords( | ||
| context: ToolContext, | ||
| keywords: Annotated[list[str] | None, "The keywords to search for."] = None, | ||
| phrases: Annotated[list[str] | None, "The phrases to search for."] = None, | ||
| limit: Annotated[ | ||
| int, "The maximum number of statuses to return. Default is 20, maximum is 40." | ||
| ] = 20, | ||
| ) -> Annotated[dict, "The statuses from Mastodon"]: | ||
| """ | ||
| Search for recent statuses by keywords and phrases. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this would be a good place to explain the relationship between keywords & phrases. Are they AND'd, OR'd? Does it match if only one keyword matches, or does it need all? Also, you mentioned that this toolkit used X as inspiration. X is one of our oldest toolkits, but I recall having the keywords/phrases was something I regretted because language models were really bad at understanding the difference between the two (I think the X toolkit might even be older than the eval framework!). It'd be super exciting to see if you're able to come up with a new & improved way to search for statuses |
||
| """ | ||
|
|
||
| if not any([keywords, phrases]): | ||
| raise RetryableToolError( | ||
| message="At least one keyword or one phrase must be provided to this tool.", | ||
| developer_message="The LLM did not provide any keywords or phrases to" | ||
| " search for recent statuses.", | ||
| additional_prompt_content="Please provide at least one keyword or one phrase" | ||
| " to search for recent statuses.", | ||
| retry_after_ms=500, | ||
| ) | ||
|
|
||
| query = "".join(f'"{phrase}"' for phrase in phrases or []) | ||
| if keywords: | ||
| query += " ".join(f"{keyword}" for keyword in keywords) | ||
|
|
||
| limit = max(1, min(limit, 40)) | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.get( | ||
| get_url( | ||
| context=context, | ||
| endpoint="search", | ||
| api_version="v2", | ||
| ), | ||
| headers=get_headers(context), | ||
| params={ | ||
| "q": query, | ||
| "limit": limit, | ||
| }, | ||
| ) | ||
| response.raise_for_status() | ||
| return {"statuses": parse_statuses(response.json())} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| from typing import Annotated | ||
|
|
||
| import httpx | ||
| from arcade_tdk import ToolContext, tool | ||
| from arcade_tdk.auth import OAuth2 | ||
|
|
||
| from arcade_mastodon.utils import get_headers, get_url | ||
|
|
||
|
|
||
| @tool( | ||
| requires_auth=OAuth2( | ||
| id="mastodon", | ||
| scopes=["read:accounts"], | ||
| ), | ||
| requires_secrets=["MASTODON_SERVER_URL"], | ||
| ) | ||
| async def lookup_single_user_by_username( | ||
| context: ToolContext, | ||
| username: Annotated[str, "The username of the Mastodon account to look up."], | ||
| ) -> Annotated[dict, "The account object from Mastodon"]: | ||
| """ | ||
| Lookup a single Mastodon account by its username. | ||
| """ | ||
|
|
||
| async with httpx.AsyncClient() as client_http: | ||
| response = await client_http.get( | ||
| get_url(context=context, endpoint=f"accounts/lookup?acct={username}"), | ||
| headers=get_headers(context), | ||
| ) | ||
| response.raise_for_status() | ||
| return {"account": response.json()} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can remove this file (also the PR you recently merged will make sure this won't be added to future toolkits, so thanks!)