Skip to content

Scripting

Velimir Majstorov edited this page Sep 24, 2026 · 7 revisions

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.

Quick Start

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

Available Functions

  • 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?) - returns string[]
  • api.getChannels(networkId?) - returns string[]
  • 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

Watching the wire, and respecting the theme

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.

Storage you can iterate

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 went

listStorage 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.

Asking the user

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.

Formatting

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.

Cleaning up

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.

Who you share channels with

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.

Bans, the way the app builds them

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.

Saved channels

if (!api.isFavorite(channel)) await api.addFavorite(channel);
await api.setAutoJoin(channel, true);

getFavorites, isFavorite, addFavorite, removeFavorite, getAutoJoinChannels, setAutoJoin.

The channel list

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".

Reactions, away, activity, flood log

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, capped

Reactions 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.

Talking to your own user

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.

Notifications, the clipboard and the composer

api.notify('Someone mentioned you', msg.text);   // system notification
api.copyToClipboard(text);
api.setInput('how about this instead?');         // puts it in the box

api.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.

Showing that something is happening

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.

Fetching a page

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.

Custom Commands and Menu Items

  • api.registerCommand(name, (args, ctx) => {}) - register a user /command. The user typing /name a b c calls the handler with args = ['a','b','c'] and ctx = { channel?, networkId?, nick? }. Return null/undefined to consume the command, a string to replace it, or { cancel: true }.
  • api.addMenuItem({ menu, label, onSelect }) - add a context-menu entry. menu is 'nick', 'channel' or 'tab' (default 'nick'); onSelect receives (target, ctx) where target is the nick/channel/tab tapped and ctx = { channel?, networkId?, nick? }.

Call registerCommand and addMenuItem at 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.

Action Helpers

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 /me action

Small Helpers

  • api.rand(min, max) - random integer in [min, max].
  • api.list(name) - a persistent, per-script named text list (a $read equivalent). Returns { add(line), all(), random(), clear() }. All methods are async — remember to await them.

Sound and Links

  • api.playSound(name) - play a sound. name is 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 external http/https link. 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.

Using your own sound from your phone

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:

  1. Open Settings → Sounds → Custom Sounds.
  2. Add a sound, give it a name (e.g. tada) and pick a file from your phone.
  3. In your script, call api.playSound('tada'). It plays your custom file.

Alternative — event slot:

  1. Open Settings → Sounds.
  2. 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. ring or notify — make good "custom" slots.)
  3. In your script, call api.playSound with that event's name, e.g. api.playSound('mention'). It plays the custom file you assigned.

History

  • 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"; searchHistory is for finding something specific.

AI

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, or null if the call could not be made. It never throws, so a hook without a try/catch cannot break; the reason is written to the script log instead.
  • await api.ai.chat(messages, options?) - multi-turn version. messages is [{ 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);
    });
  },
};

Limits per script

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.

Three rules that will bite you

  1. onRaw and onCommand must return immediately. They cannot await an AI answer. Return first, then send the answer with api.sendMessage when it arrives.
  2. 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.
  3. A script that answers inside onMessage must ignore its own output — check msg.from against api.userNick, or tag the text it sends. Two such scripts in one channel will otherwise answer each other forever.

Writing a script with AI

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.

Hooks

Connection Events

  • onConnect(networkId) - when connected
  • onDisconnect(networkId, reason?) - when disconnected

Message Events

  • onMessage(msg) - channel/query messages
  • onNotice(msg) - notice messages
  • onCTCP(type, from, text, msg) - CTCP requests
  • onAction(target, nick, text, msg) - /me actions
  • onHighlight(msg) - your nick or a highlight word was matched

Channel Events

  • 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

Other Events

  • onRaw(line, direction, msg) - every wire line, in and out. Observes only
  • onCommand(text, ctx) - outgoing command
  • onTimer(name) - timer fired

Examples

Auto-op

module.exports = {
  onJoin: (channel, nick, msg) => {
    if (nick === api.userNick) return;
    api.sendCommand('MODE ' + channel + ' +o ' + nick);
  },
};

Welcome

module.exports = {
  onJoin: (channel, nick) => {
    api.sendMessage(channel, 'Welcome, ' + nick + '!');
  },
};

Alias (/hello)

module.exports = {
  onCommand: (text) => {
    if (text.startsWith('/hello')) return '/say Hello there!';
    return text;
  },
};

CTCP Responder

module.exports = {
  onCTCP: (type, from, text) => {
    if (type === 'VERSION') {
      api.sendCTCP(from, 'VERSION', 'AndroidIRCX');
    }
  },
};

Timer Example

module.exports = {
  onConnect: () => {
    api.setTimer('periodic', 60000, true); // every 60s
  },
  onTimer: (name) => {
    if (name === 'periodic') {
      api.log('Timer fired!');
    }
  },
  onDisconnect: () => {
    api.clearTimer('periodic');
  },
};

Kick Protection

module.exports = {
  onKick: (channel, kickedNick, kickerNick, reason) => {
    if (kickedNick === api.userNick) {
      api.sendCommand('JOIN ' + channel);
    }
  },
};

Custom /opall Command

// 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 = {};

Context-Menu Item

api.addMenuItem({
  menu: 'nick',
  label: 'Slap',
  onSelect: (target, ctx) => {
    if (ctx.channel) {
      api.action(ctx.channel, 'slaps ' + target + ' with a large trout');
    }
  },
});

module.exports = {};

Random Greeter (text list)

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));
  },
};

Play your own sound on an event

A complete walkthrough for playing a custom sound from your phone.

Step 1 — add the sound (once, in the app):

  1. Open Settings → Sounds → Custom Sounds.
  2. Tap Add custom sound, pick the audio file from your phone.
  3. 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.
  • playSound respects 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).

Play a sound and offer a link on highlight

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/');
  },
};

Built-in AI scripts

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.

Tips

  • 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.
  • onCommand can cancel send by returning { cancel: true }.
  • onRaw can modify or cancel raw IRC lines.
  • Use timers for periodic tasks; clear them on disconnect.
  • Check api.isConnected() before sending commands.
  • Call registerCommand / addMenuItem at load time (top level), then export your hooks; both are cleared automatically on disable/remove/recompile.
  • api.list(...) methods are async — always await them.
  • Prefer the action helpers (api.op, api.kick, …) over hand-built sendCommand strings for readability.

Managing Scripts In App

  1. Open Settings.
  2. Go to Scripting & Ads.
  3. Open Scripts (Scripting Time & No-Ads).
  4. Add/edit scripts and enable them.
  5. Use Script Logs to inspect output and errors.

Knowing who is who

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 line

api.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.

Storing things

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.ini

Writes 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.

Talking to other scripts

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.

Your own commands

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.

What a script cannot do

Worth knowing before you write something that quietly does not work:

  • fetch and the global object are not available. api.http is 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.
  • onRaw observes, 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.action and 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 editor

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.

Related

Clone this wiki locally