-
-
Notifications
You must be signed in to change notification settings - Fork 7
Scripting
AndroidIRCX includes a built-in JavaScript scripting engine.
This page follows the in-app Scripting Help content and documents hooks, API, examples, and usage tips.
Scripts are plain JavaScript modules. Export hooks to react to events:
module.exports = {
onConnect: (networkId) => { /* ... */ },
onDisconnect: (networkId, reason) => { /* ... */ },
onMessage: (msg) => { /* msg.channel, msg.from, msg.text */ },
onNotice: (msg) => { /* notice messages */ },
onJoin: (channel, nick, msg) => { /* ... */ },
onPart: (channel, nick, reason, msg) => { /* ... */ },
onQuit: (nick, reason, msg) => { /* ... */ },
onNickChange: (oldNick, newNick, msg) => { /* ... */ },
onKick: (channel, kickedNick, kickerNick, reason, msg) => { /* ... */ },
onMode: (channel, setterNick, mode, target, msg) => { /* ... */ },
onTopic: (channel, topic, setterNick, msg) => { /* ... */ },
onInvite: (channel, inviterNick, msg) => { /* ... */ },
onCTCP: (type, from, text, msg) => { /* ... */ },
onAction: (target, nick, text, msg) => { /* /me actions */ },
onHighlight: (msg) => { /* your nick / a highlight word was matched */ },
onRaw: (line, direction, msg) => { /* return modified line or { cancel: true } */ },
onCommand: (text, ctx) => { /* return newText or { cancel: true } */ },
onTimer: (name) => { /* timer fired */ },
};-
api.log(text)- log to script log buffer api.sendMessage(channel, text, networkId?)api.sendCommand(command, networkId?)-
api.echo(target, text, networkId?)- print a line into a tab, locally api.sendNotice(target, text, networkId?)api.sendCTCP(target, type, params?, networkId?)-
api.getChannelUsers(channel, networkId?)- returnsstring[] -
api.getChannels(networkId?)- returnsstring[] -
api.setTimer(name, delayMs, repeat?)- set timer -
api.clearTimer(name)- clear timer -
api.getNetworkId()- current network ID -
api.isConnected(networkId?)- check connection -
api.userNick- current nick -
api.getConfig()- script config JSON
module.exports = {
onRaw: (line, direction) => {
if (direction === 'in' && / 352 /.test(line)) api.log(line);
},
};onRaw sees every raw line in both directions, after it has been written
or read. Anything it returns is ignored.
That is deliberate. A script able to swallow raw protocol would only have to
drop a PONG or a CAP END to hang its own connection, with nothing to show
why. To stop something going out, use onCommand, which runs before the line
is built.
const theme = api.getTheme(); // { name, isDark, colors }
const warn = theme.isDark ? 8 : 4;
api.echo(chan, api.colour('careful', warn));getTheme gives the colours, not just light-or-dark. Read it before
choosing your own: a script with a hardcoded palette clashes with every theme
the user might be on.
await api.setStorage('seen:' + nick, Date.now());
const keys = await api.listStorage('seen:'); // ['seen:alice', 'seen:bob']
for (const key of keys) {
const when = await api.getStorage(key);
}
await api.clearStorage('seen:'); // returns how many wentlistStorage is the one that makes the rest usable. Without it you can write
a value per nick but never count, iterate or clean them up — which used to
mean keeping a second key holding an index of the first and keeping the two in
step by hand. It is mIRC's $hget(table, N).item.
Each script sees only its own keys.
if (await api.confirm('Kick everyone who is idle?')) { ... }
const pick = await api.ask('Which channel?', ['#chat', '#dev', '#help']);confirm resolves false when dismissed and ask resolves null, so
neither can leave a script waiting forever.
Buttons rather than a text box: Android's alert has no text field, and a free-text modal is a screen rather than an API. Three choices is what the dialog holds.
api.sendMessage(chan, api.bold('careful') + ' ' + api.colour('red', 4));
if (api.strip(msg.text).includes('help')) { ... }bold, italic, underline, colour(text, fg, bg?) and strip.
strip is the one to remember: the moment you want to match on what somebody
said, their colour codes are in the way.
module.exports = {
onUnload: () => api.log('goodbye'),
};Runs when the script is switched off or replaced. Commands, menu items and timers are cleared for you either way — this is for what only your script knows about. It must return synchronously; nothing waits for a promise from a script on its way out, and a hook that throws does not stop it being disabled.
const both = api.getSharedChannels(nick);mIRC's $comchan. Doing it by hand means calling getChannelUsers for every
channel and intersecting, which gets slower with every channel you join.
const mask = await api.banMask(nick); // *!*@some.host
api.mode(channel, '+b ' + mask);mIRC's $mask(). Building one by hand means string surgery on hostmasks and
getting IP addresses subtly wrong — this uses the same code as the app's own
ban dialog, including replacing the last octet of an IPv4 host.
Pass a type to override the default: api.getBanTypes() returns the same set
the app offers. It resolves null when nothing is known about the nick yet,
because a WHOIS has to have happened for there to be a host to build from, and
guessing would ban the wrong people.
if (!api.isFavorite(channel)) await api.addFavorite(channel);
await api.setAutoJoin(channel, true);getFavorites, isFavorite, addFavorite, removeFavorite,
getAutoJoinChannels, setAutoJoin.
const found = await api.getChannelList('linux');Reads the list this client already has and never runs /LIST. That is
deliberate: /LIST on a large network is thousands of lines and some servers
throttle or disconnect over it, so a script cannot set one off just by asking
a question. Use the channel browser to fetch one.
An empty array means "I do not have a list", not "there are no channels".
await api.react(msg.msgid, '👍'); // toggles
api.getReactions(msg.msgid);
api.isAnyAway();
api.getUserActivity(nick); // undefined if nothing recorded
await api.getSpamLog(20); // newest last, cappedReactions need a message id, which hooks give you as msg.msgid. The flood
log is capped rather than handed over whole — it is a file that grows for as
long as the app has been used.
Three different things, and picking the wrong one is the usual mistake.
| What it does | Who sees it | |
|---|---|---|
api.echo(target, text) |
Puts a line in that tab | Only you |
api.sendNotice(target, text) |
Sends a real IRC NOTICE | Whoever you addressed |
api.log(text) |
Writes to the script log | Only you, in Settings |
Prefer echo. It is mIRC's /echo: nothing is sent, nothing is rate
limited, and no server sees it. sendNotice is real traffic that goes out and
comes back, some networks throttle it, and a few echo it to other people.
Use log for things you only want while debugging.
api.notify('Someone mentioned you', msg.text); // system notification
api.copyToClipboard(text);
api.setInput('how about this instead?'); // puts it in the boxapi.notify is capped at one a second, so a hook that fires on every channel
line cannot bury your notification shade.
api.setInput does not send. It puts text in the composer for you to read,
change and send yourself - which is the whole point when a model wrote it.
api.aiStatus(channel, 'working', { text: 'summarising', networkId });
// ... later
api.aiStatus(channel, 'failed', { text: 'could not reach the provider',
retry: '/summarize 50', networkId });
api.aiStatus(channel, 'done', { networkId });Drives the strip above the composer - the same place the typing indicator
uses. retry becomes a Retry button that runs that command again.
Worth doing for anything slow. A script that thinks for ten seconds and says nothing is indistinguishable from one that is broken.
const text = await api.http('https://github.com/AndroidIRCx/AndroidIRCx/wiki');Returns the page as text, or null. It uses the same allowlist as the
assistant - Settings > AI > Sites the assistant may read - so there is one
list of sites to reason about rather than two. Private addresses are refused
whatever the list says.
A refusal is written to the script log with the reason.
-
api.registerCommand(name, (args, ctx) => {})- register a user/command. The user typing/name a b ccalls the handler withargs = ['a','b','c']andctx = { channel?, networkId?, nick? }. Returnnull/undefinedto consume the command, a string to replace it, or{ cancel: true }. -
api.addMenuItem({ menu, label, onSelect })- add a context-menu entry.menuis'nick','channel'or'tab'(default'nick');onSelectreceives(target, ctx)wheretargetis the nick/channel/tab tapped andctx = { channel?, networkId?, nick? }.
Call
registerCommandandaddMenuItemat load time (top level of the script), not inside a hook. Commands and menu items are cleared automatically when a script is disabled, removed or recompiled.
Sugar over sendCommand, each takes an optional trailing networkId:
-
api.join(channel)/api.part(channel, reason?) api.kick(channel, nick, reason?)api.mode(target, modes)-
api.op(channel, nick)/api.deop(channel, nick) -
api.voice(channel, nick)/api.devoice(channel, nick) -
api.ban(channel, mask)/api.unban(channel, mask) api.setTopic(channel, topic)api.changeNick(newNick)-
api.setAway(reason?)/api.back() api.whois(nick)-
api.action(target, text)- send a/meaction
-
api.rand(min, max)- random integer in[min, max]. -
api.list(name)- a persistent, per-script named text list (a$readequivalent). Returns{ add(line), all(), random(), clear() }. All methods are async — remember toawaitthem.
-
api.playSound(name)- play a sound.nameis either a built-in event - one of:mention,private_message,join,kick,notice,notify,ctcp,disconnect,login,send,fail,ring,flood,op,deop- or the name of a custom sound you added in Settings → Sounds → Custom Sounds. Respects your sound settings (muted stays muted) and is rate-limited to 1 per second. -
api.openLink(url)- open an externalhttp/httpslink. It always asks you to confirm (showing the script name and URL) before opening, rejects non-http(s) schemes, and is rate-limited to 1 every 3 seconds.
playSound deliberately does not take a file path (a script must not be able to
read arbitrary files). Instead you pick the file once, safely, through the app,
and the script references it by name. There are two ways:
Preferred — named custom sound:
- Open Settings → Sounds → Custom Sounds.
- Add a sound, give it a name (e.g.
tada) and pick a file from your phone. - In your script, call
api.playSound('tada'). It plays your custom file.
Alternative — event slot:
- Open Settings → Sounds.
- Pick an event slot (e.g. Mention), and choose a sound file from your
phone for it. (Slots you don't otherwise use — e.g.
ringornotify— make good "custom" slots.) - In your script, call
api.playSoundwith that event's name, e.g.api.playSound('mention'). It plays the custom file you assigned.
-
api.searchHistory(filter)- search stored messages. -
api.getRecentMessages(channel, limit?, networkId?)- the last messages of a channel, oldest first (default 50, maximum 200). Use this for "what was just said";searchHistoryis for finding something specific.
Set a provider up first in Settings → AI → AI Providers. You need your own API key, or a model server on your own network — see AI. A Claude Pro or ChatGPT Plus subscription does not work.
-
await api.ai.ask(prompt, options?)- ask the model one question. Resolves to the answer text, ornullif the call could not be made. It never throws, so a hook without atry/catchcannot break; the reason is written to the script log instead. -
await api.ai.chat(messages, options?)- multi-turn version.messagesis[{ role: 'user' | 'assistant' | 'system', content }]. -
await api.ai.isAvailable()- check before asking. -
await api.ai.listProviders()- returns[{ id, name, model }]. A script can never read an API key.
options: { provider, maxTokens, system, channel, network }.
Naming channel matters. It tells the app the text came from that channel, and
the call is refused unless the user enabled AI for it. Leave it out only when
the prompt carries nothing but the user's own words.
module.exports = {
onConnect: () => {
api.registerCommand('explain', async (args, ctx) => {
const question = args.join(' ');
if (!question) return;
const answer = await api.ai.ask(question, {
system: 'Answer in one short line, plain text.',
maxTokens: 200,
});
if (!answer) return; // the log says why
api.sendMessage(ctx.channel, answer.substring(0, 400), ctx.networkId);
});
},
};One call every 5 seconds, 100 per day, 2 at a time, and 8000 characters per prompt. Hitting one writes a warning to the script log rather than failing loudly — these exist so a script with a bug cannot flood a channel or spend your provider credit.
-
onRawandonCommandmust return immediately. They cannotawaitan AI answer. Return first, then send the answer withapi.sendMessagewhen it arrives. -
Never pass an AI answer to
api.sendCommand. Channel text reaches the prompt, so anyone present can try to steer the reply; an injected "reply with /kick someone" would become a real kick. Send AI text as a message or a notice. -
A script that answers inside
onMessagemust ignore its own output — checkmsg.fromagainstapi.userNick, or tag the text it sends. Two such scripts in one channel will otherwise answer each other forever.
In the script editor, Generate with AI turns a description into code:
greet people who join #chat, but only once per nick per day
The result is shown for you to review, with its lint result, and is never saved or enabled by itself. The button only appears once you have an AI provider configured.
The reference the model gets is generated from the app's own hook and API lists, so it cannot invent a method that does not exist.
-
onConnect(networkId)- when connected -
onDisconnect(networkId, reason?)- when disconnected
-
onMessage(msg)- channel/query messages -
onNotice(msg)- notice messages -
onCTCP(type, from, text, msg)- CTCP requests -
onAction(target, nick, text, msg)-/meactions -
onHighlight(msg)- your nick or a highlight word was matched
-
onJoin(channel, nick, msg)- user joined -
onPart(channel, nick, reason, msg)- user parted -
onQuit(nick, reason, msg)- user quit -
onNickChange(oldNick, newNick, msg)- nick changed -
onKick(channel, kickedNick, kickerNick, reason, msg)- user kicked -
onMode(channel, setterNick, mode, target?, msg)- mode changed -
onTopic(channel, topic, setterNick, msg)- topic changed -
onInvite(channel, inviterNick, msg)- channel invite
-
onRaw(line, direction, msg)- every wire line, in and out. Observes only -
onCommand(text, ctx)- outgoing command -
onTimer(name)- timer fired
module.exports = {
onJoin: (channel, nick, msg) => {
if (nick === api.userNick) return;
api.sendCommand('MODE ' + channel + ' +o ' + nick);
},
};module.exports = {
onJoin: (channel, nick) => {
api.sendMessage(channel, 'Welcome, ' + nick + '!');
},
};module.exports = {
onCommand: (text) => {
if (text.startsWith('/hello')) return '/say Hello there!';
return text;
},
};module.exports = {
onCTCP: (type, from, text) => {
if (type === 'VERSION') {
api.sendCTCP(from, 'VERSION', 'AndroidIRCX');
}
},
};module.exports = {
onConnect: () => {
api.setTimer('periodic', 60000, true); // every 60s
},
onTimer: (name) => {
if (name === 'periodic') {
api.log('Timer fired!');
}
},
onDisconnect: () => {
api.clearTimer('periodic');
},
};module.exports = {
onKick: (channel, kickedNick, kickerNick, reason) => {
if (kickedNick === api.userNick) {
api.sendCommand('JOIN ' + channel);
}
},
};// Register at load time (top level), not inside a hook.
api.registerCommand('opall', (args, ctx) => {
const channel = ctx.channel;
if (!channel) return;
for (const nick of api.getChannelUsers(channel)) {
if (nick !== api.userNick) api.op(channel, nick);
}
});
module.exports = {};api.addMenuItem({
menu: 'nick',
label: 'Slap',
onSelect: (target, ctx) => {
if (ctx.channel) {
api.action(ctx.channel, 'slaps ' + target + ' with a large trout');
}
},
});
module.exports = {};module.exports = {
onJoin: async (channel, nick) => {
if (nick === api.userNick) return;
const line = await api.list('greetings').random();
if (line) api.sendMessage(channel, line.replace('$nick', nick));
},
};A complete walkthrough for playing a custom sound from your phone.
Step 1 — add the sound (once, in the app):
- Open Settings → Sounds → Custom Sounds.
- Tap Add custom sound, pick the audio file from your phone.
- Give it a name, e.g.
tada, and save.
Step 2 — use it in a script:
module.exports = {
// Play "tada" whenever you are highlighted (your nick / a highlight word).
onHighlight: msg => {
api.playSound('tada');
},
// Play it when someone joins a channel you're in.
onJoin: (channel, nick) => {
if (nick !== api.userNick) api.playSound('tada');
},
// Play it for your own custom /command.
onConnect: () => {
api.registerCommand('tada', () => {
api.playSound('tada');
});
},
};Notes:
- The name is matched case-insensitively, so
api.playSound('Tada')also works. -
playSoundrespects your sound settings — if sounds are muted, nothing plays. - It is rate-limited to once per second, so it can't spam audio on a busy channel.
- You can also pass a built-in event name instead of a custom one, e.g.
api.playSound('mention')(see the list under Sound and Links).
module.exports = {
onHighlight: msg => {
api.playSound('tada');
// Optional: offer to open a link (you'll be asked to confirm first).
// api.openLink('https://example.com/');
},
};Five examples ship with the app, all disabled until you enable them. They need an AI provider configured first.
| Script | What it does |
|---|---|
AI: /ai command |
/ai <question> — answers in the channel
|
AI: /summarize catch-up |
/summarize [count] — summarizes the channel, sends it to you as a notice |
AI: translate a channel |
/tr on|off per channel; translations arrive as notices |
AI: suggest a reply on highlight |
Drafts a reply when you are mentioned. Only suggests — never sends |
AI: moderation assist |
Flags possibly abusive messages to you privately. Never kicks or bans |
Only /ai speaks in the channel. The rest answer you privately, which is both
the right default for personal tools and the reason they cannot hear their own
output and loop.
Read them in the script repository — they are written to be copied.
- Scripts are disabled by default; enable each one.
- Use Lint to catch syntax errors.
- Enable logging to see script output/errors in the log tab.
-
onCommandcan cancel send by returning{ cancel: true }. -
onRawcan modify or cancel raw IRC lines. - Use timers for periodic tasks; clear them on disconnect.
- Check
api.isConnected()before sending commands. - Call
registerCommand/addMenuItemat load time (top level), then export your hooks; both are cleared automatically on disable/remove/recompile. -
api.list(...)methods are async — alwaysawaitthem. - Prefer the action helpers (
api.op,api.kick, …) over hand-builtsendCommandstrings for readability.
- Open
Settings. - Go to
Scripting & Ads. - Open
Scripts (Scripting Time & No-Ads). - Add/edit scripts and enable them.
- Use
Script Logsto inspect output and errors.
Asking the server who someone is costs a round trip and, done per message, gets you throttled. The app already keeps what it has seen, so ask it instead.
const who = api.users.get('fred');
who.host; // as last observed
who.account; // null means the server said "logged out"
who.channels; // channels you share
who.provenance; // where each fact came from: whois beats a NAMES lineapi.users.find(mask, filters) searches by hostmask, api.users.onChannel()
lists a channel with each member's modes, and api.users.matchesMask() does
IRC wildcards so you do not have to.
Match on the account, not the nick. A nick is free to take the moment its owner disconnects. This is why the built-in Auto-Op keys on the account the network verified, and why an auto-op keyed on a nick hands operator status to whoever gets there first.
api.channelState.get() gives you a channel's topic, who set it and when, and
its modes. api.channelState.getList(channel, 'ban') gives the cached ban,
exception, invite or quiet list — and status is unknown until it has been
fetched, which is not the same as the list being empty.
api.server.get() and api.server.token() expose what the server said about
itself, including ISUPPORT tokens this app has never heard of, so a script on a
new network does not have to wait for an app release.
const seen = api.store.table('seen');
await seen.set('fred', Date.now(), 7 * 24 * 3600_000); // with a TTL
await seen.increment('messages'); // atomic
await seen.compareAndSet('lock', null, 'mine');
seen.query({ prefix: 'user:', limit: 20 });Use increment rather than reading, adding one and writing back. That is
not atomic across an await: two messages arriving together both read the same
number and one of them is lost.
Every write answers { ok, reason? } rather than throwing, so a full quota is
something your script can handle.
Files live in a directory of your own, named with relative paths only:
await api.files.write('notes/today.txt', text);
const read = await api.files.read('notes/today.txt');
api.parse.lines(read.value); // also parse.json, parse.csv, parse.iniWrites are atomic — an interrupted one leaves the previous file rather than half of the new one — and every parser is total, so a hand-edited file costs you a line rather than the whole script.
Secrets go to the device Keychain with api.secrets, are excluded from
every backup and export, and cannot be enumerated: keys() returns names only.
api.signal(name, payload, target?) and the onSignal hook. Without a target
it broadcasts to every other script; a broadcast never comes back to you,
because a script handling its own broadcast is the first half of every loop
anyone writes. Depth and rate are bounded, so a chain that keeps answering
itself stops instead of freezing the app.
api.registerCommand('greet', (args, ctx) => {
api.sendMessage(ctx.channel, 'Hello ' + args[0]);
}, 'Greets someone by name');The third argument is a description, and it is worth writing: commands you
register appear in autocomplete the moment somebody types / and the first
letters, with that description beside them.
Worth knowing before you write something that quietly does not work:
-
fetchand the global object are not available.api.httpis your network, and it goes through the same allowed-site list the assistant uses. Private addresses are refused whatever the list says. - Sending is rate-limited — a generous burst, then ten lines a second. It is set to be invisible to anything reasonable and fatal to a runaway loop, which would otherwise get you banned by your own client.
- A hook that blocks the app for seconds three times gets the script disabled. A synchronous loop cannot be interrupted, so the protection is making it not happen again rather than stopping the first one.
-
onRawobserves, it does not rewrite. Rewriting outgoing protocol is an addon capability with its own permission; see Addon Platform. -
The send budget covers every path, including
api.actionand CTCP replies. A script answering every message with an action is still a flood. - A script id must look like an id: letters, digits, dot, dash and underscore, starting with a letter or digit, at most 100 characters. An id is both a storage namespace and a directory name, so it is checked rather than trusted.
And the part that is not a guarantee. Editor scripts are compiled in the
app's own JavaScript context. fetch, global, globalThis, process,
require and Function are shadowed, so they are undefined inside your
script — but a determined author still reaches the real global object through a
constructor chain, and that cannot be closed from inside the same realm.
Imported addon packages are isolated, in a separate runtime with no host
objects. So: treat a script someone sent you the way you would treat any pasted
code, and prefer a packaged addon when you want the isolation.
The script editor is a small IDE rather than a plain text box.
Autocomplete. Type api. and it lists what exists. Each suggestion shows its signature and a one-line description, not just a name, so you can tell that userNick is a value rather than a call, that setTimer wants (name, delayMs, repeat?), and which methods are async and must be awaited. api.ai. offers its own set. Tab accepts the first suggestion.
That vocabulary is generated from the app's own API list, which is also what the AI generator is given — the two cannot disagree about what exists.
Highlight. The switch above the code turns on syntax colouring. It colours the app's own hooks and api.* calls, not just plain JavaScript.
Lint checks the code compiles, without running it. Save stores it; a script only runs once you also enable it in the list.
AI writes or changes the script — see AI.
The editor scrolls, so the buttons stay reachable in landscape, and they sit pinned below the code rather than at the end of the page.
AndroidIRCX — Modern IRC client with IRCv3, ZNC support and end-to-end encryption.
🔗 Website: https://androidircx.com
📱 Google Play: https://play.google.com/store/apps/details?id=com.androidircx
💻 Source code: https://github.com/AndroidIRCX/AndroidIRCX
© AndroidIRCX Project