Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions toolkits/mastodon/.pre-commit-config.yaml
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
44 changes: 44 additions & 0 deletions toolkits/mastodon/.ruff.toml
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
55 changes: 55 additions & 0 deletions toolkits/mastodon/Makefile
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
26 changes: 26 additions & 0 deletions toolkits/mastodon/README.md
Copy link
Member

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!)

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)
Empty file.
Empty file.
173 changes: 173 additions & 0 deletions toolkits/mastodon/arcade_mastodon/tools/statuses.py
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"],
Copy link
Member

Choose a reason for hiding this comment

The 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.
Copy link
Member

@EricGustin EricGustin Jul 9, 2025

Choose a reason for hiding this comment

The 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())}
31 changes: 31 additions & 0 deletions toolkits/mastodon/arcade_mastodon/tools/users.py
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()}
Loading