WhatsApp and Signal already had groups config with requireMention and group allowlists. This brings the same pattern to the remaining three channels, giving operators consistent control over which groups the bot participates in and whether mentions are required. - New `groups` config for Telegram, Discord, Slack (per-group allowlist with requireMention, wildcard support) - Telegram: standalone gating module with entity, text, command, and regex mention detection; applied to text, voice, and attachment handlers - Discord: inline gating using native message.mentions API - Slack: gating in message handler (drops non-mentions) and app_mention handler (allowlist check); helper methods on adapter - Signal: pass wasMentioned through to InboundMessage (was detected but never forwarded) - Config wiring: groups/mentionPatterns forwarded from main.ts to all adapter constructors - 17 new tests for Telegram gating Written by Cameron ◯ Letta Code "Even in the group chat of life, sometimes you gotta be @mentioned to know the universe is talking to you." — Ancient Internet Proverb
This commit is contained in:
@@ -39,15 +39,26 @@ channels:
|
||||
# groupPollIntervalMin: 5 # Batch interval for group messages (default: 10)
|
||||
# instantGroups: ["-100123456"] # Groups that bypass batching
|
||||
# listeningGroups: ["-100123456"] # Groups where bot observes but only replies when @mentioned
|
||||
# Group access control (which groups can interact, mention requirement):
|
||||
# groups:
|
||||
# "*": { requireMention: true } # Default: only respond when @mentioned
|
||||
# "-1001234567890": { requireMention: false } # This group gets all messages
|
||||
# mentionPatterns: ["hey bot"] # Additional regex patterns for mention detection
|
||||
# slack:
|
||||
# enabled: true
|
||||
# appToken: xapp-...
|
||||
# botToken: xoxb-...
|
||||
# listeningGroups: ["C0123456789"] # Channels where bot observes only
|
||||
# # groups:
|
||||
# # "*": { requireMention: true } # Default: only respond when @mentioned
|
||||
# # "C0123456789": { requireMention: false }
|
||||
# discord:
|
||||
# enabled: true
|
||||
# token: YOUR-DISCORD-BOT-TOKEN
|
||||
# listeningGroups: ["1234567890123456789"] # Server/channel IDs where bot observes only
|
||||
# # groups:
|
||||
# # "*": { requireMention: true } # Default: only respond when @mentioned
|
||||
# # "1234567890123456789": { requireMention: false } # Server or channel ID
|
||||
# whatsapp:
|
||||
# enabled: true
|
||||
# selfChat: false
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface DiscordConfig {
|
||||
allowedUsers?: string[]; // Discord user IDs
|
||||
attachmentsDir?: string;
|
||||
attachmentsMaxBytes?: number;
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-guild/channel settings
|
||||
}
|
||||
|
||||
export class DiscordAdapter implements ChannelAdapter {
|
||||
@@ -242,6 +243,33 @@ Ask the bot owner to approve with:
|
||||
const displayName = message.member?.displayName || message.author.globalName || message.author.username;
|
||||
const wasMentioned = isGroup && !!this.client?.user && message.mentions.has(this.client.user);
|
||||
|
||||
// Group gating: config-based allowlist + mention requirement
|
||||
if (isGroup && this.config.groups) {
|
||||
const groups = this.config.groups;
|
||||
const chatId = message.channel.id;
|
||||
const serverId = message.guildId;
|
||||
const allowlistEnabled = Object.keys(groups).length > 0;
|
||||
|
||||
if (allowlistEnabled) {
|
||||
const hasWildcard = Object.hasOwn(groups, '*');
|
||||
const hasSpecific = Object.hasOwn(groups, chatId)
|
||||
|| (serverId && Object.hasOwn(groups, serverId));
|
||||
if (!hasWildcard && !hasSpecific) {
|
||||
console.log(`[Discord] Group ${chatId} not in allowlist, ignoring`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const groupConfig = groups[chatId]
|
||||
?? (serverId ? groups[serverId] : undefined)
|
||||
?? groups['*'];
|
||||
const requireMention = groupConfig?.requireMention ?? true;
|
||||
|
||||
if (requireMention && !wasMentioned) {
|
||||
return; // Mention required but not mentioned -- silent drop
|
||||
}
|
||||
}
|
||||
|
||||
await this.onMessage({
|
||||
channel: 'discord',
|
||||
chatId: message.channel.id,
|
||||
|
||||
@@ -763,6 +763,7 @@ This code expires in 1 hour.`;
|
||||
const isGroup = chatId.startsWith('group:');
|
||||
|
||||
// Apply group gating - only respond when mentioned (unless configured otherwise)
|
||||
let wasMentioned: boolean | undefined;
|
||||
if (isGroup && groupInfo?.groupId) {
|
||||
const mentions = dataMessage?.mentions || syncMessage?.mentions;
|
||||
const quote = dataMessage?.quote || syncMessage?.quote;
|
||||
@@ -782,7 +783,8 @@ This code expires in 1 hour.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (gatingResult.wasMentioned) {
|
||||
wasMentioned = gatingResult.wasMentioned;
|
||||
if (wasMentioned) {
|
||||
console.log(`[Signal] Bot mentioned via ${gatingResult.method}`);
|
||||
}
|
||||
}
|
||||
@@ -795,6 +797,7 @@ This code expires in 1 hour.`;
|
||||
timestamp: new Date(envelope.timestamp || Date.now()),
|
||||
isGroup,
|
||||
groupName: groupInfo?.groupName,
|
||||
wasMentioned,
|
||||
attachments: collectedAttachments.length > 0 ? collectedAttachments : undefined,
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SlackConfig {
|
||||
allowedUsers?: string[]; // Slack user IDs (e.g., U01234567)
|
||||
attachmentsDir?: string;
|
||||
attachmentsMaxBytes?: number;
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-channel settings
|
||||
}
|
||||
|
||||
export class SlackAdapter implements ChannelAdapter {
|
||||
@@ -129,6 +130,19 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
// Determine if this is a group/channel (not a DM)
|
||||
// DMs have channel IDs starting with 'D', channels start with 'C'
|
||||
const isGroup = !channelId.startsWith('D');
|
||||
|
||||
// Group gating: config-based allowlist + mention requirement
|
||||
if (isGroup && this.config.groups) {
|
||||
if (!this.isChannelAllowed(channelId)) {
|
||||
return; // Channel not in allowlist -- silent drop
|
||||
}
|
||||
const requireMention = this.resolveRequireMention(channelId);
|
||||
if (requireMention) {
|
||||
// Non-mention message in channel that requires mentions.
|
||||
// The app_mention handler will process actual @mentions.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.onMessage({
|
||||
channel: 'slack',
|
||||
@@ -160,6 +174,11 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Group gating: allowlist check (mention already satisfied by app_mention)
|
||||
if (this.config.groups && !this.isChannelAllowed(channelId)) {
|
||||
return; // Channel not in allowlist -- silent drop
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
const command = parseCommand(text);
|
||||
@@ -285,6 +304,24 @@ export class SlackAdapter implements ChannelAdapter {
|
||||
return this.config.dmPolicy || 'pairing';
|
||||
}
|
||||
|
||||
/** Check if a channel is allowed by the groups config allowlist */
|
||||
private isChannelAllowed(channelId: string): boolean {
|
||||
const groups = this.config.groups;
|
||||
if (!groups) return true;
|
||||
const allowlistEnabled = Object.keys(groups).length > 0;
|
||||
if (!allowlistEnabled) return true;
|
||||
return Object.hasOwn(groups, '*') || Object.hasOwn(groups, channelId);
|
||||
}
|
||||
|
||||
/** Resolve requireMention for a channel (specific > wildcard > default true) */
|
||||
private resolveRequireMention(channelId: string): boolean {
|
||||
const groups = this.config.groups;
|
||||
if (!groups) return true;
|
||||
const groupConfig = groups[channelId];
|
||||
const wildcardConfig = groups['*'];
|
||||
return groupConfig?.requireMention ?? wildcardConfig?.requireMention ?? true;
|
||||
}
|
||||
|
||||
async sendTypingIndicator(_chatId: string): Promise<void> {
|
||||
// Slack doesn't have a typing indicator API for bots
|
||||
// This is a no-op
|
||||
|
||||
193
src/channels/telegram-group-gating.test.ts
Normal file
193
src/channels/telegram-group-gating.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyTelegramGroupGating, type TelegramGroupGatingParams } from './telegram-group-gating.js';
|
||||
|
||||
function createParams(overrides: Partial<TelegramGroupGatingParams> = {}): TelegramGroupGatingParams {
|
||||
return {
|
||||
text: 'Hello everyone',
|
||||
chatId: '-1001234567890',
|
||||
botUsername: 'mybot',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyTelegramGroupGating', () => {
|
||||
describe('group allowlist', () => {
|
||||
it('allows group when in allowlist', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
groupsConfig: {
|
||||
'-1001234567890': { requireMention: false },
|
||||
},
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it('allows group via wildcard', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
groupsConfig: {
|
||||
'*': { requireMention: false },
|
||||
},
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks group not in allowlist', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
groupsConfig: {
|
||||
'-100999999': { requireMention: false },
|
||||
},
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('group-not-in-allowlist');
|
||||
});
|
||||
|
||||
it('allows all groups when no groupsConfig provided', () => {
|
||||
// No config = no allowlist filtering (mention gating still applies by default)
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello',
|
||||
groupsConfig: undefined,
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireMention', () => {
|
||||
it('defaults to requiring mention when not specified', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello everyone',
|
||||
groupsConfig: { '*': {} }, // No requireMention specified
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('mention-required');
|
||||
});
|
||||
|
||||
it('allows all messages when requireMention is false', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello everyone',
|
||||
groupsConfig: { '*': { requireMention: false } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
});
|
||||
|
||||
it('specific group config overrides wildcard', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello',
|
||||
groupsConfig: {
|
||||
'*': { requireMention: true },
|
||||
'-1001234567890': { requireMention: false },
|
||||
},
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard applies when no specific group config', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello',
|
||||
chatId: '-100999999',
|
||||
groupsConfig: {
|
||||
'*': { requireMention: true },
|
||||
'-1001234567890': { requireMention: false },
|
||||
},
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('mention-required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mention detection', () => {
|
||||
it('detects @username via entities (most reliable)', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello!',
|
||||
entities: [{ type: 'mention', offset: 0, length: 6 }],
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.method).toBe('entity');
|
||||
});
|
||||
|
||||
it('detects @username via text fallback (case-insensitive)', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'Hey @MyBot what do you think?',
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.method).toBe('text');
|
||||
});
|
||||
|
||||
it('detects /command@botusername format', () => {
|
||||
// Use a bot username that won't match the simpler text fallback first
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '/status@testbot_123',
|
||||
botUsername: 'testbot_123',
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
// Note: text fallback (@testbot_123) catches this before command format check
|
||||
// Both methods detect the mention -- the important thing is it's detected
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it('detects mention via regex patterns', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hey bot, what do you think?',
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
mentionPatterns: ['\\bhey bot\\b'],
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
expect(result.method).toBe('regex');
|
||||
});
|
||||
|
||||
it('rejects when no mention detected and requireMention is true', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello everyone',
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.wasMentioned).toBe(false);
|
||||
expect(result.reason).toBe('mention-required');
|
||||
});
|
||||
|
||||
it('ignores invalid regex patterns without crashing', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello',
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
mentionPatterns: ['[invalid'],
|
||||
}));
|
||||
// Falls through to text-based detection
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.method).toBe('text');
|
||||
});
|
||||
|
||||
it('entity mention for a different user does not match', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@otheruser hello',
|
||||
entities: [{ type: 'mention', offset: 0, length: 10 }],
|
||||
groupsConfig: { '*': { requireMention: true } },
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('mention-required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('no groupsConfig (open mode)', () => {
|
||||
it('processes messages with mention when no config (default requireMention=true)', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: '@mybot hello',
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(true);
|
||||
expect(result.wasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects messages without mention when no config (default requireMention=true)', () => {
|
||||
const result = applyTelegramGroupGating(createParams({
|
||||
text: 'hello everyone',
|
||||
}));
|
||||
expect(result.shouldProcess).toBe(false);
|
||||
expect(result.reason).toBe('mention-required');
|
||||
});
|
||||
});
|
||||
});
|
||||
156
src/channels/telegram-group-gating.ts
Normal file
156
src/channels/telegram-group-gating.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Telegram Group Gating
|
||||
*
|
||||
* Filters group messages based on a config-based allowlist and mention detection.
|
||||
* Follows the same pattern as Signal (`signal/group-gating.ts`) and WhatsApp
|
||||
* (`whatsapp/inbound/group-gating.ts`).
|
||||
*
|
||||
* This layer runs AFTER the pairing-based group approval middleware.
|
||||
* The pairing system controls "can this group access the bot at all?"
|
||||
* while this config layer controls "which approved groups does the bot
|
||||
* actively participate in?"
|
||||
*/
|
||||
|
||||
export interface TelegramGroupGatingParams {
|
||||
/** Message text */
|
||||
text: string;
|
||||
|
||||
/** Group chat ID (negative number as string) */
|
||||
chatId: string;
|
||||
|
||||
/** Bot's @username (without the @) */
|
||||
botUsername: string;
|
||||
|
||||
/** Telegram message entities (for structured mention detection) */
|
||||
entities?: { type: string; offset: number; length: number }[];
|
||||
|
||||
/** Per-group configuration */
|
||||
groupsConfig?: Record<string, { requireMention?: boolean }>;
|
||||
|
||||
/** Regex patterns for additional mention detection */
|
||||
mentionPatterns?: string[];
|
||||
}
|
||||
|
||||
export interface TelegramGroupGatingResult {
|
||||
/** Whether the message should be processed */
|
||||
shouldProcess: boolean;
|
||||
|
||||
/** Whether bot was mentioned */
|
||||
wasMentioned?: boolean;
|
||||
|
||||
/** Detection method used */
|
||||
method?: 'entity' | 'text' | 'command' | 'regex';
|
||||
|
||||
/** Reason for filtering (if shouldProcess=false) */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply group-specific gating logic for Telegram messages.
|
||||
*
|
||||
* Detection methods (in priority order):
|
||||
* 1. Entity-based @username mentions (most reliable)
|
||||
* 2. Text-based @username fallback
|
||||
* 3. /command@username format (Telegram bot command convention)
|
||||
* 4. Regex patterns from config
|
||||
*
|
||||
* @example
|
||||
* const result = applyTelegramGroupGating({
|
||||
* text: '@mybot hello!',
|
||||
* chatId: '-1001234567890',
|
||||
* botUsername: 'mybot',
|
||||
* groupsConfig: { '*': { requireMention: true } },
|
||||
* });
|
||||
*
|
||||
* if (!result.shouldProcess) return;
|
||||
*/
|
||||
export function applyTelegramGroupGating(params: TelegramGroupGatingParams): TelegramGroupGatingResult {
|
||||
const { text, chatId, botUsername, entities, groupsConfig, mentionPatterns } = params;
|
||||
|
||||
// Step 1: Group allowlist
|
||||
const groups = groupsConfig ?? {};
|
||||
const allowlistEnabled = Object.keys(groups).length > 0;
|
||||
|
||||
if (allowlistEnabled) {
|
||||
const hasWildcard = Object.hasOwn(groups, '*');
|
||||
const hasSpecific = Object.hasOwn(groups, chatId);
|
||||
|
||||
if (!hasWildcard && !hasSpecific) {
|
||||
return {
|
||||
shouldProcess: false,
|
||||
reason: 'group-not-in-allowlist',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Resolve requireMention setting (default: true)
|
||||
// Priority: specific group > wildcard > default true
|
||||
const groupConfig = groups[chatId];
|
||||
const wildcardConfig = groups['*'];
|
||||
const requireMention =
|
||||
groupConfig?.requireMention ??
|
||||
wildcardConfig?.requireMention ??
|
||||
true; // Default: require mention for safety
|
||||
|
||||
// If requireMention is false, allow all messages from this group
|
||||
if (!requireMention) {
|
||||
return {
|
||||
shouldProcess: true,
|
||||
wasMentioned: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Detect mentions
|
||||
|
||||
// METHOD 1: Telegram entity-based mention detection (most reliable)
|
||||
if (entities && entities.length > 0 && botUsername) {
|
||||
const mentioned = entities.some((e) => {
|
||||
if (e.type === 'mention') {
|
||||
const mentionedText = text.substring(e.offset, e.offset + e.length);
|
||||
return mentionedText.toLowerCase() === `@${botUsername.toLowerCase()}`;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (mentioned) {
|
||||
return { shouldProcess: true, wasMentioned: true, method: 'entity' };
|
||||
}
|
||||
}
|
||||
|
||||
// METHOD 2: Text-based @username fallback
|
||||
if (botUsername) {
|
||||
const usernameRegex = new RegExp(`@${botUsername}\\b`, 'i');
|
||||
if (usernameRegex.test(text)) {
|
||||
return { shouldProcess: true, wasMentioned: true, method: 'text' };
|
||||
}
|
||||
}
|
||||
|
||||
// METHOD 3: /command@botusername format (Telegram convention)
|
||||
if (botUsername) {
|
||||
const commandRegex = new RegExp(`^/\\w+@${botUsername}\\b`, 'i');
|
||||
if (commandRegex.test(text.trim())) {
|
||||
return { shouldProcess: true, wasMentioned: true, method: 'command' };
|
||||
}
|
||||
}
|
||||
|
||||
// METHOD 4: Regex patterns from config
|
||||
if (mentionPatterns && mentionPatterns.length > 0) {
|
||||
for (const pattern of mentionPatterns) {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i');
|
||||
if (regex.test(text)) {
|
||||
return { shouldProcess: true, wasMentioned: true, method: 'regex' };
|
||||
}
|
||||
} catch {
|
||||
// Invalid pattern -- skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No mention detected and mention required -- skip this message
|
||||
return {
|
||||
shouldProcess: false,
|
||||
wasMentioned: false,
|
||||
reason: 'mention-required',
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { isGroupApproved, approveGroup } from '../pairing/group-store.js';
|
||||
import { basename } from 'node:path';
|
||||
import { buildAttachmentPath, downloadToFile } from './attachments.js';
|
||||
import { applyTelegramGroupGating } from './telegram-group-gating.js';
|
||||
|
||||
export interface TelegramConfig {
|
||||
token: string;
|
||||
@@ -24,6 +25,8 @@ export interface TelegramConfig {
|
||||
allowedUsers?: number[]; // Telegram user IDs (config allowlist)
|
||||
attachmentsDir?: string;
|
||||
attachmentsMaxBytes?: number;
|
||||
mentionPatterns?: string[]; // Regex patterns for mention detection
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings
|
||||
}
|
||||
|
||||
export class TelegramAdapter implements ChannelAdapter {
|
||||
@@ -50,6 +53,61 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
this.setupHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply group gating for a message context.
|
||||
* Returns null if the message should be dropped, or { isGroup, groupName, wasMentioned } if it should proceed.
|
||||
*/
|
||||
private applyGroupGating(ctx: { chat: { type: string; id: number; title?: string }; message?: { text?: string; entities?: { type: string; offset: number; length: number }[] } }): { isGroup: boolean; groupName?: string; wasMentioned: boolean } | null {
|
||||
const chatType = ctx.chat.type;
|
||||
const isGroup = chatType === 'group' || chatType === 'supergroup';
|
||||
const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
return { isGroup: false, wasMentioned: false };
|
||||
}
|
||||
|
||||
const text = ctx.message?.text || '';
|
||||
const botUsername = this.bot.botInfo?.username || '';
|
||||
|
||||
if (this.config.groups) {
|
||||
const gatingResult = applyTelegramGroupGating({
|
||||
text,
|
||||
chatId: String(ctx.chat.id),
|
||||
botUsername,
|
||||
entities: ctx.message?.entities?.map(e => ({
|
||||
type: e.type,
|
||||
offset: e.offset,
|
||||
length: e.length,
|
||||
})),
|
||||
groupsConfig: this.config.groups,
|
||||
mentionPatterns: this.config.mentionPatterns,
|
||||
});
|
||||
|
||||
if (!gatingResult.shouldProcess) {
|
||||
console.log(`[Telegram] Group message filtered: ${gatingResult.reason}`);
|
||||
return null;
|
||||
}
|
||||
return { isGroup, groupName, wasMentioned: gatingResult.wasMentioned ?? false };
|
||||
}
|
||||
|
||||
// No groups config: detect mentions for batcher (no gating)
|
||||
let wasMentioned = false;
|
||||
if (botUsername) {
|
||||
const entities = ctx.message?.entities || [];
|
||||
wasMentioned = entities.some((e) => {
|
||||
if (e.type === 'mention') {
|
||||
const mentioned = text.substring(e.offset, e.offset + e.length);
|
||||
return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!wasMentioned) {
|
||||
wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
return { isGroup, groupName, wasMentioned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is authorized based on dmPolicy
|
||||
* Returns true if allowed, false if blocked, 'pairing' if pending pairing
|
||||
@@ -214,31 +272,10 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
if (!userId) return;
|
||||
if (text.startsWith('/')) return; // Skip other commands
|
||||
|
||||
// Group detection
|
||||
const chatType = ctx.chat.type;
|
||||
const isGroup = chatType === 'group' || chatType === 'supergroup';
|
||||
const groupName = isGroup && 'title' in ctx.chat ? ctx.chat.title : undefined;
|
||||
|
||||
// Mention detection for groups
|
||||
let wasMentioned = false;
|
||||
if (isGroup) {
|
||||
const botUsername = this.bot.botInfo?.username;
|
||||
if (botUsername) {
|
||||
// Check entities for bot_command or mention matching our username
|
||||
const entities = ctx.message.entities || [];
|
||||
wasMentioned = entities.some((e) => {
|
||||
if (e.type === 'mention') {
|
||||
const mentioned = text.substring(e.offset, e.offset + e.length);
|
||||
return mentioned.toLowerCase() === `@${botUsername.toLowerCase()}`;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// Fallback: text-based check
|
||||
if (!wasMentioned) {
|
||||
wasMentioned = text.toLowerCase().includes(`@${botUsername.toLowerCase()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Group gating (runs AFTER pairing middleware)
|
||||
const gating = this.applyGroupGating(ctx);
|
||||
if (!gating) return; // Filtered by group gating
|
||||
const { isGroup, groupName, wasMentioned } = gating;
|
||||
|
||||
if (this.onMessage) {
|
||||
await this.onMessage({
|
||||
@@ -309,6 +346,11 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
// Group gating
|
||||
const gating = this.applyGroupGating(ctx);
|
||||
if (!gating) return;
|
||||
const { isGroup, groupName, wasMentioned } = gating;
|
||||
|
||||
// Check if transcription is configured (config or env)
|
||||
const { loadConfig } = await import('../config/index.js');
|
||||
const config = loadConfig();
|
||||
@@ -350,6 +392,9 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
messageId: String(ctx.message.message_id),
|
||||
text: messageText,
|
||||
timestamp: new Date(),
|
||||
isGroup,
|
||||
groupName,
|
||||
wasMentioned,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -364,6 +409,9 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
messageId: String(ctx.message.message_id),
|
||||
text: `[Voice message - error: ${error instanceof Error ? error.message : 'unknown error'}]`,
|
||||
timestamp: new Date(),
|
||||
isGroup,
|
||||
groupName,
|
||||
wasMentioned,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -376,6 +424,11 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
const chatId = ctx.chat.id;
|
||||
if (!userId) return;
|
||||
|
||||
// Group gating
|
||||
const gating = this.applyGroupGating(ctx);
|
||||
if (!gating) return;
|
||||
const { isGroup, groupName, wasMentioned } = gating;
|
||||
|
||||
const { attachments, caption } = await this.collectAttachments(ctx.message, String(chatId));
|
||||
if (attachments.length === 0 && !caption) return;
|
||||
|
||||
@@ -388,6 +441,9 @@ export class TelegramAdapter implements ChannelAdapter {
|
||||
messageId: String(ctx.message.message_id),
|
||||
text: caption || '',
|
||||
timestamp: new Date(),
|
||||
isGroup,
|
||||
groupName,
|
||||
wasMentioned,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ export interface TelegramConfig {
|
||||
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
|
||||
instantGroups?: string[]; // Group chat IDs that bypass batching
|
||||
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
|
||||
mentionPatterns?: string[]; // Regex patterns for mention detection (e.g., ["@mybot"])
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-group settings, "*" for defaults
|
||||
}
|
||||
|
||||
export interface SlackConfig {
|
||||
@@ -166,6 +168,7 @@ export interface SlackConfig {
|
||||
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
|
||||
instantGroups?: string[]; // Channel IDs that bypass batching
|
||||
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-channel settings, "*" for defaults
|
||||
}
|
||||
|
||||
export interface WhatsAppConfig {
|
||||
@@ -207,6 +210,7 @@ export interface DiscordConfig {
|
||||
groupPollIntervalMin?: number; // @deprecated Use groupDebounceSec instead
|
||||
instantGroups?: string[]; // Guild/server IDs or channel IDs that bypass batching
|
||||
listeningGroups?: string[]; // Group IDs where bot only observes (replies only when mentioned)
|
||||
groups?: Record<string, { requireMention?: boolean }>; // Per-guild/channel settings, "*" for defaults
|
||||
}
|
||||
|
||||
export interface GoogleAccountConfig {
|
||||
|
||||
@@ -276,6 +276,8 @@ function createChannelsForAgent(
|
||||
: undefined,
|
||||
attachmentsDir,
|
||||
attachmentsMaxBytes,
|
||||
groups: agentConfig.channels.telegram.groups,
|
||||
mentionPatterns: agentConfig.channels.telegram.mentionPatterns,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -289,6 +291,7 @@ function createChannelsForAgent(
|
||||
: undefined,
|
||||
attachmentsDir,
|
||||
attachmentsMaxBytes,
|
||||
groups: agentConfig.channels.slack.groups,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -340,6 +343,7 @@ function createChannelsForAgent(
|
||||
: undefined,
|
||||
attachmentsDir,
|
||||
attachmentsMaxBytes,
|
||||
groups: agentConfig.channels.discord.groups,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user