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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ requirements.txt
handles.csv
check.sh
.vscode
poi/temp
poi/images*
poi/.env
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# KappaSigmaMu Bot

Element bot for Kusama Society V2

### Managing Proof-of-Ink images

We use IPFS to host the images and Pinata to pin the folder. The images are optimized and renamed to `<member_hash>.jpg` before getting uploaded. The scripts can be found inside `scripts/poi`.

#### Requirements:
- Python libraries:
```
pip3 install Pillow pillow-heif python-dotenv
```

#### Optimizing images
- Optimize an entire folder:
```
python3 optimize_multiple.py <folder_path>
```
- Rename and optimize single image:
```
python3 rename_and_optimize.py <image_path> <member_hash>
```

#### Interacting with IPFS/Pinata
- PS: requires a `.env` inside `scripts/poi` with `PINATA_API_KEY` and `PINATA_API_SECRET`
- Install IPFS and run it:
```
ipfs daemon
```
- Upload folder to Pinata and pin it:
```
python3 upload.py <file_path>
```
- Download pinned folder:
```
python3 download.py <ipfs_hash> <download_path>
```
- Full job - takes a new image, renames and optimizes it, uploads the new folder to Pinata and pins it, and finally unpins the old folder. The optional param `force` let's you overwrite an image that already exists.
```
python3 job.py <image_path> <member_hash> [optional=force]
```
20 changes: 18 additions & 2 deletions bot.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import time
import niobot
import logging
Expand All @@ -6,7 +7,8 @@
import asyncio
import os
from dotenv import load_dotenv
from niobot import Context
from niobot import Context, RoomMessage, RoomMessageText
from handle_upload import HandleUpload

load_dotenv()

Expand Down Expand Up @@ -81,6 +83,16 @@ async def on_command_error(ctx: Context, error: Exception):
async def on_ready(_: niobot.SyncResponse):
asyncio.create_task(new_period_message())

async def message_listener(room, event):
if isinstance(event, RoomMessageText):
pattern = f"([{re.escape(prefix)}|!]?upload.*)"
match = re.search(pattern, event.body)
if match:
handle_upload = HandleUpload(bot, room, event)
await handle_upload.handle(match.group(0), soc)

bot.add_event_callback(message_listener, RoomMessage)

@bot.command()
async def ping(ctx: Context):
"""Shows the roundtrip latency"""
Expand Down Expand Up @@ -112,6 +124,11 @@ async def info(ctx: Context, address: str):
else:
await ctx.respond("No info available for that address")

@bot.command()
async def upload(ctx: Context, address: str, force_or_remove: str = None):
"""Upload PoI image to IPFS. Use `force` to overwrite an existing image or `remove` to delete it."""
pass

@bot.command()
async def candidates(ctx: Context, address: str = None):
"""Shows the current candidates. Usage !candidates <address> (to optionally show info about a specific candidate))"""
Expand All @@ -133,7 +150,6 @@ async def head(ctx: Context):
await ctx.respond("The current head is `{}`".format(head))
else:
await ctx.respond("There is no head, something must have gone horribly wrong")


@bot.command()
async def set_address(ctx: Context, address: str):
Expand Down
140 changes: 140 additions & 0 deletions handle_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import os
import aiohttp
from urllib.parse import urlparse
from poi.job import job
from poi.remove import remove
from society import is_valid_address

class HandleUpload:
def __init__(self, client, room, event):
self.client = client
self.room = room
self.event = event

def is_caller_whitelisted(self):
whitelist = ["@laurogripa:matrix.org", "@s3krit:fairydust.space", "@rtti-5220:matrix.org"]
return self.event.sender in whitelist

async def handle(self, command, soc):
if not is_valid_command(command, self.event):
return

if not self.is_caller_whitelisted():
await self.respond("Unauthorized")
return

args = split_into_args(command)
if len(args) < 1 or len(args) > 2:
return

address = args[0]
if len(args) > 1 and args[1] == 'remove':
success, message = remove(address)
if success:
await self.respond("Removed file from IPFS.")
return True
else:
await self.respond("Removal failed: {}".format(message))
return False

original_event = await self.fetch_original_event()
if not is_image(original_event):
await self.respond("You are not replying to an image. Usage: `!upload <address> [force|remove]` replying to an image")
return

if not is_valid_address(address):
await self.respond("The address provided is not valid")
return

if not soc.is_member(address):
await self.respond("The address provided is not a member")
return

image_url = extract_image_url(original_event)
(success, save_path) = await self.download_image(image_url, 'poi/temp')

if not success:
await self.respond(f"Failed to download image")
return False

force = len(args) > 1 and args[1] == 'force'
upload, message = job(save_path, address, force)
if upload:
await self.respond("File uploaded to IPFS. See gallery here: https://ksmsociety.io/explore/poi/gallery")
else:
await self.respond("Upload failed: {}".format(message))

async def respond(self, message):
await self.client.room_send(
room_id=self.room.room_id,
message_type="m.room.message",
content={
"msgtype": "m.text",
"body": message,
"m.relates_to": {
"m.in_reply_to": {
"event_id": self.event.event_id
}
}
}
)

async def fetch_original_event(self):
original_event_id = extract_original_event_id(self.event)
if original_event_id:
original_event = await self.fetch_event(original_event_id)
return original_event

async def fetch_event(self, original_event_id):
response = await self.client.room_get_event(self.room.room_id, original_event_id)
return response.event if response else None

async def download_image(self, url, save_dir):
if not os.path.exists(save_dir):
os.makedirs(save_dir)

image_name = os.path.basename(urlparse(url).path)
save_path = os.path.join(save_dir, image_name)

async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
image_data = await response.read()

with open(save_path, 'wb') as file:
file.write(image_data)
return True, save_path

return False, None

# Helpers

def is_valid_command(command, event):
reply = event.formatted_body

if not reply:
return event.body.startswith(command)
else:
body_without_reply = event.body.split('\n\n', 1)[1] if '\n\n' in event.body else ''
return body_without_reply.startswith(command)

def is_image(original_event):
if original_event:
return original_event.source.get('content', {}).get('msgtype') == "m.image"

def extract_image_url(event):
mxc_url = event.source.get('content', {}).get('url', '')
server_name, media_id = mxc_url[len("mxc://"):].split("/", 1)
server_url = "https://matrix-client.matrix.org"
return f"{server_url}/_matrix/media/r0/download/{server_name}/{media_id}"

def extract_original_event_id(event):
if event:
return (event.source.get('content', {})
.get('m.relates_to', {})
.get('m.in_reply_to', {})
.get('event_id'))

def split_into_args(string):
args = string.split()
return args[1:]
53 changes: 53 additions & 0 deletions poi/download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import sys
import requests
import os
import json


def download(ipfs_hash, download_path):
try:
ls_response = requests.post(
f'http://127.0.0.1:5001/api/v0/ls?arg={ipfs_hash}')
if ls_response.status_code != 200:
raise Exception(
f"Error listing folder contents: {ls_response.text}")

folder_contents = json.loads(ls_response.text)['Objects'][0]['Links']

if not os.path.exists(download_path):
os.makedirs(download_path)

for item in folder_contents:
file_name = item['Name']
file_hash = item['Hash']
file_path = os.path.join(download_path, file_name)

cat_response = requests.post(
f'http://127.0.0.1:5001/api/v0/cat?arg={file_hash}', stream=True)
if cat_response.status_code != 200:
raise Exception(
f"Error downloading file {file_name}: {cat_response.text}")

with open(file_path, 'wb') as f:
for chunk in cat_response.iter_content(chunk_size=8192):
f.write(chunk)

print(f"Downloaded {file_name} to {download_path}")
except Exception as e:
print(f"Error: {e}")
raise e


def main():
if len(sys.argv) != 3:
print("Usage: python3 download.py <ipfs_hash> <download_path>")
sys.exit(1)

ipfs_hash = sys.argv[1]
download_path = sys.argv[2]

download(ipfs_hash, download_path)


if __name__ == "__main__":
main()
Loading