Merge branch 'main' into main
This commit is contained in:
commit
fc520fffa0
@ -68,11 +68,13 @@ Status: stable.
|
|||||||
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
|
||||||
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
||||||
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
|
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
|
||||||
|
- Build: skip redundant UI install step in the Dockerfile. (#4584) Thanks @obviyus.
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway.
|
- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway.
|
||||||
|
- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn.
|
||||||
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
|
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
|
||||||
- Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10.
|
- Telegram: accept numeric messageId/chatId in react action and honor channelId fallback. (#4533) Thanks @Ayush10.
|
||||||
- Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker.
|
- Telegram: scope native skill commands to bound agent per bot. (#4360) Thanks @robhparker.
|
||||||
|
|||||||
@ -27,7 +27,6 @@ COPY . .
|
|||||||
RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build
|
RUN OPENCLAW_A2UI_SKIP_MISSING=1 pnpm build
|
||||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||||
ENV OPENCLAW_PREFER_PNPM=1
|
ENV OPENCLAW_PREFER_PNPM=1
|
||||||
RUN pnpm ui:install
|
|
||||||
RUN pnpm ui:build
|
RUN pnpm ui:build
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import {
|
|||||||
LineConfigSchema,
|
LineConfigSchema,
|
||||||
processLineMessage,
|
processLineMessage,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
|
type ChannelStatusIssue,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
type LineConfig,
|
type LineConfig,
|
||||||
type LineChannelData,
|
type LineChannelData,
|
||||||
@ -560,19 +561,26 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
collectStatusIssues: ({ account }) => {
|
collectStatusIssues: (accounts) => {
|
||||||
const issues: Array<{ level: "error" | "warning"; message: string }> = [];
|
const issues: ChannelStatusIssue[] = [];
|
||||||
if (!account.channelAccessToken?.trim()) {
|
for (const account of accounts) {
|
||||||
issues.push({
|
const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||||
level: "error",
|
if (!account.channelAccessToken?.trim()) {
|
||||||
message: "LINE channel access token not configured",
|
issues.push({
|
||||||
});
|
channel: "line",
|
||||||
}
|
accountId,
|
||||||
if (!account.channelSecret?.trim()) {
|
kind: "config",
|
||||||
issues.push({
|
message: "LINE channel access token not configured",
|
||||||
level: "error",
|
});
|
||||||
message: "LINE channel secret not configured",
|
}
|
||||||
});
|
if (!account.channelSecret?.trim()) {
|
||||||
|
issues.push({
|
||||||
|
channel: "line",
|
||||||
|
accountId,
|
||||||
|
kind: "config",
|
||||||
|
message: "LINE channel secret not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return issues;
|
return issues;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -52,11 +52,39 @@ describe("buildAuthHealthSummary", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(statuses["anthropic:ok"]).toBe("ok");
|
expect(statuses["anthropic:ok"]).toBe("ok");
|
||||||
expect(statuses["anthropic:expiring"]).toBe("expiring");
|
// OAuth credentials with refresh tokens are auto-renewable, so they report "ok"
|
||||||
expect(statuses["anthropic:expired"]).toBe("expired");
|
expect(statuses["anthropic:expiring"]).toBe("ok");
|
||||||
|
expect(statuses["anthropic:expired"]).toBe("ok");
|
||||||
expect(statuses["anthropic:api"]).toBe("static");
|
expect(statuses["anthropic:api"]).toBe("static");
|
||||||
|
|
||||||
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
|
const provider = summary.providers.find((entry) => entry.provider === "anthropic");
|
||||||
expect(provider?.status).toBe("expired");
|
expect(provider?.status).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports expired for OAuth without a refresh token", () => {
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||||
|
const store = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
"google:no-refresh": {
|
||||||
|
type: "oauth" as const,
|
||||||
|
provider: "google-antigravity",
|
||||||
|
access: "access",
|
||||||
|
refresh: "",
|
||||||
|
expires: now - 10_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const summary = buildAuthHealthSummary({
|
||||||
|
store,
|
||||||
|
warnAfterMs: DEFAULT_OAUTH_WARN_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statuses = Object.fromEntries(
|
||||||
|
summary.profiles.map((profile) => [profile.profileId, profile.status]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(statuses["google:no-refresh"]).toBe("expired");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -123,7 +123,16 @@ function buildProfileHealth(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { status, remainingMs } = resolveOAuthStatus(credential.expires, now, warnAfterMs);
|
const hasRefreshToken = typeof credential.refresh === "string" && credential.refresh.length > 0;
|
||||||
|
const { status: rawStatus, remainingMs } = resolveOAuthStatus(
|
||||||
|
credential.expires,
|
||||||
|
now,
|
||||||
|
warnAfterMs,
|
||||||
|
);
|
||||||
|
// OAuth credentials with a valid refresh token auto-renew on first API call,
|
||||||
|
// so don't warn about access token expiration.
|
||||||
|
const status =
|
||||||
|
hasRefreshToken && (rawStatus === "expired" || rawStatus === "expiring") ? "ok" : rawStatus;
|
||||||
return {
|
return {
|
||||||
profileId,
|
profileId,
|
||||||
provider: credential.provider,
|
provider: credential.provider,
|
||||||
|
|||||||
@ -69,7 +69,6 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkStarts = new Map<number, RenderLink[]>();
|
const linkStarts = new Map<number, RenderLink[]>();
|
||||||
const linkEnds = new Map<number, RenderLink[]>();
|
|
||||||
if (options.buildLink) {
|
if (options.buildLink) {
|
||||||
for (const link of ir.links) {
|
for (const link of ir.links) {
|
||||||
if (link.start === link.end) continue;
|
if (link.start === link.end) continue;
|
||||||
@ -80,47 +79,78 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
|
|||||||
const openBucket = linkStarts.get(rendered.start);
|
const openBucket = linkStarts.get(rendered.start);
|
||||||
if (openBucket) openBucket.push(rendered);
|
if (openBucket) openBucket.push(rendered);
|
||||||
else linkStarts.set(rendered.start, [rendered]);
|
else linkStarts.set(rendered.start, [rendered]);
|
||||||
const closeBucket = linkEnds.get(rendered.end);
|
|
||||||
if (closeBucket) closeBucket.push(rendered);
|
|
||||||
else linkEnds.set(rendered.end, [rendered]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const points = [...boundaries].sort((a, b) => a - b);
|
const points = [...boundaries].sort((a, b) => a - b);
|
||||||
const stack: MarkdownStyleSpan[] = [];
|
// Unified stack for both styles and links, tracking close string and end position
|
||||||
|
const stack: { close: string; end: number }[] = [];
|
||||||
|
type OpeningItem =
|
||||||
|
| { end: number; open: string; close: string; kind: "link"; index: number }
|
||||||
|
| {
|
||||||
|
end: number;
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
kind: "style";
|
||||||
|
style: MarkdownStyle;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
let out = "";
|
let out = "";
|
||||||
|
|
||||||
for (let i = 0; i < points.length; i += 1) {
|
for (let i = 0; i < points.length; i += 1) {
|
||||||
const pos = points[i];
|
const pos = points[i];
|
||||||
|
|
||||||
|
// Close ALL elements (styles and links) in LIFO order at this position
|
||||||
while (stack.length && stack[stack.length - 1]?.end === pos) {
|
while (stack.length && stack[stack.length - 1]?.end === pos) {
|
||||||
const span = stack.pop();
|
const item = stack.pop();
|
||||||
if (!span) break;
|
if (item) out += item.close;
|
||||||
const marker = styleMarkers[span.style];
|
|
||||||
if (marker) out += marker.close;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const closingLinks = linkEnds.get(pos);
|
const openingItems: OpeningItem[] = [];
|
||||||
if (closingLinks && closingLinks.length > 0) {
|
|
||||||
for (const link of closingLinks) {
|
|
||||||
out += link.close;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openingLinks = linkStarts.get(pos);
|
const openingLinks = linkStarts.get(pos);
|
||||||
if (openingLinks && openingLinks.length > 0) {
|
if (openingLinks && openingLinks.length > 0) {
|
||||||
for (const link of openingLinks) {
|
for (const [index, link] of openingLinks.entries()) {
|
||||||
out += link.open;
|
openingItems.push({
|
||||||
|
end: link.end,
|
||||||
|
open: link.open,
|
||||||
|
close: link.close,
|
||||||
|
kind: "link",
|
||||||
|
index,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openingStyles = startsAt.get(pos);
|
const openingStyles = startsAt.get(pos);
|
||||||
if (openingStyles) {
|
if (openingStyles) {
|
||||||
for (const span of openingStyles) {
|
for (const [index, span] of openingStyles.entries()) {
|
||||||
const marker = styleMarkers[span.style];
|
const marker = styleMarkers[span.style];
|
||||||
if (!marker) continue;
|
if (!marker) continue;
|
||||||
stack.push(span);
|
openingItems.push({
|
||||||
out += marker.open;
|
end: span.end,
|
||||||
|
open: marker.open,
|
||||||
|
close: marker.close,
|
||||||
|
kind: "style",
|
||||||
|
style: span.style,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openingItems.length > 0) {
|
||||||
|
openingItems.sort((a, b) => {
|
||||||
|
if (a.end !== b.end) return b.end - a.end;
|
||||||
|
if (a.kind !== b.kind) return a.kind === "link" ? -1 : 1;
|
||||||
|
if (a.kind === "style" && b.kind === "style") {
|
||||||
|
return (STYLE_RANK.get(a.style) ?? 0) - (STYLE_RANK.get(b.style) ?? 0);
|
||||||
|
}
|
||||||
|
return a.index - b.index;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open outer spans first (larger end) so LIFO closes stay valid for same-start overlaps.
|
||||||
|
for (const item of openingItems) {
|
||||||
|
out += item.open;
|
||||||
|
stack.push({ close: item.close, end: item.end });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,4 +47,26 @@ describe("markdownToTelegramHtml", () => {
|
|||||||
const res = markdownToTelegramHtml("```js\nconst x = 1;\n```");
|
const res = markdownToTelegramHtml("```js\nconst x = 1;\n```");
|
||||||
expect(res).toBe("<pre><code>const x = 1;\n</code></pre>");
|
expect(res).toBe("<pre><code>const x = 1;\n</code></pre>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("properly nests overlapping bold and autolink (#4071)", () => {
|
||||||
|
const res = markdownToTelegramHtml("**start https://example.com** end");
|
||||||
|
expect(res).toMatch(
|
||||||
|
/<b>start <a href="https:\/\/example\.com">https:\/\/example\.com<\/a><\/b> end/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("properly nests link inside bold", () => {
|
||||||
|
const res = markdownToTelegramHtml("**bold [link](https://example.com) text**");
|
||||||
|
expect(res).toBe('<b>bold <a href="https://example.com">link</a> text</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("properly nests bold wrapping a link with trailing text", () => {
|
||||||
|
const res = markdownToTelegramHtml("**[link](https://example.com) rest**");
|
||||||
|
expect(res).toBe('<b><a href="https://example.com">link</a> rest</b>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("properly nests bold inside a link", () => {
|
||||||
|
const res = markdownToTelegramHtml("[**bold**](https://example.com)");
|
||||||
|
expect(res).toBe('<a href="https://example.com"><b>bold</b></a>');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user