Diagnostic Slack
channel_not_found: the DM conversation was never opened
The onboarding bot DMs every new hire. It works for the twelve people who tried it during the pilot and fails for everybody since with channel_not_found, for a user ID copied straight out of the profile panel. Nothing is wrong with the ID. There is no conversation for it to be delivered into, because a direct message is a channel and nobody has ever opened this one.
A DM is a conversation with its own D-prefixed ID. A user ID identifies a person, not a place to put a message. chat.postMessage will accept a U value and open the IM implicitly a lot of the time, which is why this works for the people who have already interacted with the app and fails for everyone else; the files.* family and the read methods never do it at all.
The supported sequence is conversations.open?users=<U...>, take channel.id, and post to that. conversations.open creates a conversation, so it is a write and this script will not call it — not even to probe. Instead it reads the IM inventory with conversations.list?types=im, which lists the DMs that already exist, and sorts your configured recipients into the ones that will deliver today and the ones nothing has ever been opened for.
The problem in plain words
The reason this survives testing is that testing is done by people who have already used the app. A developer DMs themselves, it works. QA installs the app, clicks the shortcut, gets a reply, and the DM works for them from then on, because that interaction opened the conversation. Every one of those people has a D channel with the app and every new user does not, so the feature passes every check made by anyone who has ever touched it.
The inconsistency across methods finishes the job. chat.postMessage with a user ID is tolerant enough that a whole codebase gets written on the assumption that a user ID is an address. Then a feature is added that uploads a file to the same recipient, and files.completeUploadExternal demands a real channel ID and refuses the U. That reads as a bug in the new feature rather than as a wrong assumption in the old one.
The audit is awkward for an honest reason: the natural way to answer "does a DM exist with this person" is to call conversations.open, which answers by creating one. A read-only check cannot use it, and neither should a health check, because a probe that opens a hundred DMs is a probe that has just written to a hundred conversations. Reading the IM list instead is the version of this question that leaves no trace.
Why it happens
Conversations are addressed, people are not. Every Slack method that takes a channel parameter wants a conversation ID, and a DM has one like any other conversation. The tolerance in chat.postMessage is a convenience layered on top, not a second addressing scheme, and it is the only place that convenience exists.
The implicit open is not documented as a guarantee. It works often enough to look like a rule and fails for users who have never interacted with the app, for other bots, and for anything outside the messaging family. Depending on it means depending on a behaviour that varies by recipient, which is the hardest kind of bug to reproduce because it is a property of who you tested with.
The D ID is stable, so cache it. Once a DM exists between the app and a user, its ID does not change. Storing it per user at the point the conversation is opened turns every later send into a plain channel post and removes the whole class of problem. Opening it on every send instead adds a call, and a write, to every message you send.
Reading the IM inventory needs its own scope. im:read lets the token enumerate its DM conversations, im:write lets it open one, and im:history lets it read the messages. An audit run without im:read sees an empty inventory and would report every recipient as unopened, which is a wrong answer rather than a missing one, so the script checks the grant before it believes its own findings.
Group DMs are a separate family. A conversation with several people is an mpim, opened by passing a comma-separated users list, governed by mpim:read and mpim:write. The scopes are separate from the one-to-one ones, so an app that DMs individuals fine can fail entirely on group DMs with nothing in common but the word "message".
The fix, as a flow
The chain is about who did the testing rather than about a bad value: everyone who has ever used the app has a conversation, and everyone who has not does not. The fix branch reads an inventory instead of probing, because the natural probe answers the question by creating the thing it is asking about.
How to fix it
Read the grant before you read anything else
auth.test, then X-OAuth-Scopes off the response. Without im:read the inventory comes back empty and every recipient looks unopened, so the script reports that the audit itself is blind rather than producing a hundred confident false findings.
List the DM conversations that already exist
conversations.list?types=im&limit=1000, paginated. Each entry carries the D ID and the user it belongs to, which is exactly the mapping the application should have cached and did not.
Never probe with conversations.open
It answers the question by creating the conversation, so it is a write, and a scheduled audit built on it opens a DM with every recipient it checks. The IM listing answers the same question and changes nothing.
Sort each configured recipient against that inventory
A stored D that appears in the listing is correct. A stored U whose IM exists is working by luck and should be replaced with the ID. A stored U with no IM is the failure, and a stored D that is not in the listing is a conversation this token cannot see at all.
Print the open call rather than making it
For every recipient with no conversation, the script prints the exact conversations.open invocation with the user ID filled in. Running it is a decision about writing into somebody's workspace, and that decision stays with the person reading the report.
Cache the D per user once it exists
Store the returned channel.id against the user in whatever holds your recipient list. The mapping does not expire. After that the send path is an ordinary post to a channel ID, and none of the method-by-method inconsistency applies to it any more.
How to check it worked
Once the conversations exist and the IDs are cached, every recipient should classify as addressed and the run should need no repairs.
python3 slack_dm_targets.py --targets recipients.json
# grant: im:read=yes im:write=yes
# inventory: 214 IM conversation(s) visible to this token
# addressed ONCALL_PRIMARY D01ALICE99 a DM conversation ID, and the IM exists
# addressed ONCALL_BACKUP D02BOBBB88 a DM conversation ID, and the IM exists
# 2 target(s) checked, 0 with no conversation to deliver into
The full code
im_index turns the IM listing into the user-to-conversation map your application should have been keeping. target_route sorts one configured recipient against it, and the interesting row is the one where a user ID happens to work: an existing conversation makes the wrong value behave correctly, which is why the bug survives so long. open_capability checks the grant first, because an audit without im:read would report every recipient as broken.
"""Report the Slack DM recipients that have no conversation to deliver into.
Read only, and pointedly so: conversations.open answers this question by
creating a DM, which makes it a write, so the IM inventory is listed instead.
Nothing is opened. For every recipient without a conversation the script prints
the open call for you to run.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_dm_targets")
API = "https://slack.com/api/"
# im:read enumerates the DMs the token already has, im:write opens a new one.
# The audit needs the first; the repair needs the second, and the repair is not
# performed here.
AUDIT_SCOPE = "im:read"
OPEN_SCOPE = "im:write"
def im_index(conversations):
"""Map user ID to the IM conversation that exists with them. Pure.
This is the mapping the application should have cached when it first opened
each DM, rebuilt from conversations.list?types=im. Entries without a user are
dropped: group DMs come back in the same family and belong to mpim scopes,
not to this one.
"""
index = {}
for conversation in conversations or []:
user = conversation.get("user")
if user and conversation.get("is_im"):
index[user] = conversation
return index
def target_route(value, index):
"""Sort one configured recipient against the DM conversations that exist.
Pure. Returns (state, dm_id, detail). The row that explains the whole bug is
user-id-open: a user ID in a channel slot, and an existing conversation
quietly making the wrong value behave like the right one.
"""
text = str(value or "").strip()
if not text:
return ("empty", None,
"no recipient at all. Something upstream resolved to an empty "
"string and every method will refuse it.")
if text.startswith("D"):
for user, conversation in index.items():
if conversation.get("id") == text:
return ("addressed", text,
"a DM conversation ID, and the IM exists with %s" % user)
return ("dm-id-unknown", None,
"a DM conversation ID that is not in this token's IM list. Either "
"it belongs to a different installation, or im:read is not "
"granted and the inventory is incomplete.")
if text[:1] in ("U", "W"):
existing = index.get(text)
if existing is not None:
return ("user-id-open", existing.get("id"),
"a user ID, and a conversation already exists, so "
"chat.postMessage delivers and the file methods still refuse "
"it. Store %s instead." % existing.get("id"))
return ("user-id-unopened", None,
"a user ID with no conversation behind it. This is the recipient "
"the feature fails for, and it fails only for people who have "
"never interacted with the app.")
return ("not-a-recipient", None,
"neither a user ID nor a DM conversation ID. A channel ID here would "
"post in public; anything else fails outright.")
def open_capability(scopes):
"""Whether this audit can see DMs, and whether the app could open one. Pure.
Returns (state, missing, detail). The blind case matters most: without
im:read the inventory is empty and every recipient classifies as unopened,
which is a wrong answer rather than a missing one.
"""
have = {s.strip() for s in str(scopes or "").split(",") if s.strip()}
can_audit = AUDIT_SCOPE in have
can_open = OPEN_SCOPE in have
if not can_audit and not can_open:
return ("unequipped", [AUDIT_SCOPE, OPEN_SCOPE],
"neither scope is granted. This token cannot list DMs and the app "
"cannot open them, so nothing about DMs works today.")
if not can_audit:
return ("blind", [AUDIT_SCOPE],
"the app can open DMs but this token cannot list them, so the "
"inventory below is empty for a reason that has nothing to do "
"with your recipients. Do not act on it.")
if not can_open:
return ("cannot-open", [OPEN_SCOPE],
"DMs can be listed but not opened. Every unopened recipient is "
"unfixable until im:write is granted and the app reinstalled.")
return ("ready", [], "both scopes granted")
def repair_command(user_id):
"""The one write call this script will not make, printed for you. Pure."""
return ('curl -sS -H "Authorization: Bearer $SLACK_BOT_TOKEN" '
"-d users=%s https://slack.com/api/conversations.open" % user_id)
def list_ims(session):
"""Every DM conversation the token can see. GET, paginated."""
out, cursor = [], ""
while True:
params = {"types": "im", "limit": 1000}
if cursor:
params["cursor"] = cursor
body = session.get(API + "conversations.list", params=params, timeout=30).json()
if body.get("ok") is not True:
raise SystemExit("conversations.list answered 200 with ok: false, "
"error=%s" % body.get("error"))
out.extend(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("--targets", required=True,
help="JSON object mapping a config key to its DM recipient value")
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 (im:read is what this audit needs)", args.token_env)
return 2
targets = json.loads(open(args.targets, encoding="utf-8").read())
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
res = session.get(API + "auth.test", timeout=30)
if res.json().get("ok") is not True:
log.error("auth.test failed: %s", res.json().get("error"))
return 2
state, missing, detail = open_capability(res.headers.get("X-OAuth-Scopes"))
log.info("grant: %s=%s %s=%s", AUDIT_SCOPE,
"no" if AUDIT_SCOPE in missing else "yes", OPEN_SCOPE,
"no" if OPEN_SCOPE in missing else "yes")
if state in ("blind", "unequipped"):
log.error("%s -- %s", state, detail)
log.error("repair: add %s to Bot Token Scopes and reinstall, then re-run",
", ".join(missing))
return 2
if state == "cannot-open":
log.warning("%s -- %s", state, detail)
index = im_index(list_ims(session))
log.info("inventory: %d IM conversation(s) visible to this token", len(index))
bad = 0
for key in sorted(targets):
route, dm_id, why = target_route(targets[key], index)
if route == "addressed":
log.info("%-16s %-16s %-12s %s", route, key, dm_id, why)
continue
bad += 1
log.warning("%-16s %-16s %-12s %s", route, key, targets[key], why)
if route == "user-id-open":
log.warning(" repair: %s=%s", key, dm_id)
elif route == "user-id-unopened":
log.warning(" repair: %s", repair_command(targets[key]))
log.warning(" then store the returned channel.id in %s", key)
else:
log.warning(" repair: put a D conversation ID in %s, or the user ID "
"you want a DM opened for", key)
log.info("%d target(s) checked, %d with no conversation to deliver into",
len(targets), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report the Slack DM recipients that have no conversation to deliver into.
*
* Read only, and pointedly so: conversations.open answers this question by
* creating a DM, which makes it a write, so the IM inventory is listed instead.
* Nothing is opened; the open call is printed for you to run.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// im:read enumerates the DMs the token already has, im:write opens a new one.
// The audit needs the first; the repair needs the second, and the repair is not
// performed here.
const AUDIT_SCOPE = 'im:read';
const OPEN_SCOPE = 'im:write';
/**
* Map user ID to the IM conversation that exists with them. Pure.
* This is the mapping the application should have cached when it first opened
* each DM. Entries without a user belong to the mpim family, not this one.
*/
export function imIndex(conversations) {
const index = new Map();
for (const conversation of conversations ?? []) {
if (conversation.user && conversation.is_im) index.set(conversation.user, conversation);
}
return index;
}
/**
* Sort one configured recipient against the DM conversations that exist. Pure.
* user-id-open is the row that explains the bug: an existing conversation
* quietly makes the wrong value behave like the right one.
*/
export function targetRoute(value, index) {
const text = String(value ?? '').trim();
if (!text) {
return ['empty', null,
'no recipient at all. Something upstream resolved to an empty string and ' +
'every method will refuse it.'];
}
if (text.startsWith('D')) {
for (const [user, conversation] of index) {
if (conversation.id === text) {
return ['addressed', text, `a DM conversation ID, and the IM exists with ${user}`];
}
}
return ['dm-id-unknown', null,
"a DM conversation ID that is not in this token's IM list. Either it " +
'belongs to a different installation, or im:read is not granted and the ' +
'inventory is incomplete.'];
}
if (text[0] === 'U' || text[0] === 'W') {
const existing = index.get(text);
if (existing) {
return ['user-id-open', existing.id,
'a user ID, and a conversation already exists, so chat.postMessage ' +
`delivers and the file methods still refuse it. Store ${existing.id} instead.`];
}
return ['user-id-unopened', null,
'a user ID with no conversation behind it. This is the recipient the ' +
'feature fails for, and it fails only for people who have never ' +
'interacted with the app.'];
}
return ['not-a-recipient', null,
'neither a user ID nor a DM conversation ID. A channel ID here would post in ' +
'public; anything else fails outright.'];
}
/**
* Whether this audit can see DMs, and whether the app could open one. Pure.
* Without im:read the inventory is empty and every recipient classifies as
* unopened, which is a wrong answer rather than a missing one.
*/
export function openCapability(scopes) {
const have = new Set(String(scopes ?? '').split(',').map((s) => s.trim()).filter(Boolean));
const canAudit = have.has(AUDIT_SCOPE);
const canOpen = have.has(OPEN_SCOPE);
if (!canAudit && !canOpen) {
return ['unequipped', [AUDIT_SCOPE, OPEN_SCOPE],
'neither scope is granted. This token cannot list DMs and the app cannot ' +
'open them, so nothing about DMs works today.'];
}
if (!canAudit) {
return ['blind', [AUDIT_SCOPE],
'the app can open DMs but this token cannot list them, so the inventory ' +
'below is empty for a reason that has nothing to do with your recipients. ' +
'Do not act on it.'];
}
if (!canOpen) {
return ['cannot-open', [OPEN_SCOPE],
'DMs can be listed but not opened. Every unopened recipient is unfixable ' +
'until im:write is granted and the app reinstalled.'];
}
return ['ready', [], 'both scopes granted'];
}
/** The one write call this script will not make, printed for you. Pure. */
export function repairCommand(userId) {
return 'curl -sS -H "Authorization: Bearer $SLACK_BOT_TOKEN" ' +
`-d users=${userId} https://slack.com/api/conversations.open`;
}
async function listIms(token) {
const out = [];
let cursor = '';
for (;;) {
const params = new URLSearchParams({ types: 'im', limit: '1000' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`${API}conversations.list?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
if (body.ok !== true) {
throw new Error(`conversations.list answered 200 with ok: false, error=${body.error}`);
}
out.push(...(body.channels ?? []));
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 targetsPath = arg(args, '--targets');
if (!targetsPath) {
console.error('usage: --targets recipients.json [--token-env SLACK_BOT_TOKEN]');
process.exitCode = 2;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv} (im:read is what this audit needs)`);
process.exitCode = 2;
return;
}
const targets = JSON.parse(await readFile(targetsPath, 'utf8'));
const authRes = await fetch(`${API}auth.test`, {
headers: { Authorization: `Bearer ${token}` },
});
const auth = await authRes.json();
if (auth.ok !== true) {
console.error(`auth.test failed: ${auth.error}`);
process.exitCode = 2;
return;
}
const [state, missing, detail] = openCapability(authRes.headers.get('x-oauth-scopes'));
console.log(`grant: ${AUDIT_SCOPE}=${missing.includes(AUDIT_SCOPE) ? 'no' : 'yes'} ` +
`${OPEN_SCOPE}=${missing.includes(OPEN_SCOPE) ? 'no' : 'yes'}`);
if (state === 'blind' || state === 'unequipped') {
console.error(`${state} -- ${detail}`);
console.error(`repair: add ${missing.join(', ')} to Bot Token Scopes and ` +
'reinstall, then re-run');
process.exitCode = 2;
return;
}
if (state === 'cannot-open') console.warn(`${state} -- ${detail}`);
const index = imIndex(await listIms(token));
console.log(`inventory: ${index.size} IM conversation(s) visible to this token`);
let bad = 0;
for (const key of Object.keys(targets).sort()) {
const [route, dmId, why] = targetRoute(targets[key], index);
if (route === 'addressed') {
console.log(`${route.padEnd(16)} ${key.padEnd(16)} ${String(dmId).padEnd(12)} ${why}`);
continue;
}
bad += 1;
console.warn(`${route.padEnd(16)} ${key.padEnd(16)} ` +
`${String(targets[key]).padEnd(12)} ${why}`);
if (route === 'user-id-open') {
console.warn(` repair: ${key}=${dmId}`);
} else if (route === 'user-id-unopened') {
console.warn(` repair: ${repairCommand(targets[key])}`);
console.warn(` then store the returned channel.id in ${key}`);
} else {
console.warn(` repair: put a D conversation ID in ${key}, or the user ID you ` +
'want a DM opened for');
}
}
console.log(`${Object.keys(targets).length} target(s) checked, ${bad} with no ` +
'conversation to deliver into');
process.exitCode = bad ? 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
Two assertions carry the note. A user ID with a conversation behind it has to classify as user-id-open and not as correct, because it works today by accident and will still be refused by the file methods; and a user ID with no conversation has to be the failure rather than a variant of the same row. The capability tests pin that a token without im:read reports itself blind instead of reporting every recipient as broken.
from slack_dm_targets import (im_index, open_capability, repair_command,
target_route)
IMS = [
{"id": "D01ALICE99", "user": "U01ALICE99", "is_im": True},
{"id": "D02BOBBB88", "user": "U02BOBBB88", "is_im": True},
{"id": "G03GROUP77", "is_im": False},
]
INDEX = im_index(IMS)
def test_the_index_maps_users_to_their_conversations():
assert set(INDEX) == {"U01ALICE99", "U02BOBBB88"}
assert INDEX["U01ALICE99"]["id"] == "D01ALICE99"
def test_a_group_conversation_is_not_in_the_one_to_one_index():
assert all(c.get("is_im") for c in INDEX.values())
def test_a_known_dm_id_is_the_only_addressed_state():
route, dm_id, _ = target_route("D01ALICE99", INDEX)
assert route == "addressed"
assert dm_id == "D01ALICE99"
def test_a_user_id_with_a_conversation_still_needs_replacing():
route, dm_id, detail = target_route("U01ALICE99", INDEX)
assert route == "user-id-open"
assert dm_id == "D01ALICE99"
assert "file methods still refuse" in detail
def test_a_user_id_with_no_conversation_is_the_failure():
route, dm_id, detail = target_route("U09NEWHIRE", INDEX)
assert route == "user-id-unopened"
assert dm_id is None
assert "never interacted" in detail
def test_a_dm_id_outside_the_inventory_is_not_reported_as_working():
route, _, _ = target_route("D99ELSEWHR", INDEX)
assert route == "dm-id-unknown"
def test_an_empty_recipient_is_its_own_state():
assert target_route("", INDEX)[0] == "empty"
assert target_route(None, INDEX)[0] == "empty"
def test_a_channel_id_is_not_a_dm_recipient():
assert target_route("C01ABCDE9", INDEX)[0] == "not-a-recipient"
def test_without_im_read_the_audit_calls_itself_blind():
state, missing, detail = open_capability("chat:write,im:write")
assert state == "blind"
assert missing == ["im:read"]
assert "Do not act on it" in detail
def test_without_im_write_the_findings_are_real_but_unfixable():
state, missing, _ = open_capability("im:read,chat:write")
assert state == "cannot-open"
assert missing == ["im:write"]
def test_neither_scope_is_its_own_verdict():
assert open_capability("chat:write")[0] == "unequipped"
assert open_capability("")[0] == "unequipped"
def test_both_scopes_granted_is_ready():
assert open_capability("im:read,im:write,chat:write")[0] == "ready"
def test_the_repair_reads_the_token_from_the_environment():
line = repair_command("U09NEWHIRE")
assert "conversations.open" in line
assert "$SLACK_BOT_TOKEN" in line
assert "users=U09NEWHIRE" in line
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { imIndex, openCapability, repairCommand, targetRoute }
from './slack-dm-targets.mjs';
const IMS = [
{ id: 'D01ALICE99', user: 'U01ALICE99', is_im: true },
{ id: 'D02BOBBB88', user: 'U02BOBBB88', is_im: true },
{ id: 'G03GROUP77', is_im: false },
];
const INDEX = imIndex(IMS);
test('the index maps users to their conversations', () => {
assert.deepEqual([...INDEX.keys()].sort(), ['U01ALICE99', 'U02BOBBB88']);
assert.equal(INDEX.get('U01ALICE99').id, 'D01ALICE99');
});
test('a group conversation is not in the one to one index', () => {
assert.ok([...INDEX.values()].every((c) => c.is_im));
});
test('a known dm id is the only addressed state', () => {
const [route, dmId] = targetRoute('D01ALICE99', INDEX);
assert.equal(route, 'addressed');
assert.equal(dmId, 'D01ALICE99');
});
test('a user id with a conversation still needs replacing', () => {
const [route, dmId, detail] = targetRoute('U01ALICE99', INDEX);
assert.equal(route, 'user-id-open');
assert.equal(dmId, 'D01ALICE99');
assert.match(detail, /file methods still refuse/);
});
test('a user id with no conversation is the failure', () => {
const [route, dmId, detail] = targetRoute('U09NEWHIRE', INDEX);
assert.equal(route, 'user-id-unopened');
assert.equal(dmId, null);
assert.match(detail, /never interacted/);
});
test('a dm id outside the inventory is not reported as working', () => {
assert.equal(targetRoute('D99ELSEWHR', INDEX)[0], 'dm-id-unknown');
});
test('an empty recipient is its own state', () => {
assert.equal(targetRoute('', INDEX)[0], 'empty');
assert.equal(targetRoute(null, INDEX)[0], 'empty');
});
test('a channel id is not a dm recipient', () => {
assert.equal(targetRoute('C01ABCDE9', INDEX)[0], 'not-a-recipient');
});
test('without im read the audit calls itself blind', () => {
const [state, missing, detail] = openCapability('chat:write,im:write');
assert.equal(state, 'blind');
assert.deepEqual(missing, ['im:read']);
assert.match(detail, /Do not act on it/);
});
test('without im write the findings are real but unfixable', () => {
const [state, missing] = openCapability('im:read,chat:write');
assert.equal(state, 'cannot-open');
assert.deepEqual(missing, ['im:write']);
});
test('neither scope is its own verdict', () => {
assert.equal(openCapability('chat:write')[0], 'unequipped');
assert.equal(openCapability('')[0], 'unequipped');
});
test('both scopes granted is ready', () => {
assert.equal(openCapability('im:read,im:write,chat:write')[0], 'ready');
});
test('the repair reads the token from the environment', () => {
const line = repairCommand('U09NEWHIRE');
assert.match(line, /conversations\.open/);
assert.match(line, /\$SLACK_BOT_TOKEN/);
assert.match(line, /users=U09NEWHIRE/);
});
FAQ
Why does DMing a user ID work for some people and not others?
Because it depends on whether a conversation already exists with that person. chat.postMessage will open the IM implicitly in many cases and reliably will not for users who have never interacted with the app, which maps almost exactly onto the difference between the people who tested the feature and the people who are now using it.
Can a read-only script check whether a DM exists without creating one?
Yes, by listing rather than opening. conversations.list with types=im returns the DM conversations the token can see, each with the user it belongs to. conversations.open would answer the same question by creating the conversation, so an audit built on it writes into every recipient's workspace as a side effect of checking.
Which scopes does this need?
im:read to enumerate DM conversations, im:write to open one, im:history to read the messages in them. Group DMs use the mpim: equivalents and are a separate grant, so an app can be perfectly configured for one-to-one DMs and unable to open a conversation with three people.
Should the D channel ID be cached, or resolved on every send?
Cached. The mapping between a user and their DM conversation with your app is stable, so opening it repeatedly adds a write and a round trip to every message for no benefit. Store the returned channel.id against the user the first time and the send path becomes an ordinary post to a channel ID.
What if the recipient is another app rather than a person?
Then no DM can be opened at all: apps cannot DM other apps, and the attempt returns cannot_dm_bot. That is a property of the recipient rather than of the conversation, so it belongs with the other recipient-side faults, along with accounts that have been deactivated since the ID was stored.
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.
- conversations.open method reference — Slack Docs
- conversations.list method reference — Slack Docs
- Permission scopes — Slack Docs
- Using the Conversations API — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.