Skip to content

Commit 8e6d002

Browse files
committed
fix: preserve Signal group session IDs
1 parent 9b5f5b8 commit 8e6d002

20 files changed

Lines changed: 428 additions & 39 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
- Agents/subagents: route group/channel subagent completions through message-tool-only handoffs when required and keep active-requester wake failures from dropping completion delivery. Fixes #82803. Thanks @galiniliev, @yozakura-ava, and @moeedahmed.
1010
- Memory-core: scan persisted memory source sessions on startup, comparing on-disk transcripts against the index and marking only missing/newer/resized files dirty for incremental sync. Fixes #82341. (#82341) Thanks @giodl73-repo.
1111
- Telegram: keep the top-level default account in the account list when named accounts or bindings are added alongside top-level credentials, preserving default polling while still letting named-only configs resolve to a single account. Fixes #82794. (#82794) Thanks @giodl73-repo.
12+
- Signal: preserve mixed-case group IDs through routing and session persistence so group auto-replies keep delivering after updates. Fixes #82827.
1213
- WhatsApp: honor forced document delivery for outbound image, GIF, and video media so `forceDocument`/`asDocument` sends preserve original media bytes instead of using compressed media payloads. (#79272) Thanks @itsuzef.
1314

1415
## 2026.5.17

src/channels/session.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,37 @@ describe("recordInboundSession", () => {
111111
expect(route.ctx).toBe(ctx);
112112
});
113113

114+
it("preserves Signal group ids before recording and route updates", async () => {
115+
const mixedGroupId = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
116+
const signalCtx: MsgContext = {
117+
Provider: "signal",
118+
ChatType: "group",
119+
From: `signal:group:${mixedGroupId}`,
120+
To: `signal:group:${mixedGroupId}`,
121+
SessionKey: `agent:main:signal:group:${mixedGroupId}`,
122+
OriginatingTo: `signal:group:${mixedGroupId}`,
123+
};
124+
125+
await recordInboundSession({
126+
storePath: "/tmp/openclaw-session-store.json",
127+
sessionKey: `Agent:Main:Signal:Group:${mixedGroupId}`,
128+
ctx: signalCtx,
129+
updateLastRoute: {
130+
sessionKey: `Agent:Main:Signal:Group:${mixedGroupId}`,
131+
channel: "signal",
132+
to: `signal:group:${mixedGroupId}`,
133+
},
134+
onRecordError: vi.fn(),
135+
});
136+
137+
expect(requireFirstCallArg(recordSessionMetaFromInboundMock).sessionKey).toBe(
138+
`agent:main:signal:group:${mixedGroupId}`,
139+
);
140+
const route = requireFirstCallArg(updateLastRouteMock);
141+
expect(route.sessionKey).toBe(`agent:main:signal:group:${mixedGroupId}`);
142+
expect(route.ctx).toBe(signalCtx);
143+
});
144+
114145
it("skips last-route updates when main DM owner pin mismatches sender", async () => {
115146
const onSkip = vi.fn();
116147

src/channels/session.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MsgContext } from "../auto-reply/templating.js";
22
import type { GroupKeyResolution } from "../config/sessions/types.js";
3+
import { normalizeSessionKeyPreservingOpaquePeerIds } from "../sessions/session-key-utils.js";
34
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
45
import type { InboundLastRouteUpdate } from "./session.types.js";
56
export type { InboundLastRouteUpdate, RecordInboundSession } from "./session.types.js";
@@ -39,7 +40,7 @@ export async function recordInboundSession(params: {
3940
trackSessionMetaTask?: (task: Promise<unknown>) => void;
4041
}): Promise<void> {
4142
const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
42-
const canonicalSessionKey = normalizeLowercaseStringOrEmpty(sessionKey);
43+
const canonicalSessionKey = normalizeSessionKeyPreservingOpaquePeerIds(sessionKey);
4344
const runtime = await loadInboundSessionRuntime();
4445
const metaTask = runtime
4546
.recordSessionMetaFromInbound({
@@ -60,7 +61,7 @@ export async function recordInboundSession(params: {
6061
if (shouldSkipPinnedMainDmRouteUpdate(update.mainDmOwnerPin)) {
6162
return;
6263
}
63-
const targetSessionKey = normalizeLowercaseStringOrEmpty(update.sessionKey);
64+
const targetSessionKey = normalizeSessionKeyPreservingOpaquePeerIds(update.sessionKey);
6465
await runtime.updateLastRoute({
6566
storePath,
6667
sessionKey: targetSessionKey,

src/config/sessions/delivery-info.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,28 @@ describe("extractDeliveryInfo", () => {
371371
});
372372
});
373373

374+
it("finds legacy lowercase Signal group entries for mixed-case group keys", () => {
375+
const mixedGroupId = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
376+
const queriedKey = `agent:main:signal:group:${mixedGroupId}`;
377+
const legacyKey = queriedKey.toLowerCase();
378+
storeState.store[legacyKey] = buildEntry({
379+
channel: "signal",
380+
to: `signal:group:${mixedGroupId}`,
381+
accountId: "default",
382+
});
383+
384+
const result = extractDeliveryInfo(queriedKey);
385+
386+
expect(result).toEqual({
387+
deliveryContext: {
388+
channel: "signal",
389+
to: `signal:group:${mixedGroupId}`,
390+
accountId: "default",
391+
},
392+
threadId: undefined,
393+
});
394+
});
395+
374396
it("prefers the freshest routable alias even when the normalized key is already routable", () => {
375397
const queriedKey = "agent:main:matrix:channel:!MiXeDCase:Example.Org";
376398
const canonicalKey = "agent:main:matrix:channel:!mixedcase:example.org";

src/config/sessions/delivery-info.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { deliveryContextFromSession } from "../../utils/delivery-context.shared.
77
import { getRuntimeConfig } from "../io.js";
88
import type { OpenClawConfig } from "../types.openclaw.js";
99
import { resolveStorePath } from "./paths.js";
10+
import { normalizeStoreSessionKey } from "./store-entry.js";
1011
import { loadSessionStore } from "./store.js";
1112
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
1213
import { parseSessionThreadInfo } from "./thread-info.js";
@@ -104,14 +105,24 @@ function findSessionEntryInStore(
104105
};
105106
for (const key of keys) {
106107
const trimmed = key.trim();
107-
const normalized = normalizeLowercaseStringOrEmpty(key);
108+
const normalized = normalizeStoreSessionKey(key);
109+
const foldedLegacyKey = normalizeLowercaseStringOrEmpty(normalized);
108110
let foundRoutableCandidate = false;
109111
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
110112
foundRoutableCandidate ||= hasRoutableDeliveryContext(
111113
deliveryContextFromSession(store[normalized]),
112114
);
113115
acceptCandidate(store[normalized]);
114116
}
117+
if (
118+
foldedLegacyKey !== normalized &&
119+
Object.prototype.hasOwnProperty.call(store, foldedLegacyKey)
120+
) {
121+
foundRoutableCandidate ||= hasRoutableDeliveryContext(
122+
deliveryContextFromSession(store[foldedLegacyKey]),
123+
);
124+
acceptCandidate(store[foldedLegacyKey]);
125+
}
115126
if (trimmed !== normalized && Object.prototype.hasOwnProperty.call(store, trimmed)) {
116127
foundRoutableCandidate ||= hasRoutableDeliveryContext(
117128
deliveryContextFromSession(store[trimmed]),
@@ -122,6 +133,9 @@ function findSessionEntryInStore(
122133
normalizedIndex ??= buildFreshestSessionEntryIndex(store);
123134
const freshest = normalizedIndex.get(normalized);
124135
acceptCandidate(freshest);
136+
if (foldedLegacyKey !== normalized) {
137+
acceptCandidate(normalizedIndex.get(foldedLegacyKey));
138+
}
125139
}
126140
}
127141
return bestEntry;
@@ -132,7 +146,7 @@ function buildFreshestSessionEntryIndex(
132146
): Map<string, SessionEntry> {
133147
const index = new Map<string, SessionEntry>();
134148
for (const [key, entry] of Object.entries(store)) {
135-
const normalized = normalizeLowercaseStringOrEmpty(key);
149+
const normalized = normalizeStoreSessionKey(key);
136150
const existing = index.get(normalized);
137151
const entryRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(entry));
138152
const existingRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(existing));
@@ -143,6 +157,22 @@ function buildFreshestSessionEntryIndex(
143157
) {
144158
index.set(normalized, entry);
145159
}
160+
const foldedLegacyKey = normalizeLowercaseStringOrEmpty(normalized);
161+
if (foldedLegacyKey === normalized) {
162+
continue;
163+
}
164+
const foldedExisting = index.get(foldedLegacyKey);
165+
const foldedExistingRoutable = hasRoutableDeliveryContext(
166+
deliveryContextFromSession(foldedExisting),
167+
);
168+
if (
169+
!foldedExisting ||
170+
(entryRoutable && !foldedExistingRoutable) ||
171+
(entryRoutable === foldedExistingRoutable &&
172+
(entry.updatedAt ?? 0) > (foldedExisting.updatedAt ?? 0))
173+
) {
174+
index.set(foldedLegacyKey, entry);
175+
}
146176
}
147177
return index;
148178
}

src/config/sessions/explicit-session-key-normalization.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,19 @@ describe("normalizeExplicitSessionKey", () => {
5656
),
5757
).toBe("agent:fina:slack:dm:abc");
5858
});
59+
60+
it("preserves Signal group ids when explicit session keys are canonicalized", () => {
61+
const mixedGroupId = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
62+
expect(
63+
normalizeExplicitSessionKey(
64+
`Agent:Main:Signal:Group:${mixedGroupId}`,
65+
makeCtx({
66+
Provider: "signal",
67+
ChatType: "group",
68+
From: `signal:group:${mixedGroupId}`,
69+
OriginatingTo: `signal:group:${mixedGroupId}`,
70+
}),
71+
),
72+
).toBe(`agent:main:signal:group:${mixedGroupId}`);
73+
});
5974
});

src/config/sessions/explicit-session-key-normalization.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MsgContext } from "../../auto-reply/templating.js";
22
import { getLoadedChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
3+
import { normalizeSessionKeyPreservingOpaquePeerIds } from "../../sessions/session-key-utils.js";
34
import {
45
normalizeLowercaseStringOrEmpty,
56
normalizeOptionalLowercaseString,
@@ -36,12 +37,12 @@ function resolveExplicitSessionKeyNormalizerCandidates(
3637
}
3738

3839
export function normalizeExplicitSessionKey(sessionKey: string, ctx: MsgContext): string {
39-
const normalized = normalizeLowercaseStringOrEmpty(sessionKey);
40+
const normalized = normalizeSessionKeyPreservingOpaquePeerIds(sessionKey);
4041
for (const channelId of resolveExplicitSessionKeyNormalizerCandidates(normalized, ctx)) {
4142
const normalize = getLoadedChannelPlugin(channelId)?.messaging?.normalizeExplicitSessionKey;
4243
const next = normalize?.({ sessionKey: normalized, ctx });
4344
if (typeof next === "string" && next.trim()) {
44-
return normalizeLowercaseStringOrEmpty(next);
45+
return normalizeSessionKeyPreservingOpaquePeerIds(next);
4546
}
4647
}
4748
return normalized;

src/config/sessions/group.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { MsgContext } from "../../auto-reply/templating.js";
3+
import { resolveGroupSessionKey } from "./group.js";
4+
5+
describe("resolveGroupSessionKey", () => {
6+
it("preserves Signal group ids from the originating target", () => {
7+
const mixedGroupId = "VWATodkf2hc8zdOS76q9Tb0+5Bi522E03qLdaQ/9ypg=";
8+
const ctx = {
9+
Provider: "signal",
10+
ChatType: "group",
11+
From: "signal:+15551234567",
12+
OriginatingTo: `signal:group:${mixedGroupId}`,
13+
} satisfies Partial<MsgContext>;
14+
15+
expect(resolveGroupSessionKey(ctx as MsgContext)).toEqual({
16+
key: `signal:group:${mixedGroupId}`,
17+
channel: "signal",
18+
id: mixedGroupId,
19+
chatType: "group",
20+
});
21+
});
22+
23+
it("keeps non-Signal group ids lowercase", () => {
24+
const ctx = {
25+
Provider: "telegram",
26+
ChatType: "group",
27+
From: "telegram:1234",
28+
OriginatingTo: "telegram:group:MiXeDGroup",
29+
} satisfies Partial<MsgContext>;
30+
31+
expect(resolveGroupSessionKey(ctx as MsgContext)).toEqual({
32+
key: "telegram:group:mixedgroup",
33+
channel: "telegram",
34+
id: "mixedgroup",
35+
chatType: "group",
36+
});
37+
});
38+
});

src/config/sessions/group.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { MsgContext } from "../../auto-reply/templating.js";
22
import { listChannelPlugins } from "../../channels/plugins/registry.js";
3+
import { normalizeSessionPeerId } from "../../sessions/session-key-utils.js";
34
import {
45
normalizeLowercaseStringOrEmpty,
56
normalizeOptionalLowercaseString,
@@ -149,7 +150,7 @@ export function resolveGroupSessionKey(ctx: MsgContext): GroupKeyResolution | nu
149150
? parts.slice(2).join(":")
150151
: parts.slice(1).join(":")
151152
: from;
152-
const finalId = normalizeLowercaseStringOrEmpty(id);
153+
const finalId = normalizeSessionPeerId({ channel: provider, peerKind: kind, peerId: id });
153154
if (!finalId) {
154155
return null;
155156
}

src/config/sessions/store-entry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { normalizeSessionKeyPreservingOpaquePeerIds } from "../../sessions/session-key-utils.js";
12
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
23
import type { SessionEntry } from "./types.js";
34

45
export function normalizeStoreSessionKey(sessionKey: string): string {
5-
return normalizeLowercaseStringOrEmpty(sessionKey);
6+
return normalizeSessionKeyPreservingOpaquePeerIds(sessionKey);
67
}
78

89
export function resolveSessionStoreEntry(params: {
@@ -15,21 +16,34 @@ export function resolveSessionStoreEntry(params: {
1516
} {
1617
const trimmedKey = params.sessionKey.trim();
1718
const normalizedKey = normalizeStoreSessionKey(trimmedKey);
19+
const foldedLegacyKey = normalizeLowercaseStringOrEmpty(normalizedKey);
1820
const legacyKeySet = new Set<string>();
1921
if (
2022
trimmedKey !== normalizedKey &&
2123
Object.prototype.hasOwnProperty.call(params.store, trimmedKey)
2224
) {
2325
legacyKeySet.add(trimmedKey);
2426
}
27+
if (
28+
foldedLegacyKey !== normalizedKey &&
29+
Object.prototype.hasOwnProperty.call(params.store, foldedLegacyKey)
30+
) {
31+
legacyKeySet.add(foldedLegacyKey);
32+
}
2533
let existing =
26-
params.store[normalizedKey] ?? (legacyKeySet.size > 0 ? params.store[trimmedKey] : undefined);
34+
params.store[normalizedKey] ??
35+
params.store[foldedLegacyKey] ??
36+
(legacyKeySet.size > 0 ? params.store[trimmedKey] : undefined);
2737
let existingUpdatedAt = existing?.updatedAt ?? 0;
2838
for (const [candidateKey, candidateEntry] of Object.entries(params.store)) {
2939
if (candidateKey === normalizedKey) {
3040
continue;
3141
}
32-
if (normalizeStoreSessionKey(candidateKey) !== normalizedKey) {
42+
const candidateMatches =
43+
normalizeStoreSessionKey(candidateKey) === normalizedKey ||
44+
(foldedLegacyKey !== normalizedKey &&
45+
normalizeLowercaseStringOrEmpty(candidateKey) === foldedLegacyKey);
46+
if (!candidateMatches) {
3347
continue;
3448
}
3549
legacyKeySet.add(candidateKey);

0 commit comments

Comments
 (0)