Skip to content
Merged
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
11 changes: 10 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,21 @@ slides/*.pdf
# IDE
.idea/
.vscode/
.kiro/
.claude/
*.swp
*.swo

# OS
.DS_Store
Thumbs.db

# Logs
*.log
npm-debug.log*

# Coverage
coverage/

# References (cloned repos for study)
references/
references/
18 changes: 12 additions & 6 deletions packages/cdk/__tests__/stacks.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,18 @@ describe("CDK Stacks E2E — synth all stacks", () => {
// ── ApiStack ──

describe("ApiStack", () => {
it("7+ Lambda functions (including prewarm + log retention custom resources)", () => {
// 7 handler functions + 1 custom resource for log retention
it("7 Lambda functions", () => {
const functions = apiTemplate.findResources("AWS::Lambda::Function");
expect(Object.keys(functions).length).toBeGreaterThanOrEqual(7);
expect(Object.keys(functions).length).toBe(7);
});

it("7 CloudWatch log groups with ONE_WEEK retention", () => {
const logGroups = apiTemplate.findResources("AWS::Logs::LogGroup");
expect(Object.keys(logGroups).length).toBe(7);
for (const [, lg] of Object.entries(logGroups)) {
const props = (lg as Record<string, unknown>).Properties as Record<string, unknown>;
expect(props).toHaveProperty("RetentionInDays", 7);
}
});

it("WebSocket API", () => {
Expand All @@ -236,10 +244,8 @@ describe("CDK Stacks E2E — synth all stacks", () => {

it("Handler Lambda functions use ARM64", () => {
const functions = apiTemplate.findResources("AWS::Lambda::Function");
for (const [id, fn] of Object.entries(functions)) {
for (const [, fn] of Object.entries(functions)) {
const props = (fn as Record<string, unknown>).Properties as Record<string, unknown>;
// Skip log retention custom resource Lambda (managed by CDK)
if (id.includes("LogRetention")) continue;
expect(props).toHaveProperty("Architectures", ["arm64"]);
}
});
Expand Down
15 changes: 14 additions & 1 deletion packages/cdk/lib/stacks/api-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,26 @@ export class ApiStack extends cdk.Stack {
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
timeout: cdk.Duration.seconds(30),
logRetention: logs.RetentionDays.ONE_WEEK,
projectRoot: monorepoRoot,
depsLockFilePath: path.join(monorepoRoot, "package-lock.json"),
bundling: bundlingDefaults,
};

const makeLogGroup = (id: string, name: string) =>
new logs.LogGroup(this, id, {
logGroupName: `/aws/lambda/${name}`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});

// ── Lambda Functions ──

const wsConnectFn = new NodejsFunction(this, "WsConnectFn", {
...nodejsFunctionDefaults,
functionName: "serverless-openclaw-ws-connect",
entry: path.join(handlersDir, "ws-connect.ts"),
handler: "handler",
logGroup: makeLogGroup("WsConnectLogGroup", "serverless-openclaw-ws-connect"),
environment: {
...commonEnv,
USER_POOL_ID: props.userPool.userPoolId,
Expand All @@ -119,6 +126,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-ws-disconnect",
entry: path.join(handlersDir, "ws-disconnect.ts"),
handler: "handler",
logGroup: makeLogGroup("WsDisconnectLogGroup", "serverless-openclaw-ws-disconnect"),
environment: { ...commonEnv },
});

Expand All @@ -127,6 +135,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-ws-message",
entry: path.join(handlersDir, "ws-message.ts"),
handler: "handler",
logGroup: makeLogGroup("WsMessageLogGroup", "serverless-openclaw-ws-message"),
environment: { ...commonEnv },
});

Expand All @@ -135,6 +144,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-telegram-webhook",
entry: path.join(handlersDir, "telegram-webhook.ts"),
handler: "handler",
logGroup: makeLogGroup("TelegramWebhookLogGroup", "serverless-openclaw-telegram-webhook"),
environment: { ...commonEnv },
});

Expand All @@ -143,6 +153,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-api-handler",
entry: path.join(handlersDir, "api-handler.ts"),
handler: "handler",
logGroup: makeLogGroup("ApiHandlerLogGroup", "serverless-openclaw-api-handler"),
environment: { ...commonEnv },
});

Expand All @@ -151,6 +162,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-watchdog",
entry: path.join(handlersDir, "watchdog.ts"),
handler: "handler",
logGroup: makeLogGroup("WatchdogLogGroup", "serverless-openclaw-watchdog"),
environment: { ...commonEnv },
});

Expand All @@ -159,6 +171,7 @@ export class ApiStack extends cdk.Stack {
functionName: "serverless-openclaw-prewarm",
entry: path.join(handlersDir, "prewarm.ts"),
handler: "handler",
logGroup: makeLogGroup("PrewarmLogGroup", "serverless-openclaw-prewarm"),
environment: {
...commonEnv,
PREWARM_DURATION: process.env.PREWARM_DURATION ?? "60",
Expand Down
10 changes: 5 additions & 5 deletions packages/cdk/lib/stacks/monitoring-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class MonitoringStack extends cdk.Stack {
dashboard.addWidgets(
sectionHeader(
"Cold Start Performance",
"Fargate 컨테이너 시작 시간. S3 복원 → Gateway 연결클라이언트 준비 단계별 소요 시간.",
"Fargate container startup time. Breakdown by phase: S3 restore → Gateway connectionClient ready.",
),
);

Expand Down Expand Up @@ -128,7 +128,7 @@ export class MonitoringStack extends cdk.Stack {
dashboard.addWidgets(
sectionHeader(
"Message Processing",
"사용자 메시지 → AI 응답 완료까지의 지연 시간과 응답 길이. 콜드 스타트 중 대기열 소비량.",
"Latency and response length from user message to AI response. Pending message consumption during cold starts.",
),
);

Expand Down Expand Up @@ -172,7 +172,7 @@ export class MonitoringStack extends cdk.Stack {
dashboard.addWidgets(
sectionHeader(
"Lambda Functions",
"Gateway Lambda 호출 수, 에러, 실행 시간. ws-message와 telegram-webhook이 핵심 핸들러.",
"Gateway Lambda invocations, errors, and duration. ws-message and telegram-webhook are the key handlers.",
),
);

Expand Down Expand Up @@ -212,7 +212,7 @@ export class MonitoringStack extends cdk.Stack {
dashboard.addWidgets(
sectionHeader(
"API Gateway",
"WebSocket 연결 수와 HTTP API 에러율. 4xx는 클라이언트 에러, 5xx는 서버 에러.",
"WebSocket connection count and HTTP API error rates. 4xx = client errors, 5xx = server errors.",
),
);

Expand Down Expand Up @@ -323,7 +323,7 @@ export class MonitoringStack extends cdk.Stack {
dashboard.addWidgets(
sectionHeader(
"Infrastructure — ECS & DynamoDB",
"Fargate 컨테이너 리소스 사용량과 DynamoDB 테이블별 읽기/쓰기 소비량.",
"Fargate container resource usage and DynamoDB read/write consumption per table.",
),
);

Expand Down
2 changes: 1 addition & 1 deletion packages/container/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function createApp(deps: BridgeDeps): express.Express {
try {
let prefix = deps.getAndClearHistoryPrefix?.() ?? "";
if (body.channel === "telegram") {
prefix += "[System: 마크다운 서식(**bold**, *italic*, ```code``` 등)을 사용하지 말고 순수 텍스트로 응답하세요.]\n";
prefix += "[System: Respond in plain text only. Do not use markdown formatting such as **bold**, *italic*, ```code```, etc.]\n";
}
const messageToSend = prefix ? prefix + body.message! : body.message!;
const generator = deps.openclawClient.sendMessage(
Expand Down
4 changes: 2 additions & 2 deletions packages/container/src/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export async function startContainer(opts: StartContainerOptions): Promise<void>
void notifyTelegram(
env.TELEGRAM_BOT_TOKEN,
telegramChatId,
"⚡ 컨테이너 시작됨. AI 엔진 연결 중...",
"⚡ Container started. Connecting to AI engine...",
);
}

Expand All @@ -81,7 +81,7 @@ export async function startContainer(opts: StartContainerOptions): Promise<void>
void notifyTelegram(
env.TELEGRAM_BOT_TOKEN,
telegramChatId,
"✅ 준비 완료! 메시지를 처리합니다...",
"✅ Ready! Processing messages...",
);
}

Expand Down
14 changes: 7 additions & 7 deletions packages/gateway/__tests__/handlers/telegram-webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe("telegram-webhook handler", () => {
expect.anything(),
"123456:ABC-DEF",
"telegram:12345",
expect.stringContaining("깨우는 중"),
expect.stringContaining("Waking up"),
);
expect(mockRouteMessage).toHaveBeenCalled();
});
Expand All @@ -179,7 +179,7 @@ describe("telegram-webhook handler", () => {
expect.anything(),
"123456:ABC-DEF",
"telegram:12345",
expect.stringContaining("깨우는 중"),
expect.stringContaining("Waking up"),
);
});

Expand Down Expand Up @@ -245,14 +245,14 @@ describe("telegram-webhook handler", () => {
expect.anything(),
expect.any(String),
"telegram:12345",
expect.stringContaining("연동 완료"),
expect.stringContaining("linked"),
);
expect(mockRouteMessage).not.toHaveBeenCalled();
});

it("should handle /link command with error", async () => {
mockVerifyOtpAndLink.mockResolvedValueOnce({
error: "OTP가 만료되었거나 유효하지 않습니다.",
error: "OTP has expired or is invalid.",
});

const event = makeEvent(
Expand All @@ -273,7 +273,7 @@ describe("telegram-webhook handler", () => {
expect.anything(),
expect.any(String),
"telegram:12345",
expect.stringContaining("만료"),
expect.stringContaining("expired"),
);
expect(mockRouteMessage).not.toHaveBeenCalled();
});
Expand All @@ -297,7 +297,7 @@ describe("telegram-webhook handler", () => {
expect.anything(),
expect.any(String),
"telegram:12345",
expect.stringContaining("사용법"),
expect.stringContaining("Usage"),
);
expect(mockVerifyOtpAndLink).not.toHaveBeenCalled();
});
Expand All @@ -321,7 +321,7 @@ describe("telegram-webhook handler", () => {
expect.anything(),
expect.any(String),
"telegram:12345",
expect.stringContaining(" UI"),
expect.stringContaining("Web UI"),
);
expect(mockRouteMessage).not.toHaveBeenCalled();
});
Expand Down
10 changes: 5 additions & 5 deletions packages/gateway/__tests__/services/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe("identity service", () => {

const result = await verifyOtpAndLink(mockSend, "67890", "000000");

expect(result).toEqual({ error: "OTP가 만료되었거나 유효하지 않습니다." });
expect(result).toEqual({ error: "OTP has expired or is invalid." });
expect(mockSend).toHaveBeenCalledTimes(1);
});

Expand All @@ -137,7 +137,7 @@ describe("identity service", () => {

const result = await verifyOtpAndLink(mockSend, "67890", "123456");

expect(result).toEqual({ error: "OTP가 만료되었거나 유효하지 않습니다." });
expect(result).toEqual({ error: "OTP has expired or is invalid." });
expect(mockSend).toHaveBeenCalledTimes(4);
});

Expand All @@ -154,7 +154,7 @@ describe("identity service", () => {
const result = await verifyOtpAndLink(mockSend, "67890", "123456");

expect(result).toEqual({
error: "Telegram 컨테이너가 실행 중입니다. 약 15분 후 다시 시도해주세요.",
error: "A Telegram container is currently running. Please try again in about 15 minutes.",
});
// Only 2 calls: OTP lookup + TaskState check. OTP NOT consumed.
expect(mockSend).toHaveBeenCalledTimes(2);
Expand All @@ -175,7 +175,7 @@ describe("identity service", () => {
const result = await verifyOtpAndLink(mockSend, "67890", "123456");

expect(result).toEqual({
error: " Telegram 계정은 이미 다른 계정에 연동되어 있습니다.",
error: "This Telegram account is already linked to a different account.",
});
// Only 3 calls: OTP lookup + TaskState + existing link. OTP NOT consumed.
expect(mockSend).toHaveBeenCalledTimes(3);
Expand Down Expand Up @@ -218,7 +218,7 @@ describe("identity service", () => {
const result = await verifyOtpAndLink(mockSend, "67890", "123456");

expect(result).toEqual({
error: "Telegram 컨테이너가 실행 중입니다. 약 15분 후 다시 시도해주세요.",
error: "A Telegram container is currently running. Please try again in about 15 minutes.",
});
// OTP NOT consumed
expect(mockSend).toHaveBeenCalledTimes(2);
Expand Down
12 changes: 8 additions & 4 deletions packages/gateway/src/handlers/telegram-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { startTask } from "../services/container.js";
import { sendTelegramMessage } from "../services/telegram.js";
import { resolveUserId, verifyOtpAndLink } from "../services/identity.js";
import { resolveSecrets } from "../services/secrets.js";
import { invokeLambdaAgent } from "../services/lambda-agent.js";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const ecs = new ECSClient({});
Expand Down Expand Up @@ -77,7 +78,7 @@ export async function handler(event: {
fetch as never,
botToken,
connectionId,
"사용법: /link {6자리 코드}",
"Usage: /link {6-digit code}",
);
}
return { statusCode: 200, body: "OK" };
Expand All @@ -86,7 +87,7 @@ export async function handler(event: {
if (botToken) {
const msg = "error" in result
? `❌ ${result.error}`
: "✅ 계정 연동 완료! 이제 웹과 Telegram이 같은 컨테이너를 공유합니다.";
: "✅ Account linked! Web and Telegram now share the same container.";
await sendTelegramMessage(fetch as never, botToken, connectionId, msg);
}
return { statusCode: 200, body: "OK" };
Expand All @@ -99,7 +100,7 @@ export async function handler(event: {
fetch as never,
botToken,
connectionId,
"연동 해제는 웹 UI 설정에서만 가능합니다.",
"Unlinking is only available from the Web UI settings.",
);
}
return { statusCode: 200, body: "OK" };
Expand All @@ -117,7 +118,7 @@ export async function handler(event: {
fetch as never,
botToken,
connectionId,
"🔄 에이전트를 깨우는 중... 잠시만 기다려주세요.",
"🔄 Waking up the agent... please wait.",
);
}

Expand Down Expand Up @@ -152,6 +153,9 @@ export async function handler(event: {
containerName: "openclaw",
environment: taskEnv,
},
agentRuntime: (process.env.AGENT_RUNTIME as "lambda" | "fargate" | "both") ?? "fargate",
invokeLambdaAgent,
lambdaAgentFunctionArn: process.env.LAMBDA_AGENT_FUNCTION_ARN ?? "",
});

return { statusCode: 200, body: "OK" };
Expand Down
Loading