-
-
Notifications
You must be signed in to change notification settings - Fork 156
Add Feishu and Lark platform support for thread bridge #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
eefc83b
c086413
1876660
5cebeaa
d2c813c
aaaafa2
0ea26c3
651c945
0ef4ec4
4912697
9d1ae1e
3414b08
a2a4569
b8fa8c5
11444e2
6b220ba
81925e1
55c814d
050d3d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -136,6 +136,7 @@ Notes: | |||||||||||||||||||||
| - ⚡ No global install required for quick experimentation | ||||||||||||||||||||||
| - 🎙️ Built-in hold-to-dictate voice input with transcription to composer draft | ||||||||||||||||||||||
| - 🤖 Optional Telegram bot bridge: send messages to bot, forward into mapped thread, send assistant reply back to Telegram | ||||||||||||||||||||||
| - 🐦 Optional Feishu bot bridge: same bidirectional messaging via Feishu using WebSocket long connection (no public IP needed) | ||||||||||||||||||||||
| - 💾 Project portability: export a project as a ZIP from project or thread menus, including matching Codex chat JSONL history under `.codex-project/chats/` | ||||||||||||||||||||||
| - 📦 Project import: restore exported project ZIPs from the browser via `Import Project` | ||||||||||||||||||||||
| - 🔁 Imported chats are rewritten for the destination `CODEX_HOME`, project path, and currently selected provider/model so they can be resumed in the new environment | ||||||||||||||||||||||
|
|
@@ -174,6 +175,41 @@ Bot commands: | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| Outgoing assistant messages are sent with Telegram `parse_mode=HTML` for formatting, with automatic plain-text fallback if HTML delivery fails. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ### Feishu Bot Bridge (Optional) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Same bidirectional messaging as Telegram, using Feishu's WebSocket long connection mode (no public IP or domain required). | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| **Setup:** | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 1. Create a self-built app at [Feishu Open Platform](https://open.feishu.cn) | ||||||||||||||||||||||
| 2. Enable bot capability | ||||||||||||||||||||||
| 3. Under Event Subscription, select **Long Connection** mode | ||||||||||||||||||||||
| 4. Subscribe to `im.message.receive_v1` event | ||||||||||||||||||||||
| 5. Add permission `im:message:send_as_bot` | ||||||||||||||||||||||
| 6. Configure in the codexapp sidebar settings (App ID, App Secret, allowed user open_ids), or use environment variables: | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ```bash | ||||||||||||||||||||||
| export FEISHU_APP_ID="<your-feishu-app-id>" | ||||||||||||||||||||||
| export FEISHU_APP_SECRET="<your-feishu-app-secret>" | ||||||||||||||||||||||
| export FEISHU_ALLOWED_USER_IDS="<your-open-id>,<optional-second-id>" | ||||||||||||||||||||||
| export FEISHU_DEFAULT_CWD="$PWD" # optional, defaults to current working directory | ||||||||||||||||||||||
| npx codexapp | ||||||||||||||||||||||
|
Comment on lines
+189
to
+196
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Document
Suggested doc fix export FEISHU_APP_ID="<your-feishu-app-id>"
export FEISHU_APP_SECRET="<your-feishu-app-secret>"
+export FEISHU_DOMAIN="feishu|lark"
export FEISHU_ALLOWED_USER_IDS="<your-open-id>,<optional-second-id>"
export FEISHU_DEFAULT_CWD="$PWD" # optional, defaults to current working directory📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| ``` | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| `FEISHU_ALLOWED_USER_IDS` is required for safe access. Use `*` to allow all users. To find your open_id, send a message to the bot — it will show in the rejection message. | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Bot commands (same as Telegram): | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| - `/start` show quick help and thread picker | ||||||||||||||||||||||
| - `/threads` list recent threads grouped by project | ||||||||||||||||||||||
| - `/newthread` create and map a new Codex thread | ||||||||||||||||||||||
| - `/thread <threadId>` map current chat to an existing thread | ||||||||||||||||||||||
| - `/current` show currently connected thread | ||||||||||||||||||||||
| - `/history` show full history for current thread | ||||||||||||||||||||||
| - `/status` show bridge/mapping status | ||||||||||||||||||||||
| - `/whoami` show your Feishu IDs and authorization state | ||||||||||||||||||||||
| - `/help` show command reference | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| --- | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| ## 🧩 Recent Product Features (from main commits) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -475,6 +475,84 @@ | |||||||||
| </button> | ||||||||||
| </div> | ||||||||||
| </div> | ||||||||||
| <button class="sidebar-settings-row" type="button" aria-live="polite" @click="isFeishuConfigOpen = !isFeishuConfigOpen"> | ||||||||||
| <span class="sidebar-settings-label">{{ t('Feishu') }}</span> | ||||||||||
| <span class="sidebar-settings-value">{{ feishuStatusText }}</span> | ||||||||||
| </button> | ||||||||||
| <div v-if="isFeishuConfigOpen" class="sidebar-settings-telegram-panel"> | ||||||||||
| <div class="sidebar-settings-row sidebar-settings-row--select" style="padding: 0"> | ||||||||||
| <span class="sidebar-settings-label">{{ t('Platform') }}</span> | ||||||||||
| <div class="sidebar-settings-segmented" role="group" :aria-label="t('Feishu platform')"> | ||||||||||
| <button | ||||||||||
| type="button" | ||||||||||
| class="sidebar-settings-segmented-option" | ||||||||||
| :class="{ 'is-active': feishuDomainDraft === 'feishu' }" | ||||||||||
| :disabled="isFeishuSaving" | ||||||||||
| @click="feishuDomainDraft = 'feishu'" | ||||||||||
| > | ||||||||||
| Feishu | ||||||||||
| </button> | ||||||||||
| <button | ||||||||||
| type="button" | ||||||||||
| class="sidebar-settings-segmented-option" | ||||||||||
| :class="{ 'is-active': feishuDomainDraft === 'lark' }" | ||||||||||
| :disabled="isFeishuSaving" | ||||||||||
| @click="feishuDomainDraft = 'lark'" | ||||||||||
| > | ||||||||||
| Lark | ||||||||||
| </button> | ||||||||||
| </div> | ||||||||||
| </div> | ||||||||||
| <label class="sidebar-settings-field"> | ||||||||||
| <span class="sidebar-settings-field-label">{{ t('App ID') }}</span> | ||||||||||
| <input | ||||||||||
| v-model="feishuAppIdDraft" | ||||||||||
| class="sidebar-settings-input" | ||||||||||
| type="text" | ||||||||||
| placeholder="cli_xxxxxxxxxx" | ||||||||||
| autocomplete="off" | ||||||||||
| spellcheck="false" | ||||||||||
| > | ||||||||||
| </label> | ||||||||||
| <label class="sidebar-settings-field"> | ||||||||||
| <span class="sidebar-settings-field-label">{{ t('App Secret') }}</span> | ||||||||||
| <input | ||||||||||
| v-model="feishuAppSecretDraft" | ||||||||||
| class="sidebar-settings-input" | ||||||||||
| type="password" | ||||||||||
| placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" | ||||||||||
| autocomplete="off" | ||||||||||
| spellcheck="false" | ||||||||||
| > | ||||||||||
| </label> | ||||||||||
| <label class="sidebar-settings-field"> | ||||||||||
| <span class="sidebar-settings-field-label">{{ t('Allowed Feishu user open_ids') }}</span> | ||||||||||
| <textarea | ||||||||||
| v-model="feishuAllowedUserIdsDraft" | ||||||||||
| class="sidebar-settings-textarea" | ||||||||||
| rows="3" | ||||||||||
| placeholder="ou_xxxxxxxxxx ou_yyyyyyyyyy" | ||||||||||
| spellcheck="false" | ||||||||||
| /> | ||||||||||
| </label> | ||||||||||
| <div class="sidebar-settings-field-help"> | ||||||||||
| {{ t('Put one Feishu open_id per line or separate them with commas. Use `*` to allow all Feishu users. You can find open_id in the bot rejection message.') }} | ||||||||||
| </div> | ||||||||||
| <div v-if="feishuConfigError" class="sidebar-settings-telegram-error"> | ||||||||||
| <span>{{ feishuConfigError }}</span> | ||||||||||
| <a class="visible-error-feedback" :href="feedbackMailto" @click="prepareFeedbackLink($event, feishuConfigError)">{{ t('Send feedback') }}</a> | ||||||||||
| </div> | ||||||||||
| <div class="sidebar-settings-telegram-actions"> | ||||||||||
| <button | ||||||||||
| class="sidebar-settings-telegram-save" | ||||||||||
| type="button" | ||||||||||
| :disabled="isFeishuSaving" | ||||||||||
| @click="saveFeishuConfig" | ||||||||||
| > | ||||||||||
| {{ isFeishuSaving ? t('Saving…') : t('Save Feishu config') }} | ||||||||||
| </button> | ||||||||||
| </div> | ||||||||||
| </div> | ||||||||||
|
Comment on lines
+478
to
+555
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Feishu manual tests missing The PR adds a new Feishu/Lark thread bridge feature and related settings, but does not add/update domain-scoped manual test documentation under tests/<domain>/ (and keeps tests.md as only an index). Without a new Feishu/Lark manual test section following the required template (prereqs, steps, expected results, rollback), the change is not reproducibly testable. Agent Prompt
|
||||||||||
| <div | ||||||||||
| v-if="showThreadContextBadge" | ||||||||||
| class="sidebar-settings-row sidebar-settings-context-row" | ||||||||||
|
|
@@ -1193,6 +1271,7 @@ import { | |||||||||
| checkoutGitBranch, | ||||||||||
| cloneGithubRepository, | ||||||||||
| configureTelegramBot, | ||||||||||
| configureFeishuBot, | ||||||||||
| createPermanentWorktree, | ||||||||||
| createWorktree, | ||||||||||
| createProjectlessThreadDirectory, | ||||||||||
|
|
@@ -1209,8 +1288,10 @@ import { | |||||||||
| getFirstLaunchPluginsCardPreference, | ||||||||||
| getHomeDirectory, | ||||||||||
| getTelegramConfig, | ||||||||||
| getFeishuConfig, | ||||||||||
| getProjectRootSuggestion, | ||||||||||
| getTelegramStatus, | ||||||||||
| getFeishuStatus, | ||||||||||
| getThreadTerminalQuickCommands, | ||||||||||
| getThreadTerminalStatus, | ||||||||||
| getWorkspaceRootsState, | ||||||||||
|
|
@@ -1227,7 +1308,7 @@ import { | |||||||||
| } from './api/codexGateway' | ||||||||||
| import type { ReasoningEffort, SpeedMode, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadAutomation, UiThreadTokenUsage } from './types/codex' | ||||||||||
| import type { ComposerDraftPayload, ThreadComposerExposed } from './components/content/ThreadComposer.vue' | ||||||||||
| import type { GitCommitFileChange, GitCommitOption, LocalDirectoryEntry, TelegramStatus, ThreadTerminalQuickCommand, WorktreeBranchOption } from './api/codexGateway' | ||||||||||
| import type { GitCommitFileChange, GitCommitOption, LocalDirectoryEntry, TelegramStatus, FeishuStatus, FeishuDomain, ThreadTerminalQuickCommand, WorktreeBranchOption } from './api/codexGateway' | ||||||||||
| import { getFreeModeStatus, setFreeMode, setFreeModeCustomKey, setCustomProvider } from './api/codexGateway' | ||||||||||
| import { getPathLeafName, getPathParent, isProjectlessChatPath, normalizePathForUi } from './pathUtils.js' | ||||||||||
| import { copyTextToClipboard } from './utils/clipboard' | ||||||||||
|
|
@@ -1663,6 +1744,13 @@ const telegramBotTokenDraft = ref('') | |||||||||
| const telegramAllowedUserIdsDraft = ref('') | ||||||||||
| const telegramConfigError = ref('') | ||||||||||
| const isTelegramSaving = ref(false) | ||||||||||
| const isFeishuConfigOpen = ref(false) | ||||||||||
| const feishuDomainDraft = ref<FeishuDomain>('feishu') | ||||||||||
| const feishuAppIdDraft = ref('') | ||||||||||
| const feishuAppSecretDraft = ref('') | ||||||||||
| const feishuAllowedUserIdsDraft = ref('') | ||||||||||
| const feishuConfigError = ref('') | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Register Unlike 🔧 Proposed fix telegramConfigError,
+ feishuConfigError,
createFolderError,📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| const isFeishuSaving = ref(false) | ||||||||||
| const isCreateFolderOpen = ref(false) | ||||||||||
| const createFolderDraft = ref('') | ||||||||||
| const createFolderError = ref('') | ||||||||||
|
|
@@ -1711,6 +1799,16 @@ const telegramStatus = ref<TelegramStatus>({ | |||||||||
| allowAllUsers: false, | ||||||||||
| lastError: '', | ||||||||||
| }) | ||||||||||
| const feishuStatus = ref<FeishuStatus>({ | ||||||||||
| configured: false, | ||||||||||
| active: false, | ||||||||||
| domain: 'feishu', | ||||||||||
| mappedChats: 0, | ||||||||||
| mappedThreads: 0, | ||||||||||
| allowedUsers: 0, | ||||||||||
| allowAllUsers: false, | ||||||||||
| lastError: '', | ||||||||||
| }) | ||||||||||
| const mobileHiddenAtMs = ref<number | null>(null) | ||||||||||
| const mobileResumeReloadTriggered = ref(false) | ||||||||||
| const mobileResumeSyncInProgress = ref(false) | ||||||||||
|
|
@@ -2121,6 +2219,17 @@ const telegramStatusText = computed(() => { | |||||||||
| const error = telegramStatus.value.lastError ? `, ${t('error')}: ${telegramStatus.value.lastError}` : '' | ||||||||||
| return `${base}, ${mapped}${error}` | ||||||||||
| }) | ||||||||||
| const feishuStatusText = computed(() => { | ||||||||||
| if (!feishuStatus.value.configured) return t('Not configured') | ||||||||||
| const base = feishuStatus.value.active ? t('Online') : t('Configured (offline)') | ||||||||||
| const platform = feishuStatus.value.domain === 'lark' ? 'Lark' : 'Feishu' | ||||||||||
| const allowlist = feishuStatus.value.allowAllUsers | ||||||||||
| ? t('allow all users') | ||||||||||
| : `${feishuStatus.value.allowedUsers} ${t('allowed user(s)')}` | ||||||||||
| const mapped = `${feishuStatus.value.mappedChats} ${t('chat(s)')}, ${feishuStatus.value.mappedThreads} ${t('thread(s)')}, ${allowlist}` | ||||||||||
| const error = feishuStatus.value.lastError ? `, ${t('error')}: ${feishuStatus.value.lastError}` : '' | ||||||||||
| return `${base}, ${platform}, ${mapped}${error}` | ||||||||||
| }) | ||||||||||
|
|
||||||||||
| onMounted(() => { | ||||||||||
| document.addEventListener('pointerdown', onDocumentPointerDown) | ||||||||||
|
|
@@ -2141,6 +2250,8 @@ onMounted(() => { | |||||||||
| void refreshDefaultProjectName() | ||||||||||
| void refreshTelegramConfig() | ||||||||||
| void refreshTelegramStatus() | ||||||||||
| void refreshFeishuConfig() | ||||||||||
| void refreshFeishuStatus() | ||||||||||
| void loadFreeModeStatus() | ||||||||||
| void refreshThreadTerminalStatus() | ||||||||||
| void refreshTerminalQuickCommands() | ||||||||||
|
|
@@ -2342,6 +2453,82 @@ async function saveTelegramConfig(): Promise<void> { | |||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async function refreshFeishuStatus(): Promise<void> { | ||||||||||
| try { | ||||||||||
| feishuStatus.value = await getFeishuStatus() | ||||||||||
| } catch (error) { | ||||||||||
| const message = error instanceof Error ? error.message : 'Failed to load Feishu status' | ||||||||||
| feishuStatus.value = { | ||||||||||
| configured: false, | ||||||||||
| active: false, | ||||||||||
| mappedChats: 0, | ||||||||||
| mappedThreads: 0, | ||||||||||
| allowedUsers: 0, | ||||||||||
| allowAllUsers: false, | ||||||||||
| domain: 'feishu', | ||||||||||
| lastError: message, | ||||||||||
| } | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async function refreshFeishuConfig(): Promise<void> { | ||||||||||
| try { | ||||||||||
| const config = await getFeishuConfig() | ||||||||||
| feishuDomainDraft.value = config.domain | ||||||||||
| feishuAppIdDraft.value = config.appId | ||||||||||
| feishuAppSecretDraft.value = config.appSecret | ||||||||||
| feishuAllowedUserIdsDraft.value = config.allowedUserIds.map((value) => String(value)).join('\n') | ||||||||||
| feishuConfigError.value = '' | ||||||||||
| } catch (error) { | ||||||||||
| feishuConfigError.value = error instanceof Error ? error.message : 'Failed to load Feishu configuration' | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function parseFeishuAllowedUserIdsInput(value: string): Array<string | '*'> { | ||||||||||
| const rawEntries = value | ||||||||||
| .split(/[\n,]/) | ||||||||||
| .map((entry) => entry.trim()) | ||||||||||
| .filter(Boolean) | ||||||||||
| const allowAllUsers = rawEntries.includes('*') | ||||||||||
| const normalizedUserIds = Array.from(new Set(rawEntries.filter((entry) => entry !== '*'))) | ||||||||||
| return allowAllUsers ? ['*', ...normalizedUserIds] : normalizedUserIds | ||||||||||
| } | ||||||||||
|
|
||||||||||
| async function saveFeishuConfig(): Promise<void> { | ||||||||||
| const appId = feishuAppIdDraft.value.trim() | ||||||||||
| const appSecret = feishuAppSecretDraft.value.trim() | ||||||||||
| const allowedUserIds = parseFeishuAllowedUserIdsInput(feishuAllowedUserIdsDraft.value) | ||||||||||
| if (!appId) { | ||||||||||
| feishuConfigError.value = t('Feishu App ID is required.') | ||||||||||
| return | ||||||||||
| } | ||||||||||
| if (!appSecret) { | ||||||||||
| feishuConfigError.value = t('Feishu App Secret is required.') | ||||||||||
| return | ||||||||||
| } | ||||||||||
| if (allowedUserIds.length === 0) { | ||||||||||
| feishuConfigError.value = t('At least one allowed Feishu user open_id or * is required.') | ||||||||||
| return | ||||||||||
| } | ||||||||||
|
|
||||||||||
| isFeishuSaving.value = true | ||||||||||
| feishuConfigError.value = '' | ||||||||||
| try { | ||||||||||
| await configureFeishuBot(appId, appSecret, feishuDomainDraft.value, allowedUserIds) | ||||||||||
| feishuAllowedUserIdsDraft.value = allowedUserIds.map((value) => String(value)).join('\n') | ||||||||||
| await Promise.all([ | ||||||||||
| refreshFeishuConfig(), | ||||||||||
| refreshFeishuStatus(), | ||||||||||
| ]) | ||||||||||
| window.alert(t('Feishu bot configured. Only allowlisted Feishu users can use the bridge.')) | ||||||||||
| } catch (error) { | ||||||||||
| feishuConfigError.value = error instanceof Error ? error.message : t('Failed to connect Feishu bot') | ||||||||||
| void refreshFeishuStatus() | ||||||||||
| } finally { | ||||||||||
| isFeishuSaving.value = false | ||||||||||
| } | ||||||||||
| } | ||||||||||
|
|
||||||||||
| function toggleSidebarSearch(): void { | ||||||||||
| isSidebarSearchVisible.value = !isSidebarSearchVisible.value | ||||||||||
| if (isSidebarSearchVisible.value) { | ||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add
card.action.triggerto the required Feishu subscriptions.The bridge also registers a handler for
card.action.triggerinsrc/server/feishuThreadBridge.ts; documenting onlyim.message.receive_v1leaves the interactive card/thread-picker path broken for users who follow the README.Suggested doc fix
📝 Committable suggestion
🤖 Prompt for AI Agents