opencode-chat-channel 1.0.0 → 1.2.0
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 +160 -98
- package/dist/channels/feishu/index.d.ts +21 -0
- package/dist/channels/feishu/index.d.ts.map +1 -0
- package/dist/channels/wecom/index.d.ts +37 -0
- package/dist/channels/wecom/index.d.ts.map +1 -0
- package/dist/index.d.ts +12 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +280 -120
- package/dist/session-manager.d.ts +36 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/types.d.ts +57 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# opencode-chat-channel
|
|
2
2
|
|
|
3
|
-
An [opencode](https://opencode.ai) plugin that connects
|
|
3
|
+
An [opencode](https://opencode.ai) plugin that connects instant messaging bots to opencode AI via a unified multi-channel architecture.
|
|
4
|
+
|
|
5
|
+
**Currently supported**: Feishu (Lark) via WebSocket long connection — no public IP required.
|
|
6
|
+
**Skeleton ready**: WeCom (企业微信) — see `src/channels/wecom/index.ts`.
|
|
4
7
|
|
|
5
8
|
[中文说明](#中文说明) · [English](#english)
|
|
6
9
|
|
|
@@ -11,67 +14,65 @@ An [opencode](https://opencode.ai) plugin that connects your Feishu (Lark) bot t
|
|
|
11
14
|
### How It Works
|
|
12
15
|
|
|
13
16
|
```
|
|
14
|
-
|
|
15
|
-
↓ WebSocket
|
|
16
|
-
Feishu Open Platform
|
|
17
|
-
↓ @larksuiteoapi/node-sdk WSClient
|
|
17
|
+
User sends message (Feishu / WeCom / ...)
|
|
18
|
+
↓ Channel adapter (WebSocket / HTTP callback / ...)
|
|
18
19
|
chat-channel plugin (this repo)
|
|
19
20
|
↓ client.session.prompt()
|
|
20
21
|
opencode AI (Sisyphus)
|
|
21
22
|
↓ text reply
|
|
22
|
-
|
|
23
|
+
User receives reply
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
- Each
|
|
26
|
+
- Each user (`open_id` / user ID) gets their own persistent opencode session
|
|
26
27
|
- Sessions expire after 2 hours of inactivity (auto-cleanup)
|
|
27
|
-
- Replies longer than 4000 characters are automatically split
|
|
28
|
+
- Replies longer than 4000 characters are automatically split
|
|
28
29
|
|
|
29
30
|
---
|
|
30
31
|
|
|
31
|
-
###
|
|
32
|
+
### Installation
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
Since this is an **opencode plugin**, opencode handles installation automatically via npm. No scripts needed.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
curl -fsSL https://raw.githubusercontent.com/coneycode/opencode-chat-channel/main/install.sh | bash
|
|
37
|
-
```
|
|
36
|
+
#### Step 1: Add to `opencode.json`
|
|
38
37
|
|
|
39
|
-
|
|
40
|
-
1. Download the plugin to `~/.config/opencode/plugins/chat-channel.ts`
|
|
41
|
-
2. Add `@larksuiteoapi/node-sdk` to `~/.config/opencode/package.json`
|
|
42
|
-
3. Run `bun install` to install dependencies
|
|
43
|
-
4. Prompt you for your Feishu **App ID** (saved to `.env`) and **App Secret** (saved to macOS Keychain)
|
|
38
|
+
Edit `~/.config/opencode/opencode.json` and add the plugin:
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
[
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"plugin": [
|
|
43
|
+
"opencode-chat-channel@latest"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
48
46
|
```
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
---
|
|
48
|
+
opencode will pull and install the package automatically on next startup.
|
|
53
49
|
|
|
54
|
-
|
|
50
|
+
#### Step 2: Select channels to enable
|
|
55
51
|
|
|
56
|
-
|
|
52
|
+
Add `CHAT_CHANNELS` to `~/.config/opencode/.env` to control which channels are active:
|
|
57
53
|
|
|
58
54
|
```bash
|
|
59
|
-
|
|
60
|
-
```
|
|
55
|
+
# ~/.config/opencode/.env
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
# Enable Feishu only (recommended to start)
|
|
58
|
+
CHAT_CHANNELS=feishu
|
|
63
59
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
60
|
+
# Enable multiple channels at once
|
|
61
|
+
# CHAT_CHANNELS=feishu,wecom
|
|
62
|
+
|
|
63
|
+
# Leave unset or empty = auto mode: any channel with valid credentials starts automatically
|
|
64
|
+
# CHAT_CHANNELS=
|
|
70
65
|
```
|
|
71
66
|
|
|
72
|
-
Then
|
|
67
|
+
Then configure credentials for each enabled channel (see sections below).
|
|
68
|
+
|
|
69
|
+
#### Step 3: Configure channel credentials
|
|
70
|
+
|
|
71
|
+
See the [Feishu Configuration](#feishu-configuration) section below.
|
|
73
72
|
|
|
74
|
-
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
### Feishu Configuration
|
|
75
76
|
|
|
76
77
|
#### Step 1: Create a Feishu Self-Built App
|
|
77
78
|
|
|
@@ -98,13 +99,21 @@ FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
|
98
99
|
# OPENCODE_BASE_URL=http://localhost:4321
|
|
99
100
|
```
|
|
100
101
|
|
|
101
|
-
**App Secret**
|
|
102
|
+
**App Secret** — choose the method for your platform:
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
| Platform | Method | Command |
|
|
105
|
+
|----------|--------|---------|
|
|
106
|
+
| macOS | Keychain (recommended) | `security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U` |
|
|
107
|
+
| Windows / Linux | `.env` file | Add `FEISHU_APP_SECRET=<secret>` to `~/.config/opencode/.env` |
|
|
108
|
+
| All platforms | Environment variable | Set `FEISHU_APP_SECRET=<secret>` before launching opencode |
|
|
109
|
+
|
|
110
|
+
> **Priority**: environment variable → macOS Keychain → `.env` file value (already loaded as env var).
|
|
111
|
+
> The plugin tries each in order and uses the first one found.
|
|
106
112
|
|
|
107
|
-
|
|
113
|
+
> ⚠️ If you store the secret in `.env`, ensure the file has restricted permissions:
|
|
114
|
+
> `chmod 600 ~/.config/opencode/.env`
|
|
115
|
+
|
|
116
|
+
**macOS Keychain** (verify):
|
|
108
117
|
|
|
109
118
|
```bash
|
|
110
119
|
security find-generic-password -a chat-channel -s opencode-chat-channel -w
|
|
@@ -119,19 +128,47 @@ opencode
|
|
|
119
128
|
You should see in the logs:
|
|
120
129
|
|
|
121
130
|
```
|
|
122
|
-
[
|
|
131
|
+
[feishu] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
132
|
+
chat-channel 已启动,活跃渠道: feishu
|
|
123
133
|
```
|
|
124
134
|
|
|
125
|
-
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
### Adding a New Channel
|
|
138
|
+
|
|
139
|
+
The plugin uses a `ChatChannel` interface. To add a new channel:
|
|
126
140
|
|
|
141
|
+
1. Create `src/channels/<name>/index.ts` implementing `ChatChannel` and exporting a `ChannelFactory`
|
|
142
|
+
2. Register the factory in `src/index.ts` → `CHANNEL_FACTORIES` array
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// src/channels/myapp/index.ts
|
|
146
|
+
import type { ChatChannel, ChannelFactory } from "../../types.js";
|
|
147
|
+
|
|
148
|
+
class MyAppChannel implements ChatChannel {
|
|
149
|
+
readonly name = "myapp";
|
|
150
|
+
async start(onMessage) { /* connect, call onMessage on each msg */ }
|
|
151
|
+
async send(target, text) { /* send reply */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const myappChannelFactory: ChannelFactory = async (client) => {
|
|
155
|
+
// read credentials, return null if not configured
|
|
156
|
+
return new MyAppChannel(...);
|
|
157
|
+
};
|
|
127
158
|
```
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// src/index.ts — add to CHANNEL_FACTORIES
|
|
162
|
+
import { myappChannelFactory } from "./channels/myapp/index.js";
|
|
163
|
+
|
|
164
|
+
const CHANNEL_FACTORIES: ChannelFactory[] = [
|
|
165
|
+
feishuChannelFactory,
|
|
166
|
+
myappChannelFactory, // ← add here
|
|
167
|
+
];
|
|
133
168
|
```
|
|
134
169
|
|
|
170
|
+
---
|
|
171
|
+
|
|
135
172
|
### FAQ
|
|
136
173
|
|
|
137
174
|
**Q: Plugin starts but bot receives no messages**
|
|
@@ -154,68 +191,65 @@ You should see in the logs:
|
|
|
154
191
|
### 工作原理
|
|
155
192
|
|
|
156
193
|
```
|
|
157
|
-
|
|
158
|
-
↓ WebSocket 长连接
|
|
159
|
-
飞书开放平台
|
|
160
|
-
↓ @larksuiteoapi/node-sdk WSClient
|
|
194
|
+
用户发消息(飞书 / 企业微信 / ...)
|
|
195
|
+
↓ 渠道适配器(WebSocket 长连接 / HTTP 回调 / ...)
|
|
161
196
|
chat-channel 插件(本仓库)
|
|
162
197
|
↓ client.session.prompt()
|
|
163
198
|
opencode AI (Sisyphus)
|
|
164
199
|
↓ 回复文本
|
|
165
|
-
|
|
200
|
+
用户收到回复
|
|
166
201
|
```
|
|
167
202
|
|
|
168
|
-
-
|
|
203
|
+
- 每个用户独享一个 opencode session,**对话历史持久保留**
|
|
169
204
|
- session 闲置 2 小时后自动回收,下次对话开新 session
|
|
170
205
|
- AI 回复超过 4000 字时自动分段发送
|
|
171
206
|
|
|
172
207
|
---
|
|
173
208
|
|
|
174
|
-
###
|
|
209
|
+
### 安装
|
|
175
210
|
|
|
176
|
-
|
|
211
|
+
本项目是 **opencode 插件**,opencode 通过 npm 自动管理安装,无需额外脚本。
|
|
177
212
|
|
|
178
|
-
|
|
179
|
-
curl -fsSL https://raw.githubusercontent.com/coneycode/opencode-chat-channel/main/install.sh | bash
|
|
180
|
-
```
|
|
213
|
+
#### 第一步:添加到 `opencode.json`
|
|
181
214
|
|
|
182
|
-
|
|
183
|
-
1. 下载插件文件到 `~/.config/opencode/plugins/chat-channel.ts`
|
|
184
|
-
2. 将 `@larksuiteoapi/node-sdk` 添加到 `~/.config/opencode/package.json`
|
|
185
|
-
3. 执行 `bun install` 安装依赖
|
|
186
|
-
4. 交互式询问你的 **App ID**(保存到 `.env`)和 **App Secret**(保存到 macOS 钥匙串)
|
|
215
|
+
编辑 `~/.config/opencode/opencode.json`,在 `plugin` 数组中添加:
|
|
187
216
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
[
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"plugin": [
|
|
220
|
+
"opencode-chat-channel@latest"
|
|
221
|
+
]
|
|
222
|
+
}
|
|
191
223
|
```
|
|
192
224
|
|
|
193
|
-
|
|
225
|
+
下次启动 opencode 时会自动拉取并安装。
|
|
194
226
|
|
|
195
|
-
|
|
227
|
+
#### 第二步:选择要启用的渠道
|
|
196
228
|
|
|
197
|
-
|
|
198
|
-
### 安装
|
|
199
|
-
|
|
200
|
-
1. **复制插件文件** 到 opencode 插件目录:
|
|
229
|
+
在 `~/.config/opencode/.env` 中配置 `CHAT_CHANNELS`,指定要开启哪些渠道:
|
|
201
230
|
|
|
202
231
|
```bash
|
|
203
|
-
|
|
204
|
-
```
|
|
232
|
+
# ~/.config/opencode/.env
|
|
205
233
|
|
|
206
|
-
|
|
234
|
+
# 只开启飞书(初始推荐)
|
|
235
|
+
CHAT_CHANNELS=feishu
|
|
207
236
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
237
|
+
# 同时开启多个渠道
|
|
238
|
+
# CHAT_CHANNELS=feishu,wecom
|
|
239
|
+
|
|
240
|
+
# 留空或不配置 = 自动模式:凭证存在的渠道自动启用
|
|
241
|
+
# CHAT_CHANNELS=
|
|
214
242
|
```
|
|
215
243
|
|
|
216
|
-
|
|
244
|
+
然后配置对应渠道的凭证(见下方各渠道配置说明)。
|
|
245
|
+
|
|
246
|
+
#### 第三步:配置渠道凭证
|
|
217
247
|
|
|
218
|
-
|
|
248
|
+
参见下方各渠道的配置说明。
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### 飞书配置
|
|
219
253
|
|
|
220
254
|
#### 第一步:创建飞书自建应用
|
|
221
255
|
|
|
@@ -238,17 +272,25 @@ cp src/index.ts ~/.config/opencode/plugins/chat-channel.ts
|
|
|
238
272
|
# ~/.config/opencode/.env
|
|
239
273
|
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
240
274
|
|
|
241
|
-
# 可选:自定义 opencode API 地址(默认:
|
|
275
|
+
# 可选:自定义 opencode API 地址(默认:http://localhost:4321)
|
|
242
276
|
# OPENCODE_BASE_URL=http://localhost:4321
|
|
243
277
|
```
|
|
244
278
|
|
|
245
|
-
**App Secret
|
|
279
|
+
**App Secret**—按使用的平台选择存储方式:
|
|
246
280
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
281
|
+
| 平台 | 方式 | 命令 |
|
|
282
|
+
|------|------|------|
|
|
283
|
+
| macOS | 钒匙串(推荐,不落盘明文) | `security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U` |
|
|
284
|
+
| Windows / Linux | 写入 `.env` 文件 | 在 `~/.config/opencode/.env` 中添加 `FEISHU_APP_SECRET=<secret>` |
|
|
285
|
+
| 所有平台 | 环境变量 | 启动 opencode 前设置 `FEISHU_APP_SECRET=<secret>` |
|
|
250
286
|
|
|
251
|
-
|
|
287
|
+
> **读取优先级**:环境变量 → macOS 钒匙串 → `.env` 文件(已在插件启动时自动读入环境变量)。
|
|
288
|
+
> 插件依次尝试,找到第一个有效值即停止。
|
|
289
|
+
|
|
290
|
+
> ⚠️ 如果将 Secret 写入 `.env`,建议限制文件权限:
|
|
291
|
+
> `chmod 600 ~/.config/opencode/.env`
|
|
292
|
+
|
|
293
|
+
**macOS 钒匙串**验证:
|
|
252
294
|
|
|
253
295
|
```bash
|
|
254
296
|
security find-generic-password -a chat-channel -s opencode-chat-channel -w
|
|
@@ -263,19 +305,39 @@ opencode
|
|
|
263
305
|
启动后日志中会看到:
|
|
264
306
|
|
|
265
307
|
```
|
|
266
|
-
[
|
|
308
|
+
[feishu] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
309
|
+
chat-channel 已启动,活跃渠道: feishu
|
|
267
310
|
```
|
|
268
311
|
|
|
269
|
-
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
### 接入新渠道
|
|
315
|
+
|
|
316
|
+
插件基于 `ChatChannel` 接口设计,新增渠道步骤:
|
|
317
|
+
|
|
318
|
+
1. 新建 `src/channels/<渠道名>/index.ts`,实现 `ChatChannel` 接口并导出 `ChannelFactory`
|
|
319
|
+
2. 在 `src/index.ts` 的 `CHANNEL_FACTORIES` 数组中注册工厂函数
|
|
320
|
+
|
|
321
|
+
参见 `src/channels/wecom/index.ts` — 企业微信骨架,含详细的实现 TODO 注释。
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
### 项目结构
|
|
270
326
|
|
|
271
327
|
```
|
|
272
|
-
|
|
273
|
-
├──
|
|
274
|
-
|
|
275
|
-
├──
|
|
276
|
-
└──
|
|
328
|
+
src/
|
|
329
|
+
├── index.ts # 插件入口,注册所有渠道
|
|
330
|
+
├── types.ts # ChatChannel 接口 & 公共类型
|
|
331
|
+
├── session-manager.ts # opencode session 管理 & 工具函数
|
|
332
|
+
└── channels/
|
|
333
|
+
├── feishu/
|
|
334
|
+
│ └── index.ts # 飞书渠道实现(已完成)
|
|
335
|
+
└── wecom/
|
|
336
|
+
└── index.ts # 企业微信渠道骨架(待实现)
|
|
277
337
|
```
|
|
278
338
|
|
|
339
|
+
---
|
|
340
|
+
|
|
279
341
|
### 常见问题
|
|
280
342
|
|
|
281
343
|
**Q: 插件启动后收不到消息**
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-chat-channel — 飞书渠道适配器
|
|
3
|
+
*
|
|
4
|
+
* 通过飞书长连接(WebSocket)接收消息,实现 ChatChannel 接口。
|
|
5
|
+
*
|
|
6
|
+
* 凭证读取优先级(FEISHU_APP_SECRET):
|
|
7
|
+
* 1. 环境变量 FEISHU_APP_SECRET(所有平台,最高优先)
|
|
8
|
+
* 2. macOS Keychain(仅 macOS,自动尝试)
|
|
9
|
+
* service=opencode-chat-channel,account=chat-channel
|
|
10
|
+
* 写入:security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U
|
|
11
|
+
* 3. ~/.config/opencode/.env 中的 FEISHU_APP_SECRET(Windows/Linux 推荐)
|
|
12
|
+
*
|
|
13
|
+
* FEISHU_APP_ID 始终从 .env 文件读取(非敏感)。
|
|
14
|
+
*/
|
|
15
|
+
import type { ChannelFactory } from "../../types.js";
|
|
16
|
+
/**
|
|
17
|
+
* 飞书渠道工厂函数。
|
|
18
|
+
* 凭证不完整时记录警告日志并返回 null(插件跳过该渠道)。
|
|
19
|
+
*/
|
|
20
|
+
export declare const feishuChannelFactory: ChannelFactory;
|
|
21
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/feishu/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AAyKjG;;;GAGG;AACH,eAAO,MAAM,oBAAoB,EAAE,cAuBlC,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-chat-channel — 企业微信渠道适配器(骨架)
|
|
3
|
+
*
|
|
4
|
+
* 企业微信接入方式:
|
|
5
|
+
* - 自建应用通过"企业微信回调"(HTTP Webhook)接收消息
|
|
6
|
+
* - 或通过"接收消息" API(需要公网地址或内网穿透)
|
|
7
|
+
*
|
|
8
|
+
* 凭证配置(待实现时填充):
|
|
9
|
+
* WECOM_CORP_ID 企业 ID,存放在 ~/.config/opencode/.env
|
|
10
|
+
* WECOM_AGENT_ID 应用 AgentId,存放在 ~/.config/opencode/.env
|
|
11
|
+
* WECOM_SECRET 应用 Secret,存 macOS Keychain:
|
|
12
|
+
* security add-generic-password -a chat-channel-wecom -s opencode-chat-channel -w <secret> -U
|
|
13
|
+
* WECOM_TOKEN 企业微信回调 Token,存放在 ~/.config/opencode/.env
|
|
14
|
+
* WECOM_ENCODING_AES_KEY 企业微信消息加解密 Key,存 macOS Keychain:
|
|
15
|
+
* security add-generic-password -a chat-channel-wecom-aes -s opencode-chat-channel -w <key> -U
|
|
16
|
+
*
|
|
17
|
+
* TODO: 完整实现
|
|
18
|
+
* 1. 启动 HTTP 服务器接收企业微信回调(验证 Token、解密消息体)
|
|
19
|
+
* 2. 调用企业微信 API 发送文本消息
|
|
20
|
+
* 3. 支持消息加解密(EnterpriseWeChatCrypto)
|
|
21
|
+
*
|
|
22
|
+
* 参考文档:
|
|
23
|
+
* https://developer.work.weixin.qq.com/document/path/90238
|
|
24
|
+
* https://developer.work.weixin.qq.com/document/path/90236
|
|
25
|
+
*/
|
|
26
|
+
import type { ChannelFactory } from "../../types.js";
|
|
27
|
+
/**
|
|
28
|
+
* 企业微信渠道工厂函数。
|
|
29
|
+
*
|
|
30
|
+
* 读取 WECOM_CORP_ID / WECOM_AGENT_ID 环境变量,
|
|
31
|
+
* 以及 Keychain 中的 WECOM_SECRET。
|
|
32
|
+
* 任意凭证缺失时返回 null,插件跳过该渠道。
|
|
33
|
+
*
|
|
34
|
+
* @todo 待企业微信实现完成后启用此工厂函数
|
|
35
|
+
*/
|
|
36
|
+
export declare const wecomChannelFactory: ChannelFactory;
|
|
37
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/channels/wecom/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,KAAK,EAAe,cAAc,EAAiC,MAAM,gBAAgB,CAAC;AA4FjG;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,EAAE,cAuBjC,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencode-chat-channel — opencode
|
|
2
|
+
* opencode-chat-channel — opencode 多渠道机器人插件
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 通过 .env 文件中的 CHAT_CHANNELS 配置项选择启用哪些渠道:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U
|
|
6
|
+
* CHAT_CHANNELS=feishu # 只启用飞书
|
|
7
|
+
* CHAT_CHANNELS=feishu,wecom # 同时启用飞书和企业微信
|
|
8
|
+
* CHAT_CHANNELS= # 留空:自动探测(凭证存在即启用)
|
|
9
|
+
* # 不配置此项:同留空,自动探测
|
|
11
10
|
*
|
|
12
|
-
*
|
|
11
|
+
* 其他配置项:
|
|
12
|
+
* OPENCODE_BASE_URL opencode API 地址(默认 http://localhost:4321)
|
|
13
|
+
*
|
|
14
|
+
* 各渠道的凭证配置详见 README。
|
|
13
15
|
*/
|
|
14
16
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
15
17
|
export declare const ChatChannelPlugin: Plugin;
|
|
16
18
|
export default ChatChannelPlugin;
|
|
19
|
+
export type { ChatChannel, ChannelFactory, ChannelName, IncomingMessage, PluginClient } from "./types.js";
|
|
20
|
+
export { SessionManager, extractResponseText, loadDotEnv } from "./session-manager.js";
|
|
17
21
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAgIlD,eAAO,MAAM,iBAAiB,EAAE,MAyF/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAGjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,WAAW,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,59 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
3
|
-
import { execFileSync } from "child_process";
|
|
4
|
-
import { readFileSync } from "fs";
|
|
5
2
|
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
// src/session-manager.ts
|
|
5
|
+
import { readFileSync } from "fs";
|
|
6
|
+
var SESSION_TTL_MS = 2 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
class SessionManager {
|
|
9
|
+
client;
|
|
10
|
+
channel;
|
|
11
|
+
titleFn;
|
|
12
|
+
constructor(client, channel, titleFn = (id) => `${channel} · ${id}`) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
this.channel = channel;
|
|
15
|
+
this.titleFn = titleFn;
|
|
16
|
+
}
|
|
17
|
+
sessions = new Map;
|
|
18
|
+
async getOrCreate(userId) {
|
|
19
|
+
const existing = this.sessions.get(userId);
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
if (existing && now - existing.lastActivity < SESSION_TTL_MS) {
|
|
22
|
+
existing.lastActivity = now;
|
|
23
|
+
return existing.sessionId;
|
|
24
|
+
}
|
|
25
|
+
const res = await this.client.session.create({
|
|
26
|
+
body: { title: this.titleFn(userId) }
|
|
27
|
+
});
|
|
28
|
+
const sessionId = res.data.id;
|
|
29
|
+
this.sessions.set(userId, { sessionId, lastActivity: now });
|
|
30
|
+
await this.client.app.log({
|
|
31
|
+
body: {
|
|
32
|
+
service: "chat-channel",
|
|
33
|
+
level: "info",
|
|
34
|
+
message: `[${this.channel}] 创建新 session: ${sessionId}`,
|
|
35
|
+
extra: { userId }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
return sessionId;
|
|
39
|
+
}
|
|
40
|
+
cleanup() {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
for (const [userId, meta] of this.sessions.entries()) {
|
|
43
|
+
if (now - meta.lastActivity > SESSION_TTL_MS) {
|
|
44
|
+
this.sessions.delete(userId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
startAutoCleanup(intervalMs = 30 * 60 * 1000) {
|
|
49
|
+
return setInterval(() => this.cleanup(), intervalMs);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function extractResponseText(parts) {
|
|
53
|
+
if (!Array.isArray(parts))
|
|
54
|
+
return "";
|
|
55
|
+
return parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
|
|
56
|
+
}
|
|
6
57
|
function loadDotEnv(envPath) {
|
|
7
58
|
try {
|
|
8
59
|
const content = readFileSync(envPath, "utf8");
|
|
@@ -22,95 +73,84 @@ function loadDotEnv(envPath) {
|
|
|
22
73
|
}
|
|
23
74
|
} catch {}
|
|
24
75
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"find-generic-password",
|
|
35
|
-
"-a",
|
|
36
|
-
"chat-channel",
|
|
37
|
-
"-s",
|
|
38
|
-
"opencode-chat-channel",
|
|
39
|
-
"-w"
|
|
40
|
-
], { encoding: "utf8" });
|
|
41
|
-
appSecret = stdout.trim() || undefined;
|
|
42
|
-
} catch {
|
|
43
|
-
appSecret = undefined;
|
|
76
|
+
|
|
77
|
+
// src/channels/feishu/index.ts
|
|
78
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
79
|
+
import { execFileSync } from "child_process";
|
|
80
|
+
var MAX_MSG_LEN = 4000;
|
|
81
|
+
var MAX_PROCESSED = 500;
|
|
82
|
+
function readAppSecret() {
|
|
83
|
+
if (process.env["FEISHU_APP_SECRET"]) {
|
|
84
|
+
return process.env["FEISHU_APP_SECRET"];
|
|
44
85
|
}
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
if (process.platform === "darwin") {
|
|
87
|
+
try {
|
|
88
|
+
const stdout = execFileSync("security", ["find-generic-password", "-a", "chat-channel", "-s", "opencode-chat-channel", "-w"], { encoding: "utf8" });
|
|
89
|
+
return stdout.trim() || undefined;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class FeishuChannel {
|
|
96
|
+
appId;
|
|
97
|
+
appSecret;
|
|
98
|
+
client;
|
|
99
|
+
name = "feishu";
|
|
100
|
+
larkClient;
|
|
101
|
+
processedMessages = new Set;
|
|
102
|
+
constructor(appId, appSecret, client) {
|
|
103
|
+
this.appId = appId;
|
|
104
|
+
this.appSecret = appSecret;
|
|
105
|
+
this.client = client;
|
|
106
|
+
this.larkClient = new Lark.Client({
|
|
107
|
+
appId,
|
|
108
|
+
appSecret,
|
|
109
|
+
appType: Lark.AppType.SelfBuild,
|
|
110
|
+
domain: Lark.Domain.Feishu
|
|
52
111
|
});
|
|
53
|
-
return {};
|
|
54
112
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
});
|
|
61
|
-
const userSessions = new Map;
|
|
62
|
-
const processedMessages = new Set;
|
|
63
|
-
const MAX_PROCESSED = 500;
|
|
64
|
-
async function getOrCreateSession(openId) {
|
|
65
|
-
const existing = userSessions.get(openId);
|
|
66
|
-
const now = Date.now();
|
|
67
|
-
if (existing && now - existing.lastActivity < SESSION_TTL_MS) {
|
|
68
|
-
existing.lastActivity = now;
|
|
69
|
-
return existing.sessionId;
|
|
70
|
-
}
|
|
71
|
-
const res = await client.session.create({
|
|
72
|
-
body: { title: `飞书对话 · ${openId}` }
|
|
113
|
+
async start(onMessage) {
|
|
114
|
+
const wsClient = new Lark.WSClient({
|
|
115
|
+
appId: this.appId,
|
|
116
|
+
appSecret: this.appSecret,
|
|
117
|
+
loggerLevel: Lark.LoggerLevel.warn
|
|
73
118
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
119
|
+
wsClient.start({
|
|
120
|
+
eventDispatcher: new Lark.EventDispatcher({}).register({
|
|
121
|
+
"im.message.receive_v1": async (data) => {
|
|
122
|
+
const parsed = this.parseEvent(data);
|
|
123
|
+
if (parsed)
|
|
124
|
+
await onMessage(parsed);
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
});
|
|
128
|
+
await this.client.app.log({
|
|
77
129
|
body: {
|
|
78
130
|
service: "chat-channel",
|
|
79
131
|
level: "info",
|
|
80
|
-
message:
|
|
81
|
-
extra: { openId }
|
|
132
|
+
message: `[feishu] 飞书机器人已启动(长连接模式),appId=${this.appId.slice(0, 8)}***`
|
|
82
133
|
}
|
|
83
134
|
});
|
|
84
|
-
return sessionId;
|
|
85
135
|
}
|
|
86
|
-
|
|
87
|
-
const now = Date.now();
|
|
88
|
-
for (const [openId, meta] of userSessions.entries()) {
|
|
89
|
-
if (now - meta.lastActivity > SESSION_TTL_MS) {
|
|
90
|
-
userSessions.delete(openId);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
setInterval(cleanupExpiredSessions, 30 * 60 * 1000);
|
|
95
|
-
async function replyToFeishu(chatId, text) {
|
|
96
|
-
const MAX_LEN = 4000;
|
|
136
|
+
async send(replyTarget, text) {
|
|
97
137
|
const chunks = [];
|
|
98
|
-
for (let i = 0;i < text.length; i +=
|
|
99
|
-
chunks.push(text.slice(i, i +
|
|
138
|
+
for (let i = 0;i < text.length; i += MAX_MSG_LEN) {
|
|
139
|
+
chunks.push(text.slice(i, i + MAX_MSG_LEN));
|
|
100
140
|
}
|
|
101
141
|
for (const chunk of chunks) {
|
|
102
|
-
await larkClient.im.message.create({
|
|
142
|
+
await this.larkClient.im.message.create({
|
|
103
143
|
params: { receive_id_type: "chat_id" },
|
|
104
144
|
data: {
|
|
105
|
-
receive_id:
|
|
145
|
+
receive_id: replyTarget,
|
|
106
146
|
content: JSON.stringify({ text: chunk }),
|
|
107
147
|
msg_type: "text"
|
|
108
148
|
}
|
|
109
149
|
});
|
|
110
150
|
}
|
|
111
151
|
}
|
|
112
|
-
async
|
|
113
|
-
await larkClient.im.message.create({
|
|
152
|
+
async sendThinking(chatId) {
|
|
153
|
+
await this.larkClient.im.message.create({
|
|
114
154
|
params: { receive_id_type: "chat_id" },
|
|
115
155
|
data: {
|
|
116
156
|
receive_id: chatId,
|
|
@@ -119,15 +159,12 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
119
159
|
}
|
|
120
160
|
});
|
|
121
161
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
async function handleMessage(data) {
|
|
128
|
-
const { message, sender } = data;
|
|
162
|
+
parseEvent(data) {
|
|
163
|
+
const { message, sender } = data ?? {};
|
|
164
|
+
if (!message || !sender)
|
|
165
|
+
return null;
|
|
129
166
|
if (message.message_type !== "text") {
|
|
130
|
-
|
|
167
|
+
this.larkClient.im.message.create({
|
|
131
168
|
params: { receive_id_type: "chat_id" },
|
|
132
169
|
data: {
|
|
133
170
|
receive_id: message.chat_id,
|
|
@@ -135,48 +172,142 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
135
172
|
msg_type: "text"
|
|
136
173
|
}
|
|
137
174
|
});
|
|
138
|
-
return;
|
|
175
|
+
return null;
|
|
139
176
|
}
|
|
140
177
|
const msgId = message.message_id;
|
|
141
|
-
if (processedMessages.has(msgId))
|
|
178
|
+
if (this.processedMessages.has(msgId))
|
|
179
|
+
return null;
|
|
180
|
+
if (this.processedMessages.size >= MAX_PROCESSED)
|
|
181
|
+
this.processedMessages.clear();
|
|
182
|
+
this.processedMessages.add(msgId);
|
|
183
|
+
let text;
|
|
184
|
+
try {
|
|
185
|
+
text = JSON.parse(message.content).text?.trim();
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
if (!text)
|
|
190
|
+
return null;
|
|
191
|
+
return {
|
|
192
|
+
messageId: msgId,
|
|
193
|
+
userId: sender.sender_id?.open_id ?? message.chat_id,
|
|
194
|
+
replyTarget: message.chat_id,
|
|
195
|
+
text
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
var feishuChannelFactory = async (client) => {
|
|
200
|
+
const appId = process.env["FEISHU_APP_ID"];
|
|
201
|
+
const appSecret = readAppSecret();
|
|
202
|
+
if (!appId || !appSecret) {
|
|
203
|
+
const isMac = process.platform === "darwin";
|
|
204
|
+
const secretHint = isMac ? `macOS: security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U
|
|
205
|
+
` + " 或在 .env 中添加 FEISHU_APP_SECRET=<secret>" : ".env 中添加 FEISHU_APP_SECRET=<secret>";
|
|
206
|
+
await client.app.log({
|
|
207
|
+
body: {
|
|
208
|
+
service: "chat-channel",
|
|
209
|
+
level: "warn",
|
|
210
|
+
message: !appId ? "[feishu] FEISHU_APP_ID 未设置,请在 .env 中添加 FEISHU_APP_ID=cli_xxx,飞书渠道已跳过" : `[feishu] FEISHU_APP_SECRET 未找到,飞书渠道已跳过。请配置:${secretHint}`
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return new FeishuChannel(appId, appSecret, client);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// src/channels/wecom/index.ts
|
|
219
|
+
class WeComChannel {
|
|
220
|
+
corpId;
|
|
221
|
+
agentId;
|
|
222
|
+
secret;
|
|
223
|
+
client;
|
|
224
|
+
name = "wecom";
|
|
225
|
+
constructor(corpId, agentId, secret, client) {
|
|
226
|
+
this.corpId = corpId;
|
|
227
|
+
this.agentId = agentId;
|
|
228
|
+
this.secret = secret;
|
|
229
|
+
this.client = client;
|
|
230
|
+
}
|
|
231
|
+
async start(onMessage) {
|
|
232
|
+
await this.client.app.log({
|
|
233
|
+
body: {
|
|
234
|
+
service: "chat-channel",
|
|
235
|
+
level: "warn",
|
|
236
|
+
message: "[wecom] 企业微信渠道尚未实现,已跳过。如需接入,请参考 src/channels/wecom/index.ts 中的 TODO。"
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
async send(replyTarget, text) {
|
|
241
|
+
throw new Error("[wecom] WeComChannel.send() 尚未实现");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
var wecomChannelFactory = async (client) => {
|
|
245
|
+
const corpId = process.env["WECOM_CORP_ID"];
|
|
246
|
+
const agentId = process.env["WECOM_AGENT_ID"];
|
|
247
|
+
const secret = undefined;
|
|
248
|
+
if (!corpId || !agentId || !secret) {
|
|
249
|
+
if (corpId || agentId) {
|
|
142
250
|
await client.app.log({
|
|
143
251
|
body: {
|
|
144
252
|
service: "chat-channel",
|
|
145
|
-
level: "
|
|
146
|
-
message:
|
|
253
|
+
level: "warn",
|
|
254
|
+
message: "[wecom] 企业微信凭证不完整(需要 WECOM_CORP_ID、WECOM_AGENT_ID、WECOM_SECRET),渠道已跳过"
|
|
147
255
|
}
|
|
148
256
|
});
|
|
149
|
-
return;
|
|
150
257
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return new WeComChannel(corpId, agentId, secret, client);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// src/index.ts
|
|
264
|
+
var CHANNEL_REGISTRY = {
|
|
265
|
+
feishu: feishuChannelFactory,
|
|
266
|
+
wecom: wecomChannelFactory
|
|
267
|
+
};
|
|
268
|
+
function resolveEnabledChannels(client) {
|
|
269
|
+
const raw = process.env["CHAT_CHANNELS"]?.trim();
|
|
270
|
+
if (!raw)
|
|
271
|
+
return null;
|
|
272
|
+
const requested = raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
273
|
+
const known = Object.keys(CHANNEL_REGISTRY);
|
|
274
|
+
const enabled = [];
|
|
275
|
+
for (const name of requested) {
|
|
276
|
+
if (known.includes(name)) {
|
|
277
|
+
enabled.push(name);
|
|
278
|
+
} else {
|
|
279
|
+
client.app.log({
|
|
280
|
+
body: {
|
|
281
|
+
service: "chat-channel",
|
|
282
|
+
level: "warn",
|
|
283
|
+
message: `[config] 未知渠道名 "${name}",已忽略。可用渠道:${known.join(", ")}`
|
|
284
|
+
}
|
|
285
|
+
});
|
|
159
286
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
287
|
+
}
|
|
288
|
+
return enabled;
|
|
289
|
+
}
|
|
290
|
+
function createMessageHandler(channel, sessionManager, client) {
|
|
291
|
+
return async (msg) => {
|
|
292
|
+
const { userId, replyTarget, text } = msg;
|
|
164
293
|
await client.app.log({
|
|
165
294
|
body: {
|
|
166
295
|
service: "chat-channel",
|
|
167
296
|
level: "info",
|
|
168
|
-
message:
|
|
169
|
-
extra: {
|
|
297
|
+
message: `[${channel.name}] 收到消息: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`,
|
|
298
|
+
extra: { userId, replyTarget }
|
|
170
299
|
}
|
|
171
300
|
});
|
|
172
|
-
|
|
301
|
+
if ("sendThinking" in channel && typeof channel.sendThinking === "function") {
|
|
302
|
+
await channel.sendThinking(replyTarget);
|
|
303
|
+
}
|
|
173
304
|
let responseText = null;
|
|
174
305
|
try {
|
|
175
|
-
const sessionId = await
|
|
306
|
+
const sessionId = await sessionManager.getOrCreate(userId);
|
|
176
307
|
const result = await client.session.prompt({
|
|
177
308
|
path: { id: sessionId },
|
|
178
309
|
body: {
|
|
179
|
-
parts: [{ type: "text", text
|
|
310
|
+
parts: [{ type: "text", text }],
|
|
180
311
|
model: {
|
|
181
312
|
providerID: "Mify-Anthropic",
|
|
182
313
|
modelID: "ppio/pa/claude-sonnet-4-6"
|
|
@@ -190,34 +321,60 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
190
321
|
body: {
|
|
191
322
|
service: "chat-channel",
|
|
192
323
|
level: "error",
|
|
193
|
-
message:
|
|
194
|
-
extra: {
|
|
324
|
+
message: `[${channel.name}] 处理消息失败: ${errorMsg}`,
|
|
325
|
+
extra: { userId }
|
|
195
326
|
}
|
|
196
327
|
});
|
|
197
|
-
await
|
|
328
|
+
await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
|
|
198
329
|
return;
|
|
199
330
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
331
|
+
await channel.send(replyTarget, responseText || "(AI 没有返回文字回复)");
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
var ChatChannelPlugin = async ({ client }) => {
|
|
335
|
+
const configDir = join(process.env["HOME"] ?? `/Users/${process.env["USER"] ?? "unknown"}`, ".config", "opencode");
|
|
336
|
+
loadDotEnv(join(configDir, ".env"));
|
|
337
|
+
const enabledNames = resolveEnabledChannels(client);
|
|
338
|
+
const factories = enabledNames ? enabledNames.map((name) => [name, CHANNEL_REGISTRY[name]]) : Object.entries(CHANNEL_REGISTRY);
|
|
339
|
+
if (enabledNames) {
|
|
340
|
+
await client.app.log({
|
|
341
|
+
body: {
|
|
342
|
+
service: "chat-channel",
|
|
343
|
+
level: "info",
|
|
344
|
+
message: `[config] CHAT_CHANNELS="${process.env["CHAT_CHANNELS"]}",将启用: ${enabledNames.join(", ") || "(无)"}`
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
const channels = [];
|
|
349
|
+
for (const [, factory] of factories) {
|
|
350
|
+
const channel = await factory(client);
|
|
351
|
+
if (channel)
|
|
352
|
+
channels.push(channel);
|
|
353
|
+
}
|
|
354
|
+
if (channels.length === 0) {
|
|
355
|
+
const hint = enabledNames ? `检查 CHAT_CHANNELS="${process.env["CHAT_CHANNELS"]}" 指定的渠道是否已配置凭证` : "请在 .env 中配置至少一个渠道的凭证,或设置 CHAT_CHANNELS=<渠道名> 明确指定";
|
|
356
|
+
await client.app.log({
|
|
357
|
+
body: {
|
|
358
|
+
service: "chat-channel",
|
|
359
|
+
level: "warn",
|
|
360
|
+
message: `所有渠道均未就绪,插件空启动。${hint}`
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
const cleanupTimers = [];
|
|
366
|
+
for (const channel of channels) {
|
|
367
|
+
const sessionManager = new SessionManager(client, channel.name, (userId) => `${channel.name} 对话 · ${userId}`);
|
|
368
|
+
const timer = sessionManager.startAutoCleanup();
|
|
369
|
+
cleanupTimers.push(timer);
|
|
370
|
+
const handleMessage = createMessageHandler(channel, sessionManager, client);
|
|
371
|
+
await channel.start(handleMessage);
|
|
205
372
|
}
|
|
206
|
-
const wsClient = new Lark.WSClient({
|
|
207
|
-
appId,
|
|
208
|
-
appSecret,
|
|
209
|
-
loggerLevel: Lark.LoggerLevel.warn
|
|
210
|
-
});
|
|
211
|
-
wsClient.start({
|
|
212
|
-
eventDispatcher: new Lark.EventDispatcher({}).register({
|
|
213
|
-
"im.message.receive_v1": handleMessage
|
|
214
|
-
})
|
|
215
|
-
});
|
|
216
373
|
await client.app.log({
|
|
217
374
|
body: {
|
|
218
375
|
service: "chat-channel",
|
|
219
376
|
level: "info",
|
|
220
|
-
message:
|
|
377
|
+
message: `chat-channel 已启动,活跃渠道: ${channels.map((c) => c.name).join(", ")}`
|
|
221
378
|
}
|
|
222
379
|
});
|
|
223
380
|
return {
|
|
@@ -237,6 +394,9 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
237
394
|
};
|
|
238
395
|
var src_default = ChatChannelPlugin;
|
|
239
396
|
export {
|
|
397
|
+
loadDotEnv,
|
|
398
|
+
extractResponseText,
|
|
240
399
|
src_default as default,
|
|
400
|
+
SessionManager,
|
|
241
401
|
ChatChannelPlugin
|
|
242
402
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-chat-channel — SessionManager
|
|
3
|
+
*
|
|
4
|
+
* 管理"渠道用户 ID → opencode session"的映射。
|
|
5
|
+
* 与具体渠道无关,所有 channel 实现共享同一套逻辑。
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginClient } from "./types.js";
|
|
8
|
+
export declare class SessionManager {
|
|
9
|
+
private readonly client;
|
|
10
|
+
private readonly channel;
|
|
11
|
+
private readonly titleFn;
|
|
12
|
+
/**
|
|
13
|
+
* @param client opencode plugin 客户端(用于创建 session 和日志)
|
|
14
|
+
* @param channel 渠道名称(用于日志标识)
|
|
15
|
+
* @param titleFn 生成 session 标题的函数(可选,默认使用 userId)
|
|
16
|
+
*/
|
|
17
|
+
constructor(client: PluginClient, channel: string, titleFn?: (userId: string) => string);
|
|
18
|
+
private readonly sessions;
|
|
19
|
+
/** 获取已有的有效 session,或为该用户创建新 session。 */
|
|
20
|
+
getOrCreate(userId: string): Promise<string>;
|
|
21
|
+
/** 清理所有已过期的 session 记录。 */
|
|
22
|
+
cleanup(): void;
|
|
23
|
+
/** 启动定时清理(默认每 30 分钟)。返回 timer,供外部 clearInterval。 */
|
|
24
|
+
startAutoCleanup(intervalMs?: number): ReturnType<typeof setInterval>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 从 AI 响应 parts 中提取纯文本。
|
|
28
|
+
* 与渠道无关,各 channel 实现均可复用。
|
|
29
|
+
*/
|
|
30
|
+
export declare function extractResponseText(parts: unknown[]): string;
|
|
31
|
+
/**
|
|
32
|
+
* 从 ~/.config/opencode/.env 读取环境变量并注入 process.env。
|
|
33
|
+
* opencode 不会自动加载该文件,需插件自行调用。
|
|
34
|
+
*/
|
|
35
|
+
export declare function loadDotEnv(envPath: string): void;
|
|
36
|
+
//# sourceMappingURL=session-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAgB/C,qBAAa,cAAc;IAOvB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAR1B;;;;OAIG;gBAEgB,MAAM,EAAE,YAAY,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MACxB;IAGxB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkC;IAE3D,wCAAwC;IAClC,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA6BlD,2BAA2B;IAC3B,OAAO,IAAI,IAAI;IASf,oDAAoD;IACpD,gBAAgB,CAAC,UAAU,SAAiB,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC;CAG9E;AAID;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,MAAM,CAO5D;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAiBhD"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-chat-channel — 多渠道抽象层
|
|
3
|
+
*
|
|
4
|
+
* 每个渠道(飞书、企业微信等)实现 ChatChannel 接口即可接入。
|
|
5
|
+
*/
|
|
6
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
7
|
+
/** opencode Plugin 客户端类型(从 Plugin 回调参数中提取) */
|
|
8
|
+
export type PluginClient = Parameters<Plugin>[0]["client"];
|
|
9
|
+
/** 从渠道收到的标准化消息 */
|
|
10
|
+
export interface IncomingMessage {
|
|
11
|
+
/** 消息唯一 ID(用于去重) */
|
|
12
|
+
messageId: string;
|
|
13
|
+
/** 用户唯一标识(用于 session 绑定,不同渠道格式不同) */
|
|
14
|
+
userId: string;
|
|
15
|
+
/** 回复目标 ID(群 ID / 用户 ID,由渠道决定具体含义) */
|
|
16
|
+
replyTarget: string;
|
|
17
|
+
/** 消息文本内容 */
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* ChatChannel — 渠道适配器接口
|
|
22
|
+
*
|
|
23
|
+
* 每个渠道实现此接口:
|
|
24
|
+
* - name: 渠道标识符(用于日志、配置 key)
|
|
25
|
+
* - start(): 启动监听,收到消息时调用 onMessage 回调
|
|
26
|
+
* - send(): 向指定 target 发送文本回复
|
|
27
|
+
* - stop(): 优雅关闭(可选)
|
|
28
|
+
*/
|
|
29
|
+
export interface ChatChannel {
|
|
30
|
+
/** 渠道唯一名称,如 "feishu"、"wecom" */
|
|
31
|
+
readonly name: string;
|
|
32
|
+
/**
|
|
33
|
+
* 启动渠道监听。
|
|
34
|
+
* 每当收到用户消息时,调用 onMessage(msg)。
|
|
35
|
+
*/
|
|
36
|
+
start(onMessage: (msg: IncomingMessage) => Promise<void>): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* 向指定目标发送文本消息。
|
|
39
|
+
* replyTarget 为 IncomingMessage.replyTarget。
|
|
40
|
+
*/
|
|
41
|
+
send(replyTarget: string, text: string): Promise<void>;
|
|
42
|
+
/** 优雅停止渠道(可选)。 */
|
|
43
|
+
stop?(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* ChannelFactory — 渠道工厂函数签名
|
|
47
|
+
*
|
|
48
|
+
* 工厂函数负责读取配置、验证凭证、构造并返回 ChatChannel 实例。
|
|
49
|
+
* 若凭证未配置,应返回 null(插件将跳过该渠道并记录日志)。
|
|
50
|
+
*/
|
|
51
|
+
export type ChannelFactory = (client: PluginClient) => Promise<ChatChannel | null>;
|
|
52
|
+
/**
|
|
53
|
+
* 已注册的渠道名称。
|
|
54
|
+
* 新增渠道时在此处添加对应字面量,便于编译期检查。
|
|
55
|
+
*/
|
|
56
|
+
export type ChannelName = "feishu" | "wecom";
|
|
57
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAElD,8CAA8C;AAC9C,MAAM,MAAM,YAAY,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AAI3D,kBAAkB;AAClB,MAAM,WAAW,eAAe;IAC9B,oBAAoB;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAID;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW;IAC1B,gCAAgC;IAChC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;OAGG;IACH,KAAK,CAAC,SAAS,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEzE;;;OAGG;IACH,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvD,kBAAkB;IAClB,IAAI,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAID;;;;;GAKG;AACH,MAAM,MAAM,cAAc,GAAG,CAC3B,MAAM,EAAE,YAAY,KACjB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;AAIjC;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,QAAQ,GAAG,OAAO,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-chat-channel",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "opencode plugin —
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "opencode plugin — multi-channel bot (Feishu/Lark, WeCom) with extensible ChatChannel interface",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
"opencode-plugin",
|
|
30
30
|
"feishu",
|
|
31
31
|
"lark",
|
|
32
|
+
"wecom",
|
|
33
|
+
"wechat",
|
|
32
34
|
"bot",
|
|
33
|
-
"websocket"
|
|
35
|
+
"websocket",
|
|
36
|
+
"multi-channel"
|
|
34
37
|
],
|
|
35
38
|
"author": "",
|
|
36
39
|
"license": "MIT",
|