跳转到内容

Template — 投票卡 Bot

群投票 Agent starter — 持久化版(对比 in-memory examples 更生产)。基于 团队投票卡 tutorial

目录结构

hashee-vote-card/
├── src/
│ ├── index.ts
│ ├── vote-handler.ts
│ └── db.ts
├── sql/schema.sql
├── package.json
├── tsconfig.json
├── .env.example
├── docker-compose.yml
└── README.md

package.json

{
"name": "hashee-vote-card",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx --env-file=.env src/index.ts",
"build": "tsc",
"db:setup": "psql $DATABASE_URL < sql/schema.sql"
},
"dependencies": {
"@hasheeai/agent-sdk-ts": "^0.2.0",
"pg": "^8.13.0",
"ulid": "^2.4.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"tsx": "^4.20.0",
"typescript": "^5.7.0"
}
}

src/index.ts

import { HasheeAgent } from "@hasheeai/agent-sdk-ts";
import {
startVote, closeVote, listVotes,
registerVoteResponseHandler,
} from "./vote-handler";
const agent = await HasheeAgent.init({
agentId: process.env.HASHEE_AGENT_ID!,
token: process.env.HASHEE_AGENT_TOKEN!,
baseUrl: "https://api.hashee.ai",
connectionMode: "websocket",
});
const MY_ID = process.env.HASHEE_AGENT_ID!;
agent.addMessageHandler(async (msg) => {
if (msg.payload?.type !== "text") return;
if (msg.conversation_type === "group" && !msg.mentions?.includes(MY_ID)) return;
const text = msg.payload.text
.replace(new RegExp(`@<U:${MY_ID}>\\s?`, "g"), "").trim();
let m = text.match(/^\/vote\s+([^\s]+)\s+(.+)$/);
if (m) return startVote(agent, msg, m[1], m[2].split(/\s+/));
if (text === "/votes") return listVotes(agent, msg);
m = text.match(/^\/close\s+([A-Z0-9]+)$/);
if (m) return closeVote(agent, msg, m[1]);
await agent.send(msg.conversation_id, {
type: "text",
text: "`/vote 标题 选项1 选项2` 发起;`/votes` 列历史;`/close <id>` 提前结束",
});
});
registerVoteResponseHandler(agent);
agent.addStatusHandler((s) => console.log(`[hashee] ${s}`));
console.log("[vote-bot] up");

src/vote-handler.ts

完整投票逻辑(含 DB 持久化 + revision 冲突处理):

import { ulid } from "ulid";
import type { HasheeAgent } from "@hasheeai/agent-sdk-ts";
import { db } from "./db";
export async function startVote(agent: HasheeAgent, msg: any, question: string, options: string[]) {
if (options.length < 2 || options.length > 5) {
await agent.send(msg.conversation_id, { type: "text", text: "投票需要 2-5 个选项。" });
return;
}
const artifactId = ulid();
await db.query(`
INSERT INTO votes (artifact_id, conv_id, question, options, created_by, ends_at)
VALUES ($1, $2, $3, $4, $5, NOW() + INTERVAL '30 minutes')
`, [artifactId, msg.conversation_id, question, JSON.stringify(options), msg.sender_id]);
await agent.sendArtifact(msg.conversation_id, {
artifact: {
artifact_id: artifactId,
subtype: "app",
title: question,
payload: await render(artifactId),
capability_flags: { allows_response: true },
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString(),
},
});
}
export function registerVoteResponseHandler(agent: HasheeAgent) {
agent.addEventHandler(async (event) => {
if (event.type !== "artifact_response") return;
if (event.payload.action !== "vote") return;
const artifactId = event.payload.ref_artifact;
const userId = event.sender_id;
const choice: string = event.payload.payload?.choice;
await db.query("BEGIN");
try {
const v = await db.query(
"SELECT * FROM votes WHERE artifact_id = $1 AND ends_at > NOW() AND closed = FALSE",
[artifactId],
);
if (v.rows.length === 0) { await db.query("ROLLBACK"); return; }
await db.query(
"INSERT INTO vote_choices (artifact_id, user_id, choice) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[artifactId, userId, choice],
);
await db.query("COMMIT");
} catch (e) { await db.query("ROLLBACK"); throw e; }
await updateVote(agent, artifactId);
});
}
async function updateVote(agent: HasheeAgent, artifactId: string) {
const v = await db.query("SELECT * FROM votes WHERE artifact_id = $1", [artifactId]);
if (v.rows.length === 0) return;
const row = v.rows[0];
const revision = row.revision + 1;
await db.query("UPDATE votes SET revision = $1 WHERE artifact_id = $2", [revision, artifactId]);
try {
await agent.updateArtifact(row.conv_id, {
ref_artifact: artifactId,
based_on_revision: revision - 1,
artifact: { payload: await render(artifactId) },
});
} catch (e: any) {
if (e.code !== "ARTIFACT_REVISION_CONFLICT") throw e;
}
}
async function render(artifactId: string) {
const v = await db.query("SELECT * FROM votes WHERE artifact_id = $1", [artifactId]);
const row = v.rows[0];
const options = row.options as string[];
const counts = await db.query(
"SELECT choice, COUNT(*)::int AS n FROM vote_choices WHERE artifact_id = $1 GROUP BY choice",
[artifactId],
);
const total = counts.rows.reduce((a, r) => a + r.n, 0);
const map: Record<string, number> = {};
for (const r of counts.rows) map[r.choice] = r.n;
return {
ui: "vote_card",
question: row.question,
options: options.map((label, i) => {
const id = String.fromCharCode(97 + i);
return { id, label, votes: map[id] ?? 0, pct: total ? Math.round((map[id] ?? 0) / total * 100) : 0 };
}),
total, closed: row.closed,
};
}
export async function closeVote(agent: HasheeAgent, msg: any, artifactId: string) {
await db.query("UPDATE votes SET closed = TRUE WHERE artifact_id = $1", [artifactId]);
await updateVote(agent, artifactId);
await agent.send(msg.conversation_id, { type: "text", text: `✓ 投票 ${artifactId} 已关闭` });
}
export async function listVotes(agent: HasheeAgent, msg: any) {
const r = await db.query(`
SELECT artifact_id, question, created_at,
(SELECT COUNT(*) FROM vote_choices WHERE artifact_id = v.artifact_id) AS votes
FROM votes v
WHERE conv_id = $1 AND created_at > NOW() - INTERVAL '7 days'
ORDER BY created_at DESC LIMIT 20
`, [msg.conversation_id]);
if (r.rows.length === 0) {
await agent.send(msg.conversation_id, { type: "text", text: "上周无投票。" });
return;
}
await agent.sendArtifact(msg.conversation_id, {
artifact: {
subtype: "info", title: "本群上周投票",
payload: { body: r.rows.map((row) => `**${row.question}** (${row.votes} 票) — \`${row.artifact_id}\``).join("\n") },
},
});
}

src/db.ts + sql/schema.sql

db.ts

import { Pool } from "pg";
export const db = new Pool({ connectionString: process.env.DATABASE_URL });

schema.sql

CREATE TABLE IF NOT EXISTS votes (
artifact_id TEXT PRIMARY KEY,
conv_id TEXT NOT NULL,
question TEXT NOT NULL,
options JSONB NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
ends_at TIMESTAMPTZ NOT NULL,
closed BOOLEAN DEFAULT FALSE,
revision INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS vote_choices (
artifact_id TEXT REFERENCES votes(artifact_id),
user_id TEXT NOT NULL,
choice TEXT NOT NULL,
ts TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(artifact_id, user_id)
);

Terminal window
docker compose up -d pg
npm install
npm run db:setup
npm run dev

群里 @VoteBot /vote 午餐 拉面 披萨 沙拉

相关