Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,29 @@

### Breaking

- `--text` is no longer `mandatory` on `send`, `reply`, `dm`, and `edit`.
Exactly one of `--text`, `--text-file` and `--text-stdin` is now required,
and giving none or more than one is a usage error (exit 64). Every existing
command line that passes `--text` keeps working.
- OAuth flow now validates the `state` parameter (CSRF protection). Callbacks without a valid state are rejected.
- HTML characters in OAuth callback responses are now escaped.

### Added

- `send`, `reply`, `dm`, and `edit` take the message text from a file with
`--text-file <path>`, or from standard input with `--text-stdin`
(`--text-file -` is the same thing). Those bytes never pass through a shell,
so they are sent unchanged: no unescaping, no line-ending rewrite, no
control-character stripping. Inline `--text` keeps the normalization it has
today, because a shell-quoted argument is the case that normalization
repairs. This closes the case where a backtick span or a `$( )` span in a
double-quoted `--text` argument is run by the shell before the CLI starts,
which changed the message while the send still reported success.
`--file` / `-f` is unchanged: it attaches a file, it does not supply text.
- Inline `--text` warns when its value contains `` ` ``, `$(` or `${`. The
warning names the habit, not a detected fault: a marker that reaches the CLI
proves the shell did not expand it that time, and a span the shell did expand
leaves no trace to detect. The message is sent either way.
- `search` command: full-text message search across every conversation the
authenticated user can see, backed by Slack's `search.messages` method.
Takes `-q`/`--query` (required), `-l`/`--limit` (default 20), and
Expand Down
79 changes: 72 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,17 +309,26 @@ dart run bin/dart_slack.dart search -q "in:#incidents from:@lee after:2026-08-01
dart run bin/dart_slack.dart search -q "deploy failed" -c C0123ABCDEF
dart run bin/dart_slack.dart search -q "deploy failed" -c incidents

# Send a message
# Send a message. Write the text to a file first, then pass the path.
# A shell runs backticks and $( ) inside a double-quoted argument, so
# inline -t can change the message before the CLI starts. See "Message text".
dart run bin/dart_slack.dart send -c <channel-id> --text-file ./message.md

# The same text from standard input
printf 'Hello from the CLI\n' |
dart run bin/dart_slack.dart send -c <channel-id> --text-stdin

# Short text with no backtick, no $( and no newline is safe inline
dart run bin/dart_slack.dart send -c <channel-id> -t "Hello from the CLI"

# Send a message with a file attached
dart run bin/dart_slack.dart send -c <channel-id> -t "See attached" -f ./report.pdf
# Send a message with a file attached. --file attaches, --text-file is the text.
dart run bin/dart_slack.dart send -c <channel-id> --text-file ./comment.md -f ./report.pdf

# Reply to a thread
dart run bin/dart_slack.dart reply -c <channel-id> -r <thread_ts> -t "Thread reply"
dart run bin/dart_slack.dart reply -c <channel-id> -r <thread_ts> --text-file ./reply.md

# Edit a message
dart run bin/dart_slack.dart edit -c <channel-id> --ts <message_ts> -t "Updated text"
dart run bin/dart_slack.dart edit -c <channel-id> --ts <message_ts> --text-file ./new.md

# Delete a message
dart run bin/dart_slack.dart delete -c <channel-id> --ts <message_ts>
Expand All @@ -343,10 +352,10 @@ dart run bin/dart_slack.dart canvas edit --canvas <canvas-id> --mode append -m "
dart run bin/dart_slack.dart canvas delete --canvas <canvas-id>

# Send a DM
dart run bin/dart_slack.dart dm -u <user-id> -t "Hey there"
dart run bin/dart_slack.dart dm -u <user-id> --text-file ./message.md

# Send a DM with a file attached
dart run bin/dart_slack.dart dm -u <user-id> -t "Here you go" -f ./notes.txt
dart run bin/dart_slack.dart dm -u <user-id> --text-file ./comment.md -f ./notes.txt

# List workspace users
dart run bin/dart_slack.dart users
Expand Down Expand Up @@ -398,6 +407,62 @@ stored token does not gain a scope on its own. Without it, Slack answers

### Message text

`send`, `reply`, `dm`, and `edit` take the message text from exactly one of
three sources. Giving none of them, or more than one, is a usage error.

| Source | Passes through a shell | Bytes sent |
| --- | --- | --- |
| `--text-file <path>` | no | exactly the file contents |
| `--text-stdin` (or `--text-file -`) | no | exactly the standard input |
| `-t` / `--text` | YES | normalized, see below |

`--file` / `-f` is a different option. It attaches a file to the message.
`--text-file` supplies the text of the message.

#### Why the inline option is the unsafe one

A shell expands a backtick span and a `$( )` span inside a double-quoted
argument. It runs them BEFORE this CLI starts, so the CLI never sees the text
the author wrote:

```sh
python3 -c 'import sys; print(repr(sys.argv[1]))' "field is `printf changed`"
# 'field is changed'
```

The marked-up span is gone and the command output is spliced in. The send then
succeeds, so nothing reports the change. The CLI cannot detect this, because
the shell destroys the evidence before the process starts. The only fix is to
keep the text out of the argument list.

`--text` therefore prints a warning when its value contains `` ` ``, `$(` or
`${`. Read that warning as "this command line is a risky habit", not as "this
message was damaged": a marker that arrives intact proves the shell did NOT
run it that time.

```sh
# Safe. The bytes never meet a shell.
cat > message.md <<'EOF'
Run `dart test` and check $(pwd).
EOF
dart run bin/dart_slack.dart send -c C0123ABCDEF --text-file message.md

# Also safe, and no temporary file.
dart run bin/dart_slack.dart send -c C0123ABCDEF --text-stdin <<'EOF'
Run `dart test` and check $(pwd).
EOF
```

File and standard-input bytes are sent unchanged. They are NOT normalized: no
unescaping, no line-ending rewrite, no control-character stripping. There is no
shell between the author and those bytes, so there is nothing to repair, and
repairing would corrupt a file that deliberately holds the two characters `\`
and `n`.

#### Normalization of inline `--text`

The rest of this section applies to `-t` / `--text` only.

`send`, `reply`, `dm`, and `edit` normalize the `--text` argument before they
call the Slack API. A plain double-quoted shell string does not interpret
backslash escapes, so `-t "line1\nline2"` sends the two literal characters `\`
Expand Down
33 changes: 11 additions & 22 deletions lib/src/cli/commands/dm_command.dart
Original file line number Diff line number Diff line change
@@ -1,38 +1,28 @@
import 'dart:io';

import 'package:dart_slack/src/cli/commands/authenticated_command.dart';
import 'package:dart_slack/src/cli/message_text.dart';
import 'package:dart_slack/src/cli/message_text_input.dart';
import 'package:dart_slack/src/slack.dart';
import 'package:mason_logger/mason_logger.dart';

/// `dart_slack dm --user <id> --text "message" [--file <path>]`
/// `dart_slack dm --user <id> --text-file <path> [--file <path>]`
///
/// The message text comes from exactly one of `--text`, `--text-file` or
/// `--text-stdin`. `--file` attaches a file and is a different option.
///
/// Sends a direct message to a Slack user.
class DmCommand extends AuthenticatedCommand {
class DmCommand extends AuthenticatedCommand with MessageTextCommand {
/// Creates a [DmCommand].
DmCommand({
required super.logger,
super.credentialsStore,
super.httpClient,
}) {
DmCommand({required super.logger, super.credentialsStore, super.httpClient}) {
argParser
..addOption(
'user',
abbr: 'u',
help: 'The user ID to message.',
mandatory: true,
)
..addOption(
'text',
abbr: 't',
help: 'The message text.',
mandatory: true,
)
..addOption(
'file',
abbr: 'f',
help: 'Path to a local file to attach.',
);
..addOption('file', abbr: 'f', help: 'Path to a local file to attach.');
addMessageTextOptions(argParser, help: 'The message text.');
}

@override
Expand All @@ -44,7 +34,6 @@ class DmCommand extends AuthenticatedCommand {
@override
Future<int> runAuthenticated(Slack slack) async {
final user = argResults!['user'] as String;
final text = normalizeMessageText(argResults!['text'] as String);
final filePath = argResults!['file'] as String?;

if (filePath != null) {
Expand All @@ -56,13 +45,13 @@ class DmCommand extends AuthenticatedCommand {
final filename = await slack.uploadFile(
channel: channel,
path: filePath,
comment: text,
comment: messageText,
);
logger.success('File "$filename" sent to $user.');
return ExitCode.success.code;
}

final message = await slack.postMessage(channel: user, text: text);
final message = await slack.postMessage(channel: user, text: messageText);
logger.success('DM sent to $user (ts: ${message.ts}).');
return ExitCode.success.code;
}
Expand Down
19 changes: 8 additions & 11 deletions lib/src/cli/commands/edit_command.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import 'package:dart_slack/src/cli/commands/authenticated_command.dart';
import 'package:dart_slack/src/cli/message_text.dart';
import 'package:dart_slack/src/cli/message_text_input.dart';
import 'package:dart_slack/src/slack.dart';
import 'package:mason_logger/mason_logger.dart';

/// `dart_slack edit --channel <id> --ts <timestamp> --text "new text"`
/// `dart_slack edit --channel <id> --ts <timestamp> --text-file <path>`
///
/// The new text comes from exactly one of `--text`, `--text-file` or
/// `--text-stdin`.
///
/// Edits an existing Slack message.
class EditCommand extends AuthenticatedCommand {
class EditCommand extends AuthenticatedCommand with MessageTextCommand {
/// Creates an [EditCommand].
EditCommand({
required super.logger,
Expand All @@ -24,13 +27,8 @@ class EditCommand extends AuthenticatedCommand {
'ts',
help: 'The message timestamp to edit.',
mandatory: true,
)
..addOption(
'text',
abbr: 't',
help: 'The new message text.',
mandatory: true,
);
addMessageTextOptions(argParser, help: 'The new message text.');
}

@override
Expand All @@ -43,9 +41,8 @@ class EditCommand extends AuthenticatedCommand {
Future<int> runAuthenticated(Slack slack) async {
final channel = argResults!['channel'] as String;
final ts = argResults!['ts'] as String;
final text = normalizeMessageText(argResults!['text'] as String);

await slack.updateMessage(channel: channel, ts: ts, text: text);
await slack.updateMessage(channel: channel, ts: ts, text: messageText);
logger.success('Message $ts in $channel updated.');
return ExitCode.success.code;
}
Expand Down
27 changes: 10 additions & 17 deletions lib/src/cli/commands/reply_command.dart
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import 'dart:io';

import 'package:dart_slack/src/cli/commands/authenticated_command.dart';
import 'package:dart_slack/src/cli/message_text.dart';
import 'package:dart_slack/src/cli/message_text_input.dart';
import 'package:dart_slack/src/slack.dart';
import 'package:mason_logger/mason_logger.dart';

/// `dart_slack reply --channel <id> --thread <ts> --text "message"`
/// `dart_slack reply --channel <id> --thread <ts> --text-file <path>`
/// `[--file <path>]`
///
/// The reply text comes from exactly one of `--text`, `--text-file` or
/// `--text-stdin`. `--file` attaches a file and is a different option.
///
/// Replies to a message thread in a Slack channel.
class ReplyCommand extends AuthenticatedCommand {
class ReplyCommand extends AuthenticatedCommand with MessageTextCommand {
/// Creates a [ReplyCommand].
ReplyCommand({
required super.logger,
Expand All @@ -29,17 +32,8 @@ class ReplyCommand extends AuthenticatedCommand {
help: 'The thread timestamp (thread_ts) to reply to.',
mandatory: true,
)
..addOption(
'text',
abbr: 't',
help: 'The reply text.',
mandatory: true,
)
..addOption(
'file',
abbr: 'f',
help: 'Path to a local file to attach.',
);
..addOption('file', abbr: 'f', help: 'Path to a local file to attach.');
addMessageTextOptions(argParser, help: 'The reply text.');
}

@override
Expand All @@ -52,7 +46,6 @@ class ReplyCommand extends AuthenticatedCommand {
Future<int> runAuthenticated(Slack slack) async {
final channel = argResults!['channel'] as String;
final thread = argResults!['thread'] as String;
final text = normalizeMessageText(argResults!['text'] as String);
final filePath = argResults!['file'] as String?;

if (filePath != null) {
Expand All @@ -64,15 +57,15 @@ class ReplyCommand extends AuthenticatedCommand {
channel: channel,
path: filePath,
threadTs: thread,
comment: text,
comment: messageText,
);
logger.success('File "$filename" sent to thread $thread in $channel.');
return ExitCode.success.code;
}

final message = await slack.postMessage(
channel: channel,
text: text,
text: messageText,
threadTs: thread,
);
logger.success(
Expand Down
30 changes: 13 additions & 17 deletions lib/src/cli/commands/send_command.dart
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import 'dart:io';

import 'package:dart_slack/src/cli/commands/authenticated_command.dart';
import 'package:dart_slack/src/cli/message_text.dart';
import 'package:dart_slack/src/cli/message_text_input.dart';
import 'package:dart_slack/src/slack.dart';
import 'package:mason_logger/mason_logger.dart';

/// `dart_slack send --channel <id> --text "message" [--file <path>]`
/// `dart_slack send --channel <id> --text-file <path> [--file <path>]`
///
/// The message text comes from exactly one of `--text`, `--text-file` or
/// `--text-stdin`. `--file` attaches a file and is a different option.
///
/// Posts a message to a Slack channel.
class SendCommand extends AuthenticatedCommand {
class SendCommand extends AuthenticatedCommand with MessageTextCommand {
/// Creates a [SendCommand].
SendCommand({
required super.logger,
Expand All @@ -22,17 +25,8 @@ class SendCommand extends AuthenticatedCommand {
help: 'The channel ID to post to.',
mandatory: true,
)
..addOption(
'text',
abbr: 't',
help: 'The message text.',
mandatory: true,
)
..addOption(
'file',
abbr: 'f',
help: 'Path to a local file to attach.',
);
..addOption('file', abbr: 'f', help: 'Path to a local file to attach.');
addMessageTextOptions(argParser, help: 'The message text.');
}

@override
Expand All @@ -44,7 +38,6 @@ class SendCommand extends AuthenticatedCommand {
@override
Future<int> runAuthenticated(Slack slack) async {
final channel = argResults!['channel'] as String;
final text = normalizeMessageText(argResults!['text'] as String);
final filePath = argResults!['file'] as String?;

if (filePath != null) {
Expand All @@ -55,13 +48,16 @@ class SendCommand extends AuthenticatedCommand {
final filename = await slack.uploadFile(
channel: channel,
path: filePath,
comment: text,
comment: messageText,
);
logger.success('File "$filename" sent to $channel.');
return ExitCode.success.code;
}

final message = await slack.postMessage(channel: channel, text: text);
final message = await slack.postMessage(
channel: channel,
text: messageText,
);
logger.success('Message sent to $channel (ts: ${message.ts}).');
return ExitCode.success.code;
}
Expand Down
Loading
Loading