slidge-whatsapp 0.2.2__cp313-cp313-manylinux_2_36_x86_64.whl

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.
slidge_whatsapp/go.sum ADDED
@@ -0,0 +1,62 @@
1
+ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2
+ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3
+ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4
+ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5
+ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6
+ github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
7
+ github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
8
+ github.com/gen2brain/go-fitz v1.24.14 h1:09weRkjVtLYNGo7l0J7DyOwBExbwi8SJ9h8YPhw9WEo=
9
+ github.com/gen2brain/go-fitz v1.24.14/go.mod h1:0KaZeQgASc20Yp5R/pFzyy7SmP01XcoHKNF842U2/S4=
10
+ github.com/go-python/gopy v0.4.11-0.20241206185020-5f285b890023 h1:XTSDrwAmmX5o2lKeIYfKbCPYPMaDSDYvknkYADJKpxE=
11
+ github.com/go-python/gopy v0.4.11-0.20241206185020-5f285b890023/go.mod h1:zMV/gSSYa9u/8Zp0WYR+L/z+kOIqIUtMg/a1/GRy5uw=
12
+ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
13
+ github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
14
+ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
15
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
16
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
17
+ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
18
+ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
19
+ github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
20
+ github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
21
+ github.com/jupiterrider/ffi v0.3.0 h1:F8N2IgRMNlL2fsO2oeE6QYW60vKhFVqQe5qVKwd/taU=
22
+ github.com/jupiterrider/ffi v0.3.0/go.mod h1:1QCaf2VVPpGyIeU3RqQ2rHYrAPT8m9l0GhQupVYQB24=
23
+ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
24
+ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
25
+ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
26
+ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
27
+ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
28
+ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
29
+ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
30
+ github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
31
+ github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
32
+ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
33
+ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34
+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
35
+ github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
36
+ github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
37
+ github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
38
+ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
39
+ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
40
+ go.mau.fi/libsignal v0.1.1 h1:m/0PGBh4QKP/I1MQ44ti4C0fMbLMuHb95cmDw01FIpI=
41
+ go.mau.fi/libsignal v0.1.1/go.mod h1:QLs89F/OA3ThdSL2Wz2p+o+fi8uuQUz0e1BRa6ExdBw=
42
+ go.mau.fi/util v0.8.4 h1:mVKlJcXWfVo8ZW3f4vqtjGpqtZqJvX4ETekxawt2vnQ=
43
+ go.mau.fi/util v0.8.4/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY=
44
+ go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19 h1:uVS+Zct5fF8rSXV9lfs87zoXdge0JXTzVGNkjmZ61UU=
45
+ go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19/go.mod h1:TLzm2XkwgufONEmiVAsFny+9uBqyEZnUoPrQAfMyuSU=
46
+ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
47
+ golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
48
+ golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
49
+ golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
50
+ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
51
+ golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
52
+ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
53
+ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
54
+ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
55
+ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
56
+ golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
57
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
58
+ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
59
+ google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
60
+ google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
61
+ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
62
+ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,256 @@
1
+ import re
2
+ from datetime import datetime, timezone
3
+ from typing import TYPE_CHECKING, AsyncIterator, Optional
4
+
5
+ from slidge.group import LegacyBookmarks, LegacyMUC, LegacyParticipant, MucType
6
+ from slidge.util.archive_msg import HistoryMessage
7
+ from slidge.util.types import Hat, HoleBound, Mention, MucAffiliation
8
+ from slixmpp.exceptions import XMPPError
9
+
10
+ from .generated import go, whatsapp
11
+
12
+ if TYPE_CHECKING:
13
+ from .contact import Contact
14
+ from .session import Session
15
+
16
+
17
+ class Participant(LegacyParticipant):
18
+ contact: "Contact"
19
+ muc: "MUC"
20
+
21
+
22
+ class MUC(LegacyMUC[str, str, Participant, str]):
23
+ session: "Session"
24
+ type = MucType.GROUP
25
+
26
+ HAS_DESCRIPTION = False
27
+ REACTIONS_SINGLE_EMOJI = True
28
+ _ALL_INFO_FILLED_ON_STARTUP = True
29
+
30
+ async def update_info(self):
31
+ try:
32
+ avatar = self.session.whatsapp.GetAvatar(self.legacy_id, self.avatar or "")
33
+ if avatar.URL and self.avatar != avatar.ID:
34
+ await self.set_avatar(avatar.URL, avatar.ID)
35
+ elif avatar.URL == "":
36
+ await self.set_avatar(None)
37
+ except RuntimeError as err:
38
+ self.session.log.error(
39
+ "Failed getting avatar for group %s: %s", self.legacy_id, err
40
+ )
41
+
42
+ async def backfill(
43
+ self,
44
+ after: HoleBound | None = None,
45
+ before: HoleBound | None = None,
46
+ ):
47
+ """
48
+ Request history for messages older than the oldest message given by ID and date.
49
+ """
50
+
51
+ if before is None:
52
+ return
53
+ # WhatsApp requires a full reference to the last seen message in performing on-demand sync.
54
+
55
+ assert isinstance(before.id, str)
56
+ oldest_message = whatsapp.Message(
57
+ ID=before.id,
58
+ IsCarbon=self.session.message_is_carbon(self, before.id),
59
+ Timestamp=int(before.timestamp.timestamp()),
60
+ )
61
+ self.session.whatsapp.RequestMessageHistory(self.legacy_id, oldest_message)
62
+
63
+ def get_message_sender(self, legacy_msg_id: str):
64
+ assert self.pk is not None
65
+ stored = self.xmpp.store.mam.get_by_legacy_id(self.pk, legacy_msg_id)
66
+ if stored is None:
67
+ raise XMPPError("internal-server-error", "Unable to find message sender")
68
+ msg = HistoryMessage(stored.stanza)
69
+ occupant_id = msg.stanza["occupant-id"]["id"]
70
+ if occupant_id == "slidge-user":
71
+ return self.session.contacts.user_legacy_id
72
+ if "@" in occupant_id:
73
+ jid_username = occupant_id.split("@")[0]
74
+ return jid_username.removeprefix("+") + "@" + whatsapp.DefaultUserServer
75
+ raise XMPPError("internal-server-error", "Unable to find message sender")
76
+
77
+ async def update_whatsapp_info(self, info: whatsapp.Group):
78
+ """
79
+ Set MUC information based on WhatsApp group information, which may or may not be partial in
80
+ case of updates to existing MUCs.
81
+ """
82
+ if info.Nickname:
83
+ self.user_nick = info.Nickname
84
+ if info.Name:
85
+ self.name = info.Name
86
+ if info.Subject.Subject:
87
+ self.subject = info.Subject.Subject
88
+ if info.Subject.SetAt:
89
+ set_at = datetime.fromtimestamp(info.Subject.SetAt, tz=timezone.utc)
90
+ self.subject_date = set_at
91
+ if info.Subject.SetByJID:
92
+ participant = await self.get_participant_by_legacy_id(
93
+ info.Subject.SetByJID
94
+ )
95
+ if name := participant.nickname:
96
+ self.subject_setter = name
97
+ self.session.whatsapp_participants[self.legacy_id] = info.Participants
98
+
99
+ async def fill_participants(self) -> AsyncIterator[Participant]:
100
+ await self.session.bookmarks.ready
101
+ try:
102
+ participants = self.session.whatsapp_participants.pop(self.legacy_id)
103
+ except KeyError:
104
+ self.log.warning("No participants!")
105
+ return
106
+ for data in participants:
107
+ participant = await self.get_participant_by_legacy_id(data.JID)
108
+ if data.Action == whatsapp.GroupParticipantActionRemove:
109
+ self.remove_participant(participant)
110
+ else:
111
+ if data.Affiliation == whatsapp.GroupAffiliationAdmin:
112
+ # Only owners can change the group name according to
113
+ # XEP-0045, so we make all "WA admins" "XMPP owners"
114
+ participant.affiliation = "owner"
115
+ participant.role = "moderator"
116
+ elif data.Affiliation == whatsapp.GroupAffiliationOwner:
117
+ # The WA owner is in fact the person who created the room
118
+ participant.set_hats(
119
+ [Hat("https://slidge.im/hats/slidge-whatsapp/owner", "Owner")]
120
+ )
121
+ participant.affiliation = "owner"
122
+ participant.role = "moderator"
123
+ else:
124
+ participant.affiliation = "member"
125
+ participant.role = "participant"
126
+ yield participant
127
+
128
+ async def replace_mentions(self, t: str):
129
+ return replace_whatsapp_mentions(
130
+ t,
131
+ participants=(
132
+ {
133
+ p.contact.jid_username: p.nickname
134
+ async for p in self.get_participants()
135
+ if p.contact is not None # should not happen
136
+ }
137
+ | {self.session.user_phone: self.user_nick}
138
+ if self.session.user_phone # user_phone *should* be set at this point,
139
+ else {} # but better safe than sorry
140
+ ),
141
+ )
142
+
143
+ async def on_avatar(self, data: Optional[bytes], mime: Optional[str]) -> None:
144
+ return self.session.whatsapp.SetAvatar(
145
+ self.legacy_id,
146
+ go.Slice_byte.from_bytes(data) if data else go.Slice_byte(),
147
+ )
148
+
149
+ async def on_set_config(
150
+ self,
151
+ name: Optional[str],
152
+ description: Optional[str],
153
+ ):
154
+ # there are no group descriptions in WA, but topics=subjects
155
+ if self.name != name:
156
+ self.session.whatsapp.SetGroupName(self.legacy_id, name)
157
+
158
+ async def on_set_subject(self, subject: str):
159
+ if self.subject != subject:
160
+ self.session.whatsapp.SetGroupTopic(self.legacy_id, subject)
161
+
162
+ async def on_set_affiliation(
163
+ self,
164
+ contact: "Contact", # type:ignore
165
+ affiliation: MucAffiliation,
166
+ reason: Optional[str],
167
+ nickname: Optional[str],
168
+ ):
169
+ assert contact.contact_pk is not None
170
+ assert self.pk is not None
171
+ if affiliation == "member":
172
+ if (
173
+ self.xmpp.store.participants.get_by_contact(self.pk, contact.contact_pk)
174
+ is not None
175
+ ):
176
+ action = whatsapp.GroupParticipantActionDemote
177
+ else:
178
+ action = whatsapp.GroupParticipantActionAdd
179
+ elif affiliation == "admin":
180
+ action = whatsapp.GroupParticipantActionPromote
181
+ elif affiliation == "outcast" or affiliation == "none":
182
+ action = whatsapp.GroupParticipantActionRemove
183
+ else:
184
+ raise XMPPError(
185
+ "bad-request",
186
+ f"You can't make a participant '{affiliation}' in WhatsApp",
187
+ )
188
+ self.session.whatsapp.UpdateGroupParticipants(
189
+ self.legacy_id,
190
+ whatsapp.Slice_whatsapp_GroupParticipant(
191
+ [whatsapp.GroupParticipant(JID=contact.legacy_id, Action=action)]
192
+ ),
193
+ )
194
+
195
+
196
+ class Bookmarks(LegacyBookmarks[str, MUC]):
197
+ session: "Session"
198
+
199
+ def __init__(self, session: "Session"):
200
+ super().__init__(session)
201
+ self.__filled = False
202
+
203
+ async def fill(self):
204
+ groups = self.session.whatsapp.GetGroups()
205
+ for group in groups:
206
+ await self.add_whatsapp_group(group)
207
+ self.__filled = True
208
+
209
+ async def add_whatsapp_group(self, data: whatsapp.Group):
210
+ muc = await self.by_legacy_id(data.JID)
211
+ await muc.update_whatsapp_info(data)
212
+ await muc.add_to_bookmarks()
213
+
214
+ async def legacy_id_to_jid_local_part(self, legacy_id: str):
215
+ return "#" + legacy_id[: legacy_id.find("@")]
216
+
217
+ async def jid_local_part_to_legacy_id(self, local_part: str):
218
+ if not local_part.startswith("#"):
219
+ raise XMPPError("bad-request", "Invalid group ID, expected '#' prefix")
220
+
221
+ if not self.__filled:
222
+ raise XMPPError(
223
+ "recipient-unavailable", "Still fetching group info, please retry later"
224
+ )
225
+
226
+ whatsapp_group_id = (
227
+ local_part.removeprefix("#") + "@" + whatsapp.DefaultGroupServer
228
+ )
229
+
230
+ if (
231
+ self.xmpp.store.rooms.get_by_legacy_id(
232
+ self.session.user_pk, whatsapp_group_id
233
+ )
234
+ is None
235
+ ):
236
+ raise XMPPError("item-not-found", f"No group found for {whatsapp_group_id}")
237
+
238
+ return whatsapp_group_id
239
+
240
+
241
+ def replace_xmpp_mentions(text: str, mentions: list[Mention]):
242
+ offset: int = 0
243
+ result: str = ""
244
+ for m in mentions:
245
+ legacy_id = "@" + m.contact.legacy_id[: m.contact.legacy_id.find("@")]
246
+ result = result + text[offset : m.start] + legacy_id
247
+ offset = m.end
248
+ return result + text[offset:] if offset > 0 else text
249
+
250
+
251
+ def replace_whatsapp_mentions(text: str, participants: dict[str, str]):
252
+ def match(m: re.Match):
253
+ group = m.group(0)
254
+ return participants.get(group.replace("@", "+"), group)
255
+
256
+ return re.sub(r"@\d+", match, text)
@@ -0,0 +1,72 @@
1
+ package media
2
+
3
+ import (
4
+ // Standard library.
5
+ "bytes"
6
+ "context"
7
+ "encoding/json"
8
+ "errors"
9
+ "fmt"
10
+ "os/exec"
11
+ )
12
+
13
+ var (
14
+ // The full path and default arguments for FFmpeg, used for converting media to supported types.
15
+ ffmpegCommand, _ = exec.LookPath("ffmpeg")
16
+ ffmpegDefaultArgs = []string{"-v", "error", "-y"}
17
+
18
+ // The full path and default arguments for FFprobe, as provided by FFmpeg, used for getting media
19
+ // metadata (e.g. duration, waveforms, etc.)
20
+ ffprobeCommand, _ = exec.LookPath("ffprobe")
21
+ ffprobeDefaultArgs = []string{"-v", "error", "-of", "json=compact=1"}
22
+ )
23
+
24
+ // FFmpeg runs the `ffmpeg` command for the arguments provided, reading from the input file and
25
+ // writing to the output file paths given.
26
+ func ffmpeg(ctx context.Context, in, out string, args ...string) error {
27
+ if ffmpegCommand == "" {
28
+ return fmt.Errorf("FFmpeg command not found")
29
+ }
30
+
31
+ args = append(ffmpegDefaultArgs, append([]string{"-i", in}, append(args, out)...)...)
32
+ cmd := exec.CommandContext(ctx, ffmpegCommand, args...)
33
+
34
+ if _, err := cmd.Output(); err != nil {
35
+ if e := new(exec.ExitError); errors.As(err, &e) {
36
+ return fmt.Errorf("%s: %s", e.Error(), bytes.TrimSpace(e.Stderr))
37
+ }
38
+ return err
39
+ }
40
+
41
+ return nil
42
+ }
43
+
44
+ // FFprobe runs the `ffprobe` command for the arguments provided, reading from the input file given.
45
+ // Depending on arguments provided, the result may be a deeply nested set of maps with no specific
46
+ // structure; exploring the raw result of `ffprobe` commands with `-of json=compact=1` is recommended.
47
+ func ffprobe(ctx context.Context, in string, args ...string) (map[string]any, error) {
48
+ if ffprobeCommand == "" {
49
+ return nil, fmt.Errorf("FFprobe command not found")
50
+ }
51
+
52
+ args = append(ffprobeDefaultArgs, append([]string{"-i", in}, args...)...)
53
+ cmd := exec.CommandContext(ctx, ffprobeCommand, args...)
54
+
55
+ stdout, err := cmd.StdoutPipe()
56
+ if err != nil {
57
+ return nil, fmt.Errorf("failed to set up standard output: %w", err)
58
+ } else if err = cmd.Start(); err != nil {
59
+ return nil, fmt.Errorf("failed to initialize FFprobe: %w", err)
60
+ }
61
+
62
+ out := make(map[string]any)
63
+ if err := json.NewDecoder(stdout).Decode(&out); err != nil {
64
+ return nil, fmt.Errorf("failed reading FFprobe output: %w", err)
65
+ }
66
+
67
+ if err = cmd.Wait(); err != nil {
68
+ return nil, fmt.Errorf("failed to wait for FFprobe command to complete: %w", err)
69
+ }
70
+
71
+ return out, nil
72
+ }