Skip to content

Commit 57ec5ae

Browse files
authored
Merge branch 'main' into fix/msteams-provider-prefixed-target-id
2 parents 8aed6c7 + 8756cc4 commit 57ec5ae

9 files changed

Lines changed: 173 additions & 27 deletions

File tree

apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1175,7 +1175,7 @@ private final class ExecApprovalsSocketLifecycleLease: @unchecked Sendable {
11751175
}
11761176

11771177
private static func releaseProcessReservation(_ path: String) {
1178-
self.processLock.withLock {
1178+
_ = self.processLock.withLock {
11791179
self.reservedPaths.remove(path)
11801180
}
11811181
}

extensions/googlechat/src/monitor-durable.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe("resolveGoogleChatDurableReplyOptions", () => {
99
payload: { text: "hello", replyToId: "thread-1" },
1010
infoKind: "final",
1111
spaceId: "spaces/AAA",
12+
hasTypingMessage: false,
1213
}),
1314
).toEqual({
1415
to: "spaces/AAA",
@@ -17,13 +18,27 @@ describe("resolveGoogleChatDurableReplyOptions", () => {
1718
});
1819
});
1920

21+
it("suppresses stale context reply metadata when the final payload is top-level", () => {
22+
expect(
23+
resolveGoogleChatDurableReplyOptions({
24+
payload: { text: "hello" },
25+
infoKind: "final",
26+
spaceId: "spaces/AAA",
27+
hasTypingMessage: false,
28+
}),
29+
).toEqual({
30+
to: "spaces/AAA",
31+
replyToId: null,
32+
});
33+
});
34+
2035
it("keeps typing preview delivery on the legacy edit path", () => {
2136
expect(
2237
resolveGoogleChatDurableReplyOptions({
2338
payload: { text: "hello" },
2439
infoKind: "final",
2540
spaceId: "spaces/AAA",
26-
typingMessageName: "spaces/AAA/messages/typing",
41+
hasTypingMessage: true,
2742
}),
2843
).toBe(false);
2944
});
@@ -34,6 +49,7 @@ describe("resolveGoogleChatDurableReplyOptions", () => {
3449
payload: { text: "hello" },
3550
infoKind: "block",
3651
spaceId: "spaces/AAA",
52+
hasTypingMessage: false,
3753
}),
3854
).toBe(false);
3955
});

extensions/googlechat/src/monitor-durable.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,26 @@ import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
33

44
type GoogleChatDurableReplyOptions = {
55
to: string;
6-
replyToId?: string;
6+
replyToId?: string | null;
77
threadId?: string;
88
};
99

1010
export function resolveGoogleChatDurableReplyOptions(params: {
1111
payload: ReplyPayload;
1212
infoKind: string;
1313
spaceId: string;
14-
typingMessageName?: string;
14+
hasTypingMessage: boolean;
1515
}): GoogleChatDurableReplyOptions | false {
16-
if (params.infoKind !== "final" || params.typingMessageName) {
16+
if (params.infoKind !== "final" || params.hasTypingMessage) {
1717
return false;
1818
}
1919
const threadId = params.payload.replyToId?.trim() || undefined;
20+
if (!threadId) {
21+
return { to: params.spaceId, replyToId: null };
22+
}
2023
return {
2124
to: params.spaceId,
22-
...(threadId ? { replyToId: threadId, threadId } : {}),
25+
replyToId: threadId,
26+
threadId,
2327
};
2428
}

extensions/googlechat/src/monitor-reply-delivery.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { ResolvedGoogleChatAccount } from "./accounts.js";
55
import { deleteGoogleChatMessage, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
66
import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js";
77

8+
export type GoogleChatTypingMessage = {
9+
name: string;
10+
thread?: string;
11+
};
12+
813
export async function deliverGoogleChatReply(params: {
914
payload: {
1015
text?: string;
@@ -18,16 +23,29 @@ export async function deliverGoogleChatReply(params: {
1823
core: GoogleChatCoreRuntime;
1924
config: OpenClawConfig;
2025
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
21-
typingMessageName?: string;
26+
typingMessage?: GoogleChatTypingMessage;
2227
}): Promise<void> {
2328
const { payload, account, spaceId, runtime, core, config, statusSink } = params;
2429
// Clear this whenever the typing message is deleted or unavailable; otherwise
2530
// text delivery can keep retrying a dead message and drop content.
26-
let typingMessageName = params.typingMessageName;
31+
let typingMessage = params.typingMessage;
32+
const replyThreadName = payload.replyToId?.trim() || undefined;
33+
const typingMessageThreadName = typingMessage?.thread?.trim() || undefined;
2734
const reply = resolveSendableOutboundReplyParts(payload);
2835
const text = reply.text;
2936
let firstTextChunk = true;
3037

38+
if (typingMessage && typingMessageThreadName !== replyThreadName) {
39+
// Typing starts before reply directives are resolved. Never edit a placeholder
40+
// from one thread into a final reply targeted at another conversation surface.
41+
try {
42+
await deleteGoogleChatMessage({ account, messageName: typingMessage.name });
43+
} catch (err) {
44+
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
45+
}
46+
typingMessage = undefined;
47+
}
48+
3149
if (reply.hasMedia) {
3250
runtime.error?.(
3351
"Google Chat outbound attachments require user OAuth and are not supported by this service-account channel; sending text fallback only.",
@@ -36,8 +54,8 @@ export async function deliverGoogleChatReply(params: {
3654

3755
if (reply.hasMedia && !reply.hasText) {
3856
try {
39-
if (typingMessageName) {
40-
await deleteGoogleChatMessage({ account, messageName: typingMessageName });
57+
if (typingMessage) {
58+
await deleteGoogleChatMessage({ account, messageName: typingMessage.name });
4159
}
4260
} catch (err) {
4361
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
@@ -54,7 +72,7 @@ export async function deliverGoogleChatReply(params: {
5472
account,
5573
space: spaceId,
5674
text: chunk,
57-
thread: payload.replyToId,
75+
thread: replyThreadName,
5876
});
5977
};
6078
const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode);
@@ -63,10 +81,10 @@ export async function deliverGoogleChatReply(params: {
6381
continue;
6482
}
6583
try {
66-
if (firstTextChunk && typingMessageName) {
84+
if (firstTextChunk && typingMessage) {
6785
await updateGoogleChatMessage({
6886
account,
69-
messageName: typingMessageName,
87+
messageName: typingMessage.name,
7088
text: chunk,
7189
});
7290
} else {
@@ -76,8 +94,8 @@ export async function deliverGoogleChatReply(params: {
7694
statusSink?.({ lastOutboundAt: Date.now() });
7795
} catch (err) {
7896
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
79-
if (firstTextChunk && typingMessageName) {
80-
typingMessageName = undefined;
97+
if (firstTextChunk && typingMessage) {
98+
typingMessage = undefined;
8199
try {
82100
await sendTextMessage(chunk);
83101
statusSink?.({ lastOutboundAt: Date.now() });

extensions/googlechat/src/monitor.reply-delivery.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ describe("Google Chat reply delivery", () => {
7777
core,
7878
config,
7979
statusSink,
80-
typingMessageName: "spaces/AAA/messages/typing",
80+
typingMessage: {
81+
name: "spaces/AAA/messages/typing",
82+
thread: "spaces/AAA/threads/root",
83+
},
8184
});
8285

8386
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
@@ -104,6 +107,37 @@ describe("Google Chat reply delivery", () => {
104107
);
105108
});
106109

110+
it("replaces a typing message when the final reply target changed", async () => {
111+
const core = createCore();
112+
const runtime = createRuntime();
113+
mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/reply" });
114+
115+
await deliverGoogleChatReply({
116+
payload: { text: "top-level reply" },
117+
account,
118+
spaceId: "spaces/AAA",
119+
runtime,
120+
core,
121+
config,
122+
typingMessage: {
123+
name: "spaces/AAA/messages/typing",
124+
thread: "spaces/AAA/threads/root",
125+
},
126+
});
127+
128+
expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({
129+
account,
130+
messageName: "spaces/AAA/messages/typing",
131+
});
132+
expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled();
133+
expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({
134+
account,
135+
space: "spaces/AAA",
136+
text: "top-level reply",
137+
thread: undefined,
138+
});
139+
});
140+
107141
it("uses text fallback without loading outbound media", async () => {
108142
const core = createCore({
109143
media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" },
@@ -121,7 +155,10 @@ describe("Google Chat reply delivery", () => {
121155
runtime,
122156
core,
123157
config,
124-
typingMessageName: "spaces/AAA/messages/typing",
158+
typingMessage: {
159+
name: "spaces/AAA/messages/typing",
160+
thread: "spaces/AAA/threads/root",
161+
},
125162
});
126163

127164
expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({
@@ -152,7 +189,10 @@ describe("Google Chat reply delivery", () => {
152189
runtime,
153190
core,
154191
config,
155-
typingMessageName: "spaces/AAA/messages/typing",
192+
typingMessage: {
193+
name: "spaces/AAA/messages/typing",
194+
thread: "spaces/AAA/threads/root",
195+
},
156196
}),
157197
).rejects.toThrow(
158198
"Google Chat outbound attachments require user OAuth and no text fallback is available.",

extensions/googlechat/src/monitor.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,57 @@ describe("googlechat monitor inbound space classification", () => {
253253
);
254254
expect(runTurn).toHaveBeenCalledOnce();
255255
});
256+
257+
it.each([
258+
{ name: "the default off mode", replyToMode: undefined, expectedThread: undefined },
259+
{ name: "explicit off mode", replyToMode: "off" as const, expectedThread: undefined },
260+
{
261+
name: "all mode",
262+
replyToMode: "all" as const,
263+
expectedThread: "spaces/CLASSIFY/threads/root",
264+
},
265+
])("targets typing messages according to $name", async ({ replyToMode, expectedThread }) => {
266+
const { core } = createInboundClassificationHarness();
267+
const account = {
268+
accountId: "work",
269+
config: { replyToMode },
270+
credentialSource: "inline",
271+
} as ResolvedGoogleChatAccount;
272+
const event = {
273+
type: "MESSAGE",
274+
space: { name: "spaces/CLASSIFY", spaceType: "SPACE" },
275+
message: {
276+
name: "spaces/CLASSIFY/messages/1",
277+
text: "hello",
278+
thread: { name: "spaces/CLASSIFY/threads/root" },
279+
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
280+
},
281+
} satisfies GoogleChatEvent;
282+
283+
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
284+
ok: true,
285+
commandAuthorized: undefined,
286+
effectiveWasMentioned: undefined,
287+
groupBotLoopProtection: undefined,
288+
groupSystemPrompt: undefined,
289+
});
290+
291+
await testing.processMessageWithPipeline({
292+
event,
293+
account,
294+
config: {},
295+
runtime: { error: vi.fn(), log: vi.fn() },
296+
core,
297+
mediaMaxMb: 10,
298+
});
299+
300+
expect(apiMocks.sendGoogleChatMessage).toHaveBeenCalledWith({
301+
account,
302+
space: "spaces/CLASSIFY",
303+
text: "_OpenClaw is typing..._",
304+
thread: expectedThread,
305+
});
306+
});
256307
});
257308

258309
describe("googlechat monitor sender bot status", () => {

extensions/googlechat/src/monitor.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { maybeHandleGoogleChatApprovalCardClick } from "./approval-card-click.js
1616
import type { GoogleChatAudienceType } from "./auth.js";
1717
import { applyGoogleChatInboundAccessPolicy } from "./monitor-access.js";
1818
import { resolveGoogleChatDurableReplyOptions } from "./monitor-durable.js";
19-
import { deliverGoogleChatReply } from "./monitor-reply-delivery.js";
19+
import { deliverGoogleChatReply, type GoogleChatTypingMessage } from "./monitor-reply-delivery.js";
2020
import {
2121
registerGoogleChatWebhookTarget,
2222
setGoogleChatWebhookEventProcessor,
@@ -354,7 +354,11 @@ async function processMessageWithPipeline(params: {
354354
);
355355
typingIndicator = "message";
356356
}
357-
let typingMessageName: string | undefined;
357+
let typingMessage: GoogleChatTypingMessage | undefined;
358+
const typingMessageThreadName =
359+
account.config.replyToMode && account.config.replyToMode !== "off"
360+
? replyThreadName
361+
: undefined;
358362

359363
// Start typing indicator (message mode only, reaction mode not supported with app auth)
360364
if (typingIndicator === "message") {
@@ -368,9 +372,11 @@ async function processMessageWithPipeline(params: {
368372
account,
369373
space: spaceId,
370374
text: `_${botName} is typing..._`,
371-
thread: replyThreadName,
375+
thread: typingMessageThreadName,
372376
});
373-
typingMessageName = result?.messageName;
377+
if (result?.messageName) {
378+
typingMessage = { name: result.messageName, thread: typingMessageThreadName };
379+
}
374380
} catch (err) {
375381
runtime.error?.(`Failed sending typing message: ${String(err)}`);
376382
}
@@ -406,7 +412,7 @@ async function processMessageWithPipeline(params: {
406412
payload,
407413
infoKind: info.kind,
408414
spaceId,
409-
typingMessageName,
415+
hasTypingMessage: Boolean(typingMessage),
410416
}),
411417
deliver: async (payload) => {
412418
await deliverGoogleChatReply({
@@ -417,10 +423,10 @@ async function processMessageWithPipeline(params: {
417423
core,
418424
config,
419425
statusSink,
420-
typingMessageName,
426+
typingMessage,
421427
});
422428
// Only use typing message for first delivery
423-
typingMessageName = undefined;
429+
typingMessage = undefined;
424430
},
425431
onDelivered: () => {
426432
statusSink?.({ lastOutboundAt: Date.now() });

src/gateway/watch-node-http.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,9 @@ export function createWatchNodeHttpRuntime(options: WatchNodeHttpRuntimeOptions)
992992
return;
993993
}
994994
const body = await readJsonBodyOrError(req, res, MAX_BODY_BYTES);
995+
if (body === undefined) {
996+
return;
997+
}
995998
if (!isStringRecord(body) || typeof body.id !== "string" || typeof body.ok !== "boolean") {
996999
sendInvalidRequest(res, "invalid node invoke result");
9971000
return;

ui/src/styles/layout.css

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1781,8 +1781,9 @@ openclaw-session-menu[popover] {
17811781
display: inline-flex;
17821782
align-items: center;
17831783
justify-content: center;
1784-
width: 30px;
1785-
height: 30px;
1784+
/* 28px keeps the fixed-width footer row from ellipsizing "commit · age". */
1785+
width: 28px;
1786+
height: 28px;
17861787
flex: 0 0 auto;
17871788
border: none;
17881789
border-radius: var(--radius-md);
@@ -1837,6 +1838,13 @@ openclaw-session-menu[popover] {
18371838
flex-shrink: 0;
18381839
}
18391840

1841+
/* The online/offline glow spreads 4px beyond the dot; the footer bar's 2px gap
1842+
alone lets it collide with the build text. 6px + gap matches the 8px dot
1843+
spacing in the settings sidebar footer. */
1844+
.sidebar-footer-bar .sidebar-status__dot {
1845+
margin-right: 6px;
1846+
}
1847+
18401848
.sidebar-status__dot.sidebar-connection-status--online {
18411849
background: var(--ok);
18421850
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ok) 14%, transparent);

0 commit comments

Comments
 (0)