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.mdpackage.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));跑
docker compose up -d pgnpm installnpm run db:setupnpm run dev群里 @VoteBot /vote 午餐 拉面 披萨 沙拉。