openclaw-telegram-multibot-relay 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PUBLISHING.md +82 -0
- package/README.md +181 -0
- package/index.js +961 -0
- package/openclaw.plugin.json +23 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tuanminhole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PUBLISHING.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Publishing Guide
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Publish this plugin so users with an existing OpenClaw installation can install it directly from ClawHub or npm.
|
|
6
|
+
|
|
7
|
+
## Recommended release flow
|
|
8
|
+
|
|
9
|
+
1. Create a dedicated GitHub repository for the plugin.
|
|
10
|
+
2. Push the plugin files from this directory into that repository.
|
|
11
|
+
3. Tag a release such as `v0.2.0`.
|
|
12
|
+
4. Publish the package to npm.
|
|
13
|
+
5. Publish the package to ClawHub.
|
|
14
|
+
|
|
15
|
+
## Required metadata
|
|
16
|
+
|
|
17
|
+
Make sure these files stay in sync:
|
|
18
|
+
|
|
19
|
+
- `package.json`
|
|
20
|
+
- `openclaw.plugin.json`
|
|
21
|
+
- `README.md`
|
|
22
|
+
- `LICENSE`
|
|
23
|
+
|
|
24
|
+
For ClawHub plugin publishing, `package.json` should include:
|
|
25
|
+
|
|
26
|
+
- `openclaw.extensions`
|
|
27
|
+
- `openclaw.compat.pluginApi`
|
|
28
|
+
- `openclaw.compat.minGatewayVersion`
|
|
29
|
+
- `openclaw.build.openclawVersion`
|
|
30
|
+
- `openclaw.build.pluginSdkVersion`
|
|
31
|
+
|
|
32
|
+
## Local validation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm pack --dry-run
|
|
36
|
+
node --check index.js
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Publish to npm
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm login
|
|
43
|
+
npm publish --access public
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publish to ClawHub
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm i -g clawhub
|
|
50
|
+
clawhub package publish . --dry-run
|
|
51
|
+
clawhub package publish .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Install commands for end users
|
|
55
|
+
|
|
56
|
+
From ClawHub:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
openclaw plugins install clawhub:openclaw-telegram-multibot-relay
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
From npm:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
openclaw plugins install openclaw-telegram-multibot-relay
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## After installation
|
|
69
|
+
|
|
70
|
+
Enable the plugin in `openclaw.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"plugins": {
|
|
75
|
+
"entries": {
|
|
76
|
+
"telegram-multibot-relay": {
|
|
77
|
+
"enabled": true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# OpenClaw Telegram Multibot Relay
|
|
2
|
+
|
|
3
|
+
[English](#english) | [Tiếng Việt](#tiếng-việt)
|
|
4
|
+
|
|
5
|
+
An OpenClaw runtime plugin for shared-gateway Telegram multibot setups.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## English
|
|
10
|
+
|
|
11
|
+
### Overview
|
|
12
|
+
|
|
13
|
+
`openclaw-telegram-multibot-relay` turns multiple Telegram bot accounts in one OpenClaw gateway into a coordinated team.
|
|
14
|
+
|
|
15
|
+
It is designed for setups where:
|
|
16
|
+
- several Telegram bots share one OpenClaw gateway
|
|
17
|
+
- each bot has its own identity and account binding
|
|
18
|
+
- bots need to hand work off to each other in public group chats
|
|
19
|
+
- reminders should appear in the native OpenClaw Cron UI
|
|
20
|
+
|
|
21
|
+
### Core capabilities
|
|
22
|
+
|
|
23
|
+
- Route a group turn to the correct Telegram bot account
|
|
24
|
+
- Prevent the wrong bot from hijacking the turn
|
|
25
|
+
- Support public cross-bot flows such as:
|
|
26
|
+
- `Bot A asks Bot B ...`
|
|
27
|
+
- `Bot A assigns Bot B ...`
|
|
28
|
+
- `Bot A reminds Bot B ...`
|
|
29
|
+
- Create one-shot and repeating reminders through native OpenClaw cron
|
|
30
|
+
- Remove reminders through the same native cron layer
|
|
31
|
+
- Match bot names dynamically from actual OpenClaw agent and Telegram account config
|
|
32
|
+
|
|
33
|
+
### Why this plugin exists
|
|
34
|
+
|
|
35
|
+
Telegram Bot API does not provide a strong built-in model for public bot-to-bot collaboration inside the same group. OpenClaw already supports multi-agent routing, multi-account routing, and internal handoff, but the public relay layer still needs explicit orchestration.
|
|
36
|
+
|
|
37
|
+
This plugin provides that orchestration while staying aligned with OpenClaw conventions:
|
|
38
|
+
- native `openclaw.plugin.json` manifest
|
|
39
|
+
- runtime registration through `definePluginEntry(...)`
|
|
40
|
+
- native OpenClaw cron integration
|
|
41
|
+
- ClawHub-compatible package metadata
|
|
42
|
+
|
|
43
|
+
### Installation
|
|
44
|
+
|
|
45
|
+
Install from ClawHub:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
openclaw plugins install clawhub:openclaw-telegram-multibot-relay
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or install from npm if you publish the package there:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
openclaw plugins install openclaw-telegram-multibot-relay
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Enable it:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"plugins": {
|
|
62
|
+
"entries": {
|
|
63
|
+
"telegram-multibot-relay": {
|
|
64
|
+
"enabled": true
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Example phrases
|
|
72
|
+
|
|
73
|
+
- `Williams asks Luna about the first 30 days of marketing`
|
|
74
|
+
- `Luna assigns Williams to draft a deploy checklist`
|
|
75
|
+
- `Williams reminds Luna tomorrow at 9:00 to prepare the content plan`
|
|
76
|
+
- `delete all reminders`
|
|
77
|
+
- `delete reminders for Williams`
|
|
78
|
+
|
|
79
|
+
### Telegram reaction behavior
|
|
80
|
+
|
|
81
|
+
The plugin first tries a real Telegram Bot API reaction.
|
|
82
|
+
|
|
83
|
+
If the target chat rejects reactions, Telegram may return `REACTION_INVALID`. In that case the plugin falls back to a short leading emoji in the message text. That is a Telegram Bot API limitation, not only a prompt issue.
|
|
84
|
+
|
|
85
|
+
### Compatibility
|
|
86
|
+
|
|
87
|
+
- Node.js `>=20`
|
|
88
|
+
- OpenClaw plugin API `>=2026.3.24`
|
|
89
|
+
- OpenClaw gateway `>=2026.3.24`
|
|
90
|
+
|
|
91
|
+
### License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Tiếng Việt
|
|
98
|
+
|
|
99
|
+
### Tổng quan
|
|
100
|
+
|
|
101
|
+
`openclaw-telegram-multibot-relay` là plugin runtime cho OpenClaw, giúp nhiều bot Telegram chạy chung trong một gateway hoạt động như một đội bot phối hợp.
|
|
102
|
+
|
|
103
|
+
Plugin phù hợp khi:
|
|
104
|
+
- nhiều bot Telegram dùng chung một OpenClaw gateway
|
|
105
|
+
- mỗi bot có identity và Telegram account binding riêng
|
|
106
|
+
- bot cần giao việc hoặc hỏi qua lại công khai trong group
|
|
107
|
+
- lịch nhắc cần đi vào Cron UI native của OpenClaw
|
|
108
|
+
|
|
109
|
+
### Năng lực chính
|
|
110
|
+
|
|
111
|
+
- Route đúng lượt chat group vào đúng bot Telegram
|
|
112
|
+
- Chặn bot sai chen vào trả lời
|
|
113
|
+
- Hỗ trợ relay công khai giữa các bot, ví dụ:
|
|
114
|
+
- `Bot A hỏi Bot B ...`
|
|
115
|
+
- `Bot A giao việc cho Bot B ...`
|
|
116
|
+
- `Bot A nhắc Bot B ...`
|
|
117
|
+
- Tạo nhắc hẹn một lần hoặc lặp lại bằng cron native của OpenClaw
|
|
118
|
+
- Xóa lịch nhắc qua đúng lớp cron native đó
|
|
119
|
+
- Match tên bot động theo agent/account config thật, không hardcode tên riêng
|
|
120
|
+
|
|
121
|
+
### Vì sao cần plugin này
|
|
122
|
+
|
|
123
|
+
Telegram Bot API không có sẵn mô hình mạnh cho việc nhiều bot phối hợp công khai trong cùng một group. OpenClaw đã có multi-agent, multi-account và handoff nội bộ, nhưng lớp relay công khai ra đúng bot vẫn cần logic bổ sung.
|
|
124
|
+
|
|
125
|
+
Plugin này bổ sung lớp đó theo đúng chuẩn OpenClaw:
|
|
126
|
+
- manifest `openclaw.plugin.json`
|
|
127
|
+
- runtime registration qua `definePluginEntry(...)`
|
|
128
|
+
- tích hợp cron native của OpenClaw
|
|
129
|
+
- metadata phù hợp để publish lên ClawHub
|
|
130
|
+
|
|
131
|
+
### Cài đặt
|
|
132
|
+
|
|
133
|
+
Cài từ ClawHub:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
openclaw plugins install clawhub:openclaw-telegram-multibot-relay
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Hoặc cài từ npm nếu package đã được publish:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
openclaw plugins install openclaw-telegram-multibot-relay
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Bật plugin:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"plugins": {
|
|
150
|
+
"entries": {
|
|
151
|
+
"telegram-multibot-relay": {
|
|
152
|
+
"enabled": true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Ví dụ câu lệnh
|
|
160
|
+
|
|
161
|
+
- `Williams hỏi Luna về marketing 30 ngày đầu`
|
|
162
|
+
- `Luna giao William soạn checklist deploy`
|
|
163
|
+
- `Williams nhắc Luna 9h sáng mai chuẩn bị plan content`
|
|
164
|
+
- `xóa hết lịch nhắc`
|
|
165
|
+
- `xóa lịch nhắc của Williams`
|
|
166
|
+
|
|
167
|
+
### Hành vi reaction trên Telegram
|
|
168
|
+
|
|
169
|
+
Plugin sẽ thử gọi reaction thật qua Telegram Bot API trước.
|
|
170
|
+
|
|
171
|
+
Nếu chat đích từ chối reaction, Telegram có thể trả `REACTION_INVALID`. Khi đó plugin sẽ fallback sang emoji ngắn ở đầu câu trả lời. Đây là giới hạn từ Telegram Bot API, không chỉ là vấn đề prompt.
|
|
172
|
+
|
|
173
|
+
### Tương thích
|
|
174
|
+
|
|
175
|
+
- Node.js `>=20`
|
|
176
|
+
- OpenClaw plugin API `>=2026.3.24`
|
|
177
|
+
- OpenClaw gateway `>=2026.3.24`
|
|
178
|
+
|
|
179
|
+
### Giấy phép
|
|
180
|
+
|
|
181
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { definePluginEntry, emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';
|
|
6
|
+
|
|
7
|
+
const THUMBS_UP = String.fromCodePoint(0x1F44D);
|
|
8
|
+
const RECENT_TURN_TTL_MS = 30_000;
|
|
9
|
+
const ACK_FALLBACK = THUMBS_UP;
|
|
10
|
+
const REACTION_CANDIDATES = [THUMBS_UP, '❤', '🔥', '👌'];
|
|
11
|
+
|
|
12
|
+
function foldText(value) {
|
|
13
|
+
return String(value || '')
|
|
14
|
+
.normalize('NFD')
|
|
15
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function escapeRegex(value) {
|
|
21
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildAliasPattern(agent) {
|
|
25
|
+
return Array.from(new Set([agent.foldedName, ...(agent.aliases || [])].filter(Boolean)))
|
|
26
|
+
.sort((a, b) => b.length - a.length)
|
|
27
|
+
.map((value) => escapeRegex(value))
|
|
28
|
+
.join('|');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stripProviderPrefix(modelRef) {
|
|
32
|
+
const raw = String(modelRef || '').trim();
|
|
33
|
+
if (!raw) return 'smart-route';
|
|
34
|
+
const parts = raw.split('/');
|
|
35
|
+
return parts.length > 1 ? parts.slice(1).join('/') : raw;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildTurnCacheKey({ content, senderId, timestamp, conversationId }) {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
c: String(content || '').trim(),
|
|
41
|
+
s: String(senderId || ''),
|
|
42
|
+
t: Number(timestamp || 0),
|
|
43
|
+
v: String(conversationId || ''),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cleanQuestionTail(value) {
|
|
48
|
+
return String(value || '')
|
|
49
|
+
.replace(/^[:,-]\s*/, '')
|
|
50
|
+
.replace(/^["'“”]+|["'“”]+$/g, '')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isGenericQuestionRequest(value) {
|
|
55
|
+
const folded = foldText(value);
|
|
56
|
+
if (!folded) return true;
|
|
57
|
+
return [
|
|
58
|
+
'bat cu cau gi',
|
|
59
|
+
'bat ky cau gi',
|
|
60
|
+
'mot cau bat ky',
|
|
61
|
+
'1 cau bat ky',
|
|
62
|
+
'1 cau gi',
|
|
63
|
+
'mot cau gi',
|
|
64
|
+
'cau gi cung duoc',
|
|
65
|
+
'cau bat ky',
|
|
66
|
+
].some((item) => folded.includes(item));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isGenericTaskRequest(value) {
|
|
70
|
+
const folded = foldText(value);
|
|
71
|
+
if (!folded) return true;
|
|
72
|
+
return [
|
|
73
|
+
'bat cu viec gi',
|
|
74
|
+
'bat ky viec gi',
|
|
75
|
+
'bat ky task nao',
|
|
76
|
+
'bat cu task nao',
|
|
77
|
+
'viec gi cung duoc',
|
|
78
|
+
'task gi cung duoc',
|
|
79
|
+
].some((item) => folded.includes(item));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildDateFromParts(baseDate, hour, minute, dayOffset = 0) {
|
|
83
|
+
return new Date(
|
|
84
|
+
baseDate.getFullYear(),
|
|
85
|
+
baseDate.getMonth(),
|
|
86
|
+
baseDate.getDate() + dayOffset,
|
|
87
|
+
hour,
|
|
88
|
+
minute,
|
|
89
|
+
0,
|
|
90
|
+
0,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseReminderSpec(text, nowMs = Date.now()) {
|
|
95
|
+
const raw = String(text || '').trim();
|
|
96
|
+
const folded = foldText(raw);
|
|
97
|
+
if (!folded) return null;
|
|
98
|
+
|
|
99
|
+
const now = new Date(nowMs);
|
|
100
|
+
let dueAt = null;
|
|
101
|
+
let matchedText = '';
|
|
102
|
+
let dueText = '';
|
|
103
|
+
let repeatEveryMs = 0;
|
|
104
|
+
|
|
105
|
+
const repeatMatch = folded.match(/\b(?:va\s+)?lap lai moi\s+(\d+)?\s*(phut|gio|ngay)\b/);
|
|
106
|
+
if (repeatMatch) {
|
|
107
|
+
const amount = Number(repeatMatch[1] || 1);
|
|
108
|
+
const unit = repeatMatch[2];
|
|
109
|
+
const unitMs = unit === 'phut'
|
|
110
|
+
? 60_000
|
|
111
|
+
: unit === 'gio'
|
|
112
|
+
? 3_600_000
|
|
113
|
+
: 86_400_000;
|
|
114
|
+
if (amount > 0) repeatEveryMs = amount * unitMs;
|
|
115
|
+
} else if (/\bmoi phut(?:\/\s*lan)?\b/.test(folded)) {
|
|
116
|
+
repeatEveryMs = 60_000;
|
|
117
|
+
} else if (/\bmoi gio(?:\/\s*lan)?\b/.test(folded)) {
|
|
118
|
+
repeatEveryMs = 3_600_000;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let match = folded.match(/\bsau\s+(\d+)\s+(phut|gio|ngay|tuan)(?:\s+nua)?\b/);
|
|
122
|
+
if (match) {
|
|
123
|
+
const amount = Number(match[1] || 0);
|
|
124
|
+
const unit = match[2];
|
|
125
|
+
const unitMs = unit === 'phut'
|
|
126
|
+
? 60_000
|
|
127
|
+
: unit === 'gio'
|
|
128
|
+
? 3_600_000
|
|
129
|
+
: unit === 'ngay'
|
|
130
|
+
? 86_400_000
|
|
131
|
+
: 604_800_000;
|
|
132
|
+
if (amount > 0) {
|
|
133
|
+
dueAt = new Date(nowMs + (amount * unitMs));
|
|
134
|
+
matchedText = match[0];
|
|
135
|
+
dueText = `${amount} ${unit}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!dueAt) {
|
|
140
|
+
const patterns = [
|
|
141
|
+
/\b(ngay mai|mai|hom nay|toi nay|sang mai|chieu mai)\s*(?:luc|vao)?\s*(\d{1,2})(?:[:h](\d{1,2}))?\s*(sang|chieu|toi)?\b/,
|
|
142
|
+
/\b(\d{1,2})(?:[:h](\d{1,2}))?\s*(sang|chieu|toi)?\s*(ngay mai|mai|hom nay|toi nay|sang mai|chieu mai)?\b/,
|
|
143
|
+
];
|
|
144
|
+
for (const pattern of patterns) {
|
|
145
|
+
match = folded.match(pattern);
|
|
146
|
+
if (!match) continue;
|
|
147
|
+
|
|
148
|
+
const ordered = pattern === patterns[0]
|
|
149
|
+
? { dayWord: match[1] || '', hour: match[2], minute: match[3], meridiem: match[4] || '' }
|
|
150
|
+
: { hour: match[1], minute: match[2], meridiem: match[3] || '', dayWord: match[4] || '' };
|
|
151
|
+
let hour = Number(ordered.hour || 0);
|
|
152
|
+
const minute = Number(ordered.minute || 0);
|
|
153
|
+
const dayWord = ordered.dayWord || '';
|
|
154
|
+
const meridiem = ordered.meridiem || '';
|
|
155
|
+
|
|
156
|
+
if (!Number.isFinite(hour) || !Number.isFinite(minute) || hour > 23 || minute > 59) continue;
|
|
157
|
+
if (meridiem === 'chieu' || meridiem === 'toi') {
|
|
158
|
+
if (hour < 12) hour += 12;
|
|
159
|
+
}
|
|
160
|
+
if (meridiem === 'sang' && hour === 12) hour = 0;
|
|
161
|
+
|
|
162
|
+
let dayOffset = 0;
|
|
163
|
+
if (dayWord === 'ngay mai' || dayWord === 'mai' || dayWord === 'sang mai' || dayWord === 'chieu mai') {
|
|
164
|
+
dayOffset = 1;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const candidate = buildDateFromParts(now, hour, minute, dayOffset);
|
|
168
|
+
if (!dayWord && candidate.getTime() <= nowMs) candidate.setDate(candidate.getDate() + 1);
|
|
169
|
+
dueAt = candidate;
|
|
170
|
+
matchedText = match[0];
|
|
171
|
+
dueText = `${String(candidate.getHours()).padStart(2, '0')}:${String(candidate.getMinutes()).padStart(2, '0')} ${String(candidate.getDate()).padStart(2, '0')}/${String(candidate.getMonth() + 1).padStart(2, '0')}`;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!dueAt) return null;
|
|
177
|
+
|
|
178
|
+
const cleanedText = raw
|
|
179
|
+
.replace(new RegExp(escapeRegex(matchedText), 'i'), '')
|
|
180
|
+
.replace(/\s{2,}/g, ' ')
|
|
181
|
+
.replace(/^[:,-]\s*/, '')
|
|
182
|
+
.trim();
|
|
183
|
+
|
|
184
|
+
const quotedMatch = raw.match(/["“](.+?)["”]/);
|
|
185
|
+
const reminderBody = String(quotedMatch?.[1] || '').trim() || cleanedText || raw;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
dueAtMs: dueAt.getTime(),
|
|
189
|
+
dueText,
|
|
190
|
+
matchedText,
|
|
191
|
+
repeatEveryMs,
|
|
192
|
+
reminderBody,
|
|
193
|
+
cleanedText: cleanedText || raw,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function safeRead(filePath) {
|
|
198
|
+
try {
|
|
199
|
+
return await fs.readFile(filePath, 'utf8');
|
|
200
|
+
} catch {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadWorkspacePrompt(workspaceDir) {
|
|
206
|
+
const fileNames = ['IDENTITY.md', 'SOUL.md', 'AGENTS.md', 'TEAM.md', 'USER.md', 'TOOLS.md', 'MEMORY.md', 'RELAY.md'];
|
|
207
|
+
const parts = [];
|
|
208
|
+
for (const fileName of fileNames) {
|
|
209
|
+
const content = await safeRead(path.join(workspaceDir, fileName));
|
|
210
|
+
if (!content.trim()) continue;
|
|
211
|
+
parts.push(`\n# File: ${fileName}\n${content.trim()}`);
|
|
212
|
+
}
|
|
213
|
+
return parts.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function resolveOpenAiLikeProvider(config, modelRef) {
|
|
217
|
+
const providers = config?.models?.providers || {};
|
|
218
|
+
const preferredKey = String(modelRef || '').split('/')[0] || '';
|
|
219
|
+
const preferred = providers[preferredKey];
|
|
220
|
+
if (preferred?.baseUrl && preferred?.apiKey && preferred?.api === 'openai-completions') {
|
|
221
|
+
return preferred;
|
|
222
|
+
}
|
|
223
|
+
for (const provider of Object.values(providers)) {
|
|
224
|
+
if (provider?.baseUrl && provider?.apiKey && provider?.api === 'openai-completions') {
|
|
225
|
+
return provider;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function callOpenAiLikeModel(config, modelRef, messages, logger) {
|
|
232
|
+
const provider = resolveOpenAiLikeProvider(config, modelRef);
|
|
233
|
+
if (!provider) throw new Error('No OpenAI-compatible provider found for relay plugin.');
|
|
234
|
+
|
|
235
|
+
const baseUrl = String(provider.baseUrl || '').replace(/\/+$/, '');
|
|
236
|
+
const apiKey = String(provider.apiKey || '').trim();
|
|
237
|
+
const model = stripProviderPrefix(modelRef);
|
|
238
|
+
|
|
239
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: {
|
|
242
|
+
'content-type': 'application/json',
|
|
243
|
+
authorization: `Bearer ${apiKey}`,
|
|
244
|
+
},
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
model,
|
|
247
|
+
temperature: 0.6,
|
|
248
|
+
messages,
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
const body = await response.text();
|
|
254
|
+
logger.warn(`relay llm request failed: ${response.status} ${body.slice(0, 300)}`);
|
|
255
|
+
throw new Error(`Relay LLM request failed with status ${response.status}.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const payload = await response.json();
|
|
259
|
+
const content = payload?.choices?.[0]?.message?.content;
|
|
260
|
+
if (typeof content === 'string' && content.trim()) return content.trim();
|
|
261
|
+
if (Array.isArray(content)) {
|
|
262
|
+
const text = content.map((item) => item?.text || '').join('').trim();
|
|
263
|
+
if (text) return text;
|
|
264
|
+
}
|
|
265
|
+
throw new Error('Relay LLM response was empty.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function resolveTelegramUsernames(config, agents) {
|
|
269
|
+
await Promise.all(agents.map(async (agent) => {
|
|
270
|
+
if (agent.username) return;
|
|
271
|
+
const token = String(agent.token || '').trim();
|
|
272
|
+
if (!token) return;
|
|
273
|
+
try {
|
|
274
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
|
275
|
+
const payload = await response.json();
|
|
276
|
+
const username = payload?.result?.username;
|
|
277
|
+
if (username) agent.username = String(username).toLowerCase();
|
|
278
|
+
} catch {
|
|
279
|
+
// Best effort only.
|
|
280
|
+
}
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resolveTelegramAccountToken(config, accountId) {
|
|
285
|
+
return String(config?.channels?.telegram?.accounts?.[accountId]?.botToken || '').trim();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function fetchTelegramChatInfo(config, accountId, chatIdInput, logger) {
|
|
289
|
+
const token = resolveTelegramAccountToken(config, accountId);
|
|
290
|
+
if (!token) return null;
|
|
291
|
+
const chatId = String(chatIdInput || '').trim();
|
|
292
|
+
if (!chatId) return null;
|
|
293
|
+
|
|
294
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/getChat`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'content-type': 'application/json' },
|
|
297
|
+
body: JSON.stringify({
|
|
298
|
+
chat_id: /^-?\d+$/.test(chatId) ? Number(chatId) : chatId,
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
if (!response.ok) {
|
|
303
|
+
const body = await response.text();
|
|
304
|
+
logger.debug?.(`relay getChat http failed for ${accountId}: ${response.status} ${body.slice(0, 200)}`);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const payload = await response.json();
|
|
309
|
+
if (!payload?.ok) {
|
|
310
|
+
logger.debug?.(`relay getChat api failed for ${accountId}: ${JSON.stringify(payload).slice(0, 200)}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return payload?.result || null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function pickAvailableReaction(chatInfo) {
|
|
318
|
+
const available = chatInfo?.available_reactions;
|
|
319
|
+
if (!available) return REACTION_CANDIDATES[0];
|
|
320
|
+
if (available === 'all') return REACTION_CANDIDATES[0];
|
|
321
|
+
if (!Array.isArray(available)) return null;
|
|
322
|
+
|
|
323
|
+
const emojis = available
|
|
324
|
+
.map((item) => item?.type === 'emoji' ? item?.emoji : null)
|
|
325
|
+
.filter(Boolean);
|
|
326
|
+
|
|
327
|
+
for (const emoji of REACTION_CANDIDATES) {
|
|
328
|
+
if (emojis.includes(emoji)) return emoji;
|
|
329
|
+
}
|
|
330
|
+
return emojis[0] || null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function sendTelegramText(config, accountId, to, text, opts = {}) {
|
|
334
|
+
const token = resolveTelegramAccountToken(config, accountId);
|
|
335
|
+
if (!token) throw new Error(`missing token for ${accountId}`);
|
|
336
|
+
|
|
337
|
+
const payload = {
|
|
338
|
+
chat_id: /^-?\d+$/.test(String(to)) ? Number(to) : String(to),
|
|
339
|
+
text: String(text || ''),
|
|
340
|
+
reply_parameters: opts.replyToMessageId ? { message_id: Number(opts.replyToMessageId) } : undefined,
|
|
341
|
+
message_thread_id: opts.messageThreadId ? Number(opts.messageThreadId) : undefined,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
|
345
|
+
method: 'POST',
|
|
346
|
+
headers: { 'content-type': 'application/json' },
|
|
347
|
+
body: JSON.stringify(payload),
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
if (!response.ok) {
|
|
351
|
+
const body = await response.text();
|
|
352
|
+
throw new Error(`sendMessage http ${response.status}: ${body.slice(0, 200)}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const result = await response.json();
|
|
356
|
+
if (!result?.ok) {
|
|
357
|
+
throw new Error(`sendMessage api failed: ${String(result?.description || 'unknown error')}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
messageId: String(result?.result?.message_id || ''),
|
|
362
|
+
chatId: String(result?.result?.chat?.id || to),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function reactMessageTelegram(config, accountId, chatIdInput, messageIdInput, emoji, logger) {
|
|
367
|
+
const token = resolveTelegramAccountToken(config, accountId);
|
|
368
|
+
if (!token) return { ok: false, warning: `missing token for ${accountId}` };
|
|
369
|
+
|
|
370
|
+
const chatId = String(chatIdInput || '').trim();
|
|
371
|
+
const messageId = Number(messageIdInput || 0);
|
|
372
|
+
if (!chatId || !messageId) return { ok: false, warning: 'missing chatId or messageId' };
|
|
373
|
+
|
|
374
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/setMessageReaction`, {
|
|
375
|
+
method: 'POST',
|
|
376
|
+
headers: { 'content-type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
chat_id: /^-?\d+$/.test(chatId) ? Number(chatId) : chatId,
|
|
379
|
+
message_id: messageId,
|
|
380
|
+
reaction: [{ type: 'emoji', emoji }],
|
|
381
|
+
is_big: false,
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
const body = await response.text();
|
|
387
|
+
logger.debug?.(`relay reaction http failed for ${accountId}: ${response.status} ${body.slice(0, 200)}`);
|
|
388
|
+
return { ok: false, warning: `http ${response.status}` };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const payload = await response.json();
|
|
392
|
+
if (!payload?.ok) {
|
|
393
|
+
logger.debug?.(`relay reaction api failed for ${accountId}: ${JSON.stringify(payload).slice(0, 200)}`);
|
|
394
|
+
return { ok: false, warning: String(payload?.description || 'telegram api error') };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function loadReminderStore(storePath) {
|
|
401
|
+
try {
|
|
402
|
+
const raw = await fs.readFile(storePath, 'utf8');
|
|
403
|
+
const parsed = JSON.parse(raw);
|
|
404
|
+
return {
|
|
405
|
+
version: 1,
|
|
406
|
+
jobs: Array.isArray(parsed?.jobs) ? parsed.jobs.filter(Boolean) : [],
|
|
407
|
+
};
|
|
408
|
+
} catch {
|
|
409
|
+
return { version: 1, jobs: [] };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function saveReminderStore(storePath, store) {
|
|
414
|
+
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
|
415
|
+
await fs.writeFile(storePath, JSON.stringify({
|
|
416
|
+
version: 1,
|
|
417
|
+
jobs: Array.isArray(store?.jobs) ? store.jobs.filter(Boolean) : [],
|
|
418
|
+
}, null, 2), 'utf8');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function buildReminderMessage(recipientMention, reminderBody) {
|
|
422
|
+
const prefix = String(recipientMention || '').trim();
|
|
423
|
+
const body = String(reminderBody || '').trim();
|
|
424
|
+
return prefix ? `${prefix} ${body}`.trim() : body;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function extractJsonPayload(raw) {
|
|
428
|
+
const text = String(raw || '').trim();
|
|
429
|
+
const firstBrace = text.indexOf('{');
|
|
430
|
+
const firstBracket = text.indexOf('[');
|
|
431
|
+
const start = firstBrace >= 0 && firstBracket >= 0 ? Math.min(firstBrace, firstBracket) : Math.max(firstBrace, firstBracket);
|
|
432
|
+
if (start < 0) throw new Error(`Unable to parse gateway response: ${text.slice(0, 200)}`);
|
|
433
|
+
return JSON.parse(text.slice(start));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function buildReminderJobName(input) {
|
|
437
|
+
return `telegram-relay:${input.accountId}:${input.to}:${randomUUID().slice(0, 8)}`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function createReminderManager(logger, runtime) {
|
|
441
|
+
const runGatewayCall = async (method, params) => {
|
|
442
|
+
const res = await runtime.system.runCommandWithTimeout(
|
|
443
|
+
['openclaw', 'gateway', 'call', method, '--params', JSON.stringify(params || {})],
|
|
444
|
+
{ timeoutMs: 30_000 },
|
|
445
|
+
);
|
|
446
|
+
if (res.code !== 0) {
|
|
447
|
+
throw new Error((res.stderr || res.stdout || `gateway call failed: ${method}`).trim());
|
|
448
|
+
}
|
|
449
|
+
return extractJsonPayload(res.stdout);
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const listPluginJobs = async () => {
|
|
453
|
+
const payload = await runGatewayCall('cron.list', { includeDisabled: true, limit: 200 });
|
|
454
|
+
const jobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
|
455
|
+
return jobs.filter((job) => String(job?.name || '').startsWith('telegram-relay:'));
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
async start() {
|
|
460
|
+
const payload = await runGatewayCall('cron.status', {});
|
|
461
|
+
logger.info(`relay reminder cron ready storePath=${payload?.storePath || 'unknown'} jobs=${payload?.jobs ?? 'n/a'}`);
|
|
462
|
+
},
|
|
463
|
+
async ensureStarted() {
|
|
464
|
+
return await this.start();
|
|
465
|
+
},
|
|
466
|
+
async stop() {
|
|
467
|
+
return;
|
|
468
|
+
},
|
|
469
|
+
async schedule(input) {
|
|
470
|
+
const schedule = Number(input.repeatEveryMs || 0) > 0
|
|
471
|
+
? { kind: 'every', everyMs: Number(input.repeatEveryMs), anchorMs: Number(input.dueAtMs) }
|
|
472
|
+
: { kind: 'at', at: new Date(Number(input.dueAtMs)).toISOString() };
|
|
473
|
+
const params = {
|
|
474
|
+
agentId: String(input.agentId || ''),
|
|
475
|
+
name: buildReminderJobName(input),
|
|
476
|
+
description: `Telegram relay reminder for ${input.accountId}`,
|
|
477
|
+
enabled: true,
|
|
478
|
+
deleteAfterRun: Number(input.repeatEveryMs || 0) <= 0,
|
|
479
|
+
schedule,
|
|
480
|
+
sessionTarget: 'main',
|
|
481
|
+
wakeMode: 'now',
|
|
482
|
+
payload: {
|
|
483
|
+
kind: 'systemEvent',
|
|
484
|
+
text: String(input.text || ''),
|
|
485
|
+
},
|
|
486
|
+
delivery: {
|
|
487
|
+
mode: 'announce',
|
|
488
|
+
channel: 'telegram',
|
|
489
|
+
to: String(input.to),
|
|
490
|
+
accountId: String(input.accountId),
|
|
491
|
+
threadId: input.messageThreadId ?? undefined,
|
|
492
|
+
bestEffort: true,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
const job = await runGatewayCall('cron.add', params);
|
|
496
|
+
logger.info(`relay reminder scheduled job=${job?.id || 'unknown'} via native cron`);
|
|
497
|
+
return job;
|
|
498
|
+
},
|
|
499
|
+
async clearAll() {
|
|
500
|
+
const jobs = await listPluginJobs();
|
|
501
|
+
for (const job of jobs) {
|
|
502
|
+
await runGatewayCall('cron.remove', { id: job.id });
|
|
503
|
+
}
|
|
504
|
+
logger.info(`relay reminder cleared jobs=${jobs.length} via native cron`);
|
|
505
|
+
return { removed: jobs.length };
|
|
506
|
+
},
|
|
507
|
+
async clearByAccount(accountId) {
|
|
508
|
+
const jobs = await listPluginJobs();
|
|
509
|
+
const matched = jobs.filter((job) => String(job?.delivery?.accountId || '') === String(accountId || ''));
|
|
510
|
+
for (const job of matched) {
|
|
511
|
+
await runGatewayCall('cron.remove', { id: job.id });
|
|
512
|
+
}
|
|
513
|
+
logger.info(`relay reminder cleared account=${accountId} jobs=${matched.length} via native cron`);
|
|
514
|
+
return { removed: matched.length };
|
|
515
|
+
},
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function buildAgentState(config, previousAgents = []) {
|
|
520
|
+
const bindings = Array.isArray(config?.bindings) ? config.bindings : [];
|
|
521
|
+
const accounts = config?.channels?.telegram?.accounts || {};
|
|
522
|
+
const list = Array.isArray(config?.agents?.list) ? config.agents.list : [];
|
|
523
|
+
const previousById = new Map(previousAgents.map((agent) => [agent.agentId, agent]));
|
|
524
|
+
|
|
525
|
+
return list.map((agent) => {
|
|
526
|
+
const binding = bindings.find((item) => item?.agentId === agent.id && item?.match?.channel === 'telegram');
|
|
527
|
+
const accountId = binding?.match?.accountId || 'default';
|
|
528
|
+
const name = String(agent.name || agent.id || '').trim();
|
|
529
|
+
const foldedName = foldText(name);
|
|
530
|
+
const foldedId = foldText(agent.id);
|
|
531
|
+
const aliases = new Set([foldedName, foldedId].filter(Boolean));
|
|
532
|
+
if (foldedName.endsWith('s') && foldedName.length > 1) aliases.add(foldedName.slice(0, -1));
|
|
533
|
+
if (foldedId.endsWith('s') && foldedId.length > 1) aliases.add(foldedId.slice(0, -1));
|
|
534
|
+
const previous = previousById.get(agent.id);
|
|
535
|
+
return {
|
|
536
|
+
agentId: agent.id,
|
|
537
|
+
accountId,
|
|
538
|
+
name,
|
|
539
|
+
foldedName,
|
|
540
|
+
aliases: Array.from(aliases),
|
|
541
|
+
workspaceDir: agent.workspace,
|
|
542
|
+
token: accounts?.[accountId]?.botToken || '',
|
|
543
|
+
username: previous?.username || '',
|
|
544
|
+
model: agent?.model?.primary || config?.agents?.defaults?.model?.primary || 'smart-route',
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function detectRelayIntent(foldedText, agents) {
|
|
550
|
+
const intentSpecs = [
|
|
551
|
+
{
|
|
552
|
+
kind: 'question',
|
|
553
|
+
verbs: ['hoi nguoc lai', 'hoi tiep', 'hoi them', 'hoi giup', 'bao hoi', 'nho hoi', 'hoi lai', 'hoi'],
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
kind: 'task',
|
|
557
|
+
verbs: ['giao viec', 'giao task', 'soan task', 'nhac viec', 'nhac', 'bao', 'noi voi', 'yeu cau'],
|
|
558
|
+
},
|
|
559
|
+
];
|
|
560
|
+
|
|
561
|
+
for (const caller of agents) {
|
|
562
|
+
for (const target of agents) {
|
|
563
|
+
if (caller.agentId === target.agentId) continue;
|
|
564
|
+
const callerPattern = buildAliasPattern(caller);
|
|
565
|
+
const targetPattern = buildAliasPattern(target);
|
|
566
|
+
for (const spec of intentSpecs) {
|
|
567
|
+
const regex = new RegExp(`\\b(?:${callerPattern})\\s+(?:${spec.verbs.map((item) => escapeRegex(item)).join('|')})\\s+(?:cho\\s+)?(?:${targetPattern})\\b([\\s\\S]*)`, 'i');
|
|
568
|
+
const match = foldedText.match(regex);
|
|
569
|
+
if (!match) continue;
|
|
570
|
+
const bodyTail = cleanQuestionTail(match[1] || '');
|
|
571
|
+
return {
|
|
572
|
+
kind: spec.kind,
|
|
573
|
+
caller,
|
|
574
|
+
target,
|
|
575
|
+
bodyTail,
|
|
576
|
+
reminder: spec.kind === 'task' ? parseReminderSpec(bodyTail) : null,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function detectOwner(event, currentAccountId, agents) {
|
|
585
|
+
const rawLower = String(event.content || event.body || '').toLowerCase();
|
|
586
|
+
const exactMentionMatches = agents.filter((agent) => agent.username && rawLower.includes(`@${String(agent.username).toLowerCase()}`));
|
|
587
|
+
if (exactMentionMatches.length === 1) return exactMentionMatches[0].accountId;
|
|
588
|
+
|
|
589
|
+
const folded = foldText(event.content || event.body || '');
|
|
590
|
+
const matchedAgents = agents.filter((agent) => {
|
|
591
|
+
if (!agent.username) return agent.aliases.some((alias) => alias && folded.includes(alias));
|
|
592
|
+
return agent.aliases.some((alias) => alias && folded.includes(alias)) || folded.includes(`@${agent.username}`);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (matchedAgents.length === 1) return matchedAgents[0].accountId;
|
|
596
|
+
if (event.wasMentioned) return currentAccountId;
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function detectClearRemindersIntent(text, agents) {
|
|
601
|
+
const folded = foldText(text);
|
|
602
|
+
if (!folded) return null;
|
|
603
|
+
const hasDelete = ['xoa', 'xoa het', 'huy', 'dung', 'clear', 'remove'].some((item) => folded.includes(item));
|
|
604
|
+
const hasReminderTarget = ['cron', 'lich nhac', 'nhac hen', 'reminder', 'lich hen'].some((item) => folded.includes(item));
|
|
605
|
+
if (!hasDelete || !hasReminderTarget) return null;
|
|
606
|
+
|
|
607
|
+
const matchedAgents = (agents || []).filter((agent) => {
|
|
608
|
+
if (agent.username && folded.includes(`@${agent.username}`)) return true;
|
|
609
|
+
return (agent.aliases || []).some((alias) => alias && folded.includes(alias));
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (matchedAgents.length === 1) {
|
|
613
|
+
return { targetAccountId: matchedAgents[0].accountId };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
return { targetAccountId: null };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function buildQuestionPrompt(caller, target) {
|
|
620
|
+
return [
|
|
621
|
+
{
|
|
622
|
+
role: 'system',
|
|
623
|
+
content: 'You generate one short, natural Telegram group question in Vietnamese. Output question text only, with no quotes and no explanation.',
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
role: 'user',
|
|
627
|
+
content: `You are ${caller.name}. Ask ${target.name} exactly one useful question related to ${target.name}'s role. Keep it under 24 words.`,
|
|
628
|
+
},
|
|
629
|
+
];
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function buildTaskPrompt(caller, target) {
|
|
633
|
+
return [
|
|
634
|
+
{
|
|
635
|
+
role: 'system',
|
|
636
|
+
content: 'You generate one short, natural Telegram task assignment in Vietnamese. Output task text only, with no quotes and no explanation.',
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
role: 'user',
|
|
640
|
+
content: `You are ${caller.name}. Assign ${target.name} exactly one concrete task related to ${target.name}'s role. Keep it under 28 words.`,
|
|
641
|
+
},
|
|
642
|
+
];
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function buildAnswerPrompt(targetPrompt, caller, target, userText, questionText) {
|
|
646
|
+
return [
|
|
647
|
+
{
|
|
648
|
+
role: 'system',
|
|
649
|
+
content: `${targetPrompt}\n\nYou are replying publicly in a Telegram group. If you are clearly being addressed, assume a short reaction has already been sent before your text reply. Reply in Vietnamese. Stay concise, practical, and in-character.`,
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
role: 'user',
|
|
653
|
+
content: `Original user request:\n${userText}\n\n${caller.name} asks ${target.name} this question:\n${questionText}\n\nReply as ${target.name} only. Do not mention internal handoff.`,
|
|
654
|
+
},
|
|
655
|
+
];
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function buildTaskAckPrompt(targetPrompt, caller, target, userText, taskText, reminder, reminderStatus) {
|
|
659
|
+
const reminderLine = reminder && reminderStatus === 'scheduled'
|
|
660
|
+
? `\nReminder scheduled for: ${reminder.dueText}`
|
|
661
|
+
: reminder && reminderStatus === 'failed'
|
|
662
|
+
? `\nReminder requested for: ${reminder.dueText}, but scheduling failed.`
|
|
663
|
+
: '';
|
|
664
|
+
return [
|
|
665
|
+
{
|
|
666
|
+
role: 'system',
|
|
667
|
+
content: `${targetPrompt}\n\nYou are replying publicly in a Telegram group. If you are clearly being addressed, assume a short reaction has already been sent before your text reply. Reply in Vietnamese. Confirm the assignment, say the first action, and stay concise and in-character.`,
|
|
668
|
+
},
|
|
669
|
+
{
|
|
670
|
+
role: 'user',
|
|
671
|
+
content: `Original user request:\n${userText}\n\n${caller.name} assigns ${target.name} this task:\n${taskText}${reminderLine}\n\nReply as ${target.name} only. Confirm ownership, say what you will do first. If reminderStatus is scheduled, mention that you set the reminder. If reminderStatus is failed, say you got the task but reminder setup failed.`,
|
|
672
|
+
},
|
|
673
|
+
];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function maybeReact(accountId, chatId, messageId, cfg, logger, reactionCache) {
|
|
677
|
+
if (!chatId || !messageId) return;
|
|
678
|
+
try {
|
|
679
|
+
const cacheKey = `${accountId}:${chatId}`;
|
|
680
|
+
let reactionEmoji = reactionCache.get(cacheKey);
|
|
681
|
+
if (!reactionEmoji) {
|
|
682
|
+
const chatInfo = await fetchTelegramChatInfo(cfg, accountId, chatId, logger);
|
|
683
|
+
reactionEmoji = pickAvailableReaction(chatInfo);
|
|
684
|
+
if (reactionEmoji) reactionCache.set(cacheKey, reactionEmoji);
|
|
685
|
+
}
|
|
686
|
+
if (!reactionEmoji) return false;
|
|
687
|
+
|
|
688
|
+
const result = await reactMessageTelegram(cfg, accountId, chatId, messageId, reactionEmoji, logger);
|
|
689
|
+
if (!result?.ok && String(result?.warning || '').includes('REACTION_INVALID')) {
|
|
690
|
+
reactionCache.delete(cacheKey);
|
|
691
|
+
}
|
|
692
|
+
return result?.ok === true;
|
|
693
|
+
} catch (error) {
|
|
694
|
+
logger.debug?.(`relay reaction failed for ${accountId}: ${String(error)}`);
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function runRelayFlow(api, event, relayIntent, reminderManager, reactionCache) {
|
|
700
|
+
const cfg = api.config;
|
|
701
|
+
const logger = api.logger;
|
|
702
|
+
const chatId = String(event.conversationId || '');
|
|
703
|
+
const messageId = Number(event.messageId || 0) || undefined;
|
|
704
|
+
const messageThreadId = event.threadId ? Number(event.threadId) : undefined;
|
|
705
|
+
const senderUsername = String(event.senderUsername || '').trim();
|
|
706
|
+
const recipientMention = senderUsername ? `@${senderUsername.replace(/^@+/, '')}` : '';
|
|
707
|
+
|
|
708
|
+
const caller = relayIntent.caller;
|
|
709
|
+
const target = relayIntent.target;
|
|
710
|
+
const callerReacted = await maybeReact(caller.accountId, chatId, messageId, cfg, logger, reactionCache);
|
|
711
|
+
const callerPrompt = await loadWorkspacePrompt(caller.workspaceDir);
|
|
712
|
+
const targetPrompt = await loadWorkspacePrompt(target.workspaceDir);
|
|
713
|
+
const targetReacted = await maybeReact(target.accountId, chatId, messageId, cfg, logger, reactionCache);
|
|
714
|
+
|
|
715
|
+
if (relayIntent.kind === 'question') {
|
|
716
|
+
let questionText = relayIntent.bodyTail;
|
|
717
|
+
if (isGenericQuestionRequest(questionText)) {
|
|
718
|
+
questionText = await callOpenAiLikeModel(cfg, caller.model, buildQuestionPrompt(caller, target), logger);
|
|
719
|
+
}
|
|
720
|
+
if (!questionText.endsWith('?')) questionText += '?';
|
|
721
|
+
|
|
722
|
+
await sendTelegramText(cfg, caller.accountId, chatId, `${callerReacted ? '' : `${ACK_FALLBACK} `}${target.name} oi, ${questionText}`, {
|
|
723
|
+
replyToMessageId: messageId,
|
|
724
|
+
messageThreadId,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const answerText = await callOpenAiLikeModel(
|
|
728
|
+
cfg,
|
|
729
|
+
target.model,
|
|
730
|
+
buildAnswerPrompt(targetPrompt || callerPrompt, caller, target, String(event.content || event.body || ''), questionText),
|
|
731
|
+
logger,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
await sendTelegramText(cfg, target.accountId, chatId, `${targetReacted ? '' : `${ACK_FALLBACK} `}${answerText}`, {
|
|
735
|
+
messageThreadId,
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
let taskText = relayIntent.bodyTail;
|
|
741
|
+
if (isGenericTaskRequest(taskText)) {
|
|
742
|
+
taskText = await callOpenAiLikeModel(cfg, caller.model, buildTaskPrompt(caller, target), logger);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
await sendTelegramText(cfg, caller.accountId, chatId, `${callerReacted ? '' : `${ACK_FALLBACK} `}${target.name} oi, ${taskText}`, {
|
|
746
|
+
replyToMessageId: messageId,
|
|
747
|
+
messageThreadId,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
let reminderStatus = 'none';
|
|
751
|
+
if (relayIntent.reminder) {
|
|
752
|
+
try {
|
|
753
|
+
await reminderManager.schedule({
|
|
754
|
+
kind: Number(relayIntent.reminder.repeatEveryMs || 0) > 0 ? 'repeat' : 'one-shot',
|
|
755
|
+
dueAtMs: relayIntent.reminder.dueAtMs,
|
|
756
|
+
repeatEveryMs: Number(relayIntent.reminder.repeatEveryMs || 0),
|
|
757
|
+
agentId: target.agentId,
|
|
758
|
+
accountId: target.accountId,
|
|
759
|
+
to: chatId,
|
|
760
|
+
messageThreadId,
|
|
761
|
+
text: buildReminderMessage(recipientMention, relayIntent.reminder.reminderBody || relayIntent.reminder.cleanedText || taskText),
|
|
762
|
+
});
|
|
763
|
+
reminderStatus = 'scheduled';
|
|
764
|
+
} catch (error) {
|
|
765
|
+
reminderStatus = 'failed';
|
|
766
|
+
logger.warn(`relay reminder schedule failed: ${String(error)}`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const ackText = await callOpenAiLikeModel(
|
|
771
|
+
cfg,
|
|
772
|
+
target.model,
|
|
773
|
+
buildTaskAckPrompt(
|
|
774
|
+
targetPrompt || callerPrompt,
|
|
775
|
+
caller,
|
|
776
|
+
target,
|
|
777
|
+
String(event.content || event.body || ''),
|
|
778
|
+
relayIntent.reminder?.cleanedText || taskText,
|
|
779
|
+
relayIntent.reminder,
|
|
780
|
+
reminderStatus,
|
|
781
|
+
),
|
|
782
|
+
logger,
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
await sendTelegramText(cfg, target.accountId, chatId, `${targetReacted ? '' : `${ACK_FALLBACK} `}${ackText}`, {
|
|
786
|
+
messageThreadId,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function handleTelegramGroupTurn(api, event, ctx, agents, recentTurns, reminderManager, reactionCache) {
|
|
791
|
+
const logger = api.logger;
|
|
792
|
+
const content = String(event.content || event.body || '').trim();
|
|
793
|
+
if (!content) return;
|
|
794
|
+
|
|
795
|
+
const rawLower = content.toLowerCase();
|
|
796
|
+
const folded = foldText(content);
|
|
797
|
+
const clearReminders = detectClearRemindersIntent(content, agents);
|
|
798
|
+
const cacheKey = buildTurnCacheKey({
|
|
799
|
+
content,
|
|
800
|
+
senderId: event.senderId || ctx.senderId,
|
|
801
|
+
timestamp: event.timestamp,
|
|
802
|
+
conversationId: ctx.conversationId,
|
|
803
|
+
});
|
|
804
|
+
const recent = recentTurns.get(cacheKey);
|
|
805
|
+
const relayIntent = recent?.relayIntent || detectRelayIntent(folded, agents);
|
|
806
|
+
logger.info(
|
|
807
|
+
`relay dispatch account=${ctx.accountId || 'default'} text="${content.slice(0, 180)}" relay=${relayIntent ? `${relayIntent.kind}:${relayIntent.caller.accountId}->${relayIntent.target.accountId}` : 'none'}`,
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
if (relayIntent) {
|
|
811
|
+
if (ctx.accountId !== relayIntent.caller.accountId) {
|
|
812
|
+
logger.info(`relay dispatch suppress non-caller account=${ctx.accountId || 'default'} expected=${relayIntent.caller.accountId}`);
|
|
813
|
+
return { handled: true };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
try {
|
|
817
|
+
logger.info(`relay dispatch handling caller=${relayIntent.caller.accountId} target=${relayIntent.target.accountId}`);
|
|
818
|
+
await runRelayFlow(api, {
|
|
819
|
+
...event,
|
|
820
|
+
content,
|
|
821
|
+
conversationId: ctx.conversationId,
|
|
822
|
+
threadId: ctx.threadId,
|
|
823
|
+
senderUsername: event.senderUsername || ctx.senderUsername,
|
|
824
|
+
}, relayIntent, reminderManager, reactionCache);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
logger.warn(`relay flow failed: ${String(error)}`);
|
|
827
|
+
}
|
|
828
|
+
return { handled: true };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const exactMentionMatches = agents.filter((agent) => agent.username && rawLower.includes(`@${String(agent.username).toLowerCase()}`));
|
|
832
|
+
const ownerAccountId = exactMentionMatches.length === 1
|
|
833
|
+
? exactMentionMatches[0].accountId
|
|
834
|
+
: (recent?.ownerAccountId || detectOwner({ ...event, content }, ctx.accountId || 'default', agents));
|
|
835
|
+
logger.info(`relay dispatch owner current=${ctx.accountId || 'default'} owner=${ownerAccountId || 'none'}`);
|
|
836
|
+
if (ownerAccountId && ownerAccountId !== ctx.accountId) {
|
|
837
|
+
logger.info(`relay dispatch suppress non-owner current=${ctx.accountId || 'default'} owner=${ownerAccountId}`);
|
|
838
|
+
return { handled: true };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (clearReminders) {
|
|
842
|
+
try {
|
|
843
|
+
const result = clearReminders.targetAccountId
|
|
844
|
+
? await reminderManager.clearByAccount(clearReminders.targetAccountId)
|
|
845
|
+
: await reminderManager.clearAll();
|
|
846
|
+
const reacted = await maybeReact(ctx.accountId || 'default', String(ctx.conversationId || ''), Number(event.messageId || 0) || undefined, api.config, logger, reactionCache);
|
|
847
|
+
const prefix = reacted ? '' : `${ACK_FALLBACK} `;
|
|
848
|
+
const targetAgent = clearReminders.targetAccountId
|
|
849
|
+
? agents.find((agent) => agent.accountId === clearReminders.targetAccountId)
|
|
850
|
+
: null;
|
|
851
|
+
const text = result.removed > 0
|
|
852
|
+
? `${prefix}Da xoa ${result.removed} lich nhac dang chay${targetAgent ? ` cua ${targetAgent.name}` : ''}.`
|
|
853
|
+
: `${prefix}Khong con lich nhac nao dang chay${targetAgent ? ` cua ${targetAgent.name}` : ''} de xoa.`;
|
|
854
|
+
await sendTelegramText(api.config, ctx.accountId || 'default', String(ctx.conversationId || ''), text, {
|
|
855
|
+
replyToMessageId: Number(event.messageId || 0) || undefined,
|
|
856
|
+
messageThreadId: ctx.threadId ? Number(ctx.threadId) : undefined,
|
|
857
|
+
});
|
|
858
|
+
} catch (error) {
|
|
859
|
+
logger.warn(`relay clear reminders failed: ${String(error)}`);
|
|
860
|
+
}
|
|
861
|
+
return { handled: true };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const plugin = definePluginEntry({
|
|
868
|
+
id: 'telegram-multibot-relay',
|
|
869
|
+
name: 'Telegram Multibot Relay',
|
|
870
|
+
description: 'Relay Telegram multibot turns, task delegation, reminders, and suppress wrong-account replies.',
|
|
871
|
+
kind: 'runtime',
|
|
872
|
+
configSchema: emptyPluginConfigSchema,
|
|
873
|
+
register(api) {
|
|
874
|
+
const logger = api.logger;
|
|
875
|
+
let agents = buildAgentState(api.config);
|
|
876
|
+
let usernamesReady = false;
|
|
877
|
+
const recentTurns = new Map();
|
|
878
|
+
const reactionCache = new Map();
|
|
879
|
+
const reminderManager = createReminderManager(logger, api.runtime);
|
|
880
|
+
|
|
881
|
+
api.registerService({
|
|
882
|
+
id: 'telegram-multibot-relay-reminders',
|
|
883
|
+
async start() {
|
|
884
|
+
await reminderManager.start();
|
|
885
|
+
},
|
|
886
|
+
async stop() {
|
|
887
|
+
await reminderManager.stop();
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
api.on('gateway_start', async () => {
|
|
892
|
+
agents = buildAgentState(api.config, agents);
|
|
893
|
+
await resolveTelegramUsernames(api.config, agents);
|
|
894
|
+
usernamesReady = true;
|
|
895
|
+
for (const agent of agents) {
|
|
896
|
+
logger.info(`relay account map agent=${agent.agentId} account=${agent.accountId} username=${agent.username || 'none'}`);
|
|
897
|
+
}
|
|
898
|
+
logger.info(`telegram relay loaded for ${agents.length} agent(s)`);
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
api.on('inbound_claim', async (event, ctx) => {
|
|
902
|
+
if (event.channel !== 'telegram') return;
|
|
903
|
+
if (!event.isGroup) return;
|
|
904
|
+
|
|
905
|
+
agents = buildAgentState(api.config, agents);
|
|
906
|
+
await reminderManager.ensureStarted();
|
|
907
|
+
if (!usernamesReady) {
|
|
908
|
+
await resolveTelegramUsernames(api.config, agents);
|
|
909
|
+
usernamesReady = true;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const content = String(event.content || event.body || '').trim();
|
|
913
|
+
const cacheKey = buildTurnCacheKey({
|
|
914
|
+
content,
|
|
915
|
+
senderId: event.senderId,
|
|
916
|
+
timestamp: event.timestamp,
|
|
917
|
+
conversationId: event.conversationId,
|
|
918
|
+
});
|
|
919
|
+
recentTurns.set(cacheKey, {
|
|
920
|
+
ownerAccountId: detectOwner(event, ctx.accountId || 'default', agents),
|
|
921
|
+
relayIntent: detectRelayIntent(foldText(content), agents),
|
|
922
|
+
createdAt: Date.now(),
|
|
923
|
+
});
|
|
924
|
+
for (const [key, value] of recentTurns.entries()) {
|
|
925
|
+
if (Date.now() - Number(value?.createdAt || 0) > RECENT_TURN_TTL_MS) {
|
|
926
|
+
recentTurns.delete(key);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const ownerAccountId = detectOwner(event, ctx.accountId || 'default', agents);
|
|
931
|
+
if (ownerAccountId && ownerAccountId === ctx.accountId) {
|
|
932
|
+
await maybeReact(ownerAccountId, String(event.conversationId || ''), Number(event.messageId || 0) || undefined, api.config, logger, reactionCache);
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}, { priority: 200 });
|
|
936
|
+
|
|
937
|
+
api.on('before_dispatch', async (event, ctx) => {
|
|
938
|
+
if (event.channel !== 'telegram') return;
|
|
939
|
+
if (!event.isGroup) return;
|
|
940
|
+
|
|
941
|
+
agents = buildAgentState(api.config, agents);
|
|
942
|
+
await reminderManager.ensureStarted();
|
|
943
|
+
if (!usernamesReady) {
|
|
944
|
+
await resolveTelegramUsernames(api.config, agents);
|
|
945
|
+
usernamesReady = true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
return await handleTelegramGroupTurn(api, event, ctx, agents, recentTurns, reminderManager, reactionCache);
|
|
949
|
+
}, { priority: 200 });
|
|
950
|
+
|
|
951
|
+
api.on('message_sending', async (event) => {
|
|
952
|
+
if (!String(event.to || '').startsWith('telegram:')) return;
|
|
953
|
+
const content = String(event.content || '');
|
|
954
|
+
if (!content.trim()) return;
|
|
955
|
+
if (content.trimStart().startsWith(ACK_FALLBACK)) return;
|
|
956
|
+
return { content: `${ACK_FALLBACK} ${content}` };
|
|
957
|
+
}, { priority: 50 });
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
export default plugin;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "telegram-multibot-relay",
|
|
3
|
+
"name": "Telegram Multibot Relay",
|
|
4
|
+
"description": "Relay Telegram multibot turns, task delegation, reminder scheduling, and reminder cleanup.",
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"kind": "runtime",
|
|
7
|
+
"enabledByDefault": false,
|
|
8
|
+
"runtimeId": "telegram-multibot-relay",
|
|
9
|
+
"capabilityTags": [
|
|
10
|
+
"telegram",
|
|
11
|
+
"multi-agent",
|
|
12
|
+
"reminders",
|
|
13
|
+
"relay"
|
|
14
|
+
],
|
|
15
|
+
"configSchema": {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {},
|
|
18
|
+
"additionalProperties": false
|
|
19
|
+
},
|
|
20
|
+
"skills": [],
|
|
21
|
+
"providers": [],
|
|
22
|
+
"channels": []
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-telegram-multibot-relay",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "OpenClaw runtime plugin for Telegram multibot relay, delegation, and native cron reminders.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": "./index.js",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"openclaw.plugin.json",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"PUBLISHING.md"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"openclaw",
|
|
18
|
+
"telegram",
|
|
19
|
+
"multibot",
|
|
20
|
+
"plugin",
|
|
21
|
+
"relay",
|
|
22
|
+
"reminder"
|
|
23
|
+
],
|
|
24
|
+
"author": "tuanminhole",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/tuanminhhole/openclaw-telegram-multibot-relay.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/tuanminhhole/openclaw-telegram-multibot-relay#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/tuanminhhole/openclaw-telegram-multibot-relay/issues"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"openclaw": {
|
|
38
|
+
"extensions": [
|
|
39
|
+
"./index.js"
|
|
40
|
+
],
|
|
41
|
+
"compat": {
|
|
42
|
+
"pluginApi": ">=2026.3.24",
|
|
43
|
+
"minGatewayVersion": "2026.3.24"
|
|
44
|
+
},
|
|
45
|
+
"build": {
|
|
46
|
+
"entry": "./index.js",
|
|
47
|
+
"openclawVersion": "2026.3.24",
|
|
48
|
+
"pluginSdkVersion": "2026.3.24"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"openclaw": "*"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"check": "node --check index.js"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=20"
|
|
59
|
+
}
|
|
60
|
+
}
|