opencode-chat-channel 1.0.0 → 1.1.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 +131 -107
- 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 +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +248 -121
- package/dist/session-manager.d.ts +36 -0
- package/dist/session-manager.d.ts.map +1 -0
- package/dist/types.d.ts +52 -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,46 @@ 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)
|
|
44
|
-
|
|
45
|
-
After installation, start opencode and you should see:
|
|
46
|
-
```
|
|
47
|
-
[chat-channel] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
> **Requirements**: `bun` and `curl` must be installed. macOS is required for Keychain-based secret storage.
|
|
51
|
-
|
|
52
|
-
---
|
|
53
|
-
|
|
54
|
-
### Manual Installation
|
|
55
|
-
|
|
56
|
-
1. **Copy the plugin file** to your opencode plugins directory:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
cp src/index.ts ~/.config/opencode/plugins/chat-channel.ts
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
2. **Add the dependency** to `~/.config/opencode/package.json`:
|
|
38
|
+
Edit `~/.config/opencode/opencode.json` and add the plugin:
|
|
63
39
|
|
|
64
40
|
```json
|
|
65
41
|
{
|
|
66
|
-
"
|
|
67
|
-
"@
|
|
68
|
-
|
|
42
|
+
"plugin": [
|
|
43
|
+
"opencode-chat-channel@latest"
|
|
44
|
+
]
|
|
69
45
|
}
|
|
70
46
|
```
|
|
71
47
|
|
|
72
|
-
|
|
48
|
+
opencode will pull and install the package automatically on next startup.
|
|
73
49
|
|
|
74
|
-
|
|
50
|
+
#### Step 2: Configure the channel(s) you want to use
|
|
51
|
+
|
|
52
|
+
See the [Feishu Configuration](#feishu-configuration) section below.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### Feishu Configuration
|
|
75
57
|
|
|
76
58
|
#### Step 1: Create a Feishu Self-Built App
|
|
77
59
|
|
|
@@ -98,13 +80,21 @@ FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
|
98
80
|
# OPENCODE_BASE_URL=http://localhost:4321
|
|
99
81
|
```
|
|
100
82
|
|
|
101
|
-
**App Secret**
|
|
83
|
+
**App Secret** — choose the method for your platform:
|
|
102
84
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
85
|
+
| Platform | Method | Command |
|
|
86
|
+
|----------|--------|---------|
|
|
87
|
+
| macOS | Keychain (recommended) | `security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U` |
|
|
88
|
+
| Windows / Linux | `.env` file | Add `FEISHU_APP_SECRET=<secret>` to `~/.config/opencode/.env` |
|
|
89
|
+
| All platforms | Environment variable | Set `FEISHU_APP_SECRET=<secret>` before launching opencode |
|
|
106
90
|
|
|
107
|
-
|
|
91
|
+
> **Priority**: environment variable → macOS Keychain → `.env` file value (already loaded as env var).
|
|
92
|
+
> The plugin tries each in order and uses the first one found.
|
|
93
|
+
|
|
94
|
+
> ⚠️ If you store the secret in `.env`, ensure the file has restricted permissions:
|
|
95
|
+
> `chmod 600 ~/.config/opencode/.env`
|
|
96
|
+
|
|
97
|
+
**macOS Keychain** (verify):
|
|
108
98
|
|
|
109
99
|
```bash
|
|
110
100
|
security find-generic-password -a chat-channel -s opencode-chat-channel -w
|
|
@@ -119,19 +109,47 @@ opencode
|
|
|
119
109
|
You should see in the logs:
|
|
120
110
|
|
|
121
111
|
```
|
|
122
|
-
[
|
|
112
|
+
[feishu] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
113
|
+
chat-channel 已启动,活跃渠道: feishu
|
|
123
114
|
```
|
|
124
115
|
|
|
125
|
-
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
### Adding a New Channel
|
|
126
119
|
|
|
120
|
+
The plugin uses a `ChatChannel` interface. To add a new channel:
|
|
121
|
+
|
|
122
|
+
1. Create `src/channels/<name>/index.ts` implementing `ChatChannel` and exporting a `ChannelFactory`
|
|
123
|
+
2. Register the factory in `src/index.ts` → `CHANNEL_FACTORIES` array
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// src/channels/myapp/index.ts
|
|
127
|
+
import type { ChatChannel, ChannelFactory } from "../../types.js";
|
|
128
|
+
|
|
129
|
+
class MyAppChannel implements ChatChannel {
|
|
130
|
+
readonly name = "myapp";
|
|
131
|
+
async start(onMessage) { /* connect, call onMessage on each msg */ }
|
|
132
|
+
async send(target, text) { /* send reply */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const myappChannelFactory: ChannelFactory = async (client) => {
|
|
136
|
+
// read credentials, return null if not configured
|
|
137
|
+
return new MyAppChannel(...);
|
|
138
|
+
};
|
|
127
139
|
```
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// src/index.ts — add to CHANNEL_FACTORIES
|
|
143
|
+
import { myappChannelFactory } from "./channels/myapp/index.js";
|
|
144
|
+
|
|
145
|
+
const CHANNEL_FACTORIES: ChannelFactory[] = [
|
|
146
|
+
feishuChannelFactory,
|
|
147
|
+
myappChannelFactory, // ← add here
|
|
148
|
+
];
|
|
133
149
|
```
|
|
134
150
|
|
|
151
|
+
---
|
|
152
|
+
|
|
135
153
|
### FAQ
|
|
136
154
|
|
|
137
155
|
**Q: Plugin starts but bot receives no messages**
|
|
@@ -154,68 +172,46 @@ You should see in the logs:
|
|
|
154
172
|
### 工作原理
|
|
155
173
|
|
|
156
174
|
```
|
|
157
|
-
|
|
158
|
-
↓ WebSocket 长连接
|
|
159
|
-
飞书开放平台
|
|
160
|
-
↓ @larksuiteoapi/node-sdk WSClient
|
|
175
|
+
用户发消息(飞书 / 企业微信 / ...)
|
|
176
|
+
↓ 渠道适配器(WebSocket 长连接 / HTTP 回调 / ...)
|
|
161
177
|
chat-channel 插件(本仓库)
|
|
162
178
|
↓ client.session.prompt()
|
|
163
179
|
opencode AI (Sisyphus)
|
|
164
180
|
↓ 回复文本
|
|
165
|
-
|
|
181
|
+
用户收到回复
|
|
166
182
|
```
|
|
167
183
|
|
|
168
|
-
-
|
|
184
|
+
- 每个用户独享一个 opencode session,**对话历史持久保留**
|
|
169
185
|
- session 闲置 2 小时后自动回收,下次对话开新 session
|
|
170
186
|
- AI 回复超过 4000 字时自动分段发送
|
|
171
187
|
|
|
172
188
|
---
|
|
173
189
|
|
|
174
|
-
### 一键安装(推荐)
|
|
175
|
-
|
|
176
|
-
在终端执行以下命令:
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
curl -fsSL https://raw.githubusercontent.com/coneycode/opencode-chat-channel/main/install.sh | bash
|
|
180
|
-
```
|
|
181
|
-
|
|
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 钥匙串)
|
|
187
|
-
|
|
188
|
-
安装完成后,启动 opencode 即可看到:
|
|
189
|
-
```
|
|
190
|
-
[chat-channel] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
> **前置条件**:需要已安装 `bun` 和 `curl`;App Secret 安全存储依赖 macOS 钥匙串。
|
|
194
|
-
|
|
195
|
-
---
|
|
196
|
-
|
|
197
|
-
### 手动安装
|
|
198
190
|
### 安装
|
|
199
191
|
|
|
200
|
-
|
|
192
|
+
本项目是 **opencode 插件**,opencode 通过 npm 自动管理安装,无需额外脚本。
|
|
201
193
|
|
|
202
|
-
|
|
203
|
-
cp src/index.ts ~/.config/opencode/plugins/chat-channel.ts
|
|
204
|
-
```
|
|
194
|
+
#### 第一步:添加到 `opencode.json`
|
|
205
195
|
|
|
206
|
-
|
|
196
|
+
编辑 `~/.config/opencode/opencode.json`,在 `plugin` 数组中添加:
|
|
207
197
|
|
|
208
198
|
```json
|
|
209
199
|
{
|
|
210
|
-
"
|
|
211
|
-
"@
|
|
212
|
-
|
|
200
|
+
"plugin": [
|
|
201
|
+
"opencode-chat-channel@latest"
|
|
202
|
+
]
|
|
213
203
|
}
|
|
214
204
|
```
|
|
215
205
|
|
|
216
|
-
|
|
206
|
+
下次启动 opencode 时会自动拉取并安装。
|
|
217
207
|
|
|
218
|
-
|
|
208
|
+
#### 第二步:配置需要使用的渠道
|
|
209
|
+
|
|
210
|
+
参见下方各渠道的配置说明。
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
### 飞书配置
|
|
219
215
|
|
|
220
216
|
#### 第一步:创建飞书自建应用
|
|
221
217
|
|
|
@@ -238,17 +234,25 @@ cp src/index.ts ~/.config/opencode/plugins/chat-channel.ts
|
|
|
238
234
|
# ~/.config/opencode/.env
|
|
239
235
|
FEISHU_APP_ID=cli_xxxxxxxxxxxxxxxx
|
|
240
236
|
|
|
241
|
-
# 可选:自定义 opencode API 地址(默认:
|
|
237
|
+
# 可选:自定义 opencode API 地址(默认:http://localhost:4321)
|
|
242
238
|
# OPENCODE_BASE_URL=http://localhost:4321
|
|
243
239
|
```
|
|
244
240
|
|
|
245
|
-
**App Secret
|
|
241
|
+
**App Secret**—按使用的平台选择存储方式:
|
|
246
242
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
243
|
+
| 平台 | 方式 | 命令 |
|
|
244
|
+
|------|------|------|
|
|
245
|
+
| macOS | 钒匙串(推荐,不落盘明文) | `security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U` |
|
|
246
|
+
| Windows / Linux | 写入 `.env` 文件 | 在 `~/.config/opencode/.env` 中添加 `FEISHU_APP_SECRET=<secret>` |
|
|
247
|
+
| 所有平台 | 环境变量 | 启动 opencode 前设置 `FEISHU_APP_SECRET=<secret>` |
|
|
250
248
|
|
|
251
|
-
|
|
249
|
+
> **读取优先级**:环境变量 → macOS 钒匙串 → `.env` 文件(已在插件启动时自动读入环境变量)。
|
|
250
|
+
> 插件依次尝试,找到第一个有效值即停止。
|
|
251
|
+
|
|
252
|
+
> ⚠️ 如果将 Secret 写入 `.env`,建议限制文件权限:
|
|
253
|
+
> `chmod 600 ~/.config/opencode/.env`
|
|
254
|
+
|
|
255
|
+
**macOS 钒匙串**验证:
|
|
252
256
|
|
|
253
257
|
```bash
|
|
254
258
|
security find-generic-password -a chat-channel -s opencode-chat-channel -w
|
|
@@ -263,19 +267,39 @@ opencode
|
|
|
263
267
|
启动后日志中会看到:
|
|
264
268
|
|
|
265
269
|
```
|
|
266
|
-
[
|
|
270
|
+
[feishu] 飞书机器人已启动(长连接模式),appId=cli_xxx***
|
|
271
|
+
chat-channel 已启动,活跃渠道: feishu
|
|
267
272
|
```
|
|
268
273
|
|
|
269
|
-
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
### 接入新渠道
|
|
277
|
+
|
|
278
|
+
插件基于 `ChatChannel` 接口设计,新增渠道步骤:
|
|
279
|
+
|
|
280
|
+
1. 新建 `src/channels/<渠道名>/index.ts`,实现 `ChatChannel` 接口并导出 `ChannelFactory`
|
|
281
|
+
2. 在 `src/index.ts` 的 `CHANNEL_FACTORIES` 数组中注册工厂函数
|
|
282
|
+
|
|
283
|
+
参见 `src/channels/wecom/index.ts` — 企业微信骨架,含详细的实现 TODO 注释。
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
### 项目结构
|
|
270
288
|
|
|
271
289
|
```
|
|
272
|
-
|
|
273
|
-
├──
|
|
274
|
-
|
|
275
|
-
├──
|
|
276
|
-
└──
|
|
290
|
+
src/
|
|
291
|
+
├── index.ts # 插件入口,注册所有渠道
|
|
292
|
+
├── types.ts # ChatChannel 接口 & 公共类型
|
|
293
|
+
├── session-manager.ts # opencode session 管理 & 工具函数
|
|
294
|
+
└── channels/
|
|
295
|
+
├── feishu/
|
|
296
|
+
│ └── index.ts # 飞书渠道实现(已完成)
|
|
297
|
+
└── wecom/
|
|
298
|
+
└── index.ts # 企业微信渠道骨架(待实现)
|
|
277
299
|
```
|
|
278
300
|
|
|
301
|
+
---
|
|
302
|
+
|
|
279
303
|
### 常见问题
|
|
280
304
|
|
|
281
305
|
**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,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencode-chat-channel — opencode
|
|
2
|
+
* opencode-chat-channel — opencode 多渠道机器人插件
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 支持多个即时通讯渠道同时运行,每个渠道独立处理消息。
|
|
5
|
+
* 当前已实现:飞书(Feishu/Lark)
|
|
6
|
+
* 骨架已创建:企业微信(WeCom)
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
* FEISHU_APP_ID
|
|
8
|
-
*
|
|
9
|
-
* 写入命令:
|
|
10
|
-
* security add-generic-password -a chat-channel -s opencode-chat-channel -w <secret> -U
|
|
8
|
+
* 凭证加载:
|
|
9
|
+
* FEISHU_APP_ID 等非敏感凭证存放在 ~/.config/opencode/.env
|
|
10
|
+
* 敏感凭证存放在 macOS Keychain(各渠道独立 service/account)
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* 每个渠道用户独享一个 opencode session,对话历史保留 2 小时。
|
|
13
13
|
*/
|
|
14
14
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
15
15
|
export declare const ChatChannelPlugin: Plugin;
|
|
16
16
|
export default ChatChannelPlugin;
|
|
17
|
+
export type { ChatChannel, ChannelFactory, IncomingMessage, PluginClient } from "./types.js";
|
|
18
|
+
export { SessionManager, extractResponseText, loadDotEnv } from "./session-manager.js";
|
|
17
19
|
//# 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;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAgGlD,eAAO,MAAM,iBAAiB,EAAE,MAoE/B,CAAC;AAEF,eAAe,iBAAiB,CAAC;AAGjC,YAAY,EAAE,WAAW,EAAE,cAAc,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC7F,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,121 @@ 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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return new WeComChannel(corpId, agentId, secret, client);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// src/index.ts
|
|
264
|
+
var OPENCODE_BASE_URL = process.env["OPENCODE_BASE_URL"] ?? "http://localhost:4321";
|
|
265
|
+
var CHANNEL_FACTORIES = [
|
|
266
|
+
feishuChannelFactory,
|
|
267
|
+
wecomChannelFactory
|
|
268
|
+
];
|
|
269
|
+
function createMessageHandler(channel, sessionManager, client) {
|
|
270
|
+
return async (msg) => {
|
|
271
|
+
const { userId, replyTarget, text } = msg;
|
|
164
272
|
await client.app.log({
|
|
165
273
|
body: {
|
|
166
274
|
service: "chat-channel",
|
|
167
275
|
level: "info",
|
|
168
|
-
message:
|
|
169
|
-
extra: {
|
|
276
|
+
message: `[${channel.name}] 收到消息: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`,
|
|
277
|
+
extra: { userId, replyTarget }
|
|
170
278
|
}
|
|
171
279
|
});
|
|
172
|
-
|
|
280
|
+
if ("sendThinking" in channel && typeof channel.sendThinking === "function") {
|
|
281
|
+
await channel.sendThinking(replyTarget);
|
|
282
|
+
}
|
|
173
283
|
let responseText = null;
|
|
174
284
|
try {
|
|
175
|
-
const sessionId = await
|
|
285
|
+
const sessionId = await sessionManager.getOrCreate(userId);
|
|
176
286
|
const result = await client.session.prompt({
|
|
177
287
|
path: { id: sessionId },
|
|
178
288
|
body: {
|
|
179
|
-
parts: [{ type: "text", text
|
|
289
|
+
parts: [{ type: "text", text }],
|
|
180
290
|
model: {
|
|
181
291
|
providerID: "Mify-Anthropic",
|
|
182
292
|
modelID: "ppio/pa/claude-sonnet-4-6"
|
|
@@ -190,34 +300,48 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
190
300
|
body: {
|
|
191
301
|
service: "chat-channel",
|
|
192
302
|
level: "error",
|
|
193
|
-
message:
|
|
194
|
-
extra: {
|
|
303
|
+
message: `[${channel.name}] 处理消息失败: ${errorMsg}`,
|
|
304
|
+
extra: { userId }
|
|
195
305
|
}
|
|
196
306
|
});
|
|
197
|
-
await
|
|
307
|
+
await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
|
|
198
308
|
return;
|
|
199
309
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
310
|
+
await channel.send(replyTarget, responseText || "(AI 没有返回文字回复)");
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
var ChatChannelPlugin = async ({ client }) => {
|
|
314
|
+
const configDir = join(process.env["HOME"] ?? `/Users/${process.env["USER"] ?? "unknown"}`, ".config", "opencode");
|
|
315
|
+
loadDotEnv(join(configDir, ".env"));
|
|
316
|
+
const channels = [];
|
|
317
|
+
for (const factory of CHANNEL_FACTORIES) {
|
|
318
|
+
const channel = await factory(client);
|
|
319
|
+
if (channel)
|
|
320
|
+
channels.push(channel);
|
|
321
|
+
}
|
|
322
|
+
if (channels.length === 0) {
|
|
323
|
+
await client.app.log({
|
|
324
|
+
body: {
|
|
325
|
+
service: "chat-channel",
|
|
326
|
+
level: "warn",
|
|
327
|
+
message: "所有渠道均未就绪(凭证缺失或未配置),插件空启动。"
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
return {};
|
|
331
|
+
}
|
|
332
|
+
const cleanupTimers = [];
|
|
333
|
+
for (const channel of channels) {
|
|
334
|
+
const sessionManager = new SessionManager(client, channel.name, (userId) => `${channel.name} 对话 · ${userId}`);
|
|
335
|
+
const timer = sessionManager.startAutoCleanup();
|
|
336
|
+
cleanupTimers.push(timer);
|
|
337
|
+
const handleMessage = createMessageHandler(channel, sessionManager, client);
|
|
338
|
+
await channel.start(handleMessage);
|
|
205
339
|
}
|
|
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
340
|
await client.app.log({
|
|
217
341
|
body: {
|
|
218
342
|
service: "chat-channel",
|
|
219
343
|
level: "info",
|
|
220
|
-
message:
|
|
344
|
+
message: `chat-channel 已启动,活跃渠道: ${channels.map((c) => c.name).join(", ")}`
|
|
221
345
|
}
|
|
222
346
|
});
|
|
223
347
|
return {
|
|
@@ -237,6 +361,9 @@ var ChatChannelPlugin = async ({ client }) => {
|
|
|
237
361
|
};
|
|
238
362
|
var src_default = ChatChannelPlugin;
|
|
239
363
|
export {
|
|
364
|
+
loadDotEnv,
|
|
365
|
+
extractResponseText,
|
|
240
366
|
src_default as default,
|
|
367
|
+
SessionManager,
|
|
241
368
|
ChatChannelPlugin
|
|
242
369
|
};
|
|
@@ -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,52 @@
|
|
|
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
|
+
//# 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"}
|
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.1.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",
|