Building a Simple Telegram Notifier as My First Drone Plugin

Published:

Drone is a Docker-based CI system, and that container-first design goes further than just the runtime environment: every plugin in a CI pipeline also runs as its own Docker container. That makes plugin development unusually flexible. You are not locked into a single language—Python, PHP, Node.js, Bash, and plenty of others all work, as long as the container can run them.

There are already many plugins available, and there is even an official Telegram plugin. The problem I ran into was that it did not support a custom Telegram API endpoint. For deployments on servers in mainland China, where Telegram often has to be accessed through a reverse proxy, that limitation makes it awkward to use. Since the node-telegram-bot-api package supports a configurable API base URL, writing a small Telegram notification plugin in Node.js turned out to be a straightforward solution.

How a Drone plugin works

A Docker container can define a startup command, so when Drone launches a plugin container, it can immediately execute the plugin script. At the same time, Drone injects both build information and plugin configuration into the container as environment variables.

That is the key reason plugins can be written in almost any language: every language can read environment variables.

A few naming conventions matter here:

  • Plugin settings are injected with a PLUGIN_xxx prefix, such as PLUGIN_TOKEN.
  • Build-related values are injected with prefixes like CI_xxx or DRONE_xxx, for example DRONE_COMMIT and CI_COMMIT_AUTHOR_NAME.

Writing the plugin

The implementation itself is very small. The main thing to remember is that plugin options should be read from variables starting with PLUGIN_. In this case, Telegram needs values such as PLUGIN_TOKEN.

Here is the plugin code:

const render = require('drone-render');
const TelegramBot = require('node-telegram-bot-api');

const {
  PLUGIN_TOKEN,
  PLUGIN_TO,
  TELEGRAM_TOKEN,
  TELEGRAM_TO,

  PLUGIN_LANG,
  PLUGIN_MESSAGE,
  PLUGIN_BASE_API_URL
} = process.env;

const TOKEN = PLUGIN_TOKEN || TELEGRAM_TOKEN;
const TO = PLUGIN_TO || TELEGRAM_TO;

if(PLUGIN_LANG) {
  render.locale(PLUGIN_LANG);
}
const bot = new TelegramBot(TOKEN, {
  baseApiUrl: PLUGIN_BASE_API_URL
});
bot.sendMessage(TO, render(PLUGIN_MESSAGE));

There is not much to it. The script reads the token and recipient from the environment, optionally switches the rendering locale, creates a Telegram bot instance with a custom baseApiUrl, and sends the rendered message.

Packaging it with Docker

Once the plugin script is ready, the next step is building an image with a Dockerfile.

There is a small but useful optimization here. Every instruction in a Dockerfile creates a layer. Dependencies installed with npm install usually change less often than the plugin script itself, so it makes sense to copy package.json and install dependencies first, then copy the frequently updated script later. That way, repeated builds and pushes only need to upload the layers that actually changed.

FROM mhart/alpine-node:8.9.3

WORKDIR /telegram-node
COPY package.json /telegram-node/package.json
RUN npm install

COPY index.js /telegram-node/index.js
ENTRYPOINT [ "node", "/telegram-node/index.js" ]

After that, the image can be built and pushed for reuse:

docker build lizheming/drone-telegram-node .
docker push lizheming/drone-telegram-node

Testing locally

Before wiring it into a CI pipeline, it is easy to test the image directly with Docker:

docker run --rm \
  -e PLUGIN_TOKEN=xxxxxxx \
  -e PLUGIN_TO=xxxxxxx \
  -e PLUGIN_MESSAGE=test \
  -e PLUGIN_BASE_API_URL=xxxx \
  lizheming/drone-telegram-node

If the message is delivered, the plugin is working.

A couple of issues worth noting

Reading values from secrets

Sensitive data such as tokens often should not be written directly into .drone.yml, especially when that configuration is visible to other project users. In those cases, Drone secrets are the better choice.

One important detail is that the secret name becomes the final environment variable name injected by Drone, so the naming cannot be arbitrary if your plugin expects a certain variable.

For example:

pipline:
  telegram:
    image: lizheming/drone-telegram-node
    secrets: [ telegram_token, telegram_to ]
    message: hello

Inside the plugin, the corresponding secret can then be read through process.env.TELEGTAM_TOKEN.

Why the plugin worked locally but not inside Drone

One problem I hit during development came from the Dockerfile entrypoint. My first version looked like this:

FROM mhart/alpine-node:8.9.3

WORKDIR /telegram-node
COPY package.json /telegram-node/package.json
RUN npm install

COPY index.js /telegram-node/index.js
ENTRYPOINT [ "node", "index.js" ]

Under a normal docker run, this works fine because WORKDIR is set, so index.js resolves to ${WORKDIR}/index.js.

But Drone needs access to the project source code when it runs a plugin, so it overrides the image's WORKDIR. That meant the container itself was healthy, and local execution looked fine, but once it ran inside Drone, the entry file could no longer be found and the plugin never actually started.

The fix was simply to use an absolute path in ENTRYPOINT instead of relying on the working directory.

Why this version was worth building

The main advantage of this plugin over the official Telegram plugin is support for a custom Telegram API address. For environments that depend on a reverse proxy to reach Telegram, that makes the plugin much more practical.

If you need Telegram notifications from Drone on servers inside China, this approach fits that use case well.