Overview
HexDroid keeps the app itself tiny with a script interpreter, a UI renderer, and the crypto primitives, and letting behaviour arrive as user-loadable .hex scripts. A script can react to incoming messages, add slash-style commands, call out to the web, schedule work, and even paint a full interactive screen.
The language is small and mIRC-flavoured, but it is its own thing. If you have written an mIRC remote or an aliases file before, most of this will feel familiar; if not, the examples below are enough to start from scratch.
http.get/http.post/media.upload, which the app gates, and not at all while a network that uses a proxy is connected. There is no filesystem access: the one way a script sees a file is media.pick, where HexDroid asks first and you choose the file yourself. There is one generic view renderer, so a script describes a screen, it never ships native code.Scripts are in Menu > Scripts. The bundled scripts shipped with HexDroid are disabled by default, you opt them in. You can also paste, import, edit, and revert scripts from there; see Installing & editing.
Anatomy of a script
A .hex file is a flat list of two kinds of block:
on <event> { … }, an event handler that runs when something happens.alias <name> { … }, a reusable command you can call from anywhere (and that the user can run as/name).
Inside a block, statements are separated by a newline or a pipe |. Comments start with ; and run to the end of the line.
; greeter.hex, answer when someone mentions you
on TEXT {
if ($contains($text, $me) == true) {
msg $chan hi $nick, you mentioned me ; newline or | separates statements
}
}
alias wave { me waves at $1- } ; usable as: /wave everyone
That is the whole shape of it: handlers react to events, aliases package up commands, and statements do the work. Everything else on this page is the vocabulary, the events you can hook, the variables you can read, the commands and functions you can call, and the view DSL for drawing screens.
Events
An on <event> block registers a handler. Event keys are case-insensitive.
| Event | Fires when… |
|---|---|
on LOAD | the script is loaded or reloaded (e.g. on app start, or after you enable/edit it). Use it to set defaults. |
on TEXT | a message arrives in any buffer. The handler can read, rewrite, or suppress the line before it is shown, see the pipeline note below. It doesn't fire for lines you send from this device (use on INPUT); your own lines sent from another client, such as through a bouncer, arrive with $isme true. |
on ACTION | like TEXT, but for /me actions. |
on INPUT | you press send in the input box, before the line is processed. Same rewrite/halt pipeline, applied to what you typed, /commands included ($iscmd is true for those). Lines sent by scripts, buttons and menus don't trigger it. |
on NUMERIC | a server line is about to be printed. $code holds the numeric and $whois is true when the line answers a WHOIS you ran. The same rewrite/halt pipeline applies, so this is how you filter server output such as WHOIS replies. |
on JOIN | someone joins a channel, you included ($isme). $nick, $chan, and $account when the server reports it. |
on PART | someone leaves a channel. $text is their part message. |
on QUIT | someone disconnects from the network. $text is the quit message; $chan is empty, since a quit isn't tied to one channel. |
on KICK | someone is kicked. $nick is who kicked, $victim who was kicked, $text the reason; $isme is true when you were kicked. |
on MODE | someone's status in a channel changes (op, voice, halfop and so on), one event per change. $mode is the change such as +v or -o, $victim whose status changed, $nick who set it; $isme is true when it's your status. |
on OP, DEOP, VOICE, DEVOICE, HELP, DEHELP | the matching status change, alongside on MODE. $victim (also $opnick, $vnick, $hnick) is who changed, $nick who set it. |
on BAN, on UNBAN | a ban is set or removed. $banmask is the mask, $nick who set it. |
on RAWMODE | any channel MODE line, once. $text is the modes with their arguments, such as +bv *!*@host nick. |
on TOPIC | someone changes the topic. $text is the new topic. |
on INVITE | you're invited. $nick invited you to $chan. echo $chan would open that channel's buffer; use echo -a to write to the one you're in. |
on CTCP | a CTCP request (not /me). $text is the command and arguments, as in VERSION or PING 1234. HexDroid still sends its own replies to the ones it knows. |
on CTCPREPLY | a reply to a CTCP you sent. $text is the command and reply. |
on SNOTICE | a server notice, alongside on NOTICE. |
on NOTIFY, on UNOTIFY | someone on your /monitor list comes online or goes offline. $nick is who. |
on APPACTIVE | HexDroid comes to the front or goes to the background. $appactive is true or false. |
on FILERCVD, on FILESENT | a DCC transfer finishes. $filename; for received files also $filesize and $path. |
on NICK | someone changes nick: $nick is the old one, $newnick the new. $isme is true for your own change. |
on NOTICE | a notice arrives, including service replies such as NickServ's. $nick, $text, $isprivate, and $isserver for server notices. A private notice has no $chan, so echo writes to the buffer you're in. Notices from ignored nicks don't reach scripts. |
on CONNECT | you finish connecting (registration completes) to a network. Handy for identifying or joining channels. |
on DISCONNECT | a connection that was up closes. $text is the reason. A failed connection attempt doesn't count. |
on SIGNAL:NAME | a custom signal NAME is raised, by the signal command, a timer, a view button, or another script. |
Capabilities raise signals too: the encrypted transport delivers messages as on SIGNAL:age_msg and dealt secrets as on SIGNAL:age_deal (see Encrypted transport). The same on SIGNAL:… form handles them.
The TEXT pipeline
Inside on TEXT you can change what the user actually sees:
rewrite <new text>, replace the line's text (e.g. to strip or annotate it).halt, stop processing and drop the line entirely.
on TEXT {
if ($contains($text, "spoiler") == true) { halt } ; hide spoilers
rewrite $replace($text, ":wave:", "👋") ; expand a shortcode
}
In on INPUT they act on your own line before it goes anywhere: rewrite changes what is sent, and halt stops it being sent at all. Test $iscmd to leave /commands alone:
on INPUT {
if ($iscmd == true) { return } ; leave /commands alone
if ($contains($text, "hunter2") == true) { ; never send this by accident
echo $chan *** not sent: that looks like your password
halt
}
rewrite $replace($text, ":shrug:", "🤷") ; expand a shortcode before it's sent
}
The same two statements work in on ACTION, on INPUT and on NUMERIC. In a NUMERIC handler, halt hides the line only, the client still acts on it internally, so dropping an end-of-MOTD or an error numeric changes nothing but the display:
on NUMERIC {
if ($whois != true) { return } ; leave the same codes alone outside a WHOIS
if ($code == 344) { halt } ; 344 RPL_WHOISCOUNTRY, don't want it
if ($code == 350) { halt } ; 350 RPL_WHOISISWEBIRC
}
An optional glob filter narrows an event: on TEXT:*help* only runs when the text matches. Channel events (JOIN, PART, KICK, MODE, the status events, BAN/UNBAN, RAWMODE, TOPIC and INVITE) filter on the channel instead, so on JOIN:#help runs only for that channel. Everything else filters on the text, so filter a numeric by testing $code in the body as above.
Headers pasted from mIRC work too: on *:TEXT:*hello*:#:{ is read as <level>:<event>:<match>:<target>:. The level (and prefixes such as @ or !) is ignored, since HexDroid has no user levels. The match text works like a filter and may contain spaces and braces; with a $ in the level it's a regex instead, as in on $*:TEXT:/^!seen (\S+)/i:#:{ or on $*:TEXT:/^\d{3}$/:#:{. The target is # for channels, ? for private messages, * for both, or a comma list such as #help,#dev. on *:SIGNAL:name: and on *:START: work too, as on SIGNAL:name and on LOAD. A handler for an event HexDroid never raises is reported in the script log when the script loads, rather than silently never running.
The IRC events only fire for live traffic, never for history replayed after a reconnect, so a bouncer's backlog doesn't greet everyone again. They report what happened; unlike TEXT, they can't rewrite or halt it:
on CONNECT { msg NickServ IDENTIFY %ns_pass } ; identify as soon as you connect
on JOIN:#help {
if ($isme == true) { return }
notice $nick Welcome to #help, $nick. Ask away, someone will answer.
}
on NOTICE {
if ($nick == NickServ && $contains($text, "You are now identified") == true) { join #private }
}
Variables & arguments
Global variables, %name
Variables prefixed with % hold strings, numbers, lists, or maps, and keep their value between events until the scripts are reloaded. They are shared by every loaded script and by every network: two scripts that both set %ep overwrite each other, and whichever on LOAD runs last wins. Give a script's settings a prefix of their own (%img_ep, %tr_ep).
| Command | Effect |
|---|---|
set %x <value> | assign (omit the value to clear it) |
set -l %x <value> | assign a local that lives only for the current handler or alias call and hides a global of the same name. Use it for scratch values. |
unset %x | remove the variable |
inc %x/dec %x | increment/decrement a numeric variable |
push %list <value> | append to a list |
setat %coll <key> <value> | set a key/index inside a map or list (nestable) |
Built-in fields, $field
Inside a handler, these read-only fields describe the event:
| Field | Value |
|---|---|
$me | your current nick |
$nick | the sender of the message/event |
$chan/$buffer/$target | the buffer the event belongs to (channel or query) |
$network | the network id |
$text | the message text (in on TEXT) |
$isme | true when the event is about you: your own line (from another client, in on TEXT), your own join, part, quit or nick change, or your being kicked |
$isprivate | true for a private message or notice, false in a channel |
$iscmd | true in on INPUT when the line is a /command |
$victim, $mode, $newnick, $account, $isserver | in on KICK, on MODE, on NICK, on JOIN and on NOTICE, see Events |
$ctime | the current Unix time, in seconds |
$ticks | milliseconds from a steady clock, for measuring how long something took. Only differences between two readings mean anything. |
$time | the local time as HH:mm:ss |
$screen.landscape, $screen.width, $screen.height, $screen.tv | the display, for choosing a view layout: 1/0 for landscape and TV, width and height in dp |
$code | the three digit numeric (in on NUMERIC), empty for server text that carries none |
$whois | true when the line (in on NUMERIC) answers a WHOIS you ran |
Positional arguments, $1, $2-
When you call an alias, or raise a signal with extra words, or attach args to a view button, those words bind to numbered arguments:
| Token | Meaning |
|---|---|
$1, $2, … | the first, second, … argument |
$2- | every argument from the second onward, space-joined |
$1- | all arguments, space-joined |
Expansion is recursive: %vars, $fields, and $func(…) calls can all be nested inside each other.
alias greet {
set -l %who $1 ; first argument, local to this call
msg $chan Hello %who, and also $2- ; mix args, vars, and rest
}
Commands
A statement is a command followed by arguments. User aliases are commands too, and a fixed set of IRC commands can be run as well (see below).
| Command | Description | Example |
|---|---|---|
echo | print a local line to a buffer (not sent to the server). The first word is the buffer: -a means the one you're in, -s the server buffer, and a variable that's empty (like $chan in on QUIT) also means the one you're in. | echo $chan loaded ok |
msg | send a message to a target. If the target is a variable that's empty, nothing is sent. | msg #room hello all |
raw | send a raw IRC line to the server | raw WHO #room |
rewrite | (in on TEXT, ACTION, INPUT or NUMERIC) replace the line | rewrite $upper($text) |
set/unset/inc/dec/push/setat | variable operations (see above) | setat %score $nick 0 |
signal | raise SIGNAL:NAME with optional args | signal refresh $chan |
timer | raise a signal after a delay (ms, at least 20), the key to non-blocking work | timer 500 refresh |
http.get/http.post | make a web request, deliver the result to a signal (see HTTP) | http.get $url done $chan |
media.pick | ask the user to choose a file, answer on a signal (see Files) | media.pick -m image/* got $chan |
media.upload | upload a picked file to a URL | media.upload $url $mediatoken done $chan |
view { … } | build and mount an interactive screen (see Views) | view { text "Hi" bold } |
toast | show a brief on-screen notice | toast Saved |
halt/return | stop the current handler (return may yield a value) | halt |
<namespace>.<method> | call a host capability, e.g. the age.* transport | age.send $chan move 5 |
me, join, part, cycle, topic, mode, invite, kick, knock, names, who, whois, whowas, list, notice, ctcp, nick, away, back, op, deop, voice, devoice, ban, unban, ignore and unignore. Commands that would drop or move your connection, such as quit or server, are left out on purpose; raw remains for a script that really means it. Any other unknown verb is reported in the buffer and skipped.Value functions
Functions are written $name(arg, arg, …) and return a value you can use anywhere a value is expected.
Strings & math
| Function | Returns |
|---|---|
$len(s) | length of a string |
$lower(s)/$upper(s) | case conversion |
$left(s,n)/$right(s,n) | first/last n characters. A negative n counts from the other end, as in mIRC: $left(s,-2) is all but the last two characters, $right(s,-2) all but the first two |
$substr(s,start,len) | substring |
$replace(s,find,with) | replace all occurrences |
$trim(s) | strip surrounding whitespace |
$contains(s,sub)/$indexof(s,sub) | membership test/position |
$repeat(s,n) | repeat a string |
$calc(expr) | evaluate an arithmetic expression |
$mod(a,b)/$idiv(a,b)/$int(n)/$abs(n) | modulo/floor division/floor/absolute value |
$round(n,dp)/$ceil(n)/$pow(a,b)/$clamp(n,lo,hi) | rounding, powers and bounds |
$rand(n)/$rand(lo,hi) | a random integer from 0 to n-1, or from lo to hi inclusive. Fine for games; for anything that must be unguessable, use $age.rand |
$min(…)/$max(…) | smallest/largest of the arguments |
$pad(s,n)/$padleft(s,n) | pad to width n on the right/left |
$capitalize(s)/$title(s) | upper-case the first letter/every word's first letter |
$re_match(s,re)/$re_find(s,re)/$re_group(s,re,n)/$re_replace(s,re,with) | regular expressions: test, first match, a capture group, replace all |
$urlencode(s) | percent-encode for a URL or form body |
$json(body,path) | a value from JSON text by dotted path, e.g. results.0.url |
$setting(key) | read a host setting (e.g. $setting(applang)) |
Lists & maps
| Function | Returns |
|---|---|
$list(a,b,…) | build a list |
$map(k,v,k,v,…) | build a map from key/value pairs |
$get(coll,key) | element by index, or value by key |
$has(coll,key) | true if the key/value is present |
$keys(map)/$values(map) | the keys/values of a map |
$len(coll) | element count |
$split(s,sep)/$join(list,sep) | string ↔ list |
$sort(list)/$reverse(list) | sorted/reversed copy |
$slice(list,from,to) | sub-list |
$concat(a,b,…) | join lists end to end |
$find(list,x)/$count(list,x) | index of/number of occurrences |
$sum(list) | numeric total |
$range(lo,hi) | a list of integers from lo to hi |
$pick(list)/$shuffle(list) | a random element/a shuffled copy |
$tojson(value) | serialise a list, map or scalar to JSON, ready for http.post -b. A string JSON reads as a number, such as 12 or -0.5, is written unquoted; 007 and 1e5 stay strings. Control characters, IRC colour and bold codes included, are escaped |
Control flow
Conditions live in parentheses and combine with && and ||. Comparisons support ==, !=, <, >, <=, >=, plus isin (substring) and iswm (wildcard match).
if ($nick == ChanServ) {
; ignore services
} elseif (%warned > 3 && $isme == false) {
msg $chan settle down
} else {
inc %warned
}
foreach %p $keys(%score) {
echo $chan %p has $get(%score, %p) points
}
while ($len(%queue) > 0) {
; ... process and shrink %queue ...
}
foreach <item> <collection> walks a list or a map's keys. halt stops the whole handler; return exits the current alias (optionally with a value).
HTTP & timers
Web requests are asynchronous: you name a signal to receive the result, plus any context words you want passed along.
Reloading scripts cancels everything still pending: a timer, request or file pick started before the reload never answers afterwards, so an edited script never receives its previous copy's callbacks.
; http.get [flags] <url> <signal> [context...] ; http.post [flags] <url> <body> <signal> [context...] http.post https://libretranslate.example/translate q=$urlencode($text)&source=auto&target=en done $chan
Flags go before the URL:
| Flag | Effect |
|---|---|
-t <type> | set Content-Type instead of letting the body's shape decide |
-h <name:value> | add a request header; repeat for more than one |
-b %var | take the body whole from a variable, spaces and quotes included |
Flag values are read before argument expansion, so a %var in one arrives as that variable's whole value rather than the words the argument splitter would otherwise break it into. That is what makes -b the way to send JSON: the positional body is a single space-delimited word, so anything containing a space has to come through -b. A header value with spaces in it (a bearer token, say) goes the same way.
set %auth Authorization: Bearer $2 set %payload $tojson($map(text, $1-, lang, irc)) http.post -t application/json -h %auth -b %payload https://paste.example/api done $chan
Without -t, the content type is inferred from the body: one starting { or [ is sent as JSON, one shaped like key=value&key=value is sent form-urlencoded, and anything else goes as text/plain.
In the receiving handler, the response is available through these fields, and $json(body, path) pulls a value out of a JSON reply:
| Field/function | Value |
|---|---|
$httpok | true if the request succeeded |
$httpstatus | HTTP status code |
$httpbody | the raw response body, cut off after about 1 MB |
$httplocation | the Location header, or empty. Upload endpoints answering 201 Created put the new URL here rather than in the body |
$httperror | why the request never got a status when $httpstatus is 0: blocked by policy, unreachable, a file too large, and so on |
$json(body, path) | a field from a JSON body by dotted path: translatedText, detectedLanguage.language, results.0.filePath |
on SIGNAL:done {
if ($httpok == true) {
echo $1 ↳ $json($httpbody, translatedText) ; $1 = the buffer we passed as context
}
else { echo $1 *** request failed ($httpstatus) $httperror }
}
timer for anything repetitive or heavy. Every handler, including timer and HTTP callbacks, runs on the UI thread, one at a time, so don't loop a screen-repaint synchronously. Schedule the next step with timer <ms> <signal> instead, each tick yields, the UI stays responsive, and you avoid the "app not responding" trap.Picking & uploading files
A script has no filesystem access and cannot name a path. The only way it sees a file is if you hand it one:
; media.pick [-m <mime>] <signal> [context...] ; media.upload [-h <name:value>] [-p <name=value>] [-f <field>] [-r] <url> <token> <signal> [context...] media.pick -m image/* picked $chan
media.pick does not open anything by itself. HexDroid shows a prompt naming the channel and network the script is running in, and only if you agree does the system file picker appear. The script is then handed a token for that one file, never a path, and can reach nothing else on the device. The pick's signal goes only to the script that asked, and only that script can upload with the token. Tokens last until scripts are reloaded.
| Field | Value |
|---|---|
$mediaok | true if a file was chosen, false if you declined |
$mediatoken | the opaque handle to pass to media.upload |
$medianame | file name as shown to you |
$mediamime | MIME type |
$mediasize | size in bytes, or -1 when the provider doesn't say |
media.upload sends the file to the URL without the bytes ever passing through the script. It POSTs multipart/form-data under field name file by default; -f <field> renames the field, -p <name=value> adds a text form field beside the file (repeatable, for an endpoint's own options), and -r sends the raw bytes as the request body instead, which is what a soju-style filehost wants. The body is prepared first so the request carries an exact Content-Length, which servers that read uploads by length (PHP among them) need. Files over 64 MB are refused. The reply carries the same fields an http.post does, $httplocation and $httperror included.
alias img { media.pick -m image/* picked $chan }
on SIGNAL:picked {
if ($mediaok != true) { return }
toast Uploading $medianame
media.upload https://paste.example/img $mediatoken done $1
}
on SIGNAL:done {
if ($httpok != true) { echo $1 upload failed ($httpstatus) $httperror | return }
set -l %url $trim($httplocation)
if ($len(%url) == 0) { set -l %url $trim($httpbody) }
msg $1 %url
}
Redirects are not followed, so an endpoint answering with a 301 to a trailing slash or to an index.php reports that 301 rather than uploading. The error names where it wanted to send you, and that address is the one to use.
Check the body even on a 2xx: many upload endpoints answer 200 with JSON that describes a failure, so $httpok alone does not mean the file was stored.
Note there is no empty-string literal in .hex: %url == "" compares against a two-character string and is never true for a blank value. Test emptiness with $len(%url) == 0.
$mediaok false rather than stacking dialogs, so a looping script cannot bury the app under choosers. After you decline, that script's further picks are refused without a prompt for a minute, unless you started them with a command. Redirects are not followed for an upload: a 3xx is reported instead of resending the file to a host the permission check never saw.Interactive views
A view { … } block describes a screen using a small layout DSL, then mounts it. Rebuild and re-mount it whenever your state changes to "redraw". Buttons report taps back as signals, so a view plus a few on SIGNAL handlers is a complete little app.
Elements
| Element | What it is |
|---|---|
column { … }/row { … } | vertical/horizontal stacks of children |
stack { … }/ring { … } | overlay children/arrange them in a circle |
surface { … } | a panel (background, padding, elevation, gradient) |
text "…" | a label |
button "label" <actionId> [args] | a tappable button. It runs the alias named <actionId> if there is one, otherwise raises on SIGNAL:<actionId> |
card "Ah" [red] [back] | a playing card (rank+suit, optionally face-down) |
image "<url>" | a remote image |
spacer | flexible empty space |
Modifiers
Any element takes trailing modifiers: bold, fill, wrap (on a row, flow children onto extra lines instead of overflowing), circle, color <hex>, bg <hex>, bgimage <url>, gradient linear:<a>:<b>, textsize <sp>, width/height/size/radius <dp>, pad/gap <dp>, weight <n>, align <where>, border <hex> [w], offsetx/offsety <dp>, and elevation <dp>. On an image, the scale mode is crop (the default), fit, or stretch.
alias counter_render {
view {
surface bg #13243f radius 20 pad 16 {
column gap 12 align center {
text Counter bold color #ffffff textsize 18
text %n color #8fd0ff textsize 40
row gap 10 {
button "-1" dec weight 1
button "+1" inc weight 1
}
button "Reset" reset fill
}
}
}
}
on LOAD { set %n 0 }
on SIGNAL:inc { inc %n | counter_render }
on SIGNAL:dec { dec %n | counter_render }
on SIGNAL:reset { set %n 0 | counter_render }
SIGNAL:view_closed. Handle it to stop any timers or loops your view was driving, so a pending tick can't re-open it.Encrypted transport (advanced)
Scripts that need to talk to other players or peers can use the age.* capability, the same Ed25519 + X25519 machinery behind +AGE. It provides identity (age.me), randomness and hashing (age.rand, age.sha), sending over a keyed channel (age.send), local loopback for solo/practice play (age.local), and sealing a secret to one recipient (age.seal). Inbound traffic arrives as on SIGNAL:age_msg (and dealt secrets as on SIGNAL:age_deal).
For running a table: age.host keys a channel as its host, $age.ready(chan) says whether it's keyed yet, $age.owner(chan) is the elected host (the lowest fingerprint present, the same on every client), and $age.members(chan) lists the other +AGE members present. age.close and age.purge tear a table down. The transport also raises on SIGNAL:age_peer when someone announces themselves on a channel, on SIGNAL:age_ready when a channel becomes keyed, and on SIGNAL:age_left when a member leaves by part, quit or kick ($from is their fingerprint, or yours when you leave or lose the connection).
The complete on-the-wire format, encoding, key derivation, sealed invites, the signed channel layer, and the 1:1 handshake and double ratchet, is published in the age-wire-format specification, so other clients can interoperate. For message-level chat encryption you do not need scripting at all, see the Encryption guide.
Worked examples
1 · A dice roller
Adds /roll (and /roll 20 for a d20).
alias roll {
set -l %sides $1
if ($len(%sides) == 0) { set -l %sides 6 } ; default d6
set -l %r $rand(1, %sides) ; 1 to %sides inclusive
me rolls a %sides-sided die: %r
}
2 · Keyword highlighter
Locally flags lines that mention a watch-word, without touching the server.
on LOAD { set %kw_watch deploy }
on TEXT {
if ($isme == false && $contains($lower($text), %kw_watch) == true) {
echo $chan ⚠ watch-word from $nick
}
}
3 · Auto-translate incoming lines
The pattern the bundled translate.hex uses: post each foreign line to a translation endpoint and echo the result underneath. Set your own endpoint and key at the top.
on LOAD {
set %tr_lang $setting(applang)
if ($len(%tr_lang) == 0) { set %tr_lang en }
set %tr_ep https://libretranslate.example/translate ; your endpoint
set %tr_key ; API key (blank = none)
}
on TEXT {
if ($isme == false) {
if ($len(%tr_key) > 0) { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang&api_key=%tr_key tr $chan $text }
else { http.post %tr_ep q=$urlencode($text)&source=auto&target=%tr_lang tr $chan $text }
}
}
on SIGNAL:tr {
if ($httpok == true) {
set -l %out $json($httpbody, translatedText)
if (%out != $2-) { echo $1 ↳ %out } ; $1 = buffer, $2- = original text
}
else { echo $1 *** translate failed ($httpstatus) $httperror }
}
4 · Filter WHOIS replies
The pattern the bundled whoisfilter.hex uses: drop the numerics you never read. Editing one list is easier than remembering which code is which, so keep the comment next to it. The $whois test keeps the filter to WHOIS output, so a 301 away reply to a message you sent still shows.
on LOAD {
set %wf_hide $list(310, 337, 339, 344, 350, 378, 379)
}
on NUMERIC {
if ($whois != true) { return }
if ($has(%wf_hide, $code) == true) { halt }
}
5 · Upload an image and paste the URL
The shape of the bundled imgpaste.hex. /img asks you for a file, uploads it, and sends the returned URL to the channel. It targets an endpoint that reads the file from an images field, strips photo metadata when strip_exif is set, and answers 200 with JSON either way, so the body decides success.
on LOAD {
set %img_ep https://paste.example/img/index.php ; exact URL, file name included
set %img_base https://paste.example ; the returned path hangs off this
}
alias img { media.pick -m image/* img_picked $chan }
on SIGNAL:img_picked {
if ($mediaok != true) { return }
toast Uploading $medianame
media.upload -f images -p strip_exif=1 %img_ep $mediatoken img_done $1
}
on SIGNAL:img_done {
if ($httpok != true) { echo $1 *** upload failed ($httpstatus) $httperror | return }
set -l %err $json($httpbody, results.0.error)
if ($len(%err) > 0) { echo $1 *** upload rejected: %err | return }
set -l %path $json($httpbody, results.0.filePath)
if ($len(%path) == 0) { echo $1 *** no path in the reply | return }
msg $1 %img_base%path
}
6 · A scoreboard with a view
Tracks points per nick (/point nick) and shows a live board.
on LOAD { set %sb_score $map() }
alias point {
if ($has(%sb_score, $1) == false) { setat %sb_score $1 0 }
setat %sb_score $1 $calc($get(%sb_score, $1) + 1)
board_render
}
alias board_render {
view {
surface bg #161b22 radius 16 pad 16 {
column gap 8 {
text Scoreboard bold textsize 18 color #ffffff
foreach %p $sort($keys(%sb_score)) {
row gap 8 { text %p weight 1 | text $get(%sb_score, %p) bold color #58a6ff }
}
button "Clear" clear fill
}
}
}
}
on SIGNAL:clear { set %sb_score $map() | board_render }
Installing & editing
- Enable/disable, bundled scripts ship disabled; flip the toggle to opt in. A disabled script never runs and never registers its commands or launchers.
- Import/Paste, add your own
.hexfrom a file or the clipboard. - Edit, opens a full-screen editor with line numbers. Save & reload writes it back and reloads immediately, so changes (like an endpoint or API key) take effect at once.
- Revert, bundled scripts can be restored to their shipped default if an edit goes wrong.
- Remove, delete a script you added.
set %… line near the top, and Save & reload. There is no separate settings field, the script is the configuration.From here, browse the command reference for everything a script can drive, or the encryption guide for message-level E2EE that needs no scripting at all.