Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7427865e46 | |||
| ff5f9c76fd | |||
| 641bbfbe2e | |||
| 5516ec4595 | |||
| 762467efd4 | |||
| 3f54bda360 | |||
| 4d1e388a48 | |||
| 10be86587a | |||
| 6a10591413 | |||
| 321c88e05e | |||
| 23562dfa61 | |||
| cb6ebf1d69 |
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"enabledMcpjsonServers": ["storkit"],
|
||||
"enabledMcpjsonServers": [
|
||||
"storkit"
|
||||
],
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./server/target/debug/storkit:*)",
|
||||
@@ -67,7 +69,8 @@
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(npm run dev:*)"
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(stat *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Element tab-completion display name breaks bot command matching"
|
||||
---
|
||||
|
||||
# Bug 447: Element tab-completion display name breaks bot command matching
|
||||
|
||||
## Description
|
||||
|
||||
When a user tab-completes a bot mention in Element, the Matrix client inserts the display name (e.g. `timmy ⚡️`) rather than the user ID (`@timmy`). If the display name contains emoji or special characters, the `strip_bot_mention` function in chat::util may fail to match it against the bot name, causing commands like `ambient on` to not be recognized.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Set bot display_name to include emoji (e.g. `timmy ⚡️`) in bot.toml\n2. In Element, tab-complete the bot name to get `timmy ⚡️`\n3. Send `timmy ⚡️ ambient on`\n4. The bot does not respond — command not matched
|
||||
|
||||
## Actual Result
|
||||
|
||||
Bot ignores the command. The display name with emoji doesn't match during strip_bot_mention, so the command text is not correctly extracted.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Bot should recognize commands regardless of whether the mention was tab-completed with the display name (including emoji) or typed manually as @localpart.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] strip_bot_mention handles display names containing emoji and special characters
|
||||
- [ ] strip_bot_mention handles Element's tab-completion format (display name followed by colon or comma)
|
||||
- [ ] Commands work whether the user types @timmy, timmy, or tab-completes timmy ⚡️
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Send OAuth login link via chat when credentials are missing"
|
||||
---
|
||||
|
||||
# Story 448: Send OAuth login link via chat when credentials are missing
|
||||
|
||||
## User Story
|
||||
|
||||
As a storkit user on Matrix or WhatsApp, I want the bot to send me a clickable OAuth authorize link when credentials are missing or expired, so that I can authenticate without terminal access or manually constructing the URL.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] When storkit detects missing or expired credentials during a chat interaction, it sends the user a clickable /oauth/authorize link
|
||||
- [ ] Works on Matrix and WhatsApp transports
|
||||
- [ ] After successful OAuth callback, the user can immediately resume chatting without restarting storkit
|
||||
- [ ] If credentials are already valid, no login link is sent
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
Generated
+15
-15
@@ -1915,9 +1915,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||
checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -4083,7 +4083,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "storkit"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -4915,9 +4915,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||
checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -4928,9 +4928,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.65"
|
||||
version = "0.4.66"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
|
||||
checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -4938,9 +4938,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||
checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -4948,9 +4948,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||
checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -4961,9 +4961,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.115"
|
||||
version = "0.2.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||
checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -5048,9 +5048,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.92"
|
||||
version = "0.3.93"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
|
||||
checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ rust-embed = "8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha2 = "0.10"
|
||||
sha2 = "0.11.0"
|
||||
serde_yaml = "0.9"
|
||||
strip-ansi-escapes = "0.2"
|
||||
tempfile = "3"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"private": true,
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("./api/client", () => {
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
getOAuthStatus: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect() {}
|
||||
@@ -65,6 +66,7 @@ describe("App", () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.getOAuthStatus.mockResolvedValue({ authenticated: false, expired: false, expires_at: 0, has_refresh_token: false });
|
||||
});
|
||||
|
||||
async function renderApp() {
|
||||
|
||||
+28
-1
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { OAuthStatus } from "./api/client";
|
||||
import { api } from "./api/client";
|
||||
import { Chat } from "./components/Chat";
|
||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||
@@ -14,6 +15,27 @@ function App() {
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
||||
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
function fetchOAuthStatus() {
|
||||
api
|
||||
.getOAuthStatus()
|
||||
.then((s) => {
|
||||
if (active) setOauthStatus(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
fetchOAuthStatus();
|
||||
const intervalId = window.setInterval(fetchOAuthStatus, 5000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
api
|
||||
@@ -182,10 +204,15 @@ function App() {
|
||||
onCloseSuggestions={closeSuggestions}
|
||||
completionError={completionError}
|
||||
currentPartial={currentPartial}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
) : (
|
||||
<div className="workspace" style={{ height: "100%" }}>
|
||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
||||
<Chat
|
||||
projectPath={projectPath}
|
||||
onCloseProject={closeProject}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -205,6 +205,13 @@ export interface CommandOutput {
|
||||
exit_code: number;
|
||||
}
|
||||
|
||||
export interface OAuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
expires_at: number;
|
||||
has_refresh_token: boolean;
|
||||
}
|
||||
|
||||
declare const __STORKIT_PORT__: string;
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
@@ -402,6 +409,10 @@ export const api = {
|
||||
deleteStory(storyId: string) {
|
||||
return callMcpTool("delete_story", { story_id: storyId });
|
||||
},
|
||||
/** Fetch OAuth status from the server. */
|
||||
getOAuthStatus() {
|
||||
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||
},
|
||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||
botCommand(command: string, args: string, baseUrl?: string) {
|
||||
return requestJson<{ response: string }>(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import type {
|
||||
AnthropicModelInfo,
|
||||
OAuthStatus,
|
||||
PipelineState,
|
||||
WizardStateData,
|
||||
} from "../api/client";
|
||||
@@ -164,9 +165,10 @@ const getContextWindowSize = (
|
||||
interface ChatProps {
|
||||
projectPath: string;
|
||||
onCloseProject: () => void;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
}
|
||||
|
||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
export function Chat({ projectPath, onCloseProject, oauthStatus = null }: ChatProps) {
|
||||
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [model, setModel] = useState("claude-code-pty");
|
||||
@@ -615,12 +617,6 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
const sendMessage = async (messageText: string) => {
|
||||
if (!messageText.trim()) return;
|
||||
|
||||
// /help — show available slash commands overlay
|
||||
if (/^\/help\s*$/i.test(messageText)) {
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// /reset — clear session and message history without LLM
|
||||
if (/^\/reset\s*$/i.test(messageText)) {
|
||||
setMessages([]);
|
||||
@@ -657,6 +653,14 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
"overview",
|
||||
"rebuild",
|
||||
"loc",
|
||||
"help",
|
||||
"ambient",
|
||||
"htop",
|
||||
"rmtree",
|
||||
"timer",
|
||||
"unblock",
|
||||
"unreleased",
|
||||
"setup",
|
||||
]);
|
||||
|
||||
if (knownCommands.has(cmd)) {
|
||||
@@ -940,6 +944,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
enableTools={enableTools}
|
||||
onToggleTools={setEnableTools}
|
||||
wsConnected={wsConnected}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
|
||||
{/* Two-column content area */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { OAuthStatus } from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
|
||||
enableTools: boolean;
|
||||
onToggleTools: (enabled: boolean) => void;
|
||||
wsConnected: boolean;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
}
|
||||
|
||||
const getContextEmoji = (percentage: number): string => {
|
||||
@@ -55,6 +57,7 @@ export function ChatHeader({
|
||||
enableTools,
|
||||
onToggleTools,
|
||||
wsConnected,
|
||||
oauthStatus = null,
|
||||
}: ChatHeaderProps) {
|
||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -340,6 +343,59 @@ export function ChatHeader({
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||
{oauthStatus !== null &&
|
||||
(!oauthStatus.authenticated || oauthStatus.expired) && (
|
||||
<button
|
||||
type="button"
|
||||
title="Authenticate with Claude via OAuth"
|
||||
onClick={() => {
|
||||
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
borderRadius: "99px",
|
||||
border: "none",
|
||||
fontSize: "0.85em",
|
||||
backgroundColor: "#1a3a5c",
|
||||
color: "#7eb8f7",
|
||||
cursor: "pointer",
|
||||
outline: "none",
|
||||
transition: "all 0.2s",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||
e.currentTarget.style.color = "#a8d4ff";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||
e.currentTarget.style.color = "#7eb8f7";
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||
e.currentTarget.style.color = "#a8d4ff";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||
e.currentTarget.style.color = "#7eb8f7";
|
||||
}}
|
||||
>
|
||||
{oauthStatus.expired ? "Re-authenticate" : "Login with Claude"}
|
||||
</button>
|
||||
)}
|
||||
{oauthStatus?.authenticated && !oauthStatus.expired && (
|
||||
<span
|
||||
title="Authenticated with Claude via OAuth"
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
color: "#4caf50",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
✓ Claude
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { OAuthStatus } from "../../api/client";
|
||||
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
|
||||
onCloseSuggestions: () => void;
|
||||
completionError: string | null;
|
||||
currentPartial: string;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
}
|
||||
|
||||
export function SelectionScreen({
|
||||
@@ -43,6 +45,7 @@ export function SelectionScreen({
|
||||
onCloseSuggestions,
|
||||
completionError,
|
||||
currentPartial,
|
||||
oauthStatus = null,
|
||||
}: SelectionScreenProps) {
|
||||
const resolvedHomeDir = homeDir
|
||||
? homeDir.endsWith("/")
|
||||
@@ -57,6 +60,37 @@ export function SelectionScreen({
|
||||
<h1>Storkit</h1>
|
||||
<p>Paste or complete a project path to start.</p>
|
||||
|
||||
{oauthStatus !== null && (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
{!oauthStatus.authenticated || oauthStatus.expired ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid #1a3a5c",
|
||||
backgroundColor: "#1a3a5c",
|
||||
color: "#7eb8f7",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.9em",
|
||||
}}
|
||||
>
|
||||
{oauthStatus.expired ? "Re-authenticate with Claude" : "Login with Claude"}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
title="Authenticated with Claude via OAuth"
|
||||
style={{ color: "#4caf50", fontSize: "0.9em" }}
|
||||
>
|
||||
✓ Authenticated with Claude
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{knownProjects.length > 0 && (
|
||||
<RecentProjectsList
|
||||
projects={knownProjects}
|
||||
|
||||
@@ -50,10 +50,48 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
name: "/overview <number>",
|
||||
description: "Show the implementation summary for a merged story.",
|
||||
},
|
||||
{
|
||||
name: "/ambient",
|
||||
description: "Toggle ambient mode: `/ambient on` or `/ambient off`.",
|
||||
},
|
||||
{
|
||||
name: "/htop",
|
||||
description:
|
||||
"Show live system and agent process dashboard: `/htop`, `/htop 10m`, `/htop stop`.",
|
||||
},
|
||||
{
|
||||
name: "/loc",
|
||||
description:
|
||||
"Show top source files by line count: `/loc` (top 10), `/loc <N>`, or `/loc <filepath>`.",
|
||||
},
|
||||
{
|
||||
name: "/rmtree <number>",
|
||||
description:
|
||||
"Delete the worktree for a story without removing it from the pipeline.",
|
||||
},
|
||||
{
|
||||
name: "/rebuild",
|
||||
description: "Rebuild the server binary and restart.",
|
||||
},
|
||||
{
|
||||
name: "/timer",
|
||||
description:
|
||||
"Schedule a deferred agent start: `/timer <story_id> <HH:MM>`, `/timer list`, `/timer cancel <story_id>`.",
|
||||
},
|
||||
{
|
||||
name: "/unblock <number>",
|
||||
description:
|
||||
"Reset a blocked story: clears blocked flag and resets retry count.",
|
||||
},
|
||||
{
|
||||
name: "/unreleased",
|
||||
description: "Show stories merged to master since the last release tag.",
|
||||
},
|
||||
{
|
||||
name: "/setup",
|
||||
description:
|
||||
"Show setup wizard progress; or `/setup confirm` / `/setup skip` / `/setup retry` to drive the wizard.",
|
||||
},
|
||||
{
|
||||
name: "/reset",
|
||||
description:
|
||||
|
||||
@@ -30,6 +30,20 @@ export default defineConfig(() => {
|
||||
proxy.on("error", (_err) => {});
|
||||
},
|
||||
},
|
||||
"/oauth": {
|
||||
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||
timeout: 120000,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (_err) => {});
|
||||
},
|
||||
},
|
||||
"/callback": {
|
||||
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||
timeout: 120000,
|
||||
configure: (proxy) => {
|
||||
proxy.on("error", (_err) => {});
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ignored: [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "storkit"
|
||||
version = "0.8.3"
|
||||
version = "0.8.4"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -616,7 +616,13 @@ pub(super) async fn handle_message(
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[matrix-bot] LLM error: {e}");
|
||||
let err_msg = format!("Error processing your request: {e}");
|
||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||
format!(
|
||||
"Authentication required. [Click here to log in to Claude]({url})"
|
||||
)
|
||||
} else {
|
||||
format!("Error processing your request: {e}")
|
||||
};
|
||||
let _ = msg_tx.send(err_msg.clone());
|
||||
(err_msg, None)
|
||||
}
|
||||
@@ -686,6 +692,24 @@ mod tests {
|
||||
assert_eq!(prompt, "@bob:example.com: What's up?");
|
||||
}
|
||||
|
||||
// -- OAuth login link formatting ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn oauth_error_produces_login_link() {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
|
||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||
assert!(msg.contains("[Click here to log in to Claude]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_oauth_error_not_formatted_as_link() {
|
||||
let err = "Some unrelated error";
|
||||
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||
}
|
||||
|
||||
// -- bot_name / system prompt -------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -383,7 +383,13 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
||||
}
|
||||
Err(e) => {
|
||||
slog!("[whatsapp] LLM error: {e}");
|
||||
let err_msg = format!("Error processing your request: {e}");
|
||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||
format!(
|
||||
"Authentication required. Log in to Claude here: {url}"
|
||||
)
|
||||
} else {
|
||||
format!("Error processing your request: {e}")
|
||||
};
|
||||
let _ = msg_tx.send(err_msg.clone());
|
||||
(err_msg, None)
|
||||
}
|
||||
@@ -491,6 +497,24 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
// ── OAuth login link formatting ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn whatsapp_oauth_error_produces_plain_text_url() {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
|
||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_non_oauth_error_not_formatted_as_link() {
|
||||
let err = "Some unrelated error occurred during processing";
|
||||
assert!(crate::llm::oauth::extract_login_url_from_error(err).is_none());
|
||||
}
|
||||
|
||||
// ── Allowlist tests ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+49
-5
@@ -46,29 +46,44 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
/// - `@bot_localpart:server.com rest` → `rest`
|
||||
/// - `@bot_localpart rest` → `rest`
|
||||
/// - `DisplayName rest` → `rest`
|
||||
/// - `DisplayName: rest` → `rest` (Element tab-completion inserts a colon)
|
||||
/// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma)
|
||||
/// - `DisplayName ⚡️: rest` → `rest` (display name with emoji)
|
||||
pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||
let trimmed = message.trim();
|
||||
|
||||
// Try full Matrix user ID (e.g. "@timmy:homeserver.local")
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||
return rest;
|
||||
return strip_mention_separator(rest);
|
||||
}
|
||||
|
||||
// Try @localpart (e.g. "@timmy")
|
||||
if let Some(localpart) = bot_user_id.split(':').next()
|
||||
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||
{
|
||||
return rest;
|
||||
return strip_mention_separator(rest);
|
||||
}
|
||||
|
||||
// Try display name (e.g. "Timmy")
|
||||
// Try display name (e.g. "Timmy" or "timmy ⚡️")
|
||||
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||
return rest;
|
||||
return strip_mention_separator(rest);
|
||||
}
|
||||
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Strip an optional Element tab-completion separator (`:` or `,`) and
|
||||
/// surrounding whitespace from the start of text that follows a bot mention.
|
||||
///
|
||||
/// Element's tab-completion inserts `DisplayName: ` (colon + space) after the
|
||||
/// name. Without this strip the leading `:` would be treated as part of the
|
||||
/// command name and no command would match.
|
||||
fn strip_mention_separator(rest: &str) -> &str {
|
||||
let rest = rest.trim_start();
|
||||
let rest = rest.strip_prefix([',', ':']).unwrap_or(rest);
|
||||
rest.trim_start()
|
||||
}
|
||||
|
||||
/// Returns `true` when `text` ends while inside an open fenced code block.
|
||||
///
|
||||
/// A fenced code block opens and closes on lines that start with ` ``` `
|
||||
@@ -334,7 +349,36 @@ mod tests {
|
||||
#[test]
|
||||
fn strip_mention_comma_after_name() {
|
||||
let rest = strip_bot_mention("@timmy, help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest.trim().trim_start_matches(',').trim(), "help");
|
||||
assert_eq!(rest.trim(), "help");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_colon_separator_element_tab_completion() {
|
||||
// Element tab-completes display names with a trailing ": "
|
||||
let rest = strip_bot_mention(
|
||||
"timmy ⚡️: ambient on",
|
||||
"timmy ⚡️",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest, "ambient on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_emoji_display_name_no_separator() {
|
||||
// Display name with emoji, no separator
|
||||
let rest = strip_bot_mention(
|
||||
"timmy ⚡️ ambient on",
|
||||
"timmy ⚡️",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
assert_eq!(rest, "ambient on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_mention_colon_after_localpart() {
|
||||
// Element may also produce "@timmy: help"
|
||||
let rest = strip_bot_mention("@timmy: help", "Timmy", "@timmy:homeserver.local");
|
||||
assert_eq!(rest, "help");
|
||||
}
|
||||
|
||||
// -- drain_complete_paragraphs ------------------------------------------
|
||||
|
||||
@@ -161,10 +161,43 @@ pub async fn refresh_access_token() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the OAuth login URL from an error message produced by the Claude Code provider.
|
||||
///
|
||||
/// The provider returns errors like:
|
||||
/// `"OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize"`
|
||||
///
|
||||
/// Returns the URL portion when the error indicates missing or expired credentials,
|
||||
/// `None` otherwise.
|
||||
pub fn extract_login_url_from_error(err: &str) -> Option<&str> {
|
||||
let marker = "Please log in: ";
|
||||
let start = err.find(marker)?;
|
||||
Some(err[start + marker.len()..].trim())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn extract_login_url_from_oauth_error() {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||
let url = extract_login_url_from_error(err);
|
||||
assert_eq!(url, Some("http://localhost:3001/oauth/authorize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_login_url_returns_none_for_unrelated_error() {
|
||||
let err = "Some other error occurred";
|
||||
assert!(extract_login_url_from_error(err).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_login_url_with_different_port() {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3002/oauth/authorize";
|
||||
let url = extract_login_url_from_error(err);
|
||||
assert_eq!(url, Some("http://localhost:3002/oauth/authorize"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_credentials_file() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -138,9 +138,11 @@ impl ClaudeCodeProvider {
|
||||
on_token("\n*Refreshing authentication token...*\n");
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
Err(_e) => {
|
||||
let port = crate::http::resolve_port();
|
||||
let login_url = format!("http://localhost:{port}/oauth/authorize");
|
||||
return Err(format!(
|
||||
"OAuth session expired. Please run `claude login` to re-authenticate. ({e})"
|
||||
"OAuth session expired or credentials missing. Please log in: {login_url}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -123,7 +123,32 @@ pub async fn rebuild_and_restart(
|
||||
workspace_root.display()
|
||||
);
|
||||
|
||||
// 3. Build the server binary, matching the current build profile so the
|
||||
// 3. Rebuild the frontend bundle so rust-embed picks up the latest assets.
|
||||
let frontend_dir = workspace_root.join("frontend");
|
||||
if frontend_dir.join("package.json").exists() {
|
||||
slog!("[rebuild] Building frontend");
|
||||
let fe_output = tokio::task::spawn_blocking({
|
||||
let frontend_dir = frontend_dir.clone();
|
||||
move || {
|
||||
std::process::Command::new("npm")
|
||||
.args(["run", "build"])
|
||||
.current_dir(&frontend_dir)
|
||||
.output()
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Frontend build task panicked: {e}"))?
|
||||
.map_err(|e| format!("Failed to run npm run build: {e}"))?;
|
||||
|
||||
if !fe_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&fe_output.stderr);
|
||||
slog!("[rebuild] Frontend build failed:\n{stderr}");
|
||||
return Err(format!("Frontend build failed:\n{stderr}"));
|
||||
}
|
||||
slog!("[rebuild] Frontend build succeeded");
|
||||
}
|
||||
|
||||
// 4. Build the server binary, matching the current build profile so the
|
||||
// re-exec via current_exe() picks up the new binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "storkit"]
|
||||
@@ -152,14 +177,14 @@ pub async fn rebuild_and_restart(
|
||||
|
||||
slog!("[rebuild] Build succeeded, re-execing with new binary");
|
||||
|
||||
// 4. Send shutdown notification before replacing the process so that chat
|
||||
// 5. Send shutdown notification before replacing the process so that chat
|
||||
// participants know the bot is going offline. Best-effort only — we
|
||||
// do not abort the rebuild if the send fails.
|
||||
if let Some(n) = notifier {
|
||||
n.notify(ShutdownReason::Rebuild).await;
|
||||
}
|
||||
|
||||
// 5. Re-exec with the new binary.
|
||||
// 6. Re-exec with the new binary.
|
||||
// Use the cargo output path rather than current_exe() so that rebuilds
|
||||
// inside Docker work correctly — the running binary may be installed at
|
||||
// /usr/local/bin/storkit (read-only) while cargo writes the new binary
|
||||
|
||||
Reference in New Issue
Block a user