mirror of
https://github.com/stablyai/agent-slack.git
synced 2026-09-19 01:06:54 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user