agent-slack 0.6.1 → 0.8.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.
package/dist/index.js CHANGED
@@ -1819,6 +1819,41 @@ class SlackApiClient {
1819
1819
  this.web = new WebClient(auth.token);
1820
1820
  }
1821
1821
  }
1822
+ async apiMultipart(method, params = {}) {
1823
+ if (this.auth.auth_type === "standard") {
1824
+ return this.api(method, params);
1825
+ }
1826
+ if (!this.workspaceUrl) {
1827
+ throw new Error("Browser auth requires workspace URL.");
1828
+ }
1829
+ const auth = this.auth;
1830
+ const url = `${this.workspaceUrl.replace(/\/$/, "")}/api/${method}`;
1831
+ const fd = new FormData;
1832
+ fd.append("token", auth.xoxc_token);
1833
+ for (const [k, v] of Object.entries(params)) {
1834
+ if (v !== undefined) {
1835
+ fd.append(k, typeof v === "object" ? JSON.stringify(v) : String(v));
1836
+ }
1837
+ }
1838
+ const response = await fetch(url, {
1839
+ method: "POST",
1840
+ headers: {
1841
+ Cookie: `d=${encodeURIComponent(auth.xoxd_cookie)}`,
1842
+ Origin: "https://app.slack.com",
1843
+ "User-Agent": getUserAgent()
1844
+ },
1845
+ body: fd
1846
+ });
1847
+ const data = await response.json().catch(() => ({}));
1848
+ if (!response.ok) {
1849
+ throw new Error(`Slack HTTP ${response.status} calling ${method}`);
1850
+ }
1851
+ if (!isRecord3(data) || data.ok !== true) {
1852
+ const error = isRecord3(data) && typeof data.error === "string" ? data.error : null;
1853
+ throw new Error(error || `Slack API error calling ${method}`);
1854
+ }
1855
+ return data;
1856
+ }
1822
1857
  async api(method, params = {}) {
1823
1858
  if (this.auth.auth_type === "standard") {
1824
1859
  if (!this.web) {
@@ -3892,191 +3927,661 @@ function toThreadListMessage(m) {
3892
3927
  return rest;
3893
3928
  }
3894
3929
 
3895
- // src/cli/message-read-actions.ts
3896
- async function handleMessageGet(input) {
3897
- const target = parseMsgTarget(input.targetInput);
3898
- if (target.kind === "user") {
3899
- throw new Error("message get does not support user ID targets. Use a channel name, channel ID, or message URL.");
3900
- }
3901
- const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
3902
- return input.ctx.withAutoRefresh({
3903
- workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
3904
- work: async () => {
3905
- if (target.kind === "url") {
3906
- const { ref: ref2 } = target;
3907
- warnOnTruncatedSlackUrl(ref2);
3908
- const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref2.workspace_url);
3909
- const includeReactions2 = Boolean(input.options.includeReactions);
3910
- const msg2 = await fetchMessage(client2, { ref: ref2, includeReactions: includeReactions2 });
3911
- const thread2 = await getThreadSummary(client2, { channelId: ref2.channel_id, msg: msg2 });
3912
- const downloadedPaths2 = await downloadMessageFiles({ auth: auth2, messages: [msg2] });
3913
- const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
3914
- const message2 = toCompactMessage(msg2, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 });
3915
- return pruneEmpty({ message: message2, thread: thread2 });
3916
- }
3917
- const ts = input.options.ts?.trim();
3918
- if (!ts) {
3919
- throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
3920
- }
3921
- await input.ctx.assertWorkspaceSpecifiedForChannelNames({
3922
- workspaceUrl,
3923
- channels: [target.channel]
3924
- });
3925
- const includeReactions = Boolean(input.options.includeReactions);
3926
- const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
3927
- const channelId = await resolveChannelId(client, target.channel);
3928
- const ref = {
3929
- workspace_url: workspace_url ?? workspaceUrl ?? "",
3930
- channel_id: channelId,
3931
- message_ts: ts,
3932
- thread_ts_hint: input.options.threadTs?.trim() || undefined,
3933
- raw: input.targetInput
3934
- };
3935
- const msg = await fetchMessage(client, { ref, includeReactions });
3936
- const thread = await getThreadSummary(client, { channelId, msg });
3937
- const downloadedPaths = await downloadMessageFiles({ auth, messages: [msg] });
3938
- const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
3939
- const message = toCompactMessage(msg, { maxBodyChars, includeReactions, downloadedPaths });
3940
- return pruneEmpty({ message, thread });
3941
- }
3942
- });
3943
- }
3944
- async function handleMessageList(input) {
3945
- const target = parseMsgTarget(input.targetInput);
3946
- if (target.kind === "user") {
3947
- throw new Error("message list does not support user ID targets. Use a channel name, channel ID, or message URL.");
3948
- }
3949
- const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
3950
- return input.ctx.withAutoRefresh({
3951
- workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
3952
- work: async () => {
3953
- const withReactions = parseReactionFilters(input.options.withReaction);
3954
- const withoutReactions = parseReactionFilters(input.options.withoutReaction);
3955
- const hasReactionFilters = withReactions.length > 0 || withoutReactions.length > 0;
3956
- if (target.kind === "url") {
3957
- if (hasReactionFilters) {
3958
- throw new Error("Reaction filters are only supported for channel history mode (not message URL thread mode)");
3930
+ // src/slack/user-cache.ts
3931
+ import { createHash } from "node:crypto";
3932
+ import { join as join13 } from "node:path";
3933
+
3934
+ // src/slack/users.ts
3935
+ async function listUsers(client, options) {
3936
+ const limit = Math.min(Math.max(options?.limit ?? 200, 1), 1000);
3937
+ const includeBots = options?.includeBots ?? false;
3938
+ let next_cursor;
3939
+ const [out, dmMap] = await Promise.all([
3940
+ (async () => {
3941
+ const users = [];
3942
+ let cursor = options?.cursor;
3943
+ while (users.length < limit) {
3944
+ const pageSize = Math.min(200, limit - users.length);
3945
+ const resp = await client.api("users.list", { limit: pageSize, cursor });
3946
+ const members = asArray(resp.members).filter(isRecord);
3947
+ for (const m of members) {
3948
+ const id = getString(m.id);
3949
+ if (!id) {
3950
+ continue;
3951
+ }
3952
+ if (!includeBots && m.is_bot) {
3953
+ continue;
3954
+ }
3955
+ users.push(toCompactUser(m));
3956
+ if (users.length >= limit) {
3957
+ break;
3958
+ }
3959
3959
  }
3960
- const { ref } = target;
3961
- warnOnTruncatedSlackUrl(ref);
3962
- const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref.workspace_url);
3963
- const includeReactions2 = Boolean(input.options.includeReactions);
3964
- const msg = await fetchMessage(client2, { ref, includeReactions: includeReactions2 });
3965
- const rootTs2 = msg.thread_ts ?? msg.ts;
3966
- const threadMessages2 = await fetchThread(client2, {
3967
- channelId: ref.channel_id,
3968
- threadTs: rootTs2,
3969
- includeReactions: includeReactions2
3970
- });
3971
- const downloadedPaths2 = await downloadMessageFiles({ auth: auth2, messages: threadMessages2 });
3972
- const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
3973
- return pruneEmpty({
3974
- messages: threadMessages2.map((m) => toCompactMessage(m, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 })).map(toThreadListMessage)
3975
- });
3976
- }
3977
- const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
3978
- await input.ctx.assertWorkspaceSpecifiedForChannelNames({
3979
- workspaceUrl,
3980
- channels: [target.channel]
3981
- });
3982
- const channelId = await resolveChannelId(client, target.channel);
3983
- const threadTs = input.options.threadTs?.trim();
3984
- const ts = input.options.ts?.trim();
3985
- if (!threadTs && !ts) {
3986
- const includeReactions2 = Boolean(input.options.includeReactions);
3987
- const limit = parseLimit(input.options.limit);
3988
- const oldest = requireOldestWhenReactionFiltersUsed({
3989
- oldest: input.options.oldest,
3990
- withReactions,
3991
- withoutReactions
3992
- });
3993
- const channelMessages = await fetchChannelHistory(client, {
3994
- channelId,
3995
- limit,
3996
- latest: input.options.latest?.trim(),
3997
- oldest,
3998
- includeReactions: includeReactions2 || hasReactionFilters,
3999
- withReactions,
4000
- withoutReactions
4001
- });
4002
- const downloadedPaths2 = await downloadMessageFiles({ auth, messages: channelMessages });
4003
- const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
4004
- return pruneEmpty({
4005
- channel_id: channelId,
4006
- messages: channelMessages.map((m) => toCompactMessage(m, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 }))
4007
- });
4008
- }
4009
- if (hasReactionFilters) {
4010
- throw new Error("Reaction filters are only supported for channel history mode (without --thread-ts/--ts)");
3960
+ const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
3961
+ const next = meta ? getString(meta.next_cursor) : undefined;
3962
+ if (!next) {
3963
+ break;
3964
+ }
3965
+ cursor = next;
3966
+ next_cursor = next;
4011
3967
  }
4012
- const rootTs = threadTs ?? await (async () => {
4013
- const ref = {
4014
- workspace_url: workspace_url ?? workspaceUrl ?? "",
4015
- channel_id: channelId,
4016
- message_ts: ts,
4017
- raw: input.targetInput
4018
- };
4019
- const includeReactions2 = Boolean(input.options.includeReactions);
4020
- const msg = await fetchMessage(client, { ref, includeReactions: includeReactions2 });
4021
- return msg.thread_ts ?? msg.ts;
4022
- })();
4023
- const includeReactions = Boolean(input.options.includeReactions);
4024
- const threadMessages = await fetchThread(client, {
4025
- channelId,
4026
- threadTs: rootTs,
4027
- includeReactions
4028
- });
4029
- const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages });
4030
- const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
4031
- return pruneEmpty({
4032
- messages: threadMessages.map((m) => toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths })).map(toThreadListMessage)
4033
- });
3968
+ return users;
3969
+ })(),
3970
+ fetchDmMap(client)
3971
+ ]);
3972
+ for (const u of out) {
3973
+ const dmId = dmMap.get(u.id);
3974
+ if (dmId) {
3975
+ u.dm_id = dmId;
4034
3976
  }
4035
- });
4036
- }
4037
-
4038
- // src/cli/message-actions.ts
4039
- function parseLimit(raw) {
4040
- if (raw === undefined) {
4041
- return;
4042
3977
  }
4043
- const n = Number.parseInt(raw, 10);
4044
- if (!Number.isFinite(n) || n < 1) {
4045
- throw new Error(`Invalid --limit value "${raw}": must be a positive integer`);
4046
- }
4047
- return n;
3978
+ return { users: out, next_cursor };
4048
3979
  }
4049
- function requireMessageTs(raw) {
4050
- const ts = raw?.trim();
4051
- if (!ts) {
4052
- throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
3980
+ async function getUser(client, input) {
3981
+ const trimmed = input.trim();
3982
+ if (!trimmed) {
3983
+ throw new Error("User is empty");
4053
3984
  }
4054
- return ts;
4055
- }
4056
- function parseReactionFilters(raw) {
4057
- if (!Array.isArray(raw) || raw.length === 0) {
4058
- return [];
3985
+ const userId = await resolveUserId(client, trimmed);
3986
+ if (!userId) {
3987
+ throw new Error(`Could not resolve user: ${input}`);
4059
3988
  }
4060
- const out = [];
4061
- for (const value of raw) {
4062
- const normalized = normalizeSlackReactionName(String(value));
4063
- if (!out.includes(normalized)) {
4064
- out.push(normalized);
4065
- }
3989
+ const resp = await client.api("users.info", { user: userId });
3990
+ const u = isRecord(resp.user) ? resp.user : null;
3991
+ if (!u || !getString(u.id)) {
3992
+ throw new Error("users.info returned no user");
4066
3993
  }
4067
- return out;
3994
+ return toCompactUser(u);
4068
3995
  }
4069
- function requireOldestWhenReactionFiltersUsed(input) {
4070
- const hasReactionFilters = input.withReactions.length > 0 || input.withoutReactions.length > 0;
4071
- const oldest = input.oldest?.trim();
4072
- if (!hasReactionFilters) {
4073
- return oldest;
3996
+ async function resolveUserId(client, input) {
3997
+ const trimmed = input.trim();
3998
+ if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
3999
+ return trimmed;
4074
4000
  }
4075
- if (!oldest) {
4076
- throw new Error('Reaction filters require --oldest "<seconds>.<micros>" to bound scan size. Example: --oldest "1770165109.628379"');
4001
+ const looksLikeEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(trimmed) && !trimmed.startsWith("@");
4002
+ if (looksLikeEmail) {
4003
+ try {
4004
+ const byEmail = await client.api("users.lookupByEmail", { email: trimmed });
4005
+ const user = isRecord(byEmail.user) ? byEmail.user : null;
4006
+ const userId = user ? getString(user.id) : undefined;
4007
+ if (userId) {
4008
+ return userId;
4009
+ }
4010
+ } catch {}
4077
4011
  }
4078
- return oldest;
4079
- }
4012
+ const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
4013
+ if (!handle) {
4014
+ return null;
4015
+ }
4016
+ let cursor;
4017
+ for (;; ) {
4018
+ const resp = await client.api("users.list", { limit: 200, cursor });
4019
+ const members = asArray(resp.members).filter(isRecord);
4020
+ const found = members.find((m) => {
4021
+ if (getString(m.name) === handle) {
4022
+ return true;
4023
+ }
4024
+ if (looksLikeEmail) {
4025
+ const profile = isRecord(m.profile) ? m.profile : null;
4026
+ const email = profile ? getString(profile.email) : undefined;
4027
+ return Boolean(email) && email?.toLowerCase() === trimmed.toLowerCase();
4028
+ }
4029
+ return false;
4030
+ });
4031
+ if (found) {
4032
+ const id = getString(found.id);
4033
+ if (id) {
4034
+ return id;
4035
+ }
4036
+ }
4037
+ const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
4038
+ const next = meta ? getString(meta.next_cursor) : undefined;
4039
+ if (!next) {
4040
+ break;
4041
+ }
4042
+ cursor = next;
4043
+ }
4044
+ return null;
4045
+ }
4046
+ async function fetchDmMap(client) {
4047
+ const map = new Map;
4048
+ let cursor;
4049
+ for (;; ) {
4050
+ const resp = await client.api("conversations.list", {
4051
+ types: "im",
4052
+ limit: 200,
4053
+ cursor
4054
+ });
4055
+ const channels = asArray(resp.channels).filter(isRecord);
4056
+ for (const ch of channels) {
4057
+ const id = getString(ch.id);
4058
+ const user = getString(ch.user);
4059
+ if (id && user) {
4060
+ map.set(user, id);
4061
+ }
4062
+ }
4063
+ const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
4064
+ const next = meta ? getString(meta.next_cursor) : undefined;
4065
+ if (!next) {
4066
+ break;
4067
+ }
4068
+ cursor = next;
4069
+ }
4070
+ return map;
4071
+ }
4072
+ async function getDmChannelForUsers(client, inputs) {
4073
+ if (!inputs || inputs.length === 0) {
4074
+ throw new Error("At least one user is required");
4075
+ }
4076
+ if (inputs.length > 8) {
4077
+ throw new Error("Slack supports a maximum of 8 users in a group DM");
4078
+ }
4079
+ const userIds = [];
4080
+ for (const input of inputs) {
4081
+ const trimmed = input.trim();
4082
+ if (!trimmed) {
4083
+ continue;
4084
+ }
4085
+ const userId = await resolveUserId(client, trimmed);
4086
+ if (!userId) {
4087
+ throw new Error(`Could not resolve user: ${input}`);
4088
+ }
4089
+ userIds.push(userId);
4090
+ }
4091
+ if (userIds.length === 0) {
4092
+ throw new Error("No valid users provided");
4093
+ }
4094
+ const resp = await client.api("conversations.open", { users: userIds.join(",") });
4095
+ const channel = isRecord(resp.channel) ? resp.channel : null;
4096
+ const channelId = channel ? getString(channel.id) : null;
4097
+ if (!channelId) {
4098
+ throw new Error("conversations.open returned no channel");
4099
+ }
4100
+ const channelType = channelId.startsWith("D") ? "dm" : "group_dm";
4101
+ return {
4102
+ user_ids: userIds,
4103
+ dm_channel_id: channelId,
4104
+ channel_type: channelType
4105
+ };
4106
+ }
4107
+ function toCompactUser(u) {
4108
+ const profile = isRecord(u.profile) ? u.profile : {};
4109
+ return {
4110
+ id: getString(u.id) ?? "",
4111
+ name: getString(u.name) ?? undefined,
4112
+ real_name: getString(u.real_name) ?? getString(profile.real_name) ?? undefined,
4113
+ display_name: getString(profile.display_name) ?? undefined,
4114
+ email: getString(profile.email) ?? undefined,
4115
+ title: getString(profile.title) ?? undefined,
4116
+ tz: getString(u.tz) ?? undefined,
4117
+ is_bot: typeof u.is_bot === "boolean" ? u.is_bot : undefined,
4118
+ deleted: typeof u.deleted === "boolean" ? u.deleted : undefined
4119
+ };
4120
+ }
4121
+
4122
+ // src/slack/user-cache.ts
4123
+ var CACHE_VERSION = 1;
4124
+ var USER_TTL_MS = 24 * 60 * 60 * 1000;
4125
+ var USER_ID_PATTERN = /^(U|W)[A-Z0-9]{8,}$/;
4126
+ var USER_MENTION_PATTERN = /<@((U|W)[A-Z0-9]{8,})(?:\|[^>]+)?>/g;
4127
+ async function resolveUsersById(input) {
4128
+ const uniqueIds = dedupeUserIds(input.userIds);
4129
+ if (uniqueIds.length === 0) {
4130
+ return new Map;
4131
+ }
4132
+ const forceRefresh = input.forceRefresh ?? false;
4133
+ const now = Date.now();
4134
+ const workspaceKey = hashWorkspaceUrl(input.workspaceUrl);
4135
+ const isUnknownWorkspace = workspaceKey === "unknown";
4136
+ const cachePath = isUnknownWorkspace ? "" : join13(getAppDir(), `users-cache-${workspaceKey}.json`);
4137
+ const diskCache = cachePath ? await loadCache(cachePath) : { version: CACHE_VERSION, entries: {} };
4138
+ const out = new Map;
4139
+ const missing = [];
4140
+ for (const userId of uniqueIds) {
4141
+ const cached = diskCache.entries[userId];
4142
+ if (!forceRefresh && cached && now - cached.fetched_at < USER_TTL_MS) {
4143
+ out.set(userId, cached.user);
4144
+ continue;
4145
+ }
4146
+ missing.push(userId);
4147
+ }
4148
+ let cacheChanged = false;
4149
+ if (missing.length > 0) {
4150
+ const fetched = [];
4151
+ const concurrency = 5;
4152
+ for (let i = 0;i < missing.length; i += concurrency) {
4153
+ const chunk = missing.slice(i, i + concurrency);
4154
+ const results = await Promise.all(chunk.map(async (userId) => ({ userId, user: await fetchUserById(input.client, userId) })));
4155
+ fetched.push(...results);
4156
+ }
4157
+ for (const item of fetched) {
4158
+ if (!item.user) {
4159
+ continue;
4160
+ }
4161
+ const entry = {
4162
+ fetched_at: now,
4163
+ user: item.user
4164
+ };
4165
+ diskCache.entries[item.userId] = entry;
4166
+ out.set(item.userId, item.user);
4167
+ cacheChanged = true;
4168
+ }
4169
+ }
4170
+ if (cachePath) {
4171
+ const prunedCache = pruneExpiredEntries(diskCache, now);
4172
+ if (Object.keys(diskCache.entries).length !== Object.keys(prunedCache.entries).length) {
4173
+ cacheChanged = true;
4174
+ }
4175
+ if (cacheChanged) {
4176
+ await writeCache(cachePath, prunedCache);
4177
+ }
4178
+ }
4179
+ return out;
4180
+ }
4181
+ function collectReferencedUserIds(messages, options) {
4182
+ const ids = new Set;
4183
+ const includeReactions = options?.includeReactions ?? false;
4184
+ for (const message of messages) {
4185
+ collectUserIdsFromMessage(message, ids, { includeReactions });
4186
+ }
4187
+ return Array.from(ids);
4188
+ }
4189
+ function toReferencedUsers(userIds, usersById) {
4190
+ const out = {};
4191
+ for (const userId of dedupeUserIds(userIds)) {
4192
+ const user = usersById.get(userId);
4193
+ if (!user) {
4194
+ continue;
4195
+ }
4196
+ out[userId] = user;
4197
+ }
4198
+ return Object.keys(out).length > 0 ? out : undefined;
4199
+ }
4200
+ function dedupeUserIds(ids) {
4201
+ const seen = new Set;
4202
+ for (const raw of ids) {
4203
+ const userId = String(raw).trim();
4204
+ if (!USER_ID_PATTERN.test(userId)) {
4205
+ continue;
4206
+ }
4207
+ seen.add(userId);
4208
+ }
4209
+ return Array.from(seen);
4210
+ }
4211
+ function hashWorkspaceUrl(workspaceUrl) {
4212
+ const trimmed = workspaceUrl.trim();
4213
+ if (!trimmed) {
4214
+ return "unknown";
4215
+ }
4216
+ let source = trimmed;
4217
+ try {
4218
+ source = new URL(trimmed).hostname.toLowerCase();
4219
+ } catch {
4220
+ source = trimmed.toLowerCase();
4221
+ }
4222
+ if (!source || source === "unknown") {
4223
+ return "unknown";
4224
+ }
4225
+ return createHash("sha256").update(source).digest("hex").slice(0, 16);
4226
+ }
4227
+ async function loadCache(path) {
4228
+ const file = await readJsonFile(path);
4229
+ if (!file || file.version !== CACHE_VERSION || !isRecord(file.entries)) {
4230
+ return { version: CACHE_VERSION, entries: {} };
4231
+ }
4232
+ const entries = {};
4233
+ for (const [userId, rawEntry] of Object.entries(file.entries)) {
4234
+ if (!USER_ID_PATTERN.test(userId) || !isRecord(rawEntry)) {
4235
+ continue;
4236
+ }
4237
+ const fetchedAt = typeof rawEntry.fetched_at === "number" ? rawEntry.fetched_at : undefined;
4238
+ const user = isRecord(rawEntry.user) ? toCompactUser(rawEntry.user) : null;
4239
+ if (!fetchedAt || !user) {
4240
+ continue;
4241
+ }
4242
+ entries[userId] = { fetched_at: fetchedAt, user };
4243
+ }
4244
+ return {
4245
+ version: CACHE_VERSION,
4246
+ entries
4247
+ };
4248
+ }
4249
+ async function writeCache(path, file) {
4250
+ try {
4251
+ await writeJsonFile(path, file);
4252
+ } catch {}
4253
+ }
4254
+ function pruneExpiredEntries(file, now) {
4255
+ const next = {};
4256
+ for (const [userId, entry] of Object.entries(file.entries)) {
4257
+ if (now - entry.fetched_at >= USER_TTL_MS) {
4258
+ continue;
4259
+ }
4260
+ next[userId] = entry;
4261
+ }
4262
+ return { version: CACHE_VERSION, entries: next };
4263
+ }
4264
+ async function fetchUserById(client, userId) {
4265
+ try {
4266
+ const resp = await client.api("users.info", { user: userId });
4267
+ const user = isRecord(resp.user) ? resp.user : null;
4268
+ if (!user) {
4269
+ return;
4270
+ }
4271
+ return toCompactUser(user);
4272
+ } catch {
4273
+ return;
4274
+ }
4275
+ }
4276
+ function collectUserIdsFromUnknown(value, out) {
4277
+ if (typeof value === "string") {
4278
+ collectMentionUserIds(value, out);
4279
+ return;
4280
+ }
4281
+ if (Array.isArray(value)) {
4282
+ for (const item of value) {
4283
+ collectUserIdsFromUnknown(item, out);
4284
+ }
4285
+ return;
4286
+ }
4287
+ if (!isRecord(value)) {
4288
+ return;
4289
+ }
4290
+ for (const [key, child] of Object.entries(value)) {
4291
+ if ((key === "user" || key === "user_id") && typeof child === "string") {
4292
+ if (USER_ID_PATTERN.test(child)) {
4293
+ out.add(child);
4294
+ }
4295
+ continue;
4296
+ }
4297
+ if (key === "users") {
4298
+ for (const maybeUserId of asArray(child)) {
4299
+ const userId = String(maybeUserId);
4300
+ if (USER_ID_PATTERN.test(userId)) {
4301
+ out.add(userId);
4302
+ }
4303
+ }
4304
+ continue;
4305
+ }
4306
+ collectUserIdsFromUnknown(child, out);
4307
+ }
4308
+ }
4309
+ function collectUserIdsFromMessage(message, out, options) {
4310
+ if (message.user && USER_ID_PATTERN.test(message.user)) {
4311
+ out.add(message.user);
4312
+ }
4313
+ if (typeof message.text === "string") {
4314
+ collectMentionUserIds(message.text, out);
4315
+ }
4316
+ collectUserIdsFromUnknown(message.blocks, out);
4317
+ collectUserIdsFromUnknown(message.attachments, out);
4318
+ if (options.includeReactions) {
4319
+ collectUserIdsFromUnknown(message.reactions, out);
4320
+ }
4321
+ }
4322
+ function collectMentionUserIds(text, out) {
4323
+ USER_MENTION_PATTERN.lastIndex = 0;
4324
+ for (;; ) {
4325
+ const match = USER_MENTION_PATTERN.exec(text);
4326
+ if (!match) {
4327
+ break;
4328
+ }
4329
+ const userId = match[1] ?? "";
4330
+ if (USER_ID_PATTERN.test(userId)) {
4331
+ out.add(userId);
4332
+ }
4333
+ }
4334
+ }
4335
+
4336
+ // src/cli/message-read-actions.ts
4337
+ async function handleMessageGet(input) {
4338
+ const target = parseMsgTarget(input.targetInput);
4339
+ if (target.kind === "user") {
4340
+ throw new Error("message get does not support user ID targets. Use a channel name, channel ID, or message URL.");
4341
+ }
4342
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
4343
+ return input.ctx.withAutoRefresh({
4344
+ workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
4345
+ work: async () => {
4346
+ if (target.kind === "url") {
4347
+ const { ref: ref2 } = target;
4348
+ warnOnTruncatedSlackUrl(ref2);
4349
+ const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref2.workspace_url);
4350
+ const includeReactions2 = Boolean(input.options.includeReactions);
4351
+ const msg2 = await fetchMessage(client2, { ref: ref2, includeReactions: includeReactions2 });
4352
+ const thread2 = await getThreadSummary(client2, { channelId: ref2.channel_id, msg: msg2 });
4353
+ const downloadedPaths2 = await downloadMessageFiles({ auth: auth2, messages: [msg2] });
4354
+ const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
4355
+ const referencedUserIds2 = collectReferencedUserIds([msg2], {
4356
+ includeReactions: includeReactions2
4357
+ });
4358
+ const usersById2 = input.options.resolveUsers || input.options.refreshUsers ? await resolveUsersById({
4359
+ client: client2,
4360
+ workspaceUrl: ref2.workspace_url,
4361
+ userIds: referencedUserIds2,
4362
+ forceRefresh: Boolean(input.options.refreshUsers)
4363
+ }) : new Map;
4364
+ const message2 = toCompactMessage(msg2, {
4365
+ maxBodyChars: maxBodyChars2,
4366
+ includeReactions: includeReactions2,
4367
+ downloadedPaths: downloadedPaths2
4368
+ });
4369
+ return pruneEmpty({
4370
+ message: message2,
4371
+ thread: thread2,
4372
+ referenced_users: toReferencedUsers(referencedUserIds2, usersById2)
4373
+ });
4374
+ }
4375
+ const ts = input.options.ts?.trim();
4376
+ if (!ts) {
4377
+ throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
4378
+ }
4379
+ await input.ctx.assertWorkspaceSpecifiedForChannelNames({
4380
+ workspaceUrl,
4381
+ channels: [target.channel]
4382
+ });
4383
+ const includeReactions = Boolean(input.options.includeReactions);
4384
+ const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
4385
+ const channelId = await resolveChannelId(client, target.channel);
4386
+ const ref = {
4387
+ workspace_url: workspace_url ?? workspaceUrl ?? "",
4388
+ channel_id: channelId,
4389
+ message_ts: ts,
4390
+ thread_ts_hint: input.options.threadTs?.trim() || undefined,
4391
+ raw: input.targetInput
4392
+ };
4393
+ const msg = await fetchMessage(client, { ref, includeReactions });
4394
+ const thread = await getThreadSummary(client, { channelId, msg });
4395
+ const downloadedPaths = await downloadMessageFiles({ auth, messages: [msg] });
4396
+ const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
4397
+ const referencedUserIds = collectReferencedUserIds([msg], {
4398
+ includeReactions
4399
+ });
4400
+ const usersById = input.options.resolveUsers || input.options.refreshUsers ? await resolveUsersById({
4401
+ client,
4402
+ workspaceUrl: ref.workspace_url,
4403
+ userIds: referencedUserIds,
4404
+ forceRefresh: Boolean(input.options.refreshUsers)
4405
+ }) : new Map;
4406
+ const message = toCompactMessage(msg, {
4407
+ maxBodyChars,
4408
+ includeReactions,
4409
+ downloadedPaths
4410
+ });
4411
+ return pruneEmpty({
4412
+ message,
4413
+ thread,
4414
+ referenced_users: toReferencedUsers(referencedUserIds, usersById)
4415
+ });
4416
+ }
4417
+ });
4418
+ }
4419
+ async function handleMessageList(input) {
4420
+ const target = parseMsgTarget(input.targetInput);
4421
+ if (target.kind === "user") {
4422
+ throw new Error("message list does not support user ID targets. Use a channel name, channel ID, or message URL.");
4423
+ }
4424
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
4425
+ return input.ctx.withAutoRefresh({
4426
+ workspaceUrl: target.kind === "url" ? target.ref.workspace_url : workspaceUrl,
4427
+ work: async () => {
4428
+ const withReactions = parseReactionFilters(input.options.withReaction);
4429
+ const withoutReactions = parseReactionFilters(input.options.withoutReaction);
4430
+ const hasReactionFilters = withReactions.length > 0 || withoutReactions.length > 0;
4431
+ if (target.kind === "url") {
4432
+ if (hasReactionFilters) {
4433
+ throw new Error("Reaction filters are only supported for channel history mode (not message URL thread mode)");
4434
+ }
4435
+ const { ref } = target;
4436
+ warnOnTruncatedSlackUrl(ref);
4437
+ const { client: client2, auth: auth2 } = await input.ctx.getClientForWorkspace(ref.workspace_url);
4438
+ const includeReactions2 = Boolean(input.options.includeReactions);
4439
+ const msg = await fetchMessage(client2, { ref, includeReactions: includeReactions2 });
4440
+ const rootTs2 = msg.thread_ts ?? msg.ts;
4441
+ const threadMessages2 = await fetchThread(client2, {
4442
+ channelId: ref.channel_id,
4443
+ threadTs: rootTs2,
4444
+ includeReactions: includeReactions2
4445
+ });
4446
+ const downloadedPaths2 = await downloadMessageFiles({ auth: auth2, messages: threadMessages2 });
4447
+ const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
4448
+ const referencedUserIds2 = collectReferencedUserIds(threadMessages2, {
4449
+ includeReactions: includeReactions2
4450
+ });
4451
+ const usersById2 = input.options.resolveUsers || input.options.refreshUsers ? await resolveUsersById({
4452
+ client: client2,
4453
+ workspaceUrl: ref.workspace_url,
4454
+ userIds: referencedUserIds2,
4455
+ forceRefresh: Boolean(input.options.refreshUsers)
4456
+ }) : new Map;
4457
+ return pruneEmpty({
4458
+ messages: threadMessages2.map((m) => toCompactMessage(m, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 })).map(toThreadListMessage),
4459
+ referenced_users: toReferencedUsers(referencedUserIds2, usersById2)
4460
+ });
4461
+ }
4462
+ const { client, auth, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
4463
+ await input.ctx.assertWorkspaceSpecifiedForChannelNames({
4464
+ workspaceUrl,
4465
+ channels: [target.channel]
4466
+ });
4467
+ const channelId = await resolveChannelId(client, target.channel);
4468
+ const threadTs = input.options.threadTs?.trim();
4469
+ const ts = input.options.ts?.trim();
4470
+ if (!threadTs && !ts) {
4471
+ const includeReactions2 = Boolean(input.options.includeReactions);
4472
+ const limit = parseLimit(input.options.limit);
4473
+ const oldest = requireOldestWhenReactionFiltersUsed({
4474
+ oldest: input.options.oldest,
4475
+ withReactions,
4476
+ withoutReactions
4477
+ });
4478
+ const channelMessages = await fetchChannelHistory(client, {
4479
+ channelId,
4480
+ limit,
4481
+ latest: input.options.latest?.trim(),
4482
+ oldest,
4483
+ includeReactions: includeReactions2 || hasReactionFilters,
4484
+ withReactions,
4485
+ withoutReactions
4486
+ });
4487
+ const downloadedPaths2 = await downloadMessageFiles({ auth, messages: channelMessages });
4488
+ const maxBodyChars2 = Number.parseInt(input.options.maxBodyChars, 10);
4489
+ const referencedUserIds2 = collectReferencedUserIds(channelMessages, {
4490
+ includeReactions: includeReactions2
4491
+ });
4492
+ const usersById2 = input.options.resolveUsers || input.options.refreshUsers ? await resolveUsersById({
4493
+ client,
4494
+ workspaceUrl: workspace_url ?? workspaceUrl ?? "",
4495
+ userIds: referencedUserIds2,
4496
+ forceRefresh: Boolean(input.options.refreshUsers)
4497
+ }) : new Map;
4498
+ return pruneEmpty({
4499
+ channel_id: channelId,
4500
+ messages: channelMessages.map((m) => toCompactMessage(m, { maxBodyChars: maxBodyChars2, includeReactions: includeReactions2, downloadedPaths: downloadedPaths2 })),
4501
+ referenced_users: toReferencedUsers(referencedUserIds2, usersById2)
4502
+ });
4503
+ }
4504
+ if (hasReactionFilters) {
4505
+ throw new Error("Reaction filters are only supported for channel history mode (without --thread-ts/--ts)");
4506
+ }
4507
+ const rootTs = threadTs ?? await (async () => {
4508
+ const ref = {
4509
+ workspace_url: workspace_url ?? workspaceUrl ?? "",
4510
+ channel_id: channelId,
4511
+ message_ts: ts,
4512
+ raw: input.targetInput
4513
+ };
4514
+ const includeReactions2 = Boolean(input.options.includeReactions);
4515
+ const msg = await fetchMessage(client, { ref, includeReactions: includeReactions2 });
4516
+ return msg.thread_ts ?? msg.ts;
4517
+ })();
4518
+ const includeReactions = Boolean(input.options.includeReactions);
4519
+ const threadMessages = await fetchThread(client, {
4520
+ channelId,
4521
+ threadTs: rootTs,
4522
+ includeReactions
4523
+ });
4524
+ const downloadedPaths = await downloadMessageFiles({ auth, messages: threadMessages });
4525
+ const maxBodyChars = Number.parseInt(input.options.maxBodyChars, 10);
4526
+ const referencedUserIds = collectReferencedUserIds(threadMessages, {
4527
+ includeReactions
4528
+ });
4529
+ const usersById = input.options.resolveUsers || input.options.refreshUsers ? await resolveUsersById({
4530
+ client,
4531
+ workspaceUrl: workspace_url ?? workspaceUrl ?? "",
4532
+ userIds: referencedUserIds,
4533
+ forceRefresh: Boolean(input.options.refreshUsers)
4534
+ }) : new Map;
4535
+ return pruneEmpty({
4536
+ messages: threadMessages.map((m) => toCompactMessage(m, { maxBodyChars, includeReactions, downloadedPaths })).map(toThreadListMessage),
4537
+ referenced_users: toReferencedUsers(referencedUserIds, usersById)
4538
+ });
4539
+ }
4540
+ });
4541
+ }
4542
+
4543
+ // src/cli/message-actions.ts
4544
+ function parseLimit(raw) {
4545
+ if (raw === undefined) {
4546
+ return;
4547
+ }
4548
+ const n = Number.parseInt(raw, 10);
4549
+ if (!Number.isFinite(n) || n < 1) {
4550
+ throw new Error(`Invalid --limit value "${raw}": must be a positive integer`);
4551
+ }
4552
+ return n;
4553
+ }
4554
+ function requireMessageTs(raw) {
4555
+ const ts = raw?.trim();
4556
+ if (!ts) {
4557
+ throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
4558
+ }
4559
+ return ts;
4560
+ }
4561
+ function parseReactionFilters(raw) {
4562
+ if (!Array.isArray(raw) || raw.length === 0) {
4563
+ return [];
4564
+ }
4565
+ const out = [];
4566
+ for (const value of raw) {
4567
+ const normalized = normalizeSlackReactionName(String(value));
4568
+ if (!out.includes(normalized)) {
4569
+ out.push(normalized);
4570
+ }
4571
+ }
4572
+ return out;
4573
+ }
4574
+ function requireOldestWhenReactionFiltersUsed(input) {
4575
+ const hasReactionFilters = input.withReactions.length > 0 || input.withoutReactions.length > 0;
4576
+ const oldest = input.oldest?.trim();
4577
+ if (!hasReactionFilters) {
4578
+ return oldest;
4579
+ }
4580
+ if (!oldest) {
4581
+ throw new Error('Reaction filters require --oldest "<seconds>.<micros>" to bound scan size. Example: --oldest "1770165109.628379"');
4582
+ }
4583
+ return oldest;
4584
+ }
4080
4585
  async function sendMessage(input) {
4081
4586
  const target = parseMsgTarget(String(input.targetInput));
4082
4587
  const blocks = input.text ? textToRichTextBlocks(input.text) : null;
@@ -6322,7 +6827,7 @@ function collectOptionValue(value, previous = []) {
6322
6827
  }
6323
6828
  function registerMessageCommand(input) {
6324
6829
  const messageCmd = input.program.command("message").description("Read/write Slack messages (token-efficient JSON)");
6325
- messageCmd.command("get", { isDefault: true }).description("Fetch a single Slack message (with thread summary if any)").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").option("--thread-ts <ts>", "Thread root ts hint (useful for thread permalinks)").option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").action(async (...args) => {
6830
+ messageCmd.command("get", { isDefault: true }).description("Fetch a single Slack message (with thread summary if any)").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--ts <ts>", "Message ts (required when using #channel/channel id)").option("--thread-ts <ts>", "Thread root ts hint (useful for thread permalinks)").option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").option("--resolve-users", "Resolve user IDs to user profiles").option("--refresh-users", "Refresh user profile cache before resolving user IDs (implies --resolve-users)").action(async (...args) => {
6326
6831
  const [targetInput, options] = args;
6327
6832
  try {
6328
6833
  const payload = await handleMessageGet({ ctx: input.ctx, targetInput, options });
@@ -6332,7 +6837,7 @@ function registerMessageCommand(input) {
6332
6837
  process.exitCode = 1;
6333
6838
  }
6334
6839
  });
6335
- messageCmd.command("list").description("List recent channel messages, or fetch a full thread").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--thread-ts <ts>", "Thread root ts (lists thread replies instead of channel history)").option("--ts <ts>", "Message ts (resolve message to its thread)").option("--limit <n>", "Max messages to return for channel history (default 25, max 200)").option("--oldest <ts>", "Only messages after this ts (channel history mode)").option("--latest <ts>", "Only messages before this ts (channel history mode)").option("--with-reaction <emoji>", "Only include messages with this reaction (repeatable; channel history mode; requires --oldest)", collectOptionValue, []).option("--without-reaction <emoji>", "Only include messages without this reaction (repeatable; channel history mode; requires --oldest)", collectOptionValue, []).option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").action(async (...args) => {
6840
+ messageCmd.command("list").description("List recent channel messages, or fetch a full thread").argument("<target>", "Slack message URL, #channel, or channel ID").option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when using #channel/channel id across multiple workspaces)").option("--thread-ts <ts>", "Thread root ts (lists thread replies instead of channel history)").option("--ts <ts>", "Message ts (resolve message to its thread)").option("--limit <n>", "Max messages to return for channel history (default 25, max 200)").option("--oldest <ts>", "Only messages after this ts (channel history mode)").option("--latest <ts>", "Only messages before this ts (channel history mode)").option("--with-reaction <emoji>", "Only include messages with this reaction (repeatable; channel history mode; requires --oldest)", collectOptionValue, []).option("--without-reaction <emoji>", "Only include messages without this reaction (repeatable; channel history mode; requires --oldest)", collectOptionValue, []).option("--max-body-chars <n>", "Max content characters to include (default 8000, -1 for unlimited)", "8000").option("--include-reactions", "Include reactions + reacting users").option("--resolve-users", "Resolve user IDs to user profiles").option("--refresh-users", "Refresh user profile cache before resolving user IDs (implies --resolve-users)").action(async (...args) => {
6336
6841
  const [targetInput, options] = args;
6337
6842
  try {
6338
6843
  const payload = await handleMessageList({ ctx: input.ctx, targetInput, options });
@@ -6524,7 +7029,7 @@ async function channelTokenForSearch(client, channel) {
6524
7029
  } catch {}
6525
7030
  return null;
6526
7031
  }
6527
- async function resolveUserId(client, input) {
7032
+ async function resolveUserId2(client, input) {
6528
7033
  const trimmed = input.trim();
6529
7034
  if (!trimmed) {
6530
7035
  return;
@@ -6712,7 +7217,7 @@ async function searchFilesViaSearchApi(client, input) {
6712
7217
  }
6713
7218
  async function searchFilesInChannelsFallback(client, input) {
6714
7219
  const channelIds = await Promise.all(input.channels.map((c) => resolveChannelId(client, c)));
6715
- const userId = input.user ? await resolveUserId(client, input.user) : undefined;
7220
+ const userId = input.user ? await resolveUserId2(client, input.user) : undefined;
6716
7221
  const queryLower = input.query.trim().toLowerCase();
6717
7222
  const ts_from = input.after ? dateToUnixSeconds(input.after, "start") : undefined;
6718
7223
  const ts_to = input.before ? dateToUnixSeconds(input.before, "end") : undefined;
@@ -6810,7 +7315,7 @@ function passesFileContentTypeFilter(f, contentType) {
6810
7315
  async function searchMessagesViaSearchApi(client, input) {
6811
7316
  const matches = input.rawMatches;
6812
7317
  if (matches.length === 0) {
6813
- return [];
7318
+ return { messages: [] };
6814
7319
  }
6815
7320
  const messageRefs = [];
6816
7321
  for (const m of matches) {
@@ -6834,6 +7339,7 @@ async function searchMessagesViaSearchApi(client, input) {
6834
7339
  }
6835
7340
  const downloadedPaths = {};
6836
7341
  const downloadsDir = input.download ? await ensureDownloadsDir() : null;
7342
+ const resolvedMessages = [];
6837
7343
  const out = [];
6838
7344
  for (const ref of messageRefs) {
6839
7345
  let full = null;
@@ -6872,21 +7378,36 @@ async function searchMessagesViaSearchApi(client, input) {
6872
7378
  if (!passesContentTypeFilter(compact, input.contentType)) {
6873
7379
  continue;
6874
7380
  }
6875
- out.push(stripThreadListFields(compact));
7381
+ resolvedMessages.push(full);
7382
+ out.push(toSearchCompactMessage(compact, ref.permalink));
6876
7383
  if (out.length >= input.limit) {
6877
7384
  break;
6878
7385
  }
6879
7386
  }
6880
- return out;
7387
+ const referencedUserIds = collectReferencedUserIds(resolvedMessages, {
7388
+ includeReactions: false
7389
+ });
7390
+ const shouldResolveUsers = input.resolveUsers || input.refreshUsers;
7391
+ const usersById = shouldResolveUsers ? await resolveUsersById({
7392
+ client,
7393
+ workspaceUrl: input.workspace_url ?? "",
7394
+ userIds: referencedUserIds,
7395
+ forceRefresh: Boolean(input.refreshUsers)
7396
+ }) : new Map;
7397
+ return {
7398
+ messages: out,
7399
+ referenced_users: toReferencedUsers(referencedUserIds, usersById)
7400
+ };
6881
7401
  }
6882
7402
  async function searchMessagesInChannelsFallback(client, input) {
6883
7403
  const channelIds = await Promise.all(input.channels.map((c) => resolveChannelId(client, c)));
6884
7404
  const queryLower = input.query.trim().toLowerCase();
6885
- const userId = input.user ? await resolveUserId(client, input.user) : undefined;
7405
+ const userId = input.user ? await resolveUserId2(client, input.user) : undefined;
6886
7406
  const afterSec = input.after ? dateToUnixSeconds(input.after, "start") : null;
6887
7407
  const beforeSec = input.before ? dateToUnixSeconds(input.before, "end") : null;
6888
7408
  const downloadsDir = input.download ? await ensureDownloadsDir() : null;
6889
7409
  const downloadedPaths = {};
7410
+ const matchedSummaries = [];
6890
7411
  const results = [];
6891
7412
  for (const channelId of channelIds) {
6892
7413
  let cursorLatest;
@@ -6934,9 +7455,22 @@ async function searchMessagesInChannelsFallback(client, input) {
6934
7455
  if (!passesContentTypeFilter(compact, input.contentType)) {
6935
7456
  continue;
6936
7457
  }
6937
- results.push(stripThreadListFields(compact));
7458
+ matchedSummaries.push(summary);
7459
+ results.push(toSearchCompactMessage(compact));
6938
7460
  if (results.length >= input.limit) {
6939
- return results;
7461
+ const referencedUserIds2 = collectReferencedUserIds(matchedSummaries, {
7462
+ includeReactions: false
7463
+ });
7464
+ const usersById2 = await resolveUsersById({
7465
+ client,
7466
+ workspaceUrl: input.workspace_url ?? "",
7467
+ userIds: referencedUserIds2,
7468
+ forceRefresh: Boolean(input.refreshUsers)
7469
+ });
7470
+ return {
7471
+ messages: results,
7472
+ referenced_users: toReferencedUsers(referencedUserIds2, usersById2)
7473
+ };
6940
7474
  }
6941
7475
  }
6942
7476
  if (!cursorLatest) {
@@ -6949,7 +7483,20 @@ async function searchMessagesInChannelsFallback(client, input) {
6949
7483
  }
6950
7484
  }
6951
7485
  }
6952
- return results;
7486
+ const referencedUserIds = collectReferencedUserIds(matchedSummaries, {
7487
+ includeReactions: false
7488
+ });
7489
+ const shouldResolveUsers = input.resolveUsers || input.refreshUsers;
7490
+ const usersById = shouldResolveUsers ? await resolveUsersById({
7491
+ client,
7492
+ workspaceUrl: input.workspace_url ?? "",
7493
+ userIds: referencedUserIds,
7494
+ forceRefresh: Boolean(input.refreshUsers)
7495
+ }) : new Map;
7496
+ return {
7497
+ messages: results,
7498
+ referenced_users: toReferencedUsers(referencedUserIds, usersById)
7499
+ };
6953
7500
  }
6954
7501
  function passesContentTypeFilter(m, contentType) {
6955
7502
  if (contentType === "any") {
@@ -6973,9 +7520,9 @@ function passesContentTypeFilter(m, contentType) {
6973
7520
  }
6974
7521
  return true;
6975
7522
  }
6976
- function stripThreadListFields(m) {
6977
- const { channel_id: _channelId, thread_ts: _threadTs, ...rest } = m;
6978
- return rest;
7523
+ function toSearchCompactMessage(m, permalink) {
7524
+ const { thread_ts: _threadTs, ...rest } = m;
7525
+ return permalink ? { ...rest, permalink } : rest;
6979
7526
  }
6980
7527
  async function downloadFilesForMessage(input) {
6981
7528
  for (const f of input.message.files ?? []) {
@@ -7067,8 +7614,9 @@ async function searchSlack(input) {
7067
7614
  const out = {};
7068
7615
  if (input.options.kind === "messages" || input.options.kind === "all") {
7069
7616
  if (input.options.channels?.length) {
7070
- out.messages = await searchMessagesInChannelsFallback(input.client, {
7617
+ const messageResult = await searchMessagesInChannelsFallback(input.client, {
7071
7618
  auth: input.auth,
7619
+ workspace_url: input.options.workspace_url,
7072
7620
  query: input.options.query,
7073
7621
  channels: input.options.channels,
7074
7622
  user: input.options.user,
@@ -7077,11 +7625,15 @@ async function searchSlack(input) {
7077
7625
  limit,
7078
7626
  maxContentChars,
7079
7627
  contentType,
7080
- download
7628
+ download,
7629
+ resolveUsers: input.options.resolve_users,
7630
+ refreshUsers: input.options.refresh_users
7081
7631
  });
7632
+ out.messages = messageResult.messages;
7633
+ out.referenced_users = messageResult.referenced_users;
7082
7634
  } else {
7083
7635
  const rawMatches = await searchMessagesRaw(input.client, { query: slackQuery, limit });
7084
- out.messages = await searchMessagesViaSearchApi(input.client, {
7636
+ const messageResult = await searchMessagesViaSearchApi(input.client, {
7085
7637
  auth: input.auth,
7086
7638
  workspace_url: input.options.workspace_url,
7087
7639
  slack_query: slackQuery,
@@ -7089,8 +7641,12 @@ async function searchSlack(input) {
7089
7641
  maxContentChars,
7090
7642
  contentType,
7091
7643
  download,
7092
- rawMatches
7644
+ rawMatches,
7645
+ resolveUsers: input.options.resolve_users,
7646
+ refreshUsers: input.options.refresh_users
7093
7647
  });
7648
+ out.messages = messageResult.messages;
7649
+ out.referenced_users = messageResult.referenced_users;
7094
7650
  }
7095
7651
  }
7096
7652
  if (input.options.kind === "files" || input.options.kind === "all") {
@@ -7121,7 +7677,7 @@ async function searchSlack(input) {
7121
7677
 
7122
7678
  // src/cli/search-command.ts
7123
7679
  function addSearchOptions(cmd) {
7124
- return cmd.option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when searching across multiple workspaces)").option("--channel <channel...>", "Channel filter (#name, name, or id). Repeatable.").option("--user <user>", "User filter (@name, name, or user id U...)").option("--after <date>", "Only results after YYYY-MM-DD").option("--before <date>", "Only results before YYYY-MM-DD").option("--content-type <type>", "Filter content type: any|text|image|snippet|file (default any)").option("--limit <n>", "Max results (default 20)", "20").option("--max-content-chars <n>", "Max message content characters (default 4000, -1 for unlimited)", "4000");
7680
+ return cmd.option("--workspace <url>", "Workspace selector (full URL or unique substring; needed when searching across multiple workspaces)").option("--channel <channel...>", "Channel filter (#name, name, or id). Repeatable.").option("--user <user>", "User filter (@name, name, or user id U...)").option("--after <date>", "Only results after YYYY-MM-DD").option("--before <date>", "Only results before YYYY-MM-DD").option("--content-type <type>", "Filter content type: any|text|image|snippet|file (default any)").option("--limit <n>", "Max results (default 20)", "20").option("--max-content-chars <n>", "Max message content characters (default 4000, -1 for unlimited)", "4000").option("--resolve-users", "Resolve user IDs to user profiles").option("--refresh-users", "Refresh user profile cache before resolving referenced users (implies --resolve-users)");
7125
7681
  }
7126
7682
  async function runSearch(input) {
7127
7683
  const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
@@ -7148,39 +7704,604 @@ async function runSearch(input) {
7148
7704
  content_type: contentType,
7149
7705
  limit,
7150
7706
  max_content_chars: maxContentChars,
7151
- download: true
7707
+ download: true,
7708
+ resolve_users: Boolean(input.options.resolveUsers || input.options.refreshUsers),
7709
+ refresh_users: Boolean(input.options.refreshUsers)
7710
+ }
7711
+ });
7712
+ }
7713
+ });
7714
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
7715
+ }
7716
+ function registerSearchCommand(input) {
7717
+ const searchCmd = input.program.command("search").description("Search Slack messages and files (token-efficient JSON)");
7718
+ const create = (spec) => addSearchOptions(searchCmd.command(spec.name).description(spec.desc)).argument("<query>", "Search query").action(async (...args) => {
7719
+ const [query, options] = args;
7720
+ try {
7721
+ await runSearch({ ctx: input.ctx, kind: spec.kind, query, options });
7722
+ } catch (err) {
7723
+ console.error(input.ctx.errorMessage(err));
7724
+ process.exitCode = 1;
7725
+ }
7726
+ });
7727
+ create({ kind: "all", name: "all", desc: "Search messages and files" });
7728
+ create({ kind: "messages", name: "messages", desc: "Search messages" });
7729
+ create({ kind: "files", name: "files", desc: "Search files" });
7730
+ }
7731
+
7732
+ // src/slack/later.ts
7733
+ async function fetchLaterItems(client, options) {
7734
+ const stateFilter = options?.state ?? "in_progress";
7735
+ const limit = options?.limit ?? 20;
7736
+ const maxBodyChars = options?.maxBodyChars ?? 4000;
7737
+ const countsOnly = options?.countsOnly ?? false;
7738
+ let currentCursor = options?.cursor;
7739
+ let allRawItems = [];
7740
+ let counts = {};
7741
+ let nextCursor;
7742
+ while (true) {
7743
+ const resp = await client.api("saved.list", {
7744
+ limit: 50,
7745
+ cursor: currentCursor
7746
+ });
7747
+ if (!currentCursor) {
7748
+ counts = isRecord5(resp.counts) ? resp.counts : {};
7749
+ }
7750
+ const pageRawItems = asArray2(resp.saved_items).filter(isRecord5);
7751
+ allRawItems.push(...pageRawItems);
7752
+ const meta = isRecord5(resp.response_metadata) ? resp.response_metadata : null;
7753
+ nextCursor = meta ? getString5(meta.next_cursor) : undefined;
7754
+ if (countsOnly) {
7755
+ break;
7756
+ }
7757
+ let filtered2 = allRawItems.filter((item) => getString5(item.item_type) === "message");
7758
+ if (stateFilter !== "all") {
7759
+ filtered2 = filtered2.filter((item) => getString5(item.state) === stateFilter);
7760
+ }
7761
+ if (filtered2.length >= limit || !nextCursor) {
7762
+ break;
7763
+ }
7764
+ currentCursor = nextCursor;
7765
+ }
7766
+ const result = {
7767
+ counts: {
7768
+ in_progress: getNumber3(counts.uncompleted_count) ?? 0,
7769
+ archived: getNumber3(counts.archived_count) ?? 0,
7770
+ completed: getNumber3(counts.completed_count) ?? 0,
7771
+ total: getNumber3(counts.total_count) ?? 0
7772
+ },
7773
+ items: [],
7774
+ next_cursor: nextCursor
7775
+ };
7776
+ if (countsOnly) {
7777
+ return result;
7778
+ }
7779
+ let filtered = allRawItems.filter((item) => getString5(item.item_type) === "message");
7780
+ if (stateFilter !== "all") {
7781
+ filtered = filtered.filter((item) => getString5(item.state) === stateFilter);
7782
+ }
7783
+ filtered = filtered.slice(0, limit);
7784
+ result.items = await Promise.all(filtered.map(async (item) => {
7785
+ const channelId = getString5(item.item_id) ?? "";
7786
+ const ts = getString5(item.ts) ?? "";
7787
+ const state = getString5(item.state) ?? "in_progress";
7788
+ const dateSaved = getNumber3(item.date_created) ?? 0;
7789
+ const dateCompleted = getNumber3(item.date_completed);
7790
+ let channelName;
7791
+ let message;
7792
+ try {
7793
+ const info = await client.api("conversations.info", {
7794
+ channel: channelId
7795
+ });
7796
+ const ch = isRecord5(info.channel) ? info.channel : null;
7797
+ if (ch) {
7798
+ channelName = getString5(ch.name) ?? getString5(ch.name_normalized) ?? undefined;
7799
+ if (ch.is_im && !channelName) {
7800
+ const userId = getString5(ch.user);
7801
+ if (userId) {
7802
+ try {
7803
+ const userInfo = await client.api("users.info", { user: userId });
7804
+ const u = isRecord5(userInfo.user) ? userInfo.user : null;
7805
+ const profile = u && isRecord5(u.profile) ? u.profile : null;
7806
+ channelName = getString5(profile?.display_name) || getString5(u?.real_name) || getString5(u?.name) || undefined;
7807
+ } catch {}
7808
+ }
7809
+ }
7810
+ }
7811
+ } catch {}
7812
+ if (ts) {
7813
+ try {
7814
+ const history = await client.api("conversations.history", {
7815
+ channel: channelId,
7816
+ latest: ts,
7817
+ inclusive: true,
7818
+ limit: 1
7819
+ });
7820
+ const msgs = asArray2(history.messages).filter(isRecord5);
7821
+ const msg = msgs.find((m) => getString5(m.ts) === ts);
7822
+ if (msg) {
7823
+ const rendered = renderSlackMessageContent(msg);
7824
+ const content = maxBodyChars >= 0 && rendered.length > maxBodyChars ? `${rendered.slice(0, maxBodyChars)}
7825
+ …` : rendered;
7826
+ message = {
7827
+ author: getString5(msg.user) || getString5(msg.bot_id) ? {
7828
+ user_id: getString5(msg.user) ?? undefined,
7829
+ bot_id: getString5(msg.bot_id) ?? undefined
7830
+ } : undefined,
7831
+ content: content || undefined,
7832
+ thread_ts: getString5(msg.thread_ts) ?? undefined,
7833
+ reply_count: getNumber3(msg.reply_count) ?? undefined
7834
+ };
7835
+ }
7836
+ } catch {}
7837
+ }
7838
+ return {
7839
+ channel_id: channelId,
7840
+ channel_name: channelName,
7841
+ ts,
7842
+ state,
7843
+ date_saved: dateSaved,
7844
+ date_completed: dateCompleted && dateCompleted > 0 ? dateCompleted : undefined,
7845
+ message
7846
+ };
7847
+ }));
7848
+ return result;
7849
+ }
7850
+ async function updateLaterMark(client, input) {
7851
+ await client.apiMultipart("saved.update", {
7852
+ item_id: input.channelId,
7853
+ item_type: "message",
7854
+ ts: input.ts,
7855
+ mark: input.mark
7856
+ });
7857
+ }
7858
+ async function saveLater(client, input) {
7859
+ await client.api("saved.add", {
7860
+ item_id: input.channelId,
7861
+ item_type: "message",
7862
+ ts: input.ts
7863
+ });
7864
+ }
7865
+ async function removeLater(client, input) {
7866
+ await client.api("saved.delete", {
7867
+ item_id: input.channelId,
7868
+ item_type: "message",
7869
+ ts: input.ts
7870
+ });
7871
+ }
7872
+ async function setLaterReminder(client, input) {
7873
+ await client.apiMultipart("saved.update", {
7874
+ item_id: input.channelId,
7875
+ item_type: "message",
7876
+ ts: input.ts,
7877
+ date_due: String(input.dateDue)
7878
+ });
7879
+ }
7880
+ function parseReminderDuration(input) {
7881
+ const now = Math.floor(Date.now() / 1000);
7882
+ const trimmed = input.trim().toLowerCase();
7883
+ const relMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*(m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/);
7884
+ if (relMatch) {
7885
+ const amount = Number.parseFloat(relMatch[1]);
7886
+ const unit = relMatch[2].charAt(0);
7887
+ if (unit === "m") {
7888
+ return now + amount * 60;
7889
+ }
7890
+ if (unit === "h") {
7891
+ return now + amount * 3600;
7892
+ }
7893
+ if (unit === "d") {
7894
+ return now + amount * 86400;
7895
+ }
7896
+ }
7897
+ const tomorrow9am = getNext9am(1);
7898
+ if (trimmed === "tomorrow") {
7899
+ return tomorrow9am;
7900
+ }
7901
+ const dayNames = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
7902
+ const dayIndex = dayNames.indexOf(trimmed);
7903
+ if (dayIndex >= 0) {
7904
+ const today = new Date;
7905
+ const currentDay = today.getDay();
7906
+ let daysUntil = dayIndex - currentDay;
7907
+ if (daysUntil <= 0) {
7908
+ daysUntil += 7;
7909
+ }
7910
+ return getNext9am(daysUntil);
7911
+ }
7912
+ const asNum = Number(trimmed);
7913
+ if (!Number.isNaN(asNum) && asNum > 1e9) {
7914
+ return asNum;
7915
+ }
7916
+ throw new Error(`Invalid duration: "${input}". Use: 30m, 1h, 3h, 2d, tomorrow, monday, or a unix timestamp.`);
7917
+ }
7918
+ function getNext9am(daysFromNow) {
7919
+ const date = new Date;
7920
+ date.setDate(date.getDate() + daysFromNow);
7921
+ date.setHours(9, 0, 0, 0);
7922
+ return Math.floor(date.getTime() / 1000);
7923
+ }
7924
+ function isRecord5(value) {
7925
+ return typeof value === "object" && value !== null;
7926
+ }
7927
+ function asArray2(value) {
7928
+ return Array.isArray(value) ? value : [];
7929
+ }
7930
+ function getString5(value) {
7931
+ return typeof value === "string" ? value : undefined;
7932
+ }
7933
+ function getNumber3(value) {
7934
+ return typeof value === "number" ? value : undefined;
7935
+ }
7936
+
7937
+ // src/cli/later-command.ts
7938
+ function resolveTargetRef(input) {
7939
+ const { targetInput, options, ctx } = input;
7940
+ const target = parseMsgTarget(targetInput);
7941
+ if (target.kind === "url") {
7942
+ return {
7943
+ workspaceUrl: target.ref.workspace_url,
7944
+ getChannelAndTs: async () => ({
7945
+ channelId: target.ref.channel_id,
7946
+ ts: target.ref.message_ts
7947
+ })
7948
+ };
7949
+ }
7950
+ const workspaceUrl = ctx.effectiveWorkspaceUrl(options.workspace);
7951
+ const ts = options.ts?.trim();
7952
+ if (!ts) {
7953
+ throw new Error('When targeting a channel, you must pass --ts "<seconds>.<micros>"');
7954
+ }
7955
+ return {
7956
+ workspaceUrl,
7957
+ getChannelAndTs: async (client) => {
7958
+ const channelId = await resolveChannelId(client, target.kind === "channel" ? target.channel : targetInput);
7959
+ return { channelId, ts };
7960
+ }
7961
+ };
7962
+ }
7963
+ function registerLaterCommand(input) {
7964
+ const laterCmd = input.program.command("later").description("Manage saved-for-later messages (Slack's Later tab)");
7965
+ laterCmd.command("list", { isDefault: true }).description("List saved-for-later messages").option("--workspace <url>", "Workspace URL (defaults to your configured workspace)").option("--state <state>", "Filter by state: in_progress (default), archived, completed, all", "in_progress").option("--limit <n>", "Max items to show (default 20)", "20").option("--max-body-chars <n>", "Max content characters per message (default 4000, -1 for unlimited)", "4000").option("--counts-only", "Only show counts per state, skip message content").action(async (options) => {
7966
+ try {
7967
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
7968
+ const state = parseState(options.state ?? "in_progress");
7969
+ const payload = await input.ctx.withAutoRefresh({
7970
+ workspaceUrl,
7971
+ work: async () => {
7972
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
7973
+ return fetchLaterItems(client, {
7974
+ state,
7975
+ limit: Number.parseInt(options.limit ?? "20", 10),
7976
+ maxBodyChars: Number.parseInt(options.maxBodyChars ?? "4000", 10),
7977
+ countsOnly: options.countsOnly
7978
+ });
7979
+ }
7980
+ });
7981
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
7982
+ } catch (err) {
7983
+ console.error(input.ctx.errorMessage(err));
7984
+ process.exitCode = 1;
7985
+ }
7986
+ });
7987
+ laterCmd.command("complete").description("Mark a saved message as completed").argument("<target>", "Slack message URL or channel ID").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
7988
+ const [targetInput, options] = args;
7989
+ try {
7990
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
7991
+ await input.ctx.withAutoRefresh({
7992
+ workspaceUrl: ref.workspaceUrl,
7993
+ work: async () => {
7994
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
7995
+ const { channelId, ts } = await ref.getChannelAndTs(client);
7996
+ await updateLaterMark(client, { channelId, ts, mark: "completed" });
7997
+ }
7998
+ });
7999
+ console.log(JSON.stringify({ ok: true }));
8000
+ } catch (err) {
8001
+ console.error(input.ctx.errorMessage(err));
8002
+ process.exitCode = 1;
8003
+ }
8004
+ });
8005
+ laterCmd.command("archive").description("Archive a saved message").argument("<target>", "Slack message URL or channel ID").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
8006
+ const [targetInput, options] = args;
8007
+ try {
8008
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
8009
+ await input.ctx.withAutoRefresh({
8010
+ workspaceUrl: ref.workspaceUrl,
8011
+ work: async () => {
8012
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
8013
+ const { channelId, ts } = await ref.getChannelAndTs(client);
8014
+ await updateLaterMark(client, { channelId, ts, mark: "archived" });
8015
+ }
8016
+ });
8017
+ console.log(JSON.stringify({ ok: true }));
8018
+ } catch (err) {
8019
+ console.error(input.ctx.errorMessage(err));
8020
+ process.exitCode = 1;
8021
+ }
8022
+ });
8023
+ laterCmd.command("reopen").description("Move a saved message back to in-progress").argument("<target>", "Slack message URL or channel ID").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
8024
+ const [targetInput, options] = args;
8025
+ try {
8026
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
8027
+ await input.ctx.withAutoRefresh({
8028
+ workspaceUrl: ref.workspaceUrl,
8029
+ work: async () => {
8030
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
8031
+ const { channelId, ts } = await ref.getChannelAndTs(client);
8032
+ await Promise.allSettled([
8033
+ updateLaterMark(client, { channelId, ts, mark: "uncompleted" }),
8034
+ updateLaterMark(client, { channelId, ts, mark: "unarchived" })
8035
+ ]);
8036
+ }
8037
+ });
8038
+ console.log(JSON.stringify({ ok: true }));
8039
+ } catch (err) {
8040
+ console.error(input.ctx.errorMessage(err));
8041
+ process.exitCode = 1;
8042
+ }
8043
+ });
8044
+ laterCmd.command("save").description("Save a message for later").argument("<target>", "Slack message URL or channel ID").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
8045
+ const [targetInput, options] = args;
8046
+ try {
8047
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
8048
+ await input.ctx.withAutoRefresh({
8049
+ workspaceUrl: ref.workspaceUrl,
8050
+ work: async () => {
8051
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
8052
+ const { channelId, ts } = await ref.getChannelAndTs(client);
8053
+ await saveLater(client, { channelId, ts });
8054
+ }
8055
+ });
8056
+ console.log(JSON.stringify({ ok: true }));
8057
+ } catch (err) {
8058
+ console.error(input.ctx.errorMessage(err));
8059
+ process.exitCode = 1;
8060
+ }
8061
+ });
8062
+ laterCmd.command("remove").description("Remove a message from Later entirely").argument("<target>", "Slack message URL or channel ID").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
8063
+ const [targetInput, options] = args;
8064
+ try {
8065
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
8066
+ await input.ctx.withAutoRefresh({
8067
+ workspaceUrl: ref.workspaceUrl,
8068
+ work: async () => {
8069
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
8070
+ const { channelId, ts } = await ref.getChannelAndTs(client);
8071
+ await removeLater(client, { channelId, ts });
8072
+ }
8073
+ });
8074
+ console.log(JSON.stringify({ ok: true }));
8075
+ } catch (err) {
8076
+ console.error(input.ctx.errorMessage(err));
8077
+ process.exitCode = 1;
8078
+ }
8079
+ });
8080
+ laterCmd.command("remind").description("Set a reminder for a saved message").argument("<target>", "Slack message URL or channel ID").requiredOption("--in <duration>", "When to remind: 30m, 1h, 3h, 2d, tomorrow, monday, etc.").option("--workspace <url>", "Workspace URL").option("--ts <ts>", "Message ts (required when using channel ID)").action(async (...args) => {
8081
+ const [targetInput, options] = args;
8082
+ try {
8083
+ const ref = resolveTargetRef({ targetInput, options, ctx: input.ctx });
8084
+ const reminderTime = parseReminderDuration(options.in);
8085
+ await input.ctx.withAutoRefresh({
8086
+ workspaceUrl: ref.workspaceUrl,
8087
+ work: async () => {
8088
+ const { client } = await input.ctx.getClientForWorkspace(ref.workspaceUrl);
8089
+ const { channelId, ts } = await ref.getChannelAndTs(client);
8090
+ await setLaterReminder(client, { channelId, ts, dateDue: reminderTime });
8091
+ }
8092
+ });
8093
+ console.log(JSON.stringify({
8094
+ ok: true,
8095
+ remind_at: reminderTime
8096
+ }));
8097
+ } catch (err) {
8098
+ console.error(input.ctx.errorMessage(err));
8099
+ process.exitCode = 1;
8100
+ }
8101
+ });
8102
+ }
8103
+ function parseState(value) {
8104
+ const v = value.toLowerCase().trim();
8105
+ if (v === "in_progress" || v === "in-progress" || v === "active" || v === "open") {
8106
+ return "in_progress";
8107
+ }
8108
+ if (v === "archived" || v === "archive") {
8109
+ return "archived";
8110
+ }
8111
+ if (v === "completed" || v === "complete" || v === "done") {
8112
+ return "completed";
8113
+ }
8114
+ if (v === "all") {
8115
+ return "all";
8116
+ }
8117
+ return "in_progress";
8118
+ }
8119
+
8120
+ // src/slack/unreads.ts
8121
+ async function fetchUnreads(client, options) {
8122
+ const includeMessages = options?.includeMessages ?? true;
8123
+ const maxMessages = options?.maxMessagesPerChannel ?? 10;
8124
+ const maxBodyChars = options?.maxBodyChars ?? 4000;
8125
+ const skipSystem = options?.skipSystemMessages ?? true;
8126
+ const resp = await client.api("client.counts", {
8127
+ thread_count_by_channel: true
8128
+ });
8129
+ const channels = asArray3(resp.channels).filter(isRecord6);
8130
+ const mpims = asArray3(resp.mpims).filter(isRecord6);
8131
+ const ims = asArray3(resp.ims).filter(isRecord6);
8132
+ const allEntries = [
8133
+ ...channels.map((c) => ({ ...c, type: "channel" })),
8134
+ ...mpims.map((c) => ({ ...c, type: "mpim" })),
8135
+ ...ims.map((c) => ({ ...c, type: "dm" }))
8136
+ ];
8137
+ const withUnreads = allEntries.filter((c) => c.has_unreads);
8138
+ const channelInfos = await Promise.all(withUnreads.map(async (entry) => {
8139
+ const channelInfoPromise = (async () => {
8140
+ let name;
8141
+ let { type } = entry;
8142
+ try {
8143
+ const info = await client.api("conversations.info", {
8144
+ channel: entry.id
8145
+ });
8146
+ const ch = isRecord6(info.channel) ? info.channel : null;
8147
+ if (ch) {
8148
+ name = getString6(ch.name) ?? getString6(ch.name_normalized) ?? undefined;
8149
+ if (ch.is_im) {
8150
+ type = "dm";
8151
+ const userId = getString6(ch.user);
8152
+ if (userId && !name) {
8153
+ try {
8154
+ const userInfo = await client.api("users.info", { user: userId });
8155
+ const u = isRecord6(userInfo.user) ? userInfo.user : null;
8156
+ const profile = u && isRecord6(u.profile) ? u.profile : null;
8157
+ name = getString6(profile?.display_name) || getString6(u?.real_name) || getString6(u?.name) || undefined;
8158
+ } catch {}
8159
+ }
8160
+ } else if (ch.is_mpim) {
8161
+ type = "mpim";
8162
+ } else if (ch.is_group || ch.is_private) {
8163
+ type = "channel";
8164
+ } else {
8165
+ type = "channel";
8166
+ }
7152
8167
  }
7153
- });
8168
+ } catch {}
8169
+ return { name, type };
8170
+ })();
8171
+ const historyPromise = (async () => {
8172
+ let messages;
8173
+ let unreadCount = entry.unread_count_display ?? entry.unread_count ?? (entry.has_unreads ? 1 : 0);
8174
+ if (includeMessages && entry.last_read) {
8175
+ try {
8176
+ const history = await client.api("conversations.history", {
8177
+ channel: entry.id,
8178
+ oldest: entry.last_read,
8179
+ limit: maxMessages,
8180
+ inclusive: false
8181
+ });
8182
+ let msgs = asArray3(history.messages).filter(isRecord6);
8183
+ if (skipSystem) {
8184
+ msgs = msgs.filter((m) => {
8185
+ const subtype = getString6(m.subtype);
8186
+ if (!subtype) {
8187
+ return true;
8188
+ }
8189
+ const systemSubtypes = [
8190
+ "channel_join",
8191
+ "channel_leave",
8192
+ "channel_topic",
8193
+ "channel_purpose",
8194
+ "channel_name",
8195
+ "channel_archive",
8196
+ "channel_unarchive",
8197
+ "group_join",
8198
+ "group_leave",
8199
+ "group_topic",
8200
+ "group_purpose",
8201
+ "group_name",
8202
+ "group_archive",
8203
+ "group_unarchive"
8204
+ ];
8205
+ return !systemSubtypes.includes(subtype);
8206
+ });
8207
+ }
8208
+ if (entry.unread_count_display === undefined && entry.unread_count === undefined) {
8209
+ unreadCount = msgs.length;
8210
+ if (history.has_more) {
8211
+ unreadCount = Math.max(unreadCount, 2);
8212
+ }
8213
+ }
8214
+ messages = msgs.map((m) => {
8215
+ const rendered = renderSlackMessageContent(m);
8216
+ const content = maxBodyChars >= 0 && rendered.length > maxBodyChars ? `${rendered.slice(0, maxBodyChars)}
8217
+ ...` : rendered;
8218
+ return {
8219
+ ts: getString6(m.ts) ?? "",
8220
+ author: getString6(m.user) || getString6(m.bot_id) ? {
8221
+ user_id: getString6(m.user) ?? undefined,
8222
+ bot_id: getString6(m.bot_id) ?? undefined
8223
+ } : undefined,
8224
+ content: content || undefined,
8225
+ thread_ts: getString6(m.thread_ts) ?? undefined,
8226
+ reply_count: getNumber4(m.reply_count) ?? undefined
8227
+ };
8228
+ });
8229
+ messages.sort((a, b) => Number.parseFloat(a.ts) - Number.parseFloat(b.ts));
8230
+ } catch {}
8231
+ }
8232
+ return { messages, unreadCount };
8233
+ })();
8234
+ const [channelData, historyData] = await Promise.all([channelInfoPromise, historyPromise]);
8235
+ return {
8236
+ channel_id: entry.id,
8237
+ channel_name: channelData.name,
8238
+ channel_type: channelData.type,
8239
+ unread_count: historyData.unreadCount,
8240
+ mention_count: entry.mention_count ?? 0,
8241
+ messages: historyData.messages
8242
+ };
8243
+ }));
8244
+ channelInfos.sort((a, b) => {
8245
+ if (a.mention_count !== b.mention_count) {
8246
+ return b.mention_count - a.mention_count;
7154
8247
  }
8248
+ return b.unread_count - a.unread_count;
7155
8249
  });
7156
- console.log(JSON.stringify(pruneEmpty(payload), null, 2));
8250
+ const threads = isRecord6(resp.threads) ? resp.threads : null;
8251
+ const threadInfo = threads?.has_unreads ? {
8252
+ has_unreads: true,
8253
+ mention_count: threads.mention_count ?? 0
8254
+ } : null;
8255
+ return { channels: channelInfos, threads: threadInfo };
8256
+ }
8257
+ function isRecord6(value) {
8258
+ return typeof value === "object" && value !== null;
7157
8259
  }
7158
- function registerSearchCommand(input) {
7159
- const searchCmd = input.program.command("search").description("Search Slack messages and files (token-efficient JSON)");
7160
- const create = (spec) => addSearchOptions(searchCmd.command(spec.name).description(spec.desc)).argument("<query>", "Search query").action(async (...args) => {
7161
- const [query, options] = args;
8260
+ function asArray3(value) {
8261
+ return Array.isArray(value) ? value : [];
8262
+ }
8263
+ function getString6(value) {
8264
+ return typeof value === "string" ? value : undefined;
8265
+ }
8266
+ function getNumber4(value) {
8267
+ return typeof value === "number" ? value : undefined;
8268
+ }
8269
+
8270
+ // src/cli/unreads-command.ts
8271
+ function registerUnreadsCommand(input) {
8272
+ input.program.command("unreads").description("Show all unread messages across channels, DMs, and threads").option("--workspace <url>", "Workspace URL (defaults to your configured workspace)").option("--counts-only", "Only show unread counts, do not fetch message content").option("--max-messages <n>", "Max unread messages to fetch per channel (default 10)", "10").option("--max-body-chars <n>", "Max content characters per message (default 4000, -1 for unlimited)", "4000").option("--include-system", "Include system messages (joins, leaves, topic changes, etc.)").action(async (options) => {
7162
8273
  try {
7163
- await runSearch({ ctx: input.ctx, kind: spec.kind, query, options });
8274
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
8275
+ const payload = await input.ctx.withAutoRefresh({
8276
+ workspaceUrl,
8277
+ work: async () => {
8278
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
8279
+ return fetchUnreads(client, {
8280
+ includeMessages: !options.countsOnly,
8281
+ maxMessagesPerChannel: Number.parseInt(options.maxMessages ?? "10", 10),
8282
+ maxBodyChars: Number.parseInt(options.maxBodyChars ?? "4000", 10),
8283
+ skipSystemMessages: !options.includeSystem
8284
+ });
8285
+ }
8286
+ });
8287
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
7164
8288
  } catch (err) {
7165
8289
  console.error(input.ctx.errorMessage(err));
7166
8290
  process.exitCode = 1;
7167
8291
  }
7168
8292
  });
7169
- create({ kind: "all", name: "all", desc: "Search messages and files" });
7170
- create({ kind: "messages", name: "messages", desc: "Search messages" });
7171
- create({ kind: "files", name: "files", desc: "Search files" });
7172
8293
  }
7173
8294
 
7174
8295
  // src/lib/update.ts
7175
8296
  import { execSync as execSync2 } from "node:child_process";
7176
- import { createHash } from "node:crypto";
8297
+ import { createHash as createHash2 } from "node:crypto";
7177
8298
  import { chmod, copyFile as copyFile2, mkdir as mkdir5, readFile as readFile7, rename, rm as rm3, writeFile as writeFile4 } from "node:fs/promises";
7178
8299
  import { tmpdir as tmpdir5 } from "node:os";
7179
- import { basename as basename3, join as join13 } from "node:path";
8300
+ import { basename as basename3, join as join14 } from "node:path";
7180
8301
  var REPO = "stablyai/agent-slack";
7181
8302
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
7182
8303
  function getCachePath() {
7183
- return join13(getAppDir(), "update-check.json");
8304
+ return join14(getAppDir(), "update-check.json");
7184
8305
  }
7185
8306
  function compareSemver(a, b) {
7186
8307
  const pa = a.replace(/^v/, "").split(".").map(Number);
@@ -7276,16 +8397,16 @@ function detectPlatformAsset() {
7276
8397
  }
7277
8398
  async function sha256(filePath) {
7278
8399
  const data = await readFile7(filePath);
7279
- return createHash("sha256").update(data).digest("hex");
8400
+ return createHash2("sha256").update(data).digest("hex");
7280
8401
  }
7281
8402
  async function performUpdate(latest) {
7282
8403
  const asset = detectPlatformAsset();
7283
8404
  const tag = `v${latest}`;
7284
8405
  const baseUrl = `https://github.com/${REPO}/releases/download/${tag}`;
7285
- const tmp = join13(tmpdir5(), `agent-slack-update-${Date.now()}`);
8406
+ const tmp = join14(tmpdir5(), `agent-slack-update-${Date.now()}`);
7286
8407
  await mkdir5(tmp, { recursive: true });
7287
- const binTmp = join13(tmp, asset);
7288
- const sumsTmp = join13(tmp, "checksums-sha256.txt");
8408
+ const binTmp = join14(tmp, asset);
8409
+ const sumsTmp = join14(tmp, "checksums-sha256.txt");
7289
8410
  try {
7290
8411
  const [binResp, sumsResp] = await Promise.all([
7291
8412
  fetch(`${baseUrl}/${asset}`, { signal: AbortSignal.timeout(120000) }),
@@ -7300,288 +8421,100 @@ async function performUpdate(latest) {
7300
8421
  await writeFile4(binTmp, Buffer.from(await binResp.arrayBuffer()));
7301
8422
  const sumsText = await sumsResp.text();
7302
8423
  await writeFile4(sumsTmp, sumsText);
7303
- const expected = sumsText.split(`
7304
- `).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1] === asset)?.[0];
7305
- if (!expected) {
7306
- return { success: false, message: `Checksum not found for ${asset} in release checksums` };
7307
- }
7308
- const actual = await sha256(binTmp);
7309
- if (actual !== expected) {
7310
- return { success: false, message: `Checksum mismatch: expected ${expected}, got ${actual}` };
7311
- }
7312
- const currentBin = process.execPath;
7313
- const backupPath = `${currentBin}.bak`;
7314
- await rename(currentBin, backupPath);
7315
- try {
7316
- await copyFile2(binTmp, currentBin);
7317
- await chmod(currentBin, 493);
7318
- await rm3(backupPath, { force: true });
7319
- } catch (err) {
7320
- try {
7321
- await rename(backupPath, currentBin);
7322
- } catch {}
7323
- throw err;
7324
- }
7325
- return { success: true, message: `Updated agent-slack to ${latest}` };
7326
- } finally {
7327
- await rm3(tmp, { recursive: true, force: true }).catch(() => {});
7328
- }
7329
- }
7330
- async function backgroundUpdateCheck() {
7331
- if (process.env.AGENT_SLACK_NO_UPDATE_CHECK === "1") {
7332
- return;
7333
- }
7334
- try {
7335
- const result = await checkForUpdate();
7336
- if (result?.update_available) {
7337
- const cmd = getUpdateCommand();
7338
- process.stderr.write(`
7339
- Update available: ${result.current} → ${result.latest}. Run "${cmd}" to upgrade.
7340
- `);
7341
- }
7342
- } catch {}
7343
- }
7344
-
7345
- // src/cli/update-command.ts
7346
- function registerUpdateCommand(input) {
7347
- input.program.command("update").description("Update agent-slack to the latest version").option("--check", "Only check for updates (don't install)").action(async (...args) => {
7348
- const [options] = args;
7349
- const method = detectInstallMethod();
7350
- try {
7351
- const result = await checkForUpdate(true);
7352
- if (!result) {
7353
- console.error("Could not check for updates. Check your network connection.");
7354
- process.exitCode = 1;
7355
- return;
7356
- }
7357
- if (!result.update_available) {
7358
- console.log(JSON.stringify(pruneEmpty({ ...result, install_method: method, status: "up_to_date" }), null, 2));
7359
- return;
7360
- }
7361
- if (options.check) {
7362
- console.log(JSON.stringify(pruneEmpty({
7363
- ...result,
7364
- install_method: method,
7365
- update_command: getUpdateCommand(method),
7366
- status: "update_available"
7367
- }), null, 2));
7368
- return;
7369
- }
7370
- process.stderr.write(`Updating agent-slack ${result.current} → ${result.latest}...
7371
- `);
7372
- let outcome;
7373
- if (method === "npm" || method === "bun") {
7374
- process.stderr.write(`Detected install method: ${method}
7375
- `);
7376
- outcome = performPackageManagerUpdate(method);
7377
- } else {
7378
- outcome = await performUpdate(result.latest);
7379
- }
7380
- if (!outcome.success) {
7381
- console.error(outcome.message);
7382
- process.exitCode = 1;
7383
- return;
7384
- }
7385
- console.log(JSON.stringify(pruneEmpty({
7386
- status: "updated",
7387
- install_method: method,
7388
- previous_version: result.current,
7389
- new_version: result.latest,
7390
- message: outcome.message
7391
- }), null, 2));
7392
- } catch (err) {
7393
- console.error(err instanceof Error ? err.message : String(err));
7394
- process.exitCode = 1;
7395
- }
7396
- });
7397
- }
7398
-
7399
- // src/slack/users.ts
7400
- async function listUsers(client, options) {
7401
- const limit = Math.min(Math.max(options?.limit ?? 200, 1), 1000);
7402
- const includeBots = options?.includeBots ?? false;
7403
- let next_cursor;
7404
- const [out, dmMap] = await Promise.all([
7405
- (async () => {
7406
- const users = [];
7407
- let cursor = options?.cursor;
7408
- while (users.length < limit) {
7409
- const pageSize = Math.min(200, limit - users.length);
7410
- const resp = await client.api("users.list", { limit: pageSize, cursor });
7411
- const members = asArray(resp.members).filter(isRecord);
7412
- for (const m of members) {
7413
- const id = getString(m.id);
7414
- if (!id) {
7415
- continue;
7416
- }
7417
- if (!includeBots && m.is_bot) {
7418
- continue;
7419
- }
7420
- users.push(toCompactUser(m));
7421
- if (users.length >= limit) {
7422
- break;
7423
- }
7424
- }
7425
- const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
7426
- const next = meta ? getString(meta.next_cursor) : undefined;
7427
- if (!next) {
7428
- break;
7429
- }
7430
- cursor = next;
7431
- next_cursor = next;
7432
- }
7433
- return users;
7434
- })(),
7435
- fetchDmMap(client)
7436
- ]);
7437
- for (const u of out) {
7438
- const dmId = dmMap.get(u.id);
7439
- if (dmId) {
7440
- u.dm_id = dmId;
7441
- }
7442
- }
7443
- return { users: out, next_cursor };
7444
- }
7445
- async function getUser(client, input) {
7446
- const trimmed = input.trim();
7447
- if (!trimmed) {
7448
- throw new Error("User is empty");
7449
- }
7450
- const userId = await resolveUserId2(client, trimmed);
7451
- if (!userId) {
7452
- throw new Error(`Could not resolve user: ${input}`);
7453
- }
7454
- const resp = await client.api("users.info", { user: userId });
7455
- const u = isRecord(resp.user) ? resp.user : null;
7456
- if (!u || !getString(u.id)) {
7457
- throw new Error("users.info returned no user");
7458
- }
7459
- return toCompactUser(u);
7460
- }
7461
- async function resolveUserId2(client, input) {
7462
- const trimmed = input.trim();
7463
- if (/^U[A-Z0-9]{8,}$/.test(trimmed)) {
7464
- return trimmed;
7465
- }
7466
- const looksLikeEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(trimmed) && !trimmed.startsWith("@");
7467
- if (looksLikeEmail) {
7468
- try {
7469
- const byEmail = await client.api("users.lookupByEmail", { email: trimmed });
7470
- const user = isRecord(byEmail.user) ? byEmail.user : null;
7471
- const userId = user ? getString(user.id) : undefined;
7472
- if (userId) {
7473
- return userId;
7474
- }
7475
- } catch {}
7476
- }
7477
- const handle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
7478
- if (!handle) {
7479
- return null;
7480
- }
7481
- let cursor;
7482
- for (;; ) {
7483
- const resp = await client.api("users.list", { limit: 200, cursor });
7484
- const members = asArray(resp.members).filter(isRecord);
7485
- const found = members.find((m) => {
7486
- if (getString(m.name) === handle) {
7487
- return true;
7488
- }
7489
- if (looksLikeEmail) {
7490
- const profile = isRecord(m.profile) ? m.profile : null;
7491
- const email = profile ? getString(profile.email) : undefined;
7492
- return Boolean(email) && email?.toLowerCase() === trimmed.toLowerCase();
7493
- }
7494
- return false;
7495
- });
7496
- if (found) {
7497
- const id = getString(found.id);
7498
- if (id) {
7499
- return id;
7500
- }
7501
- }
7502
- const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
7503
- const next = meta ? getString(meta.next_cursor) : undefined;
7504
- if (!next) {
7505
- break;
8424
+ const expected = sumsText.split(`
8425
+ `).map((line) => line.trim().split(/\s+/)).find((parts) => parts[1] === asset)?.[0];
8426
+ if (!expected) {
8427
+ return { success: false, message: `Checksum not found for ${asset} in release checksums` };
7506
8428
  }
7507
- cursor = next;
7508
- }
7509
- return null;
7510
- }
7511
- async function fetchDmMap(client) {
7512
- const map = new Map;
7513
- let cursor;
7514
- for (;; ) {
7515
- const resp = await client.api("conversations.list", {
7516
- types: "im",
7517
- limit: 200,
7518
- cursor
7519
- });
7520
- const channels = asArray(resp.channels).filter(isRecord);
7521
- for (const ch of channels) {
7522
- const id = getString(ch.id);
7523
- const user = getString(ch.user);
7524
- if (id && user) {
7525
- map.set(user, id);
7526
- }
8429
+ const actual = await sha256(binTmp);
8430
+ if (actual !== expected) {
8431
+ return { success: false, message: `Checksum mismatch: expected ${expected}, got ${actual}` };
7527
8432
  }
7528
- const meta = isRecord(resp.response_metadata) ? resp.response_metadata : null;
7529
- const next = meta ? getString(meta.next_cursor) : undefined;
7530
- if (!next) {
7531
- break;
8433
+ const currentBin = process.execPath;
8434
+ const backupPath = `${currentBin}.bak`;
8435
+ await rename(currentBin, backupPath);
8436
+ try {
8437
+ await copyFile2(binTmp, currentBin);
8438
+ await chmod(currentBin, 493);
8439
+ await rm3(backupPath, { force: true });
8440
+ } catch (err) {
8441
+ try {
8442
+ await rename(backupPath, currentBin);
8443
+ } catch {}
8444
+ throw err;
7532
8445
  }
7533
- cursor = next;
8446
+ return { success: true, message: `Updated agent-slack to ${latest}` };
8447
+ } finally {
8448
+ await rm3(tmp, { recursive: true, force: true }).catch(() => {});
7534
8449
  }
7535
- return map;
7536
8450
  }
7537
- async function getDmChannelForUsers(client, inputs) {
7538
- if (!inputs || inputs.length === 0) {
7539
- throw new Error("At least one user is required");
7540
- }
7541
- if (inputs.length > 8) {
7542
- throw new Error("Slack supports a maximum of 8 users in a group DM");
8451
+ async function backgroundUpdateCheck() {
8452
+ if (process.env.AGENT_SLACK_NO_UPDATE_CHECK === "1") {
8453
+ return;
7543
8454
  }
7544
- const userIds = [];
7545
- for (const input of inputs) {
7546
- const trimmed = input.trim();
7547
- if (!trimmed) {
7548
- continue;
7549
- }
7550
- const userId = await resolveUserId2(client, trimmed);
7551
- if (!userId) {
7552
- throw new Error(`Could not resolve user: ${input}`);
8455
+ try {
8456
+ const result = await checkForUpdate();
8457
+ if (result?.update_available) {
8458
+ const cmd = getUpdateCommand();
8459
+ process.stderr.write(`
8460
+ Update available: ${result.current} → ${result.latest}. Run "${cmd}" to upgrade.
8461
+ `);
7553
8462
  }
7554
- userIds.push(userId);
7555
- }
7556
- if (userIds.length === 0) {
7557
- throw new Error("No valid users provided");
7558
- }
7559
- const resp = await client.api("conversations.open", { users: userIds.join(",") });
7560
- const channel = isRecord(resp.channel) ? resp.channel : null;
7561
- const channelId = channel ? getString(channel.id) : null;
7562
- if (!channelId) {
7563
- throw new Error("conversations.open returned no channel");
7564
- }
7565
- const channelType = channelId.startsWith("D") ? "dm" : "group_dm";
7566
- return {
7567
- user_ids: userIds,
7568
- dm_channel_id: channelId,
7569
- channel_type: channelType
7570
- };
8463
+ } catch {}
7571
8464
  }
7572
- function toCompactUser(u) {
7573
- const profile = isRecord(u.profile) ? u.profile : {};
7574
- return {
7575
- id: getString(u.id) ?? "",
7576
- name: getString(u.name) ?? undefined,
7577
- real_name: getString(u.real_name) ?? getString(profile.real_name) ?? undefined,
7578
- display_name: getString(profile.display_name) ?? undefined,
7579
- email: getString(profile.email) ?? undefined,
7580
- title: getString(profile.title) ?? undefined,
7581
- tz: getString(u.tz) ?? undefined,
7582
- is_bot: typeof u.is_bot === "boolean" ? u.is_bot : undefined,
7583
- deleted: typeof u.deleted === "boolean" ? u.deleted : undefined
7584
- };
8465
+
8466
+ // src/cli/update-command.ts
8467
+ function registerUpdateCommand(input) {
8468
+ input.program.command("update").description("Update agent-slack to the latest version").option("--check", "Only check for updates (don't install)").action(async (...args) => {
8469
+ const [options] = args;
8470
+ const method = detectInstallMethod();
8471
+ try {
8472
+ const result = await checkForUpdate(true);
8473
+ if (!result) {
8474
+ console.error("Could not check for updates. Check your network connection.");
8475
+ process.exitCode = 1;
8476
+ return;
8477
+ }
8478
+ if (!result.update_available) {
8479
+ console.log(JSON.stringify(pruneEmpty({ ...result, install_method: method, status: "up_to_date" }), null, 2));
8480
+ return;
8481
+ }
8482
+ if (options.check) {
8483
+ console.log(JSON.stringify(pruneEmpty({
8484
+ ...result,
8485
+ install_method: method,
8486
+ update_command: getUpdateCommand(method),
8487
+ status: "update_available"
8488
+ }), null, 2));
8489
+ return;
8490
+ }
8491
+ process.stderr.write(`Updating agent-slack ${result.current} → ${result.latest}...
8492
+ `);
8493
+ let outcome;
8494
+ if (method === "npm" || method === "bun") {
8495
+ process.stderr.write(`Detected install method: ${method}
8496
+ `);
8497
+ outcome = performPackageManagerUpdate(method);
8498
+ } else {
8499
+ outcome = await performUpdate(result.latest);
8500
+ }
8501
+ if (!outcome.success) {
8502
+ console.error(outcome.message);
8503
+ process.exitCode = 1;
8504
+ return;
8505
+ }
8506
+ console.log(JSON.stringify(pruneEmpty({
8507
+ status: "updated",
8508
+ install_method: method,
8509
+ previous_version: result.current,
8510
+ new_version: result.latest,
8511
+ message: outcome.message
8512
+ }), null, 2));
8513
+ } catch (err) {
8514
+ console.error(err instanceof Error ? err.message : String(err));
8515
+ process.exitCode = 1;
8516
+ }
8517
+ });
7585
8518
  }
7586
8519
 
7587
8520
  // src/cli/user-command.ts
@@ -7768,7 +8701,7 @@ function registerChannelCommand(input) {
7768
8701
  }
7769
8702
  let userId;
7770
8703
  if (options.user) {
7771
- const resolvedUserId = await resolveUserId2(client, options.user);
8704
+ const resolvedUserId = await resolveUserId(client, options.user);
7772
8705
  if (!resolvedUserId) {
7773
8706
  throw new Error(`Could not resolve user: ${options.user}`);
7774
8707
  }
@@ -7851,7 +8784,7 @@ function registerChannelCommand(input) {
7851
8784
  const resolvedUserIds = [];
7852
8785
  const unresolvedUsers = [];
7853
8786
  for (const userInput of userInputs) {
7854
- const userId = await resolveUserId2(client, userInput);
8787
+ const userId = await resolveUserId(client, userInput);
7855
8788
  if (!userId) {
7856
8789
  unresolvedUsers.push(userInput);
7857
8790
  continue;
@@ -7921,6 +8854,274 @@ function registerChannelCommand(input) {
7921
8854
  });
7922
8855
  }
7923
8856
 
8857
+ // src/slack/workflows.ts
8858
+ async function listChannelWorkflows(client, channelId) {
8859
+ const [bookmarked, featured] = await Promise.all([
8860
+ listBookmarkedWorkflows(client, channelId),
8861
+ listFeaturedWorkflows(client, channelId)
8862
+ ]);
8863
+ const featuredIds = new Set(featured.map((f) => f.trigger_id));
8864
+ const seen = new Set;
8865
+ const workflows = [];
8866
+ for (const bk of bookmarked) {
8867
+ if (bk.trigger_id) {
8868
+ seen.add(bk.trigger_id);
8869
+ }
8870
+ workflows.push({
8871
+ title: bk.title,
8872
+ trigger_id: bk.trigger_id ?? "",
8873
+ link: bk.link,
8874
+ app_id: bk.app_id,
8875
+ featured: bk.trigger_id ? featuredIds.has(bk.trigger_id) : false
8876
+ });
8877
+ }
8878
+ for (const ft of featured) {
8879
+ if (!seen.has(ft.trigger_id)) {
8880
+ workflows.push({
8881
+ title: ft.title,
8882
+ trigger_id: ft.trigger_id,
8883
+ featured: true
8884
+ });
8885
+ }
8886
+ }
8887
+ return { channel_id: channelId, workflows };
8888
+ }
8889
+ async function listBookmarkedWorkflows(client, channelId) {
8890
+ const resp = await client.api("bookmarks.list", {
8891
+ channel_id: channelId
8892
+ });
8893
+ return asArray(resp.bookmarks).filter(isRecord).filter((b) => {
8894
+ const link = getString(b.link) ?? "";
8895
+ const shortcutId = getString(b.shortcut_id);
8896
+ return shortcutId || link.includes("slack.com/shortcuts/");
8897
+ }).map((b) => {
8898
+ const link = getString(b.link);
8899
+ const shortcutId = getString(b.shortcut_id);
8900
+ const triggerId = shortcutId || extractTriggerId(link);
8901
+ return {
8902
+ title: getString(b.title) ?? "",
8903
+ trigger_id: triggerId,
8904
+ link,
8905
+ app_id: getString(b.app_id)
8906
+ };
8907
+ });
8908
+ }
8909
+ async function listFeaturedWorkflows(client, channelId) {
8910
+ try {
8911
+ const resp = await client.api("workflows.featured.list", {
8912
+ channel_ids: JSON.stringify([channelId])
8913
+ });
8914
+ const entries = asArray(resp.featured_workflows).filter(isRecord);
8915
+ const entry = entries.find((e) => getString(e.channel_id) === channelId);
8916
+ if (!entry) {
8917
+ return [];
8918
+ }
8919
+ return asArray(entry.triggers).filter(isRecord).map((t) => ({
8920
+ trigger_id: getString(t.id) ?? "",
8921
+ title: getString(t.title) ?? ""
8922
+ })).filter((t) => t.trigger_id);
8923
+ } catch {
8924
+ return [];
8925
+ }
8926
+ }
8927
+ async function previewWorkflow(client, triggerId) {
8928
+ const resp = await client.api("workflows.triggers.preview", {
8929
+ trigger_ids: triggerId
8930
+ });
8931
+ const triggers = asArray(resp.triggers).filter(isRecord);
8932
+ if (triggers.length === 0) {
8933
+ const rejected = asArray(resp.rejected_triggers);
8934
+ if (rejected.length > 0) {
8935
+ throw new Error(`Trigger ${triggerId} was rejected — you may not have access`);
8936
+ }
8937
+ throw new Error(`No preview data returned for trigger ${triggerId}`);
8938
+ }
8939
+ const t = triggers[0];
8940
+ const wf = isRecord(t.workflow) ? t.workflow : {};
8941
+ const wfApp = isRecord(wf.app) ? wf.app : {};
8942
+ const details = isRecord(t.workflow_details) ? t.workflow_details : {};
8943
+ return {
8944
+ trigger_id: getString(t.id) ?? triggerId,
8945
+ type: getString(t.type) ?? "",
8946
+ name: getString(t.name) ?? "",
8947
+ description: getString(t.description) ?? "",
8948
+ shortcut_url: getString(t.shortcut_url),
8949
+ workflow: {
8950
+ id: getString(wf.workflow_id) ?? "",
8951
+ title: getString(wf.title) ?? "",
8952
+ description: getString(wf.description) ?? "",
8953
+ app_id: getString(wf.app_id) ?? getString(wfApp.id) ?? "",
8954
+ app_name: getString(wfApp.name) ?? ""
8955
+ },
8956
+ collaborators: asArray(details.collaborators).map((c) => typeof c === "string" ? c : "").filter(Boolean)
8957
+ };
8958
+ }
8959
+ async function runWorkflow(client, input) {
8960
+ const clientToken = `cli-${Date.now()}`;
8961
+ const resp = await client.api("workflows.triggers.trip", {
8962
+ url: input.shortcutUrl,
8963
+ client_token: clientToken,
8964
+ context: JSON.stringify({
8965
+ location: "bookmark",
8966
+ channel_id: input.channelId,
8967
+ trigger_id: input.triggerId
8968
+ }),
8969
+ run_precheck: true
8970
+ });
8971
+ return {
8972
+ function_execution_id: getString(resp.function_execution_id) ?? "",
8973
+ trigger_execution_id: getString(resp.trigger_execution_id) ?? "",
8974
+ is_slow_workflow: resp.is_slow_workflow === true
8975
+ };
8976
+ }
8977
+ async function resolveShortcutUrl(client, input) {
8978
+ const { channelId, triggerId } = input;
8979
+ const resp = await client.api("bookmarks.list", {
8980
+ channel_id: channelId
8981
+ });
8982
+ const bookmarks = asArray(resp.bookmarks).filter(isRecord);
8983
+ for (const b of bookmarks) {
8984
+ const shortcutId = getString(b.shortcut_id);
8985
+ if (shortcutId === triggerId) {
8986
+ const link = getString(b.link);
8987
+ if (link) {
8988
+ return link;
8989
+ }
8990
+ }
8991
+ }
8992
+ throw new Error(`Could not find shortcut URL for trigger ${triggerId} in channel bookmarks`);
8993
+ }
8994
+ async function getWorkflowSchema(client, workflowId) {
8995
+ const resp = await client.api("workflows.get", { workflow_id: workflowId });
8996
+ const wf = isRecord(resp.workflow) ? resp.workflow : null;
8997
+ if (!wf) {
8998
+ throw new Error(`No workflow found for ID ${workflowId}`);
8999
+ }
9000
+ const steps = asArray(wf.steps).filter(isRecord);
9001
+ const stepSummaries = [];
9002
+ let fields = [];
9003
+ let formTitle;
9004
+ for (const step of steps) {
9005
+ const fn = isRecord(step.function) ? step.function : {};
9006
+ const callbackId = getString(fn.callback_id) ?? "";
9007
+ const title = getString(fn.title) ?? callbackId;
9008
+ stepSummaries.push(title);
9009
+ if (callbackId === "open_form") {
9010
+ const inputs = isRecord(step.inputs) ? step.inputs : {};
9011
+ const titleInput = isRecord(inputs.title) ? inputs.title : {};
9012
+ formTitle = getString(titleInput.value);
9013
+ const fieldsInput = isRecord(inputs.fields) ? inputs.fields : {};
9014
+ const fieldsValue = isRecord(fieldsInput.value) ? fieldsInput.value : {};
9015
+ const elements = asArray(fieldsValue.elements).filter(isRecord);
9016
+ const required = new Set(asArray(fieldsValue.required).map((r) => typeof r === "string" ? r : "").filter(Boolean));
9017
+ fields = elements.map((el) => ({
9018
+ name: getString(el.name) ?? "",
9019
+ title: getString(el.title) ?? "",
9020
+ type: getString(el.type) ?? "string",
9021
+ description: getString(el.description) ?? "",
9022
+ required: required.has(getString(el.name) ?? ""),
9023
+ long: el.long === true ? true : undefined
9024
+ }));
9025
+ }
9026
+ }
9027
+ return {
9028
+ workflow_id: getString(wf.id) ?? workflowId,
9029
+ title: getString(wf.title) ?? "",
9030
+ description: getString(wf.description) ?? "",
9031
+ form_title: formTitle,
9032
+ fields,
9033
+ steps: stepSummaries
9034
+ };
9035
+ }
9036
+ function extractTriggerId(link) {
9037
+ if (!link) {
9038
+ return;
9039
+ }
9040
+ const match = link.match(/slack\.com\/shortcuts\/(Ft[A-Za-z0-9]+)/);
9041
+ return match?.[1];
9042
+ }
9043
+
9044
+ // src/cli/workflow-command.ts
9045
+ function registerWorkflowCommand(input) {
9046
+ const workflowCmd = input.program.command("workflow").description("Discover and interact with Slack workflows");
9047
+ workflowCmd.command("list").description("List workflows bookmarked or featured in a channel").argument("<channel>", "Channel id or name (#channel, channel, C...)").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
9048
+ const [channel, options] = args;
9049
+ try {
9050
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
9051
+ const payload = await input.ctx.withAutoRefresh({
9052
+ workspaceUrl,
9053
+ work: async () => {
9054
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
9055
+ const channelId = await resolveChannelId(client, channel);
9056
+ return await listChannelWorkflows(client, channelId);
9057
+ }
9058
+ });
9059
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
9060
+ } catch (err) {
9061
+ console.error(input.ctx.errorMessage(err));
9062
+ process.exitCode = 1;
9063
+ }
9064
+ });
9065
+ workflowCmd.command("preview").description("Get workflow metadata from a trigger ID (no side effects)").argument("<trigger-id>", "Trigger ID (Ft...)").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
9066
+ const [triggerId, options] = args;
9067
+ try {
9068
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
9069
+ const payload = await input.ctx.withAutoRefresh({
9070
+ workspaceUrl,
9071
+ work: async () => {
9072
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
9073
+ return await previewWorkflow(client, triggerId);
9074
+ }
9075
+ });
9076
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
9077
+ } catch (err) {
9078
+ console.error(input.ctx.errorMessage(err));
9079
+ process.exitCode = 1;
9080
+ }
9081
+ });
9082
+ workflowCmd.command("get").description("Get workflow definition including form fields and steps (accepts Ft... or Wf...)").argument("<id>", "Trigger ID (Ft...) or Workflow ID (Wf...)").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
9083
+ const [id, options] = args;
9084
+ try {
9085
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
9086
+ const payload = await input.ctx.withAutoRefresh({
9087
+ workspaceUrl,
9088
+ work: async () => {
9089
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
9090
+ let workflowId = id;
9091
+ if (id.startsWith("Ft")) {
9092
+ const preview = await previewWorkflow(client, id);
9093
+ workflowId = preview.workflow.id;
9094
+ }
9095
+ return await getWorkflowSchema(client, workflowId);
9096
+ }
9097
+ });
9098
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
9099
+ } catch (err) {
9100
+ console.error(input.ctx.errorMessage(err));
9101
+ process.exitCode = 1;
9102
+ }
9103
+ });
9104
+ workflowCmd.command("run").description("Trip a workflow trigger").argument("<trigger-id>", "Trigger ID (Ft...)").requiredOption("--channel <id-or-name>", "Channel where the workflow is bookmarked").option("--workspace <url>", "Workspace selector (full URL or unique substring; required if you have multiple workspaces)").action(async (...args) => {
9105
+ const [triggerId, options] = args;
9106
+ try {
9107
+ const workspaceUrl = input.ctx.effectiveWorkspaceUrl(options.workspace);
9108
+ const payload = await input.ctx.withAutoRefresh({
9109
+ workspaceUrl,
9110
+ work: async () => {
9111
+ const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
9112
+ const channelId = await resolveChannelId(client, options.channel);
9113
+ const shortcutUrl = await resolveShortcutUrl(client, { channelId, triggerId });
9114
+ return await runWorkflow(client, { shortcutUrl, channelId, triggerId });
9115
+ }
9116
+ });
9117
+ console.log(JSON.stringify(pruneEmpty(payload), null, 2));
9118
+ } catch (err) {
9119
+ console.error(input.ctx.errorMessage(err));
9120
+ process.exitCode = 1;
9121
+ }
9122
+ });
9123
+ }
9124
+
7924
9125
  // src/index.ts
7925
9126
  var program = new Command;
7926
9127
  program.name("agent-slack").description("Slack automation CLI for AI agents").version(getPackageVersion());
@@ -7929,9 +9130,12 @@ registerAuthCommand({ program, ctx });
7929
9130
  registerMessageCommand({ program, ctx });
7930
9131
  registerCanvasCommand({ program, ctx });
7931
9132
  registerSearchCommand({ program, ctx });
9133
+ registerLaterCommand({ program, ctx });
9134
+ registerUnreadsCommand({ program, ctx });
7932
9135
  registerUpdateCommand({ program });
7933
9136
  registerUserCommand({ program, ctx });
7934
9137
  registerChannelCommand({ program, ctx });
9138
+ registerWorkflowCommand({ program, ctx });
7935
9139
  program.parse(process.argv);
7936
9140
  if (!process.argv.slice(2).length) {
7937
9141
  program.outputHelp();
@@ -7941,5 +9145,5 @@ if (subcommand && subcommand !== "update") {
7941
9145
  backgroundUpdateCheck();
7942
9146
  }
7943
9147
 
7944
- //# debugId=3EBC31FAC3ADA2FD64756E2164756E21
9148
+ //# debugId=4F295E0EDEFC23F164756E2164756E21
7945
9149
  //# sourceMappingURL=index.js.map