@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 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. Perfect for remote development - get notified when tasks complete or errors occur via email or DingTalk, even if you're not at your terminal.
6
-
7
- ## Why ul-opencode-event?
8
-
9
- - ๐ŸŒ **Remote Development Friendly** - Desktop notifications don't work when you're away. Our plugin sends notifications to your email (and more channels coming soon), so you'll always know when your AI coding agent finishes a task.
10
- - ๐Ÿ“ฌ **Multi-Channel Support** - Supports SMTP email and DingTalk robot, with more notification channels planned (webhook, Telegram, Slack, etc.)
11
- - โšก **Real-time Alerts** - Get notified immediately when sessions complete (`idle`) or encounter errors (`error`)
12
- - ๐Ÿ”ง **Easy Configuration** - Simple JSON config with channel-level customization
13
-
14
- ## Roadmap
15
-
16
- | Channel | Status | Description |
17
- |---------|--------|-------------|
18
- | โœ… SMTP (Email) | Available | Email notifications via any SMTP server |
19
- | ๐Ÿ”œ Webhook | Planned | HTTP webhook for custom integrations |
20
- | ๐Ÿ”œ Telegram | Planned | Telegram bot notifications |
21
- | ๐Ÿ”œ Slack | Planned | Slack incoming webhooks |
22
- | โœ… DingTalk | Available | ้’‰้’‰ๆœบๅ™จไบบ |
23
- | ๐Ÿ”œ WeChat Work | Planned | ไผไธšๅพฎไฟก |
24
-
25
- **Have a channel request?** Open an issue on our [Gitee repo](https://gitee.com/augushong/ul-opencode-event)!
26
-
27
- ## Quick Start
28
-
29
- ```bash
30
- # 1. ๅฎ‰่ฃ…
31
- npm install @ulthon/ul-opencode-event
32
-
33
- # 2. ๅˆ›ๅปบ้…็ฝฎๆ–‡ไปถ๏ผˆ้กน็›ฎๆ น็›ฎๅฝ•๏ผ‰
34
- cp node_modules/@ulthon/ul-opencode-event/examples/ul-opencode-event.json ./
35
-
36
- # 3. ็ผ–่พ‘้…็ฝฎ๏ผŒๅกซๅ…ฅไฝ ็š„ SMTP ไฟกๆฏ
37
-
38
- # 4. ๆต‹่ฏ•่ฟžๆŽฅ
39
- npx @ulthon/ul-opencode-event test
40
-
41
- # 5. ๆทปๅŠ ๅˆฐ OpenCode ้…็ฝฎ
42
- # ๅœจ .opencode/settings.json ไธญๆทปๅŠ :
43
- # { "plugin": ["@ulthon/ul-opencode-event"] }
44
- ```
45
- ## Installation
46
- ```bash
47
- npm install @ulthon/ul-opencode-event
48
- ```
49
- Then add to your OpenCode configuration (`.opencode/settings.json`):
50
- ```json
51
- {
52
- "plugin": ["@ulthon/ul-opencode-event"]
53
- }
54
- ```
55
- ## Configuration
56
- ### Configuration File Locations
57
- The plugin looks for configuration in these locations (in order of priority):
58
- 1. **Project-level**: `.opencode/ul-opencode-event.json` or `./ul-opencode-event.json`
59
- 2. **Global**: `~/.config/opencode/ul-opencode-event.json`
60
- Project-level configuration overrides global configuration (channels are merged by name).
61
- ### Minimal Configuration
62
- Create `ul-opencode-event.json` in your project root:
63
- ```json
64
- {
65
- "channels": [
66
- {
67
- "type": "smtp",
68
- "enabled": true,
69
- "name": "My Email",
70
- "config": {
71
- "host": "smtp.example.com",
72
- "port": 465,
73
- "secure": true,
74
- "auth": {
75
- "user": "your-email@example.com",
76
- "pass": "your-app-password"
77
- }
78
- },
79
- "recipients": ["receiver@example.com"],
80
- "events": {
81
- "idle": true,
82
- "error": true
83
- }
84
- }
85
- ]
86
- }
87
- ```
88
- ### Common SMTP Servers
89
- | Provider | Host | Port | Secure | Auth |
90
- |----------|------|------|--------|------|
91
- | QQ Mail | smtp.qq.com | 465 | true | Authorization code |
92
- | 163 Mail | smtp.163.com | 465 | true | Authorization code |
93
- | Gmail | smtp.gmail.com | 587 | false | App Password |
94
- | Outlook | smtp.office365.com | 587 | false | Password |
95
- > **Note**: For QQ/163 Mail, use the authorization code (ๆŽˆๆƒ็ ), not your login password.
96
- > For Gmail, use an App Password with 2FA enabled.
97
- ### Full Configuration Example
98
- ```json
99
- {
100
- "projectName": "my-project",
101
- "channels": [
102
- {
103
- "type": "smtp",
104
- "enabled": true,
105
- "name": "QQ้‚ฎ็ฎฑ",
106
- "config": {
107
- "host": "smtp.qq.com",
108
- "port": 465,
109
- "secure": true,
110
- "auth": {
111
- "user": "your@qq.com",
112
- "pass": "your-authorization-code"
113
- }
114
- },
115
- "recipients": ["admin@example.com", "dev-team@example.com"],
116
- "events": {
117
- "created": false,
118
- "idle": true,
119
- "error": true
120
- },
121
- "templates": {
122
- "idle": {
123
- "subject": "[{{projectName}}] ไปปๅŠกๅฎŒๆˆ",
124
- "body": "ไปปๅŠกๅทฒๅฎŒๆˆ\n\nๆ—ถ้—ด: {{timestamp}}\n่€—ๆ—ถ: {{duration}}\nๆถˆๆฏ: {{message}}"
125
- },
126
- "error": {
127
- "subject": "[{{projectName}}] ไปปๅŠก้”™่ฏฏ",
128
- "body": "ไปปๅŠกๅ‘็”Ÿ้”™่ฏฏ\n\nๆ—ถ้—ด: {{timestamp}}\n้”™่ฏฏ: {{error}}"
129
- }
130
- }
131
- }
132
- ]
133
- }
134
- ```
135
- ### Channel Configuration
136
- | Field | Type | Required | Description |
137
- |-------|------|----------|-------------|
138
- | `type` | string | Yes | Channel type: `smtp` |
139
- | `enabled` | boolean | No | Whether this channel is active (default: true) |
140
- | `name` | string | No | Channel name for identification |
141
- | `config` | object | Yes | SMTP configuration |
142
- | `recipients` | string[] | Yes | Email recipients |
143
- | `events` | object | No | Which events to notify |
144
- | `templates` | object | No | Custom templates for each event |
145
- ### SMTP Configuration
146
- | Field | Type | Required | Description |
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
- - ๅ›พ็‰‡: `![](url)`
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
  */
@@ -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
+ }
@@ -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: not yet implemented
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';
@@ -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: not yet implemented
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
- // feishu: not implemented -> returns null via getFormatter fallback
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 channel configuration (placeholder for future)
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.27",
4
- "description": "OpenCode notification plugin - sends notifications via email or DingTalk when session events occur",
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": [