Merge 6374567697 into 894c6251c5
This commit is contained in:
commit
3ca62143f4
16 changed files with 1652 additions and 136 deletions
|
|
@ -12,10 +12,15 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false,
|
||||
"guest_mode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +29,13 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
|
|||
| Champ | Type | Requis | Description |
|
||||
| --------------- | ------ | ------ | ------------------------------------------------------------------------ |
|
||||
| enabled | bool | Oui | Activer ou non le canal Telegram |
|
||||
| token | string | Oui | Token de l'API Bot Telegram |
|
||||
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
|
||||
| proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 |
|
||||
| settings.proxy | string | Non | URL du proxy pour se connecter à l'API Telegram (ex. http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | Non | Activer le formatage Telegram MarkdownV2 |
|
||||
| settings.business_mode | bool | Non | Activer la gestion des messages Telegram Business |
|
||||
| settings.business_owner | string | Non | ID utilisateur Telegram du propriétaire Business à ignorer |
|
||||
| settings.business_commands_enable | bool | Non | Autoriser les commandes du bot dans les chats Telegram Business |
|
||||
| settings.guest_mode | bool | Non | Activer la gestion et les réponses des messages Telegram Guest |
|
||||
|
||||
## Configuration initiale
|
||||
|
||||
|
|
@ -37,6 +45,56 @@ Le canal Telegram utilise le long polling via l'API Bot Telegram pour une commun
|
|||
4. Renseigner le Token dans le fichier de configuration
|
||||
5. (Optionnel) Configurer `allow_from` pour restreindre les identifiants utilisateur autorisés à interagir (les IDs peuvent être obtenus via `@userinfobot`)
|
||||
|
||||
## Mode Telegram Business
|
||||
|
||||
Définissez `settings.business_mode: true` pour recevoir et répondre aux messages Telegram Business des comptes connectés. Les réponses utilisent le `business_connection_id` entrant, et PicoClaw marque les messages Business comme lus lorsque le bot dispose du droit `can_read_messages`.
|
||||
|
||||
Définissez `settings.business_owner` avec l'ID utilisateur Telegram du propriétaire du compte Business. Les messages Business envoyés par cet utilisateur sont ignorés, ce qui évite de répondre aux messages envoyés manuellement depuis le compte connecté.
|
||||
|
||||
Par défaut, les commandes du bot sont ignorées dans les chats Business. Définissez `settings.business_commands_enable: true` pour autoriser `/new`, `/help`, `/show`, `/list` et `/use`.
|
||||
|
||||
Exemple :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mode Telegram Guest
|
||||
|
||||
Définissez `settings.guest_mode: true` pour recevoir les mises à jour `guest_message` et répondre avec la méthode Telegram `answerGuestQuery`. Les messages Guest proviennent de chats où le bot n'est pas membre, donc PicoClaw crée des sessions séparées avec le `guest_query_id` entrant.
|
||||
|
||||
Lorsque `settings.guest_mode` est `false`, PicoClaw ne demande pas les mises à jour Guest et ignore tout message Guest décodé. Les indicateurs de saisie et de placeholder sont ignorés pour les réponses Guest car Telegram requiert une seule réponse `answerGuestQuery`.
|
||||
|
||||
Exemple :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Formatage avancées
|
||||
|
||||
Vous pouvez définir `use_markdown_v2: true` pour activer les options de formatage améliorées. Cela permet au bot d'utiliser toutes les fonctionnalités de Telegram MarkdownV2, y compris les styles imbriqués, les spoilers et les blocs de largeur fixe personnalisés.
|
||||
|
|
@ -47,9 +105,10 @@ Vous pouvez définir `use_markdown_v2: true` pour activer les options de formata
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false,
|
||||
"guest_mode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +29,13 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
|||
| フィールド | 型 | 必須 | 説明 |
|
||||
| --------------- | ------ | ---- | ----------------------------------------------------------------- |
|
||||
| enabled | bool | はい | Telegram チャンネルを有効にするかどうか |
|
||||
| token | string | はい | Telegram Bot API トークン |
|
||||
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
|
||||
| proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする |
|
||||
| settings.proxy | string | いいえ | Telegram API への接続に使用するプロキシ URL (例: http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | いいえ | Telegram MarkdownV2 フォーマットを有効にする |
|
||||
| settings.business_mode | bool | いいえ | Telegram Business メッセージ処理を有効にする |
|
||||
| settings.business_owner | string | いいえ | 無視する Business アカウント所有者の Telegram ユーザー ID |
|
||||
| settings.business_commands_enable | bool | いいえ | Telegram Business チャットで Bot コマンドを処理する |
|
||||
| settings.guest_mode | bool | いいえ | Telegram Guest メッセージ処理と返信を有効にする |
|
||||
|
||||
## セットアップ手順
|
||||
|
||||
|
|
@ -37,6 +45,56 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
|||
4. 設定ファイルにトークンを入力する
|
||||
5. (任意) `allow_from` を設定して、対話を許可するユーザー ID を制限する(ID は `@userinfobot` で取得可能)
|
||||
|
||||
## Telegram Business モード
|
||||
|
||||
`settings.business_mode: true` を設定すると、接続された Business アカウントの Telegram Business メッセージを受信して返信できます。返信には受信した `business_connection_id` が使われ、Bot に `can_read_messages` 権限がある場合は受信 Business メッセージを既読にします。
|
||||
|
||||
`settings.business_owner` には Business アカウント所有者の Telegram ユーザー ID を設定します。このユーザーからの Business メッセージは無視されるため、接続済みアカウントから手動送信したメッセージへの自動返信を避けられます。
|
||||
|
||||
Business チャット内の Bot コマンドは既定で無視されます。`/new`、`/help`、`/show`、`/list`、`/use` を処理したい場合は `settings.business_commands_enable: true` を設定してください。
|
||||
|
||||
例:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Telegram Guest モード
|
||||
|
||||
`settings.guest_mode: true` を設定すると、`guest_message` 更新を受信し、Telegram の `answerGuestQuery` メソッドで返信できます。Guest メッセージは Bot が参加していないチャットから届くため、PicoClaw は受信した `guest_query_id` を使って別セッションとして扱います。
|
||||
|
||||
`settings.guest_mode` が `false` の場合、Guest 更新は要求されず、デコードされた Guest メッセージも無視されます。Telegram は単一の `answerGuestQuery` 応答のみを必要とするため、Guest 応答のプレースホルダーと入力インジケーターはスキップされます。
|
||||
|
||||
例:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 高度なフォーマット
|
||||
|
||||
`use_markdown_v2: true` を設定することで、增强されたフォーマットオプションを有効にできます。これにより、ボットは Telegram MarkdownV2 の全機能(ネストされたスタイル、スポイラー、カスタム固定幅ブロックなど)を利用できます。
|
||||
|
|
@ -47,9 +105,10 @@ Telegram チャンネルは、Telegram Bot API を使用したロングポーリ
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
# Telegram
|
||||
|
||||
The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), and built-in command handling.
|
||||
The Telegram channel uses long polling via the Telegram Bot API for bot-based communication. It supports text messages, media attachments (photos, voice, audio, documents), voice transcription ([setup](../../guides/providers.md#voice-transcription)), built-in command handling, and optional Telegram Business chats.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -12,22 +12,30 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"guest_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | ------ | -------- | ------------------------------------------------------------------ |
|
||||
| enabled | bool | Yes | Whether to enable the Telegram channel |
|
||||
| token | string | Yes | Telegram Bot API Token |
|
||||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------------ | ------ | -------- | ------------------------------------------------------------------------ |
|
||||
| enabled | bool | Yes | Whether to enable the Telegram channel |
|
||||
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
|
||||
| settings.proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
|
||||
| settings.business_mode | bool | No | Enable Telegram Business message handling |
|
||||
| settings.business_owner | string | No | Telegram user ID of the Business account owner to ignore |
|
||||
| settings.business_commands_enable | bool | No | Allow bot commands in Telegram Business chats |
|
||||
| settings.guest_mode | bool | No | Enable Telegram Guest Mode update handling and replies |
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -61,6 +69,56 @@ Examples:
|
|||
explain how to squash the last 3 commits
|
||||
```
|
||||
|
||||
## Telegram Business Mode
|
||||
|
||||
Set `settings.business_mode: true` to receive and reply to Telegram Business messages from connected business accounts. Business replies are sent with the incoming `business_connection_id`, and incoming business messages are marked as read when the bot has the `can_read_messages` business right. If marking a message as read fails, PicoClaw still processes the message.
|
||||
|
||||
Use `settings.business_owner` to store the Telegram user ID of the business account owner. Business messages from that user are skipped, which prevents the bot from responding to messages you send manually from the connected business account.
|
||||
|
||||
By default, bot commands in business chats are ignored. Set `settings.business_commands_enable: true` if you want commands such as `/new`, `/help`, `/show`, `/list`, and `/use` to be handled in Telegram Business chats.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Telegram Guest Mode
|
||||
|
||||
Set `settings.guest_mode: true` to receive `guest_message` updates and reply with Telegram's `answerGuestQuery` method. Guest messages come from chats where the bot is not a member, so PicoClaw keeps them in separate sessions using the incoming `guest_query_id`.
|
||||
|
||||
When `settings.guest_mode` is false, guest updates are not requested and any decoded guest message is ignored. Placeholder and typing indicators are skipped for guest replies because Telegram requires a single `answerGuestQuery` response.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Formatting
|
||||
|
||||
You can set `use_markdown_v2: true` to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.
|
||||
|
|
@ -71,9 +129,10 @@ You can set `use_markdown_v2: true` to enable enhanced formatting options. This
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false,
|
||||
"guest_mode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +29,13 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
|
|||
| Campo | Tipo | Obrigatório | Descrição |
|
||||
| --------------- | ------ | ----------- | -------------------------------------------------------------------------- |
|
||||
| enabled | bool | Sim | Se o canal Telegram deve ser habilitado |
|
||||
| token | string | Sim | Token da API de Bot do Telegram |
|
||||
| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
|
||||
| proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 |
|
||||
| settings.proxy | string | Não | URL do proxy para conexão com a API do Telegram (ex. http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | Não | Habilitar formatação Telegram MarkdownV2 |
|
||||
| settings.business_mode | bool | Não | Habilitar tratamento de mensagens Telegram Business |
|
||||
| settings.business_owner | string | Não | ID de usuário Telegram do proprietário Business a ser ignorado |
|
||||
| settings.business_commands_enable | bool | Não | Permitir comandos do bot em chats Telegram Business |
|
||||
| settings.guest_mode | bool | Não | Habilitar tratamento e respostas de mensagens Telegram Guest |
|
||||
|
||||
## Configuração inicial
|
||||
|
||||
|
|
@ -37,6 +45,56 @@ O canal Telegram utiliza long polling via a API de Bot do Telegram para comunica
|
|||
4. Preencha o Token no arquivo de configuração
|
||||
5. (Opcional) Configure `allow_from` para restringir quais IDs de usuário podem interagir (os IDs podem ser obtidos via `@userinfobot`)
|
||||
|
||||
## Modo Telegram Business
|
||||
|
||||
Defina `settings.business_mode: true` para receber e responder mensagens Telegram Business de contas comerciais conectadas. As respostas usam o `business_connection_id` recebido, e as mensagens Business são marcadas como lidas quando o bot tem o direito `can_read_messages`.
|
||||
|
||||
Use `settings.business_owner` para informar o ID de usuário Telegram do proprietário da conta Business. Mensagens Business desse usuário são ignoradas, evitando respostas automáticas a mensagens enviadas manualmente pela conta conectada.
|
||||
|
||||
Por padrão, comandos do bot em chats Business são ignorados. Defina `settings.business_commands_enable: true` para processar `/new`, `/help`, `/show`, `/list` e `/use`.
|
||||
|
||||
Exemplo :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Modo Telegram Guest
|
||||
|
||||
Defina `settings.guest_mode: true` para receber atualizações `guest_message` e responder com o método Telegram `answerGuestQuery`. Mensagens Guest vêm de chats onde o bot não é membro, então o PicoClaw mantém sessões separadas usando o `guest_query_id` recebido.
|
||||
|
||||
Quando `settings.guest_mode` é `false`, atualizações Guest não são solicitadas e qualquer mensagem Guest decodificada é ignorada. Indicadores de digitação e placeholders são ignorados para respostas Guest porque o Telegram requer uma única resposta `answerGuestQuery`.
|
||||
|
||||
Exemplo :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Formatação Avançada
|
||||
|
||||
Você pode definir `use_markdown_v2: true` para habilitar opções de formatação aprimoradas. Isso permite que o bot utilize todos os recursos do Telegram MarkdownV2, incluindo estilos aninhados, spoilers e blocos de largura fixa personalizados.
|
||||
|
|
@ -47,9 +105,10 @@ Você pode definir `use_markdown_v2: true` para habilitar opções de formataç
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false,
|
||||
"guest_mode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +29,13 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
|
|||
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||
| -------------- | ------ | -------- | ------------------------------------------------------------------------ |
|
||||
| enabled | bool | Có | Có bật kênh Telegram hay không |
|
||||
| token | string | Có | Token API Bot Telegram |
|
||||
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả |
|
||||
| proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 |
|
||||
| settings.proxy | string | Không | URL proxy để kết nối với Telegram API (ví dụ: http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | Không | Bật định dạng Telegram MarkdownV2 |
|
||||
| settings.business_mode | bool | Không | Bật xử lý tin nhắn Telegram Business |
|
||||
| settings.business_owner | string | Không | ID người dùng Telegram của chủ tài khoản Business cần bỏ qua |
|
||||
| settings.business_commands_enable | bool | Không | Cho phép lệnh bot trong chat Telegram Business |
|
||||
| settings.guest_mode | bool | Không | Bật xử lý và trả lời tin nhắn Telegram Guest |
|
||||
|
||||
## Hướng dẫn thiết lập
|
||||
|
||||
|
|
@ -37,6 +45,56 @@ Kênh Telegram sử dụng long polling qua Telegram Bot API để giao tiếp d
|
|||
4. Điền Token vào file cấu hình
|
||||
5. (Tùy chọn) Cấu hình `allow_from` để giới hạn ID người dùng được phép tương tác (có thể lấy ID qua `@userinfobot`)
|
||||
|
||||
## Chế độ Telegram Business
|
||||
|
||||
Đặt `settings.business_mode: true` để nhận và trả lời tin nhắn Telegram Business từ các tài khoản doanh nghiệp đã kết nối. Phản hồi dùng `business_connection_id` nhận được, và PicoClaw đánh dấu tin nhắn Business là đã đọc khi bot có quyền `can_read_messages`.
|
||||
|
||||
Đặt `settings.business_owner` thành ID người dùng Telegram của chủ tài khoản Business. Tin nhắn Business từ người dùng này sẽ bị bỏ qua để tránh bot trả lời các tin nhắn bạn gửi thủ công từ tài khoản đã kết nối.
|
||||
|
||||
Theo mặc định, lệnh bot trong chat Business bị bỏ qua. Đặt `settings.business_commands_enable: true` để xử lý `/new`, `/help`, `/show`, `/list` và `/use`.
|
||||
|
||||
Ví dụ :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Chế độ Telegram Guest
|
||||
|
||||
Đặt `settings.guest_mode: true` để nhận cập nhật `guest_message` và trả lời bằng phương thức Telegram `answerGuestQuery`. Tin nhắn Guest đến từ chat mà bot không phải là thành viên, nên PicoClaw tách phiên bằng `guest_query_id` nhận được.
|
||||
|
||||
Khi `settings.guest_mode` là `false`, cập nhật Guest không được yêu cầu và mọi tin nhắn Guest được giải mã sẽ bị bỏ qua. Các chỉ báo gõ và placeholder được bỏ qua cho phản hồi Guest vì Telegram yêu cầu một phản hồi `answerGuestQuery` duy nhất.
|
||||
|
||||
Ví dụ :
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Định dạng nâng cao
|
||||
|
||||
Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn định dạng nâng cao. Điều này cho phép bot sử dụng toàn bộ các tính năng của Telegram MarkdownV2, bao gồm các kiểu lồng nhau, spoiler và các khối chiều rộng cố định tùy chỉnh.
|
||||
|
|
@ -47,9 +105,10 @@ Bạn có thể đặt `use_markdown_v2: true` để bật các tùy chọn đ
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,10 +12,15 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||
"allow_from": ["123456789"],
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false
|
||||
"settings": {
|
||||
"proxy": "",
|
||||
"use_markdown_v2": false,
|
||||
"business_mode": false,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": false,
|
||||
"guest_mode": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +29,13 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
|
|||
| 字段 | 类型 | 必填 | 描述 |
|
||||
| ---------------- | ------ | ---- | --------------------------------------------------------- |
|
||||
| enabled | bool | 是 | 是否启用 Telegram 频道 |
|
||||
| token | string | 是 | Telegram 机器人 API Token |
|
||||
| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
|
||||
| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
|
||||
| use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 |
|
||||
| settings.proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
|
||||
| settings.use_markdown_v2 | bool | 否 | 启用 Telegram MarkdownV2 格式化 |
|
||||
| settings.business_mode | bool | 否 | 启用 Telegram Business 消息处理 |
|
||||
| settings.business_owner | string | 否 | 需要忽略的 Business 账号所有者 Telegram 用户 ID |
|
||||
| settings.business_commands_enable | bool | 否 | 允许在 Telegram Business 聊天中处理机器人命令 |
|
||||
| settings.guest_mode | bool | 否 | 启用 Telegram Guest 消息处理和回复 |
|
||||
|
||||
## 设置流程
|
||||
|
||||
|
|
@ -37,6 +45,56 @@ Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器
|
|||
4. 将 Token 填入配置文件中
|
||||
5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
|
||||
|
||||
## Telegram Business Mode
|
||||
|
||||
设置 `settings.business_mode: true` 后,PicoClaw 会接收并回复已连接商业账号的 Telegram Business 消息。回复会使用传入的 `business_connection_id`,当机器人具备 `can_read_messages` 权限时,传入的 Business 消息会被标记为已读。
|
||||
|
||||
使用 `settings.business_owner` 保存商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略,避免机器人回复你从已连接商业账号手动发送的消息。
|
||||
|
||||
默认情况下,Business 聊天中的机器人命令会被忽略。设置 `settings.business_commands_enable: true` 后,可处理 `/new`、`/help`、`/show`、`/list` 和 `/use`。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"business_mode": true,
|
||||
"business_owner": "123456789",
|
||||
"business_commands_enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Telegram Guest Mode
|
||||
|
||||
设置 `settings.guest_mode: true` 后,PicoClaw 会接收 `guest_message` 更新,并使用 Telegram 的 `answerGuestQuery` 方法回复。Guest 消息来自机器人未加入的聊天,因此 PicoClaw 会使用传入的 `guest_query_id` 建立独立会话。
|
||||
|
||||
当 `settings.guest_mode` 为 `false` 时,PicoClaw 不会请求 Guest 更新,并会忽略任何已解码的 Guest 消息。Guest 回复的占位符和打字指示器会被跳过,因为 Telegram 需要单一的 `answerGuestQuery` 响应。
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel_list": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"allow_from": ["123456789"],
|
||||
"settings": {
|
||||
"guest_mode": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 内置命令
|
||||
|
||||
Telegram 会在启动时自动注册 PicoClaw 的顶级 Bot 命令,包括 `/start`、`/help`、`/show`、`/list` 和 `/use`。
|
||||
|
|
@ -67,9 +125,10 @@ explain how to squash the last 3 commits
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"type": "telegram",
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"],
|
||||
"use_markdown_v2": true
|
||||
"settings": {
|
||||
"use_markdown_v2": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -30,6 +32,8 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const telegramGuestMessageUpdates = "guest_message"
|
||||
|
||||
var (
|
||||
reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
|
||||
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
|
||||
|
|
@ -43,6 +47,34 @@ var (
|
|||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||
)
|
||||
|
||||
func telegramAllowedUpdates(businessMode, guestMode bool) []string {
|
||||
updates := []string{
|
||||
telego.MessageUpdates,
|
||||
telego.EditedMessageUpdates,
|
||||
telego.ChannelPostUpdates,
|
||||
telego.EditedChannelPostUpdates,
|
||||
telego.CallbackQueryUpdates,
|
||||
telego.InlineQueryUpdates,
|
||||
telego.ChosenInlineResultUpdates,
|
||||
telego.ShippingQueryUpdates,
|
||||
telego.PreCheckoutQueryUpdates,
|
||||
telego.PollUpdates,
|
||||
telego.PollAnswerUpdates,
|
||||
}
|
||||
if businessMode {
|
||||
updates = append(updates,
|
||||
telego.BusinessConnectionUpdates,
|
||||
telego.BusinessMessageUpdates,
|
||||
telego.EditedBusinessMessageUpdates,
|
||||
telego.DeletedBusinessMessagesUpdates,
|
||||
)
|
||||
}
|
||||
if guestMode {
|
||||
updates = append(updates, telegramGuestMessageUpdates)
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
type TelegramChannel struct {
|
||||
*channels.BaseChannel
|
||||
bot *telego.Bot
|
||||
|
|
@ -57,6 +89,7 @@ type TelegramChannel struct {
|
|||
registerFunc func(context.Context, []commands.Definition) error
|
||||
commandRegDelayFn func(int) time.Duration
|
||||
commandRegCancel context.CancelFunc
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewTelegramChannel(
|
||||
|
|
@ -112,6 +145,7 @@ func NewTelegramChannel(
|
|||
bc: bc,
|
||||
chatIDs: make(map[string]int64),
|
||||
tgCfg: telegramCfg,
|
||||
httpClient: newTelegramHTTPClient(telegramCfg),
|
||||
}
|
||||
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||
return ch, nil
|
||||
|
|
@ -122,12 +156,20 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{
|
||||
Timeout: 30,
|
||||
})
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
return fmt.Errorf("failed to start long polling: %w", err)
|
||||
updateParams := &telego.GetUpdatesParams{
|
||||
Timeout: 30,
|
||||
AllowedUpdates: telegramAllowedUpdates(c.businessModeEnabled(), c.guestModeEnabled()),
|
||||
}
|
||||
var updates <-chan telego.Update
|
||||
if c.guestModeEnabled() {
|
||||
updates = c.updatesViaLongPollingWithGuest(c.ctx, updateParams)
|
||||
} else {
|
||||
var err error
|
||||
updates, err = c.bot.UpdatesViaLongPolling(c.ctx, updateParams)
|
||||
if err != nil {
|
||||
c.cancel()
|
||||
return fmt.Errorf("failed to start long polling: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
bh, err := th.NewBotHandler(c.bot, updates)
|
||||
|
|
@ -140,6 +182,20 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
|
|||
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
return c.handleMessage(ctx, &message)
|
||||
}, th.AnyMessage())
|
||||
if c.businessModeEnabled() {
|
||||
bh.HandleBusinessMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
return c.handleBusinessMessage(ctx, &message)
|
||||
}, th.AnyBusinessMessage())
|
||||
bh.HandleEditedBusinessMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
return c.handleBusinessMessage(ctx, &message)
|
||||
}, th.AnyEditedBusinessMessage())
|
||||
bh.HandleBusinessConnection(func(ctx *th.Context, connection telego.BusinessConnection) error {
|
||||
return c.handleBusinessConnection(ctx, connection)
|
||||
}, th.AnyBusinessConnection())
|
||||
bh.HandleDeletedBusinessMessages(func(ctx *th.Context, deleted telego.BusinessMessagesDeleted) error {
|
||||
return c.handleDeletedBusinessMessages(ctx, deleted)
|
||||
}, th.AnyDeletedBusinessMessages())
|
||||
}
|
||||
|
||||
c.SetRunning(true)
|
||||
logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
|
||||
|
|
@ -182,6 +238,138 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func newTelegramHTTPClient(telegramCfg *config.TelegramSettings) *http.Client {
|
||||
if telegramCfg == nil || strings.TrimSpace(telegramCfg.Proxy) == "" {
|
||||
return http.DefaultClient
|
||||
}
|
||||
proxyURL, err := url.Parse(telegramCfg.Proxy)
|
||||
if err != nil {
|
||||
return http.DefaultClient
|
||||
}
|
||||
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) telegramAPIBaseURL() string {
|
||||
if c != nil && c.tgCfg != nil {
|
||||
if baseURL := strings.TrimRight(strings.TrimSpace(c.tgCfg.BaseURL), "/"); baseURL != "" {
|
||||
return baseURL
|
||||
}
|
||||
}
|
||||
return "https://api.telegram.org"
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) postTelegramAPI(ctx context.Context, method string, params any, result any) error {
|
||||
if c == nil || c.bot == nil {
|
||||
return fmt.Errorf("telegram bot is nil")
|
||||
}
|
||||
body, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/bot%s/%s", c.telegramAPIBaseURL(), c.bot.Token(), method)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := c.httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var apiResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
|
||||
return err
|
||||
}
|
||||
if !apiResp.OK {
|
||||
if apiResp.Description != "" {
|
||||
return fmt.Errorf("telegram %s: %s", method, apiResp.Description)
|
||||
}
|
||||
return fmt.Errorf("telegram %s: HTTP %d", method, resp.StatusCode)
|
||||
}
|
||||
if result != nil && len(apiResp.Result) > 0 {
|
||||
if err := json.Unmarshal(apiResp.Result, result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type telegramRawUpdate struct {
|
||||
telego.Update
|
||||
GuestMessage *telegramGuestMessage `json:"guest_message,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) updatesViaLongPollingWithGuest(
|
||||
ctx context.Context,
|
||||
params *telego.GetUpdatesParams,
|
||||
) <-chan telego.Update {
|
||||
updates := make(chan telego.Update, 100)
|
||||
go c.pollTelegramUpdatesWithGuest(ctx, params, updates)
|
||||
return updates
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) pollTelegramUpdatesWithGuest(
|
||||
ctx context.Context,
|
||||
params *telego.GetUpdatesParams,
|
||||
updates chan<- telego.Update,
|
||||
) {
|
||||
defer close(updates)
|
||||
if params == nil {
|
||||
params = &telego.GetUpdatesParams{Timeout: 30}
|
||||
}
|
||||
pollParams := *params
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
var rawUpdates []telegramRawUpdate
|
||||
if err := c.postTelegramAPI(ctx, "getUpdates", &pollParams, &rawUpdates); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
logger.ErrorCF("telegram", "Guest-mode long polling failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, update := range rawUpdates {
|
||||
if update.UpdateID >= pollParams.Offset {
|
||||
pollParams.Offset = update.UpdateID + 1
|
||||
}
|
||||
if update.GuestMessage != nil {
|
||||
if err := c.handleGuestMessage(ctx, update.GuestMessage); err != nil {
|
||||
logger.ErrorCF("telegram", "Guest message handling failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case updates <- update.Update:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||
if !c.IsRunning() {
|
||||
return nil, channels.ErrNotRunning
|
||||
|
|
@ -189,7 +377,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
|||
|
||||
useMarkdownV2 := c.tgCfg.UseMarkdownV2
|
||||
|
||||
chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
||||
target, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||
}
|
||||
|
|
@ -199,6 +387,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
|||
}
|
||||
|
||||
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
||||
if target.guestQueryID != "" {
|
||||
if isToolFeedback {
|
||||
return nil, nil
|
||||
}
|
||||
return c.answerGuestQuery(ctx, target.guestQueryID, msg.Content, useMarkdownV2)
|
||||
}
|
||||
toolFeedbackContent := msg.Content
|
||||
if isToolFeedback {
|
||||
toolFeedbackContent = fitToolFeedbackForTelegram(msg.Content, useMarkdownV2, 4096)
|
||||
|
|
@ -253,8 +447,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
|||
|
||||
if smallerLen <= 0 {
|
||||
msgID, err := c.sendChunk(ctx, sendChunkParams{
|
||||
chatID: chatID,
|
||||
threadID: threadID,
|
||||
target: target,
|
||||
content: content,
|
||||
replyToID: replyToID,
|
||||
mdFallback: chunk,
|
||||
|
|
@ -293,8 +486,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
|||
}
|
||||
|
||||
msgID, err := c.sendChunk(ctx, sendChunkParams{
|
||||
chatID: chatID,
|
||||
threadID: threadID,
|
||||
target: target,
|
||||
content: content,
|
||||
replyToID: replyToID,
|
||||
mdFallback: chunk,
|
||||
|
|
@ -318,8 +510,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]
|
|||
}
|
||||
|
||||
type sendChunkParams struct {
|
||||
chatID int64
|
||||
threadID int
|
||||
target telegramOutboundTarget
|
||||
content string
|
||||
replyToID string
|
||||
mdFallback string
|
||||
|
|
@ -332,8 +523,9 @@ func (c *TelegramChannel) sendChunk(
|
|||
ctx context.Context,
|
||||
params sendChunkParams,
|
||||
) (string, error) {
|
||||
tgMsg := tu.Message(tu.ID(params.chatID), params.content)
|
||||
tgMsg.MessageThreadID = params.threadID
|
||||
tgMsg := tu.Message(tu.ID(params.target.chatID), params.content)
|
||||
tgMsg.MessageThreadID = params.target.threadID
|
||||
tgMsg.BusinessConnectionID = params.target.businessConnectionID
|
||||
if params.useMarkdownV2 {
|
||||
tgMsg.WithParseMode(telego.ModeMarkdownV2)
|
||||
} else {
|
||||
|
|
@ -363,6 +555,55 @@ func (c *TelegramChannel) sendChunk(
|
|||
return strconv.Itoa(pMsg.MessageID), nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) answerGuestQuery(
|
||||
ctx context.Context,
|
||||
guestQueryID string,
|
||||
content string,
|
||||
useMarkdownV2 bool,
|
||||
) ([]string, error) {
|
||||
guestQueryID = strings.TrimSpace(guestQueryID)
|
||||
if guestQueryID == "" {
|
||||
return nil, fmt.Errorf("missing guest query ID: %w", channels.ErrSendFailed)
|
||||
}
|
||||
chunks := channels.SplitMessage(content, 4000)
|
||||
if len(chunks) > 0 {
|
||||
content = chunks[0]
|
||||
}
|
||||
parsedContent := parseContent(content, useMarkdownV2)
|
||||
parseMode := telego.ModeHTML
|
||||
if useMarkdownV2 {
|
||||
parseMode = telego.ModeMarkdownV2
|
||||
}
|
||||
params := map[string]any{
|
||||
"guest_query_id": guestQueryID,
|
||||
"result": map[string]any{
|
||||
"type": "article",
|
||||
"id": "picoclaw-reply",
|
||||
"title": "PicoClaw",
|
||||
"input_message_content": map[string]any{
|
||||
"message_text": parsedContent,
|
||||
"parse_mode": parseMode,
|
||||
},
|
||||
},
|
||||
}
|
||||
var result struct {
|
||||
MessageID int `json:"message_id,omitempty"`
|
||||
}
|
||||
if err := c.postTelegramAPI(ctx, "answerGuestQuery", params, &result); err != nil {
|
||||
logParseFailed(err, useMarkdownV2)
|
||||
params["result"].(map[string]any)["input_message_content"] = map[string]any{
|
||||
"message_text": content,
|
||||
}
|
||||
if retryErr := c.postTelegramAPI(ctx, "answerGuestQuery", params, &result); retryErr != nil {
|
||||
return nil, fmt.Errorf("telegram answer guest query: %w", channels.ErrTemporary)
|
||||
}
|
||||
}
|
||||
if result.MessageID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{strconv.Itoa(result.MessageID)}, nil
|
||||
}
|
||||
|
||||
// maxTypingDuration limits how long the typing indicator can run.
|
||||
// Prevents endless typing when the LLM fails/hangs and preSend never invokes cancel.
|
||||
// Matches channels.Manager's typingStopTTL (5 min) so behavior is consistent.
|
||||
|
|
@ -375,13 +616,17 @@ const maxTypingDuration = 5 * time.Minute
|
|||
// The goroutine also exits automatically after maxTypingDuration if cancel is
|
||||
// never called (e.g. when the LLM fails or times out without publishing).
|
||||
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
||||
cid, threadID, err := parseTelegramChatID(chatID)
|
||||
target, err := resolveTelegramOutboundTarget(chatID, nil)
|
||||
if err != nil {
|
||||
return func() {}, err
|
||||
}
|
||||
if target.guestQueryID != "" {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||
action.MessageThreadID = threadID
|
||||
action := tu.ChatAction(tu.ID(target.chatID), telego.ChatActionTyping)
|
||||
action.MessageThreadID = target.threadID
|
||||
action.BusinessConnectionID = target.businessConnectionID
|
||||
|
||||
// Send the first typing action immediately
|
||||
_ = c.bot.SendChatAction(ctx, action)
|
||||
|
|
@ -398,8 +643,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
|||
case <-maxCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
|
||||
a.MessageThreadID = threadID
|
||||
a := tu.ChatAction(tu.ID(target.chatID), telego.ChatActionTyping)
|
||||
a.MessageThreadID = target.threadID
|
||||
a.BusinessConnectionID = target.businessConnectionID
|
||||
_ = c.bot.SendChatAction(typingCtx, a)
|
||||
}
|
||||
}
|
||||
|
|
@ -411,16 +657,23 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
|
|||
// EditMessage implements channels.MessageEditor.
|
||||
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
||||
useMarkdownV2 := c.tgCfg.UseMarkdownV2
|
||||
cid, _, err := parseTelegramChatID(chatID)
|
||||
target, err := resolveTelegramOutboundTarget(chatID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.guestQueryID != "" {
|
||||
return nil
|
||||
}
|
||||
mid, err := strconv.Atoi(messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.guestQueryID != "" {
|
||||
return nil
|
||||
}
|
||||
parsedContent := parseContent(content, useMarkdownV2)
|
||||
editMsg := tu.EditMessageText(tu.ID(cid), mid, parsedContent)
|
||||
editMsg := tu.EditMessageText(tu.ID(target.chatID), mid, parsedContent)
|
||||
editMsg.BusinessConnectionID = target.businessConnectionID
|
||||
if useMarkdownV2 {
|
||||
editMsg.WithParseMode(telego.ModeMarkdownV2)
|
||||
} else {
|
||||
|
|
@ -439,7 +692,9 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
|
|||
// Network errors or timeouts should NOT trigger a retry with different content.
|
||||
if strings.Contains(err.Error(), "Bad Request") {
|
||||
logParseFailed(err, useMarkdownV2)
|
||||
_, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content))
|
||||
plainEdit := tu.EditMessageText(tu.ID(target.chatID), mid, content)
|
||||
plainEdit.BusinessConnectionID = target.businessConnectionID
|
||||
_, err = c.bot.EditMessageText(ctx, plainEdit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -467,7 +722,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
|
|||
|
||||
// DeleteMessage implements channels.MessageDeleter.
|
||||
func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
||||
cid, _, err := parseTelegramChatID(chatID)
|
||||
target, err := resolveTelegramOutboundTarget(chatID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -475,8 +730,14 @@ func (c *TelegramChannel) DeleteMessage(ctx context.Context, chatID string, mess
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.businessConnectionID != "" {
|
||||
return c.bot.DeleteBusinessMessages(ctx, &telego.DeleteBusinessMessagesParams{
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
MessageIDs: []int{mid},
|
||||
})
|
||||
}
|
||||
return c.bot.DeleteMessage(ctx, &telego.DeleteMessageParams{
|
||||
ChatID: tu.ID(cid),
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageID: mid,
|
||||
})
|
||||
}
|
||||
|
|
@ -575,13 +836,17 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
|
|||
|
||||
text := phCfg.GetRandomText()
|
||||
|
||||
cid, threadID, err := parseTelegramChatID(chatID)
|
||||
target, err := resolveTelegramOutboundTarget(chatID, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if target.guestQueryID != "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
phMsg := tu.Message(tu.ID(cid), text)
|
||||
phMsg.MessageThreadID = threadID
|
||||
phMsg := tu.Message(tu.ID(target.chatID), text)
|
||||
phMsg.MessageThreadID = target.threadID
|
||||
phMsg.BusinessConnectionID = target.businessConnectionID
|
||||
pMsg, err := c.bot.SendMessage(ctx, phMsg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -598,7 +863,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
trackedChatID := telegramToolFeedbackChatKey(msg.ChatID, &msg.Context)
|
||||
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(trackedChatID)
|
||||
|
||||
chatID, threadID, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
||||
target, err := resolveTelegramOutboundTarget(msg.ChatID, &msg.Context)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
|
||||
}
|
||||
|
|
@ -632,10 +897,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
switch part.Type {
|
||||
case "image":
|
||||
params := &telego.SendPhotoParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Photo: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Photo: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendPhoto(ctx, params)
|
||||
if err != nil && strings.Contains(err.Error(), "PHOTO_INVALID_DIMENSIONS") {
|
||||
|
|
@ -645,10 +911,11 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
}
|
||||
|
||||
docParams := &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Document: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Document: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendDocument(ctx, docParams)
|
||||
}
|
||||
|
|
@ -658,35 +925,39 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
fn := strings.ToLower(part.Filename)
|
||||
if strings.Contains(fn, "voice") && (strings.HasSuffix(fn, ".ogg") || strings.HasSuffix(fn, ".oga")) {
|
||||
vparams := &telego.SendVoiceParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Voice: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Voice: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendVoice(ctx, vparams)
|
||||
} else {
|
||||
params := &telego.SendAudioParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Audio: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Audio: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendAudio(ctx, params)
|
||||
}
|
||||
case "video":
|
||||
params := &telego.SendVideoParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Video: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Video: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendVideo(ctx, params)
|
||||
default: // "file" or unknown types
|
||||
params := &telego.SendDocumentParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageThreadID: threadID,
|
||||
Document: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
BusinessConnectionID: target.businessConnectionID,
|
||||
ChatID: tu.ID(target.chatID),
|
||||
MessageThreadID: target.threadID,
|
||||
Document: telego.InputFile{File: file},
|
||||
Caption: part.Caption,
|
||||
}
|
||||
tgResult, err = c.bot.SendDocument(ctx, params)
|
||||
}
|
||||
|
|
@ -713,6 +984,164 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
|
|||
}
|
||||
|
||||
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
|
||||
return c.handleTelegramMessage(ctx, message, "", "")
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) businessModeEnabled() bool {
|
||||
return c != nil && c.tgCfg != nil && c.tgCfg.BusinessMode
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) guestModeEnabled() bool {
|
||||
return c != nil && c.tgCfg != nil && c.tgCfg.GuestMode
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleBusinessMessage(ctx context.Context, message *telego.Message) error {
|
||||
if !c.businessModeEnabled() {
|
||||
return nil
|
||||
}
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
}
|
||||
businessConnectionID := strings.TrimSpace(message.BusinessConnectionID)
|
||||
if businessConnectionID == "" {
|
||||
return fmt.Errorf("business message missing business_connection_id")
|
||||
}
|
||||
if c.isBusinessOwnerMessage(message) {
|
||||
logger.DebugCF("telegram", "Business message ignored from configured owner", map[string]any{
|
||||
"business_connection_id": businessConnectionID,
|
||||
"user_id": fmt.Sprintf("%d", message.From.ID),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if c.isDisabledBusinessCommand(message) {
|
||||
logger.DebugCF("telegram", "Business bot command ignored because business_commands_enable is false", map[string]any{
|
||||
"business_connection_id": businessConnectionID,
|
||||
"user_id": fmt.Sprintf("%d", message.From.ID),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
c.markBusinessMessageRead(ctx, businessConnectionID, message.Chat.ID, message.MessageID)
|
||||
return c.handleTelegramMessage(ctx, message, businessConnectionID, "")
|
||||
}
|
||||
|
||||
type telegramGuestMessage struct {
|
||||
telego.Message
|
||||
GuestQueryID string `json:"guest_query_id,omitempty"`
|
||||
}
|
||||
|
||||
func (m *telegramGuestMessage) UnmarshalJSON(data []byte) error {
|
||||
var message telego.Message
|
||||
if err := json.Unmarshal(data, &message); err != nil {
|
||||
return err
|
||||
}
|
||||
var guestFields struct {
|
||||
GuestQueryID string `json:"guest_query_id,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &guestFields); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Message = message
|
||||
m.GuestQueryID = guestFields.GuestQueryID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleGuestMessage(ctx context.Context, message *telegramGuestMessage) error {
|
||||
if !c.guestModeEnabled() {
|
||||
return nil
|
||||
}
|
||||
if message == nil {
|
||||
return fmt.Errorf("guest message is nil")
|
||||
}
|
||||
guestQueryID := strings.TrimSpace(message.GuestQueryID)
|
||||
if guestQueryID == "" {
|
||||
return fmt.Errorf("guest message missing guest_query_id")
|
||||
}
|
||||
return c.handleTelegramMessage(ctx, &message.Message, "", guestQueryID)
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) isDisabledBusinessCommand(message *telego.Message) bool {
|
||||
if c == nil || c.tgCfg == nil || c.tgCfg.BusinessCommandsEnable || message == nil {
|
||||
return false
|
||||
}
|
||||
return isTelegramBotCommandMessage(message)
|
||||
}
|
||||
|
||||
func isTelegramBotCommandMessage(message *telego.Message) bool {
|
||||
if message == nil {
|
||||
return false
|
||||
}
|
||||
for _, entity := range message.Entities {
|
||||
if entity.Type == telego.EntityTypeBotCommand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(strings.TrimSpace(message.Text), "/")
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) isBusinessOwnerMessage(message *telego.Message) bool {
|
||||
if c == nil || c.tgCfg == nil || message == nil || message.From == nil {
|
||||
return false
|
||||
}
|
||||
ownerID := strings.TrimSpace(c.tgCfg.BusinessOwner)
|
||||
if ownerID == "" {
|
||||
return false
|
||||
}
|
||||
return ownerID == fmt.Sprintf("%d", message.From.ID)
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) markBusinessMessageRead(
|
||||
ctx context.Context,
|
||||
businessConnectionID string,
|
||||
chatID int64,
|
||||
messageID int,
|
||||
) {
|
||||
if c == nil || c.bot == nil || strings.TrimSpace(businessConnectionID) == "" || chatID == 0 || messageID == 0 {
|
||||
return
|
||||
}
|
||||
if err := c.bot.ReadBusinessMessage(ctx, &telego.ReadBusinessMessageParams{
|
||||
BusinessConnectionID: businessConnectionID,
|
||||
ChatID: chatID,
|
||||
MessageID: messageID,
|
||||
}); err != nil {
|
||||
logger.DebugCF("telegram", "Failed to mark business message as read", map[string]any{
|
||||
"business_connection_id": businessConnectionID,
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleBusinessConnection(_ context.Context, connection telego.BusinessConnection) error {
|
||||
if !c.businessModeEnabled() {
|
||||
return nil
|
||||
}
|
||||
logger.InfoCF("telegram", "Business connection updated", map[string]any{
|
||||
"business_connection_id": connection.ID,
|
||||
"user_chat_id": connection.UserChatID,
|
||||
"is_enabled": connection.IsEnabled,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleDeletedBusinessMessages(_ context.Context, deleted telego.BusinessMessagesDeleted) error {
|
||||
if !c.businessModeEnabled() {
|
||||
return nil
|
||||
}
|
||||
logger.DebugCF("telegram", "Business messages deleted", map[string]any{
|
||||
"business_connection_id": deleted.BusinessConnectionID,
|
||||
"chat_id": deleted.Chat.ID,
|
||||
"message_count": len(deleted.MessageIDs),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) handleTelegramMessage(
|
||||
ctx context.Context,
|
||||
message *telego.Message,
|
||||
businessConnectionID string,
|
||||
guestQueryID string,
|
||||
) error {
|
||||
if message == nil {
|
||||
return fmt.Errorf("message is nil")
|
||||
}
|
||||
|
|
@ -745,7 +1174,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
content := ""
|
||||
mediaPaths := []string{}
|
||||
|
||||
chatIDStr := fmt.Sprintf("%d", chatID)
|
||||
chatIDStr := formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0)
|
||||
messageIDStr := fmt.Sprintf("%d", message.MessageID)
|
||||
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
|
||||
|
||||
|
|
@ -864,10 +1293,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
// route to the correct topic and each topic gets its own session.
|
||||
// Only forum groups (IsForum) are handled; regular group reply threads
|
||||
// must share one session per group.
|
||||
compositeChatID := fmt.Sprintf("%d", chatID)
|
||||
compositeChatID := formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0)
|
||||
threadID := message.MessageThreadID
|
||||
if message.Chat.IsForum && threadID != 0 {
|
||||
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
|
||||
compositeChatID = formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, threadID)
|
||||
}
|
||||
|
||||
logger.DebugCF("telegram", "Received message", map[string]any{
|
||||
|
|
@ -889,16 +1318,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
|||
"first_name": user.FirstName,
|
||||
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
|
||||
}
|
||||
if businessConnectionID != "" {
|
||||
metadata["business_connection_id"] = businessConnectionID
|
||||
}
|
||||
if guestQueryID != "" {
|
||||
metadata["guest_query_id"] = guestQueryID
|
||||
}
|
||||
|
||||
inboundCtx := bus.InboundContext{
|
||||
Channel: c.Name(),
|
||||
ChatID: fmt.Sprintf("%d", chatID),
|
||||
ChatID: formatTelegramDeliveryChatID(chatID, businessConnectionID, guestQueryID, 0),
|
||||
ChatType: peerKind,
|
||||
SenderID: platformID,
|
||||
MessageID: messageID,
|
||||
Mentioned: isMentioned,
|
||||
Raw: metadata,
|
||||
}
|
||||
if businessConnectionID != "" {
|
||||
inboundCtx.Account = businessConnectionID
|
||||
}
|
||||
if guestQueryID != "" {
|
||||
inboundCtx.Account = guestQueryID
|
||||
}
|
||||
if message.Chat.IsForum && threadID != 0 {
|
||||
inboundCtx.TopicID = fmt.Sprintf("%d", threadID)
|
||||
}
|
||||
|
|
@ -1123,11 +1564,11 @@ func (c *TelegramChannel) PrepareToolFeedbackMessageContent(content string) stri
|
|||
}
|
||||
|
||||
func telegramToolFeedbackChatKey(chatID string, outboundCtx *bus.InboundContext) string {
|
||||
resolvedChatID, threadID, err := resolveTelegramOutboundTarget(chatID, outboundCtx)
|
||||
if err != nil || threadID == 0 {
|
||||
target, err := resolveTelegramOutboundTarget(chatID, outboundCtx)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(chatID)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d", resolvedChatID, threadID)
|
||||
return target.String()
|
||||
}
|
||||
|
||||
func (c *TelegramChannel) ToolFeedbackMessageChatID(chatID string, outboundCtx *bus.InboundContext) string {
|
||||
|
|
@ -1153,26 +1594,131 @@ func parseTelegramChatID(chatID string) (int64, int, error) {
|
|||
return cid, tid, nil
|
||||
}
|
||||
|
||||
func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (int64, int, error) {
|
||||
type telegramOutboundTarget struct {
|
||||
businessConnectionID string
|
||||
guestQueryID string
|
||||
chatID int64
|
||||
threadID int
|
||||
}
|
||||
|
||||
func (t telegramOutboundTarget) String() string {
|
||||
return formatTelegramDeliveryChatID(t.chatID, t.businessConnectionID, t.guestQueryID, t.threadID)
|
||||
}
|
||||
|
||||
func formatTelegramDeliveryChatID(chatID int64, businessConnectionID string, guestQueryID string, threadID int) string {
|
||||
base := fmt.Sprintf("%d", chatID)
|
||||
if strings.TrimSpace(guestQueryID) != "" {
|
||||
base = fmt.Sprintf("guest:%s:%d", url.QueryEscape(strings.TrimSpace(guestQueryID)), chatID)
|
||||
} else if strings.TrimSpace(businessConnectionID) != "" {
|
||||
base = fmt.Sprintf("business:%s:%d", url.QueryEscape(strings.TrimSpace(businessConnectionID)), chatID)
|
||||
}
|
||||
if threadID != 0 {
|
||||
return fmt.Sprintf("%s/%d", base, threadID)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func parseTelegramOutboundTarget(chatID string) (telegramOutboundTarget, error) {
|
||||
targetChatID := strings.TrimSpace(chatID)
|
||||
if strings.HasPrefix(targetChatID, "business:") {
|
||||
rest := strings.TrimPrefix(targetChatID, "business:")
|
||||
parts := strings.SplitN(rest, ":", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return telegramOutboundTarget{}, fmt.Errorf("invalid business chat ID %q", chatID)
|
||||
}
|
||||
businessConnectionID, unescapeErr := url.QueryUnescape(strings.TrimSpace(parts[0]))
|
||||
if unescapeErr != nil {
|
||||
return telegramOutboundTarget{}, fmt.Errorf("invalid business connection ID in chat ID %q: %w", chatID, unescapeErr)
|
||||
}
|
||||
cid, threadID, err := parseTelegramChatID(parts[1])
|
||||
if err != nil {
|
||||
return telegramOutboundTarget{}, err
|
||||
}
|
||||
return telegramOutboundTarget{
|
||||
businessConnectionID: businessConnectionID,
|
||||
chatID: cid,
|
||||
threadID: threadID,
|
||||
}, nil
|
||||
}
|
||||
if strings.HasPrefix(targetChatID, "guest:") {
|
||||
rest := strings.TrimPrefix(targetChatID, "guest:")
|
||||
parts := strings.SplitN(rest, ":", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return telegramOutboundTarget{}, fmt.Errorf("invalid guest chat ID %q", chatID)
|
||||
}
|
||||
guestQueryID, unescapeErr := url.QueryUnescape(strings.TrimSpace(parts[0]))
|
||||
if unescapeErr != nil {
|
||||
return telegramOutboundTarget{}, fmt.Errorf("invalid guest query ID in chat ID %q: %w", chatID, unescapeErr)
|
||||
}
|
||||
cid, threadID, err := parseTelegramChatID(parts[1])
|
||||
if err != nil {
|
||||
return telegramOutboundTarget{}, err
|
||||
}
|
||||
return telegramOutboundTarget{
|
||||
guestQueryID: guestQueryID,
|
||||
chatID: cid,
|
||||
threadID: threadID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cid, threadID, err := parseTelegramChatID(targetChatID)
|
||||
if err != nil {
|
||||
return telegramOutboundTarget{}, err
|
||||
}
|
||||
return telegramOutboundTarget{chatID: cid, threadID: threadID}, nil
|
||||
}
|
||||
|
||||
func guestQueryIDFromContext(outboundCtx *bus.InboundContext) string {
|
||||
if outboundCtx == nil {
|
||||
return ""
|
||||
}
|
||||
if outboundCtx.Raw != nil {
|
||||
if guestQueryID := strings.TrimSpace(outboundCtx.Raw["guest_query_id"]); guestQueryID != "" {
|
||||
return guestQueryID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func businessConnectionIDFromContext(outboundCtx *bus.InboundContext) string {
|
||||
if outboundCtx == nil {
|
||||
return ""
|
||||
}
|
||||
if outboundCtx.Raw != nil {
|
||||
if businessConnectionID := strings.TrimSpace(outboundCtx.Raw["business_connection_id"]); businessConnectionID != "" {
|
||||
return businessConnectionID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(outboundCtx.Account)
|
||||
}
|
||||
|
||||
func resolveTelegramOutboundTarget(chatID string, outboundCtx *bus.InboundContext) (telegramOutboundTarget, error) {
|
||||
targetChatID := strings.TrimSpace(chatID)
|
||||
if targetChatID == "" && outboundCtx != nil {
|
||||
targetChatID = strings.TrimSpace(outboundCtx.ChatID)
|
||||
}
|
||||
resolvedChatID, resolvedThreadID, err := parseTelegramChatID(targetChatID)
|
||||
resolved, err := parseTelegramOutboundTarget(targetChatID)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return telegramOutboundTarget{}, err
|
||||
}
|
||||
if resolvedThreadID != 0 || outboundCtx == nil {
|
||||
return resolvedChatID, resolvedThreadID, nil
|
||||
if resolved.businessConnectionID == "" {
|
||||
resolved.businessConnectionID = businessConnectionIDFromContext(outboundCtx)
|
||||
}
|
||||
if resolved.guestQueryID == "" {
|
||||
resolved.guestQueryID = guestQueryIDFromContext(outboundCtx)
|
||||
}
|
||||
if resolved.threadID != 0 || outboundCtx == nil {
|
||||
return resolved, nil
|
||||
}
|
||||
topicID := strings.TrimSpace(outboundCtx.TopicID)
|
||||
if topicID == "" {
|
||||
return resolvedChatID, resolvedThreadID, nil
|
||||
return resolved, nil
|
||||
}
|
||||
if threadID, convErr := strconv.Atoi(topicID); convErr == nil {
|
||||
return resolvedChatID, threadID, nil
|
||||
resolved.threadID = threadID
|
||||
return resolved, nil
|
||||
}
|
||||
return resolvedChatID, resolvedThreadID, nil
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func logParseFailed(err error, useMarkdownV2 bool) {
|
||||
|
|
@ -1284,16 +1830,19 @@ func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (chann
|
|||
return nil, fmt.Errorf("streaming disabled in config")
|
||||
}
|
||||
|
||||
cid, threadID, err := parseTelegramChatID(chatID)
|
||||
target, err := resolveTelegramOutboundTarget(chatID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if target.businessConnectionID != "" {
|
||||
return nil, fmt.Errorf("streaming is not supported for telegram business chats")
|
||||
}
|
||||
|
||||
streamCfg := c.tgCfg.Streaming
|
||||
return &telegramStreamer{
|
||||
bot: c.bot,
|
||||
chatID: cid,
|
||||
threadID: threadID,
|
||||
chatID: target.chatID,
|
||||
threadID: target.threadID,
|
||||
draftID: cryptoRandInt(),
|
||||
throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
|
||||
minGrowth: streamCfg.MinGrowthChars,
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ package telegram
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/channels"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||
|
|
@ -16,6 +19,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
|||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{BusinessMode: true},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
|
|
@ -46,3 +50,343 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
|||
t.Fatalf("content=%q", inbound.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_DisabledBusinessModeIgnoresMessage(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{BusinessMode: false},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "ignored business message",
|
||||
MessageID: 18,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ch.handleBusinessMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleBusinessMessage error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("expected disabled business mode to ignore message, got %#v", inbound)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_BusinessOwnerIgnoresMessage(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{
|
||||
BusinessMode: true,
|
||||
BusinessOwner: "42",
|
||||
},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "owner should be ignored",
|
||||
MessageID: 19,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Owner",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ch.handleBusinessMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleBusinessMessage error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("expected owner business message to be ignored, got %#v", inbound)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_DisabledBusinessCommandsIgnoresCommand(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{
|
||||
BusinessMode: true,
|
||||
BusinessCommandsEnable: false,
|
||||
},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "/new",
|
||||
MessageID: 20,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Entities: []telego.MessageEntity{{
|
||||
Type: telego.EntityTypeBotCommand,
|
||||
Offset: 0,
|
||||
Length: len("/new"),
|
||||
}},
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ch.handleBusinessMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleBusinessMessage error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("expected disabled business commands to ignore message, got %#v", inbound)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_EnabledBusinessCommandsForwardsCommand(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{
|
||||
BusinessMode: true,
|
||||
BusinessCommandsEnable: true,
|
||||
},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "/new",
|
||||
MessageID: 21,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Entities: []telego.MessageEntity{{
|
||||
Type: telego.EntityTypeBotCommand,
|
||||
Offset: 0,
|
||||
Length: len("/new"),
|
||||
}},
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ch.handleBusinessMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleBusinessMessage error: %v", err)
|
||||
}
|
||||
|
||||
inbound, ok := <-messageBus.InboundChan()
|
||||
if !ok {
|
||||
t.Fatal("expected inbound message to be forwarded")
|
||||
}
|
||||
if inbound.Content != "/new" {
|
||||
t.Fatalf("content=%q", inbound.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramAllowedUpdates_BusinessMode(t *testing.T) {
|
||||
disabled := strings.Join(telegramAllowedUpdates(false, false), ",")
|
||||
if strings.Contains(disabled, telego.BusinessMessageUpdates) {
|
||||
t.Fatalf("disabled updates include business messages: %s", disabled)
|
||||
}
|
||||
|
||||
enabled := strings.Join(telegramAllowedUpdates(true, false), ",")
|
||||
if !strings.Contains(enabled, telego.BusinessMessageUpdates) {
|
||||
t.Fatalf("enabled updates do not include business messages: %s", enabled)
|
||||
}
|
||||
if !strings.Contains(enabled, telego.DeletedBusinessMessagesUpdates) {
|
||||
t.Fatalf("enabled updates do not include deleted business messages: %s", enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramAllowedUpdates_GuestMode(t *testing.T) {
|
||||
disabled := strings.Join(telegramAllowedUpdates(false, false), ",")
|
||||
if strings.Contains(disabled, telegramGuestMessageUpdates) {
|
||||
t.Fatalf("disabled updates include guest messages: %s", disabled)
|
||||
}
|
||||
|
||||
enabled := strings.Join(telegramAllowedUpdates(false, true), ",")
|
||||
if !strings.Contains(enabled, telegramGuestMessageUpdates) {
|
||||
t.Fatalf("enabled updates do not include guest messages: %s", enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGuestMessage_DisabledGuestModeIgnoresMessage(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{GuestMode: false},
|
||||
}
|
||||
|
||||
msg := &telegramGuestMessage{
|
||||
Message: telego.Message{
|
||||
Text: "ignored guest message",
|
||||
MessageID: 18,
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
},
|
||||
GuestQueryID: "guest-query-1",
|
||||
}
|
||||
|
||||
if err := ch.handleGuestMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleGuestMessage error: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case inbound := <-messageBus.InboundChan():
|
||||
t.Fatalf("expected disabled guest mode to ignore message, got %#v", inbound)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGuestMessage_ForwardsWithGuestContext(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{GuestMode: true},
|
||||
}
|
||||
|
||||
msg := &telegramGuestMessage{
|
||||
Message: telego.Message{
|
||||
Text: "hello from guest",
|
||||
MessageID: 17,
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
},
|
||||
GuestQueryID: "guest-query-1",
|
||||
}
|
||||
|
||||
if err := ch.handleGuestMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleGuestMessage error: %v", err)
|
||||
}
|
||||
|
||||
inbound, ok := <-messageBus.InboundChan()
|
||||
if !ok {
|
||||
t.Fatal("expected inbound message to be forwarded")
|
||||
}
|
||||
if inbound.ChatID != "guest:guest-query-1:777" {
|
||||
t.Fatalf("chat_id=%q", inbound.ChatID)
|
||||
}
|
||||
if inbound.Context.Account != "guest-query-1" {
|
||||
t.Fatalf("account=%q", inbound.Context.Account)
|
||||
}
|
||||
if inbound.Context.Raw["guest_query_id"] != "guest-query-1" {
|
||||
t.Fatalf("guest_query_id=%q", inbound.Context.Raw["guest_query_id"])
|
||||
}
|
||||
if inbound.Content != "hello from guest" {
|
||||
t.Fatalf("content=%q", inbound.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramRawUpdateDecodesGuestQueryID(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"update_id": 1001,
|
||||
"guest_message": {
|
||||
"message_id": 17,
|
||||
"guest_query_id": "guest-query-1",
|
||||
"date": 1760000000,
|
||||
"chat": {"id": 777, "type": "private"},
|
||||
"from": {"id": 42, "is_bot": false, "first_name": "Alice"},
|
||||
"text": "hello from guest"
|
||||
}
|
||||
}`)
|
||||
|
||||
var update telegramRawUpdate
|
||||
if err := json.Unmarshal(payload, &update); err != nil {
|
||||
t.Fatalf("unmarshal raw update: %v", err)
|
||||
}
|
||||
if update.GuestMessage == nil {
|
||||
t.Fatal("guest message was not decoded")
|
||||
}
|
||||
if update.GuestMessage.GuestQueryID != "guest-query-1" {
|
||||
t.Fatalf("guest_query_id=%q", update.GuestMessage.GuestQueryID)
|
||||
}
|
||||
if update.GuestMessage.Text != "hello from guest" {
|
||||
t.Fatalf("text=%q", update.GuestMessage.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_ForwardsWithBusinessContext(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
ch := &TelegramChannel{
|
||||
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
|
||||
chatIDs: make(map[string]int64),
|
||||
ctx: context.Background(),
|
||||
tgCfg: &config.TelegramSettings{BusinessMode: true},
|
||||
}
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "hello from business",
|
||||
MessageID: 17,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
if err := ch.handleBusinessMessage(context.Background(), msg); err != nil {
|
||||
t.Fatalf("handleBusinessMessage error: %v", err)
|
||||
}
|
||||
|
||||
inbound, ok := <-messageBus.InboundChan()
|
||||
if !ok {
|
||||
t.Fatal("expected inbound message to be forwarded")
|
||||
}
|
||||
if inbound.ChatID != "business:biz-conn-1:777" {
|
||||
t.Fatalf("chat_id=%q", inbound.ChatID)
|
||||
}
|
||||
if inbound.Context.Account != "biz-conn-1" {
|
||||
t.Fatalf("account=%q", inbound.Context.Account)
|
||||
}
|
||||
if inbound.Context.Raw["business_connection_id"] != "biz-conn-1" {
|
||||
t.Fatalf("business_connection_id=%q", inbound.Context.Raw["business_connection_id"])
|
||||
}
|
||||
if inbound.Content != "hello from business" {
|
||||
t.Fatalf("content=%q", inbound.Content)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
|
@ -150,6 +152,90 @@ func newTestChannelWithConstructor(
|
|||
}
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_MarksMessageRead(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
if !strings.Contains(url, "readBusinessMessage") {
|
||||
t.Fatalf("unexpected API call: %s", url)
|
||||
}
|
||||
b, err := json.Marshal(true)
|
||||
require.NoError(t, err)
|
||||
return &ta.Response{Ok: true, Result: b}, nil
|
||||
},
|
||||
}
|
||||
ch := newTestChannel(t, caller)
|
||||
ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
|
||||
ch.BaseChannel.SetOwner(ch)
|
||||
ch.ctx = context.Background()
|
||||
ch.tgCfg.BusinessMode = true
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "hello from business",
|
||||
MessageID: 17,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, ch.handleBusinessMessage(context.Background(), msg))
|
||||
require.Len(t, caller.calls, 1)
|
||||
assert.Contains(t, caller.calls[0].URL, "readBusinessMessage")
|
||||
|
||||
var params struct {
|
||||
BusinessConnectionID string `json:"business_connection_id"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
MessageID int `json:"message_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
|
||||
assert.Equal(t, "biz-conn-1", params.BusinessConnectionID)
|
||||
assert.Equal(t, int64(777), params.ChatID)
|
||||
assert.Equal(t, 17, params.MessageID)
|
||||
|
||||
inbound := <-messageBus.InboundChan()
|
||||
assert.Equal(t, "hello from business", inbound.Content)
|
||||
}
|
||||
|
||||
func TestHandleBusinessMessage_ReadFailureStillForwardsMessage(t *testing.T) {
|
||||
messageBus := bus.NewMessageBus()
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
return nil, errors.New("missing can_read_messages")
|
||||
},
|
||||
}
|
||||
ch := newTestChannel(t, caller)
|
||||
ch.BaseChannel = channels.NewBaseChannel("telegram", nil, messageBus, nil)
|
||||
ch.BaseChannel.SetOwner(ch)
|
||||
ch.ctx = context.Background()
|
||||
ch.tgCfg.BusinessMode = true
|
||||
|
||||
msg := &telego.Message{
|
||||
Text: "still forward me",
|
||||
MessageID: 18,
|
||||
BusinessConnectionID: "biz-conn-1",
|
||||
Chat: telego.Chat{
|
||||
ID: 777,
|
||||
Type: "private",
|
||||
},
|
||||
From: &telego.User{
|
||||
ID: 42,
|
||||
FirstName: "Alice",
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, ch.handleBusinessMessage(context.Background(), msg))
|
||||
require.Len(t, caller.calls, 1)
|
||||
|
||||
inbound := <-messageBus.InboundChan()
|
||||
assert.Equal(t, "still forward me", inbound.Content)
|
||||
}
|
||||
|
||||
func TestSendMedia_ImageFallbacksToDocumentOnInvalidDimensions(t *testing.T) {
|
||||
constructor := &multipartRecordingConstructor{}
|
||||
caller := &stubCaller{
|
||||
|
|
@ -271,6 +357,94 @@ func TestSend_ShortMessage_SingleCall(t *testing.T) {
|
|||
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
|
||||
}
|
||||
|
||||
func TestSend_BusinessMessageIncludesBusinessConnectionID(t *testing.T) {
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
return successResponse(t), nil
|
||||
},
|
||||
}
|
||||
ch := newTestChannel(t, caller)
|
||||
|
||||
_, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "business:biz-conn-1:777",
|
||||
Content: "hello business",
|
||||
Context: bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "business:biz-conn-1:777",
|
||||
Account: "biz-conn-1",
|
||||
Raw: map[string]string{
|
||||
"business_connection_id": "biz-conn-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, caller.calls, 1)
|
||||
|
||||
var params struct {
|
||||
ChatID int64 `json:"chat_id"`
|
||||
BusinessConnectionID string `json:"business_connection_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(caller.calls[0].Data.BodyRaw, ¶ms))
|
||||
assert.Equal(t, int64(777), params.ChatID)
|
||||
assert.Equal(t, "biz-conn-1", params.BusinessConnectionID)
|
||||
assert.Equal(t, "hello business", params.Text)
|
||||
}
|
||||
|
||||
func TestSend_GuestMessageUsesAnswerGuestQuery(t *testing.T) {
|
||||
var requestPath string
|
||||
var params struct {
|
||||
GuestQueryID string `json:"guest_query_id"`
|
||||
Result struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
InputMessageContent struct {
|
||||
MessageText string `json:"message_text"`
|
||||
ParseMode string `json:"parse_mode"`
|
||||
} `json:"input_message_content"`
|
||||
} `json:"result"`
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestPath = r.URL.Path
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(¶ms))
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":55}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ch := newTestChannel(t, &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
t.Fatalf("guest reply must not use telego caller: %s", url)
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
ch.tgCfg.BaseURL = server.URL
|
||||
|
||||
ids, err := ch.Send(context.Background(), bus.OutboundMessage{
|
||||
ChatID: "guest:guest-query-1:777",
|
||||
Content: "hello guest",
|
||||
Context: bus.InboundContext{
|
||||
Channel: "telegram",
|
||||
ChatID: "guest:guest-query-1:777",
|
||||
Account: "guest-query-1",
|
||||
Raw: map[string]string{
|
||||
"guest_query_id": "guest-query-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"55"}, ids)
|
||||
assert.Equal(t, "/bot"+testToken+"/answerGuestQuery", requestPath)
|
||||
assert.Equal(t, "guest-query-1", params.GuestQueryID)
|
||||
assert.Equal(t, "article", params.Result.Type)
|
||||
assert.Equal(t, "picoclaw-reply", params.Result.ID)
|
||||
assert.Equal(t, "PicoClaw", params.Result.Title)
|
||||
assert.Equal(t, "hello guest", params.Result.InputMessageContent.MessageText)
|
||||
assert.Equal(t, telego.ModeHTML, params.Result.InputMessageContent.ParseMode)
|
||||
}
|
||||
|
||||
func TestSend_NonToolFeedbackDeletesTrackedProgressMessage(t *testing.T) {
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
|
|
@ -686,6 +860,17 @@ func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "invalid thread ID")
|
||||
}
|
||||
|
||||
func TestParseTelegramOutboundTarget_BusinessConnectionIDWithColon(t *testing.T) {
|
||||
formatted := formatTelegramDeliveryChatID(777, "biz:conn:1", "", 42)
|
||||
|
||||
target, err := parseTelegramOutboundTarget(formatted)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(777), target.chatID)
|
||||
assert.Equal(t, 42, target.threadID)
|
||||
assert.Equal(t, "biz:conn:1", target.businessConnectionID)
|
||||
}
|
||||
|
||||
func TestSend_WithForumThreadID(t *testing.T) {
|
||||
caller := &stubCaller{
|
||||
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
|
||||
|
|
|
|||
|
|
@ -359,11 +359,15 @@ type WhatsAppSettings struct {
|
|||
}
|
||||
|
||||
type TelegramSettings struct {
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
|
||||
Proxy string `json:"proxy" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
BusinessMode bool `json:"business_mode" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_MODE"`
|
||||
GuestMode bool `json:"guest_mode" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_GUEST_MODE"`
|
||||
BusinessOwner string `json:"business_owner" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_OWNER"`
|
||||
BusinessCommandsEnable bool `json:"business_commands_enable" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_BUSINESS_COMMANDS_ENABLE"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-" env:"PICOCLAW_CHANNELS_TELEGRAM_USE_MARKDOWN_V2"`
|
||||
}
|
||||
|
||||
type FeishuSettings struct {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,14 @@ import (
|
|||
// ─── Test extend structs (simplified, settings + secure in one struct) ───
|
||||
|
||||
type testTelegramConfig struct {
|
||||
BaseURL string `json:"base_url" yaml:"-"`
|
||||
Proxy string `json:"proxy" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty"`
|
||||
BaseURL string `json:"base_url" yaml:"-"`
|
||||
Proxy string `json:"proxy" yaml:"-"`
|
||||
BusinessMode bool `json:"business_mode" yaml:"-"`
|
||||
BusinessOwner string `json:"business_owner" yaml:"-"`
|
||||
BusinessCommandsEnable bool `json:"business_commands_enable" yaml:"-"`
|
||||
UseMarkdownV2 bool `json:"use_markdown_v2" yaml:"-"`
|
||||
Streaming StreamingConfig `json:"streaming,omitempty" yaml:"-"`
|
||||
Token SecureString `json:"token,omitzero" yaml:"token,omitempty"`
|
||||
}
|
||||
|
||||
type testDiscordConfig struct {
|
||||
|
|
@ -107,6 +110,9 @@ func TestChannel_JSON_Unmarshal(t *testing.T) {
|
|||
"reasoning_channel_id": "-100xxx",
|
||||
"settings": {
|
||||
"base_url": "https://custom-api.example.com",
|
||||
"business_mode": true,
|
||||
"business_owner": "42",
|
||||
"business_commands_enable": true,
|
||||
"use_markdown_v2": true,
|
||||
"streaming": {"enabled": true, "throttle_seconds": 2},
|
||||
"token": "[NOT_HERE]"
|
||||
|
|
@ -126,6 +132,9 @@ func TestChannel_JSON_Unmarshal(t *testing.T) {
|
|||
var cfg testTelegramConfig
|
||||
require.NoError(t, ch.Decode(&cfg))
|
||||
assert.Equal(t, "https://custom-api.example.com", cfg.BaseURL)
|
||||
assert.True(t, cfg.BusinessMode)
|
||||
assert.Equal(t, "42", cfg.BusinessOwner)
|
||||
assert.True(t, cfg.BusinessCommandsEnable)
|
||||
assert.True(t, cfg.UseMarkdownV2)
|
||||
assert.True(t, cfg.Streaming.Enabled)
|
||||
assert.Equal(t, 2, cfg.Streaming.ThrottleSeconds)
|
||||
|
|
|
|||
|
|
@ -496,8 +496,12 @@ func defaultChannels() ChannelsConfig {
|
|||
"typing": map[string]any{"enabled": true},
|
||||
"placeholder": map[string]any{"enabled": true, "text": []string{"Thinking... 💭"}},
|
||||
"settings": map[string]any{
|
||||
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
|
||||
"use_markdown_v2": false,
|
||||
"streaming": map[string]any{"enabled": true, "throttle_seconds": 3, "min_growth_chars": 200},
|
||||
"business_mode": false,
|
||||
"guest_mode": false,
|
||||
"business_owner": "",
|
||||
"business_commands_enable": false,
|
||||
"use_markdown_v2": false,
|
||||
},
|
||||
},
|
||||
"feishu": map[string]any{},
|
||||
|
|
|
|||
|
|
@ -86,6 +86,49 @@ export function TelegramForm({
|
|||
placeholder="https://api.telegram.org"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.businessMode")}
|
||||
hint={t("channels.form.desc.businessMode")}
|
||||
checked={asBool(config.business_mode)}
|
||||
onCheckedChange={(checked) => onChange("business_mode", checked)}
|
||||
ariaLabel={t("channels.field.businessMode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.guestMode")}
|
||||
hint={t("channels.form.desc.guestMode")}
|
||||
checked={asBool(config.guest_mode)}
|
||||
onCheckedChange={(checked) => onChange("guest_mode", checked)}
|
||||
ariaLabel={t("channels.field.guestMode")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label={t("channels.field.businessOwner")}
|
||||
hint={t("channels.form.desc.businessOwner")}
|
||||
>
|
||||
<Input
|
||||
value={asString(config.business_owner)}
|
||||
onChange={(e) => onChange("business_owner", e.target.value)}
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<SwitchCardField
|
||||
label={t("channels.field.businessCommandsEnable")}
|
||||
hint={t("channels.form.desc.businessCommandsEnable")}
|
||||
checked={asBool(config.business_commands_enable)}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange("business_commands_enable", checked)
|
||||
}
|
||||
ariaLabel={t("channels.field.businessCommandsEnable")}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
|
|||
|
|
@ -375,6 +375,10 @@
|
|||
"verificationToken": "Verification Token",
|
||||
"encryptKey": "Encrypt Key",
|
||||
"baseUrl": "API Base URL",
|
||||
"businessMode": "Business Mode",
|
||||
"guestMode": "Guest Mode",
|
||||
"businessOwner": "Business Owner",
|
||||
"businessCommandsEnable": "Business Commands",
|
||||
"proxy": "HTTP Proxy",
|
||||
"mentionOnly": "Mention Only",
|
||||
"typingEnabled": "Typing Indicator",
|
||||
|
|
@ -414,6 +418,10 @@
|
|||
"verificationToken": "Verification token for event callbacks.",
|
||||
"encryptKey": "Encryption key used to decrypt callback payloads.",
|
||||
"baseUrl": "Platform API base URL. Official endpoint is used by default.",
|
||||
"businessMode": "Receive and reply to Telegram Business messages for connected business accounts.",
|
||||
"guestMode": "Receive and reply to Telegram guest messages from chats where the bot is not a member.",
|
||||
"businessOwner": "Telegram user ID of the business account owner. Business messages from this user are ignored.",
|
||||
"businessCommandsEnable": "Allow bot commands such as /new in Telegram Business chats.",
|
||||
"proxy": "HTTP proxy address for outbound network access.",
|
||||
"mentionOnly": "Only respond when the bot is explicitly mentioned in group chats.",
|
||||
"typingEnabled": "Display typing status while the assistant is generating a response.",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,10 @@
|
|||
"verificationToken": "Token de Verificação",
|
||||
"encryptKey": "Chave de Criptografia",
|
||||
"baseUrl": "URL Base da API",
|
||||
"businessMode": "Modo Business",
|
||||
"guestMode": "Modo Guest",
|
||||
"businessOwner": "Proprietário Business",
|
||||
"businessCommandsEnable": "Comandos Business",
|
||||
"proxy": "Proxy HTTP",
|
||||
"mentionOnly": "Apenas com Menção",
|
||||
"typingEnabled": "Indicador de Digitação",
|
||||
|
|
@ -406,6 +410,10 @@
|
|||
"verificationToken": "Token de verificação para callbacks de eventos.",
|
||||
"encryptKey": "Chave de criptografia usada para descriptografar payloads de callback.",
|
||||
"baseUrl": "URL base da API da plataforma. O endpoint oficial é usado por padrão.",
|
||||
"businessMode": "Receber e responder mensagens do Telegram Business para contas comerciais conectadas.",
|
||||
"guestMode": "Receber e responder mensagens Guest do Telegram em chats onde o bot não é membro.",
|
||||
"businessOwner": "ID de usuário do Telegram do proprietário da conta comercial. Mensagens Business desse usuário são ignoradas.",
|
||||
"businessCommandsEnable": "Permitir comandos do bot, como /new, em chats do Telegram Business.",
|
||||
"proxy": "Endereço de proxy HTTP para acesso de rede de saída.",
|
||||
"mentionOnly": "Responder apenas quando o bot for explicitamente mencionado em chats em grupo.",
|
||||
"typingEnabled": "Exibir status de digitação enquanto o assistente está gerando uma resposta.",
|
||||
|
|
|
|||
|
|
@ -375,6 +375,10 @@
|
|||
"verificationToken": "Verification Token",
|
||||
"encryptKey": "Encrypt Key",
|
||||
"baseUrl": "API Base URL",
|
||||
"businessMode": "Business Mode",
|
||||
"guestMode": "Guest Mode",
|
||||
"businessOwner": "Business Owner",
|
||||
"businessCommandsEnable": "Business Commands",
|
||||
"proxy": "HTTP 代理",
|
||||
"mentionOnly": "仅提及时响应",
|
||||
"typingEnabled": "输入中提示",
|
||||
|
|
@ -414,6 +418,10 @@
|
|||
"verificationToken": "事件回调验证令牌",
|
||||
"encryptKey": "消息加密密钥,用于解密回调内容",
|
||||
"baseUrl": "平台 API 地址,默认使用官方地址",
|
||||
"businessMode": "接收并回复已连接商业账号的 Telegram Business 消息",
|
||||
"guestMode": "接收并回复机器人未加入聊天中的 Telegram Guest 消息",
|
||||
"businessOwner": "商业账号所有者的 Telegram 用户 ID。来自该用户的 Business 消息会被忽略。",
|
||||
"businessCommandsEnable": "允许在 Telegram Business 聊天中处理 /new 等机器人命令。",
|
||||
"proxy": "HTTP 代理地址,用于网络访问",
|
||||
"mentionOnly": "在群聊中仅当明确提及时才响应",
|
||||
"typingEnabled": "在生成回复时显示“正在输入”状态",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue