Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
497 changes: 497 additions & 0 deletions apps/controller/openapi.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dotenv": "^16.6.1",
"hono": "^4.7.5",
"lowdb": "^7.0.1",
"pdf-parse": "^2.4.5",
"zod": "^3.24.2"
},
"devDependencies": {
Expand Down
15 changes: 15 additions & 0 deletions apps/controller/src/app/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { WorkspaceTemplateWriter } from "../runtime/workspace-template-writer.js
import { AgentService } from "../services/agent-service.js";
import { AnalyticsService } from "../services/analytics-service.js";
import { ArtifactService } from "../services/artifact-service.js";
import { AttachmentStore } from "../services/attachment-store.js";
import { ChannelFallbackService } from "../services/channel-fallback-service.js";
import { ChannelService } from "../services/channel-service.js";
import { DesktopLocalService } from "../services/desktop-local-service.js";
Expand Down Expand Up @@ -60,6 +61,7 @@ export interface ControllerContainer {
desktopLocalService: DesktopLocalService;
analyticsService: AnalyticsService;
artifactService: ArtifactService;
attachmentStore: AttachmentStore;
templateService: TemplateService;
skillhubService: SkillhubService;
openclawSyncService: OpenClawSyncService;
Expand Down Expand Up @@ -159,6 +161,18 @@ export async function createContainer(): Promise<ControllerContainer> {
openclawSyncService,
);
const githubStarVerificationService = new GithubStarVerificationService();
const attachmentStore = new AttachmentStore({
openclawStateDir: env.openclawStateDir,
});
// Sweep expired webchat attachments on boot. Fire-and-forget so controller
// startup isn't blocked by disk I/O, and errors are swallowed-with-log by
// the store itself.
void attachmentStore.cleanupExpired().catch((err) => {
logger.warn(
{ error: err instanceof Error ? err.message : String(err) },
"attachment-store: startup cleanup failed",
);
});

configStore.onCloudStateChanged = async (_change) => {
// Auto-select a valid default model: on login, pick a managed model;
Expand Down Expand Up @@ -209,6 +223,7 @@ export async function createContainer(): Promise<ControllerContainer> {
),
analyticsService,
artifactService: new ArtifactService(artifactsStore),
attachmentStore,
templateService: new TemplateService(configStore, openclawSyncService),
skillhubService,
openclawSyncService,
Expand Down
2 changes: 2 additions & 0 deletions apps/controller/src/app/create-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cors } from "hono/cors";
import { registerArtifactRoutes } from "../routes/artifact-routes.js";
import { registerBotRoutes } from "../routes/bot-routes.js";
import { registerChannelRoutes } from "../routes/channel-routes.js";
import { registerChatRoutes } from "../routes/chat-routes.js";
import { registerDesktopCompatRoutes } from "../routes/desktop-compat-routes.js";
import { registerDesktopRewardsRoutes } from "../routes/desktop-rewards-routes.js";
import { registerDesktopRoutes } from "../routes/desktop-routes.js";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function createApp(container: ControllerContainer) {
registerDesktopCompatRoutes(app, container);
registerDesktopRewardsRoutes(app, container);
registerChannelRoutes(app, container);
registerChatRoutes(app, container);
registerSessionRoutes(app, container);
registerModelRoutes(app, container);
registerProviderOAuthRoutes(app, container);
Expand Down
36 changes: 36 additions & 0 deletions apps/controller/src/routes/bot-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,42 @@ export function registerBotRoutes(
async (c) => c.json({ bots: await container.agentService.listBots() }, 200),
);

app.openapi(
createRoute({
method: "get",
path: "/api/v1/bots/default",
tags: ["Bots"],
responses: {
200: {
content: { "application/json": { schema: botResponseSchema } },
description: "Default bot (existing or newly created)",
},
500: {
content: { "application/json": { schema: errorSchema } },
description: "Failed to get or create default bot",
},
},
}),
async (c) => {
try {
return c.json(
await container.agentService.getOrCreateDefaultBot(),
200,
);
} catch (err) {
return c.json(
{
message:
err instanceof Error
? err.message
: "Failed to get or create bot",
},
500,
);
}
},
);

app.openapi(
createRoute({
method: "get",
Expand Down
211 changes: 211 additions & 0 deletions apps/controller/src/routes/chat-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { type OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
import { sessionResponseSchema } from "@nexu/shared";
import type { ControllerContainer } from "../app/container.js";
import { ChatService } from "../services/chat-service.js";
import type { ControllerBindings } from "../types.js";

// ~10 MB base64 cap → raw file ≈ 7.5 MB; prevents OOM/body-size attacks
const MAX_ATTACHMENT_CONTENT_BYTES = 10_000_000;

const chatAttachmentSchema = z.object({
// Images go through OpenClaw's chat.send attachments pipeline unchanged.
// Files (PDF / text-readable) are extracted on the controller and folded
// into message text as <file>…</file> blocks before forwarding; OpenClaw's
// gateway RPC itself only carries images over the wire.
type: z.enum(["image", "file"]),
content: z.string().max(MAX_ATTACHMENT_CONTENT_BYTES),
metadata: z
.object({
mimeType: z.string().optional(),
filename: z.string().optional(),
size: z.number().optional(),
})
.optional(),
});

const localChatMessageInputSchema = z.object({
type: z.enum(["text", "image", "video", "audio", "file"]),
content: z.string().max(MAX_ATTACHMENT_CONTENT_BYTES),
metadata: z
.object({
width: z.number().optional(),
height: z.number().optional(),
duration: z.number().optional(),
mimeType: z.string().optional(),
filename: z.string().optional(),
size: z.number().optional(),
})
.optional(),
/** Optional additional attachments for multipart (text + images/files) messages */
attachments: z.array(chatAttachmentSchema).optional(),
});

const localChatMessageOutputSchema = z.object({
id: z.string(),
role: z.string(),
type: z.string(),
content: z.unknown(),
timestamp: z.number().nullable(),
createdAt: z.string().nullable(),
});

export function registerChatRoutes(
app: OpenAPIHono<ControllerBindings>,
container: ControllerContainer,
): void {
const chatService = new ChatService(
container.gatewayService,
container.attachmentStore,
);

// GET /api/v1/chat/session - Resolve a named sessionKey to a real session
app.openapi(
createRoute({
method: "get",
path: "/api/v1/chat/session",
tags: ["Chat"],
request: {
query: z.object({
botId: z.string(),
sessionKey: z.string(),
}),
},
responses: {
200: {
description: "Session resolved from sessionKey",
content: {
"application/json": {
schema: z.object({
session: sessionResponseSchema.nullable(),
}),
},
},
},
},
}),
async (c) => {
const { botId, sessionKey } = c.req.valid("query");
const session = await container.sessionService.getSessionBySessionKey(
botId,
sessionKey,
);
return c.json({ session });
},
);

// POST /api/v1/chat/local - Send local chat message (direct to main session)
app.openapi(
createRoute({
method: "post",
path: "/api/v1/chat/local",
tags: ["Chat"],
request: {
body: {
content: {
"application/json": {
schema: z.object({
botId: z.string(),
sessionKey: z.string(),
message: localChatMessageInputSchema,
}),
},
},
},
},
responses: {
200: {
description: "Local chat message sent",
content: {
"application/json": {
schema: z.object({
session: sessionResponseSchema.nullable(),
message: localChatMessageOutputSchema,
}),
},
},
},
},
}),
async (c) => {
const { botId, message } = c.req.valid("json");

// Send message to agent main session — do NOT pre-create the session
// here. Pre-creating writes an empty key-based .jsonl file that
// appears as a ghost entry in the sessions list. OpenClaw will create
// the real UUID-named JSONL and register it in sessions.json as part of
// processing chat.send, so we look it up afterwards.
const result = await chatService.sendLocalMessage(botId, message);

// Best-effort session lookup immediately after send. sessions.json is
// flushed asynchronously by OpenClaw, so this may return null on the
// very first message. The frontend handles that case with its own
// 3-second discovery retry loop — no server-side sleep needed here.
//
// Always look up via the main session key — chat.send always targets
// agent:{botId}:main regardless of which channel key the frontend
// sends in the body (the body's sessionKey is informational only).
const mainSessionKey = `agent:${botId}:main`;
const session = await container.sessionService.getSessionBySessionKey(
botId,
mainSessionKey,
);

return c.json({
session,
message: result,
});
},
);

// GET /api/v1/chat/history - Full aggregated message history across all
// compacted sessions for a bot's main webchat conversation.
// When OpenClaw performs context compaction it creates a new UUID-named JSONL
// and leaves previous session files orphaned on disk. This endpoint collects
// all such orphaned files plus the current main session file, sorts them
// chronologically, and returns the concatenated message list — giving the
// frontend a single continuous timeline regardless of how many compactions
// have occurred.
app.openapi(
createRoute({
method: "get",
path: "/api/v1/chat/history",
tags: ["Chat"],
request: {
query: z.object({
botId: z.string(),
limit: z.coerce.number().int().min(1).max(2000).optional(),
}),
},
responses: {
200: {
description:
"Full conversation history aggregated across all compacted sessions",
content: {
"application/json": {
schema: z.object({
messages: z.array(
z.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
content: z.unknown(),
timestamp: z.number().nullable(),
createdAt: z.string().nullable(),
}),
),
sessionCount: z.number(),
}),
},
},
},
},
}),
async (c) => {
const { botId, limit } = c.req.valid("query");
const result = await container.sessionService.getFullMainChatHistory(
botId,
limit,
);
return c.json(result);
},
);
}
6 changes: 5 additions & 1 deletion apps/controller/src/runtime/openclaw-ws-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,11 @@ export class OpenClawWsClient {
const id = randomUUID();
const signedAtMs = Date.now();
const role = "operator";
const scopes = ["operator.admin"];
// operator.admin covers admin-level access; operator.read and operator.write
// must be explicitly included so write-scoped operations (Feishu/WeChat
// announce, sub-agent follow-up calls) do not hit "missing scope" rejections
// on the loopback gateway. See OpenClaw CHANGELOG: #22582.
const scopes = ["operator.admin", "operator.read", "operator.write"];
const clientId = "gateway-client";
const clientMode = "backend";
const platform = process.platform;
Expand Down
Loading