feat: return sent message metadata (#81)

* feat: return sent message metadata

Expose posted channel_id and ts for message send, plus permalinks when the workspace URL is known.

Co-Authored-By: Wonsley <wonsley@tron.haus>

* fix: return permalinks for resolved workspaces

* refactor: tighten message send return shape

Rename `url` to `permalink` to match Slack's own vocabulary, drop the
`workspace_url` echo from output, and only include `thread_ts` when the
send actually targeted an existing thread (previously it was defaulted
to the posted `ts` on root messages, which misleadingly implied the
message was a threaded reply).

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Wonsley <scott+wonsley@wesfarmers.com.au>
Co-authored-by: Wonsley <wonsley@tron.haus>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Scott Arthur
2026-04-28 16:58:08 +10:00
committed by GitHub
parent e517068545
commit b27b0b33ec
7 changed files with 156 additions and 17 deletions
+2
View File
@@ -231,6 +231,8 @@ Attach options for `message send`:
- `--attach <path>` upload a local file (repeatable)
`message send` returns `channel_id` plus the posted `ts` and a `permalink` (for non-attachment sends). `thread_ts` appears only when replying in a thread.
### List, create, and invite channels
```bash
+2
View File
@@ -162,6 +162,8 @@ Attach options for `message send`:
- `--attach <path>` upload a local file (repeatable)
`message send` returns `channel_id` plus the posted `ts` and a `permalink` (for non-attachment sends). `thread_ts` appears only when replying in a thread.
## List channels + create/invite users
```bash
+7
View File
@@ -19,6 +19,13 @@ All commands print JSON to stdout.
- `referenced_users?: { [user_id]: { id, name?, real_name?, display_name?, ... } }`
- Messages are compact and omit redundant fields on each item where possible.
- `message send` returns:
- `ok: true`
- `channel_id: "C..." | "D..."`
- `ts?: "<seconds>.<micros>"` — the posted message's ts; absent on file-attachment sends
- `thread_ts?: "<seconds>.<micros>"` — present only when the send was into an existing thread
- `permalink?: "https://.../archives/..."` — present when `ts` is known and a workspace URL was resolvable
Message payload fields keep canonical user IDs (for example `author.user_id`, reaction `users[]`, and `@U...` mentions in rendered content).
`referenced_users` provides display metadata for those IDs. The cache is per-workspace with a 24-hour per-entry TTL.
This behavior is opt-in and requires passing the `--resolve-users` flag (or `--refresh-users` to bypass the cache).
+40 -15
View File
@@ -7,6 +7,7 @@ import { warnOnTruncatedSlackUrl } from "./message-url-warning.ts";
import { textToRichTextBlocks } from "../slack/rich-text.ts";
import type { SlackApiClient } from "../slack/client.ts";
import { uploadLocalFileToSlack } from "../slack/upload.ts";
import { buildSlackMessageUrl } from "../slack/url.ts";
export type MessageCommandOptions = {
maxBodyChars: string;
@@ -87,14 +88,15 @@ export async function sendMessage(input: {
if (target.kind === "url") {
const { ref } = target;
warnOnTruncatedSlackUrl(ref);
await input.ctx.withAutoRefresh({
return await input.ctx.withAutoRefresh({
workspaceUrl: ref.workspace_url,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(ref.workspace_url);
const { client, workspace_url } = await input.ctx.getClientForWorkspace(ref.workspace_url);
const msg = await fetchMessage(client, { ref });
const threadTs = msg.thread_ts ?? msg.ts;
await sendMessageToChannel({
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? ref.workspace_url,
channelId: ref.channel_id,
text: input.text,
blocks,
@@ -103,18 +105,18 @@ export async function sendMessage(input: {
});
},
});
return { ok: true };
}
if (target.kind === "user") {
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
await input.ctx.withAutoRefresh({
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const dmChannelId = await openDmChannel(client, target.userId);
await sendMessageToChannel({
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? workspaceUrl,
channelId: dmChannelId,
text: input.text,
blocks,
@@ -122,7 +124,6 @@ export async function sendMessage(input: {
});
},
});
return { ok: true };
}
const workspaceUrl = input.ctx.effectiveWorkspaceUrl(input.options.workspace);
@@ -130,13 +131,14 @@ export async function sendMessage(input: {
workspaceUrl,
channels: [String(target.channel)],
});
await input.ctx.withAutoRefresh({
return await input.ctx.withAutoRefresh({
workspaceUrl,
work: async () => {
const { client } = await input.ctx.getClientForWorkspace(workspaceUrl);
const { client, workspace_url } = await input.ctx.getClientForWorkspace(workspaceUrl);
const channelId = await resolveChannelId(client, String(target.channel));
await sendMessageToChannel({
return await sendMessageToChannel({
client,
workspaceUrl: workspace_url ?? workspaceUrl,
channelId,
text: input.text,
blocks,
@@ -145,7 +147,6 @@ export async function sendMessage(input: {
});
},
});
return { ok: true };
}
function normalizeAttachPaths(raw: string[] | undefined): string[] {
@@ -163,20 +164,38 @@ function normalizeAttachPaths(raw: string[] | undefined): string[] {
async function sendMessageToChannel(input: {
client: SlackApiClient;
workspaceUrl?: string;
channelId: string;
text: string;
blocks?: unknown[] | null;
threadTs?: string;
attachPaths: string[];
}): Promise<void> {
}): Promise<Record<string, unknown>> {
if (input.attachPaths.length === 0) {
await input.client.api("chat.postMessage", {
const resp = await input.client.api("chat.postMessage", {
channel: input.channelId,
text: input.text,
thread_ts: input.threadTs,
...(input.blocks ? { blocks: input.blocks } : {}),
});
return;
const ts = typeof resp.ts === "string" ? resp.ts : undefined;
const channelId = typeof resp.channel === "string" ? resp.channel : input.channelId;
const permalink =
input.workspaceUrl && ts
? buildSlackMessageUrl({
workspace_url: input.workspaceUrl,
channel_id: channelId,
message_ts: ts,
thread_ts: input.threadTs,
})
: undefined;
return {
ok: true,
channel_id: channelId,
ts,
thread_ts: input.threadTs,
permalink,
};
}
if (input.blocks) {
@@ -196,6 +215,12 @@ async function sendMessageToChannel(input: {
});
initialComment = "";
}
return {
ok: true,
channel_id: input.channelId,
thread_ts: input.threadTs,
};
}
export async function editMessage(input: {
+16
View File
@@ -53,3 +53,19 @@ export function parseSlackMessageUrl(input: string): SlackMessageRef {
const workspace_url = `${url.protocol}//${url.host}`;
return { workspace_url, channel_id, message_ts, thread_ts_hint, raw: input, possiblyTruncated };
}
export function buildSlackMessageUrl(input: {
workspace_url: string;
channel_id: string;
message_ts: string;
thread_ts?: string;
}): string {
const workspaceUrl = input.workspace_url.replace(/\/$/, "");
const digits = input.message_ts.replace(".", "");
const url = new URL(`${workspaceUrl}/archives/${input.channel_id}/p${digits}`);
if (input.thread_ts && input.thread_ts !== input.message_ts) {
url.searchParams.set("thread_ts", input.thread_ts);
url.searchParams.set("cid", input.channel_id);
}
return url.toString();
}
+63 -1
View File
@@ -12,6 +12,9 @@ function createContext(calls: { method: string; params: Record<string, unknown>
if (method === "files.getUploadURLExternal") {
return { ok: true, upload_url: "https://upload.example/file", file_id: "F123" };
}
if (method === "chat.postMessage") {
return { ok: true, channel: String(params.channel), ts: "1770165109.628379" };
}
return { ok: true };
},
};
@@ -26,6 +29,7 @@ function createContext(calls: { method: string; params: Record<string, unknown>
getClientForWorkspace: async () => ({
client: client as never,
auth: { auth_type: "standard", token: "x" as const },
workspace_url: "https://workspace.slack.com",
}),
normalizeUrl: (u: string) => u,
errorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
@@ -57,7 +61,37 @@ describe("sendMessage", () => {
const calls: { method: string; params: Record<string, unknown> }[] = [];
const ctx = createContext(calls);
await sendMessage({
const result = await sendMessage({
ctx,
targetInput: "C12345678",
text: "hello",
options: { workspace: "https://workspace.slack.com" },
});
expect(calls).toEqual([
{
method: "chat.postMessage",
params: {
channel: "C12345678",
text: "hello",
thread_ts: undefined,
},
},
]);
expect(result).toEqual({
ok: true,
channel_id: "C12345678",
ts: "1770165109.628379",
thread_ts: undefined,
permalink: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
});
});
test("returns a permalink when the workspace was resolved implicitly", async () => {
const calls: { method: string; params: Record<string, unknown> }[] = [];
const ctx = createContext(calls);
const result = await sendMessage({
ctx,
targetInput: "C12345678",
text: "hello",
@@ -74,6 +108,34 @@ describe("sendMessage", () => {
},
},
]);
expect(result).toEqual({
ok: true,
channel_id: "C12345678",
ts: "1770165109.628379",
thread_ts: undefined,
permalink: "https://workspace.slack.com/archives/C12345678/p1770165109628379",
});
});
test("threaded reply returns thread_ts distinct from ts", async () => {
const calls: { method: string; params: Record<string, unknown> }[] = [];
const ctx = createContext(calls);
const result = await sendMessage({
ctx,
targetInput: "C12345678",
text: "reply",
options: { threadTs: "1770160000.000001" },
});
expect(result).toEqual({
ok: true,
channel_id: "C12345678",
ts: "1770165109.628379",
thread_ts: "1770160000.000001",
permalink:
"https://workspace.slack.com/archives/C12345678/p1770165109628379?thread_ts=1770160000.000001&cid=C12345678",
});
});
test("uploads attachment and uses message text as initial comment", async () => {
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { parseSlackMessageUrl } from "../src/slack/url.ts";
import { buildSlackMessageUrl, parseSlackMessageUrl } from "../src/slack/url.ts";
describe("parseSlackMessageUrl", () => {
test("parses archives URL with p<digits>", () => {
@@ -19,3 +19,28 @@ describe("parseSlackMessageUrl", () => {
expect(ref.thread_ts_hint).toBe("1770160000.000001");
});
});
describe("buildSlackMessageUrl", () => {
test("builds a permalink for a root message", () => {
expect(
buildSlackMessageUrl({
workspace_url: "https://stablygroup.slack.com/",
channel_id: "C060RS20UMV",
message_ts: "1770165109.628379",
}),
).toBe("https://stablygroup.slack.com/archives/C060RS20UMV/p1770165109628379");
});
test("includes thread metadata for replies", () => {
expect(
buildSlackMessageUrl({
workspace_url: "https://stablygroup.slack.com",
channel_id: "C060RS20UMV",
message_ts: "1770165110.000001",
thread_ts: "1770165109.628379",
}),
).toBe(
"https://stablygroup.slack.com/archives/C060RS20UMV/p1770165110000001?thread_ts=1770165109.628379&cid=C060RS20UMV",
);
});
});