跳转到内容

做一个 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);

部署

Terminal window
# 长驻进程(不能用 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 ticketsummarize 时拉外部 API + 添加到表单 default
周五自动生成 weekly review0 17 * * 5 cron 出周报
提交后自动归类(“在做 X 项目”)LLM extract topic + tag DB
跨团队对比team_id 列 + group by team 汇总

相关页面