@ihazz/bitrix24 1.0.3 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.ts CHANGED
@@ -43,9 +43,9 @@ const COMMAND_GROUP_LABELS = {
43
43
  export const OPENCLAW_COMMANDS: BotCommandDef[] = [
44
44
  // ── Status ──
45
45
  { command: 'help', en: 'Show available commands', ru: 'Показать доступные команды', group: 'status' },
46
- { command: 'commands', en: 'List all slash commands', ru: 'Список всех команд', group: 'status' },
46
+ { command: 'commands', en: 'List all slash commands', ru: 'Показать все команды', group: 'status' },
47
47
  { command: 'status', en: 'Show current status', ru: 'Показать текущий статус', group: 'status' },
48
- { command: 'context', en: 'Explain how context is built', ru: 'Объяснить построение контекста', group: 'status' },
48
+ { command: 'context', en: 'Explain how context is built', ru: 'Объяснить, как формируется контекст', group: 'status' },
49
49
  { command: 'whoami', en: 'Show your sender ID', ru: 'Показать ваш ID', group: 'status' },
50
50
  { command: 'usage', en: 'Usage and cost summary', ru: 'Использование и стоимость', params: 'off | tokens | full | cost', group: 'status' },
51
51
 
@@ -57,8 +57,8 @@ export const OPENCLAW_COMMANDS: BotCommandDef[] = [
57
57
  { command: 'session', en: 'Manage session settings', ru: 'Настройки сессии', params: 'ttl | ...', group: 'session' },
58
58
 
59
59
  // ── Options ──
60
- { command: 'model', en: 'Show or set the model', ru: 'Показать/сменить модель', params: 'model name', group: 'options' },
61
- { command: 'models', en: 'List available models', ru: 'Список моделей', params: 'provider', group: 'options' },
60
+ { command: 'model', en: 'Show or set the model', ru: 'Показать или сменить модель', params: 'model name', group: 'options' },
61
+ { command: 'models', en: 'List available models', ru: 'Показать модели', params: 'provider', group: 'options' },
62
62
  { command: 'think', en: 'Set thinking level', ru: 'Уровень размышлений', params: 'off | low | medium | high', group: 'options' },
63
63
  { command: 'verbose', en: 'Toggle verbose mode', ru: 'Подробный режим', params: 'on | off', group: 'options' },
64
64
  { command: 'reasoning', en: 'Toggle reasoning visibility', ru: 'Видимость рассуждений', params: 'on | off | stream', group: 'options' },
@@ -67,9 +67,9 @@ export const OPENCLAW_COMMANDS: BotCommandDef[] = [
67
67
  { command: 'queue', en: 'Adjust queue settings', ru: 'Настройки очереди', params: 'mode | debounce | cap | drop', group: 'options' },
68
68
 
69
69
  // ── Management ──
70
- { command: 'config', en: 'Show or set config values', ru: 'Показать/задать конфигурацию', params: 'show | get | set | unset', group: 'management' },
70
+ { command: 'config', en: 'Show or set config values', ru: 'Показать или изменить конфигурацию', params: 'show | get | set | unset', group: 'management' },
71
71
  { command: 'debug', en: 'Set runtime debug overrides', ru: 'Отладочные настройки', params: 'show | reset | set | unset', group: 'management' },
72
- { command: 'approve', en: 'Approve or deny exec requests', ru: 'Одобрить/отклонить запросы', group: 'management' },
72
+ { command: 'approve', en: 'Approve or deny exec requests', ru: 'Одобрить или отклонить запросы', group: 'management' },
73
73
  { command: 'activation', en: 'Set group activation mode', ru: 'Режим активации в группах', params: 'mention | always', group: 'management' },
74
74
  { command: 'send', en: 'Set send policy', ru: 'Политика отправки', params: 'on | off | inherit', group: 'management' },
75
75
  { command: 'subagents', en: 'Manage subagent runs', ru: 'Управление субагентами', group: 'management' },
@@ -153,7 +153,7 @@ export function formatModelsCommandReply(
153
153
  const isRu = lang?.toLowerCase().startsWith('ru') ?? false;
154
154
  const header = isRu ? '[B]Провайдеры[/B]' : '[B]Providers[/B]';
155
155
  const showModelsLabel = isRu
156
- ? `[COLOR=${COMMAND_META_COLOR}]Посмотреть модели провайдера:[/COLOR]`
156
+ ? `[COLOR=${COMMAND_META_COLOR}]Модели провайдера:[/COLOR]`
157
157
  : `[COLOR=${COMMAND_META_COLOR}]Show provider models:[/COLOR]`;
158
158
  const switchModelLabel = isRu
159
159
  ? `[COLOR=${COMMAND_META_COLOR}]Сменить модель:[/COLOR]`
@@ -1,5 +1,22 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ const GroupSchema = z.object({
4
+ groupPolicy: z.enum(['disabled', 'webhookUser', 'pairing', 'allowlist', 'open']).optional(),
5
+ requireMention: z.boolean().optional().default(true),
6
+ allowFrom: z.array(z.string()).optional(),
7
+ watch: z.array(z.object({
8
+ userId: z.string().min(1),
9
+ topics: z.array(z.string().min(1)).optional(),
10
+ mode: z.enum(['reply', 'notifyOwnerDm']).optional(),
11
+ })).optional(),
12
+ });
13
+
14
+ const AgentWatchRuleSchema = z.object({
15
+ userId: z.string().min(1),
16
+ topics: z.array(z.string().min(1)).optional(),
17
+ mode: z.enum(['notifyOwnerDm']).optional(),
18
+ });
19
+
3
20
  const AccountSchema = z.object({
4
21
  enabled: z.boolean().optional().default(true),
5
22
  webhookUrl: z.string().url().optional(),
@@ -13,7 +30,13 @@ const AccountSchema = z.object({
13
30
  agentMode: z.boolean().optional().default(false),
14
31
  pollingIntervalMs: z.number().int().min(500).optional().default(3000),
15
32
  pollingFastIntervalMs: z.number().int().min(50).optional().default(100),
16
- dmPolicy: z.enum(['pairing', 'webhookUser']).optional().default('webhookUser'),
33
+ dmPolicy: z.enum(['pairing', 'webhookUser', 'allowlist', 'open']).optional().default('webhookUser'),
34
+ groupPolicy: z.enum(['disabled', 'webhookUser', 'pairing', 'allowlist', 'open']).optional().default('webhookUser'),
35
+ groupAllowFrom: z.array(z.string()).optional(),
36
+ requireMention: z.boolean().optional().default(true),
37
+ historyLimit: z.number().int().min(0).optional().default(100),
38
+ groups: z.record(z.string(), GroupSchema).optional(),
39
+ agentWatch: z.record(z.string(), z.array(AgentWatchRuleSchema)).optional(),
17
40
  showTyping: z.boolean().optional().default(true),
18
41
  streamUpdates: z.boolean().optional().default(false),
19
42
  updateIntervalMs: z.number().int().min(500).optional().default(10000),
package/src/config.ts CHANGED
@@ -51,15 +51,17 @@ export function resolveAccount(
51
51
  enabled: boolean;
52
52
  } {
53
53
  const id = accountId || DEFAULT_ACCOUNT_ID;
54
- const config = getConfig(cfg, id);
54
+ let config = getConfig(cfg, id);
55
55
 
56
- // Validate config against Zod schema (validation only, no transform)
56
+ // Validate and normalize config against Zod schema.
57
57
  if (config.webhookUrl) {
58
58
  const result = Bitrix24ConfigSchema.safeParse(config);
59
59
  if (!result.success) {
60
60
  const issues = result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ');
61
61
  throw new Error(`[bitrix24] Invalid config for account "${id}": ${issues}`);
62
62
  }
63
+
64
+ config = result.data;
63
65
  }
64
66
 
65
67
  return {
@@ -0,0 +1,279 @@
1
+ import type {
2
+ Bitrix24AccountConfig,
3
+ Bitrix24AgentWatchConfig,
4
+ Bitrix24GroupConfig,
5
+ Bitrix24GroupPolicy,
6
+ Bitrix24GroupWatchConfig,
7
+ } from './types.js';
8
+ import type { PluginRuntime, ChannelPairingAdapter } from './runtime.js';
9
+ import type { AccessResult } from './access-control.js';
10
+ import {
11
+ getWebhookUserId,
12
+ isIdentityAllowed,
13
+ normalizeAllowEntry,
14
+ normalizeAllowList,
15
+ } from './access-control.js';
16
+
17
+ export interface ResolvedBitrix24GroupAccess {
18
+ groupPolicy: Bitrix24GroupPolicy;
19
+ requireMention: boolean;
20
+ senderAllowFrom: string[];
21
+ watch: Bitrix24GroupWatchConfig[];
22
+ matchedGroupKey?: string;
23
+ matchedGroupConfig?: Bitrix24GroupConfig;
24
+ groupAllowed: boolean;
25
+ }
26
+
27
+ function normalizeWatchRules<T extends { userId: string; topics?: string[]; mode?: string }>(
28
+ rules: T[] | undefined,
29
+ ): T[] {
30
+ return (rules ?? []).map((rule) => ({
31
+ ...rule,
32
+ userId: normalizeAllowEntry(String(rule.userId)),
33
+ topics: Array.isArray(rule.topics)
34
+ ? rule.topics.map((topic) => String(topic).trim()).filter(Boolean)
35
+ : undefined,
36
+ }));
37
+ }
38
+
39
+ export function normalizeGroupEntry(entry: string): string {
40
+ const normalized = String(entry).trim().toLowerCase();
41
+ if (/^\d+$/.test(normalized)) {
42
+ return normalized;
43
+ }
44
+ if (/^chat\d+$/.test(normalized)) {
45
+ return normalized;
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ export function normalizeGroupAllowList(entries: string[] | undefined): string[] {
51
+ if (!Array.isArray(entries)) {
52
+ return [];
53
+ }
54
+
55
+ return [...new Set(
56
+ entries
57
+ .map((entry) => normalizeGroupEntry(String(entry)))
58
+ .filter(Boolean),
59
+ )];
60
+ }
61
+
62
+ export function buildGroupMatchKeys(params: {
63
+ dialogId?: string;
64
+ chatId?: string;
65
+ }): string[] {
66
+ const keys: string[] = [];
67
+ const normalizedDialogId = params.dialogId ? normalizeGroupEntry(params.dialogId) : '';
68
+ const normalizedChatId = params.chatId ? normalizeGroupEntry(params.chatId) : '';
69
+
70
+ if (normalizedDialogId) {
71
+ keys.push(normalizedDialogId);
72
+ const dialogMatch = normalizedDialogId.match(/^chat(\d+)$/);
73
+ if (dialogMatch) {
74
+ keys.push(dialogMatch[1]);
75
+ }
76
+ }
77
+
78
+ if (normalizedChatId) {
79
+ keys.push(normalizedChatId);
80
+ if (/^\d+$/.test(normalizedChatId)) {
81
+ keys.push(`chat${normalizedChatId}`);
82
+ }
83
+ }
84
+
85
+ return [...new Set(keys)];
86
+ }
87
+
88
+ export function resolveGroupAccess(params: {
89
+ config: Bitrix24AccountConfig;
90
+ dialogId?: string;
91
+ chatId?: string;
92
+ }): ResolvedBitrix24GroupAccess {
93
+ const matchKeys = buildGroupMatchKeys(params);
94
+ const groups = params.config.groups ?? {};
95
+ const wildcardGroupConfig = groups['*'];
96
+ let matchedGroupKey: string | undefined;
97
+ let matchedGroupConfig: Bitrix24GroupConfig | undefined;
98
+
99
+ for (const key of matchKeys) {
100
+ if (groups[key]) {
101
+ matchedGroupKey = key;
102
+ matchedGroupConfig = groups[key];
103
+ break;
104
+ }
105
+ }
106
+
107
+ if (!matchedGroupConfig && wildcardGroupConfig) {
108
+ matchedGroupKey = '*';
109
+ matchedGroupConfig = wildcardGroupConfig;
110
+ }
111
+
112
+ const explicitGroupMatched = Boolean(
113
+ matchedGroupKey
114
+ && matchedGroupKey !== '*'
115
+ && matchedGroupConfig,
116
+ );
117
+ const groupAllowFrom = normalizeGroupAllowList(params.config.groupAllowFrom);
118
+ const groupAllowed = groupAllowFrom.length === 0
119
+ || explicitGroupMatched
120
+ || matchKeys.some((key) => groupAllowFrom.includes(key));
121
+
122
+ const watchRules = [
123
+ ...(matchedGroupKey && matchedGroupKey !== '*' ? (matchedGroupConfig?.watch ?? []) : []),
124
+ ...(wildcardGroupConfig?.watch ?? []),
125
+ ];
126
+
127
+ return {
128
+ groupPolicy: matchedGroupConfig?.groupPolicy ?? params.config.groupPolicy ?? 'webhookUser',
129
+ requireMention: matchedGroupConfig?.requireMention ?? params.config.requireMention ?? true,
130
+ senderAllowFrom: normalizeAllowList([
131
+ ...(params.config.allowFrom ?? []),
132
+ ...(matchedGroupConfig?.allowFrom ?? []),
133
+ ]),
134
+ watch: normalizeWatchRules(watchRules).map((rule) => ({
135
+ ...rule,
136
+ mode: rule.mode === 'notifyOwnerDm' ? 'notifyOwnerDm' : 'reply',
137
+ })),
138
+ matchedGroupKey,
139
+ matchedGroupConfig,
140
+ groupAllowed,
141
+ };
142
+ }
143
+
144
+ export function resolveAgentWatchRules(params: {
145
+ config: Bitrix24AccountConfig;
146
+ dialogId?: string;
147
+ chatId?: string;
148
+ }): Bitrix24AgentWatchConfig[] {
149
+ const matchKeys = buildGroupMatchKeys(params);
150
+ const scopes = params.config.agentWatch ?? {};
151
+ const resolvedRules: Bitrix24AgentWatchConfig[] = [];
152
+
153
+ for (const key of matchKeys) {
154
+ if (scopes[key]) {
155
+ resolvedRules.push(...normalizeWatchRules(scopes[key]).map((rule) => ({
156
+ ...rule,
157
+ mode: 'notifyOwnerDm' as const,
158
+ })));
159
+ break;
160
+ }
161
+ }
162
+
163
+ if (scopes['*']) {
164
+ resolvedRules.push(...normalizeWatchRules(scopes['*']).map((rule) => ({
165
+ ...rule,
166
+ mode: 'notifyOwnerDm' as const,
167
+ })));
168
+ }
169
+
170
+ return resolvedRules;
171
+ }
172
+
173
+ export async function checkGroupAccessPassive(params: {
174
+ senderId: string;
175
+ dialogId?: string;
176
+ chatId?: string;
177
+ config: Bitrix24AccountConfig;
178
+ runtime: PluginRuntime;
179
+ accountId: string;
180
+ logger?: { debug: (...args: unknown[]) => void };
181
+ }): Promise<AccessResult> {
182
+ const {
183
+ senderId,
184
+ dialogId,
185
+ chatId,
186
+ config,
187
+ runtime,
188
+ accountId,
189
+ logger,
190
+ } = params;
191
+ const identity = normalizeAllowEntry(String(senderId));
192
+ const group = resolveGroupAccess({ config, dialogId, chatId });
193
+
194
+ if (!group.groupAllowed || group.groupPolicy === 'disabled') {
195
+ logger?.debug('Group passive access denied (group disabled or not allowed)', {
196
+ senderId,
197
+ dialogId,
198
+ chatId,
199
+ groupAllowed: group.groupAllowed,
200
+ groupPolicy: group.groupPolicy,
201
+ });
202
+ return 'deny';
203
+ }
204
+
205
+ if (group.groupPolicy === 'open') {
206
+ return 'allow';
207
+ }
208
+
209
+ if (group.groupPolicy === 'webhookUser') {
210
+ const webhookUserId = getWebhookUserId(config.webhookUrl);
211
+ return webhookUserId && webhookUserId === identity ? 'allow' : 'deny';
212
+ }
213
+
214
+ if (group.groupPolicy === 'allowlist') {
215
+ return isIdentityAllowed(identity, group.senderAllowFrom) ? 'allow' : 'deny';
216
+ }
217
+
218
+ const storeAllowFrom = await runtime.channel.pairing.readAllowFromStore('bitrix24', '', accountId);
219
+ const approved = [...new Set([
220
+ ...group.senderAllowFrom,
221
+ ...normalizeAllowList(storeAllowFrom),
222
+ ])];
223
+
224
+ return approved.includes(identity) ? 'allow' : 'pairing';
225
+ }
226
+
227
+ export async function checkGroupAccessWithPairing(params: {
228
+ senderId: string;
229
+ dialogId?: string;
230
+ chatId?: string;
231
+ config: Bitrix24AccountConfig;
232
+ runtime: PluginRuntime;
233
+ accountId: string;
234
+ pairingAdapter: ChannelPairingAdapter;
235
+ logger?: { debug: (...args: unknown[]) => void };
236
+ }): Promise<AccessResult> {
237
+ const passiveResult = await checkGroupAccessPassive({
238
+ senderId: params.senderId,
239
+ dialogId: params.dialogId,
240
+ chatId: params.chatId,
241
+ config: params.config,
242
+ runtime: params.runtime,
243
+ accountId: params.accountId,
244
+ logger: params.logger,
245
+ });
246
+ if (passiveResult !== 'pairing') {
247
+ return passiveResult;
248
+ }
249
+
250
+ const identity = normalizeAllowEntry(String(params.senderId));
251
+ const group = resolveGroupAccess({
252
+ config: params.config,
253
+ dialogId: params.dialogId,
254
+ chatId: params.chatId,
255
+ });
256
+
257
+ try {
258
+ await params.runtime.channel.pairing.upsertPairingRequest({
259
+ channel: 'bitrix24',
260
+ id: identity,
261
+ accountId: params.accountId,
262
+ meta: {
263
+ dialogId: params.dialogId,
264
+ chatId: params.chatId,
265
+ scope: 'group',
266
+ },
267
+ pairingAdapter: params.pairingAdapter,
268
+ });
269
+ return 'pairing';
270
+ } catch (err) {
271
+ params.logger?.debug('Group pairing request failed, falling back to deny', {
272
+ senderId: params.senderId,
273
+ dialogId: params.dialogId,
274
+ chatId: params.chatId,
275
+ error: err,
276
+ });
277
+ return 'deny';
278
+ }
279
+ }
@@ -0,0 +1,122 @@
1
+ export interface HistoryEntry {
2
+ messageId: string;
3
+ sender: string;
4
+ senderId?: string;
5
+ body: string;
6
+ timestamp?: number;
7
+ wasMentioned?: boolean;
8
+ eventScope?: 'bot' | 'user';
9
+ }
10
+
11
+ export interface ConversationMeta {
12
+ key: string;
13
+ dialogId: string;
14
+ chatId?: string;
15
+ chatName?: string;
16
+ chatType?: string;
17
+ isGroup?: boolean;
18
+ lastActivityAt?: number;
19
+ }
20
+
21
+ export class HistoryCache {
22
+ private readonly entries = new Map<string, HistoryEntry[]>();
23
+ private readonly conversations = new Map<string, ConversationMeta>();
24
+ private readonly maxKeys: number;
25
+
26
+ constructor(params?: { maxKeys?: number }) {
27
+ this.maxKeys = Math.max(1, params?.maxKeys ?? 1000);
28
+ }
29
+
30
+ append(params: {
31
+ key: string;
32
+ entry: HistoryEntry;
33
+ limit: number;
34
+ meta?: Omit<ConversationMeta, 'key'>;
35
+ }): HistoryEntry[] {
36
+ const key = params.key;
37
+ const limit = Math.max(0, params.limit);
38
+ const currentEntries = this.entries.get(key) ?? [];
39
+ const nextEntries = currentEntries.some((entry) => entry.messageId === params.entry.messageId)
40
+ ? currentEntries
41
+ : [...currentEntries, params.entry];
42
+ const trimmedEntries = limit > 0
43
+ ? nextEntries.slice(-limit)
44
+ : [];
45
+
46
+ if (params.meta) {
47
+ this.conversations.set(key, {
48
+ ...this.conversations.get(key),
49
+ ...params.meta,
50
+ key,
51
+ });
52
+ }
53
+
54
+ this.touch(key, trimmedEntries);
55
+ this.evictIfNeeded();
56
+ return trimmedEntries;
57
+ }
58
+
59
+ get(key: string, limit?: number): HistoryEntry[] {
60
+ const entries = this.entries.get(key) ?? [];
61
+ if (entries.length === 0) {
62
+ return [];
63
+ }
64
+
65
+ this.touch(key, entries);
66
+ if (typeof limit !== 'number' || limit < 0) {
67
+ return [...entries];
68
+ }
69
+
70
+ return entries.slice(-limit);
71
+ }
72
+
73
+ findByMessageId(key: string, messageId: string | undefined): HistoryEntry | undefined {
74
+ if (!messageId) {
75
+ return undefined;
76
+ }
77
+
78
+ const entries = this.entries.get(key);
79
+ if (!entries || entries.length === 0) {
80
+ return undefined;
81
+ }
82
+
83
+ this.touch(key, entries);
84
+ return entries.find((entry) => entry.messageId === messageId);
85
+ }
86
+
87
+ clear(key: string): void {
88
+ this.entries.delete(key);
89
+ this.conversations.delete(key);
90
+ }
91
+
92
+ clearAll(): void {
93
+ this.entries.clear();
94
+ this.conversations.clear();
95
+ }
96
+
97
+ size(): number {
98
+ return this.entries.size;
99
+ }
100
+
101
+ listConversations(): ConversationMeta[] {
102
+ return [...this.entries.keys()]
103
+ .map((key) => this.conversations.get(key))
104
+ .filter((conversation): conversation is ConversationMeta => Boolean(conversation));
105
+ }
106
+
107
+ private touch(key: string, value: HistoryEntry[]): void {
108
+ this.entries.delete(key);
109
+ this.entries.set(key, value);
110
+ }
111
+
112
+ private evictIfNeeded(): void {
113
+ while (this.entries.size > this.maxKeys) {
114
+ const oldestKey = this.entries.keys().next().value;
115
+ if (typeof oldestKey !== 'string') {
116
+ break;
117
+ }
118
+ this.entries.delete(oldestKey);
119
+ this.conversations.delete(oldestKey);
120
+ }
121
+ }
122
+ }