@vui-design/openclaw-plugin-feishu-progress 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "id": "feishu-progress",
3
+ "configSchema": {
4
+ "type": "object",
5
+ "additionalProperties": true,
6
+ "properties": {}
7
+ }
8
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@vui-design/openclaw-plugin-feishu-progress",
3
+ "version": "0.1.5",
4
+ "description": "飞书任务进度卡片插件 — 在 AI 执行复杂多步骤任务时,实时更新飞书进度卡片",
5
+ "keywords": [
6
+ "openclaw-plugin",
7
+ "feishu",
8
+ "lark",
9
+ "progress"
10
+ ],
11
+ "license": "MIT",
12
+ "author": "vui-design",
13
+ "type": "module",
14
+ "main": "src/index.ts",
15
+ "files": [
16
+ "src/",
17
+ "openclaw.plugin.json"
18
+ ],
19
+ "openclaw": {
20
+ "extensions": [
21
+ "./src/index.ts"
22
+ ],
23
+ "installDependencies": true,
24
+ "install": {
25
+ "npmSpec": "@vui-design/openclaw-plugin-feishu-progress",
26
+ "localPath": ".",
27
+ "defaultChoice": "npm"
28
+ }
29
+ },
30
+ "peerDependencies": {
31
+ "openclaw": ">=2026.2.13"
32
+ }
33
+ }
@@ -0,0 +1,58 @@
1
+ import type { StepEntry } from './session-store.js';
2
+
3
+ export interface CardInput {
4
+ stepIndex: number;
5
+ totalSteps: number;
6
+ done?: boolean;
7
+ }
8
+
9
+ export interface CardSession {
10
+ steps: StepEntry[];
11
+ startTime: number;
12
+ }
13
+
14
+ function formatElapsed(ms: number): string {
15
+ const sec = Math.floor(ms / 1000);
16
+ if (sec < 60) return `${sec}s`;
17
+ return `${Math.floor(sec / 60)}m ${sec % 60}s`;
18
+ }
19
+
20
+ export function buildProgressCard(input: CardInput, session: CardSession): object {
21
+ const elapsed = formatElapsed(Date.now() - session.startTime);
22
+ const isDone = input.done === true;
23
+
24
+ const headerText = isDone
25
+ ? `任务完成 · 用时 ${elapsed}`
26
+ : `正在执行任务 · 已用时 ${elapsed}`;
27
+
28
+ const stepElements = session.steps.map((s) => {
29
+ const icon =
30
+ s.status === 'done' ? '✅' : s.status === 'active' ? '🔄' : '⬜';
31
+ return {
32
+ tag: 'div',
33
+ text: { tag: 'lark_md', content: `${icon} ${s.label}` },
34
+ };
35
+ });
36
+
37
+ return {
38
+ config: { wide_screen_mode: true },
39
+ header: {
40
+ template: isDone ? 'green' : 'blue',
41
+ title: {
42
+ tag: 'plain_text',
43
+ content: '🤖 OpenClaw 任务进度',
44
+ },
45
+ },
46
+ elements: [
47
+ {
48
+ tag: 'div',
49
+ text: {
50
+ tag: 'lark_md',
51
+ content: `**${isDone ? '✅' : '⏳'} ${headerText}**`,
52
+ },
53
+ },
54
+ { tag: 'hr' },
55
+ ...stepElements,
56
+ ],
57
+ };
58
+ }
@@ -0,0 +1,82 @@
1
+ export interface FeishuConfig {
2
+ appId: string;
3
+ appSecret: string;
4
+ domain?: 'feishu' | 'lark';
5
+ }
6
+
7
+ interface TokenCache {
8
+ token: string;
9
+ expiresAt: number;
10
+ }
11
+
12
+ export class FeishuClient {
13
+ private tokenCache: TokenCache | null = null;
14
+
15
+ constructor(private config: FeishuConfig) {}
16
+
17
+ private get baseUrl(): string {
18
+ return this.config.domain === 'lark'
19
+ ? 'https://open.larksuite.com'
20
+ : 'https://open.feishu.cn';
21
+ }
22
+
23
+ async getToken(): Promise<string> {
24
+ if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
25
+ return this.tokenCache.token;
26
+ }
27
+ const res = await fetch(
28
+ `${this.baseUrl}/open-apis/auth/v3/tenant_access_token/internal`,
29
+ {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({
33
+ app_id: this.config.appId,
34
+ app_secret: this.config.appSecret,
35
+ }),
36
+ }
37
+ );
38
+ const data = (await res.json()) as { tenant_access_token: string; expire: number };
39
+ // 提前 5 分钟过期,避免边界问题
40
+ this.tokenCache = {
41
+ token: data.tenant_access_token,
42
+ expiresAt: Date.now() + (data.expire - 300) * 1000,
43
+ };
44
+ return this.tokenCache.token;
45
+ }
46
+
47
+ async sendCard(chatId: string, card: object): Promise<string> {
48
+ const token = await this.getToken();
49
+ const res = await fetch(
50
+ `${this.baseUrl}/open-apis/im/v1/messages?receive_id_type=chat_id`,
51
+ {
52
+ method: 'POST',
53
+ headers: {
54
+ Authorization: `Bearer ${token}`,
55
+ 'Content-Type': 'application/json',
56
+ },
57
+ body: JSON.stringify({
58
+ receive_id: chatId,
59
+ msg_type: 'interactive',
60
+ content: JSON.stringify(card),
61
+ }),
62
+ }
63
+ );
64
+ const data = (await res.json()) as { data: { message_id: string } };
65
+ return data.data.message_id;
66
+ }
67
+
68
+ async updateCard(messageId: string, card: object): Promise<void> {
69
+ const token = await this.getToken();
70
+ await fetch(`${this.baseUrl}/open-apis/im/v1/messages/${messageId}`, {
71
+ method: 'PATCH',
72
+ headers: {
73
+ Authorization: `Bearer ${token}`,
74
+ 'Content-Type': 'application/json',
75
+ },
76
+ body: JSON.stringify({
77
+ msg_type: 'interactive',
78
+ content: JSON.stringify(card),
79
+ }),
80
+ });
81
+ }
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { FeishuClient } from './feishu-client.js';
2
+ import { buildProgressCard } from './card-builder.js';
3
+ import { sessionStore } from './session-store.js';
4
+ import type { StepEntry } from './session-store.js';
5
+
6
+ // ── 注入到系统提示的 Skill 说明 ──────────────────────────────────────────────
7
+ const SKILL_PROMPT = `
8
+ ## 任务进度报告规范(report_progress 工具)
9
+
10
+ 当你需要执行包含 **3 个或以上独立工具调用** 的复杂任务时,必须遵守以下规范:
11
+
12
+ 1. **任务最开始**:调用 \`report_progress\`,提供完整的 \`allSteps\` 步骤列表、\`stepIndex: 1\`、当前步骤描述
13
+ 2. **每个主要步骤开始前**:调用 \`report_progress\` 更新 \`stepIndex\` 和 \`step\`
14
+ 3. **所有步骤完成后**:调用 \`report_progress\` 并设置 \`done: true\`
15
+
16
+ 规则:
17
+ - \`allSteps\` 只在第一次调用时传入,后续调用省略
18
+ - \`step\` 应简洁,10 字以内
19
+ - 不要等待 \`report_progress\` 的返回结果影响主任务,它只是通知机制
20
+ `.trim();
21
+
22
+ // ── 插件定义 ─────────────────────────────────────────────────────────────────
23
+ export default {
24
+ id: 'feishu-progress',
25
+
26
+ register(api: any) {
27
+ const cfg = api.config ?? {};
28
+ const client = new FeishuClient({
29
+ appId: cfg.appId,
30
+ appSecret: cfg.appSecret,
31
+ domain: cfg.domain ?? 'feishu',
32
+ });
33
+
34
+ // 将 Skill 说明注入系统提示(before_prompt_build 是稳定的生命周期钩子)
35
+ api.on('before_prompt_build', async (event: any) => {
36
+ event.appendSystemSection?.('feishu-progress-skill', SKILL_PROMPT);
37
+ });
38
+
39
+ // 注册 report_progress 工具
40
+ // 凭据未配置时跳过工具注册,避免运行时 crash
41
+ if (!cfg.appId || !cfg.appSecret) {
42
+ api.logger?.warn(
43
+ '[feishu-progress] appId / appSecret 未配置,进度卡片功能已禁用。\n' +
44
+ '请执行:\n' +
45
+ ' openclaw config set plugins.entries.openclaw-plugin-feishu-progress.config.appId "cli_xxx"\n' +
46
+ ' openclaw config set plugins.entries.openclaw-plugin-feishu-progress.config.appSecret "xxx"\n' +
47
+ '然后重启 OpenClaw。'
48
+ );
49
+ return;
50
+ }
51
+
52
+ api.registerTool((ctx: any) => ({
53
+ name: 'report_progress',
54
+ description:
55
+ '向飞书发送或更新任务进度卡片。在每个关键步骤开始前调用,让用户实时了解任务进展。',
56
+ inputSchema: {
57
+ type: 'object',
58
+ properties: {
59
+ step: {
60
+ type: 'string',
61
+ description: '当前正在执行的步骤描述(简洁,10 字以内)',
62
+ },
63
+ stepIndex: {
64
+ type: 'number',
65
+ description: '当前第几步(从 1 开始)',
66
+ },
67
+ totalSteps: {
68
+ type: 'number',
69
+ description: '本次任务的总步骤数',
70
+ },
71
+ allSteps: {
72
+ type: 'array',
73
+ items: { type: 'string' },
74
+ description: '所有步骤名称列表,仅首次调用时传入',
75
+ },
76
+ done: {
77
+ type: 'boolean',
78
+ description: '是否所有步骤已全部完成,完成时设为 true',
79
+ },
80
+ },
81
+ required: ['step', 'stepIndex', 'totalSteps'],
82
+ },
83
+
84
+ execute: async (input: {
85
+ step: string;
86
+ stepIndex: number;
87
+ totalSteps: number;
88
+ allSteps?: string[];
89
+ done?: boolean;
90
+ }) => {
91
+ // 优先取飞书 chat_id(兼容不同版本的 ctx 字段名)
92
+ const chatId: string | undefined =
93
+ ctx.channelMeta?.chatId ??
94
+ ctx.channelMeta?.chat_id ??
95
+ ctx.channelContext?.chatId ??
96
+ ctx.chatId;
97
+
98
+ if (!chatId) {
99
+ return { ok: false, reason: '无法获取飞书 chat_id,跳过进度更新' };
100
+ }
101
+
102
+ // minSteps 未达到时静默跳过
103
+ const minSteps: number = cfg.minSteps ?? 3;
104
+ if (input.totalSteps < minSteps) {
105
+ return { ok: true, skipped: true };
106
+ }
107
+
108
+ const sessionKey = `${chatId}:${ctx.sessionKey ?? ctx.runId ?? 'default'}`;
109
+ const existing = sessionStore.get(sessionKey);
110
+
111
+ // 构造步骤状态列表
112
+ let steps: StepEntry[];
113
+ if (!existing) {
114
+ // 首次调用:用 allSteps 或自动生成占位标签
115
+ const labels: string[] =
116
+ input.allSteps ??
117
+ Array.from({ length: input.totalSteps }, (_, i) =>
118
+ i + 1 === input.stepIndex ? input.step : `步骤 ${i + 1}`
119
+ );
120
+ steps = labels.map((label, i) => ({
121
+ label,
122
+ status:
123
+ i + 1 < input.stepIndex
124
+ ? 'done'
125
+ : i + 1 === input.stepIndex
126
+ ? 'active'
127
+ : 'pending',
128
+ }));
129
+ } else {
130
+ // 后续调用:更新已有步骤状态
131
+ steps = existing.steps.map((s, i) => ({
132
+ ...s,
133
+ status:
134
+ i + 1 < input.stepIndex
135
+ ? 'done'
136
+ : i + 1 === input.stepIndex && !input.done
137
+ ? 'active'
138
+ : 'pending',
139
+ }));
140
+ }
141
+
142
+ // done=true 时所有步骤标为 done
143
+ if (input.done) {
144
+ steps = steps.map((s) => ({ ...s, status: 'done' as const }));
145
+ }
146
+
147
+ const startTime = existing?.startTime ?? Date.now();
148
+ const card = buildProgressCard(
149
+ { stepIndex: input.stepIndex, totalSteps: input.totalSteps, done: input.done },
150
+ { steps, startTime }
151
+ );
152
+
153
+ try {
154
+ if (!existing) {
155
+ const msgId = await client.sendCard(chatId, card);
156
+ sessionStore.set(sessionKey, { msgId, chatId, startTime, steps });
157
+ } else {
158
+ await client.updateCard(existing.msgId, card);
159
+ sessionStore.set(sessionKey, { ...existing, steps });
160
+ }
161
+
162
+ // 任务完成后 30s 清理 session,避免内存泄漏
163
+ if (input.done) {
164
+ setTimeout(() => sessionStore.delete(sessionKey), 30_000);
165
+ }
166
+
167
+ return { ok: true };
168
+ } catch (err: any) {
169
+ return { ok: false, reason: err?.message ?? String(err) };
170
+ }
171
+ },
172
+ }));
173
+ },
174
+ };
@@ -0,0 +1,29 @@
1
+ export interface StepEntry {
2
+ label: string;
3
+ status: 'done' | 'active' | 'pending';
4
+ }
5
+
6
+ export interface SessionEntry {
7
+ msgId: string;
8
+ chatId: string;
9
+ startTime: number;
10
+ steps: StepEntry[];
11
+ }
12
+
13
+ class SessionStore {
14
+ private store = new Map<string, SessionEntry>();
15
+
16
+ get(key: string): SessionEntry | undefined {
17
+ return this.store.get(key);
18
+ }
19
+
20
+ set(key: string, entry: SessionEntry): void {
21
+ this.store.set(key, entry);
22
+ }
23
+
24
+ delete(key: string): void {
25
+ this.store.delete(key);
26
+ }
27
+ }
28
+
29
+ export const sessionStore = new SessionStore();