Skip to content

Diagnostic Slack

Membership lost: the bot was removed and nothing said so

The Monday digest ran for eleven months. Somebody tidied up the channel membership list three weeks ago, the app was in it, and out it went. No email, no error, no ticket. The job still runs, still exits 0, and the only trace is a not_in_channel in a response body nobody reads. The state is easy to check. Nobody checked, because nothing asked them to.

Read-only token Python and Node.js Tests included
A store aisle filled with lots of items
Photo by Oxana Melis on Unsplash
The short answer

Removal is not an event your app finds out about unless it asked to. Any member can remove an app with /kick or from the channel's integrations panel; Slack emits member_left_channel, but only to apps subscribed to it and only while their event delivery is healthy. Nothing is sent to the app owner, nothing appears in the app configuration, and the next send returns ok: false inside an HTTP 200.

So the check has to be a scheduled diff rather than an error handler. One paginated users.conversations for the bot user returns the authoritative set of conversations it currently belongs to. Compare that against the set the previous run stored and the finding is a transition with a window attached: these channels were held last Tuesday and are not held now. The script below does that, and separates it firmly from channels the bot never had.

The problem in plain words

The permission model is the source of this. Adding an app to a channel is an ordinary member action, and so is removing it — there is no admin gate, no confirmation naming what depends on it, and no audit trail visible to the app. Someone spring-cleaning a channel's member list sees a list of names, one of which is a robot, and the robot does not look like it is doing anything. It is doing something once a day at 09:00.

What turns a small mistake into a long outage is that the loss is undetectable from every direction at once. The send fails inside a 200 so the process exits clean. The absence of a daily message is not something anybody alerts on, because alerting on absence requires having decided in advance what should be present. And the humans in the channel do not report it, because a message that stops arriving reads as a message that was never scheduled.

Then the discovery is accidental and late. Someone asks why they have not seen the digest, somebody else says they thought it had been turned off, and the reconstruction begins with three weeks of missing history and no idea which of the fifty channels the app targets also lost it. That last part is the real cost: without a set diff, finding out means checking every channel by hand.

Digest runs formonthsthe app sits inthe channelA member tidiesthe listone kick, noconfirmationSend returns okfalseinside an HTTP 200Cron exits zeronobody reads thebodyNoticed in weekthreeby a person, not achecknothing announced it, in either direction
One member tidying a list, and eleven months of a working digest ends. The send fails inside a 200, the process exits clean, and the absence of a message is the hardest thing there is to alert on.

Why it happens

A state check answers the wrong question. "Is the bot in this channel today" is worth knowing, but it does not tell you whether something changed, when, or whether it was ever true. Reading is_member on a channel the bot was never invited to and reading it on a channel it was removed from yesterday produce the same false, and those two findings go to different people.

The snapshot is the only clock you have. The Web API does not report when membership changed — there is no per-channel joined-at field on a bot's conversation list. Storing the previous run's set turns the finding into a window: it happened between the snapshot and now. That window is as tight as your schedule, which is the argument for running this hourly rather than when somebody complains.

users.conversations beats a loop over targets. One paginated call for the bot user returns every conversation it belongs to across public channels, private channels, DMs and group DMs. Per-channel conversations.info costs a call each and misses everything not on the list, which is exactly the case where the bot is in channels nobody knows about.

The events that would have told you have to be subscribed in advance. member_left_channel fires on removal and channel_left fires for the bot itself, and neither is on by default. A bot token cannot read which events your app subscribes to — that lives in app configuration behind a different credential — so the script takes the list from your manifest and tells you what is missing rather than pretending to discover it.

Removal and never-joined need separating in the report, not in the reader's head. A channel that was never held is a configuration gap and its repair is an initial invite. A channel that was held and is now lost is an incident with a date, and its repair includes finding out who removed it and why, because the second removal usually follows the first.

The fix, as a flow

The loop underneath is the note. A removal produces no signal in either direction, so the only thing that closes the circle is a person noticing weeks later. The fix branch turns three sets into four findings, and grades its own snapshot rather than implying a precision the API cannot give.

Snapshot against todayone paginated setHeld then, missing nowthe removal, with a windowmember_left_channel absentthis run is the only watcherMissing from both setsnever invited, not lostSnapshot four months olda window, not a dateHeld in bothmembership unchanged
Held then and missing now is an incident with a window. Missing from both sets was never set up. They are the same false on the same field, and they go to different people.

How to fix it

Get the bot user ID from the token in hand

auth.test needs no scopes and returns user_id, which for a bot token is the bot user. Everything else keys off that ID, and it also confirms which workspace this credential actually points at.

Take the authoritative set in one paginated pass

users.conversations?user=<bot>&types=public_channel,private_channel&limit=1000, following next_cursor to the end. Private channels only appear with groups:read granted, so a run without it reports a set that is missing exactly the channels people are most careful about.

Diff against the previous run, not against the config

Three sets go in: what you expect, what you had last time, and what you have now. Four findings come out. Lost is the incident. Never-held is a configuration gap. Regained means somebody already fixed it. Undeclared means the bot is in channels your configuration does not mention, which is information rather than a fault.

Say how wide the window is, and admit when it is useless

A snapshot from six hours ago dates the removal to within six hours. One from four months ago dates it to within four months, which is not a date. The script grades its own snapshot and says plainly when the answer it can give is too vague to act on.

Check the events that should have raised this in real time

Pass the event list from your app manifest. If member_left_channel is missing, this run is the only thing that will ever notice a removal, and its resolution is your cron schedule. Subscribing turns a scheduled diff into an alert.

Print the new snapshot and store it

The script writes nothing, so the closing line is the snapshot itself, ready to redirect into the file the next run will read. Keeping every snapshot rather than overwriting turns the audit into a history of who your app lost access to and when.

How to check it worked

After the re-invite, run it once to fix the drift and once more to confirm the set is stable.

python3 slack_membership_drift.py --expected targets.json --snapshot last.json
# bot user U07BOTAPP1, 24 conversation(s) currently held
# window: tight -- the previous snapshot is 1 day old
# held        C01ABCDE9  membership unchanged since the snapshot
# 3 expected, 0 lost, 0 never held

The full code

Everything interesting is a set operation. diff_membership sorts the expected channels into lost, never-held, regained and held, which is the distinction the whole note turns on. snapshot_window grades how much the previous snapshot is actually worth and refuses to imply precision it does not have. alerting_gap takes the event list from your manifest, because a bot token cannot read your app's subscriptions and inventing that answer would be worse than asking for it.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_membership_drift.py
"""Report Slack channels the bot has lost membership of since the last run.

Read only. One auth.test and one paginated users.conversations, diffed against
the snapshot the previous run printed. Nothing is joined and nobody is invited:
the script names what was lost, dates it as tightly as the snapshot allows, and
prints the new snapshot for you to store.
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_membership_drift")

API = "https://slack.com/api/"

# Neither of these is subscribed by default, and a bot token cannot read which
# events an app subscribes to: that lives in app configuration behind an app
# configuration token. So the list is supplied, not discovered.
WATCHERS = {
    "member_left_channel": "fires when any member, your app included, leaves a channel",
    "channel_left": "fires when the app itself is removed from a public channel",
    "group_left": "the private channel equivalent of channel_left",
}


def diff_membership(expected, previous, current):
    """Sort the expected channels by what happened to membership. Pure.

    Returns a dict of sorted lists. The distinction that matters is lost against
    never-held: both are "the bot is not in this channel today" and they are a
    different incident with a different repair and a different owner.
    """
    expected, previous, current = set(expected), set(previous), set(current)
    return {
        "lost": sorted((expected & previous) - current),
        "never": sorted(expected - previous - current),
        "regained": sorted((expected & current) - previous),
        "held": sorted(expected & current & previous),
        "undeclared": sorted(current - expected),
    }


def snapshot_window(recorded_at, now):
    """How much the previous snapshot is worth as a clock. Pure.

    Returns (quality, detail). A removal can only be dated to the gap between the
    snapshot and this run, so a stale snapshot does not produce a vague date, it
    produces no date at all, and the script says so rather than implying one.
    """
    if recorded_at is None:
        return ("none",
                "no previous snapshot, so nothing this run reports is a change. "
                "Store the snapshot below and the next run can date things.")
    days = (now - recorded_at).days
    if days < 0:
        return ("future",
                "the snapshot is dated after this run. Something is wrong with a "
                "clock and no window can be derived from it.")
    if days <= 2:
        return ("tight", "the previous snapshot is %d day(s) old" % days)
    if days <= 30:
        return ("wide",
                "the previous snapshot is %d days old, so a removal can only be "
                "placed inside that month" % days)
    return ("useless",
            "the previous snapshot is %d days old. That is a window, not a date. "
            "Run this on a schedule and the window becomes the schedule." % days)


def alerting_gap(subscribed):
    """Which of the events that would have raised this in real time are missing.

    Pure, and deliberately fed rather than fetched: the subscription list is app
    configuration and a runtime bot token has no read access to it. Returns
    (state, rows) with rows as [(event, why it matters), ...].
    """
    have = {str(e).strip() for e in (subscribed or []) if str(e).strip()}
    missing = [(name, why) for name, why in sorted(WATCHERS.items()) if name not in have]
    if not missing:
        return ("covered", [])
    if "member_left_channel" in {name for name, _ in missing}:
        return ("blind", missing)
    return ("partial", missing)


def get(session, method, **params):
    body = session.get(API + method, params=params, timeout=30).json()
    if body.get("ok") is not True:
        raise SystemExit("%s answered 200 with ok: false, error=%s"
                         % (method, body.get("error")))
    return body


def bot_conversations(session, user_id):
    """Every conversation the bot currently belongs to. GET, paginated."""
    out, cursor = [], ""
    while True:
        params = {"user": user_id, "types": "public_channel,private_channel",
                  "limit": 1000, "exclude_archived": "false"}
        if cursor:
            params["cursor"] = cursor
        body = get(session, "users.conversations", **params)
        out.extend(c.get("id") for c in (body.get("channels") or []))
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--expected", required=True,
                    help="JSON list of channel IDs the integration targets")
    ap.add_argument("--snapshot", help="JSON snapshot printed by the previous run")
    ap.add_argument("--events", help="JSON list of the events your app manifest subscribes to")
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:read and groups:read)", args.token_env)
        return 2

    expected = json.loads(open(args.expected, encoding="utf-8").read())
    previous, recorded_at = [], None
    if args.snapshot:
        snap = json.loads(open(args.snapshot, encoding="utf-8").read())
        previous = snap.get("channels") or []
        stamp = snap.get("recorded")
        if stamp:
            recorded_at = datetime.fromisoformat(stamp)
    subscribed = []
    if args.events:
        subscribed = json.loads(open(args.events, encoding="utf-8").read())

    session = requests.Session()
    session.headers.update({"Authorization": "Bearer " + token})
    me = get(session, "auth.test")
    current = bot_conversations(session, me.get("user_id"))
    log.info("bot user %s, %d conversation(s) currently held",
             me.get("user_id"), len(current))

    now = datetime.now(timezone.utc)
    quality, detail = snapshot_window(recorded_at, now)
    log.info("window: %s -- %s", quality, detail)

    groups = diff_membership(expected, previous, current)
    for channel_id in groups["lost"]:
        log.warning("lost        %-12s held at the snapshot, not held now. Somebody "
                    "removed the app.", channel_id)
        log.warning("  repair: ask a member to run /invite @YourApp in %s, then find "
                    "out who removed it", channel_id)
    for channel_id in groups["never"]:
        log.warning("never-held  %-12s not held at the snapshot either, so this was "
                    "never set up rather than lost", channel_id)
    for channel_id in groups["regained"]:
        log.info("regained    %-12s not held at the snapshot, held now", channel_id)
    for channel_id in groups["held"]:
        log.info("held        %-12s membership unchanged since the snapshot", channel_id)
    if groups["undeclared"]:
        log.info("the bot is also in %d conversation(s) your target list does not "
                 "mention", len(groups["undeclared"]))

    state, missing = alerting_gap(subscribed)
    if state != "covered":
        log.warning("alerting: %s -- this run is the only thing that notices", state)
        for name, why in missing:
            log.warning("  subscribe to %-22s %s", name, why)

    print(json.dumps({"recorded": now.isoformat(), "channels": sorted(current)},
                     indent=2))
    log.info("%d expected, %d lost, %d never held",
             len(expected), len(groups["lost"]), len(groups["never"]))
    return 1 if groups["lost"] or groups["never"] else 0


if __name__ == "__main__":
    sys.exit(main())
slack-membership-drift.mjs
/**
 * Report Slack channels the bot has lost membership of since the last run.
 *
 * Read only. One auth.test and one paginated users.conversations, diffed
 * against the snapshot the previous run printed. Nothing is joined and nobody
 * is invited: the script names what was lost and prints the new snapshot.
 */
import { readFile } from 'node:fs/promises';

const API = 'https://slack.com/api/';

// Neither of these is subscribed by default, and a bot token cannot read which
// events an app subscribes to: that lives in app configuration behind an app
// configuration token. So the list is supplied, not discovered.
const WATCHERS = {
  channel_left: 'fires when the app itself is removed from a public channel',
  group_left: 'the private channel equivalent of channel_left',
  member_left_channel: 'fires when any member, your app included, leaves a channel',
};

/**
 * Sort the expected channels by what happened to membership. Pure.
 * lost against never-held is the distinction that matters: both are "not in
 * this channel today" and they are different incidents with different owners.
 */
export function diffMembership(expected, previous, current) {
  const exp = new Set(expected);
  const prev = new Set(previous);
  const cur = new Set(current);
  const sort = (xs) => [...xs].sort();
  return {
    lost: sort([...exp].filter((c) => prev.has(c) && !cur.has(c))),
    never: sort([...exp].filter((c) => !prev.has(c) && !cur.has(c))),
    regained: sort([...exp].filter((c) => cur.has(c) && !prev.has(c))),
    held: sort([...exp].filter((c) => cur.has(c) && prev.has(c))),
    undeclared: sort([...cur].filter((c) => !exp.has(c))),
  };
}

/**
 * How much the previous snapshot is worth as a clock. Pure.
 * A stale snapshot does not produce a vague date, it produces no date at all.
 */
export function snapshotWindow(recordedAt, now) {
  if (recordedAt === null || recordedAt === undefined) {
    return ['none',
      'no previous snapshot, so nothing this run reports is a change. Store the ' +
      'snapshot below and the next run can date things.'];
  }
  const days = Math.floor((now - recordedAt) / 86400000);
  if (days < 0) {
    return ['future',
      'the snapshot is dated after this run. Something is wrong with a clock and ' +
      'no window can be derived from it.'];
  }
  if (days <= 2) return ['tight', `the previous snapshot is ${days} day(s) old`];
  if (days <= 30) {
    return ['wide',
      `the previous snapshot is ${days} days old, so a removal can only be placed ` +
      'inside that month'];
  }
  return ['useless',
    `the previous snapshot is ${days} days old. That is a window, not a date. Run ` +
    'this on a schedule and the window becomes the schedule.'];
}

/**
 * Which of the events that would have raised this in real time are missing.
 * Pure, and deliberately fed rather than fetched: the subscription list is app
 * configuration and a runtime bot token has no read access to it.
 */
export function alertingGap(subscribed) {
  const have = new Set((subscribed ?? []).map((e) => String(e).trim()).filter(Boolean));
  const missing = Object.entries(WATCHERS)
    .filter(([name]) => !have.has(name))
    .sort(([a], [b]) => a.localeCompare(b));
  if (missing.length === 0) return ['covered', []];
  if (missing.some(([name]) => name === 'member_left_channel')) return ['blind', missing];
  return ['partial', missing];
}

async function get(token, method, params) {
  const query = new URLSearchParams(params ?? {});
  const res = await fetch(`${API}${method}?${query}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const body = await res.json();
  if (body.ok !== true) {
    throw new Error(`${method} answered 200 with ok: false, error=${body.error}`);
  }
  return body;
}

async function botConversations(token, userId) {
  const out = [];
  let cursor = '';
  for (;;) {
    const params = {
      user: userId,
      types: 'public_channel,private_channel',
      limit: '1000',
      exclude_archived: 'false',
    };
    if (cursor) params.cursor = cursor;
    const body = await get(token, 'users.conversations', params);
    out.push(...(body.channels ?? []).map((c) => c.id));
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) return out;
  }
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function main() {
  const args = process.argv.slice(2);
  const expectedPath = arg(args, '--expected');
  if (!expectedPath) {
    console.error('usage: --expected targets.json [--snapshot last.json] [--events events.json]');
    process.exitCode = 2;
    return;
  }
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (channels:read and groups:read)`);
    process.exitCode = 2;
    return;
  }

  const expected = JSON.parse(await readFile(expectedPath, 'utf8'));
  let previous = [];
  let recordedAt = null;
  const snapshotPath = arg(args, '--snapshot');
  if (snapshotPath) {
    const snap = JSON.parse(await readFile(snapshotPath, 'utf8'));
    previous = snap.channels ?? [];
    if (snap.recorded) recordedAt = new Date(snap.recorded);
  }
  const eventsPath = arg(args, '--events');
  const subscribed = eventsPath ? JSON.parse(await readFile(eventsPath, 'utf8')) : [];

  const me = await get(token, 'auth.test');
  const current = await botConversations(token, me.user_id);
  console.log(`bot user ${me.user_id}, ${current.length} conversation(s) currently held`);

  const now = new Date();
  const [quality, detail] = snapshotWindow(recordedAt, now);
  console.log(`window: ${quality} -- ${detail}`);

  const groups = diffMembership(expected, previous, current);
  for (const channelId of groups.lost) {
    console.warn(`lost        ${channelId.padEnd(12)} held at the snapshot, not held ` +
                 'now. Somebody removed the app.');
    console.warn(`  repair: ask a member to run /invite @YourApp in ${channelId}, then ` +
                 'find out who removed it');
  }
  for (const channelId of groups.never) {
    console.warn(`never-held  ${channelId.padEnd(12)} not held at the snapshot either, ` +
                 'so this was never set up rather than lost');
  }
  for (const channelId of groups.regained) {
    console.log(`regained    ${channelId.padEnd(12)} not held at the snapshot, held now`);
  }
  for (const channelId of groups.held) {
    console.log(`held        ${channelId.padEnd(12)} membership unchanged since the snapshot`);
  }
  if (groups.undeclared.length) {
    console.log(`the bot is also in ${groups.undeclared.length} conversation(s) your ` +
                'target list does not mention');
  }

  const [state, missing] = alertingGap(subscribed);
  if (state !== 'covered') {
    console.warn(`alerting: ${state} -- this run is the only thing that notices`);
    for (const [name, why] of missing) {
      console.warn(`  subscribe to ${name.padEnd(22)} ${why}`);
    }
  }

  console.log(JSON.stringify(
    { recorded: now.toISOString(), channels: [...current].sort() }, null, 2));
  console.log(`${expected.length} expected, ${groups.lost.length} lost, ` +
              `${groups.never.length} never held`);
  process.exitCode = groups.lost.length || groups.never.length ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing target list.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The diff is four buckets and the tests exist to keep two of them apart: a channel held at the snapshot and missing now is lost, and a channel missing from both is never. Collapsing those is the failure mode this whole note argues against. The window tests pin that a snapshot old enough to be meaningless is labelled as such rather than reported as a date, and the alerting tests pin that a missing member_left_channel is the difference between partial cover and none.

test_slack_membership_drift.py
from datetime import datetime, timedelta, timezone

from slack_membership_drift import alerting_gap, diff_membership, snapshot_window

NOW = datetime(2026, 8, 30, tzinfo=timezone.utc)
EXPECTED = ["C01ALERTS1", "C02DIGEST2", "C03NEVER33"]


def test_a_channel_held_before_and_missing_now_is_a_loss():
    groups = diff_membership(EXPECTED, ["C01ALERTS1", "C02DIGEST2"], ["C02DIGEST2"])
    assert groups["lost"] == ["C01ALERTS1"]


def test_a_channel_missing_from_both_sets_was_never_held():
    groups = diff_membership(EXPECTED, ["C01ALERTS1", "C02DIGEST2"], ["C02DIGEST2"])
    assert groups["never"] == ["C03NEVER33"]
    assert "C03NEVER33" not in groups["lost"]


def test_a_channel_gained_since_the_snapshot_is_not_a_finding():
    groups = diff_membership(EXPECTED, [], ["C01ALERTS1"])
    assert groups["regained"] == ["C01ALERTS1"]
    assert groups["lost"] == []


def test_conversations_outside_the_target_list_are_reported_separately():
    groups = diff_membership(["C01ALERTS1"], ["C01ALERTS1"],
                             ["C01ALERTS1", "C09RANDOM9"])
    assert groups["undeclared"] == ["C09RANDOM9"]
    assert groups["held"] == ["C01ALERTS1"]


def test_with_no_snapshot_nothing_can_be_called_a_loss():
    groups = diff_membership(EXPECTED, [], [])
    assert groups["lost"] == []
    assert len(groups["never"]) == 3


def test_a_recent_snapshot_dates_the_removal_tightly():
    quality, _ = snapshot_window(NOW - timedelta(days=1), NOW)
    assert quality == "tight"


def test_a_month_old_snapshot_is_a_window_not_a_date():
    assert snapshot_window(NOW - timedelta(days=20), NOW)[0] == "wide"


def test_an_ancient_snapshot_says_so_rather_than_implying_precision():
    quality, detail = snapshot_window(NOW - timedelta(days=200), NOW)
    assert quality == "useless"
    assert "not a date" in detail


def test_a_missing_snapshot_is_its_own_state():
    quality, detail = snapshot_window(None, NOW)
    assert quality == "none"
    assert "nothing this run reports is a change" in detail


def test_a_snapshot_from_the_future_is_a_clock_problem():
    assert snapshot_window(NOW + timedelta(days=3), NOW)[0] == "future"


def test_without_member_left_channel_nothing_raises_in_real_time():
    state, missing = alerting_gap(["app_mention"])
    assert state == "blind"
    assert "member_left_channel" in dict(missing)


def test_the_key_event_alone_is_partial_cover_rather_than_none():
    state, missing = alerting_gap(["member_left_channel"])
    assert state == "partial"
    assert "member_left_channel" not in dict(missing)


def test_all_three_events_subscribed_needs_no_warning():
    state, missing = alerting_gap(
        ["member_left_channel", "channel_left", "group_left"])
    assert state == "covered"
    assert missing == []
slack-membership-drift.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { alertingGap, diffMembership, snapshotWindow }
  from './slack-membership-drift.mjs';

const NOW = new Date('2026-08-30T00:00:00Z');
const EXPECTED = ['C01ALERTS1', 'C02DIGEST2', 'C03NEVER33'];
const daysBefore = (n) => new Date(NOW.getTime() - n * 86400000);

test('a channel held before and missing now is a loss', () => {
  const groups = diffMembership(EXPECTED, ['C01ALERTS1', 'C02DIGEST2'], ['C02DIGEST2']);
  assert.deepEqual(groups.lost, ['C01ALERTS1']);
});

test('a channel missing from both sets was never held', () => {
  const groups = diffMembership(EXPECTED, ['C01ALERTS1', 'C02DIGEST2'], ['C02DIGEST2']);
  assert.deepEqual(groups.never, ['C03NEVER33']);
  assert.ok(!groups.lost.includes('C03NEVER33'));
});

test('a channel gained since the snapshot is not a finding', () => {
  const groups = diffMembership(EXPECTED, [], ['C01ALERTS1']);
  assert.deepEqual(groups.regained, ['C01ALERTS1']);
  assert.deepEqual(groups.lost, []);
});

test('conversations outside the target list are reported separately', () => {
  const groups = diffMembership(['C01ALERTS1'], ['C01ALERTS1'],
    ['C01ALERTS1', 'C09RANDOM9']);
  assert.deepEqual(groups.undeclared, ['C09RANDOM9']);
  assert.deepEqual(groups.held, ['C01ALERTS1']);
});

test('with no snapshot nothing can be called a loss', () => {
  const groups = diffMembership(EXPECTED, [], []);
  assert.deepEqual(groups.lost, []);
  assert.equal(groups.never.length, 3);
});

test('a recent snapshot dates the removal tightly', () => {
  assert.equal(snapshotWindow(daysBefore(1), NOW)[0], 'tight');
});

test('a month old snapshot is a window not a date', () => {
  assert.equal(snapshotWindow(daysBefore(20), NOW)[0], 'wide');
});

test('an ancient snapshot says so rather than implying precision', () => {
  const [quality, detail] = snapshotWindow(daysBefore(200), NOW);
  assert.equal(quality, 'useless');
  assert.match(detail, /not a date/);
});

test('a missing snapshot is its own state', () => {
  const [quality, detail] = snapshotWindow(null, NOW);
  assert.equal(quality, 'none');
  assert.match(detail, /nothing this run reports is a change/);
});

test('a snapshot from the future is a clock problem', () => {
  assert.equal(snapshotWindow(daysBefore(-3), NOW)[0], 'future');
});

test('without member left channel nothing raises in real time', () => {
  const [state, missing] = alertingGap(['app_mention']);
  assert.equal(state, 'blind');
  assert.ok(Object.fromEntries(missing).member_left_channel);
});

test('the key event alone is partial cover rather than none', () => {
  const [state, missing] = alertingGap(['member_left_channel']);
  assert.equal(state, 'partial');
  assert.ok(!Object.fromEntries(missing).member_left_channel);
});

test('all three events subscribed needs no warning', () => {
  const [state, missing] = alertingGap(
    ['member_left_channel', 'channel_left', 'group_left']);
  assert.equal(state, 'covered');
  assert.deepEqual(missing, []);
});

FAQ

Who is allowed to remove an app from a channel?

Any member of the channel, using /kick @YourApp or the integrations tab in the channel details. It is not an admin action and there is no confirmation step that names what depends on the app. That is why this is worth auditing on a schedule rather than treating as an unlikely event: the barrier to it happening is one person tidying a member list.

Does Slack notify the app or its owner when this happens?

Not out of band. There is no email, nothing in the app configuration screen, and nothing in the install record. Slack does emit member_left_channel to apps subscribed to it, which is the real-time answer, but that subscription has to have been added in advance and event delivery has to be healthy for it to arrive.

Why diff a snapshot instead of just reading is_member on each channel?

Because is_member answers what is true now and this note is about what changed. A false on a channel the bot was never invited to and a false on a channel it was removed from yesterday are the same value and completely different findings. The snapshot is also the only clock available: nothing in the API reports when a bot joined or left a conversation.

How often should this run?

As often as you want the window to be. The script can only place a removal between the previous snapshot and this run, so an hourly job dates it to the hour and a quarterly one dates it to the quarter. Hourly is cheap here: users.conversations is one paginated call regardless of how many channels you target.

Can the script re-invite the bot itself?

No, and it would need a write scope to try. conversations.join covers public channels only and would still be a write, and private channels can never be self-joined at all. The script prints the invite for a human, which is also the right shape socially: somebody removed the app on purpose often enough that walking back in unannounced is the wrong move.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.