# @mikitasazan/notify

Typed Telegram notifications for any number of projects: **one forum per
project**, with an "Ops" tab where robots post and a "Dev" tab where people
talk, one bot, seven event types. There is no free text anywhere in the API:
a notification is a typed event, so every card from every sender has the same
shape and the same tags.

Zero runtime dependencies. Node.js 22.18+ / 24+ executes the TypeScript CLI
directly (the GitHub Action runs it straight from the checkout, with nothing
to install); the npm package ships compiled `dist/` plus type declarations.

## Why a forum per project

The alternative — one shared forum with a topic per project — does not work:
Telegram cannot hide a single topic from a member, so whoever is in the group
sees every tab. Team members would then need a separate chat of their own,
and one project would live in two places at once. A forum per project
removes the duplication: a teammate is added to their project's forum and
sees none of the others, and the layout is the same for every project.

## Installation

```bash
npm install @mikitasazan/notify
```

The CLI installs as the `notify` bin. The only secret is `OPS_BOT_TOKEN` —
the token of the Telegram bot that delivers the cards. Create a bot once
(Telegram's @BotFather does it in a minute), add it to every project chat as
an administrator, and keep the token in your secret store or in the
repository's GitHub secrets.

## Where cards go: the routing config

Routing is data, not code. One JSON file describes every project:

```json
{
  "system": "acme-web",
  "projects": {
    "acme-web": {
      "chat": "-1001234567",
      "ops": 22,
      "dev": 23,
      "title": "Acme Web",
      "display": "acme-web"
    },
    "acme-api": { "chat": "-1007654321" }
  }
}
```

| Field | Meaning |
|---|---|
| `chat` | The project's supergroup id — a forum with topics, or a plain group. |
| `ops` | The "Ops" topic id where cards land. Omit it for a plain group without topics: the card goes to the chat itself. |
| `dev` | The "Dev" topic id — people's talk; the bot does not write there. Optional. |
| `title` | The chat title. Optional: derived from the display name when omitted (`<name> · Ops` for a plain group, `<name>` for a forum). Set it when the chat's real title differs from that, and repeat the same `title` on every key that shares the chat. |
| `display` | The human-readable project name, used in chat titles and in `notify routes --json`, and on an incident card sent without `--scope`. Optional; defaults to the key. |
| `system` | Top level; must name a key of `projects`. The project that receives the package's own service cards — for example, when an event names a project missing from the table. Without it (unset, or naming no project) such cards are not sent: the caller reports it on stderr and the exit code stays 0. |

Several keys may share one `chat` (one product built from several
repositories): each key gets its own `ops` topic, and the rows share `dev`.

The config is looked up in this order:

1. `NOTIFY_ROUTES` — the whole JSON as one string;
2. `NOTIFY_ROUTES_FILE` — a path to the file;
3. `routes.json` in the root of the package (the path is resolved from the
   package's own location, so it works the same from `src/` and from
   `dist/`, from any working directory).

The first source that is set wins, and a set-but-broken source does not fall
through to the next one. `routes.json` is not part of the npm package
(`files` ships only `dist`), so npm consumers supply their table through
`NOTIFY_ROUTES` or `NOTIFY_ROUTES_FILE`; the GitHub Action runs from a
checkout and finds `routes.json` there. Both variables take the whole
document, the top-level `{ "projects": … }` object, not just the table.

A missing, broken or unreadable config never crashes a run: the table comes
up empty, a line on stderr explains why, and the exit code stays 0. The
config is loaded lazily, on first use — not at import time.

`notify routes --json` prints the effective routing table, with display
names and chat titles resolved — the machine-readable view of everything
above.

## From TypeScript

```ts
import { notify } from '@mikitasazan/notify';

await notify({
  type: 'report',
  project: 'acme-web',
  title: 'Daily summary',
  aside: 'Oct 18',
  lines: [['Pages served', 1284], ['Errors', 3]]
});
```

## From bash — a server, cron, a laptop

```bash
notify job --project acme-web --job "Nightly import" --status ok --stat "added=5"
trap 'notify job --project acme-web --job "Nightly import" --status fail --note "log: $LOG"' ERR
notify report --project acme-web --json < payload.json   # the whole event object from stdin
notify deploy --project acme-web --status ok --dry-run   # print the card, send nothing
```

The exit code is **always 0**: a notification has no right to bring down the
deploy or the cron job that called it. All diagnostics go to stderr.

## From GitHub Actions

```yaml
- uses: sazanwork/notify@v2   # pin the release you use
  if: always()
  with: { event: deploy, project: acme-web, status: '${{ job.status }}' }
  env:  { OPS_BOT_TOKEN: '${{ secrets.OPS_BOT_TOKEN }}' }
```

Config-driven routing needs v2 or newer: earlier releases compiled the
routing table into the package. The lookup order above applies to the Action
unchanged — it reads the `routes.json` of its own checkout unless
`NOTIFY_ROUTES` or `NOTIFY_ROUTES_FILE` says otherwise.

`status: ${{ job.status }}` (`success`/`failure`) maps itself to `ok`/`fail`,
so one step with `if: always()` replaces a pair of `if: success()` /
`if: failure()` steps.

For GitHub's own events — pull requests and issues — call the reusable
workflow instead of listing every event yourself:

```yaml
jobs:
  notify:
    uses: sazanwork/notify/.github/workflows/ops-notify.yml@v2
    with:
      project: acme-web
    secrets:
      OPS_BOT_TOKEN: ${{ secrets.OPS_BOT_TOKEN }}
```

Its allowlist follows one rule — one piece of news, one card: a PR is
announced when it opens, next when a review verdict arrives, last when it
closes or merges.

## Event types

| Type | When | Required fields |
|---|---|---|
| `deploy` | code shipped | `project`, `status` |
| `job` | a recurring task: an import, a backup | `project`, `job`, `status` |
| `report` | a summary with numbers | `project`, `title`, `lines` |
| `ci` | the CI outcome on the main branch | `project`, `status` |
| `pr` | a pull request event | `project`, `action`, `number`, `title` |
| `issue` | an issue event | `project`, `action`, `number`, `title` |
| `incident` | something is broken and waits for you right now | `project`, `title` |
| `session` | deprecated — an alias of `incident` | `project`, `action` |
| `heartbeat_miss` | deprecated — send `job` with `status: silent` | `project`, `job` |

Notes on the shapes:

- The bracket after the type word names the outcome — `Deploy (OK)`,
  `CI (Fail)`, `Job (Off)`, `Issue (Assigned)`. An incident has a single
  state, so its bracket names the affected scope, from `--scope`.
- `job` has two extra states besides ok/fail: `disabled` (switched off from
  outside — not its own failure) and `silent` (missed its check-in).
- `--via` (where the task ran: `vps`, `actions`, a laptop) and `--took`
  (how long it took, in your own words) are optional `job` rows.
- `--path` attaches a file to ANY event: the card becomes the caption of the
  attachment. `notify file` is a deprecated alias that sends a report with
  an attachment.
- The first line of every card carries three tags: `#type #key #outcome`.
  The key pairs a "broke" card with its later "fixed" card without comparing
  human wording. Pass `--key` explicitly from regular senders; without it
  the key is derived from the title and changes when the title does.

An event for an unknown project does not crash the caller (exit code 0) and
is not dropped silently either: a service card about the lost event goes to
the `system` project, when one is configured.

Full signatures: `src/events.ts` in the repository.

## Adding a project

1. In Telegram: create a group, open its settings and enable **Topics**, then
   add your bot as an administrator with the "Manage topics" right. This is
   the only manual step — only a live account can create a group.
2. Run `notify setup <chat_id> <project-key>` — it creates the "Ops" and
   "Dev" tabs and prints the entry with the chat id and the topic ids.
3. Add that entry to the `projects` table of your `routes.json` in JSON
   syntax (or pass the whole document through `NOTIFY_ROUTES` instead of a
   file).
4. Optional: add a three-line workflow to the project's repository calling
   `ops-notify.yml`, and PRs, reviews and issues start arriving on their
   own.

Adding a teammate is one step too: add them to the project's forum. Nothing
migrates and nothing duplicates — they see their project's Ops and Dev and
nothing else.

## Schema evolution

There is no schema version and there will never be one: a message is read
once, by a person, and is gone. Instead, one rule: a new field on an existing
type is only ever added as optional, and a required field is only ever added
as a new event type. Old caller code and a new package stay compatible in
both directions.

## What is deliberately not here

- A queue, a broker, a daemon. A notification is dozens of messages a day;
  the retry lives inside the calling process. Delivery does not deserve
  infrastructure that would itself need monitoring.
- Editing already-sent messages: that means storing `message_id` — state and
  a database.
- Auto-creating a topic on first send: a failure could spawn duplicates.
  Topic creation is the explicit `notify setup`.
- Dependencies, a build step for consumers, MarkdownV2, throttling. Throttle
  in the caller if it is a long-lived process; a CLI starts fresh each time.

## Development

```bash
npm run typecheck   # tsc --noEmit
npm test            # node --test src/*.test.ts
```

Releases are git tags of this repository; consumers pin an exact version.
