diff --git a/CHANGELOG.md b/CHANGELOG.md index 987ed27..c5a41b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,14 @@ + + +## Version 1.28.0 - 2026-01-17 + +Adds a documentation hub with pages on architecture, design, API, bots, and contributions. Increases the maximum workers in the channel message service to 10 for improved performance and updates navigation links in the about and index pages. + +**Changes:** 11 files, 2755 lines +**Languages:** HTML (2719 lines), Python (36 lines) ## Version 1.27.0 - 2026-01-16 diff --git a/pyproject.toml b/pyproject.toml index 9a509e9..966527f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "Snek" -version = "1.27.0" +version = "1.28.0" readme = "README.md" #license = { file = "LICENSE", content-type="text/markdown" } description = "Snek Chat Application by Molodetz" diff --git a/src/snek/app.py b/src/snek/app.py index b98525f..ee5ce8f 100644 --- a/src/snek/app.py +++ b/src/snek/app.py @@ -44,6 +44,7 @@ from snek.view.about import AboutHTMLView, AboutMDView from snek.view.avatar import AvatarView from snek.view.channel import ChannelAttachmentView,ChannelAttachmentUploadView, ChannelView from snek.view.docs import DocsHTMLView, DocsMDView +from snek.view.site import ArchitectureView, DesignView, ApiView, BotsView, ContributeView from snek.view.drive import DriveApiView, DriveView from snek.view.channel import ChannelDriveApiView from snek.view.container import ContainerView @@ -299,6 +300,11 @@ class Application(BaseApplication): self.router.add_view("/logout.html", LogoutView) self.router.add_view("/docs.html", DocsHTMLView) self.router.add_view("/docs.md", DocsMDView) + self.router.add_view("/architecture.html", ArchitectureView) + self.router.add_view("/design.html", DesignView) + self.router.add_view("/api.html", ApiView) + self.router.add_view("/bots.html", BotsView) + self.router.add_view("/contribute.html", ContributeView) self.router.add_view("/status.json", StatusView) self.router.add_view("/settings/index.html", SettingsIndexView) self.router.add_view("/settings/profile.html", SettingsProfileView) diff --git a/src/snek/service/channel_message.py b/src/snek/service/channel_message.py index 1de1e89..bcdf50b 100644 --- a/src/snek/service/channel_message.py +++ b/src/snek/service/channel_message.py @@ -29,7 +29,7 @@ class ChannelMessageService(BaseService): self._executor_pools = {} global jinja2_env jinja2_env = self.app.jinja2_env - self._max_workers = 1 + self._max_workers = 10 def get_or_create_executor(self, uid): if not uid in self._executor_pools: diff --git a/src/snek/templates/about.html b/src/snek/templates/about.html index 5604243..4cf8566 100644 --- a/src/snek/templates/about.html +++ b/src/snek/templates/about.html @@ -145,8 +145,14 @@
Snek Home Logo - Snek Community + Snek + Docs + Architecture + API + Bots + Contribute + About
diff --git a/src/snek/templates/docs.html b/src/snek/templates/docs.html index 21c0309..64fff57 100644 --- a/src/snek/templates/docs.html +++ b/src/snek/templates/docs.html @@ -1,10 +1,290 @@ -{% extends "base.html" %} + + + + + + Documentation - Snek Platform + + + + + + -{% block main %} -
+
+ Snek Logo +

Documentation

+

Learn how to use, extend, and contribute to the Snek platform

+
- - +
+
-{% endblock %} \ No newline at end of file + + +

Design Decisions

+

Learn why specific technologies were chosen and the philosophy behind the platform's design.

+
+ + + +

API Reference

+

Complete WebSocket RPC API documentation with all available methods for channels, messages, and more.

+
+ + + +

Bot Development

+

Build automated bots with complete Python and JavaScript examples. Integrate with AI services.

+
+ + + +

Contribute

+

Set up development environment, understand project structure, and learn how to add features.

+
+ + + +

About

+

Learn about the platform's mission, principles, and why it exists.

+
+ + +
+

Quick Start

+

Get Snek running locally in under a minute:

+
pip install git+https://retoor.molodetz.nl/retoor/snek.git
+snek serve
+

The server starts at http://localhost:8080. Register a user and start chatting.

+
+ +
+

Key Features

+

Snek is a privacy-focused platform for developers:

+ +
+ +
+

Connection Details

+

Access the platform via multiple protocols:

+ +

All protocols use your Snek credentials.

+
+ + + + {% include "sandbox.html" %} + + diff --git a/src/snek/templates/index.html b/src/snek/templates/index.html index 1d9ccd7..1d0891c 100644 --- a/src/snek/templates/index.html +++ b/src/snek/templates/index.html @@ -168,6 +168,7 @@

Professional Platform for Developers, Testers & AI Professionals

Login Register + Docs About
diff --git a/src/snek/templates/site/api.html b/src/snek/templates/site/api.html new file mode 100644 index 0000000..2f18bb1 --- /dev/null +++ b/src/snek/templates/site/api.html @@ -0,0 +1,630 @@ + + + + + + API Reference - Snek Platform Documentation + + + + + + + +
+

API Reference

+

WebSocket RPC interface documentation

+
+ +
+ + +
+

Protocol

+

Snek uses a JSON-RPC style protocol over WebSocket. Connect to /rpc.ws to establish a connection.

+ +

Request Format

+
{
+  "callId": "unique-id",
+  "method": "method_name",
+  "args": [arg1, arg2, ...]
+}
+ +

Response Format

+
{
+  "callId": "unique-id",
+  "success": true,
+  "data": { ... }
+}
+ +

Server Events

+

The server pushes events without a callId:

+
{
+  "channel_uid": "...",
+  "event": "new_message",
+  "data": { ... }
+}
+
+ +
+

Authentication

+ +
+ login(username, password) +

Authenticate and establish a session. On success, the WebSocket connection is associated with the user and subscribed to all their channels.

+

username (string) - The username

+

password (string) - The password

+

Returns: User record (without password field) on success, error on failure

+
+ +
+ ping() + Auth Required +

Keep the connection alive and update the user's last activity timestamp.

+

Returns: {"pong": []}

+
+
+ +
+

User

+ +
+ get_user(user_uid=None) + Auth Required +

Get user information. If no user_uid is provided, returns the current user's data.

+

user_uid (string, optional) - Target user's UID

+

Returns: User record (email hidden for other users)

+
+ +
+ search_user(query) + Auth Required +

Search for users by username.

+

query (string) - Search query

+

Returns: Array of matching usernames

+
+
+ +
+

Channels

+ +
+ get_channels() + Auth Required +

Get all channels the current user is a member of.

+

Returns: Array of channel objects with name, uid, tag, new_count, is_moderator, is_read_only, color

+
+ +
+ create_channel(name, description=None, is_private=False) + Auth Required +

Create a new channel. The creating user becomes the moderator.

+

name (string) - Channel name

+

description (string, optional) - Channel description

+

is_private (boolean, optional) - Whether the channel is private

+

Returns: {success: true, uid: "...", name: "..."}

+
+ +
+ update_channel(channel_uid, name, description=None, is_private=False) + Auth Required +

Update channel settings. Only the channel creator or admin can update.

+

channel_uid (string) - Channel UID

+

name (string) - New channel name

+

description (string, optional) - New description

+

is_private (boolean, optional) - Privacy setting

+

Returns: {success: true, name: "..."}

+
+ +
+ delete_channel(channel_uid) + Auth Required +

Delete a channel (soft delete). Only the channel creator or admin can delete. Public channel cannot be deleted.

+

channel_uid (string) - Channel UID

+

Returns: {success: true}

+
+ +
+ invite_user(channel_uid, username) + Auth Required +

Invite a user to a channel. Requires membership in the channel.

+

channel_uid (string) - Channel UID

+

username (string) - Username to invite

+

Returns: {success: true, username: "..."}

+
+ +
+ leave_channel(channel_uid) + Auth Required +

Leave a channel. Cannot leave the public channel.

+

channel_uid (string) - Channel UID

+

Returns: {success: true}

+
+ +
+ clear_channel(channel_uid) + Auth Required +

Clear all messages from a channel. Admin only.

+

channel_uid (string) - Channel UID

+

Returns: Boolean success

+
+
+ +
+

Messages

+ +
+ send_message(channel_uid, message, is_final=True) + Auth Required +

Send a message to a channel. If is_final is false, the message can be updated (for typing indicators).

+

channel_uid (string) - Target channel UID

+

message (string) - Message content

+

is_final (boolean, optional) - Whether the message is final (default true)

+

Returns: Message UID on success

+
+ +
+ get_messages(channel_uid, offset=0, timestamp=None) + Auth Required +

Get messages from a channel with pagination.

+

channel_uid (string) - Channel UID

+

offset (integer, optional) - Pagination offset

+

timestamp (string, optional) - Filter by timestamp

+

Returns: Array of message objects

+
+ +
+ update_message_text(message_uid, text) + Auth Required +

Update a non-final message's text. Only the message author can update. Message must be less than 8 seconds old.

+

message_uid (string) - Message UID

+

text (string) - New text content

+

Returns: {success: true}

+
+ +
+ finalize_message(message_uid) + Auth Required +

Mark a message as final, preventing further updates.

+

message_uid (string) - Message UID

+

Returns: Boolean success

+
+ +
+ mark_as_read(channel_uid) + Auth Required +

Mark all messages in a channel as read for the current user.

+

channel_uid (string) - Channel UID

+

Returns: Boolean success

+
+ +
+ get_first_unread_message_uid(channel_uid) + Auth Required +

Get the UID of the first unread message in a channel.

+

channel_uid (string) - Channel UID

+

Returns: Message UID or null

+
+
+ +
+

Presence

+ +
+ set_typing(channel_uid, color=None) + Auth Required +

Broadcast a typing indicator to channel members.

+

channel_uid (string) - Channel UID

+

color (string, optional) - Indicator color (defaults to user's color)

+

Returns: Boolean success

+
+ +
+ get_online_users(channel_uid) + Auth Required +

Get users currently online in a channel.

+

channel_uid (string) - Channel UID

+

Returns: Array of user objects sorted by nick

+
+ +
+ get_recent_users(channel_uid) + Auth Required +

Get users with recent activity in a channel.

+

channel_uid (string) - Channel UID

+

Returns: Array with uid, username, nick, last_ping

+
+ +
+ get_users(channel_uid) + Auth Required +

Get all members of a channel.

+

channel_uid (string) - Channel UID

+

Returns: Array with uid, username, nick, last_ping

+
+
+ +
+

Container

+

Container methods provide access to channel-specific Docker containers (Ubuntu terminals).

+ +
+ get_container(channel_uid) + Auth Required +

Get container information for a channel.

+

channel_uid (string) - Channel UID

+

Returns: Container object with name, cpus, memory, image, status

+
+ +
+ start_container(channel_uid) + Auth Required +

Start the container for a channel.

+

channel_uid (string) - Channel UID

+

Returns: Boolean success

+
+ +
+ stop_container(channel_uid) + Auth Required +

Stop the container for a channel.

+

channel_uid (string) - Channel UID

+

Returns: Boolean success

+
+ +
+ get_container_status(channel_uid) + Auth Required +

Get the current status of a channel's container.

+

channel_uid (string) - Channel UID

+

Returns: Status string

+
+ +
+ write_container(channel_uid, content, timeout=3) + Auth Required +

Write to container stdin and capture output.

+

channel_uid (string) - Channel UID

+

content (string) - Content to write to stdin

+

timeout (integer, optional) - Wait timeout in seconds (max 30)

+

Returns: Output string

+
+
+ +
+

Database

+

Database methods provide direct access to user-scoped database operations.

+ +
+ db_insert(table_name, record) + Auth Required +

Insert a record into a user-scoped table.

+

table_name (string) - Table name

+

record (object) - Record to insert

+

Returns: Inserted record

+
+ +
+ db_update(table_name, record) + Auth Required +

Update a record in a user-scoped table.

+

table_name (string) - Table name

+

record (object) - Record with updates

+

Returns: Updated record

+
+ +
+ db_delete(table_name, record) + Auth Required +

Delete a record from a user-scoped table.

+

table_name (string) - Table name

+

record (object) - Record identifier

+

Returns: Boolean success

+
+ +
+ db_get(table_name, record) + Auth Required +

Get a single record from a user-scoped table.

+

table_name (string) - Table name

+

record (object) - Query criteria

+

Returns: Record or null

+
+ +
+ db_find(table_name, record) + Auth Required +

Find records in a user-scoped table.

+

table_name (string) - Table name

+

record (object) - Query criteria

+

Returns: Array of records

+
+ +
+ db_upsert(table_name, record, keys) + Auth Required +

Insert or update a record based on key fields.

+

table_name (string) - Table name

+

record (object) - Record data

+

keys (array) - Key fields for matching

+

Returns: Upserted record

+
+ +
+ db_query(sql, args) + Auth Required +

Execute a raw SQL query on user-scoped data.

+

sql (string) - SQL query

+

args (array) - Query parameters

+

Returns: Query results

+
+ +
+ query(sql) + Auth Required +

Execute a read-only SQL query. Forbidden keywords (DROP, ALTER, etc.) are blocked.

+

sql (string) - SQL SELECT query

+

Returns: Array of records (sensitive fields filtered)

+
+
+ +
+

Utility

+ +
+ echo(*args) +

Echo back the provided arguments. Useful for testing.

+

args - Any arguments

+

Returns: The same arguments

+
+ +
+ echo_raw(obj) +

Send a raw JSON object through the WebSocket. No response is sent back.

+

obj (object) - Object to send

+

Returns: No response

+
+ +
+ stars_render(channel_uid, message) +

Broadcast a stars render event to all online users in a channel.

+

channel_uid (string) - Channel UID

+

message (string) - Message content

+

Returns: Boolean success

+
+
+
+ + + {% include "sandbox.html" %} + + diff --git a/src/snek/templates/site/architecture.html b/src/snek/templates/site/architecture.html new file mode 100644 index 0000000..ff14d2a --- /dev/null +++ b/src/snek/templates/site/architecture.html @@ -0,0 +1,367 @@ + + + + + + Architecture - Snek Platform Documentation + + + + + + + +
+

Architecture

+

Technical architecture and system design of the Snek platform

+
+ +
+
+

System Overview

+

Snek follows a layered architecture pattern with clear separation of concerns. The system is built on Python's aiohttp framework for asynchronous HTTP and WebSocket handling.

+
+
+
Frontend Layer
+ Vanilla JavaScript ES6 Modules | Custom Elements | WebSocket Client +
+
+
View Layer
+ HTTP Handlers | WebSocket RPC | Template Rendering | Form Processing +
+
+
Service Layer
+ Business Logic | Caching | Event Broadcasting | Authentication +
+
+
Mapper Layer
+ Data Access | CRUD Operations | Soft Delete | Query Building +
+
+
Database Layer
+ SQLite | Dataset ORM | Schema Management +
+
+
+ +
+

Backend Architecture

+ +

aiohttp Framework

+

The backend is built on aiohttp, a high-performance asynchronous HTTP client/server framework. Key features:

+
    +
  • Fully asynchronous request handling with async/await
  • +
  • Native WebSocket support for real-time communication
  • +
  • Session management via aiohttp-session
  • +
  • Static file serving and template rendering
  • +
+ +

Service Layer Pattern

+

Services encapsulate business logic and are accessed via a singleton registry:

+
user = await app.services.user.get(uid=user_uid)
+channel = await app.services.channel.create(label="#general")
+

Available services include: user, channel, channel_member, channel_message, chat, socket, container, db, and more.

+ +

Mapper Pattern

+

Mappers provide data access abstraction with consistent CRUD operations:

+
await mapper.get(uid=uid)
+await mapper.find(field=value)
+await mapper.save(model)
+async for record in mapper.query(sql, params): ...
+

All mappers support soft delete via the deleted_at field.

+ +

Caching Strategy

+

The service layer implements UID-based caching for frequently accessed data. Cache invalidation occurs on model updates.

+
+ +
+

Frontend Architecture

+ +

Vanilla JavaScript Philosophy

+

The frontend uses pure ES6 JavaScript modules without any frameworks. This ensures:

+
    +
  • Minimal bundle size and fast load times
  • +
  • No framework upgrade cycles or breaking changes
  • +
  • Direct DOM manipulation for maximum performance
  • +
  • Easy debugging without transpilation
  • +
+ +

Module Structure

+
src/snek/static/
+  app.js           # Main Application class (singleton)
+  socket.js        # WebSocket RPC client
+  chat-window.js   # Chat interface component
+  message-list.js  # Message rendering
+  chat-input.js    # Input handling
+  ...
+ +

Custom Elements

+

UI components are implemented as Custom Elements:

+
<chat-window></chat-window>
+<message-list></message-list>
+<channel-menu></channel-menu>
+<nav-menu></nav-menu>
+

Each component has its own CSS file (no Shadow DOM per project guidelines).

+
+ +
+

Database Design

+ +

Dataset ORM

+

Snek uses Dataset, a simple database abstraction layer built on SQLAlchemy. It provides:

+
    +
  • Automatic table creation and schema inference
  • +
  • Dictionary-based record access
  • +
  • Transaction support
  • +
  • Raw SQL query execution when needed
  • +
+ +

Core Tables

+
user           # User accounts
+channel        # Chat channels
+channel_member # Channel membership
+channel_message# Chat messages
+session        # User sessions
+...
+ +

Soft Delete Pattern

+

Records are never physically deleted. Instead, the deleted_at timestamp is set. All queries filter by deleted_at IS NULL by default.

+
+ +
+

Real-time Communication

+ +

WebSocket RPC System

+

Real-time features use a JSON-RPC style protocol over WebSocket:

+
{
+  "callId": "1",
+  "method": "send_message",
+  "args": ["channel_uid", "Hello world", true]
+}
+

The server responds with:

+
{
+  "callId": "1",
+  "success": true,
+  "data": "message_uid"
+}
+ +

Event Broadcasting

+

Server-initiated events are broadcast to channel subscribers:

+
{
+  "channel_uid": "...",
+  "event": "new_message",
+  "data": { ... }
+}
+ +

Channel Subscription Model

+

Users automatically subscribe to all their channels on WebSocket connection. The socket service manages subscriptions and delivers messages to the appropriate clients.

+
+ +
+

Directory Structure

+
src/snek/
+  __main__.py      # CLI entry point
+  app.py           # Application setup and routing
+  view/            # HTTP/WebSocket handlers
+    index.py       # Landing page
+    rpc.py         # WebSocket RPC
+    settings/      # Settings views
+  service/         # Business logic layer
+  mapper/          # Data persistence
+  model/           # Data models
+  form/            # Form definitions
+  system/          # Core infrastructure
+    view.py        # BaseView class
+    service.py     # BaseService class
+    mapper.py      # BaseMapper class
+    model.py       # BaseModel class
+  static/          # Frontend JavaScript and CSS
+  templates/       # Jinja2 templates
+
+
+ + + {% include "sandbox.html" %} + + diff --git a/src/snek/templates/site/bots.html b/src/snek/templates/site/bots.html new file mode 100644 index 0000000..dd77dad --- /dev/null +++ b/src/snek/templates/site/bots.html @@ -0,0 +1,655 @@ + + + + + + Bot Development - Snek Platform Documentation + + + + + + + +
+

Bot Development

+

Build automated bots for the Snek platform

+
+ +
+
+

Introduction

+

Snek bots are automated clients that connect via WebSocket and interact with the platform using the RPC API. Bots can:

+
    +
  • Monitor channels and respond to messages
  • +
  • Send automated notifications
  • +
  • Integrate with external services (CI/CD, monitoring, etc.)
  • +
  • Provide AI-powered assistance
  • +
  • Automate moderation tasks
  • +
+

Bots authenticate like regular users. Create a dedicated user account for your bot.

+
+ +
+

Protocol Overview

+

Bots communicate over WebSocket using a JSON-RPC style protocol:

+ +

Connection

+

Connect to wss://your-server/rpc.ws (or ws:// for local development).

+ +

Request Format

+
{
+    "callId": "1",
+    "method": "login",
+    "args": ["bot_username", "bot_password"]
+}
+ +

Response Format

+
{
+    "callId": "1",
+    "success": true,
+    "data": { "uid": "...", "username": "bot_username", ... }
+}
+ +

Server Events

+

After login, the server pushes events for new messages:

+
{
+    "channel_uid": "abc123",
+    "event": "new_message",
+    "data": {
+        "uid": "msg_uid",
+        "message": "Hello bot!",
+        "user_uid": "sender_uid",
+        "username": "sender_name"
+    }
+}
+
+ +
+

Python Bot Example

+

A complete, working Python bot using aiohttp:

+ + Python +
# retoor <retoor@molodetz.nl>
+
+import asyncio
+import json
+import aiohttp
+
+
+class SnekBot:
+    def __init__(self, base_url, username, password):
+        self.base_url = base_url
+        self.username = username
+        self.password = password
+        self.ws = None
+        self.session = None
+        self.call_id = 0
+        self.pending = {}
+        self.running = True
+
+    async def connect(self):
+        self.session = aiohttp.ClientSession()
+        ws_url = self.base_url.replace("https", "wss").replace("http", "ws")
+        ws_url = f"{ws_url}/rpc.ws"
+        self.ws = await self.session.ws_connect(ws_url, heartbeat=30)
+        asyncio.create_task(self._message_loop())
+        user = await self.login()
+        print(f"Logged in as {user.get('username')}")
+        return user
+
+    async def _message_loop(self):
+        try:
+            async for msg in self.ws:
+                if msg.type == aiohttp.WSMsgType.TEXT:
+                    data = json.loads(msg.data)
+                    call_id = data.get("callId")
+                    if call_id and call_id in self.pending:
+                        self.pending[call_id].set_result(data)
+                    elif data.get("event") == "new_message":
+                        await self.on_message(data.get("data", {}))
+                    elif data.get("event"):
+                        await self.on_event(data)
+                elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
+                    break
+        except Exception as e:
+            print(f"Message loop error: {e}")
+        finally:
+            self.running = False
+
+    async def call(self, method, *args):
+        self.call_id += 1
+        call_id = str(self.call_id)
+        future = asyncio.get_event_loop().create_future()
+        self.pending[call_id] = future
+        await self.ws.send_json({
+            "callId": call_id,
+            "method": method,
+            "args": list(args)
+        })
+        try:
+            result = await asyncio.wait_for(future, timeout=30)
+            return result.get("data")
+        finally:
+            self.pending.pop(call_id, None)
+
+    async def login(self):
+        return await self.call("login", self.username, self.password)
+
+    async def send_message(self, channel_uid, message):
+        return await self.call("send_message", channel_uid, message, True)
+
+    async def get_channels(self):
+        return await self.call("get_channels")
+
+    async def on_message(self, data):
+        message = data.get("message", "")
+        channel_uid = data.get("channel_uid")
+        username = data.get("username", "")
+
+        if username == self.username:
+            return
+
+        if message.lower().startswith("!ping"):
+            await self.send_message(channel_uid, "Pong!")
+
+        elif message.lower().startswith("!help"):
+            help_text = "Available commands: !ping, !help, !time"
+            await self.send_message(channel_uid, help_text)
+
+        elif message.lower().startswith("!time"):
+            import datetime
+            now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+            await self.send_message(channel_uid, f"Current time: {now}")
+
+    async def on_event(self, data):
+        event = data.get("event")
+        print(f"Event: {event}")
+
+    async def close(self):
+        self.running = False
+        if self.ws:
+            await self.ws.close()
+        if self.session:
+            await self.session.close()
+
+
+async def main():
+    bot = SnekBot(
+        base_url="https://snek.community",
+        username="my_bot",
+        password="my_bot_password"
+    )
+
+    try:
+        await bot.connect()
+        channels = await bot.get_channels()
+        print(f"Member of {len(channels)} channels")
+
+        while bot.running:
+            await asyncio.sleep(1)
+    except KeyboardInterrupt:
+        print("Shutting down...")
+    finally:
+        await bot.close()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
+
+ +
+

JavaScript Bot Example

+

A complete Node.js bot using the ws package:

+ + JavaScript (Node.js) +
// retoor <retoor@molodetz.nl>
+
+const WebSocket = require("ws")
+
+class SnekBot {
+    constructor(baseUrl, username, password) {
+        this.baseUrl = baseUrl
+        this.username = username
+        this.password = password
+        this.ws = null
+        this.callId = 0
+        this.pending = new Map()
+        this.running = true
+    }
+
+    connect() {
+        return new Promise((resolve, reject) => {
+            const wsUrl = this.baseUrl
+                .replace("https", "wss")
+                .replace("http", "ws") + "/rpc.ws"
+
+            this.ws = new WebSocket(wsUrl)
+
+            this.ws.on("open", async () => {
+                try {
+                    const user = await this.login()
+                    console.log(`Logged in as ${user.username}`)
+                    resolve(user)
+                } catch (e) {
+                    reject(e)
+                }
+            })
+
+            this.ws.on("message", (data) => {
+                const parsed = JSON.parse(data.toString())
+                const callId = parsed.callId
+
+                if (callId && this.pending.has(callId)) {
+                    this.pending.get(callId).resolve(parsed)
+                    this.pending.delete(callId)
+                } else if (parsed.event === "new_message") {
+                    this.onMessage(parsed.data || {})
+                } else if (parsed.event) {
+                    this.onEvent(parsed)
+                }
+            })
+
+            this.ws.on("close", () => {
+                this.running = false
+                console.log("Connection closed")
+            })
+
+            this.ws.on("error", (err) => {
+                console.error("WebSocket error:", err.message)
+                reject(err)
+            })
+        })
+    }
+
+    call(method, ...args) {
+        return new Promise((resolve, reject) => {
+            this.callId++
+            const callId = String(this.callId)
+
+            const timeout = setTimeout(() => {
+                this.pending.delete(callId)
+                reject(new Error("Request timeout"))
+            }, 30000)
+
+            this.pending.set(callId, {
+                resolve: (data) => {
+                    clearTimeout(timeout)
+                    resolve(data.data)
+                },
+                reject
+            })
+
+            this.ws.send(JSON.stringify({
+                callId,
+                method,
+                args
+            }))
+        })
+    }
+
+    login() {
+        return this.call("login", this.username, this.password)
+    }
+
+    sendMessage(channelUid, message) {
+        return this.call("send_message", channelUid, message, true)
+    }
+
+    getChannels() {
+        return this.call("get_channels")
+    }
+
+    async onMessage(data) {
+        const message = data.message || ""
+        const channelUid = data.channel_uid
+        const username = data.username || ""
+
+        if (username === this.username) {
+            return
+        }
+
+        if (message.toLowerCase().startsWith("!ping")) {
+            await this.sendMessage(channelUid, "Pong!")
+        } else if (message.toLowerCase().startsWith("!help")) {
+            await this.sendMessage(channelUid, "Commands: !ping, !help, !time")
+        } else if (message.toLowerCase().startsWith("!time")) {
+            const now = new Date().toISOString()
+            await this.sendMessage(channelUid, `Current time: ${now}`)
+        }
+    }
+
+    onEvent(data) {
+        console.log(`Event: ${data.event}`)
+    }
+
+    close() {
+        this.running = false
+        if (this.ws) {
+            this.ws.close()
+        }
+    }
+}
+
+async function main() {
+    const bot = new SnekBot(
+        "https://snek.community",
+        "my_bot",
+        "my_bot_password"
+    )
+
+    try {
+        await bot.connect()
+        const channels = await bot.getChannels()
+        console.log(`Member of ${channels.length} channels`)
+
+        process.on("SIGINT", () => {
+            console.log("Shutting down...")
+            bot.close()
+            process.exit(0)
+        })
+    } catch (e) {
+        console.error("Failed to connect:", e.message)
+        process.exit(1)
+    }
+}
+
+main()
+
+ +
+

Event Handling

+

Bots receive various events after authentication:

+ +

new_message

+

Received when a message is posted to a channel the bot is a member of.

+
{
+    "channel_uid": "...",
+    "event": "new_message",
+    "data": {
+        "uid": "message_uid",
+        "message": "Message content",
+        "user_uid": "sender_uid",
+        "username": "sender_username",
+        "nick": "Sender Nick",
+        "created_at": "2025-01-01T12:00:00"
+    }
+}
+ +

set_typing

+

Received when a user starts typing.

+
{
+    "channel_uid": "...",
+    "event": "set_typing",
+    "data": {
+        "user_uid": "...",
+        "username": "...",
+        "nick": "...",
+        "color": "#7ef"
+    }
+}
+ +

update_message_text

+

Received when a message is edited (non-final messages only).

+
{
+    "channel_uid": "...",
+    "event": "update_message_text",
+    "data": {
+        "message_uid": "...",
+        "text": "Updated message content"
+    }
+}
+ +

user_presence

+

Received when a user's online status changes.

+
+ +
+

Best Practices

+ +

Rate Limiting

+

Implement rate limiting to avoid overwhelming the server:

+
    +
  • Limit outgoing messages to 1-2 per second per channel
  • +
  • Batch multiple operations where possible
  • +
  • Add delays between bulk operations
  • +
+ +

Error Handling

+

Handle errors gracefully:

+
    +
  • Catch and log all exceptions
  • +
  • Handle authentication failures specifically
  • +
  • Validate input before sending to the API
  • +
+ +

Reconnection Strategy

+

Implement automatic reconnection:

+
    +
  • Detect disconnection via heartbeat timeout
  • +
  • Use exponential backoff (1s, 2s, 4s, 8s, max 60s)
  • +
  • Re-authenticate after reconnection
  • +
  • Restore subscriptions after login
  • +
+ +

Resource Cleanup

+

Clean up resources on shutdown:

+
    +
  • Close WebSocket connections properly
  • +
  • Cancel pending timers and promises
  • +
  • Handle SIGINT/SIGTERM signals
  • +
+ +

Security

+
    +
  • Store credentials in environment variables, not code
  • +
  • Use a dedicated bot account with minimal permissions
  • +
  • Validate and sanitize user input before processing
  • +
  • Log actions for audit purposes
  • +
+
+ +
+

Advanced Topics

+ +

AI Integration

+

Integrate with AI services by calling external APIs when messages match certain patterns:

+
async def on_message(self, data):
+    message = data.get("message", "")
+    if message.startswith("!ask "):
+        query = message[5:]
+        response = await self.call_ai_api(query)
+        await self.send_message(data["channel_uid"], response)
+ +

Scheduled Tasks

+

Use asyncio tasks for periodic operations:

+
async def daily_report(self):
+    while self.running:
+        await asyncio.sleep(86400)  # 24 hours
+        for channel in await self.get_channels():
+            await self.send_message(channel["uid"], "Daily report...")
+ +

Multiple Bots

+

Run multiple bot instances for different purposes. Each bot needs its own user account and WebSocket connection.

+
+
+ + + {% include "sandbox.html" %} + + diff --git a/src/snek/templates/site/contribute.html b/src/snek/templates/site/contribute.html new file mode 100644 index 0000000..fdba105 --- /dev/null +++ b/src/snek/templates/site/contribute.html @@ -0,0 +1,444 @@ + + + + + + Contribute - Snek Platform Documentation + + + + + + + +
+

Contribute

+

How to contribute to the Snek platform

+
+ +
+
+

Getting Started

+ +

Clone the Repository

+
git clone https://retoor.molodetz.nl/retoor/snek.git
+cd snek
+ +

Setup Development Environment

+
make install
+

This creates a virtual environment and installs all dependencies.

+ +

Run Locally

+
make run
+

The server starts at http://localhost:8080 by default.

+ +

Run Tests

+
pytest tests/
+
+ +
+

Project Structure

+ +
+ src/snek/
+   __main__.py      CLI entry point
+   app.py           Application setup, routing
+   view/            HTTP/WebSocket handlers
+     index.py       Landing page
+     rpc.py         WebSocket RPC
+     settings/      Settings views
+   service/         Business logic layer
+     user.py        User service
+     channel.py     Channel service
+     chat.py        Chat service
+   mapper/          Data persistence
+   model/           Data models
+   form/            Form definitions
+   system/          Core infrastructure
+     view.py        BaseView class
+     service.py     BaseService class
+     mapper.py      BaseMapper class
+     model.py       BaseModel class
+   static/          Frontend JS and CSS
+     app.js         Application singleton
+     socket.js      WebSocket client
+   templates/       Jinja2 templates
+
+
+ +
+

Code Standards

+ +

Author Attribution

+

Every file must include the author tag at the top:

+
# retoor <retoor@molodetz.nl>
+

For JavaScript:

+
// retoor <retoor@molodetz.nl>
+ +

No Comments

+

Code should be self-documenting. If you need to explain something, refactor the code to be clearer. Comments indicate code that needs improvement.

+ +

No JavaScript Frameworks

+

The frontend uses vanilla ES6 JavaScript only. No React, Vue, Angular, or similar frameworks. Use Custom Elements for component encapsulation.

+ +

Explicit Over Implicit

+

Prefer explicit code paths. No magic imports, no hidden side effects, no implicit type conversions.

+ +

Variable Naming

+

Use clear, descriptive names. Avoid abbreviations except for common conventions (uid, url, etc.). No suffixes like _super or _refactored.

+ +

Module Organization

+

JavaScript: one class per file. Python: related classes can share a file, but keep files focused. Use directories to group related modules.

+
+ +
+

Adding a New View

+

Views handle HTTP requests and render responses.

+ +

1. Create the View Class

+
# retoor <retoor@molodetz.nl>
+
+from snek.system.view import BaseView
+
+
+class MyFeatureView(BaseView):
+    login_required = True
+
+    async def get(self):
+        data = await self.services.my_service.get_data()
+        return await self.render_template("my_feature.html", {"data": data})
+ +

2. Create the Template

+

Add a template at templates/my_feature.html:

+
{% raw %}{% extends "base.html" %}
+
+{% block title %}My Feature{% endblock %}
+
+{% block main %}
+<div class="container">
+    <h1>My Feature</h1>
+    <p>{{ data }}</p>
+</div>
+{% endblock %}{% endraw %}
+ +

3. Register the Route

+

In app.py setup_router method:

+
from snek.view.my_feature import MyFeatureView
+
+self.router.add_view("/my-feature.html", MyFeatureView)
+
+ +
+

Adding a New Service

+

Services contain business logic and are accessed via app.services.name.

+ +

1. Create the Service Class

+
# retoor <retoor@molodetz.nl>
+
+from snek.system.service import BaseService
+
+
+class MyService(BaseService):
+    mapper_class = None
+
+    async def process_data(self, data):
+        return {"processed": data}
+ +

2. Register the Service

+

Services are auto-discovered from the service/ directory. The service name is derived from the filename (e.g., my_service.py becomes app.services.my_service).

+
+ +
+

Adding RPC Methods

+

RPC methods are defined in the RPCApi class in view/rpc.py.

+ +

Add the Method

+
async def my_method(self, param1, param2=None):
+    self._require_login()
+    self._require_services()
+
+    if not param1 or not isinstance(param1, str):
+        raise ValueError("Invalid param1")
+
+    result = await self.services.my_service.process(param1, param2)
+    return result
+ +

Method Guidelines

+
    +
  • Use self._require_login() for authenticated methods
  • +
  • Use self._require_services() to ensure services are available
  • +
  • Validate all input parameters
  • +
  • Return serializable data (dicts, lists, primitives)
  • +
  • Raise ValueError for invalid input
  • +
  • Raise PermissionError for authorization failures
  • +
+
+ +
+

Adding Frontend Components

+

Frontend components use Custom Elements.

+ +

1. Create the Component

+
// retoor <retoor@molodetz.nl>
+
+class MyComponent extends HTMLElement {
+    constructor() {
+        super()
+        this.data = null
+    }
+
+    connectedCallback() {
+        this.render()
+    }
+
+    render() {
+        this.innerHTML = `
+            <div class="my-component">
+                <h2>My Component</h2>
+            </div>
+        `
+    }
+}
+
+customElements.define("my-component", MyComponent)
+
+export { MyComponent }
+ +

2. Create the CSS

+

Add styles in a separate CSS file (no Shadow DOM):

+
.my-component {
+    background: #1a1a1a;
+    padding: 1rem;
+    border-radius: 6px;
+}
+
+.my-component h2 {
+    color: #7ef;
+}
+ +

3. Import in the Template

+
<script type="module" src="/my-component.js"></script>
+<link rel="stylesheet" href="/my-component.css">
+
+ +
+

Testing

+ +

Running Tests

+
pytest tests/                           # All tests
+pytest tests/test_user.py              # Single file
+pytest tests/test_user.py::test_login  # Single test
+pytest -m unit                          # Unit tests only
+pytest -m integration                   # Integration tests
+ +

Writing Tests

+
# retoor <retoor@molodetz.nl>
+
+import pytest
+
+
+@pytest.mark.unit
+async def test_my_feature():
+    result = await my_function()
+    assert result is not None
+
+ +
+

Pull Request Guidelines

+ +
    +
  1. Create a feature branch from main
  2. +
  3. Make focused, atomic commits
  4. +
  5. Write clear commit messages
  6. +
  7. Ensure all tests pass
  8. +
  9. Follow the code standards
  10. +
  11. Update documentation if needed
  12. +
  13. Submit PR with description of changes
  14. +
+ +

Commit Message Format

+
feat: add user profile editing
+fix: resolve channel subscription issue
+docs: update API documentation
+refactor: simplify message handling
+
+
+ + + {% include "sandbox.html" %} + + diff --git a/src/snek/templates/site/design.html b/src/snek/templates/site/design.html new file mode 100644 index 0000000..3ca1bc1 --- /dev/null +++ b/src/snek/templates/site/design.html @@ -0,0 +1,320 @@ + + + + + + Design Decisions - Snek Platform Documentation + + + + + + + +
+

Design Decisions

+

Philosophy and technical choices that shape the Snek platform

+
+ +
+
+

Core Philosophy

+

Snek is built on a set of guiding principles that inform every technical and design decision:

+
    +
  • Privacy First: No tracking, no analytics, no email required. User data belongs to users.
  • +
  • Performance Focus: Fast is a feature. Minimal dependencies, optimized code paths.
  • +
  • Simplicity Over Complexity: Prefer straightforward solutions. Complexity must justify itself.
  • +
  • Self-Hosting First: The platform should be trivial to deploy and maintain independently.
  • +
  • No Framework Lock-in: Avoid dependencies that impose architectural constraints.
  • +
+
+ +
+

Technical Decisions

+ +

Why aiohttp Over Flask/Django

+
+ Decision: Use aiohttp as the web framework. +
+

Rationale:

+
    +
  • Native async/await support without ASGI adapters
  • +
  • Built-in WebSocket support with no additional libraries
  • +
  • Lower memory footprint than Django
  • +
  • No ORM assumptions or admin interface overhead
  • +
  • Direct control over request/response lifecycle
  • +
+ +

Why Dataset ORM Over SQLAlchemy

+
+ Decision: Use Dataset for database access. +
+

Rationale:

+
    +
  • Zero configuration required for basic operations
  • +
  • Dictionary-based interface matches JSON API patterns
  • +
  • Automatic table creation simplifies development
  • +
  • Raw SQL available when needed for complex queries
  • +
  • Significantly less boilerplate than SQLAlchemy models
  • +
+ +

Why Vanilla JavaScript Over React/Vue

+
+ Decision: Use vanilla ES6 JavaScript with no frameworks. +
+

Rationale:

+
    +
  • No build step required - direct browser execution
  • +
  • No framework version upgrades or breaking changes
  • +
  • Smaller payload - no framework runtime overhead
  • +
  • Easier debugging - no virtual DOM abstraction
  • +
  • Custom Elements provide component encapsulation
  • +
  • Direct DOM access for performance-critical paths
  • +
+ +

Why SQLite as Default Database

+
+ Decision: Use SQLite as the default database. +
+

Rationale:

+
    +
  • Zero configuration deployment - single file database
  • +
  • No separate database server process required
  • +
  • Excellent read performance for chat workloads
  • +
  • Easy backup - copy a single file
  • +
  • PostgreSQL available for high-write scenarios
  • +
+
+ +
+

Security Decisions

+ +

No IP Logging

+
+ Decision: Do not log IP addresses or user activity. +
+

Snek does not record IP addresses, request logs, or user behavior patterns. This is a fundamental privacy commitment, not a configuration option.

+ +

No Email Required

+
+ Decision: Registration requires only username and password. +
+

Email is optional. Users can register and participate without providing any personally identifiable information. Password recovery relies on administrator assistance.

+ +

Session Handling

+

Sessions use secure, HTTP-only cookies with server-side storage. Session data is encrypted and expires after configurable inactivity periods.

+ +

Password Storage

+

Passwords are hashed using SHA-256 with a configurable salt. The plain password is never stored or logged.

+
+ +
+

UI/UX Decisions

+ +

Dark Theme Default

+
+ Decision: Use dark theme as the default and primary design. +
+

The dark theme reduces eye strain for extended use, is preferred by developers, and provides better contrast for code display.

+ +

Card-Based Layouts

+

Content is organized in cards with consistent padding, border-radius, and shadow. Cards provide visual grouping and work well across screen sizes.

+ +

Minimal Animation

+

Animations are limited to subtle transitions (hover states, menu opens). No gratuitous motion that could distract or slow down interaction.

+ +

Typography

+
    +
  • Primary font: Segoe UI (system font stack fallback)
  • +
  • Code font: Courier New (monospace)
  • +
  • Base size: 16px with 1.5 line height
  • +
  • Color palette: #eee (text), #7ef (links/accents), #0fa (secondary)
  • +
+ +

No Component Shadow DOM

+
+ Decision: Custom Elements use light DOM with separate CSS files. +
+

Each component has its own CSS file that integrates with the global stylesheet. This allows consistent theming and easier debugging compared to Shadow DOM isolation.

+
+ +
+

Code Standards

+ +

No Comments

+

Code should be self-documenting through clear naming and structure. Comments indicate code that needs refactoring.

+ +

Author Attribution

+

Every file includes the author tag at the top:

+ # retoor <retoor@molodetz.nl> + +

Modular Organization

+

Code is organized into small, focused modules. One class per file for JavaScript. Related functionality grouped in directories.

+ +

Explicit Over Implicit

+

Prefer explicit code paths over magic behavior. No auto-imports, no implicit conversions, no hidden side effects.

+
+
+ + + {% include "sandbox.html" %} + + diff --git a/src/snek/view/site.py b/src/snek/view/site.py new file mode 100644 index 0000000..4d7abb2 --- /dev/null +++ b/src/snek/view/site.py @@ -0,0 +1,28 @@ +# retoor + +from snek.system.view import BaseView + + +class ArchitectureView(BaseView): + async def get(self): + return await self.render_template("site/architecture.html") + + +class DesignView(BaseView): + async def get(self): + return await self.render_template("site/design.html") + + +class ApiView(BaseView): + async def get(self): + return await self.render_template("site/api.html") + + +class BotsView(BaseView): + async def get(self): + return await self.render_template("site/bots.html") + + +class ContributeView(BaseView): + async def get(self): + return await self.render_template("site/contribute.html")