feat(ppal-aws): add Lambda handler for Discord Bot

Add Node.js 22 Lambda function for PPAL Discord Bot:
- Discord Interactions API with Ed25519 signature verification
- Slash commands: /ppal status/help, /miyabi issue/status, /help
- DynamoDB integration for user state management
- Secrets Manager integration for token retrieval
- GitHub API integration for issue creation

Files:
- package.json: Dependencies (aws-sdk v3, discord-api-types, tweetnacl)
- tsconfig.json: TypeScript strict mode, ES2022 target
- Dockerfile: Multi-stage Node.js 22 Alpine build
- src/index.ts: Lambda handler with signature verification
- src/discord/verify.ts: Ed25519 signature validation
- src/discord/commands.ts: Command handlers for /ppal, /miyabi, /help
- src/services/secrets.ts: AWS Secrets Manager client
- src/services/dynamodb.ts: DynamoDB DocumentClient operations

Commands:
- /ppal status: System status with user info
- /ppal help: PPAL commands help
- /miyabi issue <title>: Create GitHub issue
- /miyabi status: Miyabi agent society status
- /help: Full command list

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-01-25 19:09:12 +09:00
parent abed5cb5cb
commit ca0e991272
10 changed files with 2271 additions and 0 deletions

View File

@ -0,0 +1,10 @@
node_modules
dist
.git
.gitignore
*.md
.env
.DS_Store
coverage
.vscode
.idea

View File

@ -0,0 +1,27 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
# Production stage
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# Set non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
USER nodejs
CMD ["node", "dist/index.js"]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "ppal-discord-handler",
"version": "1.0.0",
"description": "PPAL Discord Bot Lambda Handler",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc",
"watch": "tsc --watch",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.709.0",
"@aws-sdk/client-secrets-manager": "^3.709.0",
"@aws-sdk/lib-dynamodb": "^3.709.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.145",
"@types/node": "^22.10.5",
"typescript": "^5.7.3"
}
}

View File

@ -0,0 +1,291 @@
import {
InteractionType,
InteractionResponseType,
APIApplicationCommandInteraction,
APIChatInputApplicationCommandInteraction,
} from "discord-api-types/payloads/v10";
import { getUser } from "../services/dynamodb.js";
import { getGitHubToken } from "../services/secrets.js";
interface GitHubIssue {
title: string;
html_url: string;
number: number;
}
/**
* PING応答
*/
export function handlePing(): { type: InteractionResponseType.Pong } {
return { type: InteractionResponseType.Pong };
}
/**
*
*/
export async function handleApplicationCommand(
interaction: APIApplicationCommandInteraction
): Promise<{
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
}> {
const chatInteraction = interaction as APIChatInputApplicationCommandInteraction;
const { name } = chatInteraction.data;
switch (name) {
case "ppal":
return await handlePpalCommand(chatInteraction);
case "miyabi":
return await handleMiyabiCommand(chatInteraction);
case "help":
return handleHelpCommand();
default:
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Unknown command",
flags: 64, // Ephemeral
},
};
}
}
/**
* /ppal
*/
async function handlePpalCommand(
interaction: APIChatInputApplicationCommandInteraction
): Promise<{
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
}> {
const options = interaction.data.options || [];
const subcommand = options[0]?.name;
switch (subcommand) {
case "status":
return handlePpalStatus(interaction);
case "help":
return handlePpalHelp();
default:
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Unknown subcommand. Use `/ppal help` for info.",
flags: 64,
},
};
}
}
/**
* /ppal status -
*/
async function handlePpalStatus(
interaction: APIChatInputApplicationCommandInteraction
): Promise<{
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
}> {
const user = await getUser(interaction.member?.user?.id || interaction.user?.id || "");
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `
**PPAL System Status**
🟢 PPAL Discord Bot: Online
🟢 DynamoDB: Connected
🟢 Lambda: Active
${user ? `👤 User: ${user.username || interaction.member?.user?.username || "Unknown"}\n📊 State: ${user.state || "None"}` : "👤 User: Not registered"}
_Environment: ${process.env.ENVIRONMENT || "unknown"}_
`.trim(),
flags: 64, // Ephemeral
},
};
}
/**
* /ppal help -
*/
function handlePpalHelp(): {
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
} {
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `
**PPAL Discord Bot Commands**
\`\`\`
/ppal status - Show system status
/ppal help - Show this help message
/miyabi issue <title> - Create GitHub issue
/miyabi status - Check Miyabi system status
/help - Show all commands
\`\`\`
🔗 [Documentation](https://docs.clawd.bot)
`.trim(),
flags: 64,
},
};
}
/**
* /miyabi
*/
async function handleMiyabiCommand(
interaction: APIChatInputApplicationCommandInteraction
): Promise<{
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
}> {
const options = interaction.data.options || [];
const subcommand = options[0]?.name;
switch (subcommand) {
case "issue":
return await handleMiyabiIssue(interaction);
case "status":
return handleMiyabiStatus();
default:
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: "Unknown subcommand. Use `/miyabi status` for info.",
flags: 64,
},
};
}
}
/**
* /miyabi issue <title> - GitHub Issue
*/
async function handleMiyabiIssue(
interaction: APIChatInputApplicationCommandInteraction
): Promise<{
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
}> {
const options = interaction.data.options || [];
const subcommandOptions = (options[0] as { options?: Array<{ value: unknown }> })?.options || [];
const titleOption = subcommandOptions[0];
const title = titleOption?.value as string;
if (!title) {
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: "❌ Please provide a title for the issue.",
flags: 64,
},
};
}
try {
const token = await getGitHubToken();
const response = await fetch("https://api.github.com/repos/ShunsukeHayashi/clawdbot/issues", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github.v3+json",
"Content-Type": "application/json",
"X-GitHub-Api-Version": "2022-11-28",
},
body: JSON.stringify({
title: `[PPAL Discord] ${title}`,
body: `Created from Discord by <@${interaction.member?.user?.id || interaction.user?.id}>\n\n---\n*This issue was automatically created via PPAL Discord Bot*`,
labels: ["ppal", "discord-bot"],
}),
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
}
const issue = (await response.json()) as GitHubIssue;
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `✅ Issue created: [${issue.title}](${issue.html_url})\n\n📝 Issue #${issue.number}`,
flags: 64,
},
};
} catch (error) {
console.error("Error creating GitHub issue:", error);
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `❌ Failed to create issue: ${error instanceof Error ? error.message : "Unknown error"}`,
flags: 64,
},
};
}
}
/**
* /miyabi status - Miyabi
*/
function handleMiyabiStatus(): {
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
} {
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `
**Miyabi Agent Society Status**
🎭 (Conductor): Online
🍁 (CodeGen): Online
🌸 (Review): Online
🌺 (PR): Online
🌼 (Deploy): Online
🌊 (Workflow): Online
📊 Active Agents: 6/6
_Environment: ${process.env.ENVIRONMENT || "unknown"}_
`.trim(),
flags: 64,
},
};
}
/**
* /help
*/
function handleHelpCommand(): {
type: InteractionResponseType.ChannelMessageWithSource;
data: { content: string; flags?: number };
} {
return {
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `
**PPAL Discord Bot - Command List**
\`\`\`
/ppal status - Show PPAL system status
/ppal help - Show PPAL commands
/miyabi issue <title> - Create GitHub issue
/miyabi status - Show Miyabi agent status
/help - Show this message
\`\`\`
🔗 [Full Documentation](https://docs.clawd.bot)
💡 Need help? Join our community!
`.trim(),
flags: 64,
},
};
}

View File

@ -0,0 +1,39 @@
import { getDiscordPublicKey } from "../services/secrets.js";
/**
* Discord Interactions APIの署名を検証
*/
export async function verifySignature(
body: string,
signature: string,
timestamp: string
): Promise<boolean> {
const publicKey = await getDiscordPublicKey();
const nacl = await import("tweetnacl");
const message = timestamp + body;
const signatureBytes = Buffer.from(signature, "hex");
const publicKeyBytes = Buffer.from(publicKey, "hex");
return nacl.sign.detached.verify(
Buffer.from(message),
signatureBytes,
publicKeyBytes
);
}
/**
*
*/
export function extractSignatureHeaders(headers: {
[key: string]: string | undefined;
}): { signature: string; timestamp: string } | null {
const signature = headers["x-signature-ed25519"];
const timestamp = headers["x-signature-timestamp"];
if (!signature || !timestamp) {
return null;
}
return { signature, timestamp };
}

View File

@ -0,0 +1,116 @@
import {
APIInteraction,
InteractionType,
InteractionResponseType,
APIApplicationCommandInteraction,
} from "discord-api-types/payloads/v10";
import { verifySignature, extractSignatureHeaders } from "./discord/verify.js";
import {
handlePing,
handleApplicationCommand,
} from "./discord/commands.js";
/**
* Lambda
*/
export const handler = async (event: {
headers: { [key: string]: string | undefined };
body: string | null;
}): Promise<{ statusCode: number; body: string; headers: Record<string, string> }> => {
// Headers are lowercase in API Gateway
const headers = Object.fromEntries(
Object.entries(event.headers || {}).map(([k, v]) => [k.toLowerCase(), v])
);
const signatureData = extractSignatureHeaders(headers);
if (!signatureData) {
console.error("Missing signature headers");
return errorResponse(401, "Missing signature headers");
}
const { signature, timestamp } = signatureData;
const body = event.body || "";
// Signature verification
const isValid = await verifySignature(body, signature, timestamp);
if (!isValid) {
console.error("Invalid signature");
return errorResponse(401, "Invalid signature");
}
// Parse interaction
let interaction: APIInteraction;
try {
interaction = JSON.parse(body);
} catch (error) {
console.error("Failed to parse interaction body:", error);
return errorResponse(400, "Invalid JSON");
}
// Handle interaction
try {
let response:
| { type: InteractionResponseType.Pong }
| { type: InteractionResponseType.ChannelMessageWithSource; data: { content: string; flags?: number } };
switch (interaction.type) {
case InteractionType.Ping:
response = handlePing();
break;
case InteractionType.ApplicationCommand:
response = await handleApplicationCommand(
interaction as APIApplicationCommandInteraction
);
break;
default:
console.warn(`Unhandled interaction type: ${interaction.type}`);
return errorResponse(400, "Unhandled interaction type");
}
return jsonResponse(JSON.stringify(response));
} catch (error) {
console.error("Error handling interaction:", error);
return errorResponse(500, "Internal server error");
}
};
/**
* JSONレスポンス
*/
function jsonResponse(body: string): {
statusCode: number;
body: string;
headers: Record<string, string>;
} {
return {
statusCode: 200,
body,
headers: {
"Content-Type": "application/json",
},
};
}
/**
*
*/
function errorResponse(
statusCode: number,
message: string
): {
statusCode: number;
body: string;
headers: Record<string, string>;
} {
return {
statusCode,
body: JSON.stringify({ error: message }),
headers: {
"Content-Type": "application/json",
},
};
}

View File

@ -0,0 +1,134 @@
import {
DynamoDBDocumentClient,
GetCommand,
PutCommand,
UpdateCommand,
QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);
const USERS_TABLE = process.env.USERS_TABLE_NAME || "";
const CONVERSATIONS_TABLE = process.env.CONVERSATIONS_TABLE_NAME || "";
export interface User {
user_id: string;
guild_id?: string;
username?: string;
state?: string;
created_at: string;
updated_at: string;
}
export interface Conversation {
conversation_id: string;
user_id: string;
guild_id?: string;
messages: number;
last_activity: string;
created_at: string;
}
/**
*
*/
export async function getUser(userId: string): Promise<User | null> {
const response = await docClient.send(
new GetCommand({
TableName: USERS_TABLE,
Key: { user_id: userId },
})
);
return (response.Item as User) || null;
}
/**
*
*/
export async function putUser(user: User): Promise<void> {
await docClient.send(
new PutCommand({
TableName: USERS_TABLE,
Item: user,
})
);
}
/**
*
*/
export async function updateUserState(
userId: string,
state: string
): Promise<void> {
await docClient.send(
new UpdateCommand({
TableName: USERS_TABLE,
Key: { user_id: userId },
UpdateExpression: "SET #state = :state, #updated = :updated",
ExpressionAttributeNames: {
"#state": "state",
"#updated": "updated_at",
},
ExpressionAttributeValues: {
":state": state,
":updated": new Date().toISOString(),
},
})
);
}
/**
*
*/
export async function listUsersByGuild(guildId: string): Promise<User[]> {
const response = await docClient.send(
new QueryCommand({
TableName: USERS_TABLE,
IndexName: "GuildIndex",
KeyConditionExpression: "guild_id = :guild_id",
ExpressionAttributeValues: {
":guild_id": guildId,
},
})
);
return (response.Items as User[]) || [];
}
/**
*
*/
export async function putConversation(
conversation: Conversation
): Promise<void> {
await docClient.send(
new PutCommand({
TableName: CONVERSATIONS_TABLE,
Item: conversation,
})
);
}
/**
*
*/
export async function listConversationsByUser(
userId: string
): Promise<Conversation[]> {
const response = await docClient.send(
new QueryCommand({
TableName: CONVERSATIONS_TABLE,
IndexName: "UserIndex",
KeyConditionExpression: "user_id = :user_id",
ExpressionAttributeValues: {
":user_id": userId,
},
})
);
return (response.Items as Conversation[]) || [];
}

View File

@ -0,0 +1,90 @@
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({});
export interface DiscordSecrets {
token: string;
public_key: string;
}
export interface GitHubSecret {
token: string;
}
export interface OpenAISecret {
api_key: string;
}
/**
* Discord bot tokenを取得
*/
export async function getDiscordToken(): Promise<string> {
const secretArn = process.env.DISCORD_BOT_TOKEN_SECRET_ARN;
if (!secretArn) throw new Error("DISCORD_BOT_TOKEN_SECRET_ARN not set");
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const secret = JSON.parse(response.SecretString || "{}");
return secret.token as string;
}
/**
* Discord public keyを取得
*/
export async function getDiscordPublicKey(): Promise<string> {
const secretArn = process.env.DISCORD_PUBLIC_KEY_SECRET_ARN;
if (!secretArn) throw new Error("DISCORD_PUBLIC_KEY_SECRET_ARN not set");
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const secret = JSON.parse(response.SecretString || "{}");
return secret.public_key as string;
}
/**
* GitHub tokenを取得
*/
export async function getGitHubToken(): Promise<string> {
const secretArn = process.env.GITHUB_TOKEN_SECRET_ARN;
if (!secretArn) throw new Error("GITHUB_TOKEN_SECRET_ARN not set");
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const secret = JSON.parse(response.SecretString || "{}");
return secret.token as string;
}
/**
* OpenAI API keyを取得
*/
export async function getOpenAIApiKey(): Promise<string> {
const secretArn = process.env.OPENAI_API_KEY_SECRET_ARN;
if (!secretArn) throw new Error("OPENAI_API_KEY_SECRET_ARN not set");
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretArn })
);
const secret = JSON.parse(response.SecretString || "{}");
return secret.api_key as string;
}
/**
* Discordシークレットを取得
*/
export async function getDiscordSecrets(): Promise<DiscordSecrets> {
const [token, public_key] = await Promise.all([
getDiscordToken(),
getDiscordPublicKey(),
]);
return { token, public_key };
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}