Skip to content

Commit fc5696a

Browse files
authored
chore(macos): sync native i18n inventory
1 parent a14c451 commit fc5696a

15 files changed

Lines changed: 227 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- **Slack progress indicators:** use Slack's native assistant thread status and rotating loading messages by default while keeping acknowledgement reactions static; lifecycle reaction updates now require `messages.statusReactions.enabled: true`.
910
- **Control UI Talk controls:** keep voice, model, and sensitivity in the composer while moving provider, transport, VAD timing, and reasoning defaults to Settings → Communications → Talk.
1011
- **Logbook work journal:** add a disabled-by-default bundled plugin that turns paired-node screen snapshots into a private timeline, daily standup, and timeline-grounded Q&A in a plugin-contributed Control UI tab. (#99930)
1112
- **Control UI message context:** reveal per-message token, context, and model details from the timestamp on hover or activation instead of showing a separate Context button.
@@ -30,6 +31,9 @@ Docs: https://docs.openclaw.ai
3031
- **Plugin approval diagnostics:** distinguish request validation rejections, expired wait decisions, and unavailable Gateways while keeping approval failures fail-closed. (#100337) Thanks @tzy-17.
3132
- **IRC Unicode messages:** split outbound PRIVMSG payloads on UTF-16 code-point boundaries so emoji cannot be cut into lone surrogates. (#96572) Thanks @llagy009.
3233
- **OpenAI realtime voice greetings:** prevent server VAD from creating a second outbound greeting while an explicit greeting response owns the turn, without disabling caller interruption. (#86285) Thanks @giodl73-repo.
34+
- **Realtime voice tools:** filter malformed tool names at each OpenAI, Azure, and Google realtime payload boundary while preserving provider-specific valid names. (#89175) Thanks @vincentkoc.
35+
- **Discord voice status:** treat Discord error 10065 as a normal disconnected state while preserving unrelated REST failures. (#90969) Thanks @asock.
36+
- **Discord voice accounts:** isolate `@discordjs/voice` connections by Discord account and recover auto-join when gateway readiness predates listener registration. (#87530) Thanks @geekhuashan.
3337
- **iOS Voice Wake cleanup:** avoid initializing the microphone audio pipeline while disabling inactive Voice Wake, preventing simulator launch aborts and unnecessary audio setup.
3438
- **Cron duration validation:** reject positive durations that truncate below one millisecond instead of silently scheduling a zero-duration interval. (#100311) Thanks @qingminglong.
3539
- **Skill workshop proposals:** preserve the terminal newline in generated proposal Markdown while still rejecting blank raw content. (#100293) Thanks @anyech.

docs/channels/slack.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,8 @@ When a `message` tool call runs inside a Slack thread and targets the same chann
10801080

10811081
`ackReaction` sends an acknowledgement emoji while OpenClaw is processing an inbound message. `ackReactionScope` decides _when_ that emoji is actually sent.
10821082

1083+
By default, the acknowledgement stays static while Slack's native assistant thread status shows progress with rotating loading messages. Set `messages.statusReactions.enabled: true` to opt into the queued/thinking/tool/done/error reaction lifecycle instead.
1084+
10831085
### Emoji (`ackReaction`)
10841086

10851087
Resolution order:

docs/gateway/config-agents.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,8 +1400,9 @@ Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.
14001400
- Scope: `group-mentions` (default), `group-all`, `direct`, `all`, or `off`/`none` (disables ack reactions entirely).
14011401
- `removeAckAfterReply`: removes ack after reply on reaction-capable channels such as Slack, Discord, Signal, Telegram, WhatsApp, and iMessage.
14021402
- `messages.statusReactions.enabled`: enables lifecycle status reactions on Slack, Discord, Signal, Telegram, and WhatsApp.
1403-
On Slack and Discord, unset keeps status reactions enabled when ack reactions are active.
1404-
On Signal, Telegram, and WhatsApp, set it explicitly to `true` to enable lifecycle status reactions.
1403+
On Discord, unset keeps status reactions enabled when ack reactions are active.
1404+
On Slack, Signal, Telegram, and WhatsApp, set it explicitly to `true` to enable lifecycle status reactions.
1405+
Slack uses its native assistant thread status and rotating loading messages for progress by default, while keeping the configured ack reaction static.
14051406
- `messages.statusReactions.emojis`: overrides lifecycle emoji keys:
14061407
`queued`, `thinking`, `compacting`, `tool`, `coding`, `web`, `deploy`, `build`,
14071408
`concierge`, `done`, `error`, `stallSoft`, and `stallHard`.

extensions/discord/src/monitor/provider.lifecycle.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,27 @@ describe("runDiscordGatewayLifecycle", () => {
276276
});
277277
});
278278

279+
it("owns and cleans up auto-join when READY preceded voice listener registration", async () => {
280+
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
281+
const { lifecycleParams } = createLifecycleHarness();
282+
const autoJoin = vi.fn(async () => undefined);
283+
const destroy = vi.fn(async () => undefined);
284+
const voiceManager = {
285+
autoJoin,
286+
destroy,
287+
} as unknown as NonNullable<LifecycleParams["voiceManager"]>;
288+
lifecycleParams.voiceManager = voiceManager;
289+
lifecycleParams.voiceManagerRef.current = voiceManager;
290+
291+
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
292+
"gateway wait failed",
293+
);
294+
295+
expect(autoJoin).toHaveBeenCalledTimes(1);
296+
expect(destroy).toHaveBeenCalledTimes(1);
297+
expect(lifecycleParams.voiceManagerRef.current).toBeNull();
298+
});
299+
279300
it("pushes connected status when gateway is already connected at lifecycle start", async () => {
280301
const { emitter, gateway } = createGatewayHarness();
281302
gateway.isConnected = true;

extensions/discord/src/monitor/provider.lifecycle.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
import { asDateTimestampMs, parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
77
import { danger } from "openclaw/plugin-sdk/runtime-env";
88
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
9+
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
910
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
1011
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
1112
import type { DiscordVoiceManager } from "../voice/manager.js";
@@ -420,6 +421,7 @@ export async function runDiscordGatewayLifecycle(params: {
420421
gatewayRuntimeReadyTimeoutMs?: number;
421422
}) {
422423
const gateway = params.gateway;
424+
const gatewayReadyAtLifecycleStart = gateway?.isConnected === true;
423425
if (gateway) {
424426
registerGateway(params.accountId, gateway);
425427
}
@@ -515,6 +517,18 @@ export async function runDiscordGatewayLifecycle(params: {
515517
return;
516518
}
517519

520+
if (gatewayReadyAtLifecycleStart && params.voiceManager) {
521+
// READY may precede lazy voice-listener registration. Reconcile only after lifecycle
522+
// ownership begins so every startup failure still destroys the manager in `finally`.
523+
void params.voiceManager
524+
.autoJoin()
525+
.catch((err: unknown) =>
526+
params.runtime.error?.(
527+
danger(`discord voice: autoJoin failed: ${formatErrorMessage(err)}`),
528+
),
529+
);
530+
}
531+
518532
await waitForGatewayReady({
519533
gateway,
520534
abortSignal: params.abortSignal,

extensions/discord/src/monitor/provider.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ const {
3939
voiceRuntimeModuleLoadedMock,
4040
} = getProviderMonitorTestMocks();
4141

42+
const { voiceAutoJoinMock } = vi.hoisted(() => ({
43+
voiceAutoJoinMock: vi.fn(async () => undefined),
44+
}));
45+
4246
let monitorDiscordProvider: typeof import("./provider.js").monitorDiscordProvider;
4347
let providerTesting: typeof import("./provider.js").testing;
4448
let runtimeEnvModule: typeof import("openclaw/plugin-sdk/runtime-env");
@@ -140,7 +144,9 @@ function expectMessagesContainAll(messages: string[], expected: string[]): void
140144
vi.mock("../voice/manager.runtime.js", () => {
141145
voiceRuntimeModuleLoadedMock();
142146
return {
143-
DiscordVoiceManager: function DiscordVoiceManager() {},
147+
DiscordVoiceManager: function DiscordVoiceManager() {
148+
return { autoJoin: voiceAutoJoinMock };
149+
},
144150
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
145151
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
146152
DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {},
@@ -252,6 +258,7 @@ describe("monitorDiscordProvider", () => {
252258

253259
beforeEach(() => {
254260
resetDiscordProviderMonitorMocks();
261+
voiceAutoJoinMock.mockClear();
255262
vi.mocked(runtimeEnvModule.logVerbose).mockClear();
256263
providerTesting.setFetchDiscordApplicationId(async () => "app-1");
257264
providerTesting.setCreateDiscordNativeCommand(((
@@ -270,7 +277,9 @@ describe("monitorDiscordProvider", () => {
270277
providerTesting.setLoadDiscordVoiceRuntime(async () => {
271278
voiceRuntimeModuleLoadedMock();
272279
return {
273-
DiscordVoiceManager: function DiscordVoiceManager() {},
280+
DiscordVoiceManager: function DiscordVoiceManager() {
281+
return { autoJoin: voiceAutoJoinMock };
282+
},
274283
DiscordVoiceReadyListener: function DiscordVoiceReadyListener() {},
275284
DiscordVoiceResumedListener: function DiscordVoiceResumedListener() {},
276285
DiscordVoiceStateUpdateListener: function DiscordVoiceStateUpdateListener() {},

extensions/discord/src/voice/manager.e2e.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -402,12 +402,13 @@ describe("DiscordVoiceManager", () => {
402402
>[0]["discordConfig"] = { voice: { enabled: true, mode: "stt-tts" } },
403403
clientOverride?: ReturnType<typeof createClient>,
404404
cfgOverride: ConstructorParameters<typeof managerModule.DiscordVoiceManager>[0]["cfg"] = {},
405+
accountId = "default",
405406
) =>
406407
new managerModule.DiscordVoiceManager({
407408
client: (clientOverride ?? createClient()) as never,
408409
cfg: cfgOverride,
409410
discordConfig,
410-
accountId: "default",
411+
accountId,
411412
runtime: createRuntime(),
412413
});
413414

@@ -1020,11 +1021,30 @@ describe("DiscordVoiceManager", () => {
10201021

10211022
await manager.join({ guildId: "g1", channelId: "1001" });
10221023

1023-
expect(getVoiceConnectionMock).toHaveBeenCalledWith("g1");
1024+
expect(getVoiceConnectionMock).toHaveBeenCalledWith("g1", "openclaw:default");
10241025
expect(staleConnection.destroy).toHaveBeenCalledTimes(1);
10251026
expectConnectedStatus(manager, "1001");
10261027
});
10271028

1029+
it("isolates voice connections by Discord account", async () => {
1030+
const firstManager = createManager(undefined, undefined, undefined, "first");
1031+
const secondManager = createManager(undefined, undefined, undefined, "second");
1032+
1033+
await firstManager.join({ guildId: "g1", channelId: "1001" });
1034+
await secondManager.join({ guildId: "g1", channelId: "1002" });
1035+
1036+
expect(getVoiceConnectionMock).toHaveBeenNthCalledWith(1, "g1", "openclaw:first");
1037+
expect(getVoiceConnectionMock).toHaveBeenNthCalledWith(2, "g1", "openclaw:second");
1038+
expect(joinVoiceChannelMock).toHaveBeenNthCalledWith(
1039+
1,
1040+
expect.objectContaining({ group: "openclaw:first" }),
1041+
);
1042+
expect(joinVoiceChannelMock).toHaveBeenNthCalledWith(
1043+
2,
1044+
expect.objectContaining({ group: "openclaw:second" }),
1045+
);
1046+
});
1047+
10281048
it("autoJoin uses the last configured channel for duplicate guild entries", async () => {
10291049
const manager = createManager({
10301050
voice: {

extensions/discord/src/voice/manager.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ function startAutoJoin(manager: Pick<DiscordVoiceManager, "autoJoin">) {
212212
);
213213
}
214214

215+
function resolveVoiceConnectionGroup(accountId: string): string {
216+
return `openclaw:${accountId}`;
217+
}
218+
215219
function resolveDiscordVoiceAgentRoute(params: {
216220
cfg: OpenClawConfig;
217221
accountId: string;
@@ -554,7 +558,8 @@ export class DiscordVoiceManager {
554558
existingEntry.stop();
555559
this.sessions.delete(guildId);
556560
}
557-
const staleConnection = voiceSdk.getVoiceConnection(guildId);
561+
const voiceConnectionGroup = resolveVoiceConnectionGroup(this.params.accountId);
562+
const staleConnection = voiceSdk.getVoiceConnection(guildId, voiceConnectionGroup);
558563
if (staleConnection) {
559564
destroyVoiceConnectionSafely({
560565
connection: staleConnection,
@@ -568,6 +573,7 @@ export class DiscordVoiceManager {
568573
const joinedConnection = voiceSdk.joinVoiceChannel({
569574
channelId,
570575
guildId,
576+
group: voiceConnectionGroup,
571577
adapterCreator,
572578
selfDeaf: false,
573579
selfMute: false,
@@ -995,7 +1001,10 @@ export class DiscordVoiceManager {
9951001
await this.leave({ guildId });
9961002
} else {
9971003
const voiceSdk = loadDiscordVoiceSdk();
998-
const connection = voiceSdk.getVoiceConnection(guildId);
1004+
const connection = voiceSdk.getVoiceConnection(
1005+
guildId,
1006+
resolveVoiceConnectionGroup(this.params.accountId),
1007+
);
9991008
if (connection) {
10001009
destroyVoiceConnectionSafely({
10011010
connection,

extensions/slack/src/monitor/message-handler/dispatch.preview-fallback.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,6 +1796,22 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
17961796
expect(statusReactionControllerMock.setDone).toHaveBeenCalledTimes(1);
17971797
});
17981798

1799+
it("keeps Slack lifecycle reactions off by default when an ack reaction exists", async () => {
1800+
await dispatchPreparedSlackMessage(
1801+
createPreparedSlackMessage({
1802+
ackReactionMessageTs: "171234.111",
1803+
ackReactionPromise: Promise.resolve(true),
1804+
}),
1805+
);
1806+
1807+
expectRecordFields(requireRecord(capturedStatusReactionOptions, "status reaction options"), {
1808+
enabled: false,
1809+
initialEmoji: "eyes",
1810+
});
1811+
expect(statusReactionControllerMock.setQueued).not.toHaveBeenCalled();
1812+
expect(statusReactionControllerMock.setDone).not.toHaveBeenCalled();
1813+
});
1814+
17991815
it("escapes Slack mrkdwn in tool progress preview labels", async () => {
18001816
const draftStream = createDraftStreamStub();
18011817
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);

extensions/slack/src/monitor/message-handler/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -550,7 +550,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
550550
const statusReactionsEnabled =
551551
Boolean(prepared.ackReactionPromise) &&
552552
Boolean(reactionMessageTs) &&
553-
cfg.messages?.statusReactions?.enabled !== false;
553+
cfg.messages?.statusReactions?.enabled === true;
554554
const slackStatusAdapter: StatusReactionAdapter = {
555555
setReaction: async (emoji) => {
556556
await reactSlackMessage(message.channel, reactionMessageTs ?? "", toSlackEmojiName(emoji), {

0 commit comments

Comments
 (0)