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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/junior/.dependency-cruiser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ export default {
{
name: "no-chat-services-to-slack",
comment:
"Service modules must depend on small injected ports, not Slack infrastructure.",
"Service modules must depend on small injected ports, not Slack infrastructure; Slack timestamp value objects are the only exception.",
severity: "error",
from: {
path: "^src/chat/services/",
},
to: {
path: "^src/chat/slack/",
pathNot: "^src/chat/slack/timestamp\\.ts$",
},
},
{
Expand Down
3 changes: 2 additions & 1 deletion packages/junior/src/chat/runtime/conversation-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function toConversationMessage(
args: ConversationMessageInput,
): ConversationMessage {
const actor = getMessageActorIdentity(args.entry);
const slackTs = getSlackMessageTs(args.entry);
const messageHasPotentialImageAttachment = hasPotentialImageAttachment(
args.entry.attachments,
);
Expand Down Expand Up @@ -53,7 +54,7 @@ export function toConversationMessage(
imageAttachmentCount:
imageAttachmentCount > 0 ? imageAttachmentCount : undefined,
imagesHydrated: !messageHasPotentialImageAttachment,
slackTs: getSlackMessageTs(args.entry),
...(slackTs ? { slackTs } : {}),
},
};
}
6 changes: 4 additions & 2 deletions packages/junior/src/chat/runtime/processing-reaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { getChannelId, getMessageTs } from "@/chat/runtime/thread-context";
import type { TurnToolInvocation } from "@/chat/runtime/turn-input";
import { getChatConfig } from "@/chat/config";
import type { SlackMessageTs } from "@/chat/slack/timestamp";

/** Controls the automatic Slack processing reaction lifecycle for one message. */
export interface ProcessingReactionSession {
Expand All @@ -25,7 +26,8 @@ const noProcessingReaction: ProcessingReactionSession = {
function isProcessingReactionEmoji(value: unknown): boolean {
return (
typeof value === "string" &&
normalizeSlackEmojiName(value) === getChatConfig().slack.processingReactionEmoji
normalizeSlackEmojiName(value) ===
getChatConfig().slack.processingReactionEmoji
);
}

Expand Down Expand Up @@ -73,7 +75,7 @@ export async function startSlackProcessingReaction(args: {
/** Start Junior's automatic Slack processing reaction for a known Slack message. */
export async function startSlackProcessingReactionForMessage(args: {
channelId: string;
timestamp: string;
timestamp: SlackMessageTs;
logException: (
error: unknown,
eventName: string,
Expand Down
5 changes: 3 additions & 2 deletions packages/junior/src/chat/runtime/slack-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
startSlackProcessingReactionForMessage,
type ProcessingReactionSession,
} from "@/chat/runtime/processing-reaction";
import type { SlackMessageTs } from "@/chat/slack/timestamp";
import { buildAuthPauseResponse } from "@/chat/services/auth-pause-response";
import { getTurnRequestDeadline } from "@/chat/runtime/request-deadline";

Expand Down Expand Up @@ -137,7 +138,7 @@ interface ResumeSlackTurnArgs {
messageText: string;
channelId: string;
threadTs: string;
messageTs?: string;
messageTs?: SlackMessageTs;
replyContext?: ResumeReplyContext;
lockKey?: string;
initialText?: string;
Expand Down Expand Up @@ -573,7 +574,7 @@ export async function resumeAuthorizedRequest(args: {
messageText: string;
channelId: string;
threadTs: string;
messageTs?: string;
messageTs?: SlackMessageTs;
connectedText: string;
replyContext?: ResumeReplyContext;
lockKey?: string;
Expand Down
28 changes: 20 additions & 8 deletions packages/junior/src/chat/runtime/thread-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
resolveSlackChannelIdFromThreadId,
resolveSlackChannelIdFromMessage,
} from "@/chat/slack/context";
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";

function toSlackTeamId(value: unknown): string | undefined {
const candidate = toOptionalString(value);
Expand Down Expand Up @@ -126,12 +130,14 @@ export function getAssistantThreadContext(
return parsedThreadId;
}

export function getMessageTs(message: Message): string | undefined {
/** Resolve the native Slack timestamp for a message that can target Slack APIs. */
export function getMessageTs(message: Message): SlackMessageTs | undefined {
const directTs = toOptionalString(
(message as unknown as { ts?: unknown }).ts,
);
if (directTs) {
return directTs;
const parsedDirectTs = parseSlackMessageTs(directTs);
if (parsedDirectTs) {
return parsedDirectTs;
}

const raw = (message as unknown as { raw?: unknown }).raw;
Expand All @@ -140,11 +146,17 @@ export function getMessageTs(message: Message): string | undefined {
}

const rawRecord = raw as Record<string, unknown>;
return (
toOptionalString(rawRecord.ts) ??
toOptionalString(rawRecord.event_ts) ??
toOptionalString((rawRecord.message as { ts?: unknown } | undefined)?.ts)
);
const candidates = [
rawRecord.ts,
(rawRecord.message as { ts?: unknown } | undefined)?.ts,
];
for (const candidate of candidates) {
const ts = parseSlackMessageTs(candidate);
if (ts) {
return ts;
}
}
return undefined;
}

/** Resolve the Slack workspace/team id from the raw inbound message payload. */
Expand Down
18 changes: 6 additions & 12 deletions packages/junior/src/chat/runtime/turn-user-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@ import type {
ConversationMessage,
ThreadConversationState,
} from "@/chat/state/conversation";

function normalizeSlackMessageTs(
value: string | undefined,
): string | undefined {
const trimmed = value?.trim();
return trimmed && /^\d+(?:\.\d+)?$/.test(trimmed) ? trimmed : undefined;
}
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";

/** Return the user message for a persisted turn/session, if one exists. */
export function getTurnUserMessage(
Expand Down Expand Up @@ -40,11 +37,8 @@ export function getTurnUserMessageId(
/** Return the Slack timestamp for the user message that a resumed turn acts on. */
export function getTurnUserSlackMessageTs(
message: ConversationMessage | undefined,
): string | undefined {
return (
normalizeSlackMessageTs(message?.meta?.slackTs) ??
normalizeSlackMessageTs(message?.id)
);
): SlackMessageTs | undefined {
return parseSlackMessageTs(message?.meta?.slackTs);
}

/** Rebuild attachment context for a resumed turn from the persisted user message. */
Expand Down
10 changes: 7 additions & 3 deletions packages/junior/src/chat/services/conversation-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import type {
ConversationMessage,
ThreadConversationState,
} from "@/chat/state/conversation";
import { toOptionalString } from "@/chat/coerce";
import { logWarn, setSpanAttributes } from "@/chat/logging";
import {
calculateContextCompactionTargetTokens,
estimateTextTokens,
getConversationContextCompactionTriggerTokens,
} from "@/chat/services/context-budget";
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";
import { escapeXml } from "@/chat/xml";

const CONTEXT_MIN_LIVE_MESSAGES = 12;
Expand Down Expand Up @@ -463,8 +466,9 @@ export function isHumanConversationMessage(
return message.role === "user" && message.author?.isBot !== true;
}

/** Return the native Slack timestamp for a persisted conversation message. */
export function getConversationMessageSlackTs(
message: ConversationMessage,
): string | undefined {
return message.meta?.slackTs ?? toOptionalString(message.id);
): SlackMessageTs | undefined {
return parseSlackMessageTs(message.meta?.slackTs);
}
22 changes: 10 additions & 12 deletions packages/junior/src/chat/slack/message.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import type { Message } from "chat";

function isSlackMessageTs(value: string): boolean {
return /^\d+(?:\.\d+)?$/.test(value.trim());
}
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";

/**
* Preserve the native Slack message timestamp when a synthetic message ID is
* used for routing or deduplication.
*/
export function getSlackMessageTs(
message: Pick<Message, "id" | "raw">,
): string {
if (isSlackMessageTs(message.id)) {
return message.id;
): SlackMessageTs | undefined {
const idTs = parseSlackMessageTs(message.id);
if (idTs) {
return idTs;
}

if (message.raw && typeof message.raw === "object") {
const ts = (message.raw as Record<string, unknown>).ts;
if (typeof ts === "string" && ts.length > 0) {
return ts;
}
return parseSlackMessageTs((message.raw as Record<string, unknown>).ts);
}

return message.id;
return undefined;
}
25 changes: 15 additions & 10 deletions packages/junior/src/chat/slack/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
} from "@/chat/slack/client";
import { normalizeSlackEmojiName } from "@/chat/slack/emoji";
import { parseActorUserId } from "@/chat/requester";
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";

const MAX_SLACK_MESSAGE_TEXT_CHARS = 40_000;

Expand All @@ -29,8 +33,8 @@ function requireSlackThreadTimestamp(threadTs: string, action: string): string {
function requireSlackMessageTimestamp(
timestamp: string,
action: string,
): string {
const normalized = timestamp.trim();
): SlackMessageTs {
const normalized = parseSlackMessageTs(timestamp);
if (!normalized) {
throw new Error(`${action} requires a target message timestamp`);
}
Expand All @@ -51,7 +55,7 @@ function requireSlackMessageText(text: string, action: string): string {

async function getPermalinkBestEffort(args: {
channelId: string;
messageTs: string;
messageTs: SlackMessageTs;
}): Promise<string | undefined> {
try {
const response = await withSlackRetries(
Expand Down Expand Up @@ -83,7 +87,7 @@ export async function postSlackMessage(input: {
text: string;
threadTs?: string;
includePermalink?: boolean;
}): Promise<{ ts: string; permalink?: string }> {
}): Promise<{ ts: SlackMessageTs; permalink?: string }> {
const channelId = requireSlackConversationId(
input.channelId,
"Slack message posting",
Expand Down Expand Up @@ -118,17 +122,18 @@ export async function postSlackMessage(input: {
},
);

if (!response.ts) {
const messageTs = parseSlackMessageTs(response.ts);
if (!messageTs) {
throw new Error("Slack message posted without ts");
}

return {
ts: response.ts,
ts: messageTs,
...(input.includePermalink
? {
permalink: await getPermalinkBestEffort({
channelId,
messageTs: response.ts,
messageTs,
}),
}
: {}),
Expand All @@ -138,7 +143,7 @@ export async function postSlackMessage(input: {
/** Delete a previously posted Slack message through the shared outbound boundary. */
export async function deleteSlackMessage(input: {
channelId: string;
timestamp: string;
timestamp: SlackMessageTs;
}): Promise<void> {
const channelId = requireSlackConversationId(
input.channelId,
Expand Down Expand Up @@ -271,7 +276,7 @@ export async function uploadFilesToThread(input: {
/** Add a reaction to a Slack message, treating `already_reacted` as idempotent success. */
export async function addReactionToMessage(input: {
channelId: string;
timestamp: string;
timestamp: SlackMessageTs;
emoji: string;
}): Promise<{ ok: true }> {
const channelId = requireSlackConversationId(
Expand Down Expand Up @@ -319,7 +324,7 @@ export async function addReactionToMessage(input: {
/** Remove a reaction from a Slack message, treating `no_reaction` as idempotent success. */
export async function removeReactionFromMessage(input: {
channelId: string;
timestamp: string;
timestamp: SlackMessageTs;
emoji: string;
}): Promise<{ ok: true }> {
const channelId = requireSlackConversationId(
Expand Down
18 changes: 18 additions & 0 deletions packages/junior/src/chat/slack/timestamp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from "zod";

/** Native Slack message timestamp, safe to pass as Slack Web API `message_ts`. */
export const slackMessageTsSchema = z
.string()
.trim()
.regex(/^\d+(?:\.\d+)?$/)
.brand<"SlackMessageTs">();

export type SlackMessageTs = z.output<typeof slackMessageTsSchema>;

/** Parse a native Slack message timestamp from untrusted message metadata. */
export function parseSlackMessageTs(
value: unknown,
): SlackMessageTs | undefined {
const parsed = slackMessageTsSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
5 changes: 4 additions & 1 deletion packages/junior/src/chat/task-execution/slack-work.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ function slackSerializedThread(input: {
};
}

/**
* Serialize a synthetic resource-event mailbox message without a native Slack
* message timestamp so Slack Web API calls cannot target the internal id.
*/
function slackSerializedResourceEventMessage(input: {
channelId: string;
id: string;
Expand Down Expand Up @@ -159,7 +163,6 @@ function slackSerializedResourceEventMessage(input: {
channel: input.channelId,
event_type: "resource_event",
thread_ts: input.threadTs,
ts: input.id,
type: "message",
user: "UJRNEVENT",
},
Expand Down
8 changes: 6 additions & 2 deletions packages/junior/src/chat/tools/slack/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import type { ToolRuntimeContext } from "@/chat/tools/types";
import type { SlackDestination } from "@sentry/junior-plugin-api";
import type { SlackSource } from "@sentry/junior-plugin-api";
import type { SlackRequester } from "@/chat/requester";
import {
parseSlackMessageTs,
type SlackMessageTs,
} from "@/chat/slack/timestamp";

export interface SlackToolContext {
destination: SlackDestination;
source: SlackSource;
requester?: SlackRequester;
destinationChannelId: string;
messageTs?: string;
messageTs?: SlackMessageTs;
sourceChannelId: string;
teamId: string;
threadTs?: string;
Expand All @@ -30,7 +34,7 @@ export function getSlackToolContext(
requester:
context.requester?.platform === "slack" ? context.requester : undefined,
destinationChannelId: context.destination.channelId,
messageTs: context.source.messageTs,
messageTs: parseSlackMessageTs(context.source.messageTs),
sourceChannelId: context.source.channelId,
teamId: context.source.teamId,
threadTs: context.source.threadTs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ describe("Slack conversation work execution", () => {
expect(lookupSlackUser).not.toHaveBeenCalled();
expect(calls).toHaveLength(1);
expect(calls[0]?.raw).toMatchObject({ event_type: "resource_event" });
expect(calls[0]?.raw).not.toHaveProperty("ts");
expect(getMessageActorIdentity(calls[0]!)).toEqual({
userId: "UJRNEVENT",
});
Expand Down
Loading
Loading