Skip to content

Commit ab842be

Browse files
authored
Merge branch 'main' into alix/discord-pluralkit-timeouts
2 parents 490914b + 72cf43f commit ab842be

11 files changed

Lines changed: 206 additions & 101 deletions

.github/workflows/openclaw-release-telegram-qa.yml

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1871,6 +1871,7 @@ jobs:
18711871
- name: Validate required QA credential env
18721872
id: validate_credentials
18731873
env:
1874+
CREDENTIAL_ACQUIRE_TIMEOUT_MS: "60000"
18741875
JOB_TIMEOUT_MINUTES: "60"
18751876
LEASE_TTL_MS: "7200000"
18761877
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
@@ -1892,7 +1893,7 @@ jobs:
18921893
EVIDENCE_ROOT: ${{ steps.create_sut.outputs.evidence_root }}
18931894
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
18941895
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
1895-
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
1896+
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "60000"
18961897
OPENCLAW_QA_CREDENTIAL_LEASE_TTL_MS: "7200000"
18971898
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
18981899
OPENCLAW_QA_SUT_FORBIDDEN_SENTINEL: trusted-parent-${{ github.run_id }}-${{ github.run_attempt }}
@@ -1935,28 +1936,50 @@ jobs:
19351936
run_qa_attempt() (
19361937
set -euo pipefail
19371938
exec 2>&1
1938-
attempt="$1"
1939+
output_name="$1"
1940+
shift
19391941
workflow_command_token="$(openssl rand -hex 32)"
19401942
printf '::stop-commands::%s\n' "$workflow_command_token"
19411943
trap 'printf "::%s::\n" "$workflow_command_token"' EXIT
19421944
set +e
19431945
TMPDIR="${SUT_RUNTIME_ROOT}/tmp" \
19441946
pnpm openclaw qa telegram \
19451947
--repo-root "$CANDIDATE_ROOT" \
1946-
--output-dir "${EVIDENCE_RELATIVE}/attempt-${attempt}" \
1948+
--output-dir "${EVIDENCE_RELATIVE}/${output_name}" \
19471949
--provider-mode mock-openai \
19481950
--model mock-openai/gpt-5.5 \
19491951
--alt-model mock-openai/gpt-5.5-alt \
19501952
--fast \
19511953
--credential-source convex \
1952-
--credential-role ci
1954+
--credential-role ci \
1955+
"$@"
19531956
status="$?"
19541957
set -e
19551958
exit "$status"
19561959
)
19571960
1961+
if ! run_qa_attempt preflight --scenario channel-canary; then
1962+
echo "Telegram channel canary failed; skipping the remaining scenarios." >&2
1963+
exit 1
1964+
fi
1965+
terminate_sut_uid
1966+
1967+
remaining_scenarios=()
1968+
while IFS=$'\t' read -r scenario_id default_label _; do
1969+
if [[ "$default_label" == "default" && "$scenario_id" != "channel-canary" ]]; then
1970+
remaining_scenarios+=(--scenario "$scenario_id")
1971+
fi
1972+
done < <(
1973+
TMPDIR="${SUT_RUNTIME_ROOT}/tmp" \
1974+
pnpm openclaw qa telegram \
1975+
--repo-root "$CANDIDATE_ROOT" \
1976+
--provider-mode mock-openai \
1977+
--list-scenarios
1978+
)
1979+
((${#remaining_scenarios[@]} > 0))
1980+
19581981
for attempt in 1 2; do
1959-
if run_qa_attempt "$attempt"; then
1982+
if run_qa_attempt "attempt-${attempt}" "${remaining_scenarios[@]}"; then
19601983
exit 0
19611984
fi
19621985
terminate_sut_uid

extensions/discord/src/approval-handler.runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
import { logDebug, logError } from "openclaw/plugin-sdk/logging-core";
2121
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
2222
import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js";
23+
import { encodeCustomIdComponent } from "./custom-id-codec.js";
2324
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
2425
import {
2526
Button,
@@ -385,7 +386,7 @@ export function buildExecApprovalCustomId(
385386
approvalId: string,
386387
action: ExecApprovalDecision,
387388
): string {
388-
return [`execapproval:id=${encodeURIComponent(approvalId)}`, `action=${action}`].join(";");
389+
return [`execapproval:id=${encodeCustomIdComponent(approvalId)}`, `action=${action}`].join(";");
389390
}
390391

391392
async function updateMessage(params: {

extensions/discord/src/component-custom-id.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,15 @@
11
// Discord plugin module implements component custom id behavior.
2+
import {
3+
escapeCustomIdFieldValue,
4+
needsCustomIdFieldEscaping,
5+
unescapeCustomIdFieldValue,
6+
} from "./custom-id-codec.js";
27
import { parseCustomId, type ComponentParserResult } from "./internal/discord.js";
38

49
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
510
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
611
const ENCODED_CUSTOM_ID_VERSION = "1";
712

8-
function encodeCustomIdValue(value: string): string {
9-
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
10-
}
11-
12-
function needsCustomIdEncoding(value: string): boolean {
13-
return /[%;]/.test(value);
14-
}
15-
16-
function decodeCustomIdValue(value: string): string {
17-
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
18-
}
19-
2013
function decodeParsedCustomIdData(
2114
data: ComponentParserResult["data"],
2215
): ComponentParserResult["data"] {
@@ -26,7 +19,7 @@ function decodeParsedCustomIdData(
2619
return Object.fromEntries(
2720
Object.entries(data).map(([key, value]) => [
2821
key,
29-
typeof value === "string" ? decodeCustomIdValue(value) : value,
22+
typeof value === "string" ? unescapeCustomIdFieldValue(value) : value,
3023
]),
3124
) as ComponentParserResult["data"];
3225
}
@@ -36,21 +29,22 @@ export function buildDiscordComponentCustomId(params: {
3629
modalId?: string;
3730
}): string {
3831
const encoded =
39-
needsCustomIdEncoding(params.componentId) || needsCustomIdEncoding(params.modalId ?? "");
40-
const componentId = encoded ? encodeCustomIdValue(params.componentId) : params.componentId;
32+
needsCustomIdFieldEscaping(params.componentId) ||
33+
needsCustomIdFieldEscaping(params.modalId ?? "");
34+
const componentId = encoded ? escapeCustomIdFieldValue(params.componentId) : params.componentId;
4135
const base = encoded
4236
? `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};cid=${componentId}`
4337
: `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${componentId}`;
4438
const modalId = params.modalId;
4539
if (!modalId) {
4640
return base;
4741
}
48-
return `${base};mid=${encoded ? encodeCustomIdValue(modalId) : modalId}`;
42+
return `${base};mid=${encoded ? escapeCustomIdFieldValue(modalId) : modalId}`;
4943
}
5044

5145
export function buildDiscordModalCustomId(modalId: string): string {
52-
return needsCustomIdEncoding(modalId)
53-
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${encodeCustomIdValue(modalId)}`
46+
return needsCustomIdFieldEscaping(modalId)
47+
? `${DISCORD_MODAL_CUSTOM_ID_KEY}:e=${ENCODED_CUSTOM_ID_VERSION};mid=${escapeCustomIdFieldValue(modalId)}`
5448
: `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
5549
}
5650

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
decodeCustomIdComponent,
4+
encodeCustomIdComponent,
5+
escapeCustomIdFieldValue,
6+
needsCustomIdFieldEscaping,
7+
unescapeCustomIdFieldValue,
8+
} from "./custom-id-codec.js";
9+
10+
const URI_ROUND_TRIP_VALUES = [
11+
"plain",
12+
"with space",
13+
"semi;colon",
14+
"percent%value",
15+
"env|prod",
16+
"unicode-ünïcødé-🎛️",
17+
"a=b&c=d;e=f",
18+
"",
19+
];
20+
21+
describe("custom-id URI component codec", () => {
22+
it("round-trips values through encode/decode", () => {
23+
for (const value of URI_ROUND_TRIP_VALUES) {
24+
expect(decodeCustomIdComponent(encodeCustomIdComponent(value))).toBe(value);
25+
}
26+
});
27+
28+
it("never emits the ; field separator or raw %", () => {
29+
for (const value of URI_ROUND_TRIP_VALUES) {
30+
const encoded = encodeCustomIdComponent(value);
31+
expect(encoded).not.toContain(";");
32+
expect(encoded).not.toMatch(/%(?![0-9A-Fa-f]{2})/);
33+
}
34+
});
35+
36+
// Discord redelivers component ids from old messages indefinitely; values
37+
// that predate strict encoding must pass through unchanged.
38+
it("falls back to the raw value on malformed percent input", () => {
39+
expect(decodeCustomIdComponent("100%")).toBe("100%");
40+
expect(decodeCustomIdComponent("a%zzb")).toBe("a%zzb");
41+
expect(decodeCustomIdComponent("trailing%2")).toBe("trailing%2");
42+
});
43+
44+
it("decodes historical unguarded-encoded values", () => {
45+
expect(decodeCustomIdComponent("a%20b")).toBe("a b");
46+
expect(decodeCustomIdComponent("env%7Cprod")).toBe("env|prod");
47+
});
48+
});
49+
50+
describe("custom-id field escape (versioned occomp/ocmodal grammar)", () => {
51+
it("round-trips only % and the ; separator", () => {
52+
for (const value of URI_ROUND_TRIP_VALUES) {
53+
expect(unescapeCustomIdFieldValue(escapeCustomIdFieldValue(value))).toBe(value);
54+
}
55+
expect(escapeCustomIdFieldValue("a;b%c")).toBe("a%3Bb%25c");
56+
expect(escapeCustomIdFieldValue("unicode-ü 🎛️")).toBe("unicode-ü 🎛️");
57+
});
58+
59+
it("detects values that require escaping", () => {
60+
expect(needsCustomIdFieldEscaping("plain value")).toBe(false);
61+
expect(needsCustomIdFieldEscaping("has;separator")).toBe(true);
62+
expect(needsCustomIdFieldEscaping("has%percent")).toBe(true);
63+
});
64+
65+
// Wire compat: ids escaped by the pre-consolidation copies must keep
66+
// decoding byte-exactly (e=1 payloads live on old Discord messages).
67+
it("decodes historical escaped payloads case-insensitively", () => {
68+
expect(unescapeCustomIdFieldValue("a%3Bb%25c")).toBe("a;b%c");
69+
expect(unescapeCustomIdFieldValue("a%3bb%25c")).toBe("a;b%c");
70+
});
71+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Discord plugin module implements shared custom-id value codecs.
2+
3+
/**
4+
* URI-component codec for values embedded in `k=v;` custom-id grammars
5+
* (exec approvals, model picker, command args, agent components).
6+
* Decode falls back to the raw value: Discord redelivers old component ids
7+
* indefinitely and historical values may predate strict encoding.
8+
*/
9+
export function encodeCustomIdComponent(value: string): string {
10+
return encodeURIComponent(value);
11+
}
12+
13+
export function decodeCustomIdComponent(value: string): string {
14+
try {
15+
return decodeURIComponent(value);
16+
} catch {
17+
return value;
18+
}
19+
}
20+
21+
/**
22+
* Minimal field escape for the versioned `occomp`/`ocmodal` grammar: only `%`
23+
* and the `;` field separator are escaped to preserve the 100-char custom-id
24+
* budget. The wire format is versioned (`e=1`); do not swap this for the URI
25+
* codec — in-flight component ids must keep decoding byte-exactly.
26+
*/
27+
export function escapeCustomIdFieldValue(value: string): string {
28+
return value.replace(/%/g, "%25").replace(/;/g, "%3B");
29+
}
30+
31+
export function needsCustomIdFieldEscaping(value: string): boolean {
32+
return /[%;]/.test(value);
33+
}
34+
35+
export function unescapeCustomIdFieldValue(value: string): string {
36+
return value.replace(/%(25|3B)/gi, (match) => (match.toLowerCase() === "%25" ? "%" : ";"));
37+
}

extensions/discord/src/monitor/agent-components-data.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
parseDiscordModalCustomId,
66
} from "../component-custom-id.js";
77
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
8+
import { decodeCustomIdComponent } from "../custom-id-codec.js";
89
import type { ComponentData, ModalInteraction } from "../internal/discord.js";
910
import type { AgentComponentInteraction } from "./agent-components.types.js";
1011
import { formatDiscordUserTag } from "./format.js";
@@ -42,21 +43,12 @@ function mapOptionLabels(
4243

4344
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
4445
const raw = readParsedComponentId(data);
45-
const decodeSafe = (value: string): string => {
46-
if (!value.includes("%")) {
47-
return value;
48-
}
49-
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
50-
return value;
51-
}
52-
try {
53-
return decodeURIComponent(value);
54-
} catch {
55-
return value;
56-
}
57-
};
5846
const componentId =
59-
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
47+
typeof raw === "string"
48+
? decodeCustomIdComponent(raw)
49+
: typeof raw === "number"
50+
? String(raw)
51+
: null;
6052
if (!componentId) {
6153
return null;
6254
}

extensions/discord/src/monitor/exec-approvals.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,12 @@ import type {
66
DiscordExecApprovalConfig,
77
OpenClawConfig,
88
} from "openclaw/plugin-sdk/config-contracts";
9+
import { decodeCustomIdComponent } from "../custom-id-codec.js";
910
import { Button, type ButtonInteraction, type ComponentData } from "../internal/discord.js";
1011
export { buildExecApprovalCustomId } from "../approval-handler.runtime.js";
1112
import { getDiscordExecApprovalApprovers } from "../exec-approvals.js";
1213

1314
export { extractDiscordChannelId } from "../approval-native.js";
14-
function decodeCustomIdValue(value: string): string {
15-
try {
16-
return decodeURIComponent(value);
17-
} catch {
18-
return value;
19-
}
20-
}
2115

2216
export function parseExecApprovalData(
2317
data: ComponentData,
@@ -37,7 +31,7 @@ export function parseExecApprovalData(
3731
return null;
3832
}
3933
return {
40-
approvalId: decodeCustomIdValue(rawId),
34+
approvalId: decodeCustomIdComponent(rawId),
4135
action,
4236
};
4337
}

0 commit comments

Comments
 (0)