Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e46f719
docs: Update README.md
atlas-oc Jan 22, 2026
6efbcf9
refactor: Change trie dependency
atlas-oc Jan 22, 2026
ac972c4
feat(bot): Expose average shard latency in `/bot-info
atlas-oc Jan 22, 2026
86fb5b2
refactor: Simplify Cachex caches with macro
atlas-oc Jan 23, 2026
0f33fbf
fix: Prevent unbounded recursion caused by unexpected errors in creat…
atlas-oc Jan 24, 2026
536fda2
feat(bot): Alter ID autocomplete by name
atlas-oc Jan 24, 2026
f8af049
chore: Run `mix format`
atlas-oc Jan 24, 2026
73f8687
refactor: Tidy up autocomplete supervisor tree
atlas-oc Jan 24, 2026
8a267e5
fix: Improper imports
atlas-oc Jan 24, 2026
a194000
fix: Import `Cachex.Spec` in autocomplete managers
atlas-oc Jan 24, 2026
f17f4dc
chore: Add LICENSE.md
atlas-oc Jan 25, 2026
b76f370
chore: Fix LICENSE.txt
atlas-oc Jan 25, 2026
7f6b1b8
fix: Fix Docker Compose file
atlas-oc Jan 25, 2026
dab3f7a
feat(bot): [WIP] Components V2 for alters
atlas-oc Jan 25, 2026
f3f315d
chore: Run `mix format`
atlas-oc Jan 25, 2026
fc7852a
fix: Yes
atlas-oc Jan 25, 2026
da5713a
fix: Remove Nostrum shim (fixed in #619 in v0.10.1)
atlas-oc Jan 25, 2026
5449bc3
chore: Remove unused alias
atlas-oc Jan 25, 2026
e3712f4
chore: Remove Gettext & unused dependencies
atlas-oc Jan 25, 2026
8ef43b0
feat(bot): Components V2 & full autocomplete
atlas-oc Jan 26, 2026
b73f8e5
chore: Run `mix format`
atlas-oc Jan 26, 2026
44d80b9
chore: wip
atlas-oc Jan 27, 2026
ec3b890
feat(bot): Add manual autocomplete invalidation
atlas-oc Jan 28, 2026
33fec8a
feat(bot): Front buttons for alter components
atlas-oc Jan 28, 2026
c7a1623
chore: Run `mix format`
atlas-oc Jan 28, 2026
4c5db75
feat(bot): [WIP] /tag command & autocomplete
atlas-oc Jan 28, 2026
366af68
refactor: Organize Discord codebase
atlas-oc Feb 1, 2026
3ec20bd
feat(bot): Finish `/tag` command
atlas-oc Feb 2, 2026
122e38c
fix: Use prod emojis
atlas-oc Feb 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
{Credo.Check.Refactor.CondStatements, []},
# {Credo.Check.Refactor.CyclomaticComplexity, [priority: :low]},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
# {Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
Expand Down
21 changes: 21 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Octocon, LLC

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
55 changes: 48 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,60 @@ This repository contains the backend code for Octocon, which is structured into
- **octocon-web**: The Phoenix web application that serves the REST API, metrics, and admin dashboard.
- **octocon-discord**: The Discord bot that serves as an alternative interface for interacting with the Octocon platform, including "proxying" as alters.

## Distributed monolith
## Development setup

To set up a development environment for Octocon, you'll need to have the following prerequisites installed on a Unix-like operating system or WSL:
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
- Git

Once installed, follow these steps to set up your development environment:
1. Clone the repository:
```bash
git clone
cd octocon
```

2. Run the provided setup script:
```bash
./dev/setup.sh
```
This script will ensure your system is configured correctly, build the necessary Docker images, install dependencies, and perform database migrations.

3. Start the development environment:
```bash
./dev/bin/iex
```
This command will launch an interactive Elixir shell running the Octocon application inside a Docker container.

## Contributing

We welcome contributions to Octocon! If you'd like to contribute, please follow these steps:
1. Fork the repository on GitHub and create a new branch for your feature or bug fix.
2. Use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) for your commit messages.
3. Run `mix format` then `mix lint` before submitting a PR to ensure code quality.
4. Submit a pull request to this repository for review.

While we respect your time, please note that not every contribution will be accepted; certain features may not align with our project's goals or privacy/security standards. If you'd like to contribute a new feature, your best bet is to reach out to us in the `#development` channel on our [Discord server](https://discord.gg/octocon) first to discuss its feasibility. Alternatively, we welcome PRs implementing accepted suggestions posted on [our issue tracker](https://github.com/octocondev/issues/issues?q=is%3Aissue%20state%3Aopen%20type%3ASuggestion%20(label%3A%22Low%20Priority%22%20OR%20label%3A%22High%20Priority%22%20OR%20label%3AUrgent))

## Deployment structure
Octocon is designed as a distributed monolith, meaning that while the components have a clear separation of concerns, they share a common codebase which is compiled and deployed as one executable.

Octocon is generally run in a cluster of nodes, which are designed to be globally distributed across the world. One "primary region" interfaces with a larger database instance and runs the Discord bot, while "auxiliary regions" interface with database replicas. This allows for low-latency access to the data from anywhere in the world.
Octocon is generally run in a cluster of nodes, which are designed to be globally distributed across the world. One "primary" node interfaces with a generally larger database instance and runs the Discord bot, while "auxiliary" nodes interface with smaller database instances. This allows for low-latency access to the data from anywhere in the world.

When not running on Fly.io, an Octocon node knows its role in the overall cluster through an environment variable (`NODE_GROUP`), which determines which parts of the supervision tree it will run, and how it will advertise itself to its peers.

By default, Octocon is configured to discover other nodes using the [libcluster](https://github.com/bitwalker/libcluster) library with a custom Tailscale strategy. All that is necessary is for each node to form a Distributed Erlang cluster; Octocon has internal logic to determine and cache each node's role in the cluster through an RPC communication step.
In production, Octocon is configured to discover other nodes using the [libcluster](https://github.com/bitwalker/libcluster) library with a custom Tailscale strategy. All that is necessary is for each node to form a Distributed Erlang cluster; Octocon has internal logic to determine and cache each node's role in the cluster through an RPC communication step.

There are 3 node groups:
- `primary`: A node running in the primary region, which interfaces with the read-write database and runs Discord shards. Other nodes proxy their database writes to a `primary` node.
- `auxiliary`: A node running in an auxiliary region, which interfaces with a read-only replica of the database. These nodes are largely only responsible for serving HTTP requests to the API.
- `sidecar`: A node responsible for isolating CPU-bound tasks from the rest of the cluster, such as image processing and heavy encryption tasks. **At least one** sidecar must be present in the entire cluster.
- `primary`: A node running in the primary region, which interfaces with a larger database instance, runs Discord shards, and handles certain types of global state. Other nodes proxy some requests to a `primary` node.
- `auxiliary`: A node running in an auxiliary region, which interfaces with a smaller database instance. These nodes are largely only responsible for serving HTTP requests to the API.
- `sidecar`: A node responsible for isolating CPU-bound tasks from the rest of the cluster, such as image processing and heavy encryption tasks. Ideally, **at least one** sidecar should be present in the cluster. If no sidecar is present, nodes will run these tasks themselves.

**Note**: Running multiple `primary` nodes is heavily experimental and not currently recommended - our Discord library (Nostrum) still doesn't behave well in this type of distributed environment.

## Configuration & integrations

Every Octocon node must be configured with a set of environment variables to function properly. These variables are used to connect to the database, configure the Discord bot, and set up other integrations, such as cloud object storage.

### OAuth
Expand Down Expand Up @@ -65,4 +103,7 @@ Every Octocon node must be configured with a set of environment variables to fun
- `NODE_GROUP` - The group this node belongs to (`primary`, `auxiliary`, or `sidecar`).
- `PRIMARY_NODE_COUNT` - Number of primary nodes in the cluster (**values other than 1 are experimental**).
- `POOL_SIZE` - Size of the database connection pool.
- `PORT` - Port to run the Phoenix web server on.
- `PORT` - Port to run the Phoenix web server on.
- `TAILSCALE_API_AUTHKEY` - When present, enables Tailscale-based clustering using the provided auth key.
- `NODE_LIST` - When present, enables static clustering with the provided comma-separated list of node IPs.
If neither `TAILSCALE_API_AUTHKEY` nor `NODE_LIST` are present, a single-node cluster is assumed, which will run as a `primary` node.
10 changes: 7 additions & 3 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]

config :logger,
backends: [:console, Sentry.LoggerBackend]
config :octocon, :logger, [
{:handler, :sentry_handler, Sentry.LoggerHandler,
%{
config: %{metadata: [:file, :line]}
}}
]

# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
Expand Down Expand Up @@ -117,7 +121,7 @@ config :nostrum,
:direct_message_reactions,
:message_content
],
# TODO: Manual sharding
# [TODO]: Manual sharding
# num_shards: :manual,
ffmpeg: false,
gateway_compression: :zstd
Expand Down
2 changes: 1 addition & 1 deletion config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ config :octocon, :nostrum_scope, :global

config :mnesia, dir: ~c"/mnesia"

# TODO: Do not print debug messages in production
# [TODO]: Do not print debug messages in production
config :logger, level: :info

config :sentry,
Expand Down
4 changes: 2 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ if config_env() == :prod do
config :octocon, :node_group, node_group
config :octocon, :current_db_region, current_db_region

# TODO: Only :auxiliary
# TODO: Rename :auxiliary to :ingress
# [TODO]: Only :auxiliary
# [TODO]: Rename :auxiliary to :ingress
if node_group in [:primary, :auxiliary] || System.get_env("PHX_SERVER") do
config :octocon, OctoconWeb.Endpoint, server: true
else
Expand Down
41 changes: 17 additions & 24 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ services:
ports:
- "9042:9042"
restart: always
command: --seeds=scylla-nam --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-nam
volumes:
- ./dev/scylla/cassandra-rackdc.nam.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/nam:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
networks:
octo_network:
ipv4_address: 172.18.1.1

scylla-eur:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-eur
volumes:
- ./dev/scylla/cassandra-rackdc.eur.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/eur:/var/lib/scylla
Expand All @@ -24,81 +25,67 @@ services:
- scylla-nam
networks:
octo_network:

scylla-gdpr:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
volumes:
- ./dev/scylla/cassandra-rackdc.gdpr.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/gdpr:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
depends_on:
- scylla-nam
- scylla-eur
networks:
octo_network:
ipv4_address: 172.18.1.2

scylla-sam:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-sam
volumes:
- ./dev/scylla/cassandra-rackdc.sam.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/sam:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
depends_on:
- scylla-nam
- scylla-eur
- scylla-gdpr
networks:
octo_network:
ipv4_address: 172.18.1.3

scylla-sas:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-sas
volumes:
- ./dev/scylla/cassandra-rackdc.sas.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/sas:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
depends_on:
- scylla-nam
- scylla-eur
- scylla-gdpr
- scylla-sam
networks:
octo_network:
ipv4_address: 172.18.1.4

scylla-eas:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-eas
volumes:
- ./dev/scylla/cassandra-rackdc.eas.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/eas:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
depends_on:
- scylla-nam
- scylla-eur
- scylla-gdpr
- scylla-sam
- scylla-sas
networks:
octo_network:
ipv4_address: 172.18.1.5

scylla-ocn:
image: scylladb/scylla:2025.1
restart: always
command: --seeds=scylla-nam,scylla-eur --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1
command: --seeds=172.18.1.1,172.18.1.2 --smp 1 --memory 750M --overprovisioned 1 --developer-mode 1 --api-address 0.0.0.0 --broadcast-address scylla-ocn
volumes:
- ./dev/scylla/cassandra-rackdc.ocn.properties:/etc/scylla/cassandra-rackdc.properties
- ./dev/scylla/data/ocn:/var/lib/scylla
- ./dev/scylla/scylla.yaml:/etc/scylla/scylla.yaml
depends_on:
- scylla-nam
- scylla-eur
- scylla-gdpr
- scylla-sam
- scylla-sas
- scylla-eas
Expand All @@ -109,6 +96,7 @@ services:
retries: 20
networks:
octo_network:
ipv4_address: 172.18.1.6

scylla-load-keyspaces:
image: scylladb/scylla:2025.1
Expand Down Expand Up @@ -152,6 +140,7 @@ services:
- app_build:/build
- app_deps:/deps
- .:/app
user: root
depends_on:
scylla-ocn:
condition: service_healthy
Expand All @@ -161,6 +150,10 @@ services:
networks:
octo_network:
driver: bridge
ipam:
driver: default
config:
- subnet: 172.18.0.0/16

volumes:
app_build:
Expand Down
2 changes: 1 addition & 1 deletion lib/nosedrum/converters/role.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ defmodule Nosedrum.Converters.Role do
find_role(Map.values(guild.roles), text, ilike)

{:error, _reason} ->
case Api.get_guild_roles(guild_id) do
case Api.Guild.roles(guild_id) do
{:ok, roles} ->
find_role(roles, text, ilike)

Expand Down
3 changes: 2 additions & 1 deletion lib/nosedrum/storage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ defmodule Nosedrum.Storage do
}

Nostrum.Api.create_interaction_response(interaction, res)
|> IO.inspect()
end

@doc """
Expand All @@ -209,7 +210,7 @@ defmodule Nosedrum.Storage do
|> Keyword.take([:content, :embeds, :components, :allowed_mentions])
|> Map.new()

Nostrum.Api.edit_interaction_response(interaction, data)
Nostrum.Api.Interaction.edit_response(interaction, data)
end

defp convert_callback_type({type, _fn}) do
Expand Down
16 changes: 8 additions & 8 deletions lib/nosedrum/storage/dispatcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ defmodule Nosedrum.Storage.Dispatcher do
build_payload(p, c)
end)

case Nostrum.Api.bulk_overwrite_global_application_commands(command_list) do
case Nostrum.Api.ApplicationCommand.bulk_overwrite_global_commands(command_list) do
{:ok, _} = response ->
{:reply, response, commands}

Expand All @@ -118,7 +118,7 @@ defmodule Nosedrum.Storage.Dispatcher do
build_payload(p, c)
end)

case Nostrum.Api.bulk_overwrite_guild_application_commands(guild_id, command_list) do
case Nostrum.Api.ApplicationCommand.bulk_overwrite_guild_commands(guild_id, command_list) do
{:ok, _} = response ->
{:reply, response, commands}

Expand All @@ -134,7 +134,7 @@ defmodule Nosedrum.Storage.Dispatcher do

@impl true
def handle_call({:add, payload, name, command, :global}, _from, commands) do
case Nostrum.Api.create_global_application_command(payload) do
case Nostrum.Api.ApplicationCommand.create_global_command(payload) do
{:ok, _} = response ->
{:reply, response, Map.put(commands, name, command)}

Expand All @@ -148,7 +148,7 @@ defmodule Nosedrum.Storage.Dispatcher do
when is_list(guild_id_list) do
res =
Enum.reduce(guild_id_list, {[], []}, fn guild_id, {errors, responses} ->
case Nostrum.Api.create_guild_application_command(guild_id, payload) do
case Nostrum.Api.ApplicationCommand.create_guild_command(guild_id, payload) do
{:ok, _} = response ->
{errors, [response | responses]}

Expand All @@ -162,7 +162,7 @@ defmodule Nosedrum.Storage.Dispatcher do

@impl true
def handle_call({:add, payload, name, command, guild_id}, _from, commands) do
case Nostrum.Api.create_guild_application_command(guild_id, payload) do
case Nostrum.Api.ApplicationCommand.create_guild_command(guild_id, payload) do
{:ok, _} = response ->
{:reply, response, Map.put(commands, name, command)}

Expand All @@ -173,7 +173,7 @@ defmodule Nosedrum.Storage.Dispatcher do

@impl true
def handle_call({:remove, name, command_id, :global}, _from, commands) do
case Nostrum.Api.delete_global_application_command(command_id) do
case Nostrum.Api.ApplicationCommand.delete_global_command(command_id) do
{:ok} = response ->
{:reply, response, Map.delete(commands, name)}

Expand All @@ -187,7 +187,7 @@ defmodule Nosedrum.Storage.Dispatcher do
when is_list(guild_id_list) do
res =
Enum.reduce(guild_id_list, {[], []}, fn guild_id, {errors, responses} ->
case Nostrum.Api.delete_guild_application_command(guild_id, command_id) do
case Nostrum.Api.ApplicationCommand.delete_guild_command(guild_id, command_id) do
{:ok} ->
{errors, [:ok | responses]}

Expand All @@ -201,7 +201,7 @@ defmodule Nosedrum.Storage.Dispatcher do

@impl true
def handle_call({:remove, name, command_id, guild_id}, _from, commands) do
case Nostrum.Api.delete_guild_application_command(guild_id, command_id) do
case Nostrum.Api.ApplicationCommand.delete_guild_command(guild_id, command_id) do
{:ok} = response ->
{:reply, response, Map.delete(commands, name)}

Expand Down
Loading
Loading