@happycastle/openclaw-channel-talk 0.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 +206 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +56 -0
- package/src/api-client.ts +172 -0
- package/src/channel.ts +181 -0
- package/src/config-schema.ts +60 -0
- package/src/runtime.ts +33 -0
- package/src/send.ts +42 -0
- package/src/types.ts +169 -0
- package/src/webhook.ts +319 -0
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# @happycastle/openclaw-channel-talk
|
|
2
|
+
|
|
3
|
+
> ⚠️ **Unofficial** — 이 플러그인은 Channel Corp 또는 OpenClaw 팀과 관련이 없는 커뮤니티 프로젝트입니다.
|
|
4
|
+
|
|
5
|
+
[Channel Talk (채널톡)](https://channel.io) Team Chat을 OpenClaw에 연동하는 채널 플러그인입니다.
|
|
6
|
+
|
|
7
|
+
## ✨ Features
|
|
8
|
+
|
|
9
|
+
- 📨 **Team Chat 메시지 수신** — 웹훅을 통해 채널톡 팀챗 메시지를 실시간으로 수신
|
|
10
|
+
- 💬 **메시지 발송** — OpenClaw 에이전트가 채널톡 팀챗에 직접 응답
|
|
11
|
+
- 🤖 **커스텀 봇 이름** — `botName` 설정으로 봇 표시 이름 변경 가능
|
|
12
|
+
- 🔄 **자동 재시도** — API 오류(429, 5xx) 시 지수 백오프 재시도
|
|
13
|
+
- 📝 **Markdown 청킹** — 긴 메시지를 자동으로 분할하여 전송
|
|
14
|
+
- 🔒 **중복 메시지 필터링** — 동일 메시지 중복 처리 방지
|
|
15
|
+
|
|
16
|
+
## 📋 Prerequisites
|
|
17
|
+
|
|
18
|
+
- [OpenClaw](https://github.com/nicepkg/openclaw)가 설치되어 실행 중이어야 합니다
|
|
19
|
+
- Channel Talk 계정 및 API 키 (Access Key + Access Secret)
|
|
20
|
+
- 웹훅 수신을 위한 공개 URL (Tailscale Funnel, ngrok, 리버스 프록시 등)
|
|
21
|
+
|
|
22
|
+
## 🚀 설치 및 설정 가이드
|
|
23
|
+
|
|
24
|
+
### 1단계: Channel Talk API 키 발급
|
|
25
|
+
|
|
26
|
+
1. [채널 데스크](https://desk.channel.io)에 로그인
|
|
27
|
+
2. **설정** → **보안 및 개발** → **API Key 관리**로 이동
|
|
28
|
+
3. **새 API Key 생성** 클릭
|
|
29
|
+
4. **Access Key**와 **Access Secret**을 안전하게 복사해 둡니다
|
|
30
|
+
|
|
31
|
+
### 2단계: 플러그인 설치
|
|
32
|
+
|
|
33
|
+
**npm을 통한 설치 (권장):**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
openclaw plugins install @happycastle/openclaw-channel-talk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**로컬 설치 (개발용):**
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git clone https://github.com/happycastle114/openclaw-channel-talk.git
|
|
43
|
+
cd openclaw-channel-talk
|
|
44
|
+
npm install
|
|
45
|
+
# OpenClaw 설정에서 로컬 경로를 지정합니다
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 3단계: OpenClaw 설정
|
|
49
|
+
|
|
50
|
+
OpenClaw 설정 파일(`config.yaml` 또는 `config.json`)에 다음을 추가합니다:
|
|
51
|
+
|
|
52
|
+
```yaml
|
|
53
|
+
channels:
|
|
54
|
+
channel-talk:
|
|
55
|
+
# Channel Talk API 인증 정보 (필수)
|
|
56
|
+
accessKey: "your-access-key"
|
|
57
|
+
accessSecret: "your-access-secret"
|
|
58
|
+
|
|
59
|
+
# 봇 표시 이름 (선택, 기본값: API 기본 봇 이름)
|
|
60
|
+
botName: "MyBot"
|
|
61
|
+
|
|
62
|
+
# 팀챗 그룹 정책 (선택, 기본값: "open")
|
|
63
|
+
# "open" = 모든 팀챗 메시지 처리
|
|
64
|
+
# "closed" = 팀챗 메시지 처리 안 함
|
|
65
|
+
groupPolicy: "open"
|
|
66
|
+
|
|
67
|
+
# 웹훅 서버 설정 (선택)
|
|
68
|
+
webhook:
|
|
69
|
+
port: 3979 # 기본값: 3979
|
|
70
|
+
path: "/api/channel-talk" # 기본값: /api/channel-talk
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### 4단계: 웹훅 엔드포인트 공개
|
|
74
|
+
|
|
75
|
+
채널톡이 웹훅 이벤트를 보내려면 공개 URL이 필요합니다. 아래 방법 중 하나를 선택하세요:
|
|
76
|
+
|
|
77
|
+
**Tailscale Funnel (권장):**
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
tailscale funnel 3979
|
|
81
|
+
# https://your-machine.tail12345.ts.net 형태의 URL이 생성됩니다
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**ngrok:**
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ngrok http 3979
|
|
88
|
+
# https://xxxx-xxxx.ngrok-free.app 형태의 URL이 생성됩니다
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**리버스 프록시 (Nginx, Caddy 등):**
|
|
92
|
+
|
|
93
|
+
기존 도메인이 있다면 리버스 프록시로 `localhost:3979`를 포워딩합니다.
|
|
94
|
+
|
|
95
|
+
### 5단계: Channel Talk 웹훅 등록
|
|
96
|
+
|
|
97
|
+
채널톡 API를 사용하여 웹훅을 등록합니다:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
curl -X PUT "https://api.channel.io/open/v5/native/functions" \
|
|
101
|
+
-H "x-access-key: YOUR_ACCESS_KEY" \
|
|
102
|
+
-H "x-access-secret: YOUR_ACCESS_SECRET" \
|
|
103
|
+
-H "Content-Type: application/json" \
|
|
104
|
+
-d '{
|
|
105
|
+
"body": {
|
|
106
|
+
"nativeFunctions": [{
|
|
107
|
+
"name": "openclaw-webhook",
|
|
108
|
+
"uri": "https://YOUR_PUBLIC_URL/api/channel-talk",
|
|
109
|
+
"method": "POST",
|
|
110
|
+
"headers": {}
|
|
111
|
+
}]
|
|
112
|
+
}
|
|
113
|
+
}'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
> 💡 `YOUR_PUBLIC_URL`을 4단계에서 얻은 공개 URL로 교체하세요.
|
|
117
|
+
|
|
118
|
+
### 6단계: 게이트웨이 시작
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
openclaw gateway start
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
이제 채널톡 Team Chat에서 메시지를 보내면 OpenClaw 에이전트가 응답합니다! 🎉
|
|
125
|
+
|
|
126
|
+
## ⚙️ Configuration Reference
|
|
127
|
+
|
|
128
|
+
| 키 | 타입 | 필수 | 기본값 | 설명 |
|
|
129
|
+
|---|---|---|---|---|
|
|
130
|
+
| `accessKey` | `string` | ✅ | — | Channel Talk API Access Key |
|
|
131
|
+
| `accessSecret` | `string` | ✅ | — | Channel Talk API Access Secret |
|
|
132
|
+
| `enabled` | `boolean` | ❌ | `true` | 플러그인 활성화/비활성화 |
|
|
133
|
+
| `botName` | `string` | ❌ | — | 봇 메시지 표시 이름 |
|
|
134
|
+
| `groupPolicy` | `"open" \| "closed"` | ❌ | `"open"` | 팀챗 그룹 메시지 처리 정책 |
|
|
135
|
+
| `webhook.port` | `number` | ❌ | `3979` | 웹훅 서버 포트 |
|
|
136
|
+
| `webhook.path` | `string` | ❌ | `"/api/channel-talk"` | 웹훅 엔드포인트 경로 |
|
|
137
|
+
|
|
138
|
+
## 🏗️ Architecture
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
┌─────────────────┐ webhook POST ┌──────────────────┐
|
|
142
|
+
│ Channel Talk │ ───────────────────▶ │ OpenClaw │
|
|
143
|
+
│ (Team Chat) │ │ Gateway │
|
|
144
|
+
│ │ API response │ │
|
|
145
|
+
│ │ ◀─────────────────── │ ┌────────────┐ │
|
|
146
|
+
│ │ │ │ channel- │ │
|
|
147
|
+
│ │ │ │ talk plugin │ │
|
|
148
|
+
└─────────────────┘ │ └────────────┘ │
|
|
149
|
+
│ │ │
|
|
150
|
+
│ ▼ │
|
|
151
|
+
│ ┌────────────┐ │
|
|
152
|
+
│ │ Agent │ │
|
|
153
|
+
│ │ (LLM) │ │
|
|
154
|
+
│ └────────────┘ │
|
|
155
|
+
└──────────────────┘
|
|
156
|
+
|
|
157
|
+
1. 채널톡 Team Chat에 메시지 작성
|
|
158
|
+
2. 웹훅이 POST /api/channel-talk 으로 이벤트 전달
|
|
159
|
+
3. 플러그인이 메시지를 파싱하여 에이전트에 전달
|
|
160
|
+
4. 에이전트가 응답 생성
|
|
161
|
+
5. Channel Talk API로 팀챗에 응답 전송
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## 🔍 Verified API Behavior
|
|
165
|
+
|
|
166
|
+
개발 과정에서 확인된 Channel Talk API 동작 특이사항:
|
|
167
|
+
|
|
168
|
+
- **웹훅 이벤트 형식**: 이벤트는 `event: "push"`로 수신됩니다. 상위 레벨에 `type` 필드가 없을 수 있습니다.
|
|
169
|
+
- **Group ID 위치**: `groupId`는 `entity.chatId`에서 가져옵니다. `refers.group.id`에는 없을 수 있습니다.
|
|
170
|
+
- **`actAsManager` 옵션**: Team Chat에서 사용 시 `422` 에러가 발생합니다. 이 옵션은 User Chat 전용입니다.
|
|
171
|
+
- **`botName` 파라미터**: 쿼리 파라미터로 전달하면 커스텀 봇 이름이 정상 작동합니다.
|
|
172
|
+
- **메시지 발신자 타입**: 봇이 보낸 메시지는 `personType: "bot"`으로 표시됩니다.
|
|
173
|
+
|
|
174
|
+
## 🛠️ Troubleshooting
|
|
175
|
+
|
|
176
|
+
### 웹훅이 수신되지 않는 경우
|
|
177
|
+
|
|
178
|
+
1. 공개 URL이 올바르게 설정되었는지 확인합니다
|
|
179
|
+
2. 게이트웨이가 실행 중인지 확인합니다: `openclaw gateway status`
|
|
180
|
+
3. 포트가 방화벽에 의해 차단되지 않았는지 확인합니다
|
|
181
|
+
4. 웹훅 등록 curl 명령을 다시 실행합니다
|
|
182
|
+
|
|
183
|
+
### 인증 오류 (401/403)
|
|
184
|
+
|
|
185
|
+
- `accessKey`와 `accessSecret`이 올바른지 확인합니다
|
|
186
|
+
- API Key가 비활성화되지 않았는지 채널 데스크에서 확인합니다
|
|
187
|
+
|
|
188
|
+
### 메시지 전송 실패 (422)
|
|
189
|
+
|
|
190
|
+
- `actAsManager` 옵션을 사용하지 마세요 — Team Chat에서는 지원되지 않습니다
|
|
191
|
+
- `groupId`가 유효한 팀챗 그룹 ID인지 확인합니다
|
|
192
|
+
|
|
193
|
+
### 봇이 자기 메시지에 반응하는 경우
|
|
194
|
+
|
|
195
|
+
- 플러그인은 `personType: "bot"` 메시지를 자동으로 무시합니다
|
|
196
|
+
- 이 문제가 발생하면 로그를 확인해 주세요
|
|
197
|
+
|
|
198
|
+
## 📄 License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
201
|
+
|
|
202
|
+
## ⚠️ Disclaimer
|
|
203
|
+
|
|
204
|
+
이 프로젝트는 **비공식 커뮤니티 프로젝트**입니다.
|
|
205
|
+
[Channel Corp](https://channel.io) 또는 [OpenClaw](https://github.com/nicepkg/openclaw) 팀과 어떠한 제휴 관계도 없습니다.
|
|
206
|
+
Channel Talk은 Channel Corp의 상표입니다.
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
3
|
+
import { channelTalkPlugin } from "./src/channel.js";
|
|
4
|
+
import { setChannelTalkRuntime } from "./src/runtime.js";
|
|
5
|
+
|
|
6
|
+
const plugin = {
|
|
7
|
+
id: "channel-talk",
|
|
8
|
+
name: "Channel Talk",
|
|
9
|
+
description: "Channel Talk (채널톡) Team Chat channel plugin",
|
|
10
|
+
configSchema: emptyPluginConfigSchema(),
|
|
11
|
+
register(api: OpenClawPluginApi) {
|
|
12
|
+
setChannelTalkRuntime(api.runtime);
|
|
13
|
+
api.registerChannel({ plugin: channelTalkPlugin });
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@happycastle/openclaw-channel-talk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw Channel Talk (채널톡) Team Chat channel plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "happycastle114",
|
|
8
|
+
"homepage": "https://github.com/happycastle114/openclaw-channel-talk#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/happycastle114/openclaw-channel-talk.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"openclaw",
|
|
15
|
+
"channel-talk",
|
|
16
|
+
"채널톡",
|
|
17
|
+
"chatbot",
|
|
18
|
+
"team-chat",
|
|
19
|
+
"plugin"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src/",
|
|
24
|
+
"openclaw.plugin.json",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"express": "^4.19.0"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"openclaw": "*"
|
|
32
|
+
},
|
|
33
|
+
"openclaw": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./index.ts"
|
|
36
|
+
],
|
|
37
|
+
"channel": {
|
|
38
|
+
"id": "channel-talk",
|
|
39
|
+
"label": "Channel Talk",
|
|
40
|
+
"selectionLabel": "Channel Talk (채널톡)",
|
|
41
|
+
"docsPath": "channel-talk",
|
|
42
|
+
"docsLabel": "Channel Talk Setup",
|
|
43
|
+
"blurb": "채널톡 Team Chat integration",
|
|
44
|
+
"aliases": [
|
|
45
|
+
"channeltalk",
|
|
46
|
+
"채널톡"
|
|
47
|
+
],
|
|
48
|
+
"order": 110
|
|
49
|
+
},
|
|
50
|
+
"install": {
|
|
51
|
+
"npmSpec": "@happycastle/openclaw-channel-talk",
|
|
52
|
+
"localPath": "extensions/channel-talk",
|
|
53
|
+
"defaultChoice": false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Talk API v5 HTTP Client
|
|
3
|
+
* Handles authentication, retries, and message sending
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ChannelTalkConfig,
|
|
8
|
+
ChannelTalkCredentials,
|
|
9
|
+
SendMessageParams,
|
|
10
|
+
SendMessageResponse,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sleep for specified milliseconds
|
|
15
|
+
* Used for exponential backoff retry delays
|
|
16
|
+
*/
|
|
17
|
+
function sleep(ms: number): Promise<void> {
|
|
18
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create an API client for Channel Talk
|
|
23
|
+
* Returns a client object with sendMessage method
|
|
24
|
+
*
|
|
25
|
+
* @param credentials - API credentials (accessKey, accessSecret)
|
|
26
|
+
* @param baseUrl - Optional base URL override (default: https://api.channel.io)
|
|
27
|
+
* @returns API client object with sendMessage method
|
|
28
|
+
*/
|
|
29
|
+
export function createApiClient(
|
|
30
|
+
credentials: ChannelTalkCredentials,
|
|
31
|
+
baseUrl: string = 'https://api.channel.io'
|
|
32
|
+
) {
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Send a message to a Channel Talk group
|
|
36
|
+
* Implements retry logic with exponential backoff for 429/5xx errors
|
|
37
|
+
*
|
|
38
|
+
* @param params - Message parameters (groupId, plainText, blocks, options, botName)
|
|
39
|
+
* @returns Promise resolving to SendMessageResponse with messageId and groupId
|
|
40
|
+
* @throws Error on authentication failure (401/403) or after max retries exhausted
|
|
41
|
+
*/
|
|
42
|
+
async sendMessage(params: SendMessageParams): Promise<SendMessageResponse> {
|
|
43
|
+
const { groupId, plainText, blocks, options, botName } = params;
|
|
44
|
+
|
|
45
|
+
// Build request body
|
|
46
|
+
const body: Record<string, unknown> = {
|
|
47
|
+
plainText,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (blocks && blocks.length > 0) {
|
|
51
|
+
body.blocks = blocks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (options && options.length > 0) {
|
|
55
|
+
body.options = options;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build URL with optional botName query parameter
|
|
59
|
+
const url = new URL(`/open/v5/groups/${groupId}/messages`, baseUrl);
|
|
60
|
+
if (botName) {
|
|
61
|
+
url.searchParams.set('botName', botName);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Retry configuration: 2 retries with exponential backoff (1s, 3s)
|
|
65
|
+
const maxRetries = 2;
|
|
66
|
+
const retryDelays = [1000, 3000]; // milliseconds
|
|
67
|
+
|
|
68
|
+
let lastError: Error | null = null;
|
|
69
|
+
|
|
70
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
const response = await fetch(url.toString(), {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
'x-access-key': credentials.accessKey,
|
|
77
|
+
'x-access-secret': credentials.accessSecret,
|
|
78
|
+
},
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Handle authentication errors (no retry)
|
|
83
|
+
if (response.status === 401 || response.status === 403) {
|
|
84
|
+
const errorText = await response.text();
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Authentication failed (${response.status}): ${errorText}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle success
|
|
91
|
+
if (response.ok) {
|
|
92
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
// Extract messageId from response
|
|
95
|
+
// Response structure: { message: { id: "...", ... }, ... }
|
|
96
|
+
const messageId: string =
|
|
97
|
+
(String((data.message as Record<string, unknown>)?.id) || '') ||
|
|
98
|
+
(String(data.id) || '') ||
|
|
99
|
+
'';
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
messageId,
|
|
103
|
+
groupId,
|
|
104
|
+
message: data.message as Record<string, unknown>,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Handle retryable errors (429, 5xx)
|
|
109
|
+
if (response.status === 429 || response.status >= 500) {
|
|
110
|
+
const errorText = await response.text();
|
|
111
|
+
lastError = new Error(
|
|
112
|
+
`API error (${response.status}): ${errorText}`
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// If this is the last attempt, throw
|
|
116
|
+
if (attempt === maxRetries) {
|
|
117
|
+
throw lastError;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Wait before retry
|
|
121
|
+
const delayMs = retryDelays[attempt];
|
|
122
|
+
await sleep(delayMs);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Handle other 4xx errors (no retry)
|
|
127
|
+
const errorText = await response.text();
|
|
128
|
+
throw new Error(
|
|
129
|
+
`API error (${response.status}): ${errorText}`
|
|
130
|
+
);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// Network errors or other exceptions
|
|
133
|
+
if (error instanceof Error) {
|
|
134
|
+
lastError = error;
|
|
135
|
+
} else {
|
|
136
|
+
lastError = new Error(String(error));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If this is the last attempt, throw
|
|
140
|
+
if (attempt === maxRetries) {
|
|
141
|
+
throw lastError;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Wait before retry
|
|
145
|
+
const delayMs = retryDelays[attempt];
|
|
146
|
+
await sleep(delayMs);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Should not reach here, but throw last error if we do
|
|
151
|
+
throw lastError || new Error('Unknown error sending message');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Convenience function to send a message directly
|
|
158
|
+
* Creates a client and sends a message in one call
|
|
159
|
+
*
|
|
160
|
+
* @param credentials - API credentials
|
|
161
|
+
* @param params - Message parameters
|
|
162
|
+
* @param baseUrl - Optional base URL override
|
|
163
|
+
* @returns Promise resolving to SendMessageResponse
|
|
164
|
+
*/
|
|
165
|
+
export async function sendMessage(
|
|
166
|
+
credentials: ChannelTalkCredentials,
|
|
167
|
+
params: SendMessageParams,
|
|
168
|
+
baseUrl?: string
|
|
169
|
+
): Promise<SendMessageResponse> {
|
|
170
|
+
const client = createApiClient(credentials, baseUrl);
|
|
171
|
+
return client.sendMessage(params);
|
|
172
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ChannelPlugin, OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
2
|
+
import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID } from 'openclaw/plugin-sdk';
|
|
3
|
+
import type { ChannelTalkCredentials } from './types.js';
|
|
4
|
+
import { ChannelTalkConfigSchema } from './config-schema.js';
|
|
5
|
+
import { channelTalkOutbound } from './send.js';
|
|
6
|
+
import { startChannelTalkWebhook } from './webhook.js';
|
|
7
|
+
|
|
8
|
+
export type ResolvedChannelTalkAccount = {
|
|
9
|
+
accountId: string;
|
|
10
|
+
credentials: ChannelTalkCredentials;
|
|
11
|
+
config: {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
accessKey?: string;
|
|
14
|
+
accessSecret?: string;
|
|
15
|
+
botName?: string;
|
|
16
|
+
groupPolicy?: string;
|
|
17
|
+
webhook?: { port?: number; path?: string };
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function readChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | undefined {
|
|
22
|
+
return (cfg.channels as Record<string, Record<string, unknown>> | undefined)?.['channel-talk'];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveCredentials(
|
|
26
|
+
raw: Record<string, unknown> | undefined,
|
|
27
|
+
): ChannelTalkCredentials | null {
|
|
28
|
+
const accessKey = typeof raw?.accessKey === 'string' ? raw.accessKey : '';
|
|
29
|
+
const accessSecret = typeof raw?.accessSecret === 'string' ? raw.accessSecret : '';
|
|
30
|
+
if (!accessKey || !accessSecret) return null;
|
|
31
|
+
return { accessKey, accessSecret };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
id: 'channel-talk',
|
|
36
|
+
label: 'Channel Talk',
|
|
37
|
+
selectionLabel: 'Channel Talk (채널톡)',
|
|
38
|
+
docsPath: '/channels/channel-talk',
|
|
39
|
+
docsLabel: 'Channel Talk Setup',
|
|
40
|
+
blurb: '채널톡 Team Chat integration',
|
|
41
|
+
aliases: ['channeltalk', '채널톡'],
|
|
42
|
+
order: 500,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export const channelTalkPlugin: ChannelPlugin<ResolvedChannelTalkAccount> = {
|
|
46
|
+
id: 'channel-talk',
|
|
47
|
+
|
|
48
|
+
meta: { ...meta },
|
|
49
|
+
|
|
50
|
+
capabilities: {
|
|
51
|
+
chatTypes: ['channel'],
|
|
52
|
+
polls: false,
|
|
53
|
+
threads: false,
|
|
54
|
+
media: false,
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
reload: { configPrefixes: ['channels.channel-talk'] },
|
|
58
|
+
|
|
59
|
+
configSchema: buildChannelConfigSchema(ChannelTalkConfigSchema as any),
|
|
60
|
+
|
|
61
|
+
config: {
|
|
62
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
63
|
+
|
|
64
|
+
resolveAccount: (cfg) => {
|
|
65
|
+
const raw = readChannelConfig(cfg);
|
|
66
|
+
const creds = resolveCredentials(raw);
|
|
67
|
+
return {
|
|
68
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
69
|
+
credentials: creds ?? { accessKey: '', accessSecret: '' },
|
|
70
|
+
config: {
|
|
71
|
+
enabled: raw?.enabled !== false,
|
|
72
|
+
accessKey: typeof raw?.accessKey === 'string' ? raw.accessKey : undefined,
|
|
73
|
+
accessSecret: typeof raw?.accessSecret === 'string' ? raw.accessSecret : undefined,
|
|
74
|
+
botName: typeof raw?.botName === 'string' ? raw.botName : undefined,
|
|
75
|
+
groupPolicy: typeof raw?.groupPolicy === 'string' ? raw.groupPolicy : undefined,
|
|
76
|
+
webhook: raw?.webhook as { port?: number; path?: string } | undefined,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
isConfigured: (account) =>
|
|
82
|
+
Boolean(account.credentials.accessKey && account.credentials.accessSecret),
|
|
83
|
+
|
|
84
|
+
resolveAllowFrom: () => undefined,
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
outbound: channelTalkOutbound,
|
|
88
|
+
|
|
89
|
+
gateway: {
|
|
90
|
+
startAccount: async (ctx) => {
|
|
91
|
+
const port =
|
|
92
|
+
(readChannelConfig(ctx.cfg)?.webhook as { port?: number } | undefined)?.port ?? 3979;
|
|
93
|
+
ctx.setStatus({ accountId: ctx.accountId, port } as any);
|
|
94
|
+
ctx.log?.info(`starting channel-talk webhook (port ${port})`);
|
|
95
|
+
return startChannelTalkWebhook({
|
|
96
|
+
cfg: ctx.cfg,
|
|
97
|
+
runtime: ctx.runtime,
|
|
98
|
+
abortSignal: ctx.abortSignal,
|
|
99
|
+
accountId: ctx.accountId,
|
|
100
|
+
setStatus: (next) => ctx.setStatus(next as any),
|
|
101
|
+
log: ctx.log,
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
setup: {
|
|
107
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
108
|
+
|
|
109
|
+
applyAccountConfig: ({ cfg, input }) => ({
|
|
110
|
+
...cfg,
|
|
111
|
+
channels: {
|
|
112
|
+
...(cfg.channels as Record<string, unknown>),
|
|
113
|
+
'channel-talk': {
|
|
114
|
+
...readChannelConfig(cfg),
|
|
115
|
+
...(input.token ? { accessKey: input.token } : {}),
|
|
116
|
+
...(input.botToken ? { accessSecret: input.botToken } : {}),
|
|
117
|
+
enabled: true,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
} as OpenClawConfig),
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
status: {
|
|
124
|
+
defaultRuntime: {
|
|
125
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
126
|
+
running: false,
|
|
127
|
+
lastStartAt: null,
|
|
128
|
+
lastStopAt: null,
|
|
129
|
+
lastError: null,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
buildChannelSummary: ({ snapshot }) => ({
|
|
133
|
+
configured: snapshot.configured ?? false,
|
|
134
|
+
running: snapshot.running ?? false,
|
|
135
|
+
lastStartAt: snapshot.lastStartAt ?? null,
|
|
136
|
+
lastStopAt: snapshot.lastStopAt ?? null,
|
|
137
|
+
lastError: snapshot.lastError ?? null,
|
|
138
|
+
port: snapshot.port ?? null,
|
|
139
|
+
}),
|
|
140
|
+
|
|
141
|
+
probeAccount: async ({ account }) => ({
|
|
142
|
+
configured: Boolean(
|
|
143
|
+
account.credentials.accessKey && account.credentials.accessSecret,
|
|
144
|
+
),
|
|
145
|
+
enabled: account.config.enabled !== false,
|
|
146
|
+
}),
|
|
147
|
+
|
|
148
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
149
|
+
accountId: account.accountId,
|
|
150
|
+
configured: Boolean(
|
|
151
|
+
account.credentials.accessKey && account.credentials.accessSecret,
|
|
152
|
+
),
|
|
153
|
+
enabled: account.config.enabled !== false,
|
|
154
|
+
running: runtime?.running ?? false,
|
|
155
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
156
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
157
|
+
lastError: runtime?.lastError ?? null,
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
security: {
|
|
162
|
+
collectWarnings: ({ cfg }) => {
|
|
163
|
+
const raw = readChannelConfig(cfg);
|
|
164
|
+
const defaultGroupPolicy = (
|
|
165
|
+
cfg.channels as Record<string, Record<string, unknown>> | undefined
|
|
166
|
+
)?.defaults?.groupPolicy as string | undefined;
|
|
167
|
+
const groupPolicy =
|
|
168
|
+
(typeof raw?.groupPolicy === 'string' ? raw.groupPolicy : undefined) ??
|
|
169
|
+
defaultGroupPolicy ??
|
|
170
|
+
'open';
|
|
171
|
+
|
|
172
|
+
if (groupPolicy !== 'open') {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
return [
|
|
176
|
+
`- Channel Talk: groupPolicy="open" processes all team chat messages. ` +
|
|
177
|
+
`Set channels.channel-talk.groupPolicy="closed" to disable team chat processing.`,
|
|
178
|
+
];
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Talk Plugin Configuration Schema
|
|
3
|
+
* Defines the structure and validation for Channel Talk plugin configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Type } from '@sinclair/typebox';
|
|
7
|
+
import type { Static } from '@sinclair/typebox';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Channel Talk configuration schema using TypeBox
|
|
11
|
+
* Defines required and optional configuration fields for the plugin
|
|
12
|
+
*/
|
|
13
|
+
export const ChannelTalkConfigSchema = Type.Object(
|
|
14
|
+
{
|
|
15
|
+
/** Enable/disable the Channel Talk plugin */
|
|
16
|
+
enabled: Type.Optional(Type.Boolean({ default: true })),
|
|
17
|
+
|
|
18
|
+
/** Channel Talk API access key (required when enabled) */
|
|
19
|
+
accessKey: Type.String({
|
|
20
|
+
description: 'Channel Talk access key for API authentication',
|
|
21
|
+
}),
|
|
22
|
+
|
|
23
|
+
/** Channel Talk API access secret (required when enabled) */
|
|
24
|
+
accessSecret: Type.String({
|
|
25
|
+
description: 'Channel Talk access secret for API authentication',
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
/** Webhook configuration */
|
|
29
|
+
webhook: Type.Optional(
|
|
30
|
+
Type.Object({
|
|
31
|
+
/** Port for webhook server (default: 3979) */
|
|
32
|
+
port: Type.Optional(Type.Number({ default: 3979 })),
|
|
33
|
+
|
|
34
|
+
/** Path for webhook endpoint (default: /api/channel-talk) */
|
|
35
|
+
path: Type.Optional(Type.String({ default: '/api/channel-talk' })),
|
|
36
|
+
})
|
|
37
|
+
),
|
|
38
|
+
|
|
39
|
+
/** Bot display name for sent messages (optional) */
|
|
40
|
+
botName: Type.Optional(
|
|
41
|
+
Type.String({
|
|
42
|
+
description: 'Bot display name for sent messages',
|
|
43
|
+
})
|
|
44
|
+
),
|
|
45
|
+
|
|
46
|
+
/** Group chat policy: 'open' = all groups allowed, 'closed' = none allowed */
|
|
47
|
+
groupPolicy: Type.Optional(
|
|
48
|
+
Type.Enum(['open', 'closed'], { default: 'open' })
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* TypeScript type derived from the schema
|
|
58
|
+
* Use this for type-safe configuration handling
|
|
59
|
+
*/
|
|
60
|
+
export type ChannelTalkConfig = Static<typeof ChannelTalkConfigSchema>;
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Talk Plugin Runtime Singleton
|
|
3
|
+
* Manages the plugin runtime instance for access throughout the plugin
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PluginRuntime } from 'openclaw/plugin-sdk';
|
|
7
|
+
|
|
8
|
+
/** Module-level runtime instance */
|
|
9
|
+
let runtime: PluginRuntime | undefined;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set the Channel Talk plugin runtime
|
|
13
|
+
* Called during plugin initialization to store the runtime reference
|
|
14
|
+
*
|
|
15
|
+
* @param next - The PluginRuntime instance from OpenClaw
|
|
16
|
+
*/
|
|
17
|
+
export function setChannelTalkRuntime(next: PluginRuntime): void {
|
|
18
|
+
runtime = next;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the Channel Talk plugin runtime
|
|
23
|
+
* Throws if runtime has not been initialized
|
|
24
|
+
*
|
|
25
|
+
* @returns The PluginRuntime instance
|
|
26
|
+
* @throws Error if runtime is not initialized
|
|
27
|
+
*/
|
|
28
|
+
export function getChannelTalkRuntime(): PluginRuntime {
|
|
29
|
+
if (!runtime) {
|
|
30
|
+
throw new Error('Channel Talk runtime not initialized');
|
|
31
|
+
}
|
|
32
|
+
return runtime;
|
|
33
|
+
}
|
package/src/send.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ChannelOutboundAdapter } from 'openclaw/plugin-sdk';
|
|
2
|
+
import { sendMessage } from './api-client.js';
|
|
3
|
+
import { getChannelTalkRuntime } from './runtime.js';
|
|
4
|
+
|
|
5
|
+
export const channelTalkOutbound: ChannelOutboundAdapter = {
|
|
6
|
+
deliveryMode: 'direct',
|
|
7
|
+
|
|
8
|
+
chunker: (text, limit) =>
|
|
9
|
+
getChannelTalkRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
10
|
+
|
|
11
|
+
chunkerMode: 'markdown',
|
|
12
|
+
textChunkLimit: 4000,
|
|
13
|
+
|
|
14
|
+
sendText: async ({ cfg, to, text }) => {
|
|
15
|
+
const channelCfg = cfg.channels?.['channel-talk'] as
|
|
16
|
+
| { accessKey?: string; accessSecret?: string; botName?: string }
|
|
17
|
+
| undefined;
|
|
18
|
+
|
|
19
|
+
if (!channelCfg?.accessKey || !channelCfg?.accessSecret) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'Channel Talk credentials not configured: missing accessKey or accessSecret in channels.channel-talk'
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const credentials = {
|
|
26
|
+
accessKey: channelCfg.accessKey,
|
|
27
|
+
accessSecret: channelCfg.accessSecret,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const result = await sendMessage(credentials, {
|
|
31
|
+
groupId: to,
|
|
32
|
+
plainText: text,
|
|
33
|
+
botName: channelCfg.botName,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
channel: 'channel-talk',
|
|
38
|
+
messageId: result.messageId,
|
|
39
|
+
conversationId: to,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel Talk API v5 TypeScript Type Definitions
|
|
3
|
+
* Interfaces for webhook events, API requests/responses, and configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Channel Talk API credentials for authentication
|
|
8
|
+
* Uses x-access-key and x-access-secret headers
|
|
9
|
+
*/
|
|
10
|
+
export interface ChannelTalkCredentials {
|
|
11
|
+
/** Access key for API authentication */
|
|
12
|
+
accessKey: string;
|
|
13
|
+
/** Access secret for API authentication */
|
|
14
|
+
accessSecret: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Channel Talk plugin configuration
|
|
19
|
+
* Contains credentials and optional base URL override
|
|
20
|
+
*/
|
|
21
|
+
export interface ChannelTalkConfig {
|
|
22
|
+
/** API credentials */
|
|
23
|
+
credentials: ChannelTalkCredentials;
|
|
24
|
+
/** Base URL for API calls (default: https://api.channel.io) */
|
|
25
|
+
baseUrl?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Text block in a message
|
|
30
|
+
* Supports HTML formatting: <b>, <i>, <link>, etc.
|
|
31
|
+
*/
|
|
32
|
+
export interface TextBlock {
|
|
33
|
+
type: 'text';
|
|
34
|
+
/** HTML-formatted text content */
|
|
35
|
+
value: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Code block in a message
|
|
40
|
+
*/
|
|
41
|
+
export interface CodeBlock {
|
|
42
|
+
type: 'code';
|
|
43
|
+
/** Code content */
|
|
44
|
+
value: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bulleted list block in a message
|
|
49
|
+
*/
|
|
50
|
+
export interface BulletsBlock {
|
|
51
|
+
type: 'bullets';
|
|
52
|
+
/** Array of text blocks for each bullet point */
|
|
53
|
+
blocks: TextBlock[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Union type for all message block types
|
|
58
|
+
*/
|
|
59
|
+
export type MessageBlock = TextBlock | CodeBlock | BulletsBlock;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Message options that control behavior
|
|
63
|
+
*/
|
|
64
|
+
export type MessageOption =
|
|
65
|
+
| 'actAsManager'
|
|
66
|
+
| 'displayAsChannel'
|
|
67
|
+
| 'doNotPost'
|
|
68
|
+
| 'doNotSearch'
|
|
69
|
+
| 'doNotSendApp'
|
|
70
|
+
| 'doNotUpdateDesk'
|
|
71
|
+
| 'immutable'
|
|
72
|
+
| 'private'
|
|
73
|
+
| 'silent';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parameters for sending a message to Channel Talk
|
|
77
|
+
*/
|
|
78
|
+
export interface SendMessageParams {
|
|
79
|
+
/** Group ID (team chat identifier) */
|
|
80
|
+
groupId: string;
|
|
81
|
+
/** Plain text content of the message */
|
|
82
|
+
plainText: string;
|
|
83
|
+
/** Message blocks (optional, for rich formatting) */
|
|
84
|
+
blocks?: MessageBlock[];
|
|
85
|
+
/** Message options (optional) */
|
|
86
|
+
options?: MessageOption[];
|
|
87
|
+
/** Bot name to display as sender (optional) */
|
|
88
|
+
botName?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Response from sending a message
|
|
93
|
+
*/
|
|
94
|
+
export interface SendMessageResponse {
|
|
95
|
+
/** Message ID returned by API */
|
|
96
|
+
messageId: string;
|
|
97
|
+
/** Group ID where message was sent */
|
|
98
|
+
groupId: string;
|
|
99
|
+
/** Full message object from API response */
|
|
100
|
+
message?: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Manager entity in webhook event
|
|
105
|
+
*/
|
|
106
|
+
export interface ChannelTalkManager {
|
|
107
|
+
/** Manager ID */
|
|
108
|
+
id: string;
|
|
109
|
+
/** Manager name */
|
|
110
|
+
name?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Group (team chat) entity in webhook event
|
|
115
|
+
*/
|
|
116
|
+
export interface ChannelTalkGroup {
|
|
117
|
+
/** Group ID */
|
|
118
|
+
id: string;
|
|
119
|
+
/** Group name */
|
|
120
|
+
name?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* References to related entities in webhook event
|
|
125
|
+
*/
|
|
126
|
+
export interface ChannelTalkRefers {
|
|
127
|
+
/** Manager who sent/received the message */
|
|
128
|
+
manager?: ChannelTalkManager;
|
|
129
|
+
/** Group (team chat) where message occurred */
|
|
130
|
+
group?: ChannelTalkGroup;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Entity data in webhook event
|
|
135
|
+
* Contains the actual message content and metadata
|
|
136
|
+
*/
|
|
137
|
+
export interface ChannelTalkEntity {
|
|
138
|
+
/** Message ID */
|
|
139
|
+
id: string;
|
|
140
|
+
/** Chat type: 'group' for team chat, 'user' for direct, 'customer' for customer chat */
|
|
141
|
+
chatType: 'group' | 'user' | 'customer';
|
|
142
|
+
/** Person type: 'manager' for staff, 'bot' for automated messages, 'customer' for customers */
|
|
143
|
+
personType: 'manager' | 'bot' | 'customer';
|
|
144
|
+
/** Message blocks (rich formatting) */
|
|
145
|
+
blocks?: MessageBlock[];
|
|
146
|
+
/** Plain text representation of message */
|
|
147
|
+
plainText?: string;
|
|
148
|
+
/** Chat ID */
|
|
149
|
+
chatId?: string;
|
|
150
|
+
/** Person ID (sender) */
|
|
151
|
+
personId?: string;
|
|
152
|
+
/** Created timestamp */
|
|
153
|
+
createdAt?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Webhook event from Channel Talk
|
|
158
|
+
* Sent when messages are created or other events occur
|
|
159
|
+
*/
|
|
160
|
+
export interface ChannelTalkWebhookEvent {
|
|
161
|
+
/** Event action (e.g., 'push') */
|
|
162
|
+
event: string;
|
|
163
|
+
/** Event type identifier (e.g., 'message.created.teamChat') — may be absent for push events */
|
|
164
|
+
type?: string;
|
|
165
|
+
/** Entity data (message content, metadata) */
|
|
166
|
+
entity: ChannelTalkEntity;
|
|
167
|
+
/** References to related entities (manager, group) */
|
|
168
|
+
refers: ChannelTalkRefers;
|
|
169
|
+
}
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { Server } from 'node:http';
|
|
2
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk';
|
|
3
|
+
import type { ChannelTalkWebhookEvent } from './types.js';
|
|
4
|
+
import { createApiClient } from './api-client.js';
|
|
5
|
+
import { getChannelTalkRuntime } from './runtime.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
8
|
+
const DEDUP_TTL_MS = 60_000;
|
|
9
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
export type StartChannelTalkWebhookContext = {
|
|
12
|
+
cfg: OpenClawConfig;
|
|
13
|
+
runtime: { log?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void };
|
|
14
|
+
abortSignal: AbortSignal;
|
|
15
|
+
accountId?: string;
|
|
16
|
+
setStatus?: (next: Record<string, unknown>) => void;
|
|
17
|
+
log?: {
|
|
18
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
19
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
20
|
+
error: (msg: string, meta?: Record<string, unknown>) => void;
|
|
21
|
+
debug?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StartChannelTalkWebhookResult = {
|
|
26
|
+
server: Server;
|
|
27
|
+
shutdown: () => Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function startChannelTalkWebhook(
|
|
31
|
+
ctx: StartChannelTalkWebhookContext,
|
|
32
|
+
): Promise<StartChannelTalkWebhookResult> {
|
|
33
|
+
const core = getChannelTalkRuntime();
|
|
34
|
+
const log = ctx.log ?? {
|
|
35
|
+
info: (msg: string) => ctx.runtime.log?.(`[channel-talk] ${msg}`),
|
|
36
|
+
warn: (msg: string) => ctx.runtime.log?.(`[channel-talk] WARN: ${msg}`),
|
|
37
|
+
error: (msg: string) => ctx.runtime.error?.(`[channel-talk] ${msg}`),
|
|
38
|
+
debug: (msg: string) => ctx.runtime.log?.(`[channel-talk] ${msg}`),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const channelTalkCfg = (ctx.cfg.channels as Record<string, Record<string, unknown>> | undefined)?.['channel-talk'];
|
|
42
|
+
if (!channelTalkCfg) {
|
|
43
|
+
log.error('channel-talk config not found');
|
|
44
|
+
throw new Error('channel-talk config not found in cfg.channels');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const accessKey = channelTalkCfg.accessKey as string | undefined;
|
|
48
|
+
const accessSecret = channelTalkCfg.accessSecret as string | undefined;
|
|
49
|
+
if (!accessKey || !accessSecret) {
|
|
50
|
+
log.error('channel-talk credentials not configured');
|
|
51
|
+
throw new Error('channel-talk credentials (accessKey, accessSecret) not configured');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const webhookCfg = channelTalkCfg.webhook as { port?: number; path?: string } | undefined;
|
|
55
|
+
const port = webhookCfg?.port ?? 3979;
|
|
56
|
+
const webhookPath = webhookCfg?.path ?? '/api/channel-talk';
|
|
57
|
+
const botName = channelTalkCfg.botName as string | undefined;
|
|
58
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
59
|
+
|
|
60
|
+
const apiClient = createApiClient({ accessKey, accessSecret }, channelTalkCfg.baseUrl as string | undefined);
|
|
61
|
+
|
|
62
|
+
const dedupCache = new Map<string, number>();
|
|
63
|
+
|
|
64
|
+
const cleanupDedup = () => {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
for (const [key, ts] of dedupCache) {
|
|
67
|
+
if (now - ts > DEDUP_TTL_MS) {
|
|
68
|
+
dedupCache.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const dedupTimer = setInterval(cleanupDedup, DEDUP_CLEANUP_INTERVAL_MS);
|
|
74
|
+
(dedupTimer as unknown as { unref?: () => void }).unref?.();
|
|
75
|
+
|
|
76
|
+
const isDuplicate = (messageId: string): boolean => {
|
|
77
|
+
if (dedupCache.has(messageId)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
dedupCache.set(messageId, Date.now());
|
|
81
|
+
return false;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const express = (await import('express')).default;
|
|
85
|
+
const app = express();
|
|
86
|
+
app.use(express.json());
|
|
87
|
+
|
|
88
|
+
app.post(webhookPath, (req: { body: unknown }, res: { status: (code: number) => { json: (data: unknown) => void } }) => {
|
|
89
|
+
res.status(200).json({ ok: true });
|
|
90
|
+
|
|
91
|
+
const body = req.body as ChannelTalkWebhookEvent | undefined;
|
|
92
|
+
if (!body) {
|
|
93
|
+
log.debug?.('empty webhook body');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
void handleWebhookEvent(body).catch((err: unknown) => {
|
|
98
|
+
log.error('webhook handler error', { error: String(err) });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function handleWebhookEvent(event: ChannelTalkWebhookEvent): Promise<void> {
|
|
103
|
+
const isTeamChatMessage =
|
|
104
|
+
event.event === 'push' ||
|
|
105
|
+
event.type === 'message.created.teamChat';
|
|
106
|
+
|
|
107
|
+
if (!isTeamChatMessage) {
|
|
108
|
+
log.debug?.('skipping non-message event', {
|
|
109
|
+
event: event.event,
|
|
110
|
+
type: event.type,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const entity = event.entity;
|
|
116
|
+
if (!entity) {
|
|
117
|
+
log.debug?.('skipping event without entity');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (entity.chatType !== 'group') {
|
|
122
|
+
log.debug?.('skipping non-group message', { chatType: entity.chatType });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (entity.personType === 'bot') {
|
|
127
|
+
log.debug?.('skipping bot message');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const messageId = entity.id;
|
|
132
|
+
if (!messageId) {
|
|
133
|
+
log.debug?.('skipping message without id');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (isDuplicate(messageId)) {
|
|
137
|
+
log.debug?.('skipping duplicate message', { messageId });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const plainText = entity.plainText?.trim() ?? '';
|
|
142
|
+
if (!plainText) {
|
|
143
|
+
log.debug?.('skipping empty message');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const refers = event.refers;
|
|
148
|
+
const groupId = entity.chatId ?? refers?.group?.id;
|
|
149
|
+
if (!groupId) {
|
|
150
|
+
log.debug?.('skipping message without group id');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const managerId = refers?.manager?.id ?? entity.personId ?? 'unknown';
|
|
155
|
+
const managerName = refers?.manager?.name ?? managerId;
|
|
156
|
+
const timestamp = entity.createdAt ?? Date.now();
|
|
157
|
+
|
|
158
|
+
log.info('received team chat message', {
|
|
159
|
+
messageId,
|
|
160
|
+
groupId,
|
|
161
|
+
from: managerName,
|
|
162
|
+
preview: plainText.slice(0, 80),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
166
|
+
cfg: ctx.cfg,
|
|
167
|
+
channel: 'channel-talk',
|
|
168
|
+
peer: {
|
|
169
|
+
kind: 'group' as const,
|
|
170
|
+
id: groupId,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const storePath = core.channel.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
175
|
+
agentId: route.agentId,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
179
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
180
|
+
storePath,
|
|
181
|
+
sessionKey: route.sessionKey,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const formattedBody = core.channel.reply.formatAgentEnvelope({
|
|
185
|
+
channel: 'Channel Talk',
|
|
186
|
+
from: managerName,
|
|
187
|
+
timestamp: new Date(timestamp),
|
|
188
|
+
previousTimestamp,
|
|
189
|
+
envelope: envelopeOptions,
|
|
190
|
+
body: plainText,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const preview = plainText.replace(/\s+/g, ' ').slice(0, 160);
|
|
194
|
+
core.system.enqueueSystemEvent(
|
|
195
|
+
`Channel Talk message from ${managerName}: ${preview}`,
|
|
196
|
+
{
|
|
197
|
+
sessionKey: route.sessionKey,
|
|
198
|
+
contextKey: `channel-talk:message:${groupId}:${messageId}`,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
203
|
+
Body: formattedBody,
|
|
204
|
+
RawBody: plainText,
|
|
205
|
+
CommandBody: plainText,
|
|
206
|
+
From: `channel-talk:${managerId}`,
|
|
207
|
+
To: `group:${groupId}`,
|
|
208
|
+
SessionKey: route.sessionKey,
|
|
209
|
+
AccountId: route.accountId ?? accountId,
|
|
210
|
+
ChatType: 'channel' as const,
|
|
211
|
+
ConversationLabel: managerName,
|
|
212
|
+
SenderName: managerName,
|
|
213
|
+
SenderId: managerId,
|
|
214
|
+
Provider: 'channel-talk' as const,
|
|
215
|
+
Surface: 'channel-talk' as const,
|
|
216
|
+
MessageSid: messageId,
|
|
217
|
+
Timestamp: timestamp,
|
|
218
|
+
WasMentioned: false,
|
|
219
|
+
CommandAuthorized: false,
|
|
220
|
+
OriginatingChannel: 'channel-talk' as const,
|
|
221
|
+
OriginatingTo: `group:${groupId}`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await core.channel.session.recordInboundSession({
|
|
225
|
+
storePath,
|
|
226
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
227
|
+
ctx: ctxPayload,
|
|
228
|
+
onRecordError: (err: unknown) => {
|
|
229
|
+
log.debug?.(`failed updating session meta: ${String(err)}`);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const textLimit = core.channel.text.resolveTextChunkLimit(ctx.cfg, 'channel-talk');
|
|
234
|
+
|
|
235
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
236
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
237
|
+
deliver: async (payload: { text?: string }) => {
|
|
238
|
+
const replyText = payload.text;
|
|
239
|
+
if (!replyText) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const chunks = core.channel.text.chunkMarkdownText(replyText, textLimit);
|
|
244
|
+
for (const chunk of chunks) {
|
|
245
|
+
await apiClient.sendMessage({
|
|
246
|
+
groupId,
|
|
247
|
+
plainText: chunk,
|
|
248
|
+
botName,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
onError: (err: unknown) => {
|
|
253
|
+
log.error('reply dispatch error', { error: String(err) });
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
log.info('dispatching to agent', { sessionKey: route.sessionKey });
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
261
|
+
ctx: ctxPayload,
|
|
262
|
+
cfg: ctx.cfg,
|
|
263
|
+
dispatcher,
|
|
264
|
+
replyOptions,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
markDispatchIdle();
|
|
268
|
+
log.info('dispatch complete', { queuedFinal, counts });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
log.error('dispatch failed', { error: String(err) });
|
|
271
|
+
ctx.runtime.error?.(`channel-talk dispatch failed: ${String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const httpServer = app.listen(port, () => {
|
|
276
|
+
log.info(`channel-talk webhook started on port ${port}, path ${webhookPath}`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
httpServer.on('error', (err: Error) => {
|
|
280
|
+
log.error('server error', { error: String(err) });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
ctx.setStatus?.({
|
|
284
|
+
accountId,
|
|
285
|
+
running: true,
|
|
286
|
+
connected: true,
|
|
287
|
+
lastStartAt: Date.now(),
|
|
288
|
+
port,
|
|
289
|
+
webhookPath,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const shutdown = async (): Promise<void> => {
|
|
293
|
+
log.info('shutting down channel-talk webhook');
|
|
294
|
+
clearInterval(dedupTimer);
|
|
295
|
+
dedupCache.clear();
|
|
296
|
+
return new Promise<void>((resolve) => {
|
|
297
|
+
httpServer.close((err?: Error) => {
|
|
298
|
+
if (err) {
|
|
299
|
+
log.debug?.(`server close error: ${String(err)}`);
|
|
300
|
+
}
|
|
301
|
+
ctx.setStatus?.({
|
|
302
|
+
accountId,
|
|
303
|
+
running: false,
|
|
304
|
+
connected: false,
|
|
305
|
+
lastStopAt: Date.now(),
|
|
306
|
+
});
|
|
307
|
+
resolve();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
if (ctx.abortSignal) {
|
|
313
|
+
ctx.abortSignal.addEventListener('abort', () => {
|
|
314
|
+
void shutdown();
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { server: httpServer, shutdown };
|
|
319
|
+
}
|