Skip to content

Commit ccea3a0

Browse files
steipeteadam91holt
andcommitted
refactor: unify delivery target resolution
Co-authored-by: adam91holt <[email protected]>
1 parent f4f20c6 commit ccea3a0

5 files changed

Lines changed: 280 additions & 67 deletions

File tree

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { normalizeChannelId } from "../../channels/plugins/index.js";
21
import type { ChannelId } from "../../channels/plugins/types.js";
32
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
43
import type { ClawdbotConfig } from "../../config/config.js";
@@ -9,8 +8,7 @@ import {
98
} from "../../config/sessions.js";
109
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
1110
import type { OutboundChannel } from "../../infra/outbound/targets.js";
12-
import { resolveOutboundTarget } from "../../infra/outbound/targets.js";
13-
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
11+
import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "../../infra/outbound/targets.js";
1412

1513
export async function resolveDeliveryTarget(
1614
cfg: ClawdbotConfig,
@@ -26,56 +24,63 @@ export async function resolveDeliveryTarget(
2624
mode: "explicit" | "implicit";
2725
error?: Error;
2826
}> {
29-
const requestedRaw = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
30-
const requestedChannelHint = normalizeMessageChannel(requestedRaw) ?? requestedRaw;
31-
const explicitTo =
32-
typeof jobPayload.to === "string" && jobPayload.to.trim() ? jobPayload.to.trim() : undefined;
27+
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
28+
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
3329

3430
const sessionCfg = cfg.session;
3531
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
3632
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
3733
const store = loadSessionStore(storePath);
3834
const main = store[mainSessionKey];
39-
const lastChannel =
40-
main?.lastChannel && main.lastChannel !== INTERNAL_MESSAGE_CHANNEL
41-
? normalizeChannelId(main.lastChannel)
42-
: undefined;
43-
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
44-
const lastAccountId = main?.lastAccountId;
4535

46-
let channel: Exclude<OutboundChannel, "none"> | undefined =
47-
requestedChannelHint === "last"
48-
? (lastChannel ?? undefined)
49-
: requestedChannelHint === INTERNAL_MESSAGE_CHANNEL
50-
? undefined
51-
: (normalizeChannelId(requestedChannelHint) ?? undefined);
52-
if (!channel) {
36+
const preliminary = resolveSessionDeliveryTarget({
37+
entry: main,
38+
requestedChannel,
39+
explicitTo,
40+
allowMismatchedLastTo: true,
41+
});
42+
43+
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
44+
if (!preliminary.channel) {
5345
try {
5446
const selection = await resolveMessageChannelSelection({ cfg });
55-
channel = selection.channel;
47+
fallbackChannel = selection.channel;
5648
} catch {
57-
channel = lastChannel ?? DEFAULT_CHAT_CHANNEL;
49+
fallbackChannel = preliminary.lastChannel ?? DEFAULT_CHAT_CHANNEL;
5850
}
5951
}
6052

61-
const toCandidate = explicitTo ?? (lastTo || undefined);
62-
const mode: "explicit" | "implicit" = explicitTo ? "explicit" : "implicit";
53+
const resolved = fallbackChannel
54+
? resolveSessionDeliveryTarget({
55+
entry: main,
56+
requestedChannel,
57+
explicitTo,
58+
fallbackChannel,
59+
allowMismatchedLastTo: true,
60+
mode: preliminary.mode,
61+
})
62+
: preliminary;
63+
64+
const channel = resolved.channel ?? fallbackChannel ?? DEFAULT_CHAT_CHANNEL;
65+
const mode = resolved.mode as "explicit" | "implicit";
66+
const toCandidate = resolved.to;
67+
6368
if (!toCandidate) {
64-
return { channel, to: undefined, accountId: lastAccountId, mode };
69+
return { channel, to: undefined, accountId: resolved.accountId, mode };
6570
}
6671

67-
const resolved = resolveOutboundTarget({
72+
const docked = resolveOutboundTarget({
6873
channel,
6974
to: toCandidate,
7075
cfg,
71-
accountId: channel === lastChannel ? lastAccountId : undefined,
76+
accountId: resolved.accountId,
7277
mode,
7378
});
7479
return {
7580
channel,
76-
to: resolved.ok ? resolved.to : undefined,
77-
accountId: channel === lastChannel ? lastAccountId : undefined,
81+
to: docked.ok ? docked.to : undefined,
82+
accountId: resolved.accountId,
7883
mode,
79-
error: resolved.ok ? undefined : resolved.error,
84+
error: docked.ok ? undefined : docked.error,
8085
};
8186
}

src/infra/heartbeat-runner.returns-default-unset.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
119119
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
120120
channel: "none",
121121
reason: "target-none",
122+
accountId: undefined,
123+
lastChannel: undefined,
124+
lastAccountId: undefined,
122125
});
123126
});
124127

@@ -132,6 +135,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
132135
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
133136
channel: "whatsapp",
134137
to: "+1555",
138+
accountId: undefined,
139+
lastChannel: "whatsapp",
140+
lastAccountId: undefined,
135141
});
136142
});
137143

@@ -147,6 +153,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
147153
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
148154
channel: "whatsapp",
149155
to: "+555123",
156+
accountId: undefined,
157+
lastChannel: undefined,
158+
lastAccountId: undefined,
150159
});
151160
});
152161

@@ -160,6 +169,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
160169
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
161170
channel: "none",
162171
reason: "no-target",
172+
accountId: undefined,
173+
lastChannel: undefined,
174+
lastAccountId: undefined,
163175
});
164176
});
165177

@@ -177,6 +189,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
177189
channel: "whatsapp",
178190
to: "+1555",
179191
reason: "allowFrom-fallback",
192+
accountId: undefined,
193+
lastChannel: "whatsapp",
194+
lastAccountId: undefined,
180195
});
181196
});
182197

@@ -192,6 +207,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
192207
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
193208
channel: "whatsapp",
194209
210+
accountId: undefined,
211+
lastChannel: "whatsapp",
212+
lastAccountId: undefined,
195213
});
196214
});
197215

@@ -207,6 +225,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
207225
expect(resolveHeartbeatDeliveryTarget({ cfg, entry })).toEqual({
208226
channel: "whatsapp",
209227
228+
accountId: undefined,
229+
lastChannel: "whatsapp",
230+
lastAccountId: undefined,
210231
});
211232
});
212233

@@ -217,6 +238,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
217238
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
218239
channel: "telegram",
219240
to: "123",
241+
accountId: undefined,
242+
lastChannel: undefined,
243+
lastAccountId: undefined,
220244
});
221245
});
222246

@@ -234,6 +258,9 @@ describe("resolveHeartbeatDeliveryTarget", () => {
234258
).toEqual({
235259
channel: "whatsapp",
236260
to: "+1555",
261+
accountId: undefined,
262+
lastChannel: "whatsapp",
263+
lastAccountId: undefined,
237264
});
238265
});
239266
});

src/infra/heartbeat-runner.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from "../auto-reply/heartbeat.js";
99
import { getReplyFromConfig } from "../auto-reply/reply.js";
1010
import type { ReplyPayload } from "../auto-reply/types.js";
11-
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
11+
import { getChannelPlugin } from "../channels/plugins/index.js";
1212
import type { ChannelHeartbeatDeps } from "../channels/plugins/types.js";
1313
import { parseDurationMs } from "../cli/parse-duration.js";
1414
import type { ClawdbotConfig } from "../config/config.js";
@@ -26,7 +26,6 @@ import { createSubsystemLogger } from "../logging.js";
2626
import { getQueueSize } from "../process/command-queue.js";
2727
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
2828
import { normalizeAgentId } from "../routing/session-key.js";
29-
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
3029
import { emitHeartbeatEvent } from "./heartbeat-events.js";
3130
import {
3231
type HeartbeatRunResult,
@@ -337,15 +336,13 @@ export async function runHeartbeatOnce(opts: {
337336
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
338337
const previousUpdatedAt = entry?.updatedAt;
339338
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
340-
const lastChannel =
341-
entry?.lastChannel && entry.lastChannel !== INTERNAL_MESSAGE_CHANNEL
342-
? normalizeChannelId(entry.lastChannel)
343-
: undefined;
339+
const lastChannel = delivery.lastChannel;
340+
const lastAccountId = delivery.lastAccountId;
344341
const senderProvider = delivery.channel !== "none" ? delivery.channel : lastChannel;
345342
const senderAllowFrom = senderProvider
346343
? (getChannelPlugin(senderProvider)?.config.resolveAllowFrom?.({
347344
cfg,
348-
accountId: senderProvider === lastChannel ? entry?.lastAccountId : undefined,
345+
accountId: senderProvider === lastChannel ? lastAccountId : undefined,
349346
}) ?? [])
350347
: [];
351348
const sender = resolveHeartbeatSender({
@@ -460,7 +457,7 @@ export async function runHeartbeatOnce(opts: {
460457
return { status: "ran", durationMs: Date.now() - startedAt };
461458
}
462459

463-
const deliveryAccountId = delivery.channel === lastChannel ? entry?.lastAccountId : undefined;
460+
const deliveryAccountId = delivery.accountId;
464461
const heartbeatPlugin = getChannelPlugin(delivery.channel);
465462
if (heartbeatPlugin?.heartbeat?.checkReady) {
466463
const readiness = await heartbeatPlugin.heartbeat.checkReady({

src/infra/outbound/targets.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import type { ClawdbotConfig } from "../../config/config.js";
33

4-
import { resolveOutboundTarget } from "./targets.js";
4+
import { resolveOutboundTarget, resolveSessionDeliveryTarget } from "./targets.js";
55

66
describe("resolveOutboundTarget", () => {
77
it("falls back to whatsapp allowFrom via config", () => {
@@ -93,3 +93,96 @@ describe("resolveOutboundTarget", () => {
9393
}
9494
});
9595
});
96+
97+
describe("resolveSessionDeliveryTarget", () => {
98+
it("derives implicit delivery from the last route", () => {
99+
const resolved = resolveSessionDeliveryTarget({
100+
entry: {
101+
sessionId: "sess-1",
102+
updatedAt: 1,
103+
lastChannel: " whatsapp ",
104+
lastTo: " +1555 ",
105+
lastAccountId: " acct-1 ",
106+
},
107+
requestedChannel: "last",
108+
});
109+
110+
expect(resolved).toEqual({
111+
channel: "whatsapp",
112+
to: "+1555",
113+
accountId: "acct-1",
114+
mode: "implicit",
115+
lastChannel: "whatsapp",
116+
lastTo: "+1555",
117+
lastAccountId: "acct-1",
118+
});
119+
});
120+
121+
it("prefers explicit targets without reusing lastTo", () => {
122+
const resolved = resolveSessionDeliveryTarget({
123+
entry: {
124+
sessionId: "sess-2",
125+
updatedAt: 1,
126+
lastChannel: "whatsapp",
127+
lastTo: "+1555",
128+
},
129+
requestedChannel: "telegram",
130+
});
131+
132+
expect(resolved).toEqual({
133+
channel: "telegram",
134+
to: undefined,
135+
accountId: undefined,
136+
mode: "implicit",
137+
lastChannel: "whatsapp",
138+
lastTo: "+1555",
139+
lastAccountId: undefined,
140+
});
141+
});
142+
143+
it("allows mismatched lastTo when configured", () => {
144+
const resolved = resolveSessionDeliveryTarget({
145+
entry: {
146+
sessionId: "sess-3",
147+
updatedAt: 1,
148+
lastChannel: "whatsapp",
149+
lastTo: "+1555",
150+
},
151+
requestedChannel: "telegram",
152+
allowMismatchedLastTo: true,
153+
});
154+
155+
expect(resolved).toEqual({
156+
channel: "telegram",
157+
to: "+1555",
158+
accountId: undefined,
159+
mode: "implicit",
160+
lastChannel: "whatsapp",
161+
lastTo: "+1555",
162+
lastAccountId: undefined,
163+
});
164+
});
165+
166+
it("falls back to a provided channel when requested is unsupported", () => {
167+
const resolved = resolveSessionDeliveryTarget({
168+
entry: {
169+
sessionId: "sess-4",
170+
updatedAt: 1,
171+
lastChannel: "whatsapp",
172+
lastTo: "+1555",
173+
},
174+
requestedChannel: "webchat",
175+
fallbackChannel: "slack",
176+
});
177+
178+
expect(resolved).toEqual({
179+
channel: "slack",
180+
to: undefined,
181+
accountId: undefined,
182+
mode: "implicit",
183+
lastChannel: "whatsapp",
184+
lastTo: "+1555",
185+
lastAccountId: undefined,
186+
});
187+
});
188+
});

0 commit comments

Comments
 (0)