
Luminα
@campanula@qdon.space
내년에 한국에서 마츠다 세이코 라이브하는구나
진짜 일본 아티스트들 내한 공연은 시대정신인건가
@hongminhee@hollo.social · 954 following · 1270 followers
An intersectionalist, feminist, and socialist guy living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in #TypeScript, #Haskell, #Rust, & #Python. They/them.
서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. #TypeScript, #Haskell, #Rust, #Python 等으로 自由 소프트웨어 만듦.
Website | GitHub | Blog | Hackers' Pub |
---|---|---|---|
@campanula@qdon.space
내년에 한국에서 마츠다 세이코 라이브하는구나
진짜 일본 아티스트들 내한 공연은 시대정신인건가
@cocoa@hackers.pub
This tutorial will guide you through building a simple ActivityPub bot using Python. The bot will listen for mentions and, when it receives a message in a specific format, it will schedule and send a reminder back to the user after a specified delay.
For example, if a user mentions the bot with a message like "@reminder@your.host.com 10m check the oven
", the bot will reply 10 minutes later with a message like "🔔 Reminder for @user: check the oven
".
To follow this tutorial, you will need Python 3.10+ and the following libraries:
server
extra, which includes FastAPI-based components.You can install these dependencies using uv
or pip
.
# Initialize a new project with uv
uv init
# Install dependencies
uv add "apkit[server]" uvicorn cryptography
The project structure is minimal, consisting of a single Python file for our bot's logic.
.
├── main.py
└── private_key.pem
main.py
: Contains all the code for the bot.private_key.pem
: The private key for the bot's Actor. This will be generated automatically on the first run.Our application logic can be broken down into the following steps:
apkit
ActivityPub server./actor
, /inbox
, etc.).Let's dive into each section of the main.py
file.
First, we import the necessary modules and define the basic configuration for our bot.
# main.py
import asyncio
import logging
import re
import uuid
import os
from datetime import timedelta, datetime
# Imports from FastAPI, cryptography, and apkit
from fastapi import Request, Response
from fastapi.responses import JSONResponse
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization as crypto_serialization
from apkit.config import AppConfig
from apkit.server import ActivityPubServer
from apkit.server.types import Context, ActorKey
from apkit.server.responses import ActivityResponse
from apkit.models import (
Actor, Application, CryptographicKey, Follow, Create, Note, Mention, Actor as APKitActor, OrderedCollection,
)
from apkit.client import WebfingerResource, WebfingerResult, WebfingerLink
from apkit.client.asyncio.client import ActivityPubClient
# --- Logging Setup ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- Basic Configuration ---
HOST = "your.host.com" # Replace with your domain
USER_ID = "reminder" # The bot's username
Make sure to replace your.host.com
with the actual domain where your bot will be hosted. These values determine your bot's unique identifier (e.g., @reminder@your.host.com
).
ActivityPub uses HTTP Signatures to secure communication between servers. This requires each actor to have a public/private key pair. The following code generates a private key and saves it to a file if one doesn't already exist.
# main.py (continued)
# --- Key Persistence ---
KEY_FILE = "private_key.pem"
# Load the private key if it exists, otherwise generate a new one
if os.path.exists(KEY_FILE):
logger.info(f"Loading existing private key from {KEY_FILE}.")
with open(KEY_FILE, "rb") as f:
private_key = crypto_serialization.load_pem_private_key(f.read(), password=None)
else:
logger.info(f"No key file found. Generating new private key and saving to {KEY_FILE}.")
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with open(KEY_FILE, "wb") as f:
f.write(private_key.private_bytes(
encoding=crypto_serialization.Encoding.PEM,
format=crypto_serialization.PrivateFormat.PKCS8,
encryption_algorithm=crypto_serialization.NoEncryption()
))
# Generate the public key from the private key
public_key_pem = private_key.public_key().public_bytes(
encoding=crypto_serialization.Encoding.PEM,
format=crypto_serialization.PublicFormat.SubjectPublicKeyInfo
).decode('utf-8')
Next, we define the bot's Actor. The Actor is the bot's identity in the ActivityPub network. We use the Application
type, as this entity is automated.
# main.py (continued)
# --- Actor Definition ---
actor = Application(
id=f"https://{HOST}/actor",
name="Reminder Bot",
preferredUsername=USER_ID,
summary="A bot that sends you reminders. Mention me like: @reminder 5m Check the oven",
inbox=f"https://{HOST}/inbox", # Endpoint for receiving activities
outbox=f"https://{HOST}/outbox", # Endpoint for sending activities
publicKey=CryptographicKey(
id=f"https://{HOST}/actor#main-key",
owner=f"https://{HOST}/actor",
publicKeyPem=public_key_pem
)
)
We initialize the ActivityPubServer
from apkit
, providing it with a function to retrieve our Actor's keys for signing outgoing activities.
# main.py (continued)
# --- Key Retrieval Function ---
async def get_keys_for_actor(identifier: str) -> list[ActorKey]:
"""Returns the key for a given Actor ID."""
if identifier == actor.id:
return [ActorKey(key_id=actor.publicKey.id, private_key=private_key)]
return []
# --- Server Initialization ---
app = ActivityPubServer(apkit_config=AppConfig(
actor_keys=get_keys_for_actor # Register the key retrieval function
))
To serve created activities, we need to store them somewhere. For simplicity, this example uses a basic in-memory dictionary as a store and a cache. In a production application, you would replace this with a persistent database (like SQLite or PostgreSQL) and a proper cache (like Redis).
# main.py (continued)
# --- In-memory Store and Cache ---
ACTIVITY_STORE = {} # A simple dict to store created activities
CACHE = {} # A cache for recently accessed activities
CACHE_TTL = timedelta(minutes=5) # Cache expiration time (5 minutes)
This is the core logic of our bot. The parse_reminder
function uses a regular expression to extract the delay and message from a mention, and send_reminder
schedules the notification.
# main.py (continued)
# --- Reminder Parsing Logic ---
def parse_reminder(text: str) -> tuple[timedelta | None, str | None, str | None]:
"""Parses reminder text like '5m do something'."""
# ... (implementation omitted for brevity)
# --- Reminder Sending Function ---
async def send_reminder(ctx: Context, delay: timedelta, message: str, target_actor: APKitActor, original_note: Note):
"""Waits for a specified delay and then sends a reminder."""
logger.info(f"Scheduling reminder for {target_actor.id} in {delay}: '{message}'")
await asyncio.sleep(delay.total_seconds()) # Asynchronously wait
logger.info(f"Sending reminder to {target_actor.id}")
# Create the reminder Note
reminder_note = Note(...)
# Wrap it in a Create activity
reminder_create = Create(...)
# Store the created activities
ACTIVITY_STORE[reminder_note.id] = reminder_note
ACTIVITY_STORE[reminder_create.id] = reminder_create
# Send the activity to the target actor's inbox
keys = await get_keys_for_actor(f"https://{HOST}/actor")
await ctx.send(keys, target_actor, reminder_create)
logger.info(f"Reminder sent to {target_actor.id}")
We define the required ActivityPub endpoints. Since apkit
is built on FastAPI, we can use standard FastAPI decorators. The main endpoints are:
@user@host
. This is a crucial first step for federation.apkit
handles this route automatically, directing activities to the handlers we'll define in the next step.Here is the code for defining these endpoints:
# main.py (continued)
# The inbox endpoint is handled by apkit automatically.
app.inbox("/inbox")
@app.webfinger()
async def webfinger_endpoint(request: Request, acct: WebfingerResource) -> Response:
"""Handles Webfinger requests to make the bot discoverable."""
if not acct.url:
# Handle resource queries like acct:user@host
if acct.username == USER_ID and acct.host == HOST:
link = WebfingerLink(rel="self", type="application/activity+json", href=actor.id)
wf_result = WebfingerResult(subject=acct, links=[link])
return JSONResponse(wf_result.to_json(), media_type="application/jrd+json")
else:
# Handle resource queries using a URL
if acct.url == f"https://{HOST}/actor":
link = WebfingerLink(rel="self", type="application/activity+json", href=actor.id)
wf_result = WebfingerResult(subject=acct, links=[link])
return JSONResponse(wf_result.to_json(), media_type="application/jrd+json")
return JSONResponse({"message": "Not Found"}, status_code=404)
@app.get("/actor")
async def get_actor_endpoint():
"""Serves the bot's Actor object."""
return ActivityResponse(actor)
@app.get("/outbox")
async def get_outbox_endpoint():
"""Serves a collection of the bot's sent activities."""
items = sorted(ACTIVITY_STORE.values(), key=lambda x: x.id, reverse=True)
outbox_collection = OrderedCollection(
id=actor.outbox,
totalItems=len(items),
orderedItems=items
)
return ActivityResponse(outbox_collection)
@app.get("/notes/{note_id}")
async def get_note_endpoint(note_id: uuid.UUID):
"""Serves a specific Note object, with caching."""
note_uri = f"https://{HOST}/notes/{note_id}"
# Check cache first
if note_uri in CACHE and (datetime.now() - CACHE[note_uri]["timestamp"]) < CACHE_TTL:
return ActivityResponse(CACHE[note_uri]["activity"])
# If not in cache, get from store
if note_uri in ACTIVITY_STORE:
activity = ACTIVITY_STORE[note_uri]
# Add to cache before returning
CACHE[note_uri] = {"activity": activity, "timestamp": datetime.now()}
return ActivityResponse(activity)
return Response(status_code=404) # Not Found
@app.get("/creates/{create_id}")
async def get_create_endpoint(create_id: uuid.UUID):
"""Serves a specific Create activity, with caching."""
create_uri = f"https://{HOST}/creates/{create_id}"
if create_uri in CACHE and (datetime.now() - CACHE[create_uri]["timestamp"]) < CACHE_TTL:
return ActivityResponse(CACHE[create_uri]["activity"])
if create_uri in ACTIVITY_STORE:
activity = ACTIVITY_STORE[create_uri]
CACHE[create_uri] = {"activity": activity, "timestamp": datetime.now()}
return ActivityResponse(activity)
return Response(status_code=404)
We use the @app.on()
decorator to define handlers for specific activity types posted to our inbox.
Follow
requests.Create
activities (specifically for Note
objects) to schedule reminders.# main.py (continued)
# Handler for Follow activities
@app.on(Follow)
async def on_follow_activity(ctx: Context):
"""Automatically accepts follow requests."""
# ... (implementation omitted for brevity)
# Handler for Create activities
@app.on(Create)
async def on_create_activity(ctx: Context):
"""Parses mentions to schedule reminders."""
activity = ctx.activity
# Ignore if it's not a Note
if not (isinstance(activity, Create) and isinstance(activity.object, Note)):
return Response(status_code=202)
note = activity.object
# Check if the bot was mentioned
is_mentioned = any(
isinstance(tag, Mention) and tag.href == actor.id for tag in (note.tag or [])
)
if not is_mentioned:
return Response(status_code=202)
# ... (Parse reminder text)
delay, message, time_str = parse_reminder(command_text)
# If parsing is successful, schedule the reminder as a background task
if delay and message and sender_actor:
asyncio.create_task(send_reminder(ctx, delay, message, sender_actor, note))
reply_content = f"<p>✅ OK! I will remind you in {time_str}.</p>"
else:
# If parsing fails, send usage instructions
reply_content = "<p>🤔 Sorry, I didn\'t understand. Please use the format: `@reminder [time] [message]`.</p><p>Example: `@reminder 10m Check the oven`</p>"
# ... (Create and send the reply Note)
Finally, we run the application using uvicorn
.
# main.py (continued)
if __name__ == "__main__":
import uvicorn
logger.info("Starting uvicorn server...")
uvicorn.run(app, host="0.0.0.0", port=8000)
Set the HOST
and USER_ID
variables in main.py
to match your environment.
Run the server from your terminal:
uvicorn main:app --host 0.0.0.0 --port 8000
Your bot will be running at http://0.0.0.0:8000
.
Now you can mention your bot from anywhere in the Fediverse (e.g., @reminder@your.host.com
) to set a reminder.
This tutorial covers the basics of creating a simple ActivityPub bot. Since it only uses in-memory storage, all reminders will be lost on server restart. Here are some potential improvements:
ACTIVITY_STORE
with a database like SQLite or PostgreSQL.We hope this guide serves as a good starting point for building your own ActivityPub applications!
https://fedi-libs.github.io/apkit/
@me@social.perlmint.app · Reply to perlmint's post
여행 정산 하는 중에 만든 정산 시트 템플릿 대충 정산할 항목 환율이 여럿인 경우와 건별 결제자, 균등 분할 금액, 개별 금액 처리등을 커버합니다.
@yijuckhangwe@qdon.space
마쓰시타 류이치 저자의 『동아시아반일무장전선』
추천
@hannal@hackers.pub
이미지로만 보던 그 유명한 파도 그림을 보고 왔습니다. 실제로 보니 질감과 세밀함이 정말 감동스럽더라고요. 작품 교체를 하는데, 후지산이나 파도 그림은 전반기에 전시하는 것 같아요. 전반기는 11월 2일까지.
"국립 "청주" 박물관에서 전시합니나. "충주" 아니에요. 이상 충주에 가서 밥 먹고 청주에 가서 관람한 1인이었슴미다.
@kodingwarrior@hackers.pub
@z9mb1 @catamorphic @msy @joonnot @d01c2 @icecream_mable @minju2996 @2chanhaeng @lionhairdino @akastoot @jihyeok @linea @woaol @theeluwin @rangho @robin @1ho @nebuleto @moreal @harei106 @realfishbread @jcha0713 @hyunjoon @krepe90 @cetaceanightsky @aioo @ink_punctuation @yg1ee
오늘 Hackers' Public 행사 첫번째 모임에 와주셔서 정말 감사합니다! 정말 즐거웠습니다! 잘 들어가세요!
RE: https://hackers.pub/@kodingwarrior/0198e9db-763b-7135-aa89-1808e9e99227
@kodingwarrior@hackers.pub
서울에서 열리는 Hackers' Pub 오프라인 밋업, "Hackers' Public @ Seoul"이 2025월 9월 14일(일) 처음으로 개최됩니다. 처음 열리는 밋업인 만큼, 참여하는 많은 분들이 재밌게 느낄 수 있는 소재 위주로 연사자 분들을 섭외했습니다.
강연이 끝나고 난 뒤에 자유롭게 네트워킹하는 시간을 가질 예정입니다. 각자 얘기하고 싶은 주제를 들고 오시면 좋습니다.
오프라인 밋업은 여기서 참여신청이 가능합니다. https://event-us.kr/hackerspubseoul/event/110961
@hongminhee@hackers.pub
오늘은 Hackers' Public 첫 번째 모임! 다들 튜링의 사과(@TuringAppleDev)에서 오후 3시에 모여요!
@yichm@g0v.social
滅人器
@hongminhee@hollo.social
By the way, if anyone wants to create an #ActivityPub #bot server, there's also @botkit as an option.
@evan@cosocial.ca · Reply to Evan Prodromou's post
The update was hard; the jsonld.js library was choking on the FEP's context document because Codeberg Pages, where FEP context documents are hosted, is unreliable.
I ended up adding a feature to the `activitystrea.ms` NodeJS library to let you pre-cache a context document, so it didn't have to go fetch it at run time. While I was under the hood, I also fixed a couple of bugs that made it hard to work with extra context documents.
The bot server is now working; I'm going to add more contexts.
@evan@cosocial.ca
I updated the #ActivityPubBot server software to use FEP-5711, the inverse properties FEP. It is a way to declare two-way relations; if X has an `inbox` property with value Y, then Y has an `inboxOf` property with value X. It helps with navigation when you're using the ActivityPub API, and it also helps with spoofing (since both objects agree on the relationship).
@hongminhee@hackers.pub
@robin@hackers.pub
밋업때 뭔가 보여줘야 할 거 같아서 열심히 달리는중
@robin@hackers.pub
밋업때 뭔가 보여줘야 할 거 같아서 열심히 달리는중
@fedify@hollo.social
We've just published an experimental pre-release version 1.9.0-pr.431.1597 that adds CommonJS support to all npm packages in the Fedify ecosystem! 🧪
This experimental build addresses one of the most requested features—better compatibility with CommonJS-based Node.js applications, especially NestJS projects. The pre-release eliminates the need for Node.js's --experimental-require-module
flag and resolves dual package hazard issues.
Note: While we now support CommonJS for legacy project compatibility, we still recommend using ESM or migrating to Deno for the best experience with Fedify.
Install the experimental pre-release version:
npm install @fedify/fedify@1.9.0-pr.431.1597
# or for specific integrations
npm install @fedify/nestjs@1.9.0-pr.431.1597
You should now be able to use standard require()
syntax without any experimental flags.
--experimental-require-module
flag?Your feedback on this experimental build is invaluable for ensuring this major compatibility improvement works smoothly before the official 1.9.0 release!
@erincandescent@erincandescent.net
Whoa, as of 10 days ago we have functions to go between a Uint8Array
and a Base64 Normal/URL Padded/Unpadded string in JavaScript without having to go via a String of “bytes” and manually juggle the padding in every major browser.
What a time to be alive.
@2chanhaeng@hackers.pub
페디파이 빡시게 기여해서 2.0 뜨면 블로그 만들어서 블스 떠야지
@robin@hackers.pub
무슨 연합우주 스캐너 같은 게 있나...?
@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee)'s post
久しぶりにブログに記事を書きました。「オープンソース開発の近況」
@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee)'s post
오랜만에 블로그에 글을 썼습니다: 〈오픈 소스 開發 近況〉.
@hongminhee@hollo.social
I wrote a post on my blog after a long time: Recent open source development updates.
@trwnh@mastodon.social · Reply to Emelia 👸🏻's post
@thisismissem @mariusor @oranadoz @hongminhee +1, an image and a descriptor are different things and should be treated as different things. content negotiation is not a solution here -- the same information should be returned for the same resource (modulo whichever representation you ask for or receive).
@trwnh@mastodon.social · Reply to marius's post
@mariusor @hongminhee > the same URL can represent both
bad idea. an identifier should unambiguously refer to exactly 1 thing
@trwnh@mastodon.social · Reply to Evan Prodromou's post
@evan @hongminhee more and more i am thinking that Link was a bad idea from a data modeling perspective. "assume bare href instead of bare id" is something that can never make sense. if we really want to maintain validity of Link then it should *always* be embedded as an anonymous object:
icon: {
type: Image
url:
{
type: Link
href: foo
height: 400
width: 400
mediaType: image/png
}
}
here, Image.url means "representation of the Image"
@zenn_dev@rss-mstdn.studiofreesia.com
TypeScriptの型推論でCLIバリデーションをなくせた話
https://zenn.dev/hongminhee/articles/27bce6eb777dea
@hongminhee@hackers.pub
일본의 기술 블로깅 플랫폼인 Zenn 첫 페이지에 내가 Optique에 관해 썼던 글인 「TypeScriptの型推論でCLIバリデーションをなくせた話」(TypeScript의 타입 추론으로 CLI 유효성 검사를 필요 없게 만든 이야기)가 떴다!
@evan@cosocial.ca · Reply to 洪 民憙 (Hong Minhee)'s post
@hongminhee It's a place where our loosey goosey style goes into nondeterminism. We should tighten it up in the next version. My main answer would be: publishers, don't do that.
@hongminhee@hollo.social
Today I discovered an interesting inconsistency in Activity Streams specs while investigating a Fedify issue.
The question: How should we interpret URLs like "icon": "https://example.com/avatar.png"
?
JSON-LD context (https://www.w3.org/ns/activitystreams): @type: "@id"
→ “This is an IRI reference, dereference it to fetch an ActivityStreams object.”
Activity Streams Primer: “assume that a bare string is the href
of a Link
object, not an id
” (no dereferencing)
Result: JSON-LD processor-based implementations try to parse PNG files as JSON and fail.
Turns out w3c/activitystreams#595 already discusses the same issue for href
properties. I added a note that icon
, image
, etc. have the same problem.
Once again reminded of how tricky spec work can be…
#ActivityPub #Fedify #ActivityStreams #fedidev #specifications
@quillmatiq@mastodon.social · Reply to Anuj Ahooja's post
Direct link to the video: https://spectra.video/w/2mSF49ZFZPYJ9dRnJxW8D2
@quillmatiq@mastodon.social · Reply to Emelia 👸🏻's post
@thisismissem I mention the work you and @hongminhee are doing with @hollo in there. Really excited about it!
@quillmatiq@mastodon.social
@snarfed.org and I sat down with @deadsuperhero on Decentered!
First half is us talking about @anewsocial, and the second half is Sean letting me rave about all the exciting work being done on the open social web by @hongminhee, @cheeaun, @gabboman, @thisismissem, and so many others.
https://wedistribute.org/podcast/s2e5-a-new-social-with-anuj-ahooja-and-ryan-barrett/