Last active
June 20, 2024 14:45
-
-
Save bbeck/7aa04efb780e5f7b609d1ef839a39bd9 to your computer and use it in GitHub Desktop.
Gleam chat server (solves protohackers problem 3)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import gleam/dict.{type Dict} | |
import gleam/erlang/process | |
import gleam/list | |
import gleam/otp/actor | |
type RoomSubject = | |
process.Subject(Command) | |
type ClientSubject = | |
process.Subject(Message) | |
/// Commands that can be sent to the room from clients to alter the state of | |
/// the room. | |
pub type Command { | |
Join(name: String, client: ClientSubject) | |
Part(name: String) | |
Chat(name: String, message: String) | |
} | |
/// Messages sent to clients to notify them of changes to the state of the room. | |
pub type Message { | |
Joined(name: String) | |
Parted(name: String) | |
Members(names: List(String)) | |
Message(name: String, message: String) | |
} | |
/// Create and start a new, empty room. | |
pub fn new_room() -> Result(RoomSubject, actor.StartError) { | |
actor.start(State(dict.new()), handle_message) | |
} | |
/// Join a user to a room. | |
pub fn join(room: RoomSubject, name: String, client: ClientSubject) { | |
actor.send(room, Join(name, client)) | |
} | |
/// Remove a user from a room. | |
pub fn part(room: RoomSubject, name: String) { | |
actor.send(room, Part(name)) | |
} | |
/// Send a message to the room. | |
pub fn message(room: RoomSubject, name: String, message: String) { | |
actor.send(room, Chat(name, message)) | |
} | |
fn handle_message(msg: Command, state: State) -> actor.Next(Command, State) { | |
case msg { | |
Join(name: name, client: client) -> { | |
// Send the new user a presence notification that includes the list of | |
// all users in the room. | |
actor.send(client, Members(dict.keys(state.clients))) | |
// Send users already in the room a presence notification that the new | |
// user has joined. | |
let clients = dict.values(state.clients) | |
list.map(clients, fn(client) { actor.send(client, Joined(name)) }) | |
// Save this client. | |
let clients = dict.insert(state.clients, name, client) | |
actor.continue(State(clients)) | |
} | |
Part(name: name) -> { | |
// Remove this client from the room. | |
let clients = dict.delete(state.clients, name) | |
// Send remaining users a presence notification that this user has parted. | |
dict.each(clients, fn(_, client) { actor.send(client, Parted(name)) }) | |
actor.continue(State(clients)) | |
} | |
Chat(name: name, message: message) -> { | |
// Send the message to all connected clients, except for the originator | |
// of the message. | |
dict.each(state.clients, fn(cname, client) { | |
case cname { | |
_ if cname != name -> { | |
actor.send(client, Message(name, message)) | |
} | |
_ -> Nil | |
} | |
}) | |
actor.continue(state) | |
} | |
} | |
} | |
type State { | |
State(clients: Dict(String, ClientSubject)) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import gleam/bit_array | |
import gleam/bytes_builder | |
import gleam/erlang/process | |
import gleam/function | |
import gleam/option.{type Option, None, Some} | |
import gleam/otp/actor | |
import gleam/regex | |
import gleam/result | |
import gleam/string | |
import glisten | |
import problem03/chat | |
pub fn main() { | |
let assert Ok(room) = chat.new_room() | |
let assert Ok(_) = | |
glisten.handler(init(room), loop) | |
|> glisten.with_close(fn(state) { | |
case state.name { | |
Some(user) -> chat.part(room, user) | |
_ -> Nil | |
} | |
}) | |
|> glisten.serve(3000) | |
process.sleep_forever() | |
} | |
fn init(room_subject: chat.RoomSubject) { | |
fn(connection) { | |
let client_subject = process.new_subject() | |
let state = | |
State( | |
name: None, | |
room_subject: room_subject, | |
client_subject: client_subject, | |
string: "", | |
close: False, | |
) | |
let selector = | |
process.new_selector() | |
|> process.selecting(client_subject, function.identity) | |
// Ask the user for their name. | |
let assert Ok(_) = | |
glisten.send(connection, bytes_builder.from_string("Name:\n")) | |
#(state, Some(selector)) | |
} | |
} | |
fn loop(msg, state, connection) { | |
let state = case msg { | |
glisten.Packet(packet) -> { | |
// This message was a packet from over the network. Collect it and | |
// process any lines it contained or completed. | |
let text = result.unwrap(bit_array.to_string(packet), "") | |
let state = State(..state, string: state.string <> text) | |
handle_messages(state, connection) | |
} | |
glisten.User(chat.Joined(name)) -> { | |
let message = bytes_builder.from_string("* " <> name <> " joined\n") | |
let assert Ok(_) = glisten.send(connection, message) | |
state | |
} | |
glisten.User(chat.Parted(name)) -> { | |
let message = bytes_builder.from_string("* " <> name <> " left\n") | |
let assert Ok(_) = glisten.send(connection, message) | |
state | |
} | |
glisten.User(chat.Members(names)) -> { | |
let members = string.join(names, ", ") | |
let message = bytes_builder.from_string("* members: " <> members <> "\n") | |
let assert Ok(_) = glisten.send(connection, message) | |
state | |
} | |
glisten.User(chat.Message(name, message)) -> { | |
let message = | |
bytes_builder.from_string("[" <> name <> "] " <> message <> "\n") | |
let assert Ok(_) = glisten.send(connection, message) | |
state | |
} | |
} | |
case state.close { | |
False -> actor.continue(state) | |
True -> actor.Stop(process.Normal) | |
} | |
} | |
fn handle_messages(state: State, connection) -> State { | |
case state.name, string.split_once(state.string, on: "\n") { | |
None, Ok(#(name, rest)) -> { | |
// The user doesn't have a name yet, so this first line received is their | |
// name. | |
// Validate the name | |
case is_valid_username(name) { | |
True -> { | |
// Join the room. | |
chat.join(state.room_subject, name, state.client_subject) | |
let state = State(..state, name: Some(name), string: rest) | |
handle_messages(state, connection) | |
} | |
False -> { | |
// Close the connection. | |
State(..state, close: True) | |
} | |
} | |
} | |
Some(name), Ok(#(line, rest)) -> { | |
// This is a normal chat message, send it to the room. | |
chat.message(state.room_subject, name, line) | |
let state = State(..state, string: rest) | |
handle_messages(state, connection) | |
} | |
_, _ -> state | |
} | |
} | |
fn is_valid_username(name: String) -> Bool { | |
let assert Ok(re) = regex.from_string("^[a-zA-Z0-9]+$") | |
regex.check(re, name) | |
} | |
type State { | |
State( | |
name: Option(String), | |
room_subject: chat.RoomSubject, | |
client_subject: chat.ClientSubject, | |
string: String, | |
close: Bool, | |
) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This is free and unencumbered software released into the public domain. | |
Anyone is free to copy, modify, publish, use, compile, sell, or | |
distribute this software, either in source code form or as a compiled | |
binary, for any purpose, commercial or non-commercial, and by any | |
means. | |
In jurisdictions that recognize copyright laws, the author or authors | |
of this software dedicate any and all copyright interest in the | |
software to the public domain. We make this dedication for the benefit | |
of the public at large and to the detriment of our heirs and | |
successors. We intend this dedication to be an overt act of | |
relinquishment in perpetuity of all present and future rights to this | |
software under copyright law. | |
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 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. | |
For more information, please refer to <https://unlicense.org> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment