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:
parent
abed5cb5cb
commit
ca0e991272
10
workers/ppal-aws/lambda/discord-handler/.dockerignore
Normal file
10
workers/ppal-aws/lambda/discord-handler/.dockerignore
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
27
workers/ppal-aws/lambda/discord-handler/Dockerfile
Normal file
27
workers/ppal-aws/lambda/discord-handler/Dockerfile
Normal 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"]
|
||||||
1522
workers/ppal-aws/lambda/discord-handler/package-lock.json
generated
Normal file
1522
workers/ppal-aws/lambda/discord-handler/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
workers/ppal-aws/lambda/discord-handler/package.json
Normal file
23
workers/ppal-aws/lambda/discord-handler/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
291
workers/ppal-aws/lambda/discord-handler/src/discord/commands.ts
Normal file
291
workers/ppal-aws/lambda/discord-handler/src/discord/commands.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
116
workers/ppal-aws/lambda/discord-handler/src/index.ts
Normal file
116
workers/ppal-aws/lambda/discord-handler/src/index.ts
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
134
workers/ppal-aws/lambda/discord-handler/src/services/dynamodb.ts
Normal file
134
workers/ppal-aws/lambda/discord-handler/src/services/dynamodb.ts
Normal 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[]) || [];
|
||||||
|
}
|
||||||
@ -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 };
|
||||||
|
}
|
||||||
19
workers/ppal-aws/lambda/discord-handler/tsconfig.json
Normal file
19
workers/ppal-aws/lambda/discord-handler/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user