@moda-labs/bobi-events-core 0.1.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/LICENSE +201 -0
- package/README.md +32 -0
- package/dist/adapters/chat-sdk-slack.d.ts +18 -0
- package/dist/adapters/chat-sdk-slack.d.ts.map +1 -0
- package/dist/adapters/chat-sdk-slack.js +201 -0
- package/dist/adapters/chat-sdk-slack.js.map +1 -0
- package/dist/adapters/github.d.ts +3 -0
- package/dist/adapters/github.d.ts.map +1 -0
- package/dist/adapters/github.js +114 -0
- package/dist/adapters/github.js.map +1 -0
- package/dist/adapters/linear.d.ts +3 -0
- package/dist/adapters/linear.d.ts.map +1 -0
- package/dist/adapters/linear.js +47 -0
- package/dist/adapters/linear.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +25 -0
- package/dist/adapters/whatsapp.d.ts.map +1 -0
- package/dist/adapters/whatsapp.js +96 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/channels.d.ts +80 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +499 -0
- package/dist/channels.js.map +1 -0
- package/dist/circuit-breaker.d.ts +79 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +288 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/conversation.d.ts +29 -0
- package/dist/conversation.d.ts.map +1 -0
- package/dist/conversation.js +86 -0
- package/dist/conversation.js.map +1 -0
- package/dist/core.d.ts +254 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1775 -0
- package/dist/core.js.map +1 -0
- package/package.json +40 -0
- package/src/adapters/chat-sdk-slack.ts +209 -0
- package/src/adapters/github.ts +111 -0
- package/src/adapters/linear.ts +53 -0
- package/src/adapters/whatsapp.ts +120 -0
- package/src/channels.ts +600 -0
- package/src/circuit-breaker.ts +337 -0
- package/src/conversation.ts +96 -0
- package/src/core.ts +2413 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery-path circuit breaker (#299, split from #215 loop-safety).
|
|
3
|
+
*
|
|
4
|
+
* Keying decision: the breaker keys on `(deployment_id, conversation)` where
|
|
5
|
+
* conversation is derived from external channel identity:
|
|
6
|
+
* - Slack: thread_ts (or ts if no thread)
|
|
7
|
+
* - GitHub: repo + issue/PR number
|
|
8
|
+
* - Other: event type (coarse — acceptable because custom events rarely loop)
|
|
9
|
+
*
|
|
10
|
+
* EXEMPTIONS: `inbox/*` and `reply/*` topics are legitimate internal
|
|
11
|
+
* agent↔agent comms and are NEVER counted toward the breaker. They flow
|
|
12
|
+
* through a separate routing namespace (`agent/*` topics) that does not
|
|
13
|
+
* represent external conversation depth.
|
|
14
|
+
*
|
|
15
|
+
* Trip condition: ≥ THRESHOLD non-human-authored deliveries in one key within
|
|
16
|
+
* WINDOW_MS with zero human events → pause delivery for that key (buffered,
|
|
17
|
+
* not dropped). Emit `system.loop_detected`. Auto-resume after COOLDOWN_MS or
|
|
18
|
+
* on the next human-authored event in the same key.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { NormalizedEvent } from "./core.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Tunables
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export const BREAKER_THRESHOLD = 5;
|
|
28
|
+
export const BREAKER_WINDOW_MS = 60_000; // 60s
|
|
29
|
+
export const BREAKER_COOLDOWN_MS = 300_000; // 5min
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export interface BreakerState {
|
|
36
|
+
/** Timestamps of non-human deliveries within the current window */
|
|
37
|
+
timestamps: number[];
|
|
38
|
+
/** Whether the breaker is currently tripped */
|
|
39
|
+
tripped: boolean;
|
|
40
|
+
/** When the breaker was tripped (epoch ms), undefined if not tripped */
|
|
41
|
+
trippedAt?: number;
|
|
42
|
+
/** Events paused while tripped — delivered on resume */
|
|
43
|
+
paused: NormalizedEvent[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BreakerVerdict {
|
|
47
|
+
/** Whether delivery should proceed */
|
|
48
|
+
allow: boolean;
|
|
49
|
+
/** Whether this delivery tripped the breaker (first trip) */
|
|
50
|
+
justTripped: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const AGENT_LIFECYCLE_EVENTS = new Set([
|
|
54
|
+
"agent/session.completed",
|
|
55
|
+
"agent/session.failed",
|
|
56
|
+
"session.completed",
|
|
57
|
+
"session.failed",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Conversation key extraction
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns true if this event's type is exempt from breaker counting.
|
|
66
|
+
* inbox/* and reply/* are internal agent↔agent comms, not external
|
|
67
|
+
* conversation depth.
|
|
68
|
+
*/
|
|
69
|
+
export function isExemptFromBreaker(event: NormalizedEvent): boolean {
|
|
70
|
+
const t = event.type;
|
|
71
|
+
if (t.startsWith("inbox/") || t.startsWith("reply/")) return true;
|
|
72
|
+
if (event.source === "agent" && AGENT_LIFECYCLE_EVENTS.has(t)) return true;
|
|
73
|
+
// Also check topics — events published on inbox/* or reply/* topics
|
|
74
|
+
// (via createTopicEvent) carry those as their routing keys.
|
|
75
|
+
if (event.topics?.some((topic) => topic.startsWith("inbox/") || topic.startsWith("reply/"))) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (event.source === "agent" && event.topics?.some((topic) => AGENT_LIFECYCLE_EVENTS.has(topic))) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract the conversation key from an event. Returns null if no meaningful
|
|
86
|
+
* conversation can be identified (breaker won't apply).
|
|
87
|
+
*/
|
|
88
|
+
export function conversationKey(event: NormalizedEvent): string | null {
|
|
89
|
+
const payload = event.payload as Record<string, unknown> | undefined;
|
|
90
|
+
const fields = event.fields as Record<string, unknown> | undefined;
|
|
91
|
+
const payloadString = (key: string): string => {
|
|
92
|
+
const value = payload?.[key];
|
|
93
|
+
return typeof value === "string" ? value.trim() : "";
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (event.source === "slack") {
|
|
97
|
+
// Slack: thread_ts identifies a conversation (thread). If no thread,
|
|
98
|
+
// use ts (the message itself becomes the "thread" root).
|
|
99
|
+
const threadTs = (payload?.thread_ts as string) || (fields?.thread_ts as string) || "";
|
|
100
|
+
const ts = (payload?.ts as string) || (fields?.ts as string) || "";
|
|
101
|
+
const channel = (payload?.channel as string) || (fields?.channel as string) || "";
|
|
102
|
+
const key = threadTs || ts;
|
|
103
|
+
if (key && channel) return `slack:${channel}:${key}`;
|
|
104
|
+
if (key) return `slack:${key}`;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (event.source === "github") {
|
|
109
|
+
// GitHub: repo + issue/PR number identifies the conversation.
|
|
110
|
+
const repo = (payload?.repository as Record<string, unknown>)?.full_name as string | undefined;
|
|
111
|
+
const issue = payload?.issue as Record<string, unknown> | undefined;
|
|
112
|
+
const pr = payload?.pull_request as Record<string, unknown> | undefined;
|
|
113
|
+
const num = (issue?.number ?? pr?.number) as number | undefined;
|
|
114
|
+
if (repo && num !== undefined) return `github:${repo}#${num}`;
|
|
115
|
+
// Fall back to repo-level for events without an issue/PR (e.g. push)
|
|
116
|
+
if (repo) return `github:${repo}`;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.source === "monitor") {
|
|
121
|
+
// Monitor findings are often batched by one scheduled run but emitted as
|
|
122
|
+
// separate actionable findings. Key by the monitor plus the scheduler's
|
|
123
|
+
// stable finding identity so one audit pass with several distinct
|
|
124
|
+
// findings does not look like a self-reinforcing event loop.
|
|
125
|
+
const monitor = payloadString("monitor");
|
|
126
|
+
const findingKey = payloadString("finding_key") || payloadString("key");
|
|
127
|
+
if (monitor && findingKey) {
|
|
128
|
+
return `monitor:${encodeURIComponent(monitor)}:${encodeURIComponent(findingKey)}`;
|
|
129
|
+
}
|
|
130
|
+
if (monitor) return `monitor:${encodeURIComponent(monitor)}`;
|
|
131
|
+
return `monitor:${encodeURIComponent(event.type)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Fallback for custom/linear events: use the event type as a coarse key.
|
|
135
|
+
// This means N different events of the same type within one deployment
|
|
136
|
+
// share a single bucket — acceptable because custom loops are rare and
|
|
137
|
+
// the breaker trips only on sustained depth.
|
|
138
|
+
return `other:${event.type}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Determine whether an event is authored by a bot (non-human).
|
|
143
|
+
* Human-authored events reset the breaker window.
|
|
144
|
+
*/
|
|
145
|
+
export function isBotAuthored(event: NormalizedEvent): boolean {
|
|
146
|
+
const payload = event.payload as Record<string, unknown> | undefined;
|
|
147
|
+
|
|
148
|
+
if (event.source === "slack") {
|
|
149
|
+
// Slack: bot_id present on the inner event means a bot posted it.
|
|
150
|
+
const innerEvent = payload?.event as Record<string, unknown> | undefined;
|
|
151
|
+
const botId = (innerEvent?.bot_id as string) || (payload?.bot_id as string) || "";
|
|
152
|
+
return !!botId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (event.source === "github") {
|
|
156
|
+
// GitHub: sender.type === "Bot" or well-known agent logins.
|
|
157
|
+
const sender = payload?.sender as Record<string, unknown> | undefined;
|
|
158
|
+
if (sender?.type === "Bot") return true;
|
|
159
|
+
const login = (sender?.login as string) || "";
|
|
160
|
+
// Common bot suffixes: [bot], -bot, _bot
|
|
161
|
+
if (login.endsWith("[bot]") || login.endsWith("-bot") || login.endsWith("_bot")) return true;
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// For custom/linear sources: conservatively treat as bot-authored if the
|
|
166
|
+
// source field suggests automation. This avoids false negatives on agent-
|
|
167
|
+
// published events while still allowing human-triggered custom events to
|
|
168
|
+
// reset the breaker.
|
|
169
|
+
const source = event.source || "";
|
|
170
|
+
if (source === "agent" || source === "monitor" || source === "system") return true;
|
|
171
|
+
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Breaker engine (in-memory, per-process)
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Map of `deploymentId:conversationKey` → BreakerState */
|
|
180
|
+
const states = new Map<string, BreakerState>();
|
|
181
|
+
|
|
182
|
+
function getState(compositeKey: string): BreakerState {
|
|
183
|
+
let s = states.get(compositeKey);
|
|
184
|
+
if (!s) {
|
|
185
|
+
s = { timestamps: [], tripped: false, paused: [] };
|
|
186
|
+
states.set(compositeKey, s);
|
|
187
|
+
}
|
|
188
|
+
return s;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Prune timestamps outside the current window.
|
|
193
|
+
*/
|
|
194
|
+
function pruneWindow(state: BreakerState, now: number): void {
|
|
195
|
+
const cutoff = now - BREAKER_WINDOW_MS;
|
|
196
|
+
state.timestamps = state.timestamps.filter((t) => t > cutoff);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check whether a tripped breaker has cooled down and should auto-resume.
|
|
201
|
+
*/
|
|
202
|
+
function checkCooldown(state: BreakerState, now: number): boolean {
|
|
203
|
+
if (!state.tripped || !state.trippedAt) return false;
|
|
204
|
+
return now - state.trippedAt >= BREAKER_COOLDOWN_MS;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Record a delivery attempt and return a verdict.
|
|
209
|
+
*
|
|
210
|
+
* Call this BEFORE actually delivering the event. If `allow` is false,
|
|
211
|
+
* the caller must buffer the event (it is already added to state.paused).
|
|
212
|
+
*/
|
|
213
|
+
export function recordDelivery(
|
|
214
|
+
deploymentId: string,
|
|
215
|
+
event: NormalizedEvent,
|
|
216
|
+
): BreakerVerdict {
|
|
217
|
+
const convKey = conversationKey(event);
|
|
218
|
+
if (!convKey) return { allow: true, justTripped: false };
|
|
219
|
+
|
|
220
|
+
const compositeKey = `${deploymentId}:${convKey}`;
|
|
221
|
+
const state = getState(compositeKey);
|
|
222
|
+
const now = Date.now();
|
|
223
|
+
|
|
224
|
+
// Auto-resume on cooldown expiry
|
|
225
|
+
if (state.tripped && checkCooldown(state, now)) {
|
|
226
|
+
resumeState(state);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Human event resets the breaker
|
|
230
|
+
if (!isBotAuthored(event)) {
|
|
231
|
+
resumeState(state);
|
|
232
|
+
return { allow: true, justTripped: false };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// If already tripped, buffer the event
|
|
236
|
+
if (state.tripped) {
|
|
237
|
+
state.paused.push(event);
|
|
238
|
+
return { allow: false, justTripped: false };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Record this bot delivery
|
|
242
|
+
pruneWindow(state, now);
|
|
243
|
+
state.timestamps.push(now);
|
|
244
|
+
|
|
245
|
+
// Check threshold
|
|
246
|
+
if (state.timestamps.length >= BREAKER_THRESHOLD) {
|
|
247
|
+
state.tripped = true;
|
|
248
|
+
state.trippedAt = now;
|
|
249
|
+
state.paused.push(event);
|
|
250
|
+
return { allow: false, justTripped: true };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { allow: true, justTripped: false };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Resume a tripped breaker — returns any paused events that should now
|
|
258
|
+
* be delivered.
|
|
259
|
+
*/
|
|
260
|
+
function resumeState(state: BreakerState): void {
|
|
261
|
+
state.tripped = false;
|
|
262
|
+
state.trippedAt = undefined;
|
|
263
|
+
state.timestamps = [];
|
|
264
|
+
// Note: paused events are drained by the caller via drainPaused()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Drain paused events for a deployment+conversation. Returns the events
|
|
269
|
+
* that were buffered and clears the pause buffer. The caller is responsible
|
|
270
|
+
* for delivering them.
|
|
271
|
+
*/
|
|
272
|
+
export function drainPaused(deploymentId: string, event: NormalizedEvent): NormalizedEvent[] {
|
|
273
|
+
const convKey = conversationKey(event);
|
|
274
|
+
if (!convKey) return [];
|
|
275
|
+
const compositeKey = `${deploymentId}:${convKey}`;
|
|
276
|
+
const state = states.get(compositeKey);
|
|
277
|
+
if (!state || state.paused.length === 0) return [];
|
|
278
|
+
const drained = state.paused.splice(0);
|
|
279
|
+
return drained;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Check whether a breaker is currently tripped for a deployment+conversation.
|
|
284
|
+
* Used after cooldown to decide whether to flush paused events.
|
|
285
|
+
*/
|
|
286
|
+
export function isBreakerTripped(deploymentId: string, convKey: string): boolean {
|
|
287
|
+
const compositeKey = `${deploymentId}:${convKey}`;
|
|
288
|
+
const state = states.get(compositeKey);
|
|
289
|
+
if (!state) return false;
|
|
290
|
+
if (state.tripped && state.trippedAt && checkCooldown(state, Date.now())) {
|
|
291
|
+
resumeState(state);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return state.tripped;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build the system.loop_detected event to be published.
|
|
299
|
+
*/
|
|
300
|
+
export function buildLoopDetectedEvent(
|
|
301
|
+
deploymentId: string,
|
|
302
|
+
convKey: string,
|
|
303
|
+
triggerEvent: NormalizedEvent,
|
|
304
|
+
): NormalizedEvent {
|
|
305
|
+
return {
|
|
306
|
+
v: 2,
|
|
307
|
+
id: crypto.randomUUID(),
|
|
308
|
+
source: "system",
|
|
309
|
+
type: "system.loop_detected",
|
|
310
|
+
topics: ["system.loop_detected"],
|
|
311
|
+
delivery: "bulk",
|
|
312
|
+
text: `Circuit breaker tripped: deployment=${deploymentId} conversation=${convKey} trigger=${triggerEvent.type}`,
|
|
313
|
+
fields: {
|
|
314
|
+
deployment_id: deploymentId,
|
|
315
|
+
conversation_key: convKey,
|
|
316
|
+
trigger_event_type: triggerEvent.type,
|
|
317
|
+
trigger_event_id: triggerEvent.id,
|
|
318
|
+
threshold: BREAKER_THRESHOLD,
|
|
319
|
+
window_ms: BREAKER_WINDOW_MS,
|
|
320
|
+
cooldown_ms: BREAKER_COOLDOWN_MS,
|
|
321
|
+
},
|
|
322
|
+
timestamp: new Date().toISOString(),
|
|
323
|
+
payload: {
|
|
324
|
+
deployment_id: deploymentId,
|
|
325
|
+
conversation_key: convKey,
|
|
326
|
+
trigger_event: triggerEvent,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Reset (for testing)
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
export function resetAllBreakers(): void {
|
|
336
|
+
states.clear();
|
|
337
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation references - channel-agnostic addressing for chat replies (#618).
|
|
3
|
+
*
|
|
4
|
+
* A conversation reference is an opaque string the agent echoes back verbatim
|
|
5
|
+
* to reply into the thread/DM an inbound event came from. Only adapters build
|
|
6
|
+
* refs and only the gateway parses them; the agent never learns platform
|
|
7
|
+
* addressing semantics.
|
|
8
|
+
*
|
|
9
|
+
* Grammar:
|
|
10
|
+
* <source>:<scope>:<chat_type>:<chat_id>[:thread:<thread_id>]
|
|
11
|
+
*
|
|
12
|
+
* where <scope> is the platform's tenancy unit (Slack team id, WhatsApp phone
|
|
13
|
+
* number id). Segment values must not contain ":" - buildConversation enforces
|
|
14
|
+
* this, so a platform whose ids carry ":" (Matrix, Teams) needs a grammar
|
|
15
|
+
* extension (escaping or a new trailer), not a silent mis-parse.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ChatType = "dm" | "group" | "channel";
|
|
19
|
+
|
|
20
|
+
export interface Conversation {
|
|
21
|
+
source: string;
|
|
22
|
+
scope: string;
|
|
23
|
+
chatType: ChatType;
|
|
24
|
+
chatId: string;
|
|
25
|
+
threadId?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const CHAT_TYPES: ReadonlySet<string> = new Set(["dm", "group", "channel"]);
|
|
29
|
+
|
|
30
|
+
export function buildConversation(c: Conversation): string {
|
|
31
|
+
const segments = [c.source, c.scope, c.chatType, c.chatId];
|
|
32
|
+
if (c.threadId !== undefined) segments.push(c.threadId);
|
|
33
|
+
for (const s of segments) {
|
|
34
|
+
if (!s || s.includes(":")) {
|
|
35
|
+
throw new Error(`invalid conversation segment: ${JSON.stringify(s)}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const base = `${c.source}:${c.scope}:${c.chatType}:${c.chatId}`;
|
|
39
|
+
return c.threadId ? `${base}:thread:${c.threadId}` : base;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseConversation(ref: string): Conversation | null {
|
|
43
|
+
if (typeof ref !== "string") return null;
|
|
44
|
+
const parts = ref.split(":");
|
|
45
|
+
if (parts.length !== 4 && parts.length !== 6) return null;
|
|
46
|
+
if (parts.some((p) => p === "")) return null;
|
|
47
|
+
const [source, scope, chatType, chatId] = parts;
|
|
48
|
+
if (!CHAT_TYPES.has(chatType)) return null;
|
|
49
|
+
const conv: Conversation = {
|
|
50
|
+
source,
|
|
51
|
+
scope,
|
|
52
|
+
chatType: chatType as ChatType,
|
|
53
|
+
chatId,
|
|
54
|
+
};
|
|
55
|
+
if (parts.length === 6) {
|
|
56
|
+
if (parts[4] !== "thread") return null;
|
|
57
|
+
conv.threadId = parts[5];
|
|
58
|
+
}
|
|
59
|
+
return conv;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Slack channel_type -> chat_type. "im" is a 1:1 DM, "mpim" a group DM;
|
|
63
|
+
// public ("channel") and private ("group") channels both address as channel.
|
|
64
|
+
export function slackChatType(channelType: string): ChatType {
|
|
65
|
+
if (channelType === "im") return "dm";
|
|
66
|
+
if (channelType === "mpim") return "group";
|
|
67
|
+
return "channel";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build the reply address for an inbound Slack message, or undefined when the
|
|
71
|
+
// payload lacks usable ids. Owns the reply-anchoring policy: replies land in
|
|
72
|
+
// the originating thread, and a top-level message anchors its own thread (ts),
|
|
73
|
+
// matching where the placeholder handler posts.
|
|
74
|
+
export function slackConversation(
|
|
75
|
+
teamId: string,
|
|
76
|
+
channel: string,
|
|
77
|
+
channelType: string,
|
|
78
|
+
ts: string,
|
|
79
|
+
threadTs?: string,
|
|
80
|
+
): string | undefined {
|
|
81
|
+
if (!teamId || !channel) return undefined;
|
|
82
|
+
const anchor = threadTs || ts;
|
|
83
|
+
try {
|
|
84
|
+
return buildConversation({
|
|
85
|
+
source: "slack",
|
|
86
|
+
scope: teamId,
|
|
87
|
+
chatType: slackChatType(channelType),
|
|
88
|
+
chatId: channel,
|
|
89
|
+
...(anchor ? { threadId: anchor } : {}),
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// A colon-bearing id in the webhook payload must not kill normalization;
|
|
93
|
+
// the event just ships without a reply address.
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|