@ulthon/ul-opencode-event 0.1.27 โ 0.1.28
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.
- package/README.md +147 -431
- package/dist/cli.js +158 -2
- package/dist/config.js +1 -1
- package/dist/feishu.d.ts +50 -0
- package/dist/feishu.js +209 -0
- package/dist/formatters.d.ts +2 -1
- package/dist/formatters.js +7 -2
- package/dist/handler.js +13 -0
- package/dist/types.d.ts +3 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,431 +1,147 @@
|
|
|
1
|
-
# @ulthon/ul-opencode-event
|
|
2
|
-
|
|
3
|
-
**Never miss an OpenCode notification, even when you're away from your desk.**
|
|
4
|
-
|
|
5
|
-
OpenCode multi-channel notification plugin that keeps you informed wherever you are.
|
|
6
|
-
|
|
7
|
-
## Why ul-opencode-event?
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
## Roadmap
|
|
15
|
-
|
|
16
|
-
| Channel | Status | Description |
|
|
17
|
-
|---------|--------|-------------|
|
|
18
|
-
|
|
|
19
|
-
|
|
|
20
|
-
|
|
|
21
|
-
|
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
npm install @ulthon/ul-opencode-event
|
|
32
|
-
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
###
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
| `host` | string | Yes | SMTP server hostname or IPv4/IPv6 address |
|
|
149
|
-
| `port` | number | Yes | SMTP port (465 for SSL, 587 for STARTTLS) |
|
|
150
|
-
| `secure` | boolean | No | Use SSL (default: true for port 465) |
|
|
151
|
-
| `localAddress` | string | No | Local interface to bind for IPv4/IPv6 control |
|
|
152
|
-
| `auth.user` | string | Yes | SMTP username (usually your email) |
|
|
153
|
-
| `auth.pass` | string | Yes | SMTP password or authorization code |
|
|
154
|
-
|
|
155
|
-
### DingTalk ๆบๅจไบบ้
็ฝฎ
|
|
156
|
-
|
|
157
|
-
้่ฟ้้็พค่ชๅฎไนๆบๅจไบบๆฅๆถ OpenCode ้็ฅๆถๆฏ๏ผๆฏๆ Markdown ๆ ผๅผๅ @ไบบๅ่ฝใ
|
|
158
|
-
|
|
159
|
-
#### ่ทๅ Webhook ๅ Secret
|
|
160
|
-
|
|
161
|
-
1. ๆๅผ้้็พค๏ผ็นๅปๅณไธ่ง **็พค่ฎพ็ฝฎ** > **ๆบ่ฝ็พคๅฉๆ** > **ๆทปๅ ๆบๅจไบบ**
|
|
162
|
-
2. ้ๆฉ **่ชๅฎไน** ๆบๅจไบบ๏ผ่พๅ
ฅๆบๅจไบบๅ็งฐๅๅคดๅ
|
|
163
|
-
3. **ๅฎๅ
จ่ฎพ็ฝฎ** ้ๆฉ **ๅ ็ญพ** ๆจกๅผ๏ผๆจ่๏ผ๏ผๅคๅถ็ๆ็ **Secret**
|
|
164
|
-
4. ๅฎๆๅๅคๅถ **Webhook ๅฐๅ**๏ผๆ ผๅผ: `https://oapi.dingtalk.com/robot/send?access_token=xxx`๏ผ
|
|
165
|
-
|
|
166
|
-
> **ๅปบ่ฎฎ**: ๅง็ปๅผๅฏๅ ็ญพๆจกๅผ๏ผ้ฟๅ
Webhook ่ขซๆถๆ่ฐ็จใ
|
|
167
|
-
|
|
168
|
-
#### ๆๅฐ้
็ฝฎ
|
|
169
|
-
|
|
170
|
-
```json
|
|
171
|
-
{
|
|
172
|
-
"channels": [
|
|
173
|
-
{
|
|
174
|
-
"type": "dingtalk",
|
|
175
|
-
"enabled": true,
|
|
176
|
-
"name": "้้้็ฅ",
|
|
177
|
-
"config": {
|
|
178
|
-
"webhook": "https://oapi.dingtalk.com/robot/send?access_token=YOUR_ACCESS_TOKEN"
|
|
179
|
-
},
|
|
180
|
-
"events": {
|
|
181
|
-
"idle": true,
|
|
182
|
-
"error": true
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
]
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
#### ๅฎๆด้
็ฝฎ็คบไพ
|
|
190
|
-
|
|
191
|
-
```json
|
|
192
|
-
{
|
|
193
|
-
"channels": [
|
|
194
|
-
{
|
|
195
|
-
"type": "dingtalk",
|
|
196
|
-
"enabled": true,
|
|
197
|
-
"name": "้้้็ฅ",
|
|
198
|
-
"config": {
|
|
199
|
-
"webhook": "https://oapi.dingtalk.com/robot/send?access_token=YOUR_ACCESS_TOKEN",
|
|
200
|
-
"secret": "SEC-your-secret-key-here",
|
|
201
|
-
"at": {
|
|
202
|
-
"atMobiles": ["13800138000"],
|
|
203
|
-
"atAll": false
|
|
204
|
-
}
|
|
205
|
-
},
|
|
206
|
-
"events": {
|
|
207
|
-
"created": false,
|
|
208
|
-
"idle": true,
|
|
209
|
-
"error": true
|
|
210
|
-
},
|
|
211
|
-
"templates": {
|
|
212
|
-
"idle": {
|
|
213
|
-
"body": "### [{{projectName}}] ไปปๅกๅฎๆ\n\n**่ๆถ**: {{duration}}\n**ๆถๆฏ**: {{message}}\n\n> {{timestamp}}"
|
|
214
|
-
},
|
|
215
|
-
"error": {
|
|
216
|
-
"body": "### [{{projectName}}] ไปปๅก้่ฏฏ\n\n**้่ฏฏ**: {{error}}\n\n> {{timestamp}}"
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
]
|
|
221
|
-
}
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
#### DingTalk ้
็ฝฎๅญๆฎต่ฏดๆ
|
|
225
|
-
|
|
226
|
-
| Field | Type | Required | Default | Description |
|
|
227
|
-
|-------|------|----------|---------|-------------|
|
|
228
|
-
| `webhook` | string | Yes | - | ้้ๆบๅจไบบ Webhook ๅฐๅ |
|
|
229
|
-
| `secret` | string | No | - | ๅ ็ญพๅฏ้ฅ๏ผๅฎๅ
จ่ฎพ็ฝฎ้"ๅ ็ญพ"ๆถๆไพ๏ผ |
|
|
230
|
-
| `at.atMobiles` | string[] | No | - | ่ฆ @็ๆๅๆๆบๅทๅ่กจ |
|
|
231
|
-
| `at.atAll` | boolean | No | false | ๆฏๅฆ @ๆๆไบบ |
|
|
232
|
-
|
|
233
|
-
> **ๆณจๆ**: ้้้้ไธ้่ฆ `recipients` ๅญๆฎต๏ผๆถๆฏๅ้ๅฐๆบๅจไบบๆๅจ็้้็พคใ
|
|
234
|
-
|
|
235
|
-
#### @ไบบๅ่ฝ
|
|
236
|
-
|
|
237
|
-
้้ๆฏๆๅจๆถๆฏไธญ @ๆๅฎ็พคๆๅ๏ผ
|
|
238
|
-
|
|
239
|
-
- **@ๆๅฎๆๅ**: ๅจ `at.atMobiles` ไธญๅกซๅๆๆบๅทๆฐ็ป๏ผๅฆ `["13800138000", "13900139000"]`
|
|
240
|
-
- **@ๆๆไบบ**: ่ฎพ็ฝฎ `at.atAll` ไธบ `true`
|
|
241
|
-
- **ไธ@ไปปไฝไบบ**: ไธ้
็ฝฎ `at` ๅญๆฎต๏ผๆ่ฎพไธบ็ฉบๅฏน่ฑก `{}`
|
|
242
|
-
|
|
243
|
-
้ป่ฎค่กไธบๆฏไธ @ไปปไฝไบบใๅฆๆๅๆถ่ฎพ็ฝฎไบ `atMobiles` ๅ `atAll=true`๏ผๅฐไปฅ `atAll` ไธบๅใ
|
|
244
|
-
|
|
245
|
-
#### ้้ๅธธ่ง้ฎ้ข
|
|
246
|
-
|
|
247
|
-
**Q: ็ญพๅๅคฑ่ดฅ (signature mismatch) ๆไนๅ๏ผ**
|
|
248
|
-
|
|
249
|
-
A: ๆฃๆฅไปฅไธๅ ็น๏ผ
|
|
250
|
-
1. ็กฎ่ฎค `secret` ๅผไธ้้ๅๅฐๆพ็คบ็ๅฎๅ
จไธ่ด๏ผๆณจๆๅๅ็ฉบๆ ผ๏ผ
|
|
251
|
-
2. ็กฎ่ฎคๆๅกๅจๆถ้ดไธๆ ๅๆถ้ดๅๅทฎไธ่ถ
่ฟ 1 ๅฐๆถ
|
|
252
|
-
3. ๅฆๆไฝฟ็จไปฃ็/TUN ็ฏๅข๏ผ็กฎ่ฎค็ฝ็ป่ฝๆญฃๅธธ่ฎฟ้ฎ `oapi.dingtalk.com`
|
|
253
|
-
|
|
254
|
-
**Q: ๆถๆฏๅ้้ข็ๆ้ๅถๅ๏ผ**
|
|
255
|
-
|
|
256
|
-
A: ๆใๆฏไธชๆบๅจไบบๆฏๆกๆถๆฏไธ่ถ
่ฟ 20 ๆก/ๅ้ใๅฆๆ็ญๆถ้ดๅ
ไบง็ๅคง้ไบไปถ้็ฅ๏ผๅฆๆน้ๆไปถ็ผ่พ๏ผ๏ผ้จๅๆถๆฏๅฏ่ฝ่ขซ้ๆตไธขๅผใ
|
|
257
|
-
|
|
258
|
-
**Q: ้้ Markdown ๆฏๆๅชไบๆ ผๅผ๏ผ**
|
|
259
|
-
|
|
260
|
-
A: ้้ Markdown ๆฏๆๆ้็่ฏญๆณๅญ้๏ผ
|
|
261
|
-
- ๆ ้ข: `#` `##` `###`
|
|
262
|
-
- ๅ ็ฒ: `**text**`
|
|
263
|
-
- ๅผ็จ: `>`
|
|
264
|
-
- ๆๅบ/ๆ ๅบๅ่กจ
|
|
265
|
-
- ้พๆฅ: `[text](url)`
|
|
266
|
-
- ๅพ็: ``
|
|
267
|
-
|
|
268
|
-
ไธๆฏๆ่กจๆ ผใไปฃ็ ๅ้ซไบฎใHTML ๆ ็ญพ็ญใ็ผๅ่ชๅฎไนๆจกๆฟๆถ่ฏทไฝฟ็จๆฏๆ็่ฏญๆณใ
|
|
269
|
-
|
|
270
|
-
**Q: ๆถๆฏไธขๅคฑๆๆถไธๅฐๆไนๅ๏ผ**
|
|
271
|
-
|
|
272
|
-
A: ๆๆฅๆญฅ้ชค๏ผ
|
|
273
|
-
1. ่ฟ่ก `npx @ulthon/ul-opencode-event test --channel "้้้็ฅ"` ๆต่ฏ่ฟๆฅ
|
|
274
|
-
2. ่ฎพ็ฝฎ็ฏๅขๅ้ `UL_OPENCODE_EVENT_DEBUG=1` ๆฅ็่ฏฆ็ปๆฅๅฟ
|
|
275
|
-
3. ๆฃๆฅ้้็พคไธญๆบๅจไบบๆฏๅฆๅทฒ่ขซ็งป้คๆ็ฆ็จ
|
|
276
|
-
4. ็กฎ่ฎค Webhook ๅฐๅไธญ็ `access_token` ๆช่ฟๆ๏ผ้ๅธธไธไผ่ฟๆ๏ผ
|
|
277
|
-
|
|
278
|
-
### IPv4/IPv6 Configuration
|
|
279
|
-
|
|
280
|
-
By default, the plugin automatically supports both IPv4 and IPv6. Nodemailer will:
|
|
281
|
-
- Resolve DNS to both IPv4 (A) and IPv6 (AAAA) records
|
|
282
|
-
- Try both address families in parallel
|
|
283
|
-
- Use whichever connects successfully
|
|
284
|
-
|
|
285
|
-
#### Force IPv4
|
|
286
|
-
If you want to force IPv4 connection (e.g., IPv6 is unavailable or unstable):
|
|
287
|
-
```json
|
|
288
|
-
{
|
|
289
|
-
"config": {
|
|
290
|
-
"host": "smtp.qq.com",
|
|
291
|
-
"port": 465,
|
|
292
|
-
"secure": true,
|
|
293
|
-
"localAddress": "0.0.0.0",
|
|
294
|
-
"auth": {
|
|
295
|
-
"user": "your@qq.com",
|
|
296
|
-
"pass": "authorization-code"
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
#### Force IPv6
|
|
303
|
-
If you want to force IPv6 connection:
|
|
304
|
-
```json
|
|
305
|
-
{
|
|
306
|
-
"config": {
|
|
307
|
-
"host": "smtp.example.com",
|
|
308
|
-
"port": 465,
|
|
309
|
-
"secure": true,
|
|
310
|
-
"localAddress": "::",
|
|
311
|
-
"auth": {
|
|
312
|
-
"user": "your@example.com",
|
|
313
|
-
"pass": "your-password"
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
#### Use IPv6 Address as Host
|
|
320
|
-
You can also use an IPv6 address directly as the host:
|
|
321
|
-
```json
|
|
322
|
-
{
|
|
323
|
-
"config": {
|
|
324
|
-
"host": "2001:db8::1",
|
|
325
|
-
"port": 465,
|
|
326
|
-
"secure": true,
|
|
327
|
-
"auth": {
|
|
328
|
-
"user": "your@example.com",
|
|
329
|
-
"pass": "your-password"
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
### Events Configuration
|
|
336
|
-
| Event | Default | Description |
|
|
337
|
-
|-------|---------|-------------|
|
|
338
|
-
| `created` | false | When a new session starts |
|
|
339
|
-
| `idle` | true | When a session completes (AI finishes responding) |
|
|
340
|
-
| `error` | true | When a session encounters an error |
|
|
341
|
-
### Template Variables
|
|
342
|
-
| Variable | Description | Available Events |
|
|
343
|
-
|----------|-------------|------------------|
|
|
344
|
-
| `{{eventType}}` | Event type (created, idle, error) | All |
|
|
345
|
-
| `{{timestamp}}` | ISO 8601 timestamp | All |
|
|
346
|
-
| `{{projectName}}` | Project short name (directory basename or config value) | All |
|
|
347
|
-
| `{{projectPath}}` | Project full path (e.g. `/home/user/my-project`) | All |
|
|
348
|
-
| `{{sessionId}}` | Session ID | All |
|
|
349
|
-
| `{{message}}` | Completion message | idle |
|
|
350
|
-
| `{{duration}}` | Task duration | idle |
|
|
351
|
-
| `{{error}}` | Error message | error |
|
|
352
|
-
## CLI Tool
|
|
353
|
-
This package includes a CLI tool to test your notification channel configuration.
|
|
354
|
-
### Test Command
|
|
355
|
-
```bash
|
|
356
|
-
# Test all channels (validates config, tests connection, sends test email)
|
|
357
|
-
npx @ulthon/ul-opencode-event test
|
|
358
|
-
|
|
359
|
-
# Test a specific channel by name
|
|
360
|
-
npx @ulthon/ul-opencode-event test --channel "My Email"
|
|
361
|
-
|
|
362
|
-
# Only verify connection without sending email
|
|
363
|
-
npx @ulthon/ul-opencode-event test --no-send
|
|
364
|
-
```
|
|
365
|
-
### Test Output Example
|
|
366
|
-
```
|
|
367
|
-
[ul-opencode-event-cli] Starting channel test...
|
|
368
|
-
[ul-opencode-event-cli] Loaded project config: ./ul-opencode-event.json
|
|
369
|
-
[ul-opencode-event-cli] Found 1 channel(s) to test
|
|
370
|
-
|
|
371
|
-
[ul-opencode-event-cli] Testing channel: "My Email"
|
|
372
|
-
Type: smtp
|
|
373
|
-
Recipients: user@example.com
|
|
374
|
-
|
|
375
|
-
[1/3] Validating configuration...
|
|
376
|
-
[OK] Configuration is valid
|
|
377
|
-
|
|
378
|
-
[2/3] Testing SMTP connection to smtp.example.com:465...
|
|
379
|
-
[OK] Connection successful
|
|
380
|
-
|
|
381
|
-
[3/3] Sending test email...
|
|
382
|
-
[OK] Test email sent successfully
|
|
383
|
-
Message ID: <xxx>
|
|
384
|
-
|
|
385
|
-
[SUCCESS] Channel "My Email" passed all tests
|
|
386
|
-
|
|
387
|
-
[ul-opencode-event-cli] Test Summary:
|
|
388
|
-
Passed: 1
|
|
389
|
-
Failed: 0
|
|
390
|
-
```
|
|
391
|
-
## Troubleshooting
|
|
392
|
-
### SMTP connection failed
|
|
393
|
-
**Q: Does this plugin support IPv4? IPv6?**
|
|
394
|
-
**A: Yes, both are supported.** The plugin uses nodemailer which automatically:
|
|
395
|
-
- Resolves DNS to both IPv4 (A) and IPv6 (AAAA) records
|
|
396
|
-
- Tries both address families in parallel
|
|
397
|
-
- Falls back if one fails
|
|
398
|
-
**Common issues:**
|
|
399
|
-
1. **Wrong password**: Use authorization code for QQ/163, App Password for Gmail
|
|
400
|
-
2. **Wrong port**: Use 465 for SSL, 587 for STARTTLS
|
|
401
|
-
3. **Firewall**: Ensure outbound connections to SMTP port are allowed
|
|
402
|
-
4. **IPv6 issues**: If your network has incomplete IPv6, connections may timeout
|
|
403
|
-
- Check your network's IPv6 configuration
|
|
404
|
-
- Contact your network administrator if needed
|
|
405
|
-
### Debug mode
|
|
406
|
-
```bash
|
|
407
|
-
# Enable debug logging
|
|
408
|
-
UL_OPENCODE_EVENT_DEBUG=1 npx @ulthon/ul-opencode-event test
|
|
409
|
-
```
|
|
410
|
-
### No notifications sent
|
|
411
|
-
1. Check if `enabled: true` is set
|
|
412
|
-
2. Check if the event type is enabled in `events`
|
|
413
|
-
3. Check SMTP credentials are correct
|
|
414
|
-
4. Ensure config file exists at correct location
|
|
415
|
-
## Local Development
|
|
416
|
-
```bash
|
|
417
|
-
# Build
|
|
418
|
-
npm run build
|
|
419
|
-
|
|
420
|
-
# Test locally without publishing
|
|
421
|
-
node dist/cli.js test
|
|
422
|
-
|
|
423
|
-
# Or use npm link
|
|
424
|
-
npm link
|
|
425
|
-
npx @ulthon/ul-opencode-event test
|
|
426
|
-
|
|
427
|
-
# Unlink when done
|
|
428
|
-
npm unlink -g @ulthon/ul-opencode-event
|
|
429
|
-
```
|
|
430
|
-
## License
|
|
431
|
-
MIT
|
|
1
|
+
# @ulthon/ul-opencode-event
|
|
2
|
+
|
|
3
|
+
**Never miss an OpenCode notification, even when you're away from your desk.**
|
|
4
|
+
|
|
5
|
+
OpenCode multi-channel notification plugin that keeps you informed wherever you are. Get notified when tasks complete or errors occur via email, DingTalk, or Feishu.
|
|
6
|
+
|
|
7
|
+
## Why ul-opencode-event?
|
|
8
|
+
|
|
9
|
+
- **Remote Development Friendly** - Get notified when your AI coding agent finishes a task
|
|
10
|
+
- **Multi-Channel Support** - SMTP email, DingTalk robot, Feishu robot, with more planned
|
|
11
|
+
- **Real-time Alerts** - Immediate notifications on session `idle` or `error` events
|
|
12
|
+
- **Easy Configuration** - Simple JSON config with template variables
|
|
13
|
+
|
|
14
|
+
## Roadmap
|
|
15
|
+
|
|
16
|
+
| Channel | Status | Description |
|
|
17
|
+
|---------|--------|-------------|
|
|
18
|
+
| SMTP (Email) | Available | Email notifications via any SMTP server |
|
|
19
|
+
| DingTalk | Available | DingTalk robot notifications |
|
|
20
|
+
| Feishu | Available | Feishu robot notifications |
|
|
21
|
+
| Webhook | Planned | HTTP webhook for custom integrations |
|
|
22
|
+
| Telegram | Planned | Telegram bot notifications |
|
|
23
|
+
| Slack | Planned | Slack incoming webhooks |
|
|
24
|
+
| WeChat Work | Planned | Enterprise WeChat |
|
|
25
|
+
|
|
26
|
+
**Have a channel request?** Open an issue on our [Gitee repo](https://gitee.com/augushong/ul-opencode-event)!
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @ulthon/ul-opencode-event
|
|
32
|
+
cp node_modules/@ulthon/ul-opencode-event/examples/ul-opencode-event.json ./
|
|
33
|
+
# Edit config with your SMTP credentials
|
|
34
|
+
npx @ulthon/ul-opencode-event test
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Add to `~/.config/opencode/opencode.json`:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{ "plugin": ["@ulthon/ul-opencode-event"] }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
### File Locations
|
|
46
|
+
|
|
47
|
+
1. **Project-level**: `.opencode/ul-opencode-event.json` or `./ul-opencode-event.json`
|
|
48
|
+
2. **Global**: `~/.config/opencode/ul-opencode-event.json`
|
|
49
|
+
|
|
50
|
+
Project-level config overrides global (channels merged by name).
|
|
51
|
+
|
|
52
|
+
### Minimal SMTP Example
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"channels": [{
|
|
57
|
+
"type": "smtp",
|
|
58
|
+
"enabled": true,
|
|
59
|
+
"name": "My Email",
|
|
60
|
+
"config": {
|
|
61
|
+
"host": "smtp.qq.com",
|
|
62
|
+
"port": 465,
|
|
63
|
+
"secure": true,
|
|
64
|
+
"auth": { "user": "your-email@example.com", "pass": "your-app-password" }
|
|
65
|
+
},
|
|
66
|
+
"recipients": ["receiver@example.com"],
|
|
67
|
+
"events": { "idle": true, "error": true }
|
|
68
|
+
}]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Common SMTP Servers
|
|
73
|
+
|
|
74
|
+
| Provider | Host | Port | Auth |
|
|
75
|
+
|----------|------|------|------|
|
|
76
|
+
| QQ Mail | smtp.qq.com | 465 | Authorization code |
|
|
77
|
+
| 163 Mail | smtp.163.com | 465 | Authorization code |
|
|
78
|
+
| Gmail | smtp.gmail.com | 587 | App Password |
|
|
79
|
+
| Outlook | smtp.office365.com | 587 | Password |
|
|
80
|
+
|
|
81
|
+
> For QQ/163 use the authorization code, not login password. For Gmail use an App Password with 2FA.
|
|
82
|
+
|
|
83
|
+
### Other Channels
|
|
84
|
+
|
|
85
|
+
- **DingTalk**: See [docs/dingtalk-channel.md](docs/dingtalk-channel.md) for robot setup, @mentions, and FAQ.
|
|
86
|
+
- **Feishu**: See [docs/feishu-channel.md](docs/feishu-channel.md) for robot setup, card format, and FAQ.
|
|
87
|
+
|
|
88
|
+
### Events
|
|
89
|
+
|
|
90
|
+
| Event | Default | Description |
|
|
91
|
+
|-------|---------|-------------|
|
|
92
|
+
| `created` | false | When a new session starts |
|
|
93
|
+
| `idle` | true | When a session completes |
|
|
94
|
+
| `error` | true | When a session encounters an error |
|
|
95
|
+
|
|
96
|
+
### Template Variables
|
|
97
|
+
|
|
98
|
+
| Variable | Description | Events |
|
|
99
|
+
|----------|-------------|--------|
|
|
100
|
+
| `{{eventType}}` | Event type (created, idle, error) | All |
|
|
101
|
+
| `{{timestamp}}` | ISO 8601 timestamp | All |
|
|
102
|
+
| `{{projectName}}` | Project short name | All |
|
|
103
|
+
| `{{projectPath}}` | Project full path | All |
|
|
104
|
+
| `{{sessionId}}` | Session ID | All |
|
|
105
|
+
| `{{message}}` | Completion message | idle |
|
|
106
|
+
| `{{duration}}` | Task duration | idle |
|
|
107
|
+
| `{{error}}` | Error message | error |
|
|
108
|
+
|
|
109
|
+
See [docs/template-configuration.md](docs/template-configuration.md) for customization details.
|
|
110
|
+
|
|
111
|
+
## CLI Tool
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx @ulthon/ul-opencode-event test # Test all channels
|
|
115
|
+
npx @ulthon/ul-opencode-event test --channel "My Email" # Test specific channel
|
|
116
|
+
npx @ulthon/ul-opencode-event test --no-send # Verify connection only
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Documentation
|
|
120
|
+
|
|
121
|
+
| Document | Description |
|
|
122
|
+
|----------|-------------|
|
|
123
|
+
| [Configuration](docs/configuration.md) | Config file locations, merge rules, events |
|
|
124
|
+
| [SMTP Channel](docs/smtp-channel.md) | Email configuration, IPv4/IPv6, FAQ |
|
|
125
|
+
| [DingTalk Channel](docs/dingtalk-channel.md) | Configuration, @mentions, FAQ |
|
|
126
|
+
| [Feishu Channel](docs/feishu-channel.md) | Configuration, card format, FAQ |
|
|
127
|
+
| [Template Configuration](docs/template-configuration.md) | Template variables and customization |
|
|
128
|
+
| [Channel Development](docs/channel-development.md) | Developing new notification channels |
|
|
129
|
+
|
|
130
|
+
## Troubleshooting
|
|
131
|
+
|
|
132
|
+
- **Wrong password**: Use authorization code for QQ/163, App Password for Gmail
|
|
133
|
+
- **Wrong port**: Use 465 for SSL, 587 for STARTTLS
|
|
134
|
+
- **No notifications**: Check `enabled: true`, event types, and config file location
|
|
135
|
+
- **IPv4/IPv6**: Both supported; if IPv6 unstable, set `localAddress` to `"0.0.0.0"`
|
|
136
|
+
- **Debug mode**: `UL_OPENCODE_EVENT_DEBUG=1 npx @ulthon/ul-opencode-event test`
|
|
137
|
+
|
|
138
|
+
## Local Development
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
npm run build && node dist/cli.js test
|
|
142
|
+
# Or: npm link && npx @ulthon/ul-opencode-event test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as os from 'os';
|
|
|
14
14
|
import { lookup } from 'node:dns/promises';
|
|
15
15
|
import { isIP } from 'node:net';
|
|
16
16
|
import { generateSign, createDingTalkFormatter } from './dingtalk.js';
|
|
17
|
+
import { generateFeishuSign, createFeishuFormatter } from './feishu.js';
|
|
17
18
|
const CLI_PREFIX = '[ul-opencode-event-cli]';
|
|
18
19
|
const LOOKUP_TIMEOUT_MS = 1200;
|
|
19
20
|
/**
|
|
@@ -75,8 +76,11 @@ function validateChannel(channel) {
|
|
|
75
76
|
if (channel.type === 'dingtalk') {
|
|
76
77
|
return validateDingtalkChannel(channel, errors);
|
|
77
78
|
}
|
|
79
|
+
if (channel.type === 'feishu') {
|
|
80
|
+
return validateFeishuChannel(channel, errors);
|
|
81
|
+
}
|
|
78
82
|
if (channel.type !== 'smtp') {
|
|
79
|
-
errors.push(`Unsupported channel type: "${channel.type}". Supported types: "smtp", "dingtalk".`);
|
|
83
|
+
errors.push(`Unsupported channel type: "${channel.type}". Supported types: "smtp", "dingtalk", "feishu".`);
|
|
80
84
|
return { valid: false, errors };
|
|
81
85
|
}
|
|
82
86
|
// SMTP validation
|
|
@@ -139,6 +143,30 @@ function validateDingtalkChannel(channel, errors) {
|
|
|
139
143
|
}
|
|
140
144
|
return { valid: errors.length === 0, errors };
|
|
141
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Validate Feishu channel configuration
|
|
148
|
+
*/
|
|
149
|
+
function validateFeishuChannel(channel, errors) {
|
|
150
|
+
const config = channel.config;
|
|
151
|
+
// Check webhook URL
|
|
152
|
+
if (!config.webhook || !config.webhook.trim()) {
|
|
153
|
+
errors.push('Feishu webhook URL is missing');
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const webhookUrl = config.webhook.trim();
|
|
157
|
+
try {
|
|
158
|
+
new URL(webhookUrl);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
errors.push(`Feishu webhook URL is invalid: "${webhookUrl}"`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Check secret (optional but must be non-empty string if provided)
|
|
165
|
+
if (config.secret !== undefined && typeof config.secret === 'string' && !config.secret.trim()) {
|
|
166
|
+
errors.push('Feishu secret must be a non-empty string');
|
|
167
|
+
}
|
|
168
|
+
return { valid: errors.length === 0, errors };
|
|
169
|
+
}
|
|
142
170
|
/**
|
|
143
171
|
* Create SMTP transporter
|
|
144
172
|
* Supports IPv4/IPv6 via localAddress option
|
|
@@ -211,6 +239,10 @@ async function testConnection(channel) {
|
|
|
211
239
|
if (channel.type === 'dingtalk') {
|
|
212
240
|
return testDingtalkConnection(channel);
|
|
213
241
|
}
|
|
242
|
+
// Feishu connection test
|
|
243
|
+
if (channel.type === 'feishu') {
|
|
244
|
+
return testFeishuConnection(channel);
|
|
245
|
+
}
|
|
214
246
|
// SMTP connection test (existing logic)
|
|
215
247
|
const config = channel.config;
|
|
216
248
|
const preResolvedHost = await resolveHostViaLookup(config.host);
|
|
@@ -311,6 +343,54 @@ async function testDingtalkConnection(channel) {
|
|
|
311
343
|
return { success: false, error: message };
|
|
312
344
|
}
|
|
313
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Test Feishu webhook connection by sending a minimal text message
|
|
348
|
+
*/
|
|
349
|
+
async function testFeishuConnection(channel) {
|
|
350
|
+
const config = channel.config;
|
|
351
|
+
const webhookUrl = config.webhook.trim();
|
|
352
|
+
// Build message body with signing if secret is configured
|
|
353
|
+
const body = {
|
|
354
|
+
msg_type: 'text',
|
|
355
|
+
content: { text: 'connection test' },
|
|
356
|
+
};
|
|
357
|
+
if (config.secret) {
|
|
358
|
+
// Feishu uses second-level timestamp
|
|
359
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
360
|
+
const sign = await generateFeishuSign(config.secret, timestamp);
|
|
361
|
+
body.timestamp = timestamp;
|
|
362
|
+
body.sign = sign;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const controller = new AbortController();
|
|
366
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
367
|
+
const response = await fetch(webhookUrl, {
|
|
368
|
+
method: 'POST',
|
|
369
|
+
headers: { 'Content-Type': 'application/json' },
|
|
370
|
+
body: JSON.stringify(body),
|
|
371
|
+
signal: controller.signal,
|
|
372
|
+
});
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
let result;
|
|
375
|
+
try {
|
|
376
|
+
result = (await response.json());
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return { success: false, error: 'Failed to parse Feishu API response' };
|
|
380
|
+
}
|
|
381
|
+
if (result.code !== undefined && result.code !== 0) {
|
|
382
|
+
return { success: false, error: `Feishu API error: code=${result.code}, msg=${result.msg ?? 'unknown'}` };
|
|
383
|
+
}
|
|
384
|
+
return { success: true };
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
388
|
+
if (message.includes('abort')) {
|
|
389
|
+
return { success: false, error: 'Feishu connection timed out (10s)' };
|
|
390
|
+
}
|
|
391
|
+
return { success: false, error: message };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
314
394
|
/**
|
|
315
395
|
* Send test email
|
|
316
396
|
*/
|
|
@@ -454,6 +534,62 @@ If you received this message, your DingTalk notification channel is configured c
|
|
|
454
534
|
return { success: false, error: message };
|
|
455
535
|
}
|
|
456
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Send test message via Feishu webhook
|
|
539
|
+
*/
|
|
540
|
+
async function sendTestFeishuMessage(channel) {
|
|
541
|
+
const config = channel.config;
|
|
542
|
+
const timestamp = new Date().toISOString();
|
|
543
|
+
const channelName = channel.name || 'Feishu Test';
|
|
544
|
+
// Use Feishu formatter to generate a properly formatted test message
|
|
545
|
+
const formatter = createFeishuFormatter(config);
|
|
546
|
+
const testBody = `This is a test message from OpenCode notification plugin.
|
|
547
|
+
|
|
548
|
+
Channel: ${channelName}
|
|
549
|
+
Type: ${channel.type}
|
|
550
|
+
Time: ${timestamp}
|
|
551
|
+
|
|
552
|
+
If you received this message, your Feishu notification channel is configured correctly.`;
|
|
553
|
+
const formattedMessage = formatter.format(`[Test] OpenCode Notification Channel Test - ${timestamp}`, testBody, { eventType: 'idle', timestamp, projectName: 'test', sessionId: 'cli-test', sessionType: 'main', projectPath: process.cwd() });
|
|
554
|
+
// Build body with signing if secret is configured
|
|
555
|
+
const body = { ...formattedMessage };
|
|
556
|
+
if (config.secret) {
|
|
557
|
+
// Feishu uses second-level timestamp
|
|
558
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
559
|
+
const sign = await generateFeishuSign(config.secret, ts);
|
|
560
|
+
body.timestamp = ts;
|
|
561
|
+
body.sign = sign;
|
|
562
|
+
}
|
|
563
|
+
try {
|
|
564
|
+
const controller = new AbortController();
|
|
565
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
566
|
+
const response = await fetch(config.webhook.trim(), {
|
|
567
|
+
method: 'POST',
|
|
568
|
+
headers: { 'Content-Type': 'application/json' },
|
|
569
|
+
body: JSON.stringify(body),
|
|
570
|
+
signal: controller.signal,
|
|
571
|
+
});
|
|
572
|
+
clearTimeout(timer);
|
|
573
|
+
let result;
|
|
574
|
+
try {
|
|
575
|
+
result = (await response.json());
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return { success: false, error: 'Failed to parse Feishu API response' };
|
|
579
|
+
}
|
|
580
|
+
if (result.code !== undefined && result.code !== 0) {
|
|
581
|
+
return { success: false, error: `Feishu API error: code=${result.code}, msg=${result.msg ?? 'unknown'}` };
|
|
582
|
+
}
|
|
583
|
+
return { success: true, messageId: `feishu-${timestamp}` };
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
587
|
+
if (message.includes('abort')) {
|
|
588
|
+
return { success: false, error: 'Feishu send timed out (10s)' };
|
|
589
|
+
}
|
|
590
|
+
return { success: false, error: message };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
457
593
|
/**
|
|
458
594
|
* Test a single channel
|
|
459
595
|
*/
|
|
@@ -473,10 +609,15 @@ async function testChannel(channel, options) {
|
|
|
473
609
|
console.log(` [OK] Configuration is valid`);
|
|
474
610
|
// Step 2: Test connection (type-specific output)
|
|
475
611
|
const isDingtalk = channel.type === 'dingtalk';
|
|
612
|
+
const isFeishu = channel.type === 'feishu';
|
|
476
613
|
if (isDingtalk) {
|
|
477
614
|
const dtConfig = channel.config;
|
|
478
615
|
console.log(`\n [2/3] Testing DingTalk webhook: ${dtConfig.webhook}...`);
|
|
479
616
|
}
|
|
617
|
+
else if (isFeishu) {
|
|
618
|
+
const fsConfig = channel.config;
|
|
619
|
+
console.log(`\n [2/3] Testing Feishu webhook: ${fsConfig.webhook}...`);
|
|
620
|
+
}
|
|
480
621
|
else {
|
|
481
622
|
const smtpConfig = channel.config;
|
|
482
623
|
console.log(`\n [2/3] Testing SMTP connection to ${smtpConfig.host}:${smtpConfig.port}...`);
|
|
@@ -492,7 +633,7 @@ async function testChannel(channel, options) {
|
|
|
492
633
|
}
|
|
493
634
|
// Step 3: Send test message (if not skipped)
|
|
494
635
|
if (options.noSend) {
|
|
495
|
-
const skipLabel = isDingtalk ? 'test message' : 'test email';
|
|
636
|
+
const skipLabel = isDingtalk || isFeishu ? 'test message' : 'test email';
|
|
496
637
|
console.log(`\n [3/3] Skipping ${skipLabel} (--no-send flag)`);
|
|
497
638
|
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests (connection only)`);
|
|
498
639
|
return true;
|
|
@@ -512,6 +653,21 @@ async function testChannel(channel, options) {
|
|
|
512
653
|
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
|
|
513
654
|
console.log(` Please check your DingTalk group chat.`);
|
|
514
655
|
}
|
|
656
|
+
else if (isFeishu) {
|
|
657
|
+
console.log(`\n [3/3] Sending test message to Feishu...`);
|
|
658
|
+
const sendResult = await sendTestFeishuMessage(channel);
|
|
659
|
+
if (!sendResult.success) {
|
|
660
|
+
console.log(` [FAIL] Failed to send test message: ${sendResult.error}`);
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
console.log(` [OK] Test message sent successfully`);
|
|
664
|
+
if (sendResult.info) {
|
|
665
|
+
console.log(` [INFO] ${sendResult.info}`);
|
|
666
|
+
}
|
|
667
|
+
console.log(` Message ID: ${sendResult.messageId}`);
|
|
668
|
+
console.log(`\n [SUCCESS] Channel "${channelName}" passed all tests`);
|
|
669
|
+
console.log(` Please check your Feishu group chat.`);
|
|
670
|
+
}
|
|
515
671
|
else {
|
|
516
672
|
console.log(`\n [3/3] Sending test email...`);
|
|
517
673
|
const sendResult = await sendTestEmail(channel);
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as path from 'path';
|
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import { DEFAULT_SESSION_TYPES } from './types.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
-
const SUPPORTED_CHANNEL_TYPES = ['smtp', 'dingtalk'];
|
|
6
|
+
const SUPPORTED_CHANNEL_TYPES = ['smtp', 'dingtalk', 'feishu'];
|
|
7
7
|
/**
|
|
8
8
|
* Default configuration returned when no config files exist
|
|
9
9
|
*/
|
package/dist/feishu.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu (Lark) custom robot webhook sender module
|
|
3
|
+
*
|
|
4
|
+
* Sends notifications via Feishu custom robot webhook API using interactive cards.
|
|
5
|
+
* Uses Web Crypto API for HMAC-SHA256 signing (no external dependencies).
|
|
6
|
+
* Uses Node.js built-in fetch for HTTP requests.
|
|
7
|
+
*
|
|
8
|
+
* Reference: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
|
|
9
|
+
*/
|
|
10
|
+
import type { Channel, ChannelSender, ChannelFormatter, FeishuConfig } from './types.js';
|
|
11
|
+
/**
|
|
12
|
+
* Generate HmacSHA256 signature for Feishu webhook authentication.
|
|
13
|
+
*
|
|
14
|
+
* Algorithm: base64(hmac_sha256(timestamp + "\n" + secret, ""))
|
|
15
|
+
*
|
|
16
|
+
* Key difference from DingTalk:
|
|
17
|
+
* - Feishu: key = timestamp + "\n" + secret, message = "" (empty)
|
|
18
|
+
* - DingTalk: key = secret, message = timestamp + "\n" + secret
|
|
19
|
+
*
|
|
20
|
+
* @param secret - The robot signing secret
|
|
21
|
+
* @param timestamp - Second-level timestamp string
|
|
22
|
+
* @returns Base64-encoded signature
|
|
23
|
+
*/
|
|
24
|
+
export declare function generateFeishuSign(secret: string, timestamp: string): Promise<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Create a Feishu interactive card formatter.
|
|
27
|
+
*
|
|
28
|
+
* Converts subject + body into Feishu's interactive card JSON structure:
|
|
29
|
+
* { msg_type: "interactive", card: { header, elements } }
|
|
30
|
+
*
|
|
31
|
+
* Formatting rules:
|
|
32
|
+
* - Subject becomes card header title, truncated at 100 chars
|
|
33
|
+
* - Lines matching "key: value" get bold keys (**key**: value)
|
|
34
|
+
* - Error-looking lines wrapped in ``` code blocks
|
|
35
|
+
* - No DingTalk-style character escaping (lark_md uses standard Markdown)
|
|
36
|
+
* - Total content truncated at 4000 chars
|
|
37
|
+
*/
|
|
38
|
+
export declare function createFeishuFormatter(_config: FeishuConfig): ChannelFormatter;
|
|
39
|
+
/**
|
|
40
|
+
* Create a Feishu webhook sender for the given channel configuration.
|
|
41
|
+
*
|
|
42
|
+
* Factory function that validates channel config and returns a sender
|
|
43
|
+
* or null if the channel is invalid/disabled.
|
|
44
|
+
*
|
|
45
|
+
* Validation:
|
|
46
|
+
* - channel.type must be 'feishu'
|
|
47
|
+
* - channel.enabled must not be false
|
|
48
|
+
* - webhook URL must be present and look valid (http:// or https://)
|
|
49
|
+
*/
|
|
50
|
+
export declare function createFeishuSender(channel: Channel): Promise<ChannelSender | null>;
|
package/dist/feishu.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu (Lark) custom robot webhook sender module
|
|
3
|
+
*
|
|
4
|
+
* Sends notifications via Feishu custom robot webhook API using interactive cards.
|
|
5
|
+
* Uses Web Crypto API for HMAC-SHA256 signing (no external dependencies).
|
|
6
|
+
* Uses Node.js built-in fetch for HTTP requests.
|
|
7
|
+
*
|
|
8
|
+
* Reference: https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot
|
|
9
|
+
*/
|
|
10
|
+
import { logger } from './logger.js';
|
|
11
|
+
// โโโ Signing โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
12
|
+
/**
|
|
13
|
+
* Generate HmacSHA256 signature for Feishu webhook authentication.
|
|
14
|
+
*
|
|
15
|
+
* Algorithm: base64(hmac_sha256(timestamp + "\n" + secret, ""))
|
|
16
|
+
*
|
|
17
|
+
* Key difference from DingTalk:
|
|
18
|
+
* - Feishu: key = timestamp + "\n" + secret, message = "" (empty)
|
|
19
|
+
* - DingTalk: key = secret, message = timestamp + "\n" + secret
|
|
20
|
+
*
|
|
21
|
+
* @param secret - The robot signing secret
|
|
22
|
+
* @param timestamp - Second-level timestamp string
|
|
23
|
+
* @returns Base64-encoded signature
|
|
24
|
+
*/
|
|
25
|
+
export async function generateFeishuSign(secret, timestamp) {
|
|
26
|
+
if (!secret) {
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
const stringToSign = `${timestamp}\n${secret}`;
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
// Feishu: key = timestamp + "\n" + secret
|
|
32
|
+
const keyData = encoder.encode(stringToSign);
|
|
33
|
+
// Feishu: message = "" (empty)
|
|
34
|
+
const data = encoder.encode('');
|
|
35
|
+
const cryptoKey = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
36
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
37
|
+
// Convert ArrayBuffer to base64 without Buffer (pure browser/Node compat)
|
|
38
|
+
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
|
39
|
+
}
|
|
40
|
+
// โโโ Formatting โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
41
|
+
/** Max title length before truncation */
|
|
42
|
+
const MAX_TITLE_LENGTH = 100;
|
|
43
|
+
/** Max content length before truncation */
|
|
44
|
+
const MAX_CONTENT_LENGTH = 4000;
|
|
45
|
+
/** Keep this many chars when truncating */
|
|
46
|
+
const TRUNCATE_KEEP_LENGTH = 3800;
|
|
47
|
+
const TRUNCATE_SUFFIX = '\n... (truncated)';
|
|
48
|
+
/** Pattern to detect key: value lines for bold formatting */
|
|
49
|
+
const KEY_VALUE_PATTERN = /^(\s*\w[\w\s]*?)\s*:\s*(.+)$/;
|
|
50
|
+
/** Pattern to detect error-like content */
|
|
51
|
+
const ERROR_INDICATORS = /\b(error|Error|stack trace|Stack|Exception)\b/;
|
|
52
|
+
/**
|
|
53
|
+
* Create a Feishu interactive card formatter.
|
|
54
|
+
*
|
|
55
|
+
* Converts subject + body into Feishu's interactive card JSON structure:
|
|
56
|
+
* { msg_type: "interactive", card: { header, elements } }
|
|
57
|
+
*
|
|
58
|
+
* Formatting rules:
|
|
59
|
+
* - Subject becomes card header title, truncated at 100 chars
|
|
60
|
+
* - Lines matching "key: value" get bold keys (**key**: value)
|
|
61
|
+
* - Error-looking lines wrapped in ``` code blocks
|
|
62
|
+
* - No DingTalk-style character escaping (lark_md uses standard Markdown)
|
|
63
|
+
* - Total content truncated at 4000 chars
|
|
64
|
+
*/
|
|
65
|
+
export function createFeishuFormatter(_config) {
|
|
66
|
+
return {
|
|
67
|
+
format(subject, body, _payload) {
|
|
68
|
+
// 1. Title: truncate at 100 chars with ... suffix
|
|
69
|
+
const rawTitle = subject.trim();
|
|
70
|
+
const title = rawTitle.length > MAX_TITLE_LENGTH
|
|
71
|
+
? rawTitle.slice(0, MAX_TITLE_LENGTH) + '...'
|
|
72
|
+
: rawTitle;
|
|
73
|
+
// 2. Process body lines
|
|
74
|
+
const processedLines = [];
|
|
75
|
+
for (const line of body.split('\n')) {
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
if (!trimmed) {
|
|
78
|
+
processedLines.push('');
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Check for key: value pattern -> **key**: value
|
|
82
|
+
const kvMatch = trimmed.match(KEY_VALUE_PATTERN);
|
|
83
|
+
if (kvMatch && kvMatch[1] && kvMatch[2]) {
|
|
84
|
+
processedLines.push(`**${kvMatch[1].trim()}**: ${kvMatch[2].trim()}`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// Check for error-like content -> wrap in code block
|
|
88
|
+
if (ERROR_INDICATORS.test(trimmed)) {
|
|
89
|
+
processedLines.push(`\`\`\`\n${trimmed}\n\`\`\``);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Default: pass through (lark_md supports standard Markdown)
|
|
93
|
+
processedLines.push(trimmed);
|
|
94
|
+
}
|
|
95
|
+
// 3. Join lines
|
|
96
|
+
let text = processedLines.join('\n');
|
|
97
|
+
if (text.length > MAX_CONTENT_LENGTH) {
|
|
98
|
+
text = text.slice(0, TRUNCATE_KEEP_LENGTH) + TRUNCATE_SUFFIX;
|
|
99
|
+
}
|
|
100
|
+
// 4. Build interactive card message
|
|
101
|
+
const message = {
|
|
102
|
+
msg_type: 'interactive',
|
|
103
|
+
card: {
|
|
104
|
+
config: { wide_screen_mode: true },
|
|
105
|
+
header: {
|
|
106
|
+
title: { tag: 'plain_text', content: title },
|
|
107
|
+
template: 'blue',
|
|
108
|
+
},
|
|
109
|
+
elements: [
|
|
110
|
+
{
|
|
111
|
+
tag: 'div',
|
|
112
|
+
text: { tag: 'lark_md', content: text },
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
return message;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// โโโ Sending โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
122
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
123
|
+
/**
|
|
124
|
+
* Create a Feishu webhook sender for the given channel configuration.
|
|
125
|
+
*
|
|
126
|
+
* Factory function that validates channel config and returns a sender
|
|
127
|
+
* or null if the channel is invalid/disabled.
|
|
128
|
+
*
|
|
129
|
+
* Validation:
|
|
130
|
+
* - channel.type must be 'feishu'
|
|
131
|
+
* - channel.enabled must not be false
|
|
132
|
+
* - webhook URL must be present and look valid (http:// or https://)
|
|
133
|
+
*/
|
|
134
|
+
export async function createFeishuSender(channel) {
|
|
135
|
+
if (channel.type !== 'feishu') {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (channel.enabled === false) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const config = channel.config;
|
|
142
|
+
if (!config.webhook) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
// Validate webhook URL format
|
|
146
|
+
const webhookUrl = config.webhook.trim();
|
|
147
|
+
if (!webhookUrl.startsWith('http://') && !webhookUrl.startsWith('https://')) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const channelName = channel.name || 'Feishu';
|
|
151
|
+
const secret = config.secret;
|
|
152
|
+
return {
|
|
153
|
+
send: async (message) => {
|
|
154
|
+
// Expect FormattedMessage from createFeishuFormatter
|
|
155
|
+
const msg = message;
|
|
156
|
+
if (!msg || msg.msg_type !== 'interactive' || !msg.card) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Build message body (sign goes in body, not URL)
|
|
160
|
+
const body = { ...msg };
|
|
161
|
+
if (secret) {
|
|
162
|
+
// Feishu uses second-level timestamp (not milliseconds like DingTalk)
|
|
163
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
164
|
+
const sign = await generateFeishuSign(secret, timestamp);
|
|
165
|
+
body.timestamp = timestamp;
|
|
166
|
+
body.sign = sign;
|
|
167
|
+
}
|
|
168
|
+
// Send via fetch with timeout
|
|
169
|
+
const controller = new AbortController();
|
|
170
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
171
|
+
logger.debug(`Feishu webhook POST ${webhookUrl}`);
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(webhookUrl, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: controller.signal,
|
|
178
|
+
});
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
logger.debug(`Feishu response: ${response.status} ${response.statusText}`);
|
|
181
|
+
// Parse response - Feishu returns { code: 0, msg: "success" }
|
|
182
|
+
let result;
|
|
183
|
+
try {
|
|
184
|
+
result = (await response.json());
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
result = {};
|
|
188
|
+
}
|
|
189
|
+
if (result.code !== undefined && result.code !== 0) {
|
|
190
|
+
// Feishu API returned an error
|
|
191
|
+
logger.debug(`[${channelName}] Feishu API error: code=${result.code}, msg=${result.msg ?? 'unknown'}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Success (code === 0 or no code in response)
|
|
195
|
+
logger.debug(`[${channelName}] Feishu message sent successfully`);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
// Network error or abort - log error
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
logger.error(`Feishu send failed: ${message}`);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
close: async () => {
|
|
205
|
+
// No persistent connections for HTTP-based webhook sending
|
|
206
|
+
// This is a no-op but required by ChannelSender interface
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
package/dist/formatters.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - SMTP: pass-through (subject + text body)
|
|
8
8
|
* - DingTalk: Markdown formatting with escaping and truncation
|
|
9
|
-
* - Feishu:
|
|
9
|
+
* - Feishu: Interactive card messages for Feishu bots
|
|
10
10
|
*/
|
|
11
11
|
import type { ChannelType, Channel, ChannelFormatter } from './types.js';
|
|
12
12
|
/**
|
|
@@ -25,3 +25,4 @@ export declare const emailFormatter: ChannelFormatter;
|
|
|
25
25
|
*/
|
|
26
26
|
export declare function getFormatter(channelType: ChannelType, channel: Channel): ChannelFormatter | null;
|
|
27
27
|
export { createDingTalkFormatter } from './dingtalk.js';
|
|
28
|
+
export { createFeishuFormatter } from './feishu.js';
|
package/dist/formatters.js
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - SMTP: pass-through (subject + text body)
|
|
8
8
|
* - DingTalk: Markdown formatting with escaping and truncation
|
|
9
|
-
* - Feishu:
|
|
9
|
+
* - Feishu: Interactive card messages for Feishu bots
|
|
10
10
|
*/
|
|
11
11
|
import { createDingTalkFormatter } from './dingtalk.js';
|
|
12
|
+
import { createFeishuFormatter } from './feishu.js';
|
|
12
13
|
// โโโ Email Formatter (SMTP) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
13
14
|
/**
|
|
14
15
|
* Email/pass-through formatter for SMTP channels.
|
|
@@ -25,7 +26,7 @@ export const emailFormatter = {
|
|
|
25
26
|
/** Built-in formatter lookup by channel type (excluding dingtalk which needs config) */
|
|
26
27
|
const FORMATTER_REGISTRY = {
|
|
27
28
|
smtp: emailFormatter,
|
|
28
|
-
//
|
|
29
|
+
// dingtalk and feishu formatters created dynamically via getFormatter
|
|
29
30
|
};
|
|
30
31
|
// โโโ Public API โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
31
32
|
/**
|
|
@@ -39,7 +40,11 @@ export function getFormatter(channelType, channel) {
|
|
|
39
40
|
if (channelType === 'dingtalk') {
|
|
40
41
|
return createDingTalkFormatter(channel.config);
|
|
41
42
|
}
|
|
43
|
+
if (channelType === 'feishu') {
|
|
44
|
+
return createFeishuFormatter(channel.config);
|
|
45
|
+
}
|
|
42
46
|
return FORMATTER_REGISTRY[channelType] ?? null;
|
|
43
47
|
}
|
|
44
48
|
// โโโ Re-exports โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
45
49
|
export { createDingTalkFormatter } from './dingtalk.js';
|
|
50
|
+
export { createFeishuFormatter } from './feishu.js';
|
package/dist/handler.js
CHANGED
|
@@ -62,6 +62,19 @@ export function createEventHandler(config) {
|
|
|
62
62
|
logger.error(`Failed to initialize channel '${channel.name ?? String(i)}': ${error instanceof Error ? error.message : String(error)}`);
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
else if (channel.type === 'feishu' && channel.enabled !== false) {
|
|
66
|
+
try {
|
|
67
|
+
const feishuModule = await import('./feishu.js');
|
|
68
|
+
const sender = await feishuModule.createFeishuSender(channel);
|
|
69
|
+
if (sender) {
|
|
70
|
+
const key = channel.name || `channel-${i}`;
|
|
71
|
+
senders.set(key, sender);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
logger.error(`Failed to initialize channel '${channel.name ?? String(i)}': ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
65
78
|
}
|
|
66
79
|
}
|
|
67
80
|
// Process each channel using cached senders
|
package/dist/types.d.ts
CHANGED
|
@@ -42,7 +42,6 @@ export interface Channel {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Supported channel types
|
|
45
|
-
* Phase 1: Only SMTP is implemented
|
|
46
45
|
*/
|
|
47
46
|
export type ChannelType = 'smtp' | 'feishu' | 'dingtalk';
|
|
48
47
|
/**
|
|
@@ -69,10 +68,12 @@ export interface SMTPConfig {
|
|
|
69
68
|
};
|
|
70
69
|
}
|
|
71
70
|
/**
|
|
72
|
-
* Feishu
|
|
71
|
+
* Feishu (Lark) custom robot channel configuration
|
|
73
72
|
*/
|
|
74
73
|
export interface FeishuConfig {
|
|
75
74
|
webhook: string;
|
|
75
|
+
/** Secret for HMAC-SHA256 signature verification */
|
|
76
|
+
secret?: string;
|
|
76
77
|
}
|
|
77
78
|
/**
|
|
78
79
|
* DingTalk channel configuration (placeholder for future)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulthon/ul-opencode-event",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "OpenCode notification plugin - sends notifications via email or
|
|
3
|
+
"version": "0.1.28",
|
|
4
|
+
"description": "OpenCode notification plugin - sends notifications via email, DingTalk, or Feishu when session events occur",
|
|
5
5
|
"author": "augushong",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"email",
|
|
32
32
|
"smtp",
|
|
33
33
|
"dingtalk",
|
|
34
|
+
"feishu",
|
|
34
35
|
"cli"
|
|
35
36
|
],
|
|
36
37
|
"files": [
|