@trydying/opencode-feishu-notifier 0.3.1 → 0.3.2

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,202 @@
1
+ import type { FeishuConfig } from "../config"
2
+
3
+ type TenantTokenResponse = {
4
+ code: number
5
+ msg: string
6
+ tenant_access_token?: string
7
+ expire?: number
8
+ }
9
+
10
+ interface TokenCacheEntry {
11
+ token: string
12
+ expiresAt: number // timestamp in milliseconds
13
+ }
14
+
15
+ const tokenCache = new Map<string, TokenCacheEntry>()
16
+
17
+ function clearTokenCache(config: FeishuConfig) {
18
+ const cacheKey = `${config.appId}:${config.appSecret}`
19
+ tokenCache.delete(cacheKey)
20
+ }
21
+
22
+ function isTokenExpiredError(code: number): boolean {
23
+ // Common Feishu token expiration error codes
24
+ return code === 99991663 || code === 99991664 || code === 99991668
25
+ }
26
+
27
+ type MessageResponse = {
28
+ code: number
29
+ msg: string
30
+ data?: {
31
+ message_id?: string
32
+ }
33
+ }
34
+
35
+ type FeishuPostContent = {
36
+ post: {
37
+ zh_cn: {
38
+ title: string
39
+ content: Array<Array<{
40
+ tag: string
41
+ text?: string
42
+ href?: string
43
+ un_escape?: boolean
44
+ }>>
45
+ }
46
+ }
47
+ }
48
+
49
+ function ensureFetch(): typeof fetch {
50
+ if (typeof fetch === "undefined") {
51
+ throw new Error("Global fetch is not available. Use Node.js 18+.")
52
+ }
53
+
54
+ return fetch
55
+ }
56
+
57
+ async function readJson<T>(response: Response): Promise<T> {
58
+ const text = await response.text()
59
+ if (!text) {
60
+ throw new Error(`Empty response from Feishu API (${response.status}).`)
61
+ }
62
+
63
+ return JSON.parse(text) as T
64
+ }
65
+
66
+ export async function getTenantAccessToken(config: FeishuConfig): Promise<string> {
67
+ const cacheKey = `${config.appId}:${config.appSecret}`
68
+ const now = Date.now()
69
+
70
+ // Check cache
71
+ const cached = tokenCache.get(cacheKey)
72
+ if (cached && cached.expiresAt > now + 60000) { // 60 second buffer
73
+ return cached.token
74
+ }
75
+
76
+ const fetchImpl = ensureFetch()
77
+ const response = await fetchImpl(
78
+ "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
79
+ {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json"
83
+ },
84
+ body: JSON.stringify({
85
+ app_id: config.appId,
86
+ app_secret: config.appSecret
87
+ })
88
+ }
89
+ )
90
+
91
+ if (!response.ok) {
92
+ const errorText = await response.text().catch(() => `Failed to read error response`)
93
+ throw new Error(`Feishu auth request failed: ${response.status} - ${errorText}`)
94
+ }
95
+
96
+ const payload = await readJson<TenantTokenResponse>(response)
97
+ if (payload.code !== 0 || !payload.tenant_access_token) {
98
+ throw new Error(`Feishu auth failed: ${payload.msg} (${payload.code})`)
99
+ }
100
+
101
+ // Cache token with expiration (default 2 hours if not provided)
102
+ const expiresIn = payload.expire ? payload.expire * 1000 : 2 * 60 * 60 * 1000
103
+ const expiresAt = now + expiresIn - 60000 // 60 second buffer
104
+
105
+ tokenCache.set(cacheKey, {
106
+ token: payload.tenant_access_token,
107
+ expiresAt
108
+ })
109
+
110
+ return payload.tenant_access_token
111
+ }
112
+
113
+ async function sendMessage(
114
+ config: FeishuConfig,
115
+ msgType: "text" | "post",
116
+ content: unknown
117
+ ): Promise<MessageResponse> {
118
+ const fetchImpl = ensureFetch()
119
+
120
+ const sendWithToken = async (retryOnTokenExpired = true): Promise<MessageResponse> => {
121
+ const token = await getTenantAccessToken(config)
122
+ const response = await fetchImpl(
123
+ `https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=${config.receiverType}`,
124
+ {
125
+ method: "POST",
126
+ headers: {
127
+ Authorization: `Bearer ${token}`,
128
+ "Content-Type": "application/json"
129
+ },
130
+ body: JSON.stringify({
131
+ receive_id: config.receiverId,
132
+ msg_type: msgType,
133
+ content: JSON.stringify(content)
134
+ })
135
+ }
136
+ )
137
+
138
+ if (!response.ok) {
139
+ const errorText = await response.text().catch(() => `Failed to read error response`)
140
+ throw new Error(`Feishu message request failed: ${response.status} - ${errorText}`)
141
+ }
142
+
143
+ const payload = await readJson<MessageResponse>(response)
144
+ if (payload.code !== 0) {
145
+ // Check if token expired
146
+ if (retryOnTokenExpired && isTokenExpiredError(payload.code)) {
147
+ clearTokenCache(config)
148
+ return sendWithToken(false) // Retry once
149
+ }
150
+ throw new Error(`Feishu message failed: ${payload.msg} (${payload.code}) - Response: ${JSON.stringify(payload)}`)
151
+ }
152
+
153
+ return payload
154
+ }
155
+
156
+ return sendWithToken()
157
+ }
158
+
159
+ export async function sendTextMessage(
160
+ config: FeishuConfig,
161
+ text: string
162
+ ): Promise<MessageResponse> {
163
+ return sendMessage(config, "text", { text })
164
+ }
165
+
166
+ /**
167
+ * 将纯文本转换为飞书富文本(post)格式
168
+ * 简化实现:所有文本作为一个段落
169
+ */
170
+ function textToPostContent(text: string, title: string = "OpenCode 通知"): FeishuPostContent {
171
+ // 移除空行,但保留换行符
172
+ const cleanedText = text.split('\n').filter(line => line.trim().length > 0).join('\n')
173
+
174
+ const content = [
175
+ [
176
+ {
177
+ tag: 'text',
178
+ text: cleanedText,
179
+ un_escape: true
180
+ }
181
+ ]
182
+ ]
183
+
184
+ return {
185
+ post: {
186
+ zh_cn: {
187
+ title,
188
+ content
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ export async function sendRichTextMessage(
195
+ config: FeishuConfig,
196
+ text: string,
197
+ title?: string,
198
+ richContent?: FeishuPostContent
199
+ ): Promise<MessageResponse> {
200
+ const postContent = richContent || textToPostContent(text, title)
201
+ return sendMessage(config, "post", postContent)
202
+ }
@@ -0,0 +1,322 @@
1
+ import os from "os";
2
+
3
+ export type FeishuPostContent = {
4
+ post: {
5
+ zh_cn: {
6
+ title: string
7
+ content: Array<Array<{
8
+ tag: string
9
+ text?: string
10
+ href?: string
11
+ un_escape?: boolean
12
+ }>>
13
+ }
14
+ }
15
+ }
16
+
17
+ export type NotificationResult = {
18
+ title: string
19
+ text: string
20
+ richContent?: FeishuPostContent
21
+ }
22
+
23
+ export type NotificationType =
24
+ | "interaction_required"
25
+ | "permission_required"
26
+ | "command_args_required"
27
+ | "confirmation_required"
28
+ | "session_idle"
29
+ | "question_asked"
30
+ | "setup_test"
31
+
32
+ type EventPayload = {
33
+ type?: string;
34
+ payload?: unknown;
35
+ properties?: Record<string, unknown>;
36
+ }
37
+
38
+ type SessionContext = {
39
+ sessionID?: string;
40
+ sessionTitle?: string;
41
+ agentName?: string;
42
+ };
43
+
44
+ type SessionClient = {
45
+ session?: {
46
+ get?: (options: { path: { id: string } }) => Promise<{
47
+ data?: {
48
+ title?: string;
49
+ };
50
+ }>;
51
+ };
52
+ };
53
+
54
+ const sessionTitleCache = new Map<string, string>();
55
+ const sessionAgentCache = new Map<string, string>();
56
+
57
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
58
+ if (value && typeof value === "object") {
59
+ return value as Record<string, unknown>;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ function readString(value: unknown): string | undefined {
65
+ return typeof value === "string" ? value : undefined;
66
+ }
67
+
68
+ function extractEventPayload(event?: EventPayload): unknown {
69
+ if (!event) {
70
+ return undefined;
71
+ }
72
+ if (event.payload !== undefined) {
73
+ return event.payload;
74
+ }
75
+ if (event.properties !== undefined) {
76
+ return event.properties;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function extractEventProperties(event?: EventPayload): Record<string, unknown> | undefined {
82
+ if (!event) {
83
+ return undefined;
84
+ }
85
+ if (event.properties) {
86
+ return asRecord(event.properties);
87
+ }
88
+ if (event.payload) {
89
+ return asRecord(event.payload);
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function extractSessionContext(event?: EventPayload): SessionContext {
95
+ const properties = extractEventProperties(event);
96
+ const info = asRecord(properties?.info);
97
+ const part = asRecord(properties?.part);
98
+
99
+ const sessionID =
100
+ readString(properties?.sessionID) ??
101
+ readString(info?.sessionID) ??
102
+ readString(info?.id) ??
103
+ readString(part?.sessionID);
104
+
105
+ const sessionTitle = readString(info?.title);
106
+
107
+ const agentName =
108
+ readString(properties?.agent) ??
109
+ readString(info?.agent) ??
110
+ readString(part?.agent) ??
111
+ readString(part?.name);
112
+
113
+ return {
114
+ sessionID,
115
+ sessionTitle,
116
+ agentName,
117
+ };
118
+ }
119
+
120
+ async function resolveSessionContext(
121
+ event?: EventPayload,
122
+ client?: SessionClient
123
+ ): Promise<SessionContext> {
124
+ const baseContext = extractSessionContext(event);
125
+ if (!baseContext.sessionID) {
126
+ return baseContext;
127
+ }
128
+
129
+ const cachedTitle = sessionTitleCache.get(baseContext.sessionID);
130
+ const cachedAgent = sessionAgentCache.get(baseContext.sessionID);
131
+ const mergedContext = {
132
+ ...baseContext,
133
+ sessionTitle: baseContext.sessionTitle ?? cachedTitle,
134
+ agentName: baseContext.agentName ?? cachedAgent,
135
+ };
136
+
137
+ if (mergedContext.sessionTitle) {
138
+ return mergedContext;
139
+ }
140
+
141
+ if (client?.session?.get) {
142
+ try {
143
+ const response = await client.session.get({
144
+ path: { id: baseContext.sessionID },
145
+ });
146
+ const title = response?.data?.title;
147
+ if (title) {
148
+ sessionTitleCache.set(baseContext.sessionID, title);
149
+ return {
150
+ ...mergedContext,
151
+ sessionTitle: title,
152
+ };
153
+ }
154
+ } catch {
155
+ // 忽略会话信息获取失败
156
+ }
157
+ }
158
+
159
+ return mergedContext;
160
+ }
161
+
162
+ export function recordEventContext(event?: EventPayload): void {
163
+ const context = extractSessionContext(event);
164
+ if (!context.sessionID) {
165
+ return;
166
+ }
167
+
168
+ if (context.sessionTitle) {
169
+ sessionTitleCache.set(context.sessionID, context.sessionTitle);
170
+ }
171
+
172
+ if (context.agentName) {
173
+ sessionAgentCache.set(context.sessionID, context.agentName);
174
+ }
175
+ }
176
+
177
+ // 保持向后兼容的标题映射
178
+ const titles: Record<NotificationType, string> = {
179
+ interaction_required: "需要交互",
180
+ permission_required: "需要权限确认",
181
+ command_args_required: "需要补充参数",
182
+ confirmation_required: "需要确认",
183
+ session_idle: "OpenCode 闲暇",
184
+ question_asked: "需要选择方案",
185
+ setup_test: "Feishu 通知测试"
186
+ }
187
+
188
+ /**
189
+ * 构建结构化通知消息(新版本)
190
+ * @param type 通知类型
191
+ * @param event 事件数据
192
+ * @param directory 工作目录(可选,默认当前目录)
193
+ * @returns 包含标题和文本的消息对象
194
+ */
195
+ export async function buildStructuredNotification(
196
+ type: NotificationType,
197
+ event?: EventPayload,
198
+ directory?: string,
199
+ client?: SessionClient
200
+ ): Promise<NotificationResult> {
201
+ // 导入模板系统
202
+ const { buildStructuredMessage } = await import("./templates")
203
+
204
+ const eventPayload = extractEventPayload(event);
205
+ const sessionContext = await resolveSessionContext(event, client);
206
+
207
+ try {
208
+ const text = await buildStructuredMessage(
209
+ type,
210
+ eventPayload,
211
+ event?.type,
212
+ directory,
213
+ sessionContext
214
+ )
215
+
216
+ // 返回标题(保持向后兼容)
217
+ return {
218
+ title: titles[type],
219
+ text,
220
+ richContent: textToPostContent(text, titles[type])
221
+ }
222
+ } catch (error) {
223
+ // 如果模板系统失败,回退到原始实现
224
+ return buildLegacyNotification(type, event)
225
+ }
226
+ }
227
+
228
+ /**
229
+ * 构建传统格式的通知消息(向后兼容)
230
+ */
231
+ export function buildLegacyNotification(
232
+ type: NotificationType,
233
+ event?: EventPayload
234
+ ): NotificationResult {
235
+ const title = titles[type]
236
+ if (type === "setup_test") {
237
+ const text = `${title}\nFeishu 通知已启用。`
238
+ return {
239
+ title,
240
+ text,
241
+ richContent: textToPostContent(text, title)
242
+ }
243
+ }
244
+
245
+ const payloadText = formatPayload(extractEventPayload(event))
246
+ const sessionContext = extractSessionContext(event)
247
+ const lines = [
248
+ `[OpenCode] ${title}`,
249
+ event?.type ? `事件类型: ${event.type}` : "",
250
+ sessionContext.sessionTitle || sessionContext.sessionID
251
+ ? `会话: ${sessionContext.sessionTitle ?? sessionContext.sessionID}`
252
+ : "",
253
+ sessionContext.agentName ? `Agent: ${sessionContext.agentName}` : "",
254
+ `主机: ${os.hostname()}`,
255
+ payloadText ? `详情: ${payloadText}` : ""
256
+ ].filter(Boolean)
257
+
258
+ const text = lines.join("\n")
259
+ return {
260
+ title,
261
+ text,
262
+ richContent: textToPostContent(text, title)
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 格式化负载文本
268
+ */
269
+ function formatPayload(payload: unknown): string {
270
+ if (!payload) {
271
+ return ""
272
+ }
273
+
274
+ const text = JSON.stringify(payload, null, 2)
275
+ if (text.length > 1200) {
276
+ return `${text.slice(0, 1200)}…`
277
+ }
278
+
279
+ return text
280
+ }
281
+
282
+ /**
283
+ * 将纯文本转换为飞书富文本(post)格式
284
+ * 简化实现:所有文本作为一个段落
285
+ */
286
+ function textToPostContent(text: string, title: string = "OpenCode 通知"): FeishuPostContent {
287
+ // 移除空行,但保留换行符
288
+ const cleanedText = text.split('\n').filter(line => line.trim().length > 0).join('\n')
289
+
290
+ const content = [
291
+ [
292
+ {
293
+ tag: 'text',
294
+ text: cleanedText,
295
+ un_escape: true
296
+ }
297
+ ]
298
+ ]
299
+
300
+ return {
301
+ post: {
302
+ zh_cn: {
303
+ title,
304
+ content
305
+ }
306
+ }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 构建通知消息(主入口,保持向后兼容)
312
+ * 默认使用结构化消息,失败时回退
313
+ */
314
+ export async function buildNotification(
315
+ type: NotificationType,
316
+ event?: EventPayload,
317
+ directory?: string,
318
+ client?: SessionClient
319
+ ): Promise<NotificationResult> {
320
+ // 默认使用结构化消息
321
+ return buildStructuredNotification(type, event, directory, client)
322
+ }