This commit is contained in:
retoor 2026-03-03 20:17:52 +01:00
commit 854045cede
7 changed files with 1135 additions and 0 deletions

25
Makefile Normal file
View File

@ -0,0 +1,25 @@
# retoor <retoor@molodetz.nl>
NIMBLE_BIN := $(HOME)/.nimble/bin
NIM := $(NIMBLE_BIN)/nim
NIMBLE := $(NIMBLE_BIN)/nimble
BIN := sneknim
export PATH := $(NIMBLE_BIN):$(PATH)
.PHONY: all build run clean deps
all: build
deps:
$(NIMBLE) install -d -y
build: deps
$(NIMBLE) build
run: build
./$(BIN) nim.json
clean:
rm -f $(BIN)
rm -rf src/nimcache

78
README.md Normal file
View File

@ -0,0 +1,78 @@
# Sneknim
retoor <retoor@molodetz.nl>
Snek chat bot written in Nim. Connects to the Snek platform via WebSocket, uses DeepSeek for LLM responses, supports tool calling with web search and deep research capabilities.
## Requirements
- Nim >= 2.0.0
- `DEEPSEEK_API_KEY` environment variable
## Build
```sh
nimble install
nimble build
```
## Configuration
Copy `config.json.example` to `config.json` and fill in your Snek credentials:
```json
{
"username": "botname",
"password": "botpassword",
"system_message": "You are a helpful assistant."
}
```
## Usage
```sh
export DEEPSEEK_API_KEY=your_key_here
./sneknim config.json
```
Or with the `--config` flag:
```sh
./sneknim --config config.json
```
## Features
- WebSocket RPC connection with automatic reconnection and exponential backoff
- DeepSeek LLM integration with tool calling support
- Per-channel conversation context with token budgeting
- Web search via rsearch API
- Deep research with iterative search, synthesis, and validation
- Live progress updates via streaming messages
- Typing indicators with random bright colors
- Bot name sanitization to prevent cross-triggering
- Ping/pong auto-response
- Channel join/leave commands via mentions
## Architecture
Single-threaded async via `std/asyncdispatch`. Each incoming message is handled concurrently across channels. Per-channel conversation contexts are stored in a table and trimmed to fit within the 120K token budget.
## Project Structure
```
src/
├── sneknim.nim Entry point, message handling loop
└── sneknim/
├── constants.nim Protocol and timing constants
├── types.nim Shared type definitions
├── config.nim JSON config loading
├── color.nim HSL bright color generation
├── rpc.nim Snek WebSocket RPC client
├── router.nim Message classification and dispatch
├── deepseek.nim DeepSeek API client
├── context.nim Conversation context management
├── tools.nim Tool registry and execution
├── research.nim Deep research state machine
└── search.nim rsearch API client
```

844
apidocs.md Normal file
View File

@ -0,0 +1,844 @@
# Snek RPC Protocol API Specification
retoor <retoor@molodetz.nl>
Version 1.0 — February 2026
---
## Table of Contents
1. [Protocol Overview](#1-protocol-overview)
2. [Connection](#2-connection)
3. [Request/Response Format](#3-requestresponse-format)
4. [Authentication](#4-authentication)
5. [Channel Operations](#5-channel-operations)
6. [Messaging](#6-messaging)
7. [Events](#7-events)
8. [Visual Feedback](#8-visual-feedback)
9. [Message Routing](#9-message-routing)
10. [Streaming](#10-streaming)
11. [Image Handling](#11-image-handling)
12. [Tool System](#12-tool-system)
13. [Error Handling](#13-error-handling)
14. [Constants Reference](#14-constants-reference)
---
## 1. Protocol Overview
The Snek RPC protocol is a JSON-based remote procedure call protocol transported over WebSocket. Clients send method invocations as JSON objects and receive corresponding responses matched by a unique call identifier. The server also pushes unsolicited events (messages, typing indicators, joins/leaves) over the same connection.
Key characteristics:
- **Transport**: WebSocket (RFC 6455)
- **Encoding**: JSON over WebSocket text frames
- **Concurrency model**: Multiplexed — multiple outstanding calls share one connection, matched by `callId`
- **Direction**: Bidirectional — client sends requests, server sends responses and pushes events
---
## 2. Connection
### Endpoint
```
wss://snek.molodetz.nl/rpc.ws
```
Plaintext variant (development only):
```
ws://snek.molodetz.nl/rpc.ws
```
### WebSocket Parameters
| Parameter | Value | Description |
|-----------|-------|-------------|
| Heartbeat interval | 30 seconds | WebSocket ping/pong keepalive (`WS_HEARTBEAT`) |
| Request timeout | 190 seconds | Maximum wait for an RPC response (`DEFAULT_REQUEST_TIMEOUT`) |
| Receive retry delay | 1.0 seconds | Delay before retrying after a receive error (`WS_RECEIVE_RETRY_DELAY`) |
### Connection Lifecycle
```
1. Open WebSocket to wss://snek.molodetz.nl/rpc.ws
2. Call login(username, password)
3. Call get_user(null) to retrieve authenticated user info
4. Call get_channels() to enumerate available channels
5. Enter receive loop — process events and messages
6. On disconnect — reconnect with exponential backoff
```
### Reconnection Strategy
| Parameter | Value |
|-----------|-------|
| Max retries | 3 (`RECONNECT_MAX_RETRIES`) |
| Initial delay | 1.0 seconds (`RECONNECT_INITIAL_DELAY`) |
| Backoff factor | 2.0x (`RECONNECT_BACKOFF_FACTOR`) |
Delays follow: 1s → 2s → 4s. After exhausting retries the client should re-establish the full connection from step 1.
---
## 3. Request/Response Format
### Client Request
Every RPC call from client to server uses the following JSON structure:
```json
{
"method": "<method_name>",
"args": [<positional_arg_1>, <positional_arg_2>, ...],
"kwargs": {<keyword_args>},
"callId": "<unique_identifier>"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `method` | string | yes | RPC method name |
| `args` | array | yes | Positional arguments (may be empty `[]`) |
| `kwargs` | object | yes | Keyword arguments (may be empty `{}`) |
| `callId` | string | yes | Client-generated unique ID to correlate response. Recommended: 16-character hex string (e.g., `os.urandom(8).hex()`) or UUID v4. |
### Server Response
The server responds with a JSON object containing the same `callId`:
```json
{
"callId": "<matching_call_id>",
"data": <result_value>
}
```
| Field | Type | Description |
|-------|------|-------------|
| `callId` | string | Matches the request `callId` |
| `data` | any | Return value — object, array, string, or null depending on the method |
Additional top-level fields may be present depending on context (e.g., `event`, `message`, `username`).
### Fire-and-Forget Requests
Some calls do not require a response. The client sends the request normally but does not wait for a matching `callId` response. This is a client-side optimization — the server may still send a response.
---
## 4. Authentication
### login
Authenticate with the Snek platform.
**Request:**
```json
{
"method": "login",
"args": ["<username>", "<password>"],
"kwargs": {},
"callId": "<id>"
}
```
**Response:**
```json
{
"callId": "<id>",
"data": { ... }
}
```
Must be the first call after establishing the WebSocket connection.
### get_user
Retrieve user information.
**Request:**
```json
{
"method": "get_user",
"args": [null],
"kwargs": {},
"callId": "<id>"
}
```
Pass `null` as the sole argument to retrieve the authenticated user.
**Response:**
```json
{
"callId": "<id>",
"data": {
"username": "botname",
"nick": "BotNick"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `username` | string | Login username (unique identifier) |
| `nick` | string | Display name |
---
## 5. Channel Operations
### get_channels
Retrieve all channels visible to the authenticated user.
**Request:**
```json
{
"method": "get_channels",
"args": [],
"kwargs": {},
"callId": "<id>"
}
```
**Response:**
```json
{
"callId": "<id>",
"data": [
{
"uid": "channel_unique_id",
"name": "Channel Name",
"tag": "dm"
},
...
]
}
```
### Channel Object
| Field | Type | Description |
|-------|------|-------------|
| `uid` | string | Unique channel identifier. Matches pattern `^[a-zA-Z0-9_-]+$` |
| `name` | string | Human-readable channel name |
| `tag` | string | Channel type. `"dm"` indicates a direct message channel |
### Channel Types
| Tag | Behavior |
|-----|----------|
| `"dm"` | Direct message — bot always responds to messages |
| other / none | Public channel — bot responds only if explicitly joined or triggered |
---
## 6. Messaging
### send_message
Send a message to a channel.
**Request:**
```json
{
"method": "send_message",
"args": ["<channel_uid>", "<message_text>", <is_final>],
"kwargs": {},
"callId": "<id>"
}
```
| Argument | Type | Required | Description |
|----------|------|----------|-------------|
| `channel_uid` | string | yes | Target channel UID |
| `message_text` | string | yes | Message content (Markdown supported) |
| `is_final` | boolean | yes | `true` for complete messages, `false` for streaming partial updates |
**Response:**
```json
{
"callId": "<id>",
"data": { ... }
}
```
### Drive URL Rewriting
Relative drive paths are rewritten before processing:
```
(/drive.bin → (https://snek.molodetz.nl/drive.bin
```
---
## 7. Events
The server pushes unsolicited events over the WebSocket connection. Events do not have a `callId` matching any pending request.
### Event Structure
```json
{
"event": "<event_type>",
"data": { ... }
}
```
### Message Event
Messages appear as a special case — they carry top-level fields instead of being nested under `data`:
```json
{
"event": "message",
"message": "Hello world",
"username": "sender_username",
"user_nick": "SenderNick",
"channel_uid": "target_channel_uid",
"is_final": true
}
```
| Field | Type | Description |
|-------|------|-------------|
| `event` | string | `"message"` |
| `message` | string | Message content |
| `username` | string | Sender's username |
| `user_nick` | string | Sender's display name |
| `channel_uid` | string | Channel where the message was sent |
| `is_final` | boolean | `true` for complete messages, `false` for streaming updates |
Clients should ignore messages where `is_final` is `false` unless implementing a streaming display.
### set_typing Event
Server-pushed event indicating a user is typing or providing visual feedback.
```json
{
"event": "set_typing",
"data": { ... }
}
```
### join / leave Events
```json
{
"event": "join",
"data": {
"channel_uid": "<channel_uid>"
}
}
```
```json
{
"event": "leave",
"data": {
"channel_uid": "<channel_uid>"
}
}
```
### Event Handling Pattern
Events are dispatched to handler methods named `on_<event_type>`. The `data` object is unpacked as keyword arguments:
```
Event: {"event": "join", "data": {"channel_uid": "abc123"}}
Handler: on_join(channel_uid="abc123")
```
Unknown events are silently ignored.
---
## 8. Visual Feedback
### set_typing
Set a colored typing indicator for the bot in a channel. Used for visual "thinking" feedback.
**Request:**
```json
{
"method": "set_typing",
"args": ["<channel_uid>", "<html_color_code>"],
"kwargs": {},
"callId": "<id>"
}
```
| Argument | Type | Description |
|----------|------|-------------|
| `channel_uid` | string | Target channel |
| `html_color_code` | string | HTML hex color code (e.g., `"#FF0000"`) |
### Color Generation
Random bright colors are generated using HSL with these ranges:
| Component | Min | Max |
|-----------|-----|-----|
| Hue | 0.0 | 1.0 |
| Saturation | 0.7 | 1.0 |
| Lightness | 0.5 | 0.7 |
The color must match the pattern `^#[0-9A-Fa-f]{6}$`.
---
## 9. Message Routing
Incoming messages are classified and routed in the following priority order:
| Priority | Condition | Handler |
|----------|-----------|---------|
| 1 | `username == self.username` | `on_own_message(channel_uid, message)` |
| 2 | Message starts with `"ping"` | `on_ping(username, user_nick, channel_uid, message_after_ping)` |
| 3 | Message contains `@nick join` or `@username join` | `on_join(channel_uid)` |
| 4 | Message contains `@nick leave` or `@username leave` | `on_leave(channel_uid)` |
| 5 | Message contains `@nick` or `@username` | `on_mention(username, user_nick, channel_uid, message)` |
| 6 | Default | `on_message(username, user_nick, channel_uid, message)` |
### Channel Permission Rules
For `on_message` processing, the bot applies these rules:
1. **DM channels** (`tag == "dm"`): Always respond.
2. **Public channels**: Respond only if:
- The bot has been explicitly joined to the channel, OR
- The message matches a configured trigger pattern, OR
- The bot's username appears in the message text.
### Ping/Pong
Messages starting with `"ping"` trigger an automatic `"pong"` response:
```
Incoming: "ping hello"
Response: "pong hello"
```
---
## 10. Streaming
The protocol supports incremental message delivery using the `is_final` field on `send_message`.
### Sending Streaming Messages
```
1. send_message(channel_uid, "Partial cont...", false) ← partial update
2. send_message(channel_uid, "Partial content h...", false) ← partial update
3. send_message(channel_uid, "Partial content here.", true) ← final message
```
Each partial message replaces the previous one in the UI. The final message (`is_final=true`) marks the message as complete.
### Receiving Streaming Messages
When receiving events, messages with `is_final=false` represent in-progress content from another user or bot. Standard bot implementations skip non-final messages and only process the final version:
```
if not data.is_final:
continue
```
### Streaming Update Interval
The minimum interval between streaming updates is `0.0` seconds (`STREAMING_UPDATE_INTERVAL`), meaning updates are sent as fast as they are generated.
---
## 11. Image Handling
### URL Extraction
Image URLs are extracted from message text using the pattern:
```
https?://\S+\.(?:png|jpg|jpeg|gif|bmp|webp|svg)(?:\?\S*)?
```
Matched URLs are stripped of trailing `.`, `'`, and `"` characters.
### Image Formats
Two encoding formats are supported:
#### OpenAI Format (default)
Images are sent as separate content blocks in the OpenAI multi-modal message format:
```json
{
"role": "user",
"content": [
{
"type": "text",
"text": "message text with URL removed"
},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,<base64_encoded_data>"
}
}
]
}
```
#### DeepSeek Format
Images are inlined into the text content as tagged references:
```json
{
"role": "user",
"content": [
{
"type": "text",
"text": "message with [image: data:image/png;base64,<base64_data>] inline"
}
]
}
```
### MIME Type Detection
MIME types are resolved in order:
1. File extension via `mimetypes.guess_type()`
2. Magic bytes detection:
| Bytes | MIME Type |
|-------|-----------|
| `\x89PNG\r\n\x1a\n` | `image/png` |
| `\xff\xd8` | `image/jpeg` |
| `GIF87a` or `GIF89a` | `image/gif` |
| `RIFF....WEBP` | `image/webp` |
3. Fallback: `image/png`
---
## 12. Tool System
The tool system follows the OpenAI function calling specification. Tools are serialized as function definitions, sent alongside the chat completion request, and executed when the LLM returns `tool_calls`.
### Tool Definition Schema
Each tool is serialized as:
```json
{
"type": "function",
"function": {
"name": "<method_name>",
"description": "<docstring>",
"parameters": {
"type": "object",
"properties": {
"<param_name>": {
"type": "<json_type>",
"default": "<default_value>"
}
},
"required": ["<param_without_default>"]
}
}
}
```
### Type Mapping
| Python Type | JSON Schema Type |
|-------------|-----------------|
| `str` | `"string"` |
| `int` | `"integer"` |
| `bool` | `"boolean"` |
| `list` | `"array"` |
| `dict` | `"object"` |
| `None` | `"null"` |
| (default) | `"string"` |
Parameters with default values are optional; parameters without defaults are listed in `required`.
### Tool Call Flow
```
1. Client sends chat completion request with tools array
2. LLM returns response with tool_calls array
3. Client executes each tool call
4. Client sends tool results back as role:"tool" messages
5. Client sends another chat completion request with updated context
6. Repeat until LLM returns a text response without tool_calls
```
### Tool Call Response (from LLM)
```json
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "database_find",
"arguments": "{\"table\": \"users\", \"query\": {\"active\": true}}"
}
}
]
}
```
### Tool Result Message
```json
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "<stringified_result>"
}
```
### Tool Call Limits
| Parameter | Value | Description |
|-----------|-------|-------------|
| Max depth | 25 | Maximum consecutive tool call rounds (`MAX_TOOL_CALL_DEPTH`) |
| Max repeated calls | 5 | Maximum identical tool invocations (`MAX_REPEATED_TOOL_CALLS`) |
| Max consecutive errors | 3 | Abort tool loop after 3 consecutive errors |
### Method Exclusions
Methods are excluded from tool serialization if:
- The name starts with `_`
- The name is `serialize` or `handle`
- The attribute is not callable
---
## 13. Error Handling
### Exception Hierarchy
```
BotError
├── ConnectionError Connection/WebSocket failures
├── AuthenticationError Login failures
├── RPCError RPC call failures, timeouts
├── ToolError Tool execution failures
├── DatabaseError Database operation failures
├── ValidationError Input validation failures
└── CircuitBreakerOpenError Circuit breaker tripped
```
### RPC Error Conditions
| Condition | Behavior |
|-----------|----------|
| WebSocket closed/closing | `RPCError` raised |
| WebSocket error frame | `RPCError` raised |
| Response timeout (190s) | `RPCError` raised |
| Invalid JSON in response | Logged as warning, skipped |
| Connection closed during call | `RPCError` raised |
### Reconnection on Send Failure
When `send_message` fails, the client retries with exponential backoff:
```
Attempt 1: send → fail → wait 1s
Attempt 2: reconnect + send → fail → wait 2s
Attempt 3: reconnect + send → fail → raise ConnectionError
```
Each reconnection attempt performs a full login sequence on a new WebSocket.
### Circuit Breaker
Tool execution is protected by a circuit breaker pattern:
| Parameter | Value |
|-----------|-------|
| Failure threshold | 5 consecutive failures |
| Recovery timeout | 60 seconds |
| Half-open max calls | 3 |
States: `CLOSED` → (threshold exceeded) → `OPEN` → (timeout elapsed) → `HALF_OPEN` → (success) → `CLOSED`
When the circuit breaker is open, tool calls fail immediately with `CircuitBreakerOpenError`.
---
## 14. Constants Reference
### Connection
| Constant | Value | Description |
|----------|-------|-------------|
| `DEFAULT_WS_URL` | `wss://snek.molodetz.nl/rpc.ws` | Default WebSocket endpoint |
| `DEFAULT_OPENAI_URL` | `https://api.openai.com` | Default LLM API base URL |
| `DEFAULT_SEARCH_API_BASE` | `https://search.molodetz.nl` | Default search API endpoint |
| `WS_HEARTBEAT` | 30.0s | WebSocket heartbeat interval |
| `DEFAULT_REQUEST_TIMEOUT` | 190s | RPC call timeout |
| `HTTP_CONNECT_TIMEOUT` | 90.0s | HTTP TCP connect timeout |
### Reconnection
| Constant | Value | Description |
|----------|-------|-------------|
| `RECONNECT_MAX_RETRIES` | 3 | Maximum reconnection attempts |
| `RECONNECT_INITIAL_DELAY` | 1.0s | First retry delay |
| `RECONNECT_BACKOFF_FACTOR` | 2.0 | Delay multiplier per retry |
| `WS_RECEIVE_RETRY_DELAY` | 1.0s | Delay after receive error |
### Concurrency
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_CONCURRENT_OPERATIONS` | 100 | WebSocket semaphore limit |
| `MAX_CONCURRENT_REQUESTS` | 10 | HTTP connection pool limit |
| `THREAD_POOL_WORKERS` | 8 | Thread pool for blocking operations |
| `PROCESS_POOL_WORKERS` | 4 | Process pool for CPU-intensive tasks |
| `EXECUTOR_TIMEOUT` | 30.0s | Thread/process pool call timeout |
### Message Handling
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_MESSAGE_HISTORY` | 50 | Recent messages retained in memory |
| `DEFAULT_OPENAI_LIMIT` | 200 | Max messages sent to LLM per session |
| `CONTEXT_WINDOW_TOKEN_LIMIT` | 120000 | Token budget for LLM context |
| `STREAMING_UPDATE_INTERVAL` | 0.0s | Min interval between streaming updates |
### Tool Limits
| Constant | Value | Description |
|----------|-------|-------------|
| `MAX_TOOL_CALL_DEPTH` | 25 | Max consecutive tool call rounds |
| `MAX_REPEATED_TOOL_CALLS` | 5 | Max identical tool invocations |
| `MAX_RETRY_ATTEMPTS` | 3 | General retry limit |
### Timing
| Constant | Value | Description |
|----------|-------|-------------|
| `TASK_CHECK_INTERVAL` | 0.5s | Service loop tick interval |
| `THINKING_TASK_INTERVAL` | 1.0s | Typing indicator refresh interval |
| `SERVICE_CLEANUP_INTERVAL` | 3600s | Periodic cleanup cycle |
| `SHUTDOWN_TIMEOUT` | 5.0s | Max wait for graceful shutdown |
| `MIN_COUNTER_UPDATE_INTERVAL` | 240s | Debounce for counter updates |
### Agentic
| Constant | Value | Description |
|----------|-------|-------------|
| `AGENTIC_MAX_RESEARCH_QUERIES` | 8 | Max search queries per research task |
| `AGENTIC_MAX_PLAN_STEPS` | 10 | Max steps in a generated plan |
| `AGENTIC_SCRATCHPAD_SIZE` | 50 | Scratchpad buffer size |
### Visual
| Constant | Value | Description |
|----------|-------|-------------|
| `COLOR_HUE_MIN` | 0.0 | HSL hue range start |
| `COLOR_HUE_MAX` | 1.0 | HSL hue range end |
| `COLOR_SATURATION_MIN` | 0.7 | HSL saturation range start |
| `COLOR_SATURATION_MAX` | 1.0 | HSL saturation range end |
| `COLOR_LIGHTNESS_MIN` | 0.5 | HSL lightness range start |
| `COLOR_LIGHTNESS_MAX` | 0.7 | HSL lightness range end |
### Media
| Constant | Value | Description |
|----------|-------|-------------|
| `MEDIA_HUNT_DEDUP_MAX_SIZE` | 10000 | Dedup cache max entries |
| `MEDIA_HUNT_DEDUP_TTL` | 86400s | Dedup cache TTL (24 hours) |
| `MEDIA_HUNT_HEAD_TIMEOUT` | 5.0s | HEAD request timeout |
| `MEDIA_HUNT_FETCH_TIMEOUT` | 30.0s | Full fetch timeout |
### Bot Name Sanitization
The following bot names are sanitized in outgoing messages to prevent cross-triggering:
```
snek, grok, snik, lisa, gemma, joanne, ira, thomas
```
Sanitization inserts a hyphen after the first character (e.g., `snek``s-nek`).
---
## Appendix A: Complete Connection Example
```
CLIENT → WebSocket CONNECT wss://snek.molodetz.nl/rpc.ws
SERVER ← 101 Switching Protocols
CLIENT → {"method":"login","args":["mybot","mypassword"],"kwargs":{},"callId":"a1b2c3d4e5f6g7h8"}
SERVER ← {"callId":"a1b2c3d4e5f6g7h8","data":{...}}
CLIENT → {"method":"get_user","args":[null],"kwargs":{},"callId":"b2c3d4e5f6g7h8a1"}
SERVER ← {"callId":"b2c3d4e5f6g7h8a1","data":{"username":"mybot","nick":"MyBot"}}
CLIENT → {"method":"get_channels","args":[],"kwargs":{},"callId":"c3d4e5f6g7h8a1b2"}
SERVER ← {"callId":"c3d4e5f6g7h8a1b2","data":[{"uid":"ch1","name":"general","tag":"public"},{"uid":"ch2","name":"DM","tag":"dm"}]}
--- receive loop ---
SERVER ← {"event":"message","message":"hello @MyBot","username":"alice","user_nick":"Alice","channel_uid":"ch1","is_final":true}
CLIENT → {"method":"send_message","args":["ch1","hey, what's up?",true],"kwargs":{},"callId":"d4e5f6g7h8a1b2c3"}
SERVER ← {"callId":"d4e5f6g7h8a1b2c3","data":{...}}
```
## Appendix B: Streaming Example
```
CLIENT → {"method":"set_typing","args":["ch1","#FF6B35"],"kwargs":{},"callId":"e5f6g7h8a1b2c3d4"}
CLIENT → {"method":"send_message","args":["ch1","Working on",false],"kwargs":{},"callId":"f6g7h8a1b2c3d4e5"}
CLIENT → {"method":"send_message","args":["ch1","Working on it...",false],"kwargs":{},"callId":"g7h8a1b2c3d4e5f6"}
CLIENT → {"method":"send_message","args":["ch1","Working on it... done!",true],"kwargs":{},"callId":"h8a1b2c3d4e5f6g7"}
```
## Appendix C: Bot State Machine
```
INITIALIZING → INITIALIZED → CONNECTING → CONNECTED → RUNNING → SHUTTING_DOWN → SHUTDOWN
ERROR
```
| State | Description |
|-------|-------------|
| `INITIALIZING` | HTTP sessions, signal handlers, and background tasks being set up |
| `INITIALIZED` | Setup complete, ready to connect |
| `CONNECTING` | WebSocket connection in progress |
| `CONNECTED` | Authenticated and channels enumerated |
| `RUNNING` | Processing messages in the receive loop |
| `SHUTTING_DOWN` | Graceful shutdown initiated, cancelling background tasks |
| `SHUTDOWN` | All resources released |
| `ERROR` | Initialization or fatal runtime failure |

5
config.json.example Normal file
View File

@ -0,0 +1,5 @@
{
"username": "botname",
"password": "botpassword",
"system_message": "You are a helpful assistant on the Snek chat platform. Be concise and accurate."
}

11
sneknim.nimble Normal file
View File

@ -0,0 +1,11 @@
# retoor <retoor@molodetz.nl>
version = "1.0.0"
author = "retoor"
description = "Snek chat bot with DeepSeek LLM and tool support"
license = "MIT"
srcDir = "src"
bin = @["sneknim"]
requires "nim >= 2.0.0"
requires "ws >= 0.5.0"

1
src/nim.cfg Normal file
View File

@ -0,0 +1 @@
-d:ssl

171
src/sneknim.nim Normal file
View File

@ -0,0 +1,171 @@
# retoor <retoor@molodetz.nl>
import std/[asyncdispatch, json, os, strutils, sets, tables, random, strformat, logging]
import sneknim/[constants, types, config, color, rpc, router, deepseek, context, tools]
var
botConfig: BotConfig
rpcClient: SnekRpc
toolRegistry: ToolRegistry
contexts: Table[string, ChannelContext]
joinedChannels: HashSet[string]
channelMap: Table[string, ChatChannel]
proc getChannelTag(channelUid: string): string =
if channelMap.hasKey(channelUid):
return channelMap[channelUid].tag
return ""
proc getOrCreateContext(channelUid: string): ChannelContext =
if not contexts.hasKey(channelUid):
contexts[channelUid] = newChannelContext(channelUid, botConfig.systemMessage)
return contexts[channelUid]
proc sendProgress(channelUid, text: string, isFinal: bool): Future[void] {.async.} =
await rpcClient.sendMessage(channelUid, text, isFinal)
proc handleMessage(username, userNick, channelUid, message: string) {.async.} =
let action = classifyWithJoined(
username, message,
rpcClient.username, rpcClient.nick,
channelUid, joinedChannels,
getChannelTag(channelUid)
)
debug("Message action: " & $action & " from " & username & " in " & channelUid)
case action
of Ignore:
return
of RespondPing:
let rest = message[4 .. ^1].strip()
await rpcClient.sendMessage(channelUid, "pong " & rest, true)
of HandleJoin:
joinedChannels.incl(channelUid)
info("Joined channel: " & channelUid)
await rpcClient.sendMessage(channelUid, "Joined channel.", true)
of HandleLeave:
joinedChannels.excl(channelUid)
info("Left channel: " & channelUid)
await rpcClient.sendMessage(channelUid, "Left channel.", true)
of RespondChat:
try:
asyncCheck rpcClient.setTyping(channelUid, randomBrightColor())
let ctx = getOrCreateContext(channelUid)
ctx.addUserMessage(username, message)
var depth = 0
var repeatedCalls: Table[string, int]
var consecutiveErrors = 0
while depth < MaxToolCallDepth:
inc depth
debug(fmt"Tool call iteration {depth}/{MaxToolCallDepth} in {channelUid}")
asyncCheck rpcClient.setTyping(channelUid, randomBrightColor())
let payload = ctx.buildPayload()
let toolDefs = toolRegistry.getDefinitions()
let resp = await chatCompletion(payload, toolDefs)
if resp.hasKey("tool_calls") and resp["tool_calls"].kind == JArray and resp["tool_calls"].len > 0:
let assistantContent = resp{"content"}.getStr()
ctx.addAssistantToolCalls(assistantContent, resp["tool_calls"])
let toolCallCount = resp["tool_calls"].len
debug(fmt"Received {toolCallCount} tool calls")
for tc in resp["tool_calls"]:
let tcId = tc["id"].getStr()
let funcName = tc["function"]["name"].getStr()
let funcArgs = try: parseJson(tc["function"]["arguments"].getStr())
except: newJObject()
let callKey = funcName & ":" & $funcArgs
discard repeatedCalls.mgetOrPut(callKey, 0)
inc repeatedCalls[callKey]
if repeatedCalls[callKey] > MaxRepeatedToolCalls:
warn("Tool " & funcName & " exceeded max repeated calls")
ctx.addToolResult(tcId, funcName, "Error: tool called too many times with same arguments")
continue
try:
let toolResult = await toolRegistry.execute(funcName, funcArgs, channelUid)
debug("Tool " & funcName & " result length: " & $toolResult.len)
ctx.addToolResult(tcId, funcName, toolResult)
consecutiveErrors = 0
except CatchableError as e:
error("Tool " & funcName & " failed: " & e.msg)
ctx.addToolResult(tcId, funcName, "Error: " & e.msg)
inc consecutiveErrors
if consecutiveErrors >= MaxConsecutiveErrors:
break
if consecutiveErrors >= MaxConsecutiveErrors:
error("Max consecutive tool errors reached in " & channelUid)
break
continue
let content = resp{"content"}.getStr()
if content.len > 0:
let sanitized = sanitizeBotNames(content)
ctx.addAssistantMessage(sanitized)
await rpcClient.sendMessage(channelUid, sanitized, true)
break
except CatchableError as e:
error("handleMessage failed in " & channelUid & ": " & e.msg)
try:
await rpcClient.sendMessage(channelUid, "An error occurred while processing your message.", true)
except CatchableError:
discard
proc main() {.async.} =
randomize()
let logger = newConsoleLogger(lvlDebug, "[$datetime] $levelname: ")
addHandler(logger)
let configPath = if paramCount() >= 1:
let arg = paramStr(1)
if arg == "--config" and paramCount() >= 2: paramStr(2)
else: arg
else:
"config.json"
botConfig = loadConfig(configPath)
initDeepseek()
rpcClient = newSnekRpc()
toolRegistry = newToolRegistry()
toolRegistry.sendProgress = sendProgress
toolRegistry.registerDefaultTools()
contexts = initTable[string, ChannelContext]()
joinedChannels = initHashSet[string]()
channelMap = initTable[string, ChatChannel]()
info("Connecting to Snek...")
await rpcClient.connect()
asyncCheck rpcClient.receiveLoop(botConfig.username, botConfig.password)
asyncCheck rpcClient.startHeartbeat()
info("Logging in as " & botConfig.username & "...")
await rpcClient.login(botConfig.username, botConfig.password)
await rpcClient.getUser()
info("Authenticated as " & rpcClient.username & " (" & rpcClient.nick & ")")
await rpcClient.getChannels()
for ch in rpcClient.channels:
channelMap[ch.uid] = ch
info(fmt"Loaded {rpcClient.channels.len} channels")
rpcClient.onMessage = proc(username, userNick, channelUid, message: string): Future[void] {.async.} =
await handleMessage(username, userNick, channelUid, message)
info("Bot is running. Listening for messages...")
while true:
await sleepAsync(60_000)
when isMainModule:
waitFor main()