Current section
Files
Jump to
Current section
Files
src/wimp.gleam
//// A Gleam client for the Pushover push notification API.
////
//// https://pushover.net/api
import gleam/http.{Post}
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/int
import gleam/list
import gleam/json.{type Json}
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
pub type Error {
MessageLimitExceeded(limits: Limits)
InvalidApplicationToken
BadRequest(body: String)
RateLimited(limits: Limits, body: String)
UnexpectedResponse(status: Int, body: String)
}
pub type Priority {
Emergency(
// The number of seconds before resending the notification.
// Must be be at least 30, lower values will be set to 30.
retry: Int,
// How many seconds the notification will continue to be retried for.
// Must be at most 10800, higher values will be set to 10800.
expire: Int,
)
High
Normal
Low
Lowest
}
pub type Formatting {
Html
Text
Monospace
}
pub type Limits {
Limits(
app_limit: Int,
app_remaining: Int,
/// The unix timestamp when the app counter will reset.
app_reset: Int,
)
}
pub opaque type Builder {
Builder(
token: String,
user: String,
message: String,
device: Option(String),
title: Option(String),
priority: Priority,
formatting: Formatting,
time_to_live: Option(Int),
sound: Option(String),
supplimentary_url: Option(#(String, String)),
)
}
/// Create a new Pushover message to send to a Pushover user as the application
/// for the token.
///
/// Messages are limited to 1024 characters in length, longer messages will be
/// truncated.
pub fn new(
token token: String,
user user: String,
message message: String,
) -> Builder {
let message = string.slice(message, 0, length: 1024)
Builder(
token: token,
user: user,
message: message,
device: None,
title: None,
priority: Normal,
formatting: Text,
time_to_live: None,
sound: None,
supplimentary_url: None,
)
}
/// Set the formatting of the message.
///
pub fn formatting(builder: Builder, formatting: Formatting) -> Builder {
Builder(..builder, formatting: formatting)
}
/// Set the title of the message.
///
/// Titles are limited to 250 characters in length, longer titles will be
/// truncated.
pub fn title(builder: Builder, title: String) -> Builder {
let title = string.slice(title, 0, length: 250)
Builder(..builder, title: Some(title))
}
/// Set the priority of the message.
///
pub fn priority(builder: Builder, priority: Priority) -> Builder {
Builder(..builder, priority: priority)
}
/// Set the time to live of the message in seconds.
///
pub fn time_to_live(builder: Builder, seconds time_to_live: Int) -> Builder {
Builder(..builder, time_to_live: Some(time_to_live))
}
/// Set the device to send the message to.
///
pub fn device(builder: Builder, device: String) -> Builder {
Builder(..builder, device: Some(device))
}
/// Set a supplimentary URL and a title for the link.
///
pub fn supplimentary_url(
builder: Builder,
title title: String,
url url: String,
) -> Builder {
Builder(..builder, supplimentary_url: Some(#(title, url)))
}
/// Set the sound of the message.
///
/// The default sounds are:
/// - `pushover` - Pushover (default)
/// - `bike` - Bike
/// - `bugle` - Bugle
/// - `cashregister` - Cash Register
/// - `classical` - Classical
/// - `cosmic` - Cosmic
/// - `falling` - Falling
/// - `gamelan` - Gamelan
/// - `incoming` - Incoming
/// - `intermission` - Intermission
/// - `magic` - Magic
/// - `mechanical` - Mechanical
/// - `pianobar` - Piano Bar
/// - `siren` - Siren
/// - `spacealarm` - Space Alarm
/// - `tugboat` - Tug Boat
/// - `alien` - Alien Alarm (long)
/// - `climb` - Climb (long)
/// - `persistent` - Persistent (long)
/// - `echo` - Pushover Echo (long)
/// - `updown` - Up Down (long)
/// - `vibrate` - Vibrate Only
/// - `none` - None (silent)
///
pub fn sound(builder: Builder, sound: String) -> Builder {
Builder(..builder, sound: Some(sound))
}
pub fn message_request(builder: Builder) -> Request(String) {
case builder.message {
"" -> panic as "message must not be blank"
_ -> Nil
}
let properties =
[
#("token", json.string(builder.token)),
#("user", json.string(builder.user)),
#("message", json.string(builder.message)),
]
|> add_priority(builder.priority)
|> add_formatting(builder.formatting)
|> add_supplimentary_url(builder.supplimentary_url)
|> maybe_add("ttl", builder.time_to_live, json.int)
|> maybe_add("title", builder.title, json.string)
|> maybe_add("sound", builder.sound, json.string)
|> maybe_add("device", builder.device, json.string)
request.new()
|> request.set_method(Post)
|> request.set_host("api.pushover.net")
|> request.set_path("/1/messages.json")
|> request.set_body(json.to_string(json.object(properties)))
|> request.prepend_header("accept", "application/json")
|> request.prepend_header("content-type", "application/json")
}
fn add_formatting(
properties: List(#(String, Json)),
formatting: Formatting,
) -> List(#(String, Json)) {
case formatting {
Text -> properties
Html -> [#("html", json.int(1)), ..properties]
Monospace -> [#("monospace", json.int(1)), ..properties]
}
}
fn add_supplimentary_url(
properties: List(#(String, Json)),
supplimentary_url: Option(#(String, String)),
) -> List(#(String, Json)) {
case supplimentary_url {
None -> properties
Some(#(title, url)) -> [
#("url", json.string(url)),
#("url_title", json.string(title)),
..properties
]
}
}
fn add_priority(
properties: List(#(String, Json)),
priority: Priority,
) -> List(#(String, Json)) {
let #(extra, priority) = case priority {
Emergency(retry: retry, expire: expire) -> {
let extra = [
#("retry", json.int(int.max(retry, 30))),
#("expire", json.int(int.min(expire, 10_800))),
]
#(extra, 2)
}
High -> #([], 1)
Normal -> #([], 0)
Low -> #([], -1)
Lowest -> #([], -2)
}
extra
|> list.append(properties)
|> list.prepend(#("priority", json.int(priority)))
}
fn maybe_add(
properties: List(#(String, Json)),
key: String,
value: Option(t),
encode: fn(t) -> Json,
) -> List(#(String, Json)) {
case value {
None -> properties
Some(value) -> [#(key, encode(value)), ..properties]
}
}
/// Decode the response from Pushover to determine whether the message sending
/// was successful or not.
///
pub fn decode_message_response(resp: Response(String)) -> Result(Limits, Error) {
let get_int_header = fn(name) {
resp
|> response.get_header(name)
|> result.try(int.parse)
|> result.unwrap(0)
}
let app_limit = get_int_header("x-limit-app-limit")
let app_remaining = get_int_header("x-limit-app-remaining")
let app_reset = get_int_header("x-limit-app-reset")
let limits =
Limits(
app_limit: app_limit,
app_remaining: app_remaining,
app_reset: app_reset,
)
case resp.status {
200 -> Ok(limits)
400 ->
Error(case string.contains(resp.body, "application token is invalid") {
True -> InvalidApplicationToken
False -> BadRequest(resp.body)
})
429 -> Error(RateLimited(limits, resp.body))
code -> Error(UnexpectedResponse(code, resp.body))
}
}