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 CHANGED
@@ -1,6 +1,9 @@
1
1
  # opencode-chat-channel
2
2
 
3
- An [opencode](https://opencode.ai) plugin that connects your Feishu (Lark) bot to opencode via **WebSocket long connection** — no public IP required.
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
- Feishu User sends message
15
- ↓ WebSocket long connection
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
- Feishu User receives reply
23
+ User receives reply
23
24
  ```
24
25
 
25
- - Each Feishu user (`open_id`) gets their own persistent opencode session
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 into multiple messages
28
+ - Replies longer than 4000 characters are automatically split
28
29
 
29
30
  ---
30
31
 
31
- ### Quick Install (Recommended)
32
+ ### Installation
32
33
 
33
- Run this single command in your terminal:
34
+ Since this is an **opencode plugin**, opencode handles installation automatically via npm. No scripts needed.
34
35
 
35
- ```bash
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
- The installer will:
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
- "dependencies": {
67
- "@larksuiteoapi/node-sdk": "^1.37.0"
68
- }
42
+ "plugin": [
43
+ "opencode-chat-channel@latest"
44
+ ]
69
45
  }
70
46
  ```
71
47
 
72
- Then run `bun install` in `~/.config/opencode/`.
48
+ opencode will pull and install the package automatically on next startup.
73
49
 
74
- ### Configuration
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** (sensitive, store in macOS Keychain):
83
+ **App Secret** choose the method for your platform:
102
84
 
103
- ```bash
104
- security add-generic-password -a chat-channel -s opencode-chat-channel -w <your-app-secret> -U
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
- Verify:
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
- [chat-channel] 飞书机器人已启动(长连接模式),appId=cli_xxx***
112
+ [feishu] 飞书机器人已启动(长连接模式),appId=cli_xxx***
113
+ chat-channel 已启动,活跃渠道: feishu
123
114
  ```
124
115
 
125
- ### File Structure
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
- ~/.config/opencode/
129
- ├── plugins/
130
- │ └── chat-channel.ts # plugin file (copied from src/index.ts)
131
- ├── package.json # dependencies (add @larksuiteoapi/node-sdk)
132
- └── .env # FEISHU_APP_ID (App Secret in Keychain)
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
- - 每个飞书用户(`open_id`)独享一个 opencode session,**对话历史持久保留**
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
- 1. **复制插件文件** opencode 插件目录:
192
+ 本项目是 **opencode 插件**,opencode 通过 npm 自动管理安装,无需额外脚本。
201
193
 
202
- ```bash
203
- cp src/index.ts ~/.config/opencode/plugins/chat-channel.ts
204
- ```
194
+ #### 第一步:添加到 `opencode.json`
205
195
 
206
- 2. **添加依赖** 到 `~/.config/opencode/package.json`:
196
+ 编辑 `~/.config/opencode/opencode.json`,在 `plugin` 数组中添加:
207
197
 
208
198
  ```json
209
199
  {
210
- "dependencies": {
211
- "@larksuiteoapi/node-sdk": "^1.37.0"
212
- }
200
+ "plugin": [
201
+ "opencode-chat-channel@latest"
202
+ ]
213
203
  }
214
204
  ```
215
205
 
216
- 然后在 `~/.config/opencode/` 目录运行 `bun install`。
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 地址(默认: http://localhost:4321)
237
+ # 可选:自定义 opencode API 地址(默认:http://localhost:4321)
242
238
  # OPENCODE_BASE_URL=http://localhost:4321
243
239
  ```
244
240
 
245
- **App Secret**(敏感,存 macOS Keychain,不落盘明文):
241
+ **App Secret**—按使用的平台选择存储方式:
246
242
 
247
- ```bash
248
- security add-generic-password -a chat-channel -s opencode-chat-channel -w <你的AppSecret> -U
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
- [chat-channel] 飞书机器人已启动(长连接模式),appId=cli_xxx***
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
- ~/.config/opencode/
273
- ├── plugins/
274
- │ └── chat-channel.ts # 插件主文件(从 src/index.ts 复制)
275
- ├── package.json # 依赖(含 @larksuiteoapi/node-sdk)
276
- └── .env # 环境变量(FEISHU_APP_ID;App Secret 存 Keychain)
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
- * 通过飞书长连接(WebSocket)接收消息,驱动 opencode AI 回复。
4
+ * 支持多个即时通讯渠道同时运行,每个渠道独立处理消息。
5
+ * 当前已实现:飞书(Feishu/Lark)
6
+ * 骨架已创建:企业微信(WeCom)
5
7
  *
6
- * 凭证存储方案(macOS Keychain):
7
- * FEISHU_APP_ID 存放在 ~/.config/opencode/.env(非敏感,可见)
8
- * FEISHU_APP_SECRET 存放在系统钥匙串,service=opencode-chat-channel,account=chat-channel
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
- * 每个飞书用户(open_id)独享一个 opencode session,对话历史保留。
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
@@ -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;AAgDlD,eAAO,MAAM,iBAAiB,EAAE,MAsR/B,CAAC;AAEF,eAAe,iBAAiB,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
- var SESSION_TTL_MS = 2 * 60 * 60 * 1000;
26
- var OPENCODE_BASE_URL = process.env["OPENCODE_BASE_URL"] ?? "http://localhost:4321";
27
- var ChatChannelPlugin = async ({ client }) => {
28
- const configDir = join(process.env["HOME"] ?? "/Users/" + (process.env["USER"] ?? "unknown"), ".config", "opencode");
29
- loadDotEnv(join(configDir, ".env"));
30
- const appId = process.env["FEISHU_APP_ID"];
31
- let appSecret;
32
- try {
33
- const stdout = execFileSync("security", [
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 (!appId || !appSecret) {
46
- await client.app.log({
47
- body: {
48
- service: "chat-channel",
49
- level: "warn",
50
- message: !appId ? "FEISHU_APP_ID 未设置(请在 .env 中添加),飞书机器人插件已跳过" : "FEISHU_APP_SECRET 未找到(请写入 Keychain),飞书机器人插件已跳过"
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
- const larkClient = new Lark.Client({
56
- appId,
57
- appSecret,
58
- appType: Lark.AppType.SelfBuild,
59
- domain: Lark.Domain.Feishu
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
- const sessionId = res.data.id;
75
- userSessions.set(openId, { sessionId, lastActivity: now });
76
- await client.app.log({
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: `创建新 session: ${sessionId}`,
81
- extra: { openId }
132
+ message: `[feishu] 飞书机器人已启动(长连接模式),appId=${this.appId.slice(0, 8)}***`
82
133
  }
83
134
  });
84
- return sessionId;
85
135
  }
86
- function cleanupExpiredSessions() {
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 += MAX_LEN) {
99
- chunks.push(text.slice(i, i + MAX_LEN));
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: chatId,
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 function sendThinking(chatId) {
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
- function extractResponseText(parts) {
123
- if (!Array.isArray(parts))
124
- return "";
125
- return parts.filter((p) => p?.type === "text").map((p) => p?.text ?? "").join("").trim();
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
- await larkClient.im.message.create({
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: "info",
146
- message: `重复消息已忽略: ${msgId}`
253
+ level: "warn",
254
+ message: "[wecom] 企业微信凭证不完整(需要 WECOM_CORP_ID、WECOM_AGENT_ID、WECOM_SECRET),渠道已跳过"
147
255
  }
148
256
  });
149
- return;
150
257
  }
151
- if (processedMessages.size >= MAX_PROCESSED)
152
- processedMessages.clear();
153
- processedMessages.add(msgId);
154
- let userText;
155
- try {
156
- userText = JSON.parse(message.content).text?.trim();
157
- } catch {
158
- return;
159
- }
160
- if (!userText)
161
- return;
162
- const openId = sender.sender_id?.open_id ?? message.chat_id;
163
- const chatId = message.chat_id;
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: `收到消息: "${userText.slice(0, 80)}${userText.length > 80 ? "..." : ""}"`,
169
- extra: { openId, chatId }
276
+ message: `[${channel.name}] 收到消息: "${text.slice(0, 80)}${text.length > 80 ? "..." : ""}"`,
277
+ extra: { userId, replyTarget }
170
278
  }
171
279
  });
172
- await sendThinking(chatId);
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 getOrCreateSession(openId);
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: userText }],
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: `处理消息失败: ${errorMsg}`,
194
- extra: { openId }
303
+ message: `[${channel.name}] 处理消息失败: ${errorMsg}`,
304
+ extra: { userId }
195
305
  }
196
306
  });
197
- await replyToFeishu(chatId, `⚠️ 出错了:${errorMsg}`);
307
+ await channel.send(replyTarget, `⚠️ 出错了:${errorMsg}`);
198
308
  return;
199
309
  }
200
- if (responseText) {
201
- await replyToFeishu(chatId, responseText);
202
- } else {
203
- await replyToFeishu(chatId, "(AI 没有返回文字回复)");
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: `飞书机器人已启动(长连接模式),appId=${appId.slice(0, 8)}***`
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"}
@@ -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.0.0",
4
- "description": "opencode plugin — Feishu (Lark) bot channel via WebSocket long connection",
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",