做一个 daily standup bot
让 Agent 替代每天 10am 全员开会——按时间发表单,每人 2 分钟填,自动汇总 给 manager。比手工开会快、内容可追溯。
用户故事
“10 人团队跨时区,开会效率低。希望每天 10am 自己时区发个表单:(1) 昨日 做了什么 (2) 今日计划 (3) 阻塞是什么。每人填完,下午 2pm manager 收到 汇总。“
架构
Cron 每天 10am (按 user 时区) │ ▼ Agent 给每个 team member 发 form artifact │ ▼ 用户填表 → artifact_response │ ▼ 业务侧存 daily_standups 表 │Cron 每天 14:00 (manager 时区) │ ▼ Agent 汇总当日 standup → 发给 manager 私聊 + 群里 summary 卡完整代码
import { HasheeAgent } from "@hasheeai/agent-sdk-ts";import { Pool } from "pg";import { ulid } from "ulid";import { schedule } from "node-cron";
const db = new Pool({ connectionString: process.env.DATABASE_URL });
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 MANAGER_ID = process.env.MANAGER_USER_ID!;const TEAM_GROUP_CONV_ID = process.env.TEAM_GROUP_CONV_ID!;
// === 1. 每天 10am 发表单 ===
schedule("0 10 * * 1-5", async () => { // 工作日 10am const members = await db.query( "SELECT user_id, dm_conv_id, timezone FROM team_members WHERE active = TRUE", );
for (const m of members.rows) { // 按各人时区?V1 简化:统一服务器 10am;V2 用 cron-aware lib await sendStandupForm(m.user_id, m.dm_conv_id); }});
async function sendStandupForm(userId: string, dmConvId: string) { const artifactId = ulid(); await db.query( `INSERT INTO standups (artifact_id, user_id, date) VALUES ($1, $2, CURRENT_DATE) ON CONFLICT DO NOTHING`, [artifactId, userId], );
await agent.sendArtifact(dmConvId, { artifact: { artifact_id: artifactId, subtype: "form", title: "Daily Standup", payload: { fields: [ { id: "yesterday", type: "textarea", label: "昨天完成了什么?", required: true }, { id: "today", type: "textarea", label: "今天计划做什么?", required: true }, { id: "blockers", type: "textarea", label: "有什么阻塞?(无可空)" }, ], submit_label: "提交", }, }, });}
// === 2. 接收表单提交 ===
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return; if (event.payload.action !== "submit") return; const artifactId = event.payload.ref_artifact; const { yesterday, today, blockers } = event.payload.payload; if (!yesterday || !today) return;
await db.query( `UPDATE standups SET yesterday = $1, today = $2, blockers = $3, submitted_at = NOW() WHERE artifact_id = $4`, [yesterday, today, blockers ?? "", artifactId], );
await agent.send(event.payload.conversation_id, { type: "text", text: "✓ 已记录。今天加油 💪", });});
// === 3. 每天 14:00 汇总给 manager + 群 ===
schedule("0 14 * * 1-5", async () => { const r = await db.query( `SELECT s.*, t.user_display_name FROM standups s JOIN team_members t ON t.user_id = s.user_id WHERE date = CURRENT_DATE ORDER BY t.user_display_name`, );
const submitted = r.rows.filter((row) => row.submitted_at); const missing = r.rows.filter((row) => !row.submitted_at);
const body = [ `📊 **Daily Standup (${new Date().toISOString().slice(0, 10)})**`, `提交: ${submitted.length} / ${r.rows.length}`, "", ...submitted.map((row) => [ `### @<U:${row.user_id}>`, `**昨日**: ${row.yesterday}`, `**今日**: ${row.today}`, row.blockers ? `**阻塞**: ${row.blockers}` : "", ].filter(Boolean).join("\n")), "", missing.length > 0 ? `⚠️ 未提交:${missing.map((m) => `@<U:${m.user_id}>`).join(", ")}` : "", ].filter(Boolean).join("\n\n");
const mentions = [...submitted.map((s) => s.user_id), ...missing.map((m) => m.user_id)];
// 发给 manager 私聊 const managerDm = await getManagerDmConv(); await agent.sendArtifact(managerDm, { artifact: { subtype: "info", title: "Daily Standup", payload: { body } }, });
// 发到团队群(简化版,不含完整字段) await agent.send(TEAM_GROUP_CONV_ID, { type: "text", text: `📊 今日 standup: ${submitted.length}/${r.rows.length} 已提交`, });
// 阻塞 → 单独高亮 const blocked = submitted.filter((s) => s.blockers && s.blockers.trim()); if (blocked.length > 0) { await agent.sendArtifact(TEAM_GROUP_CONV_ID, { artifact: { subtype: "info", title: "🚧 今日阻塞需关注", payload: { body: blocked.map((b) => `**@<U:${b.user_id}>**: ${b.blockers}`, ).join("\n\n"), }, }, }); }});
async function getManagerDmConv(): Promise<string> { const r = await db.query( "SELECT dm_conv_id FROM team_members WHERE user_id = $1", [MANAGER_ID], ); return r.rows[0]?.dm_conv_id;}
// === 4. 用户主动 @Bot 看自己的历史 ===
agent.addMessageHandler(async (msg) => { if (msg.payload?.type !== "text") return; if (msg.payload.text.trim() !== "/my-history") return;
const r = await db.query( `SELECT date, yesterday, today, blockers FROM standups WHERE user_id = $1 AND submitted_at IS NOT NULL ORDER BY date DESC LIMIT 14`, [msg.sender_id], );
await agent.sendArtifact(msg.conversation_id, { artifact: { subtype: "info", title: "我最近 14 天 standup", payload: { body: r.rows.map((row) => `**${row.date.toISOString().slice(0, 10)}**\n- 昨日: ${row.yesterday}- 今日: ${row.today}${row.blockers ? "- 阻塞: " + row.blockers : ""}`, ).join("\n\n"), }, }, });});
// === 5. 持久化 H2A 关系映射 ===
agent.addEventHandler(async (event) => { if (event.type !== "relation.established") return; const userId = event.payload.user_id; // 业务侧:把 user_id → dm_conv_id 持久化 await db.query( `INSERT INTO team_members (user_id, user_display_name, dm_conv_id, active, timezone) VALUES ($1, $2, $3, TRUE, 'UTC') ON CONFLICT (user_id) DO UPDATE SET dm_conv_id = EXCLUDED.dm_conv_id, active = TRUE`, [userId, event.payload.display_name, event.payload.conversation_id ?? userId], );});
agent.addStatusHandler((s) => console.log(`[status] ${s}`));console.log("[standup-bot] up");DB schema
CREATE TABLE team_members ( user_id TEXT PRIMARY KEY, user_display_name TEXT NOT NULL, dm_conv_id TEXT NOT NULL, active BOOLEAN DEFAULT TRUE, timezone TEXT DEFAULT 'UTC', created_at TIMESTAMPTZ DEFAULT NOW());
CREATE TABLE standups ( artifact_id TEXT PRIMARY KEY, user_id TEXT REFERENCES team_members(user_id), date DATE NOT NULL, yesterday TEXT, today TEXT, blockers TEXT, submitted_at TIMESTAMPTZ, UNIQUE(user_id, date));CREATE INDEX ON standups(date);部署
# 长驻进程(不能用 Cloudflare Workers,因为需要 node-cron)HASHEE_AGENT_ID=... \HASHEE_AGENT_TOKEN=... \HASHEE_X25519_PRIVATE_BASE64=... \HASHEE_ED25519_PRIVATE_BASE64=... \DATABASE_URL=postgres://... \MANAGER_USER_ID=01906abc-... \TEAM_GROUP_CONV_ID=01HZ... \node dist/index.js推荐 systemd / Docker / k8s — 见 自托管 VPS。
升级路径
| 想做 | 改哪 |
|---|---|
| 按 user 各自时区发送 | 用 luxon + per-user cron schedule |
| 集成 GitHub PR / Jira ticket | summarize 时拉外部 API + 添加到表单 default |
| 周五自动生成 weekly review | 加 0 17 * * 5 cron 出周报 |
| 提交后自动归类(“在做 X 项目”) | LLM extract topic + tag DB |
| 跨团队对比 | 加 team_id 列 + group by team 汇总 |
相关页面
- Artifact 入门 — Form — 表单字段类型
- 发送回复 — 主动推送 — 主动 send 不依赖 user 触发
- 部署 — 自托管 VPS — 长驻 + cron
- Production Checklist