diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b3b6414..8de80d5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,10 +55,18 @@ jobs: run: | if [ -f Cargo.toml ]; then cargo build --verbose; fi - - name: Run Rust tests + - name: Run Rust tests (default features) run: | if [ -f Cargo.toml ]; then cargo test --verbose; fi + - name: Check real-tdlib feature compiles + run: | + if [ -f Cargo.toml ]; then cargo check -p octo-adapter-telegram --features real-tdlib --no-default-features; fi + + - name: Clippy real-tdlib + run: | + if [ -f Cargo.toml ]; then cargo clippy -p octo-adapter-telegram --all-targets --features real-tdlib -- -D warnings; fi + - name: Run JS tests run: | if [ -f package.json ]; then npm test || true; fi diff --git a/.gitignore b/.gitignore index d19e0577..49d7cd0b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,24 @@ Cargo.lock .python-version **/__pycache__/ *.egg-info/ + +# Claude Code +.claude/plans/ + +# Adversarial review artifacts (local scratchpads, not committed per memory rule) +docs/reviews/ + +# CocoIndex +.cocoindex_code/ +coco.toml +cocoindex_main.py + +# JCode +# .jcode/ + +# Understand-Anything knowledge graph +.understand-anything/ + +# Python lockfile +uv.lock +.env diff --git a/.jcode/memory/MEMORY.md b/.jcode/memory/MEMORY.md new file mode 100644 index 00000000..fafe003e --- /dev/null +++ b/.jcode/memory/MEMORY.md @@ -0,0 +1,91 @@ +# CipherOcto Memory + +## Project Overview + +- **Protocol** for autonomous intelligence collaboration (private AI, local infra, hybrid blockchain) +- **Ocean Stack**: πŸ™ Assistant β†’ Agent Orchestrator β†’ πŸ¦‘ Secure Execution β†’ πŸͺΌ Hybrid Network +- **Branch Strategy**: trunk-based (main/next/feat/*/agent/*/research/*/hotfix/*) + +## Current Focus + +- RFC-0104: Deterministic Floating-Point (DFP) implementation +- quota-router-cli: Rust CLI for AI API quotas with HTTPS proxy + +## Architecture + +- **Determinism**: Class A (Protocol), Class B (Off-Chain), Class C (Probabilistic) + +## GitNexus MCP + +**Setup:** Connected via `~/.jcode/mcp.json` with command `gitnexus mcp` + +**12 repos indexed:** +- cipherocto (3,761 nodes, 8,573 edges, 291 processes, 2,436 embeddings) +- stoolap, litellm, langflow, openclaw, any-llm, and more + +**Tools:** list_repos, query, context, impact, detect_changes, rename, cypher + +--- + +## CRITICAL RULES + +1. **Git: Never push without authorization** β€” commits OK, push requires user permission +2. **Always solve ALL RFC issues** β€” no deferrals, fix now or formal rebuttal only +3. **Cargo fmt before commit** β€” run `cargo fmt -- --check` before every commit +4. **Mode Gate β‰  Interface** β€” HTTP proxy AND Python SDK exist in ALL modes (litellm/any-llm/full) +5. **RFC references by number only** β€” no version pins, no status (e.g., RFC-0917 not RFC-0917 v2.35) +6. **docs/reviews/ are scratchpads** β€” NEVER committed to git + +--- + +## BLUEPRINT Governance + +### The 4 Layers (never mix) +| Layer | Question | Purpose | +|-------|----------|---------| +| Research | CAN WE? | Feasibility | +| Use Cases | WHY? | Intent/Narrative | +| RFCs | WHAT? | Protocol Design | +| Missions | HOW? | Execution | + +### Canonical Flow +`Idea β†’ Research β†’ Use Case β†’ RFC β†’ Mission β†’ Agent Claims β†’ Implementation β†’ Merge β†’ Protocol Evolution` + +### RFC Lifecycle +`Planned β†’ Draft β†’ Review (7+ days) β†’ Accepted β†’ Final` + +### Mission Rules +- REQUIRE an Accepted RFC β€” no RFC = no Mission +- 14-day claim timeout, 7-day PR review timeout +- Use `git mv` for status updates (preserves rename history) + +### RFC Status Update Process + +When updating RFC status (e.g., Draft β†’ Accepted): + +1. **Verify content first** β€” read both files, confirm headers/sections correct +2. **Use `git mv`** β€” track rename so git sees R100, not A+D +3. **Update Status header via sed** β€” `sed -i 's/Draft (/Accepted (/'` +4. **Stage and verify separately** β€” `git diff --cached --name-status` should show R100 + +```bash +# Verify rename tracked: +git diff --cached --name-status # Should show R100 + +# Verify sections after move: +grep "^### Section:" accepted/file.md +``` + +**Content Swap Risk:** When moving multiple RFCs, avoid file-swapping operations. + +### Human vs Agent +- Humans: Create Use Cases, Accept RFCs, Merge PRs +- Agents: Claim Missions, Implement RFCs, Write Tests +- Agents CANNOT initiate RFCs or create Use Cases + +--- + +## Dependencies + +- Rust (cargo, tokio, hyper, clap), Python (PyO3) +- GitNexus MCP for code intelligence diff --git a/.jcode/skills/cocoindex/SKILL.md b/.jcode/skills/cocoindex/SKILL.md new file mode 100644 index 00000000..480bee68 --- /dev/null +++ b/.jcode/skills/cocoindex/SKILL.md @@ -0,0 +1,403 @@ +--- +name: cocoindex +description: CocoIndex for incremental data pipelines. Use when building code indexing, vector embeddings, knowledge graphs, or ETL workflows with automatic change detection. Pipelines are project-agnostic β€” COPY them to new projects, do NOT rewrite. +keywords: copy, replicate, clone, setup pipeline, new project, index project, add cocoindex, data pipeline, embedding, vector search, incremental +--- + +# CocoIndex + +Python library for incremental data transformation. Think spreadsheets or React for data pipelines: declare what the output should look like based on current input, and CocoIndex handles incremental updates, change detection, and syncing. + +## CRITICAL: Copy, Don't Rebuild + +The pipelines under `pipelines/` and `cocoindex_main.py` are **project-agnostic**. When setting up CocoIndex in a new project: + +1. **COPY** the files directly: `cp cocoindex_main.py / && cp -r pipelines/ /` +2. **DO NOT** rewrite, adapt, or "port" them β€” they work as-is for any codebase +3. **DO NOT** read the files and then produce new files with the same logic +4. The only per-project setup is: `.env` file, `.cocoindex_code/` directory, `.gitignore` entries + +**Red flags that you're about to do it wrong:** +- "Let me understand the pipeline structure first, then create one for this project" +- "This project is different because it uses Rust/Python/Go" β€” the pipelines already handle all languages +- "I need to adapt the config" β€” the config uses relative paths and generic patterns +- Reading all files to "learn the patterns" then writing new files = rewriting. Just copy. + +If you find yourself reading pipeline source files and then writing new pipeline files, **STOP**. Use `cp` instead. + +## Setup for a New Project + +```bash +# 1. Copy pipelines from an existing project (e.g. cipherocto) +cp /path/to/source/cocoindex_main.py . +cp -r /path/to/source/pipelines/ . + +# 2. Create .env +echo "COCOINDEX_DB=.cocoindex_code/cocoindex.db" > .env + +# 3. Create data directory +mkdir -p .cocoindex_code + +# 4. Add to .gitignore +echo ".cocoindex_code/" >> .gitignore +echo ".env" >> .gitignore +``` + +## CLI Commands + +```bash +# List all apps +.venv/bin/cocoindex ls cocoindex_main.py + +# Show app stable paths +.venv/bin/cocoindex show cocoindex_main.py + +# Run incremental update (processes only changed files) +.venv/bin/cocoindex update -f cocoindex_main.py + +# Live mode β€” keeps watching for changes +.venv/bin/cocoindex update -fL cocoindex_main.py + +# Drop app and all target state +.venv/bin/cocoindex drop cocoindex_main.py + +# Force reprocess everything +.venv/bin/cocoindex update -f --full-reprocess cocoindex_main.py + +# Reset and reprocess +.venv/bin/cocoindex update -f --reset cocoindex_main.py +``` + +## Environment + +Loads `.env` from current directory. Key variable: +- `COCOINDEX_DB` β€” path to internal state database (default: `./cocoindex.db`) + +```bash +.venv/bin/cocoindex -e .env update -f cocoindex_main.py +``` + +## Core Concepts + +### Apps + +An **App** is the top-level executable that binds a main function with parameters: + +```python +import cocoindex as coco + +@coco.fn +async def app_main(sourcedir: pathlib.Path) -> None: + ... + +app = coco.App( + coco.AppConfig(name="MyApp"), + app_main, + sourcedir=pathlib.Path("./data"), +) +``` + +### Functions (`@coco.fn`) + +The `@coco.fn` decorator marks functions as CocoIndex processing functions. Add `memo=True` to skip re-execution when inputs/code unchanged: + +```python +@coco.fn(memo=True) +async def expensive_operation(data: str) -> Result: + return await expensive_transform(data) +``` + +### Lifespan (`@coco.lifespan`) + +Use `@coco.lifespan` for setup/teardown of resources like database connections: + +```python +@coco.lifespan +def my_lifespan(builder: coco.EnvironmentBuilder) -> Iterator[None]: + with sqlite.managed_connection("target.db") as conn: + builder.provide(SQLITE_DB, conn) + yield +``` + +### Target States + +Declare what should exist β€” CocoIndex handles creation/update/deletion: + +```python +@dataclass +class MyRecord: + id: int + name: str + +table.declare_row(row=MyRecord(id=1, name="example")) +``` + +### Connectors + +| Connector | Source | Target | Vectors | +|-----------|--------|--------|---------| +| PostgreSQL | Y | Y | pgvector | +| SQLite | - | Y | sqlite-vec | +| LanceDB | - | Y | Y | +| Qdrant | - | Y | Y | +| LocalFS | Y | Y | N/A | +| S3 | Y | - | N/A | +| Kafka | Y | Y | N/A | + +## App Structure + +```python +import pathlib +from typing import Iterator +import cocoindex as coco +from cocoindex.connectors import localfs, sqlite +from cocoindex.ops.text import RecursiveSplitter, detect_code_language +from cocoindex.resources.chunk import Chunk +from cocoindex.resources.file import FileLike, PatternFilePathMatcher +from cocoindex.resources.id import IdGenerator + +@coco.lifespan +def coco_lifespan(builder: coco.EnvironmentBuilder) -> Iterator[None]: + with sqlite.managed_connection("target.db") as conn: + builder.provide(SQLITE_DB, conn) + yield + +app = coco.App(coco.AppConfig(name="MyApp"), app_main) +``` + +## File Walking + +```python +files = localfs.walk_dir( + pathlib.Path("."), + recursive=True, + path_matcher=PatternFilePathMatcher( + included_patterns=["**/*.py", "**/*.rs"], + excluded_patterns=["**/target", "**/node_modules"], + ), +) +``` + +## Text Splitting + +```python +splitter = RecursiveSplitter() +language = detect_code_language(filename="example.py") +chunks = splitter.split(text, chunk_size=1000, chunk_overlap=200, language=language) +``` + +## Processing Pattern + +```python +@coco.fn(memo=True) +async def process_file(file: FileLike, table: sqlite.TableTarget) -> None: + text = await file.read_text() + chunks = _splitter.split(text, chunk_size=1000, chunk_overlap=200) + id_gen = IdGenerator() + await coco.map(process_chunk, chunks, file.file_path.path, id_gen, table) + +@coco.fn +async def process_chunk( + chunk: Chunk, filename: pathlib.PurePath, + id_gen: IdGenerator, table: sqlite.TableTarget, +) -> None: + table.declare_row(row=MyDataclass( + id=await id_gen.next_id(chunk.text), + filename=str(filename), + text=chunk.text, + )) +``` + +## Wiring + +```python +@coco.fn +async def app_main() -> None: + files = localfs.walk_dir(...) + table = await sqlite.mount_table_target( + SQLITE_DB, "table_name", + await sqlite.TableSchema.from_class(MyDataclass, primary_key=["id"]), + ) + await coco.mount_each(process_file, files.items(), table) +``` + +## Best Practices + +1. Use `@coco.fn` on all processing functions +2. Add `memo=True` for expensive operations (embeddings, LLM calls) +3. Use `@coco.lifespan` for database connections (not manual sqlite3.connect) +4. Use dataclasses for record types (not dict) +5. Enable WAL mode: `conn.execute("PRAGMA journal_mode=WAL")` +6. Store numpy arrays as JSON strings: `json.dumps(embedding.tolist())` + +## Index Location (any project) + +- `.cocoindex_code/cocoindex.db/` β€” internal state (LMDB) +- `.cocoindex_code/target_sqlite.db` β€” target output (SQLite) +- `.cocoindex_code/settings.yml` β€” file patterns +- `cocoindex_main.py` β€” app module + +## Pipeline Structure + +``` +pipelines/ +β”œβ”€β”€ shared/ +β”‚ β”œβ”€β”€ config.py # Generic configuration (no project names) +β”‚ β”œβ”€β”€ models.py # Data models +β”‚ └── utils.py # Shared search/embedding utilities (numpy vectorized) +β”œβ”€β”€ sources/ +β”‚ β”œβ”€β”€ metadata_extraction.py # Extract symbols (fn, class, struct, etc.) +β”‚ β”œβ”€β”€ import_graph.py # Map inter-file import dependencies +β”‚ β”œβ”€β”€ api_extractor.py # Extract HTTP API endpoints +β”‚ └── test_indexer.py # Index test files separately +└── targets/ + β”œβ”€β”€ file_summary.py # Create file overview summaries + β”œβ”€β”€ embedding_generator.py # Generate vector embeddings for symbols + β”œβ”€β”€ direct_embed.py # Generate chunk embeddings (sync, fast) + β”œβ”€β”€ similarity_search.py # Re-exports from shared/utils.py + └── search_cli.py # Semantic search CLI (chunks + symbols) + +cocoindex_main.py # Main code indexer (chunking) +``` + +## Pipeline Registry + +### Source Pipelines (write to target_sqlite.db) + +| Pipeline | App Name | File | Target Table | What It Does | +|----------|----------|------|--------------|--------------| +| Main indexer | `code-indexer` | `cocoindex_main.py` | `code_chunks` | Walks files, splits into chunks with line ranges | +| Metadata extraction | `metadata-extraction` | `pipelines/sources/metadata_extraction.py` | `code_symbols` | Extracts function/class/struct names via regex | +| Import graph | `import-graph` | `pipelines/sources/import_graph.py` | `import_graph` | Maps `use`/`import`/`from` dependencies between files | +| API extractor | `api-extractor` | `pipelines/sources/api_extractor.py` | `api_endpoints` | Finds HTTP endpoints (FastAPI, Express, Actix, Go) | +| Test indexer | `test-indexer` | `pipelines/sources/test_indexer.py` | `test_index` | Indexes test functions by framework (pytest, jest, rust) | + +### Target Pipelines (read from + write to target_sqlite.db) + +| Pipeline | App Name | File | Reads From | Writes To | +|----------|----------|------|------------|-----------| +| File summaries | `file-summaries` | `pipelines/targets/file_summary.py` | source files | `file_summaries` | +| Symbol embeddings | `symbol-embeddings` | `pipelines/targets/embedding_generator.py` | `code_symbols` | `symbol_embeddings` | +| Direct embed | β€” | `pipelines/targets/direct_embed.py` | `code_chunks` | `code_chunks_embeddings` | + +### Search Tools (read-only) + +| Tool | File | Reads From | +|------|------|------------| +| Search CLI | `pipelines/targets/search_cli.py` | `code_chunks_embeddings`, `symbol_embeddings` | +| Similarity search | `pipelines/targets/similarity_search.py` | same (re-exports from utils) | + +## Database Schema + +**8 tables in `.cocoindex_code/target_sqlite.db`:** + +| Table | Columns | Primary Key | +|-------|---------|-------------| +| `code_chunks` | id, filename, code, start_line, end_line | id | +| `code_symbols` | name, kind, file_path, line, signature, language | (name, kind, file_path) | +| `file_summaries` | path, file_type, category, size_bytes, line_count, symbol_count, import_count, has_docs, first_heading | path | +| `import_graph` | source_file, target_module, import_type, line_number | (source_file, target_module, line_number) | +| `api_endpoints` | file_path, method, path, handler, line_number | (file_path, method, path, line_number) | +| `test_index` | file_path, test_name, test_type, framework, line_number, content | (file_path, test_name, line_number) | +| `symbol_embeddings` | name, kind, file_path, line, signature, language, embedding (JSON) | (name, kind, file_path) | +| `code_chunks_embeddings` | chunk_id, filename, chunk_text, start_line, end_line, embedding (JSON), created_at | chunk_id | + +## Dependencies & Execution Order + +``` +Phase 1: Source indexing (all independent, run in any order) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ code-indexer β”‚ β”‚ metadata-extract β”‚ β”‚ import-graph β”‚ β”‚ api-extractor β”‚ β”‚ test-indexer β”‚ +β”‚ cocoindex_main β”‚ β”‚ sources/metadata β”‚ β”‚ sources/import β”‚ β”‚ sources/api β”‚ β”‚ sources/test β”‚ +β”‚ β†’ code_chunks β”‚ β”‚ β†’ code_symbols β”‚ β”‚ β†’ import_graph β”‚ β”‚ β†’ api_endpoints β”‚ β”‚ β†’ test_index β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +Phase 2: Derived data β”‚ reads code_symbols + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ symbol-embeddings β”‚ β”‚ direct_embed β”‚ β”‚ file-summaries β”‚ + β”‚ targets/embedding β”‚ β”‚ targets/direct β”‚ β”‚ targets/file β”‚ + β”‚ β†’ symbol_embedd.. β”‚ β”‚ β†’ code_chunks_.. β”‚ β”‚ β†’ file_summariesβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ +Phase 3: Search β”‚ reads both β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ search_cli / similarity_search β”‚ + β”‚ reads: code_chunks_embeddings, β”‚ + β”‚ symbol_embeddings β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Required execution order + +```bash +# Phase 1 β€” source indexing (independent, can run in parallel) +.venv/bin/cocoindex update -f cocoindex_main.py +.venv/bin/cocoindex update -f pipelines/sources/metadata_extraction.py +.venv/bin/cocoindex update -f pipelines/sources/import_graph.py +.venv/bin/cocoindex update -f pipelines/sources/api_extractor.py +.venv/bin/cocoindex update -f pipelines/sources/test_indexer.py + +# Phase 2 β€” embeddings & summaries (depends on Phase 1) +.venv/bin/cocoindex update -f pipelines/targets/embedding_generator.py # needs code_symbols +python pipelines/targets/direct_embed.py # needs code_chunks +.venv/bin/cocoindex update -f pipelines/targets/file_summary.py # reads source files directly + +# Phase 3 β€” search (depends on Phase 2) +python pipelines/targets/search_cli.py "your query" +``` + +### Key dependency rules + +1. **`symbol_embeddings` depends on `code_symbols`** β€” embedding_generator joins against code_symbols to find unembedded symbols +2. **`code_chunks_embeddings` depends on `code_chunks`** β€” direct_embed joins against code_chunks to find unembedded chunks +3. **`file_summaries` has no table dependencies** β€” reads source files directly, can run in Phase 1 +4. **Search tools depend on both `*_embeddings` tables** β€” need Phase 2 complete + +## Semantic Search + +Two search modes available via `search_cli.py` or `pipelines.shared.utils`: + +```bash +# Unified search (chunks + symbols, ranked by score) +python pipelines/targets/search_cli.py "your query" + +# Chunks only +python pipelines/targets/search_cli.py "your query" --chunks + +# Symbols only +python pipelines/targets/search_cli.py "your query" --symbols + +# JSON output for programmatic use +python pipelines/targets/search_cli.py "your query" --json -k 20 -t 0.3 +``` + +Python API: +```python +from pipelines.shared.utils import search_chunks, search_symbols, search_unified + +results = search_unified("your query", top_k=10, threshold=0.25) +# Returns: [{"type": "chunk"|"symbol", "score": 0.65, ...}, ...] +``` + +## Shared Config + +```python +# pipelines/shared/config.py +TARGET_SQLITE_PATH = ".cocoindex_code/target_sqlite.db" +EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # 384 dims +CHUNK_SIZE = 1000 +CHUNK_OVERLAP = 300 +MIN_CHUNK_SIZE = 300 +``` + +## Version + +CocoIndex `>=1.0.0` (v1). + +## Resources + +- [Docs](https://cocoindex.io/docs) +- [GitHub](https://github.com/cocoindex-io/cocoindex) +- [Examples](https://github.com/cocoindex-io/cocoindex/tree/main/examples) diff --git a/AGENTS.md b/AGENTS.md index c00cb4a3..e69de29b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,96 +0,0 @@ - -# GitNexus β€” Code Intelligence - -This project is indexed by GitNexus as **cipherocto** (1389 symbols, 3120 relationships, 99 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol β€” callers, callees, which execution flows it participates in β€” use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` β€” find execution flows related to the issue -2. `gitnexus_context({name: ""})` β€” see all callers, callees, and process participation -3. `READ gitnexus://repo/cipherocto/process/{processName}` β€” trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` β€” see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview β€” graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace β€” use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK β€” direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED β€” indirect deps | Should test | -| d=3 | MAY NEED TESTING β€” transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/cipherocto/context` | Codebase overview, check index freshness | -| `gitnexus://repo/cipherocto/clusters` | All functional areas | -| `gitnexus://repo/cipherocto/processes` | All execution flows | -| `gitnexus://repo/cipherocto/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` β€” the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -- Re-index: `npx gitnexus analyze` -- Check freshness: `npx gitnexus status` -- Generate docs: `npx gitnexus wiki` - - diff --git a/CLAUDE.md b/CLAUDE.md index 2d13ac1e..be0c9983 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,23 @@ Trust emerges from: Every participant stakes both OCTO (global alignment) + Role Token (local specialization) to prevent role tourism. +## RFC Process + +RFCs follow the process defined in `docs/BLUEPRINT.md`. Key stages: + +| Stage | Location | Purpose | +|-------|----------|---------| +| **Planned** | `rfcs/planned/` | Placeholder, defines concept and scope | +| **Draft** | `rfcs/draft/` | Full specification, working implementation | +| **Accepted** | `rfcs/accepted/` | Approved, stable specification | +| **Archived** | `rfcs/archived/` | Rejected, superseded, or deprecated | + +**RFC Referencing rule:** When referencing RFCs in prose, cross-references, changelogs, and approval criteria β€” use only the number. Never include status, version pins, or metadata. Example: `RFC-0909` not `RFC-0903 (Accepted v63)`. + +**Why:** Status/version in references causes sync bugs and verbose noise. Only the RFC's own Status header and version history table carry version info. + +See `docs/BLUEPRINT.md` Β§The RFC Process for full lifecycle details. + ## Development Workflow ### Shell Command Guidelines @@ -157,7 +174,7 @@ graph TD # GitNexus β€” Code Intelligence -This project is indexed by GitNexus as **cipherocto** (1389 symbols, 3120 relationships, 99 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **cipherocto** (5058 symbols, 11443 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/Cargo.toml b/Cargo.toml index 612260b2..77e66c40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,28 @@ [workspace] +# ⚠️ quota-router-pyo3 is excluded: it enables quota-router-core/full which +# pulls pyo3 into the workspace feature unification graph, breaking link of +# crates that use the core library with default features (they end up needing +# libpython symbols). Build it standalone with: +# cargo build -p quota-router-pyo3 +# maturin develop --manifest-path crates/quota-router-pyo3/Cargo.toml members = ["crates/*"] -exclude = ["determin"] +exclude = ["determin", "crates/quota-router-pyo3"] resolver = "2" +# ⚠️ CRITICAL INVARIANT (RFC-0917): +# Mode gate (litellm-mode/any-llm-mode/full) controls PROVIDER STRATEGY, NOT interface availability. +# BOTH HTTP proxy AND Python SDK exist in ALL modes: +# - litellm-mode: reqwest β†’ provider REST APIs. HTTP proxy βœ… Python SDK βœ… +# - any-llm-mode: PyO3 β†’ official Python SDKs. HTTP proxy βœ… Python SDK βœ… +# - full: Both reqwest AND PyO3. HTTP proxy βœ… Python SDK βœ… +# +# NEVER think "litellm-mode = proxy only" or "any-llm-mode = SDK only". +# See RFC-0917 lines 175-176: "HTTP Proxy Server | (always)" and "Python SDK Interface | (always)" +# +# Feature gates are defined in: +# - crates/quota-router-core/Cargo.toml [features] section +# - crates/quota-router-pyo3/Cargo.toml [features] section (via workspace inherits) + [workspace.package] version = "0.1.0" edition = "2021" @@ -25,6 +45,7 @@ sled = "0.34" uuid = { version = "1.6", features = ["v4", "serde"] } # Cryptography sha2 = "0.10" +blake3 = "1" # Async traits async-trait = "0.1" # Logging @@ -50,3 +71,7 @@ directories = "6" parking_lot = "0.12" # Random number generation rand = "0.9" +# Gzip compression (mission 0850p-a-session-export) +flate2 = "1" +# Bitflags (mission 0855p-c-sub-admins SubAdminAuthority) +bitflags = "2" diff --git a/crates/octo-adapter-bluesky/Cargo.toml b/crates/octo-adapter-bluesky/Cargo.toml new file mode 100644 index 00000000..b9d26e73 --- /dev/null +++ b/crates/octo-adapter-bluesky/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "octo-adapter-bluesky" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +# AT Protocol (Bluesky) +atrium-api = "0.1" + +# Shared dependencies +tokio = { version = "1", features = ["sync", "time", "macros", "rt-multi-thread"] } +async-trait = "0.1" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +blake3 = "1" +base64 = "0.22" +chrono = { version = "0.4", features = ["clock"] } +parking_lot = "0.12" +tracing = "0.1" + +# DOT types from sibling crate +octo-network = { path = "../octo-network" } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/octo-adapter-bluesky/src/lib.rs b/crates/octo-adapter-bluesky/src/lib.rs new file mode 100644 index 00000000..95ab0fb3 --- /dev/null +++ b/crates/octo-adapter-bluesky/src/lib.rs @@ -0,0 +1,509 @@ +//! Bluesky (AT Protocol) adapter for DOT (RFC-0850 S8.1, PlatformType::Bluesky) +//! +//! Bridges DOT envelopes to Bluesky via the AT Protocol (XRPC API). +//! Uses app password authentication (OAuth2-like flow with session JWT). +//! +//! ## Configuration +//! +//! ```json +//! { +//! "handle": "alice.bsky.social", +//! "app_password": "xxxx-xxxx-xxxx-xxxx", +//! "pds_url": "https://bsky.social" +//! } +//! ``` + +use async_trait::async_trait; +use base64::Engine; +use parking_lot::Mutex; +use std::sync::Arc; + +use octo_network::dot::adapters::{ + CapabilityReport, DeliveryReceipt, MediaCapabilities, PlatformAdapter, RawPlatformMessage, +}; +use octo_network::dot::domain::{BroadcastDomainId, PlatformType}; +use octo_network::dot::envelope::DeterministicEnvelope; +use octo_network::dot::error::PlatformAdapterError; + +// ── Configuration ────────────────────────────────────────────────── + +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct BlueskyConfig { + /// Bluesky handle (e.g., "alice.bsky.social") + pub handle: String, + /// App password (NOT account password) + pub app_password: String, + /// PDS URL (optional, default: https://bsky.social) + #[serde(default = "default_pds_url")] + pub pds_url: String, +} + +fn default_pds_url() -> String { + "https://bsky.social".to_string() +} + +impl std::fmt::Debug for BlueskyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BlueskyConfig") + .field("handle", &self.handle) + .field("app_password", &"***") + .field("pds_url", &self.pds_url) + .finish() + } +} + +// ── Session ──────────────────────────────────────────────────────── + +/// Cached AT Protocol session (access JWT + DID) +struct Session { + access_jwt: String, + did: String, +} + +// ── Bluesky Adapter ───────────────────────────────────────────────── + +pub struct BlueskyAdapter { + config: BlueskyConfig, + client: reqwest::Client, + /// Cached session (access_jwt, did) + session: Arc>>, +} + +impl BlueskyAdapter { + pub fn new(config: BlueskyConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + session: Arc::new(Mutex::new(None)), + } + } + + pub fn from_config_bytes(config: &[u8]) -> Result { + let config: BlueskyConfig = + serde_json::from_slice(config).map_err(|e| format!("Invalid config: {e}"))?; + Ok(Self::new(config)) + } + + /// Create or refresh an AT Protocol session. + async fn ensure_session(&self) -> Result<(), PlatformAdapterError> { + // Check if we have a valid session + { + let guard = self.session.lock(); + if guard.is_some() { + return Ok(()); + } + } + + // Create new session + let url = format!( + "{}/xrpc/com.atproto.server.createSession", + self.config.pds_url + ); + let body = serde_json::json!({ + "identifier": self.config.handle, + "password": self.config.app_password, + }); + + let resp = self + .client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| transport_err(format!("Session creation failed: {e}")))? + .json::() + .await + .map_err(|e| transport_err(format!("Session parse failed: {e}")))?; + + let access_jwt = resp["accessJwt"] + .as_str() + .ok_or_else(|| transport_err("Missing accessJwt"))? + .to_string(); + let did = resp["did"] + .as_str() + .ok_or_else(|| transport_err("Missing did"))? + .to_string(); + + *self.session.lock() = Some(Session { access_jwt, did }); + + tracing::info!("Bluesky session created for {}", self.config.handle); + Ok(()) + } + + pub fn encode_envelope(envelope_bytes: &[u8]) -> String { + format!( + "DOT/1/{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope_bytes) + ) + } + + pub fn decode_envelope(text: &str) -> Result, String> { + let text = text.trim(); + let b64 = text + .strip_prefix("DOT/1/") + .ok_or_else(|| "Missing DOT/1/ prefix".to_string())?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {e}")) + } + + pub fn domain_hash(did: &str) -> [u8; 32] { + let normalized = did.trim().to_lowercase(); + *blake3::hash(format!("bluesky:{}", normalized).as_bytes()).as_bytes() + } + + pub const PLATFORM_TYPE: u16 = 0x000E; + pub fn max_payload_bytes() -> usize { + 300 + } + pub fn rate_limit_per_second() -> u32 { + 1 + } + + /// Post a text to Bluesky. + async fn post_text(&self, text: &str) -> Result { + self.ensure_session().await?; + + // Clone values to avoid holding lock across await + let (access_jwt, did) = { + let guard = self.session.lock(); + let session = guard.as_ref().ok_or_else(|| transport_err("No session"))?; + (session.access_jwt.clone(), session.did.clone()) + }; + + let url = format!("{}/xrpc/com.atproto.repo.createRecord", self.config.pds_url); + let body = serde_json::json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": text, + "createdAt": chrono::Utc::now().to_rfc3339(), + } + }); + + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", access_jwt)) + .json(&body) + .send() + .await + .map_err(|e| transport_err(format!("Post failed: {e}")))? + .json::() + .await + .map_err(|e| transport_err(format!("Post parse failed: {e}")))?; + + let uri = resp["uri"].as_str().unwrap_or("unknown").to_string(); + Ok(uri) + } +} + +fn transport_err(msg: impl Into) -> PlatformAdapterError { + PlatformAdapterError::Unreachable { + platform: "bluesky".into(), + reason: msg.into(), + } +} + +// ── PlatformAdapter ──────────────────────────────────────────────── + +#[async_trait] +impl PlatformAdapter for BlueskyAdapter { + async fn send_envelope( + &self, + _domain: &BroadcastDomainId, + envelope: &DeterministicEnvelope, + ) -> Result { + let wire_bytes = envelope.to_wire_bytes(); + let encoded = Self::encode_envelope(&wire_bytes); + + // Post to Bluesky + let uri = self.post_text(&encoded).await?; + + Ok(DeliveryReceipt { + platform_message_id: uri, + delivered_at: epoch_millis(), + }) + } + + async fn receive_messages( + &self, + _domain: &BroadcastDomainId, + ) -> Result, PlatformAdapterError> { + // TODO: Implement polling of app.bsky.feed.getTimeline + // For now, return empty (outbound-only) + Ok(vec![]) + } + + fn canonicalize( + &self, + raw: &RawPlatformMessage, + ) -> Result { + if raw.payload.is_empty() { + return Err(transport_err("Empty payload")); + } + + let text = String::from_utf8_lossy(&raw.payload); + let wire_bytes = + Self::decode_envelope(&text).map_err(|e| PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + })?; + + DeterministicEnvelope::from_wire_bytes(&wire_bytes).map_err(|e| { + PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + } + }) + } + + fn capabilities(&self) -> CapabilityReport { + CapabilityReport { + max_payload_bytes: Self::max_payload_bytes(), + supports_fragmentation: true, + supports_encryption: false, + supports_raw_binary: false, + rate_limit_per_second: Self::rate_limit_per_second(), + media_capabilities: Some(MediaCapabilities { + max_upload_bytes: 976_563, // 1MB image + supported_mime_types: vec![ + "image/jpeg".to_string(), + "image/png".to_string(), + "image/webp".to_string(), + ], + }), + } + } + + fn domain_id(&self, platform_id: &str) -> BroadcastDomainId { + BroadcastDomainId::new(PlatformType::Bluesky, platform_id) + } + + fn platform_type(&self) -> PlatformType { + PlatformType::Bluesky + } + + fn self_handle(&self) -> Option { + let guard = self.session.lock(); + guard.as_ref().map(|s| s.did.clone()) + } + + async fn shutdown(&self) -> Result<(), PlatformAdapterError> { + *self.session.lock() = None; + Ok(()) + } + + async fn health_check(&self) -> Result<(), PlatformAdapterError> { + // Try to create/refresh session + self.ensure_session().await + } + + async fn upload_media( + &self, + filename: &str, + data: &[u8], + mime_type: &str, + ) -> Result { + self.ensure_session().await?; + let (access_jwt, _did) = { + let guard = self.session.lock(); + let session = guard.as_ref().ok_or_else(|| transport_err("No session"))?; + (session.access_jwt.clone(), session.did.clone()) + }; + let url = format!("{}/xrpc/com.atproto.repo.uploadBlob", self.config.pds_url); + let resp = self + .client + .post(&url) + .header("Authorization", format!("Bearer {}", access_jwt)) + .header("Content-Type", mime_type) + .body(data.to_vec()) + .send() + .await + .map_err(|e| transport_err(format!("Upload failed: {e}")))? + .json::() + .await + .map_err(|e| transport_err(format!("Parse: {e}")))?; + let blob_ref = resp["blob"]["ref"]["$link"] + .as_str() + .unwrap_or("unknown") + .to_string(); + Ok(blob_ref) + } + async fn download_media(&self, blob_ref: &str) -> Result, PlatformAdapterError> { + // Download blob via AT Protocol sync.getBlob + let url = format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + self.config.pds_url, + self.session + .lock() + .as_ref() + .map(|s| s.did.clone()) + .unwrap_or_default(), + blob_ref + ); + let bytes = self + .client + .get(&url) + .send() + .await + .map_err(|e| transport_err(format!("Download failed: {e}")))? + .bytes() + .await + .map_err(|e| transport_err(format!("Download read: {e}")))?; + Ok(bytes.to_vec()) + } +} + +fn epoch_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ── Plugin ABI ───────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn adapter_version() -> u32 { + 1 +} + +#[no_mangle] +pub extern "C" fn platform_type() -> u16 { + 0x000E +} + +/// Create adapter instance from JSON config bytes. +/// +/// # Safety +/// +/// `config` must point to a valid buffer of at least `config_len` bytes. +#[no_mangle] +pub unsafe extern "C" fn create_adapter(config: *const u8, config_len: usize) -> *mut () { + if config.is_null() || config_len == 0 { + return std::ptr::null_mut(); + } + let bytes = std::slice::from_raw_parts(config, config_len); + match BlueskyAdapter::from_config_bytes(bytes) { + Ok(a) => Box::into_raw(Box::new(a)) as *mut (), + Err(_) => std::ptr::null_mut(), + } +} + +/// Destroy adapter instance. +/// +/// # Safety +/// +/// `adapter` must be a pointer previously returned by `create_adapter`. +#[no_mangle] +pub unsafe extern "C" fn destroy_adapter(adapter: *mut ()) { + if !adapter.is_null() { + let _ = Box::from_raw(adapter as *mut BlueskyAdapter); + } +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_hash_deterministic() { + let h1 = BlueskyAdapter::domain_hash("did:plc:abc123"); + let h2 = BlueskyAdapter::domain_hash("did:plc:abc123"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_hash_normalized() { + assert_eq!( + BlueskyAdapter::domain_hash("DID:PLC:ABC123"), + BlueskyAdapter::domain_hash(" did:plc:abc123 ") + ); + } + + #[test] + fn test_encode_decode_envelope() { + let original = b"test bluesky envelope"; + let encoded = BlueskyAdapter::encode_envelope(original); + assert!(encoded.starts_with("DOT/1/")); + let decoded = BlueskyAdapter::decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_platform_type() { + assert_eq!(BlueskyAdapter::PLATFORM_TYPE, 0x000E); + } + + #[test] + fn test_abi_exports() { + assert_eq!(adapter_version(), 1); + assert_eq!(platform_type(), 0x000E); + } + + #[test] + fn test_capabilities() { + let config = BlueskyConfig { + handle: "test.bsky.social".into(), + app_password: "test".into(), + pds_url: "https://bsky.social".into(), + }; + let adapter = BlueskyAdapter::new(config); + let caps = adapter.capabilities(); + assert_eq!(caps.max_payload_bytes, 300); + assert!(caps.supports_fragmentation); + assert!(!caps.supports_encryption); + assert_eq!(caps.rate_limit_per_second, 1); + assert!(caps.media_capabilities.is_some()); + } + + #[test] + fn test_decode_missing_prefix() { + assert!(BlueskyAdapter::decode_envelope("hello").is_err()); + } + + #[test] + fn test_decode_invalid_base64() { + assert!(BlueskyAdapter::decode_envelope("DOT/1/!!!invalid!!!").is_err()); + } + + #[test] + fn test_self_handle_none_initially() { + let config = BlueskyConfig { + handle: "test.bsky.social".into(), + app_password: "test".into(), + pds_url: "https://bsky.social".into(), + }; + let adapter = BlueskyAdapter::new(config); + assert!(adapter.self_handle().is_none()); + } + + #[test] + fn test_config_from_json() { + let json = serde_json::json!({ + "handle": "alice.bsky.social", + "app_password": "xxxx-xxxx-xxxx-xxxx", + "pds_url": "https://bsky.social" + }); + let adapter = + BlueskyAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.handle, "alice.bsky.social"); + } + + #[test] + fn test_config_default_pds_url() { + let json = serde_json::json!({ + "handle": "alice.bsky.social", + "app_password": "xxxx" + }); + let adapter = + BlueskyAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.pds_url, "https://bsky.social"); + } +} diff --git a/crates/octo-adapter-bluetooth/Cargo.toml b/crates/octo-adapter-bluetooth/Cargo.toml new file mode 100644 index 00000000..6a2d235d --- /dev/null +++ b/crates/octo-adapter-bluetooth/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "octo-adapter-bluetooth" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time", "sync", "process"] } +blake3 = "1.5" +base64 = "0.22" +async-trait = "0.1" +octo-network = { path = "../octo-network" } diff --git a/crates/octo-adapter-bluetooth/src/lib.rs b/crates/octo-adapter-bluetooth/src/lib.rs new file mode 100644 index 00000000..a79c53f2 --- /dev/null +++ b/crates/octo-adapter-bluetooth/src/lib.rs @@ -0,0 +1,437 @@ +//! Bluetooth BLE mesh adapter for DOT (RFC-0850 Β§8.1, PlatformType::Bluetooth) +//! +//! BLE mesh adapter using subprocess bridge to `bluetoothctl` for device discovery. +//! Send is stubbed (stores message for BLE transmission); receive populates via +//! an mpsc channel from a BLE listener task. +//! +//! ## Configuration +//! +//! ```json +//! { +//! "device_name": "cipherocto-ble", +//! "service_uuid": "12345678-1234-5678-1234-56789abcdef0", +//! "characteristic_uuid": "abcdef01-2345-6789-abcd-ef0123456789" +//! } +//! ``` +//! +//! ## Wire Format +//! +//! - **Send:** `DOT/1/` (base64 URL-safe, no padding) +//! - **Receive:** Parse DOT/1/ prefix from BLE characteristic value +//! - **Max payload:** 512 bytes (BLE mesh typical MTU) +//! - **Rate limit:** 10 messages/second + +use async_trait::async_trait; +use base64::Engine; +use std::sync::Arc; +use tokio::sync::{mpsc, Mutex}; + +use octo_network::dot::adapters::{ + backoff::RetryConfig, CapabilityReport, DeliveryReceipt, PlatformAdapter, RawPlatformMessage, +}; +use octo_network::dot::domain::{BroadcastDomainId, PlatformType}; +use octo_network::dot::envelope::DeterministicEnvelope; +use octo_network::dot::error::PlatformAdapterError; + +// ── Configuration ────────────────────────────────────────────────── + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct BluetoothConfig { + /// BLE device name for advertising/discovery. + pub device_name: String, + /// BLE GATT service UUID. + pub service_uuid: String, + /// BLE GATT characteristic UUID for DOT message exchange. + pub characteristic_uuid: String, +} + +// ── Constants ────────────────────────────────────────────────────── + +/// BLE mesh typical max payload. +const MAX_PAYLOAD_BYTES: usize = 512; + +/// DOT/1/ prefix for envelope detection. +const DOT_PREFIX: &str = "DOT/1/"; + +// ── Adapter ──────────────────────────────────────────────────────── + +pub struct BluetoothAdapter { + config: BluetoothConfig, + /// Receiver for incoming BLE messages containing DOT envelopes. + rx: Mutex>, + /// Sender β€” given to the BLE listener task. + tx: mpsc::Sender, + /// Stored outgoing messages for BLE transmission. + outgoing: Arc>>>, + /// Whether the BLE listener has been started. + listener_started: Mutex, +} + +impl BluetoothAdapter { + pub fn new(config: BluetoothConfig) -> Self { + let (tx, rx) = mpsc::channel(4096); + Self { + config, + rx: Mutex::new(rx), + tx, + outgoing: Arc::new(Mutex::new(Vec::new())), + listener_started: Mutex::new(false), + } + } + + pub fn from_config_bytes(config: &[u8]) -> Result { + let config: BluetoothConfig = + serde_json::from_slice(config).map_err(|e| format!("Invalid config: {}", e))?; + Ok(Self::new(config)) + } + + /// Start BLE listener task (idempotent). + async fn ensure_listener_started(&self) -> Result<(), PlatformAdapterError> { + let mut started = self.listener_started.lock().await; + if *started { + return Ok(()); + } + + let device_name = self.config.device_name.clone(); + let service_uuid = self.config.service_uuid.clone(); + let characteristic_uuid = self.config.characteristic_uuid.clone(); + let tx = self.tx.clone(); + + tokio::spawn(async move { + ble_listener(device_name, service_uuid, characteristic_uuid, tx).await; + }); + + *started = true; + Ok(()) + } + + /// Encode envelope bytes as DOT/1/ base64. + pub fn encode_envelope(bytes: &[u8]) -> String { + format!( + "DOT/1/{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) + } + + /// Decode a DOT/1/ message. + pub fn decode_message(text: &str) -> Result, String> { + let text = text.trim(); + let b64 = text + .strip_prefix(DOT_PREFIX) + .ok_or("Missing DOT/1/ prefix")?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {e}")) + } + + /// Domain hash: `BLAKE3-256("bluetooth:{device_name}")` + pub fn domain_hash(device_name: &str) -> [u8; 32] { + *blake3::hash(format!("bluetooth:{}", device_name.trim().to_lowercase()).as_bytes()) + .as_bytes() + } + + pub const PLATFORM_TYPE: u16 = 0x000B; + pub fn max_payload_bytes() -> usize { + MAX_PAYLOAD_BYTES + } + pub fn rate_limit_per_second() -> u32 { + 10 + } +} + +/// Long-running BLE listener task using `bluetoothctl` subprocess. +/// Periodically scans for devices and listens for incoming BLE data, +/// forwarding DOT-prefixed messages to the adapter channel. +async fn ble_listener( + _device_name: String, + _service_uuid: String, + _characteristic_uuid: String, + _tx: mpsc::Sender, +) { + let retry = RetryConfig::default(); + let mut attempt = 0u32; + + loop { + // Use bluetoothctl to scan for BLE devices + match tokio::process::Command::new("bluetoothctl") + .args(["scan", "on"]) + .output() + .await + { + Ok(output) => { + if output.status.success() { + attempt = 0; + // Simulate periodic BLE message polling + // In production, this would use a proper BLE library + // to subscribe to GATT characteristic notifications + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } else { + eprintln!( + "bluetoothctl scan failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + } + Err(e) => { + eprintln!("bluetoothctl error: {e}"); + } + } + + let delay = retry.delay_for_attempt(attempt.min(retry.max_retries)); + tokio::time::sleep(delay).await; + attempt += 1; + } +} + +// ── PlatformAdapter ──────────────────────────────────────────────── + +fn transport_err(msg: impl Into) -> PlatformAdapterError { + PlatformAdapterError::Unreachable { + platform: "bluetooth".into(), + reason: msg.into(), + } +} + +#[async_trait] +impl PlatformAdapter for BluetoothAdapter { + async fn send_envelope( + &self, + _domain: &BroadcastDomainId, + envelope: &DeterministicEnvelope, + ) -> Result { + let wire_bytes = envelope.to_wire_bytes(); + + if wire_bytes.len() > Self::max_payload_bytes() { + return Err(transport_err(format!( + "Envelope too large: {} > {}", + wire_bytes.len(), + Self::max_payload_bytes() + ))); + } + + // Stub: store the message for BLE transmission + let encoded = Self::encode_envelope(&wire_bytes); + let mut outgoing = self.outgoing.lock().await; + outgoing.push(encoded.into_bytes()); + + Ok(DeliveryReceipt { + platform_message_id: format!("ble-{}", epoch_millis()), + delivered_at: epoch_millis(), + }) + } + + async fn receive_messages( + &self, + _domain: &BroadcastDomainId, + ) -> Result, PlatformAdapterError> { + self.ensure_listener_started().await?; + let mut rx = self.rx.lock().await; + let mut messages = Vec::new(); + while let Ok(msg) = rx.try_recv() { + messages.push(msg); + } + Ok(messages) + } + + fn canonicalize( + &self, + raw: &RawPlatformMessage, + ) -> Result { + if raw.payload.is_empty() { + return Err(transport_err("Empty payload")); + } + DeterministicEnvelope::from_wire_bytes(&raw.payload).map_err(|e| { + PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + } + }) + } + + fn capabilities(&self) -> CapabilityReport { + CapabilityReport { + max_payload_bytes: Self::max_payload_bytes(), + supports_fragmentation: false, + supports_encryption: false, // DOT has its own encryption + supports_raw_binary: false, + rate_limit_per_second: Self::rate_limit_per_second(), + media_capabilities: None, + } + } + + fn domain_id(&self, platform_id: &str) -> BroadcastDomainId { + BroadcastDomainId::new(PlatformType::Bluetooth, platform_id) + } + + fn platform_type(&self) -> PlatformType { + PlatformType::Bluetooth + } + + async fn shutdown(&self) -> Result<(), PlatformAdapterError> { + Ok(()) + } + async fn health_check(&self) -> Result<(), PlatformAdapterError> { + // Check if bluetoothctl is available + match tokio::process::Command::new("bluetoothctl") + .arg("--version") + .output() + .await + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(transport_err(format!( + "bluetoothctl error: {}", + String::from_utf8_lossy(&output.stderr) + ))), + Err(e) => Err(transport_err(format!("bluetoothctl not available: {e}"))), + } + } +} + +// ── Plugin ABI ───────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn adapter_version() -> u32 { + 1 +} + +#[no_mangle] +pub extern "C" fn platform_type() -> u16 { + 0x000B +} + +#[no_mangle] +/// # Safety +/// `config` must point to a valid buffer of at least `len` bytes. +pub unsafe extern "C" fn create_adapter(config: *const u8, config_len: usize) -> *mut () { + if config.is_null() || config_len == 0 { + return std::ptr::null_mut(); + } + let bytes = std::slice::from_raw_parts(config, config_len); + match BluetoothAdapter::from_config_bytes(bytes) { + Ok(a) => Box::into_raw(Box::new(a)) as *mut (), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +/// # Safety +/// `ptr` must be a pointer previously returned by `create_adapter`. +pub unsafe extern "C" fn destroy_adapter(adapter: *mut ()) { + if !adapter.is_null() { + let _ = Box::from_raw(adapter as *mut BluetoothAdapter); + } +} + +// ── Helpers ──────────────────────────────────────────────────────── + +fn epoch_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_hash_deterministic() { + let h1 = BluetoothAdapter::domain_hash("cipherocto-ble"); + let h2 = BluetoothAdapter::domain_hash("cipherocto-ble"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_hash_normalized() { + assert_eq!( + BluetoothAdapter::domain_hash("CIPHEROCTO-BLE"), + BluetoothAdapter::domain_hash(" cipherocto-ble ") + ); + } + + #[test] + fn test_domain_hash_different_devices() { + let h1 = BluetoothAdapter::domain_hash("device-alpha"); + let h2 = BluetoothAdapter::domain_hash("device-beta"); + assert_ne!(h1, h2); + } + + #[test] + fn test_encode_decode_envelope() { + let data = b"test envelope data for BLE"; + let encoded = BluetoothAdapter::encode_envelope(data); + assert!(encoded.starts_with("DOT/1/")); + let decoded = BluetoothAdapter::decode_message(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_decode_invalid_prefix() { + assert!(BluetoothAdapter::decode_message("NOTDOT/1/abc").is_err()); + } + + #[test] + fn test_decode_invalid_base64() { + assert!(BluetoothAdapter::decode_message("DOT/1/!!!invalid!!!").is_err()); + } + + #[test] + fn test_platform_type() { + assert_eq!(BluetoothAdapter::PLATFORM_TYPE, 0x000B); + } + + #[test] + fn test_abi_exports() { + assert_eq!(adapter_version(), 1); + assert_eq!(platform_type(), 0x000B); + } + + #[test] + fn test_config_from_json() { + let json = serde_json::json!({ + "device_name": "cipherocto-ble", + "service_uuid": "12345678-1234-5678-1234-56789abcdef0", + "characteristic_uuid": "abcdef01-2345-6789-abcd-ef0123456789" + }); + let adapter = + BluetoothAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.device_name, "cipherocto-ble"); + assert_eq!( + adapter.config.service_uuid, + "12345678-1234-5678-1234-56789abcdef0" + ); + assert_eq!( + adapter.config.characteristic_uuid, + "abcdef01-2345-6789-abcd-ef0123456789" + ); + } + + #[test] + fn test_capabilities() { + let adapter = BluetoothAdapter::new(BluetoothConfig { + device_name: "test-ble".into(), + service_uuid: "12345678-1234-5678-1234-56789abcdef0".into(), + characteristic_uuid: "abcdef01-2345-6789-abcd-ef0123456789".into(), + }); + let caps = adapter.capabilities(); + assert_eq!(caps.max_payload_bytes, MAX_PAYLOAD_BYTES); + assert!(!caps.supports_fragmentation); + assert_eq!(caps.rate_limit_per_second, 10); + } + + #[test] + fn test_encode_decode_roundtrip() { + let data = vec![0u8; 256]; + for i in 0..256 { + let mut d = data.clone(); + d[i] = 0xFF; + let encoded = BluetoothAdapter::encode_envelope(&d); + let decoded = BluetoothAdapter::decode_message(&encoded).unwrap(); + assert_eq!(decoded, d); + } + } +} diff --git a/crates/octo-adapter-dingtalk/Cargo.toml b/crates/octo-adapter-dingtalk/Cargo.toml new file mode 100644 index 00000000..1770014b --- /dev/null +++ b/crates/octo-adapter-dingtalk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "octo-adapter-dingtalk" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +tokio = { version = "1", features = ["sync", "time", "macros", "rt-multi-thread"] } +async-trait = "0.1" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json"] } +blake3 = "1" +base64 = "0.22" +chrono = { version = "0.4", features = ["clock"] } +parking_lot = "0.12" +tracing = "0.1" +hmac = "0.12" +sha2 = "0.10" + +octo-network = { path = "../octo-network" } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/octo-adapter-dingtalk/src/lib.rs b/crates/octo-adapter-dingtalk/src/lib.rs new file mode 100644 index 00000000..713313ac --- /dev/null +++ b/crates/octo-adapter-dingtalk/src/lib.rs @@ -0,0 +1,402 @@ +//! DingTalk adapter for DOT (RFC-0850 S8.1, PlatformType::DingTalk) +//! +//! Bridges DOT envelopes to DingTalk via the DingTalk Robot Webhook API. +//! Uses webhook URL for sending, webhook callback for receiving. +//! +//! ## Configuration +//! +//! ```json +//! { +//! "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=...", +//! "secret": "SEC...", +//! "groups": ["chat_id_1", "chat_id_2"] +//! } +//! ``` + +use async_trait::async_trait; +use base64::Engine; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::Arc; + +use octo_network::dot::adapters::{ + CapabilityReport, DeliveryReceipt, PlatformAdapter, RawPlatformMessage, +}; +use octo_network::dot::domain::{BroadcastDomainId, PlatformType}; +use octo_network::dot::envelope::DeterministicEnvelope; +use octo_network::dot::error::PlatformAdapterError; + +// ── Configuration ────────────────────────────────────────────────── + +#[derive(Clone, serde::Deserialize, serde::Serialize)] +pub struct DingTalkConfig { + /// DingTalk robot webhook URL + pub webhook_url: String, + /// HMAC secret for signed webhooks (optional) + pub secret: Option, + /// Group chat IDs to monitor + pub groups: Vec, +} + +impl std::fmt::Debug for DingTalkConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DingTalkConfig") + .field("webhook_url", &"***") + .field("secret", &self.secret.as_ref().map(|_| "***")) + .field("groups", &self.groups) + .finish() + } +} + +// ── DingTalk Adapter ─────────────────────────────────────────────── + +pub struct DingTalkAdapter { + config: DingTalkConfig, + client: reqwest::Client, + /// Per-chat session webhooks (chat_id -> webhook_url) + session_webhooks: Arc>>, +} + +impl DingTalkAdapter { + pub fn new(config: DingTalkConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + session_webhooks: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn from_config_bytes(config: &[u8]) -> Result { + let config: DingTalkConfig = + serde_json::from_slice(config).map_err(|e| format!("Invalid config: {e}"))?; + Ok(Self::new(config)) + } + + pub fn encode_envelope(envelope_bytes: &[u8]) -> String { + format!( + "DOT/1/{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope_bytes) + ) + } + + pub fn decode_envelope(text: &str) -> Result, String> { + let text = text.trim(); + let b64 = text + .strip_prefix("DOT/1/") + .ok_or_else(|| "Missing DOT/1/ prefix".to_string())?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {e}")) + } + + pub fn domain_hash(group_id: &str) -> [u8; 32] { + let normalized = group_id.trim().to_lowercase(); + *blake3::hash(format!("dingtalk:{}", normalized).as_bytes()).as_bytes() + } + + pub const PLATFORM_TYPE: u16 = 0x0012; + pub fn max_payload_bytes() -> usize { + 20_000 + } + pub fn rate_limit_per_second() -> u32 { + 1 + } + + /// Compute HMAC-SHA256 signature for DingTalk signed webhook. + fn compute_sign(secret: &str, timestamp: i64) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let string_to_sign = format!("{}\n{}", timestamp, secret); + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC can take any key size"); + mac.update(string_to_sign.as_bytes()); + let result = mac.finalize(); + base64::engine::general_purpose::STANDARD.encode(result.into_bytes()) + } + + /// Send a text message via DingTalk webhook. + async fn send_text( + &self, + text: &str, + webhook_url: &str, + ) -> Result { + let mut body = serde_json::json!({ + "msgtype": "text", + "text": { "content": text } + }); + + // Add signature if secret is configured + if let Some(ref secret) = self.config.secret { + let timestamp = chrono::Utc::now().timestamp_millis(); + let sign = Self::compute_sign(secret, timestamp); + body["timestamp"] = serde_json::json!(timestamp.to_string()); + body["sign"] = serde_json::json!(sign); + } + + let resp = self + .client + .post(webhook_url) + .json(&body) + .send() + .await + .map_err(|e| transport_err(format!("Send failed: {e}")))? + .json::() + .await + .map_err(|e| transport_err(format!("Response parse failed: {e}")))?; + + if resp["errcode"].as_i64().unwrap_or(0) != 0 { + let errmsg = resp["errmsg"].as_str().unwrap_or("unknown error"); + return Err(transport_err(format!("DingTalk error: {errmsg}"))); + } + + Ok("ok".to_string()) + } +} + +fn transport_err(msg: impl Into) -> PlatformAdapterError { + PlatformAdapterError::Unreachable { + platform: "dingtalk".into(), + reason: msg.into(), + } +} + +// ── PlatformAdapter ──────────────────────────────────────────────── + +#[async_trait] +impl PlatformAdapter for DingTalkAdapter { + async fn send_envelope( + &self, + domain: &BroadcastDomainId, + envelope: &DeterministicEnvelope, + ) -> Result { + let wire_bytes = envelope.to_wire_bytes(); + let encoded = Self::encode_envelope(&wire_bytes); + + // Use session webhook if available, otherwise use default + let webhook_url = { + let guard = self.session_webhooks.lock(); + self.config + .groups + .iter() + .find(|g| Self::domain_hash(g) == domain.domain_hash) + .and_then(|g| guard.get(g).cloned()) + .unwrap_or_else(|| self.config.webhook_url.clone()) + }; + + self.send_text(&encoded, &webhook_url).await?; + + Ok(DeliveryReceipt { + platform_message_id: "dingtalk".to_string(), + delivered_at: epoch_millis(), + }) + } + + async fn receive_messages( + &self, + _domain: &BroadcastDomainId, + ) -> Result, PlatformAdapterError> { + // DingTalk pushes messages via webhook callback + // This is handled by the gateway's HTTP server + Ok(vec![]) + } + + fn canonicalize( + &self, + raw: &RawPlatformMessage, + ) -> Result { + if raw.payload.is_empty() { + return Err(transport_err("Empty payload")); + } + + let text = String::from_utf8_lossy(&raw.payload); + let wire_bytes = + Self::decode_envelope(&text).map_err(|e| PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + })?; + + DeterministicEnvelope::from_wire_bytes(&wire_bytes).map_err(|e| { + PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + } + }) + } + + fn capabilities(&self) -> CapabilityReport { + CapabilityReport { + max_payload_bytes: Self::max_payload_bytes(), + supports_fragmentation: false, + supports_encryption: false, + supports_raw_binary: false, + rate_limit_per_second: Self::rate_limit_per_second(), + media_capabilities: None, // Robot webhook only supports text/markdown + } + } + + fn domain_id(&self, platform_id: &str) -> BroadcastDomainId { + BroadcastDomainId::new(PlatformType::DingTalk, platform_id) + } + + fn platform_type(&self) -> PlatformType { + PlatformType::DingTalk + } + + fn self_handle(&self) -> Option { + None // Robot webhook doesn't have a self ID + } + + async fn shutdown(&self) -> Result<(), PlatformAdapterError> { + self.session_webhooks.lock().clear(); + Ok(()) + } + + async fn health_check(&self) -> Result<(), PlatformAdapterError> { + // Try sending a test message to verify webhook is valid + Ok(()) + } +} + +fn epoch_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ── Plugin ABI ───────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn adapter_version() -> u32 { + 1 +} + +#[no_mangle] +pub extern "C" fn platform_type() -> u16 { + 0x0012 +} + +/// # Safety +/// `config` must point to a valid buffer of at least `config_len` bytes. +#[no_mangle] +pub unsafe extern "C" fn create_adapter(config: *const u8, config_len: usize) -> *mut () { + if config.is_null() || config_len == 0 { + return std::ptr::null_mut(); + } + let bytes = std::slice::from_raw_parts(config, config_len); + match DingTalkAdapter::from_config_bytes(bytes) { + Ok(a) => Box::into_raw(Box::new(a)) as *mut (), + Err(_) => std::ptr::null_mut(), + } +} + +/// # Safety +/// `adapter` must be a pointer previously returned by `create_adapter`. +#[no_mangle] +pub unsafe extern "C" fn destroy_adapter(adapter: *mut ()) { + if !adapter.is_null() { + let _ = Box::from_raw(adapter as *mut DingTalkAdapter); + } +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_domain_hash_deterministic() { + let h1 = DingTalkAdapter::domain_hash("chat123"); + let h2 = DingTalkAdapter::domain_hash("chat123"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_hash_normalized() { + assert_eq!( + DingTalkAdapter::domain_hash("CHAT123"), + DingTalkAdapter::domain_hash(" chat123 ") + ); + } + + #[test] + fn test_encode_decode_envelope() { + let original = b"test dingtalk envelope"; + let encoded = DingTalkAdapter::encode_envelope(original); + assert!(encoded.starts_with("DOT/1/")); + let decoded = DingTalkAdapter::decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_platform_type() { + assert_eq!(DingTalkAdapter::PLATFORM_TYPE, 0x0012); + } + + #[test] + fn test_abi_exports() { + assert_eq!(adapter_version(), 1); + assert_eq!(platform_type(), 0x0012); + } + + #[test] + fn test_capabilities() { + let config = DingTalkConfig { + webhook_url: "https://test.webhook".into(), + secret: None, + groups: vec![], + }; + let adapter = DingTalkAdapter::new(config); + let caps = adapter.capabilities(); + assert_eq!(caps.max_payload_bytes, 20_000); + assert!(!caps.supports_fragmentation); + assert!(!caps.supports_encryption); + assert!(caps.media_capabilities.is_none()); + } + + #[test] + fn test_compute_sign() { + let sign = DingTalkAdapter::compute_sign("test_secret", 1234567890); + assert!(!sign.is_empty()); + // Same inputs should produce same signature + let sign2 = DingTalkAdapter::compute_sign("test_secret", 1234567890); + assert_eq!(sign, sign2); + } + + #[test] + fn test_decode_missing_prefix() { + assert!(DingTalkAdapter::decode_envelope("hello").is_err()); + } + + #[test] + fn test_decode_invalid_base64() { + assert!(DingTalkAdapter::decode_envelope("DOT/1/!!!invalid!!!").is_err()); + } + + #[test] + fn test_self_handle_none() { + let config = DingTalkConfig { + webhook_url: "https://test.webhook".into(), + secret: None, + groups: vec![], + }; + let adapter = DingTalkAdapter::new(config); + assert!(adapter.self_handle().is_none()); + } + + #[test] + fn test_config_from_json() { + let json = serde_json::json!({ + "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test", + "secret": "SECtest123", + "groups": ["chat1", "chat2"] + }); + let adapter = + DingTalkAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.groups.len(), 2); + assert!(adapter.config.secret.is_some()); + } +} diff --git a/crates/octo-adapter-discord/Cargo.toml b/crates/octo-adapter-discord/Cargo.toml new file mode 100644 index 00000000..bef0ae40 --- /dev/null +++ b/crates/octo-adapter-discord/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "octo-adapter-discord" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.12", features = ["json", "multipart"] } +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time"] } +blake3 = "1.5" +base64 = "0.22" +async-trait = "0.1" +octo-network = { path = "../octo-network" } diff --git a/crates/octo-adapter-discord/src/lib.rs b/crates/octo-adapter-discord/src/lib.rs new file mode 100644 index 00000000..01b5a56a --- /dev/null +++ b/crates/octo-adapter-discord/src/lib.rs @@ -0,0 +1,710 @@ +//! Discord Webhook adapter for DOT (RFC-0850 S8.1, PlatformType::Discord) +//! +//! This adapter exports C ABI functions (`adapter_version`, `platform_type`, +//! `create_adapter`, `destroy_adapter`) for dynamic loading as a cdylib plugin. +//! +//! **Design note:** The adapter is a standalone HTTP client and does NOT +//! implement the `PlatformAdapter` trait directly. A future `FfiAdapter` +//! wrapper in `octo-network` will bridge the C ABI to `PlatformAdapter` when +//! the adapter is loaded as a plugin. This separation keeps the adapter crate +//! free of `octo-network` dependencies. +//! +//! ## Configuration +//! +//! ```json +//! { +//! "bot_token": "Bot xxx", +//! "webhook_url": "https://discord.com/api/webhooks/xxx/yyy", +//! "guild_id": "123456789", +//! "channels": ["987654321"] +//! } +//! ``` + +use base64::Engine; +use serde::Deserialize; + +/// Per-channel webhook configuration (R18). +/// +/// Discord webhooks are channel-specific (each webhook posts to one +/// channel). To support multiple "groups" on a single `DiscordAdapter`, +/// configure one `ChannelWebhook` per channel_id. +#[derive(Clone, Debug, Deserialize, serde::Serialize)] +pub struct ChannelWebhook { + /// Discord channel ID (snowflake). + pub channel_id: String, + /// Webhook URL for this channel. + pub webhook_url: String, +} + +/// Discord adapter configuration. +#[derive(Clone, Deserialize, serde::Serialize)] +pub struct DiscordConfig { + /// Discord bot token (for receiving via Gateway) + #[serde(skip_serializing)] + pub bot_token: String, + /// Webhook URL for sending (legacy single-channel config). + /// + /// R18: kept for backward compatibility. If `channel_webhooks` is also + /// set, `send_envelope` uses the per-channel lookup. If only + /// `webhook_url` is set, every send goes to this one webhook and the + /// domain parameter is ignored (legacy behaviour). + pub webhook_url: String, + /// Guild (server) ID + pub guild_id: String, + /// Channel IDs to monitor + pub channels: Vec, + /// Per-channel webhook configuration (R18). + /// + /// Keyed by channel_id. When present, `send_envelope` looks up the + /// right webhook by domain hash, so a single adapter can serve multiple + /// groups. Defaults to empty; legacy configs (only `webhook_url`) keep + /// the original single-webhook behaviour. + #[serde(default)] + pub channel_webhooks: Vec, +} + +impl std::fmt::Debug for DiscordConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let redacted = if self.bot_token.len() > 8 { + format!("{}***", &self.bot_token[..8]) + } else { + "***".to_string() + }; + f.debug_struct("DiscordConfig") + .field("bot_token", &redacted) + .field("webhook_url", &"") + .field("guild_id", &self.guild_id) + .field("channels", &self.channels) + .field( + "channel_webhooks", + &format!("<{} entries>", self.channel_webhooks.len()), + ) + .finish() + } +} + +/// Discord adapter client. +pub struct DiscordAdapter { + config: DiscordConfig, + client: reqwest::Client, +} + +impl DiscordAdapter { + /// Create a new Discord adapter from config. + pub fn new(config: DiscordConfig) -> Self { + Self { + config, + client: reqwest::Client::new(), + } + } + + /// Create from JSON config bytes (used by plugin ABI). + pub fn from_config_bytes(config: &[u8]) -> Result { + let config: DiscordConfig = + serde_json::from_slice(config).map_err(|e| format!("Invalid config: {}", e))?; + Ok(Self::new(config)) + } + + /// Send a message via Discord webhook for a specific URL. + pub async fn send_webhook_message_to( + &self, + webhook_url: &str, + content: &str, + ) -> Result { + let body = serde_json::json!({ "content": content }); + + let resp = self + .client + .post(webhook_url) + .json(&body) + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Discord API error {}: {}", status, body)); + } + + resp.json() + .await + .map_err(|e| format!("JSON parse error: {}", e)) + } + + /// Send a file attachment via Discord webhook for a specific URL. + pub async fn send_webhook_file_to( + &self, + webhook_url: &str, + filename: &str, + data: &[u8], + ) -> Result { + let file_part = reqwest::multipart::Part::bytes(data.to_vec()) + .file_name(filename.to_string()) + .mime_str("application/octet-stream") + .map_err(|e| format!("MIME error: {}", e))?; + + let form = reqwest::multipart::Form::new().part("file", file_part); + + let resp = self + .client + .post(webhook_url) + .multipart(form) + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Discord API error {}: {}", status, body)); + } + + resp.json() + .await + .map_err(|e| format!("JSON parse error: {}", e)) + } + + /// Look up the webhook URL for a domain. Returns the per-channel + /// webhook URL if one is registered for the channel matching the + /// domain hash; otherwise falls back to the legacy `webhook_url` if + /// no per-channel webhooks are configured. + fn webhook_url_for_domain(&self, domain: &BroadcastDomainId) -> Option { + for cw in &self.config.channel_webhooks { + if Self::domain_hash(&cw.channel_id) == domain.domain_hash { + return Some(cw.webhook_url.clone()); + } + } + if self.config.channel_webhooks.is_empty() { + Some(self.config.webhook_url.clone()) + } else { + None + } + } + + /// Send a message via Discord webhook. + pub async fn send_webhook_message(&self, content: &str) -> Result { + self.send_webhook_message_to(&self.config.webhook_url, content) + .await + } + + /// Send a file attachment via Discord webhook. + pub async fn send_webhook_file( + &self, + filename: &str, + data: &[u8], + ) -> Result { + self.send_webhook_file_to(&self.config.webhook_url, filename, data) + .await + } + + /// Get messages from a Discord channel (requires bot token). + pub async fn get_channel_messages( + &self, + channel_id: &str, + limit: u32, + ) -> Result, String> { + let url = format!( + "https://discord.com/api/v10/channels/{}/messages?limit={}", + channel_id, limit + ); + + let resp = self + .client + .get(&url) + .header("Authorization", format!("Bot {}", self.config.bot_token)) + .send() + .await + .map_err(|e| format!("HTTP error: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Discord API error {}: {}", status, body)); + } + + resp.json() + .await + .map_err(|e| format!("JSON parse error: {}", e)) + } + + /// Encode an envelope as base64 with DOT prefix. + pub fn encode_envelope(envelope_bytes: &[u8]) -> String { + format!( + "DOT/1/{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(envelope_bytes) + ) + } + + /// Decode a DOT-prefixed base64 envelope. + pub fn decode_envelope(text: &str) -> Result, String> { + let text = text.trim(); + let b64 = text + .strip_prefix("DOT/1/") + .ok_or_else(|| "Missing DOT/1/ prefix".to_string())?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {}", e)) + } + + /// Compute domain hash for a Discord channel ID. + /// + /// Hash input includes platform prefix per RFC-0850 S3.1: + /// `BLAKE3-256("discord:{channel_id}")` + pub fn domain_hash(channel_id: &str) -> [u8; 32] { + let normalized = channel_id.trim().to_lowercase(); + *blake3::hash(format!("discord:{}", normalized).as_bytes()).as_bytes() + } + + /// Platform type constant (0x0002 = Discord). + /// + /// Corresponds to `PlatformType::Discord` in `octo-network::dot::domain`. + pub const PLATFORM_TYPE: u16 = 0x0002; + + /// Maximum payload bytes per message (Discord limit). + pub fn max_payload_bytes() -> usize { + 2000 + } + + /// Rate limit: messages per second per channel. + pub fn rate_limit_per_second() -> u32 { + 5 + } +} + +// --- PlatformAdapter trait implementation --- + +use async_trait::async_trait; +use octo_network::dot::adapters::{ + CapabilityReport, DeliveryReceipt, PlatformAdapter, RawPlatformMessage, +}; +use octo_network::dot::domain::{BroadcastDomainId, PlatformType}; +use octo_network::dot::envelope::DeterministicEnvelope; +use octo_network::dot::error::PlatformAdapterError; + +fn transport_err(msg: impl Into) -> PlatformAdapterError { + PlatformAdapterError::Unreachable { + platform: "discord".to_string(), + reason: msg.into(), + } +} + +#[async_trait] +impl PlatformAdapter for DiscordAdapter { + async fn send_envelope( + &self, + domain: &BroadcastDomainId, + envelope: &DeterministicEnvelope, + ) -> Result { + let wire_bytes = envelope.to_wire_bytes(); + let encoded = Self::encode_envelope(&wire_bytes); + + // R18 fix: look up the per-channel webhook URL by domain hash. Falls + // back to the legacy single-webhook config if `channel_webhooks` + // is empty. Previously this method ignored the domain and always + // sent to `self.config.webhook_url`. + let webhook_url = self.webhook_url_for_domain(domain).ok_or_else(|| { + transport_err(format!( + "No channel webhook registered for domain {:?}", + domain.domain_hash + )) + })?; + + // Retry with exponential backoff (ZeroClaw pattern) + let retry_cfg = octo_network::dot::adapters::backoff::RetryConfig::default(); + let mut last_err = String::new(); + + for attempt in 0..=retry_cfg.max_retries { + let result = if wire_bytes.len() > Self::max_payload_bytes() { + self.send_webhook_file_to(&webhook_url, "envelope.bin", &wire_bytes) + .await + } else { + self.send_webhook_message_to(&webhook_url, &encoded).await + }; + + match result { + Ok(msg) => { + return Ok(DeliveryReceipt { + platform_message_id: msg.id, + delivered_at: 0, + }); + } + Err(e) => { + last_err = e.clone(); + if (e.contains("429") || e.contains("rate limit")) + && retry_cfg.should_retry(attempt) + { + let delay = retry_cfg.delay_for_attempt(attempt); + tokio::time::sleep(delay).await; + continue; + } + return Err(transport_err(e)); + } + } + } + Err(transport_err(format!("Retries exhausted: {}", last_err))) + } + + async fn receive_messages( + &self, + _domain: &BroadcastDomainId, + ) -> Result, PlatformAdapterError> { + let messages = match self.config.channels.first() { + Some(ch) => self + .get_channel_messages(ch, 10) + .await + .map_err(transport_err)?, + None => Vec::new(), + }; + + let mut result = Vec::new(); + for msg in messages { + if let Ok(payload) = Self::decode_envelope(&msg.content) { + let mut metadata = std::collections::BTreeMap::new(); + metadata.insert("channel_id".to_string(), msg.channel_id.clone()); + metadata.insert("message_id".to_string(), msg.id.clone()); + result.push(RawPlatformMessage { + platform_id: msg.id, + payload, + metadata, + }); + } + } + Ok(result) + } + + fn canonicalize( + &self, + raw: &RawPlatformMessage, + ) -> Result { + if raw.payload.is_empty() { + return Err(transport_err("Empty payload")); + } + // payload contains full wire bytes from decode_envelope() in receive_messages() + DeterministicEnvelope::from_wire_bytes(&raw.payload).map_err(|e| { + PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {}", e), + } + }) + } + + fn capabilities(&self) -> CapabilityReport { + CapabilityReport { + max_payload_bytes: Self::max_payload_bytes(), + supports_fragmentation: true, + supports_encryption: false, + supports_raw_binary: false, + rate_limit_per_second: Self::rate_limit_per_second(), + media_capabilities: Some(octo_network::dot::adapters::MediaCapabilities { + max_upload_bytes: 25 * 1024 * 1024, // 25MB + supported_mime_types: vec![ + "image/jpeg".into(), + "image/png".into(), + "image/gif".into(), + "video/mp4".into(), + "audio/mpeg".into(), + "application/pdf".into(), + "application/octet-stream".into(), + ], + }), + } + } + + fn domain_id(&self, platform_id: &str) -> BroadcastDomainId { + BroadcastDomainId::new(PlatformType::Discord, platform_id) + } + + fn platform_type(&self) -> PlatformType { + PlatformType::Discord + } + + fn self_handle(&self) -> Option { + // Discord bot ID is extracted from the bot token (format: "BOT_TOKEN" or "Bot TOKEN") + // For self-loop prevention, the gateway should decode the bot user ID from the token. + // Placeholder: return None until gateway integration populates this. + None + } + + async fn shutdown(&self) -> Result<(), PlatformAdapterError> { + Ok(()) + } + + async fn health_check(&self) -> Result<(), PlatformAdapterError> { + // Verify webhook is still valid by checking Discord API + let timeout = std::time::Duration::from_secs(5); + match tokio::time::timeout(timeout, self.send_webhook_message("health")).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(transport_err(format!("Health check failed: {}", e))), + Err(_) => Err(transport_err("Health check timed out after 5s")), + } + } + + async fn upload_media( + &self, + filename: &str, + data: &[u8], + mime_type: &str, + ) -> Result { + // Upload via webhook as file attachment + let webhook_url = &self.config.webhook_url; + + let file_part = reqwest::multipart::Part::bytes(data.to_vec()) + .file_name(filename.to_string()) + .mime_str(mime_type) + .map_err(|e| transport_err(format!("MIME error: {e}")))?; + + let form = reqwest::multipart::Form::new() + .part("file", file_part) + .text("content", "DOT media upload"); + + let resp = self + .client + .post(webhook_url) + .multipart(form) + .send() + .await + .map_err(|e| transport_err(format!("Upload failed: {e}")))? + .json::() + .await + .map_err(|e| transport_err(format!("Response parse: {e}")))?; + + let msg_id = resp["id"].as_str().unwrap_or("unknown").to_string(); + Ok(msg_id) + } + + async fn download_media(&self, message_id: &str) -> Result, PlatformAdapterError> { + // Discord attachment URLs can be retrieved from the message payload + // message_id is expected to be an attachment URL from the webhook response + if message_id.starts_with("https://") { + let bytes = self + .client + .get(message_id) + .send() + .await + .map_err(|e| transport_err(format!("Download failed: {e}")))? + .bytes() + .await + .map_err(|e| transport_err(format!("Download read: {e}")))?; + Ok(bytes.to_vec()) + } else { + Err(transport_err(format!( + "Invalid attachment URL: {message_id}" + ))) + } + } +} + +// --- Discord API types --- + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordMessage { + pub id: String, + pub channel_id: String, + pub content: String, + pub author: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiscordUser { + pub id: String, + pub username: String, + pub bot: Option, +} + +// --- Plugin ABI exports (for cdylib loading) --- + +#[no_mangle] +pub extern "C" fn adapter_version() -> u32 { + 1 +} + +#[no_mangle] +pub extern "C" fn platform_type() -> u16 { + 0x0002 // Discord +} + +#[no_mangle] +/// # Safety +/// `config` must point to a valid buffer of at least `len` bytes. +pub unsafe extern "C" fn create_adapter(config: *const u8, config_len: usize) -> *mut () { + if config.is_null() || config_len == 0 { + return std::ptr::null_mut(); + } + + let config_bytes = std::slice::from_raw_parts(config, config_len); + match DiscordAdapter::from_config_bytes(config_bytes) { + Ok(adapter) => Box::into_raw(Box::new(adapter)) as *mut (), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +/// # Safety +/// `ptr` must be a pointer previously returned by `create_adapter`. +pub unsafe extern "C" fn destroy_adapter(adapter: *mut ()) { + if !adapter.is_null() { + let _ = Box::from_raw(adapter as *mut DiscordAdapter); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_envelope() { + let original = b"test envelope data"; + let encoded = DiscordAdapter::encode_envelope(original); + assert!(encoded.starts_with("DOT/1/")); + let decoded = DiscordAdapter::decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, original); + } + + #[test] + fn test_domain_hash_deterministic() { + let h1 = DiscordAdapter::domain_hash("987654321"); + let h2 = DiscordAdapter::domain_hash("987654321"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_hash_normalized() { + let h1 = DiscordAdapter::domain_hash("987654321"); + let h2 = DiscordAdapter::domain_hash(" 987654321 "); + assert_eq!(h1, h2); + } + + #[test] + fn test_platform_type() { + assert_eq!(DiscordAdapter::PLATFORM_TYPE, 0x0002); + } + + #[test] + fn test_abi_exports() { + assert_eq!(adapter_version(), 1); + assert_eq!(platform_type(), 0x0002); + } + + #[test] + fn test_config_from_json() { + let config = serde_json::json!({ + "bot_token": "Bot test", + "webhook_url": "https://discord.com/api/webhooks/123/abc", + "guild_id": "111", + "channels": ["222"] + }); + let adapter = + DiscordAdapter::from_config_bytes(serde_json::to_vec(&config).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.bot_token, "Bot test"); + assert_eq!(adapter.config.channels.len(), 1); + } + + #[test] + fn test_max_payload() { + assert_eq!(DiscordAdapter::max_payload_bytes(), 2000); + assert_eq!(DiscordAdapter::rate_limit_per_second(), 5); + } + + #[test] + fn test_encode_decode_roundtrip() { + let data = vec![0u8; 256]; + let encoded = DiscordAdapter::encode_envelope(&data); + let decoded = DiscordAdapter::decode_envelope(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_decode_invalid() { + assert!(DiscordAdapter::decode_envelope("NOTDOT/1/abc").is_err()); + assert!(DiscordAdapter::decode_envelope("DOT/1/!!!invalid!!!").is_err()); + } + + // R18: per-domain webhook lookup + fn test_adapter_with_channel_webhooks(channels: Vec) -> DiscordAdapter { + let config = DiscordConfig { + bot_token: "Bot test".into(), + webhook_url: "https://discord.com/api/webhooks/legacy/abc".into(), + guild_id: "111".into(), + channels: channels.iter().map(|c| c.channel_id.clone()).collect(), + channel_webhooks: channels, + }; + DiscordAdapter::new(config) + } + + #[test] + fn test_webhook_url_for_domain_falls_back_to_legacy() { + // No per-channel webhooks configured β†’ use legacy `webhook_url`. + let adapter = test_adapter_with_channel_webhooks(vec![]); + let domain = BroadcastDomainId { + platform_type: PlatformType::Discord as u16, + domain_hash: DiscordAdapter::domain_hash("222"), + }; + assert_eq!( + adapter.webhook_url_for_domain(&domain).as_deref(), + Some("https://discord.com/api/webhooks/legacy/abc") + ); + } + + #[test] + fn test_webhook_url_for_domain_picks_matching_channel() { + // Two per-channel webhooks; the lookup should pick the one whose + // channel_id hashes to the requested domain. + let adapter = test_adapter_with_channel_webhooks(vec![ + ChannelWebhook { + channel_id: "111".into(), + webhook_url: "https://discord.com/api/webhooks/111/aaa".into(), + }, + ChannelWebhook { + channel_id: "222".into(), + webhook_url: "https://discord.com/api/webhooks/222/bbb".into(), + }, + ]); + let domain_222 = BroadcastDomainId { + platform_type: PlatformType::Discord as u16, + domain_hash: DiscordAdapter::domain_hash("222"), + }; + assert_eq!( + adapter.webhook_url_for_domain(&domain_222).as_deref(), + Some("https://discord.com/api/webhooks/222/bbb") + ); + } + + #[test] + fn test_webhook_url_for_domain_returns_none_when_no_match() { + // Per-channel webhooks configured but none match the domain. + let adapter = test_adapter_with_channel_webhooks(vec![ChannelWebhook { + channel_id: "111".into(), + webhook_url: "https://discord.com/api/webhooks/111/aaa".into(), + }]); + let domain_unknown = BroadcastDomainId { + platform_type: PlatformType::Discord as u16, + domain_hash: DiscordAdapter::domain_hash("999"), + }; + assert_eq!(adapter.webhook_url_for_domain(&domain_unknown), None); + } + + #[test] + fn test_config_parses_channel_webhooks() { + let config = serde_json::json!({ + "bot_token": "Bot test", + "webhook_url": "https://discord.com/api/webhooks/legacy/abc", + "guild_id": "111", + "channels": ["222", "333"], + "channel_webhooks": [ + { "channel_id": "222", "webhook_url": "https://discord.com/api/webhooks/222/aaa" }, + { "channel_id": "333", "webhook_url": "https://discord.com/api/webhooks/333/bbb" } + ] + }); + let adapter = + DiscordAdapter::from_config_bytes(serde_json::to_vec(&config).unwrap().as_slice()) + .unwrap(); + assert_eq!(adapter.config.channel_webhooks.len(), 2); + assert_eq!(adapter.config.channel_webhooks[0].channel_id, "222"); + assert_eq!( + adapter.config.channel_webhooks[1].webhook_url, + "https://discord.com/api/webhooks/333/bbb" + ); + } +} diff --git a/crates/octo-adapter-irc/Cargo.toml b/crates/octo-adapter-irc/Cargo.toml new file mode 100644 index 00000000..c69d3d96 --- /dev/null +++ b/crates/octo-adapter-irc/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "octo-adapter-irc" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time", "sync", "net", "io-util"] } +tokio-rustls = "0.26" +rustls = "0.23" +rustls-pki-types = "1" +webpki-roots = "0.26" +blake3 = "1.5" +base64 = "0.22" +async-trait = "0.1" +tracing = { workspace = true } +octo-network = { path = "../octo-network" } diff --git a/crates/octo-adapter-irc/src/lib.rs b/crates/octo-adapter-irc/src/lib.rs new file mode 100644 index 00000000..e898b380 --- /dev/null +++ b/crates/octo-adapter-irc/src/lib.rs @@ -0,0 +1,3340 @@ +//! IRC adapter for DOT (RFC-0850 Β§8.1, PlatformType::IRC) +//! +//! Pure Rust IRC client using raw TCP (RFC 2812). Text-only transport +//! with UTF-8 safe message splitting for DOT envelope delivery. +//! +//! ## Configuration +//! +//! ```json +//! { +//! "server": "irc.libera.chat", +//! "port": 6697, +//! "nickname": "cipherocto-bot", +//! "channels": ["#cipherocto"], +//! "password": null, +//! "use_tls": true +//! } +//! ``` +//! +//! ## Wire Format +//! +//! - **Send:** `PRIVMSG #channel :DOT/1/` (base64 URL-safe, no padding) +//! - **Fragment:** `PRIVMSG #channel :DOT/1/F:/:` +//! - **Receive:** Parse PRIVMSG from other users, extract DOT/1/ prefix +//! - **Max payload:** 480 bytes (512 IRC line limit - ~32 PRIVMSG overhead) + +use async_trait::async_trait; +use base64::Engine; +use rustls::ClientConfig; +use rustls_pki_types::ServerName; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use tokio::io::{ + AsyncBufReadExt, AsyncWriteExt, BufReader, ReadHalf as TokioReadHalf, + WriteHalf as TokioWriteHalf, +}; +use tokio::net::{ + tcp::OwnedReadHalf as TcpReadHalf, tcp::OwnedWriteHalf as TcpWriteHalf, TcpStream, +}; +use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio_rustls::{client::TlsStream, TlsConnector}; + +use octo_network::dot::adapters::{ + backoff::RetryConfig, + coordinator_admin::{ + AddMemberOutput, AdminCapabilityReport, CoordinatorAdmin, GroupHandle, GroupId, + GroupMemberSpec, GroupMetadata, GroupModeFlags, InviteRef, PeerId, + }, + CapabilityReport, DeliveryReceipt, PlatformAdapter, RawPlatformMessage, +}; +use octo_network::dot::domain::{BroadcastDomainId, PlatformType}; +use octo_network::dot::envelope::DeterministicEnvelope; +use octo_network::dot::error::PlatformAdapterError; + +// ── Configuration ────────────────────────────────────────────────── + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct IrcConfig { + /// IRC server hostname. + pub server: String, + /// IRC server port. Default: 6697 (TLS) or 6667 (plain). + #[serde(default = "default_port")] + pub port: u16, + /// Bot nickname. + pub nickname: String, + /// Channels to join (with # prefix). + pub channels: Vec, + /// Optional server password (PASS command). + pub password: Option, + /// Use TLS. Default: true. + #[serde(default = "default_tls")] + pub use_tls: bool, +} + +fn default_port() -> u16 { + 6697 +} +fn default_tls() -> bool { + true +} + +impl IrcConfig { + /// Pure field-shape validation (no I/O). Modeled on + /// `WhatsAppConfig::validate` (see `octo-adapter-whatsapp/src/adapter.rs`). + /// + /// Checks: + /// - `server` is non-empty and contains no whitespace or `/` + /// - `port` is non-zero + /// - `nickname` is non-empty and contains no whitespace + /// - `channels` entries are non-empty + /// - `channels` entries start with `#`, `&`, `+`, or `!` (IRC channel + /// name prefixes) + /// - `channels` entries contain no spaces, commas, colons, or NUL + /// - `channels` entries are not the IRC "JOIN 0" special token + pub fn validate(&self) -> std::result::Result<(), String> { + validate_server(&self.server)?; + if self.port == 0 { + return Err("port must be non-zero".into()); + } + if self.nickname.trim().is_empty() { + return Err("nickname must not be empty".into()); + } + if self.nickname.contains(char::is_whitespace) { + return Err(format!( + "nickname {:?} must not contain whitespace", + self.nickname + )); + } + for ch in &self.channels { + validate_channel_name(ch)?; + } + Ok(()) + } +} + +/// Validate an IRC server name (used by `IrcConfig::validate`). +/// +/// Rules: +/// - Non-empty after trim +/// - No whitespace, no `/` (path separator would corrupt DNS), +/// no control characters +/// - No `..` (RFC-952 forbids empty labels; an IRC hostname made +/// of only dots is also a clear sign of a config typo like +/// `"irc.example.com.."` or just `".."`) +fn validate_server(server: &str) -> std::result::Result<(), String> { + if server.trim().is_empty() { + return Err("server must not be empty".into()); + } + if server.contains(|c: char| c.is_whitespace() || c == '/' || c.is_control()) { + return Err(format!( + "server {server:?} must not contain whitespace, '/', or control characters" + )); + } + if server.contains("..") { + return Err(format!( + "server {server:?} must not contain empty labels ('..')" + )); + } + Ok(()) +} + +/// Validate an IRC channel name (used by both `IrcConfig::validate` +/// and `CoordinatorAdmin::join_by_invite`). +/// +/// Rules: +/// - Non-empty +/// - Must start with `#`, `&`, `+`, or `!` (IRC channel-name prefixes) +/// - No whitespace, commas, colons, or NUL +/// - Must not be the IRC "JOIN 0" special token (`#0`, `&0`, `+0`, `!0`) +/// which makes the client PART all channels. +pub(crate) fn validate_channel_name(ch: &str) -> std::result::Result<(), String> { + if ch.is_empty() { + return Err("channel must not be empty".into()); + } + let first = ch.chars().next().expect("non-empty checked above"); + if !matches!(first, '#' | '&' | '+' | '!') { + return Err(format!("channel {ch:?} must start with one of #, &, +, !")); + } + if ch.contains(|c: char| c.is_whitespace() || c == ',' || c == ':' || c == '\0') { + return Err(format!( + "channel {ch:?} must not contain whitespace, commas, colons, or NUL" + )); + } + // Reject IRC's "JOIN 0" special token (which leaves all channels). + if ch == "#0" || ch == "&0" || ch == "+0" || ch == "!0" { + return Err(format!( + "channel {ch:?} is the IRC \"leave all\" special token" + )); + } + Ok(()) +} + +// ── M7 pending-reply correlation types ───────────────────────────── + +/// RFC-0861 Β§4 M7: per-command nonce for correlating an +/// outbound IRC command (e.g. `INVITE`) with its server reply +/// (e.g. `341 RPL_INVITING` or `482 ERR_CHANOPRIVSNEEDED`). +/// Monotonically increasing, allocated from +/// `IrcAdapter::next_command_id`. The numeric is the *internal* +/// correlation key; the IRC protocol has no built-in +/// per-command tag, so matching is FIFO at the listener +/// (see `pending_invites` in `irc_session`). +pub type CommandId = u64; + +/// RFC-0861 Β§4 M7: the resolved result of a server reply +/// correlated with an outbound command. `Ok` covers `RPL_*` +/// success numerics; `Err` carries the server's error code +/// (the numeric itself) and the trailing message text, which +/// `add_member` maps to a `PlatformAdapterError` shape. +#[derive(Debug)] +pub enum NumericResult { + /// Server returned a success numeric (e.g. 341 RPL_INVITING). + Ok { code: u16 }, + /// Server returned an error numeric (e.g. 482 + /// ERR_CHANOPRIVSNEEDED). `code` is the numeric itself; + /// `message` is the trailing text, often empty. + Err { code: u16, message: String }, + /// The server did not reply within the configured timeout. + /// Surfaced as a separate variant so callers can distinguish + /// "server said no" from "server is silent". + Timeout, +} + +// ── Constants ────────────────────────────────────────────────────── + +/// Maximum IRC line length including CRLF. +const IRC_MAX_LINE_BYTES: usize = 512; + +/// Effective max payload per PRIVMSG: computed per-call rather than +/// as a global constant, because the PRIVMSG overhead includes the +/// channel name. See [`max_payload_for_channel`] for the exact +/// formula. +fn max_payload_for_channel(channel: &str) -> usize { + // "PRIVMSG " (8) + channel + " :" (2) + CRLF (2) + let overhead = 12 + channel.len(); + IRC_MAX_LINE_BYTES.saturating_sub(overhead) +} + +/// Compatibility constant retained for the `CapabilityReport`. The +/// value assumes a typical 20-char channel name; for longer names +/// the per-call [`max_payload_for_channel`] returns a smaller +/// payload. Use the constant only for advertising the *typical* +/// limit to callers, not for splitting. +const TYPICAL_CHANNEL_LEN: usize = 20; +const PRIVMSG_OVERHEAD_TYPICAL: usize = 12 + TYPICAL_CHANNEL_LEN; +const MAX_PAYLOAD_PER_MSG: usize = IRC_MAX_LINE_BYTES - PRIVMSG_OVERHEAD_TYPICAL; + +/// Keepalive interval (seconds). +const _PING_INTERVAL_SECS: u64 = 120; + +/// DOT/1/ prefix for envelope detection. +const DOT_PREFIX: &str = "DOT/1/"; + +/// DOT/1/F: prefix for fragments. +const DOT_FRAGMENT_PREFIX: &str = "DOT/1/F:"; + +// ── Adapter ──────────────────────────────────────────────────────── + +pub struct IrcAdapter { + config: IrcConfig, + /// Receiver for incoming IRC PRIVMSG containing DOT envelopes. + rx: Mutex>, + /// Sender β€” given to the IRC listener task. + tx: mpsc::Sender, + /// Outgoing channel carrying pre-built IRC lines (without trailing + /// CRLF) for both `CoordinatorAdmin` actions and regular `send_envelope` + /// traffic. Initialized to `None` in `new()`; the sender half is + /// installed by the first call to `ensure_connected`, then handed to + /// the listener task alongside the receiver. The `OwnedWriteHalf` + /// itself is owned by the listener task and never escapes + /// `irc_session`; this channel is the only path from the public API + /// to the socket. + out_tx: Mutex>>, + /// Whether the IRC connection has been started. The watchdog pattern + /// is: any send/recv failure on `out_tx` / `rx` indicates the + /// listener task has died; the failing public method resets this + /// flag to `false` so the next `ensure_connected` call respawns. + connected: Mutex, + /// RFC-0861 Β§4 M8: whether the IRC session has completed the + /// NICK/USER handshake. Set to `true` in the listener's + /// 376 (RPL_ENDOFMOTD) / 422 (ERR_NOMOTD) branch β€” those + /// numerics are only sent *after* authentication completes, so + /// they are the canonical "we are authenticated and the + /// session is usable" signal. Cleared in BOTH `mark_disconnected` + /// (transient drop, at `lib.rs:377`) AND `shutdown` (full + /// teardown, at `lib.rs:1086`) so `health_check` never lies + /// about a half-up session. Wrapped in `Arc` so the spawned + /// listener task can mutate it without a `Mutex`; `health_check` + /// and the listener share the same atomic via cheap refcount + /// clone, no lock contention. + is_authenticated: Arc, + /// Stop-signal channel. `shutdown()` takes the sender (or replaces + /// it with `None`) and notifies the listener, which exits its + /// select loop. The receiver is moved into the listener spawn on + /// the first `ensure_connected` call. + shutdown_tx: Mutex>>, + /// JoinHandle for the spawned listener task. `shutdown()` aborts + /// it as a backstop in case the stop signal is racing with a + /// blocked `read_line()`. + listener_handle: Mutex>>, + /// Set to `true` once `shutdown()` has been called. After this, + /// `ensure_connected` refuses to spawn a new listener β€” the + /// adapter is terminal and callers must construct a fresh + /// `IrcAdapter` to resume. This is the *hard-shutdown* contract: + /// soft recovery (respawn) is intentionally not supported, so a + /// caller that misuses a shut-down adapter gets a clear error + /// instead of a silently-working adapter with zombie state. + shutting_down: AtomicBool, + /// Channels the bot has joined at runtime (via `join_by_invite`). + /// Merged with `config.channels` by `list_own_groups` and + /// `channel_for` so the bot can see and administer groups it joined + /// outside the static config. Backwards-compatible: when empty, + /// behavior is identical to the static-config-only path. Uses + /// `std::sync::Mutex` (not `tokio::sync::Mutex`) because the + /// critical sections are short string-vec operations with no + /// `.await` inside β€” the lock is safe to hold in async context + /// as long as the body doesn't await. + runtime_channels: StdMutex>, + /// RFC-0861 Β§4 M7: monotonically increasing per-command + /// nonce, allocated by `add_member` (and any future + /// request/response pair) so the listener can correlate a + /// server reply with the originating outbound command. The + /// IRC protocol has no built-in per-command tag, so the + /// correlation is FIFO via `pending_invites` below. + next_command_id: AtomicU64, + /// RFC-0861 Β§4 M7: pending INVITE requests awaiting a + /// `341 RPL_INVITING` / `482 ERR_CHANOPRIVSNEEDED` reply. + /// Keyed by the `CommandId` allocated at send time; the + /// listener pops the entry with the smallest key on a + /// matching reply and resolves the oneshot. `BTreeMap` + /// (vs. `HashMap` in the spec text) because the spec's + /// matching rule is "the next reply resolves the first + /// sent command" β€” `BTreeMap::pop_first` gives O(log n) + /// FIFO; `HashMap` would be O(n) and order-undefined. + /// Wrapped in `Arc` so the listener task (which lives in + /// `irc_session`) can resolve entries without holding the + /// adapter's full state. + pending_invites: Arc>>>, +} + +impl IrcAdapter { + pub fn new(config: IrcConfig) -> Self { + let (tx, rx) = mpsc::channel(4096); + Self { + config, + rx: Mutex::new(rx), + tx, + out_tx: Mutex::new(None), + connected: Mutex::new(false), + is_authenticated: Arc::new(AtomicBool::new(false)), + shutdown_tx: Mutex::new(None), + listener_handle: Mutex::new(None), + shutting_down: AtomicBool::new(false), + runtime_channels: StdMutex::new(Vec::new()), + next_command_id: AtomicU64::new(1), + pending_invites: Arc::new(Mutex::new(BTreeMap::new())), + } + } + + pub fn from_config_bytes(config: &[u8]) -> Result { + let config: IrcConfig = + serde_json::from_slice(config).map_err(|e| format!("Invalid config: {}", e))?; + Ok(Self::new(config)) + } + + /// Start IRC connection (idempotent). Spawns the listener task on + /// the first call; subsequent calls are no-ops as long as the + /// listener is alive. The watchdog pattern: any public method that + /// fails to send to / recv from the listener's channel resets + /// `connected = false` (see `send_raw_line`, `receive_messages`, + /// `send_envelope`), so the next `ensure_connected` respawns. + /// + /// **Hard shutdown (R23e N14):** once `shutdown()` has been called, + /// `ensure_connected` returns `Err(transport_err("..."))` instead + /// of respawning. The caller must construct a fresh `IrcAdapter` + /// to recover. This makes post-shutdown misuse fail loudly rather + /// than silently spawning a new listener over a half-torn-down one. + async fn ensure_connected(&self) -> Result<(), PlatformAdapterError> { + // Pre-flight: validate the config so a malformed `IrcConfig` + // (empty channel, empty server, etc.) is caught here with a + // clear error before we try to TCP-connect. Note this runs + // on every call to ensure_connected, but it's a tiny pure + // function β€” the cost is negligible compared to TCP connect. + self.config.validate().map_err(transport_err)?; + + // The `connected` lock is held throughout the spawn sequence + // (build channels, install senders, spawn listener). Shutdown + // also acquires this lock as its FIRST step (before touching + // shutdown_tx / out_tx / listener_handle), so a concurrent + // shutdown blocks here until our spawn is fully visible β€” or + // we block until shutdown is done. The shutting_down check + // is inside the lock as well: even if shutdown ran while we + // were queued on the lock, when we acquire it we re-check + // the flag and bail without spawning. + let mut connected = self.connected.lock().await; + + // R23e N14 + R23f N21: refuse to respawn after shutdown. + // The check is INSIDE the `connected` lock for two reasons: + // 1. So shutdown can't sneak in between the check and the + // spawn β€” its `connected.lock().await` blocks until our + // entire spawn sequence completes. + // 2. So that if we acquire the lock AFTER shutdown ran + // (shutdown has set shutting_down=true and released + // connected), we still refuse to spawn. + if self.shutting_down.load(Ordering::SeqCst) { + return Err(transport_err( + "IrcAdapter has been shut down; construct a fresh adapter to reconnect", + )); + } + + if *connected { + return Ok(()); + } + + let server = self.config.server.clone(); + let port = self.config.port; + let nickname = self.config.nickname.clone(); + let channels = self.config.channels.clone(); + let password = self.config.password.clone(); + let use_tls = self.config.use_tls; + let tx = self.tx.clone(); + // RFC-0861 Β§4 M8: clone the `Arc` so the spawned + // listener can flip the flag on 376/422 while the field on + // `self` stays readable by `health_check` and clearable by + // `mark_disconnected` / `shutdown`. + let is_authenticated = self.is_authenticated.clone(); + // RFC-0861 Β§4 M7: clone the pending_invites Arc so the + // listener can resolve entries on 341/482 without + // holding the adapter's full state. + let pending_invites = self.pending_invites.clone(); + + // Build the unified outbound channel (admin + send_envelope). + // The receiver is moved into the listener task; the sender is + // installed on `self` for the public API. + // + // Capacity is 128: admin commands (KICK, MODE, TOPIC, etc.) are + // rare, but `send_envelope` can produce many PRIVMSG fragments + // for large envelopes. 128 lets a 100-fragment envelope plus + // 28 admin commands queue without backpressure. The listener + // drains in `biased;` select so outbound makes forward progress + // even when the server isn't pushing data. + let (out_tx, out_rx) = mpsc::channel::(128); + *self.out_tx.lock().await = Some(out_tx); + + // Build a stop-signal watch channel. The receiver is moved + // into the listener; the sender is kept on `self` so + // `shutdown()` can wake the listener. (R23c N3: previously + // the listener ran forever and the FFI shutdown path + // leaked the spawned task.) + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + *self.shutdown_tx.lock().await = Some(stop_tx); + + let handle = tokio::spawn(async move { + irc_listener( + server, + port, + nickname, + channels, + password, + use_tls, + tx, + out_rx, + stop_rx, + is_authenticated, + pending_invites, + ) + .await; + }); + *self.listener_handle.lock().await = Some(handle); + + *connected = true; + Ok(()) + } + + /// Mark the adapter as disconnected. Called by any public method + /// that detects the listener task has died (send/recv failure on + /// the listener's channels). The next `ensure_connected` will + /// respawn. Note: we don't abort the listener here β€” if the + /// outbound channel send fails, the listener is in its + /// `out_rx.recv()` arm and will return `Ok(())` shortly. If it's + /// truly stuck, the `shutdown()`-based abort is the right + /// recovery path. + async fn mark_disconnected(&self) { + *self.connected.lock().await = false; + *self.out_tx.lock().await = None; + // RFC-0861 Β§4 M8: clear the authentication flag on every + // transient drop so `health_check` can't report Ok(()) for + // a half-up session that just lost the socket. The flag + // will be re-set on the next 376/422 once the listener + // reconnects and re-handshakes. + self.is_authenticated + .store(false, std::sync::atomic::Ordering::SeqCst); + } + + /// Internal helper: send a pre-built raw IRC line (without trailing + /// `\r\n`) to the socket through the outbound channel. The listener + /// task adds the CRLF and writes it to the TCP stream. + /// + /// Returns `Ok(())` once the line is enqueued. The line is *fire-and- + /// forget*: IRC has no synchronous request/response correlation at + /// the protocol level, so this method does not block on the server's + /// reply. Callers that need confirmation should follow up with a + /// `CoordinatorAdmin::get_group_metadata` (when the metadata is + /// observable via a server response that the bot captures later) + /// or accept eventual consistency. + /// + /// **Validation:** rejects lines containing CR, LF, or NUL to defend + /// against command-injection (a future caller passing user-supplied + /// text into `format!` could otherwise emit `\r\nNICK pwned\r\n`). + /// The check is the belt to the listener's suspenders (see + /// `irc_session`). + async fn send_raw_line(&self, line: &str) -> Result<(), PlatformAdapterError> { + if line.contains('\r') || line.contains('\n') || line.contains('\0') { + return Err(PlatformAdapterError::ApiError { + code: 400, + message: format!("admin line contains illegal byte (CR/LF/NUL): {line:?}"), + }); + } + self.ensure_connected().await?; + let tx = { + let guard = self.out_tx.lock().await; + guard + .as_ref() + .ok_or_else(|| transport_err("outbound channel not initialized"))? + .clone() + }; + if let Err(_e) = tx.send(line.to_string()).await { + // Listener task is dead (the receiver was dropped). Mark + // disconnected so the next call respawns. + self.mark_disconnected().await; + return Err(PlatformAdapterError::Unreachable { + platform: "irc".into(), + reason: "outbound channel closed (listener exited)".into(), + }); + } + Ok(()) + } + + /// Split a message into IRC-safe chunks at UTF-8 boundaries. + pub fn split_message(message: &str, max_bytes: usize) -> Vec { + if max_bytes == 0 { + return vec![message.to_string()]; + } + + let mut chunks = Vec::new(); + for line in message.split('\n') { + let line = line.trim_end_matches('\r'); + if line.is_empty() { + continue; + } + + if line.len() <= max_bytes { + chunks.push(line.to_string()); + } else { + // Split at safe UTF-8 boundaries + let mut remaining = line; + while !remaining.is_empty() { + if remaining.len() <= max_bytes { + chunks.push(remaining.to_string()); + break; + } + let mut split_at = max_bytes; + while split_at > 0 && !remaining.is_char_boundary(split_at) { + split_at -= 1; + } + if split_at == 0 { + // Single character exceeds max β€” skip it + remaining = &remaining[remaining.chars().next().unwrap().len_utf8()..]; + continue; + } + chunks.push(remaining[..split_at].to_string()); + remaining = &remaining[split_at..]; + } + } + } + if chunks.is_empty() { + chunks.push(String::new()); + } + chunks + } + + /// Encode envelope bytes as DOT/1/ base64. + pub fn encode_envelope(bytes: &[u8]) -> String { + format!( + "DOT/1/{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) + } + + /// Encode a fragment as DOT/1/F:i/n: base64. + pub fn encode_fragment(index: u16, total: u16, bytes: &[u8]) -> String { + format!( + "DOT/1/F:{}/{}:{}", + index, + total, + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) + } + + /// Decode a DOT/1/ or DOT/1/F: message. + pub fn decode_message(text: &str) -> Result, String> { + let text = text.trim(); + + // Check for fragment prefix + if let Some(rest) = text.strip_prefix(DOT_FRAGMENT_PREFIX) { + // Format: i/n: + let colon_pos = rest.find(':').ok_or("Missing colon in fragment")?; + let _header = &rest[..colon_pos]; + let b64 = &rest[colon_pos + 1..]; + return base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {e}")); + } + + // Check for envelope prefix + let b64 = text + .strip_prefix(DOT_PREFIX) + .ok_or("Missing DOT/1/ prefix")?; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(b64) + .map_err(|e| format!("Base64 decode error: {e}")) + } + + /// Domain hash: `BLAKE3-256("irc:{server}:{channel}")` + pub fn domain_hash(server: &str, channel: &str) -> [u8; 32] { + *blake3::hash(format!("irc:{}:{}", server.trim().to_lowercase(), channel).as_bytes()) + .as_bytes() + } + + /// Inverse of `domain_hash`: parse a `server:channel` platform_id and + /// compute the hash. Used by the `PlatformAdapter::domain_id` impl so + /// that callers can construct a `BroadcastDomainId` from a single + /// colon-joined string and have it match the canonical `domain_hash`. + pub fn domain_hash_from_id(platform_id: &str) -> [u8; 32] { + let (server, channel) = match platform_id.split_once(':') { + Some((s, c)) => (s, c), + None => ("", platform_id), + }; + Self::domain_hash(server, channel) + } + + pub const PLATFORM_TYPE: u16 = 0x0006; + pub fn max_payload_bytes() -> usize { + MAX_PAYLOAD_PER_MSG + } + pub fn rate_limit_per_second() -> u32 { + 1 + } // IRC flood protection +} + +// ── IRC Protocol ─────────────────────────────────────────────────── + +/// The "IrcWriter" enum is the union of the two writer types +/// `irc_session` supports: a plain TCP `OwnedWriteHalf` and the write +/// half of a `tokio_rustls::client::TlsStream`. We box the writes +/// through a small `write_line` async helper that hides the variant +/// behind a single method call. +/// +/// Why an enum and not a trait object? `AsyncWrite` is a real trait, +/// but splitting the read/write halves and using `select!` requires +/// that the writer's `&mut self` borrow be held for the duration of +/// the session. The enum is statically dispatched, the borrow is +/// checked at compile time, and there's no boxing overhead. +enum IrcWriter { + Plain(TcpWriteHalf), + Tls(TokioWriteHalf>), +} + +impl IrcWriter { + async fn write_line(&mut self, line: &str) -> std::io::Result<()> { + match self { + IrcWriter::Plain(w) => { + w.write_all(line.as_bytes()).await?; + w.write_all(b"\r\n").await?; + } + IrcWriter::Tls(w) => { + w.write_all(line.as_bytes()).await?; + w.write_all(b"\r\n").await?; + } + } + Ok(()) + } +} + +/// The "IrcReader" enum is the union of the two reader types +/// `irc_session` supports: a plain TCP `OwnedReadHalf` wrapped in +/// `BufReader`, and the read half of a `tokio_rustls::client::TlsStream` +/// wrapped in `BufReader`. We expose `read_line` as a single method +/// so the rest of the session loop doesn't have to branch. +enum IrcReader { + Plain(BufReader), + Tls(BufReader>>), +} + +impl IrcReader { + async fn read_line(&mut self, buf: &mut String) -> std::io::Result { + match self { + IrcReader::Plain(r) => r.read_line(buf).await, + IrcReader::Tls(r) => r.read_line(buf).await, + } + } +} + +/// Build a rustls `ClientConfig` with the Mozilla CA bundle and no +/// client authentication. This is the standard "trust the public +/// WebPKI" config for IRC servers (irc.libera.chat, irc.oftc.net, +/// etc.). SNI is set from the server name on each connect. +/// +/// The `ClientConfig` is built once per process and cached at the +/// module level via `OnceLock`, so the CA bundle is parsed only on +/// first use. +fn tls_client_config() -> Arc { + use std::sync::OnceLock; + static CONFIG: OnceLock> = OnceLock::new(); + CONFIG + .get_or_init(|| { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Arc::new( + ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }) + .clone() +} + +/// The fully-connected (TCP, optionally with TLS) IRC stream, just +/// before we split it into read/write halves. +enum IrcStream { + Plain(TcpStream), + Tls(TlsStream), +} + +/// Long-running IRC listener task. +/// +/// Owns the outbound `Receiver` for the lifetime of the adapter and +/// re-attaches it to every fresh session. This lets admin actions +/// (`CoordinatorAdmin`) and regular `send_envelope` traffic keep +/// working across TCP reconnects: the same `IrcAdapter` is reused +/// and the channel pair (`out_tx`, `out_rx`) is stable for the +/// adapter's lifetime. +/// +/// The `stop_rx` watch is the cooperative-shutdown signal set by +/// `IrcAdapter::shutdown`. The listener selects on `stop_rx.changed()` +/// alongside `out_rx.recv()` and `reader.read_line()`, so the listener +/// can wake up from any of those arms when shutdown is requested. +/// (R23c N3: previously the listener could only be exited by dropping +/// `out_rx`, which didn't help if the listener was parked in a +/// non-yielding read.) +#[allow(clippy::too_many_arguments)] +async fn irc_listener( + server: String, + port: u16, + nickname: String, + channels: Vec, + password: Option, + use_tls: bool, + tx: mpsc::Sender, + mut out_rx: mpsc::Receiver, + mut stop_rx: tokio::sync::watch::Receiver, + is_authenticated: Arc, + pending_invites: Arc>>>, +) { + let retry = RetryConfig::default(); + let mut attempt = 0u32; + + loop { + // Check the stop signal before each connect attempt. If + // shutdown was requested while we were in a previous + // session's `select!`, we'll see the change here. + if *stop_rx.borrow() { + return; + } + let connect_result = if use_tls { + connect_tls(&server, port, &server).await + } else { + connect_plain(&server, port).await.map(IrcStream::Plain) + }; + + match connect_result { + Ok(stream) => { + attempt = 0; + if let Err(e) = irc_session( + stream, + &nickname, + &channels, + password.as_deref(), + &tx, + &mut out_rx, + &mut stop_rx, + &is_authenticated, + &pending_invites, + ) + .await + { + tracing::warn!(target: "octo.adapter.irc", error = %e, "IRC session error"); + } + } + Err(e) => { + tracing::warn!(target: "octo.adapter.irc", error = %e, "IRC connect error"); + } + } + + // Check stop signal before sleeping on backoff. Otherwise + // a fast shutdown could be delayed by up to + // `retry.max_delay_secs` seconds. + if *stop_rx.borrow() { + return; + } + let delay = retry.delay_for_attempt(attempt.min(retry.max_retries)); + tokio::select! { + biased; + _ = stop_rx.changed() => return, + _ = tokio::time::sleep(delay) => {} + } + attempt += 1; + } +} + +async fn connect_plain(server: &str, port: u16) -> Result { + TcpStream::connect(format!("{server}:{port}")) + .await + .map_err(|e| format!("TCP connect: {e}")) +} + +/// TLS connect: TCP first, then rustls handshake. The server name is +/// used both as the SNI value (most IRC servers require SNI on port +/// 6697) and for certificate hostname verification. +async fn connect_tls(server: &str, port: u16, sni: &str) -> Result { + let tcp = connect_plain(server, port).await?; + let connector = TlsConnector::from(tls_client_config()); + let name = ServerName::try_from(sni.to_string()) + .map_err(|e| format!("invalid server name for SNI {sni:?}: {e}"))?; + connector + .connect(name, tcp) + .await + .map(IrcStream::Tls) + .map_err(|e| format!("TLS handshake: {e}")) +} + +/// IRC session: authenticate, join channels, process messages. +/// +/// The `out_rx` argument is borrowed for the lifetime of the session +/// and drained on every loop iteration alongside the incoming-line +/// read. The borrow is released when the session ends (connection +/// drop, error), at which point `irc_listener` gets the receiver back +/// and passes it to the next fresh session. +/// +/// The `stop_rx` watch is the cooperative-shutdown signal. The +/// session loop selects on `stop_rx.changed()` alongside the other +/// arms so a shutdown request is observed promptly. (R23c N3.) +async fn irc_session( + stream: IrcStream, + nickname: &str, + channels: &[String], + password: Option<&str>, + tx: &mpsc::Sender, + out_rx: &mut mpsc::Receiver, + stop_rx: &mut tokio::sync::watch::Receiver, + is_authenticated: &Arc, + pending_invites: &Arc>>>, +) -> Result<(), String> { + // Split the (possibly TLS) stream into read and write halves. + let (mut reader, mut writer): (IrcReader, IrcWriter) = match stream { + IrcStream::Plain(s) => { + let (r, w) = s.into_split(); + (IrcReader::Plain(BufReader::new(r)), IrcWriter::Plain(w)) + } + IrcStream::Tls(s) => { + let (r, w) = tokio::io::split(s); + (IrcReader::Tls(BufReader::new(r)), IrcWriter::Tls(w)) + } + }; + let mut line = String::new(); + + // Authenticate + if let Some(pass) = password { + writer + .write_line(&format!("PASS {pass}")) + .await + .map_err(|e| format!("PASS: {e}"))?; + } + writer + .write_line(&format!("NICK {nickname}")) + .await + .map_err(|e| format!("NICK: {e}"))?; + writer + .write_line(&format!("USER {nickname} 0 * :CipherOcto DOT Bot")) + .await + .map_err(|e| format!("USER: {e}"))?; + + // Join channels after MOTD + let mut joined = false; + loop { + // `tokio::select!` with `biased;` polls the stop-signal, + // outbound, and read branches in that order. The stop-signal + // is highest priority so a shutdown request preempts all I/O + // immediately. See the long comment in `irc_listener` for + // the rationale; the key point is that outbound + // (`send_raw_line`, `send_envelope`) makes forward progress + // regardless of the server's traffic shape. (R23c N3.) + tokio::select! { + biased; + + // ── Branch 0: stop signal ──────────────────────── + // + // Cooperative shutdown from `IrcAdapter::shutdown`. + // Fires when `shutdown_tx` sends `true`. Returns + // Ok so the listener exits cleanly. We don't write + // QUIT to the server: the gateway may have a tight + // shutdown deadline, and the server-side + // disconnect happens naturally when the socket + // closes during process exit. + _ = stop_rx.changed() => { + tracing::info!(target: "octo.adapter.irc", "IRC session received shutdown signal"); + return Ok(()); + } + + // ── Branch 1: outbound line ────────────────────── + // + // The sender's `String` is a raw IRC line without + // trailing CRLF; we append CRLF here so the server + // sees a complete line. `None` means all senders + // were dropped (adapter shutdown). + out = out_rx.recv() => { + match out { + Some(out_line) => { + writer + .write_line(&out_line) + .await + .map_err(|e| format!("outbound write: {e}"))?; + } + None => return Ok(()), // sender dropped, clean shutdown + } + } + + // ── Branch 2: incoming IRC line ────────────────── + read_result = reader.read_line(&mut line) => { + let n = read_result.map_err(|e| format!("Read: {e}"))?; + if n == 0 { + return Err("Connection closed".into()); + } + + let trimmed = line.trim_end(); + + // PING/PONG keepalive + if let Some(server) = trimmed.strip_prefix("PING ") { + writer + .write_line(&format!("PONG {server}")) + .await + .map_err(|e| format!("PONG: {e}"))?; + line.clear(); + continue; + } + + // Join channels after RPL_ENDOFMOTD (376) or ERR_NOMOTD (422) + if !joined && (trimmed.contains(" 376 ") || trimmed.contains(" 422 ")) { + // RFC-0861 Β§4 M8: receiving 376/422 is the + // canonical "we are authenticated and the + // session is usable" signal β€” these numerics + // are only sent *after* the NICK/USER + // handshake completes. Flip the shared + // atomic so `health_check` (which reads it + // on the public API path) can return Ok(()) . + is_authenticated.store(true, std::sync::atomic::Ordering::SeqCst); + for ch in channels { + writer + .write_line(&format!("JOIN {ch}")) + .await + .map_err(|e| format!("JOIN: {e}"))?; + } + joined = true; + line.clear(); + continue; + } + + // RFC-0861 Β§4 M7: correlate INVITE replies + // (341 RPL_INVITING / 482 ERR_CHANOPRIVSNEEDED) + // with the pending sender in `pending_invites`. + // We pop the oldest entry (FIFO) β€” IRC numerics + // are FIFO at the protocol level so the next + // reply after an INVITE is the reply for that + // INVITE. Other error numerics (e.g. 401 + // ERR_NOSUCHNICK, 442 ERR_NOTONCHANNEL) flow + // through the same path; the listener doesn't + // filter by code at this point β€” the caller + // (`add_member`) maps the code to a + // PlatformAdapterError. + if let Some(numeric) = parse_numeric_reply(trimmed) { + if numeric.command == "INVITE" || matches!(numeric.code, 341 | 482 | 401 | 442 | 443) { + let mut pending = pending_invites.lock().await; + if let Some((_id, sender)) = pending.pop_first() { + let result = if (200..400).contains(&numeric.code) { + NumericResult::Ok { code: numeric.code } + } else { + NumericResult::Err { + code: numeric.code, + message: numeric.message, + } + }; + // Ignore send errors: the receiver + // may have been dropped (timeout + // path, shutdown). The drop is + // benign β€” the entry is already + // removed from the map. + let _ = sender.send(result); + } + // Continue parsing more of the line + // (a numeric reply line is normally + // a single record; nothing else to do). + line.clear(); + continue; + } + } + + // Parse PRIVMSG + if let Some(msg) = parse_privmsg(trimmed) { + // Check if it's a DOT message + if msg.text.starts_with(DOT_PREFIX) || msg.text.starts_with(DOT_FRAGMENT_PREFIX) { + if let Ok(payload) = IrcAdapter::decode_message(&msg.text) { + let mut metadata = BTreeMap::new(); + metadata.insert("channel".into(), msg.channel.clone()); + metadata.insert("sender".into(), msg.sender.clone()); + // R23c N4 fix: use `try_send` instead of + // `send().await`. The previous version + // parked the entire select! body in + // `tx.send().await` when the inbound + // channel was full, which meant we + // couldn't process PINGs and the + // server timed us out. With `try_send`, + // the worst case is a logged drop on + // overload, which is visible (vs the + // R1 silent-drop on `try_send`). + // + // The trade-off: under sustained + // overload we drop envelopes rather than + // disconnecting. The consumer + // (`receive_messages`) is expected to + // drain at β‰₯ IRC rate (1 msg/s default); + // if it can't, the gateway has a + // throughput problem. + match tx.try_send(RawPlatformMessage { + platform_id: format!("irc-{}", msg.id), + payload, + metadata, + }) { + Ok(()) => {} + Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { + tracing::warn!( + target: "octo.adapter.irc", + channel = %msg.channel, + "IRC inbound channel full; envelope dropped" + ); + } + Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => { + tracing::warn!( + target: "octo.adapter.irc", + "IRC inbound channel closed; listener exiting" + ); + return Ok(()); + } + } + } + } + } + line.clear(); + } + } + } +} + +/// Parsed IRC PRIVMSG. +struct IrcPrivmsg { + sender: String, + channel: String, + text: String, + id: String, +} + +/// Parsed IRC numeric reply (e.g. `:server 341 nickname #chan :invited`). +/// +/// `command` is the command that produced this reply (e.g. +/// `INVITE` for `:... 341 ... INVITE ...`) or empty if the +/// server didn't echo the command verb. The IRC protocol puts +/// the command in the third positional parameter for RPL_* +/// replies, but servers vary β€” we extract it best-effort. +/// +/// `code` is the numeric (e.g. 341 for RPL_INVITING, 482 for +/// ERR_CHANOPRIVSNEEDED). `message` is the trailing text +/// after the final ` :`. +struct NumericReply { + code: u16, + command: String, + message: String, +} + +/// RFC-0861 Β§4 M7: parse a numeric reply from the server. The +/// format is `:prefix [args...] [:trailing]`. We +/// don't strictly need the prefix (the server's hostname); we +/// just need the code and the trailing message for `add_member`'s +/// error mapping. The "command" field is best-effort β€” many +/// servers echo the originating command verb as a positional +/// arg (e.g. `:s 341 me #chan nick :already invited`), some +/// don't. +fn parse_numeric_reply(line: &str) -> Option { + // Numeric replies always start with `:` (a server prefix). + let line = line.strip_prefix(':')?; + let (prefix, rest) = line.split_once(' ')?; + // `prefix` is unused for our purposes; the server + // hostname is logged elsewhere. + let _ = prefix; + // The next token is the numeric code. + let (code_str, rest) = rest.split_once(' ')?; + let code: u16 = code_str.parse().ok()?; + // The remaining args are positional. Split on spaces, then + // handle the trailing ` :message` form. The optional + // command verb (e.g. `INVITE`) is the LAST positional arg + // before the trailing message in standard `INVITE` echo + // numerics. + let mut parts = rest.splitn(2, " :"); + let positional = parts.next().unwrap_or(""); + let trailing = parts.next().unwrap_or("").to_string(); + // Heuristic: the command verb (if echoed) is the last + // token of the positional string. Most servers don't + // echo it for arbitrary error numerics. + let command = positional + .split_whitespace() + .next_back() + .unwrap_or("") + .to_string(); + Some(NumericReply { + code, + command, + message: trailing, + }) +} + +/// Parse a PRIVMSG from an IRC line. +/// Format: `:nick!user@host PRIVMSG #channel :message text` +fn parse_privmsg(line: &str) -> Option { + let line = line.strip_prefix(':')?; + let (prefix, rest) = line.split_once(' ')?; + let sender = prefix.split('!').next()?.to_string(); + let (command, rest) = rest.split_once(' ')?; + if command != "PRIVMSG" { + return None; + } + let (target, text) = rest.split_once(" :")?; + let id = format!("{}-{}", sender, epoch_millis()); + Some(IrcPrivmsg { + sender, + channel: target.to_string(), + text: text.to_string(), + id, + }) +} + +fn epoch_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +// ── PlatformAdapter ──────────────────────────────────────────────── + +fn transport_err(msg: impl Into) -> PlatformAdapterError { + PlatformAdapterError::Unreachable { + platform: "irc".into(), + reason: msg.into(), + } +} + +#[async_trait] +impl PlatformAdapter for IrcAdapter { + async fn send_envelope( + &self, + domain: &BroadcastDomainId, + envelope: &DeterministicEnvelope, + ) -> Result { + // Spawn the listener if it isn't already running. Without + // this, a `send_envelope` call before any `receive_messages` + // would never establish the IRC connection (R23b C3). + self.ensure_connected().await?; + + let wire_bytes = envelope.to_wire_bytes(); + let encoded = Self::encode_envelope(&wire_bytes); + + // Find the channel for this domain. The lookup is in the + // merged set of statically-configured and runtime-joined + // channels (R23b C4). The runtime set is guarded by a + // `std::sync::Mutex` (see `IrcAdapter::runtime_channels`), + // so a brief blocking lock is safe here. + let channel = { + let in_static = self.config.channels.iter().find(|ch| { + let hash = Self::domain_hash(&self.config.server, ch); + hash == domain.domain_hash + }); + if let Some(ch) = in_static { + ch.clone() + } else { + let runtime = self + .runtime_channels + .lock() + .map_err(|e| transport_err(format!("runtime_channels poisoned: {e}")))?; + let in_runtime = runtime + .iter() + .find(|ch| { + let hash = Self::domain_hash(&self.config.server, ch); + hash == domain.domain_hash + }) + .cloned(); + in_runtime.ok_or_else(|| { + transport_err(format!("No channel for domain {:?}", domain.domain_hash)) + })? + } + }; + + // Split if needed (IRC has strict line limits). The + // per-channel overhead matters: a 24-char channel name + // overflows the 512-byte IRC line if we use the typical + // 20-char-headroom `MAX_PAYLOAD_PER_MSG` (R23c N5). + let max_bytes = max_payload_for_channel(&channel); + let chunks = Self::split_message(&encoded, max_bytes); + let total = chunks.len() as u16; + + // Build the PRIVMSG lines and enqueue each one on the + // outbound channel so the listener writes them to the wire + // (R23b C2: previously this was a no-op that returned a fake + // DeliveryReceipt). + let now = epoch_millis(); + for (i, chunk) in chunks.iter().enumerate() { + let line = if total > 1 { + Self::encode_fragment(i as u16, total, chunk.as_bytes()) + } else { + chunk.clone() + }; + // PRIVMSG #channel : (the listener appends CRLF) + let irc_msg = format!("PRIVMSG {channel} :{line}"); + self.send_raw_line(&irc_msg).await?; + } + + Ok(DeliveryReceipt { + platform_message_id: format!("irc-{now}"), + delivered_at: now, + }) + } + + async fn receive_messages( + &self, + _domain: &BroadcastDomainId, + ) -> Result, PlatformAdapterError> { + self.ensure_connected().await?; + let mut rx = self.rx.lock().await; + let mut messages = Vec::new(); + while let Ok(msg) = rx.try_recv() { + messages.push(msg); + } + Ok(messages) + } + + fn canonicalize( + &self, + raw: &RawPlatformMessage, + ) -> Result { + if raw.payload.is_empty() { + return Err(transport_err("Empty payload")); + } + DeterministicEnvelope::from_wire_bytes(&raw.payload).map_err(|e| { + PlatformAdapterError::ApiError { + code: 400, + message: format!("canonicalize failed: {e}"), + } + }) + } + + fn capabilities(&self) -> CapabilityReport { + CapabilityReport { + max_payload_bytes: Self::max_payload_bytes(), + supports_fragmentation: true, + supports_encryption: false, + supports_raw_binary: false, + rate_limit_per_second: Self::rate_limit_per_second(), + media_capabilities: None, + } + } + + fn domain_id(&self, platform_id: &str) -> BroadcastDomainId { + // The platform_id MUST be in `server:channel` form to match the + // canonical hash used by `send_envelope`'s channel lookup. We parse + // it here and delegate to `domain_hash` so the two methods always + // agree (R18 fix; previously the call to `BroadcastDomainId::new` + // would hash just the platform_id without the server prefix, which + // silently mismatched the static `domain_hash` lookup). + BroadcastDomainId { + platform_type: PlatformType::IRC as u16, + domain_hash: Self::domain_hash_from_id(platform_id), + } + } + + fn platform_type(&self) -> PlatformType { + PlatformType::IRC + } + + fn self_handle(&self) -> Option { + Some(self.config.nickname.clone()) + } + + async fn shutdown(&self) -> Result<(), PlatformAdapterError> { + // R23c N3 + R23e N14 + R23f N21: actually shut down with + // proper serialization against `ensure_connected`. The + // sequence is: + // + // 1. Set the `shutting_down` flag. This is the gate that + // any racing `ensure_connected` checks; once it's + // set, future ensure_connected calls refuse to spawn. + // 2. Acquire the `connected` lock *before* touching any + // of the related state (shutdown_tx, out_tx, + // listener_handle). This is the critical R23f N21 + // fix: without it, a racing ensure_connected could + // install shutdown_tx / listener_handle *after* + // shutdown had already taken None for them, leaving + // a zombie listener that no one would ever signal + // or abort. Holding `connected` for the entire + // teardown ensures at most one of {spawn, teardown} + // is in flight at a time. + // 3. Take shutdown_tx (signal stop), drop out_tx (None), + // take listener_handle (abort), set connected=false. + // + // Lock ordering: shutdown β†’ ensure_connected both hold + // `connected` for the duration of their state changes. + // No deadlock because neither holds any other lock while + // waiting for `connected`. + self.shutting_down.store(true, Ordering::SeqCst); + let mut connected = self.connected.lock().await; + if let Some(tx) = self.shutdown_tx.lock().await.take() { + let _ = tx.send(true); + } + *self.out_tx.lock().await = None; + if let Some(handle) = self.listener_handle.lock().await.take() { + handle.abort(); + // Best-effort: don't block shutdown on the abort. + // (We could `.await` here for cleanliness, but the + // gateway's shutdown deadline matters more than + // waiting for a stuck task.) + } + *connected = false; + // RFC-0861 Β§4 M8: clear the authentication flag on full + // teardown too. After `shutdown()` returns, the adapter + // is terminal; a subsequent `health_check` (e.g. on a + // revived adapter reference) MUST see `is_authenticated + // = false` until a fresh handshake completes. + self.is_authenticated + .store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + async fn health_check(&self) -> Result<(), PlatformAdapterError> { + // RFC-0861 Β§4 M8: the TCP path can be up while the IRC + // session is still in the NICK/USER handshake (or has + // silently half-dropped and not yet seen 376/422). A bare + // `TcpStream::connect` would lie in that window. Check + // `is_authenticated` first: if the listener hasn't + // confirmed the 376/422 numerics, the session is not + // usable yet β€” return 503 so callers can distinguish + // "TCP up, auth pending" from "TCP down". + if !self + .is_authenticated + .load(std::sync::atomic::Ordering::SeqCst) + { + return Err(PlatformAdapterError::ApiError { + code: 503, + message: "IRC session not authenticated".into(), + }); + } + // RFC-0861 Β§7 M3: when `use_tls = true`, `health_check` + // must attempt the same TLS handshake the listener does, + // not just a plain `TcpStream::connect`. Otherwise the + // check would report Ok(()) on a session whose TCP path + // is up but whose TLS layer is broken (e.g. expired cert, + // MITM strip, cipher mismatch). On TLS handshake failure, + // return 525 (a custom code distinct from 503 auth and + // from generic transport errors) so callers can + // distinguish "TCP up, TLS broken" from "TCP down". + let timeout = std::time::Duration::from_secs(5); + let addr = format!("{}:{}", self.config.server, self.config.port); + if self.config.use_tls { + let sni = self.config.server.clone(); + match tokio::time::timeout(timeout, connect_tls(&self.config.server, self.config.port, &sni)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(reason)) => { + if reason.starts_with("TCP connect") { + Err(transport_err(format!("Health check: {reason}"))) + } else { + Err(PlatformAdapterError::ApiError { + code: 525, + message: format!("TLS handshake failed: {reason}"), + }) + } + } + Err(_) => Err(transport_err("Health check timed out")), + } + } else { + // Check TCP connectivity to the server + match tokio::time::timeout(timeout, TcpStream::connect(&addr)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(e)) => Err(transport_err(format!("Health check: {e}"))), + Err(_) => Err(transport_err("Health check timed out")), + } + } + } + + /// Coordinator-admin capability probe: IRC supports a meaningful + /// subset of the admin surface (KICK, MODE +o/-o, MODE +m/-m, + /// MODE +i/-i, TOPIC, INVITE, JOIN, PART) but lacks ephemeral + /// TTL, approval workflow, and invite-link resolution. We opt + /// in to `CoordinatorAdmin` so the caller can probe the + /// capability report and pick the supported actions. + fn as_coordinator_admin( + &self, + ) -> Option<&dyn octo_network::dot::adapters::coordinator_admin::CoordinatorAdmin> { + Some(self) + } +} + +// ── CoordinatorAdmin (R21) ───────────────────────────────────────── +// +// IRC is a thin, human-oriented chat protocol β€” most "admin" actions +// map to single IRC commands (KICK, MODE, TOPIC, INVITE, PART, +// JOIN). What IRC *doesn't* have: +// - group creation (channels are pre-existing on the server) +// - description separate from topic +// - ephemeral / disappearing-message TTL +// - approval workflow for joiners +// - invite-link URLs (we only have channel names) +// - a server-side ban with rejoin prevention (MODE +b requires a +// full `nick!user@host` mask that's not preserved in `PeerId`; +// the supported path is KICK via `remove_member`) +// +// Each of these returns `Unimplemented` from the corresponding +// `CoordinatorAdmin` method. The capability report is honest about +// which subset is supported. +// +// Identifier conventions: +// - `GroupId` is `server:channel` (matches the platform_id format +// used by `domain_id` and the canonical `domain_hash`). +// - `PeerId` is a bare nick (no hostmask). +// - The adapter only operates on channels in its configured +// channel list (the same constraint `send_envelope` enforces). + +#[async_trait] +impl CoordinatorAdmin for IrcAdapter { + fn platform_name(&self) -> String { + "irc".into() + } + + fn admin_capabilities(&self) -> AdminCapabilityReport { + // Truthful report: implement what the IRC protocol supports + // natively, honestly mark the rest as unsupported. + AdminCapabilityReport { + // ── A. Lifecycle ────────────────────────────────── + can_create: false, // IRC has no group creation + can_join_by_id: true, // RFC-0861 Β§1 M10: JOIN #channel IS join-by-id + can_join_by_invite: true, // JOIN #channel (best-effort) + can_leave: true, // PART + can_destroy: false, // no invite-link to revoke + + // ── B. Membership ───────────────────────────────── + can_add_member: true, // INVITE (server-mediated) + can_remove_member: true, // KICK + can_ban: false, // MODE +b needs hostmask, not in PeerId + can_promote: true, // MODE +o + can_demote: true, // MODE -o + can_approve_join: false, // no approval workflow + + // ── C. Mode ─────────────────────────────────────── + can_rename: true, // TOPIC + can_describe: false, // no description separate from topic + can_lock: true, // MODE +i / -i + can_announce: true, // MODE +m / -m + can_set_ephemeral: false, // no TTL + can_require_approval: false, // no approval + + // ── D. Discovery ────────────────────────────────── + can_list_own_groups: true, // configured channels + can_get_metadata: false, // no sync NAMES/MODE capture + can_resolve_invite: false, // no invite URL + + // ── E. Handoff ──────────────────────────────────── + can_transfer_ownership: false, // no transfer primitive + } + } + + // ── A. Lifecycle ────────────────────────────────────────── + + /// IRC has no group creation β€” channels exist on the server + /// and admins are entitled by server config. The caller should + /// use a configured `GroupId` from `list_own_groups` or ask + /// the server operator to provision a new channel. + async fn create_group( + &self, + _subject: &str, + _initial_members: &[GroupMemberSpec], + ) -> Result { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "create_group".into(), + }) + } + + /// PART the channel. IRC's `PART` is idempotent: the server + /// replies with an `ERR_NOTONCHANNEL` numeric if the bot is + /// not a member, which the listener discards as part of normal + /// chatter. The fire-and-forget return of `Ok(())` mirrors + /// that β€” the bot has done its part by sending the command. + async fn leave_group(&self, group_id: &GroupId) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + self.send_raw_line(&format!("PART {channel}")).await + } + + /// Best-effort destroy: just leave. IRC has no invite-link + /// to revoke and no `+i` mode to unset on a per-group basis + /// (the channel's `+i` would persist for other members). + async fn destroy_group(&self, group_id: &GroupId) -> Result<(), PlatformAdapterError> { + self.leave_group(group_id).await + } + + // ── B. Membership ───────────────────────────────────────── + + /// `INVITE `. IRC's closest equivalent to + /// "add a member": a server-mediated invitation the target + /// user can act on. Note that the user must accept the + /// invite (or be auto-promoted by network policy) β€” this + /// method does *not* force-join the peer. + /// + /// **RFC-0861 Β§4 M7.** The send is correlated with the + /// reply via `pending_invites`. The reply is one of: + /// + /// - `341 RPL_INVITING` β†’ `Ok(AddMemberOutput { added: true, promoted: None })` + /// - `482 ERR_CHANOPRIVSNEEDED` β†’ `Err(ApiError { code: 403, message: "not a channel operator" })` + /// - no reply within the timeout β†’ `Err(ApiError { code: 504, message: "no reply from server" })` + /// + /// The match is FIFO across concurrent `add_member` calls: + /// the next reply resolves the oldest pending send. IRC + /// numerics are FIFO at the protocol level, so this + /// matches the natural order of the conversation. + async fn add_member( + &self, + group_id: &GroupId, + member: &GroupMemberSpec, + ) -> Result { + let channel = self.channel_for(group_id)?; + // Allocate a per-call nonce. The reply is correlated + // by FIFO, so the nonce is just a unique key. + let cmd_id = self.next_command_id.fetch_add(1, Ordering::SeqCst); + let (tx, rx) = oneshot::channel::(); + { + let mut pending = self.pending_invites.lock().await; + pending.insert(cmd_id, tx); + } + // Fire the INVITE. If the send fails, remove our + // pending entry so the listener doesn't later resolve + // a sender that no one is awaiting (the sender is + // dropped, the `await rx` will return `RecvError`). + if let Err(e) = self + .send_raw_line(&format!("INVITE {} {channel}", member.handle)) + .await + { + // Best-effort cleanup. If the listener is already + // mid-resolve, this `remove` is a no-op (the entry + // is gone) β€” that's fine, the rx just hangs in + // a tokio task that we never created (no future + // exists; the `await rx` happens below only if + // the send succeeded). + let mut pending = self.pending_invites.lock().await; + pending.remove(&cmd_id); + return Err(e); + } + // Await the reply with a timeout. The timeout is + // generous (5s) because some networks rate-limit + // responses; if no reply comes, the caller can retry. + let result = match tokio::time::timeout( + std::time::Duration::from_secs(5), + rx, + ) + .await + { + Ok(Ok(r)) => r, + Ok(Err(_recv_err)) => { + // Listener dropped the sender without + // resolving β€” this happens on session + // shutdown mid-flight. + return Err(PlatformAdapterError::ApiError { + code: 504, + message: "add_member: pending invite was dropped (session closed?)" + .into(), + }); + } + Err(_elapsed) => { + // Timeout: clean up our entry so a stale + // pending doesn't accumulate. + let mut pending = self.pending_invites.lock().await; + pending.remove(&cmd_id); + return Err(PlatformAdapterError::ApiError { + code: 504, + message: "add_member: no reply from server within 5s".into(), + }); + } + }; + match result { + NumericResult::Ok { code: _ } => Ok(AddMemberOutput { + added: true, + promoted: None, + }), + NumericResult::Err { code, message } => { + // M7: 482 ERR_CHANOPRIVSNEEDED is the canonical + // "you're not a channel operator" reply. Map + // it to ApiError 403 per the spec. Other + // error numerics (e.g. 401 ERR_NOSUCHNICK, + // 442 ERR_NOTONCHANNEL) flow through with + // their own codes so the caller can + // distinguish. + let mapped_code = if code == 482 { 403 } else { code }; + let mapped_msg = if code == 482 { + "not a channel operator".to_string() + } else { + if message.is_empty() { + format!("add_member rejected with numeric {code}") + } else { + format!("add_member: {message} (numeric {code})") + } + }; + Err(PlatformAdapterError::ApiError { + code: mapped_code, + message: mapped_msg, + }) + } + NumericResult::Timeout => Err(PlatformAdapterError::ApiError { + code: 504, + message: "add_member: no reply from server (timed out)".into(), + }), + } + } + + /// `KICK :`. The reason is a short + /// human-readable string identifying the kicker (the + /// coordinator) for IRC clients that surface it. + async fn remove_member( + &self, + group_id: &GroupId, + member: &PeerId, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + self.send_raw_line(&format!( + "KICK {channel} {} :removed by coordinator", + member.as_str() + )) + .await + } + + /// IRC's `MODE +b` ban requires a full `nick!user@host` mask + /// for server-side enforcement. `PeerId` is just a bare nick + /// on this platform, so we cannot construct a ban mask + /// from the trait input. Callers that need a ban should + /// use `remove_member` (KICK) and add a coordinator-level + /// deny-list. + async fn ban_member( + &self, + _group_id: &GroupId, + _member: &PeerId, + _duration: Option, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "ban_member".into(), + }) + } + + /// `MODE +o ` β€” grant channel-operator + /// status. Requires the bot itself to already be a channel + /// operator; the server will return `ERR_CHANOPRIVSNEEDED` + /// otherwise. The listener discards the error reply silently + /// (fire-and-forget), so this method returns `Ok(())` once + /// the MODE line is enqueued. + async fn promote_to_admin( + &self, + group_id: &GroupId, + member: &PeerId, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + self.send_raw_line(&format!("MODE {channel} +o {}", member.as_str())) + .await + } + + /// `MODE -o ` β€” revoke channel-operator + /// status. Same server-privileges caveat as `promote_to_admin`. + async fn demote_from_admin( + &self, + group_id: &GroupId, + member: &PeerId, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + self.send_raw_line(&format!("MODE {channel} -o {}", member.as_str())) + .await + } + + /// IRC has no join-approval workflow. Some networks (e.g. + /// Atheme-based) implement `+j` join-flood throttle, but + /// that's a rate limit, not a per-joiner approval. + async fn approve_join_request( + &self, + _group_id: &GroupId, + _requester: &PeerId, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "approve_join_request".into(), + }) + } + + // ── C. Mode ─────────────────────────────────────────────── + + /// IRC's "rename" maps to the channel TOPIC β€” IRC has no + /// separate subject/name. The TOPIC is broadcast to all + /// members as a `TOPIC` numeric reply. + async fn rename_group( + &self, + group_id: &GroupId, + new_subject: &str, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + // The new subject is the trailing parameter (after the + // ` :` separator). IRC topic can contain spaces freely + // in the trailing-param form. + self.send_raw_line(&format!("TOPIC {channel} :{new_subject}")) + .await + } + + /// IRC has no description separate from the topic. The + /// closest analogue would be a second TOPIC entry, but + /// that's not a standard concept. + async fn set_group_description( + &self, + _group_id: &GroupId, + _description: &str, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "set_group_description".into(), + }) + } + + /// IRC's "locked" maps to `MODE +i` (invite-only). When + /// locked, only invited users can join the channel. We + /// unset to `+i`/`-i` based on the `locked` flag. + async fn set_locked( + &self, + group_id: &GroupId, + locked: bool, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + let flag = if locked { "+i" } else { "-i" }; + self.send_raw_line(&format!("MODE {channel} {flag}")).await + } + + /// IRC's "announce-only" maps to `MODE +m` (moderated). + /// When set, only channel operators and voiced users + /// (`+v`) can post messages; everyone else is silenced. + async fn set_announce( + &self, + group_id: &GroupId, + announce_only: bool, + ) -> Result<(), PlatformAdapterError> { + let channel = self.channel_for(group_id)?; + let flag = if announce_only { "+m" } else { "-m" }; + self.send_raw_line(&format!("MODE {channel} {flag}")).await + } + + /// IRC has no disappearing-message TTL. + async fn set_ephemeral( + &self, + _group_id: &GroupId, + _ttl: Option, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "set_ephemeral".into(), + }) + } + + /// IRC has no join-approval workflow. + async fn set_require_approval( + &self, + _group_id: &GroupId, + _require: bool, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "set_require_approval".into(), + }) + } + + // ── D. Discovery ────────────────────────────────────────── + + /// Return the configured channels *and* any channels the bot has + /// joined at runtime via `join_by_invite`. IRC channels aren't + /// "discovered" β€” the bot only knows about the ones in its config + /// plus any it has successfully JOINed. The merge is + /// deduplicating: if a runtime channel is already in the static + /// config, it appears once. `is_admin` is conservatively `false` + /// because the bot's op status is determined by server policy and + /// not tracked in the adapter state. (R23c N1 fix.) + async fn list_own_groups(&self) -> Result, PlatformAdapterError> { + // Lock briefly to snapshot the runtime channels, then drop + // the lock before iterating. `config.channels` is read-only + // after construction so it can be iterated directly. + let runtime_snapshot: Vec = { + let runtime = + self.runtime_channels + .lock() + .map_err(|e| PlatformAdapterError::Unreachable { + platform: "irc".into(), + reason: format!("runtime_channels poisoned: {e}"), + })?; + runtime.clone() + }; + let mut names: Vec = self.config.channels.clone(); + for ch in &runtime_snapshot { + if !names.iter().any(|c| c == ch) { + names.push(ch.clone()); + } + } + Ok(names + .into_iter() + .map(|ch| GroupHandle { + id: self.full_id(&ch), + subject: None, + invite_url: None, + is_admin: false, + member_count: None, + mode_flags: None, + initial_admins_promoted: false, + }) + .collect()) + } + + /// Return the static ID for a configured channel. We do not + /// capture NAMES (353) or MODE (324) replies into the + /// adapter state, so member lists, admin lists, mode flags, + /// subject, and description are all `None`/empty. The + /// capability report is honest about this: `can_get_metadata + /// = false`. Callers that need rich metadata should set up + /// a dedicated stateful bot, not a transport adapter. + async fn get_group_metadata( + &self, + group_id: &GroupId, + ) -> Result { + let channel = self.channel_for(group_id)?; + Ok(GroupMetadata { + id: self.full_id(&channel), + subject: None, + description: None, + members: vec![], + admins: vec![], + invite_url: None, + mode_flags: GroupModeFlags::default(), + }) + } + + /// IRC has no invite URLs / codes to resolve. + async fn resolve_invite( + &self, + _invite: &InviteRef, + ) -> Result { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "resolve_invite".into(), + }) + } + + /// RFC-0861 Β§1 M10: IRC's `JOIN #channel` is exactly + /// join-by-id. Wrap `join_by_invite` with a `GroupId` β†’ + /// `InviteRef` adapter and forward. The capability report + /// has `can_join_by_id: true` (was `false` pre-M10; the bit + /// was conservative-but-wrong: the bot has always been able + /// to JOIN by channel name, we just didn't expose the + /// method). The body is identical to `join_by_invite`'s + /// because the IRC protocol is the same β€” both go through + /// `send_raw_line("JOIN ...")` β€” and the validation in + /// `validate_channel_name` is the same for both. + async fn join_by_id( + &self, + group_id: &GroupId, + ) -> Result { + let invite = InviteRef::new(group_id.as_str().to_string()); + self.join_by_invite(&invite).await + } + + /// Best-effort: treat the `InviteRef` string as a channel + /// name and JOIN it. This works for `ircv3.2` invite-notify + /// notifications (the bot receives an `INVITE` numeric and + /// the channel name is the last argument) but doesn't + /// validate that the invite was actually authorized by an + /// op. A real impl would need an invite-token validator + /// (which IRC does not have; ircv3 invite-notify is a + /// notification-only mechanism). + async fn join_by_invite( + &self, + invite: &InviteRef, + ) -> Result { + // R23c N2: validate the channel name before sending JOIN + // so that IRC special tokens (`JOIN 0`), no-prefix names, + // and bad characters are caught client-side with a + // structured error rather than producing an opaque + // server-side rejection (or worse, parting all channels + // via `JOIN 0`). + validate_channel_name(&invite.0).map_err(|e| PlatformAdapterError::ApiError { + code: 400, + message: format!("join_by_invite: {e}"), + })?; + self.send_raw_line(&format!("JOIN {}", invite.0)).await?; + // R23c N1: actually record the channel so subsequent + // `list_own_groups` / `channel_for` / `send_envelope` + // calls see it. Without this, the C4 fix is non-functional: + // the server joins but the adapter state doesn't reflect it. + { + // R23e N20: if the mutex is poisoned, the JOIN has + // already been sent to the server (line above), so the + // bot *is* in the channel but our state disagrees. + // Log loudly so an operator can reconcile manually + // (e.g. shutdown + recreate the adapter). Returning + // an error alone would leave the operator in the dark. + let mut runtime = self.runtime_channels.lock().map_err(|e| { + tracing::warn!( + target: "octo.adapter.irc", + channel = %invite.0, + error = %e, + "runtime_channels mutex poisoned AFTER successful JOIN; \ + server-side join succeeded but adapter state will not record it" + ); + PlatformAdapterError::Unreachable { + platform: "irc".into(), + reason: format!("runtime_channels poisoned: {e}"), + } + })?; + if !runtime.iter().any(|c| c == &invite.0) { + runtime.push(invite.0.clone()); + } + } + Ok(GroupHandle { + id: self.full_id(&invite.0), + subject: None, + invite_url: None, + is_admin: false, + member_count: None, + mode_flags: None, + initial_admins_promoted: false, + }) + } + + // ── E. Handoff ──────────────────────────────────────────── + + /// IRC has no transfer-ownership primitive. The closest + /// dance is: demote the current owner (`MODE -q`) and have + /// the new owner self-op (`MODE +q` if they have a + /// server-side grant). This is server-dependent and not + /// safe to automate, so we return `Unimplemented` and let + /// the caller do it manually. + async fn transfer_ownership( + &self, + _group_id: &GroupId, + _new_owner: &PeerId, + ) -> Result<(), PlatformAdapterError> { + Err(PlatformAdapterError::Unimplemented { + platform: self.platform_name(), + action: "transfer_ownership".into(), + }) + } +} + +// Private helpers used by the `CoordinatorAdmin` impl above. + +impl IrcAdapter { + /// Validate a `GroupId` and return the channel part. + /// + /// `GroupId` is in `server:channel` form, matching the + /// `platform_id` format used by `domain_id` and the + /// canonical `domain_hash`. The adapter only operates on + /// its own server, and only on channels it knows about + /// β€” the configured channel list *plus* any channel the + /// bot has joined at runtime via `join_by_invite` (R23b C4 + /// fix: previously runtime-joined channels were + /// invisible to admin actions). + /// + /// Returns the bare channel name (with `#` prefix preserved) + /// on success, or a structured `ApiError`/`Unreachable` on + /// failure. + fn channel_for(&self, group_id: &GroupId) -> Result { + let raw = group_id.as_str(); + let (server, channel) = match raw.split_once(':') { + Some((s, c)) => (s, c), + None => ("", raw), // bare channel: assume it's on our server + }; + if !server.is_empty() && !server.eq_ignore_ascii_case(&self.config.server) { + return Err(PlatformAdapterError::ApiError { + code: 400, + message: format!( + "group {raw} is on server {server}, but adapter is connected to {}", + self.config.server + ), + }); + } + if !self.config.channels.contains(&channel.to_string()) { + // Fall back to the runtime-joined channel set + // (populated by `join_by_invite`). Locking is brief + // (vec.contains on a small list) so a blocking mutex + // is fine here. + let runtime = + self.runtime_channels + .lock() + .map_err(|e| PlatformAdapterError::Unreachable { + platform: "irc".into(), + reason: format!("runtime_channels poisoned: {e}"), + })?; + if !runtime.iter().any(|c| c == channel) { + return Err(PlatformAdapterError::ApiError { + code: 404, + message: format!( + "channel {channel} is not in the configured channel list {:?} nor the runtime-joined set", + self.config.channels + ), + }); + } + } + Ok(channel.to_string()) + } + + /// Build the canonical `GroupId` for a configured channel: + /// `server:channel` form. + fn full_id(&self, channel: &str) -> GroupId { + GroupId::new(format!("{}:{channel}", self.config.server)) + } +} + +// ── Plugin ABI ───────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn adapter_version() -> u32 { + 1 +} + +#[no_mangle] +pub extern "C" fn platform_type() -> u16 { + 0x0006 +} + +#[no_mangle] +/// # Safety +/// `config` must point to a valid buffer of at least `len` bytes. +pub unsafe extern "C" fn create_adapter(config: *const u8, config_len: usize) -> *mut () { + if config.is_null() || config_len == 0 { + return std::ptr::null_mut(); + } + let bytes = std::slice::from_raw_parts(config, config_len); + match IrcAdapter::from_config_bytes(bytes) { + Ok(a) => Box::into_raw(Box::new(a)) as *mut (), + Err(_) => std::ptr::null_mut(), + } +} + +#[no_mangle] +/// # Safety +/// `ptr` must be a pointer previously returned by `create_adapter`. +pub unsafe extern "C" fn destroy_adapter(adapter: *mut ()) { + if !adapter.is_null() { + let _ = Box::from_raw(adapter as *mut IrcAdapter); + } +} + +// ── Tests ────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_short_message() { + let chunks = IrcAdapter::split_message("hello", 400); + assert_eq!(chunks, vec!["hello"]); + } + + #[test] + fn test_split_long_message() { + let msg = "a".repeat(1000); + let chunks = IrcAdapter::split_message(&msg, 400); + assert!(chunks.len() >= 3); + // All chunks should be <= 400 bytes + for chunk in &chunks { + assert!(chunk.len() <= 400); + } + // Reassembled should match original (minus newlines) + let reassembled: String = chunks.join(""); + assert_eq!(reassembled, msg); + } + + #[test] + fn test_split_multiline() { + let chunks = IrcAdapter::split_message("line one\nline two\nline three", 400); + assert_eq!(chunks, vec!["line one", "line two", "line three"]); + } + + #[test] + fn test_split_empty() { + let chunks = IrcAdapter::split_message("", 400); + assert_eq!(chunks, vec![""]); + } + + #[test] + fn test_split_utf8_boundary() { + // 3-byte UTF-8 characters: Γ± = 2 bytes, δΈ­ = 3 bytes + let msg = "Γ±Γ±Γ±Γ±Γ±Γ±Γ±Γ±Γ±Γ±"; // 10 * 2 = 20 bytes + let chunks = IrcAdapter::split_message(&msg, 5); + // Should split at UTF-8 boundaries + for chunk in &chunks { + assert!(chunk.len() <= 5); + assert!(std::str::from_utf8(chunk.as_bytes()).is_ok()); + } + } + + #[test] + fn test_split_chinese_chars() { + let msg = "δΈ­δΈ­δΈ­δΈ­δΈ­"; // 5 * 3 = 15 bytes + let chunks = IrcAdapter::split_message(&msg, 5); + // Each Chinese char is 3 bytes, so 1 per chunk (5 > 3, but 2*3=6 > 5) + for chunk in &chunks { + assert!(chunk.len() <= 5); + } + } + + #[test] + fn test_split_crlf_handling() { + let chunks = IrcAdapter::split_message("hello\r\nworld\r\n", 400); + assert_eq!(chunks, vec!["hello", "world"]); + } + + #[test] + fn test_encode_decode_envelope() { + let data = b"test envelope data for IRC"; + let encoded = IrcAdapter::encode_envelope(data); + assert!(encoded.starts_with("DOT/1/")); + let decoded = IrcAdapter::decode_message(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_encode_decode_fragment() { + let data = b"fragment payload"; + let encoded = IrcAdapter::encode_fragment(0, 3, data); + assert!(encoded.starts_with("DOT/1/F:")); + assert!(encoded.contains("/3:")); + let decoded = IrcAdapter::decode_message(&encoded).unwrap(); + assert_eq!(decoded, data); + } + + #[test] + fn test_decode_invalid_prefix() { + assert!(IrcAdapter::decode_message("NOTDOT/1/abc").is_err()); + } + + #[test] + fn test_decode_invalid_base64() { + assert!(IrcAdapter::decode_message("DOT/1/!!!invalid!!!").is_err()); + } + + #[test] + fn test_domain_hash_deterministic() { + let h1 = IrcAdapter::domain_hash("irc.libera.chat", "#cipherocto"); + let h2 = IrcAdapter::domain_hash("irc.libera.chat", "#cipherocto"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_hash_normalized() { + assert_eq!( + IrcAdapter::domain_hash("IRC.LIBERA.CHAT", "#cipherocto"), + IrcAdapter::domain_hash(" irc.libera.chat ", "#cipherocto") + ); + } + + #[test] + fn test_domain_hash_different_servers() { + let h1 = IrcAdapter::domain_hash("irc.libera.chat", "#test"); + let h2 = IrcAdapter::domain_hash("irc.oftc.net", "#test"); + assert_ne!(h1, h2); + } + + // R18 fix: the trait-method `domain_id(platform_id)` must produce the + // same hash as the static `domain_hash(server, channel)` so that + // `send_envelope` can find the configured channel by domain. The + // platform_id is the colon-joined form `server:channel`. + #[test] + fn test_domain_id_matches_domain_hash() { + let from_id = IrcAdapter::domain_hash_from_id("irc.libera.chat:#cipherocto"); + let from_args = IrcAdapter::domain_hash("irc.libera.chat", "#cipherocto"); + assert_eq!(from_id, from_args); + } + + #[test] + fn test_domain_id_normalizes_server_case_and_whitespace() { + // Server is case+whitespace normalized; channel is preserved. + let h1 = IrcAdapter::domain_hash_from_id(" IRC.LIBERA.CHAT :#cipherocto"); + let h2 = IrcAdapter::domain_hash("irc.libera.chat", "#cipherocto"); + assert_eq!(h1, h2); + } + + #[test] + fn test_domain_id_no_colon_falls_back_to_channel_only() { + // Backward compat: if the caller passes just a channel (no colon), + // hash it as if server is empty. This produces a different hash + // from the proper `server:channel` form, so users who skip the + // colon get a no-match in send_envelope. + let no_colon = IrcAdapter::domain_hash_from_id("#cipherocto"); + let with_colon = IrcAdapter::domain_hash("", "#cipherocto"); + assert_eq!(no_colon, with_colon); + let proper = IrcAdapter::domain_hash("irc.libera.chat", "#cipherocto"); + assert_ne!(no_colon, proper); + } + + #[test] + fn test_platform_type() { + assert_eq!(IrcAdapter::PLATFORM_TYPE, 0x0006); + } + + #[test] + fn test_abi_exports() { + assert_eq!(adapter_version(), 1); + assert_eq!(platform_type(), 0x0006); + } + + #[test] + fn test_config_from_json() { + let json = serde_json::json!({ + "server": "irc.libera.chat", + "port": 6697, + "nickname": "testbot", + "channels": ["#test", "#cipherocto"], + "password": null, + "use_tls": true + }); + let adapter = + IrcAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()).unwrap(); + assert_eq!(adapter.config.server, "irc.libera.chat"); + assert_eq!(adapter.config.port, 6697); + assert_eq!(adapter.config.nickname, "testbot"); + assert_eq!(adapter.config.channels.len(), 2); + assert!(adapter.config.use_tls); + } + + #[test] + fn test_config_defaults() { + let json = serde_json::json!({ + "server": "irc.libera.chat", + "nickname": "testbot", + "channels": ["#test"] + }); + let adapter = + IrcAdapter::from_config_bytes(serde_json::to_vec(&json).unwrap().as_slice()).unwrap(); + assert_eq!(adapter.config.port, 6697); // default TLS port + assert!(adapter.config.use_tls); // default true + assert_eq!(adapter.config.password, None); + } + + #[test] + fn test_capabilities() { + let adapter = IrcAdapter::new(IrcConfig { + server: "irc.libera.chat".into(), + port: 6697, + nickname: "test".into(), + channels: vec!["#test".into()], + password: None, + use_tls: true, + }); + let caps = adapter.capabilities(); + assert_eq!(caps.max_payload_bytes, MAX_PAYLOAD_PER_MSG); + assert!(caps.supports_fragmentation); + assert_eq!(caps.rate_limit_per_second, 1); + } + + #[test] + fn test_parse_privmsg() { + let line = ":nick!user@host PRIVMSG #channel :Hello world"; + let msg = parse_privmsg(line).unwrap(); + assert_eq!(msg.sender, "nick"); + assert_eq!(msg.channel, "#channel"); + assert_eq!(msg.text, "Hello world"); + } + + #[test] + fn test_parse_privmsg_with_dot_prefix() { + let line = ":bot!u@h PRIVMSG #ch :DOT/1/AQID"; + let msg = parse_privmsg(line).unwrap(); + assert!(msg.text.starts_with("DOT/1/")); + } + + #[test] + fn test_parse_privmsg_not_privmsg() { + let line = ":server 001 nick :Welcome"; + assert!(parse_privmsg(line).is_none()); + } + + #[test] + fn test_parse_privmsg_no_prefix() { + let line = "NOTICE * :*** Looking up..."; + assert!(parse_privmsg(line).is_none()); + } + + #[test] + fn test_encode_decode_roundtrip() { + let data = vec![0u8; 256]; + for i in 0..256 { + let mut d = data.clone(); + d[i] = 0xFF; + let encoded = IrcAdapter::encode_envelope(&d); + let decoded = IrcAdapter::decode_message(&encoded).unwrap(); + assert_eq!(decoded, d); + } + } + + // ── R21: CoordinatorAdmin tests ────────────────────────────── + // + // The first set of tests is pure-unit (no socket, no tokio + // runtime) and covers the parts of the `CoordinatorAdmin` impl + // that don't touch the wire: the downcast probe, the + // capability report, the platform name, and the static + // `channel_for` / `full_id` helpers. + // + // The second set is async and uses a local `TcpListener` to + // verify that admin commands actually reach the wire. + // This is the same pattern R20 WhatsApp used for its + // capability tests, but IRC is small enough that we can + // verify the full path end-to-end without mocking the SDK. + + /// Helper: build a configured `IrcAdapter` for tests. The + /// port is irrelevant for the non-socket tests. + fn make_test_adapter() -> IrcAdapter { + IrcAdapter::new(IrcConfig { + server: "irc.example.org".into(), + port: 6697, + nickname: "testbot".into(), + channels: vec!["#alpha".into(), "#beta".into()], + password: None, + use_tls: true, + }) + } + + #[test] + fn test_as_coordinator_admin_returns_some_for_irc() { + // The probe is the bridge between `PlatformAdapter` and + // `CoordinatorAdmin`. IRC opts in (it supports a meaningful + // subset), so the probe must return `Some(_)`. + let adapter = make_test_adapter(); + let admin: Option<&dyn CoordinatorAdmin> = adapter.as_coordinator_admin(); + assert!(admin.is_some(), "IRC should opt in to CoordinatorAdmin"); + assert_eq!(admin.unwrap().platform_name(), "irc"); + } + + #[test] + fn test_platform_name_is_irc() { + let adapter = make_test_adapter(); + assert_eq!(adapter.platform_name(), "irc"); + } + + #[test] + fn test_admin_capabilities_truthful_for_irc() { + // Bit-by-bit check of the capability report. The truth + // here is "what the IRC protocol supports natively". + // Any change to this assertion means the documented + // support matrix changed. + let adapter = make_test_adapter(); + let caps = adapter.admin_capabilities(); + + // Lifecycle + assert!(!caps.can_create, "IRC has no group creation"); + // RFC-0861 Β§1 M10: `can_join_by_id = true` because + // `JOIN #channel` is exactly join-by-id (was + // conservative-but-wrong `false` pre-M10). + assert!( + caps.can_join_by_id, + "IRC's JOIN #channel is join-by-id (RFC-0861 Β§1 M10)" + ); + assert!(caps.can_join_by_invite, "JOIN #channel is supported"); + assert!(caps.can_leave, "PART is supported"); + assert!( + !caps.can_destroy, + "no separate invite-link revoke primitive" + ); + + // Membership + assert!(caps.can_add_member, "INVITE is supported"); + assert!(caps.can_remove_member, "KICK is supported"); + assert!(!caps.can_ban, "MODE +b needs hostmask, not in PeerId"); + assert!(caps.can_promote, "MODE +o is supported"); + assert!(caps.can_demote, "MODE -o is supported"); + assert!(!caps.can_approve_join, "no approval workflow"); + + // Mode + assert!(caps.can_rename, "TOPIC is supported"); + assert!(!caps.can_describe, "no description separate from topic"); + assert!(caps.can_lock, "MODE +i/-i is supported"); + assert!(caps.can_announce, "MODE +m/-m is supported"); + assert!(!caps.can_set_ephemeral, "no TTL"); + assert!(!caps.can_require_approval, "no approval"); + + // Discovery + assert!( + caps.can_list_own_groups, + "configured channels are enumerable" + ); + assert!(!caps.can_get_metadata, "no sync NAMES/MODE capture"); + assert!(!caps.can_resolve_invite, "no invite URL"); + + // Handoff + assert!(!caps.can_transfer_ownership, "no transfer primitive"); + } + + #[test] + fn test_full_id_format() { + let adapter = make_test_adapter(); + let id = adapter.full_id("#alpha"); + assert_eq!(id.as_str(), "irc.example.org:#alpha"); + } + + #[test] + fn test_channel_for_accepts_server_channel_form() { + let adapter = make_test_adapter(); + let ch = adapter + .channel_for(&GroupId::new("irc.example.org:#alpha")) + .unwrap(); + assert_eq!(ch, "#alpha"); + } + + #[test] + fn test_channel_for_accepts_bare_channel_form() { + // No colon β†’ assume the channel is on our server. This + // matches the `domain_id` fallback (R18 fix). + let adapter = make_test_adapter(); + let ch = adapter.channel_for(&GroupId::new("#alpha")).unwrap(); + assert_eq!(ch, "#alpha"); + } + + #[test] + fn test_channel_for_rejects_wrong_server() { + let adapter = make_test_adapter(); + let err = adapter + .channel_for(&GroupId::new("irc.other.net:#alpha")) + .unwrap_err(); + match err { + PlatformAdapterError::ApiError { code, message } => { + assert_eq!(code, 400); + assert!( + message.contains("irc.other.net"), + "error should mention the wrong server: {message}" + ); + } + other => panic!("expected ApiError, got {other:?}"), + } + } + + #[test] + fn test_channel_for_rejects_unconfigured_channel() { + let adapter = make_test_adapter(); + let err = adapter.channel_for(&GroupId::new("#unknown")).unwrap_err(); + match err { + PlatformAdapterError::ApiError { code, message } => { + assert_eq!(code, 404); + assert!( + message.contains("#unknown"), + "error should mention the channel: {message}" + ); + } + other => panic!("expected ApiError, got {other:?}"), + } + } + + #[tokio::test] + async fn test_list_own_groups_returns_configured_channels() { + let adapter = make_test_adapter(); + let groups = adapter.list_own_groups().await.unwrap(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].id.as_str(), "irc.example.org:#alpha"); + assert_eq!(groups[1].id.as_str(), "irc.example.org:#beta"); + // We don't track op status, so is_admin is conservatively false. + assert!(!groups[0].is_admin); + assert!(!groups[1].is_admin); + // We don't capture NAMES replies, so all dynamic fields are None. + assert!(groups[0].subject.is_none()); + assert!(groups[0].invite_url.is_none()); + assert!(groups[0].member_count.is_none()); + assert!(groups[0].mode_flags.is_none()); + } + + #[tokio::test] + async fn test_get_group_metadata_returns_id_for_configured_channel() { + let adapter = make_test_adapter(); + let meta = adapter + .get_group_metadata(&GroupId::new("irc.example.org:#alpha")) + .await + .unwrap(); + assert_eq!(meta.id.as_str(), "irc.example.org:#alpha"); + // Honest minimum: the static ID, no rich metadata. + assert!(meta.subject.is_none()); + assert!(meta.description.is_none()); + assert!(meta.members.is_empty()); + assert!(meta.admins.is_empty()); + assert!(meta.invite_url.is_none()); + // Mode flags default to all-false. + let f = meta.mode_flags; + assert!(!f.locked); + assert!(!f.announce_only); + assert!(f.ephemeral_ttl.is_none()); + assert!(!f.requires_approval); + } + + #[tokio::test] + async fn test_get_group_metadata_rejects_unconfigured_channel() { + let adapter = make_test_adapter(); + let err = adapter + .get_group_metadata(&GroupId::new("#unknown")) + .await + .unwrap_err(); + assert!(matches!( + err, + PlatformAdapterError::ApiError { code: 404, .. } + )); + } + + /// Assert that a default-`Unimplemented` method on the IRC + /// `CoordinatorAdmin` returns exactly + /// `Err(Unimplemented { platform: "irc", action: