slk 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/slk/api/conversations.rb +6 -2
- data/lib/slk/cli.rb +1 -0
- data/lib/slk/commands/help.rb +2 -0
- data/lib/slk/commands/search.rb +55 -48
- data/lib/slk/commands/sent.rb +288 -0
- data/lib/slk/formatters/attachment_formatter.rb +7 -3
- data/lib/slk/formatters/search_formatter.rb +25 -11
- data/lib/slk/formatters/sent_formatter.rb +193 -0
- data/lib/slk/models/search_result.rb +2 -0
- data/lib/slk/runner.rb +4 -0
- data/lib/slk/services/search_pages.rb +65 -0
- data/lib/slk/services/sent_changes.rb +293 -0
- data/lib/slk/services/sent_channel_label.rb +66 -0
- data/lib/slk/services/sent_conversations.rb +227 -0
- data/lib/slk/support/check_in_time.rb +73 -0
- data/lib/slk/version.rb +1 -1
- data/lib/slk.rb +7 -0
- metadata +9 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7db83e915de4b86bafd0cc62e8a67fdaee0dcd9209cb94a1f8fd6a5a2704c46
|
|
4
|
+
data.tar.gz: eb25d2aafaa23a51016e64c2f5eb40c0117aeacb829d784caff9a50078946992
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d928dbf2906c22dc3ad108503c6ef4bfaeb68c29c0ed562dcd27e3515cf1468df426801dc6f7e73a3c2f9bb47d79fd7bf90054b3a9fc424453a35e8f60fd75d
|
|
7
|
+
data.tar.gz: 93850e6e114cd1634ce2546631b1c370eb94731ff284eb29c05c458a533f16fb1229afde2ca27f60e6ea2e00d12828d6db17342f1d336350cc59d5ae788cc1bb
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.11.0] - 2026-09-23
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `slk sent` now expands your sent-message search hits into conversations: full paginated threads and DMs, merged channel windows (5 messages before and 30 minutes after your posts), and replies to your top-level posts. It groups by conversation and caps displayed messages with `--max` (default 200, `0` for all). `--before` and `--after-minutes` tune channel windows. `--mine` keeps the previous flat timeline and JSON shape.
|
|
15
|
+
- `slk sent --json` now returns `{date, range, conversations}`. Each conversation includes its workspace, channel, type, thread timestamp, `last_speaker_is_me`, `dropped_messages`, and chronological messages with sender, name, text, and `mine`. `channel_name` remains Slack's raw value; `channel_label` is the resolved display label for DMs and channels. The sender-only format remains available through `--mine --json`, with `channel_label` added to counts.
|
|
16
|
+
|
|
17
|
+
- `slk sent` text now wraps to the interactive terminal's width (or `--width N`), with indented reply and context continuations; `--no-wrap` disables wrapping. The redundant `▶` marker on your own messages and the last-speaker status lines are gone from text output; JSON still includes `last_speaker_is_me`. Thread headings now show the thread ID instead of repeating the parent message; channel and DM headings keep their readable names. A thin divider separates conversations without splitting context from new messages. Orphaned replies put their parent reference on a separate line so long thread IDs do not steal the message's wrap width.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `slk sent --changed-since TIME` shows a stateless diff of conversations you participated in over `--lookback DAYS` (default 7). TIME accepts local `H:MM` or `HH:MM`, ISO datetime, Slack epoch/ts, or `90m`/`2h`/`1d`; clock times use the latest local occurrence (yesterday when that time has not arrived today). Search discovers conversations, but exact-timestamp history and replies determine what changed: old threads and DMs with new replies appear even when you did not post today. Channel chatter alone does not count; channel windows are fetched only for your own new top-level posts. Changed output orders conversations by latest activity, newest at the bottom, and includes up to `--context N` earlier messages (default 2). Text marks older context with `·` without a separate cutoff divider; JSON labels each new message. JSON adds `changed_since: {iso, ts}`, `lookback_days`, per-conversation `new_count` and `new_from_others`, and per-message `new`, retaining the flat, timestamp-sorted `thread_ts` contract. Thread subscriptions contribute unread followed threads when available. A paginated history scan per watched channel identifies active thread roots from Slack's `latest_reply` (rather than probing every root); quiet DMs use a one-message history probe. Edits/reactions do not change message timestamps; conversations you never posted in and very recent posts missing from Slack's search index remain blind spots.
|
|
22
|
+
- `slk sent [today|yesterday|YYYY-MM-DD]` finds conversations you posted in, across workspaces by default. `--since YYYY-MM-DD` searches through today, `-w` selects a workspace, and `--mine` shows only your indexed messages in a flat chronological timeline with per-channel counts. Slack's index can lag, excludes deleted messages, and uses your profile timezone for date operators; DM history bounds use your local timezone.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `slk sent` now orders conversations with exact Slack timestamps and workspace/channel/thread tie-breakers, so identical start times stay deterministic across platforms.
|
|
27
|
+
- `slk sent --changed-since` no longer silently omits followed threads when their lookup fails due to network, rate-limit, authentication, or malformed-response errors. Unsupported or unauthorized-to-use subscription views still fall back to search-derived conversations. Sender lookup caches the authenticated user ID and reports a workspace-specific API error if Slack omits it. Deleted threads are recognized by Slack's error code, not message text, and invalid-time errors are limited to actual input conversions. Text wrapping falls back to 72 columns if a terminal cannot report its width.
|
|
28
|
+
- `slk sent` now includes the first post of each channel window in Slack history (and expands its replies), displays DM names even for threads, resolves group-DM participants, and shows file-only thread parents through file labels. Expanded replies now appear nested beneath their parent in text output, while JSON remains flat and timestamp-sorted with `thread_ts` links.
|
|
29
|
+
- Attachment and unfurl preview titles, authors, text, and image labels now decode HTML entities like message bodies.
|
|
30
|
+
- `slk search --all` now searches every workspace and tags each result. Search paginates past Slack's 100-result page size when `-n` requests more, and warns when the requested limit cuts off matches.
|
|
31
|
+
|
|
8
32
|
## [0.10.0] - 2026-09-18
|
|
9
33
|
|
|
10
34
|
### Added
|
|
@@ -15,17 +15,21 @@ module Slk
|
|
|
15
15
|
@api.post(@workspace, 'conversations.list', params)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# rubocop:disable Metrics/ParameterLists
|
|
19
|
+
def history(channel:, limit: 20, cursor: nil, oldest: nil, latest: nil, inclusive: false)
|
|
19
20
|
params = { channel: channel, limit: limit }
|
|
20
21
|
params[:cursor] = cursor if cursor
|
|
21
22
|
params[:oldest] = oldest if oldest
|
|
22
23
|
params[:latest] = latest if latest
|
|
24
|
+
params[:inclusive] = true if inclusive
|
|
23
25
|
@api.post(@workspace, 'conversations.history', params)
|
|
24
26
|
end
|
|
27
|
+
# rubocop:enable Metrics/ParameterLists
|
|
25
28
|
|
|
26
|
-
def replies(channel:, timestamp:, limit: 100, cursor: nil)
|
|
29
|
+
def replies(channel:, timestamp:, limit: 100, cursor: nil, oldest: nil)
|
|
27
30
|
params = { channel: channel, ts: timestamp, limit: limit }
|
|
28
31
|
params[:cursor] = cursor if cursor
|
|
32
|
+
params[:oldest] = oldest if oldest
|
|
29
33
|
# Use form encoding - some workspaces (Enterprise Grid) require it
|
|
30
34
|
@api.post_form(@workspace, 'conversations.replies', params)
|
|
31
35
|
end
|
data/lib/slk/cli.rb
CHANGED
data/lib/slk/commands/help.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Slk
|
|
|
12
12
|
['dnd', 'Manage Do Not Disturb'],
|
|
13
13
|
['messages', 'Read channel or DM messages'],
|
|
14
14
|
['search', 'Search messages across channels'],
|
|
15
|
+
['sent', 'Show your sent messages across workspaces'],
|
|
15
16
|
['unread', 'View and clear unread messages'],
|
|
16
17
|
['activity', 'Show activity feed (reactions, mentions, threads)'],
|
|
17
18
|
['later', 'Show saved "Later" items'],
|
|
@@ -84,6 +85,7 @@ module Slk
|
|
|
84
85
|
slk status schedule "Vet" 1p-3p Schedule a status (am/pm or 24h)
|
|
85
86
|
slk dnd 1h Enable DND for 1 hour
|
|
86
87
|
slk messages #general Read channel messages
|
|
88
|
+
slk sent Show messages you sent today
|
|
87
89
|
slk preset meeting Apply preset
|
|
88
90
|
EXAMPLES
|
|
89
91
|
end
|
data/lib/slk/commands/search.rb
CHANGED
|
@@ -41,8 +41,8 @@ module Slk
|
|
|
41
41
|
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/AbcSize
|
|
42
42
|
def handle_option(arg, args, remaining)
|
|
43
43
|
case arg
|
|
44
|
-
when '-n', '--limit' then @options[:limit] =
|
|
45
|
-
when '--page' then @options[:page] =
|
|
44
|
+
when '-n', '--limit' then @options[:limit] = positive_integer(arg, args)
|
|
45
|
+
when '--page' then @options[:page] = positive_integer(arg, args)
|
|
46
46
|
when '--in' then @options[:in_channel] = require_value(arg, args)
|
|
47
47
|
when '--from' then @options[:from_user] = require_value(arg, args)
|
|
48
48
|
when '--after' then @options[:after_date] = require_value(arg, args)
|
|
@@ -56,11 +56,19 @@ module Slk
|
|
|
56
56
|
|
|
57
57
|
def require_value(option, args)
|
|
58
58
|
value = args.shift
|
|
59
|
-
raise ArgumentError, "#{option} requires a value" unless value
|
|
59
|
+
raise ArgumentError, "#{option} requires a value" unless value && !value.start_with?('-')
|
|
60
60
|
|
|
61
61
|
value
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def positive_integer(option, args)
|
|
65
|
+
value = require_value(option, args)
|
|
66
|
+
number = Integer(value, exception: false)
|
|
67
|
+
raise ArgumentError, "#{option} expects a positive integer" unless number&.positive?
|
|
68
|
+
|
|
69
|
+
number
|
|
70
|
+
end
|
|
71
|
+
|
|
64
72
|
def help_text
|
|
65
73
|
help = Support::HelpFormatter.new('slk search <query> [options]')
|
|
66
74
|
help.description('Search messages across channels and DMs.')
|
|
@@ -84,11 +92,12 @@ module Slk
|
|
|
84
92
|
|
|
85
93
|
def add_options_section(help)
|
|
86
94
|
help.section('OPTIONS') do |s|
|
|
87
|
-
s.option('-n, --limit N', '
|
|
88
|
-
s.option('--page N', '
|
|
95
|
+
s.option('-n, --limit N', 'Results per workspace (default: 20; paginates past 100)')
|
|
96
|
+
s.option('--page N', 'Starting page in each workspace')
|
|
89
97
|
s.option('--threads', 'Show thread replies inline')
|
|
90
98
|
s.option('--json', 'Output as JSON')
|
|
91
|
-
s.option('-w, --workspace', 'Specify workspace')
|
|
99
|
+
s.option('-w, --workspace NAME', 'Specify workspace')
|
|
100
|
+
s.option('--all', 'Search every workspace (results tagged by workspace)')
|
|
92
101
|
s.option('-v, --verbose', 'Show debug information')
|
|
93
102
|
s.option('-q, --quiet', 'Suppress output')
|
|
94
103
|
end
|
|
@@ -110,23 +119,19 @@ module Slk
|
|
|
110
119
|
1
|
|
111
120
|
end
|
|
112
121
|
|
|
113
|
-
# rubocop:disable Metrics/MethodLength
|
|
114
122
|
def search_and_display(query)
|
|
115
|
-
workspace
|
|
116
|
-
full_query = build_query(query)
|
|
123
|
+
raise ArgumentError, 'Use either --all or --workspace, not both' if @options[:all] && @options[:workspace]
|
|
117
124
|
|
|
125
|
+
full_query = build_query(query)
|
|
118
126
|
debug("Searching: #{full_query}")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
results = parse_results(response)
|
|
126
|
-
display_results(results, workspace, response)
|
|
127
|
+
searches = target_workspaces.map do |workspace|
|
|
128
|
+
[workspace, Services::SearchPages.new(runner.search_api(workspace.name)).fetch(
|
|
129
|
+
query: full_query, limit: @options[:limit], page: @options[:page]
|
|
130
|
+
)]
|
|
131
|
+
end
|
|
132
|
+
display_results(searches)
|
|
127
133
|
0
|
|
128
134
|
end
|
|
129
|
-
# rubocop:enable Metrics/MethodLength
|
|
130
135
|
|
|
131
136
|
def build_query(base_query)
|
|
132
137
|
parts = [base_query]
|
|
@@ -138,47 +143,50 @@ module Slk
|
|
|
138
143
|
parts.join(' ')
|
|
139
144
|
end
|
|
140
145
|
|
|
141
|
-
def
|
|
142
|
-
matches = response.dig('messages', 'matches') || []
|
|
143
|
-
matches.map { |m| Models::SearchResult.from_api(m) }
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def display_results(results, workspace, response)
|
|
146
|
+
def display_results(searches)
|
|
147
147
|
if @options[:json]
|
|
148
|
-
output_json_results(
|
|
148
|
+
output_json_results(searches)
|
|
149
149
|
else
|
|
150
|
-
display_text_results(
|
|
150
|
+
display_text_results(searches)
|
|
151
151
|
end
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
def output_json_results(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
page: pagination['page'],
|
|
160
|
-
page_count: pagination['page_count'],
|
|
161
|
-
total_count: pagination['total_count']
|
|
162
|
-
}
|
|
163
|
-
})
|
|
154
|
+
def output_json_results(searches)
|
|
155
|
+
results = searches.flat_map do |workspace, data|
|
|
156
|
+
data[:results].map { |result| result.to_h.merge(workspace: workspace.name) }
|
|
157
|
+
end
|
|
158
|
+
output_json(results: results, pagination: json_pagination(searches))
|
|
164
159
|
end
|
|
165
160
|
|
|
166
|
-
def
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if results.empty?
|
|
170
|
-
puts 'No results found.'
|
|
171
|
-
return
|
|
161
|
+
def json_pagination(searches)
|
|
162
|
+
pages = searches.to_h do |workspace, data|
|
|
163
|
+
[workspace.name, data[:pagination].merge('truncated' => data[:truncated] == true)]
|
|
172
164
|
end
|
|
165
|
+
searches.length == 1 ? pages.values.first : pages
|
|
166
|
+
end
|
|
173
167
|
|
|
174
|
-
|
|
168
|
+
def display_text_results(searches)
|
|
169
|
+
searches.each { |workspace, data| report_search(workspace, data) }
|
|
170
|
+
entries = searches.flat_map { |workspace, data| data[:results].map { |result| [result, workspace] } }
|
|
171
|
+
return puts 'No results found.' if entries.empty?
|
|
172
|
+
|
|
173
|
+
entries.each_with_index do |(result, workspace), index|
|
|
175
174
|
display_single_result(result, workspace)
|
|
176
|
-
puts if index <
|
|
175
|
+
puts if index < entries.length - 1
|
|
177
176
|
end
|
|
178
177
|
end
|
|
179
178
|
|
|
179
|
+
def report_search(workspace, data)
|
|
180
|
+
show_pagination_info(data[:pagination], workspace) if @options[:verbose]
|
|
181
|
+
return unless data[:truncated]
|
|
182
|
+
|
|
183
|
+
total = data[:pagination]['total_count']
|
|
184
|
+
quantity = total ? "#{data[:results].length} of #{total}" : data[:results].length.to_s
|
|
185
|
+
warn("#{workspace.name}: Showing #{quantity} matches; raise -n for more.")
|
|
186
|
+
end
|
|
187
|
+
|
|
180
188
|
def display_single_result(result, workspace)
|
|
181
|
-
runner.search_formatter.display_result(result, workspace, format_options)
|
|
189
|
+
runner.search_formatter.display_result(result, workspace, format_options.merge(workspace_label: @options[:all]))
|
|
182
190
|
show_thread_replies(result, workspace) if should_show_thread?(result)
|
|
183
191
|
end
|
|
184
192
|
|
|
@@ -209,13 +217,12 @@ module Slk
|
|
|
209
217
|
lines[1..].each { |line| puts " #{line}" }
|
|
210
218
|
end
|
|
211
219
|
|
|
212
|
-
def show_pagination_info(
|
|
213
|
-
pagination = response.dig('messages', 'pagination') || {}
|
|
220
|
+
def show_pagination_info(pagination, workspace)
|
|
214
221
|
total = pagination['total_count'] || 0
|
|
215
222
|
page = pagination['page'] || 1
|
|
216
223
|
page_count = pagination['page_count'] || 1
|
|
217
224
|
|
|
218
|
-
debug("Page #{page}/#{page_count} (#{total} total results)")
|
|
225
|
+
debug("#{workspace.name}: Page #{page}/#{page_count} (#{total} total results)")
|
|
219
226
|
end
|
|
220
227
|
end
|
|
221
228
|
# rubocop:enable Metrics/ClassLength
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slk
|
|
4
|
+
module Commands
|
|
5
|
+
# All indexed messages sent by the authenticated user on a date/range.
|
|
6
|
+
# rubocop:disable Metrics/ClassLength
|
|
7
|
+
class Sent < Base
|
|
8
|
+
def execute
|
|
9
|
+
result = validate_options
|
|
10
|
+
return result if result
|
|
11
|
+
|
|
12
|
+
run_sent
|
|
13
|
+
rescue ApiError => e
|
|
14
|
+
error("Sent search failed: #{e.message}")
|
|
15
|
+
1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def default_options
|
|
21
|
+
super.merge(since: nil, mine: false, changed_since: nil, lookback: 7, context: 2,
|
|
22
|
+
before: Services::SentConversations::DEFAULT_BEFORE,
|
|
23
|
+
after_minutes: Services::SentConversations::DEFAULT_AFTER_MINUTES,
|
|
24
|
+
max: Services::SentConversations::DEFAULT_MAX)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def default_width
|
|
28
|
+
return nil unless $stdout.tty?
|
|
29
|
+
|
|
30
|
+
columns = IO.console&.winsize&.last
|
|
31
|
+
columns&.positive? ? columns : super
|
|
32
|
+
rescue SystemCallError, IOError, NotImplementedError
|
|
33
|
+
super
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
37
|
+
def handle_option(arg, args, remaining)
|
|
38
|
+
case arg
|
|
39
|
+
when '--since' then @options[:since] = option_value(arg, args)
|
|
40
|
+
when '--changed-since' then @options[:changed_since] = option_value(arg, args)
|
|
41
|
+
when '--lookback' then @options[:lookback] = positive_integer(arg, args)
|
|
42
|
+
when '--context' then @options[:context] = nonnegative_integer(arg, args)
|
|
43
|
+
when '--mine' then @options[:mine] = true
|
|
44
|
+
when '--before' then @options[:before] = before_limit(arg, args)
|
|
45
|
+
when '--after-minutes' then @options[:after_minutes] = nonnegative_integer(arg, args)
|
|
46
|
+
when '--max' then @options[:max] = nonnegative_integer(arg, args)
|
|
47
|
+
else super
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
51
|
+
|
|
52
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
53
|
+
def help_text
|
|
54
|
+
help = Support::HelpFormatter.new('slk sent [today|yesterday|YYYY-MM-DD] [options]')
|
|
55
|
+
help.description('Catch up on conversations you posted in, across all workspaces.')
|
|
56
|
+
help.note('Search is indexed: very recent and deleted messages may be missing.')
|
|
57
|
+
help.note('Dates/DM history bounds use your local clock; Slack search uses your profile timezone.')
|
|
58
|
+
help.note('Long --since ranges can be slow (search is rate-limited; 429s retry once).')
|
|
59
|
+
help.note('Conversations you only read or were mentioned in but never posted in are out of scope.')
|
|
60
|
+
help.note('A future --mentions flag could seed mentioned conversations via to:me search.')
|
|
61
|
+
help.note('JSON: {date, range, conversations:[{workspace, channel_id, channel_name, channel_label, type,')
|
|
62
|
+
help.note('thread_ts, last_speaker_is_me, dropped_messages, messages:[{ts, user,')
|
|
63
|
+
help.note('user_name, text, mine, thread_ts}]}]}; --mine keeps counts/results JSON,')
|
|
64
|
+
help.note('with channel_label added to counts. channel_name remains the raw Slack name.')
|
|
65
|
+
help.note('JSON messages stay flat and timestamp-sorted; thread_ts links replies to their parent.')
|
|
66
|
+
help.note('Text output nests expanded replies under their parent, even when sent later, and wraps')
|
|
67
|
+
help.note('to the terminal width on a TTY. Use --width N to override or --no-wrap to disable.')
|
|
68
|
+
help.note('Thread headings show the thread ID; channel and DM headings keep their readable names.')
|
|
69
|
+
help.note('A thin divider separates conversations, not messages within a conversation.')
|
|
70
|
+
help.note('For --changed-since, H:MM or HH:MM means the most recent local occurrence: if that time has not')
|
|
71
|
+
help.note('arrived today, it means yesterday. Dated timestamps in the future are rejected.')
|
|
72
|
+
help.note('--changed-since is stateless: exact-ts history/replies detect new messages; search only finds')
|
|
73
|
+
help.note('the watch set. Edits/reactions do not change ts and are invisible. Search index lag may')
|
|
74
|
+
help.note('omit posts from the last few minutes. Conversations you never posted in remain out of scope.')
|
|
75
|
+
help.note('Unread followed threads supplement the watch set when available; --lookback must include')
|
|
76
|
+
help.note('the days you posted in any other threads you want to watch.')
|
|
77
|
+
help.note('Changed text marks earlier context with · and puts the most recently active conversation last.')
|
|
78
|
+
help.note('A wide lookback checks many thread roots and can take minutes under Slack rate limits.')
|
|
79
|
+
help.note('Changed JSON adds changed_since:{iso, ts}, lookback_days, new_count, new_from_others,')
|
|
80
|
+
help.note('and per-message new. Its messages stay flat and timestamp-sorted with thread_ts links.')
|
|
81
|
+
help.section('OPTIONS') do |s|
|
|
82
|
+
s.option('--since YYYY-MM-DD', 'From this date through today (inclusive)')
|
|
83
|
+
s.option('--changed-since TIME', 'Only changed conversations (H:MM/HH:MM, ISO, epoch, 90m, 2h, 1d)')
|
|
84
|
+
s.option('--lookback DAYS', 'Days of sent messages to watch (default: 7; changed mode)')
|
|
85
|
+
s.option('--context N', 'Earlier messages per changed conversation (default: 2)')
|
|
86
|
+
s.option('--mine', 'Only your messages, flat timeline with counts (previous behavior)')
|
|
87
|
+
s.option('--before N', 'Channel messages before a window (default: 5, max: 200)')
|
|
88
|
+
s.option('--after-minutes N', 'Channel window after each post (default: 30)')
|
|
89
|
+
s.option('--max N', 'Messages per conversation (default: 200; 0 for all)')
|
|
90
|
+
s.option('-w, --workspace NAME', 'Search one workspace instead of all')
|
|
91
|
+
s.option('--all', 'Search all workspaces (default)')
|
|
92
|
+
s.option('--json', 'Output conversations as JSON (or counts/results with --mine)')
|
|
93
|
+
s.option('--width N', 'Wrap text at N columns (default: terminal width on TTY)')
|
|
94
|
+
s.option('--no-wrap', 'Disable text wrapping')
|
|
95
|
+
end
|
|
96
|
+
help.section('EXAMPLES') do |s|
|
|
97
|
+
s.example('slk sent', 'Today in all workspaces')
|
|
98
|
+
s.example('slk sent yesterday', 'Yesterday in all workspaces')
|
|
99
|
+
s.example('slk sent 2026-09-19', 'One specific day')
|
|
100
|
+
s.example('slk sent --since 2026-09-15', 'From Sep 15 through today')
|
|
101
|
+
s.example('slk sent --mine --json', 'Only your sent messages, flat JSON')
|
|
102
|
+
s.example('slk sent --changed-since 15:17 --lookback 3', 'Diff watched conversations since 15:17')
|
|
103
|
+
end
|
|
104
|
+
help.render
|
|
105
|
+
end
|
|
106
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def run_sent
|
|
111
|
+
raise UsageError, 'Use either --all or --workspace, not both.' if @options[:all] && @options[:workspace]
|
|
112
|
+
|
|
113
|
+
return run_changes if @options[:changed_since]
|
|
114
|
+
|
|
115
|
+
query = date_query
|
|
116
|
+
entries = collect_entries(query)
|
|
117
|
+
@options[:mine] ? emit(entries, query) : emit_context(entries)
|
|
118
|
+
0
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
122
|
+
def run_changes
|
|
123
|
+
validate_changed_options
|
|
124
|
+
since = Support::CheckInTime.parse(@options[:changed_since])
|
|
125
|
+
entries = collect_entries(changed_query)
|
|
126
|
+
changes = Services::SentChanges.new(
|
|
127
|
+
runner: runner, since: since, context: @options[:context], max: @options[:max],
|
|
128
|
+
before: @options[:before], after_minutes: @options[:after_minutes]
|
|
129
|
+
).collect(entries, workspaces: selected_workspaces)
|
|
130
|
+
Formatters::SentFormatter.new(runner: runner, options: format_options).display_changes(
|
|
131
|
+
changes, changed_since: since, lookback_days: @options[:lookback], json: @options[:json]
|
|
132
|
+
)
|
|
133
|
+
0
|
|
134
|
+
end
|
|
135
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
136
|
+
|
|
137
|
+
def validate_changed_options
|
|
138
|
+
if positional_args.any? || @options[:since]
|
|
139
|
+
raise UsageError, 'Use --changed-since alone, not with a date or --since.'
|
|
140
|
+
end
|
|
141
|
+
raise UsageError, 'Use --changed-since without --mine.' if @options[:mine]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def changed_query
|
|
145
|
+
start_date = Date.today - (@options[:lookback] - 1)
|
|
146
|
+
"from:me after:#{start_date - 1} before:#{Date.today + 1}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def positive_integer(flag, args)
|
|
150
|
+
number = nonnegative_integer(flag, args)
|
|
151
|
+
raise UsageError, "#{flag} expects a positive integer." if number.zero?
|
|
152
|
+
|
|
153
|
+
number
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def before_limit(flag, args)
|
|
157
|
+
number = nonnegative_integer(flag, args)
|
|
158
|
+
raise UsageError, '--before must be at most 200.' if number > 200
|
|
159
|
+
|
|
160
|
+
number
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def nonnegative_integer(flag, args)
|
|
164
|
+
value = option_value(flag, args)
|
|
165
|
+
number = Integer(value, exception: false)
|
|
166
|
+
raise UsageError, "#{flag} expects a non-negative integer." unless number && !number.negative?
|
|
167
|
+
|
|
168
|
+
number
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def date_query
|
|
172
|
+
raise UsageError, 'Use either a date or --since, not both.' if @options[:since] && positional_args.any?
|
|
173
|
+
raise UsageError, 'Expected one date: today, yesterday, or YYYY-MM-DD.' if positional_args.length > 1
|
|
174
|
+
|
|
175
|
+
@options[:since] ? range_query : day_query
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def range_query
|
|
179
|
+
since = parse_date(@options[:since])
|
|
180
|
+
raise UsageError, '--since must not be in the future.' if since > Date.today
|
|
181
|
+
|
|
182
|
+
"from:me after:#{since - 1} before:#{Date.today + 1}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def day_query
|
|
186
|
+
date = case positional_args.first
|
|
187
|
+
when nil, 'today' then Date.today
|
|
188
|
+
when 'yesterday' then Date.today - 1
|
|
189
|
+
else parse_date(positional_args.first)
|
|
190
|
+
end
|
|
191
|
+
"from:me on:#{date}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_date(value)
|
|
195
|
+
raise UsageError, "Invalid date: #{value.inspect}. Use YYYY-MM-DD." unless value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
196
|
+
|
|
197
|
+
Date.iso8601(value)
|
|
198
|
+
rescue Date::Error
|
|
199
|
+
raise UsageError, "Invalid date: #{value.inspect}. Use YYYY-MM-DD."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def collect_entries(query)
|
|
203
|
+
entries = selected_workspaces.flat_map { |workspace| fetch_workspace(workspace, query) }
|
|
204
|
+
entries.sort_by { |workspace, result| [result.ts.to_f, workspace.name, result.channel_id.to_s] }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def selected_workspaces
|
|
208
|
+
@options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def fetch_workspace(workspace, query)
|
|
212
|
+
pages = Services::SearchPages.new(runner.search_api(workspace.name)).fetch(query: query, sort_dir: 'asc')
|
|
213
|
+
pages[:results].map { |result| [workspace, result] }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def emit_context(entries)
|
|
217
|
+
start_date, end_date = selected_dates
|
|
218
|
+
conversations = Services::SentConversations.new(
|
|
219
|
+
runner: runner, start_date: start_date, end_date: end_date,
|
|
220
|
+
before: @options[:before], after_minutes: @options[:after_minutes], max: @options[:max]
|
|
221
|
+
).collect(entries)
|
|
222
|
+
Formatters::SentFormatter.new(runner: runner, options: format_options).display(
|
|
223
|
+
conversations, start_date: start_date, end_date: end_date, json: @options[:json]
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def selected_dates
|
|
228
|
+
return [parse_date(@options[:since]), Date.today] if @options[:since]
|
|
229
|
+
|
|
230
|
+
date = case positional_args.first
|
|
231
|
+
when nil, 'today' then Date.today
|
|
232
|
+
when 'yesterday' then Date.today - 1
|
|
233
|
+
else parse_date(positional_args.first)
|
|
234
|
+
end
|
|
235
|
+
[date, date]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def emit(entries, query)
|
|
239
|
+
counts = entries.group_by { |workspace, result| [workspace.name, result.channel_id] }
|
|
240
|
+
if @options[:json]
|
|
241
|
+
output_json(query: query, counts: json_counts(counts), results: entries.map do |workspace, result|
|
|
242
|
+
result.to_h.merge(workspace: workspace.name)
|
|
243
|
+
end)
|
|
244
|
+
else
|
|
245
|
+
display_counts(counts, entries)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def json_counts(counts)
|
|
250
|
+
counts.map do |(workspace, channel_id), group|
|
|
251
|
+
result = group.first.last
|
|
252
|
+
{ workspace: workspace, channel_id: channel_id, channel_name: result.channel_name,
|
|
253
|
+
channel_label: destination_label(runner.workspace(workspace), result),
|
|
254
|
+
channel_type: result.channel_type, count: group.size }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def display_counts(counts, entries)
|
|
259
|
+
if entries.empty?
|
|
260
|
+
puts 'No sent messages found.'
|
|
261
|
+
return
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
counts.each_value { |group| display_count(group) }
|
|
265
|
+
puts
|
|
266
|
+
entries.each { |workspace, result| display_entry(workspace, result) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def display_entry(workspace, result)
|
|
270
|
+
options = format_options.merge(workspace_label: true, channel_label: destination_label(workspace, result))
|
|
271
|
+
runner.search_formatter.display_result(result, workspace, options)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def display_count(group)
|
|
275
|
+
workspace, result = group.first
|
|
276
|
+
puts "[#{workspace.name}] #{destination_label(workspace, result)}: #{group.size}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def destination_label(workspace, result)
|
|
280
|
+
runner.sent_channel_label.label(
|
|
281
|
+
workspace: workspace, type: result.channel_type, name: result.channel_name,
|
|
282
|
+
channel_id: result.channel_id, self_user_id: result.user_id, self_username: result.username
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
# rubocop:enable Metrics/ClassLength
|
|
287
|
+
end
|
|
288
|
+
end
|
|
@@ -32,7 +32,7 @@ module Slk
|
|
|
32
32
|
format_author(attachment, lines)
|
|
33
33
|
format_text(att_text, lines, options) if att_text && block_images.empty?
|
|
34
34
|
format_image(attachment, lines, options, message_ts: message_ts, index: index) if image_url
|
|
35
|
-
block_images
|
|
35
|
+
format_block_images(block_images, lines)
|
|
36
36
|
end
|
|
37
37
|
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
38
38
|
|
|
@@ -46,9 +46,13 @@ module Slk
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def format_block_images(images, lines)
|
|
50
|
+
images.each { |img| lines << "> [Image: #{@text_processor.call(img)}]" }
|
|
51
|
+
end
|
|
52
|
+
|
|
49
53
|
def format_author(attachment, lines)
|
|
50
54
|
author = attachment['author_name'] || attachment['author_subname']
|
|
51
|
-
lines << "> #{@output.bold(author)}:" if author
|
|
55
|
+
lines << "> #{@output.bold(@text_processor.call(author))}:" if author
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def format_text(att_text, lines, options)
|
|
@@ -73,7 +77,7 @@ module Slk
|
|
|
73
77
|
else
|
|
74
78
|
image_url = attachment['image_url'] || attachment['thumb_url']
|
|
75
79
|
filename = attachment['title'] || extract_filename(image_url)
|
|
76
|
-
lines << "> [Image: #{filename}]"
|
|
80
|
+
lines << "> [Image: #{@text_processor.call(filename)}]"
|
|
77
81
|
end
|
|
78
82
|
end
|
|
79
83
|
|
|
@@ -23,25 +23,38 @@ module Slk
|
|
|
23
23
|
# Display a single search result
|
|
24
24
|
def display_result(result, workspace, options = {})
|
|
25
25
|
timestamp = @output.blue("[#{format_time(result.timestamp)}]")
|
|
26
|
-
channel = @output.cyan(
|
|
26
|
+
channel = @output.cyan(display_channel_label(result, workspace, options))
|
|
27
27
|
user = @output.bold("#{resolve_user(result, workspace)}:")
|
|
28
28
|
text = prepare_text(result.text, workspace, options)
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
workspace_label = workspace_prefix(workspace, options)
|
|
31
|
+
@output.puts "#{timestamp} #{workspace_label}#{channel} #{user} #{text}"
|
|
32
|
+
display_files(result.files, workspace, options) if result.files&.any?
|
|
32
33
|
end
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
# Channel label used by search timelines and per-channel summaries.
|
|
36
|
+
def channel_label(result, workspace)
|
|
37
|
+
channel_label_for(result.channel_type, result.channel_name, workspace)
|
|
38
|
+
end
|
|
35
39
|
|
|
36
|
-
def
|
|
37
|
-
if
|
|
38
|
-
|
|
39
|
-
@mentions.replace("<@#{result.channel_name}>", workspace)
|
|
40
|
+
def channel_label_for(type, name, workspace)
|
|
41
|
+
if %w[im mpim].include?(type)
|
|
42
|
+
@mentions.replace("<@#{name}>", workspace)
|
|
40
43
|
else
|
|
41
|
-
"##{
|
|
44
|
+
"##{name}"
|
|
42
45
|
end
|
|
43
46
|
end
|
|
44
47
|
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def display_channel_label(result, workspace, options)
|
|
51
|
+
options[:channel_label] || channel_label(result, workspace)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def workspace_prefix(workspace, options)
|
|
55
|
+
options[:workspace_label] ? "#{@output.cyan("[#{workspace.name}]")} " : ''
|
|
56
|
+
end
|
|
57
|
+
|
|
45
58
|
def resolve_user(result, workspace)
|
|
46
59
|
# If we have a username, use it directly
|
|
47
60
|
return result.username if result.username && !result.username.empty?
|
|
@@ -57,9 +70,10 @@ module Slk
|
|
|
57
70
|
@text_processor.process(text, workspace, options)
|
|
58
71
|
end
|
|
59
72
|
|
|
60
|
-
def display_files(files)
|
|
73
|
+
def display_files(files, workspace, options)
|
|
61
74
|
files.each do |file|
|
|
62
|
-
|
|
75
|
+
name = prepare_text(file[:name], workspace, options)
|
|
76
|
+
@output.puts @output.blue("[Image: #{name}]")
|
|
63
77
|
end
|
|
64
78
|
end
|
|
65
79
|
|