diff --git a/README.md b/README.md index 6f64007..b845e8a 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,16 @@ You can link a private Telegram group to a ride so that participants are automat **Note:** Telegram does not allow bots to add users directly — participants receive a private invite link instead. +### Joining the Ride Group Chat + +Once a group is attached, the ride message displays a notice with instructions. Any participant who has joined the ride can request an invite link by sending the bot a private message: + +``` +/joinchat #rideId +``` + +The bot sends a single-use invite link valid for 24 hours. The command only works if you have joined the ride. + ## Route Support The bot supports route links from: diff --git a/SPECIFICATION.MD b/SPECIFICATION.MD index 28bc04e..5314187 100644 --- a/SPECIFICATION.MD +++ b/SPECIFICATION.MD @@ -124,7 +124,7 @@ All handlers extend **[BaseCommandHandler](cci:2://file:///workspace/src/command - **ListRidesCommandHandler**: Paginated list of user's rides - **ListParticipantsCommandHandler**: List all participants for a specific ride (shows all without truncation, organized by participation state) - **ParticipationHandlers**: Join/thinking/pass ride functionality; triggers group membership sync via `GroupManagementService` when a group is attached to the ride -- **GroupCommandHandler**: `/attach #rideId` (links a group to a ride, posts and pins the ride message, adds existing participants) and `/detach` (unlinks the group); both commands are group-chat-only +- **GroupCommandHandler**: `/attach #rideId` (links a group to a ride, posts and pins the ride message, adds existing participants, updates all existing ride messages), `/detach` (unlinks the group, updates all existing ride messages), and `/joinchat #rideId` (private-only: sends an invite link to the ride's group chat if the user has joined the ride); attach/detach are group-chat-only - **StartCommandHandler**: Welcome message - **HelpCommandHandler**: Multi-page help system - **FromStravaCommandHandler**: Import or update a ride from a Strava club event URL; uses `StravaEventParser` to fetch event data and maps it to ride fields; repeated calls with the same URL by the same user update the existing ride @@ -149,6 +149,7 @@ All handlers extend **[BaseCommandHandler](cci:2://file:///workspace/src/command - Formats ride lists with pagination - Handles date/time formatting with timezone support - **Share line for creators**: Shows "Share this ride: `/shareride #ID`" for ride creators in private chats +- **Group chat line**: When a group is attached to a ride (`ride.groupId` is set), shows a notice with `/joinchat #ID` instructions in all ride messages; line is absent (no extra whitespace) when no group is attached - Groups ride details logically ### **8. Utilities ([/src/utils/](cci:7://file:///workspace/src/utils:0:0-0:0))** diff --git a/docs/changes/joinchat-group-chat-line-specification.md b/docs/changes/joinchat-group-chat-line-specification.md new file mode 100644 index 0000000..b2d15d0 --- /dev/null +++ b/docs/changes/joinchat-group-chat-line-specification.md @@ -0,0 +1,168 @@ +# Specification: /joinchat Command and Group Chat Line in Ride Messages + +## Overview + +This change extends the group attachment feature with two improvements: + +1. **Group chat line in ride messages** — when a group is attached to a ride, all ride messages (in all chats) display a notice telling participants how to join the group chat. The line disappears when the group is detached. Attaching or detaching a group triggers an update of all existing ride messages. + +2. **`/joinchat` command** — a new private-only command that lets a ride participant request an invite link to the ride's group chat directly from the bot. + +--- + +## Ride Message: Group Chat Line + +### When shown + +The line appears **only when `ride.groupId` is set**. When no group is attached, the line is absent with no extra blank lines. + +### Placement + +After the participants section (joined / thinking / not interested), before the footer (`🎫 #Ride #ID`), with one blank line between the participants section and the notice, and one blank line between the notice and the footer: + +``` +🙅 Not interested: 3 + +Join the ride's private group chat: send /joinchat #abc123 to the bot in private messages (only works if you have joined the ride). + +🎫 #Ride #abc123 +``` + +Without a group: + +``` +🙅 Not interested: 3 + +🎫 #Ride #abc123 +``` + +### Template change + +The `templates.ride` i18n key gets a `{groupChatLine}` placeholder inserted between the blank line and `{shareLine}`: + +``` +{groupChatLine}{shareLine}🎫 #Ride #{id}{cancelledInstructions} +``` + +### Formatter logic (`MessageFormatter.formatRideMessage`) + +```javascript +const groupChatLine = ride.groupId + ? `${this.translate('formatter.groupChatLine', { id: ride.id }, language)}\n\n` + : ''; +message = message.replace('{groupChatLine}', groupChatLine); +``` + +### Message update on attach / detach + +- **`/attach`**: after saving `groupId`, all pre-existing ride messages are updated via `RideMessagesService.updateRideMessages`. The new message posted in the group is created with the `groupId` already set, so it immediately shows the notice. +- **`/detach`**: after clearing `groupId`, all ride messages are updated to remove the notice. + +Both updates are best-effort (wrapped in try/catch with logging). + +--- + +## `/joinchat` Command + +### Context + +**Private chat only.** (Registered in `Bot.js` under `privateOnly` commands.) + +### Arguments + +`rideId` — the ride ID, optionally prefixed with `#`. Parsed via the existing `RideMessagesService.extractRideId` utility. + +### Success flow + +1. Parse ride ID from command text +2. Fetch ride by ID — error if not found +3. Check `ride.groupId` is set — error if not +4. Check caller (`ctx.from.id`) is in `ride.participation.joined` — error if not +5. Call `GroupManagementService.addParticipant(ctx.api, ride.groupId, ctx.from.id, ctx.lang, ride.createdBy)` + - Unbans the user (so previously-kicked users can accept a fresh link) + - Creates a single-use invite link (24-hour expiry) + - Sends the link to the user via DM + - If the DM fails with 403 (user hasn't started the bot): notifies the ride creator instead + +No reply is sent by the handler itself; `addParticipant` handles all messaging. + +### Error cases + +| Condition | i18n key | +|---|---| +| Ride ID missing or invalid | `commands.group.invalidRideIdUsage` | +| Ride not found | `commands.group.rideNotFound` | +| Ride has no attached group | `commands.group.joinchatNoGroup` | +| Caller not in `joined` list | `commands.group.joinchatNotParticipant` | + +--- + +## i18n Keys + +### New keys + +#### `formatter.groupChatLine` + +| Locale | Value | +|---|---| +| EN | `Join the ride's private group chat: send /joinchat #{id} to the bot in private messages (only works if you have joined the ride).` | +| RU | `Присоединяйтесь к закрытой группе поездки: напишите /joinchat #{id} боту в личные сообщения (работает только если вы записались в поездку).` | + +#### `commands.group.joinchatNoGroup` + +| Locale | Value | +|---|---| +| EN | `This ride doesn't have an attached group chat.` | +| RU | `К этой поездке не привязана группа.` | + +#### `commands.group.joinchatNotParticipant` + +| Locale | Value | +|---|---| +| EN | `You need to join the ride first.` | +| RU | `Сначала нужно записаться в поездку.` | + +#### `bot.commandDescriptions.joinchat` + +| Locale | Value | +|---|---| +| EN | `Join the private group chat for a ride` | +| RU | `Войти в закрытую группу поездки` | + +### Updated `help2` template + +Both locales get a new **💬 Joining the Ride Group Chat** section appended after the group attachment section. + +--- + +## Implementation Details + +### `GroupCommandHandler.handleAttach` changes + +- Builds a local `rideWithGroupId = { ...ride, groupId }` after `updateRide` so that `createRideMessage` uses the ride object with `groupId` set (ensuring the new group message immediately shows the notice) +- After the participant-add loop, fetches the latest ride from storage and calls `updateRideMessages` (best-effort) + +### `GroupCommandHandler.handleDetach` changes + +- After `updateRide({ groupId: null })`, calls `updateRideMessages({ ...ride, groupId: null }, ctx)` (best-effort) before sending the success reply + +### `GroupCommandHandler.handleJoinChat` (new method) + +Delegates to `GroupManagementService.addParticipant` — no new membership logic is introduced; the same invite-link mechanism used by `ParticipationHandlers` is reused. + +--- + +## Files Changed + +| File | Change | +|---|---| +| `src/i18n/locales/en.js` | Add `formatter.groupChatLine`, `commands.group.joinchatNoGroup`, `commands.group.joinchatNotParticipant`, `bot.commandDescriptions.joinchat`; add `{groupChatLine}` to `templates.ride`; update `help2` | +| `src/i18n/locales/ru.js` | Same as EN | +| `src/formatters/MessageFormatter.js` | Compute and replace `{groupChatLine}` in `formatRideMessage` | +| `src/commands/GroupCommandHandler.js` | Update `handleAttach` (use `rideWithGroupId`, call `updateRideMessages` after attach); update `handleDetach` (call `updateRideMessages` before reply); add `handleJoinChat` | +| `src/core/Bot.js` | Register `/joinchat` as a `privateOnly` command | +| `src/__tests__/commands/group-command-handler.test.js` | Update attach/detach assertions; add `handleJoinChat` test suite | +| `src/__tests__/core/bot.test.js` | Update private command count (11 → 12) | +| `README.md` | Add "Joining the Ride Group Chat" section | +| `SPECIFICATION.MD` | Update `GroupCommandHandler` entry; add group chat line to formatter description | +| `docs/changes/joinchat-group-chat-line-specification.md` | This document | diff --git a/src/__tests__/commands/group-command-handler.test.js b/src/__tests__/commands/group-command-handler.test.js index d0c0ea1..81e0bcb 100644 --- a/src/__tests__/commands/group-command-handler.test.js +++ b/src/__tests__/commands/group-command-handler.test.js @@ -138,14 +138,20 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => { expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.botNeedsAddMembersPermission')); }); - it('should attach group, post message, pin it, and reply success', async () => { + it('should attach group, post message, pin it, update messages, and reply success', async () => { mockRideService.getRide.mockResolvedValue(makeRide()); await handler.handleAttach(mockCtx); expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: GROUP_ID }); - expect(mockRideMessagesService.createRideMessage).toHaveBeenCalled(); + expect(mockRideMessagesService.createRideMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: RIDE_ID, groupId: GROUP_ID }), + mockCtx, + null + ); expect(mockCtx.api.pinChatMessage).toHaveBeenCalledWith(GROUP_ID, 777, { disable_notification: true }); + expect(mockRideService.getRide).toHaveBeenCalledTimes(2); + expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalled(); expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.attachSuccess')); }); @@ -226,6 +232,10 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => { await handler.handleDetach(mockCtx); expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: null }); + expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalledWith( + expect.objectContaining({ id: RIDE_ID, groupId: null }), + mockCtx + ); expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.detachSuccess')); }); @@ -237,6 +247,10 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => { await handler.handleDetach(mockCtx); expect(mockRideService.updateRide).toHaveBeenCalledWith(RIDE_ID, { groupId: null }); + expect(mockRideMessagesService.updateRideMessages).toHaveBeenCalledWith( + expect.objectContaining({ id: RIDE_ID, groupId: null }), + mockCtx + ); expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.detachSuccess')); }); @@ -251,4 +265,59 @@ describe.each(['en', 'ru'])('GroupCommandHandler (%s)', (language) => { expect(mockRideService.updateRide).not.toHaveBeenCalled(); }); }); + + // ─── /joinchat tests ────────────────────────────────────────────────────────── + + describe('handleJoinChat', () => { + beforeEach(() => { + mockCtx.chat.type = 'private'; + mockCtx.message.text = `/joinchat #${RIDE_ID}`; + }); + + it('should reply if ride not found', async () => { + mockRideService.getRide.mockResolvedValue(null); + + await handler.handleJoinChat(mockCtx); + + expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.rideNotFound')); + expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled(); + }); + + it('should reply if ride has no attached group', async () => { + mockRideService.getRide.mockResolvedValue(makeRide({ groupId: null })); + + await handler.handleJoinChat(mockCtx); + + expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.joinchatNoGroup')); + expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled(); + }); + + it('should reply if user has not joined the ride', async () => { + mockRideService.getRide.mockResolvedValue(makeRide({ groupId: GROUP_ID })); + + await handler.handleJoinChat(mockCtx); + + expect(mockCtx.reply).toHaveBeenCalledWith(tr('commands.group.joinchatNotParticipant')); + expect(mockGroupManagementService.addParticipant).not.toHaveBeenCalled(); + }); + + it('should call addParticipant if user has joined the ride', async () => { + const ride = makeRide({ + groupId: GROUP_ID, + participation: { + joined: [{ userId: CREATOR_ID, firstName: 'Creator', lastName: '', username: 'creator' }], + thinking: [], + skipped: [] + } + }); + mockRideService.getRide.mockResolvedValue(ride); + + await handler.handleJoinChat(mockCtx); + + expect(mockGroupManagementService.addParticipant).toHaveBeenCalledWith( + mockCtx.api, GROUP_ID, CREATOR_ID, language, CREATOR_ID + ); + expect(mockCtx.reply).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/__tests__/core/bot.test.js b/src/__tests__/core/bot.test.js index c215321..53a1e65 100644 --- a/src/__tests__/core/bot.test.js +++ b/src/__tests__/core/bot.test.js @@ -69,7 +69,7 @@ describe('Bot', () => { expect(bot.botConfig.callbacks).toBeInstanceOf(Array); // Check that commands are properly configured - expect(bot.botConfig.commands.privateOnly.length).toBe(12); // 12 private commands + expect(bot.botConfig.commands.privateOnly.length).toBe(13); // 13 private commands expect(bot.botConfig.commands.mixed.length).toBe(1); // 1 mixed command (shareride) expect(bot.botConfig.callbacks.length).toBe(7); // 7 callback handlers }); diff --git a/src/commands/GroupCommandHandler.js b/src/commands/GroupCommandHandler.js index 2b4c8f4..e761ad3 100644 --- a/src/commands/GroupCommandHandler.js +++ b/src/commands/GroupCommandHandler.js @@ -87,6 +87,7 @@ export class GroupCommandHandler extends BaseCommandHandler { // Save groupId to ride await this.rideService.updateRide(rideId, { groupId }); + const rideWithGroupId = { ...ride, groupId }; // Rename the group (best-effort) try { @@ -100,7 +101,7 @@ export class GroupCommandHandler extends BaseCommandHandler { // Post and pin the ride message in the group try { - const { sentMessage } = await this.rideMessagesService.createRideMessage(ride, ctx, ctx.message?.message_thread_id); + const { sentMessage } = await this.rideMessagesService.createRideMessage(rideWithGroupId, ctx, ctx.message?.message_thread_id); if (sentMessage?.message_id) { try { await ctx.api.pinChatMessage(groupId, sentMessage.message_id, { disable_notification: true }); @@ -118,9 +119,52 @@ export class GroupCommandHandler extends BaseCommandHandler { await this.groupManagementService.addParticipant(ctx.api, groupId, participant.userId, ctx.lang, ride.createdBy); } + // Update all pre-existing ride messages to show the group chat line (best-effort) + try { + const latestRide = await this.rideService.getRide(rideId); + if (latestRide) await this.rideMessagesService.updateRideMessages(latestRide, ctx); + } catch (updateError) { + console.error('GroupCommandHandler: failed to update ride messages after attach:', updateError); + } + await ctx.reply(this.translate(ctx, 'commands.group.attachSuccess')); } + /** + * Handle /joinchat #rideId — sends an invite link to join the ride's group chat + * @param {import('grammy').Context} ctx + */ + async handleJoinChat(ctx) { + const { rideId, error } = this.rideMessagesService.extractRideId(ctx.message, ctx.lang ? { language: ctx.lang } : {}); + if (!rideId) { + await ctx.reply(error || this.translate(ctx, 'commands.group.invalidRideIdUsage')); + return; + } + + let ride; + try { + ride = await this.rideService.getRide(rideId); + } catch (e) { + ride = null; + } + if (!ride) { + await ctx.reply(this.translate(ctx, 'commands.group.rideNotFound')); + return; + } + if (!ride.groupId) { + await ctx.reply(this.translate(ctx, 'commands.group.joinchatNoGroup')); + return; + } + + const isParticipant = (ride.participation?.joined || []).some(p => p.userId === ctx.from.id); + if (!isParticipant) { + await ctx.reply(this.translate(ctx, 'commands.group.joinchatNotParticipant')); + return; + } + + await this.groupManagementService.addParticipant(ctx.api, ride.groupId, ctx.from.id, ctx.lang, ride.createdBy); + } + /** * Handle /detach — unlinks the current group from its ride * @param {import('grammy').Context} ctx @@ -162,6 +206,11 @@ export class GroupCommandHandler extends BaseCommandHandler { } await this.rideService.updateRide(ride.id, { groupId: null }); + try { + await this.rideMessagesService.updateRideMessages({ ...ride, groupId: null }, ctx); + } catch (updateError) { + console.error('GroupCommandHandler: failed to update ride messages after detach:', updateError); + } await ctx.reply(this.translate(ctx, 'commands.group.detachSuccess')); } } diff --git a/src/core/Bot.js b/src/core/Bot.js index 3a111ad..9e8583f 100644 --- a/src/core/Bot.js +++ b/src/core/Bot.js @@ -84,6 +84,7 @@ export class Bot { { command: 'dupride', descriptionKey: 'bot.commandDescriptions.dupride', handler: (ctx) => duplicateRideHandler.handle(ctx) }, { command: 'resumeride', descriptionKey: 'bot.commandDescriptions.resumeride', handler: (ctx) => resumeRideHandler.handle(ctx) }, { command: 'airide', descriptionKey: 'bot.commandDescriptions.airide', handler: (ctx) => this.aiRideHandler.handle(ctx) }, + { command: 'joinchat', descriptionKey: 'bot.commandDescriptions.joinchat', handler: (ctx) => groupHandler.handleJoinChat(ctx) }, { command: 'fromstrava', descriptionKey: 'bot.commandDescriptions.fromstrava', handler: (ctx) => this.fromStravaHandler.handle(ctx) }, ], publicOnly: [ diff --git a/src/formatters/MessageFormatter.js b/src/formatters/MessageFormatter.js index 6948227..2c11bad 100644 --- a/src/formatters/MessageFormatter.js +++ b/src/formatters/MessageFormatter.js @@ -200,6 +200,11 @@ export class MessageFormatter { : ''; message = message.replace('{shareLine}', shareLine); + const groupChatLine = ride.groupId + ? `${this.translate('formatter.groupChatLine', { id: ride.id }, language)}\n\n` + : ''; + message = message.replace('{groupChatLine}', groupChatLine); + message = message.replace('{id}', ride.id); // Remove lines that contain only emoji and empty content (e.g., "🤔 Thinking (0): ") diff --git a/src/i18n/locales/en.js b/src/i18n/locales/en.js index 4107be7..ff1654e 100644 --- a/src/i18n/locales/en.js +++ b/src/i18n/locales/en.js @@ -185,6 +185,12 @@ Only the ride creator can attach a group: The bot will post the ride info in the group and automatically add/remove members as participants join or leave the ride. To unlink the group, use /detach in the group chat. +💬 Joining the Ride Group Chat +Once a group is attached to a ride, the ride message shows a notice with instructions. +Any participant who has joined the ride can request an invite link by sending the bot a private message: +/joinchat #rideId +The bot will send you a single-use invite link (valid 24 hours). The command only works if you have joined the ride. + `.trim(), ride: ` @@ -195,7 +201,7 @@ To unlink the group, use /detach in the group chat. 🤔 {thinkingLabel} ({thinkingCount}): {thinking} 🙅 {notInterestedLabel}: {notInterestedCount} -{shareLine}🎫 #Ride #{id}{cancelledInstructions} +{groupChatLine}{shareLine}🎫 #Ride #{id}{cancelledInstructions} `.trim(), cancelled: '❌ CANCELLED', @@ -328,6 +334,8 @@ Click here to start a private chat: @botname inviteLinkSent: 'You\'ve been invited to the ride group: {link}\n\nThis group is for ride coordination, pre- and post-ride discussion, and sharing photos. The link is valid for 24 hours.', inviteLinkForCreator: 'A participant couldn\'t receive the group invite link automatically — they haven\'t started a conversation with the bot. Please forward this link to them manually: {link}', invalidRideIdUsage: 'Please provide a valid ride ID. Usage: /attach #rideID', + joinchatNoGroup: 'This ride doesn\'t have an attached group chat.', + joinchatNotParticipant: 'You need to join the ride first.', chatTitle: 'Ride: {title} @ {date}' }, delete: { @@ -377,6 +385,7 @@ Click here to start a private chat: @botname andMoreParticipants: '{displayedList} and {count} more', upToSpeed: 'up to {max} km/h', shareLine: 'Share this ride: /shareride #{id}', + groupChatLine: '
Join the ride\'s private group chat: send /joinchat #{id} to the bot in private messages (only works if you have joined the ride).
', labels: { when: 'When', category: 'Category', @@ -505,6 +514,7 @@ Click here to start a private chat: @botname attach: 'Attach a Telegram group to a ride', detach: 'Detach the Telegram group from its ride', airide: 'Create or update a ride using AI', + joinchat: 'Join the private group chat for a ride', fromstrava: 'Create or update a ride from a Strava event' } } diff --git a/src/i18n/locales/ru.js b/src/i18n/locales/ru.js index eb285f8..67eb841 100644 --- a/src/i18n/locales/ru.js +++ b/src/i18n/locales/ru.js @@ -185,6 +185,12 @@ id: abc123 (or #abc123) Бот опубликует информацию о поездке в группе и будет автоматически добавлять/удалять участников по мере изменения их статуса. Чтобы отвязать группу, используйте /detach в группе. +💬 Вступление в группу поездки +Когда группа привязана к поездке, в сообщении о поездке появляется подсказка. +Любой участник, записавшийся в поездку, может запросить ссылку-приглашение, написав боту в личные сообщения: +/joinchat #rideId +Бот вышлет одноразовую ссылку (действительна 24 часа). Команда работает только если вы записались в поездку. + `.trim(), ride: ` @@ -195,7 +201,7 @@ id: abc123 (or #abc123) 🤔 {thinkingLabel} ({thinkingCount}): {thinking} 🙅 {notInterestedLabel}: {notInterestedCount} -{shareLine}🎫 #Ride #{id}{cancelledInstructions} +{groupChatLine}{shareLine}🎫 #Ride #{id}{cancelledInstructions} `.trim(), cancelled: '❌ ОТМЕНЕНО', @@ -328,6 +334,8 @@ id: abc123 (or #abc123) inviteLinkSent: 'Вы приглашены в группу поездки: {link}\n\nЭта группа предназначена для организации поездки, общения до и после неё, а также обмена фотографиями. Ссылка действительна 24 часа.', inviteLinkForCreator: 'Участник не смог получить ссылку-приглашение автоматически — он ещё не начал разговор с ботом. Пожалуйста, отправьте ему эту ссылку вручную: {link}', invalidRideIdUsage: 'Укажите корректный ID поездки. Использование: /attach #rideID', + joinchatNoGroup: 'К этой поездке не привязана группа.', + joinchatNotParticipant: 'Сначала нужно записаться в поездку.', chatTitle: 'Поездка: {title} @ {date}' }, delete: { @@ -377,6 +385,7 @@ id: abc123 (or #abc123) andMoreParticipants: '{displayedList} и еще {count}', upToSpeed: 'до {max} км/ч', shareLine: 'Поделиться поездкой: /shareride #{id}', + groupChatLine: '
Присоединяйтесь к закрытой группе поездки: напишите /joinchat #{id} боту в личные сообщения (работает только если вы записались в поездку).
', labels: { when: 'Когда', category: 'Категория', @@ -505,6 +514,7 @@ id: abc123 (or #abc123) attach: 'Привязать Telegram-группу к поездке', detach: 'Отвязать Telegram-группу от поездки', airide: 'Создать или обновить поездку с помощью AI', + joinchat: 'Войти в закрытую группу поездки', fromstrava: 'Создать или обновить поездку из события Strava' } }